Files
agentic-dev/packages/auth/src/infrastructure/services/authentication.service.ts
Danijel Martinek bf04ad70b2 chore: repair pre-existing lint failures blocking the lint gate
- 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>
2026-07-10 16:21:26 +02:00

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;
}
}
}