Skip to content

fix(auth-service): hide Resend on OTP screen when sign-in cannot recover#165

Open
aspiers wants to merge 37 commits intomainfrom
fix/otp-expiry-resend-bug
Open

fix(auth-service): hide Resend on OTP screen when sign-in cannot recover#165
aspiers wants to merge 37 commits intomainfrom
fix/otp-expiry-resend-bug

Conversation

@aspiers
Copy link
Copy Markdown
Contributor

@aspiers aspiers commented May 7, 2026

Summary

  • Hide the Resend code button on the OTP screen the moment the sign-in can no longer recover; surface Start over in its place. The screen never offers an action that cannot complete the flow.
  • Inline action on the OTP-expired error now branches the same way: Start over when the flow is dead, Send a new code when it can still finish.
  • Reconciles on heartbeat ticks (including transient ones) and on the visibilitychange event, so a backgrounded tab returning to focus reflects reality immediately.

Why

Bug report: user requested an OTP, waited ~11 minutes, got "OTP expired" (correct), clicked Send a new code, received a fresh email, typed it, and only then saw "Sign in failed". A fresh code was issued that could never have worked because the upstream PAR row had silently lapsed during the wait.

The earlier reactive abort gate (PR #154) bounces the user cleanly to the OAuth client when this happens, but only after the user has already invested time typing a code that was never going to succeed. This change makes the offer disappear before the user can act on it, so the only forward paths surfaced are paths that actually work.

How

  • New lastSuccessfulHeartbeatAt timestamp updated on every ok:true ping. Initialised to page-load time (PAR is fresh on render).
  • New parLikelyDead() returns true when now - lastSuccessfulHeartbeatAt >= 5min (upstream's AUTHORIZATION_INACTIVITY_TIMEOUT) or when the page has already been told the flow aborted.
  • New refreshResendVisibility() toggles #btn-resend#btn-start-over based on parLikelyDead(). Called from heartbeat tick, visibilitychange, OTP-step show, and the inline-action error path.

Out of scope

Server-side enforcement of the same invariant — /email-otp/send-verification-otp and /sign-in/email-otp will still process direct POSTs that bypass the UI. Worth doing as a defence-in-depth follow-up; not load-bearing for honest-user UX, which is what this PR addresses.

Test plan

  • New @resend-hidden-when-par-dead scenario passes.
  • Full @otp-and-par-expiry / @par-heartbeat / @resend-after-par-dead / @otp-expiry suite passes (7 scenarios, 78 steps).
  • pnpm format:check, pnpm lint, pnpm typecheck, pnpm test (1017 unit tests) all green locally.
  • CI on this branch.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • OTP UI shows “Start over” when a sign-in can’t be recovered; inline “Send a new code” on lockout. Demo now surfaces “session expired”; OAuth session cookie extended to 1 hour. Account Settings shows visible success/error flash banners.
  • Bug Fixes

    • Don’t flash “Invalid OTP” on empty submit; focus first OTP box. Clear OTP boxes on resend; paste/keystroke filtered to OTP charset; SMS autofill distributes across boxes. Recovery links reliably return to the originating sign-in; friendlier error and lockout wording. Reserved handles marked unavailable.
  • Tests

    • Expanded E2E and unit tests for OTP, PAR liveness, recovery, and error flows.

Copilot AI review requested due to automatic review settings May 7, 2026 03:36
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
epds-demo Ready Ready Preview, Comment May 7, 2026 0:05am

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 7, 2026

🦋 Changeset detected

Latest commit: 473f6a0

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 7, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Detects when an upstream Pushed Authorization Request (PAR) is no longer recoverable and updates OTP UI/flow: client heartbeat and visibility checks reconcile standalone “Resend code” versus an imperatively inserted “Start over”; centralizes OTP verify error messages; tightens OTP input sanitization; adds tests and changeset notes.

Changes

PAR-Death UI Recovery

Layer / File(s) Summary
Contracts & Availability
packages/shared/src/client-metadata.ts, packages/pds-core/src/lib/reserved-handle.ts, packages/pds-core/src/index.ts
Malformed/non-http(s) client_id now yields empty metadata; new reserved-handle helpers check reserved subdomains and are used by /_internal/check-handle.
Render contract / Preview
packages/auth-service/src/routes/login-page.ts, packages/auth-service/src/routes/preview.ts, packages/auth-service/src/__tests__/*
renderLoginPage now requires requestUri; preview routes and tests updated to pass a preview/requestUri value.
OTP Error Helper & Unit Tests
packages/auth-service/src/lib/otp-verify-error.ts, packages/auth-service/src/__tests__/otp-verify-error.test.ts, packages/auth-service/src/__tests__/login-page.test.ts
Adds exported pickOtpVerifyErrorMessage(err) and unit tests mapping expired/lockout/invalid/unknown inputs; login-page tests updated to assert isUnrecoverable detection and verifyLockedOut presence.
Client Heartbeat & Resend UI
packages/auth-service/src/routes/login-page.ts
Adds PAR timing state and parLikelyDead(); pingHeartbeat() swallows network errors and always calls refreshResendVisibility(); adds visibilitychange handler; refreshResendVisibility() toggles standalone #btn-resend and imperatively inserts /auth/abort “Start over”; OTP paste/input cleaning and submit refinements included; initial server-rendered OTP path calls startHeartbeat() + refreshResendVisibility().
Server Routes: Recovery & Account-login
packages/auth-service/src/routes/recovery.ts, packages/auth-service/src/routes/account-login.ts
GET /auth/recover shows friendly “No active sign-in” when request_uri missing; both routes use pickOtpVerifyErrorMessage for OTP verify errors; forms render errors with role="alert"; OTP input sanitizers tightened; account-login adds autocomplete="email".
Demo app: Callback & Login
packages/demo/src/app/api/oauth/callback/route.ts, packages/demo/src/app/api/oauth/login/route.ts, packages/demo/src/app/components/LoginForm.tsx
OAuth callback classifies upstream timeout/missing-cookie/invalid_grant as session_expired and redirects accordingly; login route session cookie maxAge set to 1h; LoginForm maps session_expired to a user-facing message and updates state_mismatch copy.
Account Settings & Choose-handle
packages/auth-service/src/routes/account-settings.ts, packages/auth-service/src/routes/choose-handle.ts
Adds whitelisted FLASH_SUCCESS_MESSAGES/FLASH_ERROR_MESSAGES and flash rendering; updates POST redirects to include success codes; adjusts choose-handle error copy and focus-visible CSS.
Preview, Tests & E2E
packages/auth-service/src/routes/preview.ts, e2e/step-definitions/*, features/*, packages/auth-service/src/__tests__/*
Preview routes pass a preview requestUri; E2E steps accept session_expired, add visibility-based PAR re-check trigger, and assert #btn-resend hidden / #btn-start-over visible; feature files and unit tests updated/added.
Changesets / Documentation
.changeset/*.md
Multiple changeset files added/updated documenting UX fixes, messaging, and small behavior changes across sign-in, recovery, handle picking, and account settings.

Sequence Diagram

sequenceDiagram
  participant User
  participant Browser
  participant AuthService
  participant DemoApp
  User->>Browser: open OTP page
  Browser->>AuthService: GET /auth/ping (pingHeartbeat)
  AuthService-->>Browser: 200 or network error / status mapping
  Browser->>Browser: evaluate parLikelyDead() -> refreshResendVisibility()
  Browser-->>User: show Start over OR show Resend
  DemoApp->>Browser: callback redirect (/?error=session_expired or auth_failed)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • s-adamantine

🐰 When PAR's pulse grows faint and cold,
The Resend fades — the story told.
"Start over" hops in, bright and near,
A little rabbit nudge: try again, dear. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 24.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and specifically summarizes the main change: hiding the Resend button on the OTP screen when sign-in recovery is no longer possible, with the Start over action replacing it. This directly addresses the core objective of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/otp-expiry-resend-bug

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@railway-app
Copy link
Copy Markdown

railway-app Bot commented May 7, 2026

🚅 Deployed to the ePDS-pr-165 environment in ePDS

Service Status Web Updated (UTC)
@certified-app/auth-service ✅ Success (View Logs) Web May 7, 2026 at 12:06 pm
@certified-app/pds-core ✅ Success (View Logs) Web May 7, 2026 at 10:28 am
@certified-app/demo untrusted ✅ Success (View Logs) Web May 7, 2026 at 8:51 am
@certified-app/demo ✅ Success (View Logs) Web May 7, 2026 at 8:50 am

@coveralls-official
Copy link
Copy Markdown

coveralls-official Bot commented May 7, 2026

Coverage Report for CI Build 25494650133

Coverage increased (+0.2%) to 55.72%

Details

  • Coverage increased (+0.2%) from the base build.
  • Patch coverage: 16 uncovered changes across 5 files (16 of 32 lines covered, 50.0%).
  • 7 coverage regressions across 4 files.

Uncovered Changes

File Changed Covered %
packages/demo/src/app/api/oauth/callback/route.ts 9 0 0.0%
packages/auth-service/src/routes/account-settings.ts 9 5 55.56%
packages/auth-service/src/routes/recovery.ts 1 0 0.0%
packages/pds-core/src/index.ts 1 0 0.0%
packages/shared/src/client-metadata.ts 2 1 50.0%

Coverage Regressions

7 previously-covered lines in 4 files lost coverage.

File Lines Losing Coverage Coverage
packages/auth-service/src/routes/account-settings.ts 2 6.1%
packages/auth-service/src/routes/login-page.ts 2 79.6%
packages/auth-service/src/routes/recovery.ts 2 11.72%
packages/auth-service/src/routes/account-login.ts 1 0.0%

Coverage Stats

Coverage Status
Relevant Lines: 2955
Covered Lines: 1642
Line Coverage: 55.57%
Relevant Branches: 1835
Covered Branches: 1027
Branch Coverage: 55.97%
Branches in Coverage %: Yes
Coverage Strength: 5.8 hits per line

💛 - Coveralls

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
.changeset/hide-resend-when-sign-in-cannot-recover.md (1)

1-6: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add the required Affects: line and avoid jargon for End users.

Two coding-guideline violations on this changeset:

  1. Missing Affects: line. Every changeset must include an **Affects:** line listing audiences in the order End users, Client app developers, Operators.
  2. End-user jargon. Once End users is an audience, the summary must avoid OTP/DID/PAR/OAuth jargon. "OTP screen" should be reworded for end users (the second sentence already does it well by talking about codes and emails).
📝 Proposed rewrite
 ---
 '@certified-app/auth-service': patch
 ---

-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.
+**Affects:** End users
+
+Hide the "Resend code" button on the code-entry screen when the sign-in can no longer be recovered, and offer "Start over" instead. Previously, a user who left this screen open long enough for the sign-in 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.

As per coding guidelines: "Every changeset must include an Affects: line listing audiences (End users, Client app developers, Operators in that order)" and "Write changeset summaries in plain language without OTP/DID/PAR/OAuth jargon when End users is an audience".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/hide-resend-when-sign-in-cannot-recover.md around lines 1 - 6,
The changeset is missing the required "Affects:" line listing the audiences in
the order End users, Client app developers, Operators. Add this line to comply
with the guideline. Also, rewrite the summary text to remove jargon like "OTP
screen" to make it clear for End users, using simple terms like "verification
code screen" or "code entry screen" instead.
packages/auth-service/src/routes/login-page.ts (1)

775-793: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Two "Start over" buttons render simultaneously when /auth/ping returns par_expired.

When pingHeartbeat() resolves to { ok: false, reason: 'par_expired' }:

  1. The .then() branch (lines 777–784) calls showFlowAbortedNotice(), which creates a button with class flash-action inside the error banner.
  2. The .finally() branch (line 792) calls refreshResendVisibility(). Because flowAborted is now true, parLikelyDead() returns true and a second button (#btn-start-over, class btn-secondary) is inserted before the resend button.

Both buttons appear on the page simultaneously, though in different locations. The user sees two visually distinct "Start over" controls — one styled as inline text within the error notice, another as a secondary button in the form actions area.

To fix: Have showFlowAbortedNotice() set a flag that refreshResendVisibility() checks — if the abort notice has already fired, skip inserting the #btn-start-over button since one is already present and the form is disabled. Alternatively, have showFlowAbortedNotice() not render its own button and rely solely on refreshResendVisibility() for the "Start over" surface.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/auth-service/src/routes/login-page.ts` around lines 775 - 793, When
showFlowAbortedNotice() renders the abort banner, set a persistent flag (e.g.,
flowAbortedShown = true) that indicates the abort notice has already provided a
"Start over" control; then update refreshResendVisibility() (and any helpers it
uses such as parLikelyDead()) to check that flag and skip inserting the
auxiliary `#btn-start-over` button when flowAbortedShown is true so only one
"Start over" control is ever rendered; ensure the flag is cleared where the flow
is truly restarted so normal behavior resumes.
🧹 Nitpick comments (1)
packages/auth-service/src/routes/login-page.ts (1)

869-892: 💤 Low value

Prefer a CSS class over inline style.display for the Resend toggle.

This is runtime visibility (not "dynamic values set at render time"), so per the repo guideline it should be driven by a class such as .hidden. The existing display: none declarations on .step-otp / .step-email.hidden already follow that convention; this new toggle could match.

♻️ Sketch of class-based toggle
       function refreshResendVisibility() {
         var resendBtn = document.getElementById('btn-resend');
         var startOverLink = document.getElementById('btn-start-over');
         if (!resendBtn) return;
         if (parLikelyDead()) {
-          resendBtn.style.display = 'none';
+          resendBtn.classList.add('hidden');
           if (!startOverLink) {
             ...
           }
         } else {
-          resendBtn.style.display = '';
+          resendBtn.classList.remove('hidden');
           if (startOverLink && startOverLink.parentNode) {
             startOverLink.parentNode.removeChild(startOverLink);
           }
         }
       }

…with a generic .hidden { display: none; } rule alongside the existing .step-email.hidden. (Pre-existing errorEl.style.display usage in showFlash is out of scope here.)

As per coding guidelines: "Use CSS classes to control visibility (hidden, active) rather than inline display style except for dynamic values set at render time".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/auth-service/src/routes/login-page.ts` around lines 869 - 892, The
refreshResendVisibility function currently toggles visibility via
resendBtn.style.display and should instead add/remove a CSS visibility class;
update the logic in refreshResendVisibility to use
resendBtn.classList.add('hidden') / .remove('hidden') (and mirror removal for
startOverLink via classList or insert/remove DOM as before) rather than setting
style.display, ensure the created startOverLink does not rely on inline display
and that the code references the existing .hidden CSS class (keep element IDs
btn-resend and btn-start-over and the same click handler).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In @.changeset/hide-resend-when-sign-in-cannot-recover.md:
- Around line 1-6: The changeset is missing the required "Affects:" line listing
the audiences in the order End users, Client app developers, Operators. Add this
line to comply with the guideline. Also, rewrite the summary text to remove
jargon like "OTP screen" to make it clear for End users, using simple terms like
"verification code screen" or "code entry screen" instead.

In `@packages/auth-service/src/routes/login-page.ts`:
- Around line 775-793: When showFlowAbortedNotice() renders the abort banner,
set a persistent flag (e.g., flowAbortedShown = true) that indicates the abort
notice has already provided a "Start over" control; then update
refreshResendVisibility() (and any helpers it uses such as parLikelyDead()) to
check that flag and skip inserting the auxiliary `#btn-start-over` button when
flowAbortedShown is true so only one "Start over" control is ever rendered;
ensure the flag is cleared where the flow is truly restarted so normal behavior
resumes.

---

Nitpick comments:
In `@packages/auth-service/src/routes/login-page.ts`:
- Around line 869-892: The refreshResendVisibility function currently toggles
visibility via resendBtn.style.display and should instead add/remove a CSS
visibility class; update the logic in refreshResendVisibility to use
resendBtn.classList.add('hidden') / .remove('hidden') (and mirror removal for
startOverLink via classList or insert/remove DOM as before) rather than setting
style.display, ensure the created startOverLink does not rely on inline display
and that the code references the existing .hidden CSS class (keep element IDs
btn-resend and btn-start-over and the same click handler).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2295e746-1f09-4076-90ad-8f0dc32c74e0

📥 Commits

Reviewing files that changed from the base of the PR and between e060e2f and d0a5ecf.

📒 Files selected for processing (4)
  • .changeset/hide-resend-when-sign-in-cannot-recover.md
  • e2e/step-definitions/auth.steps.ts
  • features/passwordless-authentication.feature
  • packages/auth-service/src/routes/login-page.ts

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the auth-service login OTP UI to proactively stop offering “Resend code” once the underlying OAuth/PAR flow can no longer complete, replacing it with a “Start over” action, and adds an e2e scenario to assert the behavior.

Changes:

  • Track the last successful heartbeat timestamp and derive parLikelyDead() to decide when the flow is no longer recoverable.
  • Toggle OTP-screen actions via refreshResendVisibility() (hide Resend code, show Start over) and re-check on heartbeat ticks and visibilitychange.
  • Add an e2e scenario + step definitions asserting Resend is hidden and Start over is shown when the PAR is expired.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
packages/auth-service/src/routes/login-page.ts Adds PAR-liveness tracking and UI toggling to hide Resend when the flow is no longer recoverable.
features/passwordless-authentication.feature Adds a new scenario asserting Resend is not offered when PAR is dead.
e2e/step-definitions/auth.steps.ts Adds steps to trigger the UI re-check and assert Resend hidden / Start over visible.
.changeset/hide-resend-when-sign-in-cannot-recover.md Patch changeset documenting the UX fix.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +767 to +770
function parLikelyDead() {
if (flowAborted) return true;
return Date.now() - lastSuccessfulHeartbeatAt >= parInactivityTimeoutMs;
}
Comment on lines +873 to +885
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';
});
resendBtn.parentNode.insertBefore(startOverLink, resendBtn);
}
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>
…doesn't

Two changes that close the bug-report hole on the demo client:

1. Bump oauth_state cookie maxAge from 600s to 3600s, matching
   auth-service's auth_flow row TTL. The demo's cookie carries the
   state, code verifier, token endpoint and issuer for the OAuth
   callback to complete; if it expires before the user submits the
   OTP, the callback can't find any of that and bounces silently.
   600s was shorter than realistic OTP-form sit times (the bug
   report was an 11-minute wait). Aligning with auth_flow's 60-min
   budget means as long as the auth-service can still recover the
   flow, the demo can too.

2. Distinguish "cookie missing" from "auth failed" on the callback.
   Previously every silent-fail path bounced to /?error=auth_failed
   ("Authentication failed. Please try again."), which is misleading
   when the sign-in itself succeeded — the user typed a fresh OTP
   correctly, the auth-service issued the OAuth code, the demo just
   couldn't finish because its own session cookie had aged out. Now
   the cookie-missing branch redirects to /?error=session_expired
   ("Your sign-in took too long to finish. Please sign in again.")
   so the user understands what happened without thinking they
   typed the code wrong.

Test: new @demo-cookie-expiry @bug-report scenario clears the cookie
mid-flow and asserts the session-expired error surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 7, 2026 04:08
@railway-app railway-app Bot temporarily deployed to ePDS / ePDS-pr-165 May 7, 2026 04:08 Destroyed
…uses it

The OTP form's "Use different email" button took the user back to
the email-entry form but left the previous email pre-filled. That's
exactly the misleading "looks like the form remembered me" UX they
were trying to escape — and forces an extra clearing keystroke
before they can type the new address.

Fix: showEmailStep() now resets emailInput.value, currentEmail, and
focuses the field so the user can just start typing.

Test: new @use-different-email scenario.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@railway-app railway-app Bot temporarily deployed to ePDS / ePDS-pr-165 May 7, 2026 04:13 Destroyed
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Comment on lines +869 to +886
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';
});
resendBtn.parentNode.insertBefore(startOverLink, resendBtn);
}
} else {
Comment on lines 262 to 268
resp2.cookies.set(oauthCookie.name, oauthCookie.value, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 600,
maxAge: 60 * 60,
path: '/',
})
Comment on lines 279 to 286
const response = NextResponse.redirect(authUrl)
response.cookies.set(oauthCookie.name, oauthCookie.value, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 600,
maxAge: 60 * 60,
path: '/',
})
Clicking Verify before typing the code (or pressing Enter on an
empty form) flashed "Invalid OTP" — misleading (the user typed
nothing, not an invalid code) AND burned a real /sign-in/email-otp
call against better-auth's rate limiter, so a confused user
tab-clicking Verify could lock themselves out faster than necessary.

Fix: short-circuit the submit handler when otp.length is shorter
than the number of OTP boxes; focus the first empty box instead.

Test: new @verify-empty-otp scenario.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After 5 wrong codes, better-auth's email-otp plugin throws
TOO_MANY_ATTEMPTS and deletes the verification row in the same
call. Subsequent attempts hit the deleted row and return INVALID_OTP
("Invalid OTP") — so the user, who has just been locked out, sees
a generic typo-shaped error and naturally tries again. Each retry
just confirms the same dead-end.

The only forward path is a fresh Resend. Make the page surface
that path inline next to the lockout error, the same way the
OTP-expired branch already does.

Implementation: widen the inline-action regex from /expir|too long/
to /expir|too long|too many|attempt/ so it matches better-auth's
TOO_MANY_ATTEMPTS message ("Too many attempts"). Rename the var
isExpired → isUnrecoverable to reflect the broader contract.

Test: new @too-many-attempts scenario burns 5 attempts via direct
fetch then triggers the 6th through the form, asserts both the
"Too many attempts" error AND the Send-a-new-code inline action.
Existing unit tests updated to reference the new var name + regex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 7, 2026 04:30
The main login page's OTP boxes already filter typed/pasted
content to the configured charset (numeric → digits-only;
alphanumeric → letters+digits, uppercased). The account-login
and recovery forms use a single-input variant of the same
form and didn't have the same filter — pasting "1234-5678"
or accidentally typing a letter into a numeric-only code
would put stray characters in the input that better-auth
would then reject as INVALID_OTP.

Apply the same charset regex (built per-render from
opts.otpCharset) on the inline `oninput` of both single-input
forms, matching the segmented version's behaviour.

Recovery's existing `oninput` already stripped whitespace +
hyphens; the new regex supersedes it (digits-only includes
both). Account-login had no inline filter; gains it now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated 3 comments.

Comment on lines +903 to +912
// 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 +408 to +413
# better-auth's email-otp plugin allows up to 3 wrong attempts on a
# single code. The 4th wrong submit returns "Too many attempts" AND
# deletes the verification row — so any further attempts on that
# same code path are doomed to fail (better-auth then returns
# "Invalid OTP" because the row no longer exists, which would mislead
# the user into thinking they have a typo).
Comment on lines +6 to +25
* translate the caught error into user-facing copy. They have the
* same three meaningful cases:
*
* 1. Lockout / aged-out — "Too many attempts" or "OTP expired"
* from better-auth, OR the post-lockout INVALID_OTP that fires
* against a deleted verification row. The current code path is
* dead; more typing cannot succeed. Point the user at Resend.
*
* 2. Recoverable typo — "Invalid OTP" against a live row. Just
* ask the user to re-type.
*
* 3. Internal failure — anything else (network, DB, unexpected).
* Show a generic try-again message.
*
* Returning the message string rather than a structured kind keeps
* the call sites simple — they just feed it straight into their
* `renderOtpForm({ error: ... })` helper.
*
* The branching is exported as a pure function so unit tests can
* cover all three branches without standing up a router.
…t taken"

The handle picker's error message for the handle_taken error code
was "That handle was just taken — please choose another." That's
accurate for the rare race where another user claims the handle
in the same second, but most handle_taken responses fire because
the handle is reserved (admin, www, support, etc.) — and "just
taken" implies the user could try again later, when in reality a
reserved handle will never be available.

"That handle is not available" covers both cases honestly without
implying transience.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@blacksmith-sh

This comment has been minimized.

The @demo-cookie-expiry scenario flaked twice on CI (Railway
preview env, OTP email never arrived within 60s). The original
scenario walked the full email-OTP cycle just to land on the
demo callback with no cookie — but the only thing under test
is the demo's branching between session_expired vs auth_failed.

Refactor: jump straight to /api/oauth/callback?code=...&state=...
with no cookie set. The callback's first checks are
"if (error)" → not present, "if (!code || !state)" → present;
then it tries getOAuthSessionFromCookie which returns null,
and routes to session_expired. That's the entire surface we
care about for the bug-report fix.

Faster (1s vs 30s+), deterministic, no Mailpit dependency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 31 out of 31 changed files in this pull request and generated 5 comments.

Comment on lines +904 to +912
// `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 +9 to +15
* 1. Lockout / aged-out — "Too many attempts" or "OTP expired"
* from better-auth, OR the post-lockout INVALID_OTP that fires
* against a deleted verification row. The current code path is
* dead; more typing cannot succeed. Point the user at Resend.
*
* 2. Recoverable typo — "Invalid OTP" against a live row. Just
* ask the user to re-type.
Comment on lines +57 to +58
console.error(
`[oauth/callback] Auth error from PDS: ${error} (${errorDescription})`,
Comment on lines +56 to +60
const code = isTimeout ? 'session_expired' : 'auth_failed'
console.error(
`[oauth/callback] Auth error from PDS: ${error} (${errorDescription})`,
)
return NextResponse.redirect(new URL(`/?error=${code}`, baseUrl))
Comment on lines +426 to +429
Then the login page shows an OTP verification form
When the user submits enough wrong OTPs to trigger the lockout
Then the verification form shows an "Too many attempts" error
And a "Send a new code" inline action is offered
aspiers and others added 2 commits May 7, 2026 08:24
iOS and Android offer to autofill a verification code into the
first input tagged autocomplete=one-time-code. They drop the
entire code into that box as a single input event with the full
string. Our handler kept only the LAST char ("v.slice(-1)") on
multi-char input — meaning the user got the first 7 digits
discarded and only the 8th in box 0, with no way to see what
went wrong.

When v.length > 1 on the input handler, distribute v[0..N]
across otpBoxes starting at the current idx, mirroring the
paste handler. Then focus the last filled box and auto-submit
if the grid is full.

Test: unit assertion that the rendered handler distributes v
across boxes (v[i] → otpBoxes[idx + i].value) for v.length > 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…vailable

The handle picker on auth-service runs a live availability check
against pds-core's /_internal/check-handle endpoint. The endpoint
only checked accountManager.getAccount() — it did not check
upstream's reservedSubdomains list. So reserved handles like
"admin", "www", "support" came back as ✓ Available on the live
check, only to be rejected with HandleUnavailableError on Submit.

The user pays for the round-trip with their typing time and
gets bounced back to the picker with no indication of what
went wrong upfront.

Lift the upstream's reservedSubdomains object via a deep import
into a small helper (`isReservedSubdomain`), and OR it into the
`exists` field returned by /_internal/check-handle. The picker's
existing client-side branch — `data.available` falsy → "✗ Not
available" — now fires upfront for reserved handles, so the
user sees the same disabled-Submit + helpful-error UX as for
already-taken handles.

Test: 4 unit tests on the reserved-handle helper covering
sample reserved handles, case-insensitivity, normal handles,
and the empty-string case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (4)
packages/demo/src/app/api/oauth/callback/route.ts (3)

57-59: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

error and errorDescription are logged raw — log injection risk.

Both values come directly from user-controlled URL query params. A crafted redirect to the callback URL with an error_description containing newlines can forge log lines. The sanitizeForLog helper is already imported at line 30 and used elsewhere in this file.

This was flagged in a previous review without being addressed.

🛡️ Proposed fix
      console.error(
-        `[oauth/callback] Auth error from PDS: ${error} (${errorDescription})`,
+        `[oauth/callback] Auth error from PDS: ${sanitizeForLog(error)} (${sanitizeForLog(errorDescription)})`,
      )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/demo/src/app/api/oauth/callback/route.ts` around lines 57 - 59, The
console.error call in the OAuth callback logs user-controlled values raw (error
and errorDescription) creating a log-injection risk; update the callback handler
in route.ts to sanitize both values with the existing sanitizeForLog helper
before logging (e.g., call sanitizeForLog(error) and
sanitizeForLog(errorDescription)) and use those sanitized variables in the
console.error message so no raw query param content is written to logs.

56-56: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

const code at both inner declarations shadows the outer OAuth code param (line 38).

The variable name code is already bound in the outer try-block scope as the OAuth authorization code (request.nextUrl.searchParams.get('code')). The inner const code at line 56 and the new const code at line 195 both shadow it within their respective if-block scopes. Although the blocks each return before the outer code is needed again, the name reuse obscures the intent and will trip @typescript-eslint/no-shadow if that rule is enabled.

The fix at line 56 was noted in a previous review but was not applied. Line 195 introduces the same pattern in new code.

♻️ Proposed fix
-      const code = isTimeout ? 'session_expired' : 'auth_failed'
-      console.error(...)
-      return NextResponse.redirect(new URL(`/?error=${code}`, baseUrl))
+      const errorCode = isTimeout ? 'session_expired' : 'auth_failed'
+      console.error(...)
+      return NextResponse.redirect(new URL(`/?error=${errorCode}`, baseUrl))
-      const code =
-        tokenRes.status === 400 && /invalid_grant/i.test(errBody)
-          ? 'session_expired'
-          : 'auth_failed'
-      return NextResponse.redirect(new URL(`/?error=${code}`, baseUrl))
+      const errorCode =
+        tokenRes.status === 400 && /invalid_grant/i.test(errBody)
+          ? 'session_expired'
+          : 'auth_failed'
+      return NextResponse.redirect(new URL(`/?error=${errorCode}`, baseUrl))

Also applies to: 195-198

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/demo/src/app/api/oauth/callback/route.ts` at line 56, The inner
declarations named "code" shadow the outer OAuth authorization code variable
(the one from request.nextUrl.searchParams.get('code')), so rename the inner
variables in both places (the branch where isTimeout sets
'session_expired'/'auth_failed' and the later branch around the new logic at
~195) to a distinct identifier such as "errorCode" or "authErrorCode" and update
any uses in those blocks (including the return payload) to avoid shadowing while
preserving the same string values and control flow; ensure you only change the
inner bindings (not the outer OAuth "code") and run the linter to confirm no
no-shadow violations remain.

53-55: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

|session alternative in the timeout regex is still present despite the previous fix.

The previous review flagged this and the comment thread was marked "✅ Addressed", but the current code still contains the bare session alternative. Any error_description that merely contains the word "session" (e.g., "Session not found", "Invalid session state") will be misclassified as a timeout and surface the wrong session_expired banner to the user.

🐛 Proposed fix
-        /timed out|too long|expired|session/i.test(errorDescription)
+        /timed out|too long|expired/i.test(errorDescription)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/demo/src/app/api/oauth/callback/route.ts` around lines 53 - 55,
Update the isTimeout check in route.ts so it no longer treats any
errorDescription containing the bare word "session" as a timeout: in the
isTimeout expression (the variable using error and errorDescription), replace
the current regex (/timed out|too long|expired|session/i) with one that only
matches timeout-related phrases (for example, require word boundaries and
explicit phrases like /\b(?:timed out|too long|expired|session (?:timed
out|expired|ended)|session expired)\b/i or simply /\b(?:timed out|too
long|expired|session (?:timed out|expired))\b/i) so only genuine timeout phrases
trigger session_expired; keep the rest of the logic unchanged and ensure you
reference the same isTimeout, error, and errorDescription variables.
e2e/step-definitions/account-recovery.steps.ts (1)

389-400: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

textContent() includes <script> / hidden elements — use innerText() to check only visible text.

The intent is to assert that the user cannot see "request_uri" on the page, but textContent() also collects text from inline <script> blocks and hidden inputs. An OAuth error page that echoes back params via a script variable would cause a false positive, failing the assertion even when nothing visible leaks the field name.

🐛 Proposed fix
-    const body = (await page.locator('body').textContent()) ?? ''
+    const body = (await page.locator('body').innerText()) ?? ''
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/step-definitions/account-recovery.steps.ts` around lines 389 - 400, The
step definition that checks for absence of a technical field name uses
page.locator('body').textContent(), which captures hidden and script text and
can produce false positives; change the call to await
page.locator('body').innerText() (keeping the existing null-coalescing and
toLowerCase comparison) so only visible text is inspected — update the assertion
inside the Then step (the async function using getPage and body) to use
innerText() instead of textContent().
🧹 Nitpick comments (2)
packages/auth-service/src/routes/preview.ts (1)

219-223: ⚡ Quick win

Extract the duplicate fake requestUri string into a named module-level constant.

The literal 'urn:ietf:params:oauth:request_uri:preview' is duplicated at lines 223 and 255. The file already establishes the pattern of naming preview sentinel values as SCREAMING_SNAKE_CASE constants (FAKE_FLOW_ID, FAKE_REQUEST_URI, etc.), and the coding guidelines require that pattern for module-level magic values.

♻️ Proposed refactor
 const FAKE_REQUEST_URI =
   'urn:ietf:params:oauth:request_uri:req-preview-0000000000000000'
+const FAKE_LOGIN_REQUEST_URI = 'urn:ietf:params:oauth:request_uri:preview'
 const FAKE_EMAIL = 'alice@example.com'
-        // Preview pages don't sit behind a real OAuth flow — pass a
-        // recognisable fake request_uri so the recovery link renders
-        // something shaped right without pretending to point at a
-        // live PAR.
-        requestUri: 'urn:ietf:params:oauth:request_uri:preview',
+        // Preview pages don't sit behind a real OAuth flow — pass a
+        // recognisable fake request_uri so the recovery link renders
+        // something shaped right without pretending to point at a live PAR.
+        requestUri: FAKE_LOGIN_REQUEST_URI,
-        // See preview/login note above — fake request_uri.
-        requestUri: 'urn:ietf:params:oauth:request_uri:preview',
+        // See preview/login note above — fake request_uri.
+        requestUri: FAKE_LOGIN_REQUEST_URI,

As per coding guidelines: "Use SCREAMING_SNAKE_CASE for module-level magic value constants."

Also applies to: 254-255

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/auth-service/src/routes/preview.ts` around lines 219 - 223, Extract
the duplicated literal 'urn:ietf:params:oauth:request_uri:preview' into a
module-level SCREAMING_SNAKE_CASE constant (e.g. FAKE_REQUEST_URI) and replace
the two inline occurrences with that constant; locate usage near the preview
request construction (requestUri property) and the other spot currently using
the same string (the recovery link logic) and swap them to use FAKE_REQUEST_URI
alongside the existing FAKE_FLOW_ID constants to follow the file's established
pattern.
packages/auth-service/src/routes/account-login.ts (1)

214-214: 💤 Low value

Consider moving static margin-top into the CSS constant.

style="margin-top: 12px;" is a hardcoded value — not a dynamic value computed at render time — so it fits the existing CSS template string better. The letter-spacing on line 210 is correctly inline because it is dynamic.

♻️ Suggested refactor
-        <button type="submit" class="btn-secondary">Resend code</button>
+        <button type="submit" class="btn-secondary resend-btn">Resend code</button>

Add to const CSS:

   .btn-secondary { display: inline-block; color: `#0f1828`; background: none; border: none; font-size: 14px; cursor: pointer; text-decoration: underline; }
+  .resend-btn { margin-top: 12px; }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/auth-service/src/routes/account-login.ts` at line 214, The inline
static style style="margin-top: 12px;" on the POST form for "/account/send-otp"
should be moved into the existing CSS template string (const CSS) and applied
via a class or selector; update const CSS to include a rule (e.g.,
.send-otp-form { margin-top: 12px; } or form[action="/account/send-otp"] {
margin-top: 12px; }) and remove the inline style attribute in the form element
in packages/auth-service/src/routes/account-login.ts so only dynamic styles
(like the letter-spacing) remain inline.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@e2e/step-definitions/account-recovery.steps.ts`:
- Around line 389-400: The step definition that checks for absence of a
technical field name uses page.locator('body').textContent(), which captures
hidden and script text and can produce false positives; change the call to await
page.locator('body').innerText() (keeping the existing null-coalescing and
toLowerCase comparison) so only visible text is inspected — update the assertion
inside the Then step (the async function using getPage and body) to use
innerText() instead of textContent().

In `@packages/demo/src/app/api/oauth/callback/route.ts`:
- Around line 57-59: The console.error call in the OAuth callback logs
user-controlled values raw (error and errorDescription) creating a log-injection
risk; update the callback handler in route.ts to sanitize both values with the
existing sanitizeForLog helper before logging (e.g., call sanitizeForLog(error)
and sanitizeForLog(errorDescription)) and use those sanitized variables in the
console.error message so no raw query param content is written to logs.
- Line 56: The inner declarations named "code" shadow the outer OAuth
authorization code variable (the one from
request.nextUrl.searchParams.get('code')), so rename the inner variables in both
places (the branch where isTimeout sets 'session_expired'/'auth_failed' and the
later branch around the new logic at ~195) to a distinct identifier such as
"errorCode" or "authErrorCode" and update any uses in those blocks (including
the return payload) to avoid shadowing while preserving the same string values
and control flow; ensure you only change the inner bindings (not the outer OAuth
"code") and run the linter to confirm no no-shadow violations remain.
- Around line 53-55: Update the isTimeout check in route.ts so it no longer
treats any errorDescription containing the bare word "session" as a timeout: in
the isTimeout expression (the variable using error and errorDescription),
replace the current regex (/timed out|too long|expired|session/i) with one that
only matches timeout-related phrases (for example, require word boundaries and
explicit phrases like /\b(?:timed out|too long|expired|session (?:timed
out|expired|ended)|session expired)\b/i or simply /\b(?:timed out|too
long|expired|session (?:timed out|expired))\b/i) so only genuine timeout phrases
trigger session_expired; keep the rest of the logic unchanged and ensure you
reference the same isTimeout, error, and errorDescription variables.

---

Nitpick comments:
In `@packages/auth-service/src/routes/account-login.ts`:
- Line 214: The inline static style style="margin-top: 12px;" on the POST form
for "/account/send-otp" should be moved into the existing CSS template string
(const CSS) and applied via a class or selector; update const CSS to include a
rule (e.g., .send-otp-form { margin-top: 12px; } or
form[action="/account/send-otp"] { margin-top: 12px; }) and remove the inline
style attribute in the form element in
packages/auth-service/src/routes/account-login.ts so only dynamic styles (like
the letter-spacing) remain inline.

In `@packages/auth-service/src/routes/preview.ts`:
- Around line 219-223: Extract the duplicated literal
'urn:ietf:params:oauth:request_uri:preview' into a module-level
SCREAMING_SNAKE_CASE constant (e.g. FAKE_REQUEST_URI) and replace the two inline
occurrences with that constant; locate usage near the preview request
construction (requestUri property) and the other spot currently using the same
string (the recovery link logic) and swap them to use FAKE_REQUEST_URI alongside
the existing FAKE_FLOW_ID constants to follow the file's established pattern.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 10648965-07f2-4255-8848-9c627e0c382b

📥 Commits

Reviewing files that changed from the base of the PR and between 4a899b6 and fe13498.

📒 Files selected for processing (26)
  • .changeset/account-login-honest-lockout-message.md
  • .changeset/charset-filter-on-server-rendered-otp-forms.md
  • .changeset/clear-otp-boxes-on-resend.md
  • .changeset/filter-paste-to-otp-charset.md
  • .changeset/friendlier-demo-error-banners.md
  • .changeset/friendlier-stale-authorize-link-error.md
  • .changeset/handle-not-available-message.md
  • .changeset/recovery-honest-lockout-message.md
  • .changeset/recovery-link-uses-real-request-uri.md
  • .changeset/sms-autofill-distributes-across-otp-boxes.md
  • .changeset/spam-hint-on-otp-form.md
  • e2e/step-definitions/account-recovery.steps.ts
  • e2e/step-definitions/auth.steps.ts
  • features/account-recovery.feature
  • features/passwordless-authentication.feature
  • packages/auth-service/src/__tests__/heartbeat-toggle.test.ts
  • packages/auth-service/src/__tests__/login-page.test.ts
  • packages/auth-service/src/__tests__/otp-verify-error.test.ts
  • packages/auth-service/src/lib/otp-verify-error.ts
  • packages/auth-service/src/routes/account-login.ts
  • packages/auth-service/src/routes/choose-handle.ts
  • packages/auth-service/src/routes/login-page.ts
  • packages/auth-service/src/routes/preview.ts
  • packages/auth-service/src/routes/recovery.ts
  • packages/demo/src/app/api/oauth/callback/route.ts
  • packages/demo/src/app/components/LoginForm.tsx
✅ Files skipped from review due to trivial changes (13)
  • .changeset/account-login-honest-lockout-message.md
  • .changeset/spam-hint-on-otp-form.md
  • .changeset/charset-filter-on-server-rendered-otp-forms.md
  • .changeset/clear-otp-boxes-on-resend.md
  • packages/auth-service/src/routes/choose-handle.ts
  • .changeset/handle-not-available-message.md
  • .changeset/friendlier-stale-authorize-link-error.md
  • .changeset/sms-autofill-distributes-across-otp-boxes.md
  • packages/auth-service/src/lib/otp-verify-error.ts
  • .changeset/recovery-honest-lockout-message.md
  • packages/auth-service/src/tests/heartbeat-toggle.test.ts
  • .changeset/recovery-link-uses-real-request-uri.md
  • .changeset/friendlier-demo-error-banners.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • e2e/step-definitions/auth.steps.ts
  • packages/auth-service/src/routes/recovery.ts
  • packages/auth-service/src/tests/login-page.test.ts
  • packages/auth-service/src/routes/login-page.ts

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 36 out of 36 changed files in this pull request and generated 1 comment.

Comment on lines +903 to +912
// 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.
aspiers and others added 4 commits May 7, 2026 08:45
Buttons on the OTP form had :hover styles but no :focus-visible
styles — so keyboard users tabbing through the form had no idea
which button was focused (the browser default focus ring is
suppressed by some user-agent stylesheets and the .btn-primary
explicitly sets `border: none`).

Add :focus-visible styles on .btn-primary and .btn-secondary
that match the existing --focus-border CSS custom property the
input fields already use, with a 2px outline + 2px offset so
the ring sits clear of the rounded button edges.

Pure CSS change; no behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same focus-visible treatment that the main login page got in the
previous commit, applied to the standalone account-login form,
the recovery form, and the handle-picker buttons. All three had
:hover styles and no :focus-visible — keyboard users tabbing
through had no idea where focus was.

Pure CSS change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Covers both prior commits (50077c0 main login + 645a2fb other
routes) — single end-user change with one consolidated entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The demo's sign-in error banner was a plain <div>. When an error
appeared screen-reader users got no announcement — visual users
saw the red banner, screen-reader users had no idea anything had
changed.

role="alert" matches the auth-service error banners in the
previous commits (which got role=status / role=alert) and tells
assistive tech to announce the message immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 37 out of 37 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

packages/pds-core/src/lib/reserved-handle.ts:1

  • Using the in operator can produce false positives for keys inherited from Object.prototype (e.g. "toString", "constructor", "__proto__"), which would incorrectly flag some handles as reserved. Prefer an own-property check such as Object.hasOwn(reservedSubdomains, local.toLowerCase()) (or Object.prototype.hasOwnProperty.call(...)) to avoid prototype-chain matches.

document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'visible' && heartbeatEnabled) {
pingHeartbeat();
refreshResendVisibility();
Comment on lines +812 to +814
(url) =>
url.origin === origin &&
/^[?&](error=(auth_failed|session_expired))/.test(url.search),
Comment on lines +904 to +912
// `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.
@blacksmith-sh

This comment has been minimized.

aspiers and others added 2 commits May 7, 2026 09:44
…route

The /_internal/check-handle handler open-coded the OR-shape that
combines DB existence with reserved-subdomain match. Extract that
into a pure helper (handleIsUnavailable) on the existing
reserved-handle module so:

  - The route handler stays thin (one call instead of three lines).
  - Unit tests can cover the OR-shape directly without standing up
    a PDS instance — including the corner cases (account exists +
    not reserved, account missing + reserved, both, neither, case-
    insensitive matching, empty fullHandle).

Six new unit tests on the helper. No behaviour change at the HTTP
boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shared renderError() helper produces every styled HTML
fallback page (Sign-in session expired, internal failures,
session not found, …). The error message <p> was a plain
paragraph; screen-reader users got no announcement when these
pages loaded.

role="alert" tells assistive tech to announce immediately on
page render. Matches the role=alert treatment we just gave the
inline server-rendered error banners on account-login and
recovery.

One-character behaviour change; no test fixtures depend on the
exact HTML.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 38 changed files in this pull request and generated 2 comments.

Comment on lines +904 to +912
// `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.
// 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.
aspiers and others added 2 commits May 7, 2026 10:27
resolveClientMetadata returned `{ client_name: clientId }` when the
clientId failed to parse as a URL, or used a non-http(s) protocol.
The intent was probably "give the caller something to display" —
but the upshot was that a malformed query-string client_id ("Sign
in to not-a-url") or a schemeless one ("Sign in to javascript:…")
showed up verbatim in:

  - the auth-service login page <title> tag
  - the consent screen header
  - the OTP form's "Sign in to <appName>" heading

— effectively letting the URL bar shape user-visible text.

Return `{}` from those branches instead, so the caller's
fallback chain (`client_name || extractDomain(clientId) ||
'an application'`) ends in the static "an application" string.

Tests: two existing assertions on the leaky behaviour are
inverted to assert the fallback path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…er every action

Every POST handler in /account/* redirects back to /account with a
?success=<code> or ?error=<code> query param to confirm the action
took effect. The GET handler was completely ignoring those query
params — the user clicked "Add backup email" → server processed
it → server redirected back → user landed on the same page with
zero indication anything had happened.

Wire the params through to renderSettingsPage and render a
green flash banner on success, red on error. Whitelist the
recognised codes via FLASH_SUCCESS_MESSAGES / FLASH_ERROR_MESSAGES
(both exported, both unit-tested) so an attacker can't craft a
URL like `?error=Some+raw+text+to+display` to inject arbitrary
copy. Banners go through escapeHtml on render too as defence in
depth.

Also added two missing redirects:
  - POST /account/backup-email/remove → ?success=backup_removed
  - POST /account/sessions/revoke    → ?success=session_revoked

Tests: new account-settings-flash.test.ts asserts every code
redirected to from a POST handler has a matching entry, plus
the unknown-code → undefined safety test and the no-HTML
test on the message values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 44 out of 44 changed files in this pull request and generated 2 comments.

Comment on lines +18 to +22
import { reservedSubdomains } from '@atproto/pds/dist/handle/reserved.js'

export function isReservedSubdomain(local: string): boolean {
return local.toLowerCase() in reservedSubdomains
}
Comment on lines +904 to +912
// `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.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/auth-service/src/routes/account-settings.ts (2)

260-276: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

?success=backup_removed is shown even when removal is silently skipped

When did is null (PDS account not found for the signed-in email), the if (did && email) guard skips ctx.db.removeBackupEmail entirely, but line 274 still redirects to ?success=backup_removed, showing "Backup email removed." for an operation that did nothing. There is also no try/catch around the DB call; a synchronous throw from better-sqlite3 inside an async Express 4 handler will become an unhandled rejection rather than a clean error redirect.

🐛 Proposed fix
-      if (did && email) {
-        ctx.db.removeBackupEmail(did, email)
-      }
-      res.redirect(303, '/account?success=backup_removed')
+      if (!did || !email) {
+        res.redirect(303, '/account?error=account_not_found')
+        return
+      }
+      try {
+        ctx.db.removeBackupEmail(did, email)
+        res.redirect(303, '/account?success=backup_removed')
+      } catch (err) {
+        logger.error({ err }, 'Failed to remove backup email')
+        res.redirect(303, '/account?error=delete_failed')
+      }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/auth-service/src/routes/account-settings.ts` around lines 260 - 276,
The POST /account/backup-email/remove handler currently always redirects with
?success=backup_removed even when getDidByEmail returns null or the removal is
skipped, and it lacks error handling for ctx.db.removeBackupEmail which may
throw; modify the async handler around getDidByEmail and
ctx.db.removeBackupEmail so that you only redirect with success when a removal
actually ran (e.g., when did and email are truthy and removeBackupEmail
completes), wrap the removeBackupEmail call in a try/catch to catch synchronous
DB errors from ctx.db.removeBackupEmail and on error redirect to an error query
(e.g., /account?error=backup_remove_failed) or send an appropriate 500, and
ensure you call res.redirect only once (success path after successful removal,
not unconditionally at the end). Use the existing symbols: the route handler for
'/account/backup-email/remove', getDidByEmail, ctx.db.removeBackupEmail, and
res.redirect to locate and implement the fix.

279-295: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

?success=session_revoked is sent even when the revocation fails

The catch block (lines 290–292) logs the warning and falls through; execution always reaches line 294, so a user now sees the "Session revoked." success banner even when revokeSession threw. Before this PR the redirect was to plain /account with no feedback, so the misleading message is newly introduced here.

🐛 Proposed fix
     const tokenToRevoke = req.body.session_token as string
     if (tokenToRevoke) {
+      let revokeOk = false
       try {
         await auth.api.revokeSession({
           body: { token: tokenToRevoke },
           headers: fromNodeHeaders(req.headers),
         })
+        revokeOk = true
       } catch (err) {
         logger.warn({ err }, 'Failed to revoke session')
       }
+      if (!revokeOk) {
+        res.redirect(303, '/account?error=revoke_failed')
+        return
+      }
     }
     res.redirect(303, '/account?success=session_revoked')

Also add revoke_failed to FLASH_ERROR_MESSAGES:

+  revoke_failed: "We couldn't revoke that session. Please try again.",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/auth-service/src/routes/account-settings.ts` around lines 279 - 295,
The POST /account/session/revoke route always redirects with
?success=session_revoked even when auth.api.revokeSession throws; update the
route handler (router.post('/account/session/revoke')) so that inside the catch
block you redirect to '/account?error=revoke_failed' (or otherwise avoid sending
the success query) instead of falling through to the success redirect, and also
add 'revoke_failed' to the FLASH_ERROR_MESSAGES constant so the UI can show the
corresponding error message for the revoke failure.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/auth-service/src/routes/account-settings.ts`:
- Around line 260-276: The POST /account/backup-email/remove handler currently
always redirects with ?success=backup_removed even when getDidByEmail returns
null or the removal is skipped, and it lacks error handling for
ctx.db.removeBackupEmail which may throw; modify the async handler around
getDidByEmail and ctx.db.removeBackupEmail so that you only redirect with
success when a removal actually ran (e.g., when did and email are truthy and
removeBackupEmail completes), wrap the removeBackupEmail call in a try/catch to
catch synchronous DB errors from ctx.db.removeBackupEmail and on error redirect
to an error query (e.g., /account?error=backup_remove_failed) or send an
appropriate 500, and ensure you call res.redirect only once (success path after
successful removal, not unconditionally at the end). Use the existing symbols:
the route handler for '/account/backup-email/remove', getDidByEmail,
ctx.db.removeBackupEmail, and res.redirect to locate and implement the fix.
- Around line 279-295: The POST /account/session/revoke route always redirects
with ?success=session_revoked even when auth.api.revokeSession throws; update
the route handler (router.post('/account/session/revoke')) so that inside the
catch block you redirect to '/account?error=revoke_failed' (or otherwise avoid
sending the success query) instead of falling through to the success redirect,
and also add 'revoke_failed' to the FLASH_ERROR_MESSAGES constant so the UI can
show the corresponding error message for the revoke failure.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8d1b9886-341d-4ee6-9f4c-0591b729f615

📥 Commits

Reviewing files that changed from the base of the PR and between fea121e and 40ad0e0.

📒 Files selected for processing (7)
  • .changeset/account-settings-flash-messages.md
  • .changeset/fallback-app-name-on-malformed-client-id.md
  • packages/auth-service/src/__tests__/account-settings-flash.test.ts
  • packages/auth-service/src/routes/account-settings.ts
  • packages/shared/src/__tests__/client-metadata.test.ts
  • packages/shared/src/client-metadata.ts
  • packages/shared/src/render-error.ts
✅ Files skipped from review due to trivial changes (4)
  • packages/shared/src/render-error.ts
  • packages/auth-service/src/tests/account-settings-flash.test.ts
  • .changeset/account-settings-flash-messages.md
  • .changeset/fallback-app-name-on-malformed-client-id.md

…ndler

The GET /account handler open-coded the success/error code →
message lookup in 4 lines. Lift that into a pure helper
(resolveAccountFlashFromQuery) so:

  - Coverage tooling can see the lookup branches without an
    integration test (route handler stays at the same coverage
    level, but the lookup itself is now fully covered).
  - Future maintainers swapping which params plumb through
    don't have to re-derive the safety logic.
  - Non-string query values (?success=foo&success=bar arrays,
    numeric values) are handled explicitly rather than relying
    on Express type coercion.

5 new unit tests covering known codes, empty query, unknown
codes (URL-injection safety), and non-string query values.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented May 7, 2026

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants