invalidateSession previously ignored its argument, so a stateless JWT stayed valid until exp after logout (audit finding B5). createSession now embeds the minted session.id in the token as jti; invalidateSession records that jti in an in-memory denylist with expiry-based pruning, and validateSession rejects denylisted (and jti-less, failing closed) tokens. The denylist is per-process — the single-process limitation and the shared-store upgrade path are documented in session-denylist.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
213 lines
6.9 KiB
TypeScript
213 lines
6.9 KiB
TypeScript
import crypto from "node:crypto";
|
|
import { getPayload, type SanitizedConfig } from "payload";
|
|
import type { IAuthenticationService } from "../../application/services/authentication.service.interface";
|
|
import type { Cookie } from "../../entities/models/cookie";
|
|
import type { Session } from "../../entities/models/session";
|
|
import type { User } from "../../entities/models/user";
|
|
import { InMemorySessionDenylist } from "./session-denylist";
|
|
|
|
const SALT_LENGTH = 16;
|
|
const KEY_LENGTH = 64;
|
|
const ITERATIONS = 100_000;
|
|
const DIGEST = "sha512";
|
|
const SEPARATOR = ":";
|
|
|
|
const COOKIE_NAME = "payload-token";
|
|
const SESSION_DURATION_SECONDS = 7200; // 2 hours (matches Payload default)
|
|
|
|
export class AuthenticationService implements IAuthenticationService {
|
|
constructor(
|
|
private config: SanitizedConfig,
|
|
// Server-side revocation (audit finding B5). In-memory: revocations are
|
|
// per-process — see session-denylist.ts for the limitation write-up.
|
|
private denylist: InMemorySessionDenylist = new InMemorySessionDenylist(),
|
|
) {}
|
|
|
|
generateUserId(): string {
|
|
return crypto.randomUUID();
|
|
}
|
|
|
|
async hashPassword(password: string): Promise<string> {
|
|
const salt = crypto.randomBytes(SALT_LENGTH).toString("hex");
|
|
const hash = await new Promise<string>((resolve, reject) => {
|
|
crypto.pbkdf2(
|
|
password,
|
|
salt,
|
|
ITERATIONS,
|
|
KEY_LENGTH,
|
|
DIGEST,
|
|
(err, derivedKey) => {
|
|
if (err) reject(err);
|
|
else resolve(derivedKey.toString("hex"));
|
|
},
|
|
);
|
|
});
|
|
return `${salt}${SEPARATOR}${hash}`;
|
|
}
|
|
|
|
async verifyPassword(storedHash: string, password: string): Promise<boolean> {
|
|
const parts = storedHash.split(SEPARATOR);
|
|
if (parts.length !== 2) return false;
|
|
const salt = parts[0]!;
|
|
const expectedHash = parts[1]!;
|
|
const actualHash = await new Promise<string>((resolve, reject) => {
|
|
crypto.pbkdf2(
|
|
password,
|
|
salt,
|
|
ITERATIONS,
|
|
KEY_LENGTH,
|
|
DIGEST,
|
|
(err, derivedKey) => {
|
|
if (err) reject(err);
|
|
else resolve(derivedKey.toString("hex"));
|
|
},
|
|
);
|
|
});
|
|
return crypto.timingSafeEqual(
|
|
Buffer.from(expectedHash, "hex"),
|
|
Buffer.from(actualHash, "hex"),
|
|
);
|
|
}
|
|
|
|
async createSession(
|
|
user: User,
|
|
): Promise<{ session: Session; cookie: Cookie }> {
|
|
const payload = await getPayload({ config: this.config });
|
|
const expiresAt = new Date(Date.now() + SESSION_DURATION_SECONDS * 1000);
|
|
|
|
// The session id doubles as the JWT `jti` so the token can be revoked
|
|
// server-side via the denylist (audit finding B5).
|
|
const sessionId = crypto.randomUUID();
|
|
const token = this.signToken(user.id, sessionId, payload.secret);
|
|
|
|
const session: Session = {
|
|
id: sessionId,
|
|
userId: user.id,
|
|
expiresAt,
|
|
};
|
|
const cookie: Cookie = {
|
|
name: COOKIE_NAME,
|
|
value: token,
|
|
attributes: {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
path: "/",
|
|
sameSite: "lax",
|
|
maxAge: SESSION_DURATION_SECONDS,
|
|
},
|
|
};
|
|
|
|
return { session, cookie };
|
|
}
|
|
|
|
async validateSession(
|
|
token: string,
|
|
): Promise<{ user: User; session: Session }> {
|
|
const payload = await getPayload({ config: this.config });
|
|
const decoded = this.verifyToken(token, payload.secret);
|
|
if (!decoded) throw new Error("Invalid or expired session token");
|
|
// Server-side revocation check (audit finding B5): a signed, unexpired
|
|
// token is still rejected once its jti has been invalidated.
|
|
if (this.denylist.isRevoked(decoded.jti)) {
|
|
throw new Error("Session has been revoked");
|
|
}
|
|
|
|
const userDoc = await payload.findByID({
|
|
collection: "users" as const,
|
|
id: decoded.id,
|
|
overrideAccess: true,
|
|
});
|
|
|
|
const user: User = {
|
|
id: userDoc.id as string,
|
|
username: (userDoc as Record<string, unknown>).username as string,
|
|
passwordHash: (userDoc as Record<string, unknown>).passwordHash as string,
|
|
};
|
|
|
|
const session: Session = {
|
|
id: decoded.jti,
|
|
userId: user.id,
|
|
expiresAt: new Date(decoded.exp * 1000),
|
|
};
|
|
|
|
return { user, session };
|
|
}
|
|
|
|
async invalidateSession(sessionId: string): Promise<{ blankCookie: Cookie }> {
|
|
// `sessionId` is the JWT `jti` (the `session.id` returned by
|
|
// createSession/validateSession). Denylist it for the maximum token
|
|
// lifetime — beyond that, the token's own `exp` rejects it (B5).
|
|
this.denylist.revoke(sessionId, SESSION_DURATION_SECONDS);
|
|
return {
|
|
blankCookie: {
|
|
name: COOKIE_NAME,
|
|
value: "",
|
|
attributes: {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
path: "/",
|
|
sameSite: "lax",
|
|
maxAge: 0,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Sign a HS256 JWT using Payload's instance secret. No external dependency. */
|
|
private signToken(userId: string, jti: string, secret: string): string {
|
|
const header = Buffer.from(
|
|
JSON.stringify({ alg: "HS256", typ: "JWT" }),
|
|
).toString("base64url");
|
|
const exp = Math.floor(Date.now() / 1000) + SESSION_DURATION_SECONDS;
|
|
const body = Buffer.from(
|
|
JSON.stringify({ id: userId, collection: "users", exp, jti }),
|
|
).toString("base64url");
|
|
const signature = crypto
|
|
.createHmac("sha256", secret)
|
|
.update(`${header}.${body}`)
|
|
.digest("base64url");
|
|
return `${header}.${body}.${signature}`;
|
|
}
|
|
|
|
/** Verify and decode a HS256 JWT. Returns null on invalid/expired token. */
|
|
private verifyToken(
|
|
token: string,
|
|
secret: string,
|
|
): { id: string; exp: number; jti: string } | null {
|
|
const parts = token.split(".");
|
|
if (parts.length !== 3) return null;
|
|
const [header, body, signature] = parts as [string, string, string];
|
|
const expected = crypto
|
|
.createHmac("sha256", secret)
|
|
.update(`${header}.${body}`)
|
|
.digest();
|
|
const provided = Buffer.from(signature, "base64url");
|
|
// Constant-time comparison, mirroring verifyPassword (audit finding B4).
|
|
// timingSafeEqual requires equal-length buffers; a length mismatch is
|
|
// already an invalid signature, and the guard leaks nothing an attacker
|
|
// does not know (the expected HMAC-SHA256 length is public).
|
|
if (provided.length !== expected.length) return null;
|
|
if (!crypto.timingSafeEqual(provided, expected)) return null;
|
|
try {
|
|
const decoded = JSON.parse(Buffer.from(body, "base64url").toString()) as {
|
|
id?: unknown;
|
|
exp?: unknown;
|
|
jti?: unknown;
|
|
};
|
|
// Fail closed: tokens without a jti cannot be revoked, so they are
|
|
// not accepted (audit finding B5).
|
|
if (
|
|
typeof decoded.id !== "string" ||
|
|
typeof decoded.exp !== "number" ||
|
|
typeof decoded.jti !== "string"
|
|
) {
|
|
return null;
|
|
}
|
|
if (decoded.exp < Math.floor(Date.now() / 1000)) return null;
|
|
return { id: decoded.id, exp: decoded.exp, jti: decoded.jti };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|