// 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",
)
}