@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
text/typescript
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,
};
}