authui-container/server/auth-server.ts (364 lines of code) (raw):
/*
* Copyright 2020 Google Inc. All Rights Reserved.
*
* 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 express = require('express');
import bodyParser = require('body-parser');
import * as templates from './templates';
import path = require('path');
import { Server } from 'http';
import { MetadataServer } from './api/metadata-server';
import { CloudStorageHandler } from './api/cloud-storage-handler';
import { ErrorResponse, ERROR_MAP } from '../server/utils/error';
import { isNonNullObject } from '../common/validator';
import { DefaultUiConfigBuilder } from '../common/config-builder';
import { UiConfig } from '../common/config';
import { IapSettingsHandler, IapSettings } from './api/iap-settings-handler';
import { GcipHandler, TenantUiConfig, GcipConfig } from './api/gcip-handler';
import { isLastCharLetterOrNumber } from '../common/index';
import { createProxyMiddleware } from 'http-proxy-middleware';
// Defines the Auth server OAuth scopes needed for internal usage.
// This is used to query APIs to determine the default config.
export const AUTH_SERVER_SCOPES = [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/identitytoolkit',
];
// Configuration file name.
const CONFIG_FILE_NAME = 'config.json';
// The current hosted UI version.
export const HOSTED_UI_VERSION = '__XXX_HOSTED_UI_VERSION_XXX__';
// The maximum allowed length of a GCS bucket.
export const MAX_BUCKET_STRING_LENGTH = 63;
// Character to substitute at the end of a bucket name to ensure it
// ends with a letter or number.
export const ALLOWED_LAST_CHAR = '0';
/**
* Executes post actions when proxy failed.
* @param target Target address which requests are proxied to.
*/
function errorCallback(err: Error, req: any, res: any, target?: any) {
log(`Failed to proxy requests to target ${target}: ${err.message}`);
}
/**
* Renders the sign-in UI HTML container and serves it in the response.
* @param req The expressjs request.
* @param res The expressjs response.
*/
function serveContentForSignIn(req: any, res: any) {
const logo = 'https://img.icons8.com/cotton/2x/cloud.png';
res.set('Content-Type', 'text/html');
res.end(templates.main({
logo,
}));
}
/**
* Depending on the DEBUG_CONSOLE environment variable, this will log the provided arguments to the console.
* @param args The list of arguments to log.
*/
function log(...args: any[]) {
if (process.env.DEBUG_CONSOLE === 'true' || process.env.DEBUG_CONSOLE === '1') {
// tslint:disable-next-line:no-console
console.log.apply(console, arguments);
}
}
/** Abstracts the express JS server used to handle all authentication related operations. */
export class AuthServer {
/** The http.Server instance corresponding to the started server. */
public server: Server;
/** Metadata server instance. */
private metadataServer: MetadataServer;
/** Bucket name where custom configurations will be stored. */
private bucketName: string | null;
/** GCIP API handler. */
private gcipHandler: GcipHandler;
/** IAP settings handler. */
private iapSettingsHandler: IapSettingsHandler;
/** Default config promise. This stores the default config in memory. */
private defaultConfigPromise: Promise<UiConfig> | null;
/**
* Creates an instance of the auth server using the specified express application instance.
* @param app The express application instance.
*/
constructor(private readonly app: express.Application) {
// Metadata server is used to retrieve current app data (project ID, number, GCP zone, etc).
// It is also used to call APIs on behalf of the service. This is mostly for read operations.
// For example to read the default app configuration.
// For write operations, the admin OAuth access token is used.
this.metadataServer = new MetadataServer(AUTH_SERVER_SCOPES, log);
this.bucketName = null;
// GCIP handler used to construct the default configuration file and to populate
// the web UI config (apiKey + authDomain).
this.gcipHandler = new GcipHandler(this.metadataServer, this.metadataServer);
// IAP settings handler used to list all IAP enabled services and their settings.
this.iapSettingsHandler = new IapSettingsHandler(this.metadataServer, this.metadataServer);
this.init();
}
/**
* Starts the authentication server at the specified port number.
* @param port The port to start the server with. This defaults to 8080.
* @return A promise that resolves on readiness.
*/
start(port: string = '8080'): Promise<void> {
return new Promise((resolve, reject) => {
this.server = this.app.listen(parseInt(port, 10), () => {
// Log current version of hosted UI.
// tslint:disable-next-line:no-console
console.log('Server started with version', HOSTED_UI_VERSION);
resolve();
});
});
}
/** Closes the server. */
stop() {
if (this.server) {
this.server.close();
}
}
/**
* Initializes the server endpoints.
*/
private init() {
this.app.enable('trust proxy');
// Oauth handler widget code.
// Proxy these requests to <project>.firebaseapp.com.
// set this up before adding json body parser to the app. This causes POST requests to fail as mentioned in
// https://github.com/chimurai/http-proxy-middleware/issues/171#issuecomment-356218599
this.fetchAuthDomainProxyTarget().then(authDomainProxyTarget => {
log(`Proxy auth requests to target ${authDomainProxyTarget}`);
this.app.use('/__/auth/', createProxyMiddleware({
target: authDomainProxyTarget,
// set to true to pass SSL cert checks.
// This causes the SNI/Host Header of the proxy request to be set to the targetURL
// '<project>.firebaseapp.com'.
changeOrigin: true,
logLevel: 'debug',
onError: errorCallback
}));
// Support JSON-encoded bodies.
this.app.use(bodyParser.json());
// Support URL-encoded bodies.
this.app.use(bodyParser.urlencoded({
extended: true
}));
// Post requests needs bodyParser to be setup first.
// Otherwise, URLs in the request payload are not parsed correctly.
// Administrative API for writing a custom configuration to.
// This will save the configuration in a predetermined GCS bucket.
if (this.isAdminAllowed()) {
this.app.post('/set_admin_config', (req: express.Request, res: express.Response) => {
if (!req.headers.authorization ||
req.headers.authorization.split(' ').length <= 1) {
this.handleErrorResponse(res, ERROR_MAP.UNAUTHENTICATED);
} else if (!isNonNullObject(req.body) ||
Object.keys(req.body).length === 0) {
this.handleErrorResponse(res, ERROR_MAP.INVALID_ARGUMENT);
} else {
const accessToken = req.headers.authorization.split(' ')[1];
try {
// Validate config before saving it.
DefaultUiConfigBuilder.validateConfig(req.body);
this.setConfigForAdmin(accessToken, req.body).then(() => {
res.set('Content-Type', 'application/json');
res.send(JSON.stringify({
status: 200,
message: 'Changes successfully saved.',
}));
}).catch((err) => {
this.handleError(res, err);
});
} catch (e) {
this.handleErrorResponse(
res,
{
error: {
code: 400,
status: 'INVALID_ARGUMENT',
message: e.message || 'Invalid UI configuration.',
},
});
}
}
});
}
})
// Static assets.
// Note that in production, this is served from dist/server/auth-server.js.
this.app.use('/static', express.static(path.join(__dirname, '../public')));
// IAP sign-in flow.
this.app.get('/', (req: express.Request, res: express.Response) => {
// Serve content for signed in user.
return serveContentForSignIn(req, res);
});
// Provide easy way for developer to determine the auth domain being used.
// This is the location to which requests are being proxied. This is same as
// the output of /gcipConfig authDomain, but it validates that the proxy target
// was read correctly by the constructor/init() code.
this.app.get('/authdomain-proxytarget', (req: express.Request, res: express.Response) => {
this.fetchAuthDomainProxyTarget()
.then((authDomainProxyTarget) => {
res.set('Content-Type', 'text/html');
res.end(authDomainProxyTarget);
})
.catch((err) => {
this.handleError(res, err);
});
});
// Provide easy way for developer to determine version.
this.app.get('/versionz', (req: express.Request, res: express.Response) => {
res.set('Content-Type', 'text/html');
res.end(HOSTED_UI_VERSION);
});
// Developers can disable admin panel when deploying Cloud Run service.
if (this.isAdminAllowed()) {
// Administrative sign-in UI config customization.
this.app.get('/admin', (req: express.Request, res: express.Response) => {
res.set('Content-Type', 'text/html');
res.end(templates.admin({}));
});
// Administrative API for reading the current app configuration.
// This could be either saved in GCS (custom config), environment variable (custom config)
// or in memory (default config).
this.app.get('/get_admin_config', (req: express.Request, res: express.Response) => {
if (!req.headers.authorization ||
req.headers.authorization.split(' ').length <= 1) {
this.handleErrorResponse(res, ERROR_MAP.UNAUTHENTICATED);
} else {
// Use the hostname of the request to figure out the URL of the hosted UI.
// This should be used as authDomain in admin config, unless it was overridden to a different value.
// This enables authDomain to be in the same origin as the sign-in UI.
const accessToken = req.headers.authorization.split(' ')[1];
this.getConfigForAdmin(accessToken, req.hostname).then((config) => {
res.set('Content-Type', 'application/json');
res.send(JSON.stringify(config || {}));
}).catch((err) => {
this.handleError(res, err);
});
}
});
}
// Used to return the auth configuration (apiKey + authDomain).
this.app.get('/gcipConfig', (req: express.Request, res: express.Response) => {
this.gcipHandler.getGcipConfig()
.then((gcipConfig) => {
res.set('Content-Type', 'application/json');
res.send(JSON.stringify(gcipConfig));
})
.catch((err) => {
this.handleError(res, err);
});
});
// Returns the custom config (if available) or the default config, needed to render
// the sign-in UI for IAP.
this.app.get('/config', (req: express.Request, res: express.Response) => {
// Use the hostname of the request to figure out the URL of the hosted UI.
// This should be used as authDomain in config, unless it was overridden to a different value.
// This enables authDomain to be in the same origin as the sign-in UI.
this.getFallbackConfig(req.hostname)
.then((currentConfig) => {
if (!currentConfig) {
this.handleErrorResponse(res, ERROR_MAP.NOT_FOUND);
} else {
res.set('Content-Type', 'application/json');
res.send(JSON.stringify(currentConfig));
}
})
.catch((err) => {
this.handleError(res, err);
});
});
}
/** @return Destination where requests with path "__/auth/" are proxied to. */
private fetchAuthDomainProxyTarget(): Promise<string> {
if (this.gcipHandler === undefined) {
return Promise.reject(new Error('Gcip handler has not been initialized!'));
}
return this.gcipHandler.getGcipConfig()
.then((gcipConfig) => `https://${gcipConfig.authDomain}`);
}
/** @return Whether admin panel is allowed. */
private isAdminAllowed(): boolean {
return !(process.env.ALLOW_ADMIN === 'false' || process.env.ALLOW_ADMIN === '0');
}
/**
* @return A promise that resolves with the current UI config.
* The hostname parameter is used to override the authDomain to the hostname of the signin-page, i.e the requester UI.
*/
private getFallbackConfig(hostname: string): Promise<UiConfig | null> {
// Parse config from environment variable first.
if (process.env.UI_CONFIG) {
try {
const config: UiConfig = JSON.parse(process.env.UI_CONFIG);
DefaultUiConfigBuilder.validateConfig(config);
return Promise.resolve(config);
} catch (error) {
// Ignore but log error.
log(`Invalid configuration in environment variable UI_CONFIG: ${error.message}`);
}
}
// Parse config from GCS bucket if available.
const cloudStorageHandler = new CloudStorageHandler(this.metadataServer, this.metadataServer);
return this.getBucketName()
.then((bucketName) => {
return cloudStorageHandler.readFile(bucketName, CONFIG_FILE_NAME);
})
.catch((error) => {
// If not available in GCS, use default config.
if (!this.defaultConfigPromise) {
// Default config should be retrieved once and cached in memory.
this.defaultConfigPromise = this.getDefaultConfig(hostname);
}
return this.defaultConfigPromise.then((config) => {
if (!config) {
// Do not cache config if IAP is not yet enabled.
this.defaultConfigPromise = null;
// Return default config.
return null;
}
return config;
})
.catch((err) => {
// Do not cache errors in building default config.
this.defaultConfigPromise = null;
throw err;
});
});
}
/**
* Returns the map of tenant IDs and their TenantUiConfigs.
* @param tenantIds The list of tenant IDs whose TenantUiConfigs are to be returend.
* @return A promise that resolves with a object containing the mapping of tenant IDs and
* their TenantUiConfigs as retrieved from GCIP.
*/
private getTenantUiConfigForTenants(
tenantIds: string[]): Promise<{[key: string]: TenantUiConfig}> {
const optionsMap: {[key: string]: TenantUiConfig} = {};
const getConfigLocal = (): Promise<{[key: string]: TenantUiConfig}> => {
if (tenantIds.length === 0) {
return Promise.resolve(optionsMap);
}
const tenantId = tenantIds.pop();
return this.gcipHandler.getTenantUiConfig(tenantId)
.then((options) => {
if (tenantId.charAt(0) === '_') {
optionsMap._ = options;
} else {
optionsMap[tenantId] = options;
}
return getConfigLocal();
});
}
return getConfigLocal();
}
/**
* @return A promise that resolves with the constructed default UI config if available.
* If IAP is not configured, null is returned instead.
* @param hostname The hostname of the requesting app. This will be used as the authDomain field in UiConfig.
*/
private getDefaultConfig(hostname: string): Promise<UiConfig | null> {
let gcipConfig: GcipConfig;
let projectId: string;
return this.metadataServer.getProjectId()
.then((retrievedProjectId) => {
projectId = retrievedProjectId;
return this.gcipHandler.getGcipConfig();
})
.then((retrievedGcipConfig) => {
gcipConfig = retrievedGcipConfig
return this.iapSettingsHandler.listIapSettings()
.catch((error) => {
return [] as IapSettings[];
});
})
.then((iapSettings) => {
// Get list of all tenants used.
const tenantIdsSet = new Set<string>();
iapSettings.forEach((iapConfig) => {
if (iapConfig &&
iapConfig.accessSettings &&
iapConfig.accessSettings.gcipSettings &&
iapConfig.accessSettings.gcipSettings.tenantIds) {
// Add underlying tenant IDs to set.
iapConfig.accessSettings.gcipSettings.tenantIds.forEach((tenantId) => {
tenantIdsSet.add(tenantId);
});
}
});
return Array.from(tenantIdsSet);
})
.then((tenantIds) => {
return this.getTenantUiConfigForTenants(tenantIds);
})
.then((optionsMap) => {
const defaultUiConfig = new DefaultUiConfigBuilder(projectId, hostname, gcipConfig, optionsMap);
return defaultUiConfig.build();
});
}
/** @return A promise that resolves with the service GCS bucket name. */
private getBucketName(): Promise<string> {
const bucketPrefix = `gcip-iap-bucket-${process.env.K_CONFIGURATION}-`;
if (this.bucketName) {
return Promise.resolve(this.bucketName);
} else if (process.env.GCS_BUCKET_NAME) {
this.bucketName = process.env.GCS_BUCKET_NAME;
return Promise.resolve(this.bucketName);
}
return this.metadataServer.getProjectNumber()
.then((projectNumber) => {
// https://cloud.google.com/storage/docs/naming-buckets#requirements
// Bucket names cannot exceed a certain limit. Trim overflowing characters.
// Bucket names must also start and end with a number or letter.
let computedBucketName =
`${bucketPrefix}${projectNumber}`.substr(0, MAX_BUCKET_STRING_LENGTH);
// Last character should always be a number, unless the bucket name is trimmed.
if (!isLastCharLetterOrNumber(computedBucketName)) {
// Last char is not a letter or number. Replace with 0.
computedBucketName =
computedBucketName.substr(0, computedBucketName.length - 1) + ALLOWED_LAST_CHAR;
}
this.bucketName = computedBucketName;
return this.bucketName;
});
}
/**
* Returns the current UI config if found in GCS. If not, the default config is
* returned instead.
* @param accessToken The personal admin user OAuth access token.
* @param hostname The hostname of the requesting app. This will be used as the authDomain field in UiConfig.
* @return A promise that resolves with the UI config.
*/
private getConfigForAdmin(accessToken: string, hostname: string): Promise<UiConfig | null> {
let bucketName: string;
const fileName = CONFIG_FILE_NAME;
// Required OAuth scope: https://www.googleapis.com/auth/devstorage.read_write
const accessTokenManager = {
getAccessToken: () => Promise.resolve(accessToken),
};
const cloudStorageHandler = new CloudStorageHandler(this.metadataServer, accessTokenManager);
// Check bucket exists first.
return this.getBucketName()
.then((retrievedBucketName) => {
bucketName = retrievedBucketName;
return cloudStorageHandler.readFile(bucketName, fileName);
})
.catch((error) => {
if ((error.message && error.message.toLowerCase().indexOf('not found') !== -1) ||
error.statusCode === 404) {
// Since we can't check permissions on a non-existant bucket,
// check user can list buckets.
return cloudStorageHandler.listBuckets()
.then(() => {
// If not found, but user can list buckets, return default config.
// Otherwise throw an error.
return this.getDefaultConfig(hostname);
});
}
throw error;
})
}
/**
* Saves the custom configuration to the expected GCS bucket.
* @param accessToken The personal admin user OAuth access token.
* @param customConfig The custom configuration JSON file to be saved.
* @return A promise that resolves on successful saving.
*/
private setConfigForAdmin(accessToken: string, customConfig: any): Promise<void> {
let bucketName: string;
const fileName = CONFIG_FILE_NAME;
// Required OAuth scope: https://www.googleapis.com/auth/devstorage.read_write
const accessTokenManager = {
getAccessToken: () => Promise.resolve(accessToken),
};
const cloudStorageHandler = new CloudStorageHandler(this.metadataServer, accessTokenManager);
// Check bucket exists first.
return this.getBucketName()
.then((retrievedBucketName) => {
bucketName = retrievedBucketName;
return cloudStorageHandler.readFile(bucketName, fileName);
})
.catch((error) => {
if ((error.message && error.message.toLowerCase().indexOf('not found') !== -1) ||
error.statusCode === 404) {
// Create bucket.
return cloudStorageHandler.createBucket(bucketName);
}
throw error;
})
.then(() => {
// Bucket either exists or just created. Write update file to it.
return cloudStorageHandler.writeFile(bucketName, fileName, customConfig);
});
}
/**
* Handles the provided error response object.
* @param res The express response object.
* @param errorResponse The error response to return in the response.
*/
private handleErrorResponse(
res: express.Response,
errorResponse: ErrorResponse) {
res.status(errorResponse.error.code).json(errorResponse);
}
/**
* Handles the provided error.
* @param res The express response object.
* @param error The associated error object.
*/
private handleError(res: express.Response, error: Error) {
if (error && (error as any).cloudCompliant) {
this.handleErrorResponse(res, (error as any).rawResponse);
} else {
// Response with unknown error.
this.handleErrorResponse(
res,
{
error: {
code: 500,
status: 'UNKNOWN',
message: error.message || 'Unknown server error.',
},
});
}
}
}