mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-20 15:26:06 +08:00
Switch to OAuth for Office 365 accounts now that MSFT support is live! #1118
This commit is contained in:
parent
c0d816ab96
commit
052f6dd5fe
|
@ -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',
|
||||
|
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -363,6 +363,7 @@
|
|||
}
|
||||
}
|
||||
.page.account-setup.google,
|
||||
.page.account-setup.office,
|
||||
.page.account-setup.AccountOnboardingSuccess {
|
||||
.logo-container {
|
||||
padding-top: 160px;
|
||||
|
|
2
mailsync
2
mailsync
|
@ -1 +1 @@
|
|||
Subproject commit 0384ec31502b7f4f3e55d81f6300e9835fe17632
|
||||
Subproject commit 812977b1a011a2d91d448a7cb88eb802be3ea6a1
|
Loading…
Reference in a new issue