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