diff --git a/config.e2e-local.yaml b/config.e2e-local.yaml index bfbd0d93..c6f780b4 100644 --- a/config.e2e-local.yaml +++ b/config.e2e-local.yaml @@ -94,10 +94,6 @@ eth_rpc: chain_id: 31337 request_timeout: "30s" -jwks: - url: "" - issuer: "" - monitoring: enabled: false diff --git a/pkg/auth/config.go b/pkg/auth/config.go new file mode 100644 index 00000000..4040e7f2 --- /dev/null +++ b/pkg/auth/config.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import "time" + +// Config configures Sign-In with Ethereum (EIP-4361) login and the JWTs it issues +// for read endpoints. It is always required; read endpoints are gated by a bearer +// token. The matching public key is published at /.well-known/jwks.json so other +// services (e.g. the indexer) can validate tokens without a shared secret. +type Config struct { + // PrivateKey is the RSA signing key as a base64-encoded PEM (PKCS#1 or PKCS#8). + // Supply it via env substitution — private_key: "${JWT_PRIVATE_KEY}" — where the + // env holds `base64 < key.pem`. It is base64-encoded so the (multi-line) PEM + // survives being expanded into a single YAML scalar. + PrivateKey string `yaml:"private_key" validate:"required"` + // KeyID is the JWKS "kid" advertised for the signing key. + KeyID string `yaml:"kid" validate:"required" default:"default"` + // Issuer is the JWT "iss" claim (and the value validators check). + Issuer string `yaml:"issuer" validate:"required" default:"canton-middleware"` + // Audience is the JWT "aud" claim. + Audience string `yaml:"audience" validate:"required" default:"canton-middleware-api"` + // TokenTTL is how long an issued JWT stays valid. + TokenTTL time.Duration `yaml:"token_ttl" validate:"required,gt=0" default:"30m"` + // NonceTTL is how long a login nonce stays valid before it must be re-fetched. + NonceTTL time.Duration `yaml:"nonce_ttl" validate:"required,gt=0" default:"5m"` + // Domain is the EIP-4361 domain the SIWE message must bind to (e.g. "app.example.com"). + Domain string `yaml:"domain" validate:"required"` + // URI is the EIP-4361 uri the SIWE message must bind to (e.g. "https://app.example.com"). + URI string `yaml:"uri" validate:"required"` + // ChainID is the EIP-155 chain id the SIWE message must declare. + ChainID int `yaml:"chain_id" validate:"required,gt=0"` +} diff --git a/pkg/auth/service/http.go b/pkg/auth/service/http.go new file mode 100644 index 00000000..4c35b826 --- /dev/null +++ b/pkg/auth/service/http.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 + +package service + +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" +) + +const maxRequestBodyBytes = 1 << 20 // 1MB + +type httpHandler struct { + svc Service + logger *zap.Logger +} + +// RegisterRoutes mounts the login and JWKS endpoints on r. +func RegisterRoutes(r chi.Router, svc Service, logger *zap.Logger) { + h := &httpHandler{svc: svc, logger: logger} + + r.Get("/auth/nonce", apphttp.HandleError(h.nonce)) + r.Post("/auth/login", apphttp.HandleError(h.login)) + r.Get("/.well-known/jwks.json", apphttp.HandleError(h.jwks)) + + logger.Info("SIWE login enabled", zap.String("path", "/auth/login")) +} + +// nonce issues a login nonce for the caller's address (GET /auth/nonce?address=). +// The nonce is keyed by address so repeat requests reuse the same live value, which +// stops an unauthenticated caller from churning the store; the address is the one +// the client will put in its SIWE message and is not a secret. +func (h *httpHandler) nonce(w http.ResponseWriter, r *http.Request) error { + address := strings.TrimSpace(r.URL.Query().Get("address")) + if !auth.ValidateEVMAddress(address) { + return apperrors.BadRequestError(nil, "address query parameter must be a 0x-prefixed 40-hex-char EVM address") + } + + nonce, err := h.svc.Nonce(auth.NormalizeAddress(address)) + if err != nil { + h.logger.Warn("nonce issuance rejected", zap.Error(err)) + return apperrors.GeneralError(err) + } + + writeJSON(w, http.StatusOK, &auth.NonceResponse{Nonce: nonce}) + return nil +} + +func (h *httpHandler) login(w http.ResponseWriter, r *http.Request) error { + var req auth.LoginRequest + if err := readJSON(r, &req); err != nil { + return err + } + if req.Message == "" || req.Signature == "" { + return apperrors.BadRequestError(nil, "message and signature are required") + } + + res, err := h.svc.Login(r.Context(), req.Message, req.Signature) + if err != nil { + return err + } + + writeJSON(w, http.StatusOK, res) + return nil +} + +func (h *httpHandler) jwks(w http.ResponseWriter, _ *http.Request) error { + writeJSON(w, http.StatusOK, h.svc.JWKS()) + return nil +} + +func readJSON(r *http.Request, dst any) error { + dec := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodyBytes)) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + return apperrors.BadRequestError(err, "invalid JSON") + } + return nil +} + +func writeJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} diff --git a/pkg/auth/service/http_test.go b/pkg/auth/service/http_test.go new file mode 100644 index 00000000..a94f4a63 --- /dev/null +++ b/pkg/auth/service/http_test.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/mock" + "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/auth/jwt" + "github.com/chainsafe/canton-middleware/pkg/auth/service/mocks" +) + +func newLoginTestServer(svc Service) http.Handler { + r := chi.NewRouter() + RegisterRoutes(r, svc, zap.NewNop()) + return r +} + +func TestNonceHTTP_ReturnsNonce(t *testing.T) { + svc := mocks.NewService(t) + svc.EXPECT().Nonce(mock.Anything).Return("the-nonce", nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/auth/nonce?address=0x0000000000000000000000000000000000000001", nil) + newLoginTestServer(svc).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var got auth.NonceResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Nonce != "the-nonce" { + t.Fatalf("nonce = %q, want the-nonce", got.Nonce) + } +} + +func TestNonceHTTP_MissingOrInvalidAddress_Returns400(t *testing.T) { + svc := mocks.NewService(t) // Nonce must not be called + + for _, q := range []string{"/auth/nonce", "/auth/nonce?address=not-an-address"} { + rec := httptest.NewRecorder() + newLoginTestServer(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, q, nil)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("%s: status = %d, want 400", q, rec.Code) + } + } +} + +func TestLoginHTTP_Success(t *testing.T) { + svc := mocks.NewService(t) + svc.EXPECT().Login(mock.Anything, "msg", "sig"). + Return(&auth.LoginResponse{Token: "tok", ExpiresAt: 12345}, nil) + + body, _ := json.Marshal(auth.LoginRequest{Message: "msg", Signature: "sig"}) + rec := httptest.NewRecorder() + newLoginTestServer(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", strings.NewReader(string(body)))) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String()) + } + var got auth.LoginResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Token != "tok" || got.ExpiresAt != 12345 { + t.Fatalf("response = %+v, want {tok 12345}", got) + } +} + +func TestLoginHTTP_InvalidJSON_Returns400(t *testing.T) { + svc := mocks.NewService(t) // Login must not be called + rec := httptest.NewRecorder() + newLoginTestServer(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", strings.NewReader("{not json"))) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestLoginHTTP_MissingFields_Returns400(t *testing.T) { + svc := mocks.NewService(t) // Login must not be called + body, _ := json.Marshal(auth.LoginRequest{Message: "msg"}) // signature missing + rec := httptest.NewRecorder() + newLoginTestServer(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", strings.NewReader(string(body)))) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestLoginHTTP_ServiceUnauthorized_Returns401(t *testing.T) { + svc := mocks.NewService(t) + svc.EXPECT().Login(mock.Anything, "msg", "sig"). + Return(nil, apperrors.UnAuthorizedError(nil, "invalid sign-in message or signature")) + + body, _ := json.Marshal(auth.LoginRequest{Message: "msg", Signature: "sig"}) + rec := httptest.NewRecorder() + newLoginTestServer(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", strings.NewReader(string(body)))) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestJWKSHTTP_ServesKeySet(t *testing.T) { + svc := mocks.NewService(t) + svc.EXPECT().JWKS().Return(jwt.JWKS{Keys: []jwt.JWK{{Kid: "kid-1", Kty: "RSA"}}}) + + rec := httptest.NewRecorder() + newLoginTestServer(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/.well-known/jwks.json", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var got jwt.JWKS + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Keys) != 1 || got.Keys[0].Kid != "kid-1" { + t.Fatalf("jwks = %+v, want one key kid-1", got) + } +} diff --git a/pkg/auth/service/log.go b/pkg/auth/service/log.go new file mode 100644 index 00000000..f85d1425 --- /dev/null +++ b/pkg/auth/service/log.go @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "time" + + "go.uber.org/zap" + + "github.com/chainsafe/canton-middleware/pkg/auth" + "github.com/chainsafe/canton-middleware/pkg/auth/jwt" +) + +const loginServiceName = "LoginService" + +// logService wraps Service with logging of the login call. Nonce and JWKS are +// trivial accessors and pass through unlogged; Login is the meaningful operation, +// so its entry/exit, duration, and errors are recorded (never the message or +// signature, which are sensitive). +type logService struct { + svc Service + logger *zap.Logger +} + +// NewLog creates a logging decorator for the login Service. +func NewLog(svc Service, logger *zap.Logger) Service { + return &logService{svc: svc, logger: logger} +} + +func (ls *logService) Nonce(address string) (string, error) { return ls.svc.Nonce(address) } + +func (ls *logService) JWKS() jwt.JWKS { return ls.svc.JWKS() } + +func (ls *logService) Login(ctx context.Context, message, signature string) (resp *auth.LoginResponse, err error) { + start := time.Now() + + ls.logger.Info("Login started", + zap.String("service", loginServiceName), + zap.String("method", "Login"), + ) + + defer func() { + duration := time.Since(start) + + if err != nil { + ls.logger.Error("Login failed", + zap.String("service", loginServiceName), + zap.String("method", "Login"), + zap.Duration("duration", duration), + zap.Error(err), + ) + } else { + fields := []zap.Field{ + zap.String("service", loginServiceName), + zap.String("method", "Login"), + zap.Duration("duration", duration), + } + // resp is non-nil on the nil-error path, but guard defensively: this + // decorator wraps an interface any implementation could satisfy. + if resp != nil { + fields = append(fields, zap.Int64("expires_at", resp.ExpiresAt)) + } + ls.logger.Info("Login completed", fields...) + } + }() + + return ls.svc.Login(ctx, message, signature) +} diff --git a/pkg/auth/service/login.go b/pkg/auth/service/login.go new file mode 100644 index 00000000..ecc8aa7b --- /dev/null +++ b/pkg/auth/service/login.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package service implements the Sign-In with Ethereum (EIP-4361) login flow: it +// issues single-use nonces, verifies signed SIWE messages, and mints the JWTs that +// authenticate read endpoints. The cryptographic primitives (JWT issuer, SIWE +// verifier, JWKS) live in pkg/auth/jwt and the nonce store in +// pkg/auth/service/nonce_provider; this package orchestrates them and binds the +// authenticated address to a registered user's Canton party. +package service + +import ( + "context" + "errors" + "time" + + "github.com/ethereum/go-ethereum/common" + + apperrors "github.com/chainsafe/canton-middleware/pkg/app/errors" + "github.com/chainsafe/canton-middleware/pkg/auth" + "github.com/chainsafe/canton-middleware/pkg/auth/jwt" + "github.com/chainsafe/canton-middleware/pkg/user" +) + +// The login service depends only on the narrow interfaces below, declared at the +// consumer so they can be mocked in isolation. Concrete implementations come from +// pkg/auth/jwt (Verifier, Issuer), pkg/auth/service/nonce_provider (NonceStore), +// and pkg/userstore (UserLookup). + +//go:generate mockery --name Service --output mocks --outpkg mocks --filename mock_service.go --with-expecter +//go:generate mockery --name Verifier --output mocks --outpkg mocks --filename mock_verifier.go --with-expecter +//go:generate mockery --name Issuer --output mocks --outpkg mocks --filename mock_issuer.go --with-expecter +//go:generate mockery --name NonceStore --output mocks --outpkg mocks --filename mock_nonce_store.go --with-expecter +//go:generate mockery --name UserLookup --output mocks --outpkg mocks --filename mock_user_lookup.go --with-expecter + +// UserLookup resolves a registered user by EVM address so login can bind the token +// to the user's Canton party. Satisfied by *userstore.Store. +type UserLookup interface { + GetUserByEVMAddress(ctx context.Context, evmAddress string) (*user.User, error) +} + +// NonceStore issues and consumes single-use login nonces keyed by address. Consume +// must be atomic so a given nonce satisfies at most one login. Implementations live +// under nonce_provider (e.g. nonceprovider.InMemory). +type NonceStore interface { + // Issue returns a nonce for address, valid for the store's TTL. Calling it again + // for an address that still holds a live nonce returns the same value. It may + // return an error if the store is at capacity. + Issue(address string) (string, error) + // Consume returns true exactly once for a live, previously-issued nonce and + // removes it; false for unknown, expired, or already-consumed nonces. + Consume(nonce string) bool +} + +// Verifier validates a signed SIWE login message and returns the recovered EVM +// address and the message nonce. Satisfied by *jwt.SIWEVerifier. +type Verifier interface { + Verify(message, signature string) (common.Address, string, error) +} + +// Issuer mints session JWTs and publishes the signing key set. Satisfied by +// *jwt.Issuer. +type Issuer interface { + Issue(evmAddress, cantonPartyID string) (string, time.Time, error) + JWKS() jwt.JWKS +} + +// Service orchestrates the SIWE login flow. +type Service interface { + // Nonce issues a single-use login nonce for the given EVM address. + Nonce(address string) (string, error) + // JWKS returns the public signing key set for token validation by other services. + JWKS() jwt.JWKS + // Login verifies a signed SIWE message and, if the recovered address belongs to + // a registered user, issues a JWT bound to that user's Canton party. + Login(ctx context.Context, message, signature string) (*auth.LoginResponse, error) +} + +type loginService struct { + verifier Verifier + issuer Issuer + nonces NonceStore + users UserLookup +} + +// New builds a login Service from its collaborators. +func New( + verifier Verifier, + issuer Issuer, + nonces NonceStore, + users UserLookup, +) Service { + return &loginService{ + verifier: verifier, + issuer: issuer, + nonces: nonces, + users: users, + } +} + +func (s *loginService) Nonce(address string) (string, error) { return s.nonces.Issue(address) } + +func (s *loginService) JWKS() jwt.JWKS { return s.issuer.JWKS() } + +func (s *loginService) Login(ctx context.Context, message, signature string) (*auth.LoginResponse, error) { + addr, nonce, err := s.verifier.Verify(message, signature) + if err != nil { + return nil, apperrors.UnAuthorizedError(err, "invalid sign-in message or signature") + } + // Enforce single-use only after the signature checks out, so a bad signature + // cannot burn a victim's outstanding nonce. + if !s.nonces.Consume(nonce) { + return nil, apperrors.UnAuthorizedError(nil, "nonce is unknown, expired, or already used") + } + evmAddress := auth.NormalizeAddress(addr.Hex()) + + usr, err := s.users.GetUserByEVMAddress(ctx, evmAddress) + if err != nil && !errors.Is(err, user.ErrUserNotFound) { + return nil, err + } + if usr == nil { + return nil, apperrors.UnAuthorizedError(nil, "address is not registered") + } + if usr.CantonPartyID == "" { + return nil, apperrors.UnAuthorizedError(nil, "user has no Canton party id") + } + + token, exp, err := s.issuer.Issue(evmAddress, usr.CantonPartyID) + if err != nil { + return nil, apperrors.GeneralError(err) + } + + return &auth.LoginResponse{Token: token, ExpiresAt: exp.Unix()}, nil +} diff --git a/pkg/auth/service/login_test.go b/pkg/auth/service/login_test.go new file mode 100644 index 00000000..b25c988f --- /dev/null +++ b/pkg/auth/service/login_test.go @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 + +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + + apperrors "github.com/chainsafe/canton-middleware/pkg/app/errors" + "github.com/chainsafe/canton-middleware/pkg/auth" + "github.com/chainsafe/canton-middleware/pkg/auth/jwt" + "github.com/chainsafe/canton-middleware/pkg/auth/service/mocks" + "github.com/chainsafe/canton-middleware/pkg/user" +) + +const ( + loginMessage = "localhost wants you to sign in..." + loginSig = "0xsignature" + loginNonce = "nonce0001" +) + +// testAddr is an arbitrary EVM address; loginAddr is its checksummed form, which is +// what the service normalizes to before the store lookup and token issuance. +var ( + testAddr = common.HexToAddress("0x00000000000000000000000000000000000000ff") + loginAddr = auth.NormalizeAddress(testAddr.Hex()) +) + +func newLoginDeps(t *testing.T) (*mocks.Verifier, *mocks.Issuer, *mocks.NonceStore, *mocks.UserLookup) { + t.Helper() + return mocks.NewVerifier(t), mocks.NewIssuer(t), mocks.NewNonceStore(t), mocks.NewUserLookup(t) +} + +func TestLogin_Success(t *testing.T) { + v, iss, n, u := newLoginDeps(t) + exp := time.Unix(1_700_000_000, 0) + + v.EXPECT().Verify(loginMessage, loginSig).Return(testAddr, loginNonce, nil) + n.EXPECT().Consume(loginNonce).Return(true) + u.EXPECT().GetUserByEVMAddress(mock.Anything, loginAddr). + Return(&user.User{EVMAddress: loginAddr, CantonPartyID: "party::abc"}, nil) + iss.EXPECT().Issue(loginAddr, "party::abc").Return("the-token", exp, nil) + + res, err := New(v, iss, n, u).Login(context.Background(), loginMessage, loginSig) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if res.Token != "the-token" { + t.Fatalf("token = %q, want the-token", res.Token) + } + if res.ExpiresAt != exp.Unix() { + t.Fatalf("expires_at = %d, want %d", res.ExpiresAt, exp.Unix()) + } +} + +func TestLogin_InvalidSignature(t *testing.T) { + v, iss, n, u := newLoginDeps(t) + // Verification fails; nothing else must be called — in particular the nonce must + // not be consumed on a bad signature (the mocks fail the test on any extra call). + v.EXPECT().Verify(loginMessage, loginSig).Return(common.Address{}, "", errors.New("bad signature")) + + _, err := New(v, iss, n, u).Login(context.Background(), loginMessage, loginSig) + requireUnauthorized(t, err) +} + +func TestLogin_NonceRejected(t *testing.T) { + v, iss, n, u := newLoginDeps(t) + v.EXPECT().Verify(loginMessage, loginSig).Return(testAddr, loginNonce, nil) + n.EXPECT().Consume(loginNonce).Return(false) // reused / expired / unknown + + _, err := New(v, iss, n, u).Login(context.Background(), loginMessage, loginSig) + requireUnauthorized(t, err) +} + +func TestLogin_UnregisteredAddress(t *testing.T) { + v, iss, n, u := newLoginDeps(t) + v.EXPECT().Verify(loginMessage, loginSig).Return(testAddr, loginNonce, nil) + n.EXPECT().Consume(loginNonce).Return(true) + u.EXPECT().GetUserByEVMAddress(mock.Anything, loginAddr).Return(nil, user.ErrUserNotFound) + + _, err := New(v, iss, n, u).Login(context.Background(), loginMessage, loginSig) + requireUnauthorized(t, err) +} + +func TestLogin_MissingCantonParty(t *testing.T) { + v, iss, n, u := newLoginDeps(t) + v.EXPECT().Verify(loginMessage, loginSig).Return(testAddr, loginNonce, nil) + n.EXPECT().Consume(loginNonce).Return(true) + u.EXPECT().GetUserByEVMAddress(mock.Anything, loginAddr). + Return(&user.User{EVMAddress: loginAddr}, nil) // CantonPartyID empty + + _, err := New(v, iss, n, u).Login(context.Background(), loginMessage, loginSig) + requireUnauthorized(t, err) +} + +func TestLogin_StoreError(t *testing.T) { + v, iss, n, u := newLoginDeps(t) + storeErr := errors.New("db unavailable") + v.EXPECT().Verify(loginMessage, loginSig).Return(testAddr, loginNonce, nil) + n.EXPECT().Consume(loginNonce).Return(true) + u.EXPECT().GetUserByEVMAddress(mock.Anything, loginAddr).Return(nil, storeErr) + + _, err := New(v, iss, n, u).Login(context.Background(), loginMessage, loginSig) + if !errors.Is(err, storeErr) { + t.Fatalf("expected wrapped store error, got %v", err) + } +} + +func TestLogin_IssueError(t *testing.T) { + v, iss, n, u := newLoginDeps(t) + v.EXPECT().Verify(loginMessage, loginSig).Return(testAddr, loginNonce, nil) + n.EXPECT().Consume(loginNonce).Return(true) + u.EXPECT().GetUserByEVMAddress(mock.Anything, loginAddr). + Return(&user.User{CantonPartyID: "party::abc"}, nil) + iss.EXPECT().Issue(loginAddr, "party::abc").Return("", time.Time{}, errors.New("sign failure")) + + _, err := New(v, iss, n, u).Login(context.Background(), loginMessage, loginSig) + if !apperrors.Is(err, apperrors.CategoryGeneralError) { + t.Fatalf("expected CategoryGeneralError, got %v", err) + } +} + +func TestNonce_Delegates(t *testing.T) { + v, iss, n, u := newLoginDeps(t) + n.EXPECT().Issue(loginAddr).Return("fresh-nonce", nil) + + got, err := New(v, iss, n, u).Nonce(loginAddr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "fresh-nonce" { + t.Fatalf("Nonce() = %q, want fresh-nonce", got) + } +} + +func TestJWKS_Delegates(t *testing.T) { + v, iss, n, u := newLoginDeps(t) + want := jwt.JWKS{Keys: []jwt.JWK{{Kid: "kid-1", Kty: "RSA"}}} + iss.EXPECT().JWKS().Return(want) + + got := New(v, iss, n, u).JWKS() + if len(got.Keys) != 1 || got.Keys[0].Kid != "kid-1" { + t.Fatalf("JWKS() = %+v, want %+v", got, want) + } +} + +func requireUnauthorized(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected an error, got nil") + } + if !apperrors.Is(err, apperrors.CategoryUnauthorized) { + t.Fatalf("expected CategoryUnauthorized, got %v", err) + } +} diff --git a/pkg/auth/service/mocks/mock_issuer.go b/pkg/auth/service/mocks/mock_issuer.go new file mode 100644 index 00000000..28f0cf44 --- /dev/null +++ b/pkg/auth/service/mocks/mock_issuer.go @@ -0,0 +1,146 @@ +// Code generated by mockery v2.53.6. DO NOT EDIT. + +package mocks + +import ( + jwt "github.com/chainsafe/canton-middleware/pkg/auth/jwt" + mock "github.com/stretchr/testify/mock" + + time "time" +) + +// Issuer is an autogenerated mock type for the Issuer type +type Issuer struct { + mock.Mock +} + +type Issuer_Expecter struct { + mock *mock.Mock +} + +func (_m *Issuer) EXPECT() *Issuer_Expecter { + return &Issuer_Expecter{mock: &_m.Mock} +} + +// Issue provides a mock function with given fields: evmAddress, cantonPartyID +func (_m *Issuer) Issue(evmAddress string, cantonPartyID string) (string, time.Time, error) { + ret := _m.Called(evmAddress, cantonPartyID) + + if len(ret) == 0 { + panic("no return value specified for Issue") + } + + var r0 string + var r1 time.Time + var r2 error + if rf, ok := ret.Get(0).(func(string, string) (string, time.Time, error)); ok { + return rf(evmAddress, cantonPartyID) + } + if rf, ok := ret.Get(0).(func(string, string) string); ok { + r0 = rf(evmAddress, cantonPartyID) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string, string) time.Time); ok { + r1 = rf(evmAddress, cantonPartyID) + } else { + r1 = ret.Get(1).(time.Time) + } + + if rf, ok := ret.Get(2).(func(string, string) error); ok { + r2 = rf(evmAddress, cantonPartyID) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// Issuer_Issue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Issue' +type Issuer_Issue_Call struct { + *mock.Call +} + +// Issue is a helper method to define mock.On call +// - evmAddress string +// - cantonPartyID string +func (_e *Issuer_Expecter) Issue(evmAddress interface{}, cantonPartyID interface{}) *Issuer_Issue_Call { + return &Issuer_Issue_Call{Call: _e.mock.On("Issue", evmAddress, cantonPartyID)} +} + +func (_c *Issuer_Issue_Call) Run(run func(evmAddress string, cantonPartyID string)) *Issuer_Issue_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string)) + }) + return _c +} + +func (_c *Issuer_Issue_Call) Return(_a0 string, _a1 time.Time, _a2 error) *Issuer_Issue_Call { + _c.Call.Return(_a0, _a1, _a2) + return _c +} + +func (_c *Issuer_Issue_Call) RunAndReturn(run func(string, string) (string, time.Time, error)) *Issuer_Issue_Call { + _c.Call.Return(run) + return _c +} + +// JWKS provides a mock function with no fields +func (_m *Issuer) JWKS() jwt.JWKS { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for JWKS") + } + + var r0 jwt.JWKS + if rf, ok := ret.Get(0).(func() jwt.JWKS); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(jwt.JWKS) + } + + return r0 +} + +// Issuer_JWKS_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'JWKS' +type Issuer_JWKS_Call struct { + *mock.Call +} + +// JWKS is a helper method to define mock.On call +func (_e *Issuer_Expecter) JWKS() *Issuer_JWKS_Call { + return &Issuer_JWKS_Call{Call: _e.mock.On("JWKS")} +} + +func (_c *Issuer_JWKS_Call) Run(run func()) *Issuer_JWKS_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Issuer_JWKS_Call) Return(_a0 jwt.JWKS) *Issuer_JWKS_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Issuer_JWKS_Call) RunAndReturn(run func() jwt.JWKS) *Issuer_JWKS_Call { + _c.Call.Return(run) + return _c +} + +// NewIssuer creates a new instance of Issuer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewIssuer(t interface { + mock.TestingT + Cleanup(func()) +}) *Issuer { + mock := &Issuer{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/auth/service/mocks/mock_nonce_store.go b/pkg/auth/service/mocks/mock_nonce_store.go new file mode 100644 index 00000000..efc819b6 --- /dev/null +++ b/pkg/auth/service/mocks/mock_nonce_store.go @@ -0,0 +1,134 @@ +// Code generated by mockery v2.53.6. DO NOT EDIT. + +package mocks + +import mock "github.com/stretchr/testify/mock" + +// NonceStore is an autogenerated mock type for the NonceStore type +type NonceStore struct { + mock.Mock +} + +type NonceStore_Expecter struct { + mock *mock.Mock +} + +func (_m *NonceStore) EXPECT() *NonceStore_Expecter { + return &NonceStore_Expecter{mock: &_m.Mock} +} + +// Consume provides a mock function with given fields: nonce +func (_m *NonceStore) Consume(nonce string) bool { + ret := _m.Called(nonce) + + if len(ret) == 0 { + panic("no return value specified for Consume") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(string) bool); ok { + r0 = rf(nonce) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// NonceStore_Consume_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Consume' +type NonceStore_Consume_Call struct { + *mock.Call +} + +// Consume is a helper method to define mock.On call +// - nonce string +func (_e *NonceStore_Expecter) Consume(nonce interface{}) *NonceStore_Consume_Call { + return &NonceStore_Consume_Call{Call: _e.mock.On("Consume", nonce)} +} + +func (_c *NonceStore_Consume_Call) Run(run func(nonce string)) *NonceStore_Consume_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *NonceStore_Consume_Call) Return(_a0 bool) *NonceStore_Consume_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *NonceStore_Consume_Call) RunAndReturn(run func(string) bool) *NonceStore_Consume_Call { + _c.Call.Return(run) + return _c +} + +// Issue provides a mock function with given fields: address +func (_m *NonceStore) Issue(address string) (string, error) { + ret := _m.Called(address) + + if len(ret) == 0 { + panic("no return value specified for Issue") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string) (string, error)); ok { + return rf(address) + } + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(address) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(address) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NonceStore_Issue_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Issue' +type NonceStore_Issue_Call struct { + *mock.Call +} + +// Issue is a helper method to define mock.On call +// - address string +func (_e *NonceStore_Expecter) Issue(address interface{}) *NonceStore_Issue_Call { + return &NonceStore_Issue_Call{Call: _e.mock.On("Issue", address)} +} + +func (_c *NonceStore_Issue_Call) Run(run func(address string)) *NonceStore_Issue_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *NonceStore_Issue_Call) Return(_a0 string, _a1 error) *NonceStore_Issue_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *NonceStore_Issue_Call) RunAndReturn(run func(string) (string, error)) *NonceStore_Issue_Call { + _c.Call.Return(run) + return _c +} + +// NewNonceStore creates a new instance of NonceStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewNonceStore(t interface { + mock.TestingT + Cleanup(func()) +}) *NonceStore { + mock := &NonceStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/auth/service/mocks/mock_service.go b/pkg/auth/service/mocks/mock_service.go new file mode 100644 index 00000000..06bbe72c --- /dev/null +++ b/pkg/auth/service/mocks/mock_service.go @@ -0,0 +1,201 @@ +// Code generated by mockery v2.53.6. DO NOT EDIT. + +package mocks + +import ( + context "context" + + auth "github.com/chainsafe/canton-middleware/pkg/auth" + + jwt "github.com/chainsafe/canton-middleware/pkg/auth/jwt" + + mock "github.com/stretchr/testify/mock" +) + +// Service is an autogenerated mock type for the Service type +type Service struct { + mock.Mock +} + +type Service_Expecter struct { + mock *mock.Mock +} + +func (_m *Service) EXPECT() *Service_Expecter { + return &Service_Expecter{mock: &_m.Mock} +} + +// JWKS provides a mock function with no fields +func (_m *Service) JWKS() jwt.JWKS { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for JWKS") + } + + var r0 jwt.JWKS + if rf, ok := ret.Get(0).(func() jwt.JWKS); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(jwt.JWKS) + } + + return r0 +} + +// Service_JWKS_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'JWKS' +type Service_JWKS_Call struct { + *mock.Call +} + +// JWKS is a helper method to define mock.On call +func (_e *Service_Expecter) JWKS() *Service_JWKS_Call { + return &Service_JWKS_Call{Call: _e.mock.On("JWKS")} +} + +func (_c *Service_JWKS_Call) Run(run func()) *Service_JWKS_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *Service_JWKS_Call) Return(_a0 jwt.JWKS) *Service_JWKS_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Service_JWKS_Call) RunAndReturn(run func() jwt.JWKS) *Service_JWKS_Call { + _c.Call.Return(run) + return _c +} + +// Login provides a mock function with given fields: ctx, message, signature +func (_m *Service) Login(ctx context.Context, message string, signature string) (*auth.LoginResponse, error) { + ret := _m.Called(ctx, message, signature) + + if len(ret) == 0 { + panic("no return value specified for Login") + } + + var r0 *auth.LoginResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (*auth.LoginResponse, error)); ok { + return rf(ctx, message, signature) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) *auth.LoginResponse); ok { + r0 = rf(ctx, message, signature) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*auth.LoginResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, message, signature) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Service_Login_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Login' +type Service_Login_Call struct { + *mock.Call +} + +// Login is a helper method to define mock.On call +// - ctx context.Context +// - message string +// - signature string +func (_e *Service_Expecter) Login(ctx interface{}, message interface{}, signature interface{}) *Service_Login_Call { + return &Service_Login_Call{Call: _e.mock.On("Login", ctx, message, signature)} +} + +func (_c *Service_Login_Call) Run(run func(ctx context.Context, message string, signature string)) *Service_Login_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *Service_Login_Call) Return(_a0 *auth.LoginResponse, _a1 error) *Service_Login_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Service_Login_Call) RunAndReturn(run func(context.Context, string, string) (*auth.LoginResponse, error)) *Service_Login_Call { + _c.Call.Return(run) + return _c +} + +// Nonce provides a mock function with given fields: address +func (_m *Service) Nonce(address string) (string, error) { + ret := _m.Called(address) + + if len(ret) == 0 { + panic("no return value specified for Nonce") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string) (string, error)); ok { + return rf(address) + } + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(address) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(address) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Service_Nonce_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Nonce' +type Service_Nonce_Call struct { + *mock.Call +} + +// Nonce is a helper method to define mock.On call +// - address string +func (_e *Service_Expecter) Nonce(address interface{}) *Service_Nonce_Call { + return &Service_Nonce_Call{Call: _e.mock.On("Nonce", address)} +} + +func (_c *Service_Nonce_Call) Run(run func(address string)) *Service_Nonce_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Service_Nonce_Call) Return(_a0 string, _a1 error) *Service_Nonce_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Service_Nonce_Call) RunAndReturn(run func(string) (string, error)) *Service_Nonce_Call { + _c.Call.Return(run) + return _c +} + +// NewService creates a new instance of Service. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewService(t interface { + mock.TestingT + Cleanup(func()) +}) *Service { + mock := &Service{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/auth/service/mocks/mock_user_lookup.go b/pkg/auth/service/mocks/mock_user_lookup.go new file mode 100644 index 00000000..f8833db4 --- /dev/null +++ b/pkg/auth/service/mocks/mock_user_lookup.go @@ -0,0 +1,97 @@ +// Code generated by mockery v2.53.6. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + user "github.com/chainsafe/canton-middleware/pkg/user" +) + +// UserLookup is an autogenerated mock type for the UserLookup type +type UserLookup struct { + mock.Mock +} + +type UserLookup_Expecter struct { + mock *mock.Mock +} + +func (_m *UserLookup) EXPECT() *UserLookup_Expecter { + return &UserLookup_Expecter{mock: &_m.Mock} +} + +// GetUserByEVMAddress provides a mock function with given fields: ctx, evmAddress +func (_m *UserLookup) GetUserByEVMAddress(ctx context.Context, evmAddress string) (*user.User, error) { + ret := _m.Called(ctx, evmAddress) + + if len(ret) == 0 { + panic("no return value specified for GetUserByEVMAddress") + } + + var r0 *user.User + var r1 error + 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) *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) error); ok { + r1 = rf(ctx, evmAddress) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// UserLookup_GetUserByEVMAddress_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserByEVMAddress' +type UserLookup_GetUserByEVMAddress_Call struct { + *mock.Call +} + +// GetUserByEVMAddress is a helper method to define mock.On call +// - ctx context.Context +// - evmAddress string +func (_e *UserLookup_Expecter) GetUserByEVMAddress(ctx interface{}, evmAddress interface{}) *UserLookup_GetUserByEVMAddress_Call { + return &UserLookup_GetUserByEVMAddress_Call{Call: _e.mock.On("GetUserByEVMAddress", ctx, evmAddress)} +} + +func (_c *UserLookup_GetUserByEVMAddress_Call) Run(run func(ctx context.Context, evmAddress string)) *UserLookup_GetUserByEVMAddress_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *UserLookup_GetUserByEVMAddress_Call) Return(_a0 *user.User, _a1 error) *UserLookup_GetUserByEVMAddress_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *UserLookup_GetUserByEVMAddress_Call) RunAndReturn(run func(context.Context, string) (*user.User, error)) *UserLookup_GetUserByEVMAddress_Call { + _c.Call.Return(run) + return _c +} + +// NewUserLookup creates a new instance of UserLookup. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewUserLookup(t interface { + mock.TestingT + Cleanup(func()) +}) *UserLookup { + mock := &UserLookup{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/auth/service/mocks/mock_verifier.go b/pkg/auth/service/mocks/mock_verifier.go new file mode 100644 index 00000000..47bc442a --- /dev/null +++ b/pkg/auth/service/mocks/mock_verifier.go @@ -0,0 +1,101 @@ +// Code generated by mockery v2.53.6. DO NOT EDIT. + +package mocks + +import ( + common "github.com/ethereum/go-ethereum/common" + mock "github.com/stretchr/testify/mock" +) + +// Verifier is an autogenerated mock type for the Verifier type +type Verifier struct { + mock.Mock +} + +type Verifier_Expecter struct { + mock *mock.Mock +} + +func (_m *Verifier) EXPECT() *Verifier_Expecter { + return &Verifier_Expecter{mock: &_m.Mock} +} + +// Verify provides a mock function with given fields: message, signature +func (_m *Verifier) Verify(message string, signature string) (common.Address, string, error) { + ret := _m.Called(message, signature) + + if len(ret) == 0 { + panic("no return value specified for Verify") + } + + var r0 common.Address + var r1 string + var r2 error + if rf, ok := ret.Get(0).(func(string, string) (common.Address, string, error)); ok { + return rf(message, signature) + } + if rf, ok := ret.Get(0).(func(string, string) common.Address); ok { + r0 = rf(message, signature) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(common.Address) + } + } + + if rf, ok := ret.Get(1).(func(string, string) string); ok { + r1 = rf(message, signature) + } else { + r1 = ret.Get(1).(string) + } + + if rf, ok := ret.Get(2).(func(string, string) error); ok { + r2 = rf(message, signature) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// Verifier_Verify_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Verify' +type Verifier_Verify_Call struct { + *mock.Call +} + +// Verify is a helper method to define mock.On call +// - message string +// - signature string +func (_e *Verifier_Expecter) Verify(message interface{}, signature interface{}) *Verifier_Verify_Call { + return &Verifier_Verify_Call{Call: _e.mock.On("Verify", message, signature)} +} + +func (_c *Verifier_Verify_Call) Run(run func(message string, signature string)) *Verifier_Verify_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string), args[1].(string)) + }) + return _c +} + +func (_c *Verifier_Verify_Call) Return(_a0 common.Address, _a1 string, _a2 error) *Verifier_Verify_Call { + _c.Call.Return(_a0, _a1, _a2) + return _c +} + +func (_c *Verifier_Verify_Call) RunAndReturn(run func(string, string) (common.Address, string, error)) *Verifier_Verify_Call { + _c.Call.Return(run) + return _c +} + +// NewVerifier creates a new instance of Verifier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewVerifier(t interface { + mock.TestingT + Cleanup(func()) +}) *Verifier { + mock := &Verifier{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/auth/service/nonce_provider/in_memory.go b/pkg/auth/service/nonce_provider/in_memory.go new file mode 100644 index 00000000..4be55dc5 --- /dev/null +++ b/pkg/auth/service/nonce_provider/in_memory.go @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package nonceprovider contains implementations of the login nonce store. +package nonceprovider + +import ( + "errors" + "sync" + "time" + + siwe "github.com/spruceid/siwe-go" +) + +// maxAddresses caps how many distinct addresses can hold a live nonce at once. +// GET /auth/nonce is unauthenticated, so without a bound an attacker could flood +// the store and exhaust memory. Because each address maps to at most one live +// nonce (repeat requests reuse it), the map only grows with distinct addresses, +// and when the cap is hit the store rejects new issuance rather than evicting a +// live nonce — so an ongoing flood can never invalidate a nonce a real user is +// mid-login with. The cap is far above any legitimate concurrent-login volume. +const maxAddresses = 1 << 16 // 65536 + +// ErrStoreFull is returned by Issue when the store is at capacity and no expired +// entries can be reclaimed. It signals transient overload, not a client error. +var ErrStoreFull = errors.New("nonce store is at capacity") + +type entry struct { + nonce string + expiry time.Time +} + +// InMemory is an in-process nonce store suitable for a single api-server replica. +// Nonces are keyed by the requesting address so a given address holds at most one +// live nonce. Nonces are short-lived, so losing them on restart is harmless; run +// more than one replica and this must be replaced with a shared (e.g. Postgres) store. +type InMemory struct { + mu sync.Mutex + byAddr map[string]entry // address -> its live nonce + expiry + byNonce map[string]string // nonce -> address (reverse index for Consume) + ttl time.Duration + max int + nextSweep time.Time + now func() time.Time +} + +// NewInMemory creates an InMemory nonce store whose nonces expire after ttl. +func NewInMemory(ttl time.Duration) *InMemory { + return &InMemory{ + byAddr: make(map[string]entry), + byNonce: make(map[string]string), + ttl: ttl, + max: maxAddresses, + now: time.Now, + } +} + +// Issue returns a nonce for address. If the address already holds a live nonce it +// is returned unchanged, so repeat requests (retries, an attacker replaying the +// same address) do not grow the store. Otherwise a fresh nonce is minted. It +// purges expired entries on a fixed cadence and, when the store is full of live +// entries, returns ErrStoreFull instead of evicting another address's live nonce. +func (s *InMemory) Issue(address string) (string, error) { + now := s.now() + + s.mu.Lock() + defer s.mu.Unlock() + + if now.After(s.nextSweep) { + s.purgeExpired(now) + s.nextSweep = now.Add(s.ttl) + } + + // Reuse the address's current nonce while it is still valid. + if e, ok := s.byAddr[address]; ok && now.Before(e.expiry) { + return e.nonce, nil + } + + // No live nonce for this address: we are about to add (or replace) an entry. + // If we would grow past the cap, try reclaiming expired entries first, then + // refuse — never drop another address's live nonce. + if _, exists := s.byAddr[address]; !exists && len(s.byAddr) >= s.max { + s.purgeExpired(now) + if len(s.byAddr) >= s.max { + return "", ErrStoreFull + } + } + + // Drop any stale nonce this address held so its reverse index does not leak. + if old, ok := s.byAddr[address]; ok { + delete(s.byNonce, old.nonce) + } + + nonce := siwe.GenerateNonce() + s.byAddr[address] = entry{nonce: nonce, expiry: now.Add(s.ttl)} + s.byNonce[nonce] = address + return nonce, nil +} + +// Consume removes the nonce and reports whether it was live at the moment of use. +func (s *InMemory) Consume(nonce string) bool { + now := s.now() + + s.mu.Lock() + defer s.mu.Unlock() + + address, ok := s.byNonce[nonce] + if !ok { + return false + } + e := s.byAddr[address] + delete(s.byNonce, nonce) + delete(s.byAddr, address) + return now.Before(e.expiry) +} + +// purgeExpired removes all entries whose expiry has passed. Callers must hold s.mu. +// The condition mirrors Issue/Consume (live iff now.Before(expiry)) so an entry at +// exactly its expiry instant is treated as expired here too. +func (s *InMemory) purgeExpired(now time.Time) { + for address, e := range s.byAddr { + if !now.Before(e.expiry) { + delete(s.byAddr, address) + delete(s.byNonce, e.nonce) + } + } +} diff --git a/pkg/auth/service/nonce_provider/in_memory_test.go b/pkg/auth/service/nonce_provider/in_memory_test.go new file mode 100644 index 00000000..256934a2 --- /dev/null +++ b/pkg/auth/service/nonce_provider/in_memory_test.go @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 + +package nonceprovider + +import ( + "errors" + "strconv" + "testing" + "time" +) + +func TestInMemory_SingleUse(t *testing.T) { + store := NewInMemory(time.Minute) + nonce, err := store.Issue("0xabc") + if err != nil { + t.Fatalf("issue: %v", err) + } + + if !store.Consume(nonce) { + t.Fatal("first consume should succeed") + } + if store.Consume(nonce) { + t.Fatal("second consume of same nonce must fail") + } + if store.Consume("never-issued") { + t.Fatal("consuming an unknown nonce must fail") + } +} + +func TestInMemory_ReusesLiveNoncePerAddress(t *testing.T) { + store := NewInMemory(time.Minute) + + first, err := store.Issue("0xabc") + if err != nil { + t.Fatalf("issue: %v", err) + } + second, err := store.Issue("0xabc") + if err != nil { + t.Fatalf("re-issue: %v", err) + } + if first != second { + t.Fatalf("expected the same live nonce to be reused, got %q then %q", first, second) + } + + // A different address gets its own nonce. + other, _ := store.Issue("0xdef") + if other == first { + t.Fatal("different addresses must get different nonces") + } +} + +func TestInMemory_Expiry(t *testing.T) { + store := NewInMemory(time.Minute) + base := time.Unix(1_700_000_000, 0) + store.now = func() time.Time { return base } + + nonce, _ := store.Issue("0xabc") + + // Advance past the TTL before consuming. + store.now = func() time.Time { return base.Add(2 * time.Minute) } + if store.Consume(nonce) { + t.Fatal("expired nonce must not be consumable") + } +} + +func TestInMemory_RejectsWhenFull_WithoutEvictingLive(t *testing.T) { + store := NewInMemory(time.Hour) // long TTL so nothing expires during the test + store.max = 4 + + issued := make([]string, 0, store.max) + for i := 0; i < store.max; i++ { + n, err := store.Issue("addr-" + strconv.Itoa(i)) + if err != nil { + t.Fatalf("issue %d: %v", i, err) + } + issued = append(issued, n) + } + + // A new distinct address is rejected — never at the cost of a live nonce. + if _, err := store.Issue("addr-overflow"); !errors.Is(err, ErrStoreFull) { + t.Fatalf("expected ErrStoreFull, got %v", err) + } + + // Every previously-issued nonce is still valid (none were evicted). + for i, n := range issued { + if !store.Consume(n) { + t.Fatalf("nonce %d was evicted but should have survived", i) + } + } +} + +func TestInMemory_ReclaimsExpiredBeforeRejecting(t *testing.T) { + store := NewInMemory(time.Minute) + base := time.Unix(1_700_000_000, 0) + store.now = func() time.Time { return base } + store.max = 2 + + _, _ = store.Issue("a") + _, _ = store.Issue("b") // now full + + // After the TTL, those entries are reclaimable, so a new address succeeds. + store.now = func() time.Time { return base.Add(2 * time.Minute) } + if _, err := store.Issue("c"); err != nil { + t.Fatalf("expected expired entries to be reclaimed, got %v", err) + } +} diff --git a/pkg/auth/types.go b/pkg/auth/types.go new file mode 100644 index 00000000..8c17b356 --- /dev/null +++ b/pkg/auth/types.go @@ -0,0 +1,15 @@ +package auth + +type NonceResponse struct { + Nonce string `json:"nonce"` +} + +type LoginRequest struct { + Message string `json:"message"` + Signature string `json:"signature"` +} + +type LoginResponse struct { + Token string `json:"token"` + ExpiresAt int64 `json:"expires_at"` // unix seconds +} diff --git a/pkg/config/config.go b/pkg/config/config.go index a861adad..8dc285fd 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/chainsafe/canton-middleware/pkg/app/http" + "github.com/chainsafe/canton-middleware/pkg/auth" canton "github.com/chainsafe/canton-middleware/pkg/cantonsdk/client" "github.com/chainsafe/canton-middleware/pkg/cantonsdk/ledger" "github.com/chainsafe/canton-middleware/pkg/custodial" @@ -74,7 +75,7 @@ type APIServer struct { Token *token.Config `yaml:"token" validate:"required"` TokenProvider *TokenProviderConfig `yaml:"token_provider" default:"-"` // omit → defaults to canton mode EthRPC *ethrpc.Config `yaml:"eth_rpc" validate:"required"` - JWKS *JWKS `yaml:"jwks" default:"-"` // nil by default (feature disabled) + Auth *auth.Config `yaml:"auth" default:"-"` // nil disables read-endpoint auth (SIWE login + JWT) AcceptWorker *custodial.AcceptWorkerConfig `yaml:"accept_worker" default:"-"` // nil disables the worker Monitoring *Monitoring `yaml:"monitoring" validate:"required"` Logging *log.Config `yaml:"logging" validate:"required"` @@ -114,12 +115,6 @@ type Monitoring struct { HealthCheckURL string `yaml:"health_check_url" default:"/health"` } -// JWKS contains JWKS configuration for JWT validation -type JWKS struct { - URL string `yaml:"url" default:""` - Issuer string `yaml:"issuer" default:""` -} - // KeyManagement contains settings for custodial Canton key management type KeyManagement struct { // MasterKeyEnv is the environment variable name containing the master encryption key (base64) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 4fa82b10..b424b9e5 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -143,8 +143,8 @@ func TestLoadAPIServer_AppliesDefaults(t *testing.T) { t.Fatalf("key_management.key_derivation default mismatch: got %q", cfg.KeyManagement.KeyDerivation) } - if cfg.JWKS != nil { - t.Fatal("jwks should default to nil when omitted") + if cfg.Auth != nil { + t.Fatal("auth should default to nil when omitted") } if cfg.Canton.Bridge != nil { t.Fatal("canton.bridge should default to nil when omitted") @@ -422,4 +422,5 @@ func setDefaultConfigEnv(t *testing.T) { t.Setenv("CANTON_USDCX_ISSUER_PARTY", "usdcx-issuer::1220test") t.Setenv("CANTON_USDCX_REGISTRY_URL", "http://usdcx-registry:8090") t.Setenv("ADMIN_API_KEY", "test-admin-key") + t.Setenv("JWT_PRIVATE_KEY", "dummy-base64-key") } diff --git a/pkg/config/defaults/config.api-server.docker.yaml b/pkg/config/defaults/config.api-server.docker.yaml index b5f9dffa..9ee928e0 100644 --- a/pkg/config/defaults/config.api-server.docker.yaml +++ b/pkg/config/defaults/config.api-server.docker.yaml @@ -99,10 +99,6 @@ eth_rpc: request_timeout: "30s" # JWKS endpoint for JWT validation (optional - if not using EVM signatures) -jwks: - url: "" - issuer: "" - 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 0512eaed..3a2c06c7 100644 --- a/pkg/config/defaults/config.api-server.local-devnet.yaml +++ b/pkg/config/defaults/config.api-server.local-devnet.yaml @@ -91,10 +91,6 @@ eth_rpc: chain_id: 1155111101 # Custom: Sepolia (11155111) + 01 suffix for Canton local request_timeout: "60s" -jwks: - url: "" - issuer: "" - 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 f8413487..74613802 100644 --- a/pkg/config/defaults/config.api-server.mainnet.yaml +++ b/pkg/config/defaults/config.api-server.mainnet.yaml @@ -73,10 +73,6 @@ eth_rpc: chain_id: 1337 request_timeout: "60s" -jwks: - url: "" - issuer: "" - monitoring: enabled: true server: