✨ feat(auth): pkg/auth skeleton for OpenID Connect (ADR-0028 Phase B prep) #69
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