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

155 changes: 144 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,154 @@

'use strict';

import { GoogleOAuth } from './google/google-oauth.js';
import { Ui } from '../../browser/ui.js';
// import { AcctStore } from '../../platform/store/acct-store.js';
ioanmo226 marked this conversation as resolved.
Show resolved Hide resolved
import { AuthRes, OAuth, OAuthTokensResponse } from './generic/oauth.js';
import { AuthenticationConfiguration } from '../../authentication-configuration.js';
import { Url } from '../../core/common.js';
import { OAuth2 } from '../../oauth2/oauth2.js';
import { Bm } from '../../browser/browser-msg.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';

// import { GoogleOAuth } from './google/google-oauth.js';
ioanmo226 marked this conversation as resolved.
Show resolved Hide resolved
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) {
return await this.newAuthPopup(acctEmail, { oauth: storage.authentication.oauth });
}
return authRes;
};

public static async newAuthPopup(acctEmail: string, authConf: AuthenticationConfiguration): Promise<AuthRes> {
acctEmail = acctEmail?.toLowerCase();
ioanmo226 marked this conversation as resolved.
Show resolved Hide resolved
const authRequest = this.newAuthRequest(acctEmail, this.OAUTH_REQUEST_SCOPES);
const authUrl = this.apiOAuthCodeUrl(authConf, authRequest.expectedState, acctEmail);
// Added below logic because in service worker, it's not possible to access window object.
// Therefore need to retrieve screenDimensions when calling service worker and pass it to OAuth2
const screenDimensions = Ui.getScreenDimensions();
const authWindowResult = await OAuth2.webAuthFlow(authUrl, screenDimensions);
const authRes = await this.getAuthRes({
acctEmail,
expectedState: authRequest.expectedState,
authWindowResult,
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: authConf.oauth.redirectUrl,
scope: this.OAUTH_REQUEST_SCOPES.join(' '),
login_hint: acctEmail,
});
/* eslint-enable @typescript-eslint/naming-convention */
}

private static async getAuthRes({
acctEmail,
expectedState,
authWindowResult,
authConf,
}: {
acctEmail: string;
expectedState: string;
authWindowResult: Bm.AuthWindowResult;
authConf: AuthenticationConfiguration;
}): Promise<AuthRes> {
/* eslint-disable @typescript-eslint/naming-convention */
try {
if (!authWindowResult.url) {
return { acctEmail, result: 'Denied', error: 'Invalid response url', id_token: undefined };
}
if (authWindowResult.error) {
return { acctEmail, result: 'Denied', error: authWindowResult.error, id_token: undefined };
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const uncheckedUrlParams = Url.parse(['scope', 'code', 'state'], authWindowResult.url);
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,
client_secret: authConf.oauth.clientSecret,
redirect_uri: authConf.oauth.redirectUrl,
},
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