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

#5444 ConfiguredIDPOAuth shows authentication popup and save JWT to storage #5795

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
Loading