From ede014e85b9b0d92d091b0020f69438091861985 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 13:23:16 +0100 Subject: [PATCH] oidc: test stub IdP + happy-path exchange test --- go.mod | 5 +- go.sum | 2 + internal/server/oidc/oidc_test.go | 49 +++++++ internal/server/oidc/oidctest/stub.go | 181 ++++++++++++++++++++++++++ 4 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 internal/server/oidc/oidc_test.go create mode 100644 internal/server/oidc/oidctest/stub.go diff --git a/go.mod b/go.mod index 276bf7a..4a2e12d 100644 --- a/go.mod +++ b/go.mod @@ -4,24 +4,25 @@ go 1.25.0 require ( github.com/coder/websocket v1.8.14 + github.com/coreos/go-oidc/v3 v3.18.0 github.com/go-chi/chi/v5 v5.2.5 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/oklog/ulid/v2 v2.1.1 github.com/robfig/cron/v3 v3.0.1 golang.org/x/crypto v0.50.0 + golang.org/x/oauth2 v0.36.0 golang.org/x/sys v0.43.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.50.0 ) require ( - github.com/coreos/go-oidc/v3 v3.18.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/oauth2 v0.36.0 // indirect modernc.org/libc v1.72.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 3b106ca..b48b8bf 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= diff --git a/internal/server/oidc/oidc_test.go b/internal/server/oidc/oidc_test.go new file mode 100644 index 0000000..509feec --- /dev/null +++ b/internal/server/oidc/oidc_test.go @@ -0,0 +1,49 @@ +package oidc + +import ( + "context" + "testing" + "time" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/config" + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/oidc/oidctest" +) + +func TestClientExchangeAgainstStub(t *testing.T) { + t.Parallel() + stub := oidctest.New(t) + cfg := &config.OIDCConfig{ + Issuer: stub.URL(), ClientID: "test-client", ClientSecret: "x", + Scopes: []string{"openid"}, RoleClaim: "groups", + RoleMapping: map[string]string{"rm-admins": "admin"}, + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + c, err := New(ctx, cfg, "http://rm.example") + if err != nil { + t.Fatalf("new client: %v", err) + } + code := stub.MintCode(map[string]any{ + "sub": "abc", + "preferred_username": "alice", + "email": "alice@example.com", + "groups": []string{"rm-admins"}, + }) + verifier, _, err := PKCEPair() + if err != nil { + t.Fatalf("pkce: %v", err) + } + claims, raw, err := c.Exchange(ctx, code, verifier) + if err != nil { + t.Fatalf("exchange: %v", err) + } + if claims.Subject != "abc" || claims.PreferredUsername != "alice" { + t.Errorf("claims: %+v", claims) + } + if c.MapRole(claims.Roles) != "admin" { + t.Errorf("role: got %q", c.MapRole(claims.Roles)) + } + if raw == "" { + t.Error("raw id_token must be non-empty") + } +} diff --git a/internal/server/oidc/oidctest/stub.go b/internal/server/oidc/oidctest/stub.go new file mode 100644 index 0000000..e1e4b95 --- /dev/null +++ b/internal/server/oidc/oidctest/stub.go @@ -0,0 +1,181 @@ +// 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 +}