feat: route client auth through clients app

This commit is contained in:
Arthur Belleville 2026-05-01 11:55:05 +02:00
parent 90d34833e8
commit e18dd8f017
No known key found for this signature in database
12 changed files with 131 additions and 11 deletions

View file

@ -14,9 +14,9 @@ steps:
- '--region'
- 'europe-west1'
- '--set-env-vars'
- 'NODE_ENV=$_NODE_ENV,SUPABASE_URL=$_SUPABASE_URL,EMAIL_USER=$_EMAIL_USER,EMAIL_CLIENT_ID=$_EMAIL_CLIENT_ID,R2_ACCOUNT_ID=$_R2_ACCOUNT_ID,CORS_ORIGIN=$_CORS_ORIGIN,XTABLO_URL=$_XTABLO_URL,TASKS_SECRET=$_TASKS_SECRET,LOG_LEVEL=$_LOG_LEVEL,STRIPE_SOLO_PRICE_ID=$_STRIPE_SOLO_PRICE_ID,STRIPE_TEAM_PRICE_ID=$_STRIPE_TEAM_PRICE_ID,STRIPE_FOUNDER_PRICE_ID=$_STRIPE_FOUNDER_PRICE_ID'
- 'NODE_ENV=$_NODE_ENV,SUPABASE_URL=$_SUPABASE_URL,EMAIL_USER=$_EMAIL_USER,EMAIL_CLIENT_ID=$_EMAIL_CLIENT_ID,R2_ACCOUNT_ID=$_R2_ACCOUNT_ID,CORS_ORIGIN=$_CORS_ORIGIN,XTABLO_URL=$_XTABLO_URL,TASKS_SECRET=$_TASKS_SECRET,LOG_LEVEL=$_LOG_LEVEL,STRIPE_SOLO_PRICE_ID=$_STRIPE_SOLO_PRICE_ID,STRIPE_TEAM_PRICE_ID=$_STRIPE_TEAM_PRICE_ID,STRIPE_FOUNDER_PRICE_ID=$_STRIPE_FOUNDER_PRICE_ID,CLIENTS_URL=$_CLIENTS_URL,CLIENT_AUTH_COOKIE_DOMAIN=$_CLIENT_AUTH_COOKIE_DOMAIN,CLIENT_AUTH_COOKIE_NAME=$_CLIENT_AUTH_COOKIE_NAME,CLIENT_MAGIC_LINK_TTL_MINUTES=$_CLIENT_MAGIC_LINK_TTL_MINUTES,CLIENT_SESSION_TTL_DAYS=$_CLIENT_SESSION_TTL_DAYS'
images:
- 'europe-west1-docker.pkg.dev/$_AR_PROJECT_ID/$_AR_REPOSITORY/xtablo-source/$_SERVICE_NAME:$COMMIT_SHA'
options:
logging: CLOUD_LOGGING_ONLY
logging: CLOUD_LOGGING_ONLY

View file

@ -4,6 +4,7 @@ import type { Secrets } from "../../secrets.js";
const baseSecrets: Secrets = {
adminTokenSigningSecret: "admin-token-signing-secret",
clientAuthJwtSecret: "client-auth-jwt-secret",
supabaseServiceRoleKey: "service-role-from-secret-manager",
supabaseConnectionString: "postgres://secret-manager",
supabaseCaCert: "ca-cert",
@ -51,4 +52,20 @@ describe("createConfig stripe env overrides", () => {
expect(config.API_BASE_URL).toBe("https://api-staging.xtablo.com/api/v1");
});
it("loads the client auth JWT secret from Secret Manager in non-test environments", () => {
process.env.NODE_ENV = "staging";
process.env.SUPABASE_URL = "https://example.supabase.co";
process.env.EMAIL_USER = "test@xtablo.com";
process.env.EMAIL_CLIENT_ID = "client-id";
process.env.STRIPE_SOLO_PRICE_ID = "price_solo";
process.env.STRIPE_TEAM_PRICE_ID = "price_team";
process.env.STRIPE_FOUNDER_PRICE_ID = "price_founder";
process.env.R2_ACCOUNT_ID = "r2-account";
delete process.env.CLIENT_AUTH_JWT_SECRET;
const config = createConfig(baseSecrets);
expect(config.CLIENT_AUTH_JWT_SECRET).toBe("client-auth-jwt-secret");
});
});

View file

@ -109,6 +109,11 @@ describe.skipIf(!hasClientAuthSchema)("Client Auth Endpoints", () => {
expect(links).toHaveLength(1);
expect(links?.[0]?.consumed_at).toBeNull();
const sentMail = mockSendMail.mock.calls[0]?.[0];
expect(sentMail?.to).toBe(email);
expect(sentMail?.html).toContain(`${config.CLIENTS_URL}/auth/exchange?token=`);
expect(sentMail?.html).not.toContain(`${config.API_BASE_URL}/client-auth/exchange?token=`);
});
it("rejects an expired or consumed exchange token", async () => {

View file

@ -328,6 +328,17 @@ describe.skipIf(!hasClientAuthSchema)("Client Invites Endpoints", () => {
);
});
it("emails a client-app exchange URL instead of the API host", async () => {
const res = await postInvite(ownerUser, adminTabloId, testEmail);
expect(res.status).toBe(200);
const sentMail = mockSendMail.mock.calls[0]?.[0];
expect(sentMail?.to).toBe(testEmail);
expect(sentMail?.html).toContain(`${config.CLIENTS_URL}/auth/exchange?token=`);
expect(sentMail?.html).not.toContain(`${config.API_BASE_URL}/client-auth/exchange?token=`);
});
it("rejects emails already used by a main-app account", async () => {
const res = await postInvite(ownerUser, adminTabloId, ownerUser.email);

View file

@ -161,10 +161,10 @@ 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_JWT_SECRET: isTestMode
? process.env.CLIENT_AUTH_JWT_SECRET || "client-auth-local-secret"
: process.env.CLIENT_AUTH_JWT_SECRET ||
secrets!.clientAuthJwtSecret,
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),

View file

@ -23,7 +23,7 @@ export const getAuthenticatedRouter = (config: AppConfig) => {
authRouter.route(
"/client-invites",
getClientInvitesRouter({
apiBaseUrl: config.API_BASE_URL,
clientsUrl: config.CLIENTS_URL,
jwtSecret: config.CLIENT_AUTH_JWT_SECRET,
ttlMinutes: config.CLIENT_MAGIC_LINK_TTL_MINUTES,
})

View file

@ -17,6 +17,7 @@ const publicFactory = createFactory<BaseEnv>();
const clientFactory = createFactory<ClientEnv>();
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "");
const sendClientMagicLinkEmail = async (
transporter: BaseEnv["Variables"]["transporter"],
@ -107,7 +108,7 @@ const requestLink = (config: {
await sendClientMagicLinkEmail(transporter, {
email: client.email,
subject: "Votre lien de connexion Xtablo",
url: `${config.apiBaseUrl}/client-auth/exchange?token=${encodeURIComponent(
url: `${trimTrailingSlash(config.clientsUrl)}/auth/exchange?token=${encodeURIComponent(
magicLinkResult.token
)}`,
});

View file

@ -15,6 +15,7 @@ const authFactory = createFactory<AuthEnv>();
const publicFactory = createFactory<BaseEnv>();
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "");
const findInviteByToken = async (token: string, supabase: BaseEnv["Variables"]["supabase"]) =>
supabase
@ -69,7 +70,7 @@ const sendSetupEmail = async (
const createClientInvite = (
middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>,
config: {
apiBaseUrl: string;
clientsUrl: string;
jwtSecret: string;
ttlMinutes: number;
}
@ -134,7 +135,7 @@ const createClientInvite = (
try {
await sendSetupEmail(transporter, {
email: rawEmail,
setupUrl: `${config.apiBaseUrl}/client-auth/exchange?token=${encodeURIComponent(
setupUrl: `${trimTrailingSlash(config.clientsUrl)}/auth/exchange?token=${encodeURIComponent(
magicLinkResult.token
)}`,
});
@ -358,7 +359,7 @@ const cancelClientInvite = (middlewareManager: ReturnType<typeof MiddlewareManag
});
export const getClientInvitesRouter = (config: {
apiBaseUrl: string;
clientsUrl: string;
jwtSecret: string;
ttlMinutes: number;
}) => {

View file

@ -22,6 +22,7 @@ export type Secrets = {
supabaseConnectionString: string;
supabaseCaCert: string;
adminTokenSigningSecret: string;
clientAuthJwtSecret: string;
emailClientSecret: string;
emailRefreshToken: string;
r2AccessKeyId: string;
@ -44,6 +45,7 @@ export async function loadSecrets(): Promise<Secrets> {
supabaseConnectionString: await fetchSecret("supabase-connection-string"),
supabaseCaCert: await fetchSecret("supabase-ca-cert"),
adminTokenSigningSecret: await fetchSecret("admin-token-signing-secret"),
clientAuthJwtSecret: await fetchSecret("client-auth-jwt-secret"),
emailClientSecret: await fetchSecret("email-client-secret"),
emailRefreshToken: await fetchSecret("email-refresh-token"),
r2AccessKeyId: await fetchSecret("r2-access-key-id"),

View file

@ -0,0 +1,45 @@
import { waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../test/testHelpers";
import { ClientMagicLinkExchangePage } from "./ClientMagicLinkExchangePage";
const originalLocation = window.location;
describe("ClientMagicLinkExchangePage", () => {
const replaceMock = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("VITE_API_URL", "https://api-staging.xtablo.com");
Object.defineProperty(window, "location", {
configurable: true,
value: {
...originalLocation,
replace: replaceMock,
},
});
});
afterEach(() => {
vi.unstubAllEnvs();
Object.defineProperty(window, "location", {
configurable: true,
value: originalLocation,
});
});
it("hands the token off to the API exchange endpoint", async () => {
renderWithProviders(<ClientMagicLinkExchangePage />, {
path: "/auth/exchange",
route: "/auth/exchange?token=magic-token-123",
testUser: undefined,
});
await waitFor(() => {
expect(replaceMock).toHaveBeenCalledWith(
"https://api-staging.xtablo.com/api/v1/client-auth/exchange?token=magic-token-123"
);
});
});
});

View file

@ -0,0 +1,36 @@
import { useEffect } from "react";
import { Navigate, useSearchParams } from "react-router-dom";
function trimTrailingSlash(value: string) {
return value.replace(/\/+$/, "");
}
function getClientAuthExchangeUrl(token: string) {
const apiUrl = trimTrailingSlash(import.meta.env.VITE_API_URL ?? "");
return `${apiUrl}/api/v1/client-auth/exchange?token=${encodeURIComponent(token)}`;
}
export function ClientMagicLinkExchangePage() {
const [searchParams] = useSearchParams();
const token = searchParams.get("token");
useEffect(() => {
if (!token) {
return;
}
window.location.replace(getClientAuthExchangeUrl(token));
}, [token]);
if (!token) {
return <Navigate to="/login" replace />;
}
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary" />
</div>
);
}
export { getClientAuthExchangeUrl };

View file

@ -1,6 +1,7 @@
import { Route, Routes } from "react-router-dom";
import { ClientAuthGate } from "./components/ClientAuthGate";
import { ClientLayout } from "./components/ClientLayout";
import { ClientMagicLinkExchangePage } from "./pages/ClientMagicLinkExchangePage";
import { ClientTabloListPage } from "./pages/ClientTabloListPage";
import { ClientTabloPage } from "./pages/ClientTabloPage";
import { LoginPage } from "./pages/LoginPage";
@ -8,6 +9,7 @@ import { LoginPage } from "./pages/LoginPage";
export default function AppRoutes() {
return (
<Routes>
<Route path="/auth/exchange" element={<ClientMagicLinkExchangePage />} />
<Route path="/login" element={<LoginPage />} />
<Route element={<ClientAuthGate />}>
<Route element={<ClientLayout />}>