~exprez135/mailspring-libre

ea274d5a6091d76b80a7bcb0aa0c919719b72fc6 — Ben Gotow 4 years ago 62f31cc
Switch to OAuth for Office 365 accounts now that MSFT support is live! #1118
M app/internal_packages/onboarding/lib/account-providers.tsx => app/internal_packages/onboarding/lib/account-providers.tsx +1 -1
@@ 17,7 17,7 @@ const AccountProviders = [
    ),
    icon: 'ic-settings-account-gmail.png',
    headerIcon: 'setup-icon-provider-gmail.png',
    color: '#e99999',
    color: '#FFFFFF00',
  },
  {
    provider: 'office365',

M app/internal_packages/onboarding/lib/oauth-signin-page.tsx => app/internal_packages/onboarding/lib/oauth-signin-page.tsx +3 -2
@@ 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"
          />

M app/internal_packages/onboarding/lib/onboarding-helpers.ts => app/internal_packages/onboarding/lib/onboarding-helpers.ts +141 -33
@@ 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) {

M app/internal_packages/onboarding/lib/onboarding-root.tsx => app/internal_packages/onboarding/lib/onboarding-root.tsx +2 -0
@@ 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,

M app/internal_packages/onboarding/lib/page-account-settings-gmail.tsx => app/internal_packages/onboarding/lib/page-account-settings-gmail.tsx +1 -2
@@ 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}
      />

A app/internal_packages/onboarding/lib/page-account-settings-o365.tsx => app/internal_packages/onboarding/lib/page-account-settings-o365.tsx +34 -0
@@ 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}
      />
    );
  }
}

M app/internal_packages/onboarding/styles/onboarding.less => app/internal_packages/onboarding/styles/onboarding.less +1 -0
@@ 363,6 363,7 @@
  }
}
.page.account-setup.google,
.page.account-setup.office,
.page.account-setup.AccountOnboardingSuccess {
  .logo-container {
    padding-top: 160px;

M mailsync => mailsync +1 -1
@@ 1,1 1,1 @@
Subproject commit 0384ec31502b7f4f3e55d81f6300e9835fe17632
Subproject commit 812977b1a011a2d91d448a7cb88eb802be3ea6a1