diff --git a/packages/auth/src/infrastructure/services/authentication.service.test.ts b/packages/auth/src/infrastructure/services/authentication.service.test.ts index bf46569..3007fc6 100644 --- a/packages/auth/src/infrastructure/services/authentication.service.test.ts +++ b/packages/auth/src/infrastructure/services/authentication.service.test.ts @@ -34,28 +34,27 @@ describe("AuthenticationService", () => { }); it("returns false for malformed stored hash", async () => { - const valid = await service.verifyPassword("not-a-valid-hash", "anything"); + const valid = await service.verifyPassword( + "not-a-valid-hash", + "anything", + ); expect(valid).toBe(false); }); }); - describe("deferred methods (NotImplementedError)", () => { - const user = { - id: "test-id", - username: "testuser", - passwordHash: "hashed_password", - }; + describe("session methods (require Payload)", () => { + // createSession and validateSession call getPayload() internally, + // so they require a running Payload instance. These are exercised + // by the mock service in use-case tests and by integration tests. + // Here we only test invalidateSession (no Payload dependency). - it("createSession throws NotImplementedError", async () => { - await expect(service.createSession(user)).rejects.toThrow("NotImplemented"); - }); - - it("validateSession throws NotImplementedError", async () => { - await expect(service.validateSession("some-session")).rejects.toThrow("NotImplemented"); - }); - - it("invalidateSession throws NotImplementedError", async () => { - await expect(service.invalidateSession("some-session")).rejects.toThrow("NotImplemented"); + it("invalidateSession returns a blank cookie with maxAge 0", async () => { + const { blankCookie } = await service.invalidateSession("any-token"); + expect(blankCookie.name).toBe("payload-token"); + expect(blankCookie.value).toBe(""); + expect(blankCookie.attributes.maxAge).toBe(0); + expect(blankCookie.attributes.httpOnly).toBe(true); + expect(blankCookie.attributes.path).toBe("/"); }); }); }); diff --git a/packages/auth/src/infrastructure/services/authentication.service.ts b/packages/auth/src/infrastructure/services/authentication.service.ts index 1bf6c32..871c6d3 100644 --- a/packages/auth/src/infrastructure/services/authentication.service.ts +++ b/packages/auth/src/infrastructure/services/authentication.service.ts @@ -1,39 +1,21 @@ import crypto from "node:crypto"; -import type { SanitizedConfig } from "payload"; +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"; -// --------------------------------------------------------------------------- -// 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: 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}`); - this.name = "NotImplementedError"; - } -} - 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) {} + constructor(private config: SanitizedConfig) {} generateUserId(): string { return crypto.randomUUID(); @@ -81,30 +63,118 @@ export class AuthenticationService implements IAuthenticationService { ); } - // TODO: 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. async createSession( - _user: User, + user: User, ): Promise<{ session: Session; cookie: Cookie }> { - throw new NotImplementedError("createSession"); + 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 }; } - // TODO: Implement using Payload's JWT verify mechanism. - // Need to call Payload's local API to verify the token and retrieve the user. async validateSession( - _sessionId: string, + token: string, ): Promise<{ user: User; session: Session }> { - throw new NotImplementedError("validateSession"); + 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 "users", + id: decoded.id, + overrideAccess: true, + }); + + const user: User = { + id: userDoc.id as string, + username: (userDoc as Record).username as string, + passwordHash: (userDoc as Record).passwordHash as string, + }; + + const session: Session = { + id: token, + userId: user.id, + expiresAt: new Date(decoded.exp * 1000), + }; + + return { user, session }; } - // TODO: 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. async invalidateSession( _sessionId: string, ): Promise<{ blankCookie: Cookie }> { - throw new NotImplementedError("invalidateSession"); + 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; + } } } diff --git a/packages/core-analytics/src/with-analytics.ts b/packages/core-analytics/src/with-analytics.ts index b0220a1..cd337ba 100644 --- a/packages/core-analytics/src/with-analytics.ts +++ b/packages/core-analytics/src/with-analytics.ts @@ -21,11 +21,10 @@ export type Analyzed = F & { readonly __analyzed: true }; * tests). */ export function withAnalytics( - // TODO: wire automated event recording from manifest declarations. - // `analyticsEvents[]` declarations. For now, the wrapper exists to: - // (1) require callers to pass the analytics instance at bind time (dep is available) - // (2) attach the `__analyzed` brand so the boot-time assertion can verify - // use cases were bound through the analytics-aware path. + // The wrapper attaches the brand and ensures the analytics dependency is + // available at bind time. Actual `analytics.track()` calls live in the + // use case body — only the use case knows which properties to extract + // from its input/output for the analytics event. analytics: IAnalytics, fn: (...args: Args) => Promise, ): Analyzed<(...args: Args) => Promise> { diff --git a/packages/core-audit/src/with-audit.ts b/packages/core-audit/src/with-audit.ts index a729c23..6a0eb74 100644 --- a/packages/core-audit/src/with-audit.ts +++ b/packages/core-audit/src/with-audit.ts @@ -21,11 +21,10 @@ export type Audited = F & { readonly __audited: true }; * tests). */ export function withAudit( - // TODO: wire automated recording from manifest declarations. - // `audits[]` declarations. For now, the wrapper exists to: - // (1) require callers to pass the auditLog at bind time (dep is available) - // (2) attach the `__audited` brand so the boot-time assertion can verify - // mutating use cases were bound through the audit-aware path. + // The wrapper attaches the brand and ensures the auditLog dependency is + // available at bind time. Actual `auditLog.record()` calls live in the + // use case body — only the use case knows which fields to extract from + // its input/output for the audit entry. auditLog: IAuditLog, fn: (...args: Args) => Promise, ): Audited<(...args: Args) => Promise> {