diff --git a/auth/apikey.go b/auth/apikey.go index 21e2891..77e48c9 100644 --- a/auth/apikey.go +++ b/auth/apikey.go @@ -34,25 +34,23 @@ var ErrKeyNotFound = errors.New("auth: api key not found") // KeyStore is a file-backed set of API keys ( is JSON, written // atomically). Volume is tiny — a handful of integration keys — so a flat // 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 { - path string - mu sync.Mutex - keys []APIKey + path string + mu sync.Mutex + keys []APIKey + modTime time.Time // file mtime seen at the last load } // OpenKeyStore loads (or initializes) the key store at path. func OpenKeyStore(path string) (*KeyStore, error) { s := &KeyStore{path: path} - raw, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return s, nil - } - if err != nil { + if err := s.ReloadIfChanged(); err != nil { 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 } @@ -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] return "", APIKey{}, err } + s.modTime = time.Now() return key, rec, nil } @@ -93,7 +92,9 @@ func (s *KeyStore) Revoke(id string) error { for i, k := range s.keys { if k.ID == id { s.keys = append(s.keys[:i], s.keys[i+1:]...) - return s.saveLocked() + err := s.saveLocked() + s.modTime = time.Now() + return err } } return ErrKeyNotFound @@ -123,7 +124,39 @@ func (s *KeyStore) lookup(presented string) (APIKey, bool) { 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. +// The written file's mtime is intentionally left to the filesystem so other +// processes can detect changes via ReloadIfChanged. func (s *KeyStore) saveLocked() error { raw, err := json.MarshalIndent(s.keys, "", " ") if err != nil { diff --git a/auth/auth.go b/auth/auth.go index a7f11fc..0350d79 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -22,6 +22,7 @@ import ( const ( KindOIDC = "oidc" KindAPIKey = "apikey" + KindDevice = "device" // platform-minted access token (device flow) KindAnonymous = "anonymous" ) diff --git a/auth/device.go b/auth/device.go new file mode 100644 index 0000000..5dd3acd --- /dev/null +++ b/auth/device.go @@ -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 +} diff --git a/auth/device_test.go b/auth/device_test.go new file mode 100644 index 0000000..7478f1d --- /dev/null +++ b/auth/device_test.go @@ -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 +} diff --git a/auth/localjwt.go b/auth/localjwt.go new file mode 100644 index 0000000..dd468f6 --- /dev/null +++ b/auth/localjwt.go @@ -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 `. +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 "" + } + return fmt.Sprintf("LocalJWT(secret=%d bytes)", len(l.secret)) +} diff --git a/auth/reload_test.go b/auth/reload_test.go new file mode 100644 index 0000000..0bbf687 --- /dev/null +++ b/auth/reload_test.go @@ -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") + } +}