From fbf00a3cd08e0fc234b9d752e967e55f343c00c0 Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Tue, 5 May 2026 19:24:41 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(auth):=20pkg/auth=20skeleton?= =?UTF-8?q?=20for=20OpenID=20Connect=20(ADR-0028=20Phase=20B=20prep)=20(#6?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gabriel Radureau Co-committed-by: Gabriel Radureau --- pkg/auth/oidc.go | 104 ++++++++++++++++++++++++++++++++++++++++++ pkg/auth/oidc_test.go | 13 ++++++ 2 files changed, 117 insertions(+) create mode 100644 pkg/auth/oidc.go create mode 100644 pkg/auth/oidc_test.go diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go new file mode 100644 index 0000000..81b3c4a --- /dev/null +++ b/pkg/auth/oidc.go @@ -0,0 +1,104 @@ +// Package auth provides OpenID Connect client primitives for the +// dance-lessons-coach passwordless-auth migration (ADR-0028 Phase B). +// +// This file defines the client surface only. HTTP handlers wire-up +// happens in pkg/user/api/oidc_handler.go (separate phase B.3). +package auth + +import ( + "context" + "crypto/rsa" + "net/http" + "sync" + "time" +) + +// OIDCClient is a per-provider OIDC client. +// Holds the discovery document + JWKS cache + OAuth code-exchange config. +type OIDCClient struct { + issuerURL string + clientID string + clientSecret string + httpClient *http.Client + + // discovery document, lazy-fetched on first use + discoveryMu sync.RWMutex + discovery *Discovery + + // JWKS cache (id_token signature verification keys), refreshed periodically + jwksMu sync.RWMutex + jwks map[string]*rsa.PublicKey + jwksFetched time.Time +} + +// Discovery is the subset of the .well-known/openid-configuration document we use. +type Discovery struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JWKSUri string `json:"jwks_uri"` + UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"` +} + +// TokenResponse is the response from the token endpoint after code exchange. +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token,omitempty"` + IDToken string `json:"id_token"` + Scope string `json:"scope,omitempty"` +} + +// IDTokenClaims represents the parsed claims from an ID token. +type IDTokenClaims struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + Audience string `json:"aud"` + ExpirationTime int64 `json:"exp"` + IssuedAt int64 `json:"iat"` + Nonce string `json:"nonce,omitempty"` + Email string `json:"email,omitempty"` + EmailVerified bool `json:"email_verified,omitempty"` +} + +// NewOIDCClient constructs a client. Discovery + JWKS are NOT fetched eagerly; +// they are lazy-loaded on first use to avoid blocking server startup if the +// provider is temporarily down. +func NewOIDCClient(issuerURL, clientID, clientSecret string) *OIDCClient { + return &OIDCClient{ + issuerURL: issuerURL, + clientID: clientID, + clientSecret: clientSecret, + httpClient: &http.Client{Timeout: 10 * time.Second}, + jwks: make(map[string]*rsa.PublicKey), + } +} + +// Discover fetches and caches the .well-known document. Idempotent. +// First call: HTTP fetch + cache. Subsequent calls: cached value. +func (c *OIDCClient) Discover(ctx context.Context) (*Discovery, error) { + // TODO Phase B.3: implement (HTTP GET issuerURL + "/.well-known/openid-configuration") + return nil, nil // placeholder for skeleton phase +} + +// RefreshJWKS fetches JWKS URI, parse keys, populate jwks map. +// TODO Phase B.3: implement +func (c *OIDCClient) RefreshJWKS(ctx context.Context) error { + // TODO Phase B.3: implement (HTTP GET to JWKS URI from discovery, parse keys) + return nil // placeholder for skeleton phase +} + +// ExchangeCode exchanges an authorization code for an access token and ID token. +// TODO Phase B.3: implement +func (c *OIDCClient) ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI string) (*TokenResponse, error) { + // TODO Phase B.3: implement (POST to token_endpoint with code, code_verifier, redirect_uri) + return nil, nil // placeholder for skeleton phase +} + +// ValidateIDToken verifies the signature and claims of an ID token. +// TODO Phase B.3: implement +func (c *OIDCClient) ValidateIDToken(ctx context.Context, idToken string) (*IDTokenClaims, error) { + // TODO Phase B.3: implement (verify signature with JWKS, validate claims) + return nil, nil // placeholder for skeleton phase +} diff --git a/pkg/auth/oidc_test.go b/pkg/auth/oidc_test.go new file mode 100644 index 0000000..952e6f5 --- /dev/null +++ b/pkg/auth/oidc_test.go @@ -0,0 +1,13 @@ +package auth + +import "testing" + +func TestNewOIDCClient(t *testing.T) { + c := NewOIDCClient("https://example.com", "client_id", "client_secret") + if c == nil { + t.Fatal("NewOIDCClient returned nil") + } + if c.issuerURL != "https://example.com" { + t.Errorf("issuerURL not set: got %q", c.issuerURL) + } +}