diff --git a/src/__tests__/firebaseAdmin.test.js b/src/__tests__/firebaseAdmin.test.js index 2592ab37..c5c194a6 100644 --- a/src/__tests__/firebaseAdmin.test.js +++ b/src/__tests__/firebaseAdmin.test.js @@ -65,7 +65,7 @@ describe('verifyIdToken', () => { expect(token).toEqual('some-token') }) - it('returns an AuthUser with a new token when the token is refreshed', async () => { + it('returns an AuthUser with a new token when the token is refreshed because of a Firebase auth/id-token-expired error', async () => { const { verifyIdToken } = require('src/firebaseAdmin') // Mock the behavior of refreshing the token. @@ -99,6 +99,40 @@ describe('verifyIdToken', () => { expect(token).toEqual('a-new-token') }) + it('returns an AuthUser with a new token when the token is refreshed because of a Firebase auth/argument-error error', async () => { + const { verifyIdToken } = require('src/firebaseAdmin') + + // Mock the behavior of refreshing the token. + global.fetch.mockImplementation(async (endpoint) => { + if (endpoint.indexOf(googleRefreshTokenEndpoint) === 0) { + return { + ...createMockFetchResponse(), + json: () => Promise.resolve({ id_token: 'a-new-token' }), + } + } + // Incorrect endpoint. Return a 500. + return { ...createMockFetchResponse(), ok: false, status: 500 } + }) + + // 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 mockFirebaseUser = createMockFirebaseUserAdminSDK() + const admin = getFirebaseAdminApp() + admin.auth().verifyIdToken.mockImplementation(async (token) => { + if (token === 'some-token') { + throw expiredTokenErr + } else { + return mockFirebaseUser + } + }) + const AuthUser = await verifyIdToken('some-token', 'my-refresh-token') + const token = await AuthUser.getIdToken() + expect(token).toEqual('a-new-token') + }) + it('calls the Google token refresh endpoint with the public Firebase API key as a query parameter value', async () => { const { verifyIdToken } = require('src/firebaseAdmin') @@ -160,6 +194,86 @@ describe('verifyIdToken', () => { }) }) + it('returns an unauthenticated AuthUser if verifying the token fails with auth/invalid-user-token', async () => { + const { verifyIdToken } = require('src/firebaseAdmin') + + const expiredTokenErr = new Error('Mock error message.') + expiredTokenErr.code = 'auth/invalid-user-token' + const mockFirebaseUser = createMockFirebaseUserAdminSDK() + const admin = getFirebaseAdminApp() + admin.auth().verifyIdToken.mockImplementation(async (token) => { + if (token === 'some-token') { + throw expiredTokenErr + } else { + return mockFirebaseUser + } + }) + const AuthUser = await verifyIdToken('some-token', 'my-refresh-token') + expect(AuthUser.id).toEqual(null) + const token = await AuthUser.getIdToken() + expect(token).toEqual(null) + }) + + it('returns an unauthenticated AuthUser if verifying the token fails with auth/user-token-expired', async () => { + const { verifyIdToken } = require('src/firebaseAdmin') + + const expiredTokenErr = new Error('Mock error message.') + expiredTokenErr.code = 'auth/user-token-expired' + const mockFirebaseUser = createMockFirebaseUserAdminSDK() + const admin = getFirebaseAdminApp() + admin.auth().verifyIdToken.mockImplementation(async (token) => { + if (token === 'some-token') { + throw expiredTokenErr + } else { + return mockFirebaseUser + } + }) + const AuthUser = await verifyIdToken('some-token', 'my-refresh-token') + expect(AuthUser.id).toEqual(null) + const token = await AuthUser.getIdToken() + expect(token).toEqual(null) + }) + + it('returns an unauthenticated AuthUser if verifying the token fails with auth/user-disabled', async () => { + const { verifyIdToken } = require('src/firebaseAdmin') + + const expiredTokenErr = new Error('Mock error message.') + expiredTokenErr.code = 'auth/user-disabled' + const mockFirebaseUser = createMockFirebaseUserAdminSDK() + const admin = getFirebaseAdminApp() + admin.auth().verifyIdToken.mockImplementation(async (token) => { + if (token === 'some-token') { + throw expiredTokenErr + } else { + return mockFirebaseUser + } + }) + const AuthUser = await verifyIdToken('some-token', 'my-refresh-token') + expect(AuthUser.id).toEqual(null) + const token = await AuthUser.getIdToken() + expect(token).toEqual(null) + }) + + it('returns an unauthenticated AuthUser if there is no refresh token and the id token has expired', async () => { + const { verifyIdToken } = require('src/firebaseAdmin') + + const expiredTokenErr = new Error('Mock error message.') + expiredTokenErr.code = 'auth/id-token-expired' + const mockFirebaseUser = createMockFirebaseUserAdminSDK() + const admin = getFirebaseAdminApp() + admin.auth().verifyIdToken.mockImplementation(async (token) => { + if (token === 'some-token') { + throw expiredTokenErr + } else { + return mockFirebaseUser + } + }) + const AuthUser = await verifyIdToken('some-token') + expect(AuthUser.id).toEqual(null) + const token = await AuthUser.getIdToken() + expect(token).toEqual(null) + }) + it('throws if there is an error refreshing the token', async () => { const { verifyIdToken } = require('src/firebaseAdmin') global.fetch.mockImplementation(async () => ({ diff --git a/src/firebaseAdmin.js b/src/firebaseAdmin.js index b2cce216..c9f49beb 100644 --- a/src/firebaseAdmin.js +++ b/src/firebaseAdmin.js @@ -2,9 +2,6 @@ import getFirebaseAdminApp from 'src/initFirebaseAdminSDK' import createAuthUser from 'src/createAuthUser' import { getConfig } from 'src/config' -// https://firebase.google.com/docs/auth/admin/errors -const FIREBASE_ERROR_TOKEN_EXPIRED = 'auth/id-token-expired' - // If the FIREBASE_AUTH_EMULATOR_HOST variable is set, send the token request to the emulator const getTokenPrefix = () => process.env.FIREBASE_AUTH_EMULATOR_HOST @@ -59,13 +56,37 @@ export const verifyIdToken = async (token, refreshToken = null) => { try { firebaseUser = await admin.auth().verifyIdToken(token) } catch (e) { - // If the user's ID token has expired, refresh it if possible. - if (refreshToken && e.code === FIREBASE_ERROR_TOKEN_EXPIRED) { - newToken = await refreshExpiredIdToken(refreshToken) - firebaseUser = await admin.auth().verifyIdToken(newToken) - } else { - // Otherwise, throw. - throw e + // https://firebase.google.com/docs/auth/admin/errors + switch (e.code) { + case 'auth/id-token-expired': + case 'auth/argument-error': + // If the user's ID token has expired, refresh it if possible. + if (refreshToken) { + newToken = await refreshExpiredIdToken(refreshToken) + firebaseUser = await admin.auth().verifyIdToken(newToken) + } else { + // Return an unauthenticated user + newToken = null + firebaseUser = null + } + break + case 'auth/invalid-user-token': + case 'auth/user-token-expired': + case 'auth/user-disabled': + // Return an unauthenticated user + newToken = null + firebaseUser = null + break + default: + // TODO: return an unauthenticated user for any error, then + // call an optional onAuthError callback provided by user + // for the unexpected errors (ones we don't handle above). + // Rationale: it's not particularly easy for developers to + // catch errors in `withAuthUserSSR`, and in most cases an + // unauthed user + optional error log is preferable to a 500 + // error. + // Otherwise, throw. + throw e } } const AuthUser = createAuthUser({