package oidcstub_test import ( "context" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "net/url" "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) } // 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) } }