// 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(v) { $("content").replaceChildren(v); } 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); } ////////////////////////////////////////// // credentials form ////////////////////////////////////////// class CredentialsForm extends HTMLElement { constructor() { super(); this.replaceChildren($("btpl-credentials").content.cloneNode(true)); let frm = this.querySelector("form"); frm['submit'].value = this.getAttribute('submit-text'); frm.onsubmit = wrapHandler(async ev => { ev.preventDefault(); this.dispatchEvent(new CustomEvent('confirm', { detail: { email: ev.target['email'].value, password: ev.target['password'].value, }, })); }); } connectedCallback() { this.querySelector("form")['email'].focus(); } }; customElements.define('credentials-form', CredentialsForm); ////////////////////////////////////////// // signin form ////////////////////////////////////////// class Signin extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({mode: 'open'}); shadow.append( $("styles").cloneNode(true), $("tpl-desktop-signin").content.cloneNode(true)); let frm = shadow.querySelector("credentials-form"); frm.addEventListener('confirm', wrapHandler(async ev => { await this._signin(ev.detail.email, ev.detail.password); })); shadow.querySelector("a._signup").onclick = wrapHandler(async ev => { ev.preventDefault(); window.location = ev.target.href; }); shadow.querySelector("a._reset").onclick = wrapHandler(async ev => { ev.preventDefault(); window.location = ev.target.href; }); } // 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 _signin(email, password) { let c = new AuthClient(client_config.auth_server_base_url); try { let session = await c.signIn(email, password, { keys: true }); switchTo(new SigninConfirm(email, 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; } } } }; customElements.define('do-signin', Signin); ////////////////////////////////////////// // sign-in confirmation ////////////////////////////////////////// class SigninConfirm extends HTMLElement { constructor(email, session) { super(); const shadow = this.attachShadow({mode: 'open'}); shadow.append( $("styles").cloneNode(true), $("tpl-desktop-signin-confirm").content.cloneNode(true)); let frm = shadow.querySelector("form"); frm.querySelector("a._resend").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); try { await c.sessionVerifyCode(session.sessionToken, frm["code"].value); channel.loadCredentials(email, session, { offered: [], declined: [] }); switchTo(new SignedIn()); } catch (e) { showError("verification failed: ", e); } }); } connectedCallback() { this.shadowRoot.querySelector("form")['code'].focus(); } }; customElements.define('do-signin-confirm', SigninConfirm); ////////////////////////////////////////// // password reset ////////////////////////////////////////// class ResetPassword extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({mode: 'open'}); shadow.append( $("styles").cloneNode(true), $("tpl-desktop-resetpw").content.cloneNode(true)); let frm = shadow.querySelector("form"); 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"); if (code === null) { return showRecoverableError("password reset aborted"); } let reset_token = await c.passwordForgotVerifyCode(code, token.passwordForgotToken); switchTo(new ResetPasswordCreate(frm['email'].value, reset_token.accountResetToken)); }); } connectedCallback() { this.shadowRoot.querySelector("form")['email'].focus(); } }; customElements.define('do-resetpw', ResetPassword); class ResetPasswordCreate extends HTMLElement { constructor(email, reset_token) { super(); const shadow = this.attachShadow({mode: 'open'}); shadow.append( $("styles").cloneNode(true), $("tpl-desktop-resetpw-newpw").content.cloneNode(true)); let frm = shadow.querySelector("form"); frm.onsubmit = async ev => { ev.preventDefault(); if (frm['new'].value != frm['new-confirm'].value) { return showRecoverableError("passwords don't match!"); } let c = new AuthClient(client_config.auth_server_base_url); await c.accountReset(email, frm['new'].value, reset_token); window.location.hash = ""; switchTo(new Signin()); }; } connectedCallback() { this.shadowRoot.querySelector("form")['new'].focus(); } }; customElements.define('do-resetpw-create', ResetPasswordCreate); ////////////////////////////////////////// // signup form ////////////////////////////////////////// class Signup extends HTMLElement { constructor(code) { super(); const shadow = this.attachShadow({mode: 'open'}); shadow.replaceChildren( $("styles").cloneNode(true), $("tpl-desktop-signup").content.cloneNode(true)); let frm = shadow.querySelector("credentials-form"); frm.addEventListener('confirm', async ev => { await this._signup(ev.detail.email, ev.detail.password, code); }) } async _signup(email, password, code) { let c = new AuthClient(client_config.auth_server_base_url); let session = await c.signUp(email, password, { keys: true, style: code, }); switchTo(new CWTS(email, session)); } }; customElements.define('do-signup', Signup); class CWTS extends HTMLElement { constructor(email, session) { super(); // TODO we don't query browser capabilities, but we probably should const shadow = this.attachShadow({mode: 'open'}); shadow.replaceChildren( $("styles").cloneNode(true), $("tpl-desktop-cwts").content.cloneNode(true)); let frm = shadow.querySelector("form"); 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(new SignupUnverified()); }); } }; customElements.define('do-cwts', CWTS); class SignupUnverified extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({mode: 'open'}); shadow.replaceChildren( $("styles").cloneNode(true), $("tpl-desktop-signup-unverified").content.cloneNode(true)); } } customElements.define('do-signup-unverified', SignupUnverified); ////////////////////////////////////////// // verification ////////////////////////////////////////// class SignedIn extends HTMLElement { constructor() { super(); this.replaceChildren($("tpl-desktop-signedin").content.cloneNode(true)); } }; customElements.define('signed-in', SignedIn); async function verify(context) { let c = new AuthClient(client_config.auth_server_base_url); try { await c.verifyCode(context.uid, context.code) switchTo(new SignedIn()); } catch(e) { showError("verification failed: ", e); } } ////////////////////////////////////////// // settings ////////////////////////////////////////// class Settings extends HTMLElement { constructor(session) { super(); const shadow = this.attachShadow({mode: 'open'}); this.session = session; shadow.replaceChildren( $("styles").cloneNode(true), $("tpl-desktop-settings").content.cloneNode(true)); for (let a of shadow.querySelectorAll("nav a")) { a.onclick = wrapHandler(async ev => { ev.preventDefault(); window.location = ev.target.href; await this._display(); }); } this._display(); } async _display() { let tab = SettingsMain; if (window.location.hash == "#/settings/change-password") { tab = SettingsChangePassword; } else if (window.location.hash == "#/settings/destroy") { tab = SettingsDestroy; } try { this.shadowRoot.querySelector(".tab").replaceChildren(new tab(this.session)); } catch(e) { showError("initialization failed: ", e); } } }; customElements.define('do-settings', Settings); class SettingsMain extends HTMLElement { constructor(session) { super(); this.session = session; this.replaceChildren($("tpl-desktop-settings-main").content.cloneNode(true)); this._display(); } async _display() { showMessage("Loading ...", "animate"); let ac = new AuthClient(client_config.auth_server_base_url) let pc = new ProfileClient(ac, this.session); let initProfile = async () => { let profile = await pc.getProfile(); this.querySelector("#name").value = profile.displayName || ""; this.querySelector("#frm-name").onsubmit = wrapHandler(async ev => { ev.preventDefault(); showMessage("Applying ...") await pc.setDisplayName(ev.target['name'].value); hideMessage(); }); this.querySelector("#avatar-img").src = profile.avatar; this.querySelector("#frm-avatar").onsubmit = wrapHandler(async ev => { ev.preventDefault(); showMessage("Saving ...") await pc.setAvatar(ev.target['avatar'].files[0]); await this._display(); }); }; await Promise.all([ initProfile(), this._populateClients(ac), ]); hideMessage(); } async _populateClients(authClient) { let clients = await authClient.attachedClients(this.session.signedInUser.sessionToken); let body = this.querySelector("#clients tbody"); 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(this.session.signedInUser.sessionToken, info); await this._populateClients(authClient, this.session); hideMessage(); }); row.appendChild(remove); } body.appendChild(row); } } }; customElements.define('do-settings-main', SettingsMain); class SettingsChangePassword extends HTMLElement { constructor(session) { super(); this.replaceChildren($("tpl-desktop-settings-chpw").content.cloneNode(true)); let frm = this.querySelector("form"); frm.onsubmit = wrapHandler(async ev => { ev.preventDefault(); if (frm['new'].value != frm['new-confirm'].value) { return showRecoverableError("passwords don't match!"); } let c = new AuthClient(client_config.auth_server_base_url); await c.passwordChange(session.signedInUser.email, frm['old'].value, frm['new'].value, {}); channel.passwordChanged(session.signedInUser.email, session.signedInUser.uid); alert("password changed"); }); } connectedCallback() { this.querySelector("form")['old'].focus(); } }; customElements.define('do-settings-chpw', SettingsChangePassword); class SettingsDestroy extends HTMLElement { constructor(session) { super(); this.replaceChildren($("tpl-desktop-settings-destroy").content.cloneNode(true)); this.querySelector("credentials-form").addEventListener('confirm', wrapHandler(async ev => { let c = new AuthClient(client_config.auth_server_base_url); await c.accountDestroy(ev.detail.email, ev.detail.password); channel.accountDestroyed(ev.detail.email, session.signedInUser.uid); switchTo(new AccountDestroyed()); })); } }; customElements.define('do-settings-destroy', SettingsDestroy); class AccountDestroyed extends HTMLElement { constructor() { super(); this.replaceChildren( $("styles").cloneNode(true), $("tpl-desktop-deleted").content.cloneNode(true)); } }; customElements.define('account-destroyed', AccountDestroyed); ////////////////////////////////////////// // generate invite ////////////////////////////////////////// class GenerateInvite extends HTMLElement { constructor(session) { super(); const shadow = this.attachShadow({mode: 'open'}); shadow.replaceChildren( $("styles").cloneNode(true), $("tpl-desktop-generate-invite").content.cloneNode(true)); let frm = shadow.querySelector("form"); shadow.querySelector("#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) }); shadow.querySelector("#link").href = resp.url; shadow.querySelector("#link").innerText = resp.url; shadow.querySelector("#result").hidden = false; }); } }; customElements.define('do-generate-invite', GenerateInvite); ////////////////////////////////////////// // fenix signin ////////////////////////////////////////// class FenixSignin extends HTMLElement { constructor(session) { super(); const shadow = this.attachShadow({mode: 'open'}); shadow.replaceChildren( $("styles").cloneNode(true), $("tpl-fenix-signin-warning").content.cloneNode(true)); shadow.querySelector("#signin").onclick = wrapHandler(async ev => { ev.preventDefault(); switchTo(new FenixSigninEnter()); }); } } customElements.define('do-fenix-signin', FenixSignin); class FenixSigninEnter extends HTMLElement { constructor(session) { super(); const shadow = this.attachShadow({mode: 'open'}); shadow.replaceChildren( $("styles").cloneNode(true), $("tpl-fenix-signin").content.cloneNode(true)); shadow.querySelector("credentials-form").addEventListener('confirm', wrapHandler(async ev => { ev.preventDefault(); await this._step2(ev.detail.email, ev.detail.password); })); } async _step2(email, password) { 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 c = new AuthClient(client_config.auth_server_base_url); let session = await c.signIn(email, password, { 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(new FenixSignin()); } finally { c.sessionDestroy(session.sessionToken); } } } customElements.define('do-fenix-signin-enter', FenixSigninEnter); ////////////////////////////////////////// // initialization ////////////////////////////////////////// let client_config; var channel = new Channel(); const isAndroid = /Android/.test(navigator.userAgent); document.body.onload = async () => { showMessage("Loading ...", "animate"); if (isAndroid) { document.head.appendChild($("tpl-fenix-style").content); } try { let resp = await fetch("/.well-known/fxa-client-configuration"); if (!resp.ok) throw new Error(resp.statusText); client_config = await resp.json(); hideMessage(); initUI(); } catch(e) { showError("initialization failed: ", e); } }; async function initUI() { if (isAndroid) { await initUIAndroid(); } else { await initUIDesktop(); } } async function initUIDesktop() { let present = wrapHandler(async () => { if (window.location.hash.startsWith("#/verify/")) { await verify(JSON.parse(urlatob(window.location.hash.substr(9)))); } else if (window.location.hash.startsWith("#/register/")) { switchTo(new Signup(window.location.hash.substr(11))); } else { let data = await channel.getStatus(); if (window.location.hash == "#/signup") { switchTo(new Signup()); } else if (window.location.hash == "#/reset-password") { switchTo(new ResetPassword()); } else if (!data.signedInUser || window.location.hash == "#/force_auth") { switchTo(new Signin()); } else if (window.location.hash == "#/settings" || window.location.hash == "#/settings/change-password" || window.location.hash == "#/settings/destroy") { switchTo(new Settings(data)); } else if (window.location.hash == "#/generate-invite") { switchTo(new GenerateInvite(data)); } else { switchTo(new SignedIn()); } } hideMessage(); }); window.onpopstate = () => window.setTimeout(present, 0); window.onhashchange = () => window.setTimeout(present, 0); await present(); } async function initUIAndroid() { switchTo(new FenixSignin()); }