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>
This commit is contained in:
mats committed 2026-08-16 12:48:03 +02:00
1 parent d74c8a0592
commit 2f3ea99745
6 files changed
+864 -12

No files matched your search

+45 -12
View File
@@ -34,25 +34,23 @@ var ErrKeyNotFound = errors.New("auth: api key not found")
// KeyStore is a file-backed set of API keys (<file> is JSON, written
// atomically). Volume is tiny — a handful of integration keys — so a flat
// file beats dragging key records into the event-sourced graph store.
//
// One store can be shared across services (e.g. /commerce/apikeys.json on a
// common volume): the writer mints/revokes in-process, readers poll with
// ReloadIfChanged so a newly minted key works without a restart.
type KeyStore struct {
path string
mu sync.Mutex
keys []APIKey
path string
mu sync.Mutex
keys []APIKey
modTime time.Time // file mtime seen at the last load
}
// OpenKeyStore loads (or initializes) the key store at path.
func OpenKeyStore(path string) (*KeyStore, error) {
s := &KeyStore{path: path}
raw, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return s, nil
}
if err != nil {
if err := s.ReloadIfChanged(); err != nil {
return nil, err
}
if err := json.Unmarshal(raw, &s.keys); err != nil {
return nil, fmt.Errorf("auth: parse %s: %w", path, err)
}
return s, nil
}
@@ -83,6 +81,7 @@ func (s *KeyStore) Mint(name string, scopes []string) (key string, rec APIKey, e
s.keys = s.keys[:len(s.keys)-1]
return "", APIKey{}, err
}
s.modTime = time.Now()
return key, rec, nil
}
@@ -93,7 +92,9 @@ func (s *KeyStore) Revoke(id string) error {
for i, k := range s.keys {
if k.ID == id {
s.keys = append(s.keys[:i], s.keys[i+1:]...)
return s.saveLocked()
err := s.saveLocked()
s.modTime = time.Now()
return err
}
}
return ErrKeyNotFound
@@ -123,7 +124,39 @@ func (s *KeyStore) lookup(presented string) (APIKey, bool) {
return APIKey{}, false
}
// ReloadIfChanged re-reads the backing file when its mtime changed since the
// last load (the store may have been updated by another process — the CMS
// mints keys, services in other containers validate them). Cheap: one stat
// when nothing changed. Safe to call on every request.
func (s *KeyStore) ReloadIfChanged() error {
info, err := os.Stat(s.path)
if errors.Is(err, os.ErrNotExist) {
return nil // no store yet — no keys, and nothing to reload
}
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if !info.ModTime().After(s.modTime) && s.keys != nil {
return nil // unchanged
}
raw, err := os.ReadFile(s.path)
if err != nil {
return err
}
var keys []APIKey
if err := json.Unmarshal(raw, &keys); err != nil {
return fmt.Errorf("auth: parse %s: %w", s.path, err)
}
s.keys = keys
s.modTime = info.ModTime()
return nil
}
// saveLocked writes the store atomically (tmp + rename). Callers hold s.mu.
// The written file's mtime is intentionally left to the filesystem so other
// processes can detect changes via ReloadIfChanged.
func (s *KeyStore) saveLocked() error {
raw, err := json.MarshalIndent(s.keys, "", " ")
if err != nil {