import { api } from './client' import type { LoginResponse, PasskeyCreationOptionsDto, PasskeyDto, PasskeyRequestOptionsDto } 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), }) } /** 提权窗口的种类:加节点、吊销和删除、改凭证(用一次就没有)。 */ export type ElevationClass = 'enroll' | 'revoke' | 'credential' export const ELEVATED_EVENT = 'moshiro:elevated' /** * 用已有的 passkey 触碰一次,给当前会话开一个提权窗口。用户取消返回 false;没有 passkey 等服务端拒绝的情况抛 ApiError。 * 浏览器只会让用户选自己的 passkey(服务端给了 allow_credentials)。 */ export async function elevate(elevation: ElevationClass): Promise { const options = await api.post('/api/admin/auth/elevation/options', { class: elevation }) let credential: PublicKeyCredential | null try { credential = (await navigator.credentials.get({ publicKey: { challenge: toBytes(options.challenge), rpId: options.rp_id, timeout: options.timeout_ms, userVerification: 'required', allowCredentials: (options.allow_credentials ?? []).map((id) => ({ type: 'public-key' as const, id: toBytes(id) })), }, })) as PublicKeyCredential | null } catch (e) { if (e instanceof DOMException && (e.name === 'NotAllowedError' || e.name === 'AbortError')) return false throw e } if (!credential) return false const response = credential.response as AuthenticatorAssertionResponse await api.post('/api/admin/auth/elevation', { class: elevation, credential_id: credential.id, authenticator_data: toText(response.authenticatorData), client_data_json: toText(response.clientDataJSON), signature: toText(response.signature), }) window.dispatchEvent(new Event(ELEVATED_EVENT)) return true } /** 给当前登录的用户注册一个新 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?.() ?? [], }) }