// Package oidctest provides a minimal OIDC provider for tests — // discovery doc, JWKS, and a token endpoint. Each test mints its // own claims; the stub signs them with an ECDSA P-256 key and the // production verifier accepts them because the JWKS is fetched live // from the stub. // // Usage: // // stub := oidctest.New(t) // code := stub.MintCode(map[string]any{ // "sub": "abc", // "preferred_username": "alice", // "groups": []string{"rm-admins"}, // }) // // stub.URL() is the issuer URL; pass to oidc.New as Issuer package oidctest import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "encoding/base64" "encoding/json" "fmt" stdhttp "net/http" "net/http/httptest" "sync" "testing" "time" "github.com/golang-jwt/jwt/v5" ) // StubIdP is an httptest-backed OIDC provider. Each test creates a // fresh one via New(t); cleanup is registered on t. type StubIdP struct { t *testing.T srv *httptest.Server mu sync.Mutex priv *ecdsa.PrivateKey kid string claims map[string]map[string]any // code → claims endSession string // optional, set by SetEndSessionEndpoint } // New constructs a stub IdP listening on a random port. Cleanup is // registered on t. func New(t *testing.T) *StubIdP { t.Helper() priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatalf("oidctest: genkey: %v", err) } s := &StubIdP{ t: t, priv: priv, kid: "stub-key", claims: map[string]map[string]any{}, } mux := stdhttp.NewServeMux() mux.HandleFunc("/.well-known/openid-configuration", s.discovery) mux.HandleFunc("/jwks.json", s.jwks) mux.HandleFunc("/token", s.token) s.srv = httptest.NewServer(mux) t.Cleanup(s.srv.Close) return s } // URL returns the base URL of the stub — pass as Issuer to // oidc.New(). func (s *StubIdP) URL() string { return s.srv.URL } // MintCode produces an authorization code that the stub will exchange // for an id_token containing the supplied claims. func (s *StubIdP) MintCode(claims map[string]any) string { s.mu.Lock() defer s.mu.Unlock() code := fmt.Sprintf("code-%d", time.Now().UnixNano()) s.claims[code] = claims return code } // SetEndSessionEndpoint configures the stub to advertise an // end_session_endpoint in its discovery doc. Used by the logout // test in E1. func (s *StubIdP) SetEndSessionEndpoint(url string) { s.mu.Lock() defer s.mu.Unlock() s.endSession = url } func (s *StubIdP) discovery(w stdhttp.ResponseWriter, _ *stdhttp.Request) { s.mu.Lock() endSession := s.endSession s.mu.Unlock() doc := map[string]any{ "issuer": s.srv.URL, "authorization_endpoint": s.srv.URL + "/authorize", "token_endpoint": s.srv.URL + "/token", "jwks_uri": s.srv.URL + "/jwks.json", "id_token_signing_alg_values_supported": []string{"ES256"}, "response_types_supported": []string{"code"}, "subject_types_supported": []string{"public"}, } if endSession != "" { doc["end_session_endpoint"] = endSession } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(doc) } func (s *StubIdP) jwks(w stdhttp.ResponseWriter, _ *stdhttp.Request) { pub := s.priv.Public().(*ecdsa.PublicKey) x := base64.RawURLEncoding.EncodeToString(padTo32(pub.X.Bytes())) y := base64.RawURLEncoding.EncodeToString(padTo32(pub.Y.Bytes())) keys := map[string]any{ "keys": []map[string]any{{ "kty": "EC", "crv": "P-256", "alg": "ES256", "use": "sig", "kid": s.kid, "x": x, "y": y, }}, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(keys) } func (s *StubIdP) token(w stdhttp.ResponseWriter, r *stdhttp.Request) { _ = r.ParseForm() code := r.PostForm.Get("code") s.mu.Lock() claims, ok := s.claims[code] if ok { delete(s.claims, code) } s.mu.Unlock() if !ok { stdhttp.Error(w, "bad code", stdhttp.StatusBadRequest) return } if _, ok := claims["iss"]; !ok { claims["iss"] = s.srv.URL } if _, ok := claims["aud"]; !ok { claims["aud"] = "test-client" } now := time.Now().Unix() claims["iat"] = now claims["exp"] = now + 600 jc := jwt.MapClaims{} for k, v := range claims { jc[k] = v } tk := jwt.NewWithClaims(jwt.SigningMethodES256, jc) tk.Header["kid"] = s.kid signed, err := tk.SignedString(s.priv) if err != nil { stdhttp.Error(w, err.Error(), 500) return } resp := map[string]any{ "access_token": "stub-access", "token_type": "Bearer", "id_token": signed, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } // padTo32 left-pads an integer big-endian byte slice to 32 bytes, // the size required by P-256 JWK x/y components. func padTo32(b []byte) []byte { if len(b) >= 32 { return b } out := make([]byte, 32) copy(out[32-len(b):], b) return out }