228 lines
7.0 KiB
JavaScript
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
|
|
}
|
|
}
|