// Package auth is the stable cross-service authentication seam for the // platform. It is provider-agnostic: one Authenticator interface with // interchangeable impls — OIDC (validates a bearer JWT against any OIDC // issuer; Zitadel is the default reference), API key (validates X-API-Key // against the KeyStore), and a Chain combinator that runs them in order. // // Packages under platform/ import only the standard library and other // platform/ packages — never an application module. Application modules // depend downward onto platform. This is what keeps the auth seam stable // and reusable. package auth import ( "errors" "net/http" "slices" ) // Kind values for Principal.Kind. Comparing against these strings is the // authorization layer's primitive: handlers ask "is this OIDC or an API // key?" rather than re-implementing the chain logic. const ( KindOIDC = "oidc" KindAPIKey = "apikey" KindAnonymous = "anonymous" ) // ErrNoCredentials is returned when a request carries no credentials for the // authenticator (no bearer token / no API key). Chain treats it like any // other failure and falls through to the next authenticator. var ErrNoCredentials = errors.New("auth: no credentials") // Principal is the authenticated caller. Authorization (scope checks) is a // thin layer on top: handlers ask "does this principal have scope X", not // "how did it authenticate". type Principal struct { Subject string `json:"subject"` // OIDC sub or API key id Scopes []string `json:"scopes,omitempty"` // e.g. ["write","admin"] Kind string `json:"kind"` // KindOIDC | KindAPIKey | KindAnonymous } // HasScope reports whether the principal carries the given scope. func (p *Principal) HasScope(scope string) bool { return slices.Contains(p.Scopes, scope) } // Authenticator inspects request credentials and returns the caller, or an // error if authentication fails. type Authenticator interface { Authenticate(r *http.Request) (*Principal, error) } // Chain tries each authenticator in order, returning the first successful // Principal. It is how bearer-then-apikey composition is expressed. type Chain []Authenticator var _ Authenticator = Chain(nil) // Authenticate returns the first non-error result. On failure it returns // the first *real* validation error — an authenticator reporting // ErrNoCredentials merely didn't apply (e.g. no X-API-Key header alongside // a bad bearer token) and must not mask why the presented credential was // rejected. func (c Chain) Authenticate(r *http.Request) (*Principal, error) { var firstErr error for _, a := range c { p, err := a.Authenticate(r) if err == nil { return p, nil } if firstErr == nil || (errors.Is(firstErr, ErrNoCredentials) && !errors.Is(err, ErrNoCredentials)) { firstErr = err } } return nil, firstErr } // Anonymous authenticates every request as an unauthenticated principal // with no scopes. It is the placeholder for tests and local-dev runs; it // must never be wired in production. type Anonymous struct{} var _ Authenticator = Anonymous{} func (Anonymous) Authenticate(*http.Request) (*Principal, error) { return &Principal{Subject: "anonymous", Kind: KindAnonymous}, nil }