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 }