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
JavaScript
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;
}
}