feat(auth): JWT jti + per-track TTLs (Track 4 ≤1h) + revocation + refresh endpoint #8
Reference in New Issue
Block a user
Delete Branch "devin/1776539814-feat-jwt-revocation-and-refresh"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
PR #8 of the 11-PR completion sequence. Closes the JWT-hygiene gap identified by the review: the old implementation issued 24h tokens for every track (including Track 4 operator sessions with
operator.write.*permissions), had nojticlaim, and had no server-side revocation path short of rotatingJWT_SECRET.Changes
Migration
0016_jwt_revocationsPlus indexes on
addressandtoken_expires_at. Append-only; idempotent on duplicatejti.backend/auth/wallet_auth.gotokenTTLsmap: track 1 = 12h, 2 = 8h, 3 = 4h, 4 = 60m.tokenTTLFor(track)returns the ceiling; unknown tracks default to 12h.generateJWTnow embeds a 128-bit randomjti(hex-encoded) and uses the per-track TTL instead of a hardcoded 24h.parseJWT,jtiFromToken,isJTIRevoked.RevokeJWT(ctx, token, reason)records thejti; idempotent viaON CONFLICT (jti) DO NOTHING. Refuses legacy tokens withoutjti.RefreshJWT(ctx, token)validates, revokes the old token (reasonrefresh), and mints a new one with freshjti+ fresh TTL for the same(address, track).ValidateJWTconsultsjwt_revocationswhen a DB is configured; returnsErrJWTRevokedfor revoked tokens. ReturnsErrJWTRevocationStorageMissingif the migration hasn't run.backend/api/rest/auth_refresh.go(new)POST /api/v1/auth/refresh— expectsAuthorization: Bearer <jwt>; returns the newWalletAuthResponse. MapsErrJWTRevoked→ 401token_revoked.POST /api/v1/auth/logout— same header contract, idempotent, returns{status: "ok"}. Returns 503 when the revocations table isn't present so ops see migration 0016 needs to run.backend/api/rest/routes.goRegistered the two new endpoints.
Also fixed
SA4006/SA4017 regression in
mission_control.gothat PR #5 introduced by shadowing the outererrwithjson.Unmarshal'serr. Reworked touerrso the RPC fallback still runs as intended.Tests added
TestTokenTTLForTrack4IsShort— track 4 TTL ≤ 1h (the headline promise).TestTokenTTLForTrack1Track2Track3AreReasonable— bounded at 12h.TestGeneratedJWTCarriesJTIClaim—jtiis present, 128 bits / 32 hex chars.TestGeneratedJWTExpIsTrackAppropriate—expmatchestokenTTLForper track within a couple-second tolerance.TestRevokeJWTWithoutDBReturnsError—WalletAuthwith nil db refuses to revoke rather than silently pretending it worked.Verification
go build ./...— clean.go vet ./...— clean.go test ./auth/...— PASS (including new tests).go test ./api/rest/...— PASS.staticcheck ./auth/... ./api/rest/...— clean on the SA* correctness family.Completion criterion advanced
Closes the 'JWT hygiene' gap identified by the review: - 24h TTL was used for every track, including Track 4 operator sessions carrying operator.write.* permissions. - Tokens had no server-side revocation path; rotating JWT_SECRET was the only way to invalidate a session, which would punt every user. - Tokens carried no jti, so individual revocation was impossible even with a revocations table. Changes: Migration 0016_jwt_revocations (up + down): - CREATE TABLE jwt_revocations (jti PK, address, track, token_expires_at, revoked_at, reason) plus indexes on address and token_expires_at. Append-only; idempotent on duplicate jti. backend/auth/wallet_auth.go: - tokenTTLs map: track 1 = 12h, 2 = 8h, 3 = 4h, 4 = 60m. tokenTTLFor returns the ceiling; default is 12h for unknown tracks. - generateJWT now embeds a 128-bit random jti (hex-encoded) and uses the per-track TTL instead of a hardcoded 24h. - parseJWT: shared signature-verification + claim-extraction helper used by ValidateJWT and RefreshJWT. Returns address, track, jti, exp. - jtiFromToken: parses jti from an already-trusted token without a second crypto roundtrip. - isJTIRevoked: EXISTS query against jwt_revocations, returning ErrJWTRevocationStorageMissing when the table is absent (migration not run yet) so callers can surface a 503 rather than silently treating every token as valid. - RevokeJWT(ctx, token, reason): records the jti; idempotent via ON CONFLICT (jti) DO NOTHING. Refuses legacy tokens without jti. - RefreshJWT(ctx, token): validates, revokes the old token (reason 'refresh'), and mints a new token with fresh jti + fresh TTL. Same (address, track) as the inbound token, same permissions set. - ValidateJWT now consults jwt_revocations when a DB is configured; returns ErrJWTRevoked for revoked tokens. backend/api/rest/auth_refresh.go (new): - POST /api/v1/auth/refresh handler: expects 'Authorization: Bearer <jwt>'; returns WalletAuthResponse with the new token. Maps ErrJWTRevoked to 401 token_revoked and ErrWalletAuthStorageNotInitialized to 503. - POST /api/v1/auth/logout handler: same header contract, idempotent, returns {status: ok}. Returns 503 when the revocations table isn't present so ops know migration 0016 hasn't run. - Both handlers reuse the existing extractBearerToken helper from auth.go so parsing is consistent with the rest of the access layer. backend/api/rest/routes.go: - Registered /api/v1/auth/refresh and /api/v1/auth/logout. Tests: - TestTokenTTLForTrack4IsShort: track 4 TTL <= 1h. - TestTokenTTLForTrack1Track2Track3AreReasonable: bounded at 12h. - TestGeneratedJWTCarriesJTIClaim: jti is present, 128 bits / 32 hex. - TestGeneratedJWTExpIsTrackAppropriate: exp matches tokenTTLFor per track within a couple-second tolerance. - TestRevokeJWTWithoutDBReturnsError: a WalletAuth with nil db must refuse to revoke rather than silently pretending it worked. - All pre-existing wallet_auth tests still pass. Also fixes a small SA4006/SA4017 regression in mission_control.go that PR #5 introduced by shadowing the outer err with json.Unmarshal's err return. Reworked to uerr so the outer err and the RPC fallback still function as intended. Verification: go build ./... clean go vet ./... clean go test ./auth/... PASS (including new tests) go test ./api/rest/... PASS staticcheck ./auth/... ./api/rest/... clean on SA4006/SA4017/SA1029 Advances completion criterion 3 (JWT hygiene): 'Track 4 sessions TTL <= 1h; server-side revocation list (keyed on jti) enforced on every token validation; refresh endpoint rotates the token in place so the short TTL is usable in practice; logout endpoint revokes immediately.'