Skip to content

Commit c81f572

Browse files
authored
feat: Fix impersonated service account parsing exception (#1862)
Added support for initializing the SDK with service account impersonation in Application Default Credentials.
1 parent dc1d320 commit c81f572

File tree

3 files changed

+167
-9
lines changed

3 files changed

+167
-9
lines changed

src/app/credential-internal.ts

+113-8
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,107 @@ class RefreshToken {
366366
}
367367

368368

369+
/**
370+
* Implementation of Credential that uses impersonated service account.
371+
*/
372+
export class ImpersonatedServiceAccountCredential implements Credential {
373+
374+
private readonly impersonatedServiceAccount: ImpersonatedServiceAccount;
375+
private readonly httpClient: HttpClient;
376+
377+
/**
378+
* Creates a new ImpersonatedServiceAccountCredential from the given parameters.
379+
*
380+
* @param impersonatedServiceAccountPathOrObject - Impersonated Service account json object or
381+
* path to a service account json file.
382+
* @param httpAgent - Optional http.Agent to use when calling the remote token server.
383+
* @param implicit - An optinal boolean indicating whether this credential was implicitly
384+
* discovered from the environment, as opposed to being explicitly specified by the developer.
385+
*
386+
* @constructor
387+
*/
388+
constructor(
389+
impersonatedServiceAccountPathOrObject: string | object,
390+
private readonly httpAgent?: Agent,
391+
readonly implicit: boolean = false) {
392+
393+
this.impersonatedServiceAccount = (typeof impersonatedServiceAccountPathOrObject === 'string') ?
394+
ImpersonatedServiceAccount.fromPath(impersonatedServiceAccountPathOrObject)
395+
: new ImpersonatedServiceAccount(impersonatedServiceAccountPathOrObject);
396+
this.httpClient = new HttpClient();
397+
}
398+
399+
public getAccessToken(): Promise<GoogleOAuthAccessToken> {
400+
const postData =
401+
'client_id=' + this.impersonatedServiceAccount.clientId + '&' +
402+
'client_secret=' + this.impersonatedServiceAccount.clientSecret + '&' +
403+
'refresh_token=' + this.impersonatedServiceAccount.refreshToken + '&' +
404+
'grant_type=refresh_token';
405+
const request: HttpRequestConfig = {
406+
method: 'POST',
407+
url: `https://${REFRESH_TOKEN_HOST}${REFRESH_TOKEN_PATH}`,
408+
headers: {
409+
'Content-Type': 'application/x-www-form-urlencoded',
410+
},
411+
data: postData,
412+
httpAgent: this.httpAgent,
413+
};
414+
return requestAccessToken(this.httpClient, request);
415+
}
416+
}
417+
418+
/**
419+
* A struct containing the properties necessary to use impersonated service account JSON credentials.
420+
*/
421+
class ImpersonatedServiceAccount {
422+
423+
public readonly clientId: string;
424+
public readonly clientSecret: string;
425+
public readonly refreshToken: string;
426+
public readonly type: string;
427+
428+
/*
429+
* Tries to load a ImpersonatedServiceAccount from a path. Throws if the path doesn't exist or the
430+
* data at the path is invalid.
431+
*/
432+
public static fromPath(filePath: string): ImpersonatedServiceAccount {
433+
try {
434+
return new ImpersonatedServiceAccount(JSON.parse(fs.readFileSync(filePath, 'utf8')));
435+
} catch (error) {
436+
// Throw a nicely formed error message if the file contents cannot be parsed
437+
throw new FirebaseAppError(
438+
AppErrorCodes.INVALID_CREDENTIAL,
439+
'Failed to parse impersonated service account file: ' + error,
440+
);
441+
}
442+
}
443+
444+
constructor(json: object) {
445+
const sourceCredentials = (json as {[key: string]: any})['source_credentials']
446+
if (sourceCredentials) {
447+
copyAttr(this, sourceCredentials, 'clientId', 'client_id');
448+
copyAttr(this, sourceCredentials, 'clientSecret', 'client_secret');
449+
copyAttr(this, sourceCredentials, 'refreshToken', 'refresh_token');
450+
copyAttr(this, sourceCredentials, 'type', 'type');
451+
}
452+
453+
let errorMessage;
454+
if (!util.isNonEmptyString(this.clientId)) {
455+
errorMessage = 'Impersonated Service Account must contain a "source_credentials.client_id" property.';
456+
} else if (!util.isNonEmptyString(this.clientSecret)) {
457+
errorMessage = 'Impersonated Service Account must contain a "source_credentials.client_secret" property.';
458+
} else if (!util.isNonEmptyString(this.refreshToken)) {
459+
errorMessage = 'Impersonated Service Account must contain a "source_credentials.refresh_token" property.';
460+
} else if (!util.isNonEmptyString(this.type)) {
461+
errorMessage = 'Impersonated Service Account must contain a "source_credentials.type" property.';
462+
}
463+
464+
if (typeof errorMessage !== 'undefined') {
465+
throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, errorMessage);
466+
}
467+
}
468+
}
469+
369470
/**
370471
* Checks if the given credential was loaded via the application default credentials mechanism. This
371472
* includes all ComputeEngineCredential instances, and the ServiceAccountCredential and RefreshTokenCredential
@@ -377,20 +478,19 @@ class RefreshToken {
377478
export function isApplicationDefault(credential?: Credential): boolean {
378479
return credential instanceof ComputeEngineCredential ||
379480
(credential instanceof ServiceAccountCredential && credential.implicit) ||
380-
(credential instanceof RefreshTokenCredential && credential.implicit);
481+
(credential instanceof RefreshTokenCredential && credential.implicit) ||
482+
(credential instanceof ImpersonatedServiceAccountCredential && credential.implicit);
381483
}
382484

383485
export function getApplicationDefault(httpAgent?: Agent): Credential {
384486
if (process.env.GOOGLE_APPLICATION_CREDENTIALS) {
385-
return credentialFromFile(process.env.GOOGLE_APPLICATION_CREDENTIALS, httpAgent);
487+
return credentialFromFile(process.env.GOOGLE_APPLICATION_CREDENTIALS, httpAgent, false)!;
386488
}
387489

388490
// It is OK to not have this file. If it is present, it must be valid.
389491
if (GCLOUD_CREDENTIAL_PATH) {
390-
const refreshToken = readCredentialFile(GCLOUD_CREDENTIAL_PATH, true);
391-
if (refreshToken) {
392-
return new RefreshTokenCredential(refreshToken, httpAgent, true);
393-
}
492+
const credential = credentialFromFile(GCLOUD_CREDENTIAL_PATH, httpAgent, true);
493+
if (credential) return credential
394494
}
395495

396496
return new ComputeEngineCredential(httpAgent);
@@ -474,9 +574,10 @@ function getDetailFromResponse(response: HttpResponse): string {
474574
return response.text || 'Missing error payload';
475575
}
476576

477-
function credentialFromFile(filePath: string, httpAgent?: Agent): Credential {
478-
const credentialsFile = readCredentialFile(filePath);
577+
function credentialFromFile(filePath: string, httpAgent?: Agent, ignoreMissing?: boolean): Credential | null {
578+
const credentialsFile = readCredentialFile(filePath, ignoreMissing);
479579
if (typeof credentialsFile !== 'object' || credentialsFile === null) {
580+
if (ignoreMissing) { return null; }
480581
throw new FirebaseAppError(
481582
AppErrorCodes.INVALID_CREDENTIAL,
482583
'Failed to parse contents of the credentials file as an object',
@@ -491,6 +592,10 @@ function credentialFromFile(filePath: string, httpAgent?: Agent): Credential {
491592
return new RefreshTokenCredential(credentialsFile, httpAgent, true);
492593
}
493594

595+
if (credentialsFile.type === 'impersonated_service_account') {
596+
return new ImpersonatedServiceAccountCredential(credentialsFile, httpAgent, true)
597+
}
598+
494599
throw new FirebaseAppError(
495600
AppErrorCodes.INVALID_CREDENTIAL,
496601
'Invalid contents in the credentials file',
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"delegates": [],
3+
"service_account_impersonation_url": "",
4+
"source_credentials": {
5+
"client_id": "client_id",
6+
"client_secret": "client_secret",
7+
"refresh_token": "refresh_token",
8+
"type": "authorized_user"
9+
},
10+
"type": "impersonated_service_account"
11+
}

test/unit/app/credential-internal.spec.ts

+43-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import {
3636
} from '../../../src/app/index';
3737
import {
3838
RefreshTokenCredential, ServiceAccountCredential,
39-
ComputeEngineCredential, getApplicationDefault, isApplicationDefault
39+
ComputeEngineCredential, getApplicationDefault, isApplicationDefault, ImpersonatedServiceAccountCredential
4040
} from '../../../src/app/credential-internal';
4141
import { HttpClient } from '../../../src/utils/api-request';
4242
import { Agent } from 'https';
@@ -59,6 +59,17 @@ const MOCK_REFRESH_TOKEN_CONFIG = {
5959
type: 'authorized_user',
6060
refresh_token: 'test_token',
6161
};
62+
const MOCK_IMPERSONATED_TOKEN_CONFIG = {
63+
delegates: [],
64+
service_account_impersonation_url: '',
65+
source_credentials: {
66+
client_id: 'test_client_id',
67+
client_secret: 'test_client_secret',
68+
refresh_token: 'test_refresh_token',
69+
type: 'authorized_user'
70+
},
71+
type: 'impersonated_service_account'
72+
}
6273

6374
const ONE_HOUR_IN_SECONDS = 60 * 60;
6475
const FIVE_MINUTES_IN_SECONDS = 5 * 60;
@@ -424,6 +435,13 @@ describe('Credential', () => {
424435
expect(c).to.be.an.instanceof(ServiceAccountCredential);
425436
});
426437

438+
it('should return a ImpersonatedCredential with impersonated GOOGLE_APPLICATION_CREDENTIALS set', () => {
439+
process.env.GOOGLE_APPLICATION_CREDENTIALS
440+
= path.resolve(__dirname, '../../resources/mock.impersonated_key.json');
441+
const c = getApplicationDefault();
442+
expect(c).to.be.an.instanceof(ImpersonatedServiceAccountCredential);
443+
});
444+
427445
it('should throw if explicitly pointing to an invalid path', () => {
428446
process.env.GOOGLE_APPLICATION_CREDENTIALS = 'invalidpath';
429447
expect(() => getApplicationDefault()).to.throw(Error);
@@ -538,6 +556,15 @@ describe('Credential', () => {
538556
expect(isApplicationDefault(c)).to.be.true;
539557
});
540558

559+
it('should return true for ImpersonatedServiceAccountCredential loaded from GOOGLE_APPLICATION_CREDENTIALS', () => {
560+
process.env.GOOGLE_APPLICATION_CREDENTIALS = path.resolve(
561+
__dirname, '../../resources/mock.impersonated_key.json'
562+
);
563+
const c = getApplicationDefault();
564+
expect(c).is.instanceOf(ImpersonatedServiceAccountCredential);
565+
expect(isApplicationDefault(c)).to.be.true;
566+
});
567+
541568
it('should return true for credential loaded from gcloud SDK', () => {
542569
if (!fs.existsSync(GCLOUD_CREDENTIAL_PATH)) {
543570
// tslint:disable-next-line:no-console
@@ -570,6 +597,11 @@ describe('Credential', () => {
570597
expect(isApplicationDefault(c)).to.be.false;
571598
});
572599

600+
it('should return false for explicitly loaded ImpersonatedServiceAccountCredential', () => {
601+
const c = new ImpersonatedServiceAccountCredential(MOCK_IMPERSONATED_TOKEN_CONFIG);
602+
expect(isApplicationDefault(c)).to.be.false;
603+
});
604+
573605
it('should return false for custom credential', () => {
574606
const c: Credential = {
575607
getAccessToken: () => {
@@ -636,5 +668,15 @@ describe('Credential', () => {
636668
expect(stub.args[0][0].httpAgent).to.equal(agent);
637669
});
638670
});
671+
672+
it('ImpersonatedServiceAccountCredential should use the provided HTTP Agent', () => {
673+
const agent = new Agent();
674+
const c = new ImpersonatedServiceAccountCredential(MOCK_IMPERSONATED_TOKEN_CONFIG, agent);
675+
return c.getAccessToken().then((token) => {
676+
expect(token.access_token).to.equal(expectedToken);
677+
expect(stub).to.have.been.calledOnce;
678+
expect(stub.args[0][0].httpAgent).to.equal(agent);
679+
});
680+
});
639681
});
640682
});

0 commit comments

Comments
 (0)