Your app already uses @atproto/oauth-client-node for handle-based login (alice.bsky.social).
Adding ePDS email login requires zero new dependencies and two small route files.
The same NodeOAuthClient SDK that handles handle login handles ePDS login — because to
the SDK, an ePDS URL is just another authorization server.
You already have:
- A
NodeOAuthClientsingleton (we call oursgetGlobalOAuthClient()) - Redis-backed
stateStoreandsessionStorewired into that client - A working
/api/oauth/callbackroute that callsclient.callback(params) ATPROTO_JWK_PRIVATEenv var with your ES256 JWK (for confidential clients)
You add one env var:
NEXT_PUBLIC_EPDS_URL=https://epds1.test.certified.app
src/app/api/oauth/epds/login/route.ts — the entire file:
import { NextRequest, NextResponse } from 'next/server'
import { getGlobalOAuthClient } from '@/lib/auth/client'
import { OAUTH_SCOPE } from '@/lib/env'
import { getRawSession } from '@/lib/session'
export const dynamic = 'force-dynamic'
// Prevent open-redirect attacks on the returnTo parameter.
// '//evil.com' passes a naive startsWith('/') check, so we parse as URL.
function isLocalPath(path: string): boolean {
try {
const url = new URL(path, 'http://localhost')
return url.host === 'localhost' && !path.startsWith('//')
} catch {
return false
}
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const email = searchParams.get('email') ?? undefined
const returnTo = searchParams.get('returnTo') ?? undefined
// Persist the returnTo path in the server-side session so the callback
// can redirect back to wherever the user was before login.
if (returnTo && isLocalPath(returnTo)) {
const session = await getRawSession()
session.returnTo = returnTo
await session.save()
}
// Feature gate: bail if ePDS is not configured.
const epdsUrl = process.env.NEXT_PUBLIC_EPDS_URL
if (!epdsUrl) {
console.error('[epds/login] NEXT_PUBLIC_EPDS_URL not configured')
return NextResponse.redirect(new URL('/?error=auth_failed', request.url))
}
//
// THIS IS THE CORE — one SDK call does everything.
//
// client.authorize() accepts three kinds of input:
// 1. A handle → 'alice.bsky.social' (resolves handle → DID → PDS → AS metadata)
// 2. A DID → 'did:plc:abc123' (resolves DID → PDS → AS metadata)
// 3. A PDS URL → 'https://epds1...' (fetches AS metadata directly) ← this one
//
// For an ePDS URL, the SDK:
// a) GETs {epdsUrl}/.well-known/oauth-authorization-server
// → learns the PAR endpoint, authorization endpoint, token endpoint, issuer
// b) Generates a PKCE code_verifier + code_challenge (S256)
// c) Generates an ephemeral EC P-256 DPoP key pair
// d) If the client is confidential (private_key_jwt), creates a client_assertion
// JWT signed with your ATPROTO_JWK_PRIVATE key
// e) POSTs a Pushed Authorization Request (PAR) to the PAR endpoint
// with: client_id, redirect_uri, scope, code_challenge, DPoP proof,
// and client_assertion (if confidential)
// f) Gets back a request_uri
// g) Stores {code_verifier, dpop_key, redirect_uri, ...} in the Redis stateStore
// h) Returns the authorization URL: {auth_endpoint}?client_id=...&request_uri=...
//
// You do NOT need to implement any of steps (a)–(h) yourself.
//
const client = await getGlobalOAuthClient()
const authUrl = await client.authorize(epdsUrl, {
scope: OAUTH_SCOPE,
})
// The SDK's returned URL doesn't include login_hint because it doesn't
// know the user's email. We append it so the ePDS can pre-fill the email
// field or skip the "enter your email" step.
const url = new URL(authUrl.toString())
if (email) {
url.searchParams.set('login_hint', email)
}
// Full-page redirect to the ePDS authorization UI.
// The user enters their email, receives an OTP code, enters it,
// and the ePDS redirects back to our callback with ?code=...&state=...
return NextResponse.redirect(url.toString())
} catch (err) {
console.error('[epds/login] Error:', err)
return NextResponse.redirect(new URL('/?error=auth_failed', request.url))
}
}The insight is that client.authorize() is identity-type-agnostic. The ATProto
OAuth spec defines that a PDS URL is a valid input to the authorization flow — the SDK
fetches /.well-known/oauth-authorization-server from that URL to discover all the
OAuth endpoints. This is the same discovery mechanism used when you pass a handle
(the SDK just does more resolution steps first: handle → DID → PDS URL → AS metadata).
An ePDS is just a PDS that supports email+OTP login. From the SDK's perspective, there is nothing special about it. The authorization server metadata tells the SDK everything it needs: where to send PAR requests, where to redirect the user, where to exchange tokens.
src/app/api/oauth/epds/callback/route.ts — the entire file:
import { NextRequest, NextResponse } from 'next/server'
import { Agent } from '@atproto/api'
import { getGlobalOAuthClient } from '@/lib/auth/client'
import { getRawSession, saveAppSession } from '@/lib/session'
import { clearAppSessionRecord } from '@/lib/auth/app-session-store'
export const dynamic = 'force-dynamic'
function isLocalPath(path: string): boolean {
try {
const url = new URL(path, 'http://localhost')
return url.host === 'localhost' && !path.startsWith('//')
} catch {
return false
}
}
export async function GET(request: NextRequest) {
try {
const client = await getGlobalOAuthClient()
const url = new URL(request.url)
const params = new URLSearchParams(url.search)
//
// client.callback() is the mirror of client.authorize().
//
// The ePDS redirected here with ?code=<authz_code>&state=<state>&iss=<issuer>.
//
// The SDK:
// a) Extracts the `state` param and looks it up in the Redis stateStore
// → retrieves the stored code_verifier, DPoP key, expected issuer, etc.
// b) Validates that the `iss` param matches the expected issuer (CSRF protection)
// c) POSTs to the token endpoint with:
// grant_type=authorization_code, code, redirect_uri, code_verifier,
// a fresh DPoP proof bound to the token endpoint URL,
// and client_assertion if confidential
// d) Receives { access_token, refresh_token, sub (DID), scope, expires_in }
// e) Validates the DID, stores the full session in the Redis sessionStore
// f) Deletes the used state from the stateStore (anti-replay)
// g) Returns { session: OAuthSession } — a live session object with the DID
//
// This is identical to what happens for handle-based login.
// The SDK doesn't know or care whether the user logged in via handle or email.
//
const { session: oauthSession } = await client.callback(params)
// --- Everything below is app-specific session management ---
// Try to fetch the user's profile (handle, display name, avatar).
// This may fail for brand-new ePDS accounts that don't have a Bluesky profile yet,
// so we fall back to using the DID as the display identifier.
let handle: string = oauthSession.did
let displayName: string | undefined
let avatar: string | undefined
try {
const agent = new Agent(oauthSession)
const { data: profile } = await agent.getProfile({ actor: oauthSession.did })
handle = profile.handle
displayName = profile.displayName
avatar = profile.avatar
} catch (err) {
console.warn('[epds/callback] Failed to fetch profile:', err)
}
// Read the returnTo path that was saved during the login step.
const rawSession = await getRawSession()
const returnTo = rawSession.returnTo || '/dashboard'
const previousSessionId = rawSession.sessionId
// Create the app-level session (separate from the OAuth token session).
// This sets an iron-session cookie and stores profile data in Redis.
const newSessionId = await saveAppSession({
did: oauthSession.did,
handle,
displayName,
avatar,
})
if (previousSessionId && previousSessionId !== newSessionId) {
await clearAppSessionRecord(previousSessionId)
}
console.log('[epds/callback] Session saved for DID:', oauthSession.did, 'handle:', handle)
const origin = new URL(request.url).origin
const redirectPath = isLocalPath(returnTo) ? returnTo : '/dashboard'
return NextResponse.redirect(`${origin}${redirectPath}`, 303)
} catch (error) {
console.error('[epds/callback] Error:', error)
const requestUrl = new URL(request.url)
return NextResponse.redirect(
`${requestUrl.origin}/?error=${encodeURIComponent('auth_failed')}`,
303,
)
}
}client.callback() matches the state query parameter to the state stored by
client.authorize(). It doesn't care which authorization server issued the callback —
it retrieves the stored context (code verifier, DPoP key, issuer, redirect_uri) and
completes the token exchange correctly.
This means your ePDS callback route is structurally identical to your handle callback
route. The only difference is the URL path (so the ePDS can redirect to a different
endpoint than the standard PDS flow, which matters for redirect_uris validation).
In fact, if you only have one redirect_uri registered, you don't even need a separate
callback route — the standard callback handles both flows transparently.
Your served client-metadata.json must list the ePDS callback in redirect_uris
so the ePDS authorization server accepts it during PAR validation:
// In your client metadata builder:
redirect_uris: process.env.NEXT_PUBLIC_EPDS_URL
? [standardCallbackUri, epdsCallbackUri] // both URIs
: [standardCallbackUri], // just the standard oneThe ePDS fetches your client-metadata.json during the PAR request and validates
that the redirect_uri in the PAR body matches one of the registered URIs.
Do not manually implement:
- PKCE (code_verifier / code_challenge) — the SDK generates and stores these
- DPoP key pairs or DPoP proof JWTs — the SDK creates, signs, and rotates these
- PAR requests — the SDK discovers the PAR endpoint and sends the request
- Token exchange — the SDK handles the authorization_code grant with all required proofs
- client_assertion JWTs — the SDK creates these automatically for confidential clients
- Endpoint discovery — the SDK fetches
.well-known/oauth-authorization-server - A separate Redis state store — the SDK uses the stateStore you already wired in
- NodeSavedSession construction — the SDK creates and stores these in your sessionStore
All of the above exist inside @atproto/oauth-client-node. Reimplementing them
introduces subtle bugs (wrong issuer, wrong audience in client_assertion, wrong
DPoP binding, wrong endpoint URLs) that produce opaque "authentication failed" errors.