Skip to content

Commit 70c4d0f

Browse files
authored
fix: Differentiating explicitly loaded vs default credentials (#764)
* Make it possible to differentiate explicitly loaded vs default credentials * Updated documentation for constructors
1 parent bd9a0dd commit 70c4d0f

File tree

6 files changed

+181
-47
lines changed

6 files changed

+181
-47
lines changed

src/auth/credential.ts

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -79,19 +79,30 @@ export class ServiceAccountCredential implements Credential {
7979
public readonly privateKey: string;
8080
public readonly clientEmail: string;
8181

82-
8382
private readonly httpClient: HttpClient;
84-
private readonly httpAgent?: Agent;
8583

86-
constructor(serviceAccountPathOrObject: string | object, httpAgent?: Agent) {
84+
/**
85+
* Creates a new ServiceAccountCredential from the given parameters.
86+
*
87+
* @param serviceAccountPathOrObject Service account json object or path to a service account json file.
88+
* @param httpAgent Optional http.Agent to use when calling the remote token server.
89+
* @param implicit An optinal boolean indicating whether this credential was implicitly discovered from the
90+
* environment, as opposed to being explicitly specified by the developer.
91+
*
92+
* @constructor
93+
*/
94+
constructor(
95+
serviceAccountPathOrObject: string | object,
96+
private readonly httpAgent?: Agent,
97+
readonly implicit: boolean = false) {
98+
8799
const serviceAccount = (typeof serviceAccountPathOrObject === 'string') ?
88100
ServiceAccount.fromPath(serviceAccountPathOrObject)
89101
: new ServiceAccount(serviceAccountPathOrObject);
90102
this.projectId = serviceAccount.projectId;
91103
this.privateKey = serviceAccount.privateKey;
92104
this.clientEmail = serviceAccount.clientEmail;
93105
this.httpClient = new HttpClient();
94-
this.httpAgent = httpAgent;
95106
}
96107

97108
public getAccessToken(): Promise<GoogleOAuthAccessToken> {
@@ -247,14 +258,26 @@ export class RefreshTokenCredential implements Credential {
247258

248259
private readonly refreshToken: RefreshToken;
249260
private readonly httpClient: HttpClient;
250-
private readonly httpAgent?: Agent;
251261

252-
constructor(refreshTokenPathOrObject: string | object, httpAgent?: Agent) {
262+
/**
263+
* Creates a new RefreshTokenCredential from the given parameters.
264+
*
265+
* @param refreshTokenPathOrObject Refresh token json object or path to a refresh token (user credentials) json file.
266+
* @param httpAgent Optional http.Agent to use when calling the remote token server.
267+
* @param implicit An optinal boolean indicating whether this credential was implicitly discovered from the
268+
* environment, as opposed to being explicitly specified by the developer.
269+
*
270+
* @constructor
271+
*/
272+
constructor(
273+
refreshTokenPathOrObject: string | object,
274+
private readonly httpAgent?: Agent,
275+
readonly implicit: boolean = false) {
276+
253277
this.refreshToken = (typeof refreshTokenPathOrObject === 'string') ?
254278
RefreshToken.fromPath(refreshTokenPathOrObject)
255279
: new RefreshToken(refreshTokenPathOrObject);
256280
this.httpClient = new HttpClient();
257-
this.httpAgent = httpAgent;
258281
}
259282

260283
public getAccessToken(): Promise<GoogleOAuthAccessToken> {
@@ -331,13 +354,27 @@ export function getApplicationDefault(httpAgent?: Agent): Credential {
331354
if (GCLOUD_CREDENTIAL_PATH) {
332355
const refreshToken = readCredentialFile(GCLOUD_CREDENTIAL_PATH, true);
333356
if (refreshToken) {
334-
return new RefreshTokenCredential(refreshToken, httpAgent);
357+
return new RefreshTokenCredential(refreshToken, httpAgent, true);
335358
}
336359
}
337360

338361
return new ComputeEngineCredential(httpAgent);
339362
}
340363

364+
/**
365+
* Checks if the given credential was loaded via the application default credentials mechanism. This
366+
* includes all ComputeEngineCredential instances, and the ServiceAccountCredential and RefreshTokenCredential
367+
* instances that were loaded from well-known files or environment variables, rather than being explicitly
368+
* instantiated.
369+
*
370+
* @param credential The credential instance to check.
371+
*/
372+
export function isApplicationDefault(credential?: Credential): boolean {
373+
return credential instanceof ComputeEngineCredential ||
374+
(credential instanceof ServiceAccountCredential && credential.implicit) ||
375+
(credential instanceof RefreshTokenCredential && credential.implicit);
376+
}
377+
341378
/**
342379
* Copies the specified property from one object to another.
343380
*
@@ -409,11 +446,11 @@ function credentialFromFile(filePath: string, httpAgent?: Agent): Credential {
409446
}
410447

411448
if (credentialsFile.type === 'service_account') {
412-
return new ServiceAccountCredential(credentialsFile, httpAgent);
449+
return new ServiceAccountCredential(credentialsFile, httpAgent, true);
413450
}
414451

415452
if (credentialsFile.type === 'authorized_user') {
416-
return new RefreshTokenCredential(credentialsFile, httpAgent);
453+
return new RefreshTokenCredential(credentialsFile, httpAgent, true);
417454
}
418455

419456
throw new FirebaseAppError(

src/firestore/firestore.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import {FirebaseApp} from '../firebase-app';
1818
import {FirebaseFirestoreError} from '../utils/error';
1919
import {FirebaseServiceInterface, FirebaseServiceInternalsInterface} from '../firebase-service';
20-
import {ServiceAccountCredential, ComputeEngineCredential} from '../auth/credential';
20+
import {ServiceAccountCredential, isApplicationDefault} from '../auth/credential';
2121
import {Firestore, Settings} from '@google-cloud/firestore';
2222

2323
import * as validator from '../utils/validator';
@@ -85,7 +85,7 @@ export function getFirestoreOptions(app: FirebaseApp): Settings {
8585
projectId: projectId!,
8686
firebaseVersion,
8787
};
88-
} else if (app.options.credential instanceof ComputeEngineCredential) {
88+
} else if (isApplicationDefault(app.options.credential)) {
8989
// Try to use the Google application default credentials.
9090
// If an explicit project ID is not available, let Firestore client discover one from the
9191
// environment. This prevents the users from having to set GOOGLE_CLOUD_PROJECT in GCP runtimes.

src/storage/storage.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import {FirebaseApp} from '../firebase-app';
1818
import {FirebaseError} from '../utils/error';
1919
import {FirebaseServiceInterface, FirebaseServiceInternalsInterface} from '../firebase-service';
20-
import {ServiceAccountCredential, ComputeEngineCredential} from '../auth/credential';
20+
import {ServiceAccountCredential, isApplicationDefault} from '../auth/credential';
2121
import {Bucket, Storage as StorageClient} from '@google-cloud/storage';
2222

2323
import * as utils from '../utils/index';
@@ -83,7 +83,7 @@ export class Storage implements FirebaseServiceInterface {
8383
client_email: credential.clientEmail,
8484
},
8585
});
86-
} else if (app.options.credential instanceof ComputeEngineCredential) {
86+
} else if (isApplicationDefault(app.options.credential)) {
8787
// Try to use the Google application default credentials.
8888
this.storageClient = new storage();
8989
} else {

test/unit/auth/credential.spec.ts

Lines changed: 85 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import * as mocks from '../../resources/mocks';
3232

3333
import {
3434
GoogleOAuthAccessToken, RefreshTokenCredential, ServiceAccountCredential,
35-
ComputeEngineCredential, getApplicationDefault,
35+
ComputeEngineCredential, getApplicationDefault, isApplicationDefault, Credential,
3636
} from '../../../src/auth/credential';
3737
import { HttpClient } from '../../../src/utils/api-request';
3838
import {Agent} from 'https';
@@ -183,15 +183,17 @@ describe('Credential', () => {
183183
projectId: mockCertificateObject.project_id,
184184
clientEmail: mockCertificateObject.client_email,
185185
privateKey: mockCertificateObject.private_key,
186+
implicit: false,
186187
});
187188
});
188189

189-
it('should return a certificate', () => {
190-
const c = new ServiceAccountCredential(mockCertificateObject);
190+
it('should return an implicit Credential', () => {
191+
const c = new ServiceAccountCredential(mockCertificateObject, undefined, true);
191192
expect(c).to.deep.include({
192193
projectId: mockCertificateObject.project_id,
193194
clientEmail: mockCertificateObject.client_email,
194195
privateKey: mockCertificateObject.private_key,
196+
implicit: true,
195197
});
196198
});
197199

@@ -267,6 +269,20 @@ describe('Credential', () => {
267269
.to.throw('Refresh token must contain a "type" property');
268270
});
269271

272+
it('should return a Credential', () => {
273+
const c = new RefreshTokenCredential(mocks.refreshToken);
274+
expect(c).to.deep.include({
275+
implicit: false,
276+
});
277+
});
278+
279+
it('should return an implicit Credential', () => {
280+
const c = new RefreshTokenCredential(mocks.refreshToken, undefined, true);
281+
expect(c).to.deep.include({
282+
implicit: true,
283+
});
284+
});
285+
270286
it('should create access tokens', () => {
271287
const scope = nock('https://www.googleapis.com')
272288
.post('/oauth2/v4/token')
@@ -477,6 +493,72 @@ describe('Credential', () => {
477493
});
478494
});
479495

496+
describe('isApplicationDefault()', () => {
497+
let fsStub: sinon.SinonStub;
498+
499+
afterEach(() => {
500+
if (fsStub) {
501+
fsStub.restore();
502+
}
503+
});
504+
505+
it('should return true for ServiceAccountCredential loaded from GOOGLE_APPLICATION_CREDENTIALS', () => {
506+
process.env.GOOGLE_APPLICATION_CREDENTIALS = path.resolve(__dirname, '../../resources/mock.key.json');
507+
const c = getApplicationDefault();
508+
expect(c).to.be.an.instanceof(ServiceAccountCredential);
509+
expect(isApplicationDefault(c)).to.be.true;
510+
});
511+
512+
it('should return true for RefreshTokenCredential loaded from GOOGLE_APPLICATION_CREDENTIALS', () => {
513+
process.env.GOOGLE_APPLICATION_CREDENTIALS = GCLOUD_CREDENTIAL_PATH;
514+
fsStub = sinon.stub(fs, 'readFileSync').returns(JSON.stringify(MOCK_REFRESH_TOKEN_CONFIG));
515+
const c = getApplicationDefault();
516+
expect(c).is.instanceOf(RefreshTokenCredential);
517+
expect(isApplicationDefault(c)).to.be.true;
518+
});
519+
520+
it('should return true for credential loaded from gcloud SDK', () => {
521+
if (!fs.existsSync(GCLOUD_CREDENTIAL_PATH)) {
522+
// tslint:disable-next-line:no-console
523+
console.log(
524+
'WARNING: Test being skipped because gcloud credentials not found. Run `gcloud beta auth ' +
525+
'application-default login`.');
526+
return;
527+
}
528+
delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
529+
const c = getApplicationDefault();
530+
expect(c).to.be.an.instanceof(RefreshTokenCredential);
531+
expect(isApplicationDefault(c)).to.be.true;
532+
});
533+
534+
it('should return true for ComputeEngineCredential', () => {
535+
delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
536+
fsStub = sinon.stub(fs, 'readFileSync').throws(new Error('no gcloud credential file'));
537+
const c = getApplicationDefault();
538+
expect(c).to.be.an.instanceof(ComputeEngineCredential);
539+
expect(isApplicationDefault(c)).to.be.true;
540+
});
541+
542+
it('should return false for explicitly loaded ServiceAccountCredential', () => {
543+
const c = new ServiceAccountCredential(mockCertificateObject);
544+
expect(isApplicationDefault(c)).to.be.false;
545+
});
546+
547+
it('should return false for explicitly loaded RefreshTokenCredential', () => {
548+
const c = new RefreshTokenCredential(mocks.refreshToken);
549+
expect(isApplicationDefault(c)).to.be.false;
550+
});
551+
552+
it('should return false for custom credential', () => {
553+
const c: Credential = {
554+
getAccessToken: () => {
555+
throw new Error();
556+
},
557+
};
558+
expect(isApplicationDefault(c)).to.be.false;
559+
});
560+
});
561+
480562
describe('HTTP Agent', () => {
481563
const expectedToken = utils.generateRandomAccessToken();
482564
let stub: sinon.SinonStub;

test/unit/firebase.spec.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import * as chaiAsPromised from 'chai-as-promised';
2727
import * as mocks from '../resources/mocks';
2828

2929
import * as firebaseAdmin from '../../src/index';
30-
import {RefreshTokenCredential, ServiceAccountCredential} from '../../src/auth/credential';
30+
import {RefreshTokenCredential, ServiceAccountCredential, isApplicationDefault} from '../../src/auth/credential';
3131

3232
chai.should();
3333
chai.use(chaiAsPromised);
@@ -112,6 +112,7 @@ describe('Firebase', () => {
112112
credential: firebaseAdmin.credential.cert(mocks.certificateObject),
113113
});
114114

115+
expect(isApplicationDefault(firebaseAdmin.app().options.credential)).to.be.false;
115116
return firebaseAdmin.app().INTERNAL.getToken()
116117
.should.eventually.have.keys(['accessToken', 'expirationTime']);
117118
});
@@ -122,6 +123,7 @@ describe('Firebase', () => {
122123
credential: firebaseAdmin.credential.cert(keyPath),
123124
});
124125

126+
expect(isApplicationDefault(firebaseAdmin.app().options.credential)).to.be.false;
125127
return firebaseAdmin.app().INTERNAL.getToken()
126128
.should.eventually.have.keys(['accessToken', 'expirationTime']);
127129
});
@@ -134,6 +136,7 @@ describe('Firebase', () => {
134136
credential: firebaseAdmin.credential.applicationDefault(),
135137
});
136138

139+
expect(isApplicationDefault(firebaseAdmin.app().options.credential)).to.be.true;
137140
return firebaseAdmin.app().INTERNAL.getToken().then((token) => {
138141
if (typeof credPath === 'undefined') {
139142
delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
@@ -155,6 +158,7 @@ describe('Firebase', () => {
155158
credential: firebaseAdmin.credential.refreshToken(mocks.refreshToken),
156159
});
157160

161+
expect(isApplicationDefault(firebaseAdmin.app().options.credential)).to.be.false;
158162
return firebaseAdmin.app().INTERNAL.getToken()
159163
.should.eventually.have.keys(['accessToken', 'expirationTime']);
160164
});

0 commit comments

Comments
 (0)