diff --git a/src/app/credential-internal.ts b/src/app/credential-internal.ts index 8d4c70b612..eafdb07059 100644 --- a/src/app/credential-internal.ts +++ b/src/app/credential-internal.ts @@ -366,6 +366,107 @@ class RefreshToken { } +/** + * Implementation of Credential that uses impersonated service account. + */ +export class ImpersonatedServiceAccountCredential implements Credential { + + private readonly impersonatedServiceAccount: ImpersonatedServiceAccount; + private readonly httpClient: HttpClient; + + /** + * Creates a new ImpersonatedServiceAccountCredential from the given parameters. + * + * @param impersonatedServiceAccountPathOrObject - Impersonated Service account json object or + * path to a service account json file. + * @param httpAgent - Optional http.Agent to use when calling the remote token server. + * @param implicit - An optinal boolean indicating whether this credential was implicitly + * discovered from the environment, as opposed to being explicitly specified by the developer. + * + * @constructor + */ + constructor( + impersonatedServiceAccountPathOrObject: string | object, + private readonly httpAgent?: Agent, + readonly implicit: boolean = false) { + + this.impersonatedServiceAccount = (typeof impersonatedServiceAccountPathOrObject === 'string') ? + ImpersonatedServiceAccount.fromPath(impersonatedServiceAccountPathOrObject) + : new ImpersonatedServiceAccount(impersonatedServiceAccountPathOrObject); + this.httpClient = new HttpClient(); + } + + public getAccessToken(): Promise<GoogleOAuthAccessToken> { + const postData = + 'client_id=' + this.impersonatedServiceAccount.clientId + '&' + + 'client_secret=' + this.impersonatedServiceAccount.clientSecret + '&' + + 'refresh_token=' + this.impersonatedServiceAccount.refreshToken + '&' + + 'grant_type=refresh_token'; + const request: HttpRequestConfig = { + method: 'POST', + url: `https://${REFRESH_TOKEN_HOST}${REFRESH_TOKEN_PATH}`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + data: postData, + httpAgent: this.httpAgent, + }; + return requestAccessToken(this.httpClient, request); + } +} + +/** + * A struct containing the properties necessary to use impersonated service account JSON credentials. + */ +class ImpersonatedServiceAccount { + + public readonly clientId: string; + public readonly clientSecret: string; + public readonly refreshToken: string; + public readonly type: string; + + /* + * Tries to load a ImpersonatedServiceAccount from a path. Throws if the path doesn't exist or the + * data at the path is invalid. + */ + public static fromPath(filePath: string): ImpersonatedServiceAccount { + try { + return new ImpersonatedServiceAccount(JSON.parse(fs.readFileSync(filePath, 'utf8'))); + } catch (error) { + // Throw a nicely formed error message if the file contents cannot be parsed + throw new FirebaseAppError( + AppErrorCodes.INVALID_CREDENTIAL, + 'Failed to parse impersonated service account file: ' + error, + ); + } + } + + constructor(json: object) { + const sourceCredentials = (json as {[key: string]: any})['source_credentials'] + if (sourceCredentials) { + copyAttr(this, sourceCredentials, 'clientId', 'client_id'); + copyAttr(this, sourceCredentials, 'clientSecret', 'client_secret'); + copyAttr(this, sourceCredentials, 'refreshToken', 'refresh_token'); + copyAttr(this, sourceCredentials, 'type', 'type'); + } + + let errorMessage; + if (!util.isNonEmptyString(this.clientId)) { + errorMessage = 'Impersonated Service Account must contain a "source_credentials.client_id" property.'; + } else if (!util.isNonEmptyString(this.clientSecret)) { + errorMessage = 'Impersonated Service Account must contain a "source_credentials.client_secret" property.'; + } else if (!util.isNonEmptyString(this.refreshToken)) { + errorMessage = 'Impersonated Service Account must contain a "source_credentials.refresh_token" property.'; + } else if (!util.isNonEmptyString(this.type)) { + errorMessage = 'Impersonated Service Account must contain a "source_credentials.type" property.'; + } + + if (typeof errorMessage !== 'undefined') { + throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, errorMessage); + } + } +} + /** * Checks if the given credential was loaded via the application default credentials mechanism. This * includes all ComputeEngineCredential instances, and the ServiceAccountCredential and RefreshTokenCredential @@ -377,20 +478,19 @@ class RefreshToken { export function isApplicationDefault(credential?: Credential): boolean { return credential instanceof ComputeEngineCredential || (credential instanceof ServiceAccountCredential && credential.implicit) || - (credential instanceof RefreshTokenCredential && credential.implicit); + (credential instanceof RefreshTokenCredential && credential.implicit) || + (credential instanceof ImpersonatedServiceAccountCredential && credential.implicit); } export function getApplicationDefault(httpAgent?: Agent): Credential { if (process.env.GOOGLE_APPLICATION_CREDENTIALS) { - return credentialFromFile(process.env.GOOGLE_APPLICATION_CREDENTIALS, httpAgent); + return credentialFromFile(process.env.GOOGLE_APPLICATION_CREDENTIALS, httpAgent, false)!; } // It is OK to not have this file. If it is present, it must be valid. if (GCLOUD_CREDENTIAL_PATH) { - const refreshToken = readCredentialFile(GCLOUD_CREDENTIAL_PATH, true); - if (refreshToken) { - return new RefreshTokenCredential(refreshToken, httpAgent, true); - } + const credential = credentialFromFile(GCLOUD_CREDENTIAL_PATH, httpAgent, true); + if (credential) return credential } return new ComputeEngineCredential(httpAgent); @@ -474,9 +574,10 @@ function getDetailFromResponse(response: HttpResponse): string { return response.text || 'Missing error payload'; } -function credentialFromFile(filePath: string, httpAgent?: Agent): Credential { - const credentialsFile = readCredentialFile(filePath); +function credentialFromFile(filePath: string, httpAgent?: Agent, ignoreMissing?: boolean): Credential | null { + const credentialsFile = readCredentialFile(filePath, ignoreMissing); if (typeof credentialsFile !== 'object' || credentialsFile === null) { + if (ignoreMissing) { return null; } throw new FirebaseAppError( AppErrorCodes.INVALID_CREDENTIAL, 'Failed to parse contents of the credentials file as an object', @@ -491,6 +592,10 @@ function credentialFromFile(filePath: string, httpAgent?: Agent): Credential { return new RefreshTokenCredential(credentialsFile, httpAgent, true); } + if (credentialsFile.type === 'impersonated_service_account') { + return new ImpersonatedServiceAccountCredential(credentialsFile, httpAgent, true) + } + throw new FirebaseAppError( AppErrorCodes.INVALID_CREDENTIAL, 'Invalid contents in the credentials file', diff --git a/test/resources/mock.impersonated_key.json b/test/resources/mock.impersonated_key.json new file mode 100644 index 0000000000..debc6a2d31 --- /dev/null +++ b/test/resources/mock.impersonated_key.json @@ -0,0 +1,11 @@ +{ + "delegates": [], + "service_account_impersonation_url": "", + "source_credentials": { + "client_id": "client_id", + "client_secret": "client_secret", + "refresh_token": "refresh_token", + "type": "authorized_user" + }, + "type": "impersonated_service_account" + } diff --git a/test/unit/app/credential-internal.spec.ts b/test/unit/app/credential-internal.spec.ts index 1b1525d974..446b01d90e 100644 --- a/test/unit/app/credential-internal.spec.ts +++ b/test/unit/app/credential-internal.spec.ts @@ -36,7 +36,7 @@ import { } from '../../../src/app/index'; import { RefreshTokenCredential, ServiceAccountCredential, - ComputeEngineCredential, getApplicationDefault, isApplicationDefault + ComputeEngineCredential, getApplicationDefault, isApplicationDefault, ImpersonatedServiceAccountCredential } from '../../../src/app/credential-internal'; import { HttpClient } from '../../../src/utils/api-request'; import { Agent } from 'https'; @@ -59,6 +59,17 @@ const MOCK_REFRESH_TOKEN_CONFIG = { type: 'authorized_user', refresh_token: 'test_token', }; +const MOCK_IMPERSONATED_TOKEN_CONFIG = { + delegates: [], + service_account_impersonation_url: '', + source_credentials: { + client_id: 'test_client_id', + client_secret: 'test_client_secret', + refresh_token: 'test_refresh_token', + type: 'authorized_user' + }, + type: 'impersonated_service_account' +} const ONE_HOUR_IN_SECONDS = 60 * 60; const FIVE_MINUTES_IN_SECONDS = 5 * 60; @@ -424,6 +435,13 @@ describe('Credential', () => { expect(c).to.be.an.instanceof(ServiceAccountCredential); }); + it('should return a ImpersonatedCredential with impersonated GOOGLE_APPLICATION_CREDENTIALS set', () => { + process.env.GOOGLE_APPLICATION_CREDENTIALS + = path.resolve(__dirname, '../../resources/mock.impersonated_key.json'); + const c = getApplicationDefault(); + expect(c).to.be.an.instanceof(ImpersonatedServiceAccountCredential); + }); + it('should throw if explicitly pointing to an invalid path', () => { process.env.GOOGLE_APPLICATION_CREDENTIALS = 'invalidpath'; expect(() => getApplicationDefault()).to.throw(Error); @@ -538,6 +556,15 @@ describe('Credential', () => { expect(isApplicationDefault(c)).to.be.true; }); + it('should return true for ImpersonatedServiceAccountCredential loaded from GOOGLE_APPLICATION_CREDENTIALS', () => { + process.env.GOOGLE_APPLICATION_CREDENTIALS = path.resolve( + __dirname, '../../resources/mock.impersonated_key.json' + ); + const c = getApplicationDefault(); + expect(c).is.instanceOf(ImpersonatedServiceAccountCredential); + expect(isApplicationDefault(c)).to.be.true; + }); + it('should return true for credential loaded from gcloud SDK', () => { if (!fs.existsSync(GCLOUD_CREDENTIAL_PATH)) { // tslint:disable-next-line:no-console @@ -570,6 +597,11 @@ describe('Credential', () => { expect(isApplicationDefault(c)).to.be.false; }); + it('should return false for explicitly loaded ImpersonatedServiceAccountCredential', () => { + const c = new ImpersonatedServiceAccountCredential(MOCK_IMPERSONATED_TOKEN_CONFIG); + expect(isApplicationDefault(c)).to.be.false; + }); + it('should return false for custom credential', () => { const c: Credential = { getAccessToken: () => { @@ -636,5 +668,15 @@ describe('Credential', () => { expect(stub.args[0][0].httpAgent).to.equal(agent); }); }); + + it('ImpersonatedServiceAccountCredential should use the provided HTTP Agent', () => { + const agent = new Agent(); + const c = new ImpersonatedServiceAccountCredential(MOCK_IMPERSONATED_TOKEN_CONFIG, agent); + return c.getAccessToken().then((token) => { + expect(token.access_token).to.equal(expectedToken); + expect(stub).to.have.been.calledOnce; + expect(stub.args[0][0].httpAgent).to.equal(agent); + }); + }); }); });