Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions extension/chrome/settings/modules/debug_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ View.run(class DebugApiView extends View {
} catch (e) {
this.renderCallRes('gmail.fetchAcctAliases', {}, undefined, e);
}
this.renderCallRes('Store.getAcct.openid', { acctEmail: this.acctEmail }, await AcctStore.get(this.acctEmail, ['openid']));
} else if (this.which === 'flowcrypt_account') {
Xss.sanitizeAppend('#content', `Unsupported which: ${Xss.escape(this.which)} (not implemented)`);
} else if (this.which === 'flowcrypt_subscription') {
Expand All @@ -40,7 +39,7 @@ View.run(class DebugApiView extends View {
const storage = await AcctStore.get(this.acctEmail, [
'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',
'successfully_received_at_leat_one_message', 'notification_setup_done_seen',
'rules', 'use_rich_text', 'fesUrl'
]);
this.renderCallRes('Local account storage', { acctEmail: this.acctEmail }, storage);
Expand Down
3 changes: 1 addition & 2 deletions extension/js/common/api/account-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,7 @@ export class AccountServer extends Api {

public loginWithOpenid = async (acctEmail: string, uuid: string, idToken: string): Promise<void> => {
if (await this.isFesUsed()) {
const fes = new EnterpriseServer(this.acctEmail);
await fes.authenticateAndUpdateLocalStore(idToken);
// FES doesn't issue any access tokens
} else {
await FlowCryptComApi.loginWithOpenid(acctEmail, uuid, idToken);
}
Expand Down
57 changes: 12 additions & 45 deletions extension/js/common/api/account-servers/enterprise-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,18 @@ import { AcctStore } from '../../platform/store/acct-store.js';
import { BackendRes, ProfileUpdate } from './flowcrypt-com-api.js';
import { Dict } from '../../core/common.js';
import { ErrorReport, UnreportableError } from '../../platform/catch.js';
import { ApiErr } from '../shared/api-error.js';
import { ApiErr, BackendAuthErr } from '../shared/api-error.js';
import { FLAVOR } from '../../core/const.js';
import { Attachment } from '../../core/attachment.js';
import { Recipients } from '../email-provider/email-provider-api.js';
import { Buf } from '../../core/buf.js';
import { DomainRulesJson, OrgRules } from '../../org-rules.js';
import { DomainRulesJson } from '../../org-rules.js';
import { InMemoryStore } from '../../platform/store/in-memory-store.js';

// todo - decide which tags to use
type EventTag = 'compose' | 'decrypt' | 'setup' | 'settings' | 'import-pub' | 'import-prv';

export namespace FesRes {
export type AccessToken = { accessToken: string };
export type ReplyToken = { replyToken: string };
export type MessageUpload = { url: string };
export type ServiceInfo = { vendor: string, service: string, orgId: string, version: string, apiVersion: string };
Expand All @@ -43,8 +42,6 @@ export class EnterpriseServer extends Api {
private apiVersion = 'v1';
private domainsThatUseLaxFesCheckEvenOnEnterprise = ['dmFsZW8uY29t'];

private IN_MEMORY_ID_TOKEN_STORAGE_KEY = 'idTokenForOnPremAuth';

constructor(private acctEmail: string) {
super();
this.domain = acctEmail.toLowerCase().split('@').pop()!;
Expand Down Expand Up @@ -87,43 +84,23 @@ export class EnterpriseServer extends Api {
return await this.request<FesRes.ServiceInfo>('GET', `/api/`);
};

public authenticateAndUpdateLocalStore = async (idToken: string): Promise<void> => {
if ((await OrgRules.newInstance(this.acctEmail)).disableFesAccessToken()) {
// the OIDC ID Token itself is used for auth, typically expires in 1 hour
await InMemoryStore.set(this.acctEmail, this.IN_MEMORY_ID_TOKEN_STORAGE_KEY, idToken);
} else {
const r = await this.request<FesRes.AccessToken>('GET', `/api/${this.apiVersion}/account/access-token`, { Authorization: `Bearer ${idToken}` });
await AcctStore.set(this.acctEmail, { fesAccessToken: r.accessToken });
}
};

public fetchAndSaveOrgRules = async (): Promise<DomainRulesJson> => {
const r = await this.request<FesRes.ClientConfiguration>('GET', `/api/${this.apiVersion}/client-configuration?domain=${this.domain}`);
await AcctStore.set(this.acctEmail, { rules: r.clientConfiguration });
return r.clientConfiguration;
};

public reportException = async (errorReport: ErrorReport): Promise<void> => {
if ((await OrgRules.newInstance(this.acctEmail)).disableFesAccessToken()) {
console.info('Reporting exceptions to FES is disabled when DISABLE_FES_ACCESS_TOKEN OrgRule is used');
return;
}
await this.request<void>('POST', `/api/${this.apiVersion}/log-collector/exception`,
await this.authHdr('accessToken'), errorReport);
await this.request<void>('POST', `/api/${this.apiVersion}/log-collector/exception`, await this.authHdr(), errorReport);
};

public reportEvent = async (tags: EventTag[], message: string, details?: string): Promise<void> => {
if ((await OrgRules.newInstance(this.acctEmail)).disableFesAccessToken()) {
console.info('Reporting events to FES is disabled when DISABLE_FES_ACCESS_TOKEN OrgRule is used');
return;
}
await this.request<void>('POST', `/api/${this.apiVersion}/log-collector/exception`,
await this.authHdr('accessToken'), { tags, message, details });
await this.authHdr(), { tags, message, details });
};

public webPortalMessageNewReplyToken = async (): Promise<FesRes.ReplyToken> => {
const disableAccessToken = (await OrgRules.newInstance(this.acctEmail)).disableFesAccessToken();
const authHdr = await this.authHdr(disableAccessToken ? 'OIDC' : 'accessToken');
const authHdr = await this.authHdr();
return await this.request<FesRes.ReplyToken>('POST', `/api/${this.apiVersion}/message/new-reply-token`, authHdr, {});
};

Expand Down Expand Up @@ -151,8 +128,7 @@ export class EnterpriseServer extends Api {
}))
});
const multipartBody = { content, details };
const disableAccessToken = (await OrgRules.newInstance(this.acctEmail)).disableFesAccessToken();
const authHdr = await this.authHdr(disableAccessToken ? 'OIDC' : 'accessToken');
const authHdr = await this.authHdr();
return await EnterpriseServer.apiCall<FesRes.MessageUpload>(
this.url, `/api/${this.apiVersion}/message`, multipartBody, 'FORM',
{ upload: progressCb }, authHdr, 'json', 'POST'
Expand All @@ -164,22 +140,13 @@ export class EnterpriseServer extends Api {
throw new UnreportableError('Account update not implemented when using FlowCrypt Enterprise Server');
};

private authHdr = async (type: 'accessToken' | 'OIDC'): Promise<Dict<string>> => {
if (type === 'accessToken') {
const { fesAccessToken } = await AcctStore.get(this.acctEmail, ['fesAccessToken']);
return { Authorization: `Bearer ${fesAccessToken}` };
} else {
const idToken = await InMemoryStore.get(this.acctEmail, this.IN_MEMORY_ID_TOKEN_STORAGE_KEY);
if (idToken) {
return { Authorization: `Bearer ${idToken}` };
}
// some customers may choose to disable authentication on FES
// in such cases, omitting the authentication header will not produce an error
// because client app doesn't know how is the server configured, it will try the request anyway
// worst case a 401 comes back which triggers a re-authentication prompt in this app
// the FES property responsible for this is api.portal.upload.authenticate=true|false
return {};
private authHdr = async (): Promise<Dict<string>> => {
const idToken = await InMemoryStore.get(this.acctEmail, InMemoryStore.ID_TOKEN_STORAGE_KEY);
if (idToken) {
return { Authorization: `Bearer ${idToken}` };
}
// user will not actually see this message, they'll see a generic login prompt
throw new BackendAuthErr('Missing id token, please re-authenticate');
};

private request = async <RT>(method: ReqMethod, path: string, headers: Dict<string> = {}, vals?: Dict<any>): Promise<RT> => {
Expand Down
11 changes: 6 additions & 5 deletions extension/js/common/api/email-provider/gmail/google-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Ui } from '../../../browser/ui.js';
import { AcctStore, AcctStoreDict } from '../../../platform/store/acct-store.js';
import { AccountServer } from '../../account-server.js';
import { EnterpriseServer } from '../../account-servers/enterprise-server.js';
import { InMemoryStore } from '../../../platform/store/in-memory-store.js';

type GoogleAuthTokenInfo = { issued_to: string, audience: string, scope: string, expires_in: number, access_type: 'offline' };
type GoogleAuthTokensResponse = { access_token: string, expires_in: number, refresh_token?: string, id_token: string, token_type: 'Bearer' };
Expand Down Expand Up @@ -147,7 +148,7 @@ export class GoogleAuth {
const acctServer = new AccountServer(authRes.acctEmail);
// fetch and store OrgRules (not authenticated)
await acctServer.accountGetAndUpdateLocalStore({ account: authRes.acctEmail, uuid });
// depending on DISABLE_FES_ACCESS_TOKEN, either fetch and store access token, or the ID token itself
// this is a no-op if FES is used. uuid is generated / stored if flowcrypt.com/api is used
await acctServer.loginWithOpenid(authRes.acctEmail, uuid, authRes.id_token);
} else {
// eventually this branch will be dropped once a public FES instance is run for these customers
Expand Down Expand Up @@ -279,20 +280,20 @@ export class GoogleAuth {
};

private static googleAuthSaveTokens = async (acctEmail: string, tokensObj: GoogleAuthTokensResponse, scopes: string[]) => {
const openid = GoogleAuth.parseIdToken(tokensObj.id_token);
const parsedOpenId = GoogleAuth.parseIdToken(tokensObj.id_token);
const { full_name, picture } = await AcctStore.get(acctEmail, ['full_name', 'picture']);
const toSave: AcctStoreDict = {
openid,
google_token_access: tokensObj.access_token,
google_token_expires: new Date().getTime() + (tokensObj.expires_in as number) * 1000,
google_token_scopes: scopes,
full_name: full_name || openid.name,
picture: picture || openid.picture,
full_name: full_name || parsedOpenId.name,
picture: picture || parsedOpenId.picture,
};
if (typeof tokensObj.refresh_token !== 'undefined') {
toSave.google_token_refresh = tokensObj.refresh_token;
}
await AcctStore.set(acctEmail, toSave);
await InMemoryStore.set(acctEmail, InMemoryStore.ID_TOKEN_STORAGE_KEY, tokensObj.id_token);
};

private static googleAuthGetTokens = async (code: string) => {
Expand Down
13 changes: 1 addition & 12 deletions extension/js/common/org-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ import { KeyAlgo } from './core/crypto/key.js';

type DomainRules$flag = 'NO_PRV_CREATE' | 'NO_PRV_BACKUP' | 'PRV_AUTOIMPORT_OR_AUTOGEN' | 'PASS_PHRASE_QUIET_AUTOGEN' |
'ENFORCE_ATTESTER_SUBMIT' | 'NO_ATTESTER_SUBMIT' | 'USE_LEGACY_ATTESTER_SUBMIT' |
'DEFAULT_REMEMBER_PASS_PHRASE' | 'HIDE_ARMOR_META' | 'FORBID_STORING_PASS_PHRASE' |
'DISABLE_FES_ACCESS_TOKEN';
'DEFAULT_REMEMBER_PASS_PHRASE' | 'HIDE_ARMOR_META' | 'FORBID_STORING_PASS_PHRASE';

export type DomainRulesJson = {
flags?: DomainRules$flag[],
Expand Down Expand Up @@ -197,14 +196,4 @@ export class OrgRules {
return (this.domainRules.flags || []).includes('HIDE_ARMOR_META');
};

/**
* Ask the client app to not fetch access token from FES, and
* instead OIDC ID Token directly for each authenticated call.
* The ID Token is kept in-memory, and typically expires in 1 hour depending on IdP settings
* This is more secure, but user may need to re-authenticate frequently.
*/
public disableFesAccessToken = (): boolean => {
return (this.domainRules.flags || []).includes('DISABLE_FES_ACCESS_TOKEN');
};

}
6 changes: 1 addition & 5 deletions extension/js/common/platform/store/acct-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Env } from '../../browser/env.js';
import { GoogleAuth } from '../../api/email-provider/gmail/google-auth.js';
import { KeyInfo } from '../../core/crypto/key.js';
import { Dict } from '../../core/common.js';
import { GmailRes } from '../../api/email-provider/gmail/gmail-parser.js';
import { DomainRulesJson } from '../../org-rules.js';
import { BrowserMsg, BgNotReadyErr } from '../../browser/browser-msg.js';
import { Ui } from '../../browser/ui.js';
Expand All @@ -31,8 +30,7 @@ export type AccountIndex = 'keys' | 'notification_setup_needed_dismissed' | 'ema
'google_token_refresh' | 'hide_message_password' | 'sendAs' |
'pubkey_sent_to' | 'full_name' | 'cryptup_enabled' | 'setup_done' |
'successfully_received_at_leat_one_message' | 'notification_setup_done_seen' | 'picture' |
'outgoing_language' | 'setup_date' | 'openid' | 'uuid' | 'use_rich_text' | 'rules' |
'fesUrl' | 'fesAccessToken';
'outgoing_language' | 'setup_date' | 'uuid' | 'use_rich_text' | 'rules' | 'fesUrl';

export type SendAsAlias = {
isPrimary: boolean;
Expand Down Expand Up @@ -62,11 +60,9 @@ export type AcctStoreDict = {
outgoing_language?: 'EN' | 'DE';
setup_date?: number;
use_rich_text?: boolean;
openid?: GmailRes.OpenId;
uuid?: string;
rules?: DomainRulesJson;
fesUrl?: string; // url where FlowCrypt Enterprise Server is deployed
fesAccessToken?: string;
};

/**
Expand Down
2 changes: 2 additions & 0 deletions extension/js/common/platform/store/in-memory-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { BrowserMsg } from '../../browser/browser-msg.js';
*/
export class InMemoryStore extends AbstractStore {

public static ID_TOKEN_STORAGE_KEY = 'idToken';

public static set = async (acctEmail: string, key: string, value: string | undefined) => {
return await BrowserMsg.send.bg.await.inMemoryStoreSet({ acctEmail, key, value });
};
Expand Down
42 changes: 2 additions & 40 deletions test/source/mock/fes/fes-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,12 @@ import { HttpClientErr } from '../lib/api';
import { MockJwt } from '../lib/oauth';

const standardFesUrl = 'fes.standardsubdomainfes.test:8001';
const disableAccessTokenFesUrl = 'fes.disablefesaccesstoken.test:8001';
const issuedAccessTokens: string[] = [];

export const mockFesEndpoints: HandlersDefinition = {
// standard fes location at https://fes.domain.com
'/api/': async ({ }, req) => {
if ([standardFesUrl, disableAccessTokenFesUrl].includes(req.headers.host || '') && req.method === 'GET') {
if ([standardFesUrl].includes(req.headers.host || '') && req.method === 'GET') {
return {
"vendor": "Mock",
"service": "enterprise-server",
Expand All @@ -32,27 +31,10 @@ export const mockFesEndpoints: HandlersDefinition = {
// this makes enterprise version tolerate missing FES - explicit 404
throw new HttpClientErr(`Not found`, 404);
}
console.log('host', req.headers.host);
throw new HttpClientErr(`Not running any FES here: ${req.headers.host}`);
},
'/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
if (email.includes(disableAccessTokenFesUrl)) {
throw new HttpClientErr('Users on domain disablefesaccesstoken.test must not fetch access token from FES');
}
issuedAccessTokens.push(fesToken);
return { 'accessToken': fesToken };
}
if (req.headers.host === disableAccessTokenFesUrl && req.method === 'GET') {
throw new HttpClientErr('Users on domain disablefesaccesstoken.test must not fetch access token from FES');
}
throw new HttpClientErr('Not Found', 404);
},
'/api/v1/client-configuration': async ({ }, req) => {
// individual OrgRules are tested using FlowCrypt backend mock, see BackendData.getOrgRules
// (except for DISABLE_FES_ACCESS_TOKEN which is FES specific and returned below)
if (req.method !== 'GET') {
throw new HttpClientErr('Unsupported method');
}
Expand All @@ -61,19 +43,10 @@ export const mockFesEndpoints: HandlersDefinition = {
clientConfiguration: { disallow_attester_search_for_domains: ['got.this@fromstandardfes.com'] },
};
}
if (req.headers.host === disableAccessTokenFesUrl && req.url === `/api/v1/client-configuration?domain=disablefesaccesstoken.test:8001`) {
return {
clientConfiguration: { flags: ['DISABLE_FES_ACCESS_TOKEN'] },
};
}
throw new HttpClientErr(`Unexpected FES domain "${req.headers.host}" and url "${req.url}"`);
},
'/api/v1/message/new-reply-token': async ({ }, req) => {
if (req.headers.host === standardFesUrl && req.method === 'POST') {
authenticate(req, 'fes');
return { 'replyToken': 'mock-fes-reply-token' };
}
if (req.headers.host === disableAccessTokenFesUrl && req.method === 'POST') {
authenticate(req, 'oidc');
return { 'replyToken': 'mock-fes-reply-token' };
}
Expand All @@ -83,7 +56,7 @@ export const mockFesEndpoints: HandlersDefinition = {
// body is a mime-multipart string, we're doing a few smoke checks here without parsing it
if (req.headers.host === standardFesUrl && req.method === 'POST') {
// test: `compose - user@standardsubdomainfes.test:8001 - PWD encrypted message with FES web portal`
authenticate(req, 'fes');
authenticate(req, 'oidc');
expect(body).to.contain('-----BEGIN PGP MESSAGE-----');
expect(body).to.contain('"associateReplyToken":"mock-fes-reply-token"');
expect(body).to.contain('"to":["to@example.com"]');
Expand All @@ -92,17 +65,6 @@ export const mockFesEndpoints: HandlersDefinition = {
expect(body).to.contain('"from":"user@standardsubdomainfes.test:8001"');
return { 'url': `http://${standardFesUrl}/message/FES-MOCK-MESSAGE-ID` };
}
if (req.headers.host === disableAccessTokenFesUrl && req.method === 'POST') {
// test: `user@disablefesaccesstoken.test:8001 - DISABLE_FES_ACCESS_TOKEN - PWD encrypted message with FES web portal`
expect(body).to.contain('-----BEGIN PGP MESSAGE-----');
expect(body).to.contain('"associateReplyToken":"mock-fes-reply-token"');
expect(body).to.contain('"to":["to@example.com"]');
expect(body).to.contain('"cc":[]');
expect(body).to.contain('"bcc":["bcc@example.com"]');
authenticate(req, 'oidc'); // important - due to DISABLE_FES_ACCESS_TOKEN
expect(body).to.contain('"from":"user@disablefesaccesstoken.test:8001"');
return { 'url': `http://${disableAccessTokenFesUrl}/message/FES-MOCK-MESSAGE-ID` };
}
throw new HttpClientErr('Not Found', 404);
},
};
Expand Down
2 changes: 1 addition & 1 deletion test/source/mock/google/google-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const mockGoogleEndpoints: HandlersDefinition = {
if (isPost(req) && grant_type === 'authorization_code' && code && client_id === oauth.clientId) { // auth code from auth screen gets exchanged for access and refresh tokens
return oauth.getRefreshTokenResponse(code);
} else if (isPost(req) && grant_type === 'refresh_token' && refreshToken && client_id === oauth.clientId) { // here also later refresh token gets exchanged for access token
return oauth.getAccessTokenResponse(refreshToken);
return oauth.getTokenResponse(refreshToken);
}
throw new Error(`Method not implemented for ${req.url}: ${req.method}`);
},
Expand Down
Loading