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>
200 lines
5.5 KiB
Go
200 lines
5.5 KiB
Go
package auth
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// APIKey is the stored record for a minted key. Only the SHA-256 of the
|
|
// full key string is kept; the key itself is shown exactly once at mint
|
|
// time. SHA-256 (not argon2/bcrypt) is sufficient because keys are 192-bit
|
|
// random secrets, not low-entropy passwords.
|
|
type APIKey struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Hash string `json:"hash"` // hex sha256 of the full key string
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
// ErrKeyNotFound is returned by Revoke for an unknown key id.
|
|
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
|
|
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}
|
|
if err := s.ReloadIfChanged(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Mint creates a key with the given display name and scopes, persists its
|
|
// hash, and returns the full key string — the only time it is available.
|
|
func (s *KeyStore) Mint(name string, scopes []string) (key string, rec APIKey, err error) {
|
|
idBytes := make([]byte, 4)
|
|
secret := make([]byte, 24)
|
|
if _, err = rand.Read(idBytes); err != nil {
|
|
return "", APIKey{}, err
|
|
}
|
|
if _, err = rand.Read(secret); err != nil {
|
|
return "", APIKey{}, err
|
|
}
|
|
id := hex.EncodeToString(idBytes)
|
|
key = "gck_" + id + "_" + base64.RawURLEncoding.EncodeToString(secret)
|
|
rec = APIKey{
|
|
ID: id,
|
|
Name: name,
|
|
Hash: hashKey(key),
|
|
Scopes: scopes,
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.keys = append(s.keys, rec)
|
|
if err := s.saveLocked(); err != nil {
|
|
s.keys = s.keys[:len(s.keys)-1]
|
|
return "", APIKey{}, err
|
|
}
|
|
s.modTime = time.Now()
|
|
return key, rec, nil
|
|
}
|
|
|
|
// Revoke deletes the key with the given id.
|
|
func (s *KeyStore) Revoke(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i, k := range s.keys {
|
|
if k.ID == id {
|
|
s.keys = append(s.keys[:i], s.keys[i+1:]...)
|
|
err := s.saveLocked()
|
|
s.modTime = time.Now()
|
|
return err
|
|
}
|
|
}
|
|
return ErrKeyNotFound
|
|
}
|
|
|
|
// List returns the stored key records. Callers serving them over HTTP
|
|
// should strip Hash themselves — it isn't secret-bearing, but is also not
|
|
// a client concern.
|
|
func (s *KeyStore) List() []APIKey {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := make([]APIKey, len(s.keys))
|
|
copy(out, s.keys)
|
|
return out
|
|
}
|
|
|
|
// lookup finds the record whose hash matches the presented key.
|
|
func (s *KeyStore) lookup(presented string) (APIKey, bool) {
|
|
h := []byte(hashKey(presented))
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, k := range s.keys {
|
|
if subtle.ConstantTimeCompare(h, []byte(k.Hash)) == 1 {
|
|
return k, true
|
|
}
|
|
}
|
|
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 {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp := s.path + ".tmp"
|
|
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, s.path)
|
|
}
|
|
|
|
func hashKey(key string) string {
|
|
sum := sha256.Sum256([]byte(key))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// APIKeyAuth authenticates requests carrying `X-API-Key: <key>` against a
|
|
// KeyStore. The Principal's Subject is the key id and its Scopes are the
|
|
// scopes the key was minted with.
|
|
type APIKeyAuth struct {
|
|
Keys *KeyStore
|
|
}
|
|
|
|
var _ Authenticator = APIKeyAuth{}
|
|
|
|
func (a APIKeyAuth) Authenticate(r *http.Request) (*Principal, error) {
|
|
presented := r.Header.Get("X-API-Key")
|
|
if presented == "" {
|
|
return nil, ErrNoCredentials
|
|
}
|
|
rec, ok := a.Keys.lookup(presented)
|
|
if !ok {
|
|
return nil, errors.New("auth: invalid api key")
|
|
}
|
|
return &Principal{Subject: rec.ID, Scopes: rec.Scopes, Kind: KindAPIKey}, nil
|
|
}
|