diff options
Diffstat (limited to 'internal/ui/static/js/webauthn.js')
-rw-r--r-- | internal/ui/static/js/webauthn.js | 196 |
1 files changed, 196 insertions, 0 deletions
diff --git a/internal/ui/static/js/webauthn.js b/internal/ui/static/js/webauthn.js new file mode 100644 index 00000000..465aa49e --- /dev/null +++ b/internal/ui/static/js/webauthn.js @@ -0,0 +1,196 @@ +function isWebAuthnSupported() { + return window.PublicKeyCredential; +} + +async function isConditionalLoginSupported() { + return isWebAuthnSupported() && + window.PublicKeyCredential.isConditionalMediationAvailable && + window.PublicKeyCredential.isConditionalMediationAvailable(); +} + +// URLBase64 to ArrayBuffer +function bufferDecode(value) { + return Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0)); +} + +// ArrayBuffer to URLBase64 +function bufferEncode(value) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(value))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +} + +function getCsrfToken() { + let element = document.querySelector("body[data-csrf-token]"); + if (element !== null) { + return element.dataset.csrfToken; + } + return ""; +} + +async function post(urlKey, username, data) { + var url = document.body.dataset[urlKey]; + if (username) { + url += "?username=" + username; + } + return fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Csrf-Token": getCsrfToken() + }, + body: JSON.stringify(data), + }); +} + +async function get(urlKey, username) { + var url = document.body.dataset[urlKey]; + if (username) { + url += "?username=" + username; + } + return fetch(url); +} + +function showError(error) { + console.log("webauthn error: " + error); + let alert = document.getElementById("webauthn-error"); + if (alert) { + alert.classList.remove("hidden"); + } +} + +async function register() { + let beginRegisterURL = "webauthnRegisterBeginUrl"; + let r = await get(beginRegisterURL); + let credOptions = await r.json(); + credOptions.publicKey.challenge = bufferDecode(credOptions.publicKey.challenge); + credOptions.publicKey.user.id = bufferDecode(credOptions.publicKey.user.id); + if(Object.hasOwn(credOptions.publicKey, 'excludeCredentials')) { + credOptions.publicKey.excludeCredentials.forEach((credential) => credential.id = bufferDecode(credential.id)); + } + let attestation = await navigator.credentials.create(credOptions); + let cred = { + id: attestation.id, + rawId: bufferEncode(attestation.rawId), + type: attestation.type, + response: { + attestationObject: bufferEncode(attestation.response.attestationObject), + clientDataJSON: bufferEncode(attestation.response.clientDataJSON), + }, + }; + let finishRegisterURL = "webauthnRegisterFinishUrl"; + let response = await post(finishRegisterURL, null, cred); + if (!response.ok) { + throw new Error("Login failed with HTTP status " + response.status); + } + console.log("registration successful"); + + let jsonData = await response.json(); + let redirect = jsonData.redirect; + window.location.href = redirect; +} + +async function login(username, conditional) { + let beginLoginURL = "webauthnLoginBeginUrl"; + let r = await get(beginLoginURL, username); + let credOptions = await r.json(); + credOptions.publicKey.challenge = bufferDecode(credOptions.publicKey.challenge); + if(Object.hasOwn(credOptions.publicKey, 'allowCredentials')) { + credOptions.publicKey.allowCredentials.forEach((credential) => credential.id = bufferDecode(credential.id)); + } + if (conditional) { + credOptions.signal = abortController.signal; + credOptions.mediation = "conditional"; + } + + var assertion; + try { + assertion = await navigator.credentials.get(credOptions); + } + catch (err) { + // swallow aborted conditional logins + if (err instanceof DOMException && err.name == "AbortError") { + return; + } + throw err; + } + + if (!assertion) { + return; + } + + let assertionResponse = { + id: assertion.id, + rawId: bufferEncode(assertion.rawId), + type: assertion.type, + response: { + authenticatorData: bufferEncode(assertion.response.authenticatorData), + clientDataJSON: bufferEncode(assertion.response.clientDataJSON), + signature: bufferEncode(assertion.response.signature), + userHandle: bufferEncode(assertion.response.userHandle), + }, + }; + + let finishLoginURL = "webauthnLoginFinishUrl"; + let response = await post(finishLoginURL, username, assertionResponse); + if (!response.ok) { + throw new Error("Login failed with HTTP status " + response.status); + } + window.location.reload(); +} + +async function conditionalLogin() { + if (await isConditionalLoginSupported()) { + login("", true); + } +} + +async function removeCreds(event) { + event.preventDefault(); + let removeCredsURL = "webauthnDeleteAllUrl"; + await post(removeCredsURL, null, {}); + window.location.reload(); +} + +let abortController = new AbortController(); +document.addEventListener("DOMContentLoaded", function () { + if (!isWebAuthnSupported()) { + return; + } + + let registerButton = document.getElementById("webauthn-register"); + if (registerButton != null) { + registerButton.disabled = false; + registerButton.addEventListener("click", (e) => { + e.preventDefault(); + register().catch((err) => showError(err)); + }); + } + + let removeCredsButton = document.getElementById("webauthn-delete"); + if (removeCredsButton != null) { + removeCredsButton.addEventListener("click", removeCreds); + } + + let loginButton = document.getElementById("webauthn-login"); + if (loginButton != null) { + loginButton.disabled = false; + let usernameField = document.getElementById("form-username"); + if (usernameField != null) { + usernameField.autocomplete += " webauthn"; + } + let passwordField = document.getElementById("form-password"); + if (passwordField != null) { + passwordField.autocomplete += " webauthn"; + } + + loginButton.addEventListener("click", (e) => { + e.preventDefault(); + abortController.abort(); + login(usernameField.value).catch(err => showError(err)); + }); + + conditionalLogin().catch(err => showError(err)); + } +}); |