Files
glchat/web/src/lib/webauthn.ts
T
grendervill cd1d662572 feat(auth): вход по ключу доступа (passkeys, WebAuthn)
Фаза 7, AGENT.md 7.1: регистрация ключа в настройках безопасности с
обязательным step-up, вход по ключу без пароля (в т.ч. без ввода почты —
обнаруживаемый ключ), несколько ключей на аккаунт, отзыв и переименование,
события безопасности и аудит.

Сервер: github.com/go-webauthn/webauthn (BSD-3-Clause), RP ID и Origin
берутся из конфига домена; если домен — IP-адрес (стенд без домена),
passkeys честно выключены (auth.passkey_unsupported). Церемонии живут в
памяти процесса 5 минут и одноразовые: повторная отправка challenge
отклоняется. Миграция 00018 пересобирает неиспользуемую таблицу
webauthn_credentials под полную запись credential в JSON. Новые ручки
входа ограничены по IP (10/мин).

Клиент: тонкая обёртка над navigator.credentials без тяжёлых SDK,
раздел «Ключи доступа» в настройках безопасности и кнопка «Войти по ключу»
на экране входа; понятные сообщения для браузеров без поддержки WebAuthn
и при отмене диалога.

Тесты: Go — регистрация/вход с эмулятором аутентификатора (реальная
проверка подписи P-256), отказ при чужом challenge, одноразовость
церемонии, обязательный step-up при управлении ключами, запрет входа
забаненному, ограничение allowCredentials при входе с почтой, лимит и
валидация имени; web — 12 vitest с моком navigator.credentials;
Playwright — живой сценарий с виртуальным аутентификатором Chromium.
2026-09-26 15:12:43 +03:00

264 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Обёртка над `navigator.credentials` для passkeys (WebAuthn).
*
* Тяжёлые SDK не используем (AGENT.md 5.2): сервер отдаёт обычный JSON
* WebAuthn, здесь он переводится в ArrayBuffer и обратно. Все ошибки
* приводятся к `WebAuthnClientError` с понятным кодом, чтобы UI показал
* человеческое сообщение, а не `NotAllowedError`.
*/
/** Коды ошибок клиента: используются в i18n (`errors.auth.passkey_*`). */
export type WebAuthnClientErrorCode =
'unsupported' | 'cancelled' | 'timeout' | 'invalid_state' | 'not_allowed' | 'failed';
export class WebAuthnClientError extends Error {
readonly code: WebAuthnClientErrorCode;
constructor(code: WebAuthnClientErrorCode, message?: string) {
super(message ?? code);
this.name = 'WebAuthnClientError';
this.code = code;
}
}
/** Публичные опции, как их отдаёт сервер (base64url-строки). */
export interface PublicKeyCredentialCreationOptionsJSON {
challenge: string;
rp: { id?: string; name: string };
user: { id: string; name: string; displayName: string };
pubKeyCredParams: PublicKeyCredentialParameters[];
timeout?: number;
attestation?: AttestationConveyancePreference;
authenticatorSelection?: AuthenticatorSelectionCriteria;
excludeCredentials?: { id: string; type: 'public-key'; transports?: string[] }[];
extensions?: AuthenticationExtensionsClientInputs;
}
export interface PublicKeyCredentialRequestOptionsJSON {
challenge: string;
timeout?: number;
rpId?: string;
allowCredentials?: { id: string; type: 'public-key'; transports?: string[] }[];
userVerification?: UserVerificationRequirement;
}
/** Ответ аутентификатора в JSON-виде — ровно то, что ждёт сервер. */
export interface RegistrationCredentialJSON {
id: string;
rawId: string;
type: 'public-key';
response: {
clientDataJSON: string;
attestationObject: string;
transports?: string[];
};
clientExtensionResults: Record<string, unknown>;
authenticatorAttachment?: string;
}
export interface AssertionCredentialJSON {
id: string;
rawId: string;
type: 'public-key';
response: {
clientDataJSON: string;
authenticatorData: string;
signature: string;
userHandle?: string;
};
clientExtensionResults: Record<string, unknown>;
authenticatorAttachment?: string;
}
/** base64url → ArrayBuffer (браузер не умеет декодировать сам). */
export function base64URLToBuffer(value: string): ArrayBuffer {
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
const binary = atob(padded + pad);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes.buffer;
}
/** ArrayBuffer → base64url без паддинга. */
export function bufferToBase64URL(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/**
* Поддерживает ли браузер passkeys. Проверяем и наличие API, и защищённый
* контекст: без HTTPS (кроме localhost) WebAuthn недоступен.
*/
export function isPasskeySupported(): boolean {
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
return false;
}
if (typeof window.PublicKeyCredential === 'undefined') {
return false;
}
return (
typeof navigator.credentials?.create === 'function' &&
typeof navigator.credentials?.get === 'function'
);
}
/** Приводит произвольную ошибку браузера к понятному коду. */
export function mapCredentialsError(error: unknown): WebAuthnClientError {
if (error instanceof WebAuthnClientError) {
return error;
}
const name = error instanceof Error ? error.name : '';
switch (name) {
case 'NotAllowedError':
// Пользователь отменил диалог или истёк таймаут: браузер не различает их.
return new WebAuthnClientError('cancelled', name);
case 'AbortError':
return new WebAuthnClientError('cancelled', name);
case 'TimeoutError':
return new WebAuthnClientError('timeout', name);
case 'InvalidStateError':
// Ключ уже зарегистрирован на этом устройстве.
return new WebAuthnClientError('invalid_state', name);
case 'NotSupportedError':
case 'SecurityError':
return new WebAuthnClientError('unsupported', name);
default:
return new WebAuthnClientError('failed', name);
}
}
/** Дескриптор ключа в формате браузера: id — ArrayBuffer. */
function toDescriptors(
descriptors: { id: string; type: 'public-key'; transports?: string[] }[] | undefined,
): PublicKeyCredentialDescriptor[] | undefined {
if (descriptors === undefined) {
return undefined;
}
return descriptors.map((descriptor) => ({
id: base64URLToBuffer(descriptor.id),
type: descriptor.type,
...(descriptor.transports === undefined
? {}
: { transports: descriptor.transports as AuthenticatorTransport[] }),
}));
}
function requireSupport(): void {
if (!isPasskeySupported()) {
throw new WebAuthnClientError('unsupported');
}
}
/**
* Создаёт ключ доступа и возвращает ответ для сервера.
* `options` — поле `publicKey` из ответа `/auth/passkeys/register/begin`.
*/
export async function createPasskey(
options: PublicKeyCredentialCreationOptionsJSON,
): Promise<RegistrationCredentialJSON> {
requireSupport();
const excludeCredentials = toDescriptors(options.excludeCredentials);
// Поля собираем поимённо: JSON-вариант содержит строковые id, и простой
// спред перенёс бы их в браузерный тип, где нужен ArrayBuffer.
const publicKey: PublicKeyCredentialCreationOptions = {
challenge: base64URLToBuffer(options.challenge),
rp: options.rp,
user: { ...options.user, id: base64URLToBuffer(options.user.id) },
pubKeyCredParams: options.pubKeyCredParams,
...(options.timeout === undefined ? {} : { timeout: options.timeout }),
...(options.attestation === undefined ? {} : { attestation: options.attestation }),
...(options.authenticatorSelection === undefined
? {}
: { authenticatorSelection: options.authenticatorSelection }),
...(options.extensions === undefined ? {} : { extensions: options.extensions }),
...(excludeCredentials === undefined ? {} : { excludeCredentials }),
};
try {
const credential = (await navigator.credentials.create({
publicKey,
})) as PublicKeyCredential | null;
if (credential === null) {
throw new WebAuthnClientError('cancelled');
}
const response = credential.response as AuthenticatorAttestationResponse;
const result: RegistrationCredentialJSON = {
id: credential.id,
rawId: bufferToBase64URL(credential.rawId),
type: 'public-key',
response: {
clientDataJSON: bufferToBase64URL(response.clientDataJSON),
attestationObject: bufferToBase64URL(response.attestationObject),
},
clientExtensionResults: credential.getClientExtensionResults() as Record<string, unknown>,
};
if (typeof response.getTransports === 'function') {
const transports = response.getTransports();
if (transports.length > 0) {
result.response.transports = transports;
}
}
if (credential.authenticatorAttachment !== null) {
result.authenticatorAttachment = credential.authenticatorAttachment;
}
return result;
} catch (error) {
throw mapCredentialsError(error);
}
}
/**
* Подписывает challenge существующим ключом.
* `options` — поле `publicKey` из ответа `/auth/passkeys/login/begin`.
*/
export async function getPasskeyAssertion(
options: PublicKeyCredentialRequestOptionsJSON,
): Promise<AssertionCredentialJSON> {
requireSupport();
const allowCredentials = toDescriptors(options.allowCredentials);
const publicKey: PublicKeyCredentialRequestOptions = {
challenge: base64URLToBuffer(options.challenge),
...(options.timeout === undefined ? {} : { timeout: options.timeout }),
...(options.rpId === undefined ? {} : { rpId: options.rpId }),
...(options.userVerification === undefined
? {}
: { userVerification: options.userVerification }),
...(allowCredentials === undefined ? {} : { allowCredentials }),
};
try {
const credential = (await navigator.credentials.get({
publicKey,
})) as PublicKeyCredential | null;
if (credential === null) {
throw new WebAuthnClientError('cancelled');
}
const response = credential.response as AuthenticatorAssertionResponse;
const result: AssertionCredentialJSON = {
id: credential.id,
rawId: bufferToBase64URL(credential.rawId),
type: 'public-key',
response: {
clientDataJSON: bufferToBase64URL(response.clientDataJSON),
authenticatorData: bufferToBase64URL(response.authenticatorData),
signature: bufferToBase64URL(response.signature),
},
clientExtensionResults: credential.getClientExtensionResults() as Record<string, unknown>,
};
if (response.userHandle !== null) {
result.response.userHandle = bufferToBase64URL(response.userHandle);
}
if (credential.authenticatorAttachment !== null) {
result.authenticatorAttachment = credential.authenticatorAttachment;
}
return result;
} catch (error) {
throw mapCredentialsError(error);
}
}