Skip to content

Commit

Permalink
Add support for Auth Emulator (#1044)
Browse files Browse the repository at this point in the history
* Basic URL replacement and custom tokens

* Fix test

* Tests actually pass

* Mock verifyIdToken

* Add another test

* Small style change

* Small simplification

* Significant cleanup of all the useEmulator stuff

* Make sure we don't use env var to short-circuit ID Token verification

* Make lint pass

* Add unit tests that go through the Auth interface

* Hiranya nits

* Make private method even more private and scary, require env

* Make the private method throw

* Fix test
  • Loading branch information
samtstern committed Oct 16, 2020
1 parent 738250d commit dbb0c78
Show file tree
Hide file tree
Showing 10 changed files with 303 additions and 54 deletions.
26 changes: 13 additions & 13 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
"@types/chai": "^4.0.0",
"@types/chai-as-promised": "^7.1.0",
"@types/firebase-token-generator": "^2.0.28",
"@types/jsonwebtoken": "^7.2.8",
"@types/jsonwebtoken": "^8.5.0",
"@types/lodash": "^4.14.104",
"@types/minimist": "^1.2.0",
"@types/mocha": "^2.2.48",
Expand Down
27 changes: 25 additions & 2 deletions src/auth/auth-api-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,10 +89,19 @@ const MAX_LIST_PROVIDER_CONFIGURATION_PAGE_SIZE = 100;
const FIREBASE_AUTH_BASE_URL_FORMAT =
'https://identitytoolkit.googleapis.com/{version}/projects/{projectId}{api}';

/** Firebase Auth base URlLformat when using the auth emultor. */
const FIREBASE_AUTH_EMULATOR_BASE_URL_FORMAT =
'http://{host}/identitytoolkit.googleapis.com/{version}/projects/{projectId}{api}';

/** The Firebase Auth backend multi-tenancy base URL format. */
const FIREBASE_AUTH_TENANT_URL_FORMAT = FIREBASE_AUTH_BASE_URL_FORMAT.replace(
'projects/{projectId}', 'projects/{projectId}/tenants/{tenantId}');

/** Firebase Auth base URL format when using the auth emultor with multi-tenancy. */
const FIREBASE_AUTH_EMULATOR_TENANT_URL_FORMAT = FIREBASE_AUTH_EMULATOR_BASE_URL_FORMAT.replace(
'projects/{projectId}', 'projects/{projectId}/tenants/{tenantId}');


/** Maximum allowed number of tenants to download at one time. */
const MAX_LIST_TENANT_PAGE_SIZE = 1000;

Expand Down Expand Up @@ -121,7 +130,14 @@ class AuthResourceUrlBuilder {
* @constructor
*/
constructor(protected app: FirebaseApp, protected version: string = 'v1') {
this.urlFormat = FIREBASE_AUTH_BASE_URL_FORMAT;
const emulatorHost = process.env.FIREBASE_AUTH_EMULATOR_HOST;
if (emulatorHost) {
this.urlFormat = utils.formatString(FIREBASE_AUTH_EMULATOR_BASE_URL_FORMAT, {
host: emulatorHost
});
} else {
this.urlFormat = FIREBASE_AUTH_BASE_URL_FORMAT;
}
}

/**
Expand Down Expand Up @@ -181,7 +197,14 @@ class TenantAwareAuthResourceUrlBuilder extends AuthResourceUrlBuilder {
*/
constructor(protected app: FirebaseApp, protected version: string, protected tenantId: string) {
super(app, version);
this.urlFormat = FIREBASE_AUTH_TENANT_URL_FORMAT;
const emulatorHost = process.env.FIREBASE_AUTH_EMULATOR_HOST
if (emulatorHost) {
this.urlFormat = utils.formatString(FIREBASE_AUTH_EMULATOR_TENANT_URL_FORMAT, {
host: emulatorHost
});
} else {
this.urlFormat = FIREBASE_AUTH_TENANT_URL_FORMAT;
}
}

/**
Expand Down
44 changes: 40 additions & 4 deletions src/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
UserIdentifier, isUidIdentifier, isEmailIdentifier, isPhoneIdentifier, isProviderIdentifier,
} from './identifier';
import { FirebaseApp } from '../firebase-app';
import { FirebaseTokenGenerator, cryptoSignerFromApp } from './token-generator';
import { FirebaseTokenGenerator, EmulatedSigner, cryptoSignerFromApp } from './token-generator';
import {
AbstractAuthRequestHandler, AuthRequestHandler, TenantAwareAuthRequestHandler,
} from './auth-api-request';
Expand All @@ -31,7 +31,9 @@ import {

import * as utils from '../utils/index';
import * as validator from '../utils/validator';
import { FirebaseTokenVerifier, createSessionCookieVerifier, createIdTokenVerifier } from './token-verifier';
import {
FirebaseTokenVerifier, createSessionCookieVerifier, createIdTokenVerifier, ALGORITHM_RS256
} from './token-verifier';
import { ActionCodeSettings } from './action-code-settings-builder';
import {
AuthProviderConfig, AuthProviderConfigFilter, ListProviderConfigResults, UpdateAuthProviderRequest,
Expand Down Expand Up @@ -141,7 +143,7 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> {
if (tokenGenerator) {
this.tokenGenerator = tokenGenerator;
} else {
const cryptoSigner = cryptoSignerFromApp(app);
const cryptoSigner = useEmulator() ? new EmulatedSigner() : cryptoSignerFromApp(app);
this.tokenGenerator = new FirebaseTokenGenerator(cryptoSigner);
}

Expand Down Expand Up @@ -735,6 +737,28 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> {
return decodedIdToken;
});
}

/**
* Enable or disable ID token verification. This is used to safely short-circuit token verification with the
* Auth emulator. When disabled ONLY unsigned tokens will pass verification, production tokens will not pass.
*
* WARNING: This is a dangerous method that will compromise your app's security and break your app in
* production. Developers should never call this method, it is for internal testing use only.
*
* @internal
*/
// @ts-expect-error: this method appears unused but is used privately.
private setJwtVerificationEnabled(enabled: boolean): void {
if (!enabled && !useEmulator()) {
// We only allow verification to be disabled in conjunction with
// the emulator environment variable.
throw new Error('This method is only available when connected to the Authentication emulator.');
}

const algorithm = enabled ? ALGORITHM_RS256 : 'none';
this.idTokenVerifier.setAlgorithm(algorithm);
this.sessionCookieVerifier.setAlgorithm(algorithm);
}
}


Expand All @@ -752,7 +776,7 @@ export class TenantAwareAuth extends BaseAuth<TenantAwareAuthRequestHandler> {
* @constructor
*/
constructor(app: FirebaseApp, tenantId: string) {
const cryptoSigner = cryptoSignerFromApp(app);
const cryptoSigner = useEmulator() ? new EmulatedSigner() : cryptoSignerFromApp(app);
const tokenGenerator = new FirebaseTokenGenerator(cryptoSigner, tenantId);
super(app, new TenantAwareAuthRequestHandler(app, tenantId), tokenGenerator);
utils.addReadonlyGetter(this, 'tenantId', tenantId);
Expand Down Expand Up @@ -868,3 +892,15 @@ export class Auth extends BaseAuth<AuthRequestHandler> implements FirebaseServic
return this.tenantManager_;
}
}

/**
* When true the SDK should communicate with the Auth Emulator for all API
* calls and also produce unsigned tokens.
*
* This alone does <b>NOT<b> short-circuit ID Token verification.
* For security reasons that must be explicitly disabled through
* setJwtVerificationEnabled(false);
*/
function useEmulator(): boolean {
return !!process.env.FIREBASE_AUTH_EMULATOR_HOST;
}
43 changes: 40 additions & 3 deletions src/auth/token-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@ import { AuthorizedHttpClient, HttpError, HttpRequestConfig, HttpClient } from '

import * as validator from '../utils/validator';
import { toWebSafeBase64 } from '../utils';
import { Algorithm } from 'jsonwebtoken';


const ALGORITHM_RS256 = 'RS256';
const ALGORITHM_RS256: Algorithm = 'RS256' as const;
const ALGORITHM_NONE: Algorithm = 'none' as const;

const ONE_HOUR_IN_SECONDS = 60 * 60;

// List of blacklisted claims which cannot be provided when creating a custom token
Expand All @@ -39,6 +42,12 @@ const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identit
* CryptoSigner interface represents an object that can be used to sign JWTs.
*/
export interface CryptoSigner {

/**
* The name of the signing algorithm.
*/
readonly algorithm: Algorithm;

/**
* Cryptographically signs a buffer of data.
*
Expand Down Expand Up @@ -82,6 +91,8 @@ interface JWTBody {
* sign data. Performs all operations locally, and does not make any RPC calls.
*/
export class ServiceAccountSigner implements CryptoSigner {

algorithm = ALGORITHM_RS256;

/**
* Creates a new CryptoSigner instance from the given service account credential.
Expand Down Expand Up @@ -124,6 +135,8 @@ export class ServiceAccountSigner implements CryptoSigner {
* @see https://cloud.google.com/compute/docs/storing-retrieving-metadata
*/
export class IAMSigner implements CryptoSigner {
algorithm = ALGORITHM_RS256;

private readonly httpClient: AuthorizedHttpClient;
private serviceAccountId?: string;

Expand Down Expand Up @@ -215,6 +228,29 @@ export class IAMSigner implements CryptoSigner {
}
}

/**
* A CryptoSigner implementation that is used when communicating with the Auth emulator.
* It produces unsigned tokens.
*/
export class EmulatedSigner implements CryptoSigner {

algorithm = ALGORITHM_NONE;

/**
* @inheritDoc
*/
public sign(_: Buffer): Promise<Buffer> {
return Promise.resolve(Buffer.from(''));
}

/**
* @inheritDoc
*/
public getAccountId(): Promise<string> {
return Promise.resolve('[email protected]');
}
}

/**
* Create a new CryptoSigner instance for the given app. If the app has been initialized with a service
* account credential, creates a ServiceAccountSigner. Otherwise creates an IAMSigner.
Expand Down Expand Up @@ -250,7 +286,7 @@ export class FirebaseTokenGenerator {
'INTERNAL ASSERT: Must provide a CryptoSigner to use FirebaseTokenGenerator.',
);
}
if (typeof tenantId !== 'undefined' && !validator.isNonEmptyString(tenantId)) {
if (typeof this.tenantId !== 'undefined' && !validator.isNonEmptyString(this.tenantId)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
'`tenantId` argument must be a non-empty string.');
Expand Down Expand Up @@ -298,7 +334,7 @@ export class FirebaseTokenGenerator {
}
return this.signer.getAccountId().then((account) => {
const header: JWTHeader = {
alg: ALGORITHM_RS256,
alg: this.signer.algorithm,
typ: 'JWT',
};
const iat = Math.floor(Date.now() / 1000);
Expand All @@ -319,6 +355,7 @@ export class FirebaseTokenGenerator {
}
const token = `${this.encodeSegment(header)}.${this.encodeSegment(body)}`;
const signPromise = this.signer.sign(Buffer.from(token));

return Promise.all([token, signPromise]);
}).then(([token, signature]) => {
return `${token}.${this.encodeSegment(signature)}`;
Expand Down
Loading

0 comments on commit dbb0c78

Please sign in to comment.