// OIDC bearer-token verification. // // OIDCVerifier wraps coreos/go-oidc/v3 with the lethe conventions: zero-value // construction, `Init` performs the discovery handshake, `Verify` returns // culpa-coded errors. The verifier is registered as a steward service in // main only when `cfg.Auth.OIDC.Enabled` (Phase 9). When disabled, the // Authenticator's `Verifier` injection point is left nil and the OIDC branch // of the middleware never fires. // // Init reaches out to the issuer's `/.well-known/openid-configuration` and // caches the JWKS — a hard-fail at startup is the chosen tradeoff: lethe // refuses to come up rather than silently accept unauthenticated requests // later if Authelia happens to be down at boot. (Risks doc, Phase 6.) package auth import ( "context" "github.com/coreos/go-oidc/v3/oidc" "go.bigb.es/auxilia/culpa" "sourcecraft.dev/bigbes/lethe/internal/config" ) // subClaim is the canonical RFC 7519 subject claim name. Used as the // fallback when the configured username claim is absent or non-string. const subClaim = "sub" // OIDCVerifier is the steward-managed bearer-token verifier. It is a // zero-value type: construct with `&OIDCVerifier{Cfg: ...}` and call Init // before Verify. type OIDCVerifier struct { Cfg config.OIDCConfig `config:""` verifier *oidc.IDTokenVerifier usernameClaim string } // Init performs OIDC discovery against Cfg.Issuer and constructs the JWKS- // backed verifier. Failures are wrapped with codes that distinguish the // discovery handshake (likely a network/issuer problem) from any other // initialization fault. func (v *OIDCVerifier) Init(ctx context.Context) error { provider, err := oidc.NewProvider(ctx, v.Cfg.Issuer) if err != nil { return culpa.WithCode(culpa.Wrap(err, "oidc discovery"), "OIDC_DISCOVERY") } v.verifier = provider.Verifier(&oidc.Config{ClientID: v.Cfg.Audience}) v.usernameClaim = v.Cfg.UsernameClaim if v.usernameClaim == "" { // Defense in depth: the validator already requires a non-empty value // when OIDC is enabled, but a zero-value here would silently fall // through to `sub` on every token, which is not what the operator // asked for. return culpa.WithCode( culpa.New("oidc username_claim is empty"), "OIDC_INIT", ) } return nil } // Verify validates raw against the cached JWKS, then extracts the username. // The chosen username field is `claims[usernameClaim]` when present and a // non-empty string, falling back to `claims[sub]`. Any verification failure // — bad signature, expired, wrong audience, malformed — surfaces with code // UNAUTHORIZED so the HTTP boundary renders 401. func (v *OIDCVerifier) Verify(ctx context.Context, raw string) (string, error) { idToken, err := v.verifier.Verify(ctx, raw) if err != nil { return "", culpa.WithCode(culpa.Wrap(err, "verify token"), "UNAUTHORIZED") } var claims map[string]any if err := idToken.Claims(&claims); err != nil { return "", culpa.WithCode(culpa.Wrap(err, "decode token claims"), "UNAUTHORIZED") } if user, ok := stringClaim(claims, v.usernameClaim); ok && user != "" { return user, nil } if sub, ok := stringClaim(claims, subClaim); ok && sub != "" { return sub, nil } return "", culpa.WithCode( culpa.New("token has no usable username claim"), "UNAUTHORIZED", ) } // stringClaim pulls key from claims as a string. Non-string values (numbers, // nested objects) are reported as missing rather than coerced — coercion // would mask a misconfigured username_claim. func stringClaim(claims map[string]any, key string) (string, bool) { v, ok := claims[key] if !ok { return "", false } s, ok := v.(string) if !ok { return "", false } return s, true }