diff --git a/Makefile b/Makefile index 56abbc84..5b1b328c 100644 --- a/Makefile +++ b/Makefile @@ -137,6 +137,11 @@ setup: deps db-up CANTON_MASTER_KEY := $(or $(CANTON_MASTER_KEY),$(shell openssl rand -base64 32)) export CANTON_MASTER_KEY +# JWT signing key for read-endpoint auth: a base64-encoded RSA PEM (single line so +# it survives env substitution into a YAML scalar). Generated per run unless set. +JWT_PRIVATE_KEY := $(or $(JWT_PRIVATE_KEY),$(shell openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 2>/dev/null | openssl base64 -A)) +export JWT_PRIVATE_KEY + build-dars: ./scripts/setup/build-dars.sh diff --git a/docker-compose.yaml b/docker-compose.yaml index 3dc5e14d..7d33016b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -171,6 +171,9 @@ services: CANTON_AUTH_CLIENT_ID: "${CANTON_AUTH_CLIENT_ID:-local-test-client}" CANTON_AUTH_CLIENT_SECRET: "${CANTON_AUTH_CLIENT_SECRET:-local-test-secret}" ETHEREUM_RELAYER_PRIVATE_KEY: "${ETHEREUM_RELAYER_PRIVATE_KEY:-ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80}" + # Needed because bootstrap also loads the api-server config, which now + # includes an auth block with private_key: "${JWT_PRIVATE_KEY}". + JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY}" volumes: - ./contracts/ethereum-wayfinder/broadcast:/app/broadcast:ro - config_state:/app/state @@ -298,6 +301,9 @@ services: CANTON_AUTH_CLIENT_ID: "${CANTON_AUTH_CLIENT_ID:-local-test-client}" CANTON_AUTH_CLIENT_SECRET: "${CANTON_AUTH_CLIENT_SECRET:-local-test-secret}" CANTON_MASTER_KEY: "${CANTON_MASTER_KEY}" # Generate with: openssl rand -base64 32 + # Base64-encoded RSA PEM for read-endpoint JWT signing. Generate with: + # openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 | openssl base64 -A + JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY}" SKIP_CANTON_SIG_VERIFY: "${SKIP_CANTON_SIG_VERIFY:-false}" # Set to "true" for local testing # Admin API: enabled on the api-server only. The bootstrap loads the same # config but leaves ADMIN_API_ENABLED unset, so admin stays off there and diff --git a/pkg/app/api/server.go b/pkg/app/api/server.go index ae713215..8d468a9e 100644 --- a/pkg/app/api/server.go +++ b/pkg/app/api/server.go @@ -14,6 +14,9 @@ import ( sharedmetrics "github.com/chainsafe/canton-middleware/internal/metrics" apphttp "github.com/chainsafe/canton-middleware/pkg/app/http" + "github.com/chainsafe/canton-middleware/pkg/auth/jwt" + authservice "github.com/chainsafe/canton-middleware/pkg/auth/service" + nonceprovider "github.com/chainsafe/canton-middleware/pkg/auth/service/nonce_provider" canton "github.com/chainsafe/canton-middleware/pkg/cantonsdk/client" cantontkn "github.com/chainsafe/canton-middleware/pkg/cantonsdk/token" "github.com/chainsafe/canton-middleware/pkg/config" @@ -160,17 +163,16 @@ func (s *Server) Run() error { ) } - // Admin config is optional (nil when the `admin` block is omitted). The token - // is resolved by the config loader (api_key: "${ADMIN_API_KEY}") and validated - // as required when enabled, so setupRouter can read it straight off the value. - var adminCfg config.AdminAPI - if cfg.Admin != nil { - adminCfg = *cfg.Admin + loginSvc, readAuth, err := s.buildReadAuth(userStore, logger) + if err != nil { + stop() + _ = g.Wait() + return err } router := s.setupRouter( svcs.evmStore, wl, cantonClient, svcs.tokenService, svcs.regSvc, svcs.transferSvc, - adminCfg, metrics, logger, + loginSvc, readAuth, metrics, logger, ) s.registerServers(g, gCtx, router, logger) @@ -308,6 +310,49 @@ func initServices( }, nil } +// buildReadAuth constructs the SIWE login handler and the middleware that guards +// the read endpoints. +// passthrough is a no-op middleware used when read authentication is disabled. +func passthrough(next http.Handler) http.Handler { return next } + +// buildReadAuth constructs the SIWE login service and the middleware guarding the +// read endpoints. Auth is optional: when the `auth` config block is absent it +// returns a nil service (login/JWKS routes are not mounted) and a passthrough +// middleware, so the read endpoints fall back to resolving the caller from the +// ?address= query parameter and are not access-controlled. +func (s *Server) buildReadAuth( + userStore authservice.UserLookup, + logger *zap.Logger, +) (authservice.Service, func(http.Handler) http.Handler, error) { + if s.cfg.Auth == nil { + logger.Warn("read-endpoint authentication is DISABLED: no `auth` config block; " + + "transfer read endpoints and /profile resolve the caller from ?address= and are not access-controlled") + return nil, passthrough, nil + } + + cfg := s.cfg.Auth + + key, err := jwt.ParseRSAPrivateKey(cfg.PrivateKey) + if err != nil { + return nil, nil, fmt.Errorf("invalid JWT signing key: %w", err) + } + + nonces := nonceprovider.NewInMemory(cfg.NonceTTL) + issuer := jwt.NewIssuer(key, cfg.KeyID, cfg.Issuer, cfg.Audience, cfg.TokenTTL) + verifier := jwt.NewSIWEVerifier(cfg.Domain, cfg.URI, cfg.ChainID) + loginSvc := authservice.NewLog(authservice.New(verifier, issuer, nonces, userStore), logger) + + validator := jwt.NewValidatorWithKey(issuer.KeyID(), issuer.PublicKey(), cfg.Issuer) + readAuthMW := jwt.RequireAuth(validator, cfg.Audience) + + logger.Info("read-endpoint authentication enabled", + zap.String("issuer", cfg.Issuer), + zap.String("audience", cfg.Audience), + zap.Duration("token_ttl", cfg.TokenTTL), + ) + return loginSvc, readAuthMW, nil +} + func (s *Server) getMasterKey() ([]byte, error) { masterKeyStr := os.Getenv(s.cfg.KeyManagement.MasterKeyEnv) if masterKeyStr == "" { @@ -382,7 +427,8 @@ func (s *Server) setupRouter( tokenService *token.Service, userService userservice.Service, transferSvc transfer.Service, - adminCfg config.AdminAPI, + loginSvc authservice.Service, + readAuth func(http.Handler) http.Handler, metrics *apphttp.HTTPMetrics, logger *zap.Logger, ) chi.Router { @@ -405,16 +451,24 @@ func (s *Server) setupRouter( // Supported tokens metadata token.RegisterRoutes(r, tokenService, logger) - // Registration endpoints - userservice.RegisterRoutes(r, userService, logger) + // SIWE login + JWKS endpoints (only when read auth is configured). + if loginSvc != nil { + authservice.RegisterRoutes(r, loginSvc, logger) + } + + // Registration endpoints (registration is self-authenticating; /profile is + // guarded by readAuth). + userservice.RegisterRoutes(r, userService, readAuth, logger) - // Admin endpoints (whitelist management), gated by a static bearer token. - if adminCfg.Enabled { - whitelist.RegisterAdminRoutes(r, wl, adminCfg.APIKey, logger) + // Admin endpoints (whitelist management), gated by a static bearer token. The + // admin block is optional (nil when omitted); the api_key is resolved and + // validated by the config loader (api_key: "${ADMIN_API_KEY}"). + if s.cfg.Admin != nil && s.cfg.Admin.Enabled { + whitelist.RegisterAdminRoutes(r, wl, s.cfg.Admin.APIKey, logger) } - // Non-custodial transfer endpoints (prepare/execute) - transfer.RegisterRoutes(r, transferSvc, logger) + // Non-custodial transfer endpoints (prepare/execute); read endpoints guarded by readAuth. + transfer.RegisterRoutes(r, transferSvc, readAuth, logger) registryHandler := registry.NewHandler(cantonClient.Token, logger) r.Handle("/registry/transfer-instruction/v1/transfer-factory", registryHandler) diff --git a/pkg/config/defaults/config.api-server.docker.yaml b/pkg/config/defaults/config.api-server.docker.yaml index 1a5de982..effb9b13 100644 --- a/pkg/config/defaults/config.api-server.docker.yaml +++ b/pkg/config/defaults/config.api-server.docker.yaml @@ -99,7 +99,20 @@ eth_rpc: chain_id: 31337 # Anvil local chain ID request_timeout: "30s" -# JWKS endpoint for JWT validation (optional - if not using EVM signatures) +# Read-endpoint authentication (SIWE login + RS256 JWT). domain/uri/chain_id are +# localhost here so the e2e suite can sign a matching EIP-4361 message. The signing +# key is a base64-encoded PEM supplied via ${JWT_PRIVATE_KEY}. +auth: + private_key: "${JWT_PRIVATE_KEY}" + kid: "default" + issuer: "canton-middleware" + audience: "canton-middleware-api" + token_ttl: "6h" + nonce_ttl: "5m" + domain: "localhost" + uri: "http://localhost" + chain_id: 31337 + monitoring: enabled: true server: diff --git a/pkg/config/defaults/config.api-server.local-devnet.yaml b/pkg/config/defaults/config.api-server.local-devnet.yaml index 9042cd78..4cda37b5 100644 --- a/pkg/config/defaults/config.api-server.local-devnet.yaml +++ b/pkg/config/defaults/config.api-server.local-devnet.yaml @@ -92,6 +92,18 @@ eth_rpc: chain_id: 1155111101 # Custom: Sepolia (11155111) + 01 suffix for Canton local request_timeout: "60s" +# The signing key is a base64-encoded PEM supplied via ${JWT_PRIVATE_KEY} (base64 < key.pem). +auth: + private_key: "${JWT_PRIVATE_KEY}" + kid: "default" + issuer: "canton-middleware" + audience: "canton-middleware-api" + token_ttl: "6h" + nonce_ttl: "1m" + domain: "dapp-dev1.01.chainsafe.dev" + uri: "https://dapp-dev1.01.chainsafe.dev" + chain_id: 1155111101 + monitoring: enabled: true server: diff --git a/pkg/config/defaults/config.api-server.mainnet.yaml b/pkg/config/defaults/config.api-server.mainnet.yaml index 42644b4a..20e5e817 100644 --- a/pkg/config/defaults/config.api-server.mainnet.yaml +++ b/pkg/config/defaults/config.api-server.mainnet.yaml @@ -74,6 +74,19 @@ eth_rpc: chain_id: 1337 request_timeout: "60s" +# The signing key is a base64-encoded PEM supplied via ${JWT_PRIVATE_KEY} (base64 < key.pem). +# domain/uri must match the production dapp origin. +auth: + private_key: "${JWT_PRIVATE_KEY}" + kid: "default" + issuer: "canton-middleware" + audience: "canton-middleware-api" + token_ttl: "6h" + nonce_ttl: "1m" + domain: "evm-middleware.chainsafe.io" + uri: "https://evm-middleware.chainsafe.io" + chain_id: 1337 + monitoring: enabled: true server: diff --git a/pkg/transfer/http.go b/pkg/transfer/http.go index 2aadc87f..9003e2d4 100644 --- a/pkg/transfer/http.go +++ b/pkg/transfer/http.go @@ -38,7 +38,11 @@ type httpHandler struct { } // RegisterRoutes registers the non-custodial prepare/execute transfer endpoints. -func RegisterRoutes(r chi.Router, svc Service, logger *zap.Logger) { +// readAuth guards the read (list) endpoints. When auth is enabled it authenticates +// the caller and puts their identity in the request context; when disabled it is a +// passthrough and the handlers fall back to the ?address= query parameter. It must +// be non-nil. +func RegisterRoutes(r chi.Router, svc Service, readAuth func(http.Handler) http.Handler, logger *zap.Logger) { h := &httpHandler{svc: svc, logger: logger} r.Post("/api/v2/transfer/prepare", apphttp.HandleError(h.prepare)) @@ -48,9 +52,12 @@ func RegisterRoutes(r chi.Router, svc Service, logger *zap.Logger) { // middleware holds the custodial user's Canton key and signs server-side. r.Post("/api/v2/transfer/custodial", apphttp.HandleError(h.sendCustodial)) - r.Get("/api/v2/transfer/incoming", apphttp.HandleError(h.listIncoming)) - r.Get("/api/v2/transfer/outgoing", apphttp.HandleError(h.listOutgoing)) - r.Get("/api/v2/transfer/completed", apphttp.HandleError(h.listCompleted)) + // Read endpoints return one caller's data. With auth enabled the caller is the + // bearer-token identity; with auth disabled they fall back to ?address=. + read := r.With(readAuth) + read.Get("/api/v2/transfer/incoming", apphttp.HandleError(h.listIncoming)) + read.Get("/api/v2/transfer/outgoing", apphttp.HandleError(h.listOutgoing)) + read.Get("/api/v2/transfer/completed", apphttp.HandleError(h.listCompleted)) r.Post("/api/v2/transfer/incoming/{contractID}/prepare", apphttp.HandleError(h.prepareAccept)) r.Post("/api/v2/transfer/incoming/{contractID}/execute", apphttp.HandleError(h.executeAccept)) @@ -149,23 +156,15 @@ func (h *httpHandler) execute(w http.ResponseWriter, r *http.Request) error { return nil } -// listIncoming is intentionally unauthenticated for now: callers pass the EVM -// address as a query parameter and receive that user's pending offers. The -// endpoint is read-only and exposes only data already visible to the receiver -// party on-ledger, so dropping the signature requirement does not leak anything -// new — it just lets clients (and tests) poll incoming offers without prior -// signing-key access. Sensitive fields (party IDs) are truncated server-side. +// listIncoming returns the caller's pending offers. Identity comes from the bearer token when auth is enabled, else from ?address=. // // Pagination is page/limit based to match the indexer envelope so each request // translates to exactly one indexer round-trip — no in-process buffering of all // offers for a receiver. func (h *httpHandler) listIncoming(w http.ResponseWriter, r *http.Request) error { - evmAddr := strings.TrimSpace(r.URL.Query().Get("address")) - if evmAddr == "" { - return apperrors.BadRequestError(nil, "address query parameter is required") - } - if !auth.ValidateEVMAddress(evmAddr) { - return apperrors.BadRequestError(nil, "invalid address: must be a 0x-prefixed 40-hex-char EVM address") + evmAddr, err := callerAddress(r) + if err != nil { + return err } p, err := parseListPagination(r) @@ -173,7 +172,7 @@ func (h *httpHandler) listIncoming(w http.ResponseWriter, r *http.Request) error return err } - resp, err := h.svc.ListIncoming(r.Context(), auth.NormalizeAddress(evmAddr), p) + resp, err := h.svc.ListIncoming(r.Context(), evmAddr, p) if err != nil { return err } @@ -182,16 +181,12 @@ func (h *httpHandler) listIncoming(w http.ResponseWriter, r *http.Request) error return nil } -// listOutgoing returns the queried address's outbound TransferOffers. Like -// listIncoming it is unauthenticated and takes the EVM address as a query param; +// listOutgoing returns the caller's outbound TransferOffers; // ?status= filters by pending|expired|accepted|canceled|rejected|all (default all). func (h *httpHandler) listOutgoing(w http.ResponseWriter, r *http.Request) error { - evmAddr := strings.TrimSpace(r.URL.Query().Get("address")) - if evmAddr == "" { - return apperrors.BadRequestError(nil, "address query parameter is required") - } - if !auth.ValidateEVMAddress(evmAddr) { - return apperrors.BadRequestError(nil, "invalid address: must be a 0x-prefixed 40-hex-char EVM address") + evmAddr, err := callerAddress(r) + if err != nil { + return err } status, err := parseOutgoingStatus(r) @@ -203,7 +198,7 @@ func (h *httpHandler) listOutgoing(w http.ResponseWriter, r *http.Request) error return err } - resp, err := h.svc.ListOutgoing(r.Context(), auth.NormalizeAddress(evmAddr), status, p) + resp, err := h.svc.ListOutgoing(r.Context(), evmAddr, status, p) if err != nil { return err } @@ -212,15 +207,11 @@ func (h *httpHandler) listOutgoing(w http.ResponseWriter, r *http.Request) error return nil } -// listCompleted returns the queried address's settled transfers across all tokens. -// Unauthenticated, EVM address as a query param, party IDs truncated. +// listCompleted returns the caller's settled transfers across all tokens. func (h *httpHandler) listCompleted(w http.ResponseWriter, r *http.Request) error { - evmAddr := strings.TrimSpace(r.URL.Query().Get("address")) - if evmAddr == "" { - return apperrors.BadRequestError(nil, "address query parameter is required") - } - if !auth.ValidateEVMAddress(evmAddr) { - return apperrors.BadRequestError(nil, "invalid address: must be a 0x-prefixed 40-hex-char EVM address") + evmAddr, err := callerAddress(r) + if err != nil { + return err } p, err := parseListPagination(r) @@ -228,7 +219,7 @@ func (h *httpHandler) listCompleted(w http.ResponseWriter, r *http.Request) erro return err } - resp, err := h.svc.ListCompleted(r.Context(), auth.NormalizeAddress(evmAddr), p) + resp, err := h.svc.ListCompleted(r.Context(), evmAddr, p) if err != nil { return err } @@ -237,6 +228,35 @@ func (h *httpHandler) listCompleted(w http.ResponseWriter, r *http.Request) erro return nil } +// callerAddress resolves the EVM address whose data the request may access. +// +// When read authentication is enabled, the auth middleware has placed the +// authenticated address (from the JWT, already normalized) in the request context, +// and it is used verbatim — a caller can only read their own data. A ?address= that +// does not match the token is rejected with a 403 rather than silently ignored, so a +// client wrongly targeting another address fails loudly instead of quietly receiving +// its own data. When auth is disabled, the middleware is a passthrough and the address +// falls back to the ?address= query parameter (not access-controlled — the +// disabled-auth posture). +func callerAddress(r *http.Request) (string, error) { + if addr, ok := auth.EVMAddressFromContext(r.Context()); ok && addr != "" { + // Normalize here too: the transfer service looks the address up verbatim + // and does not normalize, so both branches must yield a canonical address. + authenticated := auth.NormalizeAddress(addr) + if q := strings.TrimSpace(r.URL.Query().Get("address")); q != "" && + (!auth.ValidateEVMAddress(q) || auth.NormalizeAddress(q) != authenticated) { + return "", apperrors.ForbiddenError(nil, "address query parameter does not match the authenticated identity") + } + return authenticated, nil + } + + addr := strings.TrimSpace(r.URL.Query().Get("address")) + if !auth.ValidateEVMAddress(addr) { + return "", apperrors.BadRequestError(nil, "address query parameter is required: must be a 0x-prefixed 40-hex-char EVM address") + } + return auth.NormalizeAddress(addr), nil +} + // parseOutgoingStatus maps ?status= to a transfer status filter for the outgoing // endpoint. Empty or "all" means no status filter. "accepted" is accepted as a // backward-compatible alias for "completed". diff --git a/pkg/transfer/http_auth_test.go b/pkg/transfer/http_auth_test.go new file mode 100644 index 00000000..c115558e --- /dev/null +++ b/pkg/transfer/http_auth_test.go @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 + +package transfer + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "go.uber.org/zap" + + "github.com/chainsafe/canton-middleware/pkg/auth" + "github.com/chainsafe/canton-middleware/pkg/indexer" +) + +// stubService embeds Service so it satisfies the interface; only the method under +// test is implemented. Any other call panics, which is the desired signal in a test. +type stubService struct { + Service + gotAddr string +} + +func (s *stubService) ListIncoming(_ context.Context, evmAddr string, _ indexer.Pagination) (*IncomingTransfersList, error) { + s.gotAddr = evmAddr + return &IncomingTransfersList{}, nil +} + +func authAs(evmAddress string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := auth.WithAuthInfo(r.Context(), &auth.AuthInfo{ + EVMAddress: evmAddress, + CantonParty: "party::test", + }) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// TestListIncoming_UsesTokenIdentity verifies the read endpoint derives the address +// from the authenticated context (no ?address= supplied). +func TestListIncoming_UsesTokenIdentity(t *testing.T) { + // The address in context is already checksummed at token issuance; the handler + // passes it through unchanged. + authed := auth.NormalizeAddress("0x000000000000000000000000000000000000dead") + svc := &stubService{} + + r := chi.NewRouter() + RegisterRoutes(r, svc, authAs(authed), zap.NewNop()) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v2/transfer/incoming", nil) + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if svc.gotAddr != authed { + t.Fatalf("service received %q, want authenticated address %q", svc.gotAddr, authed) + } +} + +// TestListIncoming_MatchingQueryAddressAllowed verifies that a ?address= equal to the +// token identity (case-insensitively) is accepted and resolves to the token address. +func TestListIncoming_MatchingQueryAddressAllowed(t *testing.T) { + authed := auth.NormalizeAddress("0x000000000000000000000000000000000000dead") + svc := &stubService{} + + r := chi.NewRouter() + RegisterRoutes(r, svc, authAs(authed), zap.NewNop()) + + rec := httptest.NewRecorder() + // Lowercased form of the same address must still be accepted. + req := httptest.NewRequest(http.MethodGet, "/api/v2/transfer/incoming?address=0x000000000000000000000000000000000000dead", nil) + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if svc.gotAddr != authed { + t.Fatalf("service received %q, want authenticated address %q", svc.gotAddr, authed) + } +} + +// TestListIncoming_MismatchedQueryAddressForbidden verifies that a ?address= that does +// not match the token is rejected with a 403 (instead of silently returning the +// caller's own data), and the service is never reached. +func TestListIncoming_MismatchedQueryAddressForbidden(t *testing.T) { + authed := auth.NormalizeAddress("0x000000000000000000000000000000000000dead") + svc := &stubService{} + + r := chi.NewRouter() + RegisterRoutes(r, svc, authAs(authed), zap.NewNop()) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v2/transfer/incoming?address=0x0000000000000000000000000000000000000001", nil) + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body = %s", rec.Code, rec.Body.String()) + } + if svc.gotAddr != "" { + t.Fatalf("service must not be called on a mismatched address, got %q", svc.gotAddr) + } +} + +// passthrough is the middleware used when read auth is disabled. +func passthrough(next http.Handler) http.Handler { return next } + +// TestListIncoming_AuthDisabled_UsesQueryAddress verifies that with auth disabled +// (passthrough middleware, no context identity) the handler falls back to ?address=. +func TestListIncoming_AuthDisabled_UsesQueryAddress(t *testing.T) { + want := auth.NormalizeAddress("0x0000000000000000000000000000000000000001") + svc := &stubService{} + + r := chi.NewRouter() + RegisterRoutes(r, svc, passthrough, zap.NewNop()) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v2/transfer/incoming?address="+want, nil) + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if svc.gotAddr != want { + t.Fatalf("service received %q, want %q", svc.gotAddr, want) + } +} + +// TestListIncoming_AuthDisabled_MissingAddress400 verifies that with auth disabled +// and no ?address= there is no caller to resolve, so it's a 400. +func TestListIncoming_AuthDisabled_MissingAddress400(t *testing.T) { + svc := &stubService{} + + r := chi.NewRouter() + RegisterRoutes(r, svc, passthrough, zap.NewNop()) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v2/transfer/incoming", nil) + r.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if svc.gotAddr != "" { + t.Fatal("service must not be called without a resolvable caller") + } +} diff --git a/pkg/user/service/http.go b/pkg/user/service/http.go index 4324e4d8..2e3732b6 100644 --- a/pkg/user/service/http.go +++ b/pkg/user/service/http.go @@ -6,12 +6,14 @@ import ( "encoding/json" "io" "net/http" + "strings" "github.com/go-chi/chi/v5" "go.uber.org/zap" apperrors "github.com/chainsafe/canton-middleware/pkg/app/errors" apphttp "github.com/chainsafe/canton-middleware/pkg/app/http" + "github.com/chainsafe/canton-middleware/pkg/auth" "github.com/chainsafe/canton-middleware/pkg/user" ) @@ -24,7 +26,7 @@ type HTTP struct { } // RegisterRoutes registers HTTP endpoints for registration service on the given chi router -func RegisterRoutes(r chi.Router, service Service, logger *zap.Logger) { +func RegisterRoutes(r chi.Router, service Service, readAuth func(http.Handler) http.Handler, logger *zap.Logger) { h := &HTTP{ service: service, logger: logger, @@ -32,7 +34,7 @@ func RegisterRoutes(r chi.Router, service Service, logger *zap.Logger) { r.Post("/register", apphttp.HandleError(h.register)) r.Post("/register/prepare-topology", apphttp.HandleError(h.prepareTopology)) - r.Get("/profile", apphttp.HandleError(h.getUser)) + r.With(readAuth).Get("/profile", apphttp.HandleError(h.getUser)) } // register handles HTTP requests @@ -118,24 +120,28 @@ func (h *HTTP) prepareTopology(w http.ResponseWriter, r *http.Request) error { return nil } -// getUser handles GET /user?address=0x... and returns the registered user profile. -// The caller must provide an EIP-191 signature over the message via X-Signature and -// X-Message headers. Credentials are kept out of query params to avoid leaking them -// into server access logs, CDN logs, and browser history. -// Returns 404 if the address is not registered. +// getUser handles GET /profile and returns the caller's registered profile. With +// read auth enabled the caller's address comes from the bearer token (context); a +// ?address= that does not match the token is rejected with a 403 rather than silently +// ignored, so a misdirected client fails loudly instead of quietly receiving its own +// profile. With auth disabled it falls back to the ?address= query parameter. func (h *HTTP) getUser(w http.ResponseWriter, r *http.Request) error { - address := r.URL.Query().Get("address") - if address == "" { - return apperrors.BadRequestError(nil, "address query parameter required") - } - - signature := r.Header.Get("X-Signature") - message := r.Header.Get("X-Message") - if signature == "" || message == "" { - return apperrors.UnAuthorizedError(nil, "X-Signature and X-Message headers required") + address, ok := auth.EVMAddressFromContext(r.Context()) + if ok && address != "" { + address = auth.NormalizeAddress(address) + if q := strings.TrimSpace(r.URL.Query().Get("address")); q != "" && + (!auth.ValidateEVMAddress(q) || auth.NormalizeAddress(q) != address) { + return apperrors.ForbiddenError(nil, "address query parameter does not match the authenticated identity") + } + } else { + address = strings.TrimSpace(r.URL.Query().Get("address")) + if !auth.ValidateEVMAddress(address) { + return apperrors.BadRequestError(nil, "address query parameter is required") + } + address = auth.NormalizeAddress(address) } - resp, err := h.service.GetUser(r.Context(), address, message, signature) + resp, err := h.service.GetUser(r.Context(), address) if err != nil { return err } diff --git a/pkg/user/service/http_test.go b/pkg/user/service/http_test.go index fcedf4f7..5a32c3fe 100644 --- a/pkg/user/service/http_test.go +++ b/pkg/user/service/http_test.go @@ -14,16 +14,39 @@ import ( "go.uber.org/zap" apperrors "github.com/chainsafe/canton-middleware/pkg/app/errors" + "github.com/chainsafe/canton-middleware/pkg/auth" "github.com/chainsafe/canton-middleware/pkg/user" "github.com/chainsafe/canton-middleware/pkg/user/service/mocks" ) func newRegisterTestServer(svc Service) http.Handler { + return newRegisterTestServerWithAuth(svc, passthroughAuth) +} + +func newRegisterTestServerWithAuth(svc Service, readAuth func(http.Handler) http.Handler) http.Handler { r := chi.NewRouter() - RegisterRoutes(r, svc, zap.NewNop()) + RegisterRoutes(r, svc, readAuth, zap.NewNop()) return r } +// passthroughAuth forwards the request untouched, simulating a request that +// reached the handler without an authenticated identity in context. +func passthroughAuth(next http.Handler) http.Handler { return next } + +// authAs returns middleware that injects a fixed authenticated EVM address, +// simulating a validated bearer token. +func authAs(evmAddress string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := auth.WithAuthInfo(r.Context(), &auth.AuthInfo{ + EVMAddress: evmAddress, + CantonParty: "party::test", + }) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + func TestRegisterHTTP_InvalidJSON_ReturnsBadRequest(t *testing.T) { svc := mocks.NewService(t) handler := newRegisterTestServer(svc) @@ -147,13 +170,13 @@ func TestRegisterHTTP_CantonNative_ResponseCheck(t *testing.T) { } } -func TestGetUserHTTP_MissingAddress_ReturnsBadRequest(t *testing.T) { +func TestGetUserHTTP_AuthDisabled_MissingAddress_Returns400(t *testing.T) { svc := mocks.NewService(t) - handler := newRegisterTestServer(svc) + // passthroughAuth = auth disabled: no context identity and no ?address=, so + // there is no caller to resolve — a 400, and GetUser is never called. + handler := newRegisterTestServerWithAuth(svc, passthroughAuth) req := httptest.NewRequest(http.MethodGet, "/profile", nil) - req.Header.Set("X-Signature", "0xsig") - req.Header.Set("X-Message", "login:0xabc:1234567890") rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -162,40 +185,30 @@ func TestGetUserHTTP_MissingAddress_ReturnsBadRequest(t *testing.T) { } } -func TestGetUserHTTP_MissingHeaders_ReturnsUnauthorized(t *testing.T) { +func TestGetUserHTTP_AuthDisabled_UsesQueryAddress(t *testing.T) { svc := mocks.NewService(t) - handler := newRegisterTestServer(svc) + svc.EXPECT().GetUser(mock.Anything, "0x0000000000000000000000000000000000000001"). + Return(&user.User{EVMAddress: "0x0000000000000000000000000000000000000001"}, nil) + handler := newRegisterTestServerWithAuth(svc, passthroughAuth) - req := httptest.NewRequest(http.MethodGet, "/profile?address=0xabc", nil) - // no X-Signature / X-Message headers + req := httptest.NewRequest(http.MethodGet, "/profile?address=0x0000000000000000000000000000000000000001", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code) - } - - var got struct { - Error string `json:"error"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("failed to decode response JSON: %v", err) - } - if got.Error != "X-Signature and X-Message headers required" { - t.Fatalf("unexpected error %q", got.Error) + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) } } -func TestGetUserHTTP_ValidHeaders_ReturnsUser(t *testing.T) { +func TestGetUserHTTP_Authenticated_ReturnsUser(t *testing.T) { + addr := auth.NormalizeAddress("0x00000000000000000000000000000000000000ab") svc := mocks.NewService(t) svc.EXPECT(). - GetUser(mock.Anything, "0xabc", "login:0xabc:1234567890", "0xsig"). - Return(&user.User{EVMAddress: "0xabc", CantonParty: "party::xyz"}, nil) - handler := newRegisterTestServer(svc) + GetUser(mock.Anything, addr). + Return(&user.User{EVMAddress: addr, CantonParty: "party::xyz"}, nil) + handler := newRegisterTestServerWithAuth(svc, authAs(addr)) - req := httptest.NewRequest(http.MethodGet, "/profile?address=0xabc", nil) - req.Header.Set("X-Signature", "0xsig") - req.Header.Set("X-Message", "login:0xabc:1234567890") + req := httptest.NewRequest(http.MethodGet, "/profile", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -207,54 +220,58 @@ func TestGetUserHTTP_ValidHeaders_ReturnsUser(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("failed to decode response JSON: %v", err) } - if got.EVMAddress != "0xabc" { - t.Fatalf("expected evm_address %q, got %q", "0xabc", got.EVMAddress) + if got.EVMAddress != addr { + t.Fatalf("expected evm_address %q, got %q", addr, got.EVMAddress) } } -func TestGetUserHTTP_ServiceReturnsNotFound_Returns404(t *testing.T) { +func TestGetUserHTTP_Authenticated_MatchingQueryAddress_ReturnsUser(t *testing.T) { + addr := auth.NormalizeAddress("0x00000000000000000000000000000000000000ab") svc := mocks.NewService(t) svc.EXPECT(). - GetUser(mock.Anything, "0xabc", "login:0xabc:1234567890", "0xsig"). - Return(nil, apperrors.ResourceNotFoundError(nil, "user not found")) - handler := newRegisterTestServer(svc) + GetUser(mock.Anything, addr). + Return(&user.User{EVMAddress: addr, CantonParty: "party::xyz"}, nil) + handler := newRegisterTestServerWithAuth(svc, authAs(addr)) - req := httptest.NewRequest(http.MethodGet, "/profile?address=0xabc", nil) - req.Header.Set("X-Signature", "0xsig") - req.Header.Set("X-Message", "login:0xabc:1234567890") + // Lowercased form of the authenticated address must be accepted. + req := httptest.NewRequest(http.MethodGet, "/profile?address=0x00000000000000000000000000000000000000ab", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) - if rec.Code != http.StatusNotFound { - t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code) + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) } +} - var got struct { - Error string `json:"error"` - } - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("failed to decode response JSON: %v", err) - } - if got.Error != "user not found" { - t.Fatalf("expected error %q, got %q", "user not found", got.Error) +func TestGetUserHTTP_Authenticated_MismatchedQueryAddress_Returns403(t *testing.T) { + addr := auth.NormalizeAddress("0x00000000000000000000000000000000000000ab") + // The mock has no expectations: a mismatch must be rejected before the service. + svc := mocks.NewService(t) + handler := newRegisterTestServerWithAuth(svc, authAs(addr)) + + req := httptest.NewRequest(http.MethodGet, "/profile?address=0x0000000000000000000000000000000000000001", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected status %d, got %d; body = %s", http.StatusForbidden, rec.Code, rec.Body.String()) } } -func TestGetUserHTTP_ServiceReturnsUnauthorized_Returns401(t *testing.T) { +func TestGetUserHTTP_ServiceReturnsNotFound_Returns404(t *testing.T) { + addr := auth.NormalizeAddress("0x00000000000000000000000000000000000000ab") svc := mocks.NewService(t) svc.EXPECT(). - GetUser(mock.Anything, "0xabc", "login:0xabc:1234567890", "0xsig"). - Return(nil, apperrors.UnAuthorizedError(nil, "invalid signature")) - handler := newRegisterTestServer(svc) + GetUser(mock.Anything, addr). + Return(nil, apperrors.ResourceNotFoundError(nil, "user not found")) + handler := newRegisterTestServerWithAuth(svc, authAs(addr)) - req := httptest.NewRequest(http.MethodGet, "/profile?address=0xabc", nil) - req.Header.Set("X-Signature", "0xsig") - req.Header.Set("X-Message", "login:0xabc:1234567890") + req := httptest.NewRequest(http.MethodGet, "/profile", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) - if rec.Code != http.StatusUnauthorized { - t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code) } var got struct { @@ -263,7 +280,7 @@ func TestGetUserHTTP_ServiceReturnsUnauthorized_Returns401(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("failed to decode response JSON: %v", err) } - if got.Error != "invalid signature" { - t.Fatalf("expected error %q, got %q", "invalid signature", got.Error) + if got.Error != "user not found" { + t.Fatalf("expected error %q, got %q", "user not found", got.Error) } } diff --git a/pkg/user/service/log.go b/pkg/user/service/log.go index 90f4425a..3fdfa71c 100644 --- a/pkg/user/service/log.go +++ b/pkg/user/service/log.go @@ -165,7 +165,7 @@ func (ls *logService) PrepareExternalRegistration( return ls.svc.PrepareExternalRegistration(ctx, req) } -func (ls *logService) GetUser(ctx context.Context, evmAddress, msg, sig string) (usr *user.User, err error) { +func (ls *logService) GetUser(ctx context.Context, evmAddress string) (usr *user.User, err error) { start := time.Now() ls.logger.Info("GetUser started", @@ -195,7 +195,7 @@ func (ls *logService) GetUser(ctx context.Context, evmAddress, msg, sig string) } }() - return ls.svc.GetUser(ctx, evmAddress, msg, sig) + return ls.svc.GetUser(ctx, evmAddress) } // Helper functions for sensitive data redaction diff --git a/pkg/user/service/mocks/mock_service.go b/pkg/user/service/mocks/mock_service.go index 826fc519..c2d45baa 100644 --- a/pkg/user/service/mocks/mock_service.go +++ b/pkg/user/service/mocks/mock_service.go @@ -23,9 +23,9 @@ func (_m *Service) EXPECT() *Service_Expecter { return &Service_Expecter{mock: &_m.Mock} } -// GetUser provides a mock function with given fields: ctx, evmAddress, msg, sig -func (_m *Service) GetUser(ctx context.Context, evmAddress string, msg string, sig string) (*user.User, error) { - ret := _m.Called(ctx, evmAddress, msg, sig) +// GetUser provides a mock function with given fields: ctx, evmAddress +func (_m *Service) GetUser(ctx context.Context, evmAddress string) (*user.User, error) { + ret := _m.Called(ctx, evmAddress) if len(ret) == 0 { panic("no return value specified for GetUser") @@ -33,19 +33,19 @@ func (_m *Service) GetUser(ctx context.Context, evmAddress string, msg string, s var r0 *user.User var r1 error - if rf, ok := ret.Get(0).(func(context.Context, string, string, string) (*user.User, error)); ok { - return rf(ctx, evmAddress, msg, sig) + if rf, ok := ret.Get(0).(func(context.Context, string) (*user.User, error)); ok { + return rf(ctx, evmAddress) } - if rf, ok := ret.Get(0).(func(context.Context, string, string, string) *user.User); ok { - r0 = rf(ctx, evmAddress, msg, sig) + if rf, ok := ret.Get(0).(func(context.Context, string) *user.User); ok { + r0 = rf(ctx, evmAddress) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*user.User) } } - if rf, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok { - r1 = rf(ctx, evmAddress, msg, sig) + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, evmAddress) } else { r1 = ret.Error(1) } @@ -61,15 +61,13 @@ type Service_GetUser_Call struct { // GetUser is a helper method to define mock.On call // - ctx context.Context // - evmAddress string -// - msg string -// - sig string -func (_e *Service_Expecter) GetUser(ctx interface{}, evmAddress interface{}, msg interface{}, sig interface{}) *Service_GetUser_Call { - return &Service_GetUser_Call{Call: _e.mock.On("GetUser", ctx, evmAddress, msg, sig)} +func (_e *Service_Expecter) GetUser(ctx interface{}, evmAddress interface{}) *Service_GetUser_Call { + return &Service_GetUser_Call{Call: _e.mock.On("GetUser", ctx, evmAddress)} } -func (_c *Service_GetUser_Call) Run(run func(ctx context.Context, evmAddress string, msg string, sig string)) *Service_GetUser_Call { +func (_c *Service_GetUser_Call) Run(run func(ctx context.Context, evmAddress string)) *Service_GetUser_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string)) + run(args[0].(context.Context), args[1].(string)) }) return _c } @@ -79,7 +77,7 @@ func (_c *Service_GetUser_Call) Return(_a0 *user.User, _a1 error) *Service_GetUs return _c } -func (_c *Service_GetUser_Call) RunAndReturn(run func(context.Context, string, string, string) (*user.User, error)) *Service_GetUser_Call { +func (_c *Service_GetUser_Call) RunAndReturn(run func(context.Context, string) (*user.User, error)) *Service_GetUser_Call { _c.Call.Return(run) return _c } diff --git a/pkg/user/service/service.go b/pkg/user/service/service.go index 15ef79fd..be36a21a 100644 --- a/pkg/user/service/service.go +++ b/pkg/user/service/service.go @@ -9,7 +9,6 @@ import ( "errors" "fmt" "strings" - "time" "go.uber.org/zap" "google.golang.org/grpc/codes" @@ -35,10 +34,6 @@ const ( // cantonKeySize is the required size for Canton private keys (32 bytes for secp256k1) cantonKeySize = 32 - - // loginMessageMaxAge is the maximum age accepted for a GET /profile login message. - // Must match SESSION_MAX_AGE_MS in the dapp's session.ts. - loginMessageMaxAge = 24 * time.Hour ) var ( @@ -68,7 +63,7 @@ type Service interface { RegisterWeb3User(ctx context.Context, req *user.RegisterRequest) (*user.RegisterResponse, error) RegisterCantonNativeUser(ctx context.Context, req *user.RegisterRequest) (*user.RegisterResponse, error) PrepareExternalRegistration(ctx context.Context, req *user.RegisterRequest) (*user.PrepareTopologyResponse, error) - GetUser(ctx context.Context, evmAddress, msg, sig string) (*user.User, error) + GetUser(ctx context.Context, evmAddress string) (*user.User, error) } type registrationService struct { @@ -464,21 +459,11 @@ func (s *registrationService) PrepareExternalRegistration( }, nil } -func (s *registrationService) GetUser(ctx context.Context, evmAddress, msg, sig string) (*user.User, error) { +// GetUser returns the profile for evmAddress. The caller's identity is +// established by the auth middleware (bearer token) before this is reached, so no +// signature is verified here; evmAddress is taken from the authenticated context. +func (s *registrationService) GetUser(ctx context.Context, evmAddress string) (*user.User, error) { evmAddress = auth.NormalizeAddress(evmAddress) - recoveredAddr, err := auth.VerifyEIP191Signature(msg, sig) - if err != nil { - return nil, apperrors.UnAuthorizedError(err, "invalid signature") - } - if !strings.EqualFold(recoveredAddr.Hex(), evmAddress) { - return nil, apperrors.UnAuthorizedError(nil, "signature does not match address") - } - if !strings.HasPrefix(strings.ToLower(msg), "login:"+strings.ToLower(evmAddress)+":") { - return nil, apperrors.UnAuthorizedError(nil, "message must be of form login:
:") - } - if err = auth.ValidateTimedMessage(msg, loginMessageMaxAge); err != nil { - return nil, apperrors.UnAuthorizedError(err, "message expired or malformed") - } usr, err := s.store.GetUserByEVMAddress(ctx, evmAddress) if err != nil && !errors.Is(err, user.ErrUserNotFound) { diff --git a/pkg/user/service/service_test.go b/pkg/user/service/service_test.go index 83cb2c56..f33a521f 100644 --- a/pkg/user/service/service_test.go +++ b/pkg/user/service/service_test.go @@ -9,7 +9,6 @@ import ( "fmt" "strings" "testing" - "time" "github.com/ethereum/go-ethereum/crypto" "go.uber.org/zap" @@ -197,41 +196,21 @@ func TestRegistrationService_RegisterCantonNativeUser_PartyAlreadyRegistered(t * } // signLoginMessage creates a valid timed EIP-191 login message and signature. -// offsetFromNow shifts the embedded timestamp by the given duration (use a negative -// value to simulate an expired message). -func signLoginMessage(t *testing.T, offsetFromNow time.Duration) (evmAddress, message, signature string) { - t.Helper() - - privateKey, err := crypto.GenerateKey() - if err != nil { - t.Fatalf("GenerateKey() failed: %v", err) - } - - ts := time.Now().Add(offsetFromNow).Unix() - addr := auth.NormalizeAddress(crypto.PubkeyToAddress(privateKey.PublicKey).Hex()) - message = fmt.Sprintf("login:%s:%d", strings.ToLower(addr), ts) - - prefixed := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message) - hash := crypto.Keccak256Hash([]byte(prefixed)) - - sig, err := crypto.Sign(hash.Bytes(), privateKey) - if err != nil { - t.Fatalf("Sign() failed: %v", err) - } - - return addr, message, "0x" + hex.EncodeToString(sig) -} +// GetUser trusts the EVM address supplied by the caller: the auth middleware has +// already established identity from the bearer token before the service is reached, +// so these tests exercise the lookup and error mapping, not signature verification +// (which now lives in pkg/auth/jwt). func TestGetUser_Success(t *testing.T) { ctx := context.Background() - evmAddress, message, signature := signLoginMessage(t, 0) + evmAddress := auth.NormalizeAddress("0x000000000000000000000000000000000000dEaD") expected := &user.User{EVMAddress: evmAddress, CantonParty: "party::abc"} storeMock := mocks.NewStore(t) storeMock.EXPECT().GetUserByEVMAddress(ctx, evmAddress).Return(expected, nil).Once() svc := NewService(storeMock, nil, nil, zap.NewNop(), false, stubChecker{allow: true}, nil) - got, err := svc.GetUser(ctx, evmAddress, message, signature) + got, err := svc.GetUser(ctx, evmAddress) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -240,45 +219,15 @@ func TestGetUser_Success(t *testing.T) { } } -func TestGetUser_ExpiredMessage(t *testing.T) { - ctx := context.Background() - // Timestamp 25 hours in the past — beyond the 24-hour loginMessageMaxAge. - evmAddress, message, signature := signLoginMessage(t, -25*time.Hour) - - svc := NewService(nil, nil, nil, zap.NewNop(), false, nil, nil) - _, err := svc.GetUser(ctx, evmAddress, message, signature) - if err == nil { - t.Fatal("expected unauthorized error for expired message, got nil") - } - if !apperrors.Is(err, apperrors.CategoryUnauthorized) { - t.Fatalf("expected CategoryUnauthorized, got %v", err) - } -} - -func TestGetUser_WrongAddress(t *testing.T) { - ctx := context.Background() - _, message, signature := signLoginMessage(t, 0) - otherAddress := "0x000000000000000000000000000000000000dEaD" - - svc := NewService(nil, nil, nil, zap.NewNop(), false, nil, nil) - _, err := svc.GetUser(ctx, otherAddress, message, signature) - if err == nil { - t.Fatal("expected unauthorized error for mismatched address, got nil") - } - if !apperrors.Is(err, apperrors.CategoryUnauthorized) { - t.Fatalf("expected CategoryUnauthorized, got %v", err) - } -} - func TestGetUser_UserNotFound(t *testing.T) { ctx := context.Background() - evmAddress, message, signature := signLoginMessage(t, 0) + evmAddress := auth.NormalizeAddress("0x000000000000000000000000000000000000dEaD") storeMock := mocks.NewStore(t) storeMock.EXPECT().GetUserByEVMAddress(ctx, evmAddress).Return(nil, user.ErrUserNotFound).Once() svc := NewService(storeMock, nil, nil, zap.NewNop(), false, stubChecker{allow: true}, nil) - _, err := svc.GetUser(ctx, evmAddress, message, signature) + _, err := svc.GetUser(ctx, evmAddress) if err == nil { t.Fatal("expected not-found error, got nil") } @@ -287,58 +236,16 @@ func TestGetUser_UserNotFound(t *testing.T) { } } -func TestGetUser_InvalidSignature(t *testing.T) { - ctx := context.Background() - - svc := NewService(nil, nil, nil, zap.NewNop(), false, nil, nil) - _, err := svc.GetUser(ctx, "0xdeadbeef", "some message", "not-a-valid-signature") - if err == nil { - t.Fatal("expected unauthorized error for invalid signature, got nil") - } - if !apperrors.Is(err, apperrors.CategoryUnauthorized) { - t.Fatalf("expected CategoryUnauthorized, got %v", err) - } -} - -func TestGetUser_InvalidMessageFormat_ReturnsUnauthorized(t *testing.T) { - ctx := context.Background() - - privateKey, err := crypto.GenerateKey() - if err != nil { - t.Fatalf("GenerateKey() failed: %v", err) - } - addr := auth.NormalizeAddress(crypto.PubkeyToAddress(privateKey.PublicKey).Hex()) - - // Sign a message with the wrong operation prefix (transfer instead of login). - msg := fmt.Sprintf("transfer:%s:%d", strings.ToLower(addr), time.Now().Unix()) - prefixed := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(msg), msg) - hash := crypto.Keccak256Hash([]byte(prefixed)) - sig, err := crypto.Sign(hash.Bytes(), privateKey) - if err != nil { - t.Fatalf("Sign() failed: %v", err) - } - hexSig := "0x" + hex.EncodeToString(sig) - - svc := NewService(nil, nil, nil, zap.NewNop(), false, nil, nil) - _, err = svc.GetUser(ctx, addr, msg, hexSig) - if err == nil { - t.Fatal("expected unauthorized error for wrong message prefix, got nil") - } - if !apperrors.Is(err, apperrors.CategoryUnauthorized) { - t.Fatalf("expected CategoryUnauthorized, got %v", err) - } -} - func TestGetUser_StoreError(t *testing.T) { ctx := context.Background() - evmAddress, message, signature := signLoginMessage(t, 0) + evmAddress := auth.NormalizeAddress("0x000000000000000000000000000000000000dEaD") storeErr := errors.New("connection refused") storeMock := mocks.NewStore(t) storeMock.EXPECT().GetUserByEVMAddress(ctx, evmAddress).Return(nil, storeErr).Once() svc := NewService(storeMock, nil, nil, zap.NewNop(), false, stubChecker{allow: true}, nil) - _, err := svc.GetUser(ctx, evmAddress, message, signature) + _, err := svc.GetUser(ctx, evmAddress) if err == nil { t.Fatal("expected error, got nil") } diff --git a/tests/e2e/devstack/shim/apiserver.go b/tests/e2e/devstack/shim/apiserver.go index 7162f772..a8664dda 100644 --- a/tests/e2e/devstack/shim/apiserver.go +++ b/tests/e2e/devstack/shim/apiserver.go @@ -10,13 +10,16 @@ import ( "net/url" "os" "strconv" + "sync" "time" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" + siwe "github.com/spruceid/siwe-go" + "github.com/chainsafe/canton-middleware/pkg/auth" "github.com/chainsafe/canton-middleware/pkg/ethereum/contracts" "github.com/chainsafe/canton-middleware/pkg/registry" "github.com/chainsafe/canton-middleware/pkg/transfer" @@ -26,6 +29,15 @@ import ( "github.com/chainsafe/canton-middleware/tests/e2e/devstack/util" ) +// SIWE login parameters. They MUST match the api-server's auth config +// (pkg/config/defaults/config.api-server.docker.yaml) or /auth/login rejects the +// signed message. +const ( + siweDomain = "localhost" + siweURI = "http://localhost" + siweChainID = 31337 +) + // defaultAdminAPIKey is the admin bearer token used when ADMIN_API_KEY is not // set in the environment. It MUST match the default in the api-server's compose // environment (docker-compose.yaml: ADMIN_API_KEY: "${ADMIN_API_KEY:-...}") so @@ -49,6 +61,11 @@ var _ stack.APIServer = (*APIServerShim)(nil) type APIServerShim struct { httpClient evm *ethclient.Client + + // tokens caches one SIWE-issued JWT per account address so the read endpoints + // (which require a bearer token when auth is enabled) reuse a single login. + mu sync.Mutex + tokens map[common.Address]string } // NewAPIServer dials the api-server REST endpoint and its /eth JSON-RPC @@ -64,10 +81,60 @@ func NewAPIServer(ctx context.Context, manifest *stack.ServiceManifest) (*APISer endpoint: manifest.APIHTTP, client: &http.Client{Timeout: 30 * time.Second}, }, - evm: ethclient.NewClient(rpcClient), + evm: ethclient.NewClient(rpcClient), + tokens: make(map[common.Address]string), }, nil } +// Login performs the SIWE (EIP-4361) login flow for account and returns a JWT: +// fetch a nonce, build and EIP-191-sign the message, then exchange it at +// /auth/login. The account must already be registered. The token is cached, so +// the read endpoints authenticate transparently after the first call. +func (a *APIServerShim) Login(ctx context.Context, account *stack.Account) (string, error) { + var nr auth.NonceResponse + q := url.Values{"address": []string{account.Address.Hex()}} + if err := a.get(ctx, "/auth/nonce", q, &nr); err != nil { + return "", fmt.Errorf("fetch nonce: %w", err) + } + + // Build the message with the same library the server parses with, so the text + // is a guaranteed-parseable EIP-4361 message. + msg, err := siwe.InitMessage(siweDomain, account.Address.Hex(), siweURI, nr.Nonce, map[string]any{ + "chainId": siweChainID, + "issuedAt": time.Now().UTC().Format(time.RFC3339), + }) + if err != nil { + return "", fmt.Errorf("build SIWE message: %w", err) + } + raw := msg.String() + + sig, err := util.SignEIP191(account.PrivateKey, raw) + if err != nil { + return "", fmt.Errorf("sign SIWE message: %w", err) + } + + var lr auth.LoginResponse + if err := a.post(ctx, "/auth/login", "", "", auth.LoginRequest{Message: raw, Signature: sig}, &lr); err != nil { + return "", fmt.Errorf("login: %w", err) + } + + a.mu.Lock() + a.tokens[account.Address] = lr.Token + a.mu.Unlock() + return lr.Token, nil +} + +// ensureToken returns a cached JWT for account, logging in on first use. +func (a *APIServerShim) ensureToken(ctx context.Context, account *stack.Account) (string, error) { + a.mu.Lock() + tok := a.tokens[account.Address] + a.mu.Unlock() + if tok != "" { + return tok, nil + } + return a.Login(ctx, account) +} + func (a *APIServerShim) Endpoint() string { return a.endpoint } func (a *APIServerShim) RPC() *ethclient.Client { return a.evm } func (a *APIServerShim) Close() { a.evm.Close() } @@ -204,15 +271,19 @@ func (a *APIServerShim) TransferFactory(ctx context.Context) (*registry.Transfer return &resp, nil } -// ListIncomingTransfers sends GET /api/v2/transfer/incoming?address=. The -// endpoint is unauthenticated; account is used only to derive the query parameter. +// ListIncomingTransfers sends GET /api/v2/transfer/incoming as account. The caller +// is taken from account's SIWE-issued bearer token, so it returns only account's +// pending offers. func (a *APIServerShim) ListIncomingTransfers( ctx context.Context, account *stack.Account, ) (*transfer.IncomingTransfersList, error) { - q := url.Values{"address": []string{account.Address.Hex()}} + token, err := a.ensureToken(ctx, account) + if err != nil { + return nil, err + } var resp transfer.IncomingTransfersList - if err := a.get(ctx, "/api/v2/transfer/incoming", q, &resp); err != nil { + if err := a.getBearer(ctx, "/api/v2/transfer/incoming", token, &resp); err != nil { return nil, err } return &resp, nil @@ -259,34 +330,41 @@ func (a *APIServerShim) WithdrawCustodial( return &resp, nil } -// ListOutgoingTransfers sends GET /api/v2/transfer/outgoing?address=&status=. -// The endpoint is unauthenticated; account is used only to derive the address query -// parameter. An empty status omits the filter (server defaults to all). +// ListOutgoingTransfers sends GET /api/v2/transfer/outgoing[?status=] as +// account (caller from its bearer token). An empty status omits the filter (server +// defaults to all). func (a *APIServerShim) ListOutgoingTransfers( ctx context.Context, account *stack.Account, status string, ) (*transfer.OutgoingTransfersList, error) { - q := url.Values{"address": []string{account.Address.Hex()}} + token, err := a.ensureToken(ctx, account) + if err != nil { + return nil, err + } + path := "/api/v2/transfer/outgoing" if status != "" { - q.Set("status", status) + path += "?" + url.Values{"status": []string{status}}.Encode() } var resp transfer.OutgoingTransfersList - if err := a.get(ctx, "/api/v2/transfer/outgoing", q, &resp); err != nil { + if err := a.getBearer(ctx, path, token, &resp); err != nil { return nil, err } return &resp, nil } -// ListCompletedTransfers sends GET /api/v2/transfer/completed?address=. -// The endpoint is unauthenticated; account is used only to derive the query parameter. +// ListCompletedTransfers sends GET /api/v2/transfer/completed as account (caller +// from its bearer token). func (a *APIServerShim) ListCompletedTransfers( ctx context.Context, account *stack.Account, ) (*transfer.CompletedTransfersList, error) { - q := url.Values{"address": []string{account.Address.Hex()}} + token, err := a.ensureToken(ctx, account) + if err != nil { + return nil, err + } var resp transfer.CompletedTransfersList - if err := a.get(ctx, "/api/v2/transfer/completed", q, &resp); err != nil { + if err := a.getBearer(ctx, "/api/v2/transfer/completed", token, &resp); err != nil { return nil, err } return &resp, nil diff --git a/tests/e2e/devstack/stack/interfaces.go b/tests/e2e/devstack/stack/interfaces.go index a602b65f..2f978443 100644 --- a/tests/e2e/devstack/stack/interfaces.go +++ b/tests/e2e/devstack/stack/interfaces.go @@ -201,19 +201,19 @@ type APIServer interface { WithdrawCustodial(ctx context.Context, account *Account, contractID string) (*transfer.ExecuteResponse, error) // ListIncomingTransfers returns pending inbound TransferOffer details for the - // given account via GET /api/v2/transfer/incoming?address=…. The endpoint is - // unauthenticated; account is used only to derive the query parameter. + // given account via GET /api/v2/transfer/incoming. The caller is authenticated + // with account's SIWE-issued bearer token, so it returns only account's data. ListIncomingTransfers(ctx context.Context, account *Account) (*transfer.IncomingTransfersList, error) // ListOutgoingTransfers returns the account's outbound transfers via - // GET /api/v2/transfer/outgoing?address=…&status=…. status filters by - // pending|expired|completed|all (empty string = all). Unauthenticated. + // GET /api/v2/transfer/outgoing[?status=…]. status filters by + // pending|expired|completed|all (empty string = all). Authenticated as account. ListOutgoingTransfers( ctx context.Context, account *Account, status string, ) (*transfer.OutgoingTransfersList, error) // ListCompletedTransfers returns the account's settled transfers across all - // tokens via GET /api/v2/transfer/completed?address=…. Unauthenticated. + // tokens via GET /api/v2/transfer/completed. Authenticated as account. ListCompletedTransfers(ctx context.Context, account *Account) (*transfer.CompletedTransfersList, error) // PrepareAcceptTransfer prepares a non-custodial accept of an inbound offer