Skip to content

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:

  1. redirect_uri is {PUBLIC_BASE_URL}/api/v1/oauth/callback.
  2. The callback sets an HttpOnly cookie (substratum_session) on that origin; Secure is added when PUBLIC_BASE_URL uses https://.
  3. 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):

Modeclient_idredirect_uri
Production (HTTPS public URL){PUBLIC_BASE_URL}/.well-known/oauth-client-metadata.json{PUBLIC_BASE_URL}/api/v1/oauth/callback
Loopback devATProto 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 (not localhost: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, and ALLOW_HTTP_PROXY=true (docker/tranquil-pds/config.compose.toml); pds-authz-proxy preserves edge X-Forwarded-* (or sets proto/host for internal callers). The gateway rewrites PDS_PUBLIC_URL / PDS_OAUTH_ISSUER_ORIGINPDS_URL for server-side OAuth fetches (PdsRewritingHttpClient). Accounts created before a PDS_HOSTNAME change may have a stale PLC #atproto_pds endpoint — recreate the handle or migrate the DID.

Use 127.0.0.1:8080 in docs, bookmarks, and E2E — not localhost:8080.

PDS configuration reference

VariablePurpose
PDS_URLGateway → PDS internal HTTP (Compose: http://pds:3000; host dev: http://127.0.0.1:3000)
PDS_PUBLIC_URLBrowser-reachable PDS base for OAuth UI links
PDS_OAUTH_ISSUER_ORIGINGateway override when issuer metadata differs from PDS_PUBLIC_URL (loopback dev: both https://localhost:3000)
PDS_HANDLE_DOMAINTLD for handles on your dev PDS (e.g. testalice.test)
ATPROTO_APPVIEW_URLHandle / 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.createRecord for 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

SymptomLikely cause
NET::ERR_CERT_AUTHORITY_INVALID on https://localhost:3000Expected with Caddy tls internal; trust the dev CA (below) or proceed past the browser warning
Login redirects in a loopBrowser origin ≠ PUBLIC_BASE_URL; open edge URL, not Vite :14200
OAuth fails on local PDSOpened localhost:8080 instead of 127.0.0.1:8080
Remote PDS rejects clientMetadata URL not reachable; TLS/DNS mismatch with PUBLIC_BASE_URL
/api works but session missingCookie set on different host/port than SPA
401 after successful OAuthCookie 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:

text
docker/tranquil-pds/caddy-data/pki/authorities/local/root.crt

Start pds-edge once to generate that file, then trust it on your host (where Chrome/Firefox runs). Legacy export from the container (same file):

bash
docker cp "$(docker compose ps -q pds-edge)":/data/caddy/pki/authorities/local/root.crt ~/caddy-local-root.crt

Install the root.crt as a trusted root for SSL/TLS, then restart the browser:

OSSteps
macOSOpen in Keychain Access → System keychain → Trust → Always Trust for SSL
Windowscertmgr.msc → Trusted Root Certification Authorities → Import
Linuxsudo 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