Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#5801 Differentiate GoogleAuthErr And EnterpriseServerAuthErr #5814

Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Browser } from '../../../js/common/browser/browser.js';
import { BrowserEventErrHandler, Ui } from '../../../js/common/browser/ui.js';
import { Catch } from '../../../js/common/platform/catch.js';
import { NewMsgData, SendBtnTexts, SendMsgsResult } from './compose-types.js';
import { ApiErr } from '../../../js/common/api/shared/api-error.js';
import { ApiErr, EnterpriseServerAuthErr } from '../../../js/common/api/shared/api-error.js';
import { BrowserExtension } from '../../../js/common/browser/browser-extension.js';
import { BrowserMsg } from '../../../js/common/browser/browser-msg.js';
import { Settings } from '../../../js/common/settings.js';
Expand Down Expand Up @@ -82,6 +82,9 @@ export class ComposeErrModule extends ViewModule<ComposeView> {
'(This may also be caused by <a href="https://flowcrypt.com/docs/help/network-error.html" target="_blank">missing extension permissions</a>).';
}
await Ui.modal.error(netErrMsg, true);
} else if (e instanceof EnterpriseServerAuthErr) {
BrowserMsg.send.notificationShowCustomIDPAuthPopupNeeded(this.view.parentTabId, { acctEmail: this.view.acctEmail });
Settings.offerToLoginWithPopupShowModalOnErr(this.view.acctEmail, () => this.view.sendBtnModule.extractProcessSendMsg());
} else if (ApiErr.isAuthErr(e)) {
BrowserMsg.send.notificationShowAuthPopupNeeded(this.view.parentTabId, { acctEmail: this.view.acctEmail });
Settings.offerToLoginWithPopupShowModalOnErr(this.view.acctEmail, () => this.view.sendBtnModule.extractProcessSendMsg());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import { UploadedMessageData } from '../../../../js/common/api/account-server.js';
import { SendableMsg } from '../../../../js/common/api/email-provider/sendable-msg.js';
import { ApiErr } from '../../../../js/common/api/shared/api-error.js';
import { ApiErr, EnterpriseServerAuthErr } from '../../../../js/common/api/shared/api-error.js';
import { Api, RecipientType } from '../../../../js/common/api/shared/api.js';
import { Ui } from '../../../../js/common/browser/ui.js';
import { Attachment } from '../../../../js/common/core/attachment.js';
Expand Down Expand Up @@ -275,6 +275,10 @@ export class EncryptedMsgMailFormatter extends BaseMailFormatter {
replyToken: response.replyToken,
};
} catch (msgTokenErr) {
if (msgTokenErr instanceof EnterpriseServerAuthErr) {
Settings.offerToLoginCustomIDPWithPopupShowModalOnErr(this.acctEmail, () => this.view.sendBtnModule.extractProcessSendMsg());
throw new ComposerResetBtnTrigger();
}
if (ApiErr.isAuthErr(msgTokenErr)) {
Settings.offerToLoginWithPopupShowModalOnErr(this.acctEmail, () => this.view.sendBtnModule.extractProcessSendMsg());
throw new ComposerResetBtnTrigger();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export class InboxNotificationModule extends ViewModule<InboxView> {
BrowserMsg.addListener('notification_show_auth_popup_needed', async ({ acctEmail }: Bm.NotificationShowAuthPopupNeeded) => {
this.notifications.showAuthPopupNeeded(acctEmail);
});
BrowserMsg.addListener('notification_show_custom_idp_auth_popup_needed', async ({ acctEmail }: Bm.NotificationShowAuthPopupNeeded) => {
this.notifications.showCustomIDPAuthPopupNeeded(acctEmail);
});
};

private notificationShowHandler: Bm.AsyncResponselessHandler = async ({ notification, callbacks, group }: Bm.NotificationShow) => {
Expand Down
13 changes: 10 additions & 3 deletions extension/chrome/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Bm, BrowserMsg } from '../../js/common/browser/browser-msg.js';
import { Ui } from '../../js/common/browser/ui.js';
import { KeyUtil, KeyInfoWithIdentity } from '../../js/common/core/crypto/key.js';
import { Str, Url, UrlParams } from '../../js/common/core/common.js';
import { ApiErr } from '../../js/common/api/shared/api-error.js';
import { ApiErr, EnterpriseServerAuthErr } from '../../js/common/api/shared/api-error.js';
import { Assert } from '../../js/common/assert.js';

import { Catch } from '../../js/common/platform/catch.js';
Expand Down Expand Up @@ -140,6 +140,9 @@ View.run(
BrowserMsg.addListener('notification_show_auth_popup_needed', async ({ acctEmail }: Bm.NotificationShowAuthPopupNeeded) => {
this.notifications.showAuthPopupNeeded(acctEmail);
});
BrowserMsg.addListener('notification_show_custom_idp_auth_popup_needed', async ({ acctEmail }: Bm.NotificationShowAuthPopupNeeded) => {
this.notifications.showCustomIDPAuthPopupNeeded(acctEmail);
});
BrowserMsg.addListener('close_dialog', async () => {
Swal.close();
});
Expand Down Expand Up @@ -376,13 +379,17 @@ View.run(
await this.acctServer!.fetchAndSaveClientConfiguration();
$('#status-row #status_flowcrypt').text(`fc:ok`);
} catch (e) {
if (ApiErr.isAuthErr(e)) {
if (e instanceof EnterpriseServerAuthErr) {
Settings.offerToLoginCustomIDPWithPopupShowModalOnErr(this.acctEmail, () => {
window.location.reload();
});
} else if (ApiErr.isAuthErr(e)) {
const authNeededLink = $('<a class="bad" href="#">Auth Needed</a>');
authNeededLink.on(
'click',
this.setHandler(async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await Settings.loginWithPopupShowModalOnErr(this.acctEmail!, () => {
await Settings.loginWithPopupShowModalOnErr(this.acctEmail!, false, () => {
window.location.reload();
});
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import { SetupOptions, SetupView } from '../setup.js';
import { Ui } from '../../../js/common/browser/ui.js';
import { Url } from '../../../js/common/core/common.js';
import { AcctStore } from '../../../js/common/platform/store/acct-store.js';
import { AjaxErr, ApiErr } from '../../../js/common/api/shared/api-error.js';
import { AjaxErr, ApiErr, EnterpriseServerAuthErr } from '../../../js/common/api/shared/api-error.js';
import { Api } from '../../../js/common/api/shared/api.js';
import { Settings } from '../../../js/common/settings.js';
import { KeyUtil } from '../../../js/common/core/crypto/key.js';
import { OpenPGPKey } from '../../../js/common/core/crypto/pgp/openpgp-key.js';
import { Lang } from '../../../js/common/lang.js';
import { processAndStoreKeysFromEkmLocally, saveKeysAndPassPhrase } from '../../../js/common/helpers.js';
import { Xss } from '../../../js/common/platform/xss.js';
import { BrowserMsg } from '../../../js/common/browser/browser-msg.js';

export class SetupWithEmailKeyManagerModule {
public constructor(private view: SetupView) {}
Expand Down Expand Up @@ -79,6 +80,10 @@ export class SetupWithEmailKeyManagerModule {
await this.view.finalizeSetup();
await this.view.setupRender.renderSetupDone();
} catch (e) {
if (e instanceof EnterpriseServerAuthErr) {
await BrowserMsg.send.bg.await.reconnectCustomIDPAcctAuthPopup({ acctEmail: this.view.acctEmail });
return;
}
if (ApiErr.isNetErr(e) && (await Api.isInternetAccessible())) {
// frendly message when key manager is down, helpful during initial infrastructure setup
const url = this.view.clientConfiguration.getKeyManagerUrlForPrivateKeys();
Expand Down
23 changes: 18 additions & 5 deletions extension/js/common/api/account-servers/external-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,18 @@ export class ExternalService extends Api {
};

public getServiceInfo = async (): Promise<FesRes.ServiceInfo> => {
return await this.request<FesRes.ServiceInfo>(`/api/`);
return await this.request<FesRes.ServiceInfo>(`/api/`, undefined, undefined, false);
};

public fetchAndSaveClientConfiguration = async (): Promise<ClientConfigurationJson> => {
const auth = await this.request<AuthenticationConfiguration>(`/api/${this.apiVersion}/client-configuration/authentication?domain=${this.domain}`);
const auth = await this.request<AuthenticationConfiguration>(
`/api/${this.apiVersion}/client-configuration/authentication?domain=${this.domain}`,
undefined,
undefined,
false
);
await AcctStore.set(this.acctEmail, { authentication: auth });
const r = await this.request<FesRes.ClientConfiguration>(`/api/${this.apiVersion}/client-configuration?domain=${this.domain}`);
const r = await this.request<FesRes.ClientConfiguration>(`/api/${this.apiVersion}/client-configuration?domain=${this.domain}`, undefined, undefined, false);
if (r.clientConfiguration && !r.clientConfiguration.flags) {
throw new ClientConfigurationError('missing_flags');
}
Expand Down Expand Up @@ -168,7 +173,8 @@ export class ExternalService extends Api {
fmt: 'FORM';
}
| { data: Dict<Serializable>; fmt: 'JSON' },
progress?: ProgressCbs
progress?: ProgressCbs,
shouldThrowErrorForEmptyIdToken = true
): Promise<RT> => {
const values:
| {
Expand All @@ -183,6 +189,13 @@ export class ExternalService extends Api {
method: 'POST',
}
: undefined;
return await ExternalService.apiCall(this.url, path, values, progress, await ConfiguredIdpOAuth.authHdr(this.acctEmail), 'json');
return await ExternalService.apiCall(
this.url,
path,
values,
progress,
await ConfiguredIdpOAuth.authHdr(this.acctEmail, shouldThrowErrorForEmptyIdToken),
'json'
);
};
}
133 changes: 101 additions & 32 deletions extension/js/common/api/authentication/configured-idp-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,55 +3,70 @@
'use strict';

import { Ui } from '../../browser/ui.js';
import { AuthRes, OAuth, OAuthTokensResponse } from './generic/oauth.js';
import { AuthorizationHeader, AuthRes, OAuth, OAuthTokensResponse } from './generic/oauth.js';
import { AuthenticationConfiguration } from '../../authentication-configuration.js';
import { Url } from '../../core/common.js';
import { Assert, AssertError } from '../../assert.js';
import { Api } from '../shared/api.js';
import { Catch } from '../../platform/catch.js';
import { InMemoryStoreKeys } from '../../core/const.js';
import { InMemoryStore } from '../../platform/store/in-memory-store.js';
import { AcctStore } from '../../platform/store/acct-store.js';
import { BackendAuthErr } from '../shared/api-error.js';
import { AcctStore, AcctStoreDict } from '../../platform/store/acct-store.js';
import { EnterpriseServerAuthErr } from '../shared/api-error.js';
export class ConfiguredIdpOAuth extends OAuth {
public static newAuthPopupForEnterpriseServerAuthenticationIfNeeded = async (authRes: AuthRes) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const acctEmail = authRes.acctEmail!;
const storage = await AcctStore.get(acctEmail, ['authentication']);
if (storage?.authentication?.oauth?.clientId && storage.authentication.oauth.clientId !== this.GOOGLE_OAUTH_CONFIG.client_id) {
await Ui.modal.info('Google login succeeded. Now, please log in with your company credentials as well.');
return await this.newAuthPopup(acctEmail, { oauth: storage.authentication.oauth });
return await this.newAuthPopup(acctEmail);
}
return authRes;
};

public static authHdr = async (acctEmail: string, shouldThrowErrorForEmptyIdToken = true): Promise<{ authorization: string } | undefined> => {
let idToken = await InMemoryStore.getUntilAvailable(acctEmail, InMemoryStoreKeys.ID_TOKEN);
if (idToken) {
const customIDPIdToken = await InMemoryStore.get(acctEmail, InMemoryStoreKeys.CUSTOM_IDP_ID_TOKEN);
// if special JWT is stored in local store, it should be used for Enterprise Server authentication instead of Google JWT
// https://github.com/FlowCrypt/flowcrypt-browser/issues/5799
if (customIDPIdToken) {
idToken = customIDPIdToken;
public static authHdr = async (acctEmail: string, shouldThrowErrorForEmptyIdToken = true, forceRefresh = false): Promise<AuthorizationHeader | undefined> => {
const { custom_idp_token_refresh } = await AcctStore.get(acctEmail, ['custom_idp_token_refresh']); // eslint-disable-line @typescript-eslint/naming-convention
if (!forceRefresh) {
const authHdr = await this.getAuthHeaderDependsOnType(acctEmail);
if (authHdr) {
return authHdr;
}
}
if (!custom_idp_token_refresh) {
if (shouldThrowErrorForEmptyIdToken) {
throw new EnterpriseServerAuthErr(`Account ${acctEmail} not connected to FlowCrypt Browser Extension`);
}
return undefined;
}
// refresh token
const refreshTokenRes = await this.authRefreshToken(custom_idp_token_refresh, acctEmail);
if (refreshTokenRes.access_token) {
await this.authSaveTokens(acctEmail, refreshTokenRes);
const authHdr = await this.getAuthHeaderDependsOnType(acctEmail);
if (authHdr) {
return authHdr;
}
return { authorization: `Bearer ${idToken}` };
}
if (shouldThrowErrorForEmptyIdToken) {
// user will not actually see this message, they'll see a generic login prompt
throw new BackendAuthErr('Missing id token, please re-authenticate');
throw new EnterpriseServerAuthErr(
`Could not refresh custom idp auth token - did not become valid (access:${refreshTokenRes.id_token},expires_in:${
refreshTokenRes.expires_in
},now:${Date.now()})`
);
}
return undefined;
};

public static async newAuthPopup(acctEmail: string, authConf: AuthenticationConfiguration): Promise<AuthRes> {
public static async newAuthPopup(acctEmail: string): Promise<AuthRes> {
acctEmail = acctEmail.toLowerCase();
const authRequest = this.newAuthRequest(acctEmail, this.OAUTH_REQUEST_SCOPES);
const authUrl = this.apiOAuthCodeUrl(authConf, authRequest.expectedState, acctEmail);
const authUrl = await this.apiOAuthCodeUrl(authRequest.expectedState, acctEmail);
const authRes = await this.getAuthRes({
acctEmail,
expectedState: authRequest.expectedState,
authUrl,
authConf,
});
if (authRes.result === 'Success') {
if (!authRes.id_token) {
Expand All @@ -74,7 +89,29 @@ export class ConfiguredIdpOAuth extends OAuth {
return authRes;
}

private static apiOAuthCodeUrl(authConf: AuthenticationConfiguration, state: string, acctEmail: string) {
private static async authRefreshToken(refreshToken: string, acctEmail: string): Promise<OAuthTokensResponse> {
const authConf = await this.getAuthenticationConfiguration(acctEmail);
return await Api.ajax(
{
/* eslint-disable @typescript-eslint/naming-convention */
url: authConf.oauth.tokensUrl,
method: 'POST',
data: {
grant_type: 'refresh_token',
refreshToken,
client_id: authConf.oauth.clientId,
redirect_uri: chrome.identity.getRedirectURL('oauth'),
},
dataType: 'JSON',
/* eslint-enable @typescript-eslint/naming-convention */
stack: Catch.stackTrace(),
},
'json'
);
}

private static async apiOAuthCodeUrl(state: string, acctEmail: string) {
const authConf = await this.getAuthenticationConfiguration(acctEmail);
/* eslint-disable @typescript-eslint/naming-convention */
return Url.create(authConf.oauth.authCodeUrl, {
client_id: authConf.oauth.clientId,
Expand All @@ -89,17 +126,7 @@ export class ConfiguredIdpOAuth extends OAuth {
/* eslint-enable @typescript-eslint/naming-convention */
}

private static async getAuthRes({
acctEmail,
expectedState,
authUrl,
authConf,
}: {
acctEmail: string;
expectedState: string;
authUrl: string;
authConf: AuthenticationConfiguration;
}): Promise<AuthRes> {
private static async getAuthRes({ acctEmail, expectedState, authUrl }: { acctEmail: string; expectedState: string; authUrl: string }): Promise<AuthRes> {
/* eslint-disable @typescript-eslint/naming-convention */
try {
const redirectUri = await chrome.identity.launchWebAuthFlow({ url: authUrl, interactive: true });
Expand All @@ -124,7 +151,7 @@ export class ConfiguredIdpOAuth extends OAuth {
if (receivedState !== expectedState) {
return { acctEmail, result: 'Error', error: `Wrong oauth CSRF token. Please try again.`, id_token: undefined };
}
const { id_token } = await this.authGetTokens(code, authConf);
const { id_token } = await this.retrieveAndSaveAuthToken(acctEmail, code);
const { email } = this.parseIdToken(id_token);
if (!email) {
throw new Error('Missing email address in id_token');
Expand All @@ -137,15 +164,36 @@ export class ConfiguredIdpOAuth extends OAuth {
id_token: undefined,
};
}
await InMemoryStore.set(acctEmail, InMemoryStoreKeys.CUSTOM_IDP_ID_TOKEN, id_token);
return { acctEmail: email, result: 'Success', id_token };
} catch (err) {
return { acctEmail, result: 'Error', error: err instanceof AssertError ? 'Could not parse URL returned from OAuth' : String(err), id_token: undefined };
}
/* eslint-enable @typescript-eslint/naming-convention */
}

private static async authGetTokens(code: string, authConf: AuthenticationConfiguration): Promise<OAuthTokensResponse> {
// eslint-disable-next-line @typescript-eslint/naming-convention
private static async retrieveAndSaveAuthToken(acctEmail: string, authCode: string): Promise<{ id_token: string }> {
const tokensObj = await this.authGetTokens(acctEmail, authCode);
const claims = this.parseIdToken(tokensObj.id_token);
if (!claims.email) {
throw new Error('Missing email address in id_token');
}
await this.authSaveTokens(claims.email, tokensObj);
return { id_token: tokensObj.id_token }; // eslint-disable-line @typescript-eslint/naming-convention
}

private static async authSaveTokens(acctEmail: string, tokensObj: OAuthTokensResponse) {
const tokenExpires = new Date().getTime() + (tokensObj.expires_in - 120) * 1000; // let our copy expire 2 minutes beforehand
const toSave: AcctStoreDict = {};
if (typeof tokensObj.refresh_token !== 'undefined') {
toSave.custom_idp_token_refresh = tokensObj.refresh_token;
}
await AcctStore.set(acctEmail, toSave);
await InMemoryStore.set(acctEmail, InMemoryStoreKeys.CUSTOM_IDP_ID_TOKEN, tokensObj.id_token, tokenExpires);
}

private static async authGetTokens(acctEmail: string, code: string): Promise<OAuthTokensResponse> {
const authConf = await this.getAuthenticationConfiguration(acctEmail);
return await Api.ajax(
{
/* eslint-disable @typescript-eslint/naming-convention */
Expand All @@ -164,4 +212,25 @@ export class ConfiguredIdpOAuth extends OAuth {
'json'
);
}

private static async getAuthenticationConfiguration(acctEmail: string): Promise<AuthenticationConfiguration> {
const storage = await AcctStore.get(acctEmail, ['authentication']);
if (!storage.authentication) {
throw new EnterpriseServerAuthErr('Could not get authentication configuration');
}
return storage.authentication;
}

private static async getAuthHeaderDependsOnType(acctEmail: string): Promise<AuthorizationHeader | undefined> {
let idToken = await InMemoryStore.getUntilAvailable(acctEmail, InMemoryStoreKeys.ID_TOKEN);
const storage = await AcctStore.get(acctEmail, ['authentication']);
if (storage.authentication?.oauth) {
// If custom authentication (IDP) is used, return the custom IDP ID token if available.
// If the custom IDP ID token is not found, throw an EnterpriseServerAuthErr.
// The custom IDP ID token should be used for Enterprise Server authentication instead of the Google JWT.
// https://github.com/FlowCrypt/flowcrypt-browser/issues/5799
idToken = await InMemoryStore.get(acctEmail, InMemoryStoreKeys.CUSTOM_IDP_ID_TOKEN);
}
return idToken ? { authorization: `Bearer ${idToken}` } : undefined;
}
}
4 changes: 4 additions & 0 deletions extension/js/common/api/authentication/generic/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export type OAuthTokensResponse = {
};
/* eslint-enable @typescript-eslint/naming-convention */

export type AuthorizationHeader = {
authorization: string;
};

export class OAuth {
/* eslint-disable @typescript-eslint/naming-convention */
public static GOOGLE_OAUTH_CONFIG = {
Expand Down
Loading
Loading