Skip to content

Commit d0a5ecf

Browse files
aspiersclaude
andcommitted
fix(auth-service): hide Resend on OTP screen when sign-in cannot recover
Previously the OTP screen always offered "Resend code", even when the upstream PAR row had silently lapsed (suspended tab, mobile background, heartbeat throttling). The user could click Resend, receive a fresh email, type the new code, and only then see "Sign in failed" — wasting their time on a code that could not have worked. The screen now never surfaces actions that cannot complete the flow: - Track lastSuccessfulHeartbeatAt; treat the PAR as dead once we cross upstream's 5 min AUTHORIZATION_INACTIVITY_TIMEOUT without a fresh ok ping (the upstream death point is exact — no margin needed). - Hide #btn-resend and surface a #btn-start-over (→ /auth/abort) the moment parLikelyDead() flips. Reconciled on every heartbeat tick (including transient ticks, so a stale-by-time case still hides the button) and on the visibilitychange event (so a backgrounded tab returning to focus reflects reality immediately). - Inline "Send a new code" action on the OTP-expired error now branches: parLikelyDead() → "Start over"; otherwise existing "Send a new code". This is the proactive UI complement to the existing reactive abort gate. Server-side enforcement of the same invariant on /email-otp/send- verification-otp and /sign-in/email-otp is a separate follow-up. Test: new @resend-hidden-when-par-dead scenario; full @otp-and-par-expiry / @par-heartbeat / @resend-after-par-dead / @otp-expiry suite still passes (7 scenarios, 78 steps). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e060e2f commit d0a5ecf

4 files changed

Lines changed: 166 additions & 12 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@certified-app/auth-service': patch
3+
---
4+
5+
Hide the "Resend code" button on the OTP screen when the sign-in can no longer be recovered, and offer "Start over" instead. Previously, a user who left the OTP screen open long enough for the underlying sign-in window to lapse could click "Resend code", receive a fresh email, type the new code, and only then see "Sign in failed" — wasting their time on a code that could not have worked. The screen now only ever offers actions that can actually complete the sign-in.

e2e/step-definitions/auth.steps.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,3 +839,49 @@ Then(
839839
}
840840
},
841841
)
842+
843+
// ---------------------------------------------------------------------------
844+
// Resend-button visibility (fix for the "fresh OTP wasted on dead PAR" UX)
845+
// ---------------------------------------------------------------------------
846+
//
847+
// The page never offers an action that cannot complete the flow. When the
848+
// PAR is dead, the standalone Resend button is removed from view and a
849+
// Start over button takes its place — clicking it bails to /auth/abort
850+
// rather than issuing an OTP that would only fail downstream. The steps
851+
// below trigger the page's reactive ping (via the visibilitychange
852+
// handler that fires on tab-foreground) so it can observe the dead PAR
853+
// and reconcile the UI without a 5-minute wall-clock wait.
854+
855+
When('the OTP form re-checks PAR liveness', async function (this: EpdsWorld) {
856+
const page = getPage(this)
857+
// Drive the page's reactive ping via a string-source script.
858+
// Using page.evaluate(() => ...) inlines esbuild's __name helper,
859+
// which then fails in Playwright's evaluation context with
860+
// "ReferenceError: __name is not defined". Passing a string
861+
// bypasses the bundler.
862+
await page.evaluate(`(function () {
863+
Object.defineProperty(document, 'visibilityState', {
864+
configurable: true,
865+
get: function () { return 'visible' },
866+
})
867+
document.dispatchEvent(new Event('visibilitychange'))
868+
})()`)
869+
})
870+
871+
Then(
872+
'the Resend code button is no longer offered',
873+
async function (this: EpdsWorld) {
874+
const page = getPage(this)
875+
await expect(page.locator('#btn-resend')).toBeHidden({ timeout: 5_000 })
876+
},
877+
)
878+
879+
Then(
880+
'a Start over button is offered instead',
881+
async function (this: EpdsWorld) {
882+
const page = getPage(this)
883+
await expect(page.locator('#btn-start-over')).toBeVisible({
884+
timeout: 5_000,
885+
})
886+
},
887+
)

features/passwordless-authentication.feature

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,28 @@ Feature: Passwordless authentication via email OTP
343343
And the user requests a new OTP via the resend button
344344
Then the browser lands back at the demo client with an auth error
345345

346+
# The page never offers actions that cannot complete the flow. When
347+
# the upstream PAR has died (silent timeout, suspended tab,
348+
# heartbeat throttling), the standalone Resend button is removed
349+
# from view and replaced with a Start over button — so the user
350+
# never wastes time typing a fresh OTP that could not have worked.
351+
# This is the proactive complement to @resend-after-par-dead's
352+
# reactive abort gate: rather than letting the click happen and
353+
# bouncing it server-side, we surface only forward paths that can
354+
# actually succeed.
355+
@email @otp-and-par-expiry @resend-hidden-when-par-dead
356+
Scenario: Resend button is hidden when the PAR has died — Start over is offered instead
357+
When the demo client initiates an OAuth login
358+
Then the browser is redirected to the auth service login page
359+
And the login page displays an email input form
360+
When the user enters a unique test email and submits
361+
Then an OTP email arrives in the mail trap for the test email
362+
And the login page shows an OTP verification form
363+
When the PAR request_uri has expired before the bridge fires
364+
And the OTP form re-checks PAR liveness
365+
Then the Resend code button is no longer offered
366+
And a Start over button is offered instead
367+
346368
@email @otp-and-par-expiry @prompt-login
347369
Scenario: prompt=login + expired PAR — clean exit back to the OAuth client
348370
Given a returning user has a PDS account

packages/auth-service/src/routes/login-page.ts

Lines changed: 93 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -743,17 +743,38 @@ export function renderLoginPage(opts: {
743743
var heartbeatEnabled = ${JSON.stringify(opts.heartbeatEnabled)};
744744
var heartbeatHandle = null;
745745
var heartbeatIntervalMs = 3 * 60 * 1000;
746+
// Upstream's AUTHORIZATION_INACTIVITY_TIMEOUT — once this much
747+
// wall-clock time has elapsed since our last successful PAR
748+
// refresh, the upstream row is guaranteed to be dead. Used by
749+
// parLikelyDead() to hide Resend before the user can click it.
750+
var parInactivityTimeoutMs = 5 * 60 * 1000;
751+
// Page load is the implicit first PAR refresh — atproto's
752+
// PAR_EXPIRES_IN gives a fresh row 5 min on creation, and the
753+
// user just hit /oauth/authorize seconds ago. Treat now as
754+
// last-known-alive until the first ping confirms otherwise.
755+
var lastSuccessfulHeartbeatAt = Date.now();
746756
// Set to true the moment we know the flow can no longer
747757
// complete (PAR or auth_flow gone). Resend / Verify gates
748758
// check this so a click that races the proactive notice
749759
// still bails to /auth/abort instead of issuing a fresh OTP
750760
// that would only fail.
751761
var flowAborted = false;
762+
// True iff we have proof the PAR is still alive (last ping
763+
// was ok:true and was recent enough to fall inside the
764+
// upstream inactivity window). Used to gate every "offer the
765+
// user a Resend" decision so they only ever see actions that
766+
// can actually complete the flow.
767+
function parLikelyDead() {
768+
if (flowAborted) return true;
769+
return Date.now() - lastSuccessfulHeartbeatAt >= parInactivityTimeoutMs;
770+
}
752771
function pingHeartbeat() {
753772
return fetch('/auth/ping', { credentials: 'include', cache: 'no-store' })
754773
.then(function(r) { return r.json(); })
755774
.then(function(body) {
756-
if (body && body.ok === false && body.reason !== 'transient') {
775+
if (body && body.ok === true) {
776+
lastSuccessfulHeartbeatAt = Date.now();
777+
} else if (body && body.ok === false && body.reason !== 'transient') {
757778
// Auth flow / PAR genuinely dead — no point pinging again,
758779
// and no point letting the user keep typing. 'transient'
759780
// (5xx / network blip) does NOT stop the interval; the
@@ -763,7 +784,13 @@ export function renderLoginPage(opts: {
763784
}
764785
return body;
765786
})
766-
.catch(function() { return null; /* network blip — caller may retry */ });
787+
.catch(function() { return null; /* network blip — caller may retry */ })
788+
.finally(function() {
789+
// Always reconcile visibility — a 'transient' tick that
790+
// pushes us past the inactivity window must hide Resend
791+
// even though we never got a definitive 'par_expired'.
792+
refreshResendVisibility();
793+
});
767794
}
768795
function startHeartbeat() {
769796
if (!heartbeatEnabled) return;
@@ -777,6 +804,16 @@ export function renderLoginPage(opts: {
777804
}
778805
}
779806
window.addEventListener('beforeunload', stopHeartbeat);
807+
// When the tab returns to the foreground after being hidden,
808+
// setInterval may have been throttled enough that PAR has
809+
// silently lapsed. Re-ping immediately so the UI reflects
810+
// reality before the user clicks anything.
811+
document.addEventListener('visibilitychange', function() {
812+
if (document.visibilityState === 'visible' && heartbeatEnabled) {
813+
pingHeartbeat();
814+
refreshResendVisibility();
815+
}
816+
});
780817
781818
// Show the proactive "this won't work — start over" notice when
782819
// the flow is unrecoverable. Disables the OTP boxes, the verify
@@ -819,6 +856,41 @@ export function renderLoginPage(opts: {
819856
errorEl.appendChild(startOverBtn);
820857
}
821858
859+
/**
860+
* Toggle the standalone Resend button between visible and
861+
* hidden based on whether the PAR is still alive. The button
862+
* is removed from view (display:none) rather than just
863+
* disabled — a button the user cannot productively click
864+
* shouldn't be on the page at all. When hidden, a "Start over"
865+
* link is shown in its place so the user always has a forward
866+
* path. Idempotent — safe to call from heartbeat ticks,
867+
* visibility change handlers, and inline render paths.
868+
*/
869+
function refreshResendVisibility() {
870+
var resendBtn = document.getElementById('btn-resend');
871+
var startOverLink = document.getElementById('btn-start-over');
872+
if (!resendBtn) return;
873+
if (parLikelyDead()) {
874+
resendBtn.style.display = 'none';
875+
if (!startOverLink) {
876+
startOverLink = document.createElement('button');
877+
startOverLink.type = 'button';
878+
startOverLink.id = 'btn-start-over';
879+
startOverLink.className = 'btn-secondary';
880+
startOverLink.textContent = 'Start over';
881+
startOverLink.addEventListener('click', function() {
882+
window.location.href = '/auth/abort';
883+
});
884+
resendBtn.parentNode.insertBefore(startOverLink, resendBtn);
885+
}
886+
} else {
887+
resendBtn.style.display = '';
888+
if (startOverLink && startOverLink.parentNode) {
889+
startOverLink.parentNode.removeChild(startOverLink);
890+
}
891+
}
892+
}
893+
822894
/**
823895
* Reactive gate used by the Resend and Verify click handlers.
824896
* Pings /auth/ping synchronously; if the result indicates the
@@ -983,6 +1055,7 @@ export function renderLoginPage(opts: {
9831055
if (otpBoxes.length) otpBoxes[0].focus();
9841056
clearError();
9851057
startHeartbeat();
1058+
refreshResendVisibility();
9861059
}
9871060
9881061
function showEmailStep() {
@@ -1104,15 +1177,21 @@ export function renderLoginPage(opts: {
11041177
// expired") plus generic "expir"/"too long" variants.
11051178
var isExpired = /expir|too long/i.test(result.error);
11061179
if (isExpired) {
1107-
// The inline action triggers the same Resend handler,
1108-
// which itself runs abortIfFlowDead() before issuing
1109-
// a new code. So even if the PAR is dead the user
1110-
// gets the spec-compliant bounce rather than a fresh
1111-
// OTP that wouldn't work — no need to gate the
1112-
// action's visibility separately here.
1113-
showErrorWithAction(result.error, 'Send a new code', function() {
1114-
document.getElementById('btn-resend').click();
1115-
});
1180+
// Only offer "Send a new code" when the PAR is still
1181+
// alive. If it isn't, a fresh OTP would issue but
1182+
// never complete — wasting the user's time on a code
1183+
// that can't work. Show "Start over" instead so the
1184+
// only forward path we surface is one that will
1185+
// actually succeed.
1186+
if (parLikelyDead()) {
1187+
showErrorWithAction(result.error, 'Start over', function() {
1188+
window.location.href = '/auth/abort';
1189+
});
1190+
} else {
1191+
showErrorWithAction(result.error, 'Send a new code', function() {
1192+
document.getElementById('btn-resend').click();
1193+
});
1194+
}
11161195
} else {
11171196
showError(result.error);
11181197
}
@@ -1185,8 +1264,10 @@ export function renderLoginPage(opts: {
11851264
});
11861265
}
11871266
// OTP form is already visible server-side; showOtpStep() never
1188-
// ran, so kick off the heartbeat ourselves.
1267+
// ran, so kick off the heartbeat ourselves and reflect the
1268+
// current PAR-liveness state in the Resend button visibility.
11891269
startHeartbeat();
1270+
refreshResendVisibility();
11901271
}
11911272
})();
11921273
</script>

0 commit comments

Comments
 (0)