clusev/resources/js/webauthn.js

83 lines
3.0 KiB
JavaScript

// WebAuthn ceremony bridge: converts the JSON options from the server into the
// ArrayBuffers navigator.credentials needs, runs the create/get ceremony, and posts
// the (base64url-encoded) result back to the Livewire component.
// Exposed as window.clusevWebauthn for the Settings + 2FA-challenge views.
const b64uToBuf = (s) => {
const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/') + pad);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf.buffer;
};
const bufToB64u = (buf) => {
let bin = '';
const bytes = new Uint8Array(buf);
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
};
// Map server creation options (base64url strings) → PublicKeyCredentialCreationOptions.
const toCreateOptions = (o) => {
const pk = { ...o };
pk.challenge = b64uToBuf(o.challenge);
pk.user = { ...o.user, id: b64uToBuf(o.user.id) };
pk.excludeCredentials = (o.excludeCredentials || []).map((c) => ({ ...c, id: b64uToBuf(c.id) }));
return pk;
};
const toRequestOptions = (o) => {
const pk = { ...o };
pk.challenge = b64uToBuf(o.challenge);
pk.allowCredentials = (o.allowCredentials || []).map((c) => ({ ...c, id: b64uToBuf(c.id) }));
return pk;
};
const encodeAttestation = (cred) => ({
id: cred.id,
type: cred.type,
rawId: bufToB64u(cred.rawId),
response: {
clientDataJSON: bufToB64u(cred.response.clientDataJSON),
attestationObject: bufToB64u(cred.response.attestationObject),
},
clientExtensionResults: cred.getClientExtensionResults?.() ?? {},
});
const encodeAssertion = (cred) => ({
id: cred.id,
type: cred.type,
rawId: bufToB64u(cred.rawId),
response: {
clientDataJSON: bufToB64u(cred.response.clientDataJSON),
authenticatorData: bufToB64u(cred.response.authenticatorData),
signature: bufToB64u(cred.response.signature),
userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null,
},
clientExtensionResults: cred.getClientExtensionResults?.() ?? {},
});
window.clusevWebauthn = {
async register($wire) {
try {
const options = await $wire.options();
const cred = await navigator.credentials.create({ publicKey: toCreateOptions(options) });
await $wire.register(encodeAttestation(cred));
} catch (e) {
// user cancelled or the authenticator failed — surface nothing noisy
console.warn('WebAuthn registration aborted', e);
}
},
async login($wire) {
try {
const options = await $wire.assertionOptions();
const cred = await navigator.credentials.get({ publicKey: toRequestOptions(options) });
await $wire.verifyWebauthn(encodeAssertion(cred));
} catch (e) {
console.warn('WebAuthn login aborted', e);
}
},
};