First Push
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
/* 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);
|
||||
Reference in New Issue
Block a user