Files
BYW-VoiceHub/app/utils/webauthn.js
T

228 lines
7.0 KiB
JavaScript

import {
base64URLStringToBuffer,
bufferToBase64URLString,
startAuthentication
} from '@simplewebauthn/browser'
const toCredentialDescriptor = ({ id, type }) => ({
id: base64URLStringToBuffer(id),
type
})
const createWebAuthnError = (code, cause) => {
const error = new Error(code)
error.code = code
error.cause = cause
return error
}
const normalizeAuthenticationError = (error) => {
switch (error?.name) {
case 'NotAllowedError':
case 'AbortError':
return createWebAuthnError('passkeyOperationUnavailable', error)
case 'NotSupportedError':
return createWebAuthnError('passkeyLoginUnsupported', error)
case 'SecurityError':
return createWebAuthnError('passkeySecurityInvalid', error)
default:
return createWebAuthnError('passkeyFailed', error)
}
}
const normalizeRegistrationError = (error) => {
let code
switch (error?.name) {
case 'NotAllowedError':
case 'AbortError':
code = 'passkeyOperationUnavailable'
break
case 'InvalidStateError':
code = 'passkeyAlreadyRegistered'
break
case 'NotSupportedError':
code = 'passkeyAlgorithmUnsupported'
break
case 'SecurityError':
code = 'passkeySecurityInvalid'
break
default:
code = 'addDeviceFailed'
}
const normalizedError = createWebAuthnError(code, error)
normalizedError.name = error.name
return normalizedError
}
export const getWebAuthnErrorMessage = (error, messages, fallback) => {
const localizedMessage = error?.code ? messages?.[error.code] : ''
return localizedMessage || error?.data?.message || error?.statusMessage || fallback
}
const readClientExtensionResults = (credential) => {
try {
return credential.getClientExtensionResults?.() || {}
} catch (error) {
// 鸿蒙早期实现可能缺少完整扩展接口,扩展结果不参与服务端签名验证。
console.warn('读取 WebAuthn 扩展结果失败:', error)
return {}
}
}
const readOptionalResponseValue = (response, methodName) => {
try {
return response[methodName]?.()
} catch (error) {
// 部分系统凭据桥仅实现必需字段,可选元数据失败不应中断认证。
console.warn(`读取 WebAuthn ${methodName} 失败:`, error)
return undefined
}
}
export const signalUnknownWebAuthnCredential = async ({ credentialId, rpId }) => {
if (typeof window === 'undefined') return false
const signalUnknownCredential = window.PublicKeyCredential?.signalUnknownCredential
if (!credentialId || !rpId || typeof signalUnknownCredential !== 'function') {
return false
}
try {
await signalUnknownCredential.call(window.PublicKeyCredential, {
credentialId,
rpId
})
return true
} catch (error) {
// 设备可能只实现基础 WebAuthn,清理失败不能影响服务端解绑结果。
console.warn('通知系统清理失效 Passkey 失败:', error)
return false
}
}
export const startWebAuthnAuthentication = async (optionsJSON, useBrowserAutofill = false) => {
if (useBrowserAutofill) {
return startAuthentication({ optionsJSON, useBrowserAutofill: true })
}
if (!navigator.credentials?.get) {
throw createWebAuthnError('passkeyLoginUnsupported')
}
const { challenge, allowCredentials, ...remainingOptions } = optionsJSON
const publicKey = {
...remainingOptions,
challenge: base64URLStringToBuffer(challenge)
}
if (allowCredentials?.length) {
publicKey.allowCredentials = allowCredentials.map(toCredentialDescriptor)
}
// 部分鸿蒙版本的 WebAuthn 桥接会拒绝 AbortSignal,登录按钮本身已阻止重复提交。
let credential
try {
credential = await navigator.credentials.get({ publicKey })
} catch (error) {
throw normalizeAuthenticationError(error)
}
if (!credential?.response) {
throw createWebAuthnError('passkeyAuthenticationIncomplete')
}
const response = credential.response
return {
id: credential.id,
rawId: bufferToBase64URLString(credential.rawId),
response: {
authenticatorData: bufferToBase64URLString(response.authenticatorData),
clientDataJSON: bufferToBase64URLString(response.clientDataJSON),
signature: bufferToBase64URLString(response.signature),
userHandle: response.userHandle ? bufferToBase64URLString(response.userHandle) : undefined
},
type: credential.type,
clientExtensionResults: readClientExtensionResults(credential),
authenticatorAttachment: credential.authenticatorAttachment || undefined
}
}
export const startWebAuthnRegistration = async (optionsJSON) => {
if (!navigator.credentials?.create) {
throw createWebAuthnError('passkeyRegistrationUnsupported')
}
const {
challenge,
user,
excludeCredentials,
hints,
extensions,
authenticatorSelection,
...remainingOptions
} = optionsJSON
const compatibleExtensions = { ...extensions }
const compatibleAuthenticatorSelection = { ...authenticatorSelection }
// 这些提示不参与服务端验证,部分鸿蒙凭据桥会因不认识它们而拒绝整个请求。
delete compatibleExtensions.credProps
delete compatibleAuthenticatorSelection.residentKey
const publicKey = {
...remainingOptions,
challenge: base64URLStringToBuffer(challenge),
user: {
...user,
id: base64URLStringToBuffer(user.id)
}
}
if (Object.keys(compatibleExtensions).length) {
publicKey.extensions = compatibleExtensions
}
if (Object.keys(compatibleAuthenticatorSelection).length) {
publicKey.authenticatorSelection = compatibleAuthenticatorSelection
}
if (excludeCredentials?.length) {
publicKey.excludeCredentials = excludeCredentials.map(toCredentialDescriptor)
}
if (hints?.length) {
publicKey.hints = hints
}
// 部分鸿蒙版本的 WebAuthn 桥接会拒绝 AbortSignal,绑定按钮本身已阻止重复提交。
let credential
try {
credential = await navigator.credentials.create({ publicKey })
} catch (error) {
throw normalizeRegistrationError(error)
}
if (!credential?.response) {
throw createWebAuthnError('passkeyCreationIncomplete')
}
const response = credential.response
const publicKeyAlgorithm = readOptionalResponseValue(response, 'getPublicKeyAlgorithm')
const credentialPublicKey = readOptionalResponseValue(response, 'getPublicKey')
const authenticatorData = readOptionalResponseValue(response, 'getAuthenticatorData')
return {
id: credential.id,
rawId: bufferToBase64URLString(credential.rawId),
response: {
attestationObject: bufferToBase64URLString(response.attestationObject),
clientDataJSON: bufferToBase64URLString(response.clientDataJSON),
transports: readOptionalResponseValue(response, 'getTransports'),
publicKeyAlgorithm,
publicKey: credentialPublicKey ? bufferToBase64URLString(credentialPublicKey) : undefined,
authenticatorData: authenticatorData ? bufferToBase64URLString(authenticatorData) : undefined
},
type: credential.type,
clientExtensionResults: readClientExtensionResults(credential),
authenticatorAttachment: credential.authenticatorAttachment || undefined
}
}