diff options
author | pennae <github@quasiparticle.net> | 2022-07-13 10:33:30 +0200 |
---|---|---|
committer | pennae <github@quasiparticle.net> | 2022-07-13 13:27:12 +0200 |
commit | 2f8dce44d3f2be74b5c6ec0a2e7f4ceced715328 (patch) | |
tree | caff55807c5fc773a36aa773cfde9cd6ebbbb6c8 /web/js/main.js | |
download | minor-skulk-2f8dce44d3f2be74b5c6ec0a2e7f4ceced715328.tar.gz minor-skulk-2f8dce44d3f2be74b5c6ec0a2e7f4ceced715328.tar.xz minor-skulk-2f8dce44d3f2be74b5c6ec0a2e7f4ceced715328.zip |
initial import
Diffstat (limited to 'web/js/main.js')
-rw-r--r-- | web/js/main.js | 761 |
1 files changed, 761 insertions, 0 deletions
diff --git a/web/js/main.js b/web/js/main.js new file mode 100644 index 0000000..bffcdfb --- /dev/null +++ b/web/js/main.js @@ -0,0 +1,761 @@ +// https://mozilla.github.io/ecosystem-platform/reference/webchannels-in-firefox-desktop-fennec + +'use strict'; + +import AuthClient from './auth-client/browser'; +import { deriveScopedKey, encryptScopedKeys, urlatob } from './crypto'; + +class Channel { + constructor() { + this.waiting = {}; + this.idseq = 0; + + window.addEventListener( + 'WebChannelMessageToContent', + ev => { + if (ev.detail.message.error) { + for (const wait in this.waiting) { + this.waiting[wait].reject(new Error(ev.detail.message.error)); + } + this.waiting = {}; + } else { + let message = this.waiting[ev.detail.message.messageId]; + delete this.waiting[ev.detail.message.messageId]; + if (message) { + message.resolve(ev.detail.message.data); + } + } + }, + true + ); + } + + _send(id, command, data) { + const messageId = this.idseq++; + window.dispatchEvent( + new window.CustomEvent('WebChannelMessageToChrome', { + detail: JSON.stringify({ + id, + message: { command, data, messageId }, + }), + }) + ); + return messageId; + } + + _send_wait(id, command, data) { + return new Promise((resolve, reject) => { + let messageId = this._send(id, command, data); + this.waiting[messageId] = { resolve, reject }; + }); + } + + async getStatus(isPairing, service) { + return await channel._send_wait( + 'account_updates', + 'fxaccounts:fxa_status', + { isPairing, service }); + } + + loadCredentials(email, creds, engines) { + let services = undefined; + if (engines) { + services = { + sync: { + declinedEngines: engines.declined, + offeredEngines: engines.offered, + } + }; + } + this._send('account_updates', 'fxaccounts:login', { + customizeSync: !!engines, + services, + email, + keyFetchToken: creds.keyFetchToken, + sessionToken: creds.sessionToken, + uid: creds.uid, + unwrapBKey: creds.unwrapBKey, + verified: creds.verified || false, + verifiedCanLinkAccount: false + }); + } + + passwordChanged(email, uid) { + this._send('account_updates', 'fxaccounts:change_password', { + email, + uid, + verified: true, + }); + } + + accountDestroyed(email, uid) { + this._send('account_updates', 'fxaccounts:delete', { + email, + uid, + }); + } +} + +class ProfileClient { + constructor(authClient, session) { + this._authClient = authClient; + this._session = session; + } + + async _acquireToken(scope) { + // NOTE the api has destroy commands for tokens, the client doesn't + let token = await this._authClient.createOAuthToken( + this._session.signedInUser.sessionToken, + this._session.clientId, + { scope, ttl: 60 }); + return token.access_token; + } + + async _request(endpoint, token, options) { + options = options || {}; + options.mode = "same-origin"; + options.headers = new Headers({ + authorization: `bearer ${token}`, + ...(options.headers || {}), + }); + let req = new Request(`${client_config.profile_server_base_url}${endpoint}`, options); + let resp = await fetch(req); + if (!resp.ok) throw new Error(resp.statusText); + return await resp.json(); + } + + async getProfile() { + let token = await this._acquireToken("profile"); + return await this._request("/v1/profile", token); + } + + async setDisplayName(name) { + let token = await this._acquireToken("profile:display_name:write"); + return await this._request("/v1/display_name", token, { + method: "POST", + body: JSON.stringify({ displayName: name }), + }); + } + + async setAvatar(avatar) { + let token = await this._acquireToken("profile:avatar:write"); + return await this._request("/v1/avatar/upload", token, { + method: "POST", + body: avatar.slice(), + headers: { + "Content-Type": avatar.type, + }, + }); + } +} + +function $(id) { + return document.getElementById(id); +} + +function showMessage(message, className) { + let m = $("message-modal"); + m.className = className || ""; + $("message").innerText = message; + $("message-modal-close").hidden = true; + m.showModal(); +} + +function showError(prefix, e) { + console.log(e); + if (e instanceof Object && "errno" in e) + e = e.message; + showMessage(prefix + String(e), "error"); +} + +function showRecoverableError(prefix, e) { + if (e === undefined) { + e = prefix; + prefix = ""; + } + + let close = $("message-modal-close"); + close.onclick = ev => { + ev.preventDefault(); + hideMessage(); + }; + showError(prefix, e, "error"); + close.hidden = false; +} + +function hideMessage() { + $("message-modal").close(); +} + +function wrapHandler(fn) { + function failed(e) { + showRecoverableError("failed: ", e); + console.log(e); + } + + return function() { + try { + let val = fn.apply(this, arguments); + if (val instanceof Promise) val = val.catch(failed); + return val; + } catch (e) { + failed(e); + throw e; + } + }; +} + +function switchTo(id, tabID) { + for (const screen of document.getElementsByClassName("container")) { + screen.hidden = true; + } + $(id).hidden = false; + + if (tabID) { + for (const tab of $(id).getElementsByClassName("tab")) { + tab.hidden = true; + } + $(tabID).hidden = false; + } +} + +function dateDiffText(when) { + let diff = Math.round(((+new Date()) - (+when)) / 1000); + let finalize = diff < 0 ? (s => `in ${s}`) : (s => `${s} ago`); + diff = Math.abs(diff); + let s; + if (diff < 5) + return "now"; + else if (diff < 60) + s = `${diff} second`; + else if (diff < 60 * 60) + s = `${Math.round(diff / 60)} minute`; + else if (diff < 60 * 60 * 24) + s = `${Math.round(diff / 60 / 60)} hour`; + else if (diff < 60 * 60 * 24 * 31) + s = `${Math.round(diff / 60 / 60 / 24)} day`; + else + s = `${Math.round(diff / 60 / 60 / 24 / 31)} month`; + if (!/1 /.test(s)) + s += "s"; + return finalize(s); +} + +////////////////////////////////////////// +// initialization +////////////////////////////////////////// + +let client_config; +var channel = new Channel(); +const isAndroid = /Android/.test(navigator.userAgent); + +document.body.onload = () => { + showMessage("Loading ...", "animate"); + + if (isAndroid) { + document.head.appendChild($("tpl-fenix-style").content); + } + + fetch("/.well-known/fxa-client-configuration") + .then(resp => { + if (!resp.ok) throw new Error(resp.statusText); + return resp.json(); + }) + .then(data => { + client_config = data; + hideMessage(); + return initUI(); + }) + .catch(e => { + showError("initialization failed: ", e); + }); +}; + +async function initUI() { + return isAndroid + ? await initUIAndroid() + : await initUIDesktop(); +} + +async function initUIDesktop() { + let present = wrapHandler(async () => { + if (window.location.hash.startsWith("#/verify/")) { + verify_init(JSON.parse(urlatob(window.location.hash.substr(9)))); + } else if (window.location.hash.startsWith("#/register/")) { + signup_init(window.location.hash.substr(11)); + } else { + let data = await channel.getStatus(); + if (!data.signedInUser) { + signin_init(); + } else if (window.location.hash == "#/settings") { + settings_init(data); + } else if (window.location.hash == "#/settings/change-password") { + settings_chpw_init(data); + } else if (window.location.hash == "#/settings/destroy") { + settings_destroy_init(data); + } else if (window.location.hash == "#/force_auth") { + signin_init(); + } else if (window.location.hash == "#/generate-invite") { + generate_invite_init(data); + } else { + switchTo("desktop-signedin"); + } + } + hideMessage(); + }); + + window.onpopstate = () => window.setTimeout(present, 0); + + for (let a of $("desktop-settings").querySelectorAll("nav a")) { + a.onclick = async ev => { + ev.preventDefault(); + window.location = ev.target.href; + await present(); + }; + } + await present(); +} + +async function initUIAndroid() { + fenix_signin_init(); +} + +////////////////////////////////////////// +// signin form +////////////////////////////////////////// + +function signin_init() { + switchTo("desktop-signin"); + let frm = $("frm-signin"); + frm.onsubmit = wrapHandler(signin_run); + frm.getElementsByClassName("_signup")[0].onclick = wrapHandler(signin_signup_instead); + frm.getElementsByClassName("_reset")[0].onclick = wrapHandler(ev => { + ev.preventDefault(); + password_reset_init(); + }); +} + +function signin_signup_instead(ev) { + ev.preventDefault(); + signup_init(); +} + +// NOTE it looks like firefox discards its session token before asking for reauth +// (eg if oauth verification of a sync token fails). we can't use the reauth +// endpoint for that, so we'll just always log in. +async function signin_run(ev) { + ev.preventDefault(); + let frm = ev.target; + + if (frm[0].value == "" || !frm[0].validity.valid) { + return showRecoverableError("email is not valid"); + } + if (frm[1].value == "" || !frm[1].validity.valid) { + return showRecoverableError("password is not valid"); + } + + let c = new AuthClient(client_config.auth_server_base_url); + try { + let session = await c.signIn(frm["email"].value, frm["password"].value, { + keys: true, + }); + + signin_confirm_init(frm["email"].value, session); + } catch (e) { + if (e.errno == 104) { + showRecoverableError(` + account is not verified. please verify or wait a few minutes and register again. + `.trim()); + } else { + throw e; + } + } +} + +////////////////////////////////////////// +// sign-in confirmation +////////////////////////////////////////// + +function signin_confirm_init(email, session) { + let frm = $("frm-signin-confirm"); + frm.getElementsByClassName("_resend")[0].onclick = wrapHandler(async ev => { + ev.preventDefault(); + + let c = new AuthClient(client_config.auth_server_base_url); + await c.sessionResendVerifyCode(session.sessionToken); + alert("code resent!"); + }); + frm.onsubmit = wrapHandler(async ev => { + ev.preventDefault(); + + let c = new AuthClient(client_config.auth_server_base_url); + c.sessionVerifyCode(session.sessionToken, frm["code"].value) + .then(resp => { + channel.loadCredentials(email, session, { offered: [], declined: [] }); + switchTo("desktop-signedin"); + }) + .catch(e => { + showError("verification failed: ", e); + }); + }); + switchTo("desktop-signin-confirm"); +} + +////////////////////////////////////////// +// password reset +////////////////////////////////////////// + +function password_reset_init() { + let frm = $("frm-resetpw"); + frm.onsubmit = wrapHandler(async ev => { + ev.preventDefault(); + + let c = new AuthClient(client_config.auth_server_base_url); + let token = await c.passwordForgotSendCode(frm["email"].value); + let code = prompt(` + please enter your password reset code from the email you've just received + `.trim()); + if (code === null) { + return showRecoverableError("password reset aborted"); + } + + let reset_token = await c.passwordForgotVerifyCode(code, token.passwordForgotToken); + password_reset_newpw(frm['email'].value, reset_token.accountResetToken); + }); + switchTo("desktop-resetpw"); +} + +function password_reset_newpw(email, reset_token) { + let frm = $("frm-resetpw-newpw"); + frm.onsubmit = wrapHandler(async ev => { + ev.preventDefault(); + + let c = new AuthClient(client_config.auth_server_base_url); + if (frm['new'].value != frm['new-confirm'].value) { + return showRecoverableError("passwords don't match!"); + } + + await c.accountReset(email, frm['new'].value, reset_token); + switchTo("desktop-signin"); + }); + switchTo("desktop-resetpw-newpw"); +} + +////////////////////////////////////////// +// signup form +////////////////////////////////////////// + +function signup_init(code) { + let frm = $("frm-signup"); + switchTo("desktop-signup"); + frm.onsubmit = wrapHandler(async ev => signup_run(ev, code)); +} + +async function signup_run(ev, code) { + ev.preventDefault(); + let frm = ev.target; + + if (frm[0].value == "" || !frm[0].validity.valid) { + return showRecoverableError("email is not valid"); + } + if (frm[1].value == "" || !frm[1].validity.valid) { + return showRecoverableError("password is not valid"); + } + + let c = new AuthClient(client_config.auth_server_base_url); + let session = await c.signUp(frm["email"].value, frm["password"].value, { + keys: true, + style: code, + }); + + cwts_continue(frm["email"].value, session); +} + +////////////////////////////////////////// +// choose-what-to-sync form +////////////////////////////////////////// + +function cwts_continue(email, session) { + // TODO we don't query browser capabilities, but we probably should + let frm = $("frm-cwts"); + frm.onsubmit = wrapHandler((ev) => { + ev.preventDefault(); + + let offered = [], declined = []; + for (const engine of frm.querySelectorAll("input[type=checkbox]")) { + if (!engine.checked) declined.push(engine.name); + else offered.push(engine.name); + } + channel.loadCredentials(email, session, { offered, declined }); + + switchTo("desktop-signup-unverified"); + }); + switchTo("desktop-cwts"); +} + +////////////////////////////////////////// +// verification +////////////////////////////////////////// + +function verify_init(context) { + let c = new AuthClient(client_config.auth_server_base_url); + c.verifyCode(context.uid, context.code) + .then(resp => { + switchTo("desktop-signedin"); + }) + .catch(e => { + showError("verification failed: ", e); + }); +} + +////////////////////////////////////////// +// settings root +////////////////////////////////////////// + +function settings_init(session) { + switchTo("desktop-settings", "desktop-settings-main"); + + let inner = async () => { + showMessage("Loading ...", "animate"); + + let ac = new AuthClient(client_config.auth_server_base_url) + let pc = new ProfileClient(ac, session); + + let initProfile = async () => { + let profile = await pc.getProfile(); + $("settings-name").value = profile.displayName || ""; + $("frm-settings-name").onsubmit = wrapHandler(async ev => { + showMessage("Applying ...") + ev.preventDefault(); + await pc.setDisplayName($("settings-name").value); + hideMessage(); + }); + $("settings-avatar-img").src = profile.avatar; + $("frm-settings-avatar").onsubmit = wrapHandler(async ev => { + showMessage("Saving ...") + ev.preventDefault(); + await pc.setAvatar($("settings-avatar").files[0]); + settings_init(session); + }); + }; + + await Promise.all([ + initProfile(), + settings_populateClients(ac, session), + ]); + + hideMessage(); + }; + + inner().catch(e => { + showError("initialization failed: ", e); + }); +} + +async function settings_populateClients(authClient, session) { + let clients = await authClient.attachedClients(session.signedInUser.sessionToken); + + let body = $("settings-clients").getElementsByTagName("tbody")[0]; + body.innerHTML = ""; + for (const c of clients) { + let row = document.createElement("tr"); + let add = (val, tip) => { + let cell = document.createElement("td"); + cell.innerText = val || ""; + if (tip) cell.title = tip; + row.appendChild(cell); + }; + let addDateDiff = val => { + let text = dateDiffText(new Date(val)); + add(text, new Date(val)); + }; + add(c.name); + add(c.deviceType); + addDateDiff(c.createdTime * 1000); + addDateDiff(c.lastAccessTime * 1000); + add(c.scope ? "yes" : "", (c.scope ? c.scope : "").replace(/ +/g, "\n")); + if (c.isCurrentSession) { + let cell = document.createElement("td"); + cell.innerHTML = `<span class="disabled">current session</span>`; + row.appendChild(cell); + } else if (c.deviceId || c.sessionTokenId || c.refreshTokenId) { + let remove = document.createElement("button"); + remove.innerText = 'remove'; + remove.onclick = wrapHandler(async ev => { + ev.preventDefault(); + let info = { clientId: c.clientId }; + if (c.deviceId) + info.deviceId = c.deviceId; + else if (c.sessionTokenId) + info.sessionTokenId = c.sessionTokenId; + else if (c.refreshTokenId) + info.refreshTokenId = c.refreshTokenId; + showMessage("Processing ...", "animate"); + await authClient.attachedClientDestroy(session.signedInUser.sessionToken, info); + await settings_populateClients(authClient, session); + hideMessage(); + }); + row.appendChild(remove); + } + body.appendChild(row); + } +} + +////////////////////////////////////////// +// settings change password +////////////////////////////////////////// + +function settings_chpw_init(session) { + switchTo("desktop-settings", "desktop-settings-chpw"); + let frm = $("frm-settings-chpw"); + frm.onsubmit = wrapHandler(async ev => { + ev.preventDefault(); + let frm = ev.target; + + if (frm["new"].value != frm["new-confirm"].value) { + showRecoverableError("passwords don't match"); + return; + } + + let c = new AuthClient(client_config.auth_server_base_url); + let resp = await c.passwordChange(session.signedInUser.email, frm['old'].value, frm['new'].value, {}); + + channel.passwordChanged(session.signedInUser.email, session.signedInUser.uid); + alert("password changed"); + }); +} + +////////////////////////////////////////// +// settings destroy +////////////////////////////////////////// + +function settings_destroy_init(session) { + switchTo("desktop-settings", "desktop-settings-destroy"); + let frm = $("frm-settings-destroy"); + frm.onsubmit = wrapHandler(async ev => { + ev.preventDefault(); + let frm = ev.target; + + if (frm[0].value == "" || !frm[0].validity.valid) { + return showRecoverableError("email is not valid"); + } + if (frm[1].value == "" || !frm[1].validity.valid) { + return showRecoverableError("password is not valid"); + } + + let c = new AuthClient(client_config.auth_server_base_url); + await c.accountDestroy(frm["email"].value, frm["password"].value); + + channel.accountDestroyed(frm["email"], session.signedInUser.uid); + switchTo("desktop-deleted"); + }); +} + +////////////////////////////////////////// +// generate invite +////////////////////////////////////////// + +function generate_invite_init(session) { + let frm = $("frm-generate-invite"); + $("desktop-generate-invite-result").hidden = true; + frm.onsubmit = wrapHandler(async ev => { + ev.preventDefault(); + + let server_url = new URL(client_config.auth_server_base_url); + server_url.pathname = server_url.pathname.split("/").slice(0, -1).join("/") + "/_invite"; + let c = new AuthClient(server_url.toString()); + let resp = await c.sessionPost( + "/generate", + session.signedInUser.sessionToken, + { 'ttl_hours': parseInt(frm["ttl"].value) }); + + $("desktop-generate-invite-result-link").href = resp.url; + $("desktop-generate-invite-result-link").innerText = resp.url; + $("desktop-generate-invite-result").hidden = false; + + }); + switchTo("desktop-generate-invite"); +} + +////////////////////////////////////////// +// fenix signin +////////////////////////////////////////// + +function fenix_signin_init() { + switchTo("fenix-signin-warning"); + $("fenix-signin-dialog-show").onclick = wrapHandler(async ev => { + ev.preventDefault(); + switchTo("fenix-signin"); + $("frm-fenix-signin").onsubmit = wrapHandler(fenix_signin_step2); + }); +} + +async function fenix_signin_step2(ev) { + ev.preventDefault(); + + let url = new URL(window.location); + let params = new URLSearchParams(url.search); + let param = (p) => { + let val = params.get(p); + if (val === undefined) throw `missing parameter ${p}`; + return val; + }; + + let frm = ev.target; + let c = new AuthClient(client_config.auth_server_base_url); + let session = await c.signIn(frm["email"].value, frm["password"].value, { + keys: true, + }); + let verifyCode = prompt("enter verify code"); + await c.sessionVerifyCode(session.sessionToken, verifyCode); + try { + let keys = await c.accountKeys(session.keyFetchToken, session.unwrapBKey); + let scoped_keys = await c.getOAuthScopedKeyData( + session.sessionToken, + param("client_id"), + param("scope")); + for (var scope in scoped_keys) { + scoped_keys[scope] = await deriveScopedKey( + keys.kB, + session.uid, + scope, + scoped_keys[scope].keyRotationSecret, + scoped_keys[scope].keyRotationTimestamp); + } + + let keys_jwe = await encryptScopedKeys(scoped_keys, param("keys_jwk")); + + let code = await c.createOAuthCode( + session.sessionToken, + param("client_id"), + param("state"), + { + access_type: param("access_type"), + keys_jwe, + response_type: param("response_type"), + scope: param("scope"), + code_challenge_method: param("code_challenge_method"), + code_challenge: param("code_challenge"), + }); + + console.log(`browser.tabs.executeScript({code: \` + port = browser.runtime.connectNative("mozacWebchannel"); + port.postMessage({ + id: "account_updates", + message: { + command: "fxaccounts:oauth_login", + data: { + "code": "${code.code}", + "state": "${code.state}", + "redirect": "urn:ietf:wg:oauth:2.0:oob:oauth-redirect-webchannel", + "action": "signin" + }, + messageId: 1, + }}); + \`});`); + + switchTo("fenix-signin-warning"); + } finally { + c.sessionDestroy(session.sessionToken); + } +} |