~bigbes/lethe

859d3fd879fffd81f7f72b5511e9fec061e3288d — Eugene Blikh a month ago 5928a62
auth: lift oidc test stub into internal/testutil/oidcstub
4 files changed, 560 insertions(+), 198 deletions(-)

M internal/server/auth/middleware_test.go
D internal/server/auth/oidctestserver_test.go
A internal/testutil/oidcstub/oidcstub.go
A internal/testutil/oidcstub/oidcstub_test.go
M internal/server/auth/middleware_test.go => internal/server/auth/middleware_test.go +83 -40
@@ 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))

D internal/server/auth/oidctestserver_test.go => internal/server/auth/oidctestserver_test.go +0 -158
@@ 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:])
}

A internal/testutil/oidcstub/oidcstub.go => internal/testutil/oidcstub/oidcstub.go +276 -0
@@ 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:])
}

A internal/testutil/oidcstub/oidcstub_test.go => internal/testutil/oidcstub/oidcstub_test.go +201 -0
@@ 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)
}