Skip to content

Commit

Permalink
Ensure an ID token is valid before attempting to get a refresh token (#…
Browse files Browse the repository at this point in the history
…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
  • Loading branch information
kmjennison authored Aug 25, 2022
1 parent 1f4b6be commit 9c36b32
Show file tree
Hide file tree
Showing 4 changed files with 133 additions and 18 deletions.
45 changes: 45 additions & 0 deletions src/__tests__/firebaseAdmin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down
63 changes: 63 additions & 0 deletions src/__tests__/setAuthCookies.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
},
})
})
})
22 changes: 9 additions & 13 deletions src/firebaseAdmin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
21 changes: 16 additions & 5 deletions src/setAuthCookies.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.')
Expand All @@ -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.
Expand Down

0 comments on commit 9c36b32

Please sign in to comment.