88 lines
3.2 KiB
Go
88 lines
3.2 KiB
Go
// 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
|
|
}
|