264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
|
|
/**
|
|||
|
|
* Обёртка над `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);
|
|||
|
|
}
|
|||
|
|
}
|