Compare commits
1 Commits
fix/explor
...
devin/1776
| Author | SHA1 | Date | |
|---|---|---|---|
| 29fe704f3c |
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)
|
body, statusCode, err := fetchBlockscoutTransaction(r.Context(), tx)
|
||||||
if err == nil && statusCode == http.StatusOK {
|
if err == nil && statusCode == http.StatusOK {
|
||||||
var txDoc map[string]interface{}
|
var txDoc map[string]interface{}
|
||||||
if err := json.Unmarshal(body, &txDoc); err != nil {
|
if uerr := json.Unmarshal(body, &txDoc); uerr != nil {
|
||||||
err = fmt.Errorf("invalid blockscout JSON")
|
// 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 {
|
} else {
|
||||||
fromAddr = extractEthAddress(txDoc["from"])
|
fromAddr = extractEthAddress(txDoc["from"])
|
||||||
toAddr = extractEthAddress(txDoc["to"])
|
toAddr = extractEthAddress(txDoc["to"])
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ func (s *Server) SetupRoutes(mux *http.ServeMux) {
|
|||||||
// Auth endpoints
|
// Auth endpoints
|
||||||
mux.HandleFunc("/api/v1/auth/nonce", s.handleAuthNonce)
|
mux.HandleFunc("/api/v1/auth/nonce", s.handleAuthNonce)
|
||||||
mux.HandleFunc("/api/v1/auth/wallet", s.handleAuthWallet)
|
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/register", s.handleAuthRegister)
|
||||||
mux.HandleFunc("/api/v1/auth/login", s.handleAuthLogin)
|
mux.HandleFunc("/api/v1/auth/login", s.handleAuthLogin)
|
||||||
mux.HandleFunc("/api/v1/access/me", s.handleAccessMe)
|
mux.HandleFunc("/api/v1/access/me", s.handleAccessMe)
|
||||||
|
|||||||
@@ -21,8 +21,49 @@ var (
|
|||||||
ErrWalletNonceNotFoundOrExpired = errors.New("nonce not found or expired")
|
ErrWalletNonceNotFoundOrExpired = errors.New("nonce not found or expired")
|
||||||
ErrWalletNonceExpired = errors.New("nonce expired")
|
ErrWalletNonceExpired = errors.New("nonce expired")
|
||||||
ErrWalletNonceInvalid = errors.New("invalid nonce")
|
ErrWalletNonceInvalid = errors.New("invalid nonce")
|
||||||
|
ErrJWTRevoked = errors.New("token has been revoked")
|
||||||
|
ErrJWTRevocationStorageMissing = errors.New("jwt_revocations table missing; run migration 0016_jwt_revocations")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// tokenTTLs maps each track to its maximum JWT lifetime. Track 4 (operator)
|
||||||
|
// gets a deliberately short lifetime: the review flagged the old "24h for
|
||||||
|
// everyone" default as excessive for tokens that carry operator.write.*
|
||||||
|
// permissions. Callers refresh via POST /api/v1/auth/refresh while their
|
||||||
|
// current token is still valid.
|
||||||
|
var tokenTTLs = map[int]time.Duration{
|
||||||
|
1: 12 * time.Hour,
|
||||||
|
2: 8 * time.Hour,
|
||||||
|
3: 4 * time.Hour,
|
||||||
|
4: 60 * time.Minute,
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultTokenTTL is used for any track not explicitly listed above.
|
||||||
|
const defaultTokenTTL = 12 * time.Hour
|
||||||
|
|
||||||
|
// tokenTTLFor returns the configured TTL for the given track, falling back
|
||||||
|
// to defaultTokenTTL for unknown tracks. Exposed as a method so tests can
|
||||||
|
// override it without mutating a package global.
|
||||||
|
func tokenTTLFor(track int) time.Duration {
|
||||||
|
if ttl, ok := tokenTTLs[track]; ok {
|
||||||
|
return ttl
|
||||||
|
}
|
||||||
|
return defaultTokenTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMissingJWTRevocationTableError(err error) bool {
|
||||||
|
return err != nil && strings.Contains(err.Error(), `relation "jwt_revocations" does not exist`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newJTI returns a random JWT ID used for revocation tracking. 16 random
|
||||||
|
// bytes = 128 bits of entropy, hex-encoded.
|
||||||
|
func newJTI() (string, error) {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", fmt.Errorf("generate jti: %w", err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
// WalletAuth handles wallet-based authentication
|
// WalletAuth handles wallet-based authentication
|
||||||
type WalletAuth struct {
|
type WalletAuth struct {
|
||||||
db *pgxpool.Pool
|
db *pgxpool.Pool
|
||||||
@@ -207,13 +248,20 @@ func (w *WalletAuth) getUserTrack(ctx context.Context, address string) (int, err
|
|||||||
return 1, nil
|
return 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateJWT generates a JWT token with track claim
|
// generateJWT generates a JWT token with track, jti, exp, and iat claims.
|
||||||
|
// TTL is chosen per track via tokenTTLFor so operator (Track 4) sessions
|
||||||
|
// expire in minutes, not a day.
|
||||||
func (w *WalletAuth) generateJWT(address string, track int) (string, time.Time, error) {
|
func (w *WalletAuth) generateJWT(address string, track int) (string, time.Time, error) {
|
||||||
expiresAt := time.Now().Add(24 * time.Hour)
|
jti, err := newJTI()
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
expiresAt := time.Now().Add(tokenTTLFor(track))
|
||||||
|
|
||||||
claims := jwt.MapClaims{
|
claims := jwt.MapClaims{
|
||||||
"address": address,
|
"address": address,
|
||||||
"track": track,
|
"track": track,
|
||||||
|
"jti": jti,
|
||||||
"exp": expiresAt.Unix(),
|
"exp": expiresAt.Unix(),
|
||||||
"iat": time.Now().Unix(),
|
"iat": time.Now().Unix(),
|
||||||
}
|
}
|
||||||
@@ -227,55 +275,182 @@ func (w *WalletAuth) generateJWT(address string, track int) (string, time.Time,
|
|||||||
return tokenString, expiresAt, nil
|
return tokenString, expiresAt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateJWT validates a JWT token and returns the address and track
|
// ValidateJWT validates a JWT token and returns the address and track.
|
||||||
|
// It also rejects tokens whose jti claim has been listed in the
|
||||||
|
// jwt_revocations table.
|
||||||
func (w *WalletAuth) ValidateJWT(tokenString string) (string, int, error) {
|
func (w *WalletAuth) ValidateJWT(tokenString string) (string, int, error) {
|
||||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
address, track, _, _, err := w.parseJWT(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a database, enforce revocation and re-resolve the track
|
||||||
|
// (an operator revoking a wallet's Track 4 approval should not wait
|
||||||
|
// for the token to expire before losing the elevated permission).
|
||||||
|
if w.db != nil {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
jti, _ := w.jtiFromToken(tokenString)
|
||||||
|
if jti != "" {
|
||||||
|
revoked, revErr := w.isJTIRevoked(ctx, jti)
|
||||||
|
if revErr != nil && !errors.Is(revErr, ErrJWTRevocationStorageMissing) {
|
||||||
|
return "", 0, fmt.Errorf("failed to check revocation: %w", revErr)
|
||||||
|
}
|
||||||
|
if revoked {
|
||||||
|
return "", 0, ErrJWTRevoked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTrack, err := w.getUserTrack(ctx, address)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("failed to resolve current track: %w", err)
|
||||||
|
}
|
||||||
|
if currentTrack < track {
|
||||||
|
track = currentTrack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return address, track, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseJWT performs signature verification and claim extraction without
|
||||||
|
// any database round-trip. Shared between ValidateJWT and RefreshJWT.
|
||||||
|
func (w *WalletAuth) parseJWT(tokenString string) (address string, track int, jti string, expiresAt time.Time, err error) {
|
||||||
|
token, perr := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
}
|
}
|
||||||
return w.jwtSecret, nil
|
return w.jwtSecret, nil
|
||||||
})
|
})
|
||||||
|
if perr != nil {
|
||||||
if err != nil {
|
return "", 0, "", time.Time{}, fmt.Errorf("failed to parse token: %w", perr)
|
||||||
return "", 0, fmt.Errorf("failed to parse token: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !token.Valid {
|
if !token.Valid {
|
||||||
return "", 0, fmt.Errorf("invalid token")
|
return "", 0, "", time.Time{}, fmt.Errorf("invalid token")
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, ok := token.Claims.(jwt.MapClaims)
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", 0, fmt.Errorf("invalid token claims")
|
return "", 0, "", time.Time{}, fmt.Errorf("invalid token claims")
|
||||||
}
|
}
|
||||||
|
address, ok = claims["address"].(string)
|
||||||
address, ok := claims["address"].(string)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", 0, fmt.Errorf("address not found in token")
|
return "", 0, "", time.Time{}, fmt.Errorf("address not found in token")
|
||||||
}
|
}
|
||||||
|
|
||||||
trackFloat, ok := claims["track"].(float64)
|
trackFloat, ok := claims["track"].(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", 0, fmt.Errorf("track not found in token")
|
return "", 0, "", time.Time{}, fmt.Errorf("track not found in token")
|
||||||
}
|
}
|
||||||
|
track = int(trackFloat)
|
||||||
track := int(trackFloat)
|
if v, ok := claims["jti"].(string); ok {
|
||||||
if w.db == nil {
|
jti = v
|
||||||
return address, track, nil
|
|
||||||
}
|
}
|
||||||
|
if expFloat, ok := claims["exp"].(float64); ok {
|
||||||
|
expiresAt = time.Unix(int64(expFloat), 0)
|
||||||
|
}
|
||||||
|
return address, track, jti, expiresAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
// jtiFromToken parses the jti claim without doing a fresh signature check.
|
||||||
defer cancel()
|
// It is a convenience helper for callers that have already validated the
|
||||||
|
// token through parseJWT.
|
||||||
currentTrack, err := w.getUserTrack(ctx, address)
|
func (w *WalletAuth) jtiFromToken(tokenString string) (string, error) {
|
||||||
|
parser := jwt.Parser{}
|
||||||
|
token, _, err := parser.ParseUnverified(tokenString, jwt.MapClaims{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, fmt.Errorf("failed to resolve current track: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
if currentTrack < track {
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
track = currentTrack
|
if !ok {
|
||||||
|
return "", fmt.Errorf("invalid claims")
|
||||||
|
}
|
||||||
|
v, _ := claims["jti"].(string)
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isJTIRevoked checks whether the given jti appears in jwt_revocations.
|
||||||
|
// Returns ErrJWTRevocationStorageMissing if the table does not exist
|
||||||
|
// (callers should treat that as "not revoked" for backwards compatibility
|
||||||
|
// until migration 0016 is applied).
|
||||||
|
func (w *WalletAuth) isJTIRevoked(ctx context.Context, jti string) (bool, error) {
|
||||||
|
var exists bool
|
||||||
|
err := w.db.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM jwt_revocations WHERE jti = $1)`, jti,
|
||||||
|
).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
if isMissingJWTRevocationTableError(err) {
|
||||||
|
return false, ErrJWTRevocationStorageMissing
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return exists, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeJWT records the token's jti in jwt_revocations. Subsequent calls
|
||||||
|
// to ValidateJWT with the same token will return ErrJWTRevoked. Idempotent
|
||||||
|
// on duplicate jti.
|
||||||
|
func (w *WalletAuth) RevokeJWT(ctx context.Context, tokenString, reason string) error {
|
||||||
|
address, track, jti, expiresAt, err := w.parseJWT(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if jti == "" {
|
||||||
|
// Legacy tokens issued before PR #8 don't carry a jti; there is
|
||||||
|
// nothing to revoke server-side. Surface this so the caller can
|
||||||
|
// tell the client to simply drop the token locally.
|
||||||
|
return fmt.Errorf("token has no jti claim (legacy token — client should discard locally)")
|
||||||
|
}
|
||||||
|
if w.db == nil {
|
||||||
|
return fmt.Errorf("wallet auth has no database; cannot revoke")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(reason) == "" {
|
||||||
|
reason = "logout"
|
||||||
|
}
|
||||||
|
_, err = w.db.Exec(ctx,
|
||||||
|
`INSERT INTO jwt_revocations (jti, address, track, token_expires_at, reason)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (jti) DO NOTHING`,
|
||||||
|
jti, address, track, expiresAt, reason,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if isMissingJWTRevocationTableError(err) {
|
||||||
|
return ErrJWTRevocationStorageMissing
|
||||||
|
}
|
||||||
|
return fmt.Errorf("record revocation: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshJWT issues a new token for the same address+track if the current
|
||||||
|
// token is valid (signed, unexpired, not revoked) and revokes the current
|
||||||
|
// token so it cannot be replayed. Returns the new token and its exp.
|
||||||
|
func (w *WalletAuth) RefreshJWT(ctx context.Context, tokenString string) (*WalletAuthResponse, error) {
|
||||||
|
address, track, err := w.ValidateJWT(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Revoke the old token before issuing a new one. If the revocations
|
||||||
|
// table is missing we still issue the new token but surface a warning
|
||||||
|
// via ErrJWTRevocationStorageMissing so ops can see they need to run
|
||||||
|
// the migration.
|
||||||
|
var revokeErr error
|
||||||
|
if w.db != nil {
|
||||||
|
revokeErr = w.RevokeJWT(ctx, tokenString, "refresh")
|
||||||
|
if revokeErr != nil && !errors.Is(revokeErr, ErrJWTRevocationStorageMissing) {
|
||||||
|
return nil, revokeErr
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return address, track, nil
|
newToken, expiresAt, err := w.generateJWT(address, track)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &WalletAuthResponse{
|
||||||
|
Token: newToken,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
Track: track,
|
||||||
|
Permissions: getPermissionsForTrack(track),
|
||||||
|
}, revokeErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeWalletSignature(signature string) ([]byte, error) {
|
func decodeWalletSignature(signature string) ([]byte, error) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -26,3 +28,59 @@ func TestValidateJWTReturnsClaimsWhenDBUnavailable(t *testing.T) {
|
|||||||
require.Equal(t, "0x4A666F96fC8764181194447A7dFdb7d471b301C8", address)
|
require.Equal(t, "0x4A666F96fC8764181194447A7dFdb7d471b301C8", address)
|
||||||
require.Equal(t, 4, track)
|
require.Equal(t, 4, track)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTokenTTLForTrack4IsShort(t *testing.T) {
|
||||||
|
// Track 4 (operator) must have a TTL <= 1h — that is the headline
|
||||||
|
// tightening promised by completion criterion 3 (JWT hygiene).
|
||||||
|
ttl := tokenTTLFor(4)
|
||||||
|
require.LessOrEqual(t, ttl, time.Hour, "track 4 TTL must be <= 1h")
|
||||||
|
require.Greater(t, ttl, time.Duration(0), "track 4 TTL must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenTTLForTrack1Track2Track3AreReasonable(t *testing.T) {
|
||||||
|
// Non-operator tracks are allowed longer sessions, but still bounded
|
||||||
|
// at 12h so a stale laptop tab doesn't carry a week-old token.
|
||||||
|
for _, track := range []int{1, 2, 3} {
|
||||||
|
ttl := tokenTTLFor(track)
|
||||||
|
require.Greater(t, ttl, time.Duration(0), "track %d TTL must be > 0", track)
|
||||||
|
require.LessOrEqual(t, ttl, 12*time.Hour, "track %d TTL must be <= 12h", track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedJWTCarriesJTIClaim(t *testing.T) {
|
||||||
|
// Revocation keys on jti. A token issued without one is unrevokable
|
||||||
|
// and must not be produced.
|
||||||
|
a := NewWalletAuth(nil, []byte("test-secret"))
|
||||||
|
token, _, err := a.generateJWT("0x4A666F96fC8764181194447A7dFdb7d471b301C8", 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
jti, err := a.jtiFromToken(token)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, jti, "generated JWT must carry a jti claim")
|
||||||
|
require.Len(t, jti, 32, "jti should be 16 random bytes hex-encoded (32 chars)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedJWTExpIsTrackAppropriate(t *testing.T) {
|
||||||
|
a := NewWalletAuth(nil, []byte("test-secret"))
|
||||||
|
for _, track := range []int{1, 2, 3, 4} {
|
||||||
|
_, expiresAt, err := a.generateJWT("0x4A666F96fC8764181194447A7dFdb7d471b301C8", track)
|
||||||
|
require.NoError(t, err)
|
||||||
|
want := tokenTTLFor(track)
|
||||||
|
// allow a couple-second slack for test execution
|
||||||
|
actual := time.Until(expiresAt)
|
||||||
|
require.InDelta(t, want.Seconds(), actual.Seconds(), 5.0,
|
||||||
|
"track %d exp should be ~%s from now, got %s", track, want, actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokeJWTWithoutDBReturnsError(t *testing.T) {
|
||||||
|
// With w.db == nil, revocation has nowhere to write — the call must
|
||||||
|
// fail loudly so callers don't silently assume a token was revoked.
|
||||||
|
a := NewWalletAuth(nil, []byte("test-secret"))
|
||||||
|
token, _, err := a.generateJWT("0x4A666F96fC8764181194447A7dFdb7d471b301C8", 4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = a.RevokeJWT(context.Background(), token, "test")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "no database")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Migration 0016_jwt_revocations.down.sql
|
||||||
|
DROP INDEX IF EXISTS idx_jwt_revocations_expires;
|
||||||
|
DROP INDEX IF EXISTS idx_jwt_revocations_address;
|
||||||
|
DROP TABLE IF EXISTS jwt_revocations;
|
||||||
30
backend/database/migrations/0016_jwt_revocations.up.sql
Normal file
30
backend/database/migrations/0016_jwt_revocations.up.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- Migration 0016_jwt_revocations.up.sql
|
||||||
|
--
|
||||||
|
-- Introduces server-side JWT revocation for the SolaceScan backend.
|
||||||
|
--
|
||||||
|
-- Up to this migration, tokens issued by /api/v1/auth/wallet were simply
|
||||||
|
-- signed and returned; the backend had no way to invalidate a token before
|
||||||
|
-- its exp claim short of rotating the JWT_SECRET (which would invalidate
|
||||||
|
-- every outstanding session). PR #8 introduces per-token revocation keyed
|
||||||
|
-- on the `jti` claim.
|
||||||
|
--
|
||||||
|
-- The table is append-only: a row exists iff that jti has been revoked.
|
||||||
|
-- ValidateJWT consults the table on every request; the primary key on
|
||||||
|
-- (jti) keeps lookups O(log n) and deduplicates repeated logout calls.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS jwt_revocations (
|
||||||
|
jti TEXT PRIMARY KEY,
|
||||||
|
address TEXT NOT NULL,
|
||||||
|
track INT NOT NULL,
|
||||||
|
-- original exp of the revoked token, so a background janitor can
|
||||||
|
-- reap rows after they can no longer matter.
|
||||||
|
token_expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
reason TEXT NOT NULL DEFAULT 'logout'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jwt_revocations_address
|
||||||
|
ON jwt_revocations (address);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_jwt_revocations_expires
|
||||||
|
ON jwt_revocations (token_expires_at);
|
||||||
Reference in New Issue
Block a user