Files
explorer-monorepo/backend/auth/wallet_auth_test.go

120 lines
4.0 KiB
Go
Raw Normal View History

package auth
import (
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.'
2026-04-18 19:20:57 +00:00
"context"
"testing"
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.'
2026-04-18 19:20:57 +00:00
"time"
"github.com/ethereum/go-ethereum/accounts"
"github.com/ethereum/go-ethereum/crypto"
"github.com/stretchr/testify/require"
)
func TestWalletAuthSignMessageMatchesFrontend(t *testing.T) {
nonce := "abc123def456"
require.Equal(
t,
"Sign this message to authenticate with DBIS Explorer.\n\nNonce: abc123def456",
walletAuthSignMessage(nonce),
)
}
func TestAuthenticateWalletRecoversSignerFromFrontendMessage(t *testing.T) {
privateKey, err := crypto.GenerateKey()
require.NoError(t, err)
address := crypto.PubkeyToAddress(privateKey.PublicKey).Hex()
nonce := "test-nonce-001"
message := walletAuthSignMessage(nonce)
messageHash := accounts.TextHash([]byte(message))
signature, err := crypto.Sign(messageHash, privateKey)
require.NoError(t, err)
signature[64] += 27
sigBytes := make([]byte, len(signature))
copy(sigBytes, signature)
if sigBytes[64] >= 27 {
sigBytes[64] -= 27
}
pubKey, err := crypto.SigToPub(messageHash, sigBytes)
require.NoError(t, err)
require.Equal(t, address, crypto.PubkeyToAddress(*pubKey).Hex())
}
func TestDecodeWalletSignatureRejectsMalformedValues(t *testing.T) {
_, err := decodeWalletSignature("deadbeef")
require.ErrorContains(t, err, "signature must start with 0x")
_, err = decodeWalletSignature("0x1234")
require.ErrorContains(t, err, "invalid signature length")
}
func TestValidateJWTReturnsClaimsWhenDBUnavailable(t *testing.T) {
secret := []byte("test-secret")
auth := NewWalletAuth(nil, secret)
token, _, err := auth.generateJWT("0x4A666F96fC8764181194447A7dFdb7d471b301C8", 4)
require.NoError(t, err)
address, track, err := auth.ValidateJWT(token)
require.NoError(t, err)
require.Equal(t, "0x4A666F96fC8764181194447A7dFdb7d471b301C8", address)
require.Equal(t, 4, track)
}
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.'
2026-04-18 19:20:57 +00:00
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")
}