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,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
|
||||
}
|
||||
Reference in new issue
Block a user