// The browser half of the two WebAuthn ceremonies.
//
// Both are the same shape: ask the server for options, hand them to the
// platform, send back what it produced. All the checking happens on the server -
// nothing here decides whether a credential is acceptable, and nothing here
// should, because a page can be replaced by whoever is looking at it.
//
// The wire format is base64url and the platform wants ArrayBuffers, so the two
// conversions below are the whole of the impedance mismatch.
const webauthn = (() => {
const supported = () =>
typeof window.PublicKeyCredential === "function" &&
!!(navigator.credentials && navigator.credentials.create);
// base64url, not base64: no padding, and -_ where +/ would be. Getting this
// wrong produces a credential the server cannot match, with no error to say so.
const toBuffer = (value) => {
const b64 = value.replace(/-/g, "+").replace(/_/g, "/");
const pad = b64.length % 4 ? "=".repeat(4 - (b64.length % 4)) : "";
const raw = atob(b64 + pad);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out.buffer;
};
const fromBuffer = (buffer) => {
const bytes = new Uint8Array(buffer);
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
};
// The server sends the fields the spec defines as ArrayBuffers encoded as
// base64url strings. Decode exactly those and leave everything else alone.
const decodeOptions = (options) => {
const out = { ...options };
if (out.challenge) out.challenge = toBuffer(out.challenge);
if (out.user && out.user.id) out.user = { ...out.user, id: toBuffer(out.user.id) };
for (const key of ["excludeCredentials", "allowCredentials"]) {
if (Array.isArray(out[key])) {
out[key] = out[key].map((c) => ({ ...c, id: toBuffer(c.id) }));
}
}
return out;
};
const encodeCredential = (cred) => {
const r = cred.response;
const out = {
id: cred.id,
rawId: fromBuffer(cred.rawId),
type: cred.type,
clientExtensionResults: cred.getClientExtensionResults(),
response: { clientDataJSON: fromBuffer(r.clientDataJSON) },
};
if (r.attestationObject) {
out.response.attestationObject = fromBuffer(r.attestationObject);
// A hint for the server's stored transports. Not every authenticator
// reports it, which is why it is guarded rather than assumed.
if (r.getTransports) out.response.transports = r.getTransports();
} else {
out.response.authenticatorData = fromBuffer(r.authenticatorData);
out.response.signature = fromBuffer(r.signature);
out.response.userHandle = r.userHandle ? fromBuffer(r.userHandle) : null;
}
return out;
};
// The platform throws for "the person cancelled" and for "this device cannot"
// alike, and its messages are not written for a person. One readable sentence
// instead, with the original left in the console for whoever is debugging.
const friendly = (err) => {
console.debug("WebAuthn:", err);
if (err && err.name === "NotAllowedError") {
return new Error("That was cancelled, or it timed out. Try again.");
}
if (err && err.name === "InvalidStateError") {
return new Error("That authenticator is already enrolled on this account.");
}
return err instanceof Error ? err : new Error("Your device could not complete that.");
};
async function enrol(name) {
if (!supported()) throw new Error("This browser does not support passkeys.");
const options = await api.post("/api/webauthn/register/options");
let credential;
try {
credential = await navigator.credentials.create({
publicKey: decodeOptions(options),
});
} catch (err) { throw friendly(err); }
return api.post("/api/webauthn/register/verify", {
credential: encodeCredential(credential),
name: name || null,
});
}
// The second leg of sign-in. Called only after /api/login answered
// mfa_required, so the server already holds the pending state this needs.
async function assertLogin() {
if (!supported()) {
throw new Error("This account needs a passkey, and this browser cannot use one.");
}
const options = await api.post("/api/login/webauthn/options");
let credential;
try {
credential = await navigator.credentials.get({
publicKey: decodeOptions(options),
});
} catch (err) { throw friendly(err); }
return api.post("/api/login/webauthn/verify", {
credential: encodeCredential(credential),
});
}
return { supported, enrol, assertLogin };
})();