1
0
Fork 0
mirror of https://github.com/miniflux/v2.git synced 2025-06-27 16:36:00 +00:00
miniflux-v2/internal/ui/static/js/webauthn.js
Florian Rüchel 62ef8ed57a
Add WebAuthn / Passkey integration
This is a rebase of #1618 in which @dave-atx added WebAuthn support.

Closes #1618
2023-11-05 18:57:35 +01:00

196 lines
6.2 KiB
JavaScript

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));
}
});