997b2c38de
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>
167 lines
6.1 KiB
TypeScript
167 lines
6.1 KiB
TypeScript
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();
|
|
});
|
|
});
|