Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
13 changes: 13 additions & 0 deletions .changeset/record-email-as-confirmed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'ePDS': patch
---

Apps you sign in to are now told that your email address is confirmed.

**Affects:** End users, Client app developers, Operators

**End users:** Apps no longer ask you to verify an address you have already confirmed with an emailed code.

**Client app developers:** The `email_verified` claim is now `true` for accounts that signed in through the emailed-code flow, instead of always `false`.

**Operators:** Deploy the auth service and the PDS together — the signed handover between them carries a new required field, and a mixed pair rejects sign-in until both are updated. Accounts predating this release are repaired on their owner's next sign-in; to fix the rest, see "Backfilling Email Confirmation" in `docs/deployment.md`.
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,18 @@ import { AuthServiceContext } from './context.js'
schema and crashes. Leave unused tables/columns in place; they're harmless.
- Do **not** directly read or modify `@atproto/pds` database tables — use
`pds.ctx.accountManager.*` methods.
- **One documented exception:** enumerating every account.
`AccountManager` exposes `getAccount`, `getAccounts(dids)` and
`getAccountByEmail`, all of which require knowing the identifier up
front, so there is no supported "list all accounts" call. A backfill
cannot know the DIDs in advance, so
`packages/pds-core/src/backfill-email-confirmed.ts` reads
`accountManager.db.db.selectFrom('account')` directly. This is confined
to that operator-invoked script — pds-core's request path must never do
it, and the writes it performs still go through
`createEmailToken` / `confirmEmail`. Re-check this on each
`@atproto/pds` upgrade: if upstream adds an enumeration API, switch to
it and delete this exception.

## Security

Expand Down
46 changes: 46 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,49 @@ For test or development environments, you can disable the invite code
requirement entirely by setting `PDS_INVITE_REQUIRED=false` on the pds-core
service. This allows anyone who can reach the PDS to create accounts, so it
is **not recommended for production**.

## Backfilling Email Confirmation

Accounts created before email confirmation was recorded at sign-up report
`email_verified: false` to relying parties. New accounts are recorded as
confirmed automatically, and existing accounts are repaired the next time
their owner signs in, so this is only needed for accounts whose owners have
not signed in since the upgrade.

Preview first:

```bash
pnpm --filter @certified-app/pds-core backfill:email-confirmed --dry-run
```

Then run it for real:

```bash
pnpm --filter @certified-app/pds-core backfill:email-confirmed
```

Run it with the same environment as the PDS service, so it targets that
deployment's account database. It is idempotent — already-confirmed accounts
are skipped, so re-running is safe.

A trailing argument scopes the run to addresses containing it
(case-insensitive), which lets you work through a large deployment in batches
or repair a single account:

```bash
pnpm --filter @certified-app/pds-core backfill:email-confirmed @gmail.com
pnpm --filter @certified-app/pds-core backfill:email-confirmed my.account@yahoo.com
```

With no argument, every account is considered.

Accounts that cannot be confirmed are listed by DID at the end and the command
exits non-zero, so a scripted run does not report success after a partial
failure.

**This is deliberately not automatic.** It marks every account that has an
email address but no confirmation timestamp. ePDS does not block upstream's
`com.atproto.server.createAccount` XRPC route, so if you provisioned any
accounts outside the normal sign-in flow, their addresses would be marked
confirmed without having been verified. Check the dry-run count before
committing.
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe('buildEpdsCallbackUrl', () => {
flowClientId: CLIENT_ID,
email: EMAIL,
isNewAccount: false,
emailVerified: true,
pdsPublicUrl: PDS_PUBLIC_URL,
epdsCallbackSecret: SECRET,
})
Expand All @@ -72,6 +73,7 @@ describe('buildEpdsCallbackUrl', () => {
flowClientId: CLIENT_ID,
email: EMAIL,
isNewAccount: false,
emailVerified: true,
pdsPublicUrl: PDS_PUBLIC_URL,
epdsCallbackSecret: SECRET,
})
Expand All @@ -89,6 +91,7 @@ describe('buildEpdsCallbackUrl', () => {
email: EMAIL,
approved: '1',
new_account: '0',
email_verified: '1',
client_id: CLIENT_ID,
}
expect(verifyCallback(params, ts, sig, SECRET)).toBe(true)
Expand All @@ -100,6 +103,7 @@ describe('buildEpdsCallbackUrl', () => {
flowClientId: CLIENT_ID,
email: EMAIL,
isNewAccount: true,
emailVerified: true,
pdsPublicUrl: PDS_PUBLIC_URL,
epdsCallbackSecret: SECRET,
})
Expand All @@ -110,6 +114,7 @@ describe('buildEpdsCallbackUrl', () => {
email: EMAIL,
approved: '1',
new_account: '1',
email_verified: '1',
client_id: CLIENT_ID,
}
expect(
Expand All @@ -128,6 +133,7 @@ describe('buildEpdsCallbackUrl', () => {
flowClientId: null,
email: EMAIL,
isNewAccount: false,
emailVerified: true,
pdsPublicUrl: PDS_PUBLIC_URL,
epdsCallbackSecret: SECRET,
})
Expand All @@ -140,6 +146,7 @@ describe('buildEpdsCallbackUrl', () => {
email: EMAIL,
approved: '1',
new_account: '0',
email_verified: '1',
}
expect(
verifyCallback(
Expand All @@ -160,6 +167,7 @@ describe('buildEpdsCallbackUrl', () => {
flowClientId: CLIENT_ID,
email: EMAIL,
isNewAccount: true,
emailVerified: true,
pdsPublicUrl: PDS_PUBLIC_URL,
epdsCallbackSecret: SECRET,
})
Expand All @@ -172,6 +180,7 @@ describe('buildEpdsCallbackUrl', () => {
email: EMAIL,
approved: '1',
new_account: '1',
email_verified: '1',
client_id: CLIENT_ID,
}
expect(
Expand All @@ -190,6 +199,7 @@ describe('buildEpdsCallbackUrl', () => {
flowClientId: CLIENT_ID,
email: EMAIL,
isNewAccount: false,
emailVerified: true,
pdsPublicUrl: PDS_PUBLIC_URL,
epdsCallbackSecret: SECRET,
})
Expand All @@ -199,6 +209,7 @@ describe('buildEpdsCallbackUrl', () => {
email: EMAIL,
approved: '1',
new_account: '0',
email_verified: '1',
client_id: 'https://attacker.example/client-metadata.json',
}
expect(
Expand All @@ -210,4 +221,41 @@ describe('buildEpdsCallbackUrl', () => {
),
).toBe(false)
})
it('forwards emailVerified into the signed callback, both ways', () => {
// The whole point of the field: pds-core records email
// confirmation from this and nothing else, so a flow that did not
// prove control of the address must say so.
for (const [emailVerified, expected] of [
[true, '1'],
[false, '0'],
] as const) {
const url = buildEpdsCallbackUrl({
flowRequestUri: REQUEST_URI,
flowClientId: null,
email: EMAIL,
isNewAccount: true,
emailVerified,
pdsPublicUrl: PDS_PUBLIC_URL,
epdsCallbackSecret: SECRET,
})
const q = paramsFromUrl(url)
expect(requiredParam(q, 'email_verified')).toBe(expected)
// And it must verify as signed — i.e. the value on the wire is
// the value inside the HMAC, not a decoration alongside it.
expect(
verifyCallback(
{
request_uri: REQUEST_URI,
email: EMAIL,
approved: '1',
new_account: '1',
email_verified: expected,
},
requiredParam(q, 'ts'),
requiredParam(q, 'sig'),
SECRET,
),
).toBe(true)
}
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ function verifySignedCallbackUrl(url: URL): boolean {
email: url.searchParams.get('email') ?? '',
approved: url.searchParams.get('approved') ?? '',
new_account: url.searchParams.get('new_account') ?? '',
// Required, not spread conditionally: a callback that omitted it
// must fail verification rather than be read as unverified.
email_verified: url.searchParams.get('email_verified') ?? '',
...(url.searchParams.has('handle')
? { handle: url.searchParams.get('handle') ?? '' }
: {}),
Expand Down
17 changes: 15 additions & 2 deletions packages/auth-service/src/routes/choose-handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ export function createChooseHandleRouter(
clientId: string | null
}
email: string
/** Whether this sign-in proved control of `email`; see
* CallbackParams.email_verified. */
emailVerified: boolean
} | null> {
// Guard 1: auth_flow cookie
const flowId = req.cookies[AUTH_FLOW_COOKIE] as string | undefined
Expand Down Expand Up @@ -144,7 +147,16 @@ export function createChooseHandleRouter(
return null
}

return { flowId, flow, email: session.user.email.toLowerCase() }
// Carried alongside the email so the signed callback can state
// whether this sign-in actually proved control of that address.
// better-auth sets emailVerified when the emailed one-time code is
// verified. See CallbackParams.email_verified.
return {
flowId,
flow,
email: session.user.email.toLowerCase(),
emailVerified: session.user.emailVerified === true,
}
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -245,7 +257,7 @@ export function createChooseHandleRouter(
const result = await getFlowAndSession(req, res)
if (!result) return

const { flowId, flow, email } = result
const { flowId, flow, email, emailVerified } = result

// Guard: reject flows with handleMode='random' — they should skip the picker entirely
if (flow.handleMode === 'random') {
Expand Down Expand Up @@ -405,6 +417,7 @@ export function createChooseHandleRouter(
new_account: '1',
handle: normalizedLocal,
epds_handle_mode: flow.handleMode ?? '',
email_verified: emailVerified ? '1' : '0',
}
if (flow.clientId) callbackParams.client_id = flow.clientId
const { sig, ts } = signCallback(
Expand Down
40 changes: 34 additions & 6 deletions packages/auth-service/src/routes/complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ export function buildEpdsCallbackUrl(args: {
email: string
isNewAccount: boolean
flowHandleMode?: string | null
/**
* Whether this sign-in actually proved control of `email` — read
* from the better-auth session, which sets it when the emailed
* one-time code is verified. pds-core records email confirmation
* from this and nothing else, so it must reflect what the
* authenticating flow really established, not what the current
* flow happens to be. See CallbackParams.email_verified.
*/
emailVerified: boolean
pdsPublicUrl: string
epdsCallbackSecret: string
}): string {
Expand All @@ -78,6 +87,7 @@ export function buildEpdsCallbackUrl(args: {
email: args.email,
approved: '1',
new_account: args.isNewAccount ? '1' : '0',
email_verified: args.emailVerified ? '1' : '0',
}
if (args.flowClientId) callbackParams.client_id = args.flowClientId
if (args.flowHandleMode) callbackParams.epds_handle_mode = args.flowHandleMode
Expand All @@ -92,9 +102,9 @@ async function resolveCompleteIdentity(
ctx: AuthServiceContext,
pdsUrl: string,
internalSecret: string,
): Promise<{ email: string; did: string | null }> {
): Promise<{ email: string; did: string | null; viaRecovery: boolean }> {
const did = await getDidByEmail(email, pdsUrl, internalSecret)
if (did) return { email, did }
if (did) return { email, did, viaRecovery: false }

// Recovery path: session email is a backup email, not a primary. Resolve
// the backup-email -> DID mapping (auth-service-owned) and then DID ->
Expand All @@ -106,13 +116,16 @@ async function resolveCompleteIdentity(
pdsUrl,
internalSecret,
)
if (!recovered) return { email, did: null }
if (!recovered) return { email, did: null, viaRecovery: false }

logger.info(
{ flowId, did: recovered.did },
'Recovery: translated backup email to primary email via DID',
)
return { email: recovered.email, did: recovered.did }
// Reported so the caller can withhold the email-verified claim: the
// returned address is the account's primary, which this sign-in proved
// nothing about.
return { email: recovered.email, did: recovered.did, viaRecovery: true }
}

export function createCompleteRouter(
Expand Down Expand Up @@ -169,6 +182,7 @@ export function createCompleteRouter(
},
email: string,
flowId: string,
emailVerified: boolean,
): Promise<void> {
const ping = await pingParRequest(flow.requestUri, pdsUrl, internalSecret)
if (!ping.ok) {
Expand All @@ -183,6 +197,7 @@ export function createCompleteRouter(
email,
isNewAccount: true,
flowHandleMode: flow.handleMode,
emailVerified,
pdsPublicUrl: ctx.config.pdsPublicUrl,
epdsCallbackSecret: ctx.config.epdsCallbackSecret,
})
Expand Down Expand Up @@ -246,22 +261,34 @@ export function createCompleteRouter(

const sessionEmail = session.user.email.toLowerCase()

// Whether this sign-in proved control of the address the user
// actually authenticated with. better-auth sets emailVerified when
// the emailed one-time code is verified, so it is the authoritative
// answer for `sessionEmail`.
const sessionEmailVerified: boolean = session.user.emailVerified === true

// Step 4: Check whether this is a new user (no PDS account for email).
const { email, did } = await resolveCompleteIdentity(
const { email, did, viaRecovery } = await resolveCompleteIdentity(
sessionEmail,
flowId,
ctx,
pdsUrl,
internalSecret,
)

// Recovery rebinds `email` from the proved backup address to the
// account's primary. The user proved control of the backup, not the
// primary, so recovering access is not evidence about the address
// being signed here — withhold the claim.
const emailVerified = sessionEmailVerified && !viaRecovery

const isNewAccount = !did

if (isNewAccount && flow.handleMode === 'random') {
// Step 5a: skip the handle picker, let pds-core call
// generateRandomHandle() (signalled by the absent `handle`
// field in the signed callback).
await redirectNewUserRandomMode(res, flow, email, flowId)
await redirectNewUserRandomMode(res, flow, email, flowId, emailVerified)
return
}

Expand Down Expand Up @@ -290,6 +317,7 @@ export function createCompleteRouter(
email,
isNewAccount: false,
flowHandleMode: flow.handleMode,
emailVerified,
pdsPublicUrl: ctx.config.pdsPublicUrl,
epdsCallbackSecret: ctx.config.epdsCallbackSecret,
})
Expand Down
4 changes: 3 additions & 1 deletion packages/pds-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
"scripts": {
"build": "tsc --build",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js"
"start": "node dist/index.js",
"backfill:email-confirmed": "node dist/backfill-email-confirmed.js",
"backfill:email-confirmed:dev": "tsx src/backfill-email-confirmed.ts"
},
"dependencies": {
"@atproto/crypto": "^0.4.5",
Expand Down
Loading
Loading