Files
2026-08-06 11:11:11 +08:00

146 lines
5.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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);