feat: add client auth helpers and middleware
This commit is contained in:
parent
fda95d9ce4
commit
06e1114cf8
7 changed files with 591 additions and 0 deletions
76
apps/api/src/__tests__/helpers/clientSessions.test.ts
Normal file
76
apps/api/src/__tests__/helpers/clientSessions.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
114
apps/api/src/helpers/clientAccounts.ts
Normal file
114
apps/api/src/helpers/clientAccounts.ts
Normal 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 };
|
||||
}
|
||||
181
apps/api/src/helpers/clientSessions.ts
Normal file
181
apps/api/src/helpers/clientSessions.ts
Normal 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 };
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue