80fb91c033dfd367f17ba58e2095526faa016e70
2 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ae235d3b24 |
Scope sessions and application authorizations to the workspace (#23843)
Settings / Profile / Devices listed every live session for the account, so a person who belongs to two workspaces saw all of them from either one, and "Log out all other devices" signed them out everywhere. Almost nothing in Twenty is account-wide, and a session belongs to the workspace its exchange selected, so neither the list nor the revocations are another workspace's business. ## What was wrong `currentUserSessions` called `findActiveSessionsForUser(user.id)` with no workspace filter. Both revoke paths were keyed on `userId` alone, so `revokeUserSession` could target another workspace's session by id and `revokeAllOtherUserSessions` cleared every workspace at once. Sweeping the other resolvers that take `@AuthUser()` turned up the same shape in the OAuth application authorizations added in #23678: `findActiveAuthorizationsForUser(userId)` and `revokeAuthorizationById({ authorizationId, userId })`. A grant made in one workspace was listed, and revocable, from another. Everything else already pairs `@AuthUser()` with `@AuthWorkspace()`. `client-config.resolver.ts` is the reference pattern. ## The fix Both resolvers now take the workspace from the auth context and pass it down, and the service methods are renamed to say so. `revokeAllSessionsForUser` keeps `workspaceId` optional on purpose: `auth.service.ts` uses it on password change, where clearing every workspace is the intended behaviour. Sessions with no workspace (the workspace-agnostic ones minted on the default subdomain, which exists to list workspaces and carry the auto-login window) now belong to no workspace's list and survive "log out all other devices". ## Verification - New integration spec built on Tim's membership of both apple and yc: the list stays disjoint, a cross-workspace revoke by id is refused and is a no-op, and revoking all other devices in one workspace leaves the other signed in - Mutation-checked by dropping the `workspaceId` from the query, which fails the isolation test while the two revoke tests still pass, confirming each assertion targets its own mechanism - 160 unit tests, 70 integration tests across the session and OAuth suites ## Also The devices button drops its danger accent for the plain small variant, matching Deactivate in `ObjectSettings.tsx`. ## Separate finding, not fixed here `request.ip` resolves to an internal address behind the Cloudflare / nginx chain, which is why every row in the screenshot that prompted this showed the same RFC1918 address. That is an ingress configuration issue rather than an application one. It does not affect ClickHouse audit logs, which store no IP, but it does affect the two OAuth rate limiters that key on `req.ip`. --- _Generated by [Claude Code](https://claude.ai/code/session_01C6nCVbcb5ZZrz67uvqvMWF)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23843?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
997b2c38de |
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> |