- Use cases (sign-in, sign-up, sign-out) → factory functions with I*UseCase aliases - Controllers → factory functions with I*Controller aliases - DI symbols + module updated with .toDynamicValue() bindings for factories - New: real UsersRepository (Payload-backed, SanitizedConfig, contract-tested) - New: real AuthenticationService (node:crypto hashing/UUIDs; createSession/ validateSession/invalidateSession deferred — see refactor log §7) - bindProductionAuth swaps both mocks for real impls (was a no-op before) - Tests refactored to construct mocks and inject directly (no container rebinding) - Feature test constructs full chain via direct factory injection Refactor log: §2, §4.1, §4.2, §5.1, §5.2, §6.1, §7 Spec: §6.1, §7 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
94 lines
3.9 KiB
TypeScript
94 lines
3.9 KiB
TypeScript
import crypto from "node:crypto";
|
|
import 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";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Deferred methods
|
|
// ---------------------------------------------------------------------------
|
|
// `createSession`, `validateSession`, and `invalidateSession` require Payload's
|
|
// internal JWT-based auth session machinery, which does not map cleanly to a
|
|
// generic session interface without deep integration with Payload's REST/local
|
|
// API and cookie infrastructure.
|
|
//
|
|
// TODO(lazar-conformance §7): Implement these three methods once the session
|
|
// cookie strategy is settled. Until then they throw NotImplementedError to
|
|
// keep the production-shaped file in place without silently no-oping.
|
|
//
|
|
// The mock (`authentication.service.mock.ts`) handles all test paths.
|
|
|
|
class NotImplementedError extends Error {
|
|
constructor(method: string) {
|
|
super(`NotImplemented: AuthenticationService.${method} — see refactor log §7`);
|
|
this.name = "NotImplementedError";
|
|
}
|
|
}
|
|
|
|
const SALT_LENGTH = 16;
|
|
const KEY_LENGTH = 64;
|
|
const ITERATIONS = 100_000;
|
|
const DIGEST = "sha512";
|
|
const SEPARATOR = ":";
|
|
|
|
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"),
|
|
);
|
|
}
|
|
|
|
// TODO(lazar-conformance §7): Implement using Payload's local.login / JWT session issuance.
|
|
// Payload creates sessions via its REST auth endpoint; mapping that to a
|
|
// generic { session: Session; cookie: Cookie } shape requires understanding
|
|
// the JWT payload structure and the cookie name/attributes Payload uses.
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
async createSession(user: User): Promise<{ session: Session; cookie: Cookie }> {
|
|
throw new NotImplementedError("createSession");
|
|
}
|
|
|
|
// TODO(lazar-conformance §7): Implement using Payload's JWT verify mechanism.
|
|
// Need to call Payload's local API to verify the token and retrieve the user.
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
async validateSession(sessionId: string): Promise<{ user: User; session: Session }> {
|
|
throw new NotImplementedError("validateSession");
|
|
}
|
|
|
|
// TODO(lazar-conformance §7): Implement by clearing the session token.
|
|
// Payload does not have a server-side session store by default; invalidation
|
|
// is typically done client-side by clearing the cookie.
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
async invalidateSession(sessionId: string): Promise<{ blankCookie: Cookie }> {
|
|
throw new NotImplementedError("invalidateSession");
|
|
}
|
|
}
|