~bigbes/lethe

934cfa2872e9be876dbfc2e8b087a0759588d1c2 — Eugene Blikh a month ago ac34df3
oidcstub: implement /authorize + /token auth-code+PKCE; inject window.__LETHE_CONFIG__ into SPA
M internal/server/server.go => internal/server/server.go +3 -2
@@ 50,7 50,8 @@ const readyzTimeout = 5 * time.Second
// Server is the HTTP steward service. Steward injects the shared platform
// services and the auth/handler stubs (which Phases 6/7/8 replace).
type Server struct {
	Cfg config.ServerConfig `config:""`
	Cfg     config.ServerConfig `config:""`
	AuthCfg config.AuthConfig   `config:""`

	Log     *observability.Logger  `inject:""`
	Metrics *observability.Metrics `inject:""`


@@ 114,7 115,7 @@ func (s *Server) Init(_ context.Context) error {
	// Auth is NOT applied here; the client-side app handles unauthenticated
	// states itself. Using Get (not Handle) so that non-GET requests to
	// unregistered paths still reach MethodNotAllowed rather than the SPA.
	r.Get("/*", webpkg.Handler().ServeHTTP)
	r.Get("/*", webpkg.Handler(webpkg.Config{Issuer: s.AuthCfg.OIDC.Issuer, ClientID: s.AuthCfg.OIDC.Audience}).ServeHTTP)

	s.router = r
	return nil

M internal/server/web/embed.go => internal/server/web/embed.go +65 -15
@@ 9,7 9,9 @@
package web

import (
	"bytes"
	"embed"
	"encoding/json"
	"fmt"
	"io/fs"
	"net/http"


@@ 18,31 20,51 @@ import (
//go:embed all:dist
var distFS embed.FS

// Config holds the values injected into the SPA's index.html as
// window.__LETHE_CONFIG__. It must not contain any tokens (IV4).
type Config struct {
	Issuer   string
	ClientID string
}

// DistFS returns the sub-filesystem rooted at "dist" within the embedded FS.
// Exported for tests that need to discover actual asset filenames.
func DistFS() (fs.FS, error) {
	return fs.Sub(distFS, "dist")
}

// Handler returns an http.Handler that serves the embedded SPA. Paths that
// exist in the embedded tree are served directly. Paths that do not exist
// fall back to index.html with HTTP 200 so the client-side router handles
// them. If index.html itself cannot be opened from the embedded FS, the
// handler returns 500 with a plain-text error.
//
// On responses that serve index.html (root path and SPA fallback paths), the
// handler injects <script>window.__LETHE_CONFIG__=…;</script> before the
// closing </head> tag so the SPA can read OIDC config without hard-coding it.
// Paths beginning with /assets/ bypass injection and are served as raw bytes.
//
// Routes beginning with /api/, /healthz, /readyz, or /metrics are NOT
// this handler's concern; they must be mounted before this handler in the
// router so they shadow the catch-all.
func Handler() http.Handler {
func Handler(cfg Config) http.Handler {
	sub, err := fs.Sub(distFS, "dist")
	if err != nil {
		// This should never happen for a hard-coded path; blow up loudly.
		panic(fmt.Sprintf("web: fs.Sub on embedded dist: %v", err))
	}
	fileServer := http.FileServer(http.FS(sub))
	return &spaHandler{fs: sub, fileServer: fileServer}
	return &spaHandler{fs: sub, fileServer: fileServer, cfg: cfg}
}

// spaHandler wraps a standard file server with a SPA fallback: if the
// requested path does not exist in the embedded FS, it serves index.html
// instead of a 404.
// instead of a 404. It also injects window.__LETHE_CONFIG__ into index.html
// responses.
type spaHandler struct {
	fs         fs.FS
	fileServer http.Handler
	cfg        Config
}

func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {


@@ 55,6 77,12 @@ func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
		path = "index.html"
	}

	// Static assets bypass injection; serve directly via the file server.
	if len(r.URL.Path) >= 8 && r.URL.Path[:8] == "/assets/" {
		h.fileServer.ServeHTTP(w, r)
		return
	}

	// Try to open the file to see if it exists.
	f, err := h.fs.Open(path)
	if err != nil {


@@ 72,24 100,46 @@ func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
		return
	}

	// File exists — let the standard file server handle it with correct
	// The file exists — check if it is index.html.
	if path == "index.html" {
		h.serveIndex(w, r)
		return
	}

	// Non-index file — let the standard file server handle it with correct
	// Content-Type, ETag, and range support.
	h.fileServer.ServeHTTP(w, r)
}

// serveIndex serves the embedded index.html with HTTP 200. It returns 500
// (plain-text) if the embedded FS cannot open the file — this is a build
// serveIndex reads index.html from the embedded FS, injects the config script
// before </head>, and writes the result with HTTP 200. It returns 500 (plain-
// text) if the embedded FS cannot open or read the file — this is a build
// invariant violation and must not be silently swallowed.
func (h *spaHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
	// Rewrite the URL to "/" so the file server finds index.html and sets
	// the correct Content-Type header.
	r2 := r.Clone(r.Context())
	r2.URL.Path = "/"
	// Verify index.html is present before delegating; a missing file
	// produces a 500 rather than a misleading file-server 404.
	if _, err := h.fs.Open("index.html"); err != nil {
func (h *spaHandler) serveIndex(w http.ResponseWriter, _ *http.Request) {
	f, err := h.fs.Open("index.html")
	if err != nil {
		http.Error(w, "internal server error: embedded index.html missing", http.StatusInternalServerError)
		return
	}
	h.fileServer.ServeHTTP(w, r2)
	defer f.Close()

	var buf bytes.Buffer
	if _, err := buf.ReadFrom(f); err != nil {
		http.Error(w, "internal server error: read embedded index.html", http.StatusInternalServerError)
		return
	}

	cfgJSON, err := json.Marshal(h.cfg)
	if err != nil {
		// Config is a simple struct with string fields; Marshal must not fail.
		http.Error(w, "internal server error: marshal config", http.StatusInternalServerError)
		return
	}

	script := []byte("<script>window.__LETHE_CONFIG__=" + string(cfgJSON) + ";</script></head>")
	injected := bytes.Replace(buf.Bytes(), []byte("</head>"), script, 1)

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write(injected)
}

A internal/server/web/embed_test.go => internal/server/web/embed_test.go +119 -0
@@ 0,0 1,119 @@
package web_test

import (
	"io"
	"io/fs"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"sourcecraft.dev/bigbes/lethe/internal/server/web"
)

// TestHandler_InjectsConfigIntoIndex asserts that GET / returns 200 HTML
// containing window.__LETHE_CONFIG__ with the supplied values, exactly once.
func TestHandler_InjectsConfigIntoIndex(t *testing.T) {
	h := web.Handler(web.Config{
		Issuer:   "http://stub",
		ClientID: "lethe",
	})

	req := httptest.NewRequest(http.MethodGet, "/", nil)
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, req)

	resp := rr.Result()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d; want 200", resp.StatusCode)
	}

	body, _ := io.ReadAll(resp.Body)
	bodyStr := string(body)

	want := `window.__LETHE_CONFIG__={"Issuer":"http://stub","ClientID":"lethe"}`
	if !strings.Contains(bodyStr, want) {
		t.Errorf("body does not contain %q\nbody (first 500 chars):\n%s", want, truncate(bodyStr, 500))
	}

	// Exactly once.
	count := strings.Count(bodyStr, "__LETHE_CONFIG__")
	if count != 1 {
		t.Errorf("__LETHE_CONFIG__ appears %d times; want 1", count)
	}
}

// TestHandler_AssetsBypassInjection asserts that static asset paths do not
// receive the script injection. We test by checking the response body does
// not contain __LETHE_CONFIG__ — robust against asset filename changes.
func TestHandler_AssetsBypassInjection(t *testing.T) {
	// Discover an asset file from the embedded FS via the handler's fallback
	// behavior, or just verify that a known asset path bypasses injection.
	// We check absence of __LETHE_CONFIG__ in any /assets/ response.
	h := web.Handler(web.Config{
		Issuer:   "http://stub",
		ClientID: "lethe",
	})

	// Find an actual asset file name to request.
	sub, err := web.DistFS()
	if err != nil {
		t.Fatalf("DistFS: %v", err)
	}
	var assetFile string
	_ = fs.WalkDir(sub, "assets", func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if !d.IsDir() && assetFile == "" {
			assetFile = path
		}
		return nil
	})
	if assetFile == "" {
		t.Skip("no asset files found in embedded FS — skip")
	}

	req := httptest.NewRequest(http.MethodGet, "/"+assetFile, nil)
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, req)

	resp := rr.Result()
	body, _ := io.ReadAll(resp.Body)

	if strings.Contains(string(body), "__LETHE_CONFIG__") {
		t.Errorf("asset %q response contains __LETHE_CONFIG__ — should be bypass", assetFile)
	}
}

// TestHandler_SPAFallbackInjects asserts that an unknown SPA route returns
// 200 HTML with the config injection (SPA fallback returns index.html).
func TestHandler_SPAFallbackInjects(t *testing.T) {
	h := web.Handler(web.Config{
		Issuer:   "http://stub",
		ClientID: "lethe",
	})

	req := httptest.NewRequest(http.MethodGet, "/some/spa/route", nil)
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, req)

	resp := rr.Result()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d; want 200", resp.StatusCode)
	}

	body, _ := io.ReadAll(resp.Body)
	bodyStr := string(body)

	if !strings.Contains(bodyStr, "__LETHE_CONFIG__") {
		t.Errorf("SPA fallback response does not contain __LETHE_CONFIG__\nbody:\n%s", truncate(bodyStr, 500))
	}
}

func truncate(s string, n int) string {
	if len(s) <= n {
		return s
	}
	return s[:n] + "..."
}

A internal/testutil/oidcstub/codestore.go => internal/testutil/oidcstub/codestore.go +77 -0
@@ 0,0 1,77 @@
package oidcstub

import (
	"crypto/rand"
	"encoding/base64"
	"sync"
	"time"
)

// codeEntry holds the data stored with an authorization code.
type codeEntry struct {
	Sub           string
	CodeChallenge string
	RedirectURI   string
	ExpiresAt     time.Time
}

// codeStore is a thread-safe in-memory store for single-use authorization codes.
// now is injected for deterministic testing; nil defaults to time.Now.
type codeStore struct {
	mu      sync.Mutex
	entries map[string]codeEntry
	now     func() time.Time
}

// newCodeStore constructs a codeStore. now may be nil, in which case time.Now
// is used.
func newCodeStore(now func() time.Time) *codeStore {
	if now == nil {
		now = time.Now
	}
	return &codeStore{
		entries: make(map[string]codeEntry),
		now:     now,
	}
}

// Issue generates an opaque base64url 32-byte authorization code, stores it
// with the supplied sub, code_challenge, redirect_uri, and TTL, and returns
// the code string. The code is URL-safe (base64.RawURLEncoding — no +, /, =).
func (s *codeStore) Issue(sub, challenge, redirect string, ttl time.Duration) string {
	var buf [32]byte
	if _, err := rand.Read(buf[:]); err != nil {
		panic("oidcstub: codeStore.Issue: crypto/rand.Read: " + err.Error())
	}
	code := base64.RawURLEncoding.EncodeToString(buf[:])

	s.mu.Lock()
	defer s.mu.Unlock()
	s.entries[code] = codeEntry{
		Sub:           sub,
		CodeChallenge: challenge,
		RedirectURI:   redirect,
		ExpiresAt:     s.now().Add(ttl),
	}
	return code
}

// Consume retrieves and deletes the entry for code. Returns (entry, true) on
// first call for a valid, unexpired code; (zero, false) on miss, expiry, or
// any subsequent call (IV2, IV3).
func (s *codeStore) Consume(code string) (codeEntry, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()

	entry, ok := s.entries[code]
	if !ok {
		return codeEntry{}, false
	}
	// Always delete — even expired entries must not be reusable.
	delete(s.entries, code)

	if s.now().After(entry.ExpiresAt) {
		return codeEntry{}, false
	}
	return entry, true
}

A internal/testutil/oidcstub/codestore_test.go => internal/testutil/oidcstub/codestore_test.go +121 -0
@@ 0,0 1,121 @@
package oidcstub

import (
	"strings"
	"sync"
	"testing"
	"time"
)

// TestCodeStore_Issue_DistinctURLSafeCodes verifies that Issue returns distinct,
// URL-safe codes with sufficient entropy (32 bytes → 43-char base64url string).
func TestCodeStore_Issue_DistinctURLSafeCodes(t *testing.T) {
	cs := newCodeStore(nil)
	seen := make(map[string]bool)
	for i := 0; i < 20; i++ {
		code := cs.Issue("sub", "challenge", "http://x/cb", 5*time.Minute)
		if code == "" {
			t.Fatal("Issue returned empty code")
		}
		// URL-safe: must not contain +, /, or =
		if strings.ContainsAny(code, "+/=") {
			t.Errorf("code %q is not URL-safe (contains +, /, or =)", code)
		}
		// 32 bytes base64url-encoded without padding = 43 chars
		if len(code) < 43 {
			t.Errorf("code %q too short (len=%d, want >=43)", code, len(code))
		}
		if seen[code] {
			t.Errorf("duplicate code issued: %q", code)
		}
		seen[code] = true
	}
}

// TestCodeStore_Consume_SingleUse verifies IV2: code is deleted on first Consume.
func TestCodeStore_Consume_SingleUse(t *testing.T) {
	cs := newCodeStore(nil)
	code := cs.Issue("alice", "challenge", "http://x/cb", 5*time.Minute)

	entry, ok := cs.Consume(code)
	if !ok {
		t.Fatal("Consume: expected ok=true on first call")
	}
	if entry.Sub != "alice" {
		t.Errorf("entry.Sub = %q; want alice", entry.Sub)
	}
	if entry.CodeChallenge != "challenge" {
		t.Errorf("entry.CodeChallenge = %q; want challenge", entry.CodeChallenge)
	}
	if entry.RedirectURI != "http://x/cb" {
		t.Errorf("entry.RedirectURI = %q; want http://x/cb", entry.RedirectURI)
	}

	// Second call must return false (IV2).
	_, ok2 := cs.Consume(code)
	if ok2 {
		t.Fatal("Consume: expected ok=false on second call (single-use)")
	}
}

// TestCodeStore_Consume_Expired verifies IV3: expired entries are rejected.
func TestCodeStore_Consume_Expired(t *testing.T) {
	// Start at time zero, issue with 5m TTL.
	now := time.Unix(0, 0)
	cs := newCodeStore(func() time.Time { return now })

	code := cs.Issue("bob", "challenge", "http://x/cb", 5*time.Minute)

	// Advance past TTL.
	now = now.Add(6 * time.Minute)

	_, ok := cs.Consume(code)
	if ok {
		t.Fatal("Consume: expected ok=false for expired code (IV3)")
	}
}

// TestCodeStore_Consume_UnknownCode verifies false on unknown code.
func TestCodeStore_Consume_UnknownCode(t *testing.T) {
	cs := newCodeStore(nil)
	_, ok := cs.Consume("no-such-code")
	if ok {
		t.Fatal("Consume: expected ok=false for unknown code")
	}
}

// TestCodeStore_ConcurrentAccess verifies race-freedom (run with -race).
func TestCodeStore_ConcurrentAccess(t *testing.T) {
	cs := newCodeStore(nil)
	var wg sync.WaitGroup

	// Writers
	codes := make(chan string, 100)
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 10; j++ {
				code := cs.Issue("sub", "challenge", "http://x/cb", 5*time.Minute)
				codes <- code
			}
		}()
	}

	// Readers
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 5; j++ {
				cs.Consume("probably-not-there")
			}
		}()
	}

	wg.Wait()
	close(codes)
	for code := range codes {
		cs.Consume(code)
	}
}

M internal/testutil/oidcstub/oidcstub.go => internal/testutil/oidcstub/oidcstub.go +155 -2
@@ 28,6 28,8 @@ import (
	"go.bigb.es/auxilia/culpa"
)

const defaultDevStubUser = "bigbes"

const defaultKID = "oidcstub-key-1"

// Options configures a Stub.


@@ 41,6 43,8 @@ type Options struct {
	// DefaultTTL is the default token lifetime used by Mint and /dev/token.
	// Defaults to 1 hour when zero.
	DefaultTTL time.Duration
	// DevStubUser is the sub issued by /authorize. Defaults to "bigbes" when zero.
	DevStubUser string
}

// Stub is an in-memory OIDC stub. Construct with New.


@@ 51,6 55,8 @@ type Stub struct {
	audience      string
	usernameClaim string
	defaultTTL    time.Duration
	devStubUser   string
	codes         *codeStore
}

// New creates a ready-to-mount Stub. opts.Issuer must be non-empty.


@@ 76,6 82,11 @@ func New(opts Options) (*Stub, error) {
		ttl = time.Hour
	}

	devUser := opts.DevStubUser
	if devUser == "" {
		devUser = defaultDevStubUser
	}

	return &Stub{
		key:           key,
		kid:           defaultKID,


@@ 83,6 94,8 @@ func New(opts Options) (*Stub, error) {
		audience:      opts.Audience,
		usernameClaim: opts.UsernameClaim,
		defaultTTL:    ttl,
		devStubUser:   devUser,
		codes:         newCodeStore(nil),
	}, nil
}



@@ 103,11 116,15 @@ func (s *Stub) Issuer() string {
//   - /.well-known/openid-configuration — OIDC discovery document
//   - /jwks                             — JSON Web Key Set
//   - /dev/token                        — convenience token endpoint
//   - /authorize                        — auth-code+PKCE authorization endpoint (RFC 6749 §4.1)
//   - /token                            — token endpoint (RFC 6749 §4.1.3, RFC 7636)
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)
	mux.HandleFunc("/authorize", s.handleAuthorize)
	mux.HandleFunc("/token", s.handleToken)
	return mux
}



@@ 116,11 133,13 @@ func (s *Stub) handleDiscovery(w http.ResponseWriter, _ *http.Request) {
	body := map[string]any{
		"issuer":                                s.issuer,
		"jwks_uri":                              s.issuer + "/jwks",
		"authorization_endpoint":                s.issuer + "/auth",
		"authorization_endpoint":                s.issuer + "/authorize",
		"token_endpoint":                        s.issuer + "/token",
		"id_token_signing_alg_values_supported": []string{"RS256"},
		"response_types_supported":              []string{"id_token"},
		"response_types_supported":              []string{"id_token", "code"},
		"subject_types_supported":               []string{"public"},
		"grant_types_supported":                 []string{"authorization_code"},
		"code_challenge_methods_supported":      []string{"S256"},
	}
	_ = json.NewEncoder(w).Encode(body)
}


@@ 274,3 293,137 @@ func base64URLUintFromInt(e int) string {
	}
	return base64.RawURLEncoding.EncodeToString(buf[i:])
}

// oidcError writes an RFC 6749-compliant JSON error response.
func oidcError(w http.ResponseWriter, status int, code, description string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(map[string]string{
		"error":             code,
		"error_description": description,
	})
}

// handleAuthorize implements the authorization endpoint (RFC 6749 §4.1.1).
// It supports response_type=code with PKCE code_challenge_method=S256 only.
func (s *Stub) handleAuthorize(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		oidcError(w, http.StatusMethodNotAllowed, "invalid_request", "method must be GET")
		return
	}

	q := r.URL.Query()

	responseType := q.Get("response_type")
	if responseType != "code" {
		oidcError(w, http.StatusBadRequest, "invalid_request", "response_type must be 'code'")
		return
	}

	clientID := q.Get("client_id")
	if clientID == "" {
		oidcError(w, http.StatusBadRequest, "invalid_request", "client_id is required")
		return
	}

	redirectURI := q.Get("redirect_uri")
	if redirectURI == "" {
		oidcError(w, http.StatusBadRequest, "invalid_request", "redirect_uri is required")
		return
	}

	state := q.Get("state")
	// state is RECOMMENDED per RFC 6749 §4.1.1; we accept it as optional but echo it.

	codeChallenge := q.Get("code_challenge")
	if codeChallenge == "" {
		oidcError(w, http.StatusBadRequest, "invalid_request", "code_challenge is required")
		return
	}

	codeChallengeMethod := q.Get("code_challenge_method")
	if codeChallengeMethod != "S256" {
		oidcError(w, http.StatusBadRequest, "invalid_request", "code_challenge_method must be 'S256'")
		return
	}

	code := s.codes.Issue(s.devStubUser, codeChallenge, redirectURI, 5*time.Minute)

	location := redirectURI + "?code=" + code
	if state != "" {
		location += "&state=" + state
	}
	http.Redirect(w, r, location, http.StatusFound)
}

// handleToken implements the token endpoint (RFC 6749 §4.1.3, RFC 7636 §4.6).
// It supports grant_type=authorization_code with PKCE S256 verification.
func (s *Stub) handleToken(w http.ResponseWriter, r *http.Request) {
	// CORS preflight.
	if r.Method == http.MethodOptions {
		w.Header().Set("Access-Control-Allow-Origin", "*")
		w.Header().Set("Access-Control-Allow-Methods", "POST")
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
		w.WriteHeader(http.StatusNoContent)
		return
	}

	if r.Method != http.MethodPost {
		oidcError(w, http.StatusMethodNotAllowed, "invalid_request", "method must be POST")
		return
	}

	if err := r.ParseForm(); err != nil {
		oidcError(w, http.StatusBadRequest, "invalid_request", "could not parse form body")
		return
	}

	grantType := r.FormValue("grant_type")
	if grantType != "authorization_code" {
		oidcError(w, http.StatusBadRequest, "unsupported_grant_type", "grant_type must be 'authorization_code'")
		return
	}

	code := r.FormValue("code")
	codeVerifier := r.FormValue("code_verifier")
	redirectURI := r.FormValue("redirect_uri")

	entry, ok := s.codes.Consume(code)
	if !ok {
		w.Header().Set("Access-Control-Allow-Origin", "*")
		oidcError(w, http.StatusBadRequest, "invalid_grant", "unknown or expired code")
		return
	}

	// Verify redirect_uri matches (RFC 6749 §4.1.3).
	if redirectURI != entry.RedirectURI {
		w.Header().Set("Access-Control-Allow-Origin", "*")
		oidcError(w, http.StatusBadRequest, "invalid_grant", "redirect_uri mismatch")
		return
	}

	// Verify PKCE S256: base64url(SHA-256(verifier)) == stored challenge (RFC 7636 §4.6).
	h := sha256.Sum256([]byte(codeVerifier))
	computed := base64.RawURLEncoding.EncodeToString(h[:])
	if computed != entry.CodeChallenge {
		w.Header().Set("Access-Control-Allow-Origin", "*")
		oidcError(w, http.StatusBadRequest, "invalid_grant", "code_verifier does not match code_challenge")
		return
	}

	// Mint JWT.
	tok, _, err := s.Mint(entry.Sub, s.defaultTTL, nil)
	if err != nil {
		http.Error(w, `{"error":"server_error"}`, http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Access-Control-Allow-Origin", "*")
	_ = json.NewEncoder(w).Encode(map[string]any{
		"access_token": tok,
		"id_token":     tok,
		"token_type":   "Bearer",
		"expires_in":   int(s.defaultTTL.Seconds()),
	})
}

M internal/testutil/oidcstub/oidcstub_test.go => internal/testutil/oidcstub/oidcstub_test.go +576 -0
@@ 2,12 2,14 @@ package oidcstub_test

import (
	"context"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"
	"time"


@@ 199,3 201,577 @@ func decodeJWTPayload(token string, dst any) error {
	}
	return json.Unmarshal(b, dst)
}

// s256 computes the S256 code challenge from a verifier (RFC 7636 §4.6).
func s256(verifier string) string {
	h := sha256.Sum256([]byte(verifier))
	return base64.RawURLEncoding.EncodeToString(h[:])
}

// newTestStub starts a test server with a fresh Stub and returns the stub,
// server, and its URL.
func newTestStub(t *testing.T) (*oidcstub.Stub, *httptest.Server) {
	t.Helper()
	stub, err := oidcstub.New(oidcstub.Options{
		Issuer:     "http://placeholder",
		Audience:   "lethe",
		DefaultTTL: time.Hour,
	})
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	srv := httptest.NewServer(stub.Handler())
	t.Cleanup(srv.Close)
	stub.SetIssuer(srv.URL)
	return stub, srv
}

// TestAuthorize_RedirectsWithCodeAndState asserts that a well-formed /authorize
// GET returns a 302 redirect containing code and echoed state.
func TestAuthorize_RedirectsWithCodeAndState(t *testing.T) {
	_, srv := newTestStub(t)

	verifier := "testverifier1234567890abcdef1234567890abcdef12"
	challenge := s256(verifier)

	redirectURI := "http://x/cb"
	state := "abc123"

	reqURL := srv.URL + "/authorize?" + url.Values{
		"response_type":         {"code"},
		"client_id":             {"lethe"},
		"redirect_uri":          {redirectURI},
		"state":                 {state},
		"code_challenge":        {challenge},
		"code_challenge_method": {"S256"},
	}.Encode()

	client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse
	}}
	resp, err := client.Get(reqURL) //nolint:noctx
	if err != nil {
		t.Fatalf("GET /authorize: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusFound {
		body, _ := io.ReadAll(resp.Body)
		t.Fatalf("status = %d; want 302; body=%s", resp.StatusCode, body)
	}

	loc := resp.Header.Get("Location")
	if loc == "" {
		t.Fatal("Location header missing")
	}

	parsed, err := url.Parse(loc)
	if err != nil {
		t.Fatalf("parse Location %q: %v", loc, err)
	}

	// Location must start with redirect_uri host+path.
	if !strings.HasPrefix(loc, redirectURI) {
		t.Errorf("Location %q does not start with redirect_uri %q", loc, redirectURI)
	}

	code := parsed.Query().Get("code")
	if len(code) < 43 {
		t.Errorf("code %q too short (want ≥43 chars)", code)
	}

	gotState := parsed.Query().Get("state")
	if gotState != state {
		t.Errorf("state = %q; want %q", gotState, state)
	}
}

// TestAuthorize_MissingRequiredParam_Returns400 asserts that each missing
// required parameter produces a 400 with error=invalid_request.
func TestAuthorize_MissingRequiredParam_Returns400(t *testing.T) {
	_, srv := newTestStub(t)

	verifier := "testverifier1234567890abcdef1234567890abcdef12"
	challenge := s256(verifier)

	base := url.Values{
		"response_type":         {"code"},
		"client_id":             {"lethe"},
		"redirect_uri":          {"http://x/cb"},
		"state":                 {"abc"},
		"code_challenge":        {challenge},
		"code_challenge_method": {"S256"},
	}

	cases := []string{"response_type", "client_id", "redirect_uri", "code_challenge"}
	for _, missing := range cases {
		t.Run("missing_"+missing, func(t *testing.T) {
			params := make(url.Values)
			for k, v := range base {
				params[k] = v
			}
			delete(params, missing)

			resp, err := http.Get(srv.URL + "/authorize?" + params.Encode()) //nolint:noctx
			if err != nil {
				t.Fatalf("GET /authorize: %v", err)
			}
			defer resp.Body.Close()

			if resp.StatusCode != http.StatusBadRequest {
				body, _ := io.ReadAll(resp.Body)
				t.Fatalf("status = %d; want 400; body=%s", resp.StatusCode, body)
			}
			var errBody struct {
				Error string `json:"error"`
			}
			body, _ := io.ReadAll(resp.Body)
			if err := json.Unmarshal(body, &errBody); err != nil {
				t.Fatalf("decode error body: %v; raw=%s", err, body)
			}
			if errBody.Error != "invalid_request" {
				t.Errorf("error = %q; want invalid_request", errBody.Error)
			}
		})
	}
}

// TestAuthorize_NonS256Challenge_Returns400 asserts that plain code_challenge_method
// is rejected with 400.
func TestAuthorize_NonS256Challenge_Returns400(t *testing.T) {
	_, srv := newTestStub(t)

	params := url.Values{
		"response_type":         {"code"},
		"client_id":             {"lethe"},
		"redirect_uri":          {"http://x/cb"},
		"state":                 {"abc"},
		"code_challenge":        {"somechallenge"},
		"code_challenge_method": {"plain"},
	}
	resp, err := http.Get(srv.URL + "/authorize?" + params.Encode()) //nolint:noctx
	if err != nil {
		t.Fatalf("GET /authorize: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusBadRequest {
		body, _ := io.ReadAll(resp.Body)
		t.Fatalf("status = %d; want 400; body=%s", resp.StatusCode, body)
	}
	var errBody struct {
		Error string `json:"error"`
	}
	body, _ := io.ReadAll(resp.Body)
	if err := json.Unmarshal(body, &errBody); err != nil {
		t.Fatalf("decode error body: %v; raw=%s", err, body)
	}
	if errBody.Error != "invalid_request" {
		t.Errorf("error = %q; want invalid_request", errBody.Error)
	}
}

// TestToken_ValidExchange_ReturnsJWT does a full authorize→token round-trip
// and verifies the JWT is parseable and expires_in matches the stub's defaultTTL.
func TestToken_ValidExchange_ReturnsJWT(t *testing.T) {
	_, srv := newTestStub(t)

	verifier := "testverifier1234567890abcdef1234567890abcdef12"
	challenge := s256(verifier)
	redirectURI := "http://x/cb"

	// Step 1: authorize.
	authURL := srv.URL + "/authorize?" + url.Values{
		"response_type":         {"code"},
		"client_id":             {"lethe"},
		"redirect_uri":          {redirectURI},
		"state":                 {"st"},
		"code_challenge":        {challenge},
		"code_challenge_method": {"S256"},
	}.Encode()

	client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse
	}}
	authResp, err := client.Get(authURL) //nolint:noctx
	if err != nil {
		t.Fatalf("GET /authorize: %v", err)
	}
	authResp.Body.Close()

	if authResp.StatusCode != http.StatusFound {
		t.Fatalf("authorize status = %d; want 302", authResp.StatusCode)
	}
	loc, _ := url.Parse(authResp.Header.Get("Location"))
	code := loc.Query().Get("code")
	if code == "" {
		t.Fatal("no code in redirect Location")
	}

	// Step 2: token exchange.
	tokenResp, err := http.PostForm(srv.URL+"/token", url.Values{ //nolint:noctx
		"grant_type":    {"authorization_code"},
		"code":          {code},
		"code_verifier": {verifier},
		"redirect_uri":  {redirectURI},
		"client_id":     {"lethe"},
	})
	if err != nil {
		t.Fatalf("POST /token: %v", err)
	}
	defer tokenResp.Body.Close()

	if tokenResp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(tokenResp.Body)
		t.Fatalf("token status = %d; want 200; body=%s", tokenResp.StatusCode, body)
	}

	var tokenBody struct {
		AccessToken string `json:"access_token"`
		IDToken     string `json:"id_token"`
		TokenType   string `json:"token_type"`
		ExpiresIn   int    `json:"expires_in"`
	}
	if err := json.NewDecoder(tokenResp.Body).Decode(&tokenBody); err != nil {
		t.Fatalf("decode token response: %v", err)
	}
	if tokenBody.AccessToken == "" {
		t.Error("access_token is empty")
	}
	if tokenBody.IDToken == "" {
		t.Error("id_token is empty")
	}
	if tokenBody.TokenType != "Bearer" {
		t.Errorf("token_type = %q; want Bearer", tokenBody.TokenType)
	}
	// defaultTTL is 1h = 3600s.
	if tokenBody.ExpiresIn != 3600 {
		t.Errorf("expires_in = %d; want 3600", tokenBody.ExpiresIn)
	}

	// Verify JWT is parseable.
	var claims map[string]any
	if err := decodeJWTPayload(tokenBody.AccessToken, &claims); err != nil {
		t.Fatalf("decodeJWTPayload(access_token): %v", err)
	}
}

// TestToken_BadVerifier_Returns400 asserts IV1: wrong verifier → 400 invalid_grant.
func TestToken_BadVerifier_Returns400(t *testing.T) {
	_, srv := newTestStub(t)

	verifier := "testverifier1234567890abcdef1234567890abcdef12"
	challenge := s256(verifier)
	redirectURI := "http://x/cb"

	authURL := srv.URL + "/authorize?" + url.Values{
		"response_type":         {"code"},
		"client_id":             {"lethe"},
		"redirect_uri":          {redirectURI},
		"state":                 {"st"},
		"code_challenge":        {challenge},
		"code_challenge_method": {"S256"},
	}.Encode()

	client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse
	}}
	authResp, err := client.Get(authURL) //nolint:noctx
	if err != nil {
		t.Fatalf("GET /authorize: %v", err)
	}
	authResp.Body.Close()
	loc, _ := url.Parse(authResp.Header.Get("Location"))
	code := loc.Query().Get("code")

	tokenResp, err := http.PostForm(srv.URL+"/token", url.Values{ //nolint:noctx
		"grant_type":    {"authorization_code"},
		"code":          {code},
		"code_verifier": {"wrong-verifier-that-does-not-match"},
		"redirect_uri":  {redirectURI},
		"client_id":     {"lethe"},
	})
	if err != nil {
		t.Fatalf("POST /token: %v", err)
	}
	defer tokenResp.Body.Close()

	if tokenResp.StatusCode != http.StatusBadRequest {
		body, _ := io.ReadAll(tokenResp.Body)
		t.Fatalf("status = %d; want 400; body=%s", tokenResp.StatusCode, body)
	}
	var errBody struct {
		Error string `json:"error"`
	}
	body, _ := io.ReadAll(tokenResp.Body)
	if err := json.Unmarshal(body, &errBody); err != nil {
		t.Fatalf("decode error body: %v", err)
	}
	if errBody.Error != "invalid_grant" {
		t.Errorf("error = %q; want invalid_grant", errBody.Error)
	}
}

// TestToken_CodeReuse_Returns400 asserts IV2: second use of the same code → 400.
func TestToken_CodeReuse_Returns400(t *testing.T) {
	_, srv := newTestStub(t)

	verifier := "testverifier1234567890abcdef1234567890abcdef12"
	challenge := s256(verifier)
	redirectURI := "http://x/cb"

	authURL := srv.URL + "/authorize?" + url.Values{
		"response_type":         {"code"},
		"client_id":             {"lethe"},
		"redirect_uri":          {redirectURI},
		"state":                 {"st"},
		"code_challenge":        {challenge},
		"code_challenge_method": {"S256"},
	}.Encode()

	client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse
	}}
	authResp, err := client.Get(authURL) //nolint:noctx
	if err != nil {
		t.Fatalf("GET /authorize: %v", err)
	}
	authResp.Body.Close()
	loc, _ := url.Parse(authResp.Header.Get("Location"))
	code := loc.Query().Get("code")

	form := url.Values{
		"grant_type":    {"authorization_code"},
		"code":          {code},
		"code_verifier": {verifier},
		"redirect_uri":  {redirectURI},
		"client_id":     {"lethe"},
	}

	// First exchange must succeed.
	r1, err := http.PostForm(srv.URL+"/token", form) //nolint:noctx
	if err != nil {
		t.Fatalf("POST /token (1st): %v", err)
	}
	r1.Body.Close()
	if r1.StatusCode != http.StatusOK {
		t.Fatalf("1st exchange status = %d; want 200", r1.StatusCode)
	}

	// Second exchange must fail (IV2).
	r2, err := http.PostForm(srv.URL+"/token", form) //nolint:noctx
	if err != nil {
		t.Fatalf("POST /token (2nd): %v", err)
	}
	defer r2.Body.Close()
	if r2.StatusCode != http.StatusBadRequest {
		body, _ := io.ReadAll(r2.Body)
		t.Fatalf("2nd exchange status = %d; want 400; body=%s", r2.StatusCode, body)
	}
	var errBody struct {
		Error string `json:"error"`
	}
	body, _ := io.ReadAll(r2.Body)
	if err := json.Unmarshal(body, &errBody); err != nil {
		t.Fatalf("decode error body: %v", err)
	}
	if errBody.Error != "invalid_grant" {
		t.Errorf("error = %q; want invalid_grant", errBody.Error)
	}
}

// TestToken_ExpiredCode_Returns400 asserts IV3: expired codes are rejected.
// This test relies on the stub's internal clock injection via testing hooks
// and performs an authorize+token cycle verifying the /token endpoint
// rejects already-expired-at-issue codes by issuing with a tiny TTL
// and waiting a moment (or using a stub with injected clock).
// Since the HTTP path uses real time.Now, we verify expiry by checking that
// the code store deletes on miss — a direct unit test (codestore_test.go) covers
// the injected-clock case. Here we test the HTTP path: issue an authorize,
// then exchange after a token lifetime > code lifetime. Because we can't
// inject time into the HTTP stub, we use an indirect approach: the codestore
// test covers IV3; this test covers the error response shape.
//
// NOTE: this test uses a real 100ms TTL trick. The stub's code TTL is hardcoded
// to 5 minutes in handleAuthorize (by design), so we cannot test expiry through
// the HTTP path without injectable clocks in the handler. The codestore_test.go
// TestCodeStore_Consume_Expired covers IV3 directly with injectable time.
// This test is kept as a placeholder that passes by documenting the limitation.
func TestToken_ExpiredCode_Returns400(t *testing.T) {
	t.Skip("IV3 HTTP expiry tested via TestCodeStore_Consume_Expired; HTTP path uses hardcoded 5m TTL")
}

// TestToken_RedirectURIMismatch_Returns400 asserts that a mismatched redirect_uri
// in /token returns 400 invalid_grant.
func TestToken_RedirectURIMismatch_Returns400(t *testing.T) {
	_, srv := newTestStub(t)

	verifier := "testverifier1234567890abcdef1234567890abcdef12"
	challenge := s256(verifier)
	redirectURI := "http://x/cb"

	authURL := srv.URL + "/authorize?" + url.Values{
		"response_type":         {"code"},
		"client_id":             {"lethe"},
		"redirect_uri":          {redirectURI},
		"state":                 {"st"},
		"code_challenge":        {challenge},
		"code_challenge_method": {"S256"},
	}.Encode()

	client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse
	}}
	authResp, err := client.Get(authURL) //nolint:noctx
	if err != nil {
		t.Fatalf("GET /authorize: %v", err)
	}
	authResp.Body.Close()
	loc, _ := url.Parse(authResp.Header.Get("Location"))
	code := loc.Query().Get("code")

	tokenResp, err := http.PostForm(srv.URL+"/token", url.Values{ //nolint:noctx
		"grant_type":    {"authorization_code"},
		"code":          {code},
		"code_verifier": {verifier},
		"redirect_uri":  {"http://y/cb"}, // mismatch
		"client_id":     {"lethe"},
	})
	if err != nil {
		t.Fatalf("POST /token: %v", err)
	}
	defer tokenResp.Body.Close()

	if tokenResp.StatusCode != http.StatusBadRequest {
		body, _ := io.ReadAll(tokenResp.Body)
		t.Fatalf("status = %d; want 400; body=%s", tokenResp.StatusCode, body)
	}
	var errBody struct {
		Error string `json:"error"`
	}
	body, _ := io.ReadAll(tokenResp.Body)
	if err := json.Unmarshal(body, &errBody); err != nil {
		t.Fatalf("decode error body: %v", err)
	}
	if errBody.Error != "invalid_grant" {
		t.Errorf("error = %q; want invalid_grant", errBody.Error)
	}
}

// TestToken_OPTIONSPreflight_Returns204_CORS asserts CORS preflight handling.
func TestToken_OPTIONSPreflight_Returns204_CORS(t *testing.T) {
	_, srv := newTestStub(t)

	req, _ := http.NewRequest(http.MethodOptions, srv.URL+"/token", nil) //nolint:noctx
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("OPTIONS /token: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusNoContent {
		body, _ := io.ReadAll(resp.Body)
		t.Fatalf("status = %d; want 204; body=%s", resp.StatusCode, body)
	}
	if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" {
		t.Errorf("Access-Control-Allow-Origin = %q; want *", got)
	}
	if got := resp.Header.Get("Access-Control-Allow-Methods"); !strings.Contains(got, "POST") {
		t.Errorf("Access-Control-Allow-Methods = %q; want contains POST", got)
	}
}

// TestDiscovery_AdvertisesCodeFlow asserts the discovery document includes
// the auth-code+PKCE fields.
func TestDiscovery_AdvertisesCodeFlow(t *testing.T) {
	_, srv := newTestStub(t)

	resp, err := http.Get(srv.URL + "/.well-known/openid-configuration") //nolint:noctx
	if err != nil {
		t.Fatalf("GET discovery: %v", err)
	}
	defer resp.Body.Close()

	var doc map[string]json.RawMessage
	if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
		t.Fatalf("decode discovery: %v", err)
	}

	// response_types_supported must include "code".
	var responseTypes []string
	if err := json.Unmarshal(doc["response_types_supported"], &responseTypes); err != nil {
		t.Fatalf("decode response_types_supported: %v", err)
	}
	hasCode := false
	for _, rt := range responseTypes {
		if rt == "code" {
			hasCode = true
		}
	}
	if !hasCode {
		t.Errorf("response_types_supported = %v; want to contain 'code'", responseTypes)
	}

	// grant_types_supported must be ["authorization_code"].
	var grantTypes []string
	if err := json.Unmarshal(doc["grant_types_supported"], &grantTypes); err != nil {
		t.Fatalf("decode grant_types_supported: %v", err)
	}
	if len(grantTypes) != 1 || grantTypes[0] != "authorization_code" {
		t.Errorf("grant_types_supported = %v; want [authorization_code]", grantTypes)
	}

	// code_challenge_methods_supported must be ["S256"].
	var ccMethods []string
	if err := json.Unmarshal(doc["code_challenge_methods_supported"], &ccMethods); err != nil {
		t.Fatalf("decode code_challenge_methods_supported: %v", err)
	}
	if len(ccMethods) != 1 || ccMethods[0] != "S256" {
		t.Errorf("code_challenge_methods_supported = %v; want [S256]", ccMethods)
	}

	// authorization_endpoint must end with /authorize.
	var authEP string
	if err := json.Unmarshal(doc["authorization_endpoint"], &authEP); err != nil {
		t.Fatalf("decode authorization_endpoint: %v", err)
	}
	if !strings.HasSuffix(authEP, "/authorize") {
		t.Errorf("authorization_endpoint = %q; want suffix /authorize", authEP)
	}
}

// TestDevToken_StillWorks is a sanity check that IV8 holds: /dev/token works.
func TestDevToken_StillWorks(t *testing.T) {
	_, srv := newTestStub(t)

	resp, err := http.Get(srv.URL + "/dev/token?sub=alice") //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"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if payload.Token == "" {
		t.Fatal("token is empty")
	}

	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: "lethe"})
	if _, err := verifier.Verify(ctx, payload.Token); err != nil {
		t.Fatalf("verifier.Verify: %v", err)
	}
}