Skip to content

Commit

Permalink
Adds GCP OAuth (#838)
Browse files Browse the repository at this point in the history
* gitignore

* Adds GCP token to CloudAccounts

* Checkpoint

* Checkpoint

* Refactors cloud accounts

* Addresses comments

* Auto-formatting

* Reverts gallery webpack GCP local server

* Reverts webpack config change

* Minor cleanup

* Removes unused class

* Reverts initial feature flag implementation

* Removes unneeded logging

* Adds credentials getters to CloudAccounts and updates tests

* Auto-format

* Adds FakeCloudAccounts

* Removes credentialsGetter from CloudAccounts

* Removes account factories from CloudAccounts

* Addresses review comments

* Addresses review commetns

* Auto-formatting

* Auto-format

* Replace OAuth config client id

* Remove account from CloudAccounts
  • Loading branch information
mpmcroy authored Mar 23, 2021
1 parent 15cc588 commit b9222fe
Show file tree
Hide file tree
Showing 23 changed files with 1,045 additions and 212 deletions.
1 change: 1 addition & 0 deletions src/server_manager/base.webpack.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ exports.makeConfig = (options) => {
resolve: {extensions: ['.tsx', '.ts', '.js']},
plugins: [
new webpack.DefinePlugin({
'outline.gcpAuthEnabled': JSON.stringify(process.env.GCP_AUTH_ENABLED === 'true'),
// Hack to protect against @sentry/electron not having process.type defined.
'process.type': JSON.stringify('renderer'),
// Statically link the Roboto font, rather than link to fonts.googleapis.com
Expand Down
135 changes: 135 additions & 0 deletions src/server_manager/electron_app/gcp_oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Copyright 2021 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import * as electron from 'electron';
import * as express from 'express';
import {OAuth2Client} from 'google-auth-library';
import {AddressInfo} from 'net';

const OAUTH_CONFIG = {
project_id: 'outline-manager-oauth',
client_id: '946220775492-osi1dm2rhhpo4upm6qqfv9fiivv1qu6c.apps.googleusercontent.com',
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/cloud-platform',
],
};
const REDIRECT_PATH = '/gcp/oauth/callback';

function responseHtml(messageHtml: string): string {
return `<html><script>window.close()</script><body>${
messageHtml}. You can close this window.</body></html>`;
}

/**
* Verifies that the access token has the required scopes.
*
* The access token may have missing scopes if the user denies access to any
* scopes in the OAuth flow.
*
* @param oAuthClient: The GCP OAuth 2.0 client.
* @param accessToken: The granted access token.
*/
async function verifyGrantedScopes(
oAuthClient: OAuth2Client, accessToken: string): Promise<boolean> {
const getTokenInfoResponse = await oAuthClient.getTokenInfo(accessToken);
for (const requiredScope of OAUTH_CONFIG.scopes) {
const matchedScope =
getTokenInfoResponse.scopes.find((grantedScope) => grantedScope === requiredScope);
if (!matchedScope) {
return false;
}
}
return true;
}

export function runOauth(): OauthSession {
// Start web server to handle OAuth callback
const app = express();
const server = app.listen();
const port = (server.address() as AddressInfo).port;

// Open browser to OAuth URL
const oAuthClient = new OAuth2Client(
OAUTH_CONFIG.client_id,
null,
`http://localhost:${port}${REDIRECT_PATH}`,
);
const oAuthUrl = oAuthClient.generateAuthUrl({
access_type: 'offline',
scope: OAUTH_CONFIG.scopes,
});
electron.shell.openExternal(oAuthUrl);

// Handle OAuth redirect callback
let isCancelled = false;
const rejectWrapper = {reject: (error: Error) => {}};
const tokenPromise = new Promise<string>((resolve, reject) => {
rejectWrapper.reject = reject;
app.get(REDIRECT_PATH, async (request: express.Request, response: express.Response) => {
if (request.query.error) {
if (request.query.error === 'access_denied') {
isCancelled = true;
response.send(responseHtml('Authentication cancelled'));
reject(new Error('Authentication cancelled'));
} else {
response.send(responseHtml('Authentication failed'));
reject(new Error(`Authentication failed with error: ${request.query.error}`));
}
} else {
try {
const getTokenResponse = await oAuthClient.getToken(request.query.code as string);
if (getTokenResponse.res.status / 100 === 2) {
const scopesValid =
await verifyGrantedScopes(oAuthClient, getTokenResponse.tokens.access_token);
if (!scopesValid) {
console.error(
'Authentication failed with missing scope(s). Granted: ',
getTokenResponse.tokens.scope);
response.send(responseHtml('Authentication failed with missing scope(s)'));
reject(new Error('Authentication failed with missing scope(s)'));
} else if (!getTokenResponse.tokens.refresh_token) {
response.send(responseHtml('Authentication failed'));
reject(new Error('Authentication failed: Missing refresh token'));
} else {
response.send(responseHtml('Authentication successful'));
resolve(getTokenResponse.tokens.refresh_token);
}
} else {
response.send(responseHtml('Authentication failed'));
reject(new Error(
`Authentication failed with HTTP status code: ${getTokenResponse.res.status}`));
}
} catch (error) {
response.send(responseHtml('Authentication failed'));
reject(new Error(`Authentication failed with error: ${request.query.error}`));
}
}
server.close();
});
});

return {
result: tokenPromise,
isCancelled() {
return isCancelled;
},
cancel() {
console.log('Session cancelled');
isCancelled = true;
server.close();
rejectWrapper.reject(new Error('Authentication cancelled'));
}
};
}
2 changes: 1 addition & 1 deletion src/server_manager/electron_app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ function getWebAppUrl() {
}
if (debugMode) {
queryParams.set('outlineDebugMode', 'true');
console.log(`Enabling Outline debug mode`);
console.log('Enabling Outline debug mode');
}

// Append arguments to URL if any.
Expand Down
4 changes: 4 additions & 0 deletions src/server_manager/electron_app/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {ipcRenderer} from 'electron';
import {URL} from 'url';

import * as digitalocean_oauth from './digitalocean_oauth';
import * as gcp_oauth from './gcp_oauth';
import {redactManagerUrl} from './util';

// This file is run in the renderer process *before* nodeIntegration is disabled.
Expand Down Expand Up @@ -64,6 +65,9 @@ if (sentryDsn) {
// tslint:disable-next-line:no-any
(window as any).runDigitalOceanOauth = digitalocean_oauth.runOauth;

// tslint:disable-next-line:no-any
(window as any).runGcpOauth = gcp_oauth.runOauth;

// tslint:disable-next-line:no-any
(window as any).bringToFront = () => {
return ipcRenderer.send('bring-to-front');
Expand Down
2 changes: 2 additions & 0 deletions src/server_manager/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"error-do-account-info": "Failed to get DigitalOcean account information",
"error-do-auth": "Authentication with DigitalOcean failed",
"error-do-regions": "Failed to get list of available regions",
"error-gcp-auth": "Authentication with Google Cloud Platform failed",
"error-feedback": "Failed to submit feedback. Please try again.",
"error-hostname-invalid": "Must be an IP address or valid hostname.",
"error-key-add": "Failed to add key",
Expand Down Expand Up @@ -98,6 +99,7 @@
"gcp-firewall-create-2": "Type 'outline' in the 'Target tags' field.",
"gcp-firewall-create-3": "Type '0.0.0.0/0' in the 'Source IP ranges' field.",
"gcp-firewall-create-4": "Select 'Allow all' under 'Protocols and ports'.",
"gcp-oauth-connect-title": "Sign in or create an account with Google Cloud Platform.",
"gcp-name-your-project": "Name your project in the 'Project name' field.",
"gcp-select-machine-type": "Select 'f1-micro' under 'Machine type'.",
"gcp-select-networking": "Click 'Management, security, disks, networking, sole tenancy', then 'Networking'",
Expand Down
4 changes: 4 additions & 0 deletions src/server_manager/messages/master_messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,10 @@
"message": "Failed to get list of available regions",
"description": "This string appears in an error notification toast. It is shown when there is an error retrieving the regions available for server deployment."
},
"error_gcp_auth": {
"message": "Authentication with Google Cloud Platform failed",
"description": "This string appears in an error notification toast. It is shown when there is an error when logging in to the user's Google Cloud Platform account. Google Cloud Platform is a cloud server provider name and should not be translated."
},
"error_feedback": {
"message": "Failed to submit feedback. Please try again.",
"description": "This string appears in an error notification toast. It is shown when there is an error submitting the user's feedback."
Expand Down
60 changes: 60 additions & 0 deletions src/server_manager/model/accounts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright 2021 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import * as digitalocean from './digitalocean';
import * as gcp from './gcp';

export interface CloudAccounts {
/**
* Connects a DigitalOcean account.
*
* Only one DigitalOcean account can be connected at any given time.
* Subsequent calls to this method will overwrite any previously connected
* DigtialOcean account.
*
* @param accessToken: The DigitalOcean access token.
*/
connectDigitalOceanAccount(accessToken: string): digitalocean.Account;

/**
* Connects a Google Cloud Platform account.
*
* Only one Google Cloud Platform account can be connected at any given time.
* Subsequent calls to this method will overwrite any previously connected
* Google Cloud Platform account.
*
* @param refreshToken: The GCP refresh token.
*/
connectGcpAccount(refreshToken: string): gcp.Account;

/**
* Disconnects the DigitalOcean account.
*/
disconnectDigitalOceanAccount(): void;

/**
* Disconnects the Google Cloud Platform account.
*/
disconnectGcpAccount(): void;

/**
* @returns the connected DigitalOcean account (or null if none exists).
*/
getDigitalOceanAccount(): digitalocean.Account;

/**
* @returns the connected Google Cloud Platform account (or null if none exists).
*/
getGcpAccount(): gcp.Account;
}
20 changes: 20 additions & 0 deletions src/server_manager/model/gcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright 2021 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// TODO: This is just a stub atm and will need to define and implement the rest
// of the functionality.
export interface Account {
// Returns a user-friendly name associated with the account.
getName(): Promise<string>;
}
1 change: 1 addition & 0 deletions src/server_manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"electron-updater": "^4.1.2",
"eventemitter3": "^2.0.3",
"express": "^4.16.3",
"google-auth-library": "^7.0.2",
"intl-messageformat": "^7",
"jsonic": "^0.3.1",
"lit-element": "^2.3.1",
Expand Down
2 changes: 2 additions & 0 deletions src/server_manager/types/preload.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,6 @@ interface OauthSession {

declare function runDigitalOceanOauth(): OauthSession;

declare function runGcpOauth(): OauthSession;

declare function bringToFront(): void;
Loading

0 comments on commit b9222fe

Please sign in to comment.