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
@@ -0,0 +1,164 @@
|
||||
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))
|
||||
}
|
||||
Reference in new issue
Block a user