소스 검색

Ask for a new sign-in instead of "Session is not fresh" when adding a passkey (#565)

* Ask for a new sign-in instead of "Session is not fresh" when adding a passkey

A passkey can only be added to a session that began within the last day,
and a session lasts seven. Past that, Settings > Account showed Better
Auth's raw English "Session is not fresh". It now shows a translated
notice with a Sign in again button, which ends the session and comes back
to the page once the person has signed in.

Closing the browser's passkey prompt no longer shows the browser's raw
error either: the check looked for a code the passkey client never sends.
The Register Passkey button is now reset in a finally block, so no early
return can leave it spinning.

* Stay quiet when a passkey prompt is dismissed on the sign-in page

Closing or timing out the browser's passkey prompt on the sign-in page
showed "Auth cancelled": the check looked for AUTH_CANCELLED, but the
passkey client passes the WebAuthn code through and only puts its fixed
"Auth cancelled" text in the message. A closed prompt now says nothing,
as on Settings > Account, and other browser-side failures show the
translated passkey error instead of that text. The list of prompt-closed
codes moves to src/lib/passkey-prompt.ts so both pages share it.

* Say so when signing out for a new sign-in fails

The Sign in again button on the passkey notice went to the sign-in page even when the sign-out had failed, and that page sends a signed-in visitor straight back with nothing said. It now stays put and shows an error.

* Return to the page a sign-in was for after two-factor verification

Signing in again to add a passkey came back to Settings > Account only without two-factor: the redirect to the code page dropped the return path and a verified code always went to the dashboard. The code page now carries the sign-in page's redirect and goes there once verified, kept on this site.
Bernt Christian Egeland 2 일 전
부모
커밋
1066e1e0bf

+ 3 - 0
messages/de/settings.json

@@ -340,6 +340,9 @@
       "registering": "Registriere...",
       "registerSuccess": "Passkey erfolgreich registriert",
       "registerFailed": "Passkey konnte nicht registriert werden",
+      "signInAgainTitle": "Melden Sie sich erneut an, um einen Passkey hinzuzufügen",
+      "signInAgainDescription": "Zum Schutz Ihres Kontos kann ein Passkey nur kurz nach der Anmeldung hinzugefügt werden. Melden Sie sich erneut an und registrieren Sie ihn dann.",
+      "signInAgain": "Erneut anmelden",
       "delete": "Löschen",
       "deleteSuccess": "Passkey gelöscht",
       "deleteFailed": "Passkey konnte nicht gelöscht werden",

+ 3 - 0
messages/en/settings.json

@@ -340,6 +340,9 @@
       "registering": "Registering...",
       "registerSuccess": "Passkey registered successfully",
       "registerFailed": "Failed to register passkey",
+      "signInAgainTitle": "Sign in again to add a passkey",
+      "signInAgainDescription": "To keep your account safe, a passkey can only be added soon after signing in. Sign in again, then register it.",
+      "signInAgain": "Sign in again",
       "delete": "Delete",
       "deleteSuccess": "Passkey deleted",
       "deleteFailed": "Failed to delete passkey",

+ 3 - 0
messages/es/settings.json

@@ -340,6 +340,9 @@
       "registering": "Registrando...",
       "registerSuccess": "Passkey registrada correctamente",
       "registerFailed": "Error al registrar passkey",
+      "signInAgainTitle": "Inicia sesión de nuevo para añadir una passkey",
+      "signInAgainDescription": "Para proteger tu cuenta, una passkey solo se puede añadir poco después de iniciar sesión. Inicia sesión de nuevo y luego regístrala.",
+      "signInAgain": "Iniciar sesión de nuevo",
       "delete": "Eliminar",
       "deleteSuccess": "Passkey eliminada",
       "deleteFailed": "Error al eliminar passkey",

+ 3 - 0
messages/fr/settings.json

@@ -340,6 +340,9 @@
       "registering": "Enregistrement...",
       "registerSuccess": "Passkey enregistrée avec succès",
       "registerFailed": "Échec de l'enregistrement de la passkey",
+      "signInAgainTitle": "Reconnectez-vous pour ajouter une passkey",
+      "signInAgainDescription": "Pour protéger votre compte, une passkey ne peut être ajoutée que peu de temps après la connexion. Reconnectez-vous, puis enregistrez-la.",
+      "signInAgain": "Se reconnecter",
       "delete": "Supprimer",
       "deleteSuccess": "Passkey supprimée",
       "deleteFailed": "Échec de la suppression de la passkey",

+ 3 - 0
messages/it/settings.json

@@ -340,6 +340,9 @@
       "registering": "Registrazione...",
       "registerSuccess": "Passkey registrata con successo",
       "registerFailed": "Registrazione passkey fallita",
+      "signInAgainTitle": "Accedi di nuovo per aggiungere una passkey",
+      "signInAgainDescription": "Per proteggere il tuo account, una passkey può essere aggiunta solo poco dopo l'accesso. Accedi di nuovo e poi registrala.",
+      "signInAgain": "Accedi di nuovo",
       "delete": "Elimina",
       "deleteSuccess": "Passkey eliminata",
       "deleteFailed": "Eliminazione passkey fallita",

+ 3 - 0
messages/lt/settings.json

@@ -340,6 +340,9 @@
       "registering": "Registruojama...",
       "registerSuccess": "Prieigos raktas sėkmingai užregistruotas",
       "registerFailed": "Nepavyko užregistruoti prieigos rakto",
+      "signInAgainTitle": "Prisijunkite iš naujo, kad pridėtumėte prieigos raktą",
+      "signInAgainDescription": "Kad jūsų paskyra būtų saugi, prieigos raktą galima pridėti tik netrukus po prisijungimo. Prisijunkite iš naujo ir tada jį užregistruokite.",
+      "signInAgain": "Prisijungti iš naujo",
       "delete": "Ištrinti",
       "deleteSuccess": "Prieigos raktas ištrintas",
       "deleteFailed": "Nepavyko ištrinti prieigos rakto",

+ 3 - 0
messages/nb/settings.json

@@ -340,6 +340,9 @@
       "registering": "Registrerer...",
       "registerSuccess": "Passkey registrert",
       "registerFailed": "Kunne ikke registrere passkey",
+      "signInAgainTitle": "Logg inn på nytt for å legge til en passkey",
+      "signInAgainDescription": "For å beskytte kontoen din kan en passkey bare legges til kort tid etter at du har logget inn. Logg inn på nytt, og registrer den deretter.",
+      "signInAgain": "Logg inn på nytt",
       "delete": "Slett",
       "deleteSuccess": "Passkey slettet",
       "deleteFailed": "Kunne ikke slette passkey",

+ 3 - 0
messages/nl/settings.json

@@ -340,6 +340,9 @@
       "registering": "Registreren...",
       "registerSuccess": "Passkey succesvol geregistreerd",
       "registerFailed": "Passkey registreren mislukt",
+      "signInAgainTitle": "Log opnieuw in om een passkey toe te voegen",
+      "signInAgainDescription": "Om uw account te beschermen kan een passkey alleen kort na het inloggen worden toegevoegd. Log opnieuw in en registreer de passkey daarna.",
+      "signInAgain": "Opnieuw inloggen",
       "delete": "Verwijderen",
       "deleteSuccess": "Passkey verwijderd",
       "deleteFailed": "Passkey verwijderen mislukt",

+ 3 - 0
messages/pl/settings.json

@@ -340,6 +340,9 @@
       "registering": "Rejestrowanie...",
       "registerSuccess": "Passkey zarejestrowany pomyślnie",
       "registerFailed": "Nie udało się zarejestrować passkey",
+      "signInAgainTitle": "Zaloguj się ponownie, aby dodać passkey",
+      "signInAgainDescription": "Aby chronić Twoje konto, passkey można dodać tylko krótko po zalogowaniu. Zaloguj się ponownie, a potem go zarejestruj.",
+      "signInAgain": "Zaloguj się ponownie",
       "delete": "Usuń",
       "deleteSuccess": "Passkey usunięty",
       "deleteFailed": "Nie udało się usunąć passkey",

+ 3 - 0
messages/pt-BR/settings.json

@@ -340,6 +340,9 @@
       "registering": "Registrando...",
       "registerSuccess": "Passkey registrada com sucesso",
       "registerFailed": "Falha ao registrar passkey",
+      "signInAgainTitle": "Entre novamente para adicionar uma passkey",
+      "signInAgainDescription": "Para proteger sua conta, uma passkey só pode ser adicionada logo depois de entrar. Entre novamente e registre-a em seguida.",
+      "signInAgain": "Entrar novamente",
       "delete": "Excluir",
       "deleteSuccess": "Passkey excluída",
       "deleteFailed": "Falha ao excluir passkey",

+ 3 - 0
messages/pt-PT/settings.json

@@ -340,6 +340,9 @@
       "registering": "A registar...",
       "registerSuccess": "Passkey registada com sucesso",
       "registerFailed": "Não foi possível registar a passkey",
+      "signInAgainTitle": "Inicie sessão novamente para adicionar uma passkey",
+      "signInAgainDescription": "Para proteger a sua conta, uma passkey só pode ser adicionada pouco depois de iniciar sessão. Inicie sessão novamente e registe-a em seguida.",
+      "signInAgain": "Iniciar sessão novamente",
       "delete": "Eliminar",
       "deleteSuccess": "Passkey eliminada",
       "deleteFailed": "Não foi possível eliminar a passkey",

+ 3 - 0
messages/ru/settings.json

@@ -340,6 +340,9 @@
       "registering": "Регистрация...",
       "registerSuccess": "Ключ доступа успешно зарегистрирован",
       "registerFailed": "Не удалось зарегистрировать ключ доступа",
+      "signInAgainTitle": "Войдите снова, чтобы добавить ключ доступа",
+      "signInAgainDescription": "Для защиты аккаунта ключ доступа можно добавить только вскоре после входа. Войдите снова, а затем зарегистрируйте его.",
+      "signInAgain": "Войти снова",
       "delete": "Удалить",
       "deleteSuccess": "Ключ доступа удалён",
       "deleteFailed": "Не удалось удалить ключ доступа",

+ 3 - 0
messages/tr/settings.json

@@ -340,6 +340,9 @@
       "registering": "Kaydediliyor...",
       "registerSuccess": "Passkey başarıyla kaydedildi",
       "registerFailed": "Passkey kaydedilemedi",
+      "signInAgainTitle": "Passkey eklemek için yeniden giriş yapın",
+      "signInAgainDescription": "Hesabınızı korumak için passkey yalnızca giriş yaptıktan kısa süre sonra eklenebilir. Yeniden giriş yapın, ardından passkey'i kaydedin.",
+      "signInAgain": "Yeniden giriş yap",
       "delete": "Sil",
       "deleteSuccess": "Passkey silindi",
       "deleteFailed": "Passkey silinemedi",

+ 141 - 1
src/__tests__/features/auth/passkey-settings.test.tsx

@@ -1,4 +1,4 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest'
 import { render, screen, waitFor } from '@testing-library/react'
 import userEvent from '@testing-library/user-event'
 
@@ -6,9 +6,11 @@ import userEvent from '@testing-library/user-event'
 vi.mock('lucide-react', () => ({
   Fingerprint: () => <svg data-testid="icon-fingerprint" />,
   Loader2: () => <svg data-testid="icon-loader" />,
+  LogIn: () => <svg data-testid="icon-log-in" />,
   Pencil: () => <svg data-testid="icon-pencil" />,
   Plus: () => <svg data-testid="icon-plus" />,
   Trash2: () => <svg data-testid="icon-trash" />,
+  X: () => <svg data-testid="icon-x" />,
 }))
 
 // Mock UI components (pass-through)
@@ -72,20 +74,34 @@ vi.mock('@/components/ui/separator', () => ({
 // Mock sonner
 const mockToastError = vi.fn()
 const mockToastSuccess = vi.fn()
+const mockToastCustom = vi.fn()
 vi.mock('sonner', () => ({
   toast: {
     error: (...args: unknown[]) => mockToastError(...args),
     success: (...args: unknown[]) => mockToastSuccess(...args),
+    custom: (...args: unknown[]) => mockToastCustom(...args),
+    dismiss: vi.fn(),
   },
 }))
 
+/** Puts the notice the toaster was handed on screen, the way sonner would. */
+function showToastedNotice() {
+  const [draw, options] = mockToastCustom.mock.calls[0] as [
+    (toastId: string) => React.ReactElement,
+    { id: string },
+  ]
+  render(draw(options.id))
+}
+
 // Mock authClient.passkey
 const mockListUserPasskeys = vi.fn()
 const mockAddPasskey = vi.fn()
 const mockDeletePasskey = vi.fn()
 const mockUpdatePasskey = vi.fn()
+const mockSignOut = vi.fn()
 vi.mock('@/lib/auth-client', () => ({
   authClient: {
+    signOut: (...args: unknown[]) => mockSignOut(...args),
     passkey: {
       listUserPasskeys: (...args: unknown[]) => mockListUserPasskeys(...args),
       addPasskey: (...args: unknown[]) => mockAddPasskey(...args),
@@ -117,6 +133,10 @@ beforeEach(() => {
   vi.resetAllMocks()
 })
 
+afterEach(() => {
+  vi.unstubAllGlobals()
+})
+
 describe('PasskeySettings', () => {
   describe('initial load', () => {
     it('shows empty state when no passkeys exist', async () => {
@@ -227,6 +247,126 @@ describe('PasskeySettings', () => {
         expect(mockToastError).toHaveBeenCalledWith('Browser not supported')
       })
     })
+
+    it('stays on the page and says so when signing out for a new sign-in fails', async () => {
+      const assign = vi.fn()
+      vi.stubGlobal('location', {
+        ...window.location,
+        pathname: '/settings/account',
+        search: '',
+        assign,
+      })
+      mockListUserPasskeys.mockResolvedValue({ data: [] })
+      mockAddPasskey.mockResolvedValue({
+        data: null,
+        error: { code: 'SESSION_NOT_FRESH', message: 'Session is not fresh', status: 403 },
+      })
+      // Still signed in: the sign-in page would only send them straight back
+      mockSignOut.mockResolvedValue({ data: null, error: { message: 'Server error', status: 500 } })
+
+      render(<PasskeySettings />)
+      await userEvent.click(await screen.findByRole('button', { name: /register passkey/i }))
+      await waitFor(() => {
+        expect(mockToastCustom).toHaveBeenCalledTimes(1)
+      })
+      showToastedNotice()
+      await userEvent.click(screen.getByRole('button', { name: 'Sign in again' }))
+
+      await waitFor(() => {
+        expect(mockToastError).toHaveBeenCalledWith(
+          'An unexpected error occurred. Please try again.'
+        )
+      })
+      expect(assign).not.toHaveBeenCalled()
+    })
+
+    it('asks for a new sign-in, and offers it, when the sign-in is too old to add a passkey', async () => {
+      const assign = vi.fn()
+      vi.stubGlobal('location', {
+        ...window.location,
+        pathname: '/settings/account',
+        search: '',
+        assign,
+      })
+      mockListUserPasskeys.mockResolvedValue({ data: [] })
+      // What Better Auth answers once the session is older than its fresh age
+      mockAddPasskey.mockResolvedValue({
+        data: null,
+        error: {
+          code: 'SESSION_NOT_FRESH',
+          message: 'Session is not fresh',
+          status: 403,
+          statusText: 'Forbidden',
+        },
+      })
+
+      render(<PasskeySettings />)
+      await userEvent.click(await screen.findByRole('button', { name: /register passkey/i }))
+
+      await waitFor(() => {
+        expect(mockToastCustom).toHaveBeenCalledTimes(1)
+      })
+      showToastedNotice()
+      expect(screen.getByRole('status')).toHaveTextContent('Sign in again to add a passkey')
+      expect(screen.queryByText(/session is not fresh/i)).not.toBeInTheDocument()
+      expect(mockToastError).not.toHaveBeenCalled()
+      // Nothing happens to the session until the person asks for it
+      expect(mockSignOut).not.toHaveBeenCalled()
+      expect(screen.getByRole('button', { name: /register passkey/i })).toBeEnabled()
+
+      await userEvent.click(screen.getByRole('button', { name: 'Sign in again' }))
+
+      await waitFor(() => {
+        expect(assign).toHaveBeenCalledWith('/auth/sign-in?redirect=%2Fsettings%2Faccount')
+      })
+      // The sign-in page sends a signed-in visitor straight back, so the old
+      // session has to be over before the tab gets there
+      expect(mockSignOut).toHaveBeenCalledTimes(1)
+      expect(mockSignOut.mock.invocationCallOrder[0]).toBeLessThan(
+        assign.mock.invocationCallOrder[0]
+      )
+    })
+
+    // What the passkey client really hands back, see @better-auth/passkey's client
+    it.each([
+      {
+        how: 'dismissed or left to time out',
+        code: 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY',
+        message:
+          'The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.',
+      },
+      {
+        how: 'replaced by another prompt',
+        code: 'ERROR_CEREMONY_ABORTED',
+        message: 'Registration cancelled',
+      },
+    ])('says nothing and can be tried again when the browser prompt was $how', async ({
+      code,
+      message,
+    }) => {
+      mockListUserPasskeys.mockResolvedValue({ data: [] })
+      mockAddPasskey.mockResolvedValue({
+        data: null,
+        error: { code, message, status: 400, statusText: 'BAD_REQUEST' },
+      })
+
+      render(<PasskeySettings />)
+      await userEvent.click(await screen.findByRole('button', { name: /register passkey/i }))
+      await waitFor(() => {
+        expect(mockAddPasskey).toHaveBeenCalledTimes(1)
+      })
+
+      // Back to a button that can be pressed, not a spinner that never stops
+      const button = await screen.findByRole('button', { name: /register passkey/i })
+      expect(button).toBeEnabled()
+      expect(mockToastError).not.toHaveBeenCalled()
+      expect(mockToastCustom).not.toHaveBeenCalled()
+
+      await userEvent.click(button)
+      await waitFor(() => {
+        expect(mockAddPasskey).toHaveBeenCalledTimes(2)
+      })
+    })
   })
 
   describe('delete passkey', () => {

+ 47 - 0
src/__tests__/features/auth/passkey-signin.test.tsx

@@ -135,6 +135,53 @@ describe('SignInForm — Passkey', () => {
     })
   })
 
+  // What the passkey client returns when the browser's prompt is dismissed or
+  // times out (NotAllowedError) or is replaced by a newer one (AbortError):
+  // the WebAuthn code, with its own fixed message "Auth cancelled".
+  it.each([
+    'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY',
+    'ERROR_CEREMONY_ABORTED',
+  ])('says nothing when the passkey prompt is closed (%s) and keeps the form usable', async (code) => {
+    mockSignInPasskey.mockResolvedValue({
+      data: null,
+      error: { code, message: 'Auth cancelled', status: 400, statusText: 'BAD_REQUEST' },
+    })
+    mockSignInEmail.mockResolvedValue({ error: null })
+    render(<SignInForm registrationDisabled={false} />)
+
+    await userEvent.click(screen.getByText(/sign in with passkey/i))
+    await waitFor(() => expect(mockSignInPasskey).toHaveBeenCalled())
+
+    expect(screen.queryByText(/auth cancelled/i)).not.toBeInTheDocument()
+    expect(screen.queryByText(/passkey sign-in failed/i)).not.toBeInTheDocument()
+    expect(screen.getByText(/sign in with passkey/i).closest('button')).toBeEnabled()
+
+    await userEvent.type(screen.getByLabelText('Email'), 'user@test.com')
+    await userEvent.type(screen.getByLabelText('Password'), 'password123')
+    await userEvent.click(screen.getByText('Sign In'))
+    await waitFor(() => expect(mockPush).toHaveBeenCalledWith('/'))
+  })
+
+  it('shows a real message, not "Auth cancelled", when the browser refuses the passkey', async () => {
+    mockSignInPasskey.mockResolvedValue({
+      data: null,
+      error: {
+        code: 'ERROR_INVALID_RP_ID',
+        message: 'Auth cancelled',
+        status: 400,
+        statusText: 'BAD_REQUEST',
+      },
+    })
+    render(<SignInForm registrationDisabled={false} />)
+
+    await userEvent.click(screen.getByText(/sign in with passkey/i))
+
+    await waitFor(() => {
+      expect(screen.getByText(/passkey sign-in failed/i)).toBeInTheDocument()
+    })
+    expect(screen.queryByText(/auth cancelled/i)).not.toBeInTheDocument()
+  })
+
   it('shows fallback error when passkey sign-in throws', async () => {
     mockSignInPasskey.mockRejectedValue(new Error('Network error'))
     render(<SignInForm registrationDisabled={false} />)

+ 72 - 0
src/__tests__/features/auth/verify-2fa-redirect.test.tsx

@@ -0,0 +1,72 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+
+const mockPush = vi.fn()
+const mockRefresh = vi.fn()
+vi.mock('next/navigation', () => ({
+  useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
+}))
+
+vi.mock('@/components/auth-logo', () => ({ AuthLogo: () => null }))
+// The trust-device checkbox is not what this file tests.
+vi.mock('@/components/ui/checkbox', () => ({ Checkbox: () => null }))
+
+const mockVerifyTotp = vi.fn()
+vi.mock('@/lib/auth-client', () => ({
+  authClient: {
+    twoFactor: {
+      verifyTotp: (...args: unknown[]) => mockVerifyTotp(...args),
+      verifyBackupCode: vi.fn(),
+    },
+  },
+}))
+
+import VerifyTwoFactorPage from '@/app/(public)/auth/verify-2fa/page'
+import { twoFactorUrlFor } from '@/lib/signed-out'
+
+async function verifyAt(search: string) {
+  window.history.replaceState(null, '', `/auth/verify-2fa${search}`)
+  mockVerifyTotp.mockResolvedValue({ data: {}, error: null })
+  render(<VerifyTwoFactorPage />)
+  await userEvent.type(screen.getByLabelText('Authentication Code'), '123456')
+  await userEvent.click(screen.getByRole('button', { name: 'Verify' }))
+  await waitFor(() => expect(mockPush).toHaveBeenCalled())
+}
+
+describe('two-factor sign-in returns to the page it was for', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+  afterEach(() => {
+    window.history.replaceState(null, '', '/')
+  })
+
+  it('goes back to Settings > Account after a new sign-in to add a passkey', async () => {
+    await verifyAt(`?redirect=${encodeURIComponent('/settings/account')}`)
+    expect(mockPush).toHaveBeenCalledWith('/settings/account')
+  })
+
+  it('goes to the dashboard when no page was asked for', async () => {
+    await verifyAt('')
+    expect(mockPush).toHaveBeenCalledWith('/')
+  })
+
+  it('never leaves the site for a redirect pointing elsewhere', async () => {
+    await verifyAt(`?redirect=${encodeURIComponent('//evil.example/x')}`)
+    expect(mockPush).toHaveBeenCalledWith('/')
+  })
+})
+
+describe('twoFactorUrlFor', () => {
+  it('carries the sign-in page redirect on to the two-factor page', () => {
+    expect(twoFactorUrlFor(`?redirect=${encodeURIComponent('/settings/account')}`)).toBe(
+      '/auth/verify-2fa?redirect=%2Fsettings%2Faccount'
+    )
+  })
+
+  it('is the plain two-factor page without one, or with one off the site', () => {
+    expect(twoFactorUrlFor('')).toBe('/auth/verify-2fa')
+    expect(twoFactorUrlFor('?redirect=https%3A%2F%2Fevil.example')).toBe('/auth/verify-2fa')
+  })
+})

+ 9 - 3
src/app/(public)/auth/sign-in/sign-in-form.tsx

@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from 'next/navigation'
 import Link from 'next/link'
 import { useTranslations } from 'next-intl'
 import { signIn, authClient } from '@/lib/auth-client'
+import { isPasskeyPromptClosed, passkeyErrorCode } from '@/lib/passkey-prompt'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
@@ -161,10 +162,15 @@ function SignInFormInner({
     try {
       const result = await authClient.signIn.passkey()
       if (result?.error) {
-        // User dismissed the passkey prompt — not an error state
-        if ('code' in result.error && result.error.code === 'AUTH_CANCELLED') return
+        // The browser's prompt was closed or timed out: the person chose not
+        // to use a passkey, and the password form is right there.
+        if (isPasskeyPromptClosed(result.error)) return
+        // Anything else that went wrong in the browser arrives with the
+        // client's fixed English "Auth cancelled", which is not what happened.
+        const code = passkeyErrorCode(result.error)
+        const fromBrowser = code === 'AUTH_CANCELLED' || code?.startsWith('ERROR_')
         const msg = typeof result.error.message === 'string' ? result.error.message : ''
-        setError(msg || t('errors.passkeyFailed'))
+        setError((!fromBrowser && msg) || t('errors.passkeyFailed'))
       } else {
         router.push(safeRedirectPath(redirectParam))
         router.refresh()

+ 5 - 1
src/app/(public)/auth/verify-2fa/page.tsx

@@ -4,6 +4,7 @@ import { useState } from 'react'
 import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { authClient } from '@/lib/auth-client'
+import { safeRedirectPath } from '@/lib/safe-redirect'
 import { Button } from '@/components/ui/button'
 import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
@@ -35,7 +36,10 @@ export default function VerifyTwoFactorPage() {
       if (result.error) {
         setError(result.error.message || t('errors.invalidCode'))
       } else {
-        router.push('/')
+        // Back to the page the sign-in was for, such as Settings > Account
+        // when it was a new sign-in to add a passkey.
+        const redirect = new URLSearchParams(window.location.search).get('redirect')
+        router.push(safeRedirectPath(redirect))
         router.refresh()
       }
     } catch {

+ 47 - 4
src/features/settings/Components/passkey-settings.tsx

@@ -16,10 +16,15 @@ import { Input } from '@/components/ui/input'
 import { Label } from '@/components/ui/label'
 import { Button } from '@/components/ui/button'
 import { Separator } from '@/components/ui/separator'
-import { Fingerprint, Loader2, Pencil, Plus, Trash2 } from 'lucide-react'
+import { Fingerprint, Loader2, LogIn, Pencil, Plus, Trash2 } from 'lucide-react'
 import { useTranslations } from 'next-intl'
+import { showNotice } from '@/components/notice-toast'
+import { isPasskeyPromptClosed, passkeyErrorCode } from '@/lib/passkey-prompt'
+import { signInUrlFor } from '@/lib/signed-out'
 import { describeUserAgent } from '@/lib/user-agent-label'
 
+const SIGN_IN_AGAIN_TOAST_ID = 'passkey-sign-in-again'
+
 interface Passkey {
   id: string
   name: string | null
@@ -30,6 +35,8 @@ interface Passkey {
 
 export function PasskeySettings() {
   const t = useTranslations('settings')
+  const tDismiss = useTranslations('common.updateBanner')
+  const tCommon = useTranslations('common')
   const [passkeys, setPasskeys] = useState<Passkey[]>([])
   const [loading, setLoading] = useState(true)
   const [registering, setRegistering] = useState(false)
@@ -57,6 +64,25 @@ export function PasskeySettings() {
     fetchPasskeys()
   }, [])
 
+  // The sign-in page sends a signed-in visitor straight back, so this session
+  // ends before the tab goes there, set to come back to this page.
+  const signInAgain = async () => {
+    // A sign-out that did not happen would send them to a sign-in page that
+    // sends a signed-in visitor straight back here, with nothing said.
+    let signedOut = true
+    try {
+      const result = await authClient.signOut()
+      if (result?.error) signedOut = false
+    } catch {
+      signedOut = false
+    }
+    if (!signedOut) {
+      toast.error(tCommon('errors.unexpected'))
+      return
+    }
+    window.location.assign(signInUrlFor(window.location.pathname, window.location.search))
+  }
+
   const handleRegister = async () => {
     setRegistering(true)
     try {
@@ -67,8 +93,23 @@ export function PasskeySettings() {
         label === 'Unknown device' ? undefined : { name: label }
       )
       if (result?.error) {
-        // User dismissed the passkey prompt — not an error state
-        if ('code' in result.error && result.error.code === 'REGISTRATION_CANCELLED') return
+        if (isPasskeyPromptClosed(result.error)) return
+        const code = passkeyErrorCode(result.error)
+        // A passkey is only added to a session that began recently, and a
+        // session lasts days longer than that. Better Auth's own sentence
+        // ("Session is not fresh") says neither why nor what to do.
+        if (code === 'SESSION_NOT_FRESH') {
+          showNotice({
+            id: SIGN_IN_AGAIN_TOAST_ID,
+            icon: LogIn,
+            title: t('account.passkey.signInAgainTitle'),
+            description: t('account.passkey.signInAgainDescription'),
+            actionLabel: t('account.passkey.signInAgain'),
+            onAction: signInAgain,
+            dismissLabel: tDismiss('dismiss'),
+          })
+          return
+        }
         const msg = typeof result.error.message === 'string' ? result.error.message : ''
         toast.error(msg || t('account.passkey.registerFailed'))
       } else {
@@ -77,8 +118,10 @@ export function PasskeySettings() {
       }
     } catch {
       toast.error(t('account.passkey.registerFailed'))
+    } finally {
+      // Also after the early returns above, or the button would spin for good
+      setRegistering(false)
     }
-    setRegistering(false)
   }
 
   const handleDelete = async () => {

+ 2 - 1
src/lib/auth-client.ts

@@ -1,13 +1,14 @@
 import { passkeyClient } from '@better-auth/passkey/client'
 import { createAuthClient } from 'better-auth/react'
 import { twoFactorClient } from 'better-auth/plugins/two-factor'
+import { twoFactorUrlFor } from './signed-out'
 
 export const authClient = createAuthClient({
   basePath: '/api/public/auth',
   plugins: [
     twoFactorClient({
       onTwoFactorRedirect: () => {
-        window.location.href = '/auth/verify-2fa'
+        window.location.href = twoFactorUrlFor(window.location.search)
       },
     }),
     passkeyClient(),

+ 20 - 0
src/lib/passkey-prompt.ts

@@ -0,0 +1,20 @@
+/**
+ * What the passkey client reports when the browser's own passkey prompt was
+ * closed rather than completed: dismissed or left to time out (a browser does
+ * not say which, both arrive as `NotAllowedError`), or replaced by a newer
+ * prompt. Nothing went wrong, so nothing is said.
+ */
+const PROMPT_CLOSED_CODES: readonly string[] = [
+  'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY',
+  'ERROR_CEREMONY_ABORTED',
+]
+
+export function passkeyErrorCode(error: object): string | undefined {
+  const code = 'code' in error ? error.code : undefined
+  return typeof code === 'string' ? code : undefined
+}
+
+export function isPasskeyPromptClosed(error: object): boolean {
+  const code = passkeyErrorCode(error)
+  return code !== undefined && PROMPT_CLOSED_CODES.includes(code)
+}

+ 11 - 0
src/lib/signed-out.ts

@@ -18,3 +18,14 @@ export function signInUrlFor(pathname: string, search = ''): string {
 export function leaveForSignInNow(lastInputAt: number, now: number): boolean {
   return now - lastInputAt > RECENT_INPUT_MS
 }
+
+/**
+ * The two-factor page, carrying on the sign-in page's `redirect` so that a
+ * person who signs in with a code still lands where they were heading.
+ */
+export function twoFactorUrlFor(signInSearch: string): string {
+  const target = safeRedirectPath(new URLSearchParams(signInSearch).get('redirect'))
+  return target === '/'
+    ? '/auth/verify-2fa'
+    : `/auth/verify-2fa?redirect=${encodeURIComponent(target)}`
+}