Skip to content

Commit

Permalink
Handle additional token errors in verifyIdToken (#361)
Browse files Browse the repository at this point in the history
* fix: check for 'auth/argument-error' when verifying token

* feat: upgrade firebase and firebase-admin

* feat(#174): handle additional errors from `verifyIdToken`

* test(#174): add tests coverage for new errors in `verifyIdToken`

* feat: upgrade dependencies that have non breaking changes

* feat: implement pr feedback

* chore: upgrade dependencies

* Rebuild lockfile

* Include error if empty refreshToken

* Add TODO

Co-authored-by: Zino Hofmann <zino@hofmann.amsterdam>
  • Loading branch information
kmjennison and HofmannZ committed Dec 13, 2021
1 parent 0d31469 commit dda649c
Show file tree
Hide file tree
Showing 2 changed files with 146 additions and 11 deletions.
116 changes: 115 additions & 1 deletion src/__tests__/firebaseAdmin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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')

Expand Down Expand Up @@ -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 () => ({
Expand Down
41 changes: 31 additions & 10 deletions src/firebaseAdmin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down

0 comments on commit dda649c

Please # to comment.