/* Passkey 中间件:统一所有 WebAuthn 调用(登录/注册/二次验证/管理员代配)。 * * 根据 Agent 判定执行路径: * - 浏览器 / 小窗(UA 不含 "CalDisplayApp" 且未注入标志)→ 直接调用 * navigator.credentials(标准 WebAuthn,Safari 小窗环境原生支持 Passkey) * - iOS 套壳 App(注入 window.__CAL_DISPLAY_APP__ 或 UA 含 "CalDisplayApp") * → 把 {op, options} 存入服务端一次性 stash,跳转 /passkey 小窗页面; * App 拦截导航后用 ASWebAuthenticationSession 打开该页面,页面内完成 * WebAuthn 仪式,经 dashboard://passkey 回调由 App 回传结果。 * * 所有返回的 credential 均为纯 base64url JSON(与浏览器 WebAuthn 结构一致), * 调用方无需再做 ArrayBuffer 转换。 */ (function (global) { 'use strict'; // iOS 套壳 App 判定:App 注入的全局标志优先,UA 标记兜底 var IS_IOS_APP = false; try { IS_IOS_APP = window.__CAL_DISPLAY_APP__ === true; } catch (e) {} if (!IS_IOS_APP) { IS_IOS_APP = /CalDisplayApp/i.test(navigator.userAgent || ''); } var _pending = null; // {resolve, reject} // ── base64url <-> ArrayBuffer ── function b64ToBuf(b64) { var base64 = b64.replace(/-/g, '+').replace(/_/g, '/'); while (base64.length % 4) base64 += '='; var binary = atob(base64); var bytes = new Uint8Array(binary.length); for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes.buffer; } function bufToB64(buf) { var bytes = new Uint8Array(buf); var binary = ''; for (var i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } // ── options 准备(浏览器模式:base64url → ArrayBuffer)── function prepareCreation(options) { var o = JSON.parse(JSON.stringify(options)); o.challenge = b64ToBuf(o.challenge); if (o.user && o.user.id) o.user.id = b64ToBuf(o.user.id); if (o.excludeCredentials) { o.excludeCredentials = o.excludeCredentials.map(function (c) { return { id: b64ToBuf(c.id), type: c.type }; }); } return o; } function prepareAssertion(options) { var o = JSON.parse(JSON.stringify(options)); o.challenge = b64ToBuf(o.challenge); if (o.allowCredentials) { o.allowCredentials = o.allowCredentials.map(function (c) { return { id: b64ToBuf(c.id), type: c.type }; }); } return o; } // ── 浏览器直接 WebAuthn ── function webAuthnCreation(options) { return navigator.credentials.create({ publicKey: prepareCreation(options) }).then(function (cred) { return { id: cred.id, type: cred.type, rawId: bufToB64(cred.rawId), response: { clientDataJSON: bufToB64(cred.response.clientDataJSON), attestationObject: bufToB64(cred.response.attestationObject), transports: cred.response.getTransports ? cred.response.getTransports() : [] } }; }); } function webAuthnAssertion(options) { return navigator.credentials.get({ publicKey: prepareAssertion(options) }).then(function (cred) { return { id: cred.id, type: cred.type, rawId: bufToB64(cred.rawId), response: { authenticatorData: bufToB64(cred.response.authenticatorData), clientDataJSON: bufToB64(cred.response.clientDataJSON), signature: bufToB64(cred.response.signature), userHandle: cred.response.userHandle ? bufToB64(cred.response.userHandle) : null } }; }); } // ── iOS App:先把参数存入服务端一次性 stash,再跳转小窗页面 ── // 小窗(ASWebAuthenticationSession)是独立的 ephemeral 会话,读不到主容器 // 的 sessionStorage,因此参数通过服务端短时暂存传递。 function iosDispatch(op, options) { return fetch('/api/passkey/stash', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ op: op, options: options }) }) .then(function (r) { return r.json(); }) .then(function (d) { if (!d.sid) throw new Error(d.error || 'Passkey 参数暂存失败'); var base = location.origin || ('https://' + location.host); window.location.href = base + '/passkey?op=' + encodeURIComponent(op) + '&sid=' + encodeURIComponent(d.sid); return new Promise(function (resolve, reject) { _pending = { resolve: resolve, reject: reject }; // 兜底:小窗未完成/被取消时避免 promise 悬挂 setTimeout(function () { if (_pending) { _pending = null; reject(new Error('Passkey 认证超时或已取消')); } }, 120000); }); }); } // ── 供原生层(evaluateJavaScript)调用的桥接方法 ── function __iosComplete(credentialJson) { var p = _pending; _pending = null; if (p) p.resolve(credentialJson); } function __iosCancel(message) { var p = _pending; _pending = null; if (p) p.reject(new Error(message || 'Passkey 认证已取消')); } // ── 对外统一入口 ── global.PasskeyBridge = { creation: function (options) { return IS_IOS_APP ? iosDispatch('creation', options) : webAuthnCreation(options); }, assertion: function (options) { return IS_IOS_APP ? iosDispatch('assertion', options) : webAuthnAssertion(options); }, isIOSApp: function () { return IS_IOS_APP; }, __iosComplete: __iosComplete, __iosCancel: __iosCancel }; })(window);