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.

308 lines (266 loc) 8.15 kB
import { NextRequest } from "next/server"; import { cookies } from "next/headers"; import { AuthConfig, UserIdentity, AuthSession, PublicUserIdentity, TokenPair, } from "../common/types"; import { issueAccessToken, issueRefreshToken, verifyRefreshToken, } from "../common/tokens"; import { getAccessCookie, getRefreshCookie, getCsrfCookie, } from "../common/cookies"; import { InvalidCredentialsError, IdentityForbiddenError, RateLimitError, AuthError, } from "../common/errors"; export type EffectiveAuthConfig<T extends UserIdentity> = Omit< Required<AuthConfig<T>>, "jwt" > & { jwt: Required<NonNullable<AuthConfig<T>["jwt"]>>; }; function log<T extends UserIdentity>( config: EffectiveAuthConfig<T>, level: "info" | "warn" | "error", message: string, ...args: any[] ) { if (config.debug) { config.logger(level, message, ...args); } } export type SessionFailureReason = | "NO_REFRESH_TOKEN" | "INVALID_REFRESH_TOKEN" | "JTI_REUSE_DETECTED" | "ACCOUNT_FORBIDDEN" | "ACCOUNT_NOT_FOUND" | "VERSION_MISMATCH"; export async function validateAndRefreshSession<T extends UserIdentity>( config: EffectiveAuthConfig<T>, req: NextRequest ): Promise<{ session: AuthSession<T>; newTokens?: Partial<TokenPair> | null; failureReason?: SessionFailureReason; }> { log(config, "info", "Middleware: Validating session and handling refresh."); const refreshTokenValue = req.cookies.get(config.cookies.refresh.name)?.value; if (!refreshTokenValue) { return { session: null, failureReason: "NO_REFRESH_TOKEN" }; } const verifiedRefresh = await verifyRefreshToken( refreshTokenValue, config.secrets.refreshTokenSecret, { alg: config.jwt.alg } ); if (!verifiedRefresh) { return { session: null, newTokens: null, failureReason: "INVALID_REFRESH_TOKEN", }; } const { identifier, version, jti, iat } = verifiedRefresh.payload; const nowInSeconds = Math.floor(Date.now() / 1000); const tokenAge = iat !== undefined ? nowInSeconds - iat : Number.POSITIVE_INFINITY; if (await config.dal.isTokenJtiUsed(jti)) { await config.dal.invalidateAllSessionsForIdentity(identifier); log( config, "warn", `SECURITY ALERT: Reused JTI detected for ${identifier}. All sessions invalidated.` ); return { session: null, newTokens: null, failureReason: "JTI_REUSE_DETECTED", }; } const identity = await config.dal.fetchIdentityForSession(identifier); if (!identity) return { session: null, newTokens: null, failureReason: "ACCOUNT_NOT_FOUND", }; if (identity.isForbidden) return { session: null, newTokens: null, failureReason: "ACCOUNT_FORBIDDEN", }; if (identity.version !== version) return { session: null, newTokens: null, failureReason: "VERSION_MISMATCH", }; const shouldRotateRefresh = config.refreshTokenRotationIntervalSeconds > 0 && tokenAge >= config.refreshTokenRotationIntervalSeconds; const newAccessToken = await issueAccessToken( identity, config.secrets.accessTokenSecret, config.cookies.access.maxAge, config.jwt ); const newTokens: Partial<TokenPair> = { accessToken: newAccessToken }; if (shouldRotateRefresh) { log(config, "info", `Rotating refresh token for ${identifier}.`); const reuseExpiration = config.cookies.refresh.maxAge + 60; await config.dal.markTokenJtiAsUsed(jti, reuseExpiration); newTokens.refreshToken = await issueRefreshToken( identity, config.secrets.refreshTokenSecret, config.cookies.refresh.maxAge, config.jwt ); } const { version: _, isForbidden: __, ...publicIdentity } = identity; return { session: { identity: publicIdentity as PublicUserIdentity<T> }, newTokens, }; } export async function validateSessionReadOnly<T extends UserIdentity>( config: EffectiveAuthConfig<T> ): Promise<{ session: AuthSession<T>; failureReason?: SessionFailureReason }> { log( config, "info", "Server Component: Performing read-only session validation." ); const cookieStore = await cookies(); const refreshTokenValue = cookieStore.get(config.cookies.refresh.name)?.value; if (!refreshTokenValue) { return { session: null, failureReason: "NO_REFRESH_TOKEN" }; } const verifiedRefresh = await verifyRefreshToken( refreshTokenValue, config.secrets.refreshTokenSecret, { alg: config.jwt.alg } ); if (!verifiedRefresh) { return { session: null, failureReason: "INVALID_REFRESH_TOKEN" }; } const { identifier, version } = verifiedRefresh.payload; const identity = await config.dal.fetchIdentityForSession(identifier); if (!identity) return { session: null, failureReason: "ACCOUNT_NOT_FOUND" }; if (identity.isForbidden) return { session: null, failureReason: "ACCOUNT_FORBIDDEN" }; if (identity.version !== version) return { session: null, failureReason: "VERSION_MISMATCH" }; const { version: _, isForbidden: __, ...publicIdentity } = identity; return { session: { identity: publicIdentity as PublicUserIdentity<T> } }; } export async function signIn<T extends UserIdentity>( signInIdentifier: string, secret: string, config: EffectiveAuthConfig<T>, mfaCode?: string, provider?: string, authCode?: string ): Promise<PublicUserIdentity<T>> { if (await config.rateLimit(signInIdentifier)) { throw new RateLimitError(config.errorMessages.RateLimitError); } let finalIdentifier = signInIdentifier; let finalSecret = secret; if (provider && config.providers[provider]) { const creds = await config.providers[provider](authCode || ""); finalIdentifier = creds.signInIdentifier; finalSecret = creds.secret || ""; } const identity = await config.dal.fetchIdentityByCredentials( finalIdentifier, finalSecret ); if (!identity) throw new InvalidCredentialsError( config.errorMessages.InvalidCredentialsError ); if (identity.isForbidden) throw new IdentityForbiddenError( config.errorMessages.IdentityForbiddenError ); if (identity.hasMFA) { if (!mfaCode) throw new AuthError("MFA code required."); if ( !config.dal.verifyMFA || !(await config.dal.verifyMFA(identity.identifier, mfaCode)) ) { throw new AuthError("Invalid MFA code."); } } const accessToken = await issueAccessToken( identity, config.secrets.accessTokenSecret, config.cookies.access.maxAge, config.jwt ); const refreshToken = await issueRefreshToken( identity, config.secrets.refreshTokenSecret, config.cookies.refresh.maxAge, config.jwt ); const cookieStore = await cookies(); cookieStore.set( getAccessCookie( accessToken, config.cookies.access.name, config.cookies.access.maxAge ) ); cookieStore.set( getRefreshCookie( refreshToken, config.cookies.refresh.name, config.cookies.refresh.maxAge ) ); if (config.csrfEnabled) { cookieStore.set(getCsrfCookie(config.cookies.access.maxAge)); } log(config, "info", `Successful sign-in for ${identity.identifier}`); const { version, isForbidden, ...publicIdentity } = identity; return publicIdentity as PublicUserIdentity<T>; } export async function signOut<T extends UserIdentity>( config: EffectiveAuthConfig<T> ): Promise<void> { const cookieStore = await cookies(); const refreshTokenValue = cookieStore.get(config.cookies.refresh.name)?.value; cookieStore.delete(config.cookies.access.name); cookieStore.delete(config.cookies.refresh.name); if (config.csrfEnabled) { cookieStore.delete("csrf_token"); } if (!refreshTokenValue) return; const verified = await verifyRefreshToken( refreshTokenValue, config.secrets.refreshTokenSecret, { alg: config.jwt.alg } ); if (!verified) return; await config.dal.invalidateAllSessionsForIdentity( verified.payload.identifier ); log( config, "info", `Successful sign-out and session invalidation for ${verified.payload.identifier}` ); }