feat(auth): JWT jti + per-track TTLs (Track 4 ≤1h) + revocation + refresh endpoint #8

Merged
nsatoshi merged 1 commits from devin/1776539814-feat-jwt-revocation-and-refresh into master 2026-04-18 19:37:05 +00:00
Owner

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 no jti claim, and had no server-side revocation path short of rotating JWT_SECRET.

Changes

Migration 0016_jwt_revocations

CREATE TABLE jwt_revocations (
    jti              TEXT PRIMARY KEY,
    address          TEXT NOT NULL,
    track            INT NOT NULL,
    token_expires_at TIMESTAMPTZ NOT NULL,
    revoked_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    reason           TEXT NOT NULL DEFAULT 'logout'
);

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(track) returns the ceiling; unknown tracks default to 12h.
  • generateJWT now embeds a 128-bit random jti (hex-encoded) and uses the per-track TTL instead of a hardcoded 24h.
  • New helpers: parseJWT, jtiFromToken, isJTIRevoked.
  • 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 one with fresh jti + fresh TTL for the same (address, track).
  • ValidateJWT consults jwt_revocations when a DB is configured; returns ErrJWTRevoked for revoked tokens. Returns ErrJWTRevocationStorageMissing if the migration hasn't run.

backend/api/rest/auth_refresh.go (new)

  • POST /api/v1/auth/refresh — expects Authorization: Bearer <jwt>; returns the new WalletAuthResponse. Maps ErrJWTRevoked → 401 token_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.go

Registered the two new endpoints.

Also fixed

SA4006/SA4017 regression in mission_control.go that PR #5 introduced by shadowing the outer err with json.Unmarshal's err. Reworked to uerr so the RPC fallback still runs as intended.

Tests added

  • TestTokenTTLForTrack4IsShort — track 4 TTL ≤ 1h (the headline promise).
  • TestTokenTTLForTrack1Track2Track3AreReasonable — bounded at 12h.
  • TestGeneratedJWTCarriesJTIClaimjti is present, 128 bits / 32 hex chars.
  • TestGeneratedJWTExpIsTrackAppropriateexp matches tokenTTLFor per track within a couple-second tolerance.
  • TestRevokeJWTWithoutDBReturnsErrorWalletAuth with nil db refuses to revoke rather than silently pretending it worked.
  • All pre-existing tests still pass.

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

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."

## 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 no `jti` claim, and had no server-side revocation path short of rotating `JWT_SECRET`. ## Changes ### Migration `0016_jwt_revocations` ```sql CREATE TABLE jwt_revocations ( jti TEXT PRIMARY KEY, address TEXT NOT NULL, track INT NOT NULL, token_expires_at TIMESTAMPTZ NOT NULL, revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), reason TEXT NOT NULL DEFAULT 'logout' ); ``` 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(track)` returns the ceiling; unknown tracks default to 12h. - `generateJWT` now embeds a 128-bit random `jti` (hex-encoded) and uses the per-track TTL instead of a hardcoded 24h. - New helpers: `parseJWT`, `jtiFromToken`, `isJTIRevoked`. - `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 one with fresh `jti` + fresh TTL for the same `(address, track)`. - `ValidateJWT` consults `jwt_revocations` when a DB is configured; returns `ErrJWTRevoked` for revoked tokens. Returns `ErrJWTRevocationStorageMissing` if the migration hasn't run. ### `backend/api/rest/auth_refresh.go` (new) - **`POST /api/v1/auth/refresh`** — expects `Authorization: Bearer <jwt>`; returns the new `WalletAuthResponse`. Maps `ErrJWTRevoked` → 401 `token_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.go` Registered the two new endpoints. ### Also fixed SA4006/SA4017 regression in `mission_control.go` that PR #5 introduced by shadowing the outer `err` with `json.Unmarshal`'s `err`. Reworked to `uerr` so the RPC fallback still runs as intended. ## Tests added - `TestTokenTTLForTrack4IsShort` — track 4 TTL ≤ 1h (the headline promise). - `TestTokenTTLForTrack1Track2Track3AreReasonable` — bounded at 12h. - `TestGeneratedJWTCarriesJTIClaim` — `jti` is present, 128 bits / 32 hex chars. - `TestGeneratedJWTExpIsTrackAppropriate` — `exp` matches `tokenTTLFor` per track within a couple-second tolerance. - `TestRevokeJWTWithoutDBReturnsError` — `WalletAuth` with nil db refuses to revoke rather than silently pretending it worked. - All pre-existing tests still pass. ## 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 > **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."
nsatoshi added 1 commit 2026-04-18 19:21:19 +00:00
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.'
nsatoshi merged commit 46e3683217 into master 2026-04-18 19:37:05 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: d-bis/explorer-monorepo#8