05/18 Enhanced codes and functionalities 5
This commit is contained in:
@@ -316,6 +316,44 @@ const Auth = (() => {
|
||||
const notice = document.getElementById("register-notice");
|
||||
if (notice) notice.classList.remove("hidden");
|
||||
}
|
||||
|
||||
// Passkey login button (only present on login.html)
|
||||
const passkeyBtn = document.getElementById("btn-passkey-login");
|
||||
if (passkeyBtn) {
|
||||
passkeyBtn.addEventListener("click", async () => {
|
||||
const errEl = document.getElementById("passkey-error");
|
||||
errEl.classList.add("hidden");
|
||||
passkeyBtn.disabled = true;
|
||||
passkeyBtn.textContent = "Waiting for passkey…";
|
||||
|
||||
const email = (document.getElementById("email")?.value || "").trim().toLowerCase();
|
||||
const result = await PasskeyAuth.loginWithPasskey(email);
|
||||
|
||||
passkeyBtn.disabled = false;
|
||||
passkeyBtn.textContent = "🔑 Sign in with Passkey";
|
||||
|
||||
if (result.error) {
|
||||
errEl.textContent = result.error;
|
||||
errEl.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
// Tokens received — store them, then prompt for master password to unlock vault.
|
||||
sessionStorage.setItem("access_token", result.data.access_token);
|
||||
localStorage.setItem("refresh_token", result.data.refresh_token);
|
||||
sessionStorage.setItem("enc_key_salt", result.data.enc_key_salt);
|
||||
|
||||
// Derive vault key from master password.
|
||||
// Re-use the same master-password input field; if empty, show unlock overlay.
|
||||
const pw = document.getElementById("password")?.value;
|
||||
if (pw) {
|
||||
const vaultKey = await Crypto.deriveVaultKey(pw, result.data.enc_key_salt);
|
||||
VaultSession.setKey(vaultKey);
|
||||
}
|
||||
// Navigate to vault — if vault key wasn't derived, unlock overlay will show.
|
||||
window.location.href = "/vault";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { init };
|
||||
@@ -337,3 +375,208 @@ const VaultSession = (() => {
|
||||
}
|
||||
return { setKey, getKey, clear };
|
||||
})();
|
||||
|
||||
// ── PasskeyAuth — WebAuthn / Passkey login and registration management ────────
|
||||
const PasskeyAuth = (() => {
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Convert ArrayBuffer → base64url string (no padding). */
|
||||
function _bufToB64url(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let bin = '';
|
||||
bytes.forEach((b) => (bin += String.fromCharCode(b)));
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/** Convert base64url string → Uint8Array. */
|
||||
function _b64urlToBuf(str) {
|
||||
str = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (str.length % 4) str += '=';
|
||||
const bin = atob(str);
|
||||
const arr = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a PublicKeyCredentialCreationOptions or RequestOptions object
|
||||
* returned by the server (JSON) into the format expected by navigator.credentials.
|
||||
* The browser API requires ArrayBuffers for challenge and user.id; the server
|
||||
* sends base64url strings.
|
||||
*/
|
||||
function _prepareCreationOptions(opts) {
|
||||
opts.challenge = _b64urlToBuf(opts.challenge);
|
||||
if (opts.user?.id) opts.user.id = _b64urlToBuf(opts.user.id);
|
||||
if (opts.excludeCredentials) {
|
||||
opts.excludeCredentials = opts.excludeCredentials.map((c) => ({
|
||||
...c,
|
||||
id: _b64urlToBuf(c.id),
|
||||
}));
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function _prepareRequestOptions(opts) {
|
||||
opts.challenge = _b64urlToBuf(opts.challenge);
|
||||
if (opts.allowCredentials) {
|
||||
opts.allowCredentials = opts.allowCredentials.map((c) => ({
|
||||
...c,
|
||||
id: _b64urlToBuf(c.id),
|
||||
}));
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialise a PublicKeyCredential returned by navigator.credentials.create()
|
||||
* or navigator.credentials.get() into a plain JSON-serialisable object that
|
||||
* the server can accept.
|
||||
*/
|
||||
function _credentialToJson(cred) {
|
||||
const resp = cred.response;
|
||||
const obj = {
|
||||
id: cred.id,
|
||||
rawId: _bufToB64url(cred.rawId),
|
||||
type: cred.type,
|
||||
response: {},
|
||||
};
|
||||
|
||||
if (resp.clientDataJSON !== undefined)
|
||||
obj.response.clientDataJSON = _bufToB64url(resp.clientDataJSON);
|
||||
if (resp.attestationObject !== undefined)
|
||||
obj.response.attestationObject = _bufToB64url(resp.attestationObject);
|
||||
if (resp.authenticatorData !== undefined)
|
||||
obj.response.authenticatorData = _bufToB64url(resp.authenticatorData);
|
||||
if (resp.signature !== undefined)
|
||||
obj.response.signature = _bufToB64url(resp.signature);
|
||||
if (resp.userHandle !== undefined && resp.userHandle !== null)
|
||||
obj.response.userHandle = _bufToB64url(resp.userHandle);
|
||||
|
||||
// Include transport hints if available (registration only).
|
||||
if (typeof resp.getTransports === 'function') {
|
||||
obj.response.transports = resp.getTransports();
|
||||
}
|
||||
if (cred.authenticatorAttachment) {
|
||||
obj.authenticatorAttachment = cred.authenticatorAttachment;
|
||||
}
|
||||
if (cred.clientExtensionResults) {
|
||||
obj.clientExtensionResults = cred.getClientExtensionResults?.() ?? {};
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
// ── Login flow ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initiate a passkey login.
|
||||
* 1. Call /api/webauthn/authenticate/begin (optionally with email hint).
|
||||
* 2. Invoke navigator.credentials.get() — browser shows passkey picker.
|
||||
* 3. Send the assertion to /api/webauthn/authenticate/complete.
|
||||
* 4. On success: store tokens and derive vault key from master password.
|
||||
* The master password is still required to unlock the vault (ZK preserved).
|
||||
*/
|
||||
async function loginWithPasskey(email) {
|
||||
if (!window.PublicKeyCredential) {
|
||||
return { error: 'Passkeys are not supported in this browser.' };
|
||||
}
|
||||
|
||||
// Step 1: get options from server.
|
||||
const beginRes = await fetch('/api/webauthn/authenticate/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email || '' }),
|
||||
});
|
||||
if (!beginRes.ok) {
|
||||
const d = await beginRes.json().catch(() => ({}));
|
||||
return { error: d.error || 'Could not start passkey authentication.' };
|
||||
}
|
||||
const options = await beginRes.json();
|
||||
|
||||
// Step 2: browser passkey picker.
|
||||
let assertion;
|
||||
try {
|
||||
assertion = await navigator.credentials.get({
|
||||
publicKey: _prepareRequestOptions(options),
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.name === 'NotAllowedError') return { error: 'Passkey cancelled.' };
|
||||
return { error: e.message || 'Passkey authentication failed.' };
|
||||
}
|
||||
|
||||
// Step 3: send assertion to server.
|
||||
const completeRes = await fetch('/api/webauthn/authenticate/complete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(_credentialToJson(assertion)),
|
||||
});
|
||||
const completeData = await completeRes.json().catch(() => ({}));
|
||||
if (!completeRes.ok) {
|
||||
return { error: completeData.error || 'Passkey authentication failed.' };
|
||||
}
|
||||
|
||||
return { ok: true, data: completeData };
|
||||
}
|
||||
|
||||
// ── Registration flow (settings page) ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register a new passkey for the currently logged-in user.
|
||||
* Requires an active access_token in sessionStorage (set by vault.js on login).
|
||||
* @param {string} name User-friendly name for the passkey (e.g. "iPhone 15").
|
||||
*/
|
||||
async function registerPasskey(name) {
|
||||
if (!window.PublicKeyCredential) {
|
||||
return { error: 'Passkeys are not supported in this browser.' };
|
||||
}
|
||||
|
||||
const token = sessionStorage.getItem('access_token');
|
||||
if (!token) return { error: 'Not authenticated.' };
|
||||
|
||||
// Step 1: get creation options from server.
|
||||
const beginRes = await fetch('/api/webauthn/register/begin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!beginRes.ok) {
|
||||
const d = await beginRes.json().catch(() => ({}));
|
||||
return { error: d.error || 'Could not start passkey registration.' };
|
||||
}
|
||||
const options = await beginRes.json();
|
||||
|
||||
// Step 2: create credential.
|
||||
let credential;
|
||||
try {
|
||||
credential = await navigator.credentials.create({
|
||||
publicKey: _prepareCreationOptions(options),
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.name === 'NotAllowedError') return { error: 'Passkey registration cancelled.' };
|
||||
return { error: e.message || 'Passkey creation failed.' };
|
||||
}
|
||||
|
||||
// Step 3: send attestation to server.
|
||||
const payload = _credentialToJson(credential);
|
||||
payload.name = name || 'Passkey';
|
||||
|
||||
const completeRes = await fetch('/api/webauthn/register/complete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const completeData = await completeRes.json().catch(() => ({}));
|
||||
if (!completeRes.ok) {
|
||||
return { error: completeData.error || 'Passkey registration failed.' };
|
||||
}
|
||||
|
||||
return { ok: true, credential: completeData.credential };
|
||||
}
|
||||
|
||||
return { loginWithPasskey, registerPasskey };
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user