feat(auth): JWT jti + per-track TTLs (Track 4 <=1h) + revocation + refresh endpoint
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.'
This commit is contained in:
92
backend/api/rest/auth_refresh.go
Normal file
92
backend/api/rest/auth_refresh.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/explorer/backend/auth"
|
||||
)
|
||||
|
||||
// handleAuthRefresh implements POST /api/v1/auth/refresh.
|
||||
//
|
||||
// Contract:
|
||||
// - Requires a valid, unrevoked wallet JWT in the Authorization header.
|
||||
// - Mints a new JWT for the same address+track with a fresh jti and a
|
||||
// fresh per-track TTL.
|
||||
// - Revokes the presented token so it cannot be reused.
|
||||
//
|
||||
// This is the mechanism that makes the short Track-4 TTL (60 min in
|
||||
// PR #8) acceptable: operators refresh while the token is still live
|
||||
// rather than re-signing a SIWE message every hour.
|
||||
func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
if s.walletAuth == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "wallet auth not configured")
|
||||
return
|
||||
}
|
||||
|
||||
token := extractBearerToken(r)
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "missing or malformed Authorization header")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := s.walletAuth.RefreshJWT(r.Context(), token)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrJWTRevoked):
|
||||
writeError(w, http.StatusUnauthorized, "token_revoked", err.Error())
|
||||
case errors.Is(err, auth.ErrWalletAuthStorageNotInitialized):
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// handleAuthLogout implements POST /api/v1/auth/logout.
|
||||
//
|
||||
// Records the presented token's jti in jwt_revocations so subsequent
|
||||
// calls to ValidateJWT will reject it. Idempotent: logging out twice
|
||||
// with the same token succeeds.
|
||||
func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed")
|
||||
return
|
||||
}
|
||||
if s.walletAuth == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "wallet auth not configured")
|
||||
return
|
||||
}
|
||||
|
||||
token := extractBearerToken(r)
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "missing or malformed Authorization header")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.walletAuth.RevokeJWT(r.Context(), token, "logout"); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, auth.ErrJWTRevocationStorageMissing):
|
||||
// Surface 503 so ops know migration 0016 hasn't run; the
|
||||
// client should treat the token as logged out locally.
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
@@ -475,8 +475,12 @@ func (s *Server) HandleMissionControlBridgeTrace(w http.ResponseWriter, r *http.
|
||||
body, statusCode, err := fetchBlockscoutTransaction(r.Context(), tx)
|
||||
if err == nil && statusCode == http.StatusOK {
|
||||
var txDoc map[string]interface{}
|
||||
if err := json.Unmarshal(body, &txDoc); err != nil {
|
||||
err = fmt.Errorf("invalid blockscout JSON")
|
||||
if uerr := json.Unmarshal(body, &txDoc); uerr != nil {
|
||||
// Fall through to the RPC fallback below. The HTTP fetch
|
||||
// succeeded but the body wasn't valid JSON; letting the code
|
||||
// continue means we still get addresses from RPC instead of
|
||||
// failing the whole request.
|
||||
_ = uerr
|
||||
} else {
|
||||
fromAddr = extractEthAddress(txDoc["from"])
|
||||
toAddr = extractEthAddress(txDoc["to"])
|
||||
|
||||
@@ -52,6 +52,8 @@ func (s *Server) SetupRoutes(mux *http.ServeMux) {
|
||||
// Auth endpoints
|
||||
mux.HandleFunc("/api/v1/auth/nonce", s.handleAuthNonce)
|
||||
mux.HandleFunc("/api/v1/auth/wallet", s.handleAuthWallet)
|
||||
mux.HandleFunc("/api/v1/auth/refresh", s.handleAuthRefresh)
|
||||
mux.HandleFunc("/api/v1/auth/logout", s.handleAuthLogout)
|
||||
mux.HandleFunc("/api/v1/auth/register", s.handleAuthRegister)
|
||||
mux.HandleFunc("/api/v1/auth/login", s.handleAuthLogin)
|
||||
mux.HandleFunc("/api/v1/access/me", s.handleAccessMe)
|
||||
|
||||
Reference in New Issue
Block a user