// 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. Cfg holds the full AuthConfig so steward injection by type
// matches the registered `auth` config section (sibling-consistent with
// Authenticator and OIDCDevStub); the verifier reads `Cfg.OIDC.*` fields.
type OIDCVerifier struct {
Cfg config.AuthConfig `config:""`
verifier *oidc.IDTokenVerifier
usernameClaim string
}
// Init performs OIDC discovery against Cfg.OIDC.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.OIDC.Issuer)
if err != nil {
return culpa.WithCode(culpa.Wrap(err, "oidc discovery"), "OIDC_DISCOVERY")
}
v.verifier = provider.Verifier(&oidc.Config{ClientID: v.Cfg.OIDC.Audience})
v.usernameClaim = v.Cfg.OIDC.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
}