// Package auth implements the HTTP authentication boundary for the lethe // server. It provides two coordinated paths — forward-auth (a trusted // reverse-proxy header) and OIDC bearer (validated against an issuer's JWKS) // — and gates every /api/v1/* route on a shared allowlist. // // The Authenticator is a steward service: zero-value, configured via the // `config:""` tag, with all setup performed in Init. It holds the lowercased // allowlist and admin sets so the request path stays allocation-free. // // Resolution order (per spec): // 1. If OIDC is enabled and the request carries `Authorization: Bearer ...`, // the token is verified and its username claim is the resolved user. A // present-but-invalid bearer is a hard 401 — never falls back to the // header path. This is the "fail-closed" invariant. // 2. Otherwise, if forward-auth is enabled and the configured user header is // non-empty, that header value is the resolved user. // 3. Otherwise, 401. // // After resolution, the user is lowercased and matched against the allowlist // (403 on miss). The same allowlist applies regardless of which path resolved // the user — there is no "OIDC bypasses the list" mode. The resolved user is // stamped onto the request context (for handlers via IdentityFrom and for the // access logger via observability.WithUser) before next.ServeHTTP. package auth import ( "context" "net/http" "strings" "go.bigb.es/auxilia/culpa" "sourcecraft.dev/bigbes/lethe/internal/config" "sourcecraft.dev/bigbes/lethe/internal/pkg/apierror" "sourcecraft.dev/bigbes/lethe/internal/platform/observability" ) // bearerPrefix is the case-sensitive prefix per RFC 6750 §2.1 that selects // the OIDC verification path. const bearerPrefix = "Bearer " // Identity is the authenticated principal stored on the request context for // downstream handlers. IsAdmin reflects membership in `auth.admins`. type Identity struct { User string IsAdmin bool } // identityKey is the unexported context key for Identity values. Using a // dedicated zero-size type prevents collisions with values stored under // string keys elsewhere. type identityKey struct{} // WithIdentity attaches id to ctx. Handlers downstream of the auth middleware // can recover it with IdentityFrom or MustIdentity. func WithIdentity(ctx context.Context, id Identity) context.Context { return context.WithValue(ctx, identityKey{}, id) } // IdentityFrom returns the Identity previously attached with WithIdentity. // The boolean is false when no identity is present (i.e. the request was // dispatched outside the authenticated /api/v1/* group). func IdentityFrom(ctx context.Context) (Identity, bool) { v, ok := ctx.Value(identityKey{}).(Identity) return v, ok } // MustIdentity returns the Identity stored on ctx and panics if none is // present. Handlers that mount under the auth middleware can rely on it; a // missing identity is a programmer error in route mounting. func MustIdentity(ctx context.Context) Identity { id, ok := IdentityFrom(ctx) if !ok { panic("auth: MustIdentity called outside authenticated route group") } return id } // Authenticator is the steward-managed HTTP middleware that resolves the // caller's identity. Verifier is optional and only consulted when OIDC is // enabled; main.go (Phase 9) is responsible for registering OIDCVerifier // conditionally on `cfg.Auth.OIDC.Enabled`. type Authenticator struct { Cfg config.AuthConfig `config:""` Log *observability.Logger `inject:""` Verifier *OIDCVerifier `inject:"" optional:"true"` allowed map[string]struct{} admins map[string]struct{} } // Init builds the lowercased allowlist and admin sets and asserts that the // configured paths have the dependencies they need. Forward-auth without a // header name and OIDC without a verifier are config invariant breaches — // both should have been caught earlier (config validator / Phase-9 wiring), // but defense-in-depth fails closed here too. func (a *Authenticator) Init(_ context.Context) error { if a.Cfg.OIDC.Enabled && a.Verifier == nil { return culpa.WithCode( culpa.New("OIDC enabled but verifier not registered"), "CONFIG_INVALID", ) } if a.Cfg.ForwardAuth.Enabled && a.Cfg.ForwardAuth.UserHeader == "" { return culpa.WithCode( culpa.New("forward_auth enabled but user_header is empty"), "CONFIG_INVALID", ) } a.allowed = make(map[string]struct{}, len(a.Cfg.AllowedUsers)) for _, u := range a.Cfg.AllowedUsers { a.allowed[strings.ToLower(u)] = struct{}{} } a.admins = make(map[string]struct{}, len(a.Cfg.Admins)) for _, u := range a.Cfg.Admins { a.admins[strings.ToLower(u)] = struct{}{} } return nil } // Middleware returns an http.Handler that authenticates the request before // calling next. Failures render an RFC 7807 problem document via apierror // and short-circuit the chain. func (a *Authenticator) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, err := a.resolveUser(r) if err != nil { apierror.Render(w, r, err) return } user = strings.ToLower(user) if _, ok := a.allowed[user]; !ok { apierror.Render(w, r, culpa.WithCode( culpa.New("user not in allowlist"), "FORBIDDEN", )) return } _, isAdmin := a.admins[user] ctx := WithIdentity(r.Context(), Identity{User: user, IsAdmin: isAdmin}) ctx = observability.WithUser(ctx, user) next.ServeHTTP(w, r.WithContext(ctx)) }) } // resolveUser implements the bearer-then-header resolution order described // in the package doc. A present-but-invalid bearer never falls back to the // header path — that would silently downgrade auth and is the bedrock test // case in the suite. func (a *Authenticator) resolveUser(r *http.Request) (string, error) { if a.Cfg.OIDC.Enabled { authz := r.Header.Get("Authorization") if strings.HasPrefix(authz, bearerPrefix) { token := strings.TrimPrefix(authz, bearerPrefix) user, err := a.Verifier.Verify(r.Context(), token) if err != nil { return "", err } return user, nil } } if a.Cfg.ForwardAuth.Enabled { if v := r.Header.Get(a.Cfg.ForwardAuth.UserHeader); v != "" { return v, nil } } return "", culpa.WithCode( culpa.New("authentication required"), "UNAUTHORIZED", ) }