// 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.close(); 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 = `current session`; 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); } }