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)) }