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.
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.
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."
## 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."
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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