Skip to content

feat(auth): Add support for Auth Emulator #1044

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Oct 16, 2020
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 33 additions & 11 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 @@ -127,6 +129,18 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> {
protected readonly idTokenVerifier: FirebaseTokenVerifier;
protected readonly sessionCookieVerifier: FirebaseTokenVerifier;

/**
* 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
* setIdTokenVerificationEnabled(false);
*/
protected static useEmulator(): boolean {
return !!process.env.FIREBASE_AUTH_EMULATOR_HOST;
}

/**
* The BaseAuth class constructor.
*
Expand All @@ -138,17 +152,15 @@ export class BaseAuth<T extends AbstractAuthRequestHandler> {
* @constructor
*/
constructor(app: FirebaseApp, protected readonly authRequestHandler: T, tokenGenerator?: FirebaseTokenGenerator) {
const useEmulator = !!process.env.FIREBASE_AUTH_EMULATOR_HOST;

if (tokenGenerator) {
this.tokenGenerator = tokenGenerator;
} else {
const cryptoSigner = cryptoSignerFromApp(app);
this.tokenGenerator = new FirebaseTokenGenerator(cryptoSigner, undefined, useEmulator);
const cryptoSigner = BaseAuth.useEmulator() ? new EmulatedSigner() : cryptoSignerFromApp(app);
this.tokenGenerator = new FirebaseTokenGenerator(cryptoSigner);
}

this.sessionCookieVerifier = createSessionCookieVerifier(app, useEmulator);
this.idTokenVerifier = createIdTokenVerifier(app, useEmulator);
this.sessionCookieVerifier = createSessionCookieVerifier(app);
this.idTokenVerifier = createIdTokenVerifier(app);
}

/**
Expand Down Expand Up @@ -737,6 +749,17 @@ 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.
*/
// @ts-expect-error: This private method will be used in the Functions emulator
private setIdTokenVerificationEnabled(enabled: boolean): void {
const algorithm = enabled ? ALGORITHM_RS256 : "none";
this.idTokenVerifier.setAlgorithm(algorithm)
this.sessionCookieVerifier.setAlgorithm(algorithm);
}
}


Expand All @@ -754,9 +777,8 @@ export class TenantAwareAuth extends BaseAuth<TenantAwareAuthRequestHandler> {
* @constructor
*/
constructor(app: FirebaseApp, tenantId: string) {
const cryptoSigner = cryptoSignerFromApp(app);
const useEmulator = !!process.env.FIREBASE_AUTH_EMULATOR_HOST;
const tokenGenerator = new FirebaseTokenGenerator(cryptoSigner, tenantId, useEmulator);
const cryptoSigner = BaseAuth.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
64 changes: 48 additions & 16 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.
*/
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 @@ -213,6 +226,36 @@ export class IAMSigner implements CryptoSigner {
);
});
}

/**
* @inheritdoc
*/
public getAlgorithm(): string {
return ALGORITHM_RS256;
}
}

/**
* 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]');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not so sure about this ... could we do something better here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably the best thing we can do

}
}

/**
Expand Down Expand Up @@ -242,17 +285,15 @@ export class FirebaseTokenGenerator {
* @param tenantId The tenant ID to use for the generated Firebase Auth
* Custom token. If absent, then no tenant ID claim will be set in the
* resulting JWT.
* @param useEmulator When true we should generate tokens on behalf of the
* Auth Emulator, which means no signature.
*/
constructor(signer: CryptoSigner, public readonly tenantId?: string, public readonly useEmulator?: boolean) {
constructor(signer: CryptoSigner, public readonly tenantId?: string) {
if (!validator.isNonNullObject(signer)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_CREDENTIAL,
'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 @@ -299,13 +340,8 @@ export class FirebaseTokenGenerator {
}
}
return this.signer.getAccountId().then((account) => {
// When communicating with the Auth Emulator we don't sign custom tokens which means:
// - Algorithm is 'none'
// - Third token segment (signature) is empty
const alg = this.useEmulator ? 'none' : ALGORITHM_RS256;

const header: JWTHeader = {
alg,
alg: this.signer.algorithm,
typ: 'JWT',
};
const iat = Math.floor(Date.now() / 1000);
Expand All @@ -325,11 +361,7 @@ export class FirebaseTokenGenerator {
body.claims = claims;
}
const token = `${this.encodeSegment(header)}.${this.encodeSegment(body)}`;

// When running inside the Auth emulator we generate unsigned custom tokens
const signPromise = this.useEmulator
? Promise.resolve(Buffer.from(""))
: this.signer.sign(Buffer.from(token));
const signPromise = this.signer.sign(Buffer.from(token));

return Promise.all([token, signPromise]);
}).then(([token, signature]) => {
Expand Down
51 changes: 29 additions & 22 deletions src/auth/token-verifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,7 @@ export class FirebaseTokenVerifier {

constructor(private clientCertUrl: string, private algorithm: jwt.Algorithm,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This really ought to accept an options object. But it's ok to do that later.

private issuer: string, private tokenInfo: FirebaseTokenInfo,
private readonly app: FirebaseApp,
private readonly useEmulator: boolean = false) {

if (this.useEmulator) {
this.algorithm = 'none';
}
private readonly app: FirebaseApp) {

if (!validator.isURL(clientCertUrl)) {
throw new FirebaseAuthError(
Expand Down Expand Up @@ -150,16 +145,20 @@ export class FirebaseTokenVerifier {
);
}

if (this.useEmulator) {
return this.verifyJwtSignatureWithKey(jwtToken, null);
}

return util.findProjectId(this.app)
.then((projectId) => {
return this.verifyJWTWithProjectId(jwtToken, projectId);
});
}

/**
* Override the JWT signing algorithm.
* @param algorithm the new signing algorithm.
*/
public setAlgorithm(algorithm: jwt.Algorithm): void {
this.algorithm = algorithm;
}

private verifyJWTWithProjectId(jwtToken: string, projectId: string | null): Promise<DecodedIdToken> {
if (!validator.isNonEmptyString(projectId)) {
throw new FirebaseAuthError(
Expand All @@ -185,7 +184,7 @@ export class FirebaseTokenVerifier {
if (!fullDecodedToken) {
errorMessage = `Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed the entire string JWT ` +
`which represents ${this.shortNameArticle} ${this.tokenInfo.shortName}.` + verifyJwtTokenDocsMessage;
} else if (typeof header.kid === 'undefined') {
} else if (typeof header.kid === 'undefined' && this.algorithm !== "none") {
const isCustomToken = (payload.aud === FIREBASE_AUDIENCE);
const isLegacyCustomToken = (header.alg === 'HS256' && payload.v === 0 && 'd' in payload && 'uid' in payload.d);

Expand Down Expand Up @@ -223,6 +222,12 @@ export class FirebaseTokenVerifier {
return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage));
}

// When the algorithm is set to "none" there will be no signature and therefore we don't check
// the public keys.
if (this.algorithm === "none") {
return this.verifyJwtSignatureWithKey(jwtToken, null);
}

return this.fetchPublicKeys().then((publicKeys) => {
if (!Object.prototype.hasOwnProperty.call(publicKeys, header.kid)) {
return Promise.reject(
Expand Down Expand Up @@ -251,9 +256,7 @@ export class FirebaseTokenVerifier {
const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` +
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`;
return new Promise((resolve, reject) => {
// The types are wrong here so it won't accept 'null' for publicKey, but testing (including our unit tests)
// confirms that this works and is the required value when validating a token with alg: 'none'
jwt.verify(jwtToken, publicKey as any, {
jwt.verify(jwtToken, publicKey || "", {
algorithms: [this.algorithm],
}, (error: jwt.VerifyErrors | null, decodedToken: object | undefined) => {
if (error) {
Expand Down Expand Up @@ -339,14 +342,16 @@ export class FirebaseTokenVerifier {
* @param {FirebaseApp} app Firebase app instance.
* @return {FirebaseTokenVerifier}
*/
export function createIdTokenVerifier(app: FirebaseApp, useEmulator = false): FirebaseTokenVerifier {
export function createIdTokenVerifier(
app: FirebaseApp,
algorithm: jwt.Algorithm = ALGORITHM_RS256
): FirebaseTokenVerifier {
return new FirebaseTokenVerifier(
CLIENT_CERT_URL,
ALGORITHM_RS256,
algorithm,
'https://securetoken.google.com/',
ID_TOKEN_INFO,
app,
useEmulator
app
);
}

Expand All @@ -356,13 +361,15 @@ export function createIdTokenVerifier(app: FirebaseApp, useEmulator = false): Fi
* @param {FirebaseApp} app Firebase app instance.
* @return {FirebaseTokenVerifier}
*/
export function createSessionCookieVerifier(app: FirebaseApp, useEmulator = false): FirebaseTokenVerifier {
export function createSessionCookieVerifier(
app: FirebaseApp,
algorithm: jwt.Algorithm = ALGORITHM_RS256
): FirebaseTokenVerifier {
return new FirebaseTokenVerifier(
SESSION_COOKIE_CERT_URL,
ALGORITHM_RS256,
algorithm,
'https://session.firebase.google.com/',
SESSION_COOKIE_INFO,
app,
useEmulator
app
);
}
12 changes: 9 additions & 3 deletions test/resources/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { FirebaseApp, FirebaseAppOptions } from '../../src/firebase-app';
import { Credential, GoogleOAuthAccessToken } from '../../src/credential/credential-interfaces';
import { ServiceAccountCredential } from '../../src/credential/credential-internal';

const ALGORITHM = 'RS256' as 'RS256';
const ALGORITHM = 'RS256' as const;
const ONE_HOUR_IN_SECONDS = 60 * 60;

export const uid = 'someUid';
Expand Down Expand Up @@ -181,9 +181,10 @@ export const x509CertPairs = [
* Generates a mocked Firebase ID token.
*
* @param {object} overrides Overrides for the generated token's attributes.
* @param {object} claims Extra claims to add to the token.
* @return {string} A mocked Firebase ID token with any provided overrides included.
*/
export function generateIdToken(overrides?: object): string {
export function generateIdToken(overrides?: object, claims?: object): string {
const options = _.assign({
audience: projectId,
expiresIn: ONE_HOUR_IN_SECONDS,
Expand All @@ -195,7 +196,12 @@ export function generateIdToken(overrides?: object): string {
},
}, overrides);

return jwt.sign(developerClaims, certificateObject.private_key, options);
const payload = {
...developerClaims,
...claims,
};

return jwt.sign(payload, certificateObject.private_key, options);
}

/**
Expand Down
Loading