Files
BountyBoard/internal/auth/oidc.go
etalon c1cc781279 feat: authentication with local accounts, sessions, CSRF, RBAC, and OIDC PKCE (phase 3)
- 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>
2026-06-12 18:31:25 +02:00

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
}