Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ccf4ece
fix(auth-service): hide Resend on OTP screen when sign-in cannot recover
aspiers May 7, 2026
a5cb519
fix(demo): cookie outlasts OTP form sit times + honest error when it …
aspiers May 7, 2026
988be35
fix(auth-service): "Use different email" clears the email field + foc…
aspiers May 7, 2026
073da54
fix(auth-service): don't flash "Invalid OTP" on an empty Verify click
aspiers May 7, 2026
4e23ee1
fix(auth-service): inline Send-a-new-code on "Too many attempts" lockout
aspiers May 7, 2026
f6a243c
fix(demo): surface PDS timeout error_description as session_expired
aspiers May 7, 2026
4a899b6
fix(auth-service): friendlier stale-recovery-link error message
aspiers May 7, 2026
f01f703
fix(auth-service): also inline Resend after the post-lockout "Invalid…
aspiers May 7, 2026
7a177e5
fix(auth-service): friendlier stale-authorize-link error message
aspiers May 7, 2026
7dbd5d7
fix(auth-service): recovery link carries the real request_uri, not a …
aspiers May 7, 2026
11ea1cc
test(auth-service): unit-test the new login-page UX guards
aspiers May 7, 2026
5eddf75
fix(auth-service): filter pasted OTP content to the configured charset
aspiers May 7, 2026
a3f594a
fix(auth-service): filter typed OTP keystrokes to the configured charset
aspiers May 7, 2026
4aab907
fix(auth-service): account-login surfaces honest lockout message
aspiers May 7, 2026
3b82b31
test(auth-service): unit-test account-login error-message picker
aspiers May 7, 2026
010f0c0
fix(demo): invalid_grant on token exchange surfaces as session_expired
aspiers May 7, 2026
f900a9a
refactor(auth-service): share OTP-verify error-message picker between…
aspiers May 7, 2026
e0c367b
fix(auth-service): clear OTP boxes on Resend so old typing does not l…
aspiers May 7, 2026
74459b8
feat(auth-service): hint to check spam folder on OTP form
aspiers May 7, 2026
452eec3
test(auth-service): dedupe OTP-verify-error tests via shared constant…
aspiers May 7, 2026
453d9a5
fix(auth-service): explicit autocomplete=email on every email input
aspiers May 7, 2026
35e76d2
fix(auth-service): make sign-in errors announce to screen readers
aspiers May 7, 2026
c1cd111
fix(demo): friendlier error banners, no developer-facing wording
aspiers May 7, 2026
619eedf
fix(auth-service): charset filter on account-login + recovery OTP inputs
aspiers May 7, 2026
d3e75a2
fix(auth-service): handle picker error says "not available", not "jus…
aspiers May 7, 2026
97e5acb
test(e2e): de-flake @demo-cookie-expiry by skipping the email round-trip
aspiers May 7, 2026
fe13498
fix(auth-service): SMS / email autofill distributes the OTP across boxes
aspiers May 7, 2026
83de694
fix(pds-core): handle picker live-check flags reserved handles as una…
aspiers May 7, 2026
50077c0
fix(auth-service): visible keyboard focus on Verify and Resend buttons
aspiers May 7, 2026
645a2fb
fix(auth-service): visible keyboard focus on remaining route buttons
aspiers May 7, 2026
1493801
chore(changeset): keyboard focus-visible on buttons
aspiers May 7, 2026
116f833
fix(demo): error banner announces to screen readers via role=alert
aspiers May 7, 2026
fea121e
refactor(pds-core): extract handleIsUnavailable for the check-handle …
aspiers May 7, 2026
5fc9837
fix(shared): renderError adds role=alert on the error <p>
aspiers May 7, 2026
3ed32b9
fix(shared): malformed client_id no longer leaks into displayed app name
aspiers May 7, 2026
40ad0e0
fix(auth-service): account settings shows success / error banners aft…
aspiers May 7, 2026
473f6a0
refactor(auth-service): extract flash-resolver from /account route ha…
aspiers May 7, 2026
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/hide-resend-when-sign-in-cannot-recover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'ePDS': patch
---

Sign-in no longer offers "Resend code" when the new code wouldn't have worked anyway.

**Affects:** End users

**End users:** Previously, if you sat on the email-code step long enough that the underlying sign-in had silently timed out (most often: leaving the tab in the background while reading email on your phone, or coming back after an interruption), the page would still show **Resend code**. Clicking it sent you a fresh email, but the moment you typed the new code you'd see "Sign in failed" — the code was issued for a sign-in that could no longer complete, so it never had a chance.

The page now hides the Resend button as soon as it knows the sign-in can't be recovered, and shows **Start over** in its place. Clicking Start over takes you back to the app you came from to begin again, instead of letting you waste time on a code that couldn't work.

If you're actively using the page (the tab in the foreground), nothing changes: Resend stays available and works the same way it always has.
9 changes: 9 additions & 0 deletions .changeset/use-different-email-clears-form.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': patch
---

"Use different email" on the sign-in code page now clears the email field.

**Affects:** End users

**End users:** clicking **Use different email** on the code-entry page used to take you back to the email form with your previous address still filled in — exactly the opposite of what the button suggests, and forcing you to clear the field manually before you could type a new address. The form now starts empty, with the cursor in the field, so you can just type the new address and continue.
100 changes: 100 additions & 0 deletions e2e/step-definitions/auth.steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -839,3 +839,103 @@ Then(
}
},
)

// ---------------------------------------------------------------------------
// Resend-button visibility (fix for the "fresh OTP wasted on dead PAR" UX)
// ---------------------------------------------------------------------------
//
// The page never offers an action that cannot complete the flow. When the
// PAR is dead, the standalone Resend button is removed from view and a
// Start over button takes its place — clicking it bails to /auth/abort
// rather than issuing an OTP that would only fail downstream. The steps
// below trigger the page's reactive ping (via the visibilitychange
// handler that fires on tab-foreground) so it can observe the dead PAR
// and reconcile the UI without a 5-minute wall-clock wait.

When('the OTP form re-checks PAR liveness', async function (this: EpdsWorld) {
const page = getPage(this)
// Drive the page's reactive ping via a string-source script.
// Using page.evaluate(() => ...) inlines esbuild's __name helper,
// which then fails in Playwright's evaluation context with
// "ReferenceError: __name is not defined". Passing a string
// bypasses the bundler.
await page.evaluate(`(function () {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: function () { return 'visible' },
})
Comment on lines +872 to +875
document.dispatchEvent(new Event('visibilitychange'))
})()`)
Comment on lines +871 to +877
})

Then(
'the Resend code button is no longer offered',
async function (this: EpdsWorld) {
const page = getPage(this)
await expect(page.locator('#btn-resend')).toBeHidden({ timeout: 5_000 })
},
)

Then(
'a Start over button is offered instead',
async function (this: EpdsWorld) {
const page = getPage(this)
await expect(page.locator('#btn-start-over')).toBeVisible({
timeout: 5_000,
})
},
)

// ---------------------------------------------------------------------------
// Demo client cookie expiry simulation
// ---------------------------------------------------------------------------
//
// The demo client stores OAuth state (state value, codeVerifier, token
// endpoint, issuer) in a signed cookie called `oauth_state` with
// `maxAge: 600` (see packages/demo/src/app/api/oauth/login/route.ts).
// If the user spends longer than 10 minutes between starting the OAuth
// flow and the callback firing — most realistic cause: dawdling on the
// OTP form, then clicking Resend after the 10-minute mark — the cookie
// expires before the callback runs, so the callback handler can't find
// the OAuth state and silently bounces to /?error=auth_failed.
//
// This step deletes the cookie programmatically so we can exercise the
// post-cookie-expiry callback path without a 10-minute wall-clock wait.
Comment on lines +904 to +912
Comment on lines +904 to +912
Comment on lines +904 to +912
Comment on lines +904 to +912
Comment on lines +903 to +912
Comment on lines +904 to +912
Comment on lines +904 to +912
Comment on lines +904 to +912
Comment on lines +903 to +912
Comment on lines +904 to +912
Comment on lines +903 to +912
Comment on lines +904 to +912
Comment on lines +904 to +912
Comment on lines +904 to +912

When(
"the demo client's OAuth state cookie has expired",
async function (this: EpdsWorld) {
const page = getPage(this)
const ctx = page.context()
const before = await ctx.cookies()
const target = before.find((c) => c.name === 'oauth_state')
if (!target) {
throw new Error(
`Expected to find an oauth_state cookie set by the demo client but only saw: ${before.map((c) => c.name).join(', ')}`,
)
}
await ctx.clearCookies({ name: 'oauth_state' })
},
)

Then(
'the demo client surfaces a session-expired error',
async function (this: EpdsWorld) {
const origin = new URL(testEnv.demoUrl).origin
const page = getPage(this)
await page.waitForURL(`${origin}/?error=session_expired*`, {
timeout: 30_000,
})
},
)

// ---------------------------------------------------------------------------
// "Use different email" UX
// ---------------------------------------------------------------------------

Then('the email input is empty and focused', async function (this: EpdsWorld) {
const page = getPage(this)
const input = page.locator('#email')
await expect(input).toHaveValue('', { timeout: 5_000 })
await expect(input).toBeFocused({ timeout: 5_000 })
})
70 changes: 70 additions & 0 deletions features/passwordless-authentication.feature
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,76 @@ Feature: Passwordless authentication via email OTP
And the user requests a new OTP via the resend button
Then the browser lands back at the demo client with an auth error

# The page never offers actions that cannot complete the flow. When
# the upstream PAR has died (silent timeout, suspended tab,
# heartbeat throttling), the standalone Resend button is removed
# from view and replaced with a Start over button — so the user
# never wastes time typing a fresh OTP that could not have worked.
# This is the proactive complement to @resend-after-par-dead's
# reactive abort gate: rather than letting the click happen and
# bouncing it server-side, we surface only forward paths that can
# actually succeed.
@email @otp-and-par-expiry @resend-hidden-when-par-dead
Scenario: Resend button is hidden when the PAR has died — Start over is offered instead
When the demo client initiates an OAuth login
Then the browser is redirected to the auth service login page
And the login page displays an email input form
When the user enters a unique test email and submits
Then an OTP email arrives in the mail trap for the test email
And the login page shows an OTP verification form
When the PAR request_uri has expired before the bridge fires
And the OTP form re-checks PAR liveness
Then the Resend code button is no longer offered
And a Start over button is offered instead

# The demo OAuth client stores its OAuth state (state value, code
# verifier, token endpoint, issuer) in a signed cookie that has its
# own lifetime. If that cookie expires before the auth-service
# bridges the user back via /oauth/epds-callback (e.g. user
# dawdled on the OTP form long enough that the cookie aged out
# mid-flow), the demo's callback handler can't find the OAuth state
# and silently bounces to the demo home page with
# `?error=auth_failed`. The user has just typed a fresh OTP
# successfully, so this is genuinely time-wasting and misleading:
# the auth-service did everything right but the demo dropped the
# ball.
Comment on lines +389 to +399
#
# The contract the demo MUST satisfy: as long as the OAuth flow is
# still recoverable from the auth-service side (auth_flow row
# alive, PAR alive or refreshable), the demo's session cookie must
# also be alive. This scenario reproduces the failure mode by
# programmatically clearing the demo's `oauth_state` cookie just
# before the OTP submission, which is equivalent to the cookie
# having lapsed by wall-clock.
Comment on lines +404 to +407
Comment on lines +392 to +407
# The "Use different email" button on the OTP step takes the user
# back to the email-entry form so they can sign in with a different
# address. The form must be EMPTY when they get there — leaving the
# prior email pre-filled is exactly the misleading "looks like the
# form remembered me" UX that the button was meant to escape from,
# and forces the user to manually clear the field before they can
# type their actual email.
@email @use-different-email
Scenario: "Use different email" returns the user to a clean email form
When the demo client initiates an OAuth login
Then the browser is redirected to the auth service login page
And the login page displays an email input form
When the user enters a unique test email and submits
Then the login page shows an OTP verification form
When the user clicks "Use different email"
Then the email input is empty and focused

@email @demo-cookie-expiry @bug-report
Scenario: Demo client's OAuth cookie has expired by the time of callback — useful error, not generic auth_failed
When the demo client starts a new OAuth flow with random handle mode
Then the browser is redirected to the auth service login page
And the login page displays an email input form
When the user enters a unique test email and submits
Then an OTP email arrives in the mail trap for the test email
And the login page shows an OTP verification form
When the demo client's OAuth state cookie has expired
And the user enters the OTP code
Then the demo client surfaces a session-expired error

@email @otp-and-par-expiry @prompt-login
Scenario: prompt=login + expired PAR — clean exit back to the OAuth client
Given a returning user has a PDS account
Expand Down
114 changes: 102 additions & 12 deletions packages/auth-service/src/routes/login-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,17 +743,38 @@ export function renderLoginPage(opts: {
var heartbeatEnabled = ${JSON.stringify(opts.heartbeatEnabled)};
var heartbeatHandle = null;
var heartbeatIntervalMs = 3 * 60 * 1000;
// Upstream's AUTHORIZATION_INACTIVITY_TIMEOUT — once this much
// wall-clock time has elapsed since our last successful PAR
// refresh, the upstream row is guaranteed to be dead. Used by
// parLikelyDead() to hide Resend before the user can click it.
var parInactivityTimeoutMs = 5 * 60 * 1000;
// Page load is the implicit first PAR refresh — atproto's
// PAR_EXPIRES_IN gives a fresh row 5 min on creation, and the
// user just hit /oauth/authorize seconds ago. Treat now as
// last-known-alive until the first ping confirms otherwise.
var lastSuccessfulHeartbeatAt = Date.now();
Comment on lines +772 to +780
// Set to true the moment we know the flow can no longer
// complete (PAR or auth_flow gone). Resend / Verify gates
// check this so a click that races the proactive notice
// still bails to /auth/abort instead of issuing a fresh OTP
// that would only fail.
var flowAborted = false;
// True iff we have proof the PAR is still alive (last ping
// was ok:true and was recent enough to fall inside the
// upstream inactivity window). Used to gate every "offer the
// user a Resend" decision so they only ever see actions that
// can actually complete the flow.
function parLikelyDead() {
if (flowAborted) return true;
return Date.now() - lastSuccessfulHeartbeatAt >= parInactivityTimeoutMs;
}
Comment on lines +803 to +806
Comment on lines +803 to +806
function pingHeartbeat() {
return fetch('/auth/ping', { credentials: 'include', cache: 'no-store' })
.then(function(r) { return r.json(); })
.then(function(body) {
if (body && body.ok === false && body.reason !== 'transient') {
if (body && body.ok === true) {
lastSuccessfulHeartbeatAt = Date.now();
} else if (body && body.ok === false && body.reason !== 'transient') {
Comment on lines +811 to +813
// Auth flow / PAR genuinely dead — no point pinging again,
// and no point letting the user keep typing. 'transient'
// (5xx / network blip) does NOT stop the interval; the
Expand All @@ -763,7 +784,13 @@ export function renderLoginPage(opts: {
}
return body;
})
.catch(function() { return null; /* network blip — caller may retry */ });
.catch(function() { return null; /* network blip — caller may retry */ })
.finally(function() {
// Always reconcile visibility — a 'transient' tick that
// pushes us past the inactivity window must hide Resend
// even though we never got a definitive 'par_expired'.
refreshResendVisibility();
});
Comment on lines +823 to +829
}
function startHeartbeat() {
if (!heartbeatEnabled) return;
Expand All @@ -777,6 +804,16 @@ export function renderLoginPage(opts: {
}
}
window.addEventListener('beforeunload', stopHeartbeat);
// When the tab returns to the foreground after being hidden,
// setInterval may have been throttled enough that PAR has
// silently lapsed. Re-ping immediately so the UI reflects
// reality before the user clicks anything.
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'visible' && heartbeatEnabled) {
pingHeartbeat();
refreshResendVisibility();
}
});

// Show the proactive "this won't work — start over" notice when
// the flow is unrecoverable. Disables the OTP boxes, the verify
Expand Down Expand Up @@ -819,6 +856,41 @@ export function renderLoginPage(opts: {
errorEl.appendChild(startOverBtn);
}

/**
* Toggle the standalone Resend button between visible and
* hidden based on whether the PAR is still alive. The button
* is removed from view (display:none) rather than just
* disabled — a button the user cannot productively click
* shouldn't be on the page at all. When hidden, a "Start over"
* link is shown in its place so the user always has a forward
* path. Idempotent — safe to call from heartbeat ticks,
* visibility change handlers, and inline render paths.
*/
function refreshResendVisibility() {
var resendBtn = document.getElementById('btn-resend');
var startOverLink = document.getElementById('btn-start-over');
if (!resendBtn) return;
if (parLikelyDead()) {
resendBtn.style.display = 'none';
if (!startOverLink) {
startOverLink = document.createElement('button');
startOverLink.type = 'button';
startOverLink.id = 'btn-start-over';
startOverLink.className = 'btn-secondary';
startOverLink.textContent = 'Start over';
startOverLink.addEventListener('click', function() {
window.location.href = '/auth/abort';
});
Comment on lines +912 to +919
resendBtn.parentNode.insertBefore(startOverLink, resendBtn);
}
Comment on lines +909 to +921
Comment on lines +905 to +921
} else {
Comment on lines +905 to +922
resendBtn.style.display = '';
if (startOverLink && startOverLink.parentNode) {
startOverLink.parentNode.removeChild(startOverLink);
Comment on lines +901 to +925
}
}
}

/**
* Reactive gate used by the Resend and Verify click handlers.
* Pings /auth/ping synchronously; if the result indicates the
Expand Down Expand Up @@ -983,6 +1055,7 @@ export function renderLoginPage(opts: {
if (otpBoxes.length) otpBoxes[0].focus();
clearError();
startHeartbeat();
refreshResendVisibility();
}

function showEmailStep() {
Expand All @@ -992,6 +1065,15 @@ export function renderLoginPage(opts: {
if (termsEl) termsEl.style.display = 'block';
clearError();
stopHeartbeat();
// Reset the email field — the user clicked "Use different
// email" precisely to escape the previous value, so leaving
// it pre-filled both wastes a clearing keystroke and looks
// like the form remembered them when they wanted a fresh
// start. Focus the input so they can start typing
// immediately.
emailInput.value = '';
currentEmail = '';
emailInput.focus();
}

// Send OTP via better-auth
Expand Down Expand Up @@ -1104,15 +1186,21 @@ export function renderLoginPage(opts: {
// expired") plus generic "expir"/"too long" variants.
var isExpired = /expir|too long/i.test(result.error);
if (isExpired) {
// The inline action triggers the same Resend handler,
// which itself runs abortIfFlowDead() before issuing
// a new code. So even if the PAR is dead the user
// gets the spec-compliant bounce rather than a fresh
// OTP that wouldn't work — no need to gate the
// action's visibility separately here.
showErrorWithAction(result.error, 'Send a new code', function() {
document.getElementById('btn-resend').click();
});
// Only offer "Send a new code" when the PAR is still
// alive. If it isn't, a fresh OTP would issue but
// never complete — wasting the user's time on a code
// that can't work. Show "Start over" instead so the
// only forward path we surface is one that will
// actually succeed.
if (parLikelyDead()) {
showErrorWithAction(result.error, 'Start over', function() {
window.location.href = '/auth/abort';
});
} else {
showErrorWithAction(result.error, 'Send a new code', function() {
document.getElementById('btn-resend').click();
});
}
} else {
showError(result.error);
}
Expand Down Expand Up @@ -1185,8 +1273,10 @@ export function renderLoginPage(opts: {
});
}
// OTP form is already visible server-side; showOtpStep() never
// ran, so kick off the heartbeat ourselves.
// ran, so kick off the heartbeat ourselves and reflect the
// current PAR-liveness state in the Resend button visibility.
startHeartbeat();
refreshResendVisibility();
}
})();
</script>
Expand Down
Loading
Loading