Skip to content

Commit 87e3d61

Browse files
aspiersclaude
andcommitted
fix(pds-core): friendly OAuth redirect when PAR has expired
The /oauth/epds-callback handler caught all failures with a single res.json({error: 'Authentication failed'}). The catch did try to redirect back to the client first, but it called requestManager.get (requestUri) to fetch the redirect_uri — and that's the very call whose failure brought us into the catch in the first place. By the time we got there, @atproto/oauth-provider had already deleted the expired PAR row inside RequestManager.get() (line 244 of upstream's request-manager.js: expired rows throw AccessDeniedError AND are deleted in the same call), so the recovery re-fetch threw too and we fell through to raw JSON. A real user just hit this: they took their time on the OTP step, the PAR died, and they ended up staring at {"error":"Authentication failed"} on the PDS host with no way to retry from there. Two changes: 1. Stash redirect_uri and state on the closure as soon as Step 2's requestManager.get() returns successfully. The catch block now reads from these captured values instead of attempting a doomed re-fetch. When the captures are empty (Step 2 itself threw — the PAR was already dead on entry), we fall through to the HTML page below. 2. Render a styled HTML page via the existing renderError() helper when no redirect_uri is recoverable, instead of leaking JSON. Status: 400 for the expected expiry case (matching upstream's access_denied semantics), 500 for an unexpected one (matching server_error). For PAR expiry specifically (the bug the user hit), the redirect now carries error=access_denied and an error_description that reads "Your sign-in took too long to complete and timed out. Please start sign-in again." — readable, descriptive copy that an OAuth client can show directly to the user. access_denied matches the error code @atproto/oauth-provider already uses for PAR expiry on its own paths (e.g. when a user is bounced through /oauth/authorize with an expired request_uri), so clients see one error code regardless of which path inside the AS surfaced the timeout. The user-friendly text lives in error_description, which is where clients are supposed to source human-readable copy from. Turns @par-callback-error from RED to GREEN. Changeset describes the user- and developer-visible behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ac35ba8 commit 87e3d61

2 files changed

Lines changed: 80 additions & 23 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'ePDS': patch
3+
---
4+
5+
Sign-in no longer fails with a raw JSON error page when a user takes too long on the OTP step.
6+
7+
**Affects:** End users, OAuth client developers
8+
9+
**End users:** Previously, if you took more than five minutes between requesting your one-time code and submitting it (a slow inbox, switching tabs, fishing the code out of spam, multiple Resend cycles), sign-in could fail with a blank page showing only `{"error": "Authentication failed"}` on the PDS host — even though your OTP code itself was still valid. You now get redirected back to the app you were signing into with a standard OAuth error and a human-readable message explaining that sign-in timed out, so the app can offer to start the flow again. If a redirect back to the app isn't possible, you see a styled error page on the PDS instead of the raw JSON.
10+
11+
**OAuth client developers:** when a sign-in expires, `/oauth/epds-callback` now redirects to your registered `redirect_uri` with `error=access_denied`, `error_description` (a sentence explaining the timeout), `iss`, and `state` query parameters per RFC 6749 §4.1.2.1, mirroring the error code that `@atproto/oauth-provider` already uses for PAR expiry on its own paths. Apps that were checking for the previous JSON shape on this endpoint should switch to handling the standard OAuth error redirect, and source human-readable copy from `error_description` rather than from `error`.

‎packages/pds-core/src/index.ts‎

Lines changed: 69 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,18 @@ async function main() {
253253
return
254254
}
255255

256+
// Captured from Step 2's requestManager.get() — used by the catch
257+
// block to redirect any later failure back to the client per RFC
258+
// 6749 §4.1.2.1, even when the PAR row has since been deleted (in
259+
// particular: RequestManager.get() deletes any expired row in the
260+
// same call that throws AccessDeniedError, so by the time the
261+
// catch block runs there's nothing left to re-read). Empty when
262+
// Step 2 itself threw — i.e. the PAR was already dead on entry,
263+
// the case the @par-callback-error scenario covers — and the
264+
// catch falls through to a styled HTML page in that branch.
265+
let capturedRedirectUri: string | undefined
266+
let capturedState: string | undefined
267+
256268
try {
257269
// Step 1: Load or create device session
258270
const deviceInfo = await provider.deviceManager.load(
@@ -293,6 +305,12 @@ async function main() {
293305
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @atproto/oauth-provider requestManager not exported
294306
const requestData = await (provider.requestManager as any).get(requestUri)
295307
const { clientId } = requestData
308+
// Stash redirect_uri/state now while the PAR is alive — if a later
309+
// step throws and the row has since been deleted (e.g. flushed
310+
// post-success or the test-only delete-par hook), the catch block
311+
// can still mount an RFC 6749 redirect to the client.
312+
capturedRedirectUri = requestData?.parameters?.redirect_uri
313+
capturedState = requestData?.parameters?.state
296314

297315
// Step 3: Resolve or create the account.
298316
// Use the PDS accountManager directly — account.sqlite is the single source of truth.
@@ -555,33 +573,61 @@ async function main() {
555573
} catch (err) {
556574
logger.error({ err }, 'ePDS callback error')
557575

558-
// Try to redirect error back to client
559-
try {
560-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @atproto/oauth-provider requestManager not exported
561-
const requestData = await (provider.requestManager as any).get(
562-
requestUri,
576+
// Distinguish a dead PAR ("This request has expired" /
577+
// InvalidGrantError on a missing row) from a generic server
578+
// failure: the former is a normal user-paced timeout that
579+
// upstream @atproto/oauth-provider already uses
580+
// error=access_denied for on its own paths, the latter is
581+
// genuinely server_error per RFC 6749 §4.1.2.1. We follow the
582+
// upstream precedent for the timeout case so an OAuth client
583+
// sees the same `error` value regardless of which path inside
584+
// the AS surfaced the expiry — the user-friendly explanation
585+
// lives in error_description, which is where clients are
586+
// supposed to source their human-readable copy from.
587+
const errMsg = err instanceof Error ? err.message : String(err)
588+
// Match the upstream-thrown messages for both flavours of dead
589+
// PAR: "This request has expired" (AccessDeniedError, row was
590+
// alive at request time but past its expiresAt) and "Unknown
591+
// request_uri" (InvalidRequestError, row was already gone). We
592+
// also keep a generic "expired" / "invalid_grant" catch-all in
593+
// case upstream rewords its messages in a future patch release.
594+
const isExpired =
595+
/request has expired|unknown request_uri|invalid_grant|expired/i.test(
596+
errMsg,
563597
)
564-
const redirectUri = requestData?.parameters?.redirect_uri
565-
if (redirectUri) {
566-
const errorUrl = new URL(redirectUri)
567-
errorUrl.searchParams.set('error', 'server_error')
568-
errorUrl.searchParams.set(
569-
'error_description',
570-
'Authentication failed',
571-
)
572-
errorUrl.searchParams.set('iss', pdsUrl)
573-
if (requestData.parameters.state) {
574-
errorUrl.searchParams.set('state', requestData.parameters.state)
575-
}
576-
res.redirect(303, errorUrl.toString())
577-
return
578-
}
579-
} catch {
580-
// Fall through
598+
const errorCode = isExpired ? 'access_denied' : 'server_error'
599+
const errorDescription = isExpired
600+
? 'Your sign-in took too long to complete and timed out. Please start sign-in again.'
601+
: 'Authentication failed.'
602+
603+
// We rely on the redirect_uri / state captured during Step 2's
604+
// requestManager.get(). Don't re-fetch here: by the time we
605+
// reach this branch the PAR may have just been deleted by the
606+
// very call that threw above (RequestManager.get() deletes any
607+
// expired row in the same call), so a second fetch would only
608+
// ever throw the same error and give us no new information.
609+
// When the captured values are empty (i.e. Step 2 itself threw
610+
// before populating them — the PAR was already dead on entry),
611+
// we fall through to the HTML fallback below.
612+
if (!res.headersSent && capturedRedirectUri) {
613+
const errorUrl = new URL(capturedRedirectUri)
614+
errorUrl.searchParams.set('error', errorCode)
615+
errorUrl.searchParams.set('error_description', errorDescription)
616+
errorUrl.searchParams.set('iss', pdsUrl)
617+
if (capturedState) errorUrl.searchParams.set('state', capturedState)
618+
res.redirect(303, errorUrl.toString())
619+
return
581620
}
582621

622+
// No redirect_uri to send the user back to. Render a styled HTML
623+
// page on the PDS host instead of leaking JSON to the browser.
624+
// Status: 400 for an expected expiry, 500 for an unexpected
625+
// failure, matching the semantics of errorCode.
583626
if (!res.headersSent) {
584-
res.status(500).json({ error: 'Authentication failed' })
627+
res
628+
.status(isExpired ? 400 : 500)
629+
.type('html')
630+
.send(renderError(errorDescription))
585631
}
586632
}
587633
})

0 commit comments

Comments
 (0)