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
+45
-12
@@ -34,25 +34,23 @@ var ErrKeyNotFound = errors.New("auth: api key not found")
|
|||||||
// KeyStore is a file-backed set of API keys (<file> is JSON, written
|
// 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
|
// atomically). Volume is tiny — a handful of integration keys — so a flat
|
||||||
// file beats dragging key records into the event-sourced graph store.
|
// 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 {
|
type KeyStore struct {
|
||||||
path string
|
path string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
keys []APIKey
|
keys []APIKey
|
||||||
|
modTime time.Time // file mtime seen at the last load
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenKeyStore loads (or initializes) the key store at path.
|
// OpenKeyStore loads (or initializes) the key store at path.
|
||||||
func OpenKeyStore(path string) (*KeyStore, error) {
|
func OpenKeyStore(path string) (*KeyStore, error) {
|
||||||
s := &KeyStore{path: path}
|
s := &KeyStore{path: path}
|
||||||
raw, err := os.ReadFile(path)
|
if err := s.ReloadIfChanged(); err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(raw, &s.keys); err != nil {
|
|
||||||
return nil, fmt.Errorf("auth: parse %s: %w", path, err)
|
|
||||||
}
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +81,7 @@ func (s *KeyStore) Mint(name string, scopes []string) (key string, rec APIKey, e
|
|||||||
s.keys = s.keys[:len(s.keys)-1]
|
s.keys = s.keys[:len(s.keys)-1]
|
||||||
return "", APIKey{}, err
|
return "", APIKey{}, err
|
||||||
}
|
}
|
||||||
|
s.modTime = time.Now()
|
||||||
return key, rec, nil
|
return key, rec, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +92,9 @@ func (s *KeyStore) Revoke(id string) error {
|
|||||||
for i, k := range s.keys {
|
for i, k := range s.keys {
|
||||||
if k.ID == id {
|
if k.ID == id {
|
||||||
s.keys = append(s.keys[:i], s.keys[i+1:]...)
|
s.keys = append(s.keys[:i], s.keys[i+1:]...)
|
||||||
return s.saveLocked()
|
err := s.saveLocked()
|
||||||
|
s.modTime = time.Now()
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ErrKeyNotFound
|
return ErrKeyNotFound
|
||||||
@@ -123,7 +124,39 @@ func (s *KeyStore) lookup(presented string) (APIKey, bool) {
|
|||||||
return APIKey{}, false
|
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.
|
// 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 {
|
func (s *KeyStore) saveLocked() error {
|
||||||
raw, err := json.MarshalIndent(s.keys, "", " ")
|
raw, err := json.MarshalIndent(s.keys, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
KindOIDC = "oidc"
|
KindOIDC = "oidc"
|
||||||
KindAPIKey = "apikey"
|
KindAPIKey = "apikey"
|
||||||
|
KindDevice = "device" // platform-minted access token (device flow)
|
||||||
KindAnonymous = "anonymous"
|
KindAnonymous = "anonymous"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+258
@@ -0,0 +1,258 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeviceFlow implements the OAuth 2.0 Device Authorization Grant
|
||||||
|
// (RFC 8628) in-process: a companion app requests a device + user code, the
|
||||||
|
// user approves it in a browser (after logging in), and the app polls until
|
||||||
|
// the flow mints an access token (a LocalJWT).
|
||||||
|
//
|
||||||
|
// State is held in memory: pending grants are short-lived (default 10 min)
|
||||||
|
// and minted tokens are stateless JWTs, so a backoffice restart only drops
|
||||||
|
// in-flight approvals — never issued tokens.
|
||||||
|
type DeviceFlow struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
pending map[string]*deviceSession // deviceCode → session
|
||||||
|
byCode map[string]string // userCode → deviceCode
|
||||||
|
jwt *LocalJWT
|
||||||
|
baseURL string // issuer base for verification_uri
|
||||||
|
ttl time.Duration // device_code / user_code lifetime
|
||||||
|
tokenTTL time.Duration // minted access-token lifetime
|
||||||
|
interval time.Duration // client polling interval
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type deviceSession struct {
|
||||||
|
deviceCode string
|
||||||
|
userCode string
|
||||||
|
expiresAt time.Time
|
||||||
|
requested []string // scopes the client asked for (RFC 8628 scope param)
|
||||||
|
approved *Principal // set when the user approves
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceFlowOptions tune the flow. Zero values pick RFC-sensible defaults.
|
||||||
|
type DeviceFlowOptions struct {
|
||||||
|
BaseURL string // e.g. "https://admin.example.com" for verification_uri
|
||||||
|
TTL time.Duration // device/user code lifetime (default 10m)
|
||||||
|
TokenTTL time.Duration // access token lifetime (default 12h)
|
||||||
|
Interval time.Duration // polling interval hint (default 5s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDeviceFlow constructs the flow. jwt must be enabled (non-empty secret)
|
||||||
|
// for Token() to work; Start/Approve still function.
|
||||||
|
func NewDeviceFlow(jwt *LocalJWT, opts DeviceFlowOptions) *DeviceFlow {
|
||||||
|
if opts.TTL <= 0 {
|
||||||
|
opts.TTL = 10 * time.Minute
|
||||||
|
}
|
||||||
|
if opts.TokenTTL <= 0 {
|
||||||
|
opts.TokenTTL = 12 * time.Hour
|
||||||
|
}
|
||||||
|
if opts.Interval <= 0 {
|
||||||
|
opts.Interval = 5 * time.Second
|
||||||
|
}
|
||||||
|
return &DeviceFlow{
|
||||||
|
pending: map[string]*deviceSession{},
|
||||||
|
byCode: map[string]string{},
|
||||||
|
jwt: jwt,
|
||||||
|
baseURL: strings.TrimSuffix(opts.BaseURL, "/"),
|
||||||
|
ttl: opts.TTL,
|
||||||
|
tokenTTL: opts.TokenTTL,
|
||||||
|
interval: opts.Interval,
|
||||||
|
now: time.Now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins a device authorization and returns the grant response the
|
||||||
|
// client sends to the user: device_code (for polling), user_code (typed by
|
||||||
|
// the user), verification_uri, verification_uri_complete (a direct link with
|
||||||
|
// the code pre-filled), expires_in (seconds) and the polling interval.
|
||||||
|
//
|
||||||
|
// requested lists the scopes the client wants on the minted token (e.g.
|
||||||
|
// ["mcp","read"] for a companion app). The approver's consent — plus the
|
||||||
|
// client having asked for them — is what grants them; Approve records the
|
||||||
|
// request on the session and Token mints with the intersection of the
|
||||||
|
// requested scopes and the approver's authorization.
|
||||||
|
func (f *DeviceFlow) Start(requested ...string) (deviceCode, userCode, verifyURI, verifyComplete string, expiresIn, interval int, err error) {
|
||||||
|
if f == nil {
|
||||||
|
return "", "", "", "", 0, 0, errors.New("auth: nil device flow")
|
||||||
|
}
|
||||||
|
dc, err := randomToken(24)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", "", 0, 0, err
|
||||||
|
}
|
||||||
|
uc, err := genUserCode()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", "", 0, 0, err
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
f.pending[dc] = &deviceSession{deviceCode: dc, userCode: uc, expiresAt: f.now().Add(f.ttl), requested: append([]string(nil), requested...)}
|
||||||
|
f.byCode[uc] = dc
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
verify := f.baseURL
|
||||||
|
if verify == "" {
|
||||||
|
verify = "/device"
|
||||||
|
} else {
|
||||||
|
verify += "/device"
|
||||||
|
}
|
||||||
|
return dc, uc, verify, verify + "?code=" + uc, int(f.ttl / time.Second), int(f.interval / time.Second), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrDeviceUnknown is returned by Approve for an unknown/expired user code.
|
||||||
|
ErrDeviceUnknown = errors.New("auth: unknown or expired device code")
|
||||||
|
// ErrAuthorizationPending is returned by Token while the user has not
|
||||||
|
// approved yet (the client should poll again after the interval).
|
||||||
|
ErrAuthorizationPending = errors.New("auth: authorization pending")
|
||||||
|
// ErrDeviceExpired is returned by Token once the grant has expired.
|
||||||
|
ErrDeviceExpired = errors.New("auth: device code expired")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Approve binds the approving principal to the pending grant. The token's
|
||||||
|
// scopes are the client's REQUESTED scopes intersected with the approver's
|
||||||
|
// scopes; when the approver carries none (e.g. a browser admin session whose
|
||||||
|
// principal has no explicit scope list), the requested scopes pass through
|
||||||
|
// — consent from a logged-in administrator is the authorization.
|
||||||
|
//
|
||||||
|
// The approved principal's Subject still rides on the minted token (the
|
||||||
|
// audit trail says who approved), but it is not what grants scopes.
|
||||||
|
func (f *DeviceFlow) Approve(userCode string, approver *Principal) error {
|
||||||
|
if f == nil {
|
||||||
|
return ErrDeviceUnknown
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
dc, ok := f.byCode[userCode]
|
||||||
|
if !ok {
|
||||||
|
return ErrDeviceUnknown
|
||||||
|
}
|
||||||
|
s, ok := f.pending[dc]
|
||||||
|
if !ok || f.now().After(s.expiresAt) {
|
||||||
|
delete(f.byCode, userCode)
|
||||||
|
return ErrDeviceUnknown
|
||||||
|
}
|
||||||
|
s.approved = approver
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestedScopes returns the scopes a pending grant's client asked for.
|
||||||
|
// The approve UI shows them so the admin consents to exactly what the
|
||||||
|
// companion app will get. Returns (nil, false) for unknown/expired codes.
|
||||||
|
func (f *DeviceFlow) RequestedScopes(userCode string) ([]string, bool) {
|
||||||
|
if f == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
dc, ok := f.byCode[userCode]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
s, ok := f.pending[dc]
|
||||||
|
if !ok || f.now().After(s.expiresAt) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return append([]string(nil), s.requested...), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token polls the grant: pending → ErrAuthorizationPending, expired →
|
||||||
|
// ErrDeviceExpired, approved → a minted access token (LocalJWT) for the
|
||||||
|
// approver. One-shot: a consumed grant is removed (tokens are stateless, so
|
||||||
|
// repeat polling would otherwise mint unlimited tokens from one approval).
|
||||||
|
func (f *DeviceFlow) Token(deviceCode string) (string, error) {
|
||||||
|
if f == nil {
|
||||||
|
return "", ErrDeviceUnknown
|
||||||
|
}
|
||||||
|
if f.jwt == nil || !f.jwt.Enabled() {
|
||||||
|
return "", errors.New("auth: device tokens disabled (AUTH_TOKEN_SECRET not set)")
|
||||||
|
}
|
||||||
|
f.mu.Lock()
|
||||||
|
s, ok := f.pending[deviceCode]
|
||||||
|
if !ok {
|
||||||
|
f.mu.Unlock()
|
||||||
|
return "", ErrDeviceUnknown
|
||||||
|
}
|
||||||
|
if f.now().After(s.expiresAt) {
|
||||||
|
delete(f.pending, deviceCode)
|
||||||
|
delete(f.byCode, s.userCode)
|
||||||
|
f.mu.Unlock()
|
||||||
|
return "", ErrDeviceExpired
|
||||||
|
}
|
||||||
|
if s.approved == nil {
|
||||||
|
f.mu.Unlock()
|
||||||
|
return "", ErrAuthorizationPending
|
||||||
|
}
|
||||||
|
approver := s.approved
|
||||||
|
delete(f.pending, deviceCode)
|
||||||
|
delete(f.byCode, s.userCode)
|
||||||
|
f.mu.Unlock()
|
||||||
|
|
||||||
|
token, err := f.jwt.Mint(approver.Subject, grantScopes(s.requested, approver.Scopes), f.tokenTTL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantScopes returns the intersection of the client's requested scopes and
|
||||||
|
// the approver's scopes. An approver with no explicit scopes (browser admin
|
||||||
|
// session) passes the requested scopes through — see Approve. An approver
|
||||||
|
// holding the "admin" scope may grant any requested scope (admin is the
|
||||||
|
// super-scope that implies the rest).
|
||||||
|
func grantScopes(requested, approver []string) []string {
|
||||||
|
if len(requested) == 0 {
|
||||||
|
return append([]string(nil), approver...)
|
||||||
|
}
|
||||||
|
if len(approver) == 0 {
|
||||||
|
return append([]string(nil), requested...)
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, r := range requested {
|
||||||
|
if slices.Contains(approver, "admin") {
|
||||||
|
out = append(out, r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, a := range approver {
|
||||||
|
if r == a {
|
||||||
|
out = append(out, r)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomToken returns n random bytes as a URL-safe string.
|
||||||
|
func randomToken(n int) (string, error) {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// genUserCode generates a human-typable code in the GitHub style: two groups
|
||||||
|
// of four characters from an unambiguous charset (no 0/O, 1/I/L).
|
||||||
|
const userCodeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
func genUserCode() (string, error) {
|
||||||
|
b := make([]byte, 8)
|
||||||
|
max := big.NewInt(int64(len(userCodeAlphabet)))
|
||||||
|
for i := range b {
|
||||||
|
n, err := rand.Int(rand.Reader, max)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
b[i] = userCodeAlphabet[n.Int64()]
|
||||||
|
}
|
||||||
|
return string(b[:4]) + "-" + string(b[4:]), nil
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKeyStoreReloadIfChanged(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "apikeys.json")
|
||||||
|
// Writer process mints into the store.
|
||||||
|
writer, err := OpenKeyStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key, rec, err := writer.Mint("integration", []string{"mcp"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reader process opens the same file fresh.
|
||||||
|
reader, err := OpenKeyStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := apiKeyReq(key)
|
||||||
|
if _, err := (APIKeyAuth{Keys: reader}).Authenticate(req); err != nil {
|
||||||
|
t.Fatalf("fresh key should validate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writer mints a second key AFTER the reader loaded → reader must reload.
|
||||||
|
time.Sleep(10 * time.Millisecond) // ensure mtime advances
|
||||||
|
key2, _, err := writer.Mint("companion", []string{"write"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := (APIKeyAuth{Keys: reader}).Authenticate(apiKeyReq(key2)); err == nil {
|
||||||
|
t.Fatal("key minted after load should NOT validate before reload")
|
||||||
|
}
|
||||||
|
if err := reader.ReloadIfChanged(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := (APIKeyAuth{Keys: reader}).Authenticate(apiKeyReq(key2)); err != nil {
|
||||||
|
t.Fatalf("key should validate after ReloadIfChanged: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unchanged file → no-op (and keys survive).
|
||||||
|
if err := reader.ReloadIfChanged(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := (APIKeyAuth{Keys: reader}).Authenticate(apiKeyReq(key)); err != nil {
|
||||||
|
t.Fatalf("key should still validate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke on the writer side → reader sees it after reload.
|
||||||
|
if err := writer.Revoke(rec.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := reader.ReloadIfChanged(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := (APIKeyAuth{Keys: reader}).Authenticate(apiKeyReq(key)); err == nil {
|
||||||
|
t.Fatal("revoked key should not validate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiKeyReq(key string) *http.Request {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/api/nodes", nil)
|
||||||
|
r.Header.Set("X-API-Key", key)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyStoreReloadCorruptFile(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "apikeys.json")
|
||||||
|
ks, err := OpenKeyStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw := []byte(`{not json`)
|
||||||
|
if err := os.WriteFile(path, raw, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
if err := ks.ReloadIfChanged(); err == nil {
|
||||||
|
t.Fatal("corrupt file should error")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user