Skip to content

Commit 0eee059

Browse files
author
Ioan Moldovan
authored
#5799 Use custom idp token for enterprise server authentication if special jwt is stored in local store (#5802)
* feat: use custom idp token for enterprise server authentication if special jwt is stored in local store * fix: remove unused code * fix: simplify * feat: use custom idp id token for ekm too * fix: ui test * fix: logic to throw error * fix: pr reviews
1 parent a9fd800 commit 0eee059

File tree

10 files changed

+76
-55
lines changed

10 files changed

+76
-55
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export class SetupWithEmailKeyManagerModule {
5555
/* eslint-enable @typescript-eslint/naming-convention */
5656
try {
5757
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
58-
const { privateKeys } = await this.view.keyManager!.getPrivateKeys(this.view.idToken!);
58+
const { privateKeys } = await this.view.keyManager!.getPrivateKeys(this.view.acctEmail);
5959
if (privateKeys.length) {
6060
// keys already exist on keyserver, auto-import
6161
try {
@@ -115,7 +115,7 @@ export class SetupWithEmailKeyManagerModule {
115115
}
116116
const storePrvOnKm = async () => {
117117
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
118-
await this.view.keyManager!.storePrivateKey(this.view.idToken!, KeyUtil.armor(decryptablePrv));
118+
await this.view.keyManager!.storePrivateKey(this.view.acctEmail, KeyUtil.armor(decryptablePrv));
119119
};
120120
await Settings.retryUntilSuccessful(
121121
storePrvOnKm,

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

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,16 @@ import { Api, ProgressCb, ProgressCbs } from '../shared/api.js';
55
import { AcctStore } from '../../platform/store/acct-store.js';
66
import { Dict, Str } from '../../core/common.js';
77
import { ErrorReport } from '../../platform/catch.js';
8-
import { ApiErr, BackendAuthErr } from '../shared/api-error.js';
9-
import { FLAVOR, InMemoryStoreKeys } from '../../core/const.js';
8+
import { ApiErr } from '../shared/api-error.js';
9+
import { FLAVOR } from '../../core/const.js';
1010
import { Attachment } from '../../core/attachment.js';
1111
import { ParsedRecipients } from '../email-provider/email-provider-api.js';
1212
import { Buf } from '../../core/buf.js';
1313
import { ClientConfigurationError, ClientConfigurationJson } from '../../client-configuration.js';
14-
import { InMemoryStore } from '../../platform/store/in-memory-store.js';
1514
import { Serializable } from '../../platform/store/abstract-store.js';
1615
import { AuthenticationConfiguration } from '../../authentication-configuration.js';
1716
import { Xss } from '../../platform/xss.js';
17+
import { ConfiguredIdpOAuth } from '../authentication/configured-idp-oauth.js';
1818

1919
// todo - decide which tags to use
2020
type EventTag = 'compose' | 'decrypt' | 'setup' | 'settings' | 'import-pub' | 'import-prv';
@@ -160,15 +160,6 @@ export class ExternalService extends Api {
160160
});
161161
};
162162

163-
private authHdr = async (): Promise<{ authorization: string }> => {
164-
const idToken = await InMemoryStore.getUntilAvailable(this.acctEmail, InMemoryStoreKeys.ID_TOKEN);
165-
if (idToken) {
166-
return { authorization: `Bearer ${idToken}` };
167-
}
168-
// user will not actually see this message, they'll see a generic login prompt
169-
throw new BackendAuthErr('Missing id token, please re-authenticate');
170-
};
171-
172163
private request = async <RT>(
173164
path: string,
174165
vals?:
@@ -192,6 +183,6 @@ export class ExternalService extends Api {
192183
method: 'POST',
193184
}
194185
: undefined;
195-
return await ExternalService.apiCall(this.url, path, values, progress, await this.authHdr(), 'json');
186+
return await ExternalService.apiCall(this.url, path, values, progress, await ConfiguredIdpOAuth.authHdr(this.acctEmail), 'json');
196187
};
197188
}

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Catch } from '../../platform/catch.js';
1212
import { InMemoryStoreKeys } from '../../core/const.js';
1313
import { InMemoryStore } from '../../platform/store/in-memory-store.js';
1414
import { AcctStore } from '../../platform/store/acct-store.js';
15+
import { BackendAuthErr } from '../shared/api-error.js';
1516
export class ConfiguredIdpOAuth extends OAuth {
1617
public static newAuthPopupForEnterpriseServerAuthenticationIfNeeded = async (authRes: AuthRes) => {
1718
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -24,6 +25,24 @@ export class ConfiguredIdpOAuth extends OAuth {
2425
return authRes;
2526
};
2627

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;
36+
}
37+
return { authorization: `Bearer ${idToken}` };
38+
}
39+
if (shouldThrowErrorForEmptyIdToken) {
40+
// user will not actually see this message, they'll see a generic login prompt
41+
throw new BackendAuthErr('Missing id token, please re-authenticate');
42+
}
43+
return undefined;
44+
};
45+
2746
public static async newAuthPopup(acctEmail: string, authConf: AuthenticationConfiguration): Promise<AuthRes> {
2847
acctEmail = acctEmail.toLowerCase();
2948
const authRequest = this.newAuthRequest(acctEmail, this.OAUTH_REQUEST_SCOPES);

extension/js/common/api/key-server/key-manager.ts

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

55
import { Api } from './../shared/api.js';
66
import { Url } from '../../core/common.js';
7+
import { ConfiguredIdpOAuth } from '../authentication/configured-idp-oauth.js';
78

89
type LoadPrvRes = { privateKeys: { decryptedPrivateKey: string }[] };
910

@@ -15,17 +16,17 @@ export class KeyManager extends Api {
1516
this.url = Url.removeTrailingSlash(url);
1617
}
1718

18-
public getPrivateKeys = async (idToken: string): Promise<LoadPrvRes> => {
19-
return await Api.apiCall(this.url, '/v1/keys/private', undefined, undefined, idToken ? { authorization: `Bearer ${idToken}` } : undefined, 'json');
19+
public getPrivateKeys = async (acctEmail: string): Promise<LoadPrvRes> => {
20+
return await Api.apiCall(this.url, '/v1/keys/private', undefined, undefined, await ConfiguredIdpOAuth.authHdr(acctEmail, false), 'json');
2021
};
2122

22-
public storePrivateKey = async (idToken: string, privateKey: string): Promise<void> => {
23+
public storePrivateKey = async (acctEmail: string, privateKey: string): Promise<void> => {
2324
await Api.apiCall(
2425
this.url,
2526
'/v1/keys/private',
2627
{ data: { privateKey }, fmt: 'JSON', method: 'PUT' },
2728
undefined,
28-
idToken ? { authorization: `Bearer ${idToken}` } : undefined
29+
await ConfiguredIdpOAuth.authHdr(acctEmail, false)
2930
);
3031
};
3132
}

extension/js/content_scripts/webmail/generic/setup-webmail-content-script.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ export const contentScriptSetupIfVacant = async (webmailSpecific: WebmailSpecifi
349349
const keyManager = new KeyManager(clientConfiguration.getKeyManagerUrlForPrivateKeys()!);
350350
Catch.setHandledTimeout(async () => {
351351
try {
352-
const { privateKeys } = await keyManager.getPrivateKeys(idToken);
352+
const { privateKeys } = await keyManager.getPrivateKeys(acctEmail);
353353
await processKeysFromEkm(
354354
acctEmail,
355355
privateKeys.map(entry => entry.decryptedPrivateKey),

test/source/mock/fes/customer-url-fes-endpoints.ts

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ import { FesConfig } from './shared-tenant-fes-endpoints';
1111
const standardFesUrl = (port: string) => {
1212
return `fes.standardsubdomainfes.localhost:${port}`;
1313
};
14-
const issuedAccessTokens: string[] = [];
14+
export const issuedGoogleIDPIdTokens: string[] = [];
15+
export const issuedCustomIDPIdTokens: string[] = [];
1516

1617
// eslint-disable-next-line @typescript-eslint/naming-convention
1718
export const standardSubDomainFesClientConfiguration = { flags: [], disallow_attester_search_for_domains: ['[email protected]'] };
1819
export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): HandlersDefinition => {
20+
const isCustomIDPUsed = !!config?.authenticationConfiguration;
1921
return {
2022
// standard fes location at https://fes.domain.com
2123
'/api/': async ({}, req) => {
@@ -57,7 +59,7 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H
5759
},
5860
'/api/v1/message/new-reply-token': async ({}, req) => {
5961
if (parseAuthority(req) === standardFesUrl(parsePort(req)) && req.method === 'POST') {
60-
authenticate(req, 'oidc');
62+
authenticate(req, isCustomIDPUsed);
6163
return { replyToken: 'mock-fes-reply-token' };
6264
}
6365
throw new HttpClientErr('Not Found', 404);
@@ -67,7 +69,7 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H
6769
const fesUrl = standardFesUrl(port);
6870
// body is a mime-multipart string, we're doing a few smoke checks here without parsing it
6971
if (parseAuthority(req) === fesUrl && req.method === 'POST' && typeof body === 'string') {
70-
authenticate(req, 'oidc');
72+
authenticate(req, isCustomIDPUsed);
7173
if (config?.messagePostValidator) {
7274
return await config.messagePostValidator(body, fesUrl);
7375
}
@@ -79,7 +81,7 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H
7981
const port = parsePort(req);
8082
if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') {
8183
// test: `compose - [email protected]:8001 - PWD encrypted message with FES web portal`
82-
authenticate(req, 'oidc');
84+
authenticate(req, isCustomIDPUsed);
8385
expect(body).to.match(messageIdRegex(port));
8486
return {};
8587
}
@@ -89,7 +91,7 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H
8991
const port = parsePort(req);
9092
if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') {
9193
// test: `compose - [email protected]:8001 - PWD encrypted message with FES - Reply rendering`
92-
authenticate(req, 'oidc');
94+
authenticate(req, isCustomIDPUsed);
9395
expect(body).to.match(messageIdRegex(port));
9496
return {};
9597
}
@@ -102,7 +104,7 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H
102104
// test: `compose - [email protected]:8001 - PWD encrypted message with FES - Reply rendering`
103105
// test: `compose - [email protected]:8001 - PWD encrypted message with FES web portal - pubkey recipient in bcc`
104106
// test: `compose - [email protected]:8001 - PWD encrypted message with FES web portal - some sends fail with BadRequest error`
105-
authenticate(req, 'oidc');
107+
authenticate(req, isCustomIDPUsed);
106108
expect(body).to.match(messageIdRegex(port));
107109
return {};
108110
}
@@ -112,7 +114,7 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H
112114
const port = parsePort(req);
113115
if (parseAuthority(req) === standardFesUrl(port) && req.method === 'POST') {
114116
// test: `compose - [email protected]:8001 - PWD encrypted message with FES web portal`
115-
authenticate(req, 'oidc');
117+
authenticate(req, isCustomIDPUsed);
116118
expect(body).to.match(messageIdRegex(port));
117119
return {};
118120
}
@@ -125,20 +127,15 @@ export const getMockCustomerUrlFesEndpoints = (config: FesConfig | undefined): H
125127
};
126128
};
127129

128-
const authenticate = (req: { headers: IncomingHttpHeaders }, type: 'oidc' | 'fes'): string => {
130+
const authenticate = (req: { headers: IncomingHttpHeaders }, isCustomIDPUsed: boolean): string => {
129131
const jwt = (req.headers.authorization || '').replace('Bearer ', '');
130132
if (!jwt) {
131133
throw new Error('Mock FES missing authorization header');
132134
}
133-
if (type === 'oidc') {
134-
if (issuedAccessTokens.includes(jwt)) {
135-
throw new Error('Mock FES access-token call wrongly with FES token');
136-
}
137-
} else {
138-
// fes
139-
if (!issuedAccessTokens.includes(jwt)) {
140-
throw new HttpClientErr('FES mock received access token it didnt issue', 401);
141-
}
135+
const issuedTokens = isCustomIDPUsed ? issuedCustomIDPIdTokens : issuedGoogleIDPIdTokens;
136+
137+
if (!issuedTokens.includes(jwt)) {
138+
throw new HttpClientErr('FES mock received access token it didnt issue', 401);
142139
}
143140
return MockJwt.parseEmail(jwt);
144141
};

test/source/mock/google/google-endpoints.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,15 +148,15 @@ export const getMockGoogleEndpoints = (oauth: OauthMock, config: GoogleConfig |
148148
if (isPost(req)) {
149149
if (grant_type === 'authorization_code' && code && client_id === oauth.clientId) {
150150
// auth code from auth screen gets exchanged for access and refresh tokens
151-
return oauth.getRefreshTokenResponse(code);
151+
return oauth.getRefreshTokenResponse(code, false);
152152
} else if (grant_type === 'refresh_token' && refreshToken && client_id === oauth.clientId) {
153153
// here also later refresh token gets exchanged for access token
154-
return oauth.getTokenResponse(refreshToken);
154+
return oauth.getTokenResponse(refreshToken, false);
155155
}
156156
const parsedBody = body as OAuthTokenRequestModel;
157157
// Above is for Google OAuth and this is for normal OAuth
158158
if (parsedBody.grant_type === 'authorization_code' && parsedBody.code && parsedBody.client_id === OauthMock.customIDPClientId) {
159-
return oauth.getRefreshTokenResponse(parsedBody.code);
159+
return oauth.getRefreshTokenResponse(parsedBody.code, true);
160160
}
161161
}
162162
throw new Error(`Method not implemented for ${req.url}: ${req.method}`);

test/source/mock/lib/oauth.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { HttpClientErr, Status } from './api';
44

55
import { Buf } from '../../core/buf';
66
import { Str } from '../../core/common';
7+
import { issuedCustomIDPIdTokens, issuedGoogleIDPIdTokens } from '../fes/customer-url-fes-endpoints';
78

89
const authURL = 'https://localhost:8001';
910

@@ -21,6 +22,15 @@ export class OauthMock {
2122
private acctByIdToken: { [acct: string]: string } = {};
2223
private issuedIdTokensByAcct: { [acct: string]: string[] } = {};
2324

25+
public static getCustomIDPOAuthConfig = (port: number | undefined) => {
26+
return {
27+
clientId: OauthMock.customIDPClientId,
28+
clientSecret: OauthMock.customIDPClientSecret,
29+
redirectUrl: `custom-redirect-url`, // This won't be used as we use our https://{id}.chromiumapp.org with chrome.identity.getRedirectURL
30+
authCodeUrl: `https://localhost:${port}/o/oauth2/auth`,
31+
tokensUrl: `https://localhost:${port}/token`,
32+
};
33+
};
2434
public renderText = (text: string) => {
2535
return this.htmlPage(text, text);
2636
};
@@ -43,12 +53,12 @@ export class OauthMock {
4353
return url.href;
4454
};
4555

46-
public getRefreshTokenResponse = (code: string) => {
56+
public getRefreshTokenResponse = (code: string, isCustomIDPAuth: boolean) => {
4757
/* eslint-disable @typescript-eslint/naming-convention */
4858
const refresh_token = this.refreshTokenByAuthCode[code];
4959
const access_token = this.getAccessToken(refresh_token);
5060
const acct = this.acctByAccessToken[access_token];
51-
const id_token = this.generateIdToken(acct);
61+
const id_token = this.generateIdToken(acct, isCustomIDPAuth);
5262
return { access_token, refresh_token, expires_in: this.expiresIn, id_token, token_type: 'refresh_token' }; // guessed the token_type
5363
/* eslint-enable @typescript-eslint/naming-convention */
5464
};
@@ -62,12 +72,12 @@ export class OauthMock {
6272
};
6373
};
6474

65-
public getTokenResponse = (refreshToken: string) => {
75+
public getTokenResponse = (refreshToken: string, isCustomIDPAuth: boolean) => {
6676
try {
6777
/* eslint-disable @typescript-eslint/naming-convention */
6878
const access_token = this.getAccessToken(refreshToken);
6979
const acct = this.acctByAccessToken[access_token];
70-
const id_token = this.generateIdToken(acct);
80+
const id_token = this.generateIdToken(acct, isCustomIDPAuth);
7181
return { access_token, expires_in: this.expiresIn, id_token, token_type: 'Bearer' };
7282
/* eslint-enable @typescript-eslint/naming-convention */
7383
} catch (e) {
@@ -141,13 +151,18 @@ export class OauthMock {
141151

142152
// -- private
143153

144-
private generateIdToken = (email: string): string => {
154+
private generateIdToken = (email: string, isCustomIDPAuth: boolean): string => {
145155
const newIdToken = MockJwt.new(email, this.expiresIn);
146156
if (!this.issuedIdTokensByAcct[email]) {
147157
this.issuedIdTokensByAcct[email] = [];
148158
}
149159
this.issuedIdTokensByAcct[email].push(newIdToken);
150160
this.acctByIdToken[newIdToken] = email;
161+
if (isCustomIDPAuth) {
162+
issuedCustomIDPIdTokens.push(newIdToken);
163+
} else {
164+
issuedGoogleIDPIdTokens.push(newIdToken);
165+
}
151166
return newIdToken;
152167
};
153168

test/source/tests/compose.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
import { Buf } from '../core/buf';
4444
import { flowcryptCompatibilityAliasList, flowcryptCompatibilityPrimarySignature } from '../mock/google/google-endpoints';
4545
import { standardSubDomainFesClientConfiguration } from '../mock/fes/customer-url-fes-endpoints';
46+
import { OauthMock } from '../mock/lib/oauth';
4647

4748
export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: TestWithBrowser) => {
4849
if (testVariant !== 'CONSUMER-LIVE-GMAIL') {
@@ -3007,18 +3008,21 @@ export const defineComposeTests = (testVariant: TestVariant, testWithBrowser: Te
30073008
test(
30083009
'[email protected]:8001 - PWD encrypted message with FES web portal',
30093010
testWithBrowser(async (t, browser) => {
3011+
const port = t.context.urls?.port;
30103012
t.context.mockApi!.configProvider = new ConfigurationProvider({
30113013
attester: {
30123014
pubkeyLookup: {},
30133015
},
30143016
fes: {
3017+
authenticationConfiguration: {
3018+
oauth: OauthMock.getCustomIDPOAuthConfig(port),
3019+
},
30153020
messagePostValidator: processMessageFromUser,
30163021
clientConfiguration: standardSubDomainFesClientConfiguration,
30173022
},
30183023
});
3019-
const port = t.context.urls?.port;
30203024
const acct = `[email protected]:${port}`; // added port to trick extension into calling the mock
3021-
const settingsPage = await BrowserRecipe.openSettingsLoginApprove(t, browser, acct);
3025+
const settingsPage = await BrowserRecipe.openSettingsLoginApprove(t, browser, acct, true);
30223026
await SetupPageRecipe.manualEnter(
30233027
settingsPage,
30243028
'flowcrypt.test.key.used.pgp',

test/source/tests/setup.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2526,14 +2526,8 @@ AN8G3r5Htj8olot+jm9mIa5XLXWzMNUZgg==
25262526
test(
25272527
'setup - check custom authentication config from the local store (customer url fes)',
25282528
testWithBrowser(async (t, browser) => {
2529-
const port = t.context.urls?.port ?? '';
2530-
const oauthConfig = {
2531-
clientId: OauthMock.customIDPClientId,
2532-
clientSecret: OauthMock.customIDPClientSecret,
2533-
redirectUrl: `custom-redirect-url`, // This won't be used as we use our https://{id}.chromiumapp.org with chrome.identity.getRedirectURL
2534-
authCodeUrl: `https://localhost:${port}/o/oauth2/auth`,
2535-
tokensUrl: `https://localhost:${port}/token`,
2536-
};
2529+
const port = t.context.urls?.port;
2530+
const oauthConfig = OauthMock.getCustomIDPOAuthConfig(port);
25372531
t.context.mockApi!.configProvider = new ConfigurationProvider({
25382532
attester: {
25392533
pubkeyLookup: {},

0 commit comments

Comments
 (0)