UNPKG

@waelhabbaldev/next-jwt-auth

Version:

A secure, lightweight JWT authentication solution for Next.js, providing access and refresh token handling, middleware support, and easy React integration.

305 lines (275 loc) 9 kB
import { cache } from "react"; import { cookies, headers } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; import { AuthConfig, UserIdentity, AuthSession, PublicUserIdentity, ProtectionOptions, ActionProtectionOptions, } from "../common/types"; import { validateAndSanitizeBaseUrl } from "../common/utils"; import { validateAndRefreshSession, validateSessionReadOnly, signIn as internalSignIn, signOut as internalSignOut, EffectiveAuthConfig, SessionFailureReason, } from "./authentication"; import { protectPage, protectAction, protectApi } from "./protection"; import { getAccessCookie, getClearAccessCookie, getRefreshCookie, getClearRefreshCookie, getCsrfCookie, } from "../common/cookies"; import { AUTH_HEADER_KEY } from "../common/constants"; import { CsrfError } from "../common/errors"; export * from "../common/types"; export * from "../common/errors"; export { verifyAccessToken } from "../common/tokens"; /** * Validates the user-provided configuration. * @internal */ function validateConfig<T extends UserIdentity>( config: AuthConfig<T> ): AuthConfig<T> { const alg = config.jwt?.alg ?? "HS256"; const validateSecret = ( secret: import("../common/types").JWTSecret, secretName: string ) => { const checkKey = (key: string, requiredType: "string" | "object") => { if (typeof key !== "string") { if (requiredType === "object") { throw new Error( `Auth configuration error: For ${alg}, keys in '${secretName}' should be PEM-encoded strings.` ); } } if (requiredType === "string" && key.length < 32) { throw new Error( `Auth configuration error: For HS256, all string keys in '${secretName}' must be at least 32 characters long.` ); } }; const requiredType = alg === "HS256" ? "string" : "object"; if (typeof secret === "string") { checkKey(secret, requiredType); } else if (Array.isArray(secret)) { if (secret.length === 0) throw new Error( `Auth configuration error: '${secretName}' array cannot be empty for key rotation.` ); secret.forEach((s) => checkKey(s.key, requiredType)); } else if (typeof secret === "object" && secret !== null) { checkKey(secret.key, requiredType); } else { throw new Error( `Auth configuration error: Invalid type for '${secretName}'.` ); } }; validateSecret(config.secrets.accessTokenSecret, "accessTokenSecret"); validateSecret(config.secrets.refreshTokenSecret, "refreshTokenSecret"); if ( !config.cookies.access.maxAge || config.cookies.access.maxAge <= 0 || !config.cookies.refresh.maxAge || config.cookies.refresh.maxAge <= 0 ) throw new Error( "Auth configuration error: Cookie maxAge must be a positive number of seconds." ); if (!config.redirects.unauthenticated) throw new Error( "Auth configuration error: `redirects.unauthenticated` path is required." ); if (!config.redirects.unauthorized) throw new Error( "Auth configuration error: `redirects.unauthorized` path is required." ); if (!config.redirects.forbidden) throw new Error( "Auth configuration error: `redirects.forbidden` path is required." ); return { ...config, baseUrl: validateAndSanitizeBaseUrl(config.baseUrl), }; } /** * Creates and configures the authentication instance for the Next.js App Router. */ export function createAuth<T extends UserIdentity>(config: AuthConfig<T>) { const validatedConfig = validateConfig(config); const getEffectiveConfig = (): EffectiveAuthConfig<T> => ({ ...validatedConfig, debug: validatedConfig.debug ?? false, refreshTokenRotationIntervalSeconds: validatedConfig.refreshTokenRotationIntervalSeconds ?? 0, jwt: { alg: validatedConfig.jwt?.alg ?? "HS256", issuer: validatedConfig.jwt?.issuer ?? "", audience: validatedConfig.jwt?.audience ?? "", }, rateLimit: validatedConfig.rateLimit ?? (async () => false), logger: validatedConfig.logger ?? ((level, message, ...args) => console[level](`[next-jwt-auth] ${message}`, ...args)), errorMessages: validatedConfig.errorMessages ?? {}, providers: validatedConfig.providers ?? {}, csrfEnabled: validatedConfig.csrfEnabled ?? false, }); const getSessionWithFailureReason = cache( async (): Promise<{ session: AuthSession<T>; failureReason?: SessionFailureReason; }> => { const identityHeader = (await headers()).get(AUTH_HEADER_KEY); if (identityHeader) { try { const identity = JSON.parse(identityHeader) as PublicUserIdentity<T>; return { session: { identity } }; } catch {} } return await validateSessionReadOnly(getEffectiveConfig()); } ); const getSession = async (): Promise<AuthSession<T>> => { return (await getSessionWithFailureReason()).session; }; const createMiddleware = (matcher?: (req: NextRequest) => boolean) => { return async (req: NextRequest) => { if (matcher && !matcher(req)) return NextResponse.next(); const effectiveConfig = getEffectiveConfig(); const { session, newTokens } = await validateAndRefreshSession( effectiveConfig, req ); const requestHeaders = new Headers(req.headers); requestHeaders.set("x-next-pathname", req.nextUrl.pathname); if (session) { requestHeaders.set(AUTH_HEADER_KEY, JSON.stringify(session.identity)); } const response = NextResponse.next({ request: { headers: requestHeaders }, }); if (newTokens === null) { response.cookies.set( getClearAccessCookie(effectiveConfig.cookies.access.name) ); response.cookies.set( getClearRefreshCookie(effectiveConfig.cookies.refresh.name) ); } else if (newTokens?.accessToken) { response.cookies.set( getAccessCookie( newTokens.accessToken, effectiveConfig.cookies.access.name, effectiveConfig.cookies.access.maxAge ) ); if (newTokens.refreshToken) { response.cookies.set( getRefreshCookie( newTokens.refreshToken, effectiveConfig.cookies.refresh.name, effectiveConfig.cookies.refresh.maxAge ) ); } } if ( session && effectiveConfig.csrfEnabled && !req.cookies.has("csrf_token") ) { response.cookies.set( getCsrfCookie(effectiveConfig.cookies.access.maxAge) ); } return response; }; }; const getCsrfToken = async (): Promise<string> => { const cookieStore = await cookies(); let token = cookieStore.get("csrf_token")?.value; if (!token) { const effectiveConfig = getEffectiveConfig(); const newCookie = getCsrfCookie(effectiveConfig.cookies.access.maxAge); token = newCookie.value; cookieStore.set(newCookie); } return token; }; const signIn = async ( signInIdentifier: string, secret: string, mfaCode?: string, provider?: string, authCode?: string ): Promise<PublicUserIdentity<T>> => { return internalSignIn( signInIdentifier, secret, getEffectiveConfig(), mfaCode, provider, authCode ); }; const signOut = async (): Promise<void> => { return internalSignOut(getEffectiveConfig()); }; const protectPageWrapper = async <C>( options?: ProtectionOptions<T, C> ): Promise<NonNullable<AuthSession<T>>> => { return protectPage( getSessionWithFailureReason, getEffectiveConfig(), await headers(), options ); }; const protectActionWrapper = async <C>( options?: ActionProtectionOptions<T, C>, formData?: FormData ): Promise<NonNullable<AuthSession<T>>> => { const effectiveConfig = getEffectiveConfig(); if (effectiveConfig.csrfEnabled) { const cookieCsrf = (await cookies()).get("csrf_token")?.value; const formCsrf = formData?.get("csrf_token")?.toString(); if (!formCsrf || !cookieCsrf || formCsrf !== cookieCsrf) { throw new CsrfError(effectiveConfig.errorMessages.CsrfError); } } return protectAction(getSessionWithFailureReason, effectiveConfig, options); }; const protectApiWrapper = async <C>( options?: ProtectionOptions<T, C> ): Promise< { session: NonNullable<AuthSession<T>> } | { response: NextResponse } > => { return protectApi( getSessionWithFailureReason, getEffectiveConfig(), options ); }; return { getSession, createMiddleware, signIn, signOut, getCsrfToken, protectPage: protectPageWrapper, protectAction: protectActionWrapper, protectApi: protectApiWrapper, }; }