diff --git a/src/auth/authclient.ts b/src/auth/authclient.ts index f7d4d13c..9f85faec 100644 --- a/src/auth/authclient.ts +++ b/src/auth/authclient.ts @@ -13,26 +13,112 @@ // limitations under the License. import {EventEmitter} from 'events'; -import {GaxiosOptions, GaxiosPromise} from 'gaxios'; +import {GaxiosOptions, GaxiosPromise, GaxiosResponse} from 'gaxios'; import {DefaultTransporter} from '../transporters'; import {Credentials} from './credentials'; import {Headers} from './oauth2client'; +/** + * Defines the root interface for all clients that generate credentials + * for calling Google APIs. All clients should implement this interface. + */ +export interface CredentialsClient { + /** + * The project ID corresponding to the current credentials if available. + */ + projectId?: string | null; + + /** + * The expiration threshold in milliseconds before forcing token refresh. + */ + eagerRefreshThresholdMillis: number; + + /** + * Whether to force refresh on failure when making an authorization request. + */ + forceRefreshOnFailure: boolean; + + /** + * @return A promise that resolves with the current GCP access token + * response. If the current credential is expired, a new one is retrieved. + */ + getAccessToken(): Promise<{ + token?: string | null; + res?: GaxiosResponse | null; + }>; + + /** + * The main authentication interface. It takes an optional url which when + * present is the endpoint> being accessed, and returns a Promise which + * resolves with authorization header fields. + * + * The result has the form: + * { Authorization: 'Bearer ' } + * @param url The URI being authorized. + */ + getRequestHeaders(url?: string): Promise; + + /** + * Provides an alternative Gaxios request implementation with auth credentials + */ + request(opts: GaxiosOptions): GaxiosPromise; + + /** + * Sets the auth credentials. + */ + setCredentials(credentials: Credentials): void; + + /** + * Subscribes a listener to the tokens event triggered when a token is + * generated. + * + * @param event The tokens event to subscribe to. + * @param listener The listener that triggers on event trigger. + * @return The current client instance. + */ + on(event: 'tokens', listener: (tokens: Credentials) => void): this; +} + export declare interface AuthClient { on(event: 'tokens', listener: (tokens: Credentials) => void): this; } -export abstract class AuthClient extends EventEmitter { +export abstract class AuthClient + extends EventEmitter + implements CredentialsClient { protected quotaProjectId?: string; transporter = new DefaultTransporter(); credentials: Credentials = {}; + projectId?: string | null; + eagerRefreshThresholdMillis = 5 * 60 * 1000; + forceRefreshOnFailure = false; /** * Provides an alternative Gaxios request implementation with auth credentials */ abstract request(opts: GaxiosOptions): GaxiosPromise; + /** + * The main authentication interface. It takes an optional url which when + * present is the endpoint> being accessed, and returns a Promise which + * resolves with authorization header fields. + * + * The result has the form: + * { Authorization: 'Bearer ' } + * @param url The URI being authorized. + */ + abstract getRequestHeaders(url?: string): Promise; + + /** + * @return A promise that resolves with the current GCP access token + * response. If the current credential is expired, a new one is retrieved. + */ + abstract getAccessToken(): Promise<{ + token?: string | null; + res?: GaxiosResponse | null; + }>; + /** * Sets the auth credentials. */ diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index 75ab05c1..f30edd53 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -41,7 +41,13 @@ const DEFAULT_OAUTH_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'; * Offset to take into account network delays and server clock skews. */ export const EXPIRATION_TIME_OFFSET = 5 * 60 * 1000; -/** The credentials JSON file type for external account clients. */ +/** + * The credentials JSON file type for external account clients. + * There are 3 types of JSON configs: + * 1. authorized_user => Google end user credential + * 2. service_account => Google service account credential + * 3. external_Account => non-GCP service (eg. AWS, Azure, K8s) + */ export const EXTERNAL_ACCOUNT_TYPE = 'external_account'; /** Cloud resource manager URL used to retrieve project information. */ export const CLOUD_RESOURCE_MANAGER = @@ -112,14 +118,14 @@ export abstract class BaseExternalAccountClient extends AuthClient { */ public scopes?: string | string[]; private cachedAccessToken: CredentialsWithResponse | null; - private eagerRefreshThresholdMillis: number; - private forceRefreshOnFailure: boolean; protected readonly audience: string; private readonly subjectTokenType: string; private readonly serviceAccountImpersonationUrl?: string; private readonly stsCredential: sts.StsCredentials; public projectId: string | null; public projectNumber: string | null; + public readonly eagerRefreshThresholdMillis: number; + public readonly forceRefreshOnFailure: boolean; /** * Instantiate a BaseExternalAccountClient instance using the provided JSON @@ -467,9 +473,15 @@ export abstract class BaseExternalAccountClient extends AuthClient { /** * @return The list of scopes for the requested GCP access token. */ - private getScopesArray(): string[] | undefined { + private getScopesArray(): string[] { // Since scopes can be provided as string or array, the type should // be normalized. - return typeof this.scopes === 'string' ? [this.scopes] : this.scopes; + if (typeof this.scopes === 'string') { + return [this.scopes]; + } else if (typeof this.scopes === 'undefined') { + return [DEFAULT_OAUTH_SCOPE]; + } else { + return this.scopes; + } } } diff --git a/src/auth/externalclient.ts b/src/auth/externalclient.ts index f3c43966..67d02a57 100644 --- a/src/auth/externalclient.ts +++ b/src/auth/externalclient.ts @@ -15,7 +15,13 @@ import {RefreshOptions} from './oauth2client'; import { BaseExternalAccountClient, - BaseExternalAccountClientOptions, + // This is the identifier in the JSON config for the type of credential. + // This string constant indicates that an external account client should be + // instantiated. + // There are 3 types of JSON configs: + // 1. authorized_user => Google end user credential + // 2. service_account => Google service account credential + // 3. external_Account => non-GCP service (eg. AWS, Azure, K8s) EXTERNAL_ACCOUNT_TYPE, } from './baseexternalclient'; import { @@ -55,7 +61,7 @@ export class ExternalAccountClient { * provided do not correspond to an external account credential. */ static fromJSON( - options: BaseExternalAccountClientOptions, + options: ExternalAccountClientOptions, additionalOptions?: RefreshOptions ): BaseExternalAccountClient | null { if (options && options.type === EXTERNAL_ACCOUNT_TYPE) { diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index fb5065e0..e5724487 100644 --- a/src/auth/googleauth.ts +++ b/src/auth/googleauth.ts @@ -28,35 +28,41 @@ import {CredentialBody, JWTInput} from './credentials'; import {IdTokenClient} from './idtokenclient'; import {GCPEnv, getEnv} from './envDetect'; import {JWT, JWTOptions} from './jwtclient'; -import { - Headers, - OAuth2Client, - OAuth2ClientOptions, - RefreshOptions, -} from './oauth2client'; +import {Headers, OAuth2ClientOptions, RefreshOptions} from './oauth2client'; import {UserRefreshClient, UserRefreshClientOptions} from './refreshclient'; +import { + ExternalAccountClient, + ExternalAccountClientOptions, +} from './externalclient'; +import { + EXTERNAL_ACCOUNT_TYPE, + BaseExternalAccountClient, +} from './baseexternalclient'; +import {AuthClient} from './authclient'; + +/** + * Defines all types of explicit clients that are determined via ADC JSON + * config file. + */ +export type JSONClient = JWT | UserRefreshClient | BaseExternalAccountClient; export interface ProjectIdCallback { (err?: Error | null, projectId?: string | null): void; } export interface CredentialCallback { - (err: Error | null, result?: UserRefreshClient | JWT): void; + (err: Error | null, result?: JSONClient): void; } // eslint-disable-next-line @typescript-eslint/no-empty-interface interface DeprecatedGetClientOptions {} export interface ADCCallback { - ( - err: Error | null, - credential?: OAuth2Client, - projectId?: string | null - ): void; + (err: Error | null, credential?: AuthClient, projectId?: string | null): void; } export interface ADCResponse { - credential: OAuth2Client; + credential: AuthClient; projectId: string | null; } @@ -72,9 +78,10 @@ export interface GoogleAuthOptions { keyFile?: string; /** - * Object containing client_email and private_key properties + * Object containing client_email and private_key properties, or the + * external account client options. */ - credentials?: CredentialBody; + credentials?: CredentialBody | ExternalAccountClientOptions; /** * Options object passed to the constructor of the client @@ -115,9 +122,9 @@ export class GoogleAuth { private _cachedProjectId?: string | null; // To save the contents of the JSON credential file - jsonContent: JWTInput | null = null; + jsonContent: JWTInput | ExternalAccountClientOptions | null = null; - cachedCredential: JWT | UserRefreshClient | Compute | null = null; + cachedCredential: JSONClient | Compute | null = null; private keyFilename?: string; private scopes?: string | string[]; @@ -174,7 +181,8 @@ export class GoogleAuth { this.getProductionProjectId() || (await this.getFileProjectId()) || (await this.getDefaultServiceProjectId()) || - (await this.getGCEProjectId()); + (await this.getGCEProjectId()) || + (await this.getExternalAccountClientProjectId()); this._cachedProjectId = projectId; if (!projectId) { throw new Error( @@ -229,12 +237,12 @@ export class GoogleAuth { // If we've already got a cached credential, just return it. if (this.cachedCredential) { return { - credential: this.cachedCredential as JWT | UserRefreshClient, + credential: this.cachedCredential as JSONClient, projectId: await this.getProjectIdAsync(), }; } - let credential: JWT | UserRefreshClient | null; + let credential: JSONClient | null; let projectId: string | null; // Check for the existence of a local environment variable pointing to the // location of the credential file. This is typically used in local @@ -243,7 +251,10 @@ export class GoogleAuth { options ); if (credential) { - if (credential instanceof JWT) { + if ( + credential instanceof JWT || + credential instanceof BaseExternalAccountClient + ) { credential.scopes = this.scopes; } this.cachedCredential = credential; @@ -256,7 +267,10 @@ export class GoogleAuth { options ); if (credential) { - if (credential instanceof JWT) { + if ( + credential instanceof JWT || + credential instanceof BaseExternalAccountClient + ) { credential.scopes = this.scopes; } this.cachedCredential = credential; @@ -307,7 +321,7 @@ export class GoogleAuth { */ async _tryGetApplicationCredentialsFromEnvironmentVariable( options?: RefreshOptions - ): Promise { + ): Promise { const credentialsPath = process.env['GOOGLE_APPLICATION_CREDENTIALS'] || process.env['google_application_credentials']; @@ -332,7 +346,7 @@ export class GoogleAuth { */ async _tryGetApplicationCredentialsFromWellKnownFile( options?: RefreshOptions - ): Promise { + ): Promise { // First, figure out the location of the file, depending upon the OS type. let location = null; if (this._isWindows()) { @@ -377,7 +391,7 @@ export class GoogleAuth { async _getApplicationCredentialsFromFilePath( filePath: string, options: RefreshOptions = {} - ): Promise { + ): Promise { // Make sure the path looks like a string. if (!filePath || filePath.length === 0) { throw new Error('The file path is invalid.'); @@ -409,8 +423,8 @@ export class GoogleAuth { * @param options The JWT or UserRefresh options for the client * @returns JWT or UserRefresh Client with data */ - fromJSON(json: JWTInput, options?: RefreshOptions): JWT | UserRefreshClient { - let client: UserRefreshClient | JWT; + fromJSON(json: JWTInput, options?: RefreshOptions): JSONClient { + let client: JSONClient; if (!json) { throw new Error( 'Must pass in a JSON object containing the Google auth settings.' @@ -419,11 +433,18 @@ export class GoogleAuth { options = options || {}; if (json.type === 'authorized_user') { client = new UserRefreshClient(options); + client.fromJSON(json); + } else if (json.type === EXTERNAL_ACCOUNT_TYPE) { + client = ExternalAccountClient.fromJSON( + json as ExternalAccountClientOptions, + options + )!; + client.scopes = this.scopes; } else { (options as JWTOptions).scopes = this.scopes; client = new JWT(options); + client.fromJSON(json); } - client.fromJSON(json); return client; } @@ -437,17 +458,24 @@ export class GoogleAuth { private _cacheClientFromJSON( json: JWTInput, options?: RefreshOptions - ): JWT | UserRefreshClient { - let client: UserRefreshClient | JWT; + ): JSONClient { + let client: JSONClient; // create either a UserRefreshClient or JWT client. options = options || {}; if (json.type === 'authorized_user') { client = new UserRefreshClient(options); + client.fromJSON(json); + } else if (json.type === EXTERNAL_ACCOUNT_TYPE) { + client = ExternalAccountClient.fromJSON( + json as ExternalAccountClientOptions, + options + )!; + client.scopes = this.scopes; } else { (options as JWTOptions).scopes = this.scopes; client = new JWT(options); + client.fromJSON(json); } - client.fromJSON(json); // cache both raw data used to instantiate client and client itself. this.jsonContent = json; this.cachedCredential = client; @@ -459,12 +487,12 @@ export class GoogleAuth { * @param inputStream The input stream. * @param callback Optional callback. */ - fromStream(inputStream: stream.Readable): Promise; + fromStream(inputStream: stream.Readable): Promise; fromStream(inputStream: stream.Readable, callback: CredentialCallback): void; fromStream( inputStream: stream.Readable, options: RefreshOptions - ): Promise; + ): Promise; fromStream( inputStream: stream.Readable, options: RefreshOptions, @@ -474,7 +502,7 @@ export class GoogleAuth { inputStream: stream.Readable, optionsOrCallback: RefreshOptions | CredentialCallback = {}, callback?: CredentialCallback - ): Promise | void { + ): Promise | void { let options: RefreshOptions = {}; if (typeof optionsOrCallback === 'function') { callback = optionsOrCallback; @@ -494,7 +522,7 @@ export class GoogleAuth { private fromStreamAsync( inputStream: stream.Readable, options?: RefreshOptions - ): Promise { + ): Promise { return new Promise((resolve, reject) => { if (!inputStream) { throw new Error( @@ -607,6 +635,21 @@ export class GoogleAuth { } } + /** + * Gets the project ID from external account client if available. + */ + private async getExternalAccountClientProjectId(): Promise { + if (this.jsonContent && this.jsonContent.type === EXTERNAL_ACCOUNT_TYPE) { + const creds = await this.getClient(); + try { + return await (creds as BaseExternalAccountClient).getProjectId(); + } catch (e) { + return null; + } + } + return null; + } + /** * Gets the Compute Engine project ID if it can be inferred. */ @@ -649,8 +692,8 @@ export class GoogleAuth { if (this.jsonContent) { const credential: CredentialBody = { - client_email: this.jsonContent.client_email, - private_key: this.jsonContent.private_key, + client_email: (this.jsonContent as JWTInput).client_email, + private_key: (this.jsonContent as JWTInput).private_key, }; return credential; } diff --git a/src/index.ts b/src/index.ts index 722e14a4..6e2f23b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,15 @@ export { UserRefreshClient, UserRefreshClientOptions, } from './auth/refreshclient'; +export {AwsClient, AwsClientOptions} from './auth/awsclient'; +export { + IdentityPoolClient, + IdentityPoolClientOptions, +} from './auth/identitypoolclient'; +export { + ExternalAccountClient, + ExternalAccountClientOptions, +} from './auth/externalclient'; export {DefaultTransporter} from './transporters'; const auth = new GoogleAuth(); diff --git a/test/fixtures/external-account-cred.json b/test/fixtures/external-account-cred.json new file mode 100644 index 00000000..df7ba5a6 --- /dev/null +++ b/test/fixtures/external-account-cred.json @@ -0,0 +1,9 @@ +{ + "type": "external_account", + "audience": "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "file": "./test/fixtures/external-subject-token.txt" + } +} \ No newline at end of file diff --git a/test/test.baseexternalclient.ts b/test/test.baseexternalclient.ts index beb43cdd..e9975c7c 100644 --- a/test/test.baseexternalclient.ts +++ b/test/test.baseexternalclient.ts @@ -140,6 +140,33 @@ describe('BaseExternalAccountClient', () => { return new TestExternalAccountClient(externalAccountOptions); }); }); + + it('should set default RefreshOptions', () => { + const client = new TestExternalAccountClient(externalAccountOptions); + + assert(!client.forceRefreshOnFailure); + assert(client.eagerRefreshThresholdMillis === EXPIRATION_TIME_OFFSET); + }); + + it('should set custom RefreshOptions', () => { + const refreshOptions = { + eagerRefreshThresholdMillis: 5000, + forceRefreshOnFailure: true, + }; + const client = new TestExternalAccountClient( + externalAccountOptions, + refreshOptions + ); + + assert.strictEqual( + client.forceRefreshOnFailure, + refreshOptions.forceRefreshOnFailure + ); + assert.strictEqual( + client.eagerRefreshThresholdMillis, + refreshOptions.eagerRefreshThresholdMillis + ); + }); }); describe('projectNumber', () => { diff --git a/test/test.googleauth.ts b/test/test.googleauth.ts index 109998de..621a3718 100644 --- a/test/test.googleauth.ts +++ b/test/test.googleauth.ts @@ -29,10 +29,25 @@ import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; -import {GoogleAuth, JWT, UserRefreshClient, IdTokenClient} from '../src'; +import { + GoogleAuth, + JWT, + UserRefreshClient, + IdTokenClient, + ExternalAccountClient, + OAuth2Client, + ExternalAccountClientOptions, + RefreshOptions, +} from '../src'; import {CredentialBody} from '../src/auth/credentials'; import * as envDetect from '../src/auth/envDetect'; import {Compute} from '../src/auth/computeclient'; +import { + mockCloudResourceManager, + mockStsTokenExchange, +} from './externalclienthelper'; +import {BaseExternalAccountClient} from '../src/auth/baseexternalclient'; +import {AuthClient} from '../src/auth/authclient'; nock.disableNetConnect(); @@ -58,6 +73,8 @@ describe('googleauth', () => { const private2JSON = require('../../test/fixtures/private2.json'); // eslint-disable-next-line @typescript-eslint/no-var-requires const refreshJSON = require('../../test/fixtures/refresh.json'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const externalAccountJSON = require('../../test/fixtures/external-account-cred.json'); const privateKey = fs.readFileSync('./test/fixtures/private.pem', 'utf-8'); const wellKnownPathWindows = path.join( 'C:', @@ -159,10 +176,11 @@ describe('googleauth', () => { fs.createReadStream('./test/fixtures/private2.json'); } - function mockLinuxWellKnownFile() { + function mockLinuxWellKnownFile( + filePath = './test/fixtures/private2.json' + ) { exposeLinuxWellKnownFile = true; - createLinuxWellKnownStream = () => - fs.createReadStream('./test/fixtures/private2.json'); + createLinuxWellKnownStream = () => fs.createReadStream(filePath); } function nockIsGCE() { @@ -961,7 +979,7 @@ describe('googleauth', () => { // a JWTClient. assert.strictEqual( 'compute-placeholder', - res.credential.credentials.refresh_token + (res.credential as OAuth2Client).credentials.refresh_token ); }); @@ -1523,5 +1541,508 @@ describe('googleauth', () => { .post('/token') .reply(200, {}); } + + describe('for external_account types', () => { + let fromJsonSpy: sinon.SinonSpy< + [ExternalAccountClientOptions, RefreshOptions?], + BaseExternalAccountClient | null + >; + const stsSuccessfulResponse = { + access_token: 'ACCESS_TOKEN', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'scope1 scope2', + }; + const fileSubjectToken = fs.readFileSync( + externalAccountJSON.credential_source.file, + 'utf-8' + ); + // Project number should match the project number in externalAccountJSON. + const projectNumber = '123456'; + const projectId = 'my-proj-id'; + const projectInfoResponse = { + projectNumber, + projectId, + lifecycleState: 'ACTIVE', + name: 'project-name', + createTime: '2018-11-06T04:42:54.109Z', + parent: { + type: 'folder', + id: '12345678901', + }, + }; + const refreshOptions = { + eagerRefreshThresholdMillis: 5000, + forceRefreshOnFailure: true, + }; + + /** + * @return A copy of the external account JSON auth object for testing. + */ + function createExternalAccountJSON() { + const credentialSourceCopy = Object.assign( + {}, + externalAccountJSON.credential_source + ); + const jsonCopy = Object.assign({}, externalAccountJSON); + jsonCopy.credential_source = credentialSourceCopy; + return jsonCopy; + } + + /** + * Creates mock HTTP handlers for retrieving access tokens and + * optional ones for retrieving the project ID via cloud resource + * manager. + * @param mockProjectIdRetrieval Whether to mock project ID retrieval. + * @return The list of nock.Scope corresponding to the mocked HTTP + * requests. + */ + function mockGetAccessTokenAndProjectId( + mockProjectIdRetrieval = true + ): nock.Scope[] { + const scopes = [ + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience: externalAccountJSON.audience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: fileSubjectToken, + subject_token_type: externalAccountJSON.subject_token_type, + }, + }, + ]), + ]; + + if (mockProjectIdRetrieval) { + scopes.push( + mockCloudResourceManager( + projectNumber, + stsSuccessfulResponse.access_token, + 200, + projectInfoResponse + ) + ); + } + + return scopes; + } + + /** + * Asserts that the provided client was initialized with the expected + * JSON object and RefreshOptions. + * @param actualClient The actual client to assert. + * @param json The expected JSON object that the client should be + * initialized with. + * @param options The expected RefreshOptions the client should be + * initialized with. + */ + function assertExternalAccountClientInitialized( + actualClient: AuthClient, + json: ExternalAccountClientOptions, + options: RefreshOptions + ) { + // Confirm expected client is initialized. + assert(fromJsonSpy.calledOnceWithExactly(json, options)); + assert(fromJsonSpy.returned(actualClient as BaseExternalAccountClient)); + } + + beforeEach(() => { + // Listen to external account initializations. + // This is useful to confirm that a GoogleAuth returned client is + // an external account initialized with the expected parameters. + fromJsonSpy = sinon.spy(ExternalAccountClient, 'fromJSON'); + }); + + afterEach(() => { + fromJsonSpy.restore(); + }); + + describe('fromJSON()', () => { + it('should create the expected BaseExternalAccountClient', () => { + const json = createExternalAccountJSON(); + const result = auth.fromJSON(json); + + assertExternalAccountClientInitialized(result, json, {}); + }); + + it('should create client with custom RefreshOptions', () => { + const json = createExternalAccountJSON(); + const result = auth.fromJSON(json, refreshOptions); + + assertExternalAccountClientInitialized(result, json, refreshOptions); + }); + + it('should throw on invalid json', () => { + const invalidJson = createExternalAccountJSON(); + delete invalidJson.credential_source; + const auth = new GoogleAuth(); + + assert.throws(() => { + auth.fromJSON(invalidJson); + }); + }); + }); + + describe('fromStream()', () => { + it('should read the stream and create a client', async () => { + const stream = fs.createReadStream( + './test/fixtures/external-account-cred.json' + ); + const actualClient = await auth.fromStream(stream); + + assertExternalAccountClientInitialized( + actualClient, + createExternalAccountJSON(), + {} + ); + }); + + it('should include provided RefreshOptions in client', async () => { + const stream = fs.createReadStream( + './test/fixtures/external-account-cred.json' + ); + const auth = new GoogleAuth(); + const result = await auth.fromStream(stream, refreshOptions); + + assertExternalAccountClientInitialized( + result, + createExternalAccountJSON(), + refreshOptions + ); + }); + }); + + describe('getApplicationDefault()', () => { + it('should use environment variable when it is set', async () => { + const scopes = mockGetAccessTokenAndProjectId(); + // Environment variable is set up to point to + // external-account-cred.json + mockEnvVar( + 'GOOGLE_APPLICATION_CREDENTIALS', + './test/fixtures/external-account-cred.json' + ); + + const res = await auth.getApplicationDefault(); + const client = res.credential; + + assertExternalAccountClientInitialized( + client, + createExternalAccountJSON(), + {} + ); + // Project ID should also be set. + assert.deepEqual(client.projectId, projectId); + scopes.forEach(s => s.done()); + }); + + it('should use well-known file when it is available and env const is not set', async () => { + // Set up the creds. + // * Environment variable is not set. + // * Well-known file is set up to point to external-account-cred.json + mockLinuxWellKnownFile('./test/fixtures/external-account-cred.json'); + const scopes = mockGetAccessTokenAndProjectId(); + + const res = await auth.getApplicationDefault(); + const client = res.credential; + + assertExternalAccountClientInitialized( + client, + createExternalAccountJSON(), + {} + ); + assert.deepEqual(client.projectId, projectId); + scopes.forEach(s => s.done()); + }); + }); + + describe('getApplicationCredentialsFromFilePath()', () => { + it('should correctly read the file and create a valid client', async () => { + const actualClient = await auth._getApplicationCredentialsFromFilePath( + './test/fixtures/external-account-cred.json' + ); + + assertExternalAccountClientInitialized( + actualClient, + createExternalAccountJSON(), + {} + ); + }); + + it('should include provided RefreshOptions in client', async () => { + const result = await auth._getApplicationCredentialsFromFilePath( + './test/fixtures/external-account-cred.json', + refreshOptions + ); + + assertExternalAccountClientInitialized( + result, + createExternalAccountJSON(), + refreshOptions + ); + }); + }); + + describe('getProjectId()', () => { + it('should get projectId from cloud resource manager', async () => { + const scopes = mockGetAccessTokenAndProjectId(); + const keyFilename = './test/fixtures/external-account-cred.json'; + const auth = new GoogleAuth({keyFilename}); + const actualProjectId = await auth.getProjectId(); + + assert.deepEqual(actualProjectId, projectId); + scopes.forEach(s => s.done()); + }); + + it('should prioritize explicitly provided projectId', async () => { + const explicitProjectId = 'my-explictly-specified-project-id'; + const auth = new GoogleAuth({ + credentials: createExternalAccountJSON(), + projectId: explicitProjectId, + }); + const actualProjectId = await auth.getProjectId(); + + assert.deepEqual(actualProjectId, explicitProjectId); + }); + + it('should reject when client.getProjectId() fails', async () => { + const scopes = mockGetAccessTokenAndProjectId(false); + scopes.push( + mockCloudResourceManager( + projectNumber, + stsSuccessfulResponse.access_token, + 403, + { + error: { + code: 403, + message: 'The caller does not have permission', + status: 'PERMISSION_DENIED', + }, + } + ) + ); + const keyFilename = './test/fixtures/external-account-cred.json'; + const auth = new GoogleAuth({keyFilename}); + + await assert.rejects( + auth.getProjectId(), + /Unable to detect a Project Id in the current environment/ + ); + scopes.forEach(s => s.done()); + }); + + it('should reject on invalid external_account client', async () => { + const invalidOptions = createExternalAccountJSON(); + invalidOptions.credential_source.file = 'invalid'; + const auth = new GoogleAuth({credentials: invalidOptions}); + + await assert.rejects( + auth.getProjectId(), + /Unable to detect a Project Id in the current environment/ + ); + }); + + it('should reject when projectId not determinable', async () => { + const json = createExternalAccountJSON(); + json.audience = 'identitynamespace:1f12345:my_provider'; + const auth = new GoogleAuth({credentials: json}); + + await assert.rejects( + auth.getProjectId(), + /Unable to detect a Project Id in the current environment/ + ); + }); + }); + + it('tryGetApplicationCredentialsFromEnvironmentVariable() should resolve', async () => { + // Set up a mock to return path to a valid credentials file. + mockEnvVar( + 'GOOGLE_APPLICATION_CREDENTIALS', + './test/fixtures/external-account-cred.json' + ); + const result = await auth._tryGetApplicationCredentialsFromEnvironmentVariable( + refreshOptions + ); + + assert(result); + assertExternalAccountClientInitialized( + result as AuthClient, + createExternalAccountJSON(), + refreshOptions + ); + }); + + it('tryGetApplicationCredentialsFromWellKnownFile() should resolve', async () => { + // Set up a mock to return path to a valid credentials file. + mockLinuxWellKnownFile('./test/fixtures/external-account-cred.json'); + const result = await auth._tryGetApplicationCredentialsFromWellKnownFile( + refreshOptions + ); + + assert(result); + assertExternalAccountClientInitialized( + result as AuthClient, + createExternalAccountJSON(), + refreshOptions + ); + }); + + it('getApplicationCredentialsFromFilePath() should resolve', async () => { + const result = await auth._getApplicationCredentialsFromFilePath( + './test/fixtures/external-account-cred.json', + refreshOptions + ); + + assertExternalAccountClientInitialized( + result, + createExternalAccountJSON(), + refreshOptions + ); + }); + + describe('getClient()', () => { + it('should initialize from credentials', async () => { + const auth = new GoogleAuth({ + credentials: createExternalAccountJSON(), + }); + const actualClient = await auth.getClient(); + + assertExternalAccountClientInitialized( + actualClient, + createExternalAccountJSON(), + {} + ); + }); + + it('should initialize from keyFileName', async () => { + const keyFilename = './test/fixtures/external-account-cred.json'; + const auth = new GoogleAuth({keyFilename}); + const actualClient = await auth.getClient(); + + assertExternalAccountClientInitialized( + actualClient, + createExternalAccountJSON(), + {} + ); + }); + + it('should initialize from ADC', async () => { + const scopes = mockGetAccessTokenAndProjectId(); + // Set up a mock to return path to a valid credentials file. + mockEnvVar( + 'GOOGLE_APPLICATION_CREDENTIALS', + './test/fixtures/external-account-cred.json' + ); + const auth = new GoogleAuth(); + const client = await auth.getClient(); + + assertExternalAccountClientInitialized( + client, + createExternalAccountJSON(), + {} + ); + scopes.forEach(s => s.done()); + }); + + it('should allow passing scopes to get a client', async () => { + const scopes = ['http://examples.com/is/a/scope']; + const keyFilename = './test/fixtures/external-account-cred.json'; + const auth = new GoogleAuth({scopes, keyFilename}); + const client = (await auth.getClient()) as BaseExternalAccountClient; + + assert.strictEqual(client.scopes, scopes); + }); + + it('should allow passing a scope to get a client', async () => { + const scopes = 'http://examples.com/is/a/scope'; + const keyFilename = './test/fixtures/external-account-cred.json'; + const auth = new GoogleAuth({scopes, keyFilename}); + const client = (await auth.getClient()) as BaseExternalAccountClient; + + assert.strictEqual(client.scopes, scopes); + }); + }); + + it('getIdTokenClient() should reject', async () => { + const auth = new GoogleAuth({credentials: createExternalAccountJSON()}); + + await assert.rejects( + auth.getIdTokenClient('a-target-audience'), + /Cannot fetch ID token in this environment/ + ); + }); + + it('sign() should reject', async () => { + const scopes = mockGetAccessTokenAndProjectId(); + const auth = new GoogleAuth({credentials: createExternalAccountJSON()}); + + await assert.rejects( + auth.sign('abc123'), + /Cannot sign data without `client_email`/ + ); + scopes.forEach(s => s.done()); + }); + + it('getAccessToken() should get an access token', async () => { + const scopes = mockGetAccessTokenAndProjectId(false); + const keyFilename = './test/fixtures/external-account-cred.json'; + const auth = new GoogleAuth({keyFilename}); + const token = await auth.getAccessToken(); + + assert.strictEqual(token, stsSuccessfulResponse.access_token); + scopes.forEach(s => s.done()); + }); + + it('getRequestHeaders() should inject authorization header', async () => { + const scopes = mockGetAccessTokenAndProjectId(false); + const keyFilename = './test/fixtures/external-account-cred.json'; + const auth = new GoogleAuth({keyFilename}); + const headers = await auth.getRequestHeaders(); + + assert.deepStrictEqual(headers, { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + }); + scopes.forEach(s => s.done()); + }); + + it('authorizeRequest() should authorize the request', async () => { + const scopes = mockGetAccessTokenAndProjectId(false); + const keyFilename = './test/fixtures/external-account-cred.json'; + const auth = new GoogleAuth({keyFilename}); + const opts = await auth.authorizeRequest({url: 'http://example.com'}); + + assert.deepStrictEqual(opts.headers, { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + }); + scopes.forEach(s => s.done()); + }); + + it('request() should make the request with auth header', async () => { + const url = 'http://example.com'; + const data = {breakfast: 'coffee'}; + const keyFilename = './test/fixtures/external-account-cred.json'; + const scopes = mockGetAccessTokenAndProjectId(false); + scopes.push( + nock(url) + .get('/', undefined, { + reqheaders: { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + }, + }) + .reply(200, data) + ); + + const auth = new GoogleAuth({keyFilename}); + const res = await auth.request({url}); + + assert.deepStrictEqual(res.data, data); + scopes.forEach(s => s.done()); + }); + }); }); }); diff --git a/test/test.index.ts b/test/test.index.ts index a68e00f3..f790a3ca 100644 --- a/test/test.index.ts +++ b/test/test.index.ts @@ -38,5 +38,8 @@ describe('index', () => { assert(gal.OAuth2Client); assert(gal.UserRefreshClient); assert(gal.GoogleAuth); + assert(gal.ExternalAccountClient); + assert(gal.IdentityPoolClient); + assert(gal.AwsClient); }); });