OAuth and PDS origins
Last Updated: 2026-06-12
Substratum sign-in uses AT Protocol OAuth. The gateway acts as the OAuth client; the user approves access on their Personal Data Server (PDS). This page documents origin alignment (SPA, cookies, redirect URIs) and PDS-specific behavior in development vs production.
Same-origin rule (SPA + gateway)
The file explorer must load on the same origin as PUBLIC_BASE_URL. This is stricter than “same-site”: different ports or host aliases (localhost vs 127.0.0.1) are different origins.
Why:
redirect_uriis{PUBLIC_BASE_URL}/api/v1/oauth/callback.- The callback sets an
HttpOnlycookie (substratum_session) on that origin;Secureis added whenPUBLIC_BASE_URLuseshttps://. - The SPA calls
/api/*on the same origin so the browser sends the cookie.
Local dev avoids split origins by opening http://127.0.0.1:8080 (nginx edge). Vite :14200 is not forwarded to the host. See Getting started.
OAuth client metadata: production vs loopback
Gateway picks metadata based on whether PUBLIC_BASE_URL is loopback (is_loopback_public_base_url):
| Mode | client_id | redirect_uri |
|---|---|---|
| Production (HTTPS public URL) | {PUBLIC_BASE_URL}/.well-known/oauth-client-metadata.json | {PUBLIC_BASE_URL}/api/v1/oauth/callback |
| Loopback dev | ATProto localhost client (special case) | Same path on loopback base URL |
Remote PDSes must fetch the production client_id URL over HTTPS before authorizing users.
Loopback dev: PDS Sec-Fetch-Site concern
The bundled local stack serves OAuth UI at https://localhost:3000 (pds-edge Caddy TLS → pds-authz-proxy → Tranquil on :2583). Tranquil advertises an https issuer in OAuth metadata (ATProto requirement); plain HTTP on :3000 breaks in-browser redirects after consent.
Substratum therefore:
- Serves the app at
http://127.0.0.1:8080(notlocalhost:8080). - Terminates dev TLS for the PDS at
https://localhost:3000(docker/tranquil-pds/Caddyfile). - Configures Tranquil with
PDS_HOSTNAME=localhost:3000,trusted_proxy_count = 1, andALLOW_HTTP_PROXY=true(docker/tranquil-pds/config.compose.toml);pds-authz-proxypreserves edgeX-Forwarded-*(or sets proto/host for internal callers). The gateway rewritesPDS_PUBLIC_URL/PDS_OAUTH_ISSUER_ORIGIN→PDS_URLfor server-side OAuth fetches (PdsRewritingHttpClient). Accounts created before aPDS_HOSTNAMEchange may have a stale PLC#atproto_pdsendpoint — recreate the handle or migrate the DID.
Use 127.0.0.1:8080 in docs, bookmarks, and E2E — not localhost:8080.
PDS configuration reference
| Variable | Purpose |
|---|---|
PDS_URL | Gateway → PDS internal HTTP (Compose: http://pds:3000; host dev: http://127.0.0.1:3000) |
PDS_PUBLIC_URL | Browser-reachable PDS base for OAuth UI links |
PDS_OAUTH_ISSUER_ORIGIN | Gateway override when issuer metadata differs from PDS_PUBLIC_URL (loopback dev: both https://localhost:3000) |
PDS_HANDLE_DOMAIN | TLD for handles on your dev PDS (e.g. test → alice.test) |
ATPROTO_APPVIEW_URL | Handle / profile resolution (default public Bluesky AppView) |
Production sign-in against federated handles (e.g. @user.bsky.social) uses each user’s remote PDS — you typically do not deploy the Compose pds service. Gateway resolves handles via AppView + PLC (SubstratumHandleResolver).
Optional POST /api/v1/pds/signup and signup proxy require a Substratum-operated PDS with PDS_ADMIN_PASSWORD — a product choice, not required for OAuth against existing ATProto accounts.
After login: why PDS still matters
OAuth establishes a DPoP session stored in Postgres (DbSessionStore). The gateway uses it to:
- Call
com.atproto.repo.createRecordfor passport receipts (ADR 27). - Run the receipt-sync worker when enabled (ADR 28).
If OAuth succeeds but PDS writes fail, uploads may show receipt_sync: pending or failed in the explorer.
Troubleshooting
| Symptom | Likely cause |
|---|---|
NET::ERR_CERT_AUTHORITY_INVALID on https://localhost:3000 | Expected with Caddy tls internal; trust the dev CA (below) or proceed past the browser warning |
| Login redirects in a loop | Browser origin ≠ PUBLIC_BASE_URL; open edge URL, not Vite :14200 |
| OAuth fails on local PDS | Opened localhost:8080 instead of 127.0.0.1:8080 |
| Remote PDS rejects client | Metadata URL not reachable; TLS/DNS mismatch with PUBLIC_BASE_URL |
/api works but session missing | Cookie set on different host/port than SPA |
401 after successful OAuth | Cookie not sent; check proxy preserves Set-Cookie and Path=/ |
Trust Caddy dev CA (pds-edge)
The pds-edge service terminates HTTPS with Caddy’s internal CA (docker/tranquil-pds/Caddyfile). Compose bind-mounts PKI to the repo so the same dev CA survives container recreates:
docker/tranquil-pds/caddy-data/pki/authorities/local/root.crtStart pds-edge once to generate that file, then trust it on your host (where Chrome/Firefox runs). Legacy export from the container (same file):
docker cp "$(docker compose ps -q pds-edge)":/data/caddy/pki/authorities/local/root.crt ~/caddy-local-root.crtInstall the root.crt as a trusted root for SSL/TLS, then restart the browser:
| OS | Steps |
|---|---|
| macOS | Open in Keychain Access → System keychain → Trust → Always Trust for SSL |
| Windows | certmgr.msc → Trusted Root Certification Authorities → Import |
| Linux | sudo cp docker/tranquil-pds/caddy-data/pki/authorities/local/root.crt /usr/local/share/ca-certificates/caddy-local.crt && sudo update-ca-certificates |
Delete docker/tranquil-pds/caddy-data/ only to rotate the dev CA (then re-trust). See also docker/tranquil-pds/README.md.
See also
- Self-hosted troubleshooting — Express install logs,
127.0.0.1vssubstratum.localhost, desktop app - Production deployment
- Getting started
crates/auth/AGENTS.md