feat: route client auth through clients app
This commit is contained in:
parent
90d34833e8
commit
e18dd8f017
12 changed files with 131 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)}`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
45
apps/clients/src/pages/ClientMagicLinkExchangePage.test.tsx
Normal file
45
apps/clients/src/pages/ClientMagicLinkExchangePage.test.tsx
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
36
apps/clients/src/pages/ClientMagicLinkExchangePage.tsx
Normal file
36
apps/clients/src/pages/ClientMagicLinkExchangePage.tsx
Normal 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 };
|
||||
|
|
@ -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 />}>
|
||||
|
|
|
|||
Loading…
Reference in a new issue