146 lines
5.6 KiB
JavaScript
146 lines
5.6 KiB
JavaScript
/* 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);
|