6.4 KiB · text 5af55d9
// Browser-side glue for the WebAuthn ceremony.
//
// Exposes two hooks the LiveView pages bind to:
// * "WebauthnRegister" — on the security-key enrollment button
// * "WebauthnAuthenticate" — on the "use security key" button on
// the 2FA challenge page
const b64uToBuf = s => {
// Pad + swap URL-safe chars back to standard base64.
const norm = s.replace(/-/g, "+").replace(/_/g, "/")
const pad = norm.length % 4 === 0 ? "" : "=".repeat(4 - (norm.length % 4))
const bin = atob(norm + 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 => {
const bytes = new Uint8Array(buf)
let bin = ""
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
const csrfToken = () =>
document.querySelector("meta[name='csrf-token']").getAttribute("content")
const postJson = async (path, body) => {
const res = await fetch(path, {
method: "POST",
headers: {
"content-type": "application/json",
"x-csrf-token": csrfToken(),
accept: "application/json",
},
credentials: "same-origin",
body: JSON.stringify(body || {}),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`${res.status}: ${text}`)
}
return res.json()
}
const decodeCreationOptions = opts => {
opts.challenge = b64uToBuf(opts.challenge)
opts.user.id = b64uToBuf(opts.user.id)
if (opts.excludeCredentials) {
opts.excludeCredentials = opts.excludeCredentials.map(c => ({
...c,
id: b64uToBuf(c.id),
}))
}
return opts
}
const decodeRequestOptions = opts => {
opts.challenge = b64uToBuf(opts.challenge)
if (opts.allowCredentials) {
opts.allowCredentials = opts.allowCredentials.map(c => ({
...c,
id: b64uToBuf(c.id),
}))
}
return opts
}
const setStatus = (el, msg, kind) => {
// The page can render a <span data-status-for="webauthn"> to receive
// feedback. Falls back to logging.
const target =
el.closest("section, form, body").querySelector("[data-status-for='webauthn']")
if (target) {
target.textContent = msg
target.className = kind === "error" ? "text-error text-sm" : "text-success text-sm"
} else {
console.log("[webauthn]", msg)
}
}
export const WebauthnRegister = {
mounted() {
this.el.addEventListener("click", async ev => {
ev.preventDefault()
if (!window.PublicKeyCredential) {
setStatus(this.el, "Your browser doesn't support WebAuthn.", "error")
return
}
try {
setStatus(this.el, "Touch your security key to confirm…")
const start = await postJson("/api/webauthn/register/start", {})
const publicKey = decodeCreationOptions(start.publicKey)
const cred = await navigator.credentials.create({publicKey})
const name = this.el.dataset.keyName ||
window.prompt("Give this security key a name:", "Security key") ||
"Security key"
const finish = await postJson("/api/webauthn/register/finish", {
id: cred.id,
rawId: bufToB64u(cred.rawId),
type: cred.type,
attestationObject: bufToB64u(cred.response.attestationObject),
clientDataJSON: bufToB64u(cred.response.clientDataJSON),
name,
})
if (finish.recoveryCodes && finish.recoveryCodes.length) {
// First MFA setup — surface the codes inline. Render them in
// the target `[data-recovery-codes-target]` element the page
// exposes, then leave the page open so the user can copy.
const sink = document.querySelector("[data-recovery-codes-target]")
if (sink) {
sink.innerHTML = `
<div class="border border-warning rounded p-3 mt-3 space-y-2">
<p class="text-warning text-sm font-semibold">
Save these recovery codes now — each works once and they
won't be shown again. Treat them like passwords.
</p>
<pre class="text-xs bg-base-200 p-3 rounded font-mono grid grid-cols-2 gap-x-4 gap-y-1">${finish.recoveryCodes.join("\n")}</pre>
<button type="button" class="btn btn-xs" data-acknowledge-recovery>
I've saved these — refresh
</button>
</div>`
const ack = sink.querySelector("[data-acknowledge-recovery]")
ack.addEventListener("click", () => window.location.reload())
setStatus(this.el, `Security key '${finish.name}' added. Save the recovery codes below.`, "success")
} else {
// No place to render — fall back to alerting + reload.
alert("Recovery codes (save these — they won't be shown again):\n\n" + finish.recoveryCodes.join("\n"))
window.location.reload()
}
} else {
setStatus(this.el, `Security key '${finish.name}' added.`, "success")
window.location.reload()
}
} catch (err) {
setStatus(this.el, `Registration failed: ${err.message || err}`, "error")
}
})
},
}
export const WebauthnAuthenticate = {
mounted() {
this.el.addEventListener("click", async ev => {
ev.preventDefault()
if (!window.PublicKeyCredential) {
setStatus(this.el, "Your browser doesn't support WebAuthn.", "error")
return
}
try {
setStatus(this.el, "Touch your security key…")
const start = await postJson("/api/webauthn/auth/start", {})
const publicKey = decodeRequestOptions(start.publicKey)
const cred = await navigator.credentials.get({publicKey})
const finish = await postJson("/api/webauthn/auth/finish", {
id: bufToB64u(cred.rawId),
rawId: bufToB64u(cred.rawId),
type: cred.type,
authenticatorData: bufToB64u(cred.response.authenticatorData),
clientDataJSON: bufToB64u(cred.response.clientDataJSON),
signature: bufToB64u(cred.response.signature),
userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null,
})
if (finish.redirect) {
window.location.assign(finish.redirect)
}
} catch (err) {
setStatus(this.el, `Verification failed: ${err.message || err}`, "error")
}
})
},
}