oidc: test stub IdP + happy-path exchange test
This commit is contained in:
@@ -4,24 +4,25 @@ go 1.25.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/coder/websocket v1.8.14
|
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/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/oklog/ulid/v2 v2.1.1
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
golang.org/x/crypto v0.50.0
|
golang.org/x/crypto v0.50.0
|
||||||
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/sys v0.43.0
|
golang.org/x/sys v0.43.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.50.0
|
modernc.org/sqlite v1.50.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/coreos/go-oidc/v3 v3.18.0 // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/libc v1.72.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|||||||
@@ -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-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 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user