feat: sign in with a passkey (WebAuthn, ES256, discoverable credentials) and manage your passkeys [CI]

This commit is contained in:
justme committed 2026-10-07 21:57:19 +08:00
1 parent ddb6608e32
commit 6385c1b9a3
31 files changed
+2296 -42

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
import { api } from './client'
import type { LoginResponse, PasskeyCreationOptionsDto, PasskeyDto, PasskeyRequestOptionsDto } from './types'
/** 浏览器是否支持在输入框的自动填充里列出 passkey(conditional UI)。 */
export async function conditionalSupported(): Promise<boolean> {
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<ArrayBuffer> {
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<LoginResponse> {
const options = await api.post<PasskeyRequestOptionsDto>('/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<LoginResponse>('/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(要求用户验证,ES256)。 */
export async function registerPasskey(name: string, place: PasskeyPlace = 'device'): Promise<PasskeyDto> {
const options = await api.post<PasskeyCreationOptionsDto>('/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<PasskeyDto>('/api/admin/auth/passkeys', {
name,
credential_id: credential.id,
attestation_object: toText(response.attestationObject),
client_data_json: toText(response.clientDataJSON),
transports: response.getTransports?.() ?? [],
})
}