Skip to content

Commit 9100c55

Browse files
committed
feat: SIWE login service + auth config
The orchestration layer over pkg/auth/jwt, plus the config type. Not yet wired into the server (inert until the integration change). - pkg/auth/service: login flow (nonce -> SIWE verify -> issue JWT), HTTP handlers, log decorator, consumer interfaces + mockery mocks - pkg/auth/service/nonce_provider: address-keyed in-memory nonce store - pkg/auth/config.go, pkg/auth/types.go: Config + wire DTOs - pkg/config: add optional Auth block; drop the dead JWKS type/field (and its jwks: blocks from the default configs)
1 parent 46a44e6 commit 9100c55

20 files changed

Lines changed: 1581 additions & 23 deletions

config.e2e-local.yaml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,6 @@ eth_rpc:
9494
chain_id: 31337
9595
request_timeout: "30s"
9696

97-
jwks:
98-
url: ""
99-
issuer: ""
100-
10197
monitoring:
10298
enabled: false
10399

pkg/auth/config.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package auth
4+
5+
import "time"
6+
7+
// Config configures Sign-In with Ethereum (EIP-4361) login and the JWTs it issues
8+
// for read endpoints. It is always required; read endpoints are gated by a bearer
9+
// token. The matching public key is published at /.well-known/jwks.json so other
10+
// services (e.g. the indexer) can validate tokens without a shared secret.
11+
type Config struct {
12+
// PrivateKey is the RSA signing key as a base64-encoded PEM (PKCS#1 or PKCS#8).
13+
// Supply it via env substitution — private_key: "${JWT_PRIVATE_KEY}" — where the
14+
// env holds `base64 < key.pem`. It is base64-encoded so the (multi-line) PEM
15+
// survives being expanded into a single YAML scalar.
16+
PrivateKey string `yaml:"private_key" validate:"required"`
17+
// KeyID is the JWKS "kid" advertised for the signing key.
18+
KeyID string `yaml:"kid" validate:"required" default:"default"`
19+
// Issuer is the JWT "iss" claim (and the value validators check).
20+
Issuer string `yaml:"issuer" validate:"required" default:"canton-middleware"`
21+
// Audience is the JWT "aud" claim.
22+
Audience string `yaml:"audience" validate:"required" default:"canton-middleware-api"`
23+
// TokenTTL is how long an issued JWT stays valid.
24+
TokenTTL time.Duration `yaml:"token_ttl" validate:"required,gt=0" default:"30m"`
25+
// NonceTTL is how long a login nonce stays valid before it must be re-fetched.
26+
NonceTTL time.Duration `yaml:"nonce_ttl" validate:"required,gt=0" default:"5m"`
27+
// Domain is the EIP-4361 domain the SIWE message must bind to (e.g. "app.example.com").
28+
Domain string `yaml:"domain" validate:"required"`
29+
// URI is the EIP-4361 uri the SIWE message must bind to (e.g. "https://app.example.com").
30+
URI string `yaml:"uri" validate:"required"`
31+
// ChainID is the EIP-155 chain id the SIWE message must declare.
32+
ChainID int `yaml:"chain_id" validate:"required,gt=0"`
33+
}

pkg/auth/service/http.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package service
4+
5+
import (
6+
"encoding/json"
7+
"io"
8+
"net/http"
9+
"strings"
10+
11+
"github.com/go-chi/chi/v5"
12+
"go.uber.org/zap"
13+
14+
apperrors "github.com/chainsafe/canton-middleware/pkg/app/errors"
15+
apphttp "github.com/chainsafe/canton-middleware/pkg/app/http"
16+
"github.com/chainsafe/canton-middleware/pkg/auth"
17+
)
18+
19+
const maxRequestBodyBytes = 1 << 20 // 1MB
20+
21+
type httpHandler struct {
22+
svc Service
23+
logger *zap.Logger
24+
}
25+
26+
// RegisterRoutes mounts the login and JWKS endpoints on r.
27+
func RegisterRoutes(r chi.Router, svc Service, logger *zap.Logger) {
28+
h := &httpHandler{svc: svc, logger: logger}
29+
30+
r.Get("/auth/nonce", apphttp.HandleError(h.nonce))
31+
r.Post("/auth/login", apphttp.HandleError(h.login))
32+
r.Get("/.well-known/jwks.json", apphttp.HandleError(h.jwks))
33+
34+
logger.Info("SIWE login enabled", zap.String("path", "/auth/login"))
35+
}
36+
37+
// nonce issues a login nonce for the caller's address (GET /auth/nonce?address=).
38+
// The nonce is keyed by address so repeat requests reuse the same live value, which
39+
// stops an unauthenticated caller from churning the store; the address is the one
40+
// the client will put in its SIWE message and is not a secret.
41+
func (h *httpHandler) nonce(w http.ResponseWriter, r *http.Request) error {
42+
address := strings.TrimSpace(r.URL.Query().Get("address"))
43+
if !auth.ValidateEVMAddress(address) {
44+
return apperrors.BadRequestError(nil, "address query parameter must be a 0x-prefixed 40-hex-char EVM address")
45+
}
46+
47+
nonce, err := h.svc.Nonce(auth.NormalizeAddress(address))
48+
if err != nil {
49+
h.logger.Warn("nonce issuance rejected", zap.Error(err))
50+
return apperrors.GeneralError(err)
51+
}
52+
53+
writeJSON(w, http.StatusOK, &auth.NonceResponse{Nonce: nonce})
54+
return nil
55+
}
56+
57+
func (h *httpHandler) login(w http.ResponseWriter, r *http.Request) error {
58+
var req auth.LoginRequest
59+
if err := readJSON(r, &req); err != nil {
60+
return err
61+
}
62+
if req.Message == "" || req.Signature == "" {
63+
return apperrors.BadRequestError(nil, "message and signature are required")
64+
}
65+
66+
res, err := h.svc.Login(r.Context(), req.Message, req.Signature)
67+
if err != nil {
68+
return err
69+
}
70+
71+
writeJSON(w, http.StatusOK, res)
72+
return nil
73+
}
74+
75+
func (h *httpHandler) jwks(w http.ResponseWriter, _ *http.Request) error {
76+
writeJSON(w, http.StatusOK, h.svc.JWKS())
77+
return nil
78+
}
79+
80+
func readJSON(r *http.Request, dst any) error {
81+
dec := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodyBytes))
82+
dec.DisallowUnknownFields()
83+
if err := dec.Decode(dst); err != nil {
84+
return apperrors.BadRequestError(err, "invalid JSON")
85+
}
86+
return nil
87+
}
88+
89+
func writeJSON(w http.ResponseWriter, status int, data any) {
90+
w.Header().Set("Content-Type", "application/json")
91+
w.WriteHeader(status)
92+
_ = json.NewEncoder(w).Encode(data)
93+
}

pkg/auth/service/http_test.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package service
4+
5+
import (
6+
"encoding/json"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
"testing"
11+
12+
"github.com/go-chi/chi/v5"
13+
"github.com/stretchr/testify/mock"
14+
"go.uber.org/zap"
15+
16+
apperrors "github.com/chainsafe/canton-middleware/pkg/app/errors"
17+
"github.com/chainsafe/canton-middleware/pkg/auth"
18+
"github.com/chainsafe/canton-middleware/pkg/auth/jwt"
19+
"github.com/chainsafe/canton-middleware/pkg/auth/service/mocks"
20+
)
21+
22+
func newLoginTestServer(svc Service) http.Handler {
23+
r := chi.NewRouter()
24+
RegisterRoutes(r, svc, zap.NewNop())
25+
return r
26+
}
27+
28+
func TestNonceHTTP_ReturnsNonce(t *testing.T) {
29+
svc := mocks.NewService(t)
30+
svc.EXPECT().Nonce(mock.Anything).Return("the-nonce", nil)
31+
32+
rec := httptest.NewRecorder()
33+
req := httptest.NewRequest(http.MethodGet, "/auth/nonce?address=0x0000000000000000000000000000000000000001", nil)
34+
newLoginTestServer(svc).ServeHTTP(rec, req)
35+
36+
if rec.Code != http.StatusOK {
37+
t.Fatalf("status = %d, want 200", rec.Code)
38+
}
39+
var got auth.NonceResponse
40+
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
41+
t.Fatalf("decode: %v", err)
42+
}
43+
if got.Nonce != "the-nonce" {
44+
t.Fatalf("nonce = %q, want the-nonce", got.Nonce)
45+
}
46+
}
47+
48+
func TestNonceHTTP_MissingOrInvalidAddress_Returns400(t *testing.T) {
49+
svc := mocks.NewService(t) // Nonce must not be called
50+
51+
for _, q := range []string{"/auth/nonce", "/auth/nonce?address=not-an-address"} {
52+
rec := httptest.NewRecorder()
53+
newLoginTestServer(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, q, nil))
54+
if rec.Code != http.StatusBadRequest {
55+
t.Fatalf("%s: status = %d, want 400", q, rec.Code)
56+
}
57+
}
58+
}
59+
60+
func TestLoginHTTP_Success(t *testing.T) {
61+
svc := mocks.NewService(t)
62+
svc.EXPECT().Login(mock.Anything, "msg", "sig").
63+
Return(&auth.LoginResponse{Token: "tok", ExpiresAt: 12345}, nil)
64+
65+
body, _ := json.Marshal(auth.LoginRequest{Message: "msg", Signature: "sig"})
66+
rec := httptest.NewRecorder()
67+
newLoginTestServer(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", strings.NewReader(string(body))))
68+
69+
if rec.Code != http.StatusOK {
70+
t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String())
71+
}
72+
var got auth.LoginResponse
73+
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
74+
t.Fatalf("decode: %v", err)
75+
}
76+
if got.Token != "tok" || got.ExpiresAt != 12345 {
77+
t.Fatalf("response = %+v, want {tok 12345}", got)
78+
}
79+
}
80+
81+
func TestLoginHTTP_InvalidJSON_Returns400(t *testing.T) {
82+
svc := mocks.NewService(t) // Login must not be called
83+
rec := httptest.NewRecorder()
84+
newLoginTestServer(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", strings.NewReader("{not json")))
85+
86+
if rec.Code != http.StatusBadRequest {
87+
t.Fatalf("status = %d, want 400", rec.Code)
88+
}
89+
}
90+
91+
func TestLoginHTTP_MissingFields_Returns400(t *testing.T) {
92+
svc := mocks.NewService(t) // Login must not be called
93+
body, _ := json.Marshal(auth.LoginRequest{Message: "msg"}) // signature missing
94+
rec := httptest.NewRecorder()
95+
newLoginTestServer(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", strings.NewReader(string(body))))
96+
97+
if rec.Code != http.StatusBadRequest {
98+
t.Fatalf("status = %d, want 400", rec.Code)
99+
}
100+
}
101+
102+
func TestLoginHTTP_ServiceUnauthorized_Returns401(t *testing.T) {
103+
svc := mocks.NewService(t)
104+
svc.EXPECT().Login(mock.Anything, "msg", "sig").
105+
Return(nil, apperrors.UnAuthorizedError(nil, "invalid sign-in message or signature"))
106+
107+
body, _ := json.Marshal(auth.LoginRequest{Message: "msg", Signature: "sig"})
108+
rec := httptest.NewRecorder()
109+
newLoginTestServer(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", strings.NewReader(string(body))))
110+
111+
if rec.Code != http.StatusUnauthorized {
112+
t.Fatalf("status = %d, want 401", rec.Code)
113+
}
114+
}
115+
116+
func TestJWKSHTTP_ServesKeySet(t *testing.T) {
117+
svc := mocks.NewService(t)
118+
svc.EXPECT().JWKS().Return(jwt.JWKS{Keys: []jwt.JWK{{Kid: "kid-1", Kty: "RSA"}}})
119+
120+
rec := httptest.NewRecorder()
121+
newLoginTestServer(svc).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/.well-known/jwks.json", nil))
122+
123+
if rec.Code != http.StatusOK {
124+
t.Fatalf("status = %d, want 200", rec.Code)
125+
}
126+
var got jwt.JWKS
127+
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
128+
t.Fatalf("decode: %v", err)
129+
}
130+
if len(got.Keys) != 1 || got.Keys[0].Kid != "kid-1" {
131+
t.Fatalf("jwks = %+v, want one key kid-1", got)
132+
}
133+
}

pkg/auth/service/log.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
package service
4+
5+
import (
6+
"context"
7+
"time"
8+
9+
"go.uber.org/zap"
10+
11+
"github.com/chainsafe/canton-middleware/pkg/auth"
12+
"github.com/chainsafe/canton-middleware/pkg/auth/jwt"
13+
)
14+
15+
const loginServiceName = "LoginService"
16+
17+
// logService wraps Service with logging of the login call. Nonce and JWKS are
18+
// trivial accessors and pass through unlogged; Login is the meaningful operation,
19+
// so its entry/exit, duration, and errors are recorded (never the message or
20+
// signature, which are sensitive).
21+
type logService struct {
22+
svc Service
23+
logger *zap.Logger
24+
}
25+
26+
// NewLog creates a logging decorator for the login Service.
27+
func NewLog(svc Service, logger *zap.Logger) Service {
28+
return &logService{svc: svc, logger: logger}
29+
}
30+
31+
func (ls *logService) Nonce(address string) (string, error) { return ls.svc.Nonce(address) }
32+
33+
func (ls *logService) JWKS() jwt.JWKS { return ls.svc.JWKS() }
34+
35+
func (ls *logService) Login(ctx context.Context, message, signature string) (resp *auth.LoginResponse, err error) {
36+
start := time.Now()
37+
38+
ls.logger.Info("Login started",
39+
zap.String("service", loginServiceName),
40+
zap.String("method", "Login"),
41+
)
42+
43+
defer func() {
44+
duration := time.Since(start)
45+
46+
if err != nil {
47+
ls.logger.Error("Login failed",
48+
zap.String("service", loginServiceName),
49+
zap.String("method", "Login"),
50+
zap.Duration("duration", duration),
51+
zap.Error(err),
52+
)
53+
} else {
54+
ls.logger.Info("Login completed",
55+
zap.String("service", loginServiceName),
56+
zap.String("method", "Login"),
57+
zap.Duration("duration", duration),
58+
zap.Int64("expires_at", resp.ExpiresAt),
59+
)
60+
}
61+
}()
62+
63+
return ls.svc.Login(ctx, message, signature)
64+
}

0 commit comments

Comments
 (0)