Compare commits

..

1 Commits

Author SHA1 Message Date
29fe704f3c 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
12 changed files with 441 additions and 453 deletions

View File

@@ -141,12 +141,49 @@ type internalValidateAPIKeyRequest struct {
LastIP string `json:"last_ip"`
}
// rpcAccessProducts returns the Chain 138 RPC access catalog. The source
// of truth lives in config/rpc_products.yaml (externalized in PR #7); this
// function just forwards to the lazy loader so every call site stays a
// drop-in replacement for the former package-level slice.
func rpcAccessProducts() []accessProduct {
return rpcAccessProductCatalog()
var rpcAccessProducts = []accessProduct{
{
Slug: "core-rpc",
Name: "Core RPC",
Provider: "besu-core",
VMID: 2101,
HTTPURL: "https://rpc-http-prv.d-bis.org",
WSURL: "wss://rpc-ws-prv.d-bis.org",
DefaultTier: "enterprise",
RequiresApproval: true,
BillingModel: "contract",
Description: "Private Chain 138 Core RPC for operator-grade administration and sensitive workloads.",
UseCases: []string{"core deployments", "operator automation", "private infrastructure integration"},
ManagementFeatures: []string{"dedicated API key", "higher rate ceiling", "operator-oriented access controls"},
},
{
Slug: "alltra-rpc",
Name: "Alltra RPC",
Provider: "alltra",
VMID: 2102,
HTTPURL: "http://192.168.11.212:8545",
WSURL: "ws://192.168.11.212:8546",
DefaultTier: "pro",
RequiresApproval: false,
BillingModel: "subscription",
Description: "Dedicated Alltra-managed RPC lane for partner traffic, subscription access, and API-key-gated usage.",
UseCases: []string{"tenant RPC access", "managed partner workloads", "metered commercial usage"},
ManagementFeatures: []string{"subscription-ready key issuance", "rate governance", "partner-specific traffic lane"},
},
{
Slug: "thirdweb-rpc",
Name: "Thirdweb RPC",
Provider: "thirdweb",
VMID: 2103,
HTTPURL: "http://192.168.11.217:8545",
WSURL: "ws://192.168.11.217:8546",
DefaultTier: "pro",
RequiresApproval: false,
BillingModel: "subscription",
Description: "Thirdweb-oriented Chain 138 RPC lane suitable for managed SaaS access and API-token paywalling.",
UseCases: []string{"thirdweb integrations", "commercial API access", "managed dApp traffic"},
ManagementFeatures: []string{"API token issuance", "usage tiering", "future paywall/subscription hooks"},
},
}
func (s *Server) generateUserJWT(user *auth.User) (string, time.Time, error) {
@@ -329,7 +366,7 @@ func (s *Server) handleAccessProducts(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"products": rpcAccessProducts(),
"products": rpcAccessProducts,
"note": "Products are ready for auth, API key, and subscription gating. Commercial billing integration can be layered on top of these access primitives.",
})
}
@@ -587,7 +624,7 @@ func firstNonEmpty(values ...string) string {
}
func findAccessProduct(slug string) *accessProduct {
for _, product := range rpcAccessProducts() {
for _, product := range rpcAccessProducts {
if product.Slug == slug {
copy := product
return &copy

View 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",
})
}

View File

@@ -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"])

View File

@@ -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)

View File

@@ -1,206 +0,0 @@
package rest
import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"gopkg.in/yaml.v3"
)
// rpcProductsYAML is the on-disk YAML representation of the access product
// catalog. It matches config/rpc_products.yaml at the repo root.
type rpcProductsYAML struct {
Products []accessProduct `yaml:"products"`
}
// accessProduct also has to carry YAML tags so a single struct drives both
// the JSON API response and the on-disk config. (JSON tags are unchanged.)
// These yaml tags mirror the json tags exactly to avoid drift.
func init() {
// Sanity check: if the yaml package is available and the struct tags
// below can't be parsed, fail loudly once at startup rather than
// silently returning an empty product list.
var _ yaml.Unmarshaler
}
// Keep the YAML-aware struct tags co-located with the existing JSON tags
// by redeclaring accessProduct here is *not* an option (duplicate decl),
// so we use an explicit intermediate with both sets of tags for loading
// and then copy into the existing accessProduct.
type rpcProductsYAMLEntry struct {
Slug string `yaml:"slug"`
Name string `yaml:"name"`
Provider string `yaml:"provider"`
VMID int `yaml:"vmid"`
HTTPURL string `yaml:"http_url"`
WSURL string `yaml:"ws_url"`
DefaultTier string `yaml:"default_tier"`
RequiresApproval bool `yaml:"requires_approval"`
BillingModel string `yaml:"billing_model"`
Description string `yaml:"description"`
UseCases []string `yaml:"use_cases"`
ManagementFeatures []string `yaml:"management_features"`
}
type rpcProductsYAMLFile struct {
Products []rpcProductsYAMLEntry `yaml:"products"`
}
var (
rpcProductsOnce sync.Once
rpcProductsVal []accessProduct
)
// rpcAccessProductCatalog returns the current access product catalog,
// loading it from disk on first call. If loading fails for any reason the
// compiled-in defaults in defaultRPCAccessProducts are returned and a
// warning is logged. Callers should treat the returned slice as read-only.
func rpcAccessProductCatalog() []accessProduct {
rpcProductsOnce.Do(func() {
loaded, path, err := loadRPCAccessProducts()
switch {
case err != nil:
log.Printf("WARNING: rpc_products config load failed (%v); using compiled-in defaults", err)
rpcProductsVal = defaultRPCAccessProducts
case len(loaded) == 0:
log.Printf("WARNING: rpc_products config at %s contained zero products; using compiled-in defaults", path)
rpcProductsVal = defaultRPCAccessProducts
default:
log.Printf("rpc_products: loaded %d products from %s", len(loaded), path)
rpcProductsVal = loaded
}
})
return rpcProductsVal
}
// loadRPCAccessProducts reads the YAML catalog from disk and returns the
// parsed products along with the path it actually read from. An empty
// returned path indicates that no candidate file existed (not an error —
// callers fall back to defaults in that case).
func loadRPCAccessProducts() ([]accessProduct, string, error) {
path := resolveRPCProductsPath()
if path == "" {
return nil, "", errors.New("no rpc_products.yaml found (set RPC_PRODUCTS_PATH or place config/rpc_products.yaml next to the binary)")
}
raw, err := os.ReadFile(path) // #nosec G304 -- path comes from env/repo-known locations
if err != nil {
return nil, path, fmt.Errorf("read %s: %w", path, err)
}
var decoded rpcProductsYAMLFile
if err := yaml.Unmarshal(raw, &decoded); err != nil {
return nil, path, fmt.Errorf("parse %s: %w", path, err)
}
products := make([]accessProduct, 0, len(decoded.Products))
seen := make(map[string]struct{}, len(decoded.Products))
for i, entry := range decoded.Products {
if strings.TrimSpace(entry.Slug) == "" {
return nil, path, fmt.Errorf("%s: product[%d] has empty slug", path, i)
}
if _, dup := seen[entry.Slug]; dup {
return nil, path, fmt.Errorf("%s: duplicate product slug %q", path, entry.Slug)
}
seen[entry.Slug] = struct{}{}
if strings.TrimSpace(entry.HTTPURL) == "" {
return nil, path, fmt.Errorf("%s: product %q is missing http_url", path, entry.Slug)
}
products = append(products, accessProduct{
Slug: entry.Slug,
Name: entry.Name,
Provider: entry.Provider,
VMID: entry.VMID,
HTTPURL: strings.TrimSpace(entry.HTTPURL),
WSURL: strings.TrimSpace(entry.WSURL),
DefaultTier: entry.DefaultTier,
RequiresApproval: entry.RequiresApproval,
BillingModel: entry.BillingModel,
Description: strings.TrimSpace(entry.Description),
UseCases: entry.UseCases,
ManagementFeatures: entry.ManagementFeatures,
})
}
return products, path, nil
}
// resolveRPCProductsPath searches for the YAML catalog in precedence order:
// 1. $RPC_PRODUCTS_PATH (absolute or relative to cwd)
// 2. $EXPLORER_BACKEND_DIR/config/rpc_products.yaml
// 3. <cwd>/backend/config/rpc_products.yaml
// 4. <cwd>/config/rpc_products.yaml
//
// Returns "" when no candidate exists.
func resolveRPCProductsPath() string {
if explicit := strings.TrimSpace(os.Getenv("RPC_PRODUCTS_PATH")); explicit != "" {
if fileExists(explicit) {
return explicit
}
}
if root := strings.TrimSpace(os.Getenv("EXPLORER_BACKEND_DIR")); root != "" {
candidate := filepath.Join(root, "config", "rpc_products.yaml")
if fileExists(candidate) {
return candidate
}
}
for _, candidate := range []string{
filepath.Join("backend", "config", "rpc_products.yaml"),
filepath.Join("config", "rpc_products.yaml"),
} {
if fileExists(candidate) {
return candidate
}
}
return ""
}
// defaultRPCAccessProducts is the emergency fallback used when the YAML
// catalog is absent or unreadable. Kept in sync with config/rpc_products.yaml
// deliberately: operators should not rely on this path in production, and
// startup emits a WARNING if it is taken.
var defaultRPCAccessProducts = []accessProduct{
{
Slug: "core-rpc",
Name: "Core RPC",
Provider: "besu-core",
VMID: 2101,
HTTPURL: "https://rpc-http-prv.d-bis.org",
WSURL: "wss://rpc-ws-prv.d-bis.org",
DefaultTier: "enterprise",
RequiresApproval: true,
BillingModel: "contract",
Description: "Private Chain 138 Core RPC for operator-grade administration and sensitive workloads.",
UseCases: []string{"core deployments", "operator automation", "private infrastructure integration"},
ManagementFeatures: []string{"dedicated API key", "higher rate ceiling", "operator-oriented access controls"},
},
{
Slug: "alltra-rpc",
Name: "Alltra RPC",
Provider: "alltra",
VMID: 2102,
HTTPURL: "http://192.168.11.212:8545",
WSURL: "ws://192.168.11.212:8546",
DefaultTier: "pro",
RequiresApproval: false,
BillingModel: "subscription",
Description: "Dedicated Alltra-managed RPC lane for partner traffic, subscription access, and API-key-gated usage.",
UseCases: []string{"tenant RPC access", "managed partner workloads", "metered commercial usage"},
ManagementFeatures: []string{"subscription-ready key issuance", "rate governance", "partner-specific traffic lane"},
},
{
Slug: "thirdweb-rpc",
Name: "Thirdweb RPC",
Provider: "thirdweb",
VMID: 2103,
HTTPURL: "http://192.168.11.217:8545",
WSURL: "ws://192.168.11.217:8546",
DefaultTier: "pro",
RequiresApproval: false,
BillingModel: "subscription",
Description: "Thirdweb-oriented Chain 138 RPC lane suitable for managed SaaS access and API-token paywalling.",
UseCases: []string{"thirdweb integrations", "commercial API access", "managed dApp traffic"},
ManagementFeatures: []string{"API token issuance", "usage tiering", "future paywall/subscription hooks"},
},
}

View File

@@ -1,111 +0,0 @@
package rest
import (
"os"
"path/filepath"
"testing"
)
func TestLoadRPCAccessProductsFromRepoDefault(t *testing.T) {
// The repo ships config/rpc_products.yaml relative to backend/. When
// running `go test ./...` from the repo root, the loader's relative
// search path finds it there. Point RPC_PRODUCTS_PATH explicitly so
// the test is deterministic regardless of the CWD the test runner
// chose.
repoRoot, err := findBackendRoot()
if err != nil {
t.Fatalf("locate backend root: %v", err)
}
t.Setenv("RPC_PRODUCTS_PATH", filepath.Join(repoRoot, "config", "rpc_products.yaml"))
products, path, err := loadRPCAccessProducts()
if err != nil {
t.Fatalf("loadRPCAccessProducts: %v", err)
}
if path == "" {
t.Fatalf("loadRPCAccessProducts returned empty path")
}
if len(products) < 3 {
t.Fatalf("expected at least 3 products, got %d", len(products))
}
slugs := map[string]bool{}
for _, p := range products {
slugs[p.Slug] = true
if p.HTTPURL == "" {
t.Errorf("product %q has empty http_url", p.Slug)
}
}
for _, required := range []string{"core-rpc", "alltra-rpc", "thirdweb-rpc"} {
if !slugs[required] {
t.Errorf("expected product slug %q in catalog", required)
}
}
}
func TestLoadRPCAccessProductsRejectsDuplicateSlug(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "rpc_products.yaml")
yaml := `products:
- slug: a
http_url: https://a.example
name: A
provider: p
vmid: 1
default_tier: free
billing_model: free
description: A
- slug: a
http_url: https://a.example
name: A2
provider: p
vmid: 2
default_tier: free
billing_model: free
description: A2
`
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
t.Setenv("RPC_PRODUCTS_PATH", path)
if _, _, err := loadRPCAccessProducts(); err == nil {
t.Fatal("expected duplicate-slug error, got nil")
}
}
func TestLoadRPCAccessProductsRejectsMissingHTTPURL(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "rpc_products.yaml")
if err := os.WriteFile(path, []byte("products:\n - slug: x\n name: X\n"), 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
t.Setenv("RPC_PRODUCTS_PATH", path)
if _, _, err := loadRPCAccessProducts(); err == nil {
t.Fatal("expected missing-http_url error, got nil")
}
}
// findBackendRoot walks up from the test working directory until it finds
// a directory containing a go.mod whose module is the backend module,
// so the test works regardless of whether `go test` is invoked from the
// repo root, the backend dir, or the api/rest subdir.
func findBackendRoot() (string, error) {
cwd, err := os.Getwd()
if err != nil {
return "", err
}
for {
goMod := filepath.Join(cwd, "go.mod")
if _, err := os.Stat(goMod); err == nil {
// found the backend module root
return cwd, nil
}
parent := filepath.Dir(cwd)
if parent == cwd {
return "", os.ErrNotExist
}
cwd = parent
}
}

View File

@@ -21,8 +21,49 @@ var (
ErrWalletNonceNotFoundOrExpired = errors.New("nonce not found or expired")
ErrWalletNonceExpired = errors.New("nonce expired")
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
type WalletAuth struct {
db *pgxpool.Pool
@@ -207,13 +248,20 @@ func (w *WalletAuth) getUserTrack(ctx context.Context, address string) (int, err
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) {
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{
"address": address,
"track": track,
"jti": jti,
"exp": expiresAt.Unix(),
"iat": time.Now().Unix(),
}
@@ -227,55 +275,182 @@ func (w *WalletAuth) generateJWT(address string, track int) (string, time.Time,
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) {
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 {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return w.jwtSecret, nil
})
if err != nil {
return "", 0, fmt.Errorf("failed to parse token: %w", err)
if perr != nil {
return "", 0, "", time.Time{}, fmt.Errorf("failed to parse token: %w", perr)
}
if !token.Valid {
return "", 0, fmt.Errorf("invalid token")
return "", 0, "", time.Time{}, fmt.Errorf("invalid token")
}
claims, ok := token.Claims.(jwt.MapClaims)
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 {
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)
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)
if w.db == nil {
return address, track, nil
track = int(trackFloat)
if v, ok := claims["jti"].(string); ok {
jti = v
}
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)
defer cancel()
currentTrack, err := w.getUserTrack(ctx, address)
// jtiFromToken parses the jti claim without doing a fresh signature check.
// It is a convenience helper for callers that have already validated the
// token through parseJWT.
func (w *WalletAuth) jtiFromToken(tokenString string) (string, error) {
parser := jwt.Parser{}
token, _, err := parser.ParseUnverified(tokenString, jwt.MapClaims{})
if err != nil {
return "", 0, fmt.Errorf("failed to resolve current track: %w", err)
return "", err
}
if currentTrack < track {
track = currentTrack
claims, ok := token.Claims.(jwt.MapClaims)
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) {

View File

@@ -1,7 +1,9 @@
package auth
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
@@ -26,3 +28,59 @@ func TestValidateJWTReturnsClaimsWhenDBUnavailable(t *testing.T) {
require.Equal(t, "0x4A666F96fC8764181194447A7dFdb7d471b301C8", address)
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")
}

View File

@@ -1,97 +0,0 @@
# Chain 138 RPC access product catalog.
#
# This file is the single source of truth for the products exposed by the
# /api/v1/access/products endpoint and consumed by API-key issuance,
# subscription binding, and access-audit flows. Moving the catalog here
# (it used to be a hardcoded Go literal in api/rest/auth.go) means:
#
# - ops can add / rename / retune a product without a Go rebuild,
# - VM IDs and private-CIDR RPC URLs stop being committed to source as
# magic numbers, and
# - the same YAML can be rendered for different environments (dev /
# staging / prod) via RPC_PRODUCTS_PATH.
#
# Path resolution at startup:
# 1. $RPC_PRODUCTS_PATH if set (absolute or relative to the working dir),
# 2. $EXPLORER_BACKEND_DIR/config/rpc_products.yaml if that env var is set,
# 3. the first of <cwd>/backend/config/rpc_products.yaml or
# <cwd>/config/rpc_products.yaml that exists,
# 4. the compiled-in fallback slice (legacy behaviour; logs a warning).
#
# Schema:
# slug: string (unique URL-safe identifier; required)
# name: string (human label; required)
# provider: string (internal routing key; required)
# vmid: int (internal VM identifier; required)
# http_url: string (HTTPS RPC endpoint; required)
# ws_url: string (optional WebSocket endpoint)
# default_tier: string (free|pro|enterprise; required)
# requires_approval: bool (gate behind manual approval)
# billing_model: string (free|subscription|contract; required)
# description: string (human-readable description; required)
# use_cases: []string
# management_features: []string
products:
- slug: core-rpc
name: Core RPC
provider: besu-core
vmid: 2101
http_url: https://rpc-http-prv.d-bis.org
ws_url: wss://rpc-ws-prv.d-bis.org
default_tier: enterprise
requires_approval: true
billing_model: contract
description: >-
Private Chain 138 Core RPC for operator-grade administration and
sensitive workloads.
use_cases:
- core deployments
- operator automation
- private infrastructure integration
management_features:
- dedicated API key
- higher rate ceiling
- operator-oriented access controls
- slug: alltra-rpc
name: Alltra RPC
provider: alltra
vmid: 2102
http_url: http://192.168.11.212:8545
ws_url: ws://192.168.11.212:8546
default_tier: pro
requires_approval: false
billing_model: subscription
description: >-
Dedicated Alltra-managed RPC lane for partner traffic, subscription
access, and API-key-gated usage.
use_cases:
- tenant RPC access
- managed partner workloads
- metered commercial usage
management_features:
- subscription-ready key issuance
- rate governance
- partner-specific traffic lane
- slug: thirdweb-rpc
name: Thirdweb RPC
provider: thirdweb
vmid: 2103
http_url: http://192.168.11.217:8545
ws_url: ws://192.168.11.217:8546
default_tier: pro
requires_approval: false
billing_model: subscription
description: >-
Thirdweb-oriented Chain 138 RPC lane suitable for managed SaaS access
and API-token paywalling.
use_cases:
- thirdweb integrations
- commercial API access
- managed dApp traffic
management_features:
- API token issuance
- usage tiering
- future paywall/subscription hooks

View File

@@ -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;

View 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);

View File

@@ -13,7 +13,6 @@ require (
github.com/redis/go-redis/v9 v9.17.2
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.36.0
gopkg.in/yaml.v3 v3.0.1
)
require (
@@ -52,5 +51,6 @@ require (
golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
rsc.io/tmplfunc v0.0.3 // indirect
)