Skip to content
Open
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
25 changes: 25 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,31 @@ pnpm vitest run packages/shared
Tests live in `packages/<name>/src/__tests__/`. There is no per-package test
script — all tests are run from the root via vitest.

### Coverage Ratcheting Policy

Coverage thresholds in `vitest.config.ts` must **only ever increase**.
When a PR increases coverage above the current thresholds, the thresholds
must be ratcheted up to the new floor (rounded down to the nearest integer)
as part of the same PR. This ensures coverage can never regress.

```bash
pnpm test:coverage # check current coverage vs thresholds
```

After confirming coverage exceeds thresholds, update `vitest.config.ts`:

```ts
thresholds: {
statements: <new floor>,
branches: <new floor>,
functions: <new floor>,
lines: <new floor>,
},
```

**Never lower thresholds.** If a change removes tested code (e.g. deleting
a feature), add tests for other code to compensate.

## Docker

```bash
Expand Down
170 changes: 170 additions & 0 deletions docs/design/consent-flow-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# Design: Fix Consent Flow to Use Upstream OAuth UI

## Problem

The ePDS consent page (`packages/auth-service/src/routes/consent.ts`) is a
broken reimplementation of the consent UI that already exists in the upstream
`@atproto/oauth-provider-ui` package. Specifically:

1. **Hard-coded permissions** — the consent page shows "Read and write posts",
"Access your profile", "Manage your follows" regardless of what OAuth scopes
the client actually requested.

2. **Ignores OAuth scopes entirely** — never reads `parameters.scope` from the
PAR request. A client requesting `transition:chat.bsky` would still see
generic permissions.

3. **Separate consent tracking** — uses its own `client_login` table
(`hasClientLogin` / `recordClientLogin`) instead of the atproto provider's
built-in `authorizedClients` tracking, which already handles scope-level
Comment on lines +17 to +19
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use the exact table name client_logins for consistency.

The doc currently says client_login, but the schema/migration name is client_logins. Please align wording to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/design/consent-flow-fix.md` around lines 17 - 19, Update the
documentation to use the exact table name client_logins (plural) for
consistency: replace the occurrence of client_login with client_logins in the
sentence that describes the separate consent tracking and ensure the references
to hasClientLogin and recordClientLogin remain correct and aligned with the
schema/migration naming to avoid confusion with the atproto provider's
authorizedClients tracking.

consent (re-prompting when new scopes are requested).

4. **Missing consent on signup** — the new-user flow (handle picker →
`epds-callback`) bypasses `complete.ts` step 5c, so `recordClientLogin` is
never called. The user sees the consent page on their second login instead
of their first.

## Root Cause

The `epds-callback` endpoint bypasses the stock OAuth middleware entirely. It
calls `requestManager.get()` and `requestManager.setAuthorized()` directly
instead of going through `provider.authorize()`, which is what the stock PDS
uses (via `oauthMiddleware` in `auth-routes.ts`).

The stock PDS delegates the full authorization UI to `oauthMiddleware` from
`@atproto/oauth-provider`, which serves a React-based UI from the
`@atproto/oauth-provider-ui` package. This UI includes a proper consent view
(`consent-view.tsx`) that displays actual requested scopes/permissionSets.

## Fix: Hand Back to the Stock OAuth Flow After Authentication

After the auth-service authenticates the user (OTP) and pds-core creates the
account (if new), redirect back through the stock `/oauth/authorize` endpoint
instead of calling `setAuthorized()` directly.

### Flow Change

**Current flow (broken consent):**

```
OTP verify → /auth/complete → check consent (auth-service) →
→ show broken consent page (auth-service) →
→ /oauth/epds-callback → setAuthorized() → redirect to client
```
Comment on lines +49 to +53
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Specify a language for fenced code blocks (MD040).

These blocks are missing a language identifier and will keep markdownlint warnings active.

🛠️ Suggested doc fix
-```
+```text
 OTP verify → /auth/complete → check consent (auth-service) →
   → show broken consent page (auth-service) →
   → /oauth/epds-callback → setAuthorized() → redirect to client

- +text
OTP verify → /auth/complete →
→ /oauth/epds-callback → create account if needed, upsertDeviceAccount →
→ redirect to /oauth/authorize?request_uri=... (stock middleware) →
→ stock middleware calls provider.authorize() →
→ if consent needed: renders upstream consent-view.tsx with real scopes →
→ if no consent needed: auto-approves (SSO match) →
→ redirect to client

Also applies to: 57-65

🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 49-49: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/design/consent-flow-fix.md` around lines 49 - 53, The fenced code blocks
containing the flow diagrams (the OTP verify → ... sequences) are missing a
language identifier which triggers markdownlint MD040; update those backtick
fences around the flow diagrams (the two blocks showing the OTP/auth/consent →
... sequences) to include a language specifier such as "text" (i.e., change ```
to ```text) so the blocks pass MD040; ensure both occurrences referenced in the
comment (the block around the OTP verify → /auth/complete ... sequence and the
block around the /oauth/epds-callback → create account ... sequence) are
updated.


**Fixed flow:**

```
OTP verify → /auth/complete →
→ /oauth/epds-callback → create account if needed, upsertDeviceAccount →
→ redirect to /oauth/authorize?request_uri=... (stock middleware) →
→ stock middleware calls provider.authorize() →
→ if consent needed: renders upstream consent-view.tsx with real scopes →
→ if no consent needed: auto-approves (SSO match) →
→ redirect to client
```

### Implementation Steps

#### Step 1: Modify `epds-callback` to redirect through stock OAuth flow

In `packages/pds-core/src/index.ts`, after account creation and
`upsertDeviceAccount`, instead of calling `requestManager.setAuthorized()`
and building the redirect URL ourselves:

- Redirect to `/oauth/authorize?request_uri=<requestUri>&client_id=<clientId>`
- The stock `oauthMiddleware` will handle this request, find the existing
device session (just created via `upsertDeviceAccount`), check consent via
`provider.authorize()`, and either auto-approve or show the upstream
consent UI.

The `epds-callback` handler should **stop calling**:

- `requestManager.setAuthorized()`
- `provider.checkConsentRequired()`
- `provider.accountManager.setAuthorizedClient()`

These are all handled internally by the stock middleware when it processes the
`/oauth/authorize` redirect.

#### Step 2: Remove auth-service consent page

Delete or disable:

- `packages/auth-service/src/routes/consent.ts`
- The consent route registration in `packages/auth-service/src/index.ts`
- The `needsConsent` check in `packages/auth-service/src/routes/complete.ts`
(step 5b) — the auth-service no longer decides whether consent is needed
- `ctx.db.hasClientLogin()` and `ctx.db.recordClientLogin()` methods
- The `client_login` table (add a migration to drop it)

#### Step 3: Simplify `complete.ts`

`/auth/complete` no longer needs to check consent. Its only job is:

1. Verify the auth flow cookie and better-auth session
2. For new users → redirect to `/auth/choose-handle`
3. For existing users → redirect to `/oauth/epds-callback` (which then
redirects through the stock OAuth flow)

#### Step 4: Verify the stock middleware handles the redirect correctly

The key assumption to verify: when `oauthMiddleware` receives
`/oauth/authorize?request_uri=...` and finds an existing device session
(created by `upsertDeviceAccount` moments earlier), it should:

- Call `provider.authorize()` which returns `sessions` with
`consentRequired` and `loginRequired` flags
- Since `loginRequired` should be false (device session just created) and
`consentRequired` depends on whether this client was previously authorized,
it should either auto-approve or show consent
- The `permissionSets` from the requested scopes will be displayed correctly

**Risk**: the stock middleware might require a full browser login flow (cookies,
CSRF) rather than just a device session. This needs to be tested. If the
middleware requires its own session state, we may need to ensure that the
device session created by `upsertDeviceAccount` is sufficient for the
middleware to recognize the user as authenticated.

### What This Fixes

- Consent page shows actual requested scopes (from upstream `consent-view.tsx`)
- Consent tracking uses the atproto provider's `authorizedClients` system
(scope-aware, re-prompts for new scopes)
- No more hard-coded "Read and write posts" etc.
- No more separate `client_login` table
- New users see consent at the right time (determined by the provider)
- Removes ~250 lines of auth-service code (`consent.ts` + DB methods)

### Research Findings (Resolved Questions)

1. **Device identification** — the stock middleware uses `dev-id` and `ses-id`
HTTP cookies (set by `deviceManager.load()` with ~10-year expiry). Since
the browser carries these cookies on both the original `/oauth/authorize`
visit and the redirect back from `epds-callback`, the deviceId will match.
`upsertDeviceAccount(deviceId, sub)` creates the device-account association
that `authorize()` finds via `listDeviceAccounts(deviceId)`.

2. **PAR request state** — `requestManager.get()` called WITHOUT a deviceId
(as we now do in `epds-callback`) does NOT bind a deviceId to the request.
When the stock middleware subsequently calls `get(requestUri, deviceId,
clientId)`, it binds the browser's deviceId for the first time. This is
the expected PAR → redirect → authorize flow.

3. **Auto-approve conditions** — `provider.authorize()` auto-approves when:
- `prompt=none` with a single matching session (no login/consent required)
- No explicit prompt + `login_hint` matching one session (no login/consent)
- For unauthenticated clients (`token_endpoint_auth_method: 'none'`),
`requestManager.validate()` forces `prompt: 'consent'`, so consent is
always shown — this is correct behavior.

4. **Consent UI rendering** — the stock middleware serves a React SPA from
`@atproto/oauth-provider-ui`. It injects hydration data (requestUri,
clientMetadata, scope, permissionSets, sessions with consentRequired flags)
as `window.__authorizeData`. The SPA calls back to
`/oauth/authorize/api/consent` which internally calls `setAuthorized()`.

5. **AS metadata override** — the redirect from `epds-callback` to
`/oauth/authorize` is a 303 redirect on the same pds-core host. The AS
metadata's `authorization_endpoint` (pointing to the auth service) is
irrelevant here since the browser follows the redirect directly.
Loading
Loading