feat(auth): implement session methods with Payload-backed JWT
Some checks failed
CI / typecheck + lint + boundaries + test + build (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Coverage snapshot / snapshot (push) Has been cancelled
Release Please / release-please (push) Has been cancelled
Sentry PII guard (R31) / pii-guard (push) Has been cancelled
CI / Playwright e2e (push) Has been cancelled
CI / Storybook smoke tests + visual regression (push) Has been cancelled
Mutation testing (nightly) / mutate (push) Has been cancelled
Library trace revalidation (weekly) / revalidate (push) Has been cancelled
Some checks failed
CI / typecheck + lint + boundaries + test + build (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Coverage snapshot / snapshot (push) Has been cancelled
Release Please / release-please (push) Has been cancelled
Sentry PII guard (R31) / pii-guard (push) Has been cancelled
CI / Playwright e2e (push) Has been cancelled
CI / Storybook smoke tests + visual regression (push) Has been cancelled
Mutation testing (nightly) / mutate (push) Has been cancelled
Library trace revalidation (weekly) / revalidate (push) Has been cancelled
Replace NotImplementedError stubs in AuthenticationService with working implementations: createSession signs a HS256 JWT using Payload's instance secret, validateSession verifies and decodes the token then looks up the user, invalidateSession returns a blank cookie with maxAge 0. No external JWT dependency — uses Node crypto HMAC directly. Also clarify withAudit/withAnalytics comments: the wrappers intentionally delegate recording to the use case body (only it knows which fields to extract), so the TODO was misleading.
This commit is contained in:
@@ -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("/");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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 };
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user