From 9c36b32b7cd4c1a67a68315cf306d5fd435b385f Mon Sep 17 00:00:00 2001 From: Kevin Jennison Date: Thu, 25 Aug 2022 16:15:07 -0400 Subject: [PATCH] Ensure an ID token is valid before attempting to get a refresh token (#540) * Throw when an ID token is invalid when trying to get a refresh token * Call onVerifyTokenError when an ID token is not valid and we cannot refresh it * Handle possible ID token error in setAuthCookies * Clean up error vs debug log message --- src/__tests__/firebaseAdmin.test.js | 45 ++++++++++++++++++++ src/__tests__/setAuthCookies.test.js | 63 ++++++++++++++++++++++++++++ src/firebaseAdmin.js | 22 ++++------ src/setAuthCookies.js | 21 +++++++--- 4 files changed, 133 insertions(+), 18 deletions(-) diff --git a/src/__tests__/firebaseAdmin.test.js b/src/__tests__/firebaseAdmin.test.js index e8399dc2..dd1e9b5e 100644 --- a/src/__tests__/firebaseAdmin.test.js +++ b/src/__tests__/firebaseAdmin.test.js @@ -142,6 +142,24 @@ describe('verifyIdToken', () => { expect(token).toEqual('a-new-token') }) + // https://github.com/gladly-team/next-firebase-auth/issues/531 + it('calls onVerifyTokenError when an auth/argument-error occurs and is not resolvable because there is no refresh token', async () => { + expect.assertions(1) + const { verifyIdToken } = require('src/firebaseAdmin') + const { onVerifyTokenError } = getConfig() + // Mock that the original token is expired but a new token works. + const expiredTokenErr = new Error( + 'Firebase ID token has "kid" claim which does not correspond to a known public key. Most likely the ID token is expired, so get a fresh token from your client app and try again.' + ) + expiredTokenErr.code = 'auth/argument-error' + const admin = getFirebaseAdminApp() + admin.auth().verifyIdToken.mockImplementation(async () => { + throw expiredTokenErr + }) + await verifyIdToken('some-token') + expect(onVerifyTokenError).toHaveBeenCalledWith(expiredTokenErr) + }) + it('calls the Google token refresh endpoint with the public Firebase API key as a query parameter value', async () => { expect.assertions(1) const { verifyIdToken } = require('src/firebaseAdmin') @@ -918,6 +936,33 @@ describe('getCustomIdAndRefreshTokens', () => { }) }) + it('throws if the ID token is not verifiable (there is no user ID)', async () => { + expect.assertions(1) + const { getCustomIdAndRefreshTokens } = require('src/firebaseAdmin') + + // Mock the behavior of getting a custom token. + global.fetch.mockReturnValue({ + ...createMockFetchResponse(), + json: () => + Promise.resolve({ + idToken: 'the-id-token', + refreshToken: 'the-refresh-token', + }), + }) + + // Mock that the ID token is invalid. + const expiredTokenErr = new Error('Mock error message.') + expiredTokenErr.code = 'auth/invalid-user-token' + const admin = getFirebaseAdminApp() + admin.auth().verifyIdToken.mockImplementation(() => expiredTokenErr) + + admin.auth().createCustomToken.mockResolvedValue('my-custom-token') + await expect(getCustomIdAndRefreshTokens('some-token')).rejects.toThrow( + 'Failed to verify the ID token.' + ) + }) + + // https://github.com/gladly-team/next-firebase-auth/issues/531 it('throws if fetching a refresh token fails', async () => { expect.assertions(1) const { getCustomIdAndRefreshTokens } = require('src/firebaseAdmin') diff --git a/src/__tests__/setAuthCookies.test.js b/src/__tests__/setAuthCookies.test.js index 0a27cba2..33932d94 100644 --- a/src/__tests__/setAuthCookies.test.js +++ b/src/__tests__/setAuthCookies.test.js @@ -255,6 +255,37 @@ describe('setAuthCookies', () => { }) }) + it('returns the expected values when getCustomIdAndRefreshTokens throws', async () => { + expect.assertions(1) + const setAuthCookies = require('src/setAuthCookies').default + getCustomIdAndRefreshTokens.mockRejectedValue( + new Error( + '[setAuthCookies] Failed to verify the ID token. Cannot authenticate the user or get a refresh token.' + ) + ) + await testApiHandler({ + handler: async (req, res) => { + const response = await setAuthCookies(req, res) + expect(JSON.stringify(response)).toEqual( + JSON.stringify({ + idToken: null, + refreshToken: null, + AuthUser: createAuthUser(), // unauthed user + }) + ) + return res.status(200).end() + }, + test: async ({ fetch }) => { + logDebug.mockClear() + await fetch({ + headers: { + authorization: 'some-token-here', + }, + }) + }, + }) + }) + it('logs expected debug logs when the user is authenticated', async () => { expect.assertions(3) const setAuthCookies = require('src/setAuthCookies').default @@ -312,4 +343,36 @@ describe('setAuthCookies', () => { }, }) }) + + it('logs expected debug logs when getCustomIdAndRefreshTokens throws', async () => { + expect.assertions(4) + const setAuthCookies = require('src/setAuthCookies').default + getCustomIdAndRefreshTokens.mockRejectedValue( + new Error('Failed to verify the ID token.') + ) + await testApiHandler({ + handler: async (req, res) => { + await setAuthCookies(req, res) + return res.status(200).end() + }, + test: async ({ fetch }) => { + logDebug.mockClear() + await fetch({ + headers: { + authorization: 'some-token-here', + }, + }) + expect(logDebug).toHaveBeenCalledWith( + '[setAuthCookies] Attempting to set auth cookies.' + ) + expect(logDebug).toHaveBeenCalledWith( + '[setAuthCookies] Failed to verify the ID token. Cannot authenticate the user or get a refresh token.' + ) + expect(logDebug).toHaveBeenCalledWith( + '[setAuthCookies] Set auth cookies. The user is not authenticated.' + ) + expect(logDebug).toHaveBeenCalledTimes(3) + }, + }) + }) }) diff --git a/src/firebaseAdmin.js b/src/firebaseAdmin.js index 5ca63c40..6e7cd431 100644 --- a/src/firebaseAdmin.js +++ b/src/firebaseAdmin.js @@ -112,21 +112,14 @@ export const verifyIdToken = async (token, refreshToken = null) => { '[verifyIdToken] Failed to refresh the ID token. The user will be unauthenticated.' ) } - } else { - // TODO: call `onVerifyTokenError` here. Possibly just continue - // on to default case rather than breaking. - // https://github.com/gladly-team/next-firebase-auth/issues/531 - - // Return an unauthenticated user. - newToken = null - firebaseUser = null - logDebug(errorMessageVerifyFailed(e.code)) + break } - break - // Errors we consider unexpected. + // Fall through here if there is no refresh token. Without a refresh + // token, an expired ID token is not resolvable. + // eslint-disable-next-line no-fallthrough default: - // Return an unauthenticated user for any other error. + // Here, any errors are unexpected. Return an unauthenticated user. // Rationale: it's not particularly easy for developers to // catch errors in `withAuthUserSSR`, so default to returning // an unauthed user and give the developer control over @@ -168,8 +161,11 @@ export const getCustomIdAndRefreshTokens = async (token) => { const AuthUser = await verifyIdToken(token) const admin = getFirebaseAdminApp() - // FIXME: ensure a user is authenticated before proceeding. Issue: + // Ensure a user is authenticated before proceeding: // https://github.com/gladly-team/next-firebase-auth/issues/531 + if (!AuthUser.id) { + throw new Error('Failed to verify the ID token.') + } // Prefixing with "[setAuthCookies]" because that's currently the only // use case for using getCustomIdAndRefreshTokens. diff --git a/src/setAuthCookies.js b/src/setAuthCookies.js index fdfb96b3..bd48f039 100644 --- a/src/setAuthCookies.js +++ b/src/setAuthCookies.js @@ -6,6 +6,7 @@ import { } from 'src/authCookies' import { getConfig } from 'src/config' import logDebug from 'src/logDebug' +import createAuthUser from 'src/createAuthUser' const setAuthCookies = async (req, res, { token: userProvidedToken } = {}) => { logDebug('[setAuthCookies] Attempting to set auth cookies.') @@ -19,11 +20,21 @@ const setAuthCookies = async (req, res, { token: userProvidedToken } = {}) => { ) } - // Get a custom ID token and refresh token, given a valid - // Firebase ID token. - const { idToken, refreshToken, AuthUser } = await getCustomIdAndRefreshTokens( - token - ) + // Get a custom ID token and refresh token, given a valid Firebase ID + // token. If the token isn't valid, set cookies for an unauthenticated + // user. + let idToken = null + let refreshToken = null + let AuthUser = createAuthUser() // default to an unauthed user + try { + ;({ idToken, refreshToken, AuthUser } = await getCustomIdAndRefreshTokens( + token + )) + } catch (e) { + logDebug( + '[setAuthCookies] Failed to verify the ID token. Cannot authenticate the user or get a refresh token.' + ) + } // Pick a subset of the config.cookies options to // pass to setCookie.