// WebAuthn 的浏览器侧胶水。 // // 存在的理由只有一句话:navigator.credentials 要 ArrayBuffer, // 后端 go-webauthn 要 base64url 字符串,两边不会自动转换。 // JSON.stringify 一个 PublicKeyCredential 会把 ArrayBuffer 变成 {}, // 所以响应必须手工组装成 base64url。 // // 只用标准 Web API,不引 @simplewebauthn/browser 之类的包。 export function isSupported() { return typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials } // base64url 字符串 -> ArrayBuffer function toBuf(s) { const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(s.length / 4) * 4, '=')) const out = new Uint8Array(bin.length) for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) return out.buffer } // ArrayBuffer -> base64url(无填充) function toB64(buf) { const bytes = new Uint8Array(buf) let bin = '' for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]) return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } // 服务端给的 options 里这些字段是 base64url,要用前逐个还原成 ArrayBuffer function decodeOptions(pk) { const out = { ...pk } out.challenge = toBuf(pk.challenge) if (pk.user) { out.user = { ...pk.user, id: toBuf(pk.user.id) } } if (Array.isArray(pk.excludeCredentials)) { out.excludeCredentials = pk.excludeCredentials.map((c) => ({ ...c, id: toBuf(c.id) })) } if (Array.isArray(pk.allowCredentials)) { out.allowCredentials = pk.allowCredentials.map((c) => ({ ...c, id: toBuf(c.id) })) } return out } // 浏览器返回的凭据 -> 后端能解析的 JSON(全部 base64url) function encodeResponse(cred) { const r = cred.response const pick = ['clientDataJSON', 'attestationObject', 'authenticatorData', 'signature'] const response = {} for (const k of pick) { if (r[k] != null) response[k] = toB64(r[k]) } // userHandle 可能是 null(发现式登录未回填时),保留 null 而不是转成字符串 if ('userHandle' in r) response.userHandle = r.userHandle ? toB64(r.userHandle) : null return { id: cred.id, rawId: toB64(cred.rawId), type: cred.type, authenticatorAttachment: cred.authenticatorAttachment || undefined, clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}, response } } // register 注册一把新凭据。options 是后端 BeginRegistration 的产物。 export async function register(options) { if (!isSupported()) throw new Error('这个浏览器不支持 passkey') const cred = await navigator.credentials.create({ publicKey: decodeOptions(options.publicKey || options) }) return encodeResponse(cred) } // assert 用已有凭据登录。options 是后端 BeginLogin 的产物。 export async function assert(options) { if (!isSupported()) throw new Error('这个浏览器不支持 passkey') const cred = await navigator.credentials.get({ publicKey: decodeOptions(options.publicKey || options) }) return encodeResponse(cred) }