diff --git a/package-lock.json b/package-lock.json index 5ec1476a52..f4875def9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -176,9 +176,9 @@ "integrity": "sha512-L/ZnJRAq7F++utfuoTKX4CLBG5YR7tFO3PLzG1/oXXKEezJ0kRL3CMRoueBEmTCzVb/6SIs2Qlaw++uDgi5Xyg==" }, "@firebase/auth": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-0.15.2.tgz", - "integrity": "sha512-2n32PBi6x9jVhc0E/ewKLUCYYTzFEXL4PNkvrrlGKbzeTBEkkyzfgUX7OV9UF5wUOG+gurtUthuur1zspZ/9hg==", + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-0.16.2.tgz", + "integrity": "sha512-68TlDL0yh3kF8PiCzI8m8RWd/bf/xCLUsdz1NZ2Dwea0sp6e2WAhu0sem1GfhwuEwL+Ns4jCdX7qbe/OQlkVEA==", "dev": true, "requires": { "@firebase/auth-types": "0.10.1" diff --git a/package.json b/package.json index 37620aa245..ee75e5e196 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ }, "devDependencies": { "@firebase/app": "^0.6.13", - "@firebase/auth": "^0.15.2", + "@firebase/auth": "^0.16.2", "@firebase/auth-types": "^0.10.1", "@microsoft/api-extractor": "^7.11.2", "@types/bcrypt": "^2.0.0", diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index 0380debeb8..56c06c1423 100644 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -2117,10 +2117,6 @@ function emulatorHost(): string | undefined { /** * 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); */ export function useEmulator(): boolean { return !!emulatorHost(); diff --git a/src/auth/auth.ts b/src/auth/auth.ts index 8e35df82c4..56f9ac64b4 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -29,7 +29,7 @@ import * as utils from '../utils/index'; import * as validator from '../utils/validator'; import { auth } from './index'; import { - FirebaseTokenVerifier, createSessionCookieVerifier, createIdTokenVerifier, ALGORITHM_RS256 + FirebaseTokenVerifier, createSessionCookieVerifier, createIdTokenVerifier } from './token-verifier'; import { SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, @@ -115,15 +115,16 @@ export class BaseAuth implements BaseAuthI * verification. */ public verifyIdToken(idToken: string, checkRevoked = false): Promise { - return this.idTokenVerifier.verifyJWT(idToken) + const isEmulator = useEmulator(); + return this.idTokenVerifier.verifyJWT(idToken, isEmulator) .then((decodedIdToken: DecodedIdToken) => { // Whether to check if the token was revoked. - if (!checkRevoked) { - return decodedIdToken; + if (checkRevoked || isEmulator) { + return this.verifyDecodedJWTNotRevoked( + decodedIdToken, + AuthClientErrorCode.ID_TOKEN_REVOKED); } - return this.verifyDecodedJWTNotRevoked( - decodedIdToken, - AuthClientErrorCode.ID_TOKEN_REVOKED); + return decodedIdToken; }); } @@ -443,15 +444,16 @@ export class BaseAuth implements BaseAuthI */ public verifySessionCookie( sessionCookie: string, checkRevoked = false): Promise { - return this.sessionCookieVerifier.verifyJWT(sessionCookie) + const isEmulator = useEmulator(); + return this.sessionCookieVerifier.verifyJWT(sessionCookie, isEmulator) .then((decodedIdToken: DecodedIdToken) => { // Whether to check if the token was revoked. - if (!checkRevoked) { - return decodedIdToken; + if (checkRevoked || isEmulator) { + return this.verifyDecodedJWTNotRevoked( + decodedIdToken, + AuthClientErrorCode.SESSION_COOKIE_REVOKED); } - return this.verifyDecodedJWTNotRevoked( - decodedIdToken, - AuthClientErrorCode.SESSION_COOKIE_REVOKED); + return decodedIdToken; }); } @@ -675,28 +677,6 @@ export class BaseAuth implements BaseAuthI 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); - } } diff --git a/src/auth/token-verifier.ts b/src/auth/token-verifier.ts index c39aa84fc7..cbb9991f4c 100644 --- a/src/auth/token-verifier.ts +++ b/src/auth/token-verifier.ts @@ -79,7 +79,7 @@ export class FirebaseTokenVerifier { 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, @@ -135,10 +135,11 @@ export class FirebaseTokenVerifier { * Verifies the format and signature of a Firebase Auth JWT token. * * @param {string} jwtToken The Firebase Auth JWT token to verify. + * @param {boolean=} isEmulator Whether to accept Auth Emulator tokens. * @return {Promise} A promise fulfilled with the decoded claims of the Firebase Auth ID * token. */ - public verifyJWT(jwtToken: string): Promise { + public verifyJWT(jwtToken: string, isEmulator = false): Promise { if (!validator.isString(jwtToken)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_ARGUMENT, @@ -148,19 +149,15 @@ export class FirebaseTokenVerifier { return util.findProjectId(this.app) .then((projectId) => { - return this.verifyJWTWithProjectId(jwtToken, projectId); + return this.verifyJWTWithProjectId(jwtToken, projectId, isEmulator); }); } - /** - * 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 { + private verifyJWTWithProjectId( + jwtToken: string, + projectId: string | null, + isEmulator: boolean + ): Promise { if (!validator.isNonEmptyString(projectId)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_CREDENTIAL, @@ -185,7 +182,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' && this.algorithm !== 'none') { + } else if (!isEmulator && typeof header.kid === 'undefined') { const isCustomToken = (payload.aud === FIREBASE_AUDIENCE); const isLegacyCustomToken = (header.alg === 'HS256' && payload.v === 0 && 'd' in payload && 'uid' in payload.d); @@ -200,7 +197,7 @@ export class FirebaseTokenVerifier { } errorMessage += verifyJwtTokenDocsMessage; - } else if (header.alg !== this.algorithm) { + } else if (!isEmulator && header.alg !== this.algorithm) { errorMessage = `${this.tokenInfo.jwtName} has incorrect algorithm. Expected "` + this.algorithm + '" but got ' + '"' + header.alg + '".' + verifyJwtTokenDocsMessage; } else if (payload.aud !== projectId) { @@ -209,7 +206,7 @@ export class FirebaseTokenVerifier { verifyJwtTokenDocsMessage; } else if (payload.iss !== this.issuer + projectId) { errorMessage = `${this.tokenInfo.jwtName} has incorrect "iss" (issuer) claim. Expected ` + - `"${this.issuer}"` + projectId + '" but got "' + + `"${this.issuer}` + projectId + '" but got "' + payload.iss + '".' + projectIdMatchMessage + verifyJwtTokenDocsMessage; } else if (typeof payload.sub !== 'string') { errorMessage = `${this.tokenInfo.jwtName} has no "sub" (subject) claim.` + verifyJwtTokenDocsMessage; @@ -223,9 +220,8 @@ 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') { + if (isEmulator) { + // Signature checks skipped for emulator; no need to fetch public keys. return this.verifyJwtSignatureWithKey(jwtToken, null); } @@ -257,26 +253,29 @@ 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) => { - jwt.verify(jwtToken, publicKey || '', { - algorithms: [this.algorithm], - }, (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}` + - ` from your client app and try again (auth/${this.tokenInfo.expiredErrorCode.code}).` + - verifyJwtTokenDocsMessage; - return reject(new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage)); - } else if (error.name === 'JsonWebTokenError') { - const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage; - return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); + const verifyOptions: jwt.VerifyOptions = {}; + if (publicKey !== null) { + verifyOptions.algorithms = [this.algorithm]; + } + jwt.verify(jwtToken, publicKey || '', verifyOptions, + (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}` + + ` from your client app and try again (auth/${this.tokenInfo.expiredErrorCode.code}).` + + verifyJwtTokenDocsMessage; + return reject(new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage)); + } else if (error.name === 'JsonWebTokenError') { + const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage; + return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); + } + return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message)); + } else { + const decodedIdToken = (decodedToken as DecodedIdToken); + decodedIdToken.uid = decodedIdToken.sub; + resolve(decodedIdToken); } - return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message)); - } else { - const decodedIdToken = (decodedToken as DecodedIdToken); - decodedIdToken.uid = decodedIdToken.sub; - resolve(decodedIdToken); - } - }); + }); }); } diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 41e278f821..a7ac8c4636 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -37,6 +37,8 @@ chai.use(chaiAsPromised); const expect = chai.expect; +const authEmulatorHost = process.env.FIREBASE_AUTH_EMULATOR_HOST; + const newUserUid = generateRandomString(20); const nonexistentUid = generateRandomString(20); const newMultiFactorUserUid = generateRandomString(20); @@ -102,6 +104,9 @@ describe('admin.auth', () => { apiKey, authDomain: projectId + '.firebaseapp.com', }); + if (authEmulatorHost) { + (clientAuth() as any).useEmulator('http://' + authEmulatorHost); + } return cleanup(); }); @@ -137,7 +142,10 @@ describe('admin.auth', () => { }); }); - it('createUser() creates a new user with enrolled second factors', () => { + it('createUser() creates a new user with enrolled second factors', function () { + if (authEmulatorHost) { + return this.skip(); // Not yet supported in Auth Emulator. + } const enrolledFactors = [ { phoneNumber: '+16505550001', @@ -353,7 +361,7 @@ describe('admin.auth', () => { const metadata = userRecord!.metadata; expect(metadata.lastRefreshTime).to.exist; - expect(isUTCString(metadata.lastRefreshTime!)); + expect(isUTCString(metadata.lastRefreshTime!)).to.be.true; const creationTime = new Date(metadata.creationTime).getTime(); const lastRefreshTime = new Date(metadata.lastRefreshTime!).getTime(); expect(creationTime).lte(lastRefreshTime); @@ -410,7 +418,7 @@ describe('admin.auth', () => { }); }); - it('revokeRefreshTokens() invalidates existing sessions and ID tokens', () => { + it('revokeRefreshTokens() invalidates existing sessions and ID tokens', async () => { let currentIdToken: string; let currentUser: User; // Sign in with an email and password account. @@ -433,9 +441,14 @@ describe('admin.auth', () => { ), 1000)); }) .then(() => { - // verifyIdToken without checking revocation should still succeed. - return admin.auth().verifyIdToken(currentIdToken) - .should.eventually.be.fulfilled; + const verifyingIdToken = admin.auth().verifyIdToken(currentIdToken) + if (authEmulatorHost) { + // Check revocation is forced in emulator-mode and this should throw. + return verifyingIdToken.should.eventually.be.rejected; + } else { + // verifyIdToken without checking revocation should still succeed. + return verifyingIdToken.should.eventually.be.fulfilled; + } }) .then(() => { // verifyIdToken while checking for revocation should fail. @@ -522,6 +535,27 @@ describe('admin.auth', () => { it('updateUser() updates the user record with the given parameters', () => { const updatedDisplayName = 'Updated User ' + newUserUid; + return admin.auth().updateUser(newUserUid, { + email: updatedEmail, + phoneNumber: updatedPhone, + emailVerified: true, + displayName: updatedDisplayName, + }) + .then((userRecord) => { + expect(userRecord.emailVerified).to.be.true; + expect(userRecord.displayName).to.equal(updatedDisplayName); + // Confirm expected email. + expect(userRecord.email).to.equal(updatedEmail); + // Confirm expected phone number. + expect(userRecord.phoneNumber).to.equal(updatedPhone); + }); + }); + + it('updateUser() creates, updates, and removes second factors', function () { + if (authEmulatorHost) { + return this.skip(); // Not yet supported in Auth Emulator. + } + const now = new Date(1476235905000).toUTCString(); // Update user with enrolled second factors. const enrolledFactors = [ @@ -541,21 +575,11 @@ describe('admin.auth', () => { }, ]; return admin.auth().updateUser(newUserUid, { - email: updatedEmail, - phoneNumber: updatedPhone, - emailVerified: true, - displayName: updatedDisplayName, multiFactor: { enrolledFactors, }, }) .then((userRecord) => { - expect(userRecord.emailVerified).to.be.true; - expect(userRecord.displayName).to.equal(updatedDisplayName); - // Confirm expected email. - expect(userRecord.email).to.equal(updatedEmail); - // Confirm expected phone number. - expect(userRecord.phoneNumber).to.equal(updatedPhone); // Confirm second factors added to user. const actualUserRecord: {[key: string]: any} = userRecord.toJSON(); expect(actualUserRecord.multiFactor.enrolledFactors.length).to.equal(2); @@ -655,6 +679,49 @@ describe('admin.auth', () => { .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); }); + if (authEmulatorHost) { + describe('Auth emulator support', () => { + const uid = 'authEmulatorUser'; + before(() => { + return admin.auth().createUser({ + uid, + email: 'lastRefreshTimeUser@example.com', + password: 'p4ssword', + }); + }); + after(() => { + return admin.auth().deleteUser(uid); + }); + + it('verifyIdToken() succeeds when called with an unsigned token', () => { + const unsignedToken = mocks.generateIdToken({ + algorithm: 'none', + audience: projectId, + issuer: 'https://securetoken.google.com/' + projectId, + subject: uid, + }); + return admin.auth().verifyIdToken(unsignedToken); + }); + + it('verifyIdToken() fails when called with a token with wrong project', () => { + const unsignedToken = mocks.generateIdToken({ algorithm: 'none', audience: 'nosuch' }); + return admin.auth().verifyIdToken(unsignedToken) + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + }); + + it('verifyIdToken() fails when called with a token that does not belong to a user', () => { + const unsignedToken = mocks.generateIdToken({ + algorithm: 'none', + audience: projectId, + issuer: 'https://securetoken.google.com/' + projectId, + subject: 'nosuch', + }); + return admin.auth().verifyIdToken(unsignedToken) + .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); + }); + }); + } + describe('Link operations', () => { const uid = generateRandomString(20).toLowerCase(); const email = uid + '@example.com'; @@ -1253,7 +1320,10 @@ describe('admin.auth', () => { }; // Clean up temp configurations used for test. - before(() => { + before(function () { + if (authEmulatorHost) { + return this.skip(); // Not implemented. + } return removeTempConfigs().then(() => admin.auth().createProviderConfig(authProviderConfig1)); }); @@ -1385,7 +1455,10 @@ describe('admin.auth', () => { }; // Clean up temp configurations used for test. - before(() => { + before(function () { + if (authEmulatorHost) { + return this.skip(); // Not implemented. + } return removeTempConfigs().then(() => admin.auth().createProviderConfig(authProviderConfig1)); }); @@ -1484,7 +1557,6 @@ describe('admin.auth', () => { it('deleteUser() deletes the user with the given UID', () => { return Promise.all([ admin.auth().deleteUser(newUserUid), - admin.auth().deleteUser(newMultiFactorUserUid), admin.auth().deleteUser(uidFromCreateUserWithoutUid), ]).should.eventually.be.fulfilled; }); @@ -1610,8 +1682,14 @@ describe('admin.auth', () => { ), 1000)); }) .then(() => { - return admin.auth().verifySessionCookie(currentSessionCookie) - .should.eventually.be.fulfilled; + const verifyingSessionCookie = admin.auth().verifySessionCookie(currentSessionCookie); + if (authEmulatorHost) { + // Check revocation is forced in emulator-mode and this should throw. + return verifyingSessionCookie.should.eventually.be.rejected; + } else { + // verifyIdToken without checking revocation should still succeed. + return verifyingSessionCookie.should.eventually.be.fulfilled; + } }) .then(() => { return admin.auth().verifySessionCookie(currentSessionCookie, true) @@ -1824,7 +1902,10 @@ describe('admin.auth', () => { ]; fixtures.forEach((fixture) => { - it(`successfully imports users with ${fixture.name} to Firebase Auth.`, () => { + it(`successfully imports users with ${fixture.name} to Firebase Auth.`, function () { + if (authEmulatorHost) { + return this.skip(); // Auth Emulator does not support real hashes. + } importUserRecord = { uid: randomUid, email: randomUid + '@example.com', @@ -1893,10 +1974,13 @@ describe('admin.auth', () => { expect(JSON.stringify(actualUserRecord[key])) .to.be.equal(JSON.stringify((importUserRecord as any)[key])); } - }).should.eventually.be.fulfilled; + }); }); - it('successfully imports users with enrolled second factors', () => { + it('successfully imports users with enrolled second factors', function () { + if (authEmulatorHost) { + return this.skip(); // Not yet implemented. + } const uid = generateRandomString(20).toLowerCase(); const email = uid + '@example.com'; const now = new Date(1476235905000).toUTCString(); @@ -1958,25 +2042,41 @@ describe('admin.auth', () => { it('fails when invalid users are provided', () => { const users = [ - { uid: generateRandomString(20).toLowerCase(), phoneNumber: '+1error' }, { uid: generateRandomString(20).toLowerCase(), email: 'invalid' }, - { uid: generateRandomString(20).toLowerCase(), phoneNumber: '+1invalid' }, { uid: generateRandomString(20).toLowerCase(), emailVerified: 'invalid' } as any, ]; return admin.auth().importUsers(users) .then((result) => { expect(result.successCount).to.equal(0); - expect(result.failureCount).to.equal(4); - expect(result.errors.length).to.equal(4); + expect(result.failureCount).to.equal(2); + expect(result.errors.length).to.equal(2); + expect(result.errors[0].index).to.equal(0); + expect(result.errors[0].error.code).to.equals('auth/invalid-email'); + expect(result.errors[1].index).to.equal(1); + expect(result.errors[1].error.code).to.equals('auth/invalid-email-verified'); + }); + }); + + it('fails when users with invalid phone numbers are provided', function () { + if (authEmulatorHost) { + // Auth Emulator's phoneNumber validation is also lax and won't throw. + return this.skip(); + } + const users = [ + // These phoneNumbers passes local (lax) validator but fails remotely. + { uid: generateRandomString(20).toLowerCase(), phoneNumber: '+1error' }, + { uid: generateRandomString(20).toLowerCase(), phoneNumber: '+1invalid' }, + ]; + return admin.auth().importUsers(users) + .then((result) => { + expect(result.successCount).to.equal(0); + expect(result.failureCount).to.equal(2); + expect(result.errors.length).to.equal(2); expect(result.errors[0].index).to.equal(0); expect(result.errors[0].error.code).to.equals('auth/invalid-user-import'); expect(result.errors[1].index).to.equal(1); - expect(result.errors[1].error.code).to.equals('auth/invalid-email'); - expect(result.errors[2].index).to.equal(2); - expect(result.errors[2].error.code).to.equals('auth/invalid-user-import'); - expect(result.errors[3].index).to.equal(3); - expect(result.errors[3].error.code).to.equals('auth/invalid-email-verified'); - }).should.eventually.be.fulfilled; + expect(result.errors[1].error.code).to.equals('auth/invalid-user-import'); + }); }); }); }); @@ -2132,12 +2232,11 @@ function safeDelete(uid: string): Promise { * @param {string[]} uids The list of user identifiers to delete. * @return {Promise} A promise that resolves when delete operation resolves. */ -function deleteUsersWithDelay(uids: string[]): Promise { - return new Promise((resolve) => { - setTimeout(resolve, 1000); - }).then(() => { - return admin.auth().deleteUsers(uids); - }); +async function deleteUsersWithDelay(uids: string[]): Promise { + if (!authEmulatorHost) { + await new Promise((resolve) => { setTimeout(resolve, 1000); }); + } + return admin.auth().deleteUsers(uids); } /** diff --git a/test/integration/setup.ts b/test/integration/setup.ts index c70362fa89..5880a6cd0d 100644 --- a/test/integration/setup.ts +++ b/test/integration/setup.ts @@ -36,51 +36,72 @@ export let noServiceAccountApp: admin.app.App; export let cmdArgs: any; +export const isEmulator = !!process.env.FIREBASE_EMULATOR_HUB; + before(() => { - /* tslint:disable:no-console */ - let serviceAccount: any; - try { - serviceAccount = require('../resources/key.json'); - } catch (error) { - console.log(chalk.red( - 'The integration test suite requires a service account JSON file for a ' + - 'Firebase project to be saved to `test/resources/key.json`.', - error, - )); - throw error; - } + let getCredential: () => {credential?: admin.credential.Credential}; + let serviceAccountId: string; - try { - apiKey = fs.readFileSync(path.join(__dirname, '../resources/apikey.txt')).toString().trim(); - } catch (error) { - console.log(chalk.red( - 'The integration test suite requires an API key for a ' + - 'Firebase project to be saved to `test/resources/apikey.txt`.', - error, + /* tslint:disable:no-console */ + if (isEmulator) { + console.log(chalk.yellow( + 'Running integration tests against Emulator Suite. ' + + 'Some tests may be skipped due to lack of emulator support.', )); - throw error; + getCredential = () => ({}); + projectId = process.env.GCLOUD_PROJECT!; + apiKey = 'fake-api-key'; + serviceAccountId = 'fake-client-email@example.com'; + } else { + let serviceAccount: any; + try { + serviceAccount = require('../resources/key.json'); + } catch (error) { + console.log(chalk.red( + 'The integration test suite requires a service account JSON file for a ' + + 'Firebase project to be saved to `test/resources/key.json`.', + error, + )); + throw error; + } + + try { + apiKey = fs.readFileSync(path.join(__dirname, '../resources/apikey.txt')).toString().trim(); + } catch (error) { + console.log(chalk.red( + 'The integration test suite requires an API key for a ' + + 'Firebase project to be saved to `test/resources/apikey.txt`.', + error, + )); + throw error; + } + getCredential = () => ({ credential: admin.credential.cert(serviceAccount) }); + projectId = serviceAccount.project_id; + serviceAccountId = serviceAccount.client_email; } /* tslint:enable:no-console */ - projectId = serviceAccount.project_id; databaseUrl = 'https://' + projectId + '.firebaseio.com'; storageBucket = projectId + '.appspot.com'; defaultApp = admin.initializeApp({ - credential: admin.credential.cert(serviceAccount), + ...getCredential(), + projectId, databaseURL: databaseUrl, storageBucket, }); nullApp = admin.initializeApp({ - credential: admin.credential.cert(serviceAccount), + ...getCredential(), + projectId, databaseURL: databaseUrl, databaseAuthVariableOverride: null, storageBucket, }, 'null'); nonNullApp = admin.initializeApp({ - credential: admin.credential.cert(serviceAccount), + ...getCredential(), + projectId, databaseURL: databaseUrl, databaseAuthVariableOverride: { uid: generateRandomString(20), @@ -88,9 +109,14 @@ before(() => { storageBucket, }, 'nonNull'); + const noServiceAccountAppCreds = getCredential(); + if (noServiceAccountAppCreds.credential) { + noServiceAccountAppCreds.credential = new CertificatelessCredential( + noServiceAccountAppCreds.credential) + } noServiceAccountApp = admin.initializeApp({ - credential: new CertificatelessCredential(admin.credential.cert(serviceAccount)), - serviceAccountId: serviceAccount.client_email, + ...noServiceAccountAppCreds, + serviceAccountId, projectId, }, 'noServiceAccount'); diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index 26521b30cd..6991cc7617 100644 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -3193,14 +3193,22 @@ AUTH_CONFIGS.forEach((testConfig) => { describe('auth emulator support', () => { let mockAuth = testConfig.init(mocks.app()); + const userRecord = getValidUserRecord(getValidGetAccountInfoResponse()); + const validSince = new Date(userRecord.tokensValidAfterTime!); + + const stubs: sinon.SinonStub[] = []; + let clock: sinon.SinonFakeTimers; beforeEach(() => { - process.env.FIREBASE_AUTH_EMULATOR_HOST = 'localhost:9099'; + process.env.FIREBASE_AUTH_EMULATOR_HOST = '127.0.0.1:9099'; mockAuth = testConfig.init(mocks.app()); + clock = sinon.useFakeTimers(validSince.getTime()); }); afterEach(() => { + _.forEach(stubs, (s) => s.restore()); delete process.env.FIREBASE_AUTH_EMULATOR_HOST; + clock.restore(); }); it('createCustomToken() generates an unsigned token', async () => { @@ -3215,39 +3223,78 @@ AUTH_CONFIGS.forEach((testConfig) => { jwt.verify(token, '', { algorithms: ['none'] }); }); - it('verifyIdToken() rejects an unsigned token when only the env var is set', async () => { + it('verifyIdToken() should reject revoked ID tokens', () => { + const uid = userRecord.uid; + // One second before validSince. + const oneSecBeforeValidSince = Math.floor(validSince.getTime() / 1000 - 1); + const getUserStub = sinon.stub(testConfig.Auth.prototype, 'getUser') + .resolves(userRecord); + stubs.push(getUserStub); + const unsignedToken = mocks.generateIdToken({ - algorithm: 'none' + algorithm: 'none', + subject: uid, + }, { + iat: oneSecBeforeValidSince, + auth_time: oneSecBeforeValidSince, // eslint-disable-line @typescript-eslint/camelcase }); - await expect(mockAuth.verifyIdToken(unsignedToken)) - .to.be.rejectedWith('Firebase ID token has incorrect algorithm. Expected "RS256"'); + // verifyIdToken should force checking revocation in emulator mode, + // even if checkRevoked=false. + return mockAuth.verifyIdToken(unsignedToken, false) + .then(() => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm expected error returned. + expect(error).to.have.property('code', 'auth/id-token-revoked'); + // Confirm underlying API called with expected parameters. + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + }); }); - it('verifyIdToken() accepts an unsigned token when private method is called and env var is set', async () => { - (mockAuth as any).setJwtVerificationEnabled(false); + it('verifySessionCookie() should reject revoked session cookies', () => { + const uid = userRecord.uid; + // One second before validSince. + const oneSecBeforeValidSince = Math.floor(validSince.getTime() / 1000 - 1); + const getUserStub = sinon.stub(testConfig.Auth.prototype, 'getUser') + .resolves(userRecord); + stubs.push(getUserStub); + + const unsignedToken = mocks.generateIdToken({ + algorithm: 'none', + subject: uid, + issuer: 'https://session.firebase.google.com/' + mocks.projectId, + }, { + iat: oneSecBeforeValidSince, + auth_time: oneSecBeforeValidSince, // eslint-disable-line @typescript-eslint/camelcase + }); - let claims = {}; - if (testConfig.Auth === TenantAwareAuth) { - claims = { - firebase: { - tenant: TENANT_ID - } - } - } + // verifySessionCookie should force checking revocation in emulator + // mode, even if checkRevoked=false. + return mockAuth.verifySessionCookie(unsignedToken, false) + .then(() => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm expected error returned. + expect(error).to.have.property('code', 'auth/session-cookie-revoked'); + // Confirm underlying API called with expected parameters. + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + }); + }); + it('verifyIdToken() rejects an unsigned token if auth emulator is unreachable', async () => { const unsignedToken = mocks.generateIdToken({ algorithm: 'none' - }, claims); + }); - const decoded = await mockAuth.verifyIdToken(unsignedToken); - expect(decoded).to.be.ok; - }); + const errorMessage = 'Error while making request: connect ECONNREFUSED 127.0.0.1. Error code: ECONNREFUSED'; + const getUserStub = sinon.stub(testConfig.Auth.prototype, 'getUser').rejects(new Error(errorMessage)); + stubs.push(getUserStub); - 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.') + // Since revocation check is forced on in emulator mode, this will call + // the getUser method and get rejected (instead of succeed locally). + await expect(mockAuth.verifyIdToken(unsignedToken)) + .to.be.rejectedWith(errorMessage); }); }); }); diff --git a/test/unit/auth/token-verifier.spec.ts b/test/unit/auth/token-verifier.spec.ts index 6b370a8bb4..d863a0e849 100644 --- a/test/unit/auth/token-verifier.spec.ts +++ b/test/unit/auth/token-verifier.spec.ts @@ -106,7 +106,7 @@ function mockFailedFetchPublicKeys(): nock.Scope { } function createTokenVerifier( - app: FirebaseApp, + app: FirebaseApp, options: { algorithm?: Algorithm } = {} ): verifier.FirebaseTokenVerifier { const algorithm = options.algorithm || 'RS256'; @@ -544,16 +544,17 @@ describe('FirebaseTokenVerifier', () => { }); }); - it('should decode an unsigned token when the algorithm is set to none (emulator)', async () => { + it('should decode an unsigned token if isEmulator=true', async () => { clock = sinon.useFakeTimers(1000); - const emulatorVerifier = createTokenVerifier(app, { algorithm: 'none' }); + const emulatorVerifier = createTokenVerifier(app); const mockIdToken = mocks.generateIdToken({ algorithm: 'none', header: {} }); - const decoded = await emulatorVerifier.verifyJWT(mockIdToken); + const isEmulator = true; + const decoded = await emulatorVerifier.verifyJWT(mockIdToken, isEmulator); expect(decoded).to.deep.equal({ one: 'uno', two: 'dos',