summaryrefslogtreecommitdiff
path: root/web/js/main.js
diff options
context:
space:
mode:
Diffstat (limited to 'web/js/main.js')
-rw-r--r--web/js/main.js761
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);
+ }
+}