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:
@@ -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'],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+47
@@ -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'],
|
||||
});
|
||||
};
|
||||
@@ -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)));
|
||||
|
||||
|
||||
+3
@@ -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"`;
|
||||
+3
@@ -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"`;
|
||||
+6
@@ -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://',
|
||||
);
|
||||
+21
@@ -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';
|
||||
+57
@@ -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');
|
||||
});
|
||||
});
|
||||
+90
@@ -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);
|
||||
});
|
||||
});
|
||||
+93
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+76
@@ -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);
|
||||
});
|
||||
});
|
||||
+69
@@ -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();
|
||||
});
|
||||
});
|
||||
+48
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
+50
@@ -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');
|
||||
});
|
||||
});
|
||||
+166
@@ -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();
|
||||
});
|
||||
});
|
||||
+107
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
+88
@@ -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);
|
||||
});
|
||||
});
|
||||
+137
@@ -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);
|
||||
});
|
||||
});
|
||||
+251
@@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+53
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
+152
@@ -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'),
|
||||
),
|
||||
);
|
||||
};
|
||||
+95
@@ -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 },
|
||||
};
|
||||
};
|
||||
+20
@@ -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');
|
||||
+2
-12
@@ -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')
|
||||
|
||||
+34
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
+38
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
+3
-1
@@ -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}`);
|
||||
|
||||
|
||||
+125
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user