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

+306
View File
@@ -0,0 +1,306 @@
package auth
import (
"errors"
"net/http"
"strings"
"testing"
"time"
)
func TestLocalJWTMintVerify(t *testing.T) {
l := NewLocalJWT("test-secret")
tok, err := l.Mint("device-1", []string{"mcp"}, time.Hour)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(tok, "eyJ") {
t.Fatalf("token should start with base64 header: %q", tok[:8])
}
p, err := l.Verify(tok)
if err != nil {
t.Fatal(err)
}
if p.Subject != "device-1" || p.Kind != KindDevice || !p.HasScope("mcp") {
t.Fatalf("principal = %+v", p)
}
}
func TestLocalJWTTampered(t *testing.T) {
l := NewLocalJWT("test-secret")
tok, _ := l.Mint("d", nil, time.Hour)
// Flip one char in the payload segment.
parts := strings.Split(tok, ".")
payload := []byte(parts[1])
payload[0] ^= 1
tampered := parts[0] + "." + string(payload) + "." + parts[2]
if _, err := l.Verify(tampered); err == nil {
t.Fatal("tampered token verified")
}
// Wrong secret.
l2 := NewLocalJWT("other-secret")
if _, err := l2.Verify(tok); err == nil {
t.Fatal("token verified with wrong secret")
}
}
func TestLocalJWTExpiry(t *testing.T) {
l := NewLocalJWT("s")
// Pin the clock.
now := time.Unix(1_700_000_000, 0)
l.now = func() time.Time { return now }
tok, _ := l.Mint("d", nil, time.Minute)
l.now = func() time.Time { return now.Add(2 * time.Minute) }
if _, err := l.Verify(tok); !errors.Is(err, ErrTokenExpired) {
t.Fatalf("err = %v, want ErrTokenExpired", err)
}
}
func TestLocalJWTDisabled(t *testing.T) {
l := NewLocalJWT("")
if l.Enabled() {
t.Fatal("empty secret should be disabled")
}
if _, err := l.Mint("d", nil, time.Minute); err == nil {
t.Fatal("mint with empty secret should fail")
}
if _, err := l.Verify("eyJ.eyJ.abc"); !errors.Is(err, ErrNoCredentials) {
t.Fatalf("err = %v, want ErrNoCredentials", err)
}
}
func TestLocalJWTAuthenticate(t *testing.T) {
l := NewLocalJWT("s")
tok, _ := l.Mint("d", nil, time.Minute)
req, _ := http.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer "+tok)
p, err := l.Authenticate(req)
if err != nil || p.Subject != "d" {
t.Fatalf("authenticate: %v %+v", err, p)
}
// No credentials.
req2, _ := http.NewRequest(http.MethodGet, "/", nil)
if _, err := l.Authenticate(req2); !errors.Is(err, ErrNoCredentials) {
t.Fatalf("err = %v, want ErrNoCredentials", err)
}
}
func TestDeviceFlowFull(t *testing.T) {
f := NewDeviceFlow(NewLocalJWT("s"), DeviceFlowOptions{BaseURL: "https://admin.example.com"})
dc, uc := mustStartScoped(t, f)
// No requested scopes: the approver's scopes become the token's.
if err := f.Approve(uc, &Principal{Subject: "user-1", Scopes: []string{"mcp", "write"}}); err != nil {
t.Fatal(err)
}
tok, err := f.Token(dc)
if err != nil {
t.Fatal(err)
}
p, err := f.jwt.Verify(tok)
if err != nil {
t.Fatal(err)
}
if p.Subject != "user-1" || !p.HasScope("mcp") || !p.HasScope("write") {
t.Fatalf("token principal = %+v", p)
}
// One-shot: second poll fails.
if _, err := f.Token(dc); !errors.Is(err, ErrDeviceUnknown) {
t.Fatalf("err = %v, want ErrDeviceUnknown after consumption", err)
}
}
func TestDeviceFlowRequestedScopes(t *testing.T) {
f := NewDeviceFlow(NewLocalJWT("s"), DeviceFlowOptions{})
dc, uc := startScoped(t, f, "mcp", "write")
// Requested scopes show up for the approve page.
req, ok := f.RequestedScopes(uc)
if !ok || len(req) != 2 {
t.Fatalf("requested = %v %v, want [mcp write]", req, ok)
}
// Approver without scopes (browser admin session) passes requested through.
if err := f.Approve(uc, &Principal{Subject: "admin"}); err != nil {
t.Fatal(err)
}
tok, err := f.Token(dc)
if err != nil {
t.Fatal(err)
}
p, _ := f.jwt.Verify(tok)
if !p.HasScope("mcp") || !p.HasScope("write") {
t.Fatalf("requested scopes should pass through: %+v", p)
}
// No requested scopes: the approver's scopes become the token's.
dc0, uc0 := mustStartScoped(t, f)
if err := f.Approve(uc0, &Principal{Subject: "admin", Scopes: []string{"read"}}); err != nil {
t.Fatal(err)
}
tok0, err := f.Token(dc0)
if err != nil {
t.Fatal(err)
}
p0, _ := f.jwt.Verify(tok0)
if !p0.HasScope("read") {
t.Fatalf("no requested scopes should use approver scopes: %+v", p0)
}
// Requested ∩ approver: request [mcp, read], approver [write] → [] (denied
// everything the approver can't grant).
dc2, uc2 := startScoped(t, f, "mcp", "read")
if err := f.Approve(uc2, &Principal{Subject: "admin", Scopes: []string{"write"}}); err != nil {
t.Fatal(err)
}
tok2, err := f.Token(dc2)
if err != nil {
t.Fatal(err)
}
p2, _ := f.jwt.Verify(tok2)
if len(p2.Scopes) != 0 {
t.Fatalf("no overlap should grant nothing: %+v", p2)
}
// Approver with admin grants any requested scope.
dcAdm, ucAdm := startScoped(t, f, "mcp", "read", "write")
if err := f.Approve(ucAdm, &Principal{Subject: "boss", Scopes: []string{"admin"}}); err != nil {
t.Fatal(err)
}
tokAdm, err := f.Token(dcAdm)
if err != nil {
t.Fatal(err)
}
pAdm, _ := f.jwt.Verify(tokAdm)
for _, s := range []string{"mcp", "read", "write"} {
if !pAdm.HasScope(s) {
t.Fatalf("admin approver should grant %q: %+v", s, pAdm)
}
}
// Requested [mcp] ∩ approver [mcp, read] → [mcp].
dc3, uc3 := startScoped(t, f, "mcp")
// RequestedScopes reflects the original request (before consumption).
rq, ok := f.RequestedScopes(uc3)
if !ok || len(rq) != 1 || rq[0] != "mcp" {
t.Fatalf("requested = %v %v", rq, ok)
}
if err := f.Approve(uc3, &Principal{Subject: "admin", Scopes: []string{"mcp", "read"}}); err != nil {
t.Fatal(err)
}
tok3, err := f.Token(dc3)
if err != nil {
t.Fatal(err)
}
p3, _ := f.jwt.Verify(tok3)
if len(p3.Scopes) != 1 || !p3.HasScope("mcp") {
t.Fatalf("intersection should grant mcp only: %+v", p3)
}
// Unknown code.
if _, ok := f.RequestedScopes("NOPE-XXXX"); ok {
t.Fatal("unknown code should not report scopes")
}
}
func TestDeviceFlowFullLegacy(t *testing.T) {
f := NewDeviceFlow(NewLocalJWT("s"), DeviceFlowOptions{BaseURL: "https://admin.example.com"})
dc, uc, uri, complete, exp, interval := mustStart(t, f)
if !strings.Contains(uri, "https://admin.example.com/device") {
t.Fatalf("verification_uri = %q", uri)
}
if !strings.Contains(complete, "code="+uc) {
t.Fatalf("verification_uri_complete = %q", complete)
}
if exp <= 0 || interval <= 0 {
t.Fatalf("expires_in=%d interval=%d", exp, interval)
}
// Pending before approval.
if _, err := f.Token(dc); !errors.Is(err, ErrAuthorizationPending) {
t.Fatalf("err = %v, want pending", err)
}
// Unknown code.
if err := f.Approve("NOPE-XXXX", &Principal{Subject: "u"}); err == nil {
t.Fatal("approve with unknown code should fail")
}
// Approve with a valid code format.
if err := f.Approve(uc, &Principal{Subject: "user-1", Scopes: []string{"mcp", "write"}}); err != nil {
t.Fatal(err)
}
tok, err := f.Token(dc)
if err != nil {
t.Fatal(err)
}
p, err := f.jwt.Verify(tok)
if err != nil {
t.Fatal(err)
}
if p.Subject != "user-1" || !p.HasScope("mcp") || !p.HasScope("write") {
t.Fatalf("token principal = %+v", p)
}
// One-shot: second poll fails.
if _, err := f.Token(dc); !errors.Is(err, ErrDeviceUnknown) {
t.Fatalf("err = %v, want ErrDeviceUnknown after consumption", err)
}
}
func TestDeviceFlowExpiry(t *testing.T) {
now := time.Now()
f := NewDeviceFlow(NewLocalJWT("s"), DeviceFlowOptions{})
f.now = func() time.Time { return now }
dc, uc, _, _, _, _ := mustStart(t, f)
// Advance past TTL.
f.now = func() time.Time { return now.Add(11 * time.Minute) }
if err := f.Approve(uc, &Principal{Subject: "u"}); !errors.Is(err, ErrDeviceUnknown) {
t.Fatalf("approve expired = %v, want ErrDeviceUnknown", err)
}
if _, err := f.Token(dc); !errors.Is(err, ErrDeviceExpired) {
t.Fatalf("token expired = %v, want ErrDeviceExpired", err)
}
}
func TestDeviceFlowTokenDisabled(t *testing.T) {
f := NewDeviceFlow(NewLocalJWT(""), DeviceFlowOptions{})
dc, uc, _, _, _, _ := mustStart(t, f)
if err := f.Approve(uc, &Principal{Subject: "u"}); err != nil {
t.Fatal(err)
}
if _, err := f.Token(dc); err == nil {
t.Fatal("token with disabled jwt should fail")
}
}
func TestDeviceFlowUserCodeFormat(t *testing.T) {
f := NewDeviceFlow(NewLocalJWT("s"), DeviceFlowOptions{})
_, uc, _, _, _, _ := mustStart(t, f)
if len(uc) != 9 || uc[4] != '-' {
t.Fatalf("user code %q must be XXXX-XXXX", uc)
}
for _, c := range uc {
if c == '-' {
continue
}
if !strings.ContainsRune(userCodeAlphabet, c) {
t.Fatalf("user code %q contains char outside alphabet", uc)
}
}
}
func mustStart(t *testing.T, f *DeviceFlow) (dc, uc, uri, complete string, exp, interval int) {
t.Helper()
dc, uc, uri, complete, exp, interval, err := f.Start()
if err != nil {
t.Fatal(err)
}
return dc, uc, uri, complete, exp, interval
}
func mustStartScoped(t *testing.T, f *DeviceFlow, scopes ...string) (dc, uc string) {
t.Helper()
dc, uc, _, _, _, _, err := f.Start(scopes...)
if err != nil {
t.Fatal(err)
}
return dc, uc
}
func startScoped(t *testing.T, f *DeviceFlow, scopes ...string) (dc, uc string) {
t.Helper()
dc, uc, _, _, _, _, err := f.Start(scopes...)
if err != nil {
t.Fatal(err)
}
return dc, uc
}