From dbb0c785c9b72590c19655d3ab5a39338e0cd393 Mon Sep 17 00:00:00 2001 From: Sam Stern Date: Fri, 16 Oct 2020 16:59:44 -0400 Subject: [PATCH] Add support for Auth Emulator (#1044) * 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 --- package-lock.json | 26 +++++----- package.json | 2 +- src/auth/auth-api-request.ts | 27 +++++++++- src/auth/auth.ts | 44 ++++++++++++++-- src/auth/token-generator.ts | 43 +++++++++++++-- src/auth/token-verifier.ts | 45 +++++++++------- test/resources/mocks.ts | 12 +++-- test/unit/auth/auth.spec.ts | 61 ++++++++++++++++++++++ test/unit/auth/token-generator.spec.ts | 25 ++++++++- test/unit/auth/token-verifier.spec.ts | 72 +++++++++++++++++++++++--- 10 files changed, 303 insertions(+), 54 deletions(-) diff --git a/package-lock.json b/package-lock.json index f17615e944..4b4098dc78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "firebase-admin", - "version": "9.1.1", + "version": "9.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -558,7 +558,7 @@ }, "@types/firebase-token-generator": { "version": "2.0.28", - "resolved": "http://registry.npmjs.org/@types/firebase-token-generator/-/firebase-token-generator-2.0.28.tgz", + "resolved": "https://registry.npmjs.org/@types/firebase-token-generator/-/firebase-token-generator-2.0.28.tgz", "integrity": "sha1-Z1VIHZMk4mt6XItFXWgUg3aCw5Y=", "dev": true }, @@ -569,9 +569,9 @@ "dev": true }, "@types/jsonwebtoken": { - "version": "7.2.8", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-7.2.8.tgz", - "integrity": "sha512-XENN3YzEB8D6TiUww0O8SRznzy1v+77lH7UmuN54xq/IHIsyWjWOzZuFFTtoiRuaE782uAoRwBe/wwow+vQXZw==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.0.tgz", + "integrity": "sha512-9bVao7LvyorRGZCw0VmH/dr7Og+NdjYSsKAxB43OQoComFbBgsEpoR9JW6+qSq/ogwVBg8GI2MfAlk4SYI4OLg==", "dev": true, "requires": { "@types/node": "*" @@ -1284,7 +1284,7 @@ }, "binaryextensions": { "version": "1.0.1", - "resolved": "http://registry.npmjs.org/binaryextensions/-/binaryextensions-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-1.0.1.tgz", "integrity": "sha1-HmN0iLNbWL2l9HdL+WpSEqjJB1U=", "dev": true }, @@ -2990,7 +2990,7 @@ }, "firebase-token-generator": { "version": "2.0.0", - "resolved": "http://registry.npmjs.org/firebase-token-generator/-/firebase-token-generator-2.0.0.tgz", + "resolved": "https://registry.npmjs.org/firebase-token-generator/-/firebase-token-generator-2.0.0.tgz", "integrity": "sha1-l2fXWewTq9yZuhFf1eqZ2Lk9EgY=", "dev": true }, @@ -4598,7 +4598,7 @@ }, "istextorbinary": { "version": "1.0.2", - "resolved": "http://registry.npmjs.org/istextorbinary/-/istextorbinary-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-1.0.2.tgz", "integrity": "sha1-rOGTVNGpoBc+/rEITOD4ewrX3s8=", "dev": true, "requires": { @@ -5903,7 +5903,7 @@ }, "find-up": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "resolved": false, "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", "dev": true, "requires": { @@ -6263,7 +6263,7 @@ }, "path-is-absolute": { "version": "1.0.1", - "resolved": "http://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true }, @@ -6419,7 +6419,7 @@ }, "pretty-hrtime": { "version": "1.0.3", - "resolved": "http://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", + "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=", "dev": true }, @@ -6965,7 +6965,7 @@ }, "safe-regex": { "version": "1.1.0", - "resolved": "http://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", "dev": true, "requires": { @@ -7766,7 +7766,7 @@ }, "textextensions": { "version": "1.0.2", - "resolved": "http://registry.npmjs.org/textextensions/-/textextensions-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-1.0.2.tgz", "integrity": "sha1-ZUhjk+4fK7A5pgy7oFsLaL2VAdI=", "dev": true }, diff --git a/package.json b/package.json index b8babb1bc0..0c63c8ab3b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 6043341b80..81789d17e2 100644 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -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; @@ -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; + } } /** @@ -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; + } } /** diff --git a/src/auth/auth.ts b/src/auth/auth.ts index d22f3776b6..b9904eadfe 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -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'; @@ -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, @@ -141,7 +143,7 @@ export class BaseAuth { if (tokenGenerator) { this.tokenGenerator = tokenGenerator; } else { - const cryptoSigner = cryptoSignerFromApp(app); + const cryptoSigner = useEmulator() ? new EmulatedSigner() : cryptoSignerFromApp(app); this.tokenGenerator = new FirebaseTokenGenerator(cryptoSigner); } @@ -735,6 +737,28 @@ export class BaseAuth { 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); + } } @@ -752,7 +776,7 @@ export class TenantAwareAuth extends BaseAuth { * @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); @@ -868,3 +892,15 @@ export class Auth extends BaseAuth 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 NOT 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; +} diff --git a/src/auth/token-generator.ts b/src/auth/token-generator.ts index 194ed48cbf..8f6850f6c6 100644 --- a/src/auth/token-generator.ts +++ b/src/auth/token-generator.ts @@ -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 @@ -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. * @@ -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. @@ -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; @@ -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 { + return Promise.resolve(Buffer.from('')); + } + + /** + * @inheritDoc + */ + public getAccountId(): Promise { + return Promise.resolve('firebase-auth-emulator@example.com'); + } +} + /** * 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. @@ -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.'); @@ -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); @@ -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)}`; diff --git a/src/auth/token-verifier.ts b/src/auth/token-verifier.ts index 1f8e3e825a..768cc5bb7b 100644 --- a/src/auth/token-verifier.ts +++ b/src/auth/token-verifier.ts @@ -75,9 +75,10 @@ export class FirebaseTokenVerifier { private publicKeysExpireAt: number; private readonly shortNameArticle: string; - constructor(private clientCertUrl: string, private algorithm: string, + constructor(private clientCertUrl: string, private algorithm: jwt.Algorithm, private issuer: string, private tokenInfo: FirebaseTokenInfo, private readonly app: FirebaseApp) { + if (!validator.isURL(clientCertUrl)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_ARGUMENT, @@ -150,6 +151,14 @@ export class FirebaseTokenVerifier { }); } + /** + * 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 { if (!validator.isNonEmptyString(projectId)) { throw new FirebaseAuthError( @@ -175,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); @@ -213,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( @@ -237,13 +252,13 @@ export class FirebaseTokenVerifier { * @return {Promise} A promise that resolves with the decoded JWT claims on successful * verification. */ - private verifyJwtSignatureWithKey(jwtToken: string, publicKey: string): Promise { + private verifyJwtSignatureWithKey(jwtToken: string, publicKey: string | null): Promise { const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; return new Promise((resolve, reject) => { - jwt.verify(jwtToken, publicKey, { + jwt.verify(jwtToken, publicKey || "", { algorithms: [this.algorithm], - }, (error: jwt.VerifyErrors, decodedToken: string | object) => { + }, (error: jwt.VerifyErrors | null, decodedToken: object | undefined) => { if (error) { if (error.name === 'TokenExpiredError') { const errorMessage = `${this.tokenInfo.jwtName} has expired. Get a fresh ${this.tokenInfo.shortName}` + @@ -256,19 +271,9 @@ export class FirebaseTokenVerifier { } return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message)); } else { - // TODO(rsgowman): I think the typing on jwt.verify is wrong. It claims that this can be either a string or an - // object, but the code always seems to call it as an object. Investigate and upstream typing changes if this - // is actually correct. - if (typeof decodedToken === 'string') { - return reject(new FirebaseAuthError( - AuthClientErrorCode.INTERNAL_ERROR, - "Unexpected decodedToken. Expected an object but got a string: '" + decodedToken + "'", - )); - } else { - const decodedIdToken = (decodedToken as DecodedIdToken); - decodedIdToken.uid = decodedIdToken.sub; - resolve(decodedIdToken); - } + const decodedIdToken = (decodedToken as DecodedIdToken); + decodedIdToken.uid = decodedIdToken.sub; + resolve(decodedIdToken); } }); }); @@ -343,7 +348,7 @@ export function createIdTokenVerifier(app: FirebaseApp): FirebaseTokenVerifier { ALGORITHM_RS256, 'https://securetoken.google.com/', ID_TOKEN_INFO, - app, + app ); } @@ -359,6 +364,6 @@ export function createSessionCookieVerifier(app: FirebaseApp): FirebaseTokenVeri ALGORITHM_RS256, 'https://session.firebase.google.com/', SESSION_COOKIE_INFO, - app, + app ); } diff --git a/test/resources/mocks.ts b/test/resources/mocks.ts index d8a75aff71..caf919e192 100644 --- a/test/resources/mocks.ts +++ b/test/resources/mocks.ts @@ -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'; +const ALGORITHM = 'RS256' as const; const ONE_HOUR_IN_SECONDS = 60 * 60; export const uid = 'someUid'; @@ -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, @@ -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); } /** diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index 7fb5715eb7..e51d0da2c8 100644 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -3192,5 +3192,66 @@ AUTH_CONFIGS.forEach((testConfig) => { }); }); } + + describe('auth emulator support', () => { + + let mockAuth = testConfig.init(mocks.app()); + + beforeEach(() => { + process.env.FIREBASE_AUTH_EMULATOR_HOST = 'localhost:9099'; + mockAuth = testConfig.init(mocks.app()); + }); + + afterEach(() => { + delete process.env.FIREBASE_AUTH_EMULATOR_HOST; + }); + + it('createCustomToken() generates an unsigned token', async () => { + const token = await mockAuth.createCustomToken('uid1'); + + // Check the decoded token has the right algorithm + const decoded = jwt.decode(token, { complete: true }); + expect(decoded).to.have.property('header').that.has.property('alg', 'none'); + expect(decoded).to.have.property('payload').that.has.property('uid', 'uid1'); + + // Make sure this doesn't throw + jwt.verify(token, '', { algorithms: ['none'] }); + }); + + it('verifyIdToken() rejects an unsigned token when only the env var is set', async () => { + const unsignedToken = mocks.generateIdToken({ + algorithm: 'none' + }); + + await expect(mockAuth.verifyIdToken(unsignedToken)) + .to.be.rejectedWith('Firebase ID token has incorrect algorithm. Expected "RS256"'); + }); + + it('verifyIdToken() accepts an unsigned token when private method is called and env var is set', async () => { + (mockAuth as any).setJwtVerificationEnabled(false); + + let claims = {}; + if (testConfig.Auth === TenantAwareAuth) { + claims = { + firebase: { + tenant: TENANT_ID + } + } + } + + const unsignedToken = mocks.generateIdToken({ + algorithm: 'none' + }, claims); + + const decoded = await mockAuth.verifyIdToken(unsignedToken); + expect(decoded).to.be.ok; + }); + + it('private method throws when env var is unset', async () => { + delete process.env.FIREBASE_AUTH_EMULATOR_HOST; + await expect(() => (mockAuth as any).setJwtVerificationEnabled(false)) + .to.throw('This method is only available when connected to the Authentication emulator.') + }); + }); }); }); diff --git a/test/unit/auth/token-generator.spec.ts b/test/unit/auth/token-generator.spec.ts index e7e5dbaaca..4164d008be 100644 --- a/test/unit/auth/token-generator.spec.ts +++ b/test/unit/auth/token-generator.spec.ts @@ -25,7 +25,7 @@ import * as chaiAsPromised from 'chai-as-promised'; import * as mocks from '../../resources/mocks'; import { - BLACKLISTED_CLAIMS, FirebaseTokenGenerator, ServiceAccountSigner, IAMSigner, + BLACKLISTED_CLAIMS, FirebaseTokenGenerator, ServiceAccountSigner, IAMSigner, EmulatedSigner } from '../../../src/auth/token-generator'; import { ServiceAccountCredential } from '../../../src/credential/credential-internal'; @@ -308,6 +308,29 @@ describe('FirebaseTokenGenerator', () => { tokenGenerator: new FirebaseTokenGenerator(new ServiceAccountSigner(cert), tenantId), }]; + describe('Emulator', () => { + const signer = new EmulatedSigner(); + const tokenGenerator = new FirebaseTokenGenerator(signer); + + it('should generate a valid unsigned token', async () => { + const uid = 'uid123'; + const claims = { foo: 'bar' }; + const token = await tokenGenerator.createCustomToken(uid, claims); + + // Check that verify doesn't throw + // Note: the types for jsonwebtoken are wrong so we have to disguise the 'null' + jwt.verify(token, '', { algorithms: ['none'] }); + + // Decode and check all three segments + const { header, payload, signature } = jwt.decode(token, { complete: true }) as { [key: string]: any }; + expect(header).to.deep.equal({ alg: 'none', typ: 'JWT' }); + expect(payload['uid']).to.equal(uid); + expect(payload['claims']).to.deep.equal(claims); + expect(signature).to.equal(''); + }); + + }); + tokenGeneratorConfigs.forEach((tokenGeneratorConfig) => { describe(tokenGeneratorConfig.name, () => { const tokenGenerator = tokenGeneratorConfig.tokenGenerator; diff --git a/test/unit/auth/token-verifier.spec.ts b/test/unit/auth/token-verifier.spec.ts index 5a9749851c..7aae3255b8 100644 --- a/test/unit/auth/token-verifier.spec.ts +++ b/test/unit/auth/token-verifier.spec.ts @@ -34,6 +34,7 @@ import * as verifier from '../../../src/auth/token-verifier'; import { ServiceAccountCredential } from '../../../src/credential/credential-internal'; import { AuthClientErrorCode } from '../../../src/utils/error'; import { FirebaseApp } from '../../../src/firebase-app'; +import { Algorithm } from 'jsonwebtoken'; chai.should(); chai.use(sinonChai); @@ -104,6 +105,20 @@ function mockFailedFetchPublicKeys(): nock.Scope { .replyWithError('message'); } +function createTokenVerifier( + app: FirebaseApp, + options: { algorithm?: Algorithm } = {} +): verifier.FirebaseTokenVerifier { + const algorithm = options.algorithm || "RS256"; + return new verifier.FirebaseTokenVerifier( + 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', + algorithm, + 'https://securetoken.google.com/', + verifier.ID_TOKEN_INFO, + app + ); +} + describe('FirebaseTokenVerifier', () => { let app: FirebaseApp; @@ -116,13 +131,7 @@ describe('FirebaseTokenVerifier', () => { app = mocks.app(); const cert = new ServiceAccountCredential(mocks.certificateObject); tokenGenerator = new FirebaseTokenGenerator(new ServiceAccountSigner(cert)); - tokenVerifier = new verifier.FirebaseTokenVerifier( - 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', - 'RS256', - 'https://securetoken.google.com/', - verifier.ID_TOKEN_INFO, - app, - ); + tokenVerifier = createTokenVerifier(app); httpsSpy = sinon.spy(https, 'request'); }); @@ -535,6 +544,55 @@ describe('FirebaseTokenVerifier', () => { }); }); + it('should decode an unsigned token when the algorithm is set to none (emulator)', async () => { + clock = sinon.useFakeTimers(1000); + + const emulatorVerifier = createTokenVerifier(app, { algorithm: 'none' }); + const mockIdToken = mocks.generateIdToken({ + algorithm: 'none', + header: {} + }); + + const decoded = await emulatorVerifier.verifyJWT(mockIdToken); + expect(decoded).to.deep.equal({ + one: 'uno', + two: 'dos', + iat: 1, + exp: ONE_HOUR_IN_SECONDS + 1, + aud: mocks.projectId, + iss: 'https://securetoken.google.com/' + mocks.projectId, + sub: mocks.uid, + uid: mocks.uid, + }); + }); + + it('should not decode a signed token when the algorithm is set to none (emulator)', async () => { + clock = sinon.useFakeTimers(1000); + + const emulatorVerifier = createTokenVerifier(app, { algorithm: 'none' }); + const mockIdToken = mocks.generateIdToken(); + + await emulatorVerifier.verifyJWT(mockIdToken) + .should.eventually.be.rejectedWith('Firebase ID token has incorrect algorithm. Expected "none"'); + }); + + it('should not decode an unsigned token when the algorithm is not overridden (emulator)', async () => { + clock = sinon.useFakeTimers(1000); + + const idTokenNoAlg = mocks.generateIdToken({ + algorithm: 'none', + }); + await tokenVerifier.verifyJWT(idTokenNoAlg) + .should.eventually.be.rejectedWith('Firebase ID token has incorrect algorithm.'); + + const idTokenNoHeader = mocks.generateIdToken({ + algorithm: 'none', + header: {} + }); + await tokenVerifier.verifyJWT(idTokenNoHeader) + .should.eventually.be.rejectedWith('Firebase ID token has no "kid" claim.'); + }); + it('should use the given HTTP Agent', () => { const agent = new https.Agent(); const appWithAgent = mocks.appWithOptions({