@@ 13,6 13,7 @@ import (
"sourcecraft.dev/bigbes/lethe/internal/config"
"sourcecraft.dev/bigbes/lethe/internal/platform/observability"
"sourcecraft.dev/bigbes/lethe/internal/server/auth"
+ "sourcecraft.dev/bigbes/lethe/internal/testutil/oidcstub"
)
// problem mirrors the apierror.Problem extension fields the tests assert on.
@@ 68,6 69,48 @@ func mountAndServe(a *auth.Authenticator, r *http.Request) (*httptest.ResponseRe
return rec, cap
}
+// newStub creates an oidcstub.Stub backed by an httptest.Server. The server is
+// closed on test cleanup. The Stub's issuer is set to the server URL.
+func newStub(t *testing.T) *oidcstub.Stub {
+ t.Helper()
+ stub, err := oidcstub.New(oidcstub.Options{Issuer: "http://placeholder"})
+ if err != nil {
+ t.Fatalf("oidcstub.New: %v", err)
+ }
+ srv := httptest.NewServer(stub.Handler())
+ t.Cleanup(srv.Close)
+ stub.SetIssuer(srv.URL)
+ return stub
+}
+
+// signToken signs a token with the stub and fails the test on error.
+func signToken(t *testing.T, stub *oidcstub.Stub, claims map[string]any) string {
+ t.Helper()
+ tok, err := stub.SignToken(claims)
+ if err != nil {
+ t.Fatalf("oidcstub.SignToken: %v", err)
+ }
+ return tok
+}
+
+// newOIDCVerifier constructs a real OIDCVerifier pointed at the stub server,
+// audience-bound to aud.
+func newOIDCVerifier(t *testing.T, stub *oidcstub.Stub, aud string) *auth.OIDCVerifier {
+ t.Helper()
+ v := &auth.OIDCVerifier{
+ Cfg: config.OIDCConfig{
+ Enabled: true,
+ Issuer: stub.Issuer(),
+ Audience: aud,
+ UsernameClaim: "preferred_username",
+ },
+ }
+ if err := v.Init(context.Background()); err != nil {
+ t.Fatalf("OIDCVerifier.Init: %v", err)
+ }
+ return v
+}
+
// --- Forward-auth path -----------------------------------------------------
func TestForwardAuth_MissingHeader(t *testing.T) {
@@ 168,11 211,11 @@ func TestForwardAuth_CustomHeaderName(t *testing.T) {
// --- OIDC path -------------------------------------------------------------
func TestOIDC_MissingAuthorization(t *testing.T) {
- o := newOIDCTestServer(t)
- v := o.newVerifier(t, "lethe")
+ stub := newStub(t)
+ v := newOIDCVerifier(t, stub, "lethe")
a := newAuthenticator(t, config.AuthConfig{
AllowedUsers: []string{"alice"},
- OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.Issuer(), Audience: "lethe", UsernameClaim: "preferred_username"},
}, v)
rec, _ := mountAndServe(a, httptest.NewRequest(http.MethodGet, "/api/v1/x", nil))
@@ 182,11 225,11 @@ func TestOIDC_MissingAuthorization(t *testing.T) {
}
func TestOIDC_MalformedBearer(t *testing.T) {
- o := newOIDCTestServer(t)
- v := o.newVerifier(t, "lethe")
+ stub := newStub(t)
+ v := newOIDCVerifier(t, stub, "lethe")
a := newAuthenticator(t, config.AuthConfig{
AllowedUsers: []string{"alice"},
- OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.Issuer(), Audience: "lethe", UsernameClaim: "preferred_username"},
}, v)
req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
@@ 199,14 242,14 @@ func TestOIDC_MalformedBearer(t *testing.T) {
}
func TestOIDC_ValidAndAllowed(t *testing.T) {
- o := newOIDCTestServer(t)
- v := o.newVerifier(t, "lethe")
+ stub := newStub(t)
+ v := newOIDCVerifier(t, stub, "lethe")
a := newAuthenticator(t, config.AuthConfig{
AllowedUsers: []string{"alice"},
- OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.Issuer(), Audience: "lethe", UsernameClaim: "preferred_username"},
}, v)
- tok := o.signToken(t, map[string]any{
+ tok := signToken(t, stub, map[string]any{
"aud": "lethe",
"sub": "alice-uuid",
"preferred_username": "alice",
@@ 224,14 267,14 @@ func TestOIDC_ValidAndAllowed(t *testing.T) {
}
func TestOIDC_ValidNotAllowed(t *testing.T) {
- o := newOIDCTestServer(t)
- v := o.newVerifier(t, "lethe")
+ stub := newStub(t)
+ v := newOIDCVerifier(t, stub, "lethe")
a := newAuthenticator(t, config.AuthConfig{
AllowedUsers: []string{"alice"},
- OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.Issuer(), Audience: "lethe", UsernameClaim: "preferred_username"},
}, v)
- tok := o.signToken(t, map[string]any{
+ tok := signToken(t, stub, map[string]any{
"aud": "lethe",
"sub": "mallory-uuid",
"preferred_username": "mallory",
@@ 246,15 289,15 @@ func TestOIDC_ValidNotAllowed(t *testing.T) {
}
func TestOIDC_Expired(t *testing.T) {
- o := newOIDCTestServer(t)
- v := o.newVerifier(t, "lethe")
+ stub := newStub(t)
+ v := newOIDCVerifier(t, stub, "lethe")
a := newAuthenticator(t, config.AuthConfig{
AllowedUsers: []string{"alice"},
- OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.Issuer(), Audience: "lethe", UsernameClaim: "preferred_username"},
}, v)
// Expired well past go-oidc's default skew.
- tok := o.signToken(t, map[string]any{
+ tok := signToken(t, stub, map[string]any{
"aud": "lethe",
"sub": "alice",
"preferred_username": "alice",
@@ 271,14 314,14 @@ func TestOIDC_Expired(t *testing.T) {
}
func TestOIDC_WrongAudience(t *testing.T) {
- o := newOIDCTestServer(t)
- v := o.newVerifier(t, "lethe")
+ stub := newStub(t)
+ v := newOIDCVerifier(t, stub, "lethe")
a := newAuthenticator(t, config.AuthConfig{
AllowedUsers: []string{"alice"},
- OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.Issuer(), Audience: "lethe", UsernameClaim: "preferred_username"},
}, v)
- tok := o.signToken(t, map[string]any{
+ tok := signToken(t, stub, map[string]any{
"aud": "someone-else",
"sub": "alice",
"preferred_username": "alice",
@@ 293,14 336,14 @@ func TestOIDC_WrongAudience(t *testing.T) {
}
func TestOIDC_PreferredUsernameClaimUsed(t *testing.T) {
- o := newOIDCTestServer(t)
- v := o.newVerifier(t, "lethe")
+ stub := newStub(t)
+ v := newOIDCVerifier(t, stub, "lethe")
a := newAuthenticator(t, config.AuthConfig{
AllowedUsers: []string{"alice"},
- OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.Issuer(), Audience: "lethe", UsernameClaim: "preferred_username"},
}, v)
- tok := o.signToken(t, map[string]any{
+ tok := signToken(t, stub, map[string]any{
"aud": "lethe",
"sub": "u-9999",
"preferred_username": "alice",
@@ 318,14 361,14 @@ func TestOIDC_PreferredUsernameClaimUsed(t *testing.T) {
}
func TestOIDC_FallsBackToSubWhenPreferredUsernameAbsent(t *testing.T) {
- o := newOIDCTestServer(t)
- v := o.newVerifier(t, "lethe")
+ stub := newStub(t)
+ v := newOIDCVerifier(t, stub, "lethe")
a := newAuthenticator(t, config.AuthConfig{
AllowedUsers: []string{"u-9999"},
- OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.Issuer(), Audience: "lethe", UsernameClaim: "preferred_username"},
}, v)
- tok := o.signToken(t, map[string]any{
+ tok := signToken(t, stub, map[string]any{
"aud": "lethe",
"sub": "u-9999",
// no preferred_username
@@ 345,15 388,15 @@ func TestOIDC_FallsBackToSubWhenPreferredUsernameAbsent(t *testing.T) {
// --- Both enabled ---------------------------------------------------------
func TestBoth_BearerWinsOverHeader(t *testing.T) {
- o := newOIDCTestServer(t)
- v := o.newVerifier(t, "lethe")
+ stub := newStub(t)
+ v := newOIDCVerifier(t, stub, "lethe")
a := newAuthenticator(t, config.AuthConfig{
AllowedUsers: []string{"alice", "bob"},
ForwardAuth: config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
- OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.Issuer(), Audience: "lethe", UsernameClaim: "preferred_username"},
}, v)
- tok := o.signToken(t, map[string]any{
+ tok := signToken(t, stub, map[string]any{
"aud": "lethe",
"sub": "alice",
"preferred_username": "alice",
@@ 372,12 415,12 @@ func TestBoth_BearerWinsOverHeader(t *testing.T) {
}
func TestBoth_BearerInvalidWithHeader_FailsClosed(t *testing.T) {
- o := newOIDCTestServer(t)
- v := o.newVerifier(t, "lethe")
+ stub := newStub(t)
+ v := newOIDCVerifier(t, stub, "lethe")
a := newAuthenticator(t, config.AuthConfig{
AllowedUsers: []string{"alice"},
ForwardAuth: config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
- OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.Issuer(), Audience: "lethe", UsernameClaim: "preferred_username"},
}, v)
req := httptest.NewRequest(http.MethodGet, "/api/v1/x", nil)
@@ 394,12 437,12 @@ func TestBoth_BearerInvalidWithHeader_FailsClosed(t *testing.T) {
}
func TestBoth_AbsentBearerAndHeader(t *testing.T) {
- o := newOIDCTestServer(t)
- v := o.newVerifier(t, "lethe")
+ stub := newStub(t)
+ v := newOIDCVerifier(t, stub, "lethe")
a := newAuthenticator(t, config.AuthConfig{
AllowedUsers: []string{"alice"},
ForwardAuth: config.ForwardAuthConfig{Enabled: true, UserHeader: "Remote-User"},
- OIDC: config.OIDCConfig{Enabled: true, Issuer: o.issuer, Audience: "lethe", UsernameClaim: "preferred_username"},
+ OIDC: config.OIDCConfig{Enabled: true, Issuer: stub.Issuer(), Audience: "lethe", UsernameClaim: "preferred_username"},
}, v)
rec, _ := mountAndServe(a, httptest.NewRequest(http.MethodGet, "/api/v1/x", nil))
@@ 1,158 0,0 @@
-package auth_test
-
-import (
- "context"
- "crypto"
- "crypto/rand"
- "crypto/rsa"
- "crypto/sha256"
- "encoding/base64"
- "encoding/binary"
- "encoding/json"
- "fmt"
- "math/big"
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
-
- "sourcecraft.dev/bigbes/lethe/internal/config"
- "sourcecraft.dev/bigbes/lethe/internal/server/auth"
-)
-
-// oidcTestServer hosts a minimal OIDC discovery + JWKS endpoint backed by
-// an in-memory RSA key. It is not a full OP — just enough surface that the
-// coreos/go-oidc verifier can fetch the JWKS and validate RS256 signatures
-// produced by signToken.
-type oidcTestServer struct {
- srv *httptest.Server
- key *rsa.PrivateKey
- keyID string
- issuer string
-}
-
-// newOIDCTestServer builds an httptest.Server serving discovery and JWKS
-// keyed off a fresh 2048-bit RSA key. The caller must call Close (via
-// t.Cleanup) when done.
-func newOIDCTestServer(t *testing.T) *oidcTestServer {
- t.Helper()
-
- key, err := rsa.GenerateKey(rand.Reader, 2048)
- if err != nil {
- t.Fatalf("rsa.GenerateKey: %v", err)
- }
- o := &oidcTestServer{key: key, keyID: "test-key-1"}
-
- mux := http.NewServeMux()
- mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- body := map[string]any{
- "issuer": o.issuer,
- "jwks_uri": o.issuer + "/jwks",
- "authorization_endpoint": o.issuer + "/auth",
- "token_endpoint": o.issuer + "/token",
- "id_token_signing_alg_values_supported": []string{"RS256"},
- "response_types_supported": []string{"id_token"},
- "subject_types_supported": []string{"public"},
- }
- _ = json.NewEncoder(w).Encode(body)
- })
- mux.HandleFunc("/jwks", func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- jwk := map[string]any{
- "kty": "RSA",
- "alg": "RS256",
- "use": "sig",
- "kid": o.keyID,
- "n": base64URLUint(key.N),
- "e": base64URLUintFromInt(key.E),
- }
- _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{jwk}})
- })
-
- o.srv = httptest.NewServer(mux)
- o.issuer = o.srv.URL
- t.Cleanup(o.srv.Close)
- return o
-}
-
-// signToken produces a signed RS256 JWT with the supplied claims merged with
-// issuer/aud/iat/exp defaults. Claims supplied by the caller win.
-func (o *oidcTestServer) signToken(t *testing.T, claims map[string]any) string {
- t.Helper()
-
- header := map[string]any{
- "alg": "RS256",
- "typ": "JWT",
- "kid": o.keyID,
- }
-
- // Merge defaults under what the caller provided so tests can override
- // e.g. exp or aud explicitly.
- merged := map[string]any{
- "iss": o.issuer,
- "iat": time.Now().Unix(),
- "exp": time.Now().Add(time.Hour).Unix(),
- }
- for k, v := range claims {
- merged[k] = v
- }
-
- headerBytes, err := json.Marshal(header)
- if err != nil {
- t.Fatalf("marshal header: %v", err)
- }
- payloadBytes, err := json.Marshal(merged)
- if err != nil {
- t.Fatalf("marshal payload: %v", err)
- }
-
- signingInput := base64.RawURLEncoding.EncodeToString(headerBytes) +
- "." + base64.RawURLEncoding.EncodeToString(payloadBytes)
- digest := sha256.Sum256([]byte(signingInput))
- sig, err := rsa.SignPKCS1v15(rand.Reader, o.key, crypto.SHA256, digest[:])
- if err != nil {
- t.Fatalf("rsa.SignPKCS1v15: %v", err)
- }
- return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig)
-}
-
-// newVerifier constructs a real OIDCVerifier pointed at this test server,
-// audience-bound to aud.
-func (o *oidcTestServer) newVerifier(t *testing.T, aud string) *auth.OIDCVerifier {
- t.Helper()
- v := &auth.OIDCVerifier{
- Cfg: config.OIDCConfig{
- Enabled: true,
- Issuer: o.issuer,
- Audience: aud,
- UsernameClaim: "preferred_username",
- },
- }
- if err := v.Init(context.Background()); err != nil {
- t.Fatalf("OIDCVerifier.Init: %v", err)
- }
- return v
-}
-
-// base64URLUint encodes a big.Int as base64url with no padding, per JWA
-// (RFC 7518) §6.3.1.1.
-func base64URLUint(n *big.Int) string {
- return base64.RawURLEncoding.EncodeToString(n.Bytes())
-}
-
-// base64URLUintFromInt encodes the public exponent (typically 65537) as the
-// minimal big-endian byte representation, per JWA §6.3.1.2.
-func base64URLUintFromInt(e int) string {
- if e <= 0 {
- panic(fmt.Sprintf("invalid RSA exponent: %d", e))
- }
- var buf [8]byte
- binary.BigEndian.PutUint64(buf[:], uint64(e))
- // Strip leading zero bytes so the encoding is minimal.
- i := 0
- for i < len(buf)-1 && buf[i] == 0 {
- i++
- }
- return base64.RawURLEncoding.EncodeToString(buf[i:])
-}
@@ 0,0 1,276 @@
+// Package oidcstub provides a minimal in-process OIDC provider for testing.
+// It serves discovery, JWKS, and a /dev/token convenience endpoint from a
+// single http.Handler that callers can mount on any httptest.Server.
+//
+// Usage:
+//
+// stub, _ := oidcstub.New(oidcstub.Options{Issuer: "http://placeholder"})
+// srv := httptest.NewServer(stub.Handler())
+// stub.SetIssuer(srv.URL)
+// tok, _ := stub.SignToken(map[string]any{"sub": "alice", "aud": "myapp"})
+//
+// IV1: imports only stdlib + go.bigb.es/auxilia/culpa.
+package oidcstub
+
+import (
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/json"
+ "fmt"
+ "math/big"
+ "net/http"
+ "time"
+
+ "go.bigb.es/auxilia/culpa"
+)
+
+const defaultKID = "oidcstub-key-1"
+
+// Options configures a Stub.
+type Options struct {
+ // Issuer is the OIDC issuer URL. Required.
+ Issuer string
+ // Audience is the default audience claim included by Mint. Optional.
+ Audience string
+ // UsernameClaim is the JWT claim set to the sub argument by Mint. Optional.
+ UsernameClaim string
+ // DefaultTTL is the default token lifetime used by Mint and /dev/token.
+ // Defaults to 1 hour when zero.
+ DefaultTTL time.Duration
+}
+
+// Stub is an in-memory OIDC stub. Construct with New.
+type Stub struct {
+ key *rsa.PrivateKey
+ kid string
+ issuer string
+ audience string
+ usernameClaim string
+ defaultTTL time.Duration
+}
+
+// New creates a ready-to-mount Stub. opts.Issuer must be non-empty.
+// A fresh 2048-bit RSA signing key is generated on every call (AS3).
+func New(opts Options) (*Stub, error) {
+ if opts.Issuer == "" {
+ return nil, culpa.WithCode(
+ culpa.New("oidcstub: Options.Issuer is required"),
+ "OIDCSTUB_MISSING_ISSUER",
+ )
+ }
+
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ return nil, culpa.WithCode(
+ culpa.Wrap(err, "oidcstub: generate RSA key"),
+ "OIDCSTUB_KEY_GEN",
+ )
+ }
+
+ ttl := opts.DefaultTTL
+ if ttl == 0 {
+ ttl = time.Hour
+ }
+
+ return &Stub{
+ key: key,
+ kid: defaultKID,
+ issuer: opts.Issuer,
+ audience: opts.Audience,
+ usernameClaim: opts.UsernameClaim,
+ defaultTTL: ttl,
+ }, nil
+}
+
+// SetIssuer updates the issuer URL. Call this once after the httptest.Server
+// has started to swap the placeholder URL for the real one. Calling SetIssuer
+// after the first token is issued will invalidate already-issued tokens.
+func (s *Stub) SetIssuer(issuer string) {
+ s.issuer = issuer
+}
+
+// Issuer returns the current issuer URL.
+func (s *Stub) Issuer() string {
+ return s.issuer
+}
+
+// Handler returns an http.Handler that serves:
+//
+// - /.well-known/openid-configuration — OIDC discovery document
+// - /jwks — JSON Web Key Set
+// - /dev/token — convenience token endpoint
+func (s *Stub) Handler() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/.well-known/openid-configuration", s.handleDiscovery)
+ mux.HandleFunc("/jwks", s.handleJWKS)
+ mux.HandleFunc("/dev/token", s.handleDevToken)
+ return mux
+}
+
+func (s *Stub) handleDiscovery(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ body := map[string]any{
+ "issuer": s.issuer,
+ "jwks_uri": s.issuer + "/jwks",
+ "authorization_endpoint": s.issuer + "/auth",
+ "token_endpoint": s.issuer + "/token",
+ "id_token_signing_alg_values_supported": []string{"RS256"},
+ "response_types_supported": []string{"id_token"},
+ "subject_types_supported": []string{"public"},
+ }
+ _ = json.NewEncoder(w).Encode(body)
+}
+
+func (s *Stub) handleJWKS(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ jwk := map[string]any{
+ "kty": "RSA",
+ "alg": "RS256",
+ "use": "sig",
+ "kid": s.kid,
+ "n": base64URLUint(s.key.N),
+ "e": base64URLUintFromInt(s.key.E),
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{jwk}})
+}
+
+func (s *Stub) handleDevToken(w http.ResponseWriter, r *http.Request) {
+ sub := r.URL.Query().Get("sub")
+ if sub == "" {
+ http.Error(w, `{"error":"sub is required"}`, http.StatusBadRequest)
+ return
+ }
+
+ ttl := s.defaultTTL
+ if expStr := r.URL.Query().Get("exp"); expStr != "" {
+ d, err := time.ParseDuration(expStr)
+ if err != nil {
+ http.Error(w, `{"error":"invalid exp: `+expStr+`"}`, http.StatusBadRequest)
+ return
+ }
+ ttl = d
+ }
+
+ tok, expiresAt, err := s.Mint(sub, ttl, nil)
+ if err != nil {
+ http.Error(w, `{"error":"mint failed"}`, http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "token": tok,
+ "expires_at": expiresAt.Unix(),
+ })
+}
+
+// SignToken produces a signed RS256 JWT. The supplied claims are merged with
+// defaults {iss, iat, exp=iat+1h}; caller-supplied claims win on conflict.
+func (s *Stub) SignToken(claims map[string]any) (string, error) {
+ now := time.Now()
+ merged := map[string]any{
+ "iss": s.issuer,
+ "iat": now.Unix(),
+ "exp": now.Add(time.Hour).Unix(),
+ }
+ for k, v := range claims {
+ merged[k] = v
+ }
+ return s.signRaw(merged)
+}
+
+// Mint is a convenience method that builds a full token for subject sub.
+// It sets: iss, aud (if s.audience is set), iat, exp=iat+ttl,
+// the configured UsernameClaim (if non-empty) to sub, and sub=sub.
+// Extra claims are merged last so callers can override any default.
+// Returns the signed token and its expiration time.
+func (s *Stub) Mint(sub string, ttl time.Duration, extra map[string]any) (string, time.Time, error) {
+ now := time.Now()
+ expiresAt := now.Add(ttl)
+
+ claims := map[string]any{
+ "iss": s.issuer,
+ "sub": sub,
+ "iat": now.Unix(),
+ "exp": expiresAt.Unix(),
+ }
+ if s.audience != "" {
+ claims["aud"] = s.audience
+ }
+ if s.usernameClaim != "" {
+ claims[s.usernameClaim] = sub
+ }
+ // extra overrides everything above.
+ for k, v := range extra {
+ claims[k] = v
+ }
+
+ tok, err := s.signRaw(claims)
+ if err != nil {
+ return "", time.Time{}, err
+ }
+ return tok, expiresAt, nil
+}
+
+// signRaw signs the supplied claims map exactly as-is (no merging).
+func (s *Stub) signRaw(claims map[string]any) (string, error) {
+ header := map[string]any{
+ "alg": "RS256",
+ "typ": "JWT",
+ "kid": s.kid,
+ }
+
+ headerBytes, err := json.Marshal(header)
+ if err != nil {
+ return "", culpa.WithCode(
+ culpa.Wrap(err, "oidcstub: marshal JWT header"),
+ "OIDCSTUB_SIGN",
+ )
+ }
+ payloadBytes, err := json.Marshal(claims)
+ if err != nil {
+ return "", culpa.WithCode(
+ culpa.Wrap(err, "oidcstub: marshal JWT payload"),
+ "OIDCSTUB_SIGN",
+ )
+ }
+
+ signingInput := base64.RawURLEncoding.EncodeToString(headerBytes) +
+ "." + base64.RawURLEncoding.EncodeToString(payloadBytes)
+
+ digest := sha256.Sum256([]byte(signingInput))
+ sig, err := rsa.SignPKCS1v15(rand.Reader, s.key, crypto.SHA256, digest[:])
+ if err != nil {
+ return "", culpa.WithCode(
+ culpa.Wrap(err, "oidcstub: rsa.SignPKCS1v15"),
+ "OIDCSTUB_SIGN",
+ )
+ }
+ return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil
+}
+
+// base64URLUint encodes a big.Int as base64url with no padding, per JWA
+// (RFC 7518) §6.3.1.1.
+func base64URLUint(n *big.Int) string {
+ return base64.RawURLEncoding.EncodeToString(n.Bytes())
+}
+
+// base64URLUintFromInt encodes the public exponent (typically 65537) as the
+// minimal big-endian byte representation, per JWA §6.3.1.2.
+func base64URLUintFromInt(e int) string {
+ if e <= 0 {
+ panic(fmt.Sprintf("oidcstub: invalid RSA exponent: %d", e))
+ }
+ var buf [8]byte
+ binary.BigEndian.PutUint64(buf[:], uint64(e))
+ // Strip leading zero bytes so the encoding is minimal.
+ i := 0
+ for i < len(buf)-1 && buf[i] == 0 {
+ i++
+ }
+ return base64.RawURLEncoding.EncodeToString(buf[i:])
+}
@@ 0,0 1,201 @@
+package oidcstub_test
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ gooidc "github.com/coreos/go-oidc/v3/oidc"
+
+ "sourcecraft.dev/bigbes/lethe/internal/testutil/oidcstub"
+)
+
+// TestNew_RequiresIssuer asserts that New returns an error when Issuer is empty.
+func TestNew_RequiresIssuer(t *testing.T) {
+ _, err := oidcstub.New(oidcstub.Options{})
+ if err == nil {
+ t.Fatal("New(Options{}): expected error when Issuer is empty, got nil")
+ }
+}
+
+// TestSignToken_RoundtripsThroughDiscovery mounts the stub Handler on a real
+// httptest server, signs a token, then verifies it using coreos/go-oidc. The
+// audience and sub claims must survive the round-trip.
+func TestSignToken_RoundtripsThroughDiscovery(t *testing.T) {
+ // Bootstrap: create stub with placeholder issuer, start server, fix issuer.
+ stub, err := oidcstub.New(oidcstub.Options{Issuer: "http://placeholder"})
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ srv := httptest.NewServer(stub.Handler())
+ t.Cleanup(srv.Close)
+
+ stub.SetIssuer(srv.URL)
+
+ const aud = "myapp"
+ tok, err := stub.SignToken(map[string]any{
+ "sub": "user-42",
+ "aud": aud,
+ })
+ if err != nil {
+ t.Fatalf("SignToken: %v", err)
+ }
+
+ // Verify via go-oidc.
+ ctx := context.Background()
+ provider, err := gooidc.NewProvider(ctx, srv.URL)
+ if err != nil {
+ t.Fatalf("oidc.NewProvider: %v", err)
+ }
+ verifier := provider.Verifier(&gooidc.Config{ClientID: aud})
+ idToken, err := verifier.Verify(ctx, tok)
+ if err != nil {
+ t.Fatalf("verifier.Verify: %v", err)
+ }
+
+ var claims struct {
+ Aud string `json:"aud"`
+ Sub string `json:"sub"`
+ }
+ if err := idToken.Claims(&claims); err != nil {
+ t.Fatalf("Claims: %v", err)
+ }
+ if claims.Aud != aud {
+ t.Errorf("aud = %q; want %q", claims.Aud, aud)
+ }
+ if claims.Sub != "user-42" {
+ t.Errorf("sub = %q; want user-42", claims.Sub)
+ }
+}
+
+// TestMint_SetsUsernameClaim asserts that Mint sets the configured
+// username_claim to the sub argument.
+func TestMint_SetsUsernameClaim(t *testing.T) {
+ stub, err := oidcstub.New(oidcstub.Options{
+ Issuer: "http://placeholder",
+ UsernameClaim: "preferred_username",
+ })
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ srv := httptest.NewServer(stub.Handler())
+ t.Cleanup(srv.Close)
+ stub.SetIssuer(srv.URL)
+
+ tok, _, err := stub.Mint("alice", time.Hour, nil)
+ if err != nil {
+ t.Fatalf("Mint: %v", err)
+ }
+
+ // Decode claims from the token without full OIDC verification — we just
+ // want to inspect the payload.
+ var raw map[string]any
+ if err := decodeJWTPayload(tok, &raw); err != nil {
+ t.Fatalf("decodeJWTPayload: %v", err)
+ }
+
+ got, ok := raw["preferred_username"]
+ if !ok {
+ t.Fatal("preferred_username claim is absent")
+ }
+ if got != "alice" {
+ t.Errorf("preferred_username = %v; want alice", got)
+ }
+ if sub, _ := raw["sub"].(string); sub != "alice" {
+ t.Errorf("sub = %q; want alice", sub)
+ }
+}
+
+// TestDevToken_HTTP_ReturnsBearer checks that GET /dev/token?sub=alice&exp=15m
+// returns 200 with parseable JSON, and that the token verifies under the same
+// Stub.
+func TestDevToken_HTTP_ReturnsBearer(t *testing.T) {
+ stub, err := oidcstub.New(oidcstub.Options{Issuer: "http://placeholder"})
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ srv := httptest.NewServer(stub.Handler())
+ t.Cleanup(srv.Close)
+ stub.SetIssuer(srv.URL)
+
+ resp, err := http.Get(srv.URL + "/dev/token?sub=alice&exp=15m") //nolint:noctx
+ if err != nil {
+ t.Fatalf("GET /dev/token: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ t.Fatalf("status = %d; want 200; body=%s", resp.StatusCode, body)
+ }
+
+ var payload struct {
+ Token string `json:"token"`
+ ExpiresAt int64 `json:"expires_at"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if payload.Token == "" {
+ t.Fatal("token is empty")
+ }
+ if payload.ExpiresAt == 0 {
+ t.Fatal("expires_at is zero")
+ }
+
+ // Verify the token decodes correctly (basic payload check).
+ var claims map[string]any
+ if err := decodeJWTPayload(payload.Token, &claims); err != nil {
+ t.Fatalf("decodeJWTPayload: %v", err)
+ }
+ if claims["sub"] != "alice" {
+ t.Errorf("sub = %v; want alice", claims["sub"])
+ }
+}
+
+// TestDevToken_HTTP_400OnMissingSub asserts that GET /dev/token (no ?sub=)
+// returns 400.
+func TestDevToken_HTTP_400OnMissingSub(t *testing.T) {
+ stub, err := oidcstub.New(oidcstub.Options{Issuer: "http://placeholder"})
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ srv := httptest.NewServer(stub.Handler())
+ t.Cleanup(srv.Close)
+ stub.SetIssuer(srv.URL)
+
+ resp, err := http.Get(srv.URL + "/dev/token") //nolint:noctx
+ if err != nil {
+ t.Fatalf("GET /dev/token: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("status = %d; want 400", resp.StatusCode)
+ }
+}
+
+// decodeJWTPayload base64-decodes the payload segment of a JWT without
+// verifying the signature. Only for test assertions.
+func decodeJWTPayload(token string, dst any) error {
+ parts := strings.SplitN(token, ".", 3)
+ if len(parts) != 3 {
+ return fmt.Errorf("expected 3 JWT parts, got %d", len(parts))
+ }
+ b, err := base64.RawURLEncoding.DecodeString(parts[1])
+ if err != nil {
+ return err
+ }
+ return json.Unmarshal(b, dst)
+}