Files
mats 2f3ea99745 feat(auth): minted keys + device login for the admin API and MCP endpoints
platform/auth gains the LocalJWT (HS256 mint/verify, AUTH_TOKEN_SECRET,
disabled when unset), the RFC 8628 DeviceFlow (start/approve/poll with
requested-scope gating) and KeyStore.ReloadIfChanged (mtime-based, so a
freshly minted key works across processes without a restart). One shared
store at /commerce/apikeys.json (AUTH_APIKEYS_FILE) now backs the
backoffice admin gate, the CMS API and every MCP endpoint; AUTH_TOKEN_SECRET
lets the companion app's device flow mint bearer tokens the same surfaces
accept. Submodule bumps: backoffice (AuthGate + device endpoints + mcp
scope), go-cart-actor (mcpauth sources), go-graph-cms-v2 (shared store +
token chain).

Also lands the still-pending receipt-ocr sidecar (previous pass): the
PP-OCR ONNX inference service + download-ocr-models.sh + k8s
deployment/initContainer, wired into compose and the ledger RECEIPT_OCR_URL,
with the extractor pairing fix riding the go-cart-actor bump.

Deploy: AUTH_APIKEYS_FILE/AUTH_TOKEN_SECRET/AUTH_DEVICE_BASE_URL on
backoffice + cart/profile/order/ledger in compose and k8s, /device ingress
route, stack-secrets AUTH_TOKEN_SECRET optional ref, docs updated.

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-08-16 12:48:03 +02:00

116 lines
3.8 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"
KindDevice = "device" // platform-minted access token (device flow)
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"]
Roles []string `json:"roles,omitempty"` // e.g. ["editor","publisher","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)
}
// HasRole reports whether the principal carries the given role (or is an admin).
func (p *Principal) HasRole(role string) bool {
if role == "" || role == "*" {
return true
}
for _, r := range p.Roles {
if r == "admin" || r == role {
return true
}
}
return false
}
// HasAnyRole reports whether the principal carries at least one of the given roles.
func (p *Principal) HasAnyRole(roles ...string) bool {
if len(roles) == 0 {
return true
}
for _, r := range roles {
if p.HasRole(r) {
return true
}
}
return false
}
// 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
}