From 859d3fd879fffd81f7f72b5511e9fec061e3288d Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 26 Apr 2026 14:56:51 +0300 Subject: [PATCH] auth: lift oidc test stub into internal/testutil/oidcstub --- internal/server/auth/middleware_test.go | 123 ++++++--- internal/server/auth/oidctestserver_test.go | 158 ----------- internal/testutil/oidcstub/oidcstub.go | 276 ++++++++++++++++++++ internal/testutil/oidcstub/oidcstub_test.go | 201 ++++++++++++++ 4 files changed, 560 insertions(+), 198 deletions(-) delete mode 100644 internal/server/auth/oidctestserver_test.go create mode 100644 internal/testutil/oidcstub/oidcstub.go create mode 100644 internal/testutil/oidcstub/oidcstub_test.go diff --git a/internal/server/auth/middleware_test.go b/internal/server/auth/middleware_test.go index 665cdca8edf4528a5fdd23e6be30ed1cbe9ca312..c0f52194d0767f405da617a7578651b392500919 100644 --- a/internal/server/auth/middleware_test.go +++ b/internal/server/auth/middleware_test.go @@ -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)) diff --git a/internal/server/auth/oidctestserver_test.go b/internal/server/auth/oidctestserver_test.go deleted file mode 100644 index 4abb489fe6fc750140f3ac3f90d2d53ee6ce13fa..0000000000000000000000000000000000000000 --- a/internal/server/auth/oidctestserver_test.go +++ /dev/null @@ -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:]) -} diff --git a/internal/testutil/oidcstub/oidcstub.go b/internal/testutil/oidcstub/oidcstub.go new file mode 100644 index 0000000000000000000000000000000000000000..8c8a3d4d8251e333769ae7c009edfd185206648d --- /dev/null +++ b/internal/testutil/oidcstub/oidcstub.go @@ -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:]) +} diff --git a/internal/testutil/oidcstub/oidcstub_test.go b/internal/testutil/oidcstub/oidcstub_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0a93147af87ffdaaf2bb59cf1bf7bd6190b11c33 --- /dev/null +++ b/internal/testutil/oidcstub/oidcstub_test.go @@ -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) +}