import {exec} from 'child_process';
import * as fs from 'fs';
import {GaxiosOptions, GaxiosResponse} from 'gaxios';
import * as gcpMetadata from 'gcp-metadata';
import * as os from 'os';
import * as path from 'path';
import * as stream from 'stream';
import {Crypto, createCrypto} from '../crypto/crypto';
import {DefaultTransporter, Transporter} from '../transporters';
import {Compute, ComputeOptions} from './computeclient';
import {CredentialBody, ImpersonatedJWTInput, JWTInput} from './credentials';
import {IdTokenClient} from './idtokenclient';
import {GCPEnv, getEnv} from './envDetect';
import {JWT, JWTOptions} from './jwtclient';
import {Headers, OAuth2ClientOptions, RefreshOptions} from './oauth2client';
import {
UserRefreshClient,
UserRefreshClientOptions,
USER_REFRESH_ACCOUNT_TYPE,
} from './refreshclient';
import {
Impersonated,
ImpersonatedOptions,
IMPERSONATED_ACCOUNT_TYPE,
} from './impersonated';
import {
ExternalAccountClient,
ExternalAccountClientOptions,
} from './externalclient';
import {
EXTERNAL_ACCOUNT_TYPE,
BaseExternalAccountClient,
} from './baseexternalclient';
import {AuthClient} from './authclient';
import {
EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE,
ExternalAccountAuthorizedUserClient,
ExternalAccountAuthorizedUserClientOptions,
} from './externalAccountAuthorizedUserClient';
export type JSONClient =
| JWT
| UserRefreshClient
| BaseExternalAccountClient
| ExternalAccountAuthorizedUserClient
| Impersonated;
export interface ProjectIdCallback {
(err?: Error | null, projectId?: string | null): void;
}
export interface CredentialCallback {
(err: Error | null, result?: JSONClient): void;
}
export interface ADCCallback {
(err: Error | null, credential?: AuthClient, projectId?: string | null): void;
}
export interface ADCResponse {
credential: AuthClient;
projectId: string | null;
}
export interface GoogleAuthOptions<T extends AuthClient = JSONClient> {
authClient?: T;
keyFilename?: string;
keyFile?: string;
credentials?: CredentialBody | ExternalAccountClientOptions;
clientOptions?:
| JWTOptions
| OAuth2ClientOptions
| UserRefreshClientOptions
| ImpersonatedOptions;
scopes?: string | string[];
projectId?: string;
}
export const CLOUD_SDK_CLIENT_ID =
'764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com';
const GoogleAuthExceptionMessages = {
NO_PROJECT_ID_FOUND:
'Unable to detect a Project Id in the current environment. \n' +
'To learn more about authentication and Google APIs, visit: \n' +
'https://cloud.google.com/docs/authentication/getting-started',
} as const;
export class GoogleAuth<T extends AuthClient = JSONClient> {
transporter?: Transporter;
private checkIsGCE?: boolean = undefined;
useJWTAccessWithScope?: boolean;
defaultServicePath?: string;
get isGCE() {
return this.checkIsGCE;
}
private _findProjectIdPromise?: Promise<string | null>;
private _cachedProjectId?: string | null;
jsonContent: JWTInput | ExternalAccountClientOptions | null = null;
cachedCredential: JSONClient | Impersonated | Compute | T | null = null;
defaultScopes?: string | string[];
private keyFilename?: string;
private scopes?: string | string[];
private clientOptions?: RefreshOptions;
static DefaultTransporter = DefaultTransporter;
constructor(opts?: GoogleAuthOptions<T>) {
opts = opts || {};
this._cachedProjectId = opts.projectId || null;
this.cachedCredential = opts.authClient || null;
this.keyFilename = opts.keyFilename || opts.keyFile;
this.scopes = opts.scopes;
this.jsonContent = opts.credentials || null;
this.clientOptions = opts.clientOptions;
}
setGapicJWTValues(client: JWT) {
client.defaultServicePath = this.defaultServicePath;
client.useJWTAccessWithScope = this.useJWTAccessWithScope;
client.defaultScopes = this.defaultScopes;
}
getProjectId(): Promise<string>;
getProjectId(callback: ProjectIdCallback): void;
getProjectId(callback?: ProjectIdCallback): Promise<string | null> | void {
if (callback) {
this.getProjectIdAsync().then(r => callback(null, r), callback);
} else {
return this.getProjectIdAsync();
}
}
private async getProjectIdOptional(): Promise<string | null> {
try {
return await this.getProjectId();
} catch (e) {
if (
e instanceof Error &&
e.message === GoogleAuthExceptionMessages.NO_PROJECT_ID_FOUND
) {
return null;
} else {
throw e;
}
}
}
private async findAndCacheProjectId(): Promise<string> {
let projectId: string | null | undefined = null;
projectId ||= await this.getProductionProjectId();
projectId ||= await this.getFileProjectId();
projectId ||= await this.getDefaultServiceProjectId();
projectId ||= await this.getGCEProjectId();
projectId ||= await this.getExternalAccountClientProjectId();
if (projectId) {
this._cachedProjectId = projectId;
return projectId;
} else {
throw new Error(GoogleAuthExceptionMessages.NO_PROJECT_ID_FOUND);
}
}
private async getProjectIdAsync(): Promise<string | null> {
if (this._cachedProjectId) {
return this._cachedProjectId;
}
if (!this._findProjectIdPromise) {
this._findProjectIdPromise = this.findAndCacheProjectId();
}
return this._findProjectIdPromise;
}
private getAnyScopes(): string | string[] | undefined {
return this.scopes || this.defaultScopes;
}
getApplicationDefault(): Promise<ADCResponse>;
getApplicationDefault(callback: ADCCallback): void;
getApplicationDefault(options: RefreshOptions): Promise<ADCResponse>;
getApplicationDefault(options: RefreshOptions, callback: ADCCallback): void;
getApplicationDefault(
optionsOrCallback: ADCCallback | RefreshOptions = {},
callback?: ADCCallback
): void | Promise<ADCResponse> {
let options: RefreshOptions | undefined;
if (typeof optionsOrCallback === 'function') {
callback = optionsOrCallback;
} else {
options = optionsOrCallback;
}
if (callback) {
this.getApplicationDefaultAsync(options).then(
r => callback!(null, r.credential, r.projectId),
callback
);
} else {
return this.getApplicationDefaultAsync(options);
}
}
private async getApplicationDefaultAsync(
options: RefreshOptions = {}
): Promise<ADCResponse> {
if (this.cachedCredential) {
return await this.prepareAndCacheADC(this.cachedCredential);
}
const quotaProjectIdOverride = process.env['GOOGLE_CLOUD_QUOTA_PROJECT'];
let credential: JSONClient | null;
credential =
await this._tryGetApplicationCredentialsFromEnvironmentVariable(options);
if (credential) {
if (credential instanceof JWT) {
credential.scopes = this.scopes;
} else if (credential instanceof BaseExternalAccountClient) {
credential.scopes = this.getAnyScopes();
}
return await this.prepareAndCacheADC(credential, quotaProjectIdOverride);
}
credential = await this._tryGetApplicationCredentialsFromWellKnownFile(
options
);
if (credential) {
if (credential instanceof JWT) {
credential.scopes = this.scopes;
} else if (credential instanceof BaseExternalAccountClient) {
credential.scopes = this.getAnyScopes();
}
return await this.prepareAndCacheADC(credential, quotaProjectIdOverride);
}
let isGCE;
try {
isGCE = await this._checkIsGCE();
} catch (e) {
if (e instanceof Error) {
e.message = `Unexpected error determining execution environment: ${e.message}`;
}
throw e;
}
if (!isGCE) {
throw new Error(
'Could not load the default credentials. Browse to https://cloud.google.com/docs/authentication/getting-started for more information.'
);
}
(options as ComputeOptions).scopes = this.getAnyScopes();
return await this.prepareAndCacheADC(
new Compute(options),
quotaProjectIdOverride
);
}
private async prepareAndCacheADC(
credential: JSONClient | Impersonated | Compute | T,
quotaProjectIdOverride?: string
): Promise<ADCResponse> {
const projectId = await this.getProjectIdOptional();
if (quotaProjectIdOverride) {
credential.quotaProjectId = quotaProjectIdOverride;
}
this.cachedCredential = credential;
return {credential, projectId};
}
async _checkIsGCE() {
if (this.checkIsGCE === undefined) {
this.checkIsGCE = await gcpMetadata.isAvailable();
}
return this.checkIsGCE;
}
async _tryGetApplicationCredentialsFromEnvironmentVariable(
options?: RefreshOptions
): Promise<JSONClient | null> {
const credentialsPath =
process.env['GOOGLE_APPLICATION_CREDENTIALS'] ||
process.env['google_application_credentials'];
if (!credentialsPath || credentialsPath.length === 0) {
return null;
}
try {
return this._getApplicationCredentialsFromFilePath(
credentialsPath,
options
);
} catch (e) {
if (e instanceof Error) {
e.message = `Unable to read the credential file specified by the GOOGLE_APPLICATION_CREDENTIALS environment variable: ${e.message}`;
}
throw e;
}
}
async _tryGetApplicationCredentialsFromWellKnownFile(
options?: RefreshOptions
): Promise<JSONClient | null> {
let location = null;
if (this._isWindows()) {
location = process.env['APPDATA'];
} else {
const home = process.env['HOME'];
if (home) {
location = path.join(home, '.config');
}
}
if (location) {
location = path.join(
location,
'gcloud',
'application_default_credentials.json'
);
if (!fs.existsSync(location)) {
location = null;
}
}
if (!location) {
return null;
}
const client = await this._getApplicationCredentialsFromFilePath(
location,
options
);
return client;
}
async _getApplicationCredentialsFromFilePath(
filePath: string,
options: RefreshOptions = {}
): Promise<JSONClient> {
if (!filePath || filePath.length === 0) {
throw new Error('The file path is invalid.');
}
try {
filePath = fs.realpathSync(filePath);
if (!fs.lstatSync(filePath).isFile()) {
throw new Error();
}
} catch (err) {
if (err instanceof Error) {
err.message = `The file at ${filePath} does not exist, or it is not a file. ${err.message}`;
}
throw err;
}
const readStream = fs.createReadStream(filePath);
return this.fromStream(readStream, options);
}
fromImpersonatedJSON(json: ImpersonatedJWTInput): Impersonated {
if (!json) {
throw new Error(
'Must pass in a JSON object containing an impersonated refresh token'
);
}
if (json.type !== IMPERSONATED_ACCOUNT_TYPE) {
throw new Error(
`The incoming JSON object does not have the "${IMPERSONATED_ACCOUNT_TYPE}" type`
);
}
if (!json.source_credentials) {
throw new Error(
'The incoming JSON object does not contain a source_credentials field'
);
}
if (!json.service_account_impersonation_url) {
throw new Error(
'The incoming JSON object does not contain a service_account_impersonation_url field'
);
}
const sourceClient = new UserRefreshClient(
json.source_credentials.client_id,
json.source_credentials.client_secret,
json.source_credentials.refresh_token
);
const targetPrincipal = /(?<target>[^/]+):generateAccessToken$/.exec(
json.service_account_impersonation_url
)?.groups?.target;
if (!targetPrincipal) {
throw new RangeError(
`Cannot extract target principal from ${json.service_account_impersonation_url}`
);
}
const targetScopes = this.getAnyScopes() ?? [];
const client = new Impersonated({
delegates: json.delegates ?? [],
sourceClient: sourceClient,
targetPrincipal: targetPrincipal,
targetScopes: Array.isArray(targetScopes) ? targetScopes : [targetScopes],
});
return client;
}
fromJSON(
json: JWTInput | ImpersonatedJWTInput,
options: RefreshOptions = {}
): JSONClient {
let client: JSONClient;
options = options || {};
if (json.type === USER_REFRESH_ACCOUNT_TYPE) {
client = new UserRefreshClient(options);
client.fromJSON(json);
} else if (json.type === IMPERSONATED_ACCOUNT_TYPE) {
client = this.fromImpersonatedJSON(json as ImpersonatedJWTInput);
} else if (json.type === EXTERNAL_ACCOUNT_TYPE) {
client = ExternalAccountClient.fromJSON(
json as ExternalAccountClientOptions,
options
)!;
client.scopes = this.getAnyScopes();
} else if (json.type === EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE) {
client = new ExternalAccountAuthorizedUserClient(
json as ExternalAccountAuthorizedUserClientOptions,
options
);
} else {
(options as JWTOptions).scopes = this.scopes;
client = new JWT(options);
this.setGapicJWTValues(client);
client.fromJSON(json);
}
return client;
}
private _cacheClientFromJSON(
json: JWTInput,
options?: RefreshOptions
): JSONClient {
const client = this.fromJSON(json, options);
this.jsonContent = json;
this.cachedCredential = client;
return client;
}
fromStream(inputStream: stream.Readable): Promise<JSONClient>;
fromStream(inputStream: stream.Readable, callback: CredentialCallback): void;
fromStream(
inputStream: stream.Readable,
options: RefreshOptions
): Promise<JSONClient>;
fromStream(
inputStream: stream.Readable,
options: RefreshOptions,
callback: CredentialCallback
): void;
fromStream(
inputStream: stream.Readable,
optionsOrCallback: RefreshOptions | CredentialCallback = {},
callback?: CredentialCallback
): Promise<JSONClient> | void {
let options: RefreshOptions = {};
if (typeof optionsOrCallback === 'function') {
callback = optionsOrCallback;
} else {
options = optionsOrCallback;
}
if (callback) {
this.fromStreamAsync(inputStream, options).then(
r => callback!(null, r),
callback
);
} else {
return this.fromStreamAsync(inputStream, options);
}
}
private fromStreamAsync(
inputStream: stream.Readable,
options?: RefreshOptions
): Promise<JSONClient> {
return new Promise((resolve, reject) => {
if (!inputStream) {
throw new Error(
'Must pass in a stream containing the Google auth settings.'
);
}
let s = '';
inputStream
.setEncoding('utf8')
.on('error', reject)
.on('data', chunk => (s += chunk))
.on('end', () => {
try {
try {
const data = JSON.parse(s);
const r = this._cacheClientFromJSON(data, options);
return resolve(r);
} catch (err) {
if (!this.keyFilename) throw err;
const client = new JWT({
...this.clientOptions,
keyFile: this.keyFilename,
});
this.cachedCredential = client;
this.setGapicJWTValues(client);
return resolve(client);
}
} catch (err) {
return reject(err);
}
});
});
}
fromAPIKey(apiKey: string, options?: RefreshOptions): JWT {
options = options || {};
const client = new JWT(options);
client.fromAPIKey(apiKey);
return client;
}
private _isWindows() {
const sys = os.platform();
if (sys && sys.length >= 3) {
if (sys.substring(0, 3).toLowerCase() === 'win') {
return true;
}
}
return false;
}
private async getDefaultServiceProjectId(): Promise<string | null> {
return new Promise<string | null>(resolve => {
exec('gcloud config config-helper --format json', (err, stdout) => {
if (!err && stdout) {
try {
const projectId =
JSON.parse(stdout).configuration.properties.core.project;
resolve(projectId);
return;
} catch (e) {
}
}
resolve(null);
});
});
}
private getProductionProjectId() {
return (
process.env['GCLOUD_PROJECT'] ||
process.env['GOOGLE_CLOUD_PROJECT'] ||
process.env['gcloud_project'] ||
process.env['google_cloud_project']
);
}
private async getFileProjectId(): Promise<string | undefined | null> {
if (this.cachedCredential) {
return this.cachedCredential.projectId;
}
if (this.keyFilename) {
const creds = await this.getClient();
if (creds && creds.projectId) {
return creds.projectId;
}
}
const r = await this._tryGetApplicationCredentialsFromEnvironmentVariable();
if (r) {
return r.projectId;
} else {
return null;
}
}
private async getExternalAccountClientProjectId(): Promise<string | null> {
if (!this.jsonContent || this.jsonContent.type !== EXTERNAL_ACCOUNT_TYPE) {
return null;
}
const creds = await this.getClient();
return await (creds as BaseExternalAccountClient).getProjectId();
}
private async getGCEProjectId() {
try {
const r = await gcpMetadata.project('project-id');
return r;
} catch (e) {
return null;
}
}
getCredentials(): Promise<CredentialBody>;
getCredentials(
callback: (err: Error | null, credentials?: CredentialBody) => void
): void;
getCredentials(
callback?: (err: Error | null, credentials?: CredentialBody) => void
): void | Promise<CredentialBody> {
if (callback) {
this.getCredentialsAsync().then(r => callback(null, r), callback);
} else {
return this.getCredentialsAsync();
}
}
private async getCredentialsAsync(): Promise<CredentialBody> {
const client = await this.getClient();
if (client instanceof BaseExternalAccountClient) {
const serviceAccountEmail = client.getServiceAccountEmail();
if (serviceAccountEmail) {
return {client_email: serviceAccountEmail};
}
}
if (this.jsonContent) {
const credential: CredentialBody = {
client_email: (this.jsonContent as JWTInput).client_email,
private_key: (this.jsonContent as JWTInput).private_key,
};
return credential;
}
const isGCE = await this._checkIsGCE();
if (!isGCE) {
throw new Error('Unknown error.');
}
const data = await gcpMetadata.instance({
property: 'service-accounts/',
params: {recursive: 'true'},
});
if (!data || !data.default || !data.default.email) {
throw new Error('Failure from metadata server.');
}
return {client_email: data.default.email};
}
async getClient() {
if (!this.cachedCredential) {
if (this.jsonContent) {
this._cacheClientFromJSON(this.jsonContent, this.clientOptions);
} else if (this.keyFilename) {
const filePath = path.resolve(this.keyFilename);
const stream = fs.createReadStream(filePath);
await this.fromStreamAsync(stream, this.clientOptions);
} else {
await this.getApplicationDefaultAsync(this.clientOptions);
}
}
return this.cachedCredential!;
}
async getIdTokenClient(targetAudience: string): Promise<IdTokenClient> {
const client = await this.getClient();
if (!('fetchIdToken' in client)) {
throw new Error(
'Cannot fetch ID token in this environment, use GCE or set the GOOGLE_APPLICATION_CREDENTIALS environment variable to a service account credentials JSON file.'
);
}
return new IdTokenClient({targetAudience, idTokenProvider: client});
}
async getAccessToken() {
const client = await this.getClient();
return (await client.getAccessToken()).token;
}
async getRequestHeaders(url?: string) {
const client = await this.getClient();
return client.getRequestHeaders(url);
}
async authorizeRequest(opts: {
url?: string;
uri?: string;
headers?: Headers;
}) {
opts = opts || {};
const url = opts.url || opts.uri;
const client = await this.getClient();
const headers = await client.getRequestHeaders(url);
opts.headers = Object.assign(opts.headers || {}, headers);
return opts;
}
async request<T = any>(opts: GaxiosOptions): Promise<GaxiosResponse<T>> {
const client = await this.getClient();
return client.request<T>(opts);
}
getEnv(): Promise<GCPEnv> {
return getEnv();
}
async sign(data: string): Promise<string> {
const client = await this.getClient();
const crypto = createCrypto();
if (client instanceof JWT && client.key) {
const sign = await crypto.sign(client.key, data);
return sign;
}
const creds = await this.getCredentials();
if (!creds.client_email) {
throw new Error('Cannot sign data without `client_email`.');
}
return this.signBlob(crypto, creds.client_email, data);
}
private async signBlob(
crypto: Crypto,
emailOrUniqueId: string,
data: string
): Promise<string> {
const url =
'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/' +
`${emailOrUniqueId}:signBlob`;
const res = await this.request<SignBlobResponse>({
method: 'POST',
url,
data: {
payload: crypto.encodeBase64StringUtf8(data),
},
});
return res.data.signedBlob;
}
}
export interface SignBlobResponse {
keyId: string;
signedBlob: string;
}