Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure an ID token is valid before attempting to get a refresh token #540

Merged
merged 4 commits into from
Aug 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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