diff --git a/.gitignore b/.gitignore index a575b1690b9..be3a09e2413 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,9 @@ /test/puppeteer.json /test/node_modules/ /test/build/* +/test/samples/oversize.txt /node_modules/ /build/* release/log.txt release/* -npm-debug.log \ No newline at end of file +npm-debug.log diff --git a/.semaphore/semaphore.yml b/.semaphore/semaphore.yml index e124573e122..62f06e40d2b 100644 --- a/.semaphore/semaphore.yml +++ b/.semaphore/semaphore.yml @@ -25,7 +25,11 @@ blocks: - npm install - echo "NODE=$(node --version), NPM=$(npm --version), TSC=$( ./node_modules/typescript/bin/tsc --version)" - npm run-script pretest - - sudo sh -c "echo '209.250.232.81 cron.flowcrypt.com' >> /etc/hosts" && sudo sh -c "echo '127.0.0.1 google.mock.flowcryptlocal.com' >> /etc/hosts" + - sudo sh -c "echo '209.250.232.81 cron.flowcrypt.com' >> /etc/hosts" + - sudo sh -c "echo '127.0.0.1 google.mock.flowcryptlocal.com' >> /etc/hosts" + - sudo sh -c "echo '127.0.0.1 fes.standardsubdomainfes.com' >> /etc/hosts" + - sudo sh -c "echo '127.0.0.1 standardsubdomainfes.com' >> /etc/hosts" + - sudo sh -c "echo '127.0.0.1 wellknownfes.com' >> /etc/hosts" jobs: diff --git a/extension/chrome/elements/compose-modules/compose-pwd-or-pubkey-container-module.ts b/extension/chrome/elements/compose-modules/compose-pwd-or-pubkey-container-module.ts index 76bedc34405..b1fdf539d6a 100644 --- a/extension/chrome/elements/compose-modules/compose-pwd-or-pubkey-container-module.ts +++ b/extension/chrome/elements/compose-modules/compose-pwd-or-pubkey-container-module.ts @@ -11,7 +11,6 @@ import { ApiErr } from '../../../js/common/api/shared/api-error.js'; import { ViewModule } from '../../../js/common/view-module.js'; import { ComposeView } from '../compose.js'; import { AcctStore } from '../../../js/common/platform/store/acct-store.js'; -import { AccountServer } from '../../../js/common/api/account-server.js'; export class ComposePwdOrPubkeyContainerModule extends ViewModule { @@ -91,7 +90,7 @@ export class ComposePwdOrPubkeyContainerModule extends ViewModule { expirationTextEl.text(Str.pluralize(this.MSG_EXPIRE_DAYS_DEFAULT, 'day')); } else { try { - const response = await AccountServer.accountGetAndUpdateLocalStore(authInfo); + const response = await this.view.acctServer.accountGetAndUpdateLocalStore(authInfo); expirationTextEl.text(Str.pluralize(response.account.default_message_expire, 'day')); } catch (e) { ApiErr.reportIfSignificant(e); diff --git a/extension/chrome/elements/compose-modules/compose-storage-module.ts b/extension/chrome/elements/compose-modules/compose-storage-module.ts index 7ba06322393..ba29b81ff0c 100644 --- a/extension/chrome/elements/compose-modules/compose-storage-module.ts +++ b/extension/chrome/elements/compose-modules/compose-storage-module.ts @@ -18,7 +18,6 @@ import { ContactStore } from '../../../js/common/platform/store/contact-store.js import { PassphraseStore } from '../../../js/common/platform/store/passphrase-store.js'; import { Settings } from '../../../js/common/settings.js'; -import { AccountServer } from '../../../js/common/api/account-server.js'; export class ComposeStorageModule extends ViewModule { @@ -211,7 +210,7 @@ export class ComposeStorageModule extends ViewModule { const auth = await AcctStore.authInfo(this.view.acctEmail); if (auth.uuid) { try { - await AccountServer.accountGetAndUpdateLocalStore(auth); // updates storage + await this.view.acctServer.accountGetAndUpdateLocalStore(auth); // updates storage } catch (e) { if (ApiErr.isAuthErr(e)) { Settings.offerToLoginWithPopupShowModalOnErr( diff --git a/extension/chrome/elements/compose-modules/formatters/encrypted-mail-msg-formatter.ts b/extension/chrome/elements/compose-modules/formatters/encrypted-mail-msg-formatter.ts index d34a135b54d..4e85523b12a 100644 --- a/extension/chrome/elements/compose-modules/formatters/encrypted-mail-msg-formatter.ts +++ b/extension/chrome/elements/compose-modules/formatters/encrypted-mail-msg-formatter.ts @@ -21,7 +21,6 @@ import { Ui } from '../../../../js/common/browser/ui.js'; import { Xss } from '../../../../js/common/platform/xss.js'; import { AcctStore } from '../../../../js/common/platform/store/acct-store.js'; import { FlowCryptWebsite } from '../../../../js/common/api/flowcrypt-website.js'; -import { AccountServer } from '../../../../js/common/api/account-server.js'; import { FcUuidAuth } from '../../../../js/common/api/account-servers/flowcrypt-com-api.js'; import { SmimeKey } from '../../../../js/common/core/crypto/smime/smime-key.js'; @@ -45,7 +44,7 @@ export class EncryptedMsgMailFormatter extends BaseMailFormatter { const msgBodyWithReplyToken = await this.getPwdMsgSendableBodyWithOnlineReplyMsgToken(authInfo, newMsg); const pgpMimeWithAtts = await Mime.encode(msgBodyWithReplyToken, { Subject: newMsg.subject }, await this.view.attsModule.attach.collectAtts()); const { data: pwdEncryptedWithAtts } = await this.encryptDataArmor(Buf.fromUtfStr(pgpMimeWithAtts), newMsg.pwd, []); // encrypted only for pwd, not signed - const { short, admin_code } = await AccountServer.messageUpload( + const { short, admin_code } = await this.view.acctServer.messageUpload( authInfo.uuid ? authInfo : undefined, pwdEncryptedWithAtts, (p) => this.view.sendBtnModule.renderUploadProgress(p, 'FIRST-HALF'), // still need to upload to Gmail later, this request represents first half of progress @@ -110,7 +109,7 @@ export class EncryptedMsgMailFormatter extends BaseMailFormatter { } const recipients = Array.prototype.concat.apply([], Object.values(newMsgData.recipients)); try { - const response = await AccountServer.messageToken(authInfo); + const response = await this.view.acctServer.messageToken(authInfo); const infoDiv = Ui.e('div', { 'style': 'display: none;', 'class': 'cryptup_reply', diff --git a/extension/chrome/elements/compose.ts b/extension/chrome/elements/compose.ts index 339391c7ceb..b7accda2a85 100644 --- a/extension/chrome/elements/compose.ts +++ b/extension/chrome/elements/compose.ts @@ -30,6 +30,7 @@ import { Catch } from '../../js/common/platform/catch.js'; import { OrgRules } from '../../js/common/org-rules.js'; import { PubLookup } from '../../js/common/api/pub-lookup.js'; import { Scopes, AcctStore } from '../../js/common/platform/store/acct-store.js'; +import { AccountServer } from '../../js/common/api/account-server.js'; export class ComposeView extends View { @@ -54,6 +55,7 @@ export class ComposeView extends View { public emailProvider: EmailProviderInterface; public orgRules!: OrgRules; public pubLookup!: PubLookup; + public acctServer: AccountServer; public quoteModule!: ComposeQuoteModule; public sendBtnModule!: ComposeSendBtnModule; @@ -136,6 +138,7 @@ export class ComposeView extends View { this.replyMsgId = Assert.urlParamRequire.optionalString(uncheckedUrlParams, 'replyMsgId') || ''; this.isReplyBox = !!this.replyMsgId; this.emailProvider = new Gmail(this.acctEmail); + this.acctServer = new AccountServer(this.acctEmail); opgp.initWorker({ path: '/lib/openpgp.worker.js' }); } diff --git a/extension/chrome/elements/subscribe.ts b/extension/chrome/elements/subscribe.ts index 1f2f9e63314..963ecddd203 100644 --- a/extension/chrome/elements/subscribe.ts +++ b/extension/chrome/elements/subscribe.ts @@ -29,6 +29,7 @@ View.run(class SubscribeView extends View { private readonly placement: string | undefined; private authInfo: FcUuidAuth | undefined; private tabId: string | undefined; + private acctServer: AccountServer; private readonly PRODUCTS: { [productName in ProductName]: Product } = { null: { id: null, method: null, name: null, level: null }, // tslint:disable-line:no-null-keyword @@ -42,6 +43,7 @@ View.run(class SubscribeView extends View { this.acctEmail = Assert.urlParamRequire.string(uncheckedUrlParams, 'acctEmail'); this.parentTabId = Assert.urlParamRequire.string(uncheckedUrlParams, 'parentTabId'); this.placement = Assert.urlParamRequire.oneof(uncheckedUrlParams, 'placement', ['settings', 'settings_compose', 'default', 'dialog', 'gmail', 'compose', undefined]); + this.acctServer = new AccountServer(this.acctEmail); } public render = async () => { @@ -74,7 +76,7 @@ View.run(class SubscribeView extends View { private renderSubscriptionDetails = async () => { this.authInfo = await AcctStore.authInfo(this.acctEmail); try { - await AccountServer.accountGetAndUpdateLocalStore(this.authInfo); + await this.acctServer.accountGetAndUpdateLocalStore(this.authInfo); } catch (e) { if (ApiErr.isAuthErr(e)) { Xss.sanitizeRender('#content', `Not logged in. ${Ui.retryLink()}`); diff --git a/extension/chrome/settings/index.htm b/extension/chrome/settings/index.htm index c0326659fed..fb173951488 100644 --- a/extension/chrome/settings/index.htm +++ b/extension/chrome/settings/index.htm @@ -268,7 +268,7 @@

FlowCrypt Settings

g:? fc:? s:? - local_store + local_store diff --git a/extension/chrome/settings/index.ts b/extension/chrome/settings/index.ts index 7f2889ea345..a41bd5dc1ad 100644 --- a/extension/chrome/settings/index.ts +++ b/extension/chrome/settings/index.ts @@ -41,6 +41,7 @@ View.run(class SettingsView extends View { private tabId!: string; private notifications!: Notifications; private orgRules: OrgRules | undefined; + private acctServer: AccountServer | undefined; private altAccounts: JQuery; @@ -56,6 +57,7 @@ View.run(class SettingsView extends View { if (this.acctEmail) { this.acctEmail = this.acctEmail.toLowerCase().trim(); this.gmail = new Gmail(this.acctEmail); + this.acctServer = new AccountServer(this.acctEmail); } this.altAccounts = $('#alt-accounts'); } @@ -310,7 +312,7 @@ View.run(class SettingsView extends View { const authInfo = await AcctStore.authInfo(this.acctEmail!); if (authInfo.uuid) { // have auth email set try { - const response = await AccountServer.accountGetAndUpdateLocalStore(authInfo); + const response = await this.acctServer!.accountGetAndUpdateLocalStore(authInfo); $('#status-row #status_flowcrypt').text(`fc:ok`); if (response?.account?.alias) { statusContainer.find('.status-indicator-text').css('display', 'none'); diff --git a/extension/chrome/settings/modules/debug_api.ts b/extension/chrome/settings/modules/debug_api.ts index 23e8307f580..2f40a151dd7 100644 --- a/extension/chrome/settings/modules/debug_api.ts +++ b/extension/chrome/settings/modules/debug_api.ts @@ -41,7 +41,7 @@ View.run(class DebugApiView extends View { 'notification_setup_needed_dismissed', 'email_provider', 'google_token_scopes', 'hide_message_password', 'sendAs', 'outgoing_language', 'full_name', 'cryptup_enabled', 'setup_done', 'successfully_received_at_leat_one_message', 'notification_setup_done_seen', 'openid', - 'rules', 'subscription', 'use_rich_text', + 'rules', 'subscription', 'use_rich_text', 'fesUrl' ]); this.renderCallRes('Local account storage', { acctEmail: this.acctEmail }, storage); } else { @@ -54,7 +54,7 @@ View.run(class DebugApiView extends View { } private renderCallRes = (api: string, variables: Dict, result: any, error?: any) => { - const r = `${api} ${JSON.stringify(variables)}
${JSON.stringify(result, undefined, 2)} (${error ? JSON.stringify(error) : 'no err'})
`; + const r = `${api} ${JSON.stringify(variables)}
${JSON.stringify(result, undefined, 2)} (${error ? JSON.stringify(error) : 'no err'})
`; Xss.sanitizeAppend('#content', r); } diff --git a/extension/chrome/settings/modules/security.ts b/extension/chrome/settings/modules/security.ts index 652db537225..f0c4396f911 100644 --- a/extension/chrome/settings/modules/security.ts +++ b/extension/chrome/settings/modules/security.ts @@ -27,12 +27,14 @@ View.run(class SecurityView extends View { private primaryKi: KeyInfo | undefined; private authInfo: FcUuidAuth | undefined; private orgRules!: OrgRules; + private acctServer: AccountServer; constructor() { super(); const uncheckedUrlParams = Url.parse(['acctEmail', 'parentTabId']); this.acctEmail = Assert.urlParamRequire.string(uncheckedUrlParams, 'acctEmail'); this.parentTabId = Assert.urlParamRequire.string(uncheckedUrlParams, 'parentTabId'); + this.acctServer = new AccountServer(this.acctEmail); } public render = async () => { @@ -89,7 +91,7 @@ View.run(class SecurityView extends View { if (subscription.active) { Xss.sanitizeRender('.select_loader_container', Ui.spinner('green')); try { - const response = await AccountServer.accountGetAndUpdateLocalStore(this.authInfo!); + const response = await this.acctServer.accountGetAndUpdateLocalStore(this.authInfo!); $('.select_loader_container').text(''); $('.default_message_expire').val(Number(response.account.default_message_expire).toString()).prop('disabled', false).css('display', 'inline-block'); $('.default_message_expire').change(this.setHandler(() => this.onDefaultExpireUserChange())); @@ -113,7 +115,7 @@ View.run(class SecurityView extends View { private onDefaultExpireUserChange = async () => { Xss.sanitizeRender('.select_loader_container', Ui.spinner('green')); $('.default_message_expire').css('display', 'none'); - await AccountServer.accountUpdate(this.authInfo!, { default_message_expire: Number($('.default_message_expire').val()) }); + await this.acctServer.accountUpdate(this.authInfo!, { default_message_expire: Number($('.default_message_expire').val()) }); window.location.reload(); } diff --git a/extension/js/common/api/account-server.ts b/extension/js/common/api/account-server.ts index fb6ced15d8d..636bc478ba4 100644 --- a/extension/js/common/api/account-server.ts +++ b/extension/js/common/api/account-server.ts @@ -2,43 +2,67 @@ 'use strict'; +import { EnterpriseServer } from './account-servers/enterprise-server.js'; import { BackendRes, FcUuidAuth, FlowCryptComApi, ProfileUpdate } from './account-servers/flowcrypt-com-api.js'; +import { WellKnownHostMeta } from './account-servers/well-known-host-meta.js'; import { Api, ProgressCb } from './shared/api.js'; - /** * This may be calling to FlowCryptComApi or Enterprise Server (FES, customer on-prem) depending on * domain configuration fetched using WellKnownHostMeta. - * - * Current implementation only calls FlowCryptComApi, FES integration is planned */ export class AccountServer extends Api { - public static loginWithOpenid = async (acctEmail: string, uuid: string, idToken: string): Promise => { - return await FlowCryptComApi.loginWithOpenid(acctEmail, uuid, idToken); + private wellKnownHostMeta: WellKnownHostMeta; + + constructor(private acctEmail: string) { + super(); + this.wellKnownHostMeta = new WellKnownHostMeta(acctEmail); + } + + public loginWithOpenid = async (acctEmail: string, uuid: string, idToken: string): Promise => { + const fesUrl = await this.wellKnownHostMeta.getFesUrlFromCache(); + if (fesUrl) { + const fes = new EnterpriseServer(fesUrl, this.acctEmail); + await fes.getAccessTokenAndUpdateLocalStore(idToken); + } else { + await FlowCryptComApi.loginWithOpenid(acctEmail, uuid, idToken); + } } - public static accountUpdate = async (fcAuth: FcUuidAuth, profileUpdate: ProfileUpdate): Promise => { - return await FlowCryptComApi.accountUpdate(fcAuth, profileUpdate); + public accountGetAndUpdateLocalStore = async (fcAuth: FcUuidAuth): Promise => { + const fesUrl = await this.wellKnownHostMeta.getFesUrlFromCache(); + if (fesUrl) { + const fes = new EnterpriseServer(fesUrl, this.acctEmail); + return await fes.getAccountAndUpdateLocalStore(); + } else { + return await FlowCryptComApi.accountGetAndUpdateLocalStore(fcAuth); + } } - public static accountGetAndUpdateLocalStore = async (fcAuth: FcUuidAuth): Promise => { - return await FlowCryptComApi.accountGetAndUpdateLocalStore(fcAuth); + public accountUpdate = async (fcAuth: FcUuidAuth, profileUpdate: ProfileUpdate): Promise => { + const fesUrl = await this.wellKnownHostMeta.getFesUrlFromCache(); + if (fesUrl) { + const fes = new EnterpriseServer(fesUrl, this.acctEmail); + await fes.accountUpdate(profileUpdate); + } else { + await FlowCryptComApi.accountUpdate(fcAuth, profileUpdate); + } } - public static messageUpload = async (fcAuth: FcUuidAuth | undefined, encryptedDataBinary: Uint8Array, progressCb: ProgressCb): Promise => { + public messageUpload = async (fcAuth: FcUuidAuth | undefined, encryptedDataBinary: Uint8Array, progressCb: ProgressCb): Promise => { return await FlowCryptComApi.messageUpload(fcAuth, encryptedDataBinary, progressCb); } - public static messageToken = async (fcAuth: FcUuidAuth): Promise => { + public messageToken = async (fcAuth: FcUuidAuth): Promise => { return await FlowCryptComApi.messageToken(fcAuth); } - public static messageExpiration = async (fcAuth: FcUuidAuth, adminCodes: string[], addDays?: number): Promise => { + public messageExpiration = async (fcAuth: FcUuidAuth, adminCodes: string[], addDays?: number): Promise => { return await FlowCryptComApi.messageExpiration(fcAuth, adminCodes, addDays); } - public static linkMessage = async (short: string): Promise => { + public linkMessage = async (short: string): Promise => { return await FlowCryptComApi.linkMessage(short); } diff --git a/extension/js/common/api/account-servers/enterprise-server.ts b/extension/js/common/api/account-servers/enterprise-server.ts index 25f4bffa455..08db9053a4f 100644 --- a/extension/js/common/api/account-servers/enterprise-server.ts +++ b/extension/js/common/api/account-servers/enterprise-server.ts @@ -14,8 +14,9 @@ import { ErrorReport, UnreportableError } from '../../platform/catch.js'; // todo - decide which tags to use type EventTag = 'compose' | 'decrypt' | 'setup' | 'settings' | 'import-pub' | 'import-prv'; -namespace FesRes { +export namespace FesRes { export type AccessToken = { accessToken: string }; + export type ServiceInfo = { vendor: string, service: string, orgId: string, version: string, apiVersion: string } } /** @@ -36,6 +37,10 @@ export class EnterpriseServer extends Api { this.fesUrl = fesUrl.replace(/\/$/, ''); } + public getServiceInfo = async (): Promise => { + return await this.request('GET', `/api/`); + } + public getAccessTokenAndUpdateLocalStore = async (idToken: string): Promise => { const response = await this.request('GET', `/api/${this.apiVersion}/account/access-token`, { Authorization: `Bearer ${idToken}` }); await AcctStore.set(this.acctEmail, { fesAccessToken: response.accessToken }); @@ -66,7 +71,7 @@ export class EnterpriseServer extends Api { } private request = async (method: ReqMethod, path: string, headers: Dict = {}, vals?: Dict): Promise => { - return await FlowCryptComApi.apiCall(this.fesUrl, path, vals, 'JSON', undefined, headers, 'json', method); + return await FlowCryptComApi.apiCall(this.fesUrl, path, vals, method === 'GET' ? undefined : 'JSON', undefined, headers, 'json', method); } } diff --git a/extension/js/common/api/account-servers/well-known-host-meta.ts b/extension/js/common/api/account-servers/well-known-host-meta.ts index f3e913e2c88..6ee6f5a5d4d 100644 --- a/extension/js/common/api/account-servers/well-known-host-meta.ts +++ b/extension/js/common/api/account-servers/well-known-host-meta.ts @@ -9,6 +9,7 @@ import { Catch } from '../../platform/catch.js'; import { AcctStore } from '../../platform/store/acct-store.js'; import { Api } from '../shared/api.js'; import { ApiErr } from '../shared/api-error.js'; +import { FesRes, EnterpriseServer } from './enterprise-server.js'; type HostMetaResponse = { links?: { rel?: string, href?: string }[] } @@ -17,6 +18,7 @@ export class WellKnownHostMeta extends Api { private domain: string; private hostMetaUrl: string; private fesRel = 'https://flowcrypt.com/fes'; + private laxCheck = ['dmFsZW8uY29t']; constructor(private acctEmail: string) { super(); @@ -32,14 +34,20 @@ export class WellKnownHostMeta extends Api { return undefined; } const responseBuf = await this.attemptToFetchFesUrlIgnoringErrorsOnConsumerFlavor(); - if (!responseBuf) { - await this.setFesUrlToCache(undefined); - return undefined; + if (responseBuf) { + const hostMetaResponse = this.parseBufAsHostMetaResponseIgnoringErrorsOnConsumerFlavor(responseBuf); + const fesUrl = hostMetaResponse?.links?.find(link => link.rel === this.fesRel)?.href; + await this.setFesUrlToCache(fesUrl); + return fesUrl; } - const hostMetaResponse = this.parseBufAsHostMetaResponseIgnoringErrorsOnConsumerFlavor(responseBuf); - const fesUrl = hostMetaResponse?.links?.find(link => link.rel === this.fesRel)?.href; - await this.setFesUrlToCache(fesUrl); - return fesUrl; + const standardFesUrl = `https://fes.${this.domain}`; + const fesServiceInfo = await this.tryCallingFesDirectlyIgnoringErrorsOnConsumerFlavor(standardFesUrl); + if (fesServiceInfo && fesServiceInfo.service === 'enterprise-server') { + await this.setFesUrlToCache(standardFesUrl); + return standardFesUrl; + } + await this.setFesUrlToCache(undefined); + return undefined; } public getFesUrlFromCache = async (): Promise => { @@ -47,6 +55,21 @@ export class WellKnownHostMeta extends Api { return fesUrl; } + private tryCallingFesDirectlyIgnoringErrorsOnConsumerFlavor = async (fesUrl: string): Promise => { + const fes = new EnterpriseServer(fesUrl, this.acctEmail); + try { + return await fes.getServiceInfo(); + } catch (e) { + if (FLAVOR === 'consumer' || ApiErr.isNotFound(e)) { + return; + } else if (this.laxCheck.includes(btoa(this.domain)) && ApiErr.isNetErr(e)) { + return undefined; // cannot reach server for enterprises where we don't expect to find it + } else { + throw e; // strict enterprise + } + } + } + private setFesUrlToCache = async (fesUrl: string | undefined): Promise => { if (fesUrl) { await AcctStore.set(this.acctEmail, { fesUrl }); diff --git a/extension/js/common/api/email-provider/gmail/google-auth.ts b/extension/js/common/api/email-provider/gmail/google-auth.ts index 875612f775c..d638b10589f 100644 --- a/extension/js/common/api/email-provider/gmail/google-auth.ts +++ b/extension/js/common/api/email-provider/gmail/google-auth.ts @@ -144,23 +144,37 @@ export class GoogleAuth { return { result: 'Error', error: 'Grant was successful but missing acctEmail', acctEmail: authRes.acctEmail, id_token: undefined }; } try { - const uuid = Api.randomFortyHexChars(); - await AccountServer.loginWithOpenid(authRes.acctEmail, uuid, authRes.id_token); - await AccountServer.accountGetAndUpdateLocalStore({ account: authRes.acctEmail, uuid }); // will store org rules and subscription - try { - // this is here currently for debugging only, to test effect of this new mechanism on customer installations - const wellKnownHostMeta = new WellKnownHostMeta(authRes.acctEmail); - await wellKnownHostMeta.fetchAndCacheFesUrl(); - } catch (e) { - Catch.reportErr(Catch.rewrapErr(e, `WellKnownHostMeta on ${FLAVOR}`)); - } + const uuid = Api.randomFortyHexChars(); // for flowcrypt.com, if used. When FES is used, the access token is given to client. + await new WellKnownHostMeta(authRes.acctEmail).fetchAndCacheFesUrl(); // stores fesUrl if any + const acctServer = new AccountServer(authRes.acctEmail); + await acctServer.loginWithOpenid(authRes.acctEmail, uuid, authRes.id_token); // may be calling flowcrypt.com or FES + await acctServer.accountGetAndUpdateLocalStore({ account: authRes.acctEmail, uuid }); // stores OrgRules and subscription } catch (e) { + if (GoogleAuth.isFesUnreachableErr(e, authRes.acctEmail)) { + const error = `Cannot reach your company's FlowCrypt Enterprise Server (FES). Contact human@flowcrypt.com when unsure. (${String(e)})`; + return { result: 'Error', error, acctEmail: authRes.acctEmail, id_token: undefined }; + } return { result: 'Error', error: `Grant successful but error accessing fc account: ${String(e)}`, acctEmail: authRes.acctEmail, id_token: undefined }; } } return authRes; } + /** + * Happens on enterprise builds + */ + public static isFesUnreachableErr = (e: any, email: string): boolean => { + const domain = email.split('@')[1].toLowerCase(); + const errString = String(e); + if (errString.includes(`-1 when GET-ing https://${domain}/.well-known/host-meta.json`)) { + return true; // err trying to get FES url from .well-known + } + if (errString.includes(`-1 when GET-ing https://fes.${domain}/api/ `)) { // the space is important to match the full url + return true; // err trying to reach FES itself at a predictable URL + } + return false; + } + public static newOpenidAuthPopup = async ({ acctEmail }: { acctEmail?: string }): Promise => { return await GoogleAuth.newAuthPopup({ acctEmail, scopes: GoogleAuth.defaultScopes('openid'), save: false }); } diff --git a/extension/js/common/api/key-server/wkd.ts b/extension/js/common/api/key-server/wkd.ts index 143cfaaeb3a..35119a8e7a5 100644 --- a/extension/js/common/api/key-server/wkd.ts +++ b/extension/js/common/api/key-server/wkd.ts @@ -6,7 +6,6 @@ import { Api } from './../shared/api.js'; import { ApiErr } from '../shared/api-error.js'; import { opgp } from '../../core/crypto/pgp/openpgpjs-custom.js'; import { Buf } from '../../core/buf.js'; -import { Catch } from '../../platform/catch.js'; import { PubkeySearchResult } from './../pub-lookup.js'; import { KeyUtil } from '../../core/crypto/key.js'; @@ -87,7 +86,7 @@ export class Wkd extends Api { return { hasPolicy: true, buf }; } catch (e) { if (!ApiErr.isNotFound(e)) { - Catch.report(`Wkd.lookupEmail error retrieving key: ${String(e)}`); + console.info(`Wkd.lookupEmail error retrieving key: ${String(e)}`); } return { hasPolicy: true }; } diff --git a/extension/js/common/core/common.ts b/extension/js/common/core/common.ts index 905c390df66..5bc7cca00eb 100644 --- a/extension/js/common/core/common.ts +++ b/extension/js/common/core/common.ts @@ -70,6 +70,9 @@ export class Str { if (email.indexOf(' ') !== -1) { return false; } + // for MOCK tests, we need emails like me@domain.com:8001 to pass + // this then makes the extension call fes.domain.com:8001 which is where the appropriate mock runs + email = email.replace(/\:8001$/, ''); return /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/i.test(email); } diff --git a/extension/js/common/org-rules.ts b/extension/js/common/org-rules.ts index 02ead7ad103..526e5a682f7 100644 --- a/extension/js/common/org-rules.ts +++ b/extension/js/common/org-rules.ts @@ -11,7 +11,7 @@ type DomainRules$flag = 'NO_PRV_CREATE' | 'NO_PRV_BACKUP' | 'PRV_AUTOIMPORT_OR_A 'DEFAULT_REMEMBER_PASS_PHRASE'; export type DomainRulesJson = { - flags: DomainRules$flag[], + flags?: DomainRules$flag[], custom_keyserver_url?: string, key_manager_url?: string, disallow_attester_search_for_domains?: string[], @@ -37,6 +37,11 @@ export class OrgRules { } public static isPublicEmailProviderDomain = (emailAddrOrDomain: string) => { + if (emailAddrOrDomain.endsWith('.flowcrypt.com') || emailAddrOrDomain.endsWith('flowcrypt.dev')) { + // this is here for easier testing. helps our mock tests which run on flowcrypt.com subdomains + // marking it this way prevents calling FES which is not there, on enterprise builds where FES is required + return true; + } return ['gmail.com', 'yahoo.com', 'outlook.com', 'live.com'].includes(emailAddrOrDomain.split('@').pop() || 'NONE'); } @@ -67,7 +72,7 @@ export class OrgRules { * use this method when using for PUB sync */ public getKeyManagerUrlForPublicKeys = (): string | undefined => { - if (this.domainRules.flags.includes('NO_KEY_MANAGER_PUB_LOOKUP')) { + if ((this.domainRules.flags || []).includes('NO_KEY_MANAGER_PUB_LOOKUP')) { return undefined; } return this.domainRules.key_manager_url; @@ -103,14 +108,14 @@ export class OrgRules { * Some orgs expect 100% of their private keys to be imported from elsewhere (and forbid keygen in the extension) */ public canCreateKeys = (): boolean => { - return !this.domainRules.flags.includes('NO_PRV_CREATE'); + return !(this.domainRules.flags || []).includes('NO_PRV_CREATE'); } /** * Some orgs want to forbid backing up of public keys (such as inbox or other methods) */ public canBackupKeys = (): boolean => { - return !this.domainRules.flags.includes('NO_PRV_BACKUP'); + return !(this.domainRules.flags || []).includes('NO_PRV_BACKUP'); } /** @@ -119,7 +124,7 @@ export class OrgRules { * Some orgs want to make sure that their public key gets submitted to attester and conflict errors are NOT ignored: */ public mustSubmitToAttester = (): boolean => { - return this.domainRules.flags.includes('ENFORCE_ATTESTER_SUBMIT'); + return (this.domainRules.flags || []).includes('ENFORCE_ATTESTER_SUBMIT'); } /** @@ -128,7 +133,7 @@ export class OrgRules { * This behavior is also enabled as a byproduct of PASS_PHRASE_QUIET_AUTOGEN */ public rememberPassPhraseByDefault = (): boolean => { - return this.domainRules.flags.includes('DEFAULT_REMEMBER_PASS_PHRASE') || this.mustAutogenPassPhraseQuietly(); + return (this.domainRules.flags || []).includes('DEFAULT_REMEMBER_PASS_PHRASE') || this.mustAutogenPassPhraseQuietly(); } /** @@ -137,7 +142,7 @@ export class OrgRules { * If not, it will be autogenerated and stored there */ public mustAutoImportOrAutogenPrvWithKeyManager = (): boolean => { - if (!this.domainRules.flags.includes('PRV_AUTOIMPORT_OR_AUTOGEN')) { + if (!(this.domainRules.flags || []).includes('PRV_AUTOIMPORT_OR_AUTOGEN')) { return false; } if (!this.getKeyManagerUrlForPrivateKeys()) { @@ -153,14 +158,14 @@ export class OrgRules { * This creates the smoothest user experience, for organisations that use full-disk-encryption and don't need pass phrase protection */ public mustAutogenPassPhraseQuietly = (): boolean => { - return this.domainRules.flags.includes('PASS_PHRASE_QUIET_AUTOGEN'); + return (this.domainRules.flags || []).includes('PASS_PHRASE_QUIET_AUTOGEN'); } /** * Some orgs prefer to forbid publishing public keys publicly */ public canSubmitPubToAttester = (): boolean => { - return !this.domainRules.flags.includes('NO_ATTESTER_SUBMIT'); + return !(this.domainRules.flags || []).includes('NO_ATTESTER_SUBMIT'); } /** @@ -177,7 +182,7 @@ export class OrgRules { * Until the newer endpoint is ready, this flag will point users in those orgs to the original endpoint */ public useLegacyAttesterSubmit = (): boolean => { - return this.domainRules.flags.includes('USE_LEGACY_ATTESTER_SUBMIT'); + return (this.domainRules.flags || []).includes('USE_LEGACY_ATTESTER_SUBMIT'); } } diff --git a/test/source/mock.ts b/test/source/mock.ts index 8250e047a21..433f92ac4c5 100644 --- a/test/source/mock.ts +++ b/test/source/mock.ts @@ -8,6 +8,7 @@ import { opgp } from './core/crypto/pgp/openpgpjs-custom'; import { startAllApisMock } from './mock/all-apis-mock'; export const acctsWithoutMockData = [ + // todo - throw this list away and check for Config.secrets().auth.google instead 'flowcrypt.test.key.multibackup@gmail.com', 'has.pub@org-rules-test.flowcrypt.com', 'no.pub@org-rules-test.flowcrypt.com', @@ -24,6 +25,9 @@ export const acctsWithoutMockData = [ 'expire@key-manager-keygen-expiration.flowcrypt.com', 'setup@prv-create-no-prv-backup.flowcrypt.com', 'ci.tests.gmail@flowcrypt.dev', + 'user@standardsubdomainfes.com:8001', + 'user@wellknownfes.com:8001', + 'no.fes@example.com', ]; export const mock = async (logger: (line: string) => void) => { diff --git a/test/source/mock/all-apis-mock.ts b/test/source/mock/all-apis-mock.ts index 233a829e7d6..58278454000 100644 --- a/test/source/mock/all-apis-mock.ts +++ b/test/source/mock/all-apis-mock.ts @@ -11,6 +11,7 @@ import { mockKeyManagerEndpoints } from './key-manager/key-manager-endpoints'; import { mockWellKnownHostMetaEndpoints } from './host-meta/host-meta-endpoints'; import { mockWkdEndpoints } from './wkd/wkd-endpoints'; import { mockSksEndpoints } from './sks/sks-endpoints'; +import { mockFesEndpoints } from './fes/fes-endpoints'; export type HandlersDefinition = Handlers<{ query: { [k: string]: string; }; body?: unknown; }, unknown>; @@ -32,6 +33,7 @@ export const startAllApisMock = async (logger: (line: string) => void) => { ...mockWellKnownHostMetaEndpoints, ...mockWkdEndpoints, ...mockSksEndpoints, + ...mockFesEndpoints, '/favicon.ico': async () => '', }); await api.listen(8001); diff --git a/test/source/mock/fes/fes-endpoints.ts b/test/source/mock/fes/fes-endpoints.ts new file mode 100644 index 00000000000..5769822d76c --- /dev/null +++ b/test/source/mock/fes/fes-endpoints.ts @@ -0,0 +1,103 @@ +/* ©️ 2016 - present FlowCrypt a.s. Limitations apply. Contact human@flowcrypt.com */ + +import { IncomingMessage } from 'http'; +import { HandlersDefinition } from '../all-apis-mock'; +import { HttpClientErr } from '../lib/api'; +import { MockJwt } from '../lib/oauth'; + +const standardFesUrl = 'fes.standardsubdomainfes.com:8001'; +const issuedAccessTokens: string[] = []; + +export const mockFesEndpoints: HandlersDefinition = { + // standard fes location at https://fes.domain.com + '/api/': async ({ }, req) => { + if (req.headers.host === standardFesUrl && req.method === 'GET') { + return { + "vendor": "Mock", + "service": "enterprise-server", + "orgId": "standardsubdomainfes.com", + "version": "MOCK", + "apiVersion": 'v1', + }; + } + if (req.headers.host === 'fes.localhost:8001') { // test `status404 does not return any fesUrl` uses this + throw new HttpClientErr(`Not found`, 404); // this makes enterprise version tolerate missing FES - explicit 404 + } + console.log('host', req.headers.host); + throw new HttpClientErr(`Not running any FES here: ${req.headers.host}`, 400); + }, + '/api/v1/account/access-token': async ({ }, req) => { + if (req.headers.host === standardFesUrl && req.method === 'GET') { + const email = authenticate(req, 'oidc'); // 3rd party token + const fesToken = MockJwt.new(email); // fes-issued token + issuedAccessTokens.push(fesToken); + return { 'accessToken': fesToken }; + } + throw new HttpClientErr('Not Found', 404); + }, + '/api/v1/account/': async ({ }, req) => { + if (req.headers.host === standardFesUrl && req.method === 'GET') { + authenticate(req, 'fes'); + return { + account: { + default_message_expire: 30 + }, + subscription: { level: 'pro', expire: null, method: 'group', expired: 'false' }, // tslint:disable-line:no-null-keyword + domain_org_rules: { disallow_attester_search_for_domains: ['got.this@fromstandardfes.com'] }, + }; + } + throw new HttpClientErr('Not Found', 404); + }, + // fes url defined using .well-known, see mockWellKnownHostMetaEndpoints + '/custom-fes-based-on-well-known/api/': async ({ }, req) => { + if (req.method === 'GET') { + return { + "vendor": "Mock", + "service": "enterprise-server", + "orgId": "wellknownfes.com", + "version": "MOCK", + "apiVersion": 'v1', + }; + } + throw new HttpClientErr('Not Found', 404); + }, + '/custom-fes-based-on-well-known/api/v1/account/access-token': async ({ }, req) => { + if (req.method === 'GET') { + const email = authenticate(req, 'oidc'); // 3rd party token + const fesToken = MockJwt.new(email); // fes-issued token + issuedAccessTokens.push(fesToken); + return { 'accessToken': fesToken }; + } + throw new HttpClientErr('Not Found', 404); + }, + '/custom-fes-based-on-well-known/api/v1/account/': async ({ }, req) => { + if (req.method === 'GET') { + authenticate(req, 'fes'); + return { + account: { + default_message_expire: 30 + }, + subscription: { level: 'pro', expire: null, method: 'group', expired: 'false' }, // tslint:disable-line:no-null-keyword + domain_org_rules: { disallow_attester_search_for_domains: ['got.this@fromwellknownfes.com'] }, + }; + } + throw new HttpClientErr('Not Found', 404); + }, +}; + +const authenticate = (req: IncomingMessage, type: 'oidc' | 'fes'): string => { + const jwt = (req.headers.authorization || '').replace('Bearer ', ''); + if (!jwt) { + throw new Error('Mock FES missing authorization header'); + } + if (type === 'oidc') { + if (issuedAccessTokens.includes(jwt)) { + throw new Error('Mock FES access-token call wrongly with FES token'); + } + } else { // fes + if (!issuedAccessTokens.includes(jwt)) { + throw new HttpClientErr('FES mock received access token it didnt issue', 401); + } + } + return MockJwt.parseEmail(jwt); +}; diff --git a/test/source/mock/host-meta/host-meta-endpoints.ts b/test/source/mock/host-meta/host-meta-endpoints.ts index 656539256d2..d701e9e686a 100644 --- a/test/source/mock/host-meta/host-meta-endpoints.ts +++ b/test/source/mock/host-meta/host-meta-endpoints.ts @@ -4,6 +4,14 @@ import { HttpClientErr } from '../lib/api'; import { HandlersDefinition } from '../all-apis-mock'; export const mockWellKnownHostMetaEndpoints: HandlersDefinition = { + // below for ui tests + '/.well-known/host-meta.json': async ({ }, req) => { + if (req.headers.host === 'wellknownfes.com:8001') { + return { links: [{ rel: 'https://flowcrypt.com/fes', href: 'https://localhost:8001/custom-fes-based-on-well-known/' }] }; + } + throw new HttpClientErr(`Host meta for ${req.headers.host} not set up`, 404); + }, + // below for unit tests '/.well-known/host-meta.json?local=status500': async () => { throw new Error(`Intentional error host meta 500 - ignored on consumer but noticed by enterprise`); }, diff --git a/test/source/mock/lib/oauth.ts b/test/source/mock/lib/oauth.ts index 01964731563..af090a41b1c 100644 --- a/test/source/mock/lib/oauth.ts +++ b/test/source/mock/lib/oauth.ts @@ -106,6 +106,16 @@ export class OauthMock { // -- private + private generateIdToken = (email: string): string => { + const newIdToken = MockJwt.new(email, this.expiresIn); + if (!this.issuedIdTokensByAcct[email]) { + this.issuedIdTokensByAcct[email] = []; + } + this.issuedIdTokensByAcct[email].push(newIdToken); + this.acctByIdToken[newIdToken] = email; + return newIdToken; + } + private getAccessToken(refreshToken: string): string { if (this.accessTokenByRefreshToken[refreshToken]) { return this.accessTokenByRefreshToken[refreshToken]; @@ -123,12 +133,18 @@ export class OauthMock { } } - private generateIdToken = (email: string): string => { +} + +export class MockJwt { + + public static new = (email: string, expiresIn = 1 * 60 * 60): string => { const data = { at_hash: 'at_hash', - exp: this.expiresIn, - iat: 123, sub: 'sub', - aud: 'aud', azp: 'azp', + exp: expiresIn, + iat: 123, + sub: 'sub', + aud: 'aud', + azp: 'azp', iss: "https://localhost:8001", name: 'First Last', picture: 'picture', @@ -139,14 +155,17 @@ export class OauthMock { email_verified: true, }; const newIdToken = `fakeheader.${Buf.fromUtfStr(JSON.stringify(data)).toBase64UrlStr()}.${Str.sloppyRandom(30)}`; - if (!this.issuedIdTokensByAcct[email]) { - this.issuedIdTokensByAcct[email] = []; - } - this.issuedIdTokensByAcct[email].push(newIdToken); - this.acctByIdToken[newIdToken] = email; return newIdToken; } + public static parseEmail = (jwt: string): string => { + const email = JSON.parse(Buf.fromBase64Str(jwt.split('.')[1]).toUtfStr()).email; + if (!email) { + throw new Error(`Missing email in MockJwt ${jwt}`); + } + return email; + } + } export const oauth = new OauthMock(); diff --git a/test/source/test.ts b/test/source/test.ts index 8bfc0893998..08438ff00f7 100644 --- a/test/source/test.ts +++ b/test/source/test.ts @@ -21,7 +21,7 @@ import { defineUnitBrowserTests } from './tests/unit-browser'; import { mock } from './mock'; import { mockBackendData } from './mock/backend/backend-endpoints'; -const { testVariant, testGroup, oneIfNotPooled, buildDir, isMock } = getParsedCliParams(); +export const { testVariant, testGroup, oneIfNotPooled, buildDir, isMock } = getParsedCliParams(); export const internalTestState = { expectiIntentionalErrReport: false }; // updated when a particular test that causes an error is run process.setMaxListeners(60); @@ -96,7 +96,7 @@ ava.after.always('evaluate Catch.reportErr errors', async t => { const usefulErrors = mockBackendData.reportedErrors .filter(e => e.message !== 'Too few bytes to read ASN.1 value.') // on enterprise, these report errs - .filter(e => !(testVariant === 'ENTERPRISE-MOCK' && e.message.includes('.well-known/host-meta.json'))) + .filter(e => !(testVariant === 'ENTERPRISE-MOCK' && e.trace.includes('.well-known/host-meta.json'))) // todo - ideally mock tests would never call this. But we do tests with human@flowcrypt.com so it's calling here .filter(e => !e.trace.includes('-1 when GET-ing https://openpgpkey.flowcrypt.com')); // end of todo diff --git a/test/source/tests/compose.ts b/test/source/tests/compose.ts index 84a8be9b931..4d69ff07911 100644 --- a/test/source/tests/compose.ts +++ b/test/source/tests/compose.ts @@ -5,7 +5,7 @@ import { Page } from 'puppeteer'; import { BrowserHandle, Controllable, ControllablePage, ControllableFrame } from './../browser'; import { Config, Util } from './../util'; - +import { writeFile } from 'fs'; import { AvaContext } from './tooling'; import { ComposePageRecipe } from './page-recipe/compose-page-recipe'; import { Dict } from './../core/common'; @@ -698,12 +698,14 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te await sendTextAndVerifyPresentInSentMsg(t, browser, rainbow, { sign: true, encrypt: true }); })); - ava.default('oversize attachment does not get errorneously added', testWithBrowser('ci.tests.gmail', async (t, browser) => { + ava.default.skip('oversize attachment does not get errorneously added', testWithBrowser('ci.tests.gmail', async (t, browser) => { const composePage = await ComposePageRecipe.openStandalone(t, browser, 'compose'); // big file will get canceled const fileInput = await composePage.target.$('input[type=file]'); - await fileInput!.uploadFile('test/samples/large.jpg'); - await composePage.waitAndRespondToModal('confirm', 'cancel', 'The files are over 5 MB'); + const localpath = 'test/samples/oversize.txt'; + await new Promise((resolve, reject) => writeFile(localpath, 'x'.repeat(30 * 1024 * 1024), err => err ? reject(err) : resolve())); + await fileInput!.uploadFile(localpath); // 30mb + await composePage.waitAndRespondToModal('confirm', 'cancel', 'Combined attachment size is limited to 25 MB. The last file brings it to 30 MB.'); await Util.sleep(1); await composePage.notPresent('.qq-upload-file-selector'); // small file will get accepted diff --git a/test/source/tests/setup.ts b/test/source/tests/setup.ts index 0733ecfada4..738a804d7e0 100644 --- a/test/source/tests/setup.ts +++ b/test/source/tests/setup.ts @@ -457,6 +457,50 @@ AN8G3r5Htj8olot+jm9mIa5XLXWzMNUZgg== expect(details).to.contain(''); })); + /** + * You need the following lines in /etc/hosts: + * 127.0.0.1 standardsubdomainfes.com + * 127.0.0.1 fes.standardsubdomainfes.com + */ + ava.default('user@standardsubdomainfes.com:8001 - uses FES on standard domain', testWithBrowser(undefined, async (t, browser) => { + const acct = 'user@standardsubdomainfes.com:8001'; // added port to trick extension into calling the mock + const settingsPage = await BrowserRecipe.openSettingsLoginApprove(t, browser, acct); + await SetupPageRecipe.manualEnter(settingsPage, 'flowcrypt.test.key.used.pgp', { submitPubkey: false, usedPgpBefore: false }); + const debugFrame = await SettingsPageRecipe.awaitNewPageFrame(settingsPage, '@action-show-local-store-contents', ['debug_api.htm']); + await debugFrame.waitForContent('@container-pre', 'fes.standardsubdomainfes.com:8001'); // FES url on standard subdomain + await debugFrame.waitForContent('@container-pre', 'got.this@fromstandardfes.com'); // org rules from FES + })); + + /** + * You need the following line in /etc/hosts: + * 127.0.0.1 wellknownfes.com + */ + ava.default('user@wellknownfes.com:8001 - uses FES based on .well-known', testWithBrowser(undefined, async (t, browser) => { + const acct = 'user@wellknownfes.com:8001'; // added port to trick extension into calling the mock + const settingsPage = await BrowserRecipe.openSettingsLoginApprove(t, browser, acct); + await SetupPageRecipe.manualEnter(settingsPage, 'flowcrypt.test.key.used.pgp', { submitPubkey: false, usedPgpBefore: false }); + const debugFrame = await SettingsPageRecipe.awaitNewPageFrame(settingsPage, '@action-show-local-store-contents', ['debug_api.htm']); + await debugFrame.waitForContent('@container-pre', 'https://localhost:8001/custom-fes-based-on-well-known/'); // FES url grabbed from .well-known + await debugFrame.waitForContent('@container-pre', 'got.this@fromwellknownfes.com'); // org rules from FES + })); + + /** + * enterprise - expects FES to be set up. when it's not, show nice error + * consumer - tolerates the missing FES and and sets up without it + */ + ava.default('no.fes@example.com - skip FES on consumer, show friendly message on enterprise', testWithBrowser(undefined, async (t, browser) => { + const acct = 'no.fes@example.com'; + if (testVariant === 'ENTERPRISE-MOCK') { // shows err on enterprise + const settingsPage = await BrowserRecipe.openSettingsLoginApprove(t, browser, acct); + await settingsPage.waitAndRespondToModal('error', 'confirm', "Cannot reach your company's FlowCrypt Enterprise Server (FES). Contact human@flowcrypt.com when unsure."); + } else if (testVariant === 'CONSUMER-MOCK') { // allows to set up on consumer + const settingsPage = await BrowserRecipe.openSettingsLoginApprove(t, browser, acct); + await SetupPageRecipe.manualEnter(settingsPage, 'flowcrypt.test.key.used.pgp', { submitPubkey: false, usedPgpBefore: false }); + } else { + throw new Error(`Unexpected test variant ${testVariant}`); + } + })); + } }; diff --git a/test/source/util/index.ts b/test/source/util/index.ts index f4cb1b6a44b..d4bf8d22e3a 100644 --- a/test/source/util/index.ts +++ b/test/source/util/index.ts @@ -94,6 +94,9 @@ Config.secrets().auth.google.push( // these don't contain any secrets, so not wo { "email": "user@key-manager-no-pub-lookup.flowcrypt.com" }, { "email": "expire@key-manager-keygen-expiration.flowcrypt.com" }, { "email": "setup@prv-create-no-prv-backup.flowcrypt.com" }, + { "email": "user@standardsubdomainfes.com:8001" }, + { "email": 'user@wellknownfes.com:8001' }, + { 'email': 'no.fes@example.com' } ); export class Util {