From 997b2c38de99e29b893f824126f32e8b45027293 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:05:12 +0200 Subject: [PATCH] Add cookie-session integration test suite (#23715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #23642. Integration suite for the cookie-session surface, organized as one successful/failing spec pair per stage of the session lifecycle. 14 spec files, ~36 tests, all over real HTTP against the booted app. ## Coverage by stage **1. Session creation on auth exchanges** (`successful-`/`failing-session-creation`) Flag gating (default off: tokens, no cookie, no row); httpOnly cookie snapshot with 180d expiry window; SHA-256 hash-at-rest with the row bound to the apple seed workspace; scripted sign-ins without an Origin header still get the cookie; login-CSRF refuses the cookie for disallowed origins while returning the token pair; sign-in over an existing session revokes it as `SUPERSEDED`; a failed credentials exchange mints nothing. **2. Cookie delivery** (`successful-session-cookie-delivery`, `secure-deployment-session-cookie`) The runtime side door (`AUTH_COOKIE_SAME_SITE=none` forces the secure path) pins the `__Host-`/`Secure`/`SameSite=None` variant in the default CI run. The exact production combination (`__Host-`, `Secure`, `SameSite=Lax`) is covered by a dedicated spec that requires the app to boot with an https `SERVER_URL`: the secure branch is decided by config, never the transport, so no TLS is needed. It skips itself on plain-http boots; CI runs it as an extra step on one shard with `SERVER_URL=https://localhost:3000`, including the `__Host-` round-trip and the plain-cookie-name downgrade refusal. **3. Per-request authentication and the CSRF read gate** (`successful-`/`failing-session-cookie-authentication`) A cookie-only request resolves the seeded user; a `sess_` token presented as Bearer is rejected; cookie-authenticated unsafe requests with a disallowed or missing Origin get 403 `CSRF_ORIGIN_MISMATCH`; an unknown session token is unauthenticated and its dead cookie is cleared. **3b. Workspace binding** (`successful-session-workspace-binding`) Tim signs into both seeded workspaces (apple and yc); each session row is bound to the workspace its exchange selected (`workspaceId` and `userWorkspaceId` pinned to the seed ids), and each cookie resolves to its own workspace context, with no request-side input able to pivot a session across workspaces. **3c. Credentialed CORS** (`cors-credentialed-origins`) Allowlisted origins get the reflected `Access-Control-Allow-Origin` plus `Access-Control-Allow-Credentials: true` and `Vary: Origin`, preflight included; other origins keep the public wildcard. See tooling notes: this surface was previously untestable. **4. Sessions API** (`successful-`/`failing-user-sessions-api`) `currentUserSessions` marks exactly the presented session as current; `revokeUserSession` revokes by id (`USER_REVOKED`) and drops it from the listing; `revokeAllOtherUserSessions` spares the presented session; cross-user revocation and unauthenticated listing are refused. **5. Exits** (`successful-sign-out`, `failing-session-expiration`) `signOut` revokes with `USER_SIGN_OUT`, clears the cookie, and reuse fails immediately (cache invalidated, not TTL-bound); a cookie-less sign-out clears nothing, so a cross-site POST cannot log a visitor out; absolute-lifetime and idle-timeout expiry both reject and clear the cookie. **7. Cleanup cron** (`user-session-cleanup-cron`) Both halves run in-process against fixtures spanning the 30d retention boundary. Sessions: expired/revoked-beyond-retention deleted; active, recently-expired, and idle-expired rows survive (the idle case pins the known predicate gap). Refresh tokens: old-expired and old-revoked deleted, fresh kept, and a long-expired token of another type survives, pinning the `type` filter that keeps the shared `appToken` table safe from the hard-delete. Not covered here by design: the impersonation park/restore sub-funnel (stage 6, follow-up) and the client-side funnel (stage 8, front-end scope). Password-change revocation and the renewal bridge are also left to follow-ups. ## How the flag is flipped `AUTH_COOKIE_SESSIONS_ENABLED` (and `AUTH_COOKIE_SAME_SITE` for the secure side door) are toggled at runtime through the admin panel config API, reusing the `twenty-config` test utils: `DatabaseConfigDriver.set` updates its cache synchronously and `TwentyConfigService` consults the DB driver before the env driver. No `.env.test` change, no app reboot, runs in the default CI environment without the `ci:auth-cookie-sessions` label. `SERVER_URL` is env-only, hence the dedicated CI step for the production secure-deployment spec. ## Shared tooling changes - **`applyCredentialedCors` extraction (src change)**: the integration harness booted with Nest's wildcard `cors: true`, not the credentialed-allowlist setup living in `main.ts`, so the CORS surface was untestable by construction. The setup moved into `applyCredentialedCors`, now called by both the production bootstrap and `createApp`, making the harness's CORS behavior the deployed one. Behavior-neutral for production. - `makeMetadataAPIRequest` accepts an explicit `null` token for unauthenticated requests. Passing `undefined` silently fell back to the default admin token (parameter defaults apply to `undefined`), which made supposedly public requests Bearer-authenticated, bypassing both the cookie auth path and the CSRF middleware. Existing call sites are unaffected. - The `GetLoginTokenFromCredentials` / `GetAuthTokensFromLoginToken` documents moved into shared query factories; the workspace-origin builder is extracted and generalized to any seeded subdomain (`buildWorkspaceOriginForSubdomain`, reused by `getAccessTokenForCredentials`). - Suite-local helpers: `signInWithCookieCapture` (full credentials exchange returning the raw supertest response, with a `workspaceSubdomain` option), `postMetadataOperationWithHeaders` (Origin/Cookie header control), cookie extraction for both cookie names, clearing-cookie detection, snapshot normalization (token and expiry redacted), and shared `ALLOWED_ORIGIN`/`DISALLOWED_ORIGIN` constants derived from `FRONTEND_URL`. Verified locally: full suite green in CI mode on both plain-http and https-`SERVER_URL` boots; oxlint and tsc clean. --------- Co-authored-by: Félix Malfait --- .github/workflows/ci-server.yaml | 6 + .../jest-integration-secure.config.ts | 19 ++ .../twenty-server/jest-integration.config.ts | 3 + packages/twenty-server/project.json | 10 + .../utils/apply-credentialed-cors.util.ts | 47 ++++ packages/twenty-server/src/main.ts | 37 +-- ...n-cookie-delivery.integration-spec.ts.snap | 3 + ...-session-creation.integration-spec.ts.snap | 3 + .../is-secure-deployment.constant.ts | 6 + .../constants/session-origins.constants.ts | 21 ++ ...s-credentialed-origins.integration-spec.ts | 57 ++++ ...-cookie-authentication.integration-spec.ts | 90 +++++++ ...iling-session-creation.integration-spec.ts | 93 +++++++ ...ing-session-expiration.integration-spec.ts | 76 ++++++ ...ling-user-sessions-api.integration-spec.ts | 69 +++++ ...-cookie-authentication.integration-spec.ts | 48 ++++ ...ession-cookie-delivery.integration-spec.ts | 50 ++++ ...ssful-session-creation.integration-spec.ts | 166 ++++++++++++ ...sion-workspace-binding.integration-spec.ts | 107 ++++++++ .../successful-sign-out.integration-spec.ts | 88 ++++++ ...sful-user-sessions-api.integration-spec.ts | 137 ++++++++++ ...r-session-cleanup-cron.integration-spec.ts | 251 ++++++++++++++++++ .../setup-database-config-override.util.ts | 53 ++++ .../utils/sign-in-with-cookie-capture.util.ts | 152 +++++++++++ .../utils/user-session-operations.util.ts | 95 +++++++ .../build-apple-workspace-origin.util.ts | 20 ++ .../get-access-token-for-credentials.util.ts | 14 +- ...ens-from-login-token.query-factory.util.ts | 34 +++ ...ken-from-credentials.query-factory.util.ts | 38 +++ .../utils/make-metadata-api-request.util.ts | 4 +- .../secure-session-cookie.integration-spec.ts | 125 +++++++++ .../test/integration/utils/create-app.ts | 7 +- 32 files changed, 1880 insertions(+), 49 deletions(-) create mode 100644 packages/twenty-server/jest-integration-secure.config.ts create mode 100644 packages/twenty-server/src/engine/core-modules/user-session/utils/apply-credentialed-cors.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/__snapshots__/successful-session-cookie-delivery.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/__snapshots__/successful-session-creation.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/constants/is-secure-deployment.constant.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/cors-credentialed-origins.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/failing-session-cookie-authentication.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/failing-session-creation.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/failing-session-expiration.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/failing-user-sessions-api.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-session-cookie-authentication.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-session-cookie-delivery.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-session-creation.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-session-workspace-binding.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-sign-out.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-user-sessions-api.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/user-session-cleanup-cron.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/utils/setup-database-config-override.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/build-apple-workspace-origin.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/get-auth-tokens-from-login-token.query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/get-login-token-from-credentials.query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/secure-deployment/suites/secure-session-cookie.integration-spec.ts diff --git a/.github/workflows/ci-server.yaml b/.github/workflows/ci-server.yaml index 4836344b71..e2689d4f04 100644 --- a/.github/workflows/ci-server.yaml +++ b/.github/workflows/ci-server.yaml @@ -346,6 +346,12 @@ jobs: tasks: 'test:integration' configuration: 'with-db-reset' args: --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }} + # The secure-deployment suite boots the app as a production https + # deployment (SERVER_URL is env-only, forced by its jest config), so it + # cannot ride the main run. One shard is enough. + - name: Server / Run secure-deployment integration tests + if: matrix.shard == 1 + run: npx nx run twenty-server:test:integration:secure cross-version-upgrade: needs: [upgrade-changed-files-check, server-build] diff --git a/packages/twenty-server/jest-integration-secure.config.ts b/packages/twenty-server/jest-integration-secure.config.ts new file mode 100644 index 0000000000..a68f9994ea --- /dev/null +++ b/packages/twenty-server/jest-integration-secure.config.ts @@ -0,0 +1,19 @@ +import { type JestConfigWithTsJest } from 'ts-jest'; + +import jestIntegrationConfig from './jest-integration.config'; + +// Secure-deployment integration suite: same harness, but the app boots as a +// production https deployment. SERVER_URL is env-only configuration, so it +// must be set before the app is created in globalSetup; forcing it here keeps +// `nx run twenty-server:test:integration:secure` self-contained. +if (!(process.env.SERVER_URL ?? '').startsWith('https://')) { + process.env.SERVER_URL = 'https://localhost:3000'; +} + +const jestConfig: JestConfigWithTsJest = { + ...jestIntegrationConfig, + testRegex: 'test/integration/secure-deployment/.*\\.integration-spec\\.ts$', + testPathIgnorePatterns: [], +}; + +export default jestConfig; diff --git a/packages/twenty-server/jest-integration.config.ts b/packages/twenty-server/jest-integration.config.ts index 9f95e560e9..184920b3dd 100644 --- a/packages/twenty-server/jest-integration.config.ts +++ b/packages/twenty-server/jest-integration.config.ts @@ -30,6 +30,9 @@ const jestConfig: JestConfigWithTsJest = { testPathIgnorePatterns: [ ...(isBillingEnabled ? [] : ['/test/integration/billing']), ...(isClickhouseEnabled ? [] : ['/test/integration/audit']), + // Requires an app booted as a secure deployment; run through + // jest-integration-secure.config.ts (nx test:integration:secure). + '/test/integration/secure-deployment', ], testRegex: '\\.integration-spec\\.ts$', modulePathIgnorePatterns: ['/dist'], diff --git a/packages/twenty-server/project.json b/packages/twenty-server/project.json index 9cf6148549..bb9421e2e6 100644 --- a/packages/twenty-server/project.json +++ b/packages/twenty-server/project.json @@ -18,6 +18,16 @@ }, "dependsOn": ["^build"] }, + "test:integration:secure": { + "executor": "nx:run-commands", + "options": { + "cwd": "packages/twenty-server", + "commands": [ + "NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=6144\" nx jest --config ./jest-integration-secure.config.ts --logHeapUsage" + ] + }, + "parallel": false + }, "test:integration": { "executor": "nx:run-commands", "options": { diff --git a/packages/twenty-server/src/engine/core-modules/user-session/utils/apply-credentialed-cors.util.ts b/packages/twenty-server/src/engine/core-modules/user-session/utils/apply-credentialed-cors.util.ts new file mode 100644 index 0000000000..0f02c369d7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/user-session/utils/apply-credentialed-cors.util.ts @@ -0,0 +1,47 @@ +import { type INestApplication } from '@nestjs/common'; + +import { type NextFunction, type Request, type Response } from 'express'; + +import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { resolveAllowedCredentialedOrigins } from 'src/engine/core-modules/user-session/utils/resolve-allowed-credentialed-origins.util'; + +// Shared between the production bootstrap and the integration test harness so +// the CORS behavior under test is the deployed one. +export const applyCredentialedCors = ( + app: INestApplication, + twentyConfigService: TwentyConfigService, +): void => { + // The cors package only emits Vary: Origin when it reflects one, so wildcard + // and reflected responses would share a cache entry and a credentialed + // request could be served the wildcard, which browsers reject. + app.use((_request: Request, response: Response, next: NextFunction) => { + response.vary('Origin'); + next(); + }); + + app.enableCors({ + // Resolved per request rather than once at boot: the origins derive from + // config the admin panel can change, and a snapshot would drift from the + // CSRF guard, which resolves them per request and would then disagree with + // CORS about the same origin. + origin: ( + origin: string | undefined, + callback: (error: Error | null, allow?: boolean | string) => void, + ) => { + if ( + origin && + resolveAllowedCredentialedOrigins(twentyConfigService).has( + origin.toLowerCase(), + ) + ) { + return callback(null, true); + } + + return callback(null, '*'); + }, + credentials: true, + // Expose WWW-Authenticate so browser-based MCP clients can read the + // resource_metadata pointer on 401. Required by MCP authorization spec. + exposedHeaders: ['WWW-Authenticate'], + }); +}; diff --git a/packages/twenty-server/src/main.ts b/packages/twenty-server/src/main.ts index b34838a3fc..3dbbd994a7 100644 --- a/packages/twenty-server/src/main.ts +++ b/packages/twenty-server/src/main.ts @@ -6,7 +6,6 @@ import { inspect } from 'util'; import bytes from 'bytes'; import { useContainer } from 'class-validator'; -import { type NextFunction, type Request, type Response } from 'express'; import session from 'express-session'; import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; @@ -18,7 +17,7 @@ import { LoggerService } from 'src/engine/core-modules/logger/logger.service'; import { getSessionStorageOptions } from 'src/engine/core-modules/session-storage/session-storage.module-factory'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util'; -import { resolveAllowedCredentialedOrigins } from 'src/engine/core-modules/user-session/utils/resolve-allowed-credentialed-origins.util'; +import { applyCredentialedCors } from 'src/engine/core-modules/user-session/utils/apply-credentialed-cors.util'; import { shouldCaptureException } from 'src/engine/utils/global-exception-handler.util'; import { AppModule } from './app.module'; @@ -66,39 +65,7 @@ const bootstrap = async () => { app.set('trust proxy', trustProxy); - // The cors package only emits Vary: Origin when it reflects one, so wildcard - // and reflected responses would share a cache entry and a credentialed - // request could be served the wildcard, which browsers reject. - app.use((_request: Request, response: Response, next: NextFunction) => { - response.vary('Origin'); - next(); - }); - - app.enableCors({ - // Resolved per request rather than once at boot: the origins derive from - // config the admin panel can change, and a snapshot would drift from the - // CSRF guard, which resolves them per request and would then disagree with - // CORS about the same origin. - origin: ( - origin: string | undefined, - callback: (error: Error | null, allow?: boolean | string) => void, - ) => { - if ( - origin && - resolveAllowedCredentialedOrigins(twentyConfigService).has( - origin.toLowerCase(), - ) - ) { - return callback(null, true); - } - - return callback(null, '*'); - }, - credentials: true, - // Expose WWW-Authenticate so browser-based MCP clients can read the - // resource_metadata pointer on 401. Required by MCP authorization spec. - exposedHeaders: ['WWW-Authenticate'], - }); + applyCredentialedCors(app, twentyConfigService); app.use(session(getSessionStorageOptions(twentyConfigService))); diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/__snapshots__/successful-session-cookie-delivery.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/__snapshots__/successful-session-cookie-delivery.integration-spec.ts.snap new file mode 100644 index 0000000000..10d315d198 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/__snapshots__/successful-session-cookie-delivery.integration-spec.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`successful session cookie delivery on a secure deployment (integration) should deliver the host-locked secure cookie variant: secure-session-cookie 1`] = `"__Host-twenty-session=sess_; Path=/; Expires=; HttpOnly; Secure; SameSite=None"`; diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/__snapshots__/successful-session-creation.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/__snapshots__/successful-session-creation.integration-spec.ts.snap new file mode 100644 index 0000000000..612d750412 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/__snapshots__/successful-session-creation.integration-spec.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`successful user session creation on auth exchanges (integration) should mint a session and set an httpOnly cookie on sign-in from an allowed origin: session-cookie 1`] = `"twenty-session=sess_; Path=/; Expires=; HttpOnly; SameSite=Lax"`; diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/constants/is-secure-deployment.constant.ts b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/constants/is-secure-deployment.constant.ts new file mode 100644 index 0000000000..7fab7dd48a --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/constants/is-secure-deployment.constant.ts @@ -0,0 +1,6 @@ +// The secure/insecure cookie branch is decided by configuration, never by the +// transport: isSecureDeployment() reads SERVER_URL, which is env-only and so +// cannot be flipped at runtime the way the SameSite=None side door can. +export const IS_SECURE_DEPLOYMENT = (process.env.SERVER_URL ?? '').startsWith( + 'https://', +); diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants.ts b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants.ts new file mode 100644 index 0000000000..367c60b50f --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants.ts @@ -0,0 +1,21 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +// The cookie-issuance gate and the CSRF middleware allowlist FRONTEND_URL +// (resolveAllowedCredentialedOrigins), which .env.test sets to +// http://localhost:3001. Deriving it here keeps the suite honest if the test +// environment ever moves, and normalizing to URL.origin keeps the header +// aligned with the server-side comparison when FRONTEND_URL carries a path or +// trailing slash. +const resolveAllowedOrigin = (): string => { + const frontendUrl = process.env.FRONTEND_URL; + + if (!isNonEmptyString(frontendUrl)) { + return 'http://localhost:3001'; + } + + return new URL(frontendUrl).origin; +}; + +export const ALLOWED_ORIGIN = resolveAllowedOrigin(); + +export const DISALLOWED_ORIGIN = 'https://attacker.example.com'; diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/cors-credentialed-origins.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/cors-credentialed-origins.integration-spec.ts new file mode 100644 index 0000000000..5736328dcb --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/cors-credentialed-origins.integration-spec.ts @@ -0,0 +1,57 @@ +import request from 'supertest'; + +import { + ALLOWED_ORIGIN, + DISALLOWED_ORIGIN, +} from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants'; + +const SERVER_URL = `http://localhost:${APP_PORT}`; + +// The browser-side half of cross-origin cookie support, sibling of the CSRF +// middleware: allowlisted origins get credentialed CORS (reflected origin plus +// allow-credentials), everything else keeps the public wildcard. Runs +// unconditionally: the CORS setup does not depend on the cookie-sessions flag. +describe('credentialed CORS origins (integration)', () => { + it('should reflect an allowlisted origin with credentials and Vary: Origin', async () => { + const response = await request(SERVER_URL) + .get('/client-config') + .set('Origin', ALLOWED_ORIGIN) + .expect(200); + + expect(response.headers['access-control-allow-origin']).toBe( + ALLOWED_ORIGIN, + ); + expect(response.headers['access-control-allow-credentials']).toBe('true'); + // Without Vary: Origin a shared cache could serve the wildcard response + // to an allowlisted origin, whose credentialed request the browser would + // then reject. + expect(response.headers.vary).toContain('Origin'); + }); + + it('should answer a preflight for an allowlisted origin with credentials', async () => { + const response = await request(SERVER_URL) + .options('/metadata') + .set('Origin', ALLOWED_ORIGIN) + .set('Access-Control-Request-Method', 'POST') + .set('Access-Control-Request-Headers', 'content-type'); + + expect(response.status).toBeLessThan(300); + expect(response.headers['access-control-allow-origin']).toBe( + ALLOWED_ORIGIN, + ); + expect(response.headers['access-control-allow-credentials']).toBe('true'); + // Preflights are cacheable, so this is the response a shared cache is most + // likely to reuse across origins. + expect(response.headers.vary).toContain('Origin'); + }); + + it('should fall back to the public wildcard for a non-allowlisted origin', async () => { + const response = await request(SERVER_URL) + .get('/client-config') + .set('Origin', DISALLOWED_ORIGIN) + .expect(200); + + expect(response.headers['access-control-allow-origin']).toBe('*'); + expect(response.headers.vary).toContain('Origin'); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/failing-session-cookie-authentication.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/failing-session-cookie-authentication.integration-spec.ts new file mode 100644 index 0000000000..f13e0138f8 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/failing-session-cookie-authentication.integration-spec.ts @@ -0,0 +1,90 @@ +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; + +import { + extractSessionCookie, + hasClearingCookie, + postMetadataOperationWithHeaders, + signInWithCookieCapture, +} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util'; +import { currentUserIdentityQueryFactory } from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util'; +import { setupDatabaseConfigOverrideForSuite } from 'test/integration/graphql/suites/auth/user-sessions/utils/setup-database-config-override.util'; + +import { generateUserSessionToken } from 'src/engine/core-modules/user-session/utils/generate-user-session-token.util'; + +import { + ALLOWED_ORIGIN, + DISALLOWED_ORIGIN, +} from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants'; + +describe('failing session cookie authentication (integration)', () => { + setupDatabaseConfigOverrideForSuite('AUTH_COOKIE_SESSIONS_ENABLED', true); + + let sessionToken: string; + let sessionCookieName: string; + let sessionCookieHeader: string; + + beforeAll(async () => { + const signInResponse = await signInWithCookieCapture({ + originHeader: ALLOWED_ORIGIN, + }); + const sessionCookie = extractSessionCookie(signInResponse); + + if (!sessionCookie) { + throw new Error('Expected a session cookie from sign-in'); + } + + sessionToken = sessionCookie.sessionToken; + sessionCookieName = sessionCookie.cookieName; + sessionCookieHeader = sessionCookie.cookieHeader; + }); + + it('should reject a session token presented as a Bearer header', async () => { + // Cookie-only by design: accepting sess_ tokens as Bearer would reopen + // the XSS-exfiltration surface cookie sessions close. + const response = await makeMetadataAPIRequest( + currentUserIdentityQueryFactory(), + sessionToken, + ).expect(200); + + expect(response.body.data).toBeUndefined(); + expect(response.body.errors).toBeDefined(); + }); + + it('should return 403 on a cookie-authenticated request from a disallowed origin', async () => { + const response = await postMetadataOperationWithHeaders( + currentUserIdentityQueryFactory(), + { + originHeader: DISALLOWED_ORIGIN, + cookieHeader: sessionCookieHeader, + }, + 403, + ); + + expect(response.body.error).toBe('CSRF_ORIGIN_MISMATCH'); + }); + + it('should return 403 on a cookie-authenticated request without an Origin header, failing closed', async () => { + const response = await postMetadataOperationWithHeaders( + currentUserIdentityQueryFactory(), + { cookieHeader: sessionCookieHeader }, + 403, + ); + + expect(response.body.error).toBe('CSRF_ORIGIN_MISMATCH'); + }); + + it('should treat an unknown session token as unauthenticated and clear the dead cookie', async () => { + const unknownSessionToken = generateUserSessionToken(); + + const response = await postMetadataOperationWithHeaders( + currentUserIdentityQueryFactory(), + { + originHeader: ALLOWED_ORIGIN, + cookieHeader: `${sessionCookieName}=${unknownSessionToken}`, + }, + ); + + expect(response.body.errors).toBeDefined(); + expect(hasClearingCookie(response)).toBe(true); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/failing-session-creation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/failing-session-creation.integration-spec.ts new file mode 100644 index 0000000000..e67453771c --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/failing-session-creation.integration-spec.ts @@ -0,0 +1,93 @@ +import { buildAppleWorkspaceOrigin } from 'test/integration/graphql/utils/build-apple-workspace-origin.util'; +import { getLoginTokenFromCredentialsQueryFactory } from 'test/integration/graphql/utils/get-login-token-from-credentials.query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { getCoreRepository } from 'test/integration/utils/get-core-repository.util'; +import { setupDatabaseConfigOverrideForSuite } from 'test/integration/graphql/suites/auth/user-sessions/utils/setup-database-config-override.util'; + +import { + extractSessionCookie, + signInWithCookieCapture, +} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util'; + +import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity'; + +import { + ALLOWED_ORIGIN, + DISALLOWED_ORIGIN, +} from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants'; + +describe('failing user session creation on auth exchanges (integration)', () => { + describe('with cookie sessions disabled', () => { + // Explicit rather than assuming the flag's default: another suite or a + // reused database could have left an override behind. + setupDatabaseConfigOverrideForSuite('AUTH_COOKIE_SESSIONS_ENABLED', false); + + it('should return auth tokens without setting a session cookie', async () => { + const userSessionRepository = + getCoreRepository(UserSessionEntity); + const sessionCountBefore = await userSessionRepository.count(); + + const response = await signInWithCookieCapture({ + originHeader: ALLOWED_ORIGIN, + }); + + expect( + response.body.data.getAuthTokensFromLoginToken.tokens.refreshToken + .token, + ).toBeDefined(); + expect(extractSessionCookie(response)).toBeUndefined(); + + const sessionCountAfter = await userSessionRepository.count(); + + expect(sessionCountAfter).toBe(sessionCountBefore); + }); + }); + + describe('with cookie sessions enabled', () => { + setupDatabaseConfigOverrideForSuite('AUTH_COOKIE_SESSIONS_ENABLED', true); + + it('should refuse the cookie but still return tokens on a sign-in from a disallowed origin (login-CSRF)', async () => { + const userSessionRepository = + getCoreRepository(UserSessionEntity); + const sessionCountBefore = await userSessionRepository.count(); + + const response = await signInWithCookieCapture({ + originHeader: DISALLOWED_ORIGIN, + }); + + expect( + response.body.data.getAuthTokensFromLoginToken.tokens.refreshToken + .token, + ).toBeDefined(); + expect(extractSessionCookie(response)).toBeUndefined(); + + const sessionCountAfter = await userSessionRepository.count(); + + expect(sessionCountAfter).toBe(sessionCountBefore); + }); + + it('should mint nothing when the credentials exchange itself fails', async () => { + const userSessionRepository = + getCoreRepository(UserSessionEntity); + const sessionCountBefore = await userSessionRepository.count(); + + const response = await makeMetadataAPIRequest( + getLoginTokenFromCredentialsQueryFactory({ + email: 'tim@apple.dev', + password: 'wrong-password', + origin: buildAppleWorkspaceOrigin(), + }), + null, + ) + .set('Origin', ALLOWED_ORIGIN) + .expect(200); + + expect(response.body.errors).toBeDefined(); + expect(extractSessionCookie(response)).toBeUndefined(); + + const sessionCountAfter = await userSessionRepository.count(); + + expect(sessionCountAfter).toBe(sessionCountBefore); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/failing-session-expiration.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/failing-session-expiration.integration-spec.ts new file mode 100644 index 0000000000..121d3abff4 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/failing-session-expiration.integration-spec.ts @@ -0,0 +1,76 @@ +import { getCoreRepository } from 'test/integration/utils/get-core-repository.util'; + +import { + extractSessionCookie, + hasClearingCookie, + postMetadataOperationWithHeaders, + signInWithCookieCapture, +} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util'; +import { currentUserIdentityQueryFactory } from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util'; + +import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity'; +import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util'; + +import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants'; +import { setupDatabaseConfigOverrideForSuite } from 'test/integration/graphql/suites/auth/user-sessions/utils/setup-database-config-override.util'; + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +// Sessions created here are never resolved before the row is tampered with, +// so the read-through cache holds no entry and the checks hit the database. +describe('failing session expiration (integration)', () => { + setupDatabaseConfigOverrideForSuite('AUTH_COOKIE_SESSIONS_ENABLED', true); + + const signInAndTamper = async ( + tamper: Partial>, + ): Promise => { + const signInResponse = await signInWithCookieCapture({ + originHeader: ALLOWED_ORIGIN, + }); + const sessionCookie = extractSessionCookie(signInResponse); + + if (!sessionCookie) { + throw new Error('Expected a session cookie from sign-in'); + } + + await getCoreRepository(UserSessionEntity).update( + { tokenHash: hashUserSessionToken(sessionCookie.sessionToken) }, + tamper, + ); + + return sessionCookie.cookieHeader; + }; + + const expectCookieRejected = async ( + sessionCookieHeader: string, + ): Promise => { + const response = await postMetadataOperationWithHeaders( + currentUserIdentityQueryFactory(), + { + originHeader: ALLOWED_ORIGIN, + cookieHeader: sessionCookieHeader, + }, + ); + + expect(response.body.errors).toBeDefined(); + expect(hasClearingCookie(response)).toBe(true); + }; + + it('should reject a session past its absolute lifetime and clear the cookie', async () => { + const sessionCookieHeader = await signInAndTamper({ + expiresAt: new Date(Date.now() - ONE_DAY_MS), + }); + + await expectCookieRejected(sessionCookieHeader); + }); + + it('should reject a session past the idle timeout and clear the cookie', async () => { + // 31 days idle exceeds the 30d SESSION_IDLE_TIMEOUT while the absolute + // 180d lifetime is still far in the future. + const sessionCookieHeader = await signInAndTamper({ + lastActiveAt: new Date(Date.now() - 31 * ONE_DAY_MS), + }); + + await expectCookieRejected(sessionCookieHeader); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/failing-user-sessions-api.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/failing-user-sessions-api.integration-spec.ts new file mode 100644 index 0000000000..9916af1876 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/failing-user-sessions-api.integration-spec.ts @@ -0,0 +1,69 @@ +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { getCoreRepository } from 'test/integration/utils/get-core-repository.util'; + +import { + extractSessionCookie, + postMetadataOperationWithHeaders, + signInWithCookieCapture, +} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util'; +import { + currentUserSessionsQueryFactory, + revokeUserSessionQueryFactory, +} from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util'; + +import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity'; +import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util'; + +import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants'; +import { setupDatabaseConfigOverrideForSuite } from 'test/integration/graphql/suites/auth/user-sessions/utils/setup-database-config-override.util'; + +describe('failing user sessions API (integration)', () => { + setupDatabaseConfigOverrideForSuite('AUTH_COOKIE_SESSIONS_ENABLED', true); + + let timSessionId: string; + + beforeAll(async () => { + const signInResponse = await signInWithCookieCapture({ + originHeader: ALLOWED_ORIGIN, + }); + const sessionCookie = extractSessionCookie(signInResponse); + + if (!sessionCookie) { + throw new Error('Expected a session cookie from sign-in'); + } + + const sessionRow = await getCoreRepository( + UserSessionEntity, + ).findOneBy({ tokenHash: hashUserSessionToken(sessionCookie.sessionToken) }); + + if (!sessionRow) { + throw new Error('Expected a persisted session row'); + } + + timSessionId = sessionRow.id; + }); + + it('should reject an unauthenticated sessions listing', async () => { + const response = await postMetadataOperationWithHeaders( + currentUserSessionsQueryFactory(), + { originHeader: ALLOWED_ORIGIN }, + ); + + expect(response.body.errors).toBeDefined(); + }); + + it("should refuse to revoke another user's session", async () => { + // Default token authenticates Jane, a different seeded user than Tim. + const response = await makeMetadataAPIRequest( + revokeUserSessionQueryFactory({ userSessionId: timSessionId }), + ).expect(200); + + expect(response.body.errors).toBeDefined(); + + const sessionRow = await getCoreRepository( + UserSessionEntity, + ).findOneBy({ id: timSessionId }); + + expect(sessionRow?.revokedAt).toBeNull(); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-session-cookie-authentication.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-session-cookie-authentication.integration-spec.ts new file mode 100644 index 0000000000..a37b1a9a65 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-session-cookie-authentication.integration-spec.ts @@ -0,0 +1,48 @@ + +import { + extractSessionCookie, + postMetadataOperationWithHeaders, + signInWithCookieCapture, +} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util'; +import { currentUserIdentityQueryFactory } from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util'; + +import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util'; + +import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants'; +import { setupDatabaseConfigOverrideForSuite } from 'test/integration/graphql/suites/auth/user-sessions/utils/setup-database-config-override.util'; + +describe('successful session cookie authentication (integration)', () => { + setupDatabaseConfigOverrideForSuite('AUTH_COOKIE_SESSIONS_ENABLED', true); + + let sessionCookieHeader: string; + + beforeAll(async () => { + const signInResponse = await signInWithCookieCapture({ + originHeader: ALLOWED_ORIGIN, + }); + const sessionCookie = extractSessionCookie(signInResponse); + + if (!sessionCookie) { + throw new Error('Expected a session cookie from sign-in'); + } + + sessionCookieHeader = sessionCookie.cookieHeader; + }); + + it('should authenticate a request carrying only the session cookie', async () => { + const response = await postMetadataOperationWithHeaders( + currentUserIdentityQueryFactory(), + { + originHeader: ALLOWED_ORIGIN, + cookieHeader: sessionCookieHeader, + }, + ); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.currentUser).toMatchObject({ + id: USER_DATA_SEED_IDS.TIM, + email: 'tim@apple.dev', + }); + }); + +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-session-cookie-delivery.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-session-cookie-delivery.integration-spec.ts new file mode 100644 index 0000000000..b41cff40a9 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-session-cookie-delivery.integration-spec.ts @@ -0,0 +1,50 @@ + +import { + extractSessionCookie, + normalizeSessionCookieForSnapshot, + signInWithCookieCapture, +} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util'; + +import { USER_SESSION_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-cookie-name.constant'; +import { USER_SESSION_SECURE_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-secure-cookie-name.constant'; + +import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants'; +import { setupDatabaseConfigOverrideForSuite } from 'test/integration/graphql/suites/auth/user-sessions/utils/setup-database-config-override.util'; + +// SameSite=None forces Secure (browsers reject the combination without it), +// which is the one secure-deployment trigger reachable at runtime: SERVER_URL +// stays plain http in .env.test. The production combination (https SERVER_URL +// with the SameSite=Lax default) is covered by +// secure-deployment-session-cookie.integration-spec.ts under a dedicated app +// boot. +describe('successful session cookie delivery on a secure deployment (integration)', () => { + setupDatabaseConfigOverrideForSuite('AUTH_COOKIE_SESSIONS_ENABLED', true); + setupDatabaseConfigOverrideForSuite('AUTH_COOKIE_SAME_SITE', 'none'); + + it('should deliver the host-locked secure cookie variant', async () => { + const response = await signInWithCookieCapture({ + originHeader: ALLOWED_ORIGIN, + }); + + expect( + extractSessionCookie(response, USER_SESSION_COOKIE_NAME), + ).toBeUndefined(); + + const secureSessionCookie = extractSessionCookie( + response, + USER_SESSION_SECURE_COOKIE_NAME, + ); + + expect(secureSessionCookie).toBeDefined(); + + if (!secureSessionCookie) { + throw new Error('Expected a secure session cookie'); + } + + // __Host- requires Secure, Path=/ and no Domain; browsers enforce the + // prefix contract, so the snapshot pins host-only scoping. + expect( + normalizeSessionCookieForSnapshot(secureSessionCookie.rawCookie), + ).toMatchSnapshot('secure-session-cookie'); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-session-creation.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-session-creation.integration-spec.ts new file mode 100644 index 0000000000..78ca630d91 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-session-creation.integration-spec.ts @@ -0,0 +1,166 @@ +import { getCoreRepository } from 'test/integration/utils/get-core-repository.util'; + +import { + extractSessionCookie, + normalizeSessionCookieForSnapshot, + signInWithCookieCapture, +} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util'; + +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; +import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity'; +import { UserSessionRevokedReason } from 'src/engine/core-modules/user-session/types/user-session-revoked-reason.type'; +import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util'; + +import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants'; +import { IS_SECURE_DEPLOYMENT } from 'test/integration/graphql/suites/auth/user-sessions/constants/is-secure-deployment.constant'; +import { setupDatabaseConfigOverrideForSuite } from 'test/integration/graphql/suites/auth/user-sessions/utils/setup-database-config-override.util'; + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const SESSION_ABSOLUTE_LIFETIME_MS = 180 * ONE_DAY_MS; + +describe('successful user session creation on auth exchanges (integration)', () => { + setupDatabaseConfigOverrideForSuite('AUTH_COOKIE_SESSIONS_ENABLED', true); + + let firstSessionToken: string; + let firstSessionCookieHeader: string; + + it('should mint a session and set an httpOnly cookie on sign-in from an allowed origin', async () => { + const response = await signInWithCookieCapture({ + originHeader: ALLOWED_ORIGIN, + }); + + expect( + response.body.data.getAuthTokensFromLoginToken.tokens + .accessOrWorkspaceAgnosticToken.token, + ).toBeDefined(); + + const sessionCookie = extractSessionCookie(response); + + expect(sessionCookie).toBeDefined(); + + if (!sessionCookie) { + throw new Error('Expected a session cookie'); + } + + firstSessionToken = sessionCookie.sessionToken; + firstSessionCookieHeader = sessionCookie.cookieHeader; + + // Pins name, sess_ prefix, attribute list and order in one place. The + // absences matter as much as the presences: no Secure and no __Host- + // (plain-http test deployment), and no Domain, which is what makes the + // cookie host-only so browsers never send it to sibling workspace + // subdomains. The secure boot pins its own shape in + // secure-deployment-session-cookie.integration-spec.ts, so this snapshot + // only applies to the insecure one. + if (!IS_SECURE_DEPLOYMENT) { + expect( + normalizeSessionCookieForSnapshot(sessionCookie.rawCookie), + ).toMatchSnapshot('session-cookie'); + } + + const expiresAttribute = sessionCookie.rawCookie + .split(';') + .map((part) => part.trim()) + .find((part) => part.startsWith('Expires=')); + + expect(expiresAttribute).toBeDefined(); + + const cookieLifetimeMs = + new Date(expiresAttribute!.slice('Expires='.length)).getTime() - + Date.now(); + + expect(cookieLifetimeMs).toBeGreaterThan( + SESSION_ABSOLUTE_LIFETIME_MS - ONE_DAY_MS, + ); + expect(cookieLifetimeMs).toBeLessThan( + SESSION_ABSOLUTE_LIFETIME_MS + ONE_DAY_MS, + ); + }); + + it('should store only the token hash at rest, with the expected session shape', async () => { + const userSessionRepository = + getCoreRepository(UserSessionEntity); + + const storedByRawToken = await userSessionRepository.findOneBy({ + tokenHash: firstSessionToken, + }); + + expect(storedByRawToken).toBeNull(); + + const session = await userSessionRepository.findOneBy({ + tokenHash: hashUserSessionToken(firstSessionToken), + }); + + expect(session).not.toBeNull(); + + if (!session) { + throw new Error('Expected a persisted session'); + } + + expect(session).toMatchObject({ + userId: expect.any(String), + // Bound to the workspace the GraphQL origin selected: the server-side + // half of workspace scoping, alongside the host-only cookie. + workspaceId: SEED_APPLE_WORKSPACE_ID, + userWorkspaceId: expect.any(String), + isImpersonating: false, + revokedAt: null, + revokedReason: null, + // expect.any(Date) would fail: the entity's Date comes from the app's + // vm context, so it is not an instanceof the test context's Date. + lastActiveAt: expect.anything(), + }); + + const sessionLifetimeMs = + session.expiresAt.getTime() - session.createdAt.getTime(); + + expect(sessionLifetimeMs).toBeGreaterThan( + SESSION_ABSOLUTE_LIFETIME_MS - ONE_DAY_MS, + ); + expect(sessionLifetimeMs).toBeLessThan( + SESSION_ABSOLUTE_LIFETIME_MS + ONE_DAY_MS, + ); + }); + + it('should set the cookie on a sign-in without an Origin header, keeping scripted sign-ins working', async () => { + const response = await signInWithCookieCapture(); + + expect( + response.body.data.getAuthTokensFromLoginToken.tokens + .accessOrWorkspaceAgnosticToken.token, + ).toBeDefined(); + expect(extractSessionCookie(response)).toBeDefined(); + }); + + it('should revoke the presented session as superseded when signing in over it', async () => { + const response = await signInWithCookieCapture({ + originHeader: ALLOWED_ORIGIN, + cookieHeader: firstSessionCookieHeader, + }); + + const newSessionCookie = extractSessionCookie(response); + + expect(newSessionCookie).toBeDefined(); + expect(newSessionCookie?.sessionToken).not.toBe(firstSessionToken); + + const userSessionRepository = + getCoreRepository(UserSessionEntity); + + const supersededSession = await userSessionRepository.findOneBy({ + tokenHash: hashUserSessionToken(firstSessionToken), + }); + + expect(supersededSession).not.toBeNull(); + expect(supersededSession?.revokedAt).not.toBeNull(); + expect(supersededSession?.revokedReason).toBe( + UserSessionRevokedReason.Superseded, + ); + + const newSession = await userSessionRepository.findOneBy({ + tokenHash: hashUserSessionToken(newSessionCookie?.sessionToken as string), + }); + + expect(newSession).not.toBeNull(); + expect(newSession?.revokedAt).toBeNull(); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-session-workspace-binding.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-session-workspace-binding.integration-spec.ts new file mode 100644 index 0000000000..69fc839a55 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-session-workspace-binding.integration-spec.ts @@ -0,0 +1,107 @@ +import { getCoreRepository } from 'test/integration/utils/get-core-repository.util'; + +import { + extractSessionCookie, + postMetadataOperationWithHeaders, + signInWithCookieCapture, +} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util'; +import { currentUserWorkspaceContextQueryFactory } from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util'; + +import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants'; +import { setupDatabaseConfigOverrideForSuite } from 'test/integration/graphql/suites/auth/user-sessions/utils/setup-database-config-override.util'; + +import { + SEED_APPLE_WORKSPACE_ID, + SEED_YCOMBINATOR_WORKSPACE_ID, +} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; +import { USER_WORKSPACE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util'; +import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity'; +import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util'; + +// Tim is seeded in both the apple and yc workspaces, which is what makes this +// provable: same user, same credentials, two sessions, and each cookie can +// only ever reach the workspace its exchange selected. No request-side input +// (header, variable, origin) lets a session pivot to another workspace; the +// context is rebuilt from the session row alone. +describe('successful session workspace binding (integration)', () => { + setupDatabaseConfigOverrideForSuite('AUTH_COOKIE_SESSIONS_ENABLED', true); + + let appleSessionToken: string; + let ycSessionToken: string; + let appleSessionCookieHeader: string; + let ycSessionCookieHeader: string; + + const fetchWorkspaceContext = async (sessionCookieHeader: string) => { + const response = await postMetadataOperationWithHeaders( + currentUserWorkspaceContextQueryFactory(), + { + originHeader: ALLOWED_ORIGIN, + cookieHeader: sessionCookieHeader, + }, + ); + + expect(response.body.errors).toBeUndefined(); + + return response.body.data.currentUser; + }; + + beforeAll(async () => { + const appleResponse = await signInWithCookieCapture({ + originHeader: ALLOWED_ORIGIN, + }); + const ycResponse = await signInWithCookieCapture({ + workspaceSubdomain: 'yc', + originHeader: ALLOWED_ORIGIN, + }); + + const appleCookie = extractSessionCookie(appleResponse); + const ycCookie = extractSessionCookie(ycResponse); + + if (!appleCookie || !ycCookie) { + throw new Error('Expected session cookies from both workspace sign-ins'); + } + + appleSessionToken = appleCookie.sessionToken; + ycSessionToken = ycCookie.sessionToken; + appleSessionCookieHeader = appleCookie.cookieHeader; + ycSessionCookieHeader = ycCookie.cookieHeader; + }); + + it('should persist each session bound to the workspace its exchange selected', async () => { + const userSessionRepository = + getCoreRepository(UserSessionEntity); + + const appleSession = await userSessionRepository.findOneBy({ + tokenHash: hashUserSessionToken(appleSessionToken), + }); + const ycSession = await userSessionRepository.findOneBy({ + tokenHash: hashUserSessionToken(ycSessionToken), + }); + + expect(appleSession?.workspaceId).toBe(SEED_APPLE_WORKSPACE_ID); + expect(appleSession?.userWorkspaceId).toBe( + USER_WORKSPACE_DATA_SEED_IDS.TIM, + ); + expect(ycSession?.workspaceId).toBe(SEED_YCOMBINATOR_WORKSPACE_ID); + expect(ycSession?.userWorkspaceId).toBe( + USER_WORKSPACE_DATA_SEED_IDS.TIM_ACME, + ); + }); + + it('should resolve the auth context of each cookie to its own workspace, with no cross-workspace pivot', async () => { + const appleContext = await fetchWorkspaceContext(appleSessionCookieHeader); + const ycContext = await fetchWorkspaceContext(ycSessionCookieHeader); + + expect(appleContext.email).toBe('tim@apple.dev'); + expect(appleContext.currentWorkspace.id).toBe(SEED_APPLE_WORKSPACE_ID); + expect(appleContext.currentUserWorkspace.id).toBe( + USER_WORKSPACE_DATA_SEED_IDS.TIM, + ); + + expect(ycContext.email).toBe('tim@apple.dev'); + expect(ycContext.currentWorkspace.id).toBe(SEED_YCOMBINATOR_WORKSPACE_ID); + expect(ycContext.currentUserWorkspace.id).toBe( + USER_WORKSPACE_DATA_SEED_IDS.TIM_ACME, + ); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-sign-out.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-sign-out.integration-spec.ts new file mode 100644 index 0000000000..f4e289449b --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-sign-out.integration-spec.ts @@ -0,0 +1,88 @@ +import { getCoreRepository } from 'test/integration/utils/get-core-repository.util'; + +import { + extractSessionCookie, + hasClearingCookie, + postMetadataOperationWithHeaders, + signInWithCookieCapture, +} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util'; +import { + currentUserIdentityQueryFactory, + signOutQueryFactory, +} from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util'; + +import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity'; +import { UserSessionRevokedReason } from 'src/engine/core-modules/user-session/types/user-session-revoked-reason.type'; +import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util'; + +import { + ALLOWED_ORIGIN, + DISALLOWED_ORIGIN, +} from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants'; +import { setupDatabaseConfigOverrideForSuite } from 'test/integration/graphql/suites/auth/user-sessions/utils/setup-database-config-override.util'; + +describe('successful sign-out (integration)', () => { + setupDatabaseConfigOverrideForSuite('AUTH_COOKIE_SESSIONS_ENABLED', true); + + it('should revoke the presented session, clear the cookie, and reject its reuse', async () => { + const signInResponse = await signInWithCookieCapture({ + originHeader: ALLOWED_ORIGIN, + }); + const sessionCookie = extractSessionCookie(signInResponse); + + if (!sessionCookie) { + throw new Error('Expected a session cookie from sign-in'); + } + + const signOutResponse = await postMetadataOperationWithHeaders( + signOutQueryFactory(), + { + originHeader: ALLOWED_ORIGIN, + cookieHeader: sessionCookie.cookieHeader, + }, + ); + + expect(signOutResponse.body.errors).toBeUndefined(); + expect(signOutResponse.body.data.signOut).toBe(true); + expect(hasClearingCookie(signOutResponse)).toBe(true); + + const revokedRow = await getCoreRepository( + UserSessionEntity, + ).findOneBy({ + tokenHash: hashUserSessionToken(sessionCookie.sessionToken), + }); + + expect(revokedRow?.revokedAt).not.toBeNull(); + expect(revokedRow?.revokedReason).toBe( + UserSessionRevokedReason.UserSignOut, + ); + + // Revocation invalidates the cache, so reuse fails immediately, not + // after the cache TTL. + const reuseResponse = await postMetadataOperationWithHeaders( + currentUserIdentityQueryFactory(), + { + originHeader: ALLOWED_ORIGIN, + cookieHeader: sessionCookie.cookieHeader, + }, + ); + + expect(reuseResponse.body.errors).toBeDefined(); + expect(hasClearingCookie(reuseResponse)).toBe(true); + }); + + it('should not clear anything on a cookie-less sign-out, so a cross-site POST cannot log a visitor out', async () => { + // What a cross-site forgery actually looks like server-side: SameSite=Lax + // keeps the cookie off the request, and the attacker page's origin comes + // along. CSRF does not apply (no cookie), so the mutation runs and must + // still clear nothing. + const response = await postMetadataOperationWithHeaders( + signOutQueryFactory(), + { originHeader: DISALLOWED_ORIGIN }, + ); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.signOut).toBe(true); + expect(hasClearingCookie(response)).toBe(false); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-user-sessions-api.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-user-sessions-api.integration-spec.ts new file mode 100644 index 0000000000..af948ec1ee --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/successful-user-sessions-api.integration-spec.ts @@ -0,0 +1,137 @@ +import { getCoreRepository } from 'test/integration/utils/get-core-repository.util'; + +import { + extractSessionCookie, + postMetadataOperationWithHeaders, + signInWithCookieCapture, +} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util'; +import { + currentUserSessionsQueryFactory, + revokeAllOtherUserSessionsQueryFactory, + revokeUserSessionQueryFactory, +} from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util'; + +import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity'; +import { UserSessionRevokedReason } from 'src/engine/core-modules/user-session/types/user-session-revoked-reason.type'; +import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util'; + +import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants'; +import { setupDatabaseConfigOverrideForSuite } from 'test/integration/graphql/suites/auth/user-sessions/utils/setup-database-config-override.util'; + +type UserSessionApiEntry = { + id: string; + isCurrent: boolean; + isImpersonating: boolean; + authProvider: string; +}; + +describe('successful user sessions API (integration)', () => { + setupDatabaseConfigOverrideForSuite('AUTH_COOKIE_SESSIONS_ENABLED', true); + + let currentSessionCookieHeader: string; + let otherSessionToken: string; + + const fetchSessions = async (): Promise => { + const response = await postMetadataOperationWithHeaders( + currentUserSessionsQueryFactory(), + { + originHeader: ALLOWED_ORIGIN, + cookieHeader: currentSessionCookieHeader, + }, + ); + + expect(response.body.errors).toBeUndefined(); + + return response.body.data.currentUserSessions; + }; + + beforeAll(async () => { + // Two devices: the "other" one signs in first so the second sign-in does + // not supersede it (no cookie presented on either exchange). + const otherDeviceResponse = await signInWithCookieCapture({ + originHeader: ALLOWED_ORIGIN, + }); + const currentDeviceResponse = await signInWithCookieCapture({ + originHeader: ALLOWED_ORIGIN, + }); + + const otherCookie = extractSessionCookie(otherDeviceResponse); + const currentCookie = extractSessionCookie(currentDeviceResponse); + + if (!otherCookie || !currentCookie) { + throw new Error('Expected session cookies from both sign-ins'); + } + + otherSessionToken = otherCookie.sessionToken; + currentSessionCookieHeader = currentCookie.cookieHeader; + }); + + it('should list active sessions and mark only the presented one as current', async () => { + const sessions = await fetchSessions(); + + expect(sessions.length).toBeGreaterThanOrEqual(2); + + const currentSessions = sessions.filter((session) => session.isCurrent); + + expect(currentSessions).toHaveLength(1); + expect(currentSessions[0].authProvider).toBe('password'); + expect(currentSessions[0].isImpersonating).toBe(false); + }); + + it('should revoke a targeted session by id', async () => { + const otherSessionRow = await getCoreRepository( + UserSessionEntity, + ).findOneBy({ tokenHash: hashUserSessionToken(otherSessionToken) }); + + if (!otherSessionRow) { + throw new Error('Expected the other session row'); + } + + const response = await postMetadataOperationWithHeaders( + revokeUserSessionQueryFactory({ userSessionId: otherSessionRow.id }), + { + originHeader: ALLOWED_ORIGIN, + cookieHeader: currentSessionCookieHeader, + }, + ); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.revokeUserSession).toBe(true); + + const revokedRow = await getCoreRepository( + UserSessionEntity, + ).findOneBy({ id: otherSessionRow.id }); + + expect(revokedRow?.revokedAt).not.toBeNull(); + expect(revokedRow?.revokedReason).toBe(UserSessionRevokedReason.UserRevoked); + + const sessions = await fetchSessions(); + + expect( + sessions.find((session) => session.id === otherSessionRow.id), + ).toBeUndefined(); + }); + + it('should revoke every other session but keep the presented one alive', async () => { + // A fresh sign-in guarantees at least one other active session to revoke. + await signInWithCookieCapture({ originHeader: ALLOWED_ORIGIN }); + + const response = await postMetadataOperationWithHeaders( + revokeAllOtherUserSessionsQueryFactory(), + { + originHeader: ALLOWED_ORIGIN, + cookieHeader: currentSessionCookieHeader, + }, + ); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.revokeAllOtherUserSessions).toBeGreaterThanOrEqual( + 1, + ); + + const sessions = await fetchSessions(); + + expect(sessions).toHaveLength(1); + expect(sessions[0].isCurrent).toBe(true); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/user-session-cleanup-cron.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/user-session-cleanup-cron.integration-spec.ts new file mode 100644 index 0000000000..9bb140ab43 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/user-session-cleanup-cron.integration-spec.ts @@ -0,0 +1,251 @@ +import { In } from 'typeorm'; + +import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util'; +import { getCoreRepository } from 'test/integration/utils/get-core-repository.util'; + +import { + AppTokenEntity, + AppTokenType, +} from 'src/engine/core-modules/app-token/app-token.entity'; +import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity'; +import { UserSessionRevokedReason } from 'src/engine/core-modules/user-session/types/user-session-revoked-reason.type'; +import { generateUserSessionToken } from 'src/engine/core-modules/user-session/utils/generate-user-session-token.util'; +import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; +import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util'; + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +const daysAgo = (days: number): Date => new Date(Date.now() - days * ONE_DAY_MS); +const daysFromNow = (days: number): Date => + new Date(Date.now() + days * ONE_DAY_MS); + +type SeededSessionFixture = { + label: string; + tokenHash: string; + overrides: Partial; +}; + +// The retention boundary is 30 days after a session ended, where "ended" +// means absolute expiry or revocation. The cron runs against the same table +// the suite's other specs write to, so fixtures carry their own token hashes +// and are matched individually rather than by table counts. +describe('user session cleanup cron (integration)', () => { + const fixtures: SeededSessionFixture[] = [ + { + label: 'expired beyond retention', + tokenHash: hashUserSessionToken(generateUserSessionToken()), + overrides: { expiresAt: daysAgo(31), lastActiveAt: daysAgo(31) }, + }, + { + label: 'revoked beyond retention', + tokenHash: hashUserSessionToken(generateUserSessionToken()), + overrides: { + expiresAt: daysFromNow(90), + lastActiveAt: daysAgo(31), + revokedAt: daysAgo(31), + revokedReason: UserSessionRevokedReason.UserSignOut, + }, + }, + { + label: 'active', + tokenHash: hashUserSessionToken(generateUserSessionToken()), + overrides: { expiresAt: daysFromNow(90), lastActiveAt: new Date() }, + }, + { + label: 'expired within retention', + tokenHash: hashUserSessionToken(generateUserSessionToken()), + overrides: { expiresAt: daysAgo(1), lastActiveAt: daysAgo(1) }, + }, + { + // Idle-expired sessions are unusable but carry no ended marker, so the + // current predicate retains them until absolute expiry. Pinned here as + // documented behavior; tightening the predicate should flip this case. + label: 'idle-expired but not absolutely expired', + tokenHash: hashUserSessionToken(generateUserSessionToken()), + overrides: { expiresAt: daysFromNow(60), lastActiveAt: daysAgo(120) }, + }, + ]; + + const allFixtureHashes = fixtures.map((fixture) => fixture.tokenHash); + + const findRemainingFixtureHashes = async (): Promise => { + const rows = await getCoreRepository( + UserSessionEntity, + ).findBy({ tokenHash: In(allFixtureHashes) }); + + return rows.map((row) => row.tokenHash); + }; + + beforeAll(async () => { + const userSessionRepository = + getCoreRepository(UserSessionEntity); + + for (const fixture of fixtures) { + await userSessionRepository.save( + userSessionRepository.create({ + tokenHash: fixture.tokenHash, + userId: USER_DATA_SEED_IDS.TIM, + workspaceId: null, + userWorkspaceId: null, + authProvider: AuthProviderEnum.Password, + ...fixture.overrides, + }), + ); + } + }); + + afterAll(async () => { + await getCoreRepository(UserSessionEntity).delete({ + tokenHash: In(allFixtureHashes), + }); + }); + + it('should delete sessions ended beyond retention and keep the rest', async () => { + const cleanupJob = getAppProviderByClassName<{ + handle: () => Promise; + }>('UserSessionCleanupCronJob'); + + await cleanupJob.handle(); + + const remainingHashes = await findRemainingFixtureHashes(); + + // Strict lookup: a mistyped label must fail the test here, not slip + // through as not.toContain(undefined). + const getFixtureTokenHash = (label: string): string => { + const fixture = fixtures.find((candidate) => candidate.label === label); + + if (!fixture) { + throw new Error(`Unknown user session fixture: ${label}`); + } + + return fixture.tokenHash; + }; + + const expectDeleted = (label: string) => { + expect(remainingHashes).not.toContain(getFixtureTokenHash(label)); + }; + const expectKept = (label: string) => { + expect(remainingHashes).toContain(getFixtureTokenHash(label)); + }; + + expectDeleted('expired beyond retention'); + expectDeleted('revoked beyond retention'); + expectKept('active'); + expectKept('expired within retention'); + expectKept('idle-expired but not absolutely expired'); + }); + + describe('refresh-token half', () => { + type SeededAppTokenFixture = { + label: string; + type: AppTokenType; + overrides: Partial>; + }; + + const appTokenFixtures: SeededAppTokenFixture[] = [ + { + label: 'refresh token expired beyond retention', + type: AppTokenType.RefreshToken, + overrides: { expiresAt: daysAgo(31) }, + }, + { + label: 'refresh token revoked beyond retention', + type: AppTokenType.RefreshToken, + overrides: { expiresAt: daysFromNow(30), revokedAt: daysAgo(31) }, + }, + { + label: 'active refresh token', + type: AppTokenType.RefreshToken, + overrides: { expiresAt: daysFromNow(30) }, + }, + // The retention boundary itself: without these two, a regression that + // deleted every ended refresh token rather than only those past + // retention would still pass the assertions above. + { + label: 'refresh token expired within retention', + type: AppTokenType.RefreshToken, + overrides: { expiresAt: daysAgo(1) }, + }, + { + label: 'refresh token revoked within retention', + type: AppTokenType.RefreshToken, + overrides: { expiresAt: daysFromNow(30), revokedAt: daysAgo(1) }, + }, + { + // The type filter is the safety predicate: appToken is a shared table + // and other token types are routinely long-expired. Losing the filter + // would silently hard-delete password-reset or invitation history. + label: 'long-expired token of another type', + type: AppTokenType.PasswordResetToken, + overrides: { expiresAt: daysAgo(31) }, + }, + ]; + + const seededAppTokenIds = new Map(); + + beforeAll(async () => { + const appTokenRepository = + getCoreRepository(AppTokenEntity); + + for (const fixture of appTokenFixtures) { + const saved = await appTokenRepository.save( + appTokenRepository.create({ + userId: USER_DATA_SEED_IDS.TIM, + type: fixture.type, + value: '', + ...fixture.overrides, + }), + ); + + seededAppTokenIds.set(fixture.label, saved.id); + } + }); + + afterAll(async () => { + await getCoreRepository(AppTokenEntity).delete({ + id: In([...seededAppTokenIds.values()]), + }); + }); + + it('should delete only refresh tokens ended beyond retention', async () => { + const cleanupJob = getAppProviderByClassName<{ + handle: () => Promise; + }>('UserSessionCleanupCronJob'); + + await cleanupJob.handle(); + + const remainingRows = await getCoreRepository( + AppTokenEntity, + ).findBy({ id: In([...seededAppTokenIds.values()]) }); + const remainingIds = remainingRows.map((row) => row.id); + + const getSeededAppTokenId = (label: string): string => { + const id = seededAppTokenIds.get(label); + + if (id === undefined) { + throw new Error(`Unknown app token fixture: ${label}`); + } + + return id; + }; + + expect(remainingIds).not.toContain( + getSeededAppTokenId('refresh token expired beyond retention'), + ); + expect(remainingIds).not.toContain( + getSeededAppTokenId('refresh token revoked beyond retention'), + ); + expect(remainingIds).toContain(getSeededAppTokenId('active refresh token')); + expect(remainingIds).toContain( + getSeededAppTokenId('refresh token expired within retention'), + ); + expect(remainingIds).toContain( + getSeededAppTokenId('refresh token revoked within retention'), + ); + expect(remainingIds).toContain( + getSeededAppTokenId('long-expired token of another type'), + ); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/utils/setup-database-config-override.util.ts b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/utils/setup-database-config-override.util.ts new file mode 100644 index 0000000000..31ae960638 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/utils/setup-database-config-override.util.ts @@ -0,0 +1,53 @@ +import { type ConfigVariableValue } from 'twenty-shared/types'; + +import { createConfigVariable } from 'test/integration/twenty-config/utils/create-config-variable.util'; +import { deleteConfigVariable } from 'test/integration/twenty-config/utils/delete-config-variable.util'; +import { getConfigVariable } from 'test/integration/twenty-config/utils/get-config-variable.util'; +import { updateConfigVariable } from 'test/integration/twenty-config/utils/update-config-variable.util'; + +// Registers beforeAll/afterAll hooks that apply a database config override for +// the suite and restore the previous state afterwards, rather than blindly +// deleting: a suite must not erase an override another suite (or the +// environment) had in place. +export const setupDatabaseConfigOverrideForSuite = ( + key: string, + value: ConfigVariableValue, +): void => { + let previousOverrideValue: ConfigVariableValue; + let hadPreviousOverride = false; + + beforeAll(async () => { + const { data } = await getConfigVariable({ input: { key } }); + + hadPreviousOverride = data.getDatabaseConfigVariable.source === 'DATABASE'; + + if (hadPreviousOverride) { + previousOverrideValue = data.getDatabaseConfigVariable.value; + await updateConfigVariable({ input: { key, value } }); + } else { + await createConfigVariable({ input: { key, value } }); + } + }); + + afterAll(async () => { + try { + if (hadPreviousOverride) { + await updateConfigVariable({ + input: { key, value: previousOverrideValue }, + }); + } else { + await deleteConfigVariable({ input: { key } }); + } + } catch (error) { + // Reported rather than rethrown: a passing suite should not go red over + // its own teardown. But a swallowed failure leaves the override in place + // and the next suite fails for reasons that point nowhere near here, so + // it has to be visible. + process.stderr.write( + `[config-override] failed to restore ${key}, later suites may see a stale value: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + } + }); +}; diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util.ts b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util.ts new file mode 100644 index 0000000000..871c57b2b7 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util.ts @@ -0,0 +1,152 @@ +import { type Response } from 'supertest'; +import { buildWorkspaceOriginForSubdomain } from 'test/integration/graphql/utils/build-apple-workspace-origin.util'; +import { getAuthTokensFromLoginTokenQueryFactory } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.query-factory.util'; +import { getLoginTokenFromCredentialsQueryFactory } from 'test/integration/graphql/utils/get-login-token-from-credentials.query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { USER_SESSION_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-cookie-name.constant'; +import { USER_SESSION_SECURE_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-secure-cookie-name.constant'; + +type RequestHeaders = { + originHeader?: string; + cookieHeader?: string; +}; + +// Supertest requests dispatch lazily, so Origin and Cookie can be set on the +// request makeMetadataAPIRequest already built. The explicit null token keeps +// these requests off Bearer authentication, which would bypass both the +// cookie auth path and the CSRF middleware; undefined would fall back to the +// util's default admin token. +export const postMetadataOperationWithHeaders = ( + graphqlOperation: Parameters[0], + { originHeader, cookieHeader }: RequestHeaders, + expectedStatus = 200, +) => { + const graphqlRequest = makeMetadataAPIRequest(graphqlOperation, null); + + if (originHeader !== undefined) { + graphqlRequest.set('Origin', originHeader); + } + + if (cookieHeader !== undefined) { + graphqlRequest.set('Cookie', cookieHeader); + } + + return graphqlRequest.expect(expectedStatus); +}; + +type SignInOptions = RequestHeaders & { + email?: string; + password?: string; + workspaceSubdomain?: string; +}; + +// Runs the full credentials exchange (credentials -> login token -> auth +// tokens) and returns the raw supertest response of the final exchange, so +// callers can assert on set-cookie headers, not just the GraphQL body. +export const signInWithCookieCapture = async ({ + email = 'tim@apple.dev', + password = 'tim@apple.dev', + workspaceSubdomain = 'apple', + originHeader, + cookieHeader, +}: SignInOptions = {}): Promise => { + const workspaceOrigin = buildWorkspaceOriginForSubdomain(workspaceSubdomain); + + const loginTokenResponse = await postMetadataOperationWithHeaders( + getLoginTokenFromCredentialsQueryFactory({ + email, + password, + origin: workspaceOrigin, + }), + { originHeader, cookieHeader }, + ); + + const loginToken = + loginTokenResponse.body.data?.getLoginTokenFromCredentials?.loginToken + ?.token; + + expect(loginToken).toBeDefined(); + + return await postMetadataOperationWithHeaders( + getAuthTokensFromLoginTokenQueryFactory({ + loginToken, + origin: workspaceOrigin, + }), + { originHeader, cookieHeader }, + ); +}; + +// Redacts the two dynamic parts (token value, expiry timestamp) so the rest of +// the set-cookie header can be snapshot: name, attribute list and order are +// deterministic, and the snapshot also pins the absence of Domain and Secure. +export const normalizeSessionCookieForSnapshot = (rawCookie: string): string => + rawCookie + .replace(/=sess_[A-Za-z0-9_-]+/, '=sess_') + .replace(/Expires=[^;]+/, 'Expires='); + +export const getSetCookieHeaders = (response: Response): string[] => + Array.isArray(response.headers['set-cookie']) + ? response.headers['set-cookie'] + : []; + +// Which of the two names the server issues depends on SERVER_URL and +// AUTH_COOKIE_SAME_SITE, so callers match on both by default and read the name +// back off the response rather than assuming the insecure deployment. Specs +// that assert one specific variant pass the name explicitly. +const SESSION_COOKIE_NAMES = [ + USER_SESSION_SECURE_COOKIE_NAME, + USER_SESSION_COOKIE_NAME, +]; + +// startsWith keeps the plain and __Host- names distinct: the prefixed name +// does not start with the plain one. +export const extractSessionCookie = ( + response: Response, + cookieName?: string, +): + | { rawCookie: string; cookieName: string; cookieHeader: string; sessionToken: string } + | undefined => { + const candidateNames = + cookieName === undefined ? SESSION_COOKIE_NAMES : [cookieName]; + + for (const candidateName of candidateNames) { + const rawCookie = getSetCookieHeaders(response).find((cookie) => + cookie.startsWith(`${candidateName}=sess_`), + ); + + if (rawCookie === undefined) { + continue; + } + + const sessionToken = rawCookie + .split(';')[0] + .slice(`${candidateName}=`.length); + + return { + rawCookie, + cookieName: candidateName, + cookieHeader: `${candidateName}=${sessionToken}`, + sessionToken, + }; + } + + return undefined; +}; + +// A deletion cookie is the name with an empty value and an epoch expiry; the +// extractor above will not match it because the value lacks the sess_ prefix. +export const hasClearingCookie = ( + response: Response, + cookieName?: string, +): boolean => { + const candidateNames = + cookieName === undefined ? SESSION_COOKIE_NAMES : [cookieName]; + + return getSetCookieHeaders(response).some((cookie) => + candidateNames.some( + (candidateName) => + cookie.startsWith(`${candidateName}=;`) && + cookie.includes('Expires=Thu, 01 Jan 1970'), + ), + ); +}; diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util.ts b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util.ts new file mode 100644 index 0000000000..89275f6a47 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util.ts @@ -0,0 +1,95 @@ +import { gql } from 'graphql-tag'; + +export const currentUserIdentityQueryFactory = () => { + return { + query: gql` + query CurrentUser { + currentUser { + id + email + } + } + `, + variables: {}, + }; +}; + +export const currentUserWorkspaceContextQueryFactory = () => { + return { + query: gql` + query CurrentUserWorkspaceContext { + currentUser { + id + email + currentWorkspace { + id + } + currentUserWorkspace { + id + } + } + } + `, + variables: {}, + }; +}; + +export const currentUserSessionsQueryFactory = () => { + return { + query: gql` + query CurrentUserSessions { + currentUserSessions { + id + workspaceId + authProvider + isImpersonating + isCurrent + lastActiveAt + expiresAt + } + } + `, + variables: {}, + }; +}; + +export const revokeUserSessionQueryFactory = ({ + userSessionId, +}: { + userSessionId: string; +}) => { + return { + query: gql` + mutation RevokeUserSession($userSessionId: UUID!) { + revokeUserSession(userSessionId: $userSessionId) + } + `, + variables: { userSessionId }, + }; +}; + +export const revokeAllOtherUserSessionsQueryFactory = () => { + return { + query: gql` + mutation RevokeAllOtherUserSessions { + revokeAllOtherUserSessions + } + `, + variables: {}, + }; +}; + +export const signOutQueryFactory = ({ + refreshToken, +}: { + refreshToken?: string; +} = {}) => { + return { + query: gql` + mutation SignOut($refreshToken: String) { + signOut(refreshToken: $refreshToken) + } + `, + variables: { refreshToken }, + }; +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/build-apple-workspace-origin.util.ts b/packages/twenty-server/test/integration/graphql/utils/build-apple-workspace-origin.util.ts new file mode 100644 index 0000000000..cdab25b670 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/build-apple-workspace-origin.util.ts @@ -0,0 +1,20 @@ +// Workspace-selection origin for a seeded workspace, passed as the GraphQL +// `origin` variable on auth exchanges. The server resolves which workspace to +// authenticate into from this URL's subdomain +// (getWorkspaceByOriginOrDefaultWorkspace); it is routing data, unrelated to +// the HTTP Origin header the CSRF and cookie-issuance gates read. +export const buildWorkspaceOriginForSubdomain = ( + subdomain: string, +): string => { + const origin = new URL(`http://localhost:${APP_PORT}`); + + origin.hostname = + process.env.IS_MULTIWORKSPACE_ENABLED === 'true' + ? `${subdomain}.${origin.hostname}` + : origin.hostname; + + return origin.toString(); +}; + +export const buildAppleWorkspaceOrigin = (): string => + buildWorkspaceOriginForSubdomain('apple'); diff --git a/packages/twenty-server/test/integration/graphql/utils/get-access-token-for-credentials.util.ts b/packages/twenty-server/test/integration/graphql/utils/get-access-token-for-credentials.util.ts index 7826a1356c..3e884de3b5 100644 --- a/packages/twenty-server/test/integration/graphql/utils/get-access-token-for-credentials.util.ts +++ b/packages/twenty-server/test/integration/graphql/utils/get-access-token-for-credentials.util.ts @@ -1,19 +1,9 @@ import request from 'supertest'; +import { buildAppleWorkspaceOrigin } from 'test/integration/graphql/utils/build-apple-workspace-origin.util'; import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util'; const SERVER_URL = `http://localhost:${APP_PORT}`; -const buildAppleOrigin = (): string => { - const origin = new URL(SERVER_URL); - - origin.hostname = - process.env.IS_MULTIWORKSPACE_ENABLED === 'true' - ? `apple.${origin.hostname}` - : origin.hostname; - - return origin.toString(); -}; - type GetAccessTokenForCredentialsArgs = { email: string; password?: string; @@ -26,7 +16,7 @@ export const getAccessTokenForCredentials = async ({ email, password = 'tim@apple.dev', }: GetAccessTokenForCredentialsArgs): Promise => { - const origin = buildAppleOrigin(); + const origin = buildAppleWorkspaceOrigin(); const loginResponse = await request(SERVER_URL) .post('/metadata') diff --git a/packages/twenty-server/test/integration/graphql/utils/get-auth-tokens-from-login-token.query-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/get-auth-tokens-from-login-token.query-factory.util.ts new file mode 100644 index 0000000000..d2548382a9 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/get-auth-tokens-from-login-token.query-factory.util.ts @@ -0,0 +1,34 @@ +import { gql } from 'graphql-tag'; + +export type GetAuthTokensFromLoginTokenFactoryInput = { + loginToken: string; + origin: string; +}; + +export const getAuthTokensFromLoginTokenQueryFactory = ({ + loginToken, + origin, +}: GetAuthTokensFromLoginTokenFactoryInput) => { + return { + query: gql` + mutation GetAuthTokensFromLoginToken($loginToken: String!, $origin: String!) { + getAuthTokensFromLoginToken(loginToken: $loginToken, origin: $origin) { + tokens { + accessOrWorkspaceAgnosticToken { + token + expiresAt + } + refreshToken { + token + expiresAt + } + } + } + } + `, + variables: { + loginToken, + origin, + }, + }; +}; diff --git a/packages/twenty-server/test/integration/graphql/utils/get-login-token-from-credentials.query-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/get-login-token-from-credentials.query-factory.util.ts new file mode 100644 index 0000000000..9cca7ae7f5 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/get-login-token-from-credentials.query-factory.util.ts @@ -0,0 +1,38 @@ +import { gql } from 'graphql-tag'; + +export type GetLoginTokenFromCredentialsFactoryInput = { + email: string; + password: string; + origin: string; +}; + +export const getLoginTokenFromCredentialsQueryFactory = ({ + email, + password, + origin, +}: GetLoginTokenFromCredentialsFactoryInput) => { + return { + query: gql` + mutation GetLoginTokenFromCredentials( + $email: String! + $password: String! + $origin: String! + ) { + getLoginTokenFromCredentials( + email: $email + password: $password + origin: $origin + ) { + loginToken { + token + } + } + } + `, + variables: { + email, + password, + origin, + }, + }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/utils/make-metadata-api-request.util.ts b/packages/twenty-server/test/integration/metadata/suites/utils/make-metadata-api-request.util.ts index 9741608bba..f092a1063f 100644 --- a/packages/twenty-server/test/integration/metadata/suites/utils/make-metadata-api-request.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/utils/make-metadata-api-request.util.ts @@ -7,9 +7,11 @@ type GraphqlOperation = { variables?: Record; }; +// Pass null for an unauthenticated request: undefined falls back to the +// default token because parameter defaults apply to undefined, not null. export const makeMetadataAPIRequest = ( graphqlOperation: GraphqlOperation, - token: string | undefined = APPLE_JANE_ADMIN_ACCESS_TOKEN, + token: string | null | undefined = APPLE_JANE_ADMIN_ACCESS_TOKEN, ) => { const client = request(`http://localhost:${APP_PORT}`); diff --git a/packages/twenty-server/test/integration/secure-deployment/suites/secure-session-cookie.integration-spec.ts b/packages/twenty-server/test/integration/secure-deployment/suites/secure-session-cookie.integration-spec.ts new file mode 100644 index 0000000000..fdc6970e64 --- /dev/null +++ b/packages/twenty-server/test/integration/secure-deployment/suites/secure-session-cookie.integration-spec.ts @@ -0,0 +1,125 @@ +import { + extractSessionCookie, + hasClearingCookie, + normalizeSessionCookieForSnapshot, + postMetadataOperationWithHeaders, + signInWithCookieCapture, +} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util'; +import { + currentUserIdentityQueryFactory, + signOutQueryFactory, +} from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util'; + +import { + ALLOWED_ORIGIN, + DISALLOWED_ORIGIN, +} from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants'; +import { IS_SECURE_DEPLOYMENT } from 'test/integration/graphql/suites/auth/user-sessions/constants/is-secure-deployment.constant'; +import { setupDatabaseConfigOverrideForSuite } from 'test/integration/graphql/suites/auth/user-sessions/utils/setup-database-config-override.util'; + +import { USER_SESSION_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-cookie-name.constant'; +import { USER_SESSION_SECURE_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-secure-cookie-name.constant'; + +// The secure/insecure cookie branch is decided by configuration, never by the +// transport: isSecureDeployment() reads SERVER_URL, which is env-only. This +// suite therefore needs the app booted with an https SERVER_URL, which +// jest-integration-secure.config.ts guarantees; run it through +// `nx run twenty-server:test:integration:secure`. It exercises the exact +// production combination: __Host- name, Secure, and the SameSite=Lax default. +describe('session cookie on a production-like secure deployment (integration)', () => { + setupDatabaseConfigOverrideForSuite('AUTH_COOKIE_SESSIONS_ENABLED', true); + + let sessionToken: string; + + beforeAll(() => { + if (!IS_SECURE_DEPLOYMENT) { + throw new Error( + 'This suite requires an https SERVER_URL; run it via nx run twenty-server:test:integration:secure', + ); + } + }); + + it('should deliver the production cookie: __Host- name, Secure, SameSite=Lax', async () => { + const response = await signInWithCookieCapture({ + originHeader: ALLOWED_ORIGIN, + }); + + expect( + extractSessionCookie(response, USER_SESSION_COOKIE_NAME), + ).toBeUndefined(); + + const secureSessionCookie = extractSessionCookie( + response, + USER_SESSION_SECURE_COOKIE_NAME, + ); + + expect(secureSessionCookie).toBeDefined(); + + if (!secureSessionCookie) { + throw new Error('Expected the secure session cookie'); + } + + sessionToken = secureSessionCookie.sessionToken; + + // A literal rather than a snapshot: snapshots written by this dedicated + // run would count as obsolete in the plain-http run. + expect( + normalizeSessionCookieForSnapshot(secureSessionCookie.rawCookie), + ).toBe( + '__Host-twenty-session=sess_; Path=/; Expires=; HttpOnly; Secure; SameSite=Lax', + ); + }); + + it('should authenticate a request presenting the __Host- cookie', async () => { + const response = await postMetadataOperationWithHeaders( + currentUserIdentityQueryFactory(), + { + originHeader: ALLOWED_ORIGIN, + cookieHeader: `${USER_SESSION_SECURE_COOKIE_NAME}=${sessionToken}`, + }, + ); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.currentUser.email).toBe('tim@apple.dev'); + }); + + it('should ignore the plain cookie name, refusing a downgrade', async () => { + const response = await postMetadataOperationWithHeaders( + currentUserIdentityQueryFactory(), + { + originHeader: ALLOWED_ORIGIN, + cookieHeader: `${USER_SESSION_COOKIE_NAME}=${sessionToken}`, + }, + ); + + expect(response.body.errors).toBeDefined(); + }); + + it('should enforce the CSRF origin gate on __Host- cookie requests', async () => { + const response = await postMetadataOperationWithHeaders( + currentUserIdentityQueryFactory(), + { + originHeader: DISALLOWED_ORIGIN, + cookieHeader: `${USER_SESSION_SECURE_COOKIE_NAME}=${sessionToken}`, + }, + 403, + ); + + expect(response.body.error).toBe('CSRF_ORIGIN_MISMATCH'); + }); + + it('should clear the __Host- cookie on sign-out', async () => { + const response = await postMetadataOperationWithHeaders( + signOutQueryFactory(), + { + originHeader: ALLOWED_ORIGIN, + cookieHeader: `${USER_SESSION_SECURE_COOKIE_NAME}=${sessionToken}`, + }, + ); + + expect(response.body.data.signOut).toBe(true); + expect(hasClearingCookie(response, USER_SESSION_SECURE_COOKIE_NAME)).toBe( + true, + ); + }); +}); diff --git a/packages/twenty-server/test/integration/utils/create-app.ts b/packages/twenty-server/test/integration/utils/create-app.ts index f53204ea25..3e3ffbdc9c 100644 --- a/packages/twenty-server/test/integration/utils/create-app.ts +++ b/packages/twenty-server/test/integration/utils/create-app.ts @@ -10,6 +10,8 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import { AppModule } from 'src/app.module'; import { settings } from 'src/engine/constants/settings'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { applyCredentialedCors } from 'src/engine/core-modules/user-session/utils/apply-credentialed-cors.util'; import { StripeSDKMockService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/mocks/stripe-sdk-mock.service'; import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service'; import { CaptchaDriverFactory } from 'src/engine/core-modules/captcha/captcha-driver.factory'; @@ -62,9 +64,12 @@ export const createApp = async ( const app = moduleFixture.createNestApplication({ rawBody: true, - cors: true, }); + // The production CORS setup, not the Nest wildcard default, so integration + // tests exercise the credentialed-origin allowlist the deployment runs. + applyCredentialedCors(app, app.get(TwentyConfigService)); + app.use( '/graphql', graphqlUploadExpress({