- turbo/generators/config.ts used require() inside the reader generator
action (no-require-imports); use the top-level node:fs imports
- auth authentication.service.ts used a literal self-assertion
("users" as "users"); prefer-as-const
- apps/cms lacked web-next's next-env.d.ts triple-slash-reference
override, and the committed next-env.d.ts now references
.next/types/routes.d.ts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
181 lines
5.2 KiB
TypeScript
181 lines
5.2 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";
|
|
|
|
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) {}
|
|
|
|
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);
|
|
|
|
const token = this.signToken(user.id, payload.secret);
|
|
|
|
const session: Session = {
|
|
id: crypto.randomUUID(),
|
|
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");
|
|
|
|
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: token,
|
|
userId: user.id,
|
|
expiresAt: new Date(decoded.exp * 1000),
|
|
};
|
|
|
|
return { user, session };
|
|
}
|
|
|
|
async invalidateSession(
|
|
_sessionId: string,
|
|
): Promise<{ blankCookie: Cookie }> {
|
|
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, 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 }),
|
|
).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 } | 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("base64url");
|
|
if (signature !== expected) return null;
|
|
try {
|
|
const decoded = JSON.parse(Buffer.from(body, "base64url").toString()) as {
|
|
id: string;
|
|
exp: number;
|
|
};
|
|
if (decoded.exp < Math.floor(Date.now() / 1000)) return null;
|
|
return decoded;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|