Add cookie-session integration test suite (#23715)

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 <felix.malfait@gmail.com>
This commit is contained in:
Paul Rastoin
2026-08-04 12:05:12 +02:00
committed by GitHub
parent 2db730b65f
commit 997b2c38de
32 changed files with 1880 additions and 49 deletions
@@ -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;
@@ -30,6 +30,9 @@ const jestConfig: JestConfigWithTsJest = {
testPathIgnorePatterns: [
...(isBillingEnabled ? [] : ['<rootDir>/test/integration/billing']),
...(isClickhouseEnabled ? [] : ['<rootDir>/test/integration/audit']),
// Requires an app booted as a secure deployment; run through
// jest-integration-secure.config.ts (nx test:integration:secure).
'<rootDir>/test/integration/secure-deployment',
],
testRegex: '\\.integration-spec\\.ts$',
modulePathIgnorePatterns: ['<rootDir>/dist'],
+10
View File
@@ -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": {
@@ -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'],
});
};
+2 -35
View File
@@ -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)));
@@ -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_<redacted>; Path=/; Expires=<redacted>; HttpOnly; Secure; SameSite=None"`;
@@ -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_<redacted>; Path=/; Expires=<redacted>; HttpOnly; SameSite=Lax"`;
@@ -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://',
);
@@ -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';
@@ -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');
});
});
@@ -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);
});
});
@@ -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>(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>(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>(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);
});
});
});
@@ -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<Pick<UserSessionEntity, 'expiresAt' | 'lastActiveAt'>>,
): Promise<string> => {
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>(UserSessionEntity).update(
{ tokenHash: hashUserSessionToken(sessionCookie.sessionToken) },
tamper,
);
return sessionCookie.cookieHeader;
};
const expectCookieRejected = async (
sessionCookieHeader: string,
): Promise<void> => {
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);
});
});
@@ -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>(
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>(
UserSessionEntity,
).findOneBy({ id: timSessionId });
expect(sessionRow?.revokedAt).toBeNull();
});
});
@@ -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',
});
});
});
@@ -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');
});
});
@@ -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>(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>(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();
});
});
@@ -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>(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,
);
});
});
@@ -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>(
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);
});
});
@@ -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<UserSessionApiEntry[]> => {
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>(
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>(
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);
});
});
@@ -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<UserSessionEntity>;
};
// 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<string[]> => {
const rows = await getCoreRepository<UserSessionEntity>(
UserSessionEntity,
).findBy({ tokenHash: In(allFixtureHashes) });
return rows.map((row) => row.tokenHash);
};
beforeAll(async () => {
const userSessionRepository =
getCoreRepository<UserSessionEntity>(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>(UserSessionEntity).delete({
tokenHash: In(allFixtureHashes),
});
});
it('should delete sessions ended beyond retention and keep the rest', async () => {
const cleanupJob = getAppProviderByClassName<{
handle: () => Promise<void>;
}>('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<Pick<AppTokenEntity, 'expiresAt' | 'revokedAt'>>;
};
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<string, string>();
beforeAll(async () => {
const appTokenRepository =
getCoreRepository<AppTokenEntity>(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>(AppTokenEntity).delete({
id: In([...seededAppTokenIds.values()]),
});
});
it('should delete only refresh tokens ended beyond retention', async () => {
const cleanupJob = getAppProviderByClassName<{
handle: () => Promise<void>;
}>('UserSessionCleanupCronJob');
await cleanupJob.handle();
const remainingRows = await getCoreRepository<AppTokenEntity>(
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'),
);
});
});
});
@@ -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`,
);
}
});
};
@@ -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<typeof makeMetadataAPIRequest>[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<Response> => {
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_<redacted>')
.replace(/Expires=[^;]+/, 'Expires=<redacted>');
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'),
),
);
};
@@ -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 },
};
};
@@ -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');
@@ -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<string> => {
const origin = buildAppleOrigin();
const origin = buildAppleWorkspaceOrigin();
const loginResponse = await request(SERVER_URL)
.post('/metadata')
@@ -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,
},
};
};
@@ -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,
},
};
};
@@ -7,9 +7,11 @@ type GraphqlOperation = {
variables?: Record<string, unknown>;
};
// 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}`);
@@ -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_<redacted>; Path=/; Expires=<redacted>; 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,
);
});
});
@@ -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<NestExpressApplication>({
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({