import { api } from './client' import type { LoginResponse, PasskeyCreationOptionsDto, PasskeyDto, PasskeyRequestOptionsDto, SignedStatementDto } from './types' /** 浏览器是否支持在输入框的自动填充里列出 passkey(conditional UI)。 */ export async function conditionalSupported(): Promise { try { return passkeySupported() && (await PublicKeyCredential.isConditionalMediationAvailable?.()) === true } catch { return false } } /** * passkey 保存在哪里:浏览器和系统决定具体用谁的密码管理器,网站只能给提示。 * `device`:此设备或浏览器账户(Chrome 里是 Google 密码管理工具,也可以是 Windows Hello、iCloud 钥匙串); * `external`:安全密钥,或者用手机扫码。 */ export type PasskeyPlace = 'device' | 'external' /** 浏览器是否支持 WebAuthn(passkey)。 */ export function passkeySupported(): boolean { return typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials } function toBytes(text: string): Uint8Array { const padded = text.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(text.length / 4) * 4, '=') const binary = atob(padded) const out = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) return out } function toText(buffer: ArrayBuffer): string { let binary = '' for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte) return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } /** * 用 passkey 登录(可发现凭证:不需要用户名)。服务端设了会话 Cookie;返回登录响应。 * passkey 绑定在后端的域名上:只有和后端同站的页面能用,所以其他后端(跨域)不显示这个入口。 */ export async function signInWithPasskey(opts: { conditional?: boolean; signal?: AbortSignal } = {}): Promise { const options = await api.post('/api/admin/auth/passkey/options') const credential = (await navigator.credentials.get({ // conditional:不弹窗,浏览器在用户名输入框的自动填充里列出已保存的 passkey(Chrome 里就是 Google 密码管理工具里的) mediation: opts.conditional ? 'conditional' : undefined, signal: opts.signal, publicKey: { challenge: toBytes(options.challenge), rpId: options.rp_id, timeout: opts.conditional ? undefined : options.timeout_ms, userVerification: 'required' }, })) as PublicKeyCredential | null if (!credential) throw new Error('没有选择 passkey') const response = credential.response as AuthenticatorAssertionResponse return api.post('/api/admin/auth/passkey/login', { credential_id: credential.id, authenticator_data: toText(response.authenticatorData), client_data_json: toText(response.clientDataJSON), signature: toText(response.signature), }) } /** * 用 passkey 对一段文本签名(挑战是文本的 SHA-256):操作员集合、加入码的委托都是这样签出来的,节点自己验。 * 浏览器会让用户从这个站点的 passkey 里选一个;面板再检查它是不是操作员集合里的凭证。 */ export async function signStatement(statement: string): Promise { const challenge = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(statement))) let credential: PublicKeyCredential | null try { credential = (await navigator.credentials.get({ publicKey: { challenge, userVerification: 'required', timeout: 120_000 } })) as PublicKeyCredential | null } catch (e) { if (e instanceof DOMException && (e.name === 'NotAllowedError' || e.name === 'AbortError')) throw new Error('已取消') throw e } if (!credential) throw new Error('没有选择 passkey') const response = credential.response as AuthenticatorAssertionResponse return { statement, credential_id: credential.id, authenticator_data: toText(response.authenticatorData), client_data_json: toText(response.clientDataJSON), signature: toText(response.signature), } } /** 给当前登录的用户注册一个新 passkey(要求用户验证,ES256)。 */ export async function registerPasskey(name: string, place: PasskeyPlace = 'device'): Promise { const options = await api.post('/api/admin/auth/passkeys/options') const credential = (await navigator.credentials.create({ publicKey: { challenge: toBytes(options.challenge), rp: { id: options.rp_id, name: options.rp_name }, user: { id: toBytes(options.user_id), name: options.user_name, displayName: options.user_name }, pubKeyCredParams: [{ type: 'public-key', alg: -7 }], excludeCredentials: options.exclude_credentials.map((id) => ({ type: 'public-key' as const, id: toBytes(id) })), authenticatorSelection: { authenticatorAttachment: place === 'device' ? 'platform' : 'cross-platform', residentKey: 'required', requireResidentKey: true, userVerification: 'required' }, attestation: 'none', timeout: options.timeout_ms, // 给浏览器的提示(Chrome 128+):优先用设备自带的(含 Google 密码管理工具),或者安全密钥和手机 hints: place === 'device' ? ['client-device'] : ['security-key', 'hybrid'], } as PublicKeyCredentialCreationOptions, })) as PublicKeyCredential | null if (!credential) throw new Error('没有创建 passkey') const response = credential.response as AuthenticatorAttestationResponse return api.post('/api/admin/auth/passkeys', { name, credential_id: credential.id, attestation_object: toText(response.attestationObject), client_data_json: toText(response.clientDataJSON), transports: response.getTransports?.() ?? [], }) }