feat: add client auth helpers and middleware

This commit is contained in:
Arthur Belleville 2026-04-30 18:37:29 +02:00
parent fda95d9ce4
commit 06e1114cf8
7 changed files with 591 additions and 0 deletions

View file

@ -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");
});
});

View file

@ -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 () => {

View file

@ -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",

View file

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

View file

@ -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<T>(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<T extends ClientSessionClaims | ClientMagicLinkClaims>(
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<T>(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<ClientSessionClaims>(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<ClientMagicLinkClaims>(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 };

View file

@ -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<MaybeClientEnv>;
clientAuthMiddleware: MiddlewareHandler<ClientEnv>;
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<MaybeClientEnv>(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<ClientEnv>(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;
}

View file

@ -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
*/