Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: …",
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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. |
Expand Down
13 changes: 7 additions & 6 deletions cmd/dauth/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
33 changes: 22 additions & 11 deletions internal/siwe/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 == "" {
Expand Down
10 changes: 10 additions & 0 deletions internal/siwe/docs/dauth_docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
}
},
Expand Down
10 changes: 10 additions & 0 deletions internal/siwe/docs/dauth_swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
}
},
Expand Down
10 changes: 10 additions & 0 deletions internal/siwe/docs/dauth_swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions internal/siwe/nonce/nonce.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions internal/siwe/nonce/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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
Expand All @@ -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
}

Expand Down
2 changes: 2 additions & 0 deletions internal/siwe/nonce/postgres_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand All @@ -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) {
Expand Down
8 changes: 7 additions & 1 deletion internal/siwe/nonce/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
46 changes: 45 additions & 1 deletion internal/siwe/server/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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")
Expand All @@ -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()
Expand Down
Loading
Loading