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
101 changes: 100 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@ For the optional DRF views:
pip install "siwe-django[drf]"
```

For OpenAPI schemas (auto-generated by drf-spectacular):

```bash
pip install "siwe-django[drf,openapi]"
```

For the setup wizard CLI:

```bash
pip install "siwe-django[cli]"
siwe-django init # patch settings.py + urls.py + drop a template
siwe-django doctor # diagnose an existing install (CI-friendly --json)
siwe-django scaffold-templates # add the bundled sign-in template
siwe-django migrate-from-payton # rewrite payton/django-siwe-auth references
```

## Configure

```python
Expand Down Expand Up @@ -76,7 +92,8 @@ python manage.py migrate
- `GET /nonce/`: returns `{ nonce, expiresAt, domain, uri, statement,
ethereumIdentityKit }` and binds the nonce to the current Django session.
- `POST /verify/`: accepts `{ message, signature }`, verifies the SIWE message
with strict domain, URI, chain, and nonce checks, logs in the user, and returns
with strict domain, URI, chain, nonce, and bound optional-field
(`Resources`, `Request ID`, `Not Before`) checks, logs in the user, and returns
user and wallet data.
- `GET /me/`: returns the current authenticated SIWE identity.
- `POST /logout/`: destroys the Django session.
Expand All @@ -85,6 +102,9 @@ python manage.py migrate
- `DELETE /wallets/<id>/`: unlinks a wallet.
- `GET /profile/<address-or-ens>/`: proxies a display-ready Ethereum Identity
Kit profile from the Eth Follow public API.
- `POST /reauth/`: re-verifies a SIWE signature for the currently authenticated
user (step-up). Stamps the session so `@require_recent_siwe(seconds=N)` can
gate sensitive actions.

## Frontend Flow

Expand All @@ -97,6 +117,47 @@ python manage.py migrate
The server consumes each nonce after the first successful verification, so replay
attempts fail.

### Optional EIP-4361 fields

`siwe_django.services.issue_nonce` accepts `resources`, `request_id`, and
`not_before` keyword arguments and binds them to the issued nonce. When the
client signs a message that uses these fields, `verify_siwe_message` enforces
that:

- the signed `Resources` are a subset of the issued `resources`,
- the signed `Request ID` matches the bound value,
- the signed `Not Before` matches the bound timestamp.

### ReCap (ERC-5573)

`siwe_django.recap` ships helpers to build and parse ReCap capability URIs so
relying parties can scope a sign-in to specific abilities:

```python
from siwe_django.recap import encode_recap
from siwe_django.services import issue_nonce

recap_uri = encode_recap({"https://api.example.com": {"crud/read": [{}]}})
issue_nonce(request, resources=["https://api.example.com", recap_uri])
```

`siwe_django.recap.find_recap_in_resources(resources)` returns the decoded
`{"att": ..., "prf": ...}` payload from a SIWE message's `Resources` list, or
`None` when no ReCap is present.

### Smart contract wallets

`siwe-django` verifies smart contract wallet signatures via:

- **EIP-1271** for already-deployed contract wallets (Safe, multisigs, …).
- **EIP-6492** for counterfactual wallets that have not yet been deployed
(Coinbase Smart Wallet, Privy, …). The upstream `signinwithethereum`
library calls the EIP-6492 universal validator over `eth_call` so we never
need a deployed contract.

Both paths require `RPC_URLS` to contain a provider for the wallet's chain.
Without it the contract check fails and the request is rejected.

## Showcase Demo

The repository includes a full Django + Vite React demo under
Expand Down Expand Up @@ -196,6 +257,7 @@ All settings live under `SIWE_DJANGO`.
| `URI` | request root URI | Expected SIWE URI. |
| `STATEMENT` | `"Sign in with Ethereum."` | Human-readable statement for clients. |
| `NONCE_TTL_SECONDS` | `300` | Nonce lifetime. |
| `CLOCK_SKEW_SECONDS` | `60` | Tolerance applied to `Issued At`, `Not Before`, and `Expiration Time` checks. Set to `0` for strict comparison. |
| `ALLOWED_CHAIN_IDS` | `None` | Optional allow-list for message chain IDs. |
| `RPC_URLS` | `{}` | Chain ID to RPC URL map for contract wallet and token checks. |
| `ENS_ENABLED` | `False` | Enable ENS name/avatar lookup. |
Expand All @@ -209,6 +271,11 @@ All settings live under `SIWE_DJANGO`.
| `USER_FACTORY` | built-in | Dotted path for custom user creation. |
| `RATE_LIMITS` | `{}` | Optional per-view limits like `{ "verify": "5/m" }`. |
| `RATE_LIMIT_TRUST_X_FORWARDED_FOR` | `False` | Use the first `X-Forwarded-For` address for rate limits. Enable only behind a trusted proxy that strips client-supplied forwarding headers. |
| `AUDIT_ENABLED` | `True` | Persist sign-in events to `SiweAuthEvent`. Disable to forward audit data through your own pipeline. |
| `NONCE_STORE` | `siwe_django.nonce_store.DjangoOrmNonceStore` | Dotted path to the nonce store class. Swap for `siwe_django.nonce_store.RedisNonceStore` (extra: `pip install "siwe-django[redis]"`) for a Redis-backed store. |
| `REDIS_URL` | `None` | Used by `RedisNonceStore` when no client is injected. |
| `WEBHOOKS` | `[]` | Subscribers shaped `{event, url, secret, timeout?}`. `event: "*"` matches every audit event. Bodies are HMAC-SHA256 signed in the `X-Siwe-Signature` header. |
| `WEBHOOK_DISPATCHER` | `None` | Dotted path to a callable `(event, payload, subscriptions)` invoked instead of the synchronous urllib delivery (use to wire Celery / RQ). |
| `TOKEN_GATES` | `[]` | Optional group sync gates. |
| `SYNC_TOKEN_GATES_ON_LOGIN` | `True` | Sync token gates after login/linking. |

Expand Down Expand Up @@ -239,6 +306,38 @@ SIWE_DJANGO = {
Custom checkers receive `wallet` and `gate` keyword arguments and return a
boolean.

### EFP and ENS gates

Gates are not limited to on-chain holdings. The Ethereum Identity Kit / EFP
graph is a first-class authorization primitive:

```python
SIWE_DJANGO = {
"ETHID_ENABLED": True,
"TOKEN_GATES": [
{"type": "efp_followed_by", "source": "team.example.eth", "group": "team"},
{"type": "efp_min_followers", "threshold": 100, "group": "popular"},
{"type": "efp_tag", "source": "team.example.eth", "tag": "vip", "group": "vip"},
{"type": "efp_not_blocked_by", "source": "team.example.eth", "group": "members"},
{"type": "ens_required", "group": "ens-holders"},
],
}
```

| Type | Passes when |
| --- | --- |
| `efp_follower_of` | wallet follows `target` |
| `efp_followed_by` | `source` follows the wallet |
| `efp_mutual` | wallet and `hub` follow each other |
| `efp_min_followers` | wallet has at least `threshold` followers |
| `efp_tag` | `source` has tagged the wallet with `tag` |
| `efp_not_blocked_by` | `source` has not blocked or muted the wallet |
| `ens_required` | wallet has a primary ENS name |

EFP and ENS gates ignore `chain_id`. They reuse the existing `TOKEN_GATES`
group-sync semantics, so a failed gate removes the matching `Group` rather
than blocking sign-in.

## OIDC Helpers

`siwe_django.oidc.claims_for_wallet(wallet)` returns claim shapes compatible with
Expand Down
37 changes: 37 additions & 0 deletions examples/showcase/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# The Dockerfile is built with the repo root as context. Excluding heavy
# directories keeps the build fast and the final image small.
**/__pycache__
**/.pytest_cache
**/.ruff_cache
**/*.pyc
**/.DS_Store

# Virtual envs / lockfile sidecars
.venv/
venv/
.tox/
dist/
build/
*.egg-info/

# Frontend build artefacts (we rebuild inside the container)
examples/showcase/frontend/node_modules/
examples/showcase/frontend/dist/

# Local DBs / showcase volumes
**/db.sqlite3
**/db.sqlite3-journal

# Local plans / IDE / git
.git/
.github/
.context/
.vscode/
.idea/
plans/

# Other examples we don't deploy
examples/templates_demo/

# Tests / docs not needed in the runtime image
tests/
67 changes: 67 additions & 0 deletions examples/showcase/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# syntax=docker/dockerfile:1.7
#
# Multi-stage build for the siwe-django showcase.
#
# stage 1: build the Vite/React frontend with bun, output dist/ -> /frontend
# stage 2: install siwe-django + showcase deps, copy the built dist into
# backend/showcase/static_frontend/, run gunicorn behind whitenoise
#
# Build context = repo root, e.g.
# fly deploy --config examples/showcase/fly.toml --dockerfile examples/showcase/Dockerfile
#

# -----------------------------------------------------------------------------
# Stage 1: frontend
# -----------------------------------------------------------------------------
FROM oven/bun:1 AS frontend
WORKDIR /frontend

ENV VITE_BASE=/static/

COPY examples/showcase/frontend/package.json examples/showcase/frontend/package-lock.json* ./
RUN bun install --frozen-lockfile || bun install

COPY examples/showcase/frontend/ ./
RUN bun run build

# -----------------------------------------------------------------------------
# Stage 2: runtime
# -----------------------------------------------------------------------------
FROM python:3.13-slim AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PORT=8080 \
DJANGO_SETTINGS_MODULE=showcase.settings \
SIWE_DEMO_DEBUG=false \
SIWE_DEMO_SECURE_COOKIES=true \
SIWE_DEMO_USE_X_FORWARDED_PROTO=true \
SIWE_DEMO_DATABASE_PATH=/data/db.sqlite3

WORKDIR /app

RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential curl \
&& rm -rf /var/lib/apt/lists/*

# Install siwe-django (this repo) + production server deps. Copy only what
# pip needs first so the heavy install layer stays cached when only the
# showcase app changes.
COPY pyproject.toml README.md ./
COPY src/ ./src/
RUN pip install --upgrade pip \
&& pip install ".[drf]" "gunicorn>=22" "whitenoise[brotli]>=6"

# Showcase code + the built frontend bundle
COPY examples/showcase/ ./examples/showcase/
COPY --from=frontend /frontend/dist ./examples/showcase/backend/showcase/static_frontend

WORKDIR /app/examples/showcase/backend
ENV PYTHONPATH=/app/examples/showcase/backend

RUN chmod +x /app/examples/showcase/entrypoint.sh \
&& python manage.py collectstatic --noinput

EXPOSE 8080
CMD ["/app/examples/showcase/entrypoint.sh"]
36 changes: 36 additions & 0 deletions examples/showcase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,39 @@ uv run pytest
uv run python -m build
cd examples/showcase/frontend && npm run build
```

## Deploy to Fly.io

The showcase ships a multi-stage `Dockerfile` (Bun builds the Vite bundle,
Python runs `gunicorn` behind WhiteNoise) and a `fly.toml` that mounts a
1 GB volume for SQLite. One Fly app serves the React SPA and the Django
endpoints under a single origin so CSRF and session cookies stay
same-origin.

From the repository root:

```bash
# 1) Edit examples/showcase/fly.toml: set `app = "<your-fly-app>"` and the
# SIWE_DEMO_ALLOWED_HOSTS / SIWE_DEMO_DOMAIN / SIWE_DEMO_URI /
# SIWE_DEMO_CSRF_TRUSTED_ORIGINS values to match the Fly hostname you
# intend to use (or your custom domain).

# 2) Create the app + the SQLite volume.
fly launch --config examples/showcase/fly.toml --no-deploy
fly volumes create showcase_data --region lhr --size 1

# 3) Set the secrets (everything not in `fly.toml`'s [env]).
fly secrets set \
SIWE_DEMO_SECRET_KEY="$(openssl rand -hex 32)" \
SIWE_DEMO_RPC_URL_1="https://mainnet.infura.io/v3/<key>"

# 4) Deploy.
fly deploy --config examples/showcase/fly.toml
```

Hit `https://<your-fly-app>.fly.dev/` and the React SPA loads; sign-in,
session, wallet linking, and token-gate sync all work end-to-end.

To switch off SQLite-on-volume in favour of managed Postgres, swap the
`DATABASES` block in `backend/showcase/settings.py` for one that reads
`DATABASE_URL` and remove the `[[mounts]]` section from `fly.toml`.
Loading
Loading