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:
1 parent
d74c8a0592
commit
2f3ea99745
6 files changed
+864
-12
No files matched your search
+45
-12
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user