UNPKG

next-firebase-auth-edge

Version:

Next.js Firebase Authentication for Edge and server runtimes. Compatible with latest Next.js features.

204 lines (203 loc) 8.88 kB
import { NextResponse } from 'next/server'; import { AuthError, AuthErrorCode, InvalidTokenError, InvalidTokenReason, isInvalidTokenError } from '../auth/error.js'; import { getFirebaseAuth, handleExpiredToken } from '../auth/index.js'; import { debug, enableDebugMode } from '../debug/index.js'; import { AuthCookies } from './cookies/AuthCookies.js'; import { removeAuthCookies, setAuthCookies } from './cookies/index.js'; import { RequestCookiesProvider } from './cookies/parser/RequestCookiesProvider.js'; import { refreshToken } from './refresh-token.js'; import { getRequestCookiesTokens, validateOptions } from './tokens.js'; import { getReferer } from './utils.js'; import { getMetadataInternal } from './metadata.js'; import { mapJwtPayloadToDecodedIdToken } from '../auth/utils.js'; import { decodeJwt } from 'jose'; export function redirectToPath(request, path, options = { shouldClearSearchParams: false }) { const url = request.nextUrl.clone(); url.pathname = path; if (options.shouldClearSearchParams) { url.search = ''; } return NextResponse.redirect(url); } export function redirectToHome(request, options = { path: '/' }) { return redirectToPath(request, options.path, { shouldClearSearchParams: true }); } function doesRequestPathnameMatchPath(request, path) { if (typeof path === 'string') { return path === getUrlWithoutTrailingSlash(request.nextUrl.pathname); } return path.test(getUrlWithoutTrailingSlash(request.nextUrl.pathname)); } function doesRequestPathnameMatchOneOfPaths(request, paths) { return paths.some((path) => doesRequestPathnameMatchPath(request, path)); } function getUrlWithoutTrailingSlash(url) { if (url === '/') { return '/'; } return url.endsWith('/') ? url.slice(0, -1) : url; } function createLoginRedirectResponse(request, options) { const redirectKey = options.redirectParamKeyName || 'redirect'; const url = request.nextUrl.clone(); url.pathname = options.path; const encodedRedirect = encodeURIComponent(`${request.nextUrl.pathname}${url.search}`); url.search = `${redirectKey}=${encodedRedirect}`; return NextResponse.redirect(url); } export function redirectToLogin(request, options = { path: '/login', publicPaths: ['/login'] }) { if (options.publicPaths && doesRequestPathnameMatchOneOfPaths(request, options.publicPaths)) { return NextResponse.next(); } if (options.privatePaths && !doesRequestPathnameMatchOneOfPaths(request, options.privatePaths)) { return NextResponse.next(); } return createLoginRedirectResponse(request, options); } export async function createAuthMiddlewareResponse(request, options) { const url = getUrlWithoutTrailingSlash(request.nextUrl.pathname); if (url === getUrlWithoutTrailingSlash(options.loginPath)) { return setAuthCookies(request.headers, options); } if (url === getUrlWithoutTrailingSlash(options.logoutPath)) { return removeAuthCookies(request.headers, { cookieName: options.cookieName, cookieSerializeOptions: options.cookieSerializeOptions }); } if (options.refreshTokenPath && url === getUrlWithoutTrailingSlash(options.refreshTokenPath)) { return refreshToken(request, options); } return NextResponse.next(); } const defaultInvalidTokenHandler = async () => NextResponse.next(); const defaultValidTokenHandler = async (_tokens, headers) => NextResponse.next({ request: { headers } }); export async function authMiddleware(request, middlewareOptions) { const options = { enableTokenRefreshOnExpiredKidHeader: true, ...middlewareOptions }; if (options.debug) { enableDebugMode(); } validateOptions(options); const referer = getReferer(request.headers) ?? ''; const handleValidToken = options.handleValidToken ?? defaultValidTokenHandler; const handleError = options.handleError ?? defaultInvalidTokenHandler; const handleInvalidToken = options.handleInvalidToken ?? defaultInvalidTokenHandler; debug('Handle request', { path: getUrlWithoutTrailingSlash(request.nextUrl.pathname) }); const authMiddlewareResponseRoutes = [ options.loginPath, options.logoutPath, options.refreshTokenPath ] .filter(Boolean) .map((url) => getUrlWithoutTrailingSlash(url)); if (authMiddlewareResponseRoutes.includes(getUrlWithoutTrailingSlash(request.nextUrl.pathname))) { debug('Handle authentication API route'); return createAuthMiddlewareResponse(request, options); } const { verifyIdToken, handleTokenRefresh, createAnonymousUser } = getFirebaseAuth({ serviceAccount: options.serviceAccount, apiKey: options.apiKey, tenantId: options.tenantId }); try { debug('Attempt to fetch request cookies tokens'); const tokens = await getRequestCookiesTokens(request.cookies, options); return await handleExpiredToken(async () => { debug('Verifying user credentials...'); const decodedToken = await verifyIdToken(tokens.idToken, { checkRevoked: options.checkRevoked, referer }); debug('Credentials verified successfully'); const response = await handleValidToken({ token: tokens.idToken, decodedToken, customToken: tokens.customToken, metadata: tokens.metadata }, request.headers); debug('Successfully handled authenticated response'); return response; }, async () => { debug('Token has expired. Refreshing token...'); const { idToken, decodedIdToken, refreshToken, customToken } = await handleTokenRefresh(tokens.refreshToken, { referer, enableCustomToken: options.enableCustomToken }); debug('Token refreshed successfully. Updating response cookie headers...'); const metadata = await getMetadataInternal({ idToken, decodedIdToken, refreshToken, customToken }, options); const valueToSign = { idToken, refreshToken, customToken, metadata }; const cookies = new AuthCookies(RequestCookiesProvider.fromHeaders(request.headers), options); await cookies.setAuthCookies(valueToSign, request.cookies); const response = await handleValidToken({ token: idToken, decodedToken: decodedIdToken, customToken, metadata }, request.headers); debug('Successfully handled authenticated response'); await cookies.setAuthHeaders(valueToSign, response.headers); return response; }, async (e) => { if (e instanceof AuthError && e.code === AuthErrorCode.NO_MATCHING_KID) { throw InvalidTokenError.fromError(e, InvalidTokenReason.INVALID_KID); } debug('Authentication failed with error', { error: e }); return handleError(e); }, options.enableTokenRefreshOnExpiredKidHeader ?? false); } catch (error) { if (isInvalidTokenError(error)) { debug(`Token is missing or has incorrect formatting. This is expected and usually means that user has not yet logged in`, { reason: error.reason }); if (options.experimental_createAnonymousUserIfUserNotFound) { const { idToken, refreshToken } = await createAnonymousUser(options.apiKey); const decodedIdToken = mapJwtPayloadToDecodedIdToken(decodeJwt(idToken)); const metadata = await getMetadataInternal({ idToken, decodedIdToken, refreshToken }, options); const valueToSign = { idToken, refreshToken, metadata }; const cookies = new AuthCookies(RequestCookiesProvider.fromHeaders(request.headers), options); await cookies.setAuthCookies(valueToSign, request.cookies); const decodedToken = await verifyIdToken(idToken, { checkRevoked: options.checkRevoked, referer }); const response = await handleValidToken({ token: idToken, decodedToken, metadata }, request.headers); await cookies.setAuthHeaders(valueToSign, response.headers); return response; } return handleInvalidToken(error.reason); } throw error; } }