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

165 lines
4.9 KiB
Go

package auth
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
// LocalJWT signs and verifies HS256 JWTs with a shared secret. These are the
// platform's OWN access tokens (the device-authorization flow mints them) as
// opposed to OIDC tokens minted by an external issuer. Services that need to
// accept them — the backoffice admin API, the MCP endpoints — share the same
// secret via env (AUTH_TOKEN_SECRET) and validate locally; no round-trip to
// the minting service.
//
// A LocalJWT with an empty secret is DISABLED: Mint errors and Verify
// returns ErrNoCredentials, so a deployment that never sets AUTH_TOKEN_SECRET
// simply has no device tokens.
//
// Header/claims are the standard compact JWT. HS256 (HMAC-SHA256) is the
// deliberate choice: symmetric validation matches the "shared secret across
// services" deployment, and crypto/hmac is stdlib-only (platform's import
// constraint).
type LocalJWT struct {
secret []byte
now func() time.Time // injectable clock for tests
}
// NewLocalJWT returns a signer/verifier for the given secret. Empty secret →
// disabled.
func NewLocalJWT(secret string) *LocalJWT {
return &LocalJWT{secret: []byte(secret), now: time.Now}
}
// Enabled reports whether a secret is configured (Mint/Verify will work).
func (l *LocalJWT) Enabled() bool { return l != nil && len(l.secret) > 0 }
// localClaims is the payload of a platform-minted access token.
type localClaims struct {
Sub string `json:"sub"`
Scopes []string `json:"scopes,omitempty"`
Iss string `json:"iss,omitempty"`
Aud string `json:"aud,omitempty"`
Iat int64 `json:"iat"`
Exp int64 `json:"exp"`
}
// Mint creates a signed access token for subject with the given scopes,
// valid for ttl.
func (l *LocalJWT) Mint(subject string, scopes []string, ttl time.Duration) (string, error) {
if !l.Enabled() {
return "", errors.New("auth: local JWT disabled (AUTH_TOKEN_SECRET not set)")
}
if subject == "" {
return "", errors.New("auth: mint: subject required")
}
now := l.now().Unix()
claims := localClaims{
Sub: subject,
Scopes: scopes,
Iss: "platform",
Iat: now,
Exp: now + int64(ttl/time.Second),
}
payload, err := json.Marshal(claims)
if err != nil {
return "", err
}
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
body := base64.RawURLEncoding.EncodeToString(payload)
sig, err := l.sign(header + "." + body)
if err != nil {
return "", err
}
return header + "." + body + "." + sig, nil
}
func (l *LocalJWT) sign(data string) (string, error) {
mac := hmac.New(sha256.New, l.secret)
if _, err := mac.Write([]byte(data)); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
var (
// ErrTokenExpired is returned when a presented token's exp has passed.
ErrTokenExpired = errors.New("auth: token expired")
// ErrTokenInvalid is returned for malformed or incorrectly signed tokens.
ErrTokenInvalid = errors.New("auth: invalid token")
)
// Verify parses and validates a compact JWT, returning the caller principal.
// The token must be HS256-signed with the configured secret and unexpired.
func (l *LocalJWT) Verify(token string) (*Principal, error) {
if !l.Enabled() {
return nil, ErrNoCredentials
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, ErrTokenInvalid
}
// Header: must be the HS256 header (reject alg confusion).
hd, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil || string(hd) != `{"alg":"HS256","typ":"JWT"}` {
return nil, ErrTokenInvalid
}
// Signature.
want, err := l.sign(parts[0] + "." + parts[1])
if err != nil {
return nil, ErrTokenInvalid
}
if subtle.ConstantTimeCompare([]byte(parts[2]), []byte(want)) != 1 {
return nil, ErrTokenInvalid
}
// Payload.
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, ErrTokenInvalid
}
var c localClaims
if err := json.Unmarshal(raw, &c); err != nil {
return nil, ErrTokenInvalid
}
if c.Sub == "" {
return nil, ErrTokenInvalid
}
now := l.now().Unix()
if c.Exp > 0 && now > c.Exp {
return nil, ErrTokenExpired
}
if c.Iat > 0 && c.Iat > now+60 { // tolerate minor clock skew
return nil, ErrTokenInvalid
}
return &Principal{Subject: c.Sub, Scopes: c.Scopes, Kind: KindDevice}, nil
}
// Authenticate implements the platform Authenticator seam for
// `Authorization: Bearer <local jwt>`.
func (l *LocalJWT) Authenticate(r *http.Request) (*Principal, error) {
raw := r.Header.Get("Authorization")
tok, ok := strings.CutPrefix(raw, "Bearer ")
if !ok || tok == "" {
return nil, ErrNoCredentials
}
return l.Verify(tok)
}
var _ Authenticator = (*LocalJWT)(nil)
// String avoids accidental secret logging if a LocalJWT is misprinted.
func (l *LocalJWT) String() string {
if l == nil {
return "<nil>"
}
return fmt.Sprintf("LocalJWT(secret=%d bytes)", len(l.secret))
}