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>
165 lines
4.9 KiB
Go
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))
|
|
}
|