/** * Обёртка над `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; authenticatorAttachment?: string; } export interface AssertionCredentialJSON { id: string; rawId: string; type: 'public-key'; response: { clientDataJSON: string; authenticatorData: string; signature: string; userHandle?: string; }; clientExtensionResults: Record; 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 { 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, }; 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 { 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, }; 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); } }