Skip to content

Commit

Permalink
fix: Differentiating explicitly loaded vs default credentials (#764)
Browse files Browse the repository at this point in the history
* Make it possible to differentiate explicitly loaded vs default credentials

* Updated documentation for constructors
  • Loading branch information
hiranya911 committed Jan 21, 2020
1 parent bd9a0dd commit 70c4d0f
Show file tree
Hide file tree
Showing 6 changed files with 181 additions and 47 deletions.
57 changes: 47 additions & 10 deletions src/auth/credential.ts
Expand Up @@ -79,19 +79,30 @@ export class ServiceAccountCredential implements Credential {
public readonly privateKey: string;
public readonly clientEmail: string;


private readonly httpClient: HttpClient;
private readonly httpAgent?: Agent;

constructor(serviceAccountPathOrObject: string | object, httpAgent?: Agent) {
/**
* Creates a new ServiceAccountCredential from the given parameters.
*
* @param serviceAccountPathOrObject 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(
serviceAccountPathOrObject: string | object,
private readonly httpAgent?: Agent,
readonly implicit: boolean = false) {

const serviceAccount = (typeof serviceAccountPathOrObject === 'string') ?
ServiceAccount.fromPath(serviceAccountPathOrObject)
: new ServiceAccount(serviceAccountPathOrObject);
this.projectId = serviceAccount.projectId;
this.privateKey = serviceAccount.privateKey;
this.clientEmail = serviceAccount.clientEmail;
this.httpClient = new HttpClient();
this.httpAgent = httpAgent;
}

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

private readonly refreshToken: RefreshToken;
private readonly httpClient: HttpClient;
private readonly httpAgent?: Agent;

constructor(refreshTokenPathOrObject: string | object, httpAgent?: Agent) {
/**
* Creates a new RefreshTokenCredential from the given parameters.
*
* @param refreshTokenPathOrObject Refresh token json object or path to a refresh token (user credentials) 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(
refreshTokenPathOrObject: string | object,
private readonly httpAgent?: Agent,
readonly implicit: boolean = false) {

this.refreshToken = (typeof refreshTokenPathOrObject === 'string') ?
RefreshToken.fromPath(refreshTokenPathOrObject)
: new RefreshToken(refreshTokenPathOrObject);
this.httpClient = new HttpClient();
this.httpAgent = httpAgent;
}

public getAccessToken(): Promise<GoogleOAuthAccessToken> {
Expand Down Expand Up @@ -331,13 +354,27 @@ export function getApplicationDefault(httpAgent?: Agent): Credential {
if (GCLOUD_CREDENTIAL_PATH) {
const refreshToken = readCredentialFile(GCLOUD_CREDENTIAL_PATH, true);
if (refreshToken) {
return new RefreshTokenCredential(refreshToken, httpAgent);
return new RefreshTokenCredential(refreshToken, httpAgent, true);
}
}

return new ComputeEngineCredential(httpAgent);
}

/**
* Checks if the given credential was loaded via the application default credentials mechanism. This
* includes all ComputeEngineCredential instances, and the ServiceAccountCredential and RefreshTokenCredential
* instances that were loaded from well-known files or environment variables, rather than being explicitly
* instantiated.
*
* @param credential The credential instance to check.
*/
export function isApplicationDefault(credential?: Credential): boolean {
return credential instanceof ComputeEngineCredential ||
(credential instanceof ServiceAccountCredential && credential.implicit) ||
(credential instanceof RefreshTokenCredential && credential.implicit);
}

/**
* Copies the specified property from one object to another.
*
Expand Down Expand Up @@ -409,11 +446,11 @@ function credentialFromFile(filePath: string, httpAgent?: Agent): Credential {
}

if (credentialsFile.type === 'service_account') {
return new ServiceAccountCredential(credentialsFile, httpAgent);
return new ServiceAccountCredential(credentialsFile, httpAgent, true);
}

if (credentialsFile.type === 'authorized_user') {
return new RefreshTokenCredential(credentialsFile, httpAgent);
return new RefreshTokenCredential(credentialsFile, httpAgent, true);
}

throw new FirebaseAppError(
Expand Down
4 changes: 2 additions & 2 deletions src/firestore/firestore.ts
Expand Up @@ -17,7 +17,7 @@
import {FirebaseApp} from '../firebase-app';
import {FirebaseFirestoreError} from '../utils/error';
import {FirebaseServiceInterface, FirebaseServiceInternalsInterface} from '../firebase-service';
import {ServiceAccountCredential, ComputeEngineCredential} from '../auth/credential';
import {ServiceAccountCredential, isApplicationDefault} from '../auth/credential';
import {Firestore, Settings} from '@google-cloud/firestore';

import * as validator from '../utils/validator';
Expand Down Expand Up @@ -85,7 +85,7 @@ export function getFirestoreOptions(app: FirebaseApp): Settings {
projectId: projectId!,
firebaseVersion,
};
} else if (app.options.credential instanceof ComputeEngineCredential) {
} else if (isApplicationDefault(app.options.credential)) {
// Try to use the Google application default credentials.
// If an explicit project ID is not available, let Firestore client discover one from the
// environment. This prevents the users from having to set GOOGLE_CLOUD_PROJECT in GCP runtimes.
Expand Down
4 changes: 2 additions & 2 deletions src/storage/storage.ts
Expand Up @@ -17,7 +17,7 @@
import {FirebaseApp} from '../firebase-app';
import {FirebaseError} from '../utils/error';
import {FirebaseServiceInterface, FirebaseServiceInternalsInterface} from '../firebase-service';
import {ServiceAccountCredential, ComputeEngineCredential} from '../auth/credential';
import {ServiceAccountCredential, isApplicationDefault} from '../auth/credential';
import {Bucket, Storage as StorageClient} from '@google-cloud/storage';

import * as utils from '../utils/index';
Expand Down Expand Up @@ -83,7 +83,7 @@ export class Storage implements FirebaseServiceInterface {
client_email: credential.clientEmail,
},
});
} else if (app.options.credential instanceof ComputeEngineCredential) {
} else if (isApplicationDefault(app.options.credential)) {
// Try to use the Google application default credentials.
this.storageClient = new storage();
} else {
Expand Down
88 changes: 85 additions & 3 deletions test/unit/auth/credential.spec.ts
Expand Up @@ -32,7 +32,7 @@ import * as mocks from '../../resources/mocks';

import {
GoogleOAuthAccessToken, RefreshTokenCredential, ServiceAccountCredential,
ComputeEngineCredential, getApplicationDefault,
ComputeEngineCredential, getApplicationDefault, isApplicationDefault, Credential,
} from '../../../src/auth/credential';
import { HttpClient } from '../../../src/utils/api-request';
import {Agent} from 'https';
Expand Down Expand Up @@ -183,15 +183,17 @@ describe('Credential', () => {
projectId: mockCertificateObject.project_id,
clientEmail: mockCertificateObject.client_email,
privateKey: mockCertificateObject.private_key,
implicit: false,
});
});

it('should return a certificate', () => {
const c = new ServiceAccountCredential(mockCertificateObject);
it('should return an implicit Credential', () => {
const c = new ServiceAccountCredential(mockCertificateObject, undefined, true);
expect(c).to.deep.include({
projectId: mockCertificateObject.project_id,
clientEmail: mockCertificateObject.client_email,
privateKey: mockCertificateObject.private_key,
implicit: true,
});
});

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

it('should return a Credential', () => {
const c = new RefreshTokenCredential(mocks.refreshToken);
expect(c).to.deep.include({
implicit: false,
});
});

it('should return an implicit Credential', () => {
const c = new RefreshTokenCredential(mocks.refreshToken, undefined, true);
expect(c).to.deep.include({
implicit: true,
});
});

it('should create access tokens', () => {
const scope = nock('https://www.googleapis.com')
.post('/oauth2/v4/token')
Expand Down Expand Up @@ -477,6 +493,72 @@ describe('Credential', () => {
});
});

describe('isApplicationDefault()', () => {
let fsStub: sinon.SinonStub;

afterEach(() => {
if (fsStub) {
fsStub.restore();
}
});

it('should return true for ServiceAccountCredential loaded from GOOGLE_APPLICATION_CREDENTIALS', () => {
process.env.GOOGLE_APPLICATION_CREDENTIALS = path.resolve(__dirname, '../../resources/mock.key.json');
const c = getApplicationDefault();
expect(c).to.be.an.instanceof(ServiceAccountCredential);
expect(isApplicationDefault(c)).to.be.true;
});

it('should return true for RefreshTokenCredential loaded from GOOGLE_APPLICATION_CREDENTIALS', () => {
process.env.GOOGLE_APPLICATION_CREDENTIALS = GCLOUD_CREDENTIAL_PATH;
fsStub = sinon.stub(fs, 'readFileSync').returns(JSON.stringify(MOCK_REFRESH_TOKEN_CONFIG));
const c = getApplicationDefault();
expect(c).is.instanceOf(RefreshTokenCredential);
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
console.log(
'WARNING: Test being skipped because gcloud credentials not found. Run `gcloud beta auth ' +
'application-default login`.');
return;
}
delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
const c = getApplicationDefault();
expect(c).to.be.an.instanceof(RefreshTokenCredential);
expect(isApplicationDefault(c)).to.be.true;
});

it('should return true for ComputeEngineCredential', () => {
delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
fsStub = sinon.stub(fs, 'readFileSync').throws(new Error('no gcloud credential file'));
const c = getApplicationDefault();
expect(c).to.be.an.instanceof(ComputeEngineCredential);
expect(isApplicationDefault(c)).to.be.true;
});

it('should return false for explicitly loaded ServiceAccountCredential', () => {
const c = new ServiceAccountCredential(mockCertificateObject);
expect(isApplicationDefault(c)).to.be.false;
});

it('should return false for explicitly loaded RefreshTokenCredential', () => {
const c = new RefreshTokenCredential(mocks.refreshToken);
expect(isApplicationDefault(c)).to.be.false;
});

it('should return false for custom credential', () => {
const c: Credential = {
getAccessToken: () => {
throw new Error();
},
};
expect(isApplicationDefault(c)).to.be.false;
});
});

describe('HTTP Agent', () => {
const expectedToken = utils.generateRandomAccessToken();
let stub: sinon.SinonStub;
Expand Down
6 changes: 5 additions & 1 deletion test/unit/firebase.spec.ts
Expand Up @@ -27,7 +27,7 @@ import * as chaiAsPromised from 'chai-as-promised';
import * as mocks from '../resources/mocks';

import * as firebaseAdmin from '../../src/index';
import {RefreshTokenCredential, ServiceAccountCredential} from '../../src/auth/credential';
import {RefreshTokenCredential, ServiceAccountCredential, isApplicationDefault} from '../../src/auth/credential';

chai.should();
chai.use(chaiAsPromised);
Expand Down Expand Up @@ -112,6 +112,7 @@ describe('Firebase', () => {
credential: firebaseAdmin.credential.cert(mocks.certificateObject),
});

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

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

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

expect(isApplicationDefault(firebaseAdmin.app().options.credential)).to.be.false;
return firebaseAdmin.app().INTERNAL.getToken()
.should.eventually.have.keys(['accessToken', 'expirationTime']);
});
Expand Down

0 comments on commit 70c4d0f

Please sign in to comment.