c1cc781279
- argon2id (t=3, m=64MiB, p=2) PHC hashing honoring embedded params - token-bucket rate limiting (10/15min per IP+email) on login/register - opaque 32B session tokens in Mongo, 30-day sliding expiry, logout-all - CSRF double-submit cookie/header on authenticated mutations - bootstrap admin from env with forced first-login password change - requireAuth/requireRole middleware with disabled-account enforcement - OIDC code flow with PKCE: lazy discovery, account linking only on verified email, auto-created developer accounts - unit tests (RBAC matrix, CSRF, password, rate limiter) + integration suite covering the full auth matrix incl. an in-test fake IdP Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
117 lines
3.2 KiB
Go
117 lines
3.2 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"golang.org/x/oauth2"
|
|
|
|
"bountyboard/internal/config"
|
|
)
|
|
|
|
// OIDCClient implements the §7 SSO code flow with PKCE. The provider is
|
|
// discovered lazily on first use (and retried on failure) so a temporarily
|
|
// unreachable IdP cannot prevent the app from booting.
|
|
type OIDCClient struct {
|
|
cfg *config.Config
|
|
log *slog.Logger
|
|
|
|
mu sync.Mutex
|
|
provider *oidc.Provider
|
|
}
|
|
|
|
// OIDCClaims is what the rest of the app needs from a verified ID token.
|
|
type OIDCClaims struct {
|
|
Issuer string
|
|
Subject string
|
|
Email string
|
|
EmailVerified bool
|
|
Name string
|
|
}
|
|
|
|
func NewOIDCClient(cfg *config.Config, log *slog.Logger) *OIDCClient {
|
|
return &OIDCClient{cfg: cfg, log: log}
|
|
}
|
|
|
|
func (o *OIDCClient) Enabled() bool { return o.cfg.OIDC.Enabled() }
|
|
|
|
func (o *OIDCClient) getProvider(ctx context.Context) (*oidc.Provider, error) {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
if o.provider != nil {
|
|
return o.provider, nil
|
|
}
|
|
p, err := oidc.NewProvider(ctx, o.cfg.OIDC.IssuerURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("oidc discovery for %s: %w", o.cfg.OIDC.IssuerURL, err)
|
|
}
|
|
o.provider = p
|
|
return p, nil
|
|
}
|
|
|
|
func (o *OIDCClient) oauthConfig(p *oidc.Provider) *oauth2.Config {
|
|
return &oauth2.Config{
|
|
ClientID: o.cfg.OIDC.ClientID,
|
|
ClientSecret: o.cfg.OIDC.ClientSecret,
|
|
Endpoint: p.Endpoint(),
|
|
RedirectURL: o.cfg.AppBaseURL + "/api/v1/auth/oidc/callback",
|
|
Scopes: []string{oidc.ScopeOpenID, "email", "profile"},
|
|
}
|
|
}
|
|
|
|
// AuthURL starts the flow: returns the IdP redirect plus the state and PKCE
|
|
// verifier the caller must persist (short-lived cookies).
|
|
func (o *OIDCClient) AuthURL(ctx context.Context) (url, state, verifier string, err error) {
|
|
if !o.Enabled() {
|
|
return "", "", "", errors.New("oidc is not configured")
|
|
}
|
|
p, err := o.getProvider(ctx)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
state = NewToken()
|
|
verifier = oauth2.GenerateVerifier()
|
|
url = o.oauthConfig(p).AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
|
|
return url, state, verifier, nil
|
|
}
|
|
|
|
// Exchange swaps the code (with PKCE verifier) for tokens and returns the
|
|
// verified ID-token claims.
|
|
func (o *OIDCClient) Exchange(ctx context.Context, code, verifier string) (*OIDCClaims, error) {
|
|
p, err := o.getProvider(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tok, err := o.oauthConfig(p).Exchange(ctx, code, oauth2.VerifierOption(verifier))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("oidc code exchange: %w", err)
|
|
}
|
|
rawID, ok := tok.Extra("id_token").(string)
|
|
if !ok {
|
|
return nil, errors.New("token response missing id_token")
|
|
}
|
|
idToken, err := p.Verifier(&oidc.Config{ClientID: o.cfg.OIDC.ClientID}).Verify(ctx, rawID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("verify id_token: %w", err)
|
|
}
|
|
var claims struct {
|
|
Email string `json:"email"`
|
|
EmailVerified bool `json:"email_verified"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := idToken.Claims(&claims); err != nil {
|
|
return nil, fmt.Errorf("decode id_token claims: %w", err)
|
|
}
|
|
return &OIDCClaims{
|
|
Issuer: idToken.Issuer,
|
|
Subject: idToken.Subject,
|
|
Email: claims.Email,
|
|
EmailVerified: claims.EmailVerified,
|
|
Name: claims.Name,
|
|
}, nil
|
|
}
|