diff --git a/apps/api/cloudbuild.yaml b/apps/api/cloudbuild.yaml index e8a7038..90f055a 100644 --- a/apps/api/cloudbuild.yaml +++ b/apps/api/cloudbuild.yaml @@ -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 \ No newline at end of file + logging: CLOUD_LOGGING_ONLY diff --git a/apps/api/src/__tests__/config/stripe-config.test.ts b/apps/api/src/__tests__/config/stripe-config.test.ts index d573af7..ffc4ede 100644 --- a/apps/api/src/__tests__/config/stripe-config.test.ts +++ b/apps/api/src/__tests__/config/stripe-config.test.ts @@ -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"); + }); }); diff --git a/apps/api/src/__tests__/routes/clientAuth.test.ts b/apps/api/src/__tests__/routes/clientAuth.test.ts index 20aa032..3728c2b 100644 --- a/apps/api/src/__tests__/routes/clientAuth.test.ts +++ b/apps/api/src/__tests__/routes/clientAuth.test.ts @@ -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 () => { diff --git a/apps/api/src/__tests__/routes/clientInvites.test.ts b/apps/api/src/__tests__/routes/clientInvites.test.ts index 2513722..48473ec 100644 --- a/apps/api/src/__tests__/routes/clientInvites.test.ts +++ b/apps/api/src/__tests__/routes/clientInvites.test.ts @@ -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); diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index fb87d27..6cedd72 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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), diff --git a/apps/api/src/routers/authRouter.ts b/apps/api/src/routers/authRouter.ts index dea5b84..20e8fb6 100644 --- a/apps/api/src/routers/authRouter.ts +++ b/apps/api/src/routers/authRouter.ts @@ -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, }) diff --git a/apps/api/src/routers/clientAuth.ts b/apps/api/src/routers/clientAuth.ts index 3cb09fb..4da2f55 100644 --- a/apps/api/src/routers/clientAuth.ts +++ b/apps/api/src/routers/clientAuth.ts @@ -17,6 +17,7 @@ const publicFactory = createFactory(); const clientFactory = createFactory(); 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 )}`, }); diff --git a/apps/api/src/routers/clientInvites.ts b/apps/api/src/routers/clientInvites.ts index 40d798e..01b874f 100644 --- a/apps/api/src/routers/clientInvites.ts +++ b/apps/api/src/routers/clientInvites.ts @@ -15,6 +15,7 @@ const authFactory = createFactory(); const publicFactory = createFactory(); 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, 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 { diff --git a/apps/api/src/secrets.ts b/apps/api/src/secrets.ts index f3e917d..9b779e6 100644 --- a/apps/api/src/secrets.ts +++ b/apps/api/src/secrets.ts @@ -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 { 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"), diff --git a/apps/clients/src/pages/ClientMagicLinkExchangePage.test.tsx b/apps/clients/src/pages/ClientMagicLinkExchangePage.test.tsx new file mode 100644 index 0000000..dd1fd52 --- /dev/null +++ b/apps/clients/src/pages/ClientMagicLinkExchangePage.test.tsx @@ -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(, { + 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" + ); + }); + }); +}); diff --git a/apps/clients/src/pages/ClientMagicLinkExchangePage.tsx b/apps/clients/src/pages/ClientMagicLinkExchangePage.tsx new file mode 100644 index 0000000..b64b350 --- /dev/null +++ b/apps/clients/src/pages/ClientMagicLinkExchangePage.tsx @@ -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 ; + } + + return ( +
+
+
+ ); +} + +export { getClientAuthExchangeUrl }; diff --git a/apps/clients/src/routes.tsx b/apps/clients/src/routes.tsx index 74fb526..491a292 100644 --- a/apps/clients/src/routes.tsx +++ b/apps/clients/src/routes.tsx @@ -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 ( + } /> } /> }> }>