Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions apps/cas-frontend/docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@ States:
- **Pending** (ceremony started): button shows a spinner and "Подтвердите
пасскей…"; below it "Следуйте подсказке браузера или телефона. Окно можно
закрыть, тогда вход отменится."
- **Cancelled** (`NotAllowedError`, timeout): alert "Вход отменён" /
"Окно подтверждения закрылось или вышло время. Ничего не сломалось,
попробуйте ещё раз."
- **Cancelled** (`NotAllowedError`, timeout, a challenge the server no
longer has: `login_not_found`): alert "Вход отменён" / "Окно подтверждения
закрылось или вышло время. Ничего не сломалось, попробуйте ещё раз."
- **Unknown passkey** (`invalid_credential`): alert "Этот пасскей здесь не
зарегистрирован" / "Возможно, он от другого сайта, или аккаунта ещё нет."
with the link "Создать аккаунт"; the button reads "Выбрать другой пасскей".
Expand All @@ -129,7 +129,23 @@ States:
проверкой владельца. Подойдут Touch ID, Face ID, Windows Hello или менеджер
паролей на телефоне." The name stays filled; the button reads "Попробовать
ещё раз".
- **Cancelled**: same alert as on sign-in, titled "Создание отменено".
- **Cancelled** (`NotAllowedError`, timeout, `registration_not_found`): same
alert as on sign-in, titled "Создание отменено".
- **Already registered** (`InvalidStateError`, `credential_already_registered`):
alert "Такой пасскей уже есть" / "Этот пасскей уже зарегистрирован здесь."
with the link "Войти".

### Failures every ceremony can have

- **Wrong address** (`SecurityError`: the page is served from an origin the
relying party id does not cover): alert "Этот адрес не подходит для входа"
/ "Сайт открыт не по тому адресу, для которого настроен вход. Откройте его
по основному адресу."
- **Everything else** (an outage, `cross_site_request`, no network, a
verification the server could not do): alert "Что-то пошло не так" /
"Попробуйте ещё раз через минуту."
- **No session** (`unauthenticated`) is not an alert: the route loaders send
the browser to `/sign-in`.

### Session check (route loaders)

Expand Down
5 changes: 3 additions & 2 deletions apps/cas-frontend/docs/PASSKEYS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ started; the autofill list is rebuilt from it.

## What each browser does

From the vendors' documentation; a hand check on real devices is still to
be done, and this table should be corrected from it.
Checked by hand on macOS on 2026-09-16 in Safari, Chrome and Firefox: all
three show the passkey in the autofill list under the field and sign in from
a pick. The notes are from the vendors' documentation.

| Browser | Autofill offer | Notes |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
Expand Down
32 changes: 31 additions & 1 deletion apps/cas-frontend/src/entities/session/ceremonies.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { WebAuthnError } from '@simplewebauthn/browser'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { ApiError } from '#shared/api/request'
import { isCeremonyCancelled, signInWithPasskeyFromAutofill } from './ceremonies'
import {
isAuthenticatorUnsupported,
isCeremonyCancelled,
isPasskeyAlreadyRegistered,
isWrongOrigin,
signInWithPasskeyFromAutofill,
} from './ceremonies'

const { request, startAuthentication, browserSupportsWebAuthnAutofill, cancelCeremony } = vi.hoisted(() => ({
request: vi.fn(),
Expand Down Expand Up @@ -45,6 +51,30 @@ describe('isCeremonyCancelled', () => {
})
})

describe('the other verdicts of the authenticator', () => {
const named = (name: string) => new DOMException(`the browser said ${name}`, name)
const wrapped = (name: string) => new WebAuthnError({ message: 'wrapped', code: 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY', cause: named(name) })

it('tells a passkey the authenticator already holds', () => {
expect(isPasskeyAlreadyRegistered(named('InvalidStateError'))).toBe(true)
expect(isPasskeyAlreadyRegistered(wrapped('InvalidStateError'))).toBe(true)
expect(isPasskeyAlreadyRegistered(named('NotAllowedError'))).toBe(false)
})

it('tells an authenticator that cannot make a discoverable credential', () => {
expect(isAuthenticatorUnsupported(named('NotSupportedError'))).toBe(true)
expect(isAuthenticatorUnsupported(named('ConstraintError'))).toBe(true)
expect(isAuthenticatorUnsupported(wrapped('ConstraintError'))).toBe(true)
expect(isAuthenticatorUnsupported(named('NotAllowedError'))).toBe(false)
})

it('tells a page served from the wrong origin', () => {
expect(isWrongOrigin(named('SecurityError'))).toBe(true)
expect(isWrongOrigin(wrapped('SecurityError'))).toBe(true)
expect(isWrongOrigin(new TypeError('Failed to fetch'))).toBe(false)
})
})

describe('signInWithPasskeyFromAutofill', () => {
const options = (loginId: string) => ({ loginId, rcr: { publicKey: { challenge: 'c', timeout: 60_000 } } })
const picked = { id: 'cred', rawId: 'cred', response: {}, type: 'public-key', clientExtensionResults: {} }
Expand Down
25 changes: 25 additions & 0 deletions apps/cas-frontend/src/entities/session/ceremonies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,31 @@ const nameOf = (error: unknown): string | null =>
*/
export const isCeremonyCancelled = (error: unknown): boolean => nameOf(error) === 'NotAllowedError'

/**
* Whether the authenticator refused to register because it already holds a
* passkey for this account: `InvalidStateError` is its answer to a
* credential on the `excludeCredentials` list.
*/
export const isPasskeyAlreadyRegistered = (error: unknown): boolean => nameOf(error) === 'InvalidStateError'

/**
* Whether the authenticator cannot make the passkey CAS asks for: a
* discoverable credential with user verification. `NotSupportedError` and
* `ConstraintError` are the two ways the browser says so; the server says
* the same with `discoverable_credential_required` when it finds out later.
*/
export const isAuthenticatorUnsupported = (error: unknown): boolean => {
const name = nameOf(error)
return name === 'NotSupportedError' || name === 'ConstraintError'
}

/**
* Whether the page is served from an origin the relying party id does not
* cover: `SecurityError`. A deployment mistake, not something the user can
* fix from here, but they can be told which address to open.
*/
export const isWrongOrigin = (error: unknown): boolean => nameOf(error) === 'SecurityError'

/** What CAS puts into a challenge when the options carry no `timeout` (the webauthn-rs default). */
const DEFAULT_CHALLENGE_LIFETIME_MS = 60_000

Expand Down
10 changes: 9 additions & 1 deletion apps/cas-frontend/src/entities/session/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
export { getMe, logout } from './api'
export type { Me } from './api'
export { isCeremonyCancelled, registerWithPasskey, signInWithPasskey, signInWithPasskeyFromAutofill } from './ceremonies'
export {
isAuthenticatorUnsupported,
isCeremonyCancelled,
isPasskeyAlreadyRegistered,
isWrongOrigin,
registerWithPasskey,
signInWithPasskey,
signInWithPasskeyFromAutofill,
} from './ceremonies'
export type { Registered, SignedIn } from './ceremonies'
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
Expand Down Expand Up @@ -80,14 +80,68 @@ describe('CreateAccountPage', () => {
expect(screen.getByRole('button', { name: 'Создать пасскей' })).toBeDefined()
})

it('shows a failure it cannot name as the generic alert, never the raw message', async () => {
registerWithPasskey.mockRejectedValue(new TypeError('Failed to fetch'))
it.each([
['a network failure', new TypeError('Failed to fetch'), 'Failed to fetch'],
['an outage', new ApiError(503, 'database_unavailable', 'Database unavailable'), 'Database unavailable'],
['a refused cross-site request', new ApiError(403, 'cross_site_request', 'Cross-site request refused'), 'Cross-site'],
['a verification the server could not do', new ApiError(400, 'registration_verification_failed', 'Attestation invalid'), 'Attestation'],
])('shows %s as the generic alert, never the raw message', async (_, error, raw) => {
registerWithPasskey.mockRejectedValue(error)

await submit('Ада')

const alert = await screen.findByRole('alert')
expect(alert.textContent).toContain('Что-то пошло не так')
expect(alert.textContent).not.toContain('Failed to fetch')
expect(alert.textContent).not.toContain(raw)
})

it('shows a challenge the server no longer has as a cancelled ceremony', async () => {
registerWithPasskey.mockRejectedValue(new ApiError(404, 'registration_not_found', 'registration not found: expired'))

await submit('Ада')

const alert = await screen.findByRole('alert')
expect(alert.textContent).toContain('Создание отменено')
expect(alert.textContent).not.toContain('expired')
})

it.each([
['NotSupportedError', new DOMException('not supported', 'NotSupportedError')],
['ConstraintError', new DOMException('constraint', 'ConstraintError')],
['the server refusing a non-discoverable credential', new ApiError(400, 'discoverable_credential_required', 'Credential must be discoverable')],
])('explains an authenticator that cannot make a passkey (%s)', async (_, error) => {
registerWithPasskey.mockRejectedValue(error)

await submit('Ада')

const alert = await screen.findByRole('alert')
expect(alert.textContent).toContain('Не получилось создать пасскей')
expect(alert.textContent).toContain('Touch ID')
expect(screen.getByRole('button', { name: 'Попробовать ещё раз' })).toBeDefined()
})

it.each([
['InvalidStateError', new DOMException('already registered', 'InvalidStateError')],
['the server knowing the credential', new ApiError(409, 'credential_already_registered', 'Credential already registered')],
])('points a passkey that already exists here to sign-in (%s)', async (_, error) => {
registerWithPasskey.mockRejectedValue(error)

await submit('Ада')

const alert = await screen.findByRole('alert')
expect(alert.textContent).toContain('Такой пасскей уже есть')
expect(alert.textContent).not.toContain('already registered')
expect(within(alert).getByRole('link', { name: 'Войти' }).getAttribute('href')).toBe(routes.SIGN_IN)
})

it('tells a page served from the wrong origin which address to open', async () => {
registerWithPasskey.mockRejectedValue(new DOMException('The operation is insecure.', 'SecurityError'))

await submit('Ада')

const alert = await screen.findByRole('alert')
expect(alert.textContent).toContain('Этот адрес не подходит для входа')
expect(alert.textContent).not.toContain('insecure')
})

it('shows a cancelled ceremony as an alert above a still usable form', async () => {
Expand Down
58 changes: 54 additions & 4 deletions apps/cas-frontend/src/pages/create-account/CreateAccountPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useActionState } from 'react'
import type { ReactNode } from 'react'
import { Link, useNavigate } from 'react-router'

import { isCeremonyCancelled, registerWithPasskey } from '#entities/session'
import { isAuthenticatorUnsupported, isCeremonyCancelled, isPasskeyAlreadyRegistered, isWrongOrigin, registerWithPasskey } from '#entities/session'
import { isApiError } from '#shared/api/request'
import { Alert, Hero, Icon, Screen, SubmitButton, SwitchLink, TextField } from '#shared/ui'
import { routes } from '#app/routes'
Expand All @@ -10,22 +11,71 @@ import { MAX_DISPLAY_NAME_LENGTH, messages, normalizeDisplayName, validateDispla
/** What the alert above the form says; never the raw `message` of an exception. */
interface Failure {
title: string
text: string
text: ReactNode
}

const failures = {
cancelled: {
title: 'Создание отменено',
text: 'Окно подтверждения закрылось или вышло время. Ничего не сломалось, попробуйте ещё раз.',
},
unsupported: {
title: 'Не получилось создать пасскей',
text: 'Этот ключ или браузер не умеет хранить пасскеи с проверкой владельца. Подойдут Touch ID, Face ID, Windows Hello или менеджер паролей на телефоне.',
},
alreadyRegistered: {
title: 'Такой пасскей уже есть',
text: (
<>
Этот пасскей уже зарегистрирован здесь. <Link to={routes.SIGN_IN}>Войти</Link>
</>
),
},
wrongAddress: {
title: 'Этот адрес не подходит для входа',
text: 'Сайт открыт не по тому адресу, для которого настроен вход. Откройте его по основному адресу.',
},
generic: {
title: 'Что-то пошло не так',
text: 'Попробуйте ещё раз через минуту.',
},
} satisfies Record<string, Failure>

/** What this screen can say about a failed ceremony; the rest of what it can get is #715. */
const toFailure = (error: unknown): Failure => (isCeremonyCancelled(error) ? failures.cancelled : failures.generic)
/**
* Everything a failed ceremony can be, as this screen says it. A challenge
* the server no longer has (`registration_not_found`) is a ceremony that took
* too long, the same story as a closed prompt; a credential the server
* refuses as non-discoverable is the same story as an authenticator that
* cannot make one. Anything else (an outage, a refused cross-site request,
* no network, a verification the server could not do) is the generic alert.
*/
const toFailure = (error: unknown): Failure => {
if (isApiError(error)) {
switch (error.code) {
case 'registration_not_found':
return failures.cancelled
case 'discoverable_credential_required':
return failures.unsupported
case 'credential_already_registered':
return failures.alreadyRegistered
default:
return failures.generic
}
}
if (isCeremonyCancelled(error)) {
return failures.cancelled
}
if (isAuthenticatorUnsupported(error)) {
return failures.unsupported
}
if (isPasskeyAlreadyRegistered(error)) {
return failures.alreadyRegistered
}
if (isWrongOrigin(error)) {
return failures.wrongAddress
}
return failures.generic
}

interface FormState {
/** What was submitted, so the field keeps it after a failure. */
Expand Down
30 changes: 27 additions & 3 deletions apps/cas-frontend/src/pages/sign-in/SignInPage.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,38 @@ describe('SignInPage', () => {
expect(screen.getByRole('button', { name: 'Попробовать ещё раз' })).toBeDefined()
})

it('shows a failure it cannot name as the generic alert, never the raw message', async () => {
signInWithPasskey.mockRejectedValue(new TypeError('Failed to fetch'))
it('shows a challenge the server no longer has as a cancelled ceremony', async () => {
signInWithPasskey.mockRejectedValue(new ApiError(404, 'login_not_found', 'login not found: expired, unknown or already finished'))

await signIn()

const alert = await screen.findByRole('alert')
expect(alert.textContent).toContain('Вход отменён')
expect(alert.textContent).not.toContain('expired')
})

it('tells a page served from the wrong origin which address to open', async () => {
signInWithPasskey.mockRejectedValue(new DOMException('The operation is insecure.', 'SecurityError'))

await signIn()

const alert = await screen.findByRole('alert')
expect(alert.textContent).toContain('Этот адрес не подходит для входа')
expect(alert.textContent).not.toContain('insecure')
})

it.each([
['a network failure', new TypeError('Failed to fetch'), 'Failed to fetch'],
['an outage', new ApiError(503, 'database_unavailable', 'Database unavailable'), 'Database unavailable'],
['a refused cross-site request', new ApiError(403, 'cross_site_request', 'Cross-site request refused'), 'Cross-site'],
])('shows %s as the generic alert, never the raw message', async (_, error, raw) => {
signInWithPasskey.mockRejectedValue(error)

await signIn()

const alert = await screen.findByRole('alert')
expect(alert.textContent).toContain('Что-то пошло не так')
expect(alert.textContent).not.toContain('Failed to fetch')
expect(alert.textContent).not.toContain(raw)
})

it('offers the passkey through autofill as soon as the screen is up and signs in with the pick', async () => {
Expand Down
Loading