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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ PDS_EMAIL_SMTP_URL=smtp://localhost:1025
PDS_EMAIL_FROM_ADDRESS=noreply@pds.example
PDS_BLOBSTORE_DISK_LOCATION=/data/blobs

# Trusted OAuth clients — comma-separated client_id URLs.
# PDS_OAUTH_TRUSTED_CLIENTS=https://app.example/client-metadata.json

# Skip consent on sign-up for trusted clients that request it.
# Requires PDS_OAUTH_TRUSTED_CLIENTS and client metadata with
# "epds_skip_consent_on_signup": true. Default: false.
# PDS_SIGNUP_ALLOW_CONSENT_SKIP=false

# Invite code for automated account creation (ePDS creates accounts on first login).
# Required when PDS_INVITE_REQUIRED is true (the default).
# Generate with:
Expand Down
25 changes: 25 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,31 @@ Follow these guidelines when adding tests:
- **Ratchet thresholds** — after improving coverage, bump the thresholds in
`vitest.config.ts` so coverage cannot regress.

### 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
193 changes: 193 additions & 0 deletions docs/design/consent-flow-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# 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_logins` table
(`hasClientLogin` / `recordClientLogin`) instead of the atproto provider's
built-in `authorizedClients` tracking, which already handles scope-level
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):**

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

**Fixed flow:**

```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
```

### 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.

### Consent-Skip on Sign-Up

For trusted clients, a new user flow can optionally skip the consent screen
entirely on initial sign-up. This requires all three conditions:

1. **`PDS_SIGNUP_ALLOW_CONSENT_SKIP=true`** in the PDS environment
2. **Client is trusted** — listed in `PDS_OAUTH_TRUSTED_CLIENTS`
3. **Client metadata** includes `"epds_skip_consent_on_signup": true`

When all conditions are met, `epds-callback` issues the authorization code
directly (via `requestManager.setAuthorized()`) and records the client's
scopes as authorized (via `setAuthorizedClient()`). The browser goes straight
to the client app without any intermediate screen.

When consent-skip does NOT apply (e.g. untrusted client, env var disabled),
new users are redirected to `/oauth/authorize?prompt=consent`, which skips
the account selection screen (unnecessary for a just-created account) and
shows the upstream consent UI directly.

Existing users always go through the normal `/oauth/authorize` flow (no
`prompt` override) which auto-approves or shows consent as appropriate.

### 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)
- Trusted clients can skip consent on sign-up (opt-in, triple-gated)
- 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.
30 changes: 24 additions & 6 deletions docs/design/pds-white-boxing.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,14 @@ Methods used:
sliding window (see item 7 below). Used in the `/oauth/epds-callback`
handler and the `/_internal/ping-request` keepalive.
- `.setAuthorized(requestUri, client, account, deviceId, deviceMetadata)` —
marks a request as authorized and issues an authorization code. This is the
core of the ePDS callback mechanism.
marks a request as authorized and issues an authorization code. Used in the
consent-skip path.
- `.store.readRequest(requestId)` / `.store.updateRequest(requestId, data)` —
direct store access to patch the PAR request's stored `parameters`. Used to
set `login_hint` for new accounts so the stock authorize UI auto-selects the
session and skips account selection. The provider already mutates parameters
during `validate()` (forcing `prompt: 'consent'` for unauthenticated clients),
so this is consistent with upstream behavior.

**Breakage scenario:** Method renamed, signature changed, or `requestManager`
made private. The entire authorization code issuance flow stops working.
Expand Down Expand Up @@ -135,14 +141,26 @@ more than the timeout duration.
The right fix is to upstream a public keepalive/refresh API on
`@atproto/oauth-provider`.

### 8. OAuth consent tracking
### 8. OAuth consent tracking (consent-skip path)

**File:** `packages/pds-core/src/index.ts`

- `provider.checkConsentRequired(parameters, clientData)` — assumed to
return a boolean
When `PDS_SIGNUP_ALLOW_CONSENT_SKIP` is enabled, the consent-skip code path
uses several provider internals to issue an authorization code directly:

- `provider.clientManager.getClient(clientId)` — fetches the `Client` object.
The returned `client.info.isTrusted` boolean is used to gate consent-skip.
`clientManager` is a `public readonly` property but `ClientManager` and
`Client` are not re-exported.
- `provider.requestManager.get(requestUri, deviceId)` — binds the device to
the PAR request (same as item 1 above).
- `provider.requestManager.setAuthorized(requestUri, client, account, deviceId, deviceMetadata)` —
issues the authorization code (same as item 1 above).
- `provider.accountManager.setAuthorizedClient(account, client, { authorizedScopes })` —
assumed signature for recording consent
records the client as authorized so future logins can auto-approve.

The normal (non-skip) consent path delegates to the stock `/oauth/authorize`
endpoint and does not use these internals.

### 9. `provider.metadata`

Expand Down
34 changes: 31 additions & 3 deletions features/consent-screen.feature
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,40 @@ Feature: OAuth consent screen
Then no consent screen is shown
And the browser is redirected directly to the demo client with a valid session

Scenario: New user skips consent entirely
Scenario: New user sees consent screen by default
Given no PDS account exists for "newuser@example.com"
And PDS_SIGNUP_ALLOW_CONSENT_SKIP is not set
When "newuser@example.com" authenticates via OTP through the demo client
Then no consent screen is shown (account creation implies consent)
And a PDS account is created
And the browser is redirected to the demo client with a valid session
Then the consent screen is displayed (skipping account selection)
And it shows the actual OAuth scopes requested by the client
When the user clicks "Approve"
Then the browser is redirected to the demo client with a valid session

Scenario: New user skips consent when all conditions are met
Given no PDS account exists for "newuser@example.com"
And PDS_SIGNUP_ALLOW_CONSENT_SKIP is "true"
And the demo client is listed in PDS_OAUTH_TRUSTED_CLIENTS
And the demo client's metadata includes "epds_skip_consent_on_signup": true
When "newuser@example.com" authenticates via OTP through the demo client
Then a PDS account is created
And no consent screen is shown
And the browser is redirected directly to the demo client with a valid session
And the client's scopes are recorded as authorized

Scenario: Consent skip requires all three conditions
Given no PDS account exists for "newuser@example.com"
And PDS_SIGNUP_ALLOW_CONSENT_SKIP is "true"
But the demo client is NOT listed in PDS_OAUTH_TRUSTED_CLIENTS
When "newuser@example.com" authenticates via OTP through the demo client
Then the consent screen is displayed (client is not trusted)

Scenario: Consent skip does not affect subsequent OAuth flows
Given "existinguser@example.com" had consent skipped during sign-up
And a different OAuth client initiates a login
When "existinguser@example.com" authenticates via OTP through the new client
Then the consent screen is displayed for the new client
And the new client's actual OAuth scopes are shown

Scenario: Consent page shows client branding for trusted clients
Given the demo client is listed in PDS_OAUTH_TRUSTED_CLIENTS
Expand Down
16 changes: 13 additions & 3 deletions features/epds-callback.feature
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,29 @@ Feature: ePDS callback — the core integration bridge
Given the ePDS test environment is running
And a demo OAuth client is registered

Scenario: Successful callback for a new user creates an account and completes OAuth
Scenario: Callback for a new user creates an account and redirects to consent
Given no PDS account exists for "newuser@example.com"
When the user completes OTP authentication as "newuser@example.com"
Then the auth service redirects to /oauth/epds-callback with a signed URL
And the PDS creates a new account
And the browser is redirected to /oauth/authorize with prompt=consent
And the upstream consent UI is shown with actual OAuth scopes

Scenario: Callback for a new user skips consent when configured
Given no PDS account exists for "newuser@example.com"
And PDS_SIGNUP_ALLOW_CONSENT_SKIP is "true"
And the demo client is trusted and has epds_skip_consent_on_signup: true
When the user completes OTP authentication as "newuser@example.com"
Then the auth service redirects to /oauth/epds-callback with a signed URL
And the PDS creates a new account
And the browser is redirected back to the demo client with an authorization code
And the demo client exchanges the code for an access token

Scenario: Successful callback for an existing user completes OAuth
Scenario: Callback for an existing user redirects to authorize
Given "existing@example.com" has a PDS account
When the user completes OTP authentication as "existing@example.com"
Then the auth service redirects to /oauth/epds-callback
And the browser is redirected back to the demo client with an authorization code
And the browser is redirected to /oauth/authorize for consent or auto-approval

Scenario: Tampered callback URL is rejected
When a request arrives at /oauth/epds-callback with a modified email parameter
Expand Down
Loading
Loading