Skip to content

Commit

Permalink
#5444 ConfiguredIDPOAuth shows authentication popup and save JWT to s…
Browse files Browse the repository at this point in the history
…torage (#5795)

* WIP: add custom idp implementation

* WIP: added ui test

* fix: ui test

* fix: refactor

* fix: typo

* fix: test client id

* fix: pr reviews

* fix: pr reviews

* feat: use chrome.identity.launchWebAuthFlow and updated UI test
  • Loading branch information
ioanmo226 authored Aug 2, 2024
1 parent 8a22205 commit 0ead411
Show file tree
Hide file tree
Showing 16 changed files with 328 additions and 133 deletions.
148 changes: 137 additions & 11 deletions extension/js/common/api/authentication/configured-idp-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,147 @@

'use strict';

import { GoogleOAuth } from './google/google-oauth.js';
import { Ui } from '../../browser/ui.js';
import { 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 { OAuth } from './generic/oauth.js';

export class ConfiguredIdpOAuth extends OAuth {
public static newAuthPopupForEnterpriseServerAuthenticationIfNeeded = async (acctEmail: string) => {
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 !== GoogleOAuth.OAUTH.client_id) {
await Ui.modal.warning(
`Custom IdP is configured on this domain, but it is not supported on browser extension yet.
Authentication with Enterprise Server will continue using Google IdP until implemented in a future update.`
);
} else {
return;
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 authRes;
};

public static async newAuthPopup(acctEmail: string, authConf: AuthenticationConfiguration): Promise<AuthRes> {
acctEmail = acctEmail.toLowerCase();
const authRequest = this.newAuthRequest(acctEmail, this.OAUTH_REQUEST_SCOPES);
const authUrl = this.apiOAuthCodeUrl(authConf, authRequest.expectedState, acctEmail);
const authRes = await this.getAuthRes({
acctEmail,
expectedState: authRequest.expectedState,
authUrl,
authConf,
});
if (authRes.result === 'Success') {
if (!authRes.id_token) {
return {
result: 'Error',
error: 'Grant was successful but missing id_token',
acctEmail,
id_token: undefined, // eslint-disable-line @typescript-eslint/naming-convention
};
}
if (!authRes.acctEmail) {
return {
result: 'Error',
error: 'Grant was successful but missing acctEmail',
acctEmail: authRes.acctEmail,
id_token: undefined, // eslint-disable-line @typescript-eslint/naming-convention
};
}
}
return authRes;
}

private static apiOAuthCodeUrl(authConf: AuthenticationConfiguration, state: string, acctEmail: string) {
/* eslint-disable @typescript-eslint/naming-convention */
return Url.create(authConf.oauth.authCodeUrl, {
client_id: authConf.oauth.clientId,
response_type: 'code',
access_type: 'offline',
prompt: 'login',
state,
redirect_uri: chrome.identity.getRedirectURL('oauth'),
scope: this.OAUTH_REQUEST_SCOPES.join(' '),
login_hint: acctEmail,
});
/* eslint-enable @typescript-eslint/naming-convention */
}

private static async getAuthRes({
acctEmail,
expectedState,
authUrl,
authConf,
}: {
acctEmail: string;
expectedState: string;
authUrl: string;
authConf: AuthenticationConfiguration;
}): Promise<AuthRes> {
/* eslint-disable @typescript-eslint/naming-convention */
try {
const redirectUri = await chrome.identity.launchWebAuthFlow({ url: authUrl, interactive: true });
if (chrome.runtime.lastError || !redirectUri || redirectUri?.includes('access_denied')) {
return { acctEmail, result: 'Denied', error: `Failed to launch web auth flow`, id_token: undefined };
}

if (!redirectUri) {
return { acctEmail, result: 'Denied', error: 'Invalid response url', id_token: undefined };
}
const uncheckedUrlParams = Url.parse(['scope', 'code', 'state'], redirectUri);
const code = Assert.urlParamRequire.optionalString(uncheckedUrlParams, 'code');
const receivedState = Assert.urlParamRequire.string(uncheckedUrlParams, 'state');
if (!code) {
return {
acctEmail,
result: 'Denied',
error: "OAuth result was 'Success' but no auth code",
id_token: undefined,
};
}
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 { email } = this.parseIdToken(id_token);
if (!email) {
throw new Error('Missing email address in id_token');
}
if (acctEmail !== email) {
return {
acctEmail,
result: 'Error',
error: `Google account email and custom IDP email do not match. Please use the same email address..`,
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> {
return await Api.ajax(
{
/* eslint-disable @typescript-eslint/naming-convention */
url: authConf.oauth.tokensUrl,
method: 'POST',
data: {
grant_type: 'authorization_code',
code,
client_id: authConf.oauth.clientId,
redirect_uri: chrome.identity.getRedirectURL('oauth'),
},
dataType: 'JSON',
/* eslint-enable @typescript-eslint/naming-convention */
stack: Catch.stackTrace(),
},
'json'
);
}
}
60 changes: 60 additions & 0 deletions extension/js/common/api/authentication/generic/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,59 @@

'use strict';

import { GoogleAuthWindowResult$result } from '../../../browser/browser-msg.js';
import { Buf } from '../../../core/buf.js';
import { Str } from '../../../core/common.js';
import { GOOGLE_OAUTH_SCREEN_HOST, OAUTH_GOOGLE_API_HOST } from '../../../core/const.js';
import { GmailRes } from '../../email-provider/gmail/gmail-parser.js';
import { Api } from '../../shared/api.js';

export type AuthReq = { acctEmail?: string; scopes: string[]; messageId?: string; expectedState: string };
// eslint-disable-next-line @typescript-eslint/naming-convention
type AuthResultSuccess = { result: 'Success'; acctEmail: string; id_token: string; error?: undefined };
type AuthResultError = {
result: GoogleAuthWindowResult$result;
acctEmail?: string;
error?: string;
// eslint-disable-next-line @typescript-eslint/naming-convention
id_token: undefined;
};
export type AuthRes = AuthResultSuccess | AuthResultError;

/* eslint-disable @typescript-eslint/naming-convention */
export type OAuthTokensResponse = {
access_token: string;
expires_in: number;
refresh_token?: string;
id_token: string;
token_type: 'Bearer';
};
/* eslint-enable @typescript-eslint/naming-convention */

export class OAuth {
/* eslint-disable @typescript-eslint/naming-convention */
public static GOOGLE_OAUTH_CONFIG = {
client_id: '717284730244-5oejn54f10gnrektjdc4fv4rbic1bj1p.apps.googleusercontent.com',
client_secret: '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',
redirect_uri: 'https://www.google.com/robots.txt',
url_code: `${GOOGLE_OAUTH_SCREEN_HOST}/o/oauth2/auth`,
url_tokens: `${OAUTH_GOOGLE_API_HOST}/token`,
state_header: 'CRYPTUP_STATE_',
scopes: {
email: 'email',
openid: 'openid',
profile: 'https://www.googleapis.com/auth/userinfo.profile', // needed so that `name` is present in `id_token`, which is required for key-server auth when in use
compose: 'https://www.googleapis.com/auth/gmail.compose',
modify: 'https://www.googleapis.com/auth/gmail.modify',
readContacts: 'https://www.googleapis.com/auth/contacts.readonly',
readOtherContacts: 'https://www.googleapis.com/auth/contacts.other.readonly',
},
legacy_scopes: {
gmail: 'https://mail.google.com/', // causes a freakish oauth warn: "can permannently delete all your email" ...
},
};
public static OAUTH_REQUEST_SCOPES = ['offline_access', 'openid', 'profile', 'email'];
/* eslint-enable @typescript-eslint/naming-convention */
/**
* Happens on enterprise builds
*/
Expand All @@ -32,4 +80,16 @@ export class OAuth {
}
return claims;
};

public static newAuthRequest(acctEmail: string | undefined, scopes: string[]): AuthReq {
const authReq = {
acctEmail,
scopes,
csrfToken: `csrf-${Api.randomFortyHexChars()}`,
};
return {
...authReq,
expectedState: `CRYPTUP_STATE_${JSON.stringify(authReq)}`,
};
}
}
Loading

0 comments on commit 0ead411

Please sign in to comment.