diff --git a/docs/architecture.md b/docs/architecture.md index 6e319a74..b005d39f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -74,3 +74,118 @@ endpoint to issue an AT Protocol authorization code. For new users, a handle-pic - **HMAC-signed callback**: the redirect from Auth Service to PDS Core's `/oauth/epds-callback` is signed with `EPDS_CALLBACK_SECRET` so PDS Core can verify it was produced by a legitimate auth flow. + +- **Auth Service on a subdomain, not a single shared domain**: the + recommended production relationship is `AUTH_HOSTNAME` as a subdomain of + `PDS_HOSTNAME` (e.g. `auth.pds.example` / `pds.example`). This is not a hard + runtime requirement — the code also supports unrelated hostnames (e.g. + Railway preview envs where both services sit under `up.railway.app`), in + which case the shared cookie domain is `null` and device cookies stay + host-only (`deriveCookieDomain` in `pds-core/src/cookie-domain.ts`). The + cross-subdomain **device-session reuse** path only works when the subdomain + relationship holds, which is why it is the recommended arrangement. A + single-domain design — one origin, routing the auth paths + by path prefix the way [pds-gatekeeper](https://tangled.org/baileytownsend.dev/pds-gatekeeper) + does — was **considered and rejected**. The reasons: + - **Mechanism (how the takeover happens)**: PDS Core overrides the + Authorization Server metadata so `authorization_endpoint` points at the + Auth Service. OAuth clients redirect the browser straight there, so the + stock `@atproto/pds` sign-in UI is never shown. This alone does not + _require_ a separate origin — PDS Core already wraps upstream heavily and + could shadow `/oauth/authorize` in place — so it is the mechanism, not the + motivation. + + - **Cookie isolation (the motivation)**: the Auth Service's session cookies + (the Better Auth session cookie; historically `magic_account_session`, + no longer present in code) are a + _separate authentication domain_ from AT Protocol access/refresh tokens. + Giving the Auth Service its own origin keeps the two cookie namespaces from + colliding by construction. (Note the tension: cross-subdomain cookie + _sharing_ for the device-session reuse path is a _cost_ of this split — see + [pds-white-boxing.md](design/pds-white-boxing.md) items 14–15 — whereas + isolation between the two auth systems is the _benefit_.) + + - **Security-header isolation**: the Auth Service is a full web app (HTML + forms, static assets, inline scripts/styles) that needs its own CSP and + security headers, independent of PDS Core's stricter policy. Separate + origins let each set headers without one breaking the other. + + - **Independent deployability**: separate service and process, deployable and + restartable on its own. + + The contrast with pds-gatekeeper is the crux: path-based single-domain + routing is fine for a _thin interception layer_, but the Auth Service is a + _full web app with its own cookie domain_, so it was given its own origin. + + Costs of the subdomain choice (accepted, and only partly documented at the + time): the `same-site` → `same-origin` `sec-fetch-site` rewrite and the + cross-subdomain cookie-domain plumbing, both in + [pds-white-boxing.md](design/pds-white-boxing.md) (items 5, 14, 15). + + **On re-examination, none of the three benefits is a non-negotiable, and all + the costs are artifacts of being cross-origin-but-same-site:** + - _Cookie isolation_ is already done by explicit cookie **naming** + (`epds_csrf`, `epds_auth_flow`, the Better Auth session cookie, and the + `DEVICE_COOKIE_NAMES` device-session set), not by origin — so it survives a + collapse to one origin. Concretely, on a shared origin the contract is: + guaranteed-unique names, and **no parent `Domain` attribute** (cookies stay + host-only). This unique-name isolation is distinct from — and does not + require — `__Host-` prefixing; note `__Host-` mandates `Path=/` and so + cannot be combined with a `/auth` path scope, whereas plain path scoping + (`Path=/auth`) is an available but separate hardening option. The split + does not just fail to be _required_ for isolation; it actively _creates_ a + cookie problem — the `Domain=` sharing in items 14–15 — that a + single origin deletes outright. + - _Security-header isolation_ is already per-route in both packages + (`auth-service/src/lib/security-headers.ts` sets an auth-specific CSP per + request; `pds-core` already rewrites the upstream CSP per response in + `chooser-enrichment.ts` / `client-css-injection.ts`). Per-route CSP does + not need per-origin hosting. + - _Independent deployability_ is a soft benefit already weakened by a shared + repo, build, and data volume. + - The `sec-fetch-site` rewrite (item 5) exists **only** because the redirect + chain is `same-site`; a single origin makes it `same-origin`, which + upstream already allows — so the one upstream validation that looks like a + constraint actually _favors_ single-origin. + + Beyond dissolving those costs, collapsing to one origin has **positive** + benefits the split forecloses: + - **Fewer privileged cross-service endpoints.** The split forces the two + services to talk over authenticated HTTP: the HMAC-signed + `/oauth/epds-callback` and the `/_internal/*` lookup endpoints (e.g. + `/_internal/account-by-email`, which replaced the old unauthenticated + `/_magic/check-email`), each an attack surface that has to be signed, + gated, and kept in sync. **Note this benefit requires merging the + _processes_, not just the origin**: a single origin with two processes + still has an HTTP boundary (merely same-origin now). Only a process merge + turns these into in-process calls with no wire boundary to secure — a + separate decision from origin-merging; see the migration doc. + + - **Operational simplicity.** One origin means one TLS cert, one DNS record, + one CSP surface, and one set of cross-origin edge cases to reason about, + instead of a parent/subdomain pair whose relationship is itself + load-bearing config. + + A **countervailing risk** applies only if the two _processes_ are merged + (not if they stay separate behind path routing): sharing one Node runtime + could surface npm peer-dependency conflicts between `auth-service`'s + `better-auth` and `pds-core`'s `@atproto/*` stack. In practice the repo is a + pnpm workspace with a non-flat `node_modules`, so each package already + resolves its own versions and the overlap today is minimal (`express`, + aligned). The risk is real but bounded, and avoided entirely by the + keep-two-processes option. + + The remaining real risk is not any of the three benefits but external + addressability of `auth.`: `authorization_endpoint` is published in AS + metadata and cached by clients, and `EPDS_LINK_BASE_URL` appears in live + email links — so a migration is a published-URL change needing a transition, + not a flip. (There is _no_ AT Protocol identity coupling: DIDs reference the + PDS host, and OAuth `redirect_uri` is always the client's.) This + re-evaluation, and a concrete single-domain migration design, are tracked in + [#200](https://github.com/hypercerts-org/ePDS/issues/200); see + [design/single-domain-migration.md](design/single-domain-migration.md). + + Origin rationale: `better-auth-migration-plan.md`, section "Considered and + rejected: single-domain architecture" (introduced in commit `e6d6a08`; the + design itself was inherited whole from the upstream `magic-pds` project at + the initial commit `de3876e`). diff --git a/docs/design/single-domain-migration.md b/docs/design/single-domain-migration.md new file mode 100644 index 00000000..997bbbe4 --- /dev/null +++ b/docs/design/single-domain-migration.md @@ -0,0 +1,223 @@ +# Single-Domain Migration: Collapse `auth.` into the PDS origin + +**Status:** Proposal (tracked in [#200](https://github.com/hypercerts-org/ePDS/issues/200)) + +This document designs the migration from the current two-origin architecture +(`auth.pds.example` + `pds.example`) to a single origin, where the Auth Service +is served under a path prefix (e.g. `pds.example/auth/*`) on the PDS host. + +For _why_ this is worth doing, see the "Auth Service on a subdomain" decision +in [../architecture.md](../architecture.md) and the cost inventory in +[pds-white-boxing.md](pds-white-boxing.md) items 5, 14, 15. This doc assumes +that case is accepted and focuses on _how_. + +## Summary of the change + +| Concern | Today (two origins) | After (one origin) | +| -------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| Auth UI location | `https://auth.pds.example/oauth/authorize`, `/auth/*`, `/account/*` | `https://pds.example/auth/*` — including `/auth/oauth/authorize` and `/auth/account/*` (see path-shadowing note below) | +| `authorization_endpoint` (AS metadata) | `https://auth./oauth/authorize` | `https:///auth/oauth/authorize` | +| `sec-fetch-site` on `/oauth/authorize` | `same-site`, rewritten to `same-origin` (item 5) | naturally `same-origin` — rewrite **deleted** | +| Device-session cookies | `Domain=` so sibling subdomain can read them (items 14–15) | host-only on the single origin — **plumbing deleted** (see Set-Cookie contract below) | +| Auth session cookie | Better Auth session cookie (historically `magic_account_session`) on `auth.` | same cookie, host-only on `` (see Set-Cookie contract below) | +| CSP | per-route in each service (unchanged approach) | per-route, unchanged | +| Deploy units | two services | still two processes, one origin (Caddy path-routes) — or merged later | + +The routing model is exactly pds-gatekeeper's: **Caddy routes by path**, sending +the auth surface to the auth-service process and everything else to the PDS. The +auth-service stays a separate process; only its _origin_ changes. + +### Path shadowing — the auth surface must move under `/auth` + +On a single origin the auth service **cannot** keep serving `/oauth/authorize` +and `/account/*` at the root: upstream `@atproto/oauth-provider` already renders +`/oauth/*` and `/account*` on pds-core, so routing those roots to auth-service +would shadow the PDS's own endpoints. The migration therefore exposes the entire +auth surface under a non-conflicting prefix — `/auth/oauth/authorize`, +`/auth/account/*`, `/auth/*` — optionally with a Caddy path-strip rewrite so the +auth-service process can keep its current internal route paths unchanged. + +### Set-Cookie contract on the merged origin + +With one origin, the cookie isolation that the subdomain previously provided by +separation must instead be provided by the cookie attributes: + +- **Auth session cookie** (Better Auth) and **CSRF / auth-flow cookies** + (`epds_csrf`, `epds_auth_flow`): guaranteed-**unique names**, **no `Domain` + attribute** (host-only), `Secure`, `HttpOnly` where applicable, + `SameSite=Lax`. Optionally scope non-`__Host-` cookies with `Path=/auth`. +- **Device-session cookies** (`DEVICE_COOKIE_NAMES`): host-only — emit with **no + `Domain` attribute** at all, deleting the `Domain=` broadening (items + 14–15). +- `__Host-` prefixing is a possible hardening but is **mutually exclusive with a + `/auth` path scope**: `__Host-` requires `Path=/`. Pick unique-name + + host-only as the baseline; add either `__Host-` (at `Path=/`) or `Path=/auth` + scoping, not both. + +## Two levels of "merge" — keep them distinct + +This matters for both benefits and risks, so fix the terms up front: + +- **Merge the _origin_ (recommended baseline).** One hostname, Caddy path-routes + to two still-separate processes. Dissolves every cross-origin cost; keeps the + HMAC trust boundary; carries **no** npm-version risk. +- **Merge the _processes_ (optional, later).** Fold auth-service into the + pds-core Node process. Adds the "fewer privileged endpoints" benefit in full + (calls become in-process) but is the only variant that can surface + dependency conflicts. Treat as a separate, later decision — specced in full + in [single-process-merge.md](single-process-merge.md). + +## Positive benefits (beyond deleting costs) + +- **Fewer privileged cross-service endpoints.** The split _requires_ the two + services to communicate over authenticated HTTP: the HMAC-signed + `/oauth/epds-callback` plus the internal lookup endpoints (`/_internal/*`, + e.g. `/_internal/account-by-email`, which replaced the old unauthenticated + `/_magic/check-email`). Each is an attack surface that + must be signed, gated, and version-matched across the boundary. Merging the + origin lets some of these relax; merging the processes lets most become + in-process calls with no wire boundary at all. +- **Operational simplicity.** One TLS cert, one DNS record, one CSP surface, one + origin's worth of cross-origin edge cases — instead of a parent/subdomain pair + whose `endsWith('.'+pdsHostname)` relationship is itself load-bearing config + threaded through several modules. + +## Risk: npm version clashes (process-merge only) + +Applies **only** to the process-merge variant, not the origin-merge baseline. + +- Today the repo is a **pnpm workspace** (`pnpm --recursive`) with pnpm's + default non-flat `node_modules`, so `auth-service` and `pds-core` already + resolve their dependencies independently. +- Overlap is small: `pds-core` owns the heavy `@atproto/*` stack, `auth-service` + owns `better-auth`; the only shared runtime dep is `express ^4.18.2`, already + aligned. +- A single Node process would force one resolution of any shared/peer dep. The + realistic conflict surface is a future `express` (or a transitive peer both + pull) diverging. Bounded, but a real reason to prefer keeping two processes + unless the in-process-call benefit is specifically wanted. + +## What gets deleted + +The whole point. These exist solely because of cross-origin-same-site: + +1. **`packages/pds-core/src/lib/sec-fetch-site-rewrite.ts`** — the `same-site` → + `same-origin` rewrite (white-boxing item 5). On one origin the header is + `same-origin`, which upstream `@atproto/oauth-provider` already accepts. + Delete the middleware and its `_router.stack` injection. +2. **Cross-subdomain device-cookie plumbing** (white-boxing items 14–15) — the + `Domain=` broadening in `pds-core` and the sibling-reading logic in + `auth-service/src/lib/session-reuse.ts`. On one origin the device session is + directly readable; cookies stay host-only. +3. **`authHostname.endsWith('.' + pdsHostname)` special-casing** — e.g. + `session-reuse.ts:195-200`, the `parentCookieDomain` derivation in + `pds-core/src/index.ts:930`, and `authOrigin` derivation in + `chooser-enrichment.ts`. The two hostnames become one. +4. **(Process-merge only) some privileged cross-service endpoints** — the + `/_internal/*` HTTP lookups become in-process calls. The + HMAC-signed `/oauth/epds-callback` can also collapse to an in-process call + if the processes merge. See "Two levels of merge" above. + +## What stays + +- The `authorization_endpoint` **metadata override** (`pds-core/src/index.ts:643`). + Still needed — it just points at a path on the same host instead of a + subdomain. The upstream sign-in UI is still never shown. +- The **HMAC-signed `/oauth/epds-callback`** bridge. The trust boundary between + auth-service and pds-core is unchanged; they still communicate over + authenticated HTTP, not shared memory. +- **Per-route CSP** in both packages. No change. +- The **handle picker, consent, OTP** flows. No change to their logic. + +## Migration blockers (external addressability of `auth.`) + +None of the three stated benefits is a blocker; these published-URL references +are the real work. + +### 1. `authorization_endpoint` is cached by OAuth clients + +`pds-core/src/index.ts:643` publishes `https://auth./oauth/authorize` in +the AS metadata document. Clients fetch this and may cache it. A hard switch +would strand in-flight and cached clients. + +**Mitigation:** keep `auth.` resolving during a transition window. Serve a +301/302 (or continue proxying) from `auth./oauth/authorize` → +`/auth/oauth/authorize` until metadata TTLs expire and clients re-fetch. +The redirect/proxy **must preserve the complete request target** — full path and +query string — because the authorization request carries `client_id`, +`redirect_uri`, `code_challenge`, `state`, etc.; dropping the query breaks +in-flight logins. Only retire the subdomain DNS/cert after the window. + +### 2. `EPDS_LINK_BASE_URL` appears in live email links + +`auth-service/.env.example:94` — `EPDS_LINK_BASE_URL=https://auth.pds.example/auth/verify`. +Verification/recovery links already delivered to inboxes point at the subdomain. + +**Mitigation:** same transition redirect covers these — and, as above, it must +**preserve the full path and query string** so the verification token in the +link survives the hop. Update the env var to the new path-based URL for _new_ +emails; keep the subdomain redirecting for the lifetime of the longest-lived +link (OTP/verification TTLs are short — `expiresIn: 600` in +`auth-service/src/better-auth.ts` — so this window is small). + +### 3. `AUTH_HOSTNAME` is load-bearing config + +Referenced in `pds-core/src/index.ts:138`, `auth-service/src/index.ts:138`, +`session-reuse.ts`, `chooser-enrichment.ts` (`authOrigin`), +`sec-fetch-site-rewrite.ts` allowlist, `demo/.env.example` (`AUTH_ENDPOINT`). + +**Approach:** keep these as distinct values rather than conflating them — +`PDS_HOSTNAME + AUTH_PATH_PREFIX` yields `pds.example/auth`, which is a **base +URL/path, not an origin**, and today's `AuthServiceConfig.hostname` expects a +bare hostname while public-URL config carries the scheme. So: + +- keep `AuthServiceConfig.hostname` a hostname; +- introduce `AUTH_PATH_PREFIX` (default `/auth`) as the path the auth surface + mounts under; +- derive the full mount point from an explicit **origin** (`https://`) + joined with the prefix — or introduce an explicit `AUTH_BASE_URL` — and update + URL-joining so paths and origins are never concatenated as bare strings. + +Keep `AUTH_HOSTNAME` as an optional legacy override so existing subdomain +deployments keep working during transition (config-gated, not a hard cutover). + +### Non-blockers (confirmed) + +- **No AT Protocol identity coupling.** DIDs are `did:web:` (the PDS + host), never `auth.`. Grep for `did:web` shows only PDS-host and test + fixtures. +- **OAuth `redirect_uri` is always the client's**, never the auth service's. + Nothing to migrate there. +- **CORS.** The only `Access-Control-Allow-Origin: *` headers are on the demo's + public JSON documents (`client-metadata.json`, `jwks.json`) and a pds-core + metadata response — none depend on the auth origin being distinct. + +## Phased rollout + +1. **Config plumbing.** Add `AUTH_PATH_PREFIX`; derive auth origin from it; + keep `AUTH_HOSTNAME` as a legacy override. No behaviour change yet. +2. **Caddy path routing.** Add a single-origin Caddyfile variant that routes + `/auth/*`, `/oauth/authorize`, `/account/*` to auth-service and the rest to + the PDS. Stand it up in a test/preview env. +3. **Delete the cross-origin workarounds** (items 5, 14, 15 above) _behind the + single-origin config path_ so subdomain deployments are unaffected until they + flip. +4. **Transition redirects.** Serve `auth./*` → `/auth/*` for the + metadata-TTL + email-link-TTL window. +5. **Cut over** `authorization_endpoint` and `EPDS_LINK_BASE_URL` to the new + path-based URLs. +6. **Retire** the `auth.` DNS record, TLS cert, and the legacy + `AUTH_HOSTNAME` code paths after the transition window. + +## Open questions + +- Do we keep two processes (Caddy path-routes) indefinitely, or eventually merge + auth-service into the pds-core process? The process split is cheap to keep and + preserves the HMAC trust boundary; recommend keeping it and only collapsing + the _origin_. +- Preview/e2e environments assume the subdomain in several fixtures + (`preview.ts`, `preview-emails.ts`, `demo/.env.example`). These need updating + in lockstep with step 1; enumerate them before starting. +- Does any downstream trusted client hardcode `auth.` anywhere other than + by fetching AS metadata? Needs a check with the known trusted-client operators + before retiring the subdomain (step 6). diff --git a/docs/design/single-process-merge.md b/docs/design/single-process-merge.md new file mode 100644 index 00000000..ad09a95f --- /dev/null +++ b/docs/design/single-process-merge.md @@ -0,0 +1,207 @@ +# Full Merge: One Origin, One Process + +**Status:** Proposal (tracked in [#200](https://github.com/hypercerts-org/ePDS/issues/200)) + +This document specifies the **maximal** collapse of the two-service +architecture: not just a single origin (covered in +[single-domain-migration.md](single-domain-migration.md)) but a **single Node +process** hosting both the PDS and the auth UI on one Express app. + +Read [single-domain-migration.md](single-domain-migration.md) first — this doc +assumes the single-origin work is done or done concurrently, and only adds the +process-collapse layer on top. The "Two levels of merge" section there defines +the origin-merge vs. process-merge distinction; this is the process-merge half, +specced in full. + +## Why go all the way to one process + +Single-origin already dissolves every cross-origin cost. Merging the _processes_ +adds a distinct set of wins that single-origin alone cannot deliver: + +- **The HMAC callback boundary disappears.** `/oauth/epds-callback` exists so + auth-service can prove to pds-core that a redirect came from a legitimate auth + flow, over the wire, signed with `EPDS_CALLBACK_SECRET`. In one process the + auth flow can call the code-issuance path **directly** as a function — no + HMAC, no secret to rotate, no signature-verification code. Note this removes + only the cross-service _authenticity_ check; the one-time, session-bound + issuance gating must be preserved in the direct path (see "Preserving + one-time issuance" below). +- **The internal HTTP lookups disappear.** `/_internal/account-by-email` (which + replaced the old unauthenticated `/_magic/check-email`) and the + `/_internal/ping-request` keepalive become + in-process function calls against the same objects (`pds.ctx.accountManager`, + `provider.requestManager`) that pds-core already holds. No auth, no JSON + round-trip, no drift between caller and callee. +- **One deploy unit, one health check, one log stream, one restart.** True + operational singularity, not just one origin. +- **Shared in-memory state becomes trivial.** Today the PAR-keepalive + (`ping-request`) and session-reuse logic reason about state across an HTTP + boundary; in-process they share the live objects. + +The cost of going this far — and the reason single-origin is the safer default — +is the integration hazards in the next section and the npm-version risk below. + +## Target architecture + +Both packages already make this feasible: + +- `packages/auth-service/src/index.ts` exposes `createAuthService(config)` which + **returns a mountable `express.Express`** plus its context — it does not + hardwire a server. The `app.listen()` is a thin wrapper below the factory. +- `packages/pds-core/src/index.ts` mounts all its middleware onto **`pds.app`**, + the upstream PDS's own Express instance. + +So the merge is: build the auth app (or a router equivalent), **mount it onto +`pds.app` under `/auth`**, and delete auth-service's own `listen()`. One process, +one Express tree, one port. + +``` +pds.app (single Express instance, single listen) +├── (upstream PDS routes: xrpc, /oauth/authorize, /oauth/par, …) +├── asMetadataOverride → authorization_endpoint = /auth/oauth/authorize +├── /auth/oauth/authorize → auth UI (was auth-service /oauth/authorize) +├── /auth/api/auth/* → better-auth handler +├── /auth/account/* → account settings +├── /auth/complete, /auth/choose-handle, … +└── (code issuance called in-process, no /oauth/epds-callback HTTP hop) +``` + +## Integration hazards (the real work) + +These are concrete Express-level collisions between the two apps. Each must be +resolved deliberately; they are why this is a spec and not a one-line mount. + +### 1. Body-parser ordering — the sharp edge + +auth-service mounts better-auth **before** `express.json()`: + +```ts +// auth-service/src/index.ts +app.all('/api/auth/*', toNodeHandler(betterAuthInstance)) // BEFORE json() +app.use(express.urlencoded({ extended: true })) +app.use(express.json()) +``` + +better-auth parses its own request bodies and breaks if a JSON parser consumes +the stream first. pds-core's app already has its own body-parsing. **On the +merged app, `/auth/api/auth/*` must be registered ahead of any global +`express.json()`**, or scoped so the global parser skips that path. This is the +single most likely source of a silent runtime break. Mount order is +load-bearing. + +### 2. Duplicate `/static` and favicon + +Both apps do `app.use('/static', express.static(publicDir))` and serve a +favicon. Merged, these collide. Namespace the auth assets under `/auth/static` +(and `/auth/favicon`) so the two static roots don't shadow each other. + +### 3. `trust proxy`, CSRF, rate-limit — set once, not twice + +Both set `app.set('trust proxy', 1)` and install their own CSRF + rate-limit +middleware. On one app: set `trust proxy` once; scope the auth CSRF/rate-limit +middleware to the `/auth` mount (`app.use('/auth', csrfProtection(...))`) so +they don't wrap PDS routes that have their own protections. + +### 4. Error / not-found handlers + +auth-service ends with `notFoundHandler` + `errorHandler` as terminal +middleware. Terminal handlers mounted globally would swallow PDS routes. Scope +them to the `/auth` router, not the merged app root. + +### 5. CSP middleware convergence + +Both set CSP per-route (auth via `security-headers.ts`, pds-core by rewriting +upstream's CSP). These already coexist per-response, so no origin change is +needed — but confirm the auth CSP middleware only fires on `/auth/*` once +co-mounted, and does not leak `unsafe-inline` onto PDS routes. + +### 6. Shared context / DB handles + +auth-service builds `AuthServiceContext` (its own `db`, `emailSender`); pds-core +holds `pds.ctx`. Merged, decide whether they share one SQLite handle or keep +separate connections to the same files. The migration plan already notes +`account.sqlite` is the single source of truth for email→DID; in-process, the +direct-lookup replacements for `/_internal/account-by-email` read it through +`pds.ctx.accountManager` rather than a second connection. + +## Replacing the HMAC callback in-process + +Today: auth flow → HMAC-signed 303 → `pds-core` `/oauth/epds-callback` → +`provider.requestManager.setAuthorized(...)` issues the code. + +Merged: the auth-complete handler calls the same `setAuthorized` path +**directly**. Concretely: + +- Extract the body of the `/oauth/epds-callback` handler (the part that calls + `requestManager.setAuthorized` / `createAccount`) into a plain function on a + shared module, taking typed args instead of a signed request. +- The auth `/auth/complete` handler calls that function directly. +- **Preserve the one-time issuance gating in the extracted function.** Dropping + HMAC removes only the cross-service _authenticity_ check — it does **not** + remove the need for one-time, session-bound, idempotent guards. The direct + path must still ensure a repeated or replayed `/auth/complete` cannot mint a + second authorization code (the auth-flow row is consumed exactly once, keyed + to the session). Carry these guards into the shared function; they are + independent of the HMAC and must not be deleted alongside it. +- **Retain `EPDS_CALLBACK_SECRET`, the signature-verification middleware, and + the HTTP `/oauth/epds-callback` route while any legacy Auth instance can + still call it.** During a rolling deploy an old auth-service may still POST + the signed callback; deleting the verifier early would either break it or + (worse) expose an unauthenticated issuance route. Remove the secret, the + verifier, and the route **only** in a later compatibility gate, after all old + callers are drained and the direct-call path is fully deployed (see rollout + steps 4–5). + +This is the highest-value deletion in the whole merge and also the most +security-sensitive change — the HMAC existed to stop an attacker forging a +callback. In-process there is no forgeable wire boundary, but the review must +confirm no other caller (including a partially-migrated deployment) can still +reach the code-issuance path unauthenticated. + +## npm version clash — now in play + +Unlike single-origin, one process forces **one resolution** of every shared or +peer dependency across `better-auth` and the `@atproto/*` stack. + +- The repo is a pnpm workspace with non-flat `node_modules`, so today the two + packages resolve independently. Merging their runtime removes that isolation. +- Current overlap is small: the only shared runtime dep is `express ^4.18.2` + (aligned). The realistic future conflict is `express` majors, or a transitive + peer both pull (e.g. a differing `zod`/`@types/node` peer requirement between + better-auth and an @atproto package). +- **Mitigation:** before merging, run a dedup/peer audit (`pnpm why express`, + `pnpm dedupe --check`) and pin the shared set. Treat any unresolvable peer + conflict as a blocker that keeps the two processes separate (fall back to the + origin-merge-only outcome). + +## Phased rollout (extends the single-domain phases) + +The single-origin phases (see [single-domain-migration.md](single-domain-migration.md)) +come first. This process-merge adds: + +1. **Peer-dependency audit.** `pnpm why` / `pnpm dedupe --check` across the + merged dependency set; pin shared deps. Gate: no unresolvable peer conflict. +2. **Extract the callback core.** Refactor `/oauth/epds-callback`'s issuance + logic into a directly-callable function; leave the HTTP route delegating to + it (no behaviour change yet). Ship and test this alone. +3. **Mount auth onto `pds.app`.** Convert `createAuthService` into a router + mounted at `/auth` on the PDS app; resolve hazards 1–6 above. Keep + auth-service's standalone `listen()` behind a flag for rollback. +4. **Switch `/auth/complete` to the in-process call.** Route code issuance + through the extracted function; stop signing/sending the HMAC callback. The + signed HTTP route stays live and verified here — it is not yet removed. +5. **Compatibility gate — delete only once legacy callers are drained.** After + confirming no old auth-service instance still POSTs the callback, delete + `EPDS_CALLBACK_SECRET`, the callback signature middleware, the HTTP + `/oauth/epds-callback` route, the `/_internal/*` HTTP endpoints, and + auth-service's standalone server. Retire the second process. + +Each step is independently shippable and reversible until step 5. + +## Recommendation + +Do the single-origin merge first and independently — it captures most of the +value at a fraction of the risk. Treat the process merge as a **follow-on**, +gated on the peer-dependency audit (step 1) and the callback-extraction refactor +(step 2), both of which are worth doing on their own merits regardless of whether +the final process collapse happens.