Skip to content

Commit 9ba4d97

Browse files
author
Ioan Moldovan
authored
#5801 Differentiate GoogleAuthErr And EnterpriseServerAuthErr (#5814)
* WIP: add logic to refresh custom idp id token * fix: auth header issue * WIP: add error handlers * fix: if else * fix: unnecesssary protected method * fix: live gmail chat test * feat: added ui test
1 parent 748b960 commit 9ba4d97

File tree

25 files changed

+341
-114
lines changed

25 files changed

+341
-114
lines changed

extension/chrome/elements/compose-modules/compose-err-module.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Browser } from '../../../js/common/browser/browser.js';
66
import { BrowserEventErrHandler, Ui } from '../../../js/common/browser/ui.js';
77
import { Catch } from '../../../js/common/platform/catch.js';
88
import { NewMsgData, SendBtnTexts, SendMsgsResult } from './compose-types.js';
9-
import { ApiErr } from '../../../js/common/api/shared/api-error.js';
9+
import { ApiErr, EnterpriseServerAuthErr } from '../../../js/common/api/shared/api-error.js';
1010
import { BrowserExtension } from '../../../js/common/browser/browser-extension.js';
1111
import { BrowserMsg } from '../../../js/common/browser/browser-msg.js';
1212
import { Settings } from '../../../js/common/settings.js';
@@ -82,6 +82,9 @@ export class ComposeErrModule extends ViewModule<ComposeView> {
8282
'(This may also be caused by <a href="https://flowcrypt.com/docs/help/network-error.html" target="_blank">missing extension permissions</a>).';
8383
}
8484
await Ui.modal.error(netErrMsg, true);
85+
} else if (e instanceof EnterpriseServerAuthErr) {
86+
BrowserMsg.send.notificationShowCustomIDPAuthPopupNeeded(this.view.parentTabId, { acctEmail: this.view.acctEmail });
87+
Settings.offerToLoginWithPopupShowModalOnErr(this.view.acctEmail, () => this.view.sendBtnModule.extractProcessSendMsg());
8588
} else if (ApiErr.isAuthErr(e)) {
8689
BrowserMsg.send.notificationShowAuthPopupNeeded(this.view.parentTabId, { acctEmail: this.view.acctEmail });
8790
Settings.offerToLoginWithPopupShowModalOnErr(this.view.acctEmail, () => this.view.sendBtnModule.extractProcessSendMsg());

extension/chrome/elements/compose-modules/formatters/encrypted-mail-msg-formatter.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import { UploadedMessageData } from '../../../../js/common/api/account-server.js';
66
import { SendableMsg } from '../../../../js/common/api/email-provider/sendable-msg.js';
7-
import { ApiErr } from '../../../../js/common/api/shared/api-error.js';
7+
import { ApiErr, EnterpriseServerAuthErr } from '../../../../js/common/api/shared/api-error.js';
88
import { Api, RecipientType } from '../../../../js/common/api/shared/api.js';
99
import { Ui } from '../../../../js/common/browser/ui.js';
1010
import { Attachment } from '../../../../js/common/core/attachment.js';
@@ -275,6 +275,10 @@ export class EncryptedMsgMailFormatter extends BaseMailFormatter {
275275
replyToken: response.replyToken,
276276
};
277277
} catch (msgTokenErr) {
278+
if (msgTokenErr instanceof EnterpriseServerAuthErr) {
279+
Settings.offerToLoginCustomIDPWithPopupShowModalOnErr(this.acctEmail, () => this.view.sendBtnModule.extractProcessSendMsg());
280+
throw new ComposerResetBtnTrigger();
281+
}
278282
if (ApiErr.isAuthErr(msgTokenErr)) {
279283
Settings.offerToLoginWithPopupShowModalOnErr(this.acctEmail, () => this.view.sendBtnModule.extractProcessSendMsg());
280284
throw new ComposerResetBtnTrigger();

extension/chrome/settings/inbox/inbox-modules/inbox-notification-module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ export class InboxNotificationModule extends ViewModule<InboxView> {
4949
BrowserMsg.addListener('notification_show_auth_popup_needed', async ({ acctEmail }: Bm.NotificationShowAuthPopupNeeded) => {
5050
this.notifications.showAuthPopupNeeded(acctEmail);
5151
});
52+
BrowserMsg.addListener('notification_show_custom_idp_auth_popup_needed', async ({ acctEmail }: Bm.NotificationShowAuthPopupNeeded) => {
53+
this.notifications.showCustomIDPAuthPopupNeeded(acctEmail);
54+
});
5255
};
5356

5457
private notificationShowHandler: Bm.AsyncResponselessHandler = async ({ notification, callbacks, group }: Bm.NotificationShow) => {

extension/chrome/settings/index.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Bm, BrowserMsg } from '../../js/common/browser/browser-msg.js';
66
import { Ui } from '../../js/common/browser/ui.js';
77
import { KeyUtil, KeyInfoWithIdentity } from '../../js/common/core/crypto/key.js';
88
import { Str, Url, UrlParams } from '../../js/common/core/common.js';
9-
import { ApiErr } from '../../js/common/api/shared/api-error.js';
9+
import { ApiErr, EnterpriseServerAuthErr } from '../../js/common/api/shared/api-error.js';
1010
import { Assert } from '../../js/common/assert.js';
1111

1212
import { Catch } from '../../js/common/platform/catch.js';
@@ -140,6 +140,9 @@ View.run(
140140
BrowserMsg.addListener('notification_show_auth_popup_needed', async ({ acctEmail }: Bm.NotificationShowAuthPopupNeeded) => {
141141
this.notifications.showAuthPopupNeeded(acctEmail);
142142
});
143+
BrowserMsg.addListener('notification_show_custom_idp_auth_popup_needed', async ({ acctEmail }: Bm.NotificationShowAuthPopupNeeded) => {
144+
this.notifications.showCustomIDPAuthPopupNeeded(acctEmail);
145+
});
143146
BrowserMsg.addListener('close_dialog', async () => {
144147
Swal.close();
145148
});
@@ -376,13 +379,17 @@ View.run(
376379
await this.acctServer!.fetchAndSaveClientConfiguration();
377380
$('#status-row #status_flowcrypt').text(`fc:ok`);
378381
} catch (e) {
379-
if (ApiErr.isAuthErr(e)) {
382+
if (e instanceof EnterpriseServerAuthErr) {
383+
Settings.offerToLoginCustomIDPWithPopupShowModalOnErr(this.acctEmail, () => {
384+
window.location.reload();
385+
});
386+
} else if (ApiErr.isAuthErr(e)) {
380387
const authNeededLink = $('<a class="bad" href="#">Auth Needed</a>');
381388
authNeededLink.on(
382389
'click',
383390
this.setHandler(async () => {
384391
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
385-
await Settings.loginWithPopupShowModalOnErr(this.acctEmail!, () => {
392+
await Settings.loginWithPopupShowModalOnErr(this.acctEmail!, false, () => {
386393
window.location.reload();
387394
});
388395
})

extension/chrome/settings/setup/setup-key-manager-autogen.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import { SetupOptions, SetupView } from '../setup.js';
66
import { Ui } from '../../../js/common/browser/ui.js';
77
import { Url } from '../../../js/common/core/common.js';
88
import { AcctStore } from '../../../js/common/platform/store/acct-store.js';
9-
import { AjaxErr, ApiErr } from '../../../js/common/api/shared/api-error.js';
9+
import { AjaxErr, ApiErr, EnterpriseServerAuthErr } from '../../../js/common/api/shared/api-error.js';
1010
import { Api } from '../../../js/common/api/shared/api.js';
1111
import { Settings } from '../../../js/common/settings.js';
1212
import { KeyUtil } from '../../../js/common/core/crypto/key.js';
1313
import { OpenPGPKey } from '../../../js/common/core/crypto/pgp/openpgp-key.js';
1414
import { Lang } from '../../../js/common/lang.js';
1515
import { processAndStoreKeysFromEkmLocally, saveKeysAndPassPhrase } from '../../../js/common/helpers.js';
1616
import { Xss } from '../../../js/common/platform/xss.js';
17+
import { BrowserMsg } from '../../../js/common/browser/browser-msg.js';
1718

1819
export class SetupWithEmailKeyManagerModule {
1920
public constructor(private view: SetupView) {}
@@ -79,6 +80,10 @@ export class SetupWithEmailKeyManagerModule {
7980
await this.view.finalizeSetup();
8081
await this.view.setupRender.renderSetupDone();
8182
} catch (e) {
83+
if (e instanceof EnterpriseServerAuthErr) {
84+
await BrowserMsg.send.bg.await.reconnectCustomIDPAcctAuthPopup({ acctEmail: this.view.acctEmail });
85+
return;
86+
}
8287
if (ApiErr.isNetErr(e) && (await Api.isInternetAccessible())) {
8388
// frendly message when key manager is down, helpful during initial infrastructure setup
8489
const url = this.view.clientConfiguration.getKeyManagerUrlForPrivateKeys();

extension/js/common/api/account-servers/external-service.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,18 @@ export class ExternalService extends Api {
8585
};
8686

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

9191
public fetchAndSaveClientConfiguration = async (): Promise<ClientConfigurationJson> => {
92-
const auth = await this.request<AuthenticationConfiguration>(`/api/${this.apiVersion}/client-configuration/authentication?domain=${this.domain}`);
92+
const auth = await this.request<AuthenticationConfiguration>(
93+
`/api/${this.apiVersion}/client-configuration/authentication?domain=${this.domain}`,
94+
undefined,
95+
undefined,
96+
false
97+
);
9398
await AcctStore.set(this.acctEmail, { authentication: auth });
94-
const r = await this.request<FesRes.ClientConfiguration>(`/api/${this.apiVersion}/client-configuration?domain=${this.domain}`);
99+
const r = await this.request<FesRes.ClientConfiguration>(`/api/${this.apiVersion}/client-configuration?domain=${this.domain}`, undefined, undefined, false);
95100
if (r.clientConfiguration && !r.clientConfiguration.flags) {
96101
throw new ClientConfigurationError('missing_flags');
97102
}
@@ -168,7 +173,8 @@ export class ExternalService extends Api {
168173
fmt: 'FORM';
169174
}
170175
| { data: Dict<Serializable>; fmt: 'JSON' },
171-
progress?: ProgressCbs
176+
progress?: ProgressCbs,
177+
shouldThrowErrorForEmptyIdToken = true
172178
): Promise<RT> => {
173179
const values:
174180
| {
@@ -183,6 +189,13 @@ export class ExternalService extends Api {
183189
method: 'POST',
184190
}
185191
: undefined;
186-
return await ExternalService.apiCall(this.url, path, values, progress, await ConfiguredIdpOAuth.authHdr(this.acctEmail), 'json');
192+
return await ExternalService.apiCall(
193+
this.url,
194+
path,
195+
values,
196+
progress,
197+
await ConfiguredIdpOAuth.authHdr(this.acctEmail, shouldThrowErrorForEmptyIdToken),
198+
'json'
199+
);
187200
};
188201
}

extension/js/common/api/authentication/configured-idp-oauth.ts

Lines changed: 101 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,55 +3,70 @@
33
'use strict';
44

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

28-
public static authHdr = async (acctEmail: string, shouldThrowErrorForEmptyIdToken = true): Promise<{ authorization: string } | undefined> => {
29-
let idToken = await InMemoryStore.getUntilAvailable(acctEmail, InMemoryStoreKeys.ID_TOKEN);
30-
if (idToken) {
31-
const customIDPIdToken = await InMemoryStore.get(acctEmail, InMemoryStoreKeys.CUSTOM_IDP_ID_TOKEN);
32-
// if special JWT is stored in local store, it should be used for Enterprise Server authentication instead of Google JWT
33-
// https://github.com/FlowCrypt/flowcrypt-browser/issues/5799
34-
if (customIDPIdToken) {
35-
idToken = customIDPIdToken;
28+
public static authHdr = async (acctEmail: string, shouldThrowErrorForEmptyIdToken = true, forceRefresh = false): Promise<AuthorizationHeader | undefined> => {
29+
const { custom_idp_token_refresh } = await AcctStore.get(acctEmail, ['custom_idp_token_refresh']); // eslint-disable-line @typescript-eslint/naming-convention
30+
if (!forceRefresh) {
31+
const authHdr = await this.getAuthHeaderDependsOnType(acctEmail);
32+
if (authHdr) {
33+
return authHdr;
34+
}
35+
}
36+
if (!custom_idp_token_refresh) {
37+
if (shouldThrowErrorForEmptyIdToken) {
38+
throw new EnterpriseServerAuthErr(`Account ${acctEmail} not connected to FlowCrypt Browser Extension`);
39+
}
40+
return undefined;
41+
}
42+
// refresh token
43+
const refreshTokenRes = await this.authRefreshToken(custom_idp_token_refresh, acctEmail);
44+
if (refreshTokenRes.access_token) {
45+
await this.authSaveTokens(acctEmail, refreshTokenRes);
46+
const authHdr = await this.getAuthHeaderDependsOnType(acctEmail);
47+
if (authHdr) {
48+
return authHdr;
3649
}
37-
return { authorization: `Bearer ${idToken}` };
3850
}
3951
if (shouldThrowErrorForEmptyIdToken) {
4052
// user will not actually see this message, they'll see a generic login prompt
41-
throw new BackendAuthErr('Missing id token, please re-authenticate');
53+
throw new EnterpriseServerAuthErr(
54+
`Could not refresh custom idp auth token - did not become valid (access:${refreshTokenRes.id_token},expires_in:${
55+
refreshTokenRes.expires_in
56+
},now:${Date.now()})`
57+
);
4258
}
4359
return undefined;
4460
};
4561

46-
public static async newAuthPopup(acctEmail: string, authConf: AuthenticationConfiguration): Promise<AuthRes> {
62+
public static async newAuthPopup(acctEmail: string): Promise<AuthRes> {
4763
acctEmail = acctEmail.toLowerCase();
4864
const authRequest = this.newAuthRequest(acctEmail, this.OAUTH_REQUEST_SCOPES);
49-
const authUrl = this.apiOAuthCodeUrl(authConf, authRequest.expectedState, acctEmail);
65+
const authUrl = await this.apiOAuthCodeUrl(authRequest.expectedState, acctEmail);
5066
const authRes = await this.getAuthRes({
5167
acctEmail,
5268
expectedState: authRequest.expectedState,
5369
authUrl,
54-
authConf,
5570
});
5671
if (authRes.result === 'Success') {
5772
if (!authRes.id_token) {
@@ -74,7 +89,29 @@ export class ConfiguredIdpOAuth extends OAuth {
7489
return authRes;
7590
}
7691

77-
private static apiOAuthCodeUrl(authConf: AuthenticationConfiguration, state: string, acctEmail: string) {
92+
private static async authRefreshToken(refreshToken: string, acctEmail: string): Promise<OAuthTokensResponse> {
93+
const authConf = await this.getAuthenticationConfiguration(acctEmail);
94+
return await Api.ajax(
95+
{
96+
/* eslint-disable @typescript-eslint/naming-convention */
97+
url: authConf.oauth.tokensUrl,
98+
method: 'POST',
99+
data: {
100+
grant_type: 'refresh_token',
101+
refreshToken,
102+
client_id: authConf.oauth.clientId,
103+
redirect_uri: chrome.identity.getRedirectURL('oauth'),
104+
},
105+
dataType: 'JSON',
106+
/* eslint-enable @typescript-eslint/naming-convention */
107+
stack: Catch.stackTrace(),
108+
},
109+
'json'
110+
);
111+
}
112+
113+
private static async apiOAuthCodeUrl(state: string, acctEmail: string) {
114+
const authConf = await this.getAuthenticationConfiguration(acctEmail);
78115
/* eslint-disable @typescript-eslint/naming-convention */
79116
return Url.create(authConf.oauth.authCodeUrl, {
80117
client_id: authConf.oauth.clientId,
@@ -89,17 +126,7 @@ export class ConfiguredIdpOAuth extends OAuth {
89126
/* eslint-enable @typescript-eslint/naming-convention */
90127
}
91128

92-
private static async getAuthRes({
93-
acctEmail,
94-
expectedState,
95-
authUrl,
96-
authConf,
97-
}: {
98-
acctEmail: string;
99-
expectedState: string;
100-
authUrl: string;
101-
authConf: AuthenticationConfiguration;
102-
}): Promise<AuthRes> {
129+
private static async getAuthRes({ acctEmail, expectedState, authUrl }: { acctEmail: string; expectedState: string; authUrl: string }): Promise<AuthRes> {
103130
/* eslint-disable @typescript-eslint/naming-convention */
104131
try {
105132
const redirectUri = await chrome.identity.launchWebAuthFlow({ url: authUrl, interactive: true });
@@ -124,7 +151,7 @@ export class ConfiguredIdpOAuth extends OAuth {
124151
if (receivedState !== expectedState) {
125152
return { acctEmail, result: 'Error', error: `Wrong oauth CSRF token. Please try again.`, id_token: undefined };
126153
}
127-
const { id_token } = await this.authGetTokens(code, authConf);
154+
const { id_token } = await this.retrieveAndSaveAuthToken(acctEmail, code);
128155
const { email } = this.parseIdToken(id_token);
129156
if (!email) {
130157
throw new Error('Missing email address in id_token');
@@ -137,15 +164,36 @@ export class ConfiguredIdpOAuth extends OAuth {
137164
id_token: undefined,
138165
};
139166
}
140-
await InMemoryStore.set(acctEmail, InMemoryStoreKeys.CUSTOM_IDP_ID_TOKEN, id_token);
141167
return { acctEmail: email, result: 'Success', id_token };
142168
} catch (err) {
143169
return { acctEmail, result: 'Error', error: err instanceof AssertError ? 'Could not parse URL returned from OAuth' : String(err), id_token: undefined };
144170
}
145171
/* eslint-enable @typescript-eslint/naming-convention */
146172
}
147173

148-
private static async authGetTokens(code: string, authConf: AuthenticationConfiguration): Promise<OAuthTokensResponse> {
174+
// eslint-disable-next-line @typescript-eslint/naming-convention
175+
private static async retrieveAndSaveAuthToken(acctEmail: string, authCode: string): Promise<{ id_token: string }> {
176+
const tokensObj = await this.authGetTokens(acctEmail, authCode);
177+
const claims = this.parseIdToken(tokensObj.id_token);
178+
if (!claims.email) {
179+
throw new Error('Missing email address in id_token');
180+
}
181+
await this.authSaveTokens(claims.email, tokensObj);
182+
return { id_token: tokensObj.id_token }; // eslint-disable-line @typescript-eslint/naming-convention
183+
}
184+
185+
private static async authSaveTokens(acctEmail: string, tokensObj: OAuthTokensResponse) {
186+
const tokenExpires = new Date().getTime() + (tokensObj.expires_in - 120) * 1000; // let our copy expire 2 minutes beforehand
187+
const toSave: AcctStoreDict = {};
188+
if (typeof tokensObj.refresh_token !== 'undefined') {
189+
toSave.custom_idp_token_refresh = tokensObj.refresh_token;
190+
}
191+
await AcctStore.set(acctEmail, toSave);
192+
await InMemoryStore.set(acctEmail, InMemoryStoreKeys.CUSTOM_IDP_ID_TOKEN, tokensObj.id_token, tokenExpires);
193+
}
194+
195+
private static async authGetTokens(acctEmail: string, code: string): Promise<OAuthTokensResponse> {
196+
const authConf = await this.getAuthenticationConfiguration(acctEmail);
149197
return await Api.ajax(
150198
{
151199
/* eslint-disable @typescript-eslint/naming-convention */
@@ -164,4 +212,25 @@ export class ConfiguredIdpOAuth extends OAuth {
164212
'json'
165213
);
166214
}
215+
216+
private static async getAuthenticationConfiguration(acctEmail: string): Promise<AuthenticationConfiguration> {
217+
const storage = await AcctStore.get(acctEmail, ['authentication']);
218+
if (!storage.authentication) {
219+
throw new EnterpriseServerAuthErr('Could not get authentication configuration');
220+
}
221+
return storage.authentication;
222+
}
223+
224+
private static async getAuthHeaderDependsOnType(acctEmail: string): Promise<AuthorizationHeader | undefined> {
225+
let idToken = await InMemoryStore.getUntilAvailable(acctEmail, InMemoryStoreKeys.ID_TOKEN);
226+
const storage = await AcctStore.get(acctEmail, ['authentication']);
227+
if (storage.authentication?.oauth) {
228+
// If custom authentication (IDP) is used, return the custom IDP ID token if available.
229+
// If the custom IDP ID token is not found, throw an EnterpriseServerAuthErr.
230+
// The custom IDP ID token should be used for Enterprise Server authentication instead of the Google JWT.
231+
// https://github.com/FlowCrypt/flowcrypt-browser/issues/5799
232+
idToken = await InMemoryStore.get(acctEmail, InMemoryStoreKeys.CUSTOM_IDP_ID_TOKEN);
233+
}
234+
return idToken ? { authorization: `Bearer ${idToken}` } : undefined;
235+
}
167236
}

extension/js/common/api/authentication/generic/oauth.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ export type OAuthTokensResponse = {
3131
};
3232
/* eslint-enable @typescript-eslint/naming-convention */
3333

34+
export type AuthorizationHeader = {
35+
authorization: string;
36+
};
37+
3438
export class OAuth {
3539
/* eslint-disable @typescript-eslint/naming-convention */
3640
public static GOOGLE_OAUTH_CONFIG = {

0 commit comments

Comments
 (0)