✨ feat(auth): pkg/auth skeleton for OpenID Connect (ADR-0028 Phase B prep) (#69)
Co-authored-by: Gabriel Radureau <arcodange@gmail.com> Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
This commit was merged in pull request #69.
This commit is contained in:
104
pkg/auth/oidc.go
Normal file
104
pkg/auth/oidc.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
13
pkg/auth/oidc_test.go
Normal file
13
pkg/auth/oidc_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user