Switch to OAuth for Office 365 accounts now that MSFT support is live! #1118

This commit is contained in:
Ben Gotow 2020-08-17 22:59:22 -05:00
parent c0d816ab96
commit 052f6dd5fe
9 changed files with 192 additions and 40 deletions

View file

@ -6,7 +6,7 @@ const AccountProviders = [
displayName: 'Gmail or G Suite',
icon: 'ic-settings-account-gmail.png',
headerIcon: 'setup-icon-provider-gmail.png',
color: '#e99999',
color: '#FFFFFF00',
},
{
provider: 'office365',

View file

@ -13,7 +13,7 @@ interface OAuthSignInPageProps {
buildAccountFromAuthResponse: (rep: any) => Account | Promise<Account>;
onSuccess: (account: Account) => void;
onTryAgain: () => void;
iconName: string;
providerConfig: object;
serviceName: string;
}
@ -200,7 +200,8 @@ export default class OAuthSignInPage extends React.Component<
<div className={`page account-setup ${this.props.serviceName.toLowerCase()}`}>
<div className="logo-container">
<RetinaImg
name={this.props.iconName}
name={this.props.providerConfig.headerIcon}
style={{ backgroundColor: this.props.providerConfig.color, borderRadius: 44 }}
mode={RetinaImg.Mode.ContentPreserve}
className="logo"
/>

View file

@ -1,25 +1,66 @@
/* eslint global-require: 0 */
import qs from 'querystring';
import crypto from 'crypto';
import { Account, IdentityStore, MailsyncProcess } from 'mailspring-exports';
import uuidv4 from 'uuid/v4';
import { Account, IdentityStore, MailsyncProcess, localized } from 'mailspring-exports';
import MailspringProviderSettings from './mailspring-provider-settings.json';
import MailcoreProviderSettings from './mailcore-provider-settings.json';
import dns from 'dns';
export const LOCAL_SERVER_PORT = 12141;
export const LOCAL_REDIRECT_URI = `http://127.0.0.1:${LOCAL_SERVER_PORT}`;
const GMAIL_CLIENT_ID =
process.env.MS_GMAIL_CLIENT_ID ||
'662287800555-0a5h4ii0e9hsbpq0mqtul7fja0jhf9uf.apps.googleusercontent.com';
const O365_CLIENT_ID = process.env.MS_O365_CLIENT_ID || '8787a430-6eee-41e1-b914-681d90d35625';
const GMAIL_SCOPES = [
'https://mail.google.com/', // email
'https://www.googleapis.com/auth/userinfo.email', // email address
'https://www.googleapis.com/auth/userinfo.profile', // G+ profile
'https://mail.google.com/', // email
'https://www.googleapis.com/auth/contacts', // contacts
'https://www.googleapis.com/auth/calendar', // calendar
];
const O365_SCOPES = [
'user.read', // email address
'offline_access',
'Contacts.ReadWrite', // contacts
'Contacts.ReadWrite.Shared', // contacts
'Calendars.ReadWrite', // calendar
'Calendars.ReadWrite.Shared', // calendar
// Future note: When you exchane the refresh token for an access token, you may
// request these two OR the above set but NOT BOTH, because Microsoft has mapped
// two underlying systems with different tokens onto the single flow and you
// need to get an outlook token and not a Micrsosoft Graph token to use these APIs.
// https://stackoverflow.com/questions/61597263/
'https://outlook.office.com/IMAP.AccessAsUser.All', // email
'https://outlook.office.com/SMTP.Send', // email
];
// Re-created only at onboarding page load / auth session start because storing
// verifier would require additional state refactoring
const CODE_VERIFIER = uuidv4();
const CODE_CHALLENGE = crypto
.createHash('sha256')
.update(CODE_VERIFIER, 'utf8')
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
scope: string;
refresh_token: string;
id_token: string;
}
function idForAccount(emailAddress: string, connectionSettings) {
// changing your connection security settings / ports shouldn't blow
// away everything and trash your metadata. Just look at critiical fields.
@ -39,6 +80,25 @@ function idForAccount(emailAddress: string, connectionSettings) {
.substr(0, 8);
}
async function fetchPostWithFormBody<T>(url: string, body: { [key: string]: string }) {
const resp = await fetch(url, {
method: 'POST',
body: Object.entries(body)
.map(([key, value]) => encodeURIComponent(key) + '=' + encodeURIComponent(value))
.join('&'),
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
});
const json = ((await resp.json()) || {}) as T;
if (!resp.ok) {
throw new Error(
`OAuth Code exchange returned ${resp.status} ${resp.statusText}: ${JSON.stringify(json)}`
);
}
return json;
}
function mxRecordsForDomain(domain) {
return new Promise<string[]>((resolve, reject) => {
// timeout here is annoyingly long - 30s?
@ -142,39 +202,24 @@ export async function expandAccountWithCommonSettings(account: Account) {
export async function buildGmailAccountFromAuthResponse(code: string) {
/// Exchange code for an access token
const body = [];
body.push(`code=${encodeURIComponent(code)}`);
body.push(`client_id=${encodeURIComponent(GMAIL_CLIENT_ID)}`);
body.push(`redirect_uri=${encodeURIComponent(LOCAL_REDIRECT_URI)}`);
body.push(`grant_type=${encodeURIComponent('authorization_code')}`);
const resp = await fetch('https://www.googleapis.com/oauth2/v4/token', {
method: 'POST',
body: body.join('&'),
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
});
const json = (await resp.json()) || {};
if (!resp.ok) {
throw new Error(
`Gmail OAuth Code exchange returned ${resp.status} ${resp.statusText}: ${JSON.stringify(
json
)}`
);
}
const { access_token, refresh_token } = json;
const { access_token, refresh_token } = await fetchPostWithFormBody<TokenResponse>(
'https://www.googleapis.com/oauth2/v4/token',
{
code: code,
client_id: GMAIL_CLIENT_ID,
redirect_uri: `http://127.0.0.1:${LOCAL_SERVER_PORT}`,
grant_type: 'authorization_code',
}
);
// get the user's email address
const meResp = await fetch('https://www.googleapis.com/oauth2/v1/userinfo?alt=json', {
method: 'GET',
headers: { Authorization: `Bearer ${access_token}` },
});
const me = await meResp.json();
if (!meResp.ok) {
throw new Error(
`Gmail profile request returned ${resp.status} ${resp.statusText}: ${JSON.stringify(me)}`
`Gmail profile request returned ${meResp.status} ${meResp.statusText}: ${JSON.stringify(me)}`
);
}
const account = await expandAccountWithCommonSettings(
@ -198,12 +243,75 @@ export async function buildGmailAccountFromAuthResponse(code: string) {
return account;
}
export async function buildO365AccountFromAuthResponse(code: string) {
/// Exchange code for an access token
const { access_token, refresh_token } = await fetchPostWithFormBody<TokenResponse>(
`https://login.microsoftonline.com/common/oauth2/v2.0/token`,
{
code: code,
scope: O365_SCOPES.filter(f => !f.startsWith('https://outlook.office.com')).join(' '),
client_id: O365_CLIENT_ID,
code_verifier: CODE_VERIFIER,
grant_type: `authorization_code`,
redirect_uri: `http://localhost:${LOCAL_SERVER_PORT}`,
}
);
// get the user's email address
const meResp = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: { Authorization: `Bearer ${access_token}` },
});
const me = await meResp.json();
if (!meResp.ok) {
throw new Error(
`O365 profile request returned ${meResp.status} ${meResp.statusText}: ${JSON.stringify(me)}`
);
}
if (!me.mail) {
throw new Error(localized(`There is no email mailbox associated with this account.`));
}
const account = await expandAccountWithCommonSettings(
new Account({
name: me.displayName,
emailAddress: me.mail,
provider: 'office365',
settings: {
refresh_client_id: O365_CLIENT_ID,
refresh_token: refresh_token,
},
})
);
account.id = idForAccount(me.email, account.settings);
// test the account locally to ensure the refresh token can be exchanged for an account token.
await finalizeAndValidateAccount(account);
return account;
}
export function buildGmailAuthURL() {
return `https://accounts.google.com/o/oauth2/auth?client_id=${GMAIL_CLIENT_ID}&redirect_uri=${encodeURIComponent(
LOCAL_REDIRECT_URI
)}&response_type=code&scope=${encodeURIComponent(
GMAIL_SCOPES.join(' ')
)}&access_type=offline&select_account%20consent`;
return `https://accounts.google.com/o/oauth2/auth?${qs.stringify({
client_id: GMAIL_CLIENT_ID,
redirect_uri: `http://127.0.0.1:${LOCAL_SERVER_PORT}`,
response_type: 'code',
scope: GMAIL_SCOPES.join(' '),
access_type: 'offline',
prompt: 'select_account consent',
})}`;
}
export function buildO365AuthURL() {
return `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${qs.stringify({
client_id: O365_CLIENT_ID,
redirect_uri: `http://localhost:${LOCAL_SERVER_PORT}`,
response_type: 'code',
scope: O365_SCOPES.join(' '),
response_mode: 'query',
code_challenge: CODE_CHALLENGE,
code_challenge_method: 'S256',
})}`;
}
export async function finalizeAndValidateAccount(account: Account) {

View file

@ -10,6 +10,7 @@ import AuthenticatePage from './page-authenticate';
import AccountChoosePage from './page-account-choose';
import AccountSettingsPage from './page-account-settings';
import AccountSettingsPageGmail from './page-account-settings-gmail';
import AccountSettingsPageO365 from './page-account-settings-o365';
import AccountSettingsPageIMAP from './page-account-settings-imap';
import AccountOnboardingSuccess from './page-account-onboarding-success';
import InitialPreferencesPage from './page-initial-preferences';
@ -22,6 +23,7 @@ const PageComponents = {
'account-choose': AccountChoosePage,
'account-settings': AccountSettingsPage,
'account-settings-gmail': AccountSettingsPageGmail,
'account-settings-o365': AccountSettingsPageO365,
'account-settings-imap': AccountSettingsPageIMAP,
'account-onboarding-success': AccountOnboardingSuccess,
'initial-preferences': InitialPreferencesPage,

View file

@ -35,6 +35,8 @@ class OnboardingStore extends MailspringStore {
this._account = new Account({}).fromJSON(existingAccountJSON);
if (this._account.provider === 'gmail') {
this._pageStack = ['account-choose', 'account-settings-gmail'];
} else if (this._account.provider === 'office365') {
this._pageStack = ['account-choose', 'account-settings-o365'];
} else if (this._account.provider === 'imap') {
this._pageStack = ['account-choose', 'account-settings', 'account-settings-imap'];
} else {
@ -71,7 +73,12 @@ class OnboardingStore extends MailspringStore {
};
_onChooseAccountProvider = provider => {
const nextPage = provider === 'gmail' ? 'account-settings-gmail' : 'account-settings';
const nextPage =
provider === 'gmail'
? 'account-settings-gmail'
: provider === 'office365'
? 'account-settings-o365'
: 'account-settings';
// Don't carry over any type-specific account information
this._onSetAccount(

View file

@ -18,15 +18,14 @@ export default class AccountSettingsPageGmail extends React.Component<{ account:
render() {
const providerConfig = AccountProviders.find(a => a.provider === this.props.account.provider);
const { headerIcon } = providerConfig;
const goBack = () => OnboardingActions.moveToPreviousPage();
return (
<OAuthSignInPage
serviceName="Google"
providerAuthPageUrl={this._gmailAuthUrl}
providerConfig={providerConfig}
buildAccountFromAuthResponse={buildGmailAccountFromAuthResponse}
iconName={headerIcon}
onSuccess={this.onSuccess}
onTryAgain={goBack}
/>

View file

@ -0,0 +1,34 @@
import React from 'react';
import { Account } from 'mailspring-exports';
import { buildO365AccountFromAuthResponse, buildO365AuthURL } from './onboarding-helpers';
import OAuthSignInPage from './oauth-signin-page';
import * as OnboardingActions from './onboarding-actions';
import AccountProviders from './account-providers';
export default class AccountSettingsPageO365 extends React.Component<{ account: Account }> {
static displayName = 'AccountSettingsPageO365';
_authUrl = buildO365AuthURL();
onSuccess(account) {
OnboardingActions.finishAndAddAccount(account);
}
render() {
const providerConfig = AccountProviders.find(a => a.provider === this.props.account.provider);
const goBack = () => OnboardingActions.moveToPreviousPage();
return (
<OAuthSignInPage
serviceName="Office 365"
providerAuthPageUrl={this._authUrl}
providerConfig={providerConfig}
buildAccountFromAuthResponse={buildO365AccountFromAuthResponse}
onSuccess={this.onSuccess}
onTryAgain={goBack}
/>
);
}
}

View file

@ -363,6 +363,7 @@
}
}
.page.account-setup.google,
.page.account-setup.office,
.page.account-setup.AccountOnboardingSuccess {
.logo-container {
padding-top: 160px;

@ -1 +1 @@
Subproject commit 0384ec31502b7f4f3e55d81f6300e9835fe17632
Subproject commit 812977b1a011a2d91d448a7cb88eb802be3ea6a1