Skip to content

Instantly share code, notes, and snippets.

@daviddao
Last active March 3, 2026 13:27
Show Gist options
  • Select an option

  • Save daviddao/ec591de6c18eb1c4b0f4b5bf66863db8 to your computer and use it in GitHub Desktop.

Select an option

Save daviddao/ec591de6c18eb1c4b0f4b5bf66863db8 to your computer and use it in GitHub Desktop.
ePDS How To

How to add ePDS (email login) to an ATProto Next.js app

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.

Prerequisites

You already have:

  • A NodeOAuthClient singleton (we call ours getGlobalOAuthClient())
  • Redis-backed stateStore and sessionStore wired into that client
  • A working /api/oauth/callback route that calls client.callback(params)
  • ATPROTO_JWK_PRIVATE env var with your ES256 JWK (for confidential clients)

You add one env var:

NEXT_PUBLIC_EPDS_URL=https://epds1.test.certified.app

File 1: Login route

src/app/api/oauth/epds/login/route.tsthe 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))
  }
}

Why this works

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.


File 2: Callback route

src/app/api/oauth/epds/callback/route.tsthe 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,
    )
  }
}

Why this works

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.


One config change: register the ePDS callback URI

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 one

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


What NOT to do

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment