Skip to content

Commit 19fab35

Browse files
aspiersclaude
andcommitted
docs(architecture): re-examine subdomain split, add migration design
None of the three benefits (cookie isolation, security-header isolation, independent deployability) is a non-negotiable — all are reproducible on a single origin, and every documented cost (sec-fetch-site rewrite, cross-subdomain cookie plumbing) is an artifact of being cross-origin-but-same-site. Strengthen the architecture.md decision with this analysis and add docs/design/single-domain-migration.md laying out the collapse to a path-prefixed single origin, the real blockers (published authorization_endpoint, email links, AUTH_HOSTNAME config), and a phased rollout. Tracked in #200. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1c50603 commit 19fab35

2 files changed

Lines changed: 168 additions & 5 deletions

File tree

docs/architecture.md

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,39 @@ endpoint to issue an AT Protocol authorization code. For new users, a handle-pic
113113
Costs of the subdomain choice (accepted, and only partly documented at the
114114
time): the `same-site``same-origin` `sec-fetch-site` rewrite and the
115115
cross-subdomain cookie-domain plumbing, both in
116-
[pds-white-boxing.md](design/pds-white-boxing.md) (items 5, 14, 15). Whether
117-
these accumulated costs still outweigh the isolation benefits — given that
118-
PDS Core already wraps upstream and could host the auth UI in-process — has
119-
not been formally re-evaluated (tracked in
120-
[#200](https://github.com/hypercerts-org/ePDS/issues/200)).
116+
[pds-white-boxing.md](design/pds-white-boxing.md) (items 5, 14, 15).
117+
118+
**On re-examination, none of the three benefits is a non-negotiable, and all
119+
the costs are artifacts of being cross-origin-but-same-site:**
120+
121+
- _Cookie isolation_ is already done by explicit cookie **naming**
122+
(`epds_csrf`, `magic_account_session`, the `DEVICE_COOKIE_NAMES` set), not
123+
by origin — so it survives a collapse to one origin, optionally hardened
124+
with a `__Host-`/path scope. The split does not just fail to be _required_
125+
for isolation; it actively _creates_ a cookie problem — the
126+
`Domain=<parent>` sharing in items 14–15 — that a single origin deletes
127+
outright.
128+
- _Security-header isolation_ is already per-route in both packages
129+
(`auth-service/src/lib/security-headers.ts` sets an auth-specific CSP per
130+
request; `pds-core` already rewrites the upstream CSP per response in
131+
`chooser-enrichment.ts` / `client-css-injection.ts`). Per-route CSP does
132+
not need per-origin hosting.
133+
- _Independent deployability_ is a soft benefit already weakened by a shared
134+
repo, build, and data volume.
135+
- The `sec-fetch-site` rewrite (item 5) exists **only** because the redirect
136+
chain is `same-site`; a single origin makes it `same-origin`, which
137+
upstream already allows — so the one upstream validation that looks like a
138+
constraint actually _favors_ single-origin.
139+
140+
The remaining real risk is not any of the three benefits but external
141+
addressability of `auth.<host>`: `authorization_endpoint` is published in AS
142+
metadata and cached by clients, and `EPDS_LINK_BASE_URL` appears in live
143+
email links — so a migration is a published-URL change needing a transition,
144+
not a flip. (There is _no_ AT Protocol identity coupling: DIDs reference the
145+
PDS host, and OAuth `redirect_uri` is always the client's.) This
146+
re-evaluation, and a concrete single-domain migration design, are tracked in
147+
[#200](https://github.com/hypercerts-org/ePDS/issues/200); see
148+
[design/single-domain-migration.md](design/single-domain-migration.md).
121149

122150
Origin rationale: `better-auth-migration-plan.md`, section "Considered and
123151
rejected: single-domain architecture" (introduced in commit `e6d6a08`; the
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# Single-Domain Migration: Collapse `auth.<host>` into the PDS origin
2+
3+
**Status:** Proposal (tracked in [#200](https://github.com/hypercerts-org/ePDS/issues/200))
4+
5+
This document designs the migration from the current two-origin architecture
6+
(`auth.pds.example` + `pds.example`) to a single origin, where the Auth Service
7+
is served under a path prefix (e.g. `pds.example/auth/*`) on the PDS host.
8+
9+
For _why_ this is worth doing, see the "Auth Service on a subdomain" decision
10+
in [../architecture.md](../architecture.md) and the cost inventory in
11+
[pds-white-boxing.md](pds-white-boxing.md) items 5, 14, 15. This doc assumes
12+
that case is accepted and focuses on _how_.
13+
14+
## Summary of the change
15+
16+
| Concern | Today (two origins) | After (one origin) |
17+
| --- | --- | --- |
18+
| Auth UI location | `https://auth.pds.example/oauth/authorize`, `/auth/*`, `/account/*` | `https://pds.example/auth/*` (path-prefixed) |
19+
| `authorization_endpoint` (AS metadata) | `https://auth.<host>/oauth/authorize` | `https://<host>/auth/oauth/authorize` |
20+
| `sec-fetch-site` on `/oauth/authorize` | `same-site`, rewritten to `same-origin` (item 5) | naturally `same-origin` — rewrite **deleted** |
21+
| Device-session cookies | `Domain=<parent>` so sibling subdomain can read them (items 14–15) | host-only on the single origin — **plumbing deleted** |
22+
| Auth session cookie | `magic_account_session` / better-auth `session` on `auth.<host>` | same names, path-scoped `/auth` on `<host>` |
23+
| CSP | per-route in each service (unchanged approach) | per-route, unchanged |
24+
| Deploy units | two services | still two processes, one origin (Caddy path-routes) — or merged later |
25+
26+
The routing model is exactly pds-gatekeeper's: **Caddy routes by path**, sending
27+
`/auth/*` (and the auth-owned `/oauth/authorize`, `/account/*`) to the
28+
auth-service process and everything else to the PDS. The auth-service stays a
29+
separate process; only its _origin_ changes.
30+
31+
## What gets deleted
32+
33+
The whole point. These exist solely because of cross-origin-same-site:
34+
35+
1. **`packages/pds-core/src/lib/sec-fetch-site-rewrite.ts`** — the `same-site`
36+
`same-origin` rewrite (white-boxing item 5). On one origin the header is
37+
`same-origin`, which upstream `@atproto/oauth-provider` already accepts.
38+
Delete the middleware and its `_router.stack` injection.
39+
2. **Cross-subdomain device-cookie plumbing** (white-boxing items 14–15) — the
40+
`Domain=<parent>` broadening in `pds-core` and the sibling-reading logic in
41+
`auth-service/src/lib/session-reuse.ts`. On one origin the device session is
42+
directly readable; cookies stay host-only.
43+
3. **`authHostname.endsWith('.' + pdsHostname)` special-casing** — e.g.
44+
`session-reuse.ts:195-200`, the `parentCookieDomain` derivation in
45+
`pds-core/src/index.ts:930`, and `authOrigin` derivation in
46+
`chooser-enrichment.ts`. The two hostnames become one.
47+
48+
## What stays
49+
50+
- The `authorization_endpoint` **metadata override** (`pds-core/src/index.ts:643`).
51+
Still needed — it just points at a path on the same host instead of a
52+
subdomain. The upstream sign-in UI is still never shown.
53+
- The **HMAC-signed `/oauth/epds-callback`** bridge. The trust boundary between
54+
auth-service and pds-core is unchanged; they still communicate over
55+
authenticated HTTP, not shared memory.
56+
- **Per-route CSP** in both packages. No change.
57+
- The **handle picker, consent, OTP** flows. No change to their logic.
58+
59+
## Migration blockers (external addressability of `auth.<host>`)
60+
61+
None of the three stated benefits is a blocker; these published-URL references
62+
are the real work.
63+
64+
### 1. `authorization_endpoint` is cached by OAuth clients
65+
66+
`pds-core/src/index.ts:643` publishes `https://auth.<host>/oauth/authorize` in
67+
the AS metadata document. Clients fetch this and may cache it. A hard switch
68+
would strand in-flight and cached clients.
69+
70+
**Mitigation:** keep `auth.<host>` resolving during a transition window. Serve a
71+
301/302 (or continue proxying) from `auth.<host>/oauth/authorize`
72+
`<host>/auth/oauth/authorize` until metadata TTLs expire and clients re-fetch.
73+
Only retire the subdomain DNS/cert after the window.
74+
75+
### 2. `EPDS_LINK_BASE_URL` appears in live email links
76+
77+
`auth-service/.env.example:94``EPDS_LINK_BASE_URL=https://auth.pds.example/auth/verify`.
78+
Verification/recovery links already delivered to inboxes point at the subdomain.
79+
80+
**Mitigation:** same transition redirect covers these. Update the env var to the
81+
new path-based URL for _new_ emails; keep the subdomain redirecting for the
82+
lifetime of the longest-lived link (OTP/verification TTLs are short — 600s per
83+
`auth-flow.ts` — so this window is small).
84+
85+
### 3. `AUTH_HOSTNAME` is load-bearing config
86+
87+
Referenced in `pds-core/src/index.ts:138`, `auth-service/src/index.ts:138`,
88+
`session-reuse.ts`, `chooser-enrichment.ts` (`authOrigin`),
89+
`sec-fetch-site-rewrite.ts` allowlist, `demo/.env.example` (`AUTH_ENDPOINT`).
90+
91+
**Approach:** introduce an `AUTH_PATH_PREFIX` (default `/auth`) and derive the
92+
auth origin as `PDS_HOSTNAME + AUTH_PATH_PREFIX`. Keep `AUTH_HOSTNAME` as an
93+
optional legacy override so existing subdomain deployments keep working during
94+
transition (config-gated, not a hard cutover).
95+
96+
### Non-blockers (confirmed)
97+
98+
- **No AT Protocol identity coupling.** DIDs are `did:web:<pds-host>` (the PDS
99+
host), never `auth.<host>`. Grep for `did:web` shows only PDS-host and test
100+
fixtures.
101+
- **OAuth `redirect_uri` is always the client's**, never the auth service's.
102+
Nothing to migrate there.
103+
- **CORS.** The only `Access-Control-Allow-Origin: *` headers are on the demo's
104+
public JSON documents (`client-metadata.json`, `jwks.json`) and a pds-core
105+
metadata response — none depend on the auth origin being distinct.
106+
107+
## Phased rollout
108+
109+
1. **Config plumbing.** Add `AUTH_PATH_PREFIX`; derive auth origin from it;
110+
keep `AUTH_HOSTNAME` as a legacy override. No behaviour change yet.
111+
2. **Caddy path routing.** Add a single-origin Caddyfile variant that routes
112+
`/auth/*`, `/oauth/authorize`, `/account/*` to auth-service and the rest to
113+
the PDS. Stand it up in a test/preview env.
114+
3. **Delete the cross-origin workarounds** (items 5, 14, 15 above) _behind the
115+
single-origin config path_ so subdomain deployments are unaffected until they
116+
flip.
117+
4. **Transition redirects.** Serve `auth.<host>/*``<host>/auth/*` for the
118+
metadata-TTL + email-link-TTL window.
119+
5. **Cut over** `authorization_endpoint` and `EPDS_LINK_BASE_URL` to the new
120+
path-based URLs.
121+
6. **Retire** the `auth.<host>` DNS record, TLS cert, and the legacy
122+
`AUTH_HOSTNAME` code paths after the transition window.
123+
124+
## Open questions
125+
126+
- Do we keep two processes (Caddy path-routes) indefinitely, or eventually merge
127+
auth-service into the pds-core process? The process split is cheap to keep and
128+
preserves the HMAC trust boundary; recommend keeping it and only collapsing
129+
the _origin_.
130+
- Preview/e2e environments assume the subdomain in several fixtures
131+
(`preview.ts`, `preview-emails.ts`, `demo/.env.example`). These need updating
132+
in lockstep with step 1; enumerate them before starting.
133+
- Does any downstream trusted client hardcode `auth.<host>` anywhere other than
134+
by fetching AS metadata? Needs a check with the known trusted-client operators
135+
before retiring the subdomain (step 6).

0 commit comments

Comments
 (0)