diff --git a/README.md b/README.md index a8dc55b..614a819 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,16 @@ is stateless and offline-verifiable. ```json { "address": "0x6E4…A1b" } ``` -The chain is fixed by the `CHAIN_ID` config. Response: +The chain is fixed by the `CHAIN_ID` config. + +An optional `audience` (string array) requests a specific `aud` for the issued +token, e.g. `{ "address": "0x6E4…A1b", "audience": ["step-ca"] }`. Every value +must be on the `SIWE_ALLOWED_AUDIENCES` allow-list, or the challenge is rejected +with `invalid_request` (400). The audience is bound to the nonce at challenge +time and cannot be changed at `/siwe/token`. Omit it to receive the configured +default (`JWT_AUDIENCE`). This unblocks clients — e.g. step-ca certificate +enrollment — that validate the sign-in token as an `id_token` and require their +own client ID in `aud`. Response: ```json { "challenge": "dauth.dimo.zone wants you to sign in with your Ethereum account:\n0x6E4…A1b\n\nSign in to DIMO.\n\nURI: https://dauth.dimo.zone\nVersion: 1\nChain ID: 137\nNonce: …\nIssued At: …\nExpiration Time: …", @@ -125,7 +134,7 @@ RS256, with `kid` in the header. Claims: |-------|-------| | `iss` | configured issuer | | `sub` | EIP-55 checksummed address | -| `aud` | configured audience(s) | +| `aud` | configured audience(s), or an allow-listed value requested at `/siwe/challenge` (see below) | | `iat` / `nbf` / `exp` | now / now / now + `TOKEN_TTL` | | `jti` | random UUID | | `ethereum_address` | EIP-55 checksummed address | @@ -165,7 +174,8 @@ section); a few process-wide variables (`PUBLIC_BASE_URL`, `HTTP_ADDRESS`, |----------|----------|---------|-------| | `PUBLIC_BASE_URL` | yes | — | Externally reachable origin, e.g. `https://dauth.dimo.zone`. Base of each surface's `jwks_uri`; also the SIWE `uri` and default `SIWE_DOMAIN` host. | | `SIWE_ISSUER` | yes | — | Absolute URL, e.g. `https://dauth.dimo.zone/siwe`. The sign-in JWT `iss`. | -| `JWT_AUDIENCE` | yes | — | Comma-separated `aud` value(s). | +| `JWT_AUDIENCE` | yes | — | Comma-separated default `aud` value(s). | +| `SIWE_ALLOWED_AUDIENCES` | no | — | Comma-separated allow-list of `aud` values a caller may request at `/siwe/challenge`. Empty means no override is permitted. | | `SIWE_SIGNING_KEY_1`, `SIWE_SIGNING_KEY_2`, … | yes | — | PEM RSA private keys, in order. `_1` is the active signer. | | `CHAIN_ID` | no | `137` | Chain the sign-in is bound to (in the SIWE message). | | `SIWE_DOMAIN` | no | `PUBLIC_BASE_URL` host | Domain shown in the SIWE message. | diff --git a/cmd/dauth/main.go b/cmd/dauth/main.go index 9562a63..08d9450 100644 --- a/cmd/dauth/main.go +++ b/cmd/dauth/main.go @@ -174,12 +174,13 @@ func buildSIWE(ctx context.Context, cfg config.Config, keys *keyset.KeySet, log Audience: cfg.Audience, TTL: cfg.TokenTTL, }), - Domain: cfg.Domain, - URI: cfg.PublicBaseURL, - Statement: cfg.Statement, - ChainID: cfg.ChainID, - ChallengeTTL: cfg.ChallengeTTL, - Log: log, + Domain: cfg.Domain, + URI: cfg.PublicBaseURL, + Statement: cfg.Statement, + ChainID: cfg.ChainID, + ChallengeTTL: cfg.ChallengeTTL, + AllowedAudiences: cfg.AllowedAudiences, + Log: log, } wellKnown, err := oidc.NewWellKnown(oidc.Config{ diff --git a/internal/siwe/config/config.go b/internal/siwe/config/config.go index 5f9b3d0..14bb23c 100644 --- a/internal/siwe/config/config.go +++ b/internal/siwe/config/config.go @@ -36,9 +36,19 @@ type Config struct { // Token / issuer identity for the sign-in (/siwe) surface. Issuer string // SIWE_ISSUER (e.g. https://dauth.dimo.zone/siwe) — required Domain string // SIWE_DOMAIN host; defaults to the PublicBaseURL host - Audience []string // JWT_AUDIENCE (comma-separated) — required + Audience []string // JWT_AUDIENCE (comma-separated) — required; the default aud Statement string // SIWE_STATEMENT shown in the wallet prompt + // AllowedAudiences is the allow-list of non-default `aud` values a caller may + // request at /siwe/challenge (SIWE_ALLOWED_AUDIENCES, comma-separated). A + // requested audience is honored only if every value appears here; otherwise + // the challenge is rejected. Empty (the default) means no override is + // permitted and every token carries the configured Audience. This exists to + // let specific clients — e.g. step-ca cert enrollment, which validates the + // sign-in token as an id_token and requires its own clientID in `aud` — mint + // a sign-in token they can consume, without changing the default. + AllowedAudiences []string // SIWE_ALLOWED_AUDIENCES (comma-separated) — optional + // Chain the sign-in is bound to — the Chain ID in the SIWE message. DIMO // runs on a single chain, so this is one value, not a per-request field. ChainID uint64 // CHAIN_ID @@ -72,16 +82,17 @@ func (s Config) UsePostgres() bool { return s.DatabaseURL != "" } // required value is missing or malformed. func Load() (Config, error) { s := Config{ - Environment: envx.String("ENVIRONMENT", "dev"), - LogLevel: envx.String("LOG_LEVEL", "info"), - HTTPAddr: envx.String("HTTP_ADDRESS", "0.0.0.0:8080"), - OpsAddr: envx.String("OPS_ADDRESS", "0.0.0.0:8081"), - PublicBaseURL: os.Getenv("PUBLIC_BASE_URL"), - Issuer: os.Getenv("SIWE_ISSUER"), - Domain: os.Getenv("SIWE_DOMAIN"), - Statement: envx.String("SIWE_STATEMENT", "Sign in to DIMO."), - RPCURL: os.Getenv("RPC_URL"), - Audience: envx.List(os.Getenv("JWT_AUDIENCE")), + Environment: envx.String("ENVIRONMENT", "dev"), + LogLevel: envx.String("LOG_LEVEL", "info"), + HTTPAddr: envx.String("HTTP_ADDRESS", "0.0.0.0:8080"), + OpsAddr: envx.String("OPS_ADDRESS", "0.0.0.0:8081"), + PublicBaseURL: os.Getenv("PUBLIC_BASE_URL"), + Issuer: os.Getenv("SIWE_ISSUER"), + Domain: os.Getenv("SIWE_DOMAIN"), + Statement: envx.String("SIWE_STATEMENT", "Sign in to DIMO."), + RPCURL: os.Getenv("RPC_URL"), + Audience: envx.List(os.Getenv("JWT_AUDIENCE")), + AllowedAudiences: envx.List(os.Getenv("SIWE_ALLOWED_AUDIENCES")), } if s.Issuer == "" { diff --git a/internal/siwe/docs/dauth_docs.go b/internal/siwe/docs/dauth_docs.go index fc221ae..44acf84 100644 --- a/internal/siwe/docs/dauth_docs.go +++ b/internal/siwe/docs/dauth_docs.go @@ -139,6 +139,16 @@ const docTemplatedauth = `{ "address": { "type": "string", "example": "0x6E4...A1b" + }, + "audience": { + "description": "Audience optionally requests a specific ` + "`" + `aud` + "`" + ` for the issued token. Every\nvalue must be on the server's allow-list (SIWE_ALLOWED_AUDIENCES) or the\nchallenge is rejected. Omit it to receive the configured default audience.", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "step-ca" + ] } } }, diff --git a/internal/siwe/docs/dauth_swagger.json b/internal/siwe/docs/dauth_swagger.json index 7419c66..3b873d8 100644 --- a/internal/siwe/docs/dauth_swagger.json +++ b/internal/siwe/docs/dauth_swagger.json @@ -132,6 +132,16 @@ "address": { "type": "string", "example": "0x6E4...A1b" + }, + "audience": { + "description": "Audience optionally requests a specific `aud` for the issued token. Every\nvalue must be on the server's allow-list (SIWE_ALLOWED_AUDIENCES) or the\nchallenge is rejected. Omit it to receive the configured default audience.", + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "step-ca" + ] } } }, diff --git a/internal/siwe/docs/dauth_swagger.yaml b/internal/siwe/docs/dauth_swagger.yaml index 70d5e45..99bbd23 100644 --- a/internal/siwe/docs/dauth_swagger.yaml +++ b/internal/siwe/docs/dauth_swagger.yaml @@ -5,6 +5,16 @@ definitions: address: example: 0x6E4...A1b type: string + audience: + description: |- + Audience optionally requests a specific `aud` for the issued token. Every + value must be on the server's allow-list (SIWE_ALLOWED_AUDIENCES) or the + challenge is rejected. Omit it to receive the configured default audience. + example: + - step-ca + items: + type: string + type: array type: object server.challengeResponse: properties: diff --git a/internal/siwe/nonce/nonce.go b/internal/siwe/nonce/nonce.go index 0bb5c3c..5e32261 100644 --- a/internal/siwe/nonce/nonce.go +++ b/internal/siwe/nonce/nonce.go @@ -25,6 +25,10 @@ type Challenge struct { Message string // canonical EIP-4361 string the wallet signs Address common.Address // account that must sign Message ExpiresAt time.Time // absolute expiry + // Audience is the `aud` to stamp on the issued token, bound here at + // challenge time so it cannot be swapped at /token time. Empty means the + // issuer's configured default audience. + Audience []string } // Store records and atomically consumes challenges, keyed by nonce. The two diff --git a/internal/siwe/nonce/postgres.go b/internal/siwe/nonce/postgres.go index b5f57b5..ea4ac09 100644 --- a/internal/siwe/nonce/postgres.go +++ b/internal/siwe/nonce/postgres.go @@ -50,9 +50,9 @@ func NewPostgres(ctx context.Context, db *pgxpool.Pool, log zerolog.Logger) (*Po // store unavailable. func (p *Postgres) Put(ctx context.Context, id string, ch Challenge) error { _, err := p.db.Exec(ctx, - `INSERT INTO nonce_challenges (nonce, message, address, expires_at) - VALUES ($1, $2, $3, $4)`, - id, ch.Message, ch.Address.Bytes(), ch.ExpiresAt) + `INSERT INTO nonce_challenges (nonce, message, address, expires_at, audience) + VALUES ($1, $2, $3, $4, $5)`, + id, ch.Message, ch.Address.Bytes(), ch.ExpiresAt, ch.Audience) if err != nil { return fmt.Errorf("storing challenge: %w", err) } @@ -68,11 +68,12 @@ func (p *Postgres) Consume(ctx context.Context, id string) (Challenge, error) { msg string addr []byte expires time.Time + aud []string ) err := p.db.QueryRow(ctx, `DELETE FROM nonce_challenges WHERE nonce = $1 - RETURNING message, address, expires_at`, id). - Scan(&msg, &addr, &expires) + RETURNING message, address, expires_at, audience`, id). + Scan(&msg, &addr, &expires, &aud) switch { case errors.Is(err, pgx.ErrNoRows): return Challenge{}, ErrNotFound @@ -86,6 +87,7 @@ func (p *Postgres) Consume(ctx context.Context, id string) (Challenge, error) { Message: msg, Address: common.BytesToAddress(addr), ExpiresAt: expires, + Audience: aud, }, nil } diff --git a/internal/siwe/nonce/postgres_test.go b/internal/siwe/nonce/postgres_test.go index 10a1060..2abc648 100644 --- a/internal/siwe/nonce/postgres_test.go +++ b/internal/siwe/nonce/postgres_test.go @@ -44,6 +44,7 @@ func TestPostgres_PutConsume(t *testing.T) { Message: "canonical siwe message", Address: common.HexToAddress("0xc0ffee254729296a45a3885639AC7E10F9d54979"), ExpiresAt: time.Now().Add(time.Minute).UTC().Truncate(time.Microsecond), + Audience: []string{"step-ca"}, } require.NoError(t, p.Put(ctx, "nonce-1", ch)) @@ -52,6 +53,7 @@ func TestPostgres_PutConsume(t *testing.T) { assert.Equal(t, ch.Address, got.Address) assert.Equal(t, ch.Message, got.Message) assert.True(t, ch.ExpiresAt.Equal(got.ExpiresAt)) + assert.Equal(t, ch.Audience, got.Audience) } func TestPostgres_SingleUse(t *testing.T) { diff --git a/internal/siwe/nonce/schema.sql b/internal/siwe/nonce/schema.sql index 301f248..e64a7cd 100644 --- a/internal/siwe/nonce/schema.sql +++ b/internal/siwe/nonce/schema.sql @@ -6,9 +6,15 @@ CREATE TABLE IF NOT EXISTS nonce_challenges ( nonce TEXT PRIMARY KEY, -- the random nonce; also the SIWE nonce message TEXT NOT NULL, -- canonical EIP-4361 message the wallet signs address BYTEA NOT NULL, -- 20-byte Ethereum address expected to sign - expires_at TIMESTAMPTZ NOT NULL -- absolute expiry; rows past this are swept + expires_at TIMESTAMPTZ NOT NULL, -- absolute expiry; rows past this are swept + audience TEXT[] -- requested `aud` bound at challenge time; NULL = default ); +-- Additive column for tables created before audience binding existed. The +-- schema self-bootstraps on every startup, so this keeps an existing deployment +-- in sync without an out-of-band migration. +ALTER TABLE nonce_challenges ADD COLUMN IF NOT EXISTS audience TEXT[]; + -- Supports the janitor's range delete of expired rows. CREATE INDEX IF NOT EXISTS nonce_challenges_expires_at_idx ON nonce_challenges (expires_at); diff --git a/internal/siwe/server/handlers.go b/internal/siwe/server/handlers.go index 00b16b3..dec711f 100644 --- a/internal/siwe/server/handlers.go +++ b/internal/siwe/server/handlers.go @@ -28,6 +28,12 @@ type Handlers struct { ChainID uint64 // chain the sign-in is bound to ChallengeTTL time.Duration + // AllowedAudiences is the allow-list of non-default `aud` values a caller may + // request on POST /challenge. A requested audience is honored only if every + // value is in this list; empty means no override is permitted. When a caller + // omits `audience`, the issuer's configured default audience is used. + AllowedAudiences []string + Log zerolog.Logger Now func() time.Time // injectable clock; nil uses time.Now } @@ -41,6 +47,10 @@ func (h *Handlers) now() time.Time { type challengeRequest struct { Address string `json:"address" example:"0x6E4...A1b"` + // Audience optionally requests a specific `aud` for the issued token. Every + // value must be on the server's allow-list (SIWE_ALLOWED_AUDIENCES) or the + // challenge is rejected. Omit it to receive the configured default audience. + Audience []string `json:"audience,omitempty" example:"step-ca"` } type challengeResponse struct { @@ -100,6 +110,16 @@ func (h *Handlers) Challenge() http.Handler { return } + // Validate any requested audience against the allow-list and bind it to + // the nonce now, so the `aud` is fixed at challenge time and cannot be + // swapped at /token. An empty Audience falls through to the issuer's + // default. Reject before a nonce is created so a disallowed request never + // yields a usable challenge. + if !h.audienceAllowed(req.Audience) { + writeError(w, http.StatusBadRequest, "invalid_request", "requested audience is not allowed") + return + } + id, err := nonce.New() if err != nil { h.Log.Error().Err(err).Msg("generating nonce") @@ -125,6 +145,7 @@ func (h *Handlers) Challenge() http.Handler { Message: msg, Address: addr, ExpiresAt: expiresAt, + Audience: req.Audience, }); err != nil { h.Log.Warn().Err(err).Msg("storing challenge") writeError(w, http.StatusServiceUnavailable, "server_error", "challenge store unavailable, retry shortly") @@ -199,7 +220,7 @@ func (h *Handlers) Token() http.Handler { return } - tok, _, err := h.Issuer.Issue(ch.Address) + tok, _, err := h.Issuer.Issue(ch.Address, ch.Audience) if err != nil { h.Log.Error().Err(err).Msg("issuing token") writeError(w, http.StatusInternalServerError, "server_error", "could not issue token") @@ -214,6 +235,29 @@ func (h *Handlers) Token() http.Handler { }) } +// audienceAllowed reports whether a requested audience may be honored. An empty +// request is always allowed (the issuer falls back to its default audience); a +// non-empty request is allowed only if every value appears on the configured +// allow-list. With no allow-list configured, only the empty (default) request +// passes. +func (h *Handlers) audienceAllowed(requested []string) bool { + for _, want := range requested { + if !contains(h.AllowedAudiences, want) { + return false + } + } + return true +} + +func contains(haystack []string, needle string) bool { + for _, v := range haystack { + if v == needle { + return true + } + } + return false +} + func decodeJSON(r *http.Request, v any) error { dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() diff --git a/internal/siwe/server/handlers_test.go b/internal/siwe/server/handlers_test.go index aa05842..bb374aa 100644 --- a/internal/siwe/server/handlers_test.go +++ b/internal/siwe/server/handlers_test.go @@ -137,6 +137,106 @@ func TestFullFlow_Success(t *testing.T) { assert.Equal(t, e.addr.Hex(), claims.Subject) } +// tokenClaims runs challenge → sign → token and returns the validated claims of +// the issued token. challengeBody is posted to /challenge as-is so callers can +// include an `audience`. +func (e *testEnv) tokenClaims(t *testing.T, challengeBody map[string]any) token.Claims { + t.Helper() + resp, body := e.post(t, "/challenge", challengeBody) + require.Equal(t, http.StatusOK, resp.StatusCode) + challenge := body["challenge"].(string) + nonce := body["nonce"].(string) + + resp, body = e.post(t, "/token", map[string]any{"nonce": nonce, "signature": e.sign(t, challenge)}) + require.Equal(t, http.StatusOK, resp.StatusCode) + tokenStr := body["token"].(string) + require.NotEmpty(t, tokenStr) + + var claims token.Claims + _, err := jwt.NewParser(jwt.WithValidMethods([]string{"RS256"})). + ParseWithClaims(tokenStr, &claims, func(*jwt.Token) (any, error) { return e.jwksPublicKey(t), nil }) + require.NoError(t, err) + return claims +} + +// TestChallenge_DefaultAudience: omitting `audience` yields the configured +// default, unchanged from before this feature. +func TestChallenge_DefaultAudience(t *testing.T) { + e := newTestEnv(t) + defer e.ts.Close() + claims := e.tokenClaims(t, map[string]any{"address": e.addr.Hex()}) + assert.Equal(t, jwt.ClaimStrings{"dimo"}, claims.Audience) +} + +// TestChallenge_AllowListedAudience: an allow-listed override is honored and the +// token's `aud` reflects exactly the requested value. +func TestChallenge_AllowListedAudience(t *testing.T) { + e := newTestEnv(t) + defer e.ts.Close() + e.handlers.AllowedAudiences = []string{"step-ca", "other"} + + claims := e.tokenClaims(t, map[string]any{"address": e.addr.Hex(), "audience": []string{"step-ca"}}) + assert.Equal(t, jwt.ClaimStrings{"step-ca"}, claims.Audience) +} + +// TestChallenge_RejectedAudience: a non-allow-listed audience returns 400 and +// does NOT create a usable nonce — the returned nonce is empty/absent. +func TestChallenge_RejectedAudience(t *testing.T) { + e := newTestEnv(t) + defer e.ts.Close() + e.handlers.AllowedAudiences = []string{"step-ca"} + + resp, body := e.post(t, "/challenge", map[string]any{"address": e.addr.Hex(), "audience": []string{"evil"}}) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "invalid_request", body["error"]) + _, ok := body["nonce"] + assert.False(t, ok, "a rejected challenge must not return a nonce") +} + +// TestChallenge_RejectedWhenNoAllowList: with no allow-list configured, any +// requested audience is rejected; the default (omitted) path still works. +func TestChallenge_RejectedWhenNoAllowList(t *testing.T) { + e := newTestEnv(t) + defer e.ts.Close() + // e.handlers.AllowedAudiences is nil by default. + resp, body := e.post(t, "/challenge", map[string]any{"address": e.addr.Hex(), "audience": []string{"step-ca"}}) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "invalid_request", body["error"]) +} + +// TestChallenge_AudienceBoundAtChallengeTime: the audience is fixed when the +// nonce is created. The /token endpoint takes only a nonce + signature, so a +// caller cannot swap the audience there — the issued `aud` is whatever was bound +// at /challenge regardless of what /token carries. +func TestChallenge_AudienceBoundAtChallengeTime(t *testing.T) { + e := newTestEnv(t) + defer e.ts.Close() + e.handlers.AllowedAudiences = []string{"step-ca"} + + resp, body := e.post(t, "/challenge", map[string]any{"address": e.addr.Hex(), "audience": []string{"step-ca"}}) + require.Equal(t, http.StatusOK, resp.StatusCode) + challenge := body["challenge"].(string) + nonce := body["nonce"].(string) + + // An attempt to pass an audience at /token is rejected outright (unknown + // field), so the bound audience cannot be overridden post-challenge. + resp, body = e.post(t, "/token", map[string]any{ + "nonce": nonce, "signature": e.sign(t, challenge), "audience": []string{"dimo"}, + }) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "invalid_request", body["error"]) + + // The original nonce is still valid (the bad /token request never consumed + // it), and redeeming it normally yields the audience bound at challenge time. + resp, body = e.post(t, "/token", map[string]any{"nonce": nonce, "signature": e.sign(t, challenge)}) + require.Equal(t, http.StatusOK, resp.StatusCode) + var claims token.Claims + _, err := jwt.NewParser(jwt.WithValidMethods([]string{"RS256"})). + ParseWithClaims(body["token"].(string), &claims, func(*jwt.Token) (any, error) { return e.jwksPublicKey(t), nil }) + require.NoError(t, err) + assert.Equal(t, jwt.ClaimStrings{"step-ca"}, claims.Audience) +} + func TestFullFlow_ReusedNonceRejected(t *testing.T) { e := newTestEnv(t) defer e.ts.Close() diff --git a/internal/siwe/token/token.go b/internal/siwe/token/token.go index 454cd7d..79164d2 100644 --- a/internal/siwe/token/token.go +++ b/internal/siwe/token/token.go @@ -57,17 +57,27 @@ func NewIssuer(cfg Config) *Issuer { // Issue mints a signed access token for account. The subject and // ethereum_address claim are the EIP-55 checksummed address. It returns the // signed token and its expiry time. -func (i *Issuer) Issue(account common.Address) (string, time.Time, error) { +// +// audience overrides the `aud` claim for this token; when empty the issuer's +// configured default audience is used. The caller is responsible for having +// validated any non-default audience against its allow-list — Issue trusts what +// it is given. +func (i *Issuer) Issue(account common.Address, audience []string) (string, time.Time, error) { now := i.now().UTC() exp := now.Add(i.ttl) addr := account.Hex() + aud := i.audience + if len(audience) > 0 { + aud = jwt.ClaimStrings(audience) + } + claims := Claims{ EthereumAddress: addr, RegisteredClaims: jwt.RegisteredClaims{ Issuer: i.issuer, Subject: addr, - Audience: i.audience, + Audience: aud, IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(exp), diff --git a/internal/siwe/token/token_test.go b/internal/siwe/token/token_test.go index 741f108..847a203 100644 --- a/internal/siwe/token/token_test.go +++ b/internal/siwe/token/token_test.go @@ -42,7 +42,7 @@ func TestIssueRoundTrip(t *testing.T) { }) addr := common.HexToAddress("0x07b584f6a7125491c991ca2a45ab9e641b1cee1b") - signed, exp, err := iss.Issue(addr) + signed, exp, err := iss.Issue(addr, nil) require.NoError(t, err) assert.Equal(t, now.Add(10*time.Minute), exp) @@ -72,7 +72,7 @@ func TestIssue_RejectedAfterExpiry(t *testing.T) { require.NoError(t, err) now := time.Now().UTC() iss := NewIssuer(Config{Keys: ks, Issuer: "iss", Audience: []string{"a"}, TTL: time.Minute, Now: func() time.Time { return now }}) - signed, _, err := iss.Issue(common.HexToAddress("0x01")) + signed, _, err := iss.Issue(common.HexToAddress("0x01"), nil) require.NoError(t, err) pub := firstJWKSPublicKey(t, jwksRaw) @@ -81,6 +81,42 @@ func TestIssue_RejectedAfterExpiry(t *testing.T) { require.Error(t, err, "an expired token must fail validation") } +// TestIssue_AudienceOverride confirms a per-issuance audience replaces the +// issuer's configured default, while a nil/empty override falls back to it. +func TestIssue_AudienceOverride(t *testing.T) { + ks := newTestKeySet(t) + jwksRaw, err := ks.JWKS() + require.NoError(t, err) + now := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC) + iss := NewIssuer(Config{ + Keys: ks, + Issuer: "https://auth.dimo.zone", + Audience: []string{"dimo"}, + TTL: 10 * time.Minute, + Now: func() time.Time { return now }, + }) + addr := common.HexToAddress("0x07b584f6a7125491c991ca2a45ab9e641b1cee1b") + pub := firstJWKSPublicKey(t, jwksRaw) + + // Override wins. + signed, _, err := iss.Issue(addr, []string{"step-ca"}) + require.NoError(t, err) + var claims Claims + _, err = jwt.NewParser(jwt.WithValidMethods([]string{"RS256"}), jwt.WithTimeFunc(func() time.Time { return now.Add(time.Minute) })). + ParseWithClaims(signed, &claims, func(*jwt.Token) (any, error) { return pub, nil }) + require.NoError(t, err) + assert.Equal(t, jwt.ClaimStrings{"step-ca"}, claims.Audience) + + // Empty override falls back to the configured default. + signed, _, err = iss.Issue(addr, nil) + require.NoError(t, err) + claims = Claims{} + _, err = jwt.NewParser(jwt.WithValidMethods([]string{"RS256"}), jwt.WithTimeFunc(func() time.Time { return now.Add(time.Minute) })). + ParseWithClaims(signed, &claims, func(*jwt.Token) (any, error) { return pub, nil }) + require.NoError(t, err) + assert.Equal(t, jwt.ClaimStrings{"dimo"}, claims.Audience) +} + // firstJWKSPublicKey reconstructs an *rsa.PublicKey from the first JWKS entry so // the test verifies tokens exactly as an external validator would. func firstJWKSPublicKey(t *testing.T, jwksRaw []byte) *rsa.PublicKey {