From 06e1114cf84a91ea006ad682950d49724be84283 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 30 Apr 2026 18:37:29 +0200 Subject: [PATCH] feat: add client auth helpers and middleware --- .../__tests__/helpers/clientSessions.test.ts | 76 ++++++++ .../__tests__/middlewares/middlewares.test.ts | 96 ++++++++++ apps/api/src/config.ts | 15 ++ apps/api/src/helpers/clientAccounts.ts | 114 +++++++++++ apps/api/src/helpers/clientSessions.ts | 181 ++++++++++++++++++ apps/api/src/middlewares/middleware.ts | 90 +++++++++ apps/api/src/types/app.types.ts | 19 ++ 7 files changed, 591 insertions(+) create mode 100644 apps/api/src/__tests__/helpers/clientSessions.test.ts create mode 100644 apps/api/src/helpers/clientAccounts.ts create mode 100644 apps/api/src/helpers/clientSessions.ts diff --git a/apps/api/src/__tests__/helpers/clientSessions.test.ts b/apps/api/src/__tests__/helpers/clientSessions.test.ts new file mode 100644 index 0000000..954f4e2 --- /dev/null +++ b/apps/api/src/__tests__/helpers/clientSessions.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + buildClientSessionCookie, + readClientSessionCookie, + signClientMagicLink, + signClientSession, + verifyClientMagicLink, + verifyClientSession, +} from "../../helpers/clientSessions.js"; + +describe("clientSessions helpers", () => { + const secret = "client-auth-secret-for-tests"; + + it("signs and verifies a client session JWT", () => { + const token = signClientSession( + { + clientId: "client-123", + email: "client@example.com", + }, + { secret, expiresInDays: 7 } + ); + + const claims = verifyClientSession(token, { secret }); + + expect(claims.sub).toBe("client-123"); + expect(claims.email).toBe("client@example.com"); + expect(claims.type).toBe("client_session"); + }); + + it("rejects expired client session JWTs", () => { + const token = signClientSession( + { + clientId: "client-123", + email: "client@example.com", + }, + { secret, expiresInDays: -1 } + ); + + expect(() => verifyClientSession(token, { secret })).toThrow(/expired/i); + }); + + it("extracts the configured client cookie from the request", () => { + const cookie = buildClientSessionCookie("signed-token", { + cookieDomain: "clients.xtablo.com", + cookieName: "xtablo_client_session", + maxAgeSeconds: 7 * 24 * 60 * 60, + }); + + const token = readClientSessionCookie(`foo=bar; xtablo_client_session=signed-token; ${cookie}`, { + cookieName: "xtablo_client_session", + }); + + expect(token).toBe("signed-token"); + }); + + it("signs magic-link JWTs with jti and expiry claims", () => { + const token = signClientMagicLink( + { + clientId: "client-123", + email: "client@example.com", + jti: "magic-link-jti-1", + purpose: "invite", + redirectTo: "/tablo/test_tablo_owner_private", + }, + { secret, expiresInMinutes: 30 } + ); + + const claims = verifyClientMagicLink(token, { secret }); + + expect(claims.sub).toBe("client-123"); + expect(claims.email).toBe("client@example.com"); + expect(claims.jti).toBe("magic-link-jti-1"); + expect(claims.purpose).toBe("invite"); + expect(claims.redirect_to).toBe("/tablo/test_tablo_owner_private"); + }); +}); diff --git a/apps/api/src/__tests__/middlewares/middlewares.test.ts b/apps/api/src/__tests__/middlewares/middlewares.test.ts index b3a6cbe..3389218 100644 --- a/apps/api/src/__tests__/middlewares/middlewares.test.ts +++ b/apps/api/src/__tests__/middlewares/middlewares.test.ts @@ -2,6 +2,7 @@ import { Hono } from "hono"; import { testClient } from "hono/testing"; import { describe, expect, it, vi } from "vitest"; import { createConfig } from "../../config.js"; +import { signClientSession } from "../../helpers/clientSessions.js"; import { MiddlewareManager } from "../../middlewares/middleware.js"; describe("Middleware Tests", () => { @@ -58,6 +59,25 @@ describe("Middleware Tests", () => { }; }; + const createClientsSupabaseMock = (result: { + data: { id: string; email: string; normalized_email: string } | null; + error: { message: string } | null; + }) => ({ + from: vi.fn((table: string) => { + if (table !== "clients") { + throw new Error(`Unexpected table ${table}`); + } + + return { + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue(result), + }), + }), + }; + }), + }); + describe("Supabase Middleware", () => { it("should inject supabase client into context", async () => { const app = new Hono(); @@ -342,6 +362,82 @@ describe("Middleware Tests", () => { }); }); + describe("Client Auth Middleware", () => { + it("authenticates a client request from the client session cookie", async () => { + const token = signClientSession( + { + clientId: "client-123", + email: "client@example.com", + }, + { + expiresInDays: 7, + secret: config.CLIENT_AUTH_JWT_SECRET, + } + ); + + const app = new Hono(); + app.use(async (c, next) => { + // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection + (c as any).set( + "supabase", + createClientsSupabaseMock({ + data: { + id: "client-123", + email: "client@example.com", + normalized_email: "client@example.com", + }, + error: null, + }) + ); + await next(); + }); + // biome-ignore lint/suspicious/noExplicitAny: middleware added in upcoming implementation + app.use((middlewareManager as any).clientAuth); + app.get("/test", (c) => { + const client = // biome-ignore lint/suspicious/noExplicitAny: Needed for context access in tests + (c as any).get("client"); + return c.json({ client }); + }); + + const res = await app.request("http://localhost/test", { + headers: { + Cookie: `${config.CLIENT_AUTH_COOKIE_NAME}=${token}`, + }, + }); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.client.id).toBe("client-123"); + expect(data.client.email).toBe("client@example.com"); + }); + + it("returns 401 when the client cookie is missing or invalid", async () => { + const app = new Hono(); + app.use(async (c, next) => { + // biome-ignore lint/suspicious/noExplicitAny: Test-only context injection + (c as any).set( + "supabase", + createClientsSupabaseMock({ + data: null, + error: null, + }) + ); + await next(); + }); + // biome-ignore lint/suspicious/noExplicitAny: middleware added in upcoming implementation + app.use((middlewareManager as any).clientAuth); + app.get("/test", (c) => c.json({ success: true })); + + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + const client = testClient(app) as any; + const res = await client.test.$get(); + const data = await res.json(); + + expect(res.status).toBe(401); + expect(data.error).toMatch(/client session/i); + }); + }); + describe("Active Plan Access Middleware", () => { it("should reject requests when the organization has no active plan", async () => { diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index a6a4134..63985d1 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -26,6 +26,12 @@ export interface AppConfig { ADMIN_TOKEN_SIGNING_SECRET: string; ADMIN_TOKEN_AUDIENCE: string; ADMIN_APP_URL: string; + CLIENT_AUTH_JWT_SECRET: string; + CLIENT_AUTH_COOKIE_NAME: string; + CLIENT_AUTH_COOKIE_DOMAIN: string; + CLIENT_MAGIC_LINK_TTL_MINUTES: number; + CLIENT_SESSION_TTL_DAYS: number; + CLIENTS_URL: string; /** * Test user @@ -115,6 +121,15 @@ export function createConfig(secrets?: Secrets): AppConfig { : secrets!.adminTokenSigningSecret, ADMIN_TOKEN_AUDIENCE: process.env.ADMIN_TOKEN_AUDIENCE || "xtablo-admin", ADMIN_APP_URL: process.env.ADMIN_APP_URL || "http://localhost:5176", + CLIENT_AUTH_JWT_SECRET: + process.env.CLIENT_AUTH_JWT_SECRET || + process.env.ADMIN_TOKEN_SIGNING_SECRET || + "client-auth-local-secret", + CLIENT_AUTH_COOKIE_NAME: process.env.CLIENT_AUTH_COOKIE_NAME || "xtablo_client_session", + CLIENT_AUTH_COOKIE_DOMAIN: process.env.CLIENT_AUTH_COOKIE_DOMAIN || "clients.xtablo.com", + CLIENT_MAGIC_LINK_TTL_MINUTES: parseInt(process.env.CLIENT_MAGIC_LINK_TTL_MINUTES || "30", 10), + CLIENT_SESSION_TTL_DAYS: parseInt(process.env.CLIENT_SESSION_TTL_DAYS || "7", 10), + CLIENTS_URL: process.env.CLIENTS_URL || "https://clients.xtablo.com", LOG_LEVEL: "info", TEST_USER_DATA: { id: "test", diff --git a/apps/api/src/helpers/clientAccounts.ts b/apps/api/src/helpers/clientAccounts.ts new file mode 100644 index 0000000..8b13f9b --- /dev/null +++ b/apps/api/src/helpers/clientAccounts.ts @@ -0,0 +1,114 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Tables } from "@xtablo/shared-types"; + +type ClientRow = Tables<"clients">; + +export function normalizeClientEmail(email: string) { + return email.trim().toLowerCase(); +} + +export async function upsertClientByEmail(supabase: SupabaseClient, email: string) { + const normalizedEmail = normalizeClientEmail(email); + + const { data: existingClient, error: existingClientError } = await supabase + .from("clients") + .select("*") + .eq("normalized_email", normalizedEmail) + .maybeSingle(); + + if (existingClientError) { + return { client: null, error: existingClientError.message, wasCreated: false }; + } + + if (existingClient) { + return { client: existingClient as ClientRow, error: null, wasCreated: false }; + } + + const { data: insertedClient, error: insertError } = await supabase + .from("clients") + .insert({ + email: normalizedEmail, + normalized_email: normalizedEmail, + }) + .select("*") + .single(); + + if (insertError) { + return { client: null, error: insertError.message, wasCreated: false }; + } + + return { client: insertedClient as ClientRow, error: null, wasCreated: true }; +} + +export async function ensureActiveClientAccess( + supabase: SupabaseClient, + input: { + clientId: string; + grantedBy: string; + tabloId: string; + } +) { + const { data: existingAccess, error: existingAccessError } = await supabase + .from("client_access") + .select("id, revoked_at") + .eq("client_id", input.clientId) + .eq("tablo_id", input.tabloId) + .maybeSingle(); + + if (existingAccessError) { + return { error: existingAccessError.message, success: false }; + } + + if (!existingAccess) { + const { error: insertError } = await supabase.from("client_access").insert({ + client_id: input.clientId, + granted_by: input.grantedBy, + tablo_id: input.tabloId, + }); + + return { error: insertError?.message ?? null, success: !insertError }; + } + + if (existingAccess.revoked_at) { + const { error: updateError } = await supabase + .from("client_access") + .update({ + granted_at: new Date().toISOString(), + granted_by: input.grantedBy, + revoked_at: null, + }) + .eq("id", existingAccess.id); + + return { error: updateError?.message ?? null, success: !updateError }; + } + + return { error: null, success: true }; +} + +export async function clientHasAnyActiveAccess(supabase: SupabaseClient, clientId: string) { + const { count, error } = await supabase + .from("client_access") + .select("id", { count: "exact", head: true }) + .eq("client_id", clientId) + .is("revoked_at", null); + + if (error) { + return { error: error.message, hasActiveAccess: false }; + } + + return { error: null, hasActiveAccess: Boolean(count && count > 0) }; +} + +export async function revokeClientAccess( + supabase: SupabaseClient, + input: { clientId: string; tabloId: string } +) { + const { error } = await supabase + .from("client_access") + .update({ revoked_at: new Date().toISOString() }) + .eq("client_id", input.clientId) + .eq("tablo_id", input.tabloId) + .is("revoked_at", null); + + return { error: error?.message ?? null, success: !error }; +} diff --git a/apps/api/src/helpers/clientSessions.ts b/apps/api/src/helpers/clientSessions.ts new file mode 100644 index 0000000..f55d258 --- /dev/null +++ b/apps/api/src/helpers/clientSessions.ts @@ -0,0 +1,181 @@ +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; + +type TokenKind = "client_session" | "client_magic_link"; +type MagicLinkPurpose = "invite" | "login"; + +type BaseClaims = { + email: string; + exp: number; + iat: number; + sub: string; + type: TokenKind; +}; + +type ClientSessionClaims = BaseClaims & { + type: "client_session"; +}; + +type ClientMagicLinkClaims = BaseClaims & { + jti: string; + purpose: MagicLinkPurpose; + redirect_to?: string; + type: "client_magic_link"; +}; + +type SignSessionOptions = { + expiresInDays: number; + secret: string; +}; + +type SignMagicLinkOptions = { + expiresInMinutes: number; + secret: string; +}; + +type VerifyOptions = { + secret: string; +}; + +type BuildCookieOptions = { + cookieDomain?: string; + cookieName: string; + maxAgeSeconds: number; +}; + +type SignClientSessionInput = { + clientId: string; + email: string; +}; + +type SignClientMagicLinkInput = { + clientId: string; + email: string; + jti: string; + purpose: MagicLinkPurpose; + redirectTo?: string; +}; + +function encodeSegment(value: unknown) { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function decodeSegment(segment: string): T | null { + try { + return JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as T; + } catch { + return null; + } +} + +function signToken(claims: ClientSessionClaims | ClientMagicLinkClaims, secret: string) { + const header = encodeSegment({ alg: "HS256", typ: "JWT" }); + const payload = encodeSegment(claims); + const signature = createHmac("sha256", secret).update(`${header}.${payload}`).digest("base64url"); + + return `${header}.${payload}.${signature}`; +} + +function verifyToken( + token: string, + secret: string, + expectedType: TokenKind +) { + const segments = token.split("."); + if (segments.length !== 3) { + throw new Error("Invalid client session token"); + } + + const [header, payload, signature] = segments; + const expectedSignature = createHmac("sha256", secret).update(`${header}.${payload}`).digest(); + const receivedSignature = Buffer.from(signature, "base64url"); + + if ( + expectedSignature.length !== receivedSignature.length || + !timingSafeEqual(expectedSignature, receivedSignature) + ) { + throw new Error("Invalid client session token"); + } + + const claims = decodeSegment(payload); + if (!claims || claims.type !== expectedType) { + throw new Error("Invalid client session token"); + } + + if (claims.exp <= Math.floor(Date.now() / 1000)) { + throw new Error("Client session token expired"); + } + + return claims; +} + +export function signClientSession(input: SignClientSessionInput, options: SignSessionOptions) { + const now = Math.floor(Date.now() / 1000); + return signToken( + { + email: input.email, + exp: now + options.expiresInDays * 24 * 60 * 60, + iat: now, + sub: input.clientId, + type: "client_session", + }, + options.secret + ); +} + +export function verifyClientSession(token: string, options: VerifyOptions) { + return verifyToken(token, options.secret, "client_session"); +} + +export function signClientMagicLink(input: SignClientMagicLinkInput, options: SignMagicLinkOptions) { + const now = Math.floor(Date.now() / 1000); + return signToken( + { + email: input.email, + exp: now + options.expiresInMinutes * 60, + iat: now, + jti: input.jti, + purpose: input.purpose, + redirect_to: input.redirectTo, + sub: input.clientId, + type: "client_magic_link", + }, + options.secret + ); +} + +export function verifyClientMagicLink(token: string, options: VerifyOptions) { + return verifyToken(token, options.secret, "client_magic_link"); +} + +export function buildClientSessionCookie(token: string, options: BuildCookieOptions) { + const domainPart = options.cookieDomain ? `; Domain=${options.cookieDomain}` : ""; + return `${options.cookieName}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${options.maxAgeSeconds}${domainPart}`; +} + +export function clearClientSessionCookie(options: { + cookieDomain?: string; + cookieName: string; +}) { + const domainPart = options.cookieDomain ? `; Domain=${options.cookieDomain}` : ""; + return `${options.cookieName}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0${domainPart}`; +} + +export function readClientSessionCookie(cookieHeader: string | null | undefined, options: { + cookieName: string; +}) { + if (!cookieHeader) { + return null; + } + + const cookieMatch = cookieHeader.match( + new RegExp(`(?:^|;\\s*)${options.cookieName}=([^;]+)`) + ); + + return cookieMatch?.[1] ?? null; +} + +export function hashClientMagicLinkToken(token: string) { + return createHash("sha256").update(token).digest("hex"); +} + +export type { ClientMagicLinkClaims, ClientSessionClaims, MagicLinkPurpose }; diff --git a/apps/api/src/middlewares/middleware.ts b/apps/api/src/middlewares/middleware.ts index 3d8db24..67260ba 100644 --- a/apps/api/src/middlewares/middleware.ts +++ b/apps/api/src/middlewares/middleware.ts @@ -8,6 +8,8 @@ import { Stripe } from "stripe"; import { type AppConfig } from "../config.js"; import { type AdminTokenResult, verifyAdminSession } from "../helpers/adminTokens.js"; import { authenticateFromHeader } from "../helpers/auth.js"; +import { readClientSessionCookie, verifyClientSession } from "../helpers/clientSessions.js"; +import type { ClientEnv, MaybeClientEnv } from "../types/app.types.js"; import { createStripeSync } from "./stripeSync.js"; import { createTransporter } from "./transporter.js"; @@ -21,6 +23,8 @@ export type Middlewares = { maybeAuthenticatedMiddleware: MiddlewareHandler<{ Variables: { supabase: SupabaseClient; user: User | null }; }>; + maybeClientAuthMiddleware: MiddlewareHandler; + clientAuthMiddleware: MiddlewareHandler; authMiddleware: MiddlewareHandler<{ Variables: { supabase: SupabaseClient; user: User }; Bindings: { user: User }; @@ -107,6 +111,55 @@ export class MiddlewareManager { await next(); }); + const loadClientFromCookie = async ( + cookieHeader: string | undefined, + supabase: SupabaseClient + ) => { + const token = readClientSessionCookie(cookieHeader, { + cookieName: config.CLIENT_AUTH_COOKIE_NAME, + }); + + if (!token) { + return { + client: null, + error: "Client session required", + success: false as const, + }; + } + + try { + const claims = verifyClientSession(token, { + secret: config.CLIENT_AUTH_JWT_SECRET, + }); + + const { data: client, error } = await supabase + .from("clients") + .select("*") + .eq("id", claims.sub) + .maybeSingle(); + + if (error || !client) { + return { + client: null, + error: error?.message ?? "Client session required", + success: false as const, + }; + } + + return { + client, + error: null, + success: true as const, + }; + } catch (error) { + return { + client: null, + error: error instanceof Error ? error.message : "Client session required", + success: false as const, + }; + } + }; + const supabaseMiddleware = createMiddleware(async (c: Context, next: Next) => { const supabase = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY); c.set("supabase", supabase); @@ -217,6 +270,33 @@ export class MiddlewareManager { await next(); }); + const maybeClientAuthMiddleware = createMiddleware(async (c, next) => { + const supabase = c.get("supabase"); + c.set("client", null); + + const cookieHeader = c.req.header("Cookie"); + const result = await loadClientFromCookie(cookieHeader, supabase); + + if (result.success) { + c.set("client", result.client); + } + + await next(); + }); + + const clientAuthMiddleware = createMiddleware(async (c, next) => { + const supabase = c.get("supabase"); + const cookieHeader = c.req.header("Cookie"); + const result = await loadClientFromCookie(cookieHeader, supabase); + + if (!result.success) { + return c.json({ error: result.error }, 401); + } + + c.set("client", result.client); + await next(); + }); + const regularUserCheckMiddleware = createProfileAccessMiddleware(); const billingCheckoutAccessMiddleware = createProfileAccessMiddleware(); const activePlanAccessMiddleware = createMiddleware<{ @@ -283,6 +363,8 @@ export class MiddlewareManager { authMiddleware, adminAuthMiddleware, maybeAuthenticatedMiddleware, + maybeClientAuthMiddleware, + clientAuthMiddleware, r2Middleware, regularUserCheckMiddleware, billingCheckoutAccessMiddleware, @@ -305,6 +387,14 @@ export class MiddlewareManager { return this.middlewares.authMiddleware; } + get clientAuth() { + return this.middlewares.clientAuthMiddleware; + } + + get maybeClientAuth() { + return this.middlewares.maybeClientAuthMiddleware; + } + get adminAuth() { return this.middlewares.adminAuthMiddleware; } diff --git a/apps/api/src/types/app.types.ts b/apps/api/src/types/app.types.ts index 5c1f28e..d899bd6 100644 --- a/apps/api/src/types/app.types.ts +++ b/apps/api/src/types/app.types.ts @@ -1,6 +1,7 @@ import type { S3Client } from "@aws-sdk/client-s3"; import type { StripeSync } from "@supabase/stripe-sync-engine"; import type { SupabaseClient, User } from "@supabase/supabase-js"; +import type { Tables } from "@xtablo/shared-types"; import type { Hono } from "hono"; import type { Transporter } from "nodemailer"; import type Stripe from "stripe"; @@ -38,6 +39,24 @@ export type MaybeAuthEnv = BaseEnv & { }; }; +/** + * Environment with authenticated client-portal identity + */ +export type ClientEnv = BaseEnv & { + Variables: BaseEnv["Variables"] & { + client: Tables<"clients">; + }; +}; + +/** + * Environment with optional client-portal identity + */ +export type MaybeClientEnv = BaseEnv & { + Variables: BaseEnv["Variables"] & { + client: Tables<"clients"> | null; + }; +}; + /** * Type helper to extract the app type from a Hono instance */