Skip to content

Authenticate read APIs (SIWE + JWT)#351

Closed
sadiq1971 wants to merge 3 commits into
feat/outbound-transfer-authzfrom
feat/read-api-auth
Closed

Authenticate read APIs (SIWE + JWT)#351
sadiq1971 wants to merge 3 commits into
feat/outbound-transfer-authzfrom
feat/read-api-auth

Conversation

@sadiq1971

@sadiq1971 sadiq1971 commented Jul 9, 2026

Copy link
Copy Markdown
Member

Closes #341.

Read data (transfer offers/history, profile) was served publicly by ?address=. This adds optional authentication for the read path; write endpoints keep the existing EIP-191 signature path unchanged.

What it does

  • SIWE login → JWT. GET /auth/nonce?address= → sign an EIP-4361 message → POST /auth/login returns a short-lived RS256 JWT. GET /.well-known/jwks.json publishes the public key so other services (e.g. the indexer) verify tokens without a shared secret. Token claims: sub + explicit canton_party_id, evm_address.
  • Guarded reads. /api/v2/transfer/{incoming,outgoing,completed} and /profile resolve the caller from the bearer token — a caller can only read their own data.
  • Works for all users. Custodial and non-custodial users both have an EVM address and Canton party, so login and token issuance cover both.
  • Nonce store. In-memory, keyed by address: a live nonce is reused per address, and the store rejects new entries when full rather than evicting a live nonce (an unauthenticated /auth/nonce flood can't invalidate an in-flight login).

The e2e suite authenticates for real: APIServerShim.Login runs the nonce → EIP-4361 sign → /auth/login flow and attaches the JWT as a bearer token on the transfer read calls.

Optional by design

Auth is configured via an auth: block (see pkg/auth/config.go). When the block is absent it is disabled: the middleware is a passthrough and the read endpoints fall back to ?address= (the pre-existing behaviour). Devnet/mainnet and the docker/e2e stack ship with it enabled; a config without the block runs unauthenticated. The RSA signing key is supplied as a base64-encoded PEM via ${JWT_PRIVATE_KEY} (the Makefile generates one for the e2e run).

Why no refresh token

Deliberately omitted. Short-lived access tokens with no refresh token are the simplest secure-revocation story: there is no long-lived credential to steal or to build rotation/reuse-detection around, and a leaked JWT is useless after token_ttl. When a token expires the client re-runs SIWE login (silent for custodial users; one wallet prompt for non-custodial). If seamless non-custodial sessions later require it, token_ttl is already a config knob to widen first, and a refresh token can be added without changing this design.

Stacked on #348 (outbound transfer authorization); will retarget to main once that merges.

Add optional Sign-In with Ethereum (EIP-4361) login that issues short-lived
RS256 JWTs, and gate the transfer read endpoints and /profile behind them so a
caller can only read their own data.

- pkg/auth/jwt: Issuer, Validator, SIWEVerifier, JWKS, bearer middleware, key parsing
- pkg/auth/service: login orchestration (consumer interfaces + mockery), handlers, log decorator
- pkg/auth/service/nonce_provider: address-keyed in-memory nonce store (reuse per address, reject-when-full)
- Read endpoints resolve the caller from the bearer token when auth is enabled,
  else fall back to ?address= (auth is optional; nil config disables it)
- Write endpoints keep the existing EIP-191 signature path unchanged
@codecov-commenter

codecov-commenter commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 72.34568% with 112 lines in your changes missing coverage. Please review.
✅ Project coverage is 33.74%. Comparing base (a7c4655) to head (d55d82f).

Files with missing lines Patch % Lines
pkg/app/api/server.go 0.00% 34 Missing ⚠️
pkg/auth/service/log.go 0.00% 27 Missing ⚠️
pkg/auth/jwt/validator.go 73.33% 10 Missing and 10 partials ⚠️
pkg/auth/jwt/middleware.go 77.77% 6 Missing and 2 partials ⚠️
pkg/transfer/http.go 66.66% 8 Missing ⚠️
pkg/auth/service/http.go 92.50% 2 Missing and 1 partial ⚠️
pkg/auth/jwt/issuer.go 93.54% 1 Missing and 1 partial ⚠️
pkg/auth/jwt/jwks.go 90.47% 1 Missing and 1 partial ⚠️
pkg/auth/jwt/keys.go 87.50% 1 Missing and 1 partial ⚠️
pkg/auth/jwt/verifier.go 90.47% 1 Missing and 1 partial ⚠️
... and 2 more
Additional details and impacted files

Impacted file tree graph

@@                       Coverage Diff                        @@
##           feat/outbound-transfer-authz     #351      +/-   ##
================================================================
+ Coverage                         32.09%   33.74%   +1.65%     
================================================================
  Files                               154      163       +9     
  Lines                             12270    12540     +270     
================================================================
+ Hits                               3938     4232     +294     
+ Misses                             8016     7970      -46     
- Partials                            316      338      +22     
Flag Coverage Δ
unittests 33.74% <72.34%> (+1.65%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
pkg/auth/service/login.go 100.00% <100.00%> (ø)
pkg/config/config.go 71.18% <ø> (ø)
pkg/user/service/http.go 56.75% <100.00%> (-0.58%) ⬇️
pkg/user/service/service.go 20.49% <100.00%> (-2.83%) ⬇️
pkg/auth/jwt/issuer.go 93.54% <93.54%> (ø)
pkg/auth/jwt/jwks.go 90.47% <90.47%> (ø)
pkg/auth/jwt/keys.go 87.50% <87.50%> (ø)
pkg/auth/jwt/verifier.go 90.47% <90.47%> (ø)
pkg/auth/service/nonce_provider/in_memory.go 95.23% <95.23%> (ø)
pkg/user/service/log.go 0.00% <0.00%> (ø)
... and 6 more

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a robust Sign-In with Ethereum (SIWE, EIP-4361) login flow and JWT-based session authentication for read endpoints, replacing the previous basic JWKS validation. It adds comprehensive authentication primitives, including an in-memory nonce provider with capacity limits, a JWT issuer/validator, and a SIWE verifier. Read endpoints such as /profile and transfer list endpoints are now guarded by the new authentication middleware. Feedback on the implementation highlights opportunities to prevent cache stampedes during concurrent JWKS fetches by serializing the fetch operation with a mutex, and to defensively guard against a potential nil pointer dereference panic in the login logging decorator.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread pkg/auth/jwt/validator.go
Comment on lines +22 to +28
type Validator struct {
jwksURL string
issuer string
keys map[string]any
keysMu sync.RWMutex
client *http.Client
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent concurrent JWKS fetches (cache stampede) when a missing key ID is requested under high load, consider adding a separate sync.Mutex to serialize the fetch operation. This avoids holding the main keysMu read-write lock during the HTTP request (which would block all token validations) while still ensuring only a single concurrent request is made to the JWKS endpoint.

Suggested change
type Validator struct {
jwksURL string
issuer string
keys map[string]any
keysMu sync.RWMutex
client *http.Client
}
type Validator struct {
jwksURL string
issuer string
keys map[string]any
keysMu sync.RWMutex
client *http.Client
fetchMu sync.Mutex
}

Comment thread pkg/auth/jwt/validator.go
Comment on lines +96 to +115
func (v *Validator) getKey(kid string) (any, error) {
v.keysMu.RLock()
key, exists := v.keys[kid]
v.keysMu.RUnlock()
if exists {
return key, nil
}

if err := v.refreshKeys(); err != nil {
return nil, err
}

v.keysMu.RLock()
key, exists = v.keys[kid]
v.keysMu.RUnlock()
if !exists {
return nil, fmt.Errorf("key not found: %s", kid)
}
return key, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the new fetchMu to serialize the JWKS refresh operation. This prevents multiple concurrent goroutines from triggering duplicate HTTP requests to the JWKS endpoint on a cache miss, while ensuring that existing keys can still be read concurrently without blocking.

func (v *Validator) getKey(kid string) (any, error) {
    v.keysMu.RLock()
    key, exists := v.keys[kid]
    v.keysMu.RUnlock()
    if exists {
        return key, nil
    }

    v.fetchMu.Lock()
    defer v.fetchMu.Unlock()

    // Double-check under RLock in case another goroutine fetched it while we waited
    v.keysMu.RLock()
    key, exists = v.keys[kid]
    v.keysMu.RUnlock()
    if exists {
        return key, nil
    }

    if err := v.refreshKeys(); err != nil {
        return nil, err
    }

    v.keysMu.RLock()
    key, exists = v.keys[kid]
    v.keysMu.RUnlock()
    if !exists {
        return nil, fmt.Errorf("key not found: %s", kid)
    }
    return key, nil
}

Comment thread pkg/auth/service/log.go
Comment on lines +53 to +60
} else {
ls.logger.Info("Login completed",
zap.String("service", loginServiceName),
zap.String("method", "Login"),
zap.Duration("duration", duration),
zap.Int64("expires_at", resp.ExpiresAt),
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If ls.svc.Login returns a nil response and a nil error (which is syntactically possible for any function returning a pointer and an error), accessing resp.ExpiresAt will cause a nil pointer dereference panic. It is safer to defensively check if resp is non-nil before accessing its fields.

        } else {
            var expiresAt int64
            if resp != nil {
                expiresAt = resp.ExpiresAt
            }
            ls.logger.Info("Login completed",
                zap.String("service", loginServiceName),
                zap.String("method", "Login"),
                zap.Duration("duration", duration),
                zap.Int64("expires_at", expiresAt),
            )
        }

@sadiq1971
sadiq1971 marked this pull request as draft July 10, 2026 08:19
Enable read auth in the docker/e2e stack and authenticate the transfer list
calls with a real SIWE-issued JWT instead of ?address=.

- docker api-server config gets an auth block (localhost domain/uri, chain 31337)
- Makefile generates a base64 RSA PEM into JWT_PRIVATE_KEY; compose passes it to
  the api-server and bootstrap (both load the api-server config)
- APIServerShim.Login runs GET /auth/nonce -> EIP-4361 sign -> POST /auth/login,
  caches the JWT per address; the List{Incoming,Outgoing,Completed} calls attach
  it as a bearer token (lazy login on first read, so tests need no new step)
@sadiq1971

Copy link
Copy Markdown
Member Author

Superseded by the 3-way split: auth-jwt → auth-service → auth-integration (all based on main, no dependency on the outbound-transfer-authz PR).

@sadiq1971 sadiq1971 closed this Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants