Proxy API routes through the vite dev server to keep local dev same-origin (#23779)
Replaces #23774 (closed), rebased on latest main. ## Problem Since the cookie-session migration (#23642), the front sends every request with `credentials: 'include'` and the server only reflects `Access-Control-Allow-Origin` for the exact origins in the credentialed allowlist (`SERVER_URL`, `FRONTEND_URL`, `AUTH_COOKIE_ALLOWED_ORIGINS`). Any other origin gets the `*` wildcard, which browsers reject for credentialed requests. Local dev is split-origin by default (front on `localhost:3001`, API on `localhost:3000`), and with `IS_MULTIWORKSPACE_ENABLED` every workspace subdomain (`apple.localhost:3001`, ...) is yet another origin. Each locally created workspace would need a manual `AUTH_COOKIE_ALLOWED_ORIGINS` entry. ## Solution Make local dev same-origin instead of widening the CORS policy: the vite dev server now proxies all top-level API route prefixes to the backend, and the front calls its own origin. - `vite.config.ts` adds a `server.proxy` covering the backend's top-level prefixes (`/graphql`, `/metadata`, `/admin-panel`, `/auth`, `/rest`, `/file`, `/client-config`, ...), defined in `src/config/apiProxyPrefixes.ts`. Keys are anchored regexes (`^/auth($|[/?])`) so SPA routes sharing a prefix (`/authorize`, `/settings`) are not swallowed. The target defaults to `http://localhost:3000` and follows `REACT_APP_SERVER_BASE_URL`. `changeOrigin` stays off so the backend sees the browser's Host: same-origin checks (CSRF, cookie issuance) and workspace resolution by subdomain work unchanged through the proxy. - `config/index.ts` collapses to `window._env_?.REACT_APP_SERVER_BASE_URL || window.location.origin`. Every supported production path injects `window._env_` (docker entrypoint fails hard without `REACT_APP_SERVER_BASE_URL`; a server-served front gets it from `generateFrontConfig()`), and in dev the current origin is correct on `localhost:3001` and every `*.localhost:3001` workspace subdomain thanks to the proxy. The removed `http://<hostname>:3000` fallback only served an un-injected production bundle browsed on localhost, a setup whose credentialed auth the cookie-session migration had already broken. The credentialed allowlist itself is unchanged and stays strict; since dev traffic is same-origin, the per-subdomain cookie-allowlist problem disappears without loosening any production CORS/CSRF policy. ## Tests - `src/config/__tests__/apiProxyPrefixes.test.ts` guards the proxy boundary in both directions: representative backend path shapes (including `/metadata?query=...` and `/auth/...`) must match, every SPA route from the `AppPath` enum and vite's own dev paths must not — so a future route collision fails unit tests instead of breaking dev. - Verified against running dev servers: API paths proxy to the backend from both `localhost:3001` and `apple.localhost:3001`, while SPA routes `/settings` and `/authorize` still serve the vite app; a same-origin POST from `apple.localhost:3001` goes through with no CORS involvement. - `lint:diff-with-main` and `typecheck` pass for twenty-front. --------- Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import { join } from 'path';
|
||||
|
||||
import { YogaDriver, type YogaDriverConfig } from '@graphql-yoga/nestjs';
|
||||
import { SentryModule } from '@sentry/nestjs/setup';
|
||||
import { ApiPath } from 'twenty-shared/types';
|
||||
|
||||
import { AdminPanelGraphQLApiModule } from 'src/engine/api/graphql/admin-panel-graphql-api.module';
|
||||
import { CoreGraphQLApiModule } from 'src/engine/api/graphql/core-graphql-api.module';
|
||||
@@ -131,7 +132,7 @@ export class AppModule {
|
||||
// A cross-origin form post from the identity provider, authenticated on the
|
||||
// assertion rather than the cookie.
|
||||
.exclude({
|
||||
path: 'auth/saml/callback/:identityProviderId',
|
||||
path: `${ApiPath.Auth}/saml/callback/:identityProviderId`,
|
||||
method: RequestMethod.POST,
|
||||
})
|
||||
.forRoutes({ path: '*path', method: RequestMethod.ALL });
|
||||
@@ -141,30 +142,30 @@ export class AppModule {
|
||||
GraphQLHydrateRequestFromTokenMiddleware,
|
||||
WorkspaceAuthContextMiddleware,
|
||||
)
|
||||
.forRoutes({ path: 'graphql', method: RequestMethod.ALL });
|
||||
.forRoutes({ path: ApiPath.GraphQL, method: RequestMethod.ALL });
|
||||
|
||||
consumer
|
||||
.apply(
|
||||
GraphQLHydrateRequestFromTokenMiddleware,
|
||||
WorkspaceAuthContextMiddleware,
|
||||
)
|
||||
.forRoutes({ path: 'metadata', method: RequestMethod.ALL });
|
||||
.forRoutes({ path: ApiPath.Metadata, method: RequestMethod.ALL });
|
||||
|
||||
consumer
|
||||
.apply(
|
||||
GraphQLHydrateRequestFromTokenMiddleware,
|
||||
WorkspaceAuthContextMiddleware,
|
||||
)
|
||||
.forRoutes({ path: 'admin-panel', method: RequestMethod.ALL });
|
||||
.forRoutes({ path: ApiPath.AdminPanel, method: RequestMethod.ALL });
|
||||
|
||||
consumer
|
||||
.apply(McpMethodGuardMiddleware)
|
||||
.forRoutes({ path: 'mcp', method: RequestMethod.ALL });
|
||||
.forRoutes({ path: ApiPath.Mcp, method: RequestMethod.ALL });
|
||||
|
||||
for (const method of MIGRATED_REST_METHODS) {
|
||||
consumer
|
||||
.apply(RestCoreMiddleware, WorkspaceAuthContextMiddleware)
|
||||
.forRoutes({ path: 'rest/*path', method });
|
||||
.forRoutes({ path: `${ApiPath.Rest}/*path`, method });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user