Skip to content

fix(jwt-auth.guard): refuse and clear tokenPair when proxy identity differs#8

Merged
awais786 merged 5 commits into
foss-mainfrom
fix/proxy-auth-stale-session-on-user-switch
May 18, 2026
Merged

fix(jwt-auth.guard): refuse and clear tokenPair when proxy identity differs#8
awais786 merged 5 commits into
foss-mainfrom
fix/proxy-auth-stale-session-on-user-switch

Conversation

@awais786
Copy link
Copy Markdown
Collaborator

@awais786 awais786 commented May 15, 2026

Summary

Closes the stale-session-on-user-switch class of bug in Twenty. JwtAuthGuard validated the Bearer JWT from the tokenPair cookie without consulting the upstream identity that oauth2-proxy was asserting. Once Twenty issued the cookie via /auth/sso/proxy-login, the cookie was the sole source of identity on every subsequent request.

After a portal "Logout all" + login as a different user — which clears the shared _oauth2_proxy cookie and Cognito SSO session but not Twenty's tokenPair cookie on twenty.<domain> — refreshing the Twenty tab kept serving the previous user.

Implements proxy-auth-middleware Rule 2 ("Identity mismatch SHALL flush the existing session immediately") per the cross-app contract in awais786/sso-rules-moneta:openspec/specs/proxy-auth-middleware/spec.md.

Behaviour

After successful validateTokenByRequest, before binding data to the request:

Condition Effect
AUTH_TYPE !== SSO Bypass — Twenty's native auth path is unaffected
data.user absent (API key, application token) Bypass — programmatic identity, no upstream user to compare
SSO + user + header matches JWT email (normalised both sides) Pass through, no DB writes
SSO + user + header absent Pass through — header absence is NOT a logout signal per spec (internal calls, OPTIONS preflight, direct backend hits)
SSO + user + header asserts different identity response.clearCookie('tokenPair', { path: '/' })return false. Frontend bootstrap finds no tokenPair, routes through /auth/sso/proxy-login under new upstream identity, gets fresh tokens

Spec conformance

Spec requirement Implementation
Rule 2 — mismatch SHALL flush session immediately clearCookie('tokenPair') before return false; no path where mismatch is detected and the stale cookie survives
Header absence is NOT a logout signal if (!headerRaw || headerRaw.trim() === '') return true short-circuits the mismatch path
Bidirectional email normalisation normalizeProxyEmail(headerRaw) (lowercase + trim) on header; .toLowerCase() on data.user.email. Regression-guard test pins this.
Email-shape detection MUST avoid polynomial-backtracking regex indexOf-based check (atIdx > 0 && dotIdx > atIdx + 1), not regex. Same rule Outline twentyhq#19 adopted.
Bare-username synthesis <local>@${DEFAULT_EMAIL_DOMAIN} matches the shape Twenty's /auth/sso/proxy-login uses to provision the user
AUTH_TYPE-not-SSO bypass twentyConfigService.get('AUTH_TYPE') === 'SSO' gates the entire mismatch check

Test plan

packages/twenty-server/src/engine/guards/__tests__/jwt-auth.guard.spec.ts — 11 cases, all passing:

  • ✅ SSO match → passes through, no clearCookie
  • ✅ SSO header absent → passes through, no clearCookie
  • ✅ SSO whitespace-only header → passes through (treated as absent)
  • ✅ SSO mismatch → refuse + clearCookie('tokenPair', { path: '/' })
  • ✅ SSO case- and whitespace-variant match — bidirectional-normalisation regression guard
  • ✅ SSO bare-username synthesis against DEFAULT_EMAIL_DOMAIN
  • AUTH_TYPE unset → mismatch check NOT run (non-SSO bypass)
  • ✅ API-key auth (no user on context) → mismatch check NOT run
  • ✅ Application auth (no user on context) → mismatch check NOT run
  • clearCookie unavailable (defensive harness check) → still refuses cleanly
  • validateTokenByRequest throws → returns false

Run: cd packages/twenty-server && npx jest src/engine/guards/__tests__/jwt-auth.guard.spec.ts

Manual repro verification

  1. Log in to FOSS portal as user A; open Twenty (twenty.<domain>).
  2. Click Logout all on the portal.
  3. Log in as user B via mPass.
  4. Refresh Twenty tab.

Before: still served as A.
After: tokenPair cleared on the refresh; bootstrap routes through /auth/sso/proxy-login; served as B.

Out of scope

This addresses Twenty only. Plane has the analogous fix in Pressingly/plane#29, Outline in Pressingly/outline#19, Penpot in Pressingly/penpot#18. SurfSense is architecturally immune (FastAPI re-derives identity from headers on every request, no native session cookie).

🤖 Generated with Claude Code

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens SSO authentication by detecting stale tokenPair sessions when the upstream oauth2-proxy identity no longer matches the JWT user, and clearing the cookie to force a clean re-auth flow.

Changes:

  • Added SSO-only identity consistency check in JwtAuthGuard comparing X-Auth-Request-Email (normalized) to the JWT user email; on mismatch, clears tokenPair and refuses the request.
  • Added unit tests covering match/mismatch, normalization, bare-username synthesis via DEFAULT_EMAIL_DOMAIN, non-SSO modes, API-key tokens, and missing clearCookie.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
packages/twenty-server/src/engine/guards/jwt-auth.guard.ts Adds SSO stale-session detection and best-effort tokenPair cookie clearing on identity mismatch.
packages/twenty-server/src/engine/guards/tests/jwt-auth.guard.spec.ts Introduces a new spec suite validating the new guard behavior across key scenarios.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +88 to +92
private matchesProxyIdentity(request: any, jwtEmail: string): boolean {
const rawHeader = this.firstHeaderValue(
request.headers?.['x-auth-request-email'],
);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit 4a3acb157dmatchesProxyIdentity now calls a new resolveProxyIdentity helper that prefers x-auth-request-email and falls back to x-auth-request-user, mirroring SsoProxyLoginController.resolveEmail(). Tests at jwt-auth.guard.spec.ts:143 (falls back to X-Auth-Request-User) and :293 (prefers X-Auth-Request-Email over X-Auth-Request-User when both are present) pin the behaviour.

Comment on lines +70 to +88
describe('JwtAuthGuard', () => {
it('returns true when the proxy header matches the JWT user email', async () => {
const { guard } = buildGuard();
const { context } = buildContext({
headers: { 'x-auth-request-email': 'alice@example.com' },
});

expect(await guard.canActivate(context)).toBe(true);
});

it('returns true when no proxy header is present (e.g. internal calls)', async () => {
const { guard } = buildGuard();
const { context, clearCookie } = buildContext();

expect(await guard.canActivate(context)).toBe(true);
expect(clearCookie).not.toHaveBeenCalled();
});

it('refuses and clears tokenPair when proxy email differs from JWT user', async () => {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed — fallback behaviour is now covered by:

  • falls back to X-Auth-Request-User when X-Auth-Request-Email is absent (line 143)
  • prefers X-Auth-Request-Email over X-Auth-Request-User when both are present (line 293)
  • synthesises bare X-Auth-Request-User against DEFAULT_EMAIL_DOMAIN before comparing (line 364)
  • refuses and clears tokenPair when bare proxy identity arrives without DEFAULT_EMAIL_DOMAIN configured (line 392)

…iffers

Closes the stale-session-on-user-switch class of bug in Twenty. In SSO
mode, JwtAuthGuard previously validated the Bearer JWT from the
tokenPair cookie without ever consulting the upstream identity that
oauth2-proxy was asserting. Once Twenty issued its tokenPair cookie via
/auth/sso/proxy-login, the cookie was the sole source of identity on
every subsequent request.

After a portal "Logout all" + login as a different user — which clears
the shared _oauth2_proxy cookie and Cognito SSO session but NOT
Twenty's tokenPair cookie on its own subdomain — refreshing the Twenty
tab kept serving the previous user from the stale Bearer.

Implements proxy-auth-middleware Rule 2 ("Identity mismatch SHALL flush
the existing session immediately") per the cross-app contract in
awais786/sso-rules-moneta:openspec/specs/proxy-auth-middleware/spec.md.

Behaviour:
  - AUTH_TYPE != SSO: bypass entirely (Twenty's native auth path).
  - No `data.user` on the auth context (API key, application token):
    bypass (programmatic identity, no upstream user to compare).
  - SSO + user present + proxy header matches JWT email (normalised on
    both sides): pass through, no DB writes.
  - SSO + user present + proxy header absent: pass through. Header
    absence is NOT a logout signal per the spec — internal calls,
    OPTIONS preflight, and direct backend hits legitimately arrive
    without it.
  - SSO + user present + proxy header asserts a DIFFERENT identity:
    response.clearCookie('tokenPair', { path: '/' }), return false.
    The frontend bootstraps without a tokenPair, routes through
    /auth/sso/proxy-login under the new upstream identity, and gets
    fresh tokens.

normalizeProxyEmail uses indexOf-based email-shape detection rather
than regex per the spec's polynomial-backtracking avoidance rule.
Bare-username Cognito pools (user_id_claim=cognito:username) get
synthesised against DEFAULT_EMAIL_DOMAIN so the comparison matches the
shape the SSO proxy-login flow uses to provision the user.

Tests (jwt-auth.guard.spec.ts, 11 cases):
  - SSO match → passes through, no clearCookie
  - SSO header absent → passes through, no clearCookie
  - SSO whitespace-only header → passes through (treats as absent)
  - SSO mismatch → refuse + clearCookie('tokenPair', { path: '/' })
  - SSO case- and whitespace-variant match — bidirectional norm guard
  - SSO bare-username synthesis against DEFAULT_EMAIL_DOMAIN
  - AUTH_TYPE unset → mismatch check NOT run
  - API-key auth (no user) → mismatch check NOT run
  - Application auth (no user) → mismatch check NOT run
  - clearCookie unavailable (defensive) → still refuses cleanly
  - validateTokenByRequest throws → returns false

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@awais786 awais786 force-pushed the fix/proxy-auth-stale-session-on-user-switch branch from 0cf3292 to d8b3e2b Compare May 15, 2026 21:09
@awais786
Copy link
Copy Markdown
Collaborator Author

Pressingly/plane#29 @copilot review with this rules.

Copy link
Copy Markdown

Copilot AI commented May 15, 2026

Pressingly/plane#29 @copilot review with this rules.

Applied the analogous follow-up here in 4a3acb1: JwtAuthGuard now mirrors /auth/sso/proxy-login by preferring X-Auth-Request-Email and falling back to X-Auth-Request-User, with added tests for the fallback, header precedence, and bare-username synthesis.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment on lines +142 to +158
* - If it isn't email-shaped (e.g. oauth2-proxy is forwarding a bare
* Cognito username via user_id_claim=cognito:username), synthesise
* `<local>@${DEFAULT_EMAIL_DOMAIN}` so the resulting key matches the
* one Twenty's SSO proxy-login flow uses to provision the user.
*
* The email-shape check uses indexOf rather than a regex to avoid
* polynomial-backtracking complexity on adversarial input — per
* openspec proxy-auth-middleware §"email-shape detection on header
* values SHALL avoid polynomial-backtracking regex".
*/
private normalizeProxyEmail(raw: string): string {
const trimmed = raw.toLowerCase().trim();
const atIdx = trimmed.indexOf('@');
const dotIdx = trimmed.indexOf('.', atIdx + 1);
const isEmailShaped = atIdx > 0 && dotIdx > atIdx + 1;

if (isEmailShaped) {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit 095250b3f7. normalizeProxyIdentity now uses .includes('@') rather than a stricter email-shape regex — any value containing @ is treated as an email as-is, matching SsoProxyLoginController.resolveEmail() semantics. The previous @.+\..+ form rejected legitimate emails with no dot in the domain (e.g. user@localhost); now they parse the same way in both code paths. Tests at :235 and :263 cover the @-without-dot case.

Comment on lines +162 to +165
const domain = this.twentyConfigService.get('DEFAULT_EMAIL_DOMAIN');
const localPart = trimmed.split('@')[0];

return `${localPart}@${domain}`;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit 4ac21b60b8 — fail-closed on unresolved bare-username identity. When the proxy header is a bare username (no @) and DEFAULT_EMAIL_DOMAIN is unset, normalizeProxyIdentity now returns null instead of synthesising an invalid local@ value. matchesProxyIdentity treats the null as mismatch → flush. Test refuses and clears tokenPair when bare proxy identity arrives without DEFAULT_EMAIL_DOMAIN configured (line 392) pins the behaviour.

@awais786
Copy link
Copy Markdown
Collaborator Author

Copy link
Copy Markdown

Copilot AI commented May 16, 2026

https://github.com/Pressingly/twenty/blob/foss-main/packages/twenty-server/src/engine/core-modules/auth/controllers/sso-proxy-login.controller.spec.ts#L19 @copilot

Aligned the guard with the proxy-login controller behavior in 4ac21b6: identities with @ are now treated the same way as resolveEmail(), and bare usernames without DEFAULT_EMAIL_DOMAIN now fail closed instead of synthesizing an invalid address. Added matching regression coverage in the guard spec and re-ran the guard + proxy-login controller specs.

JwtAuthGuard only runs on routes decorated with the guard. Twenty's
REST and GraphQL middleware also validate the JWT and hydrate the
request via MiddlewareService — without the mismatch check there, a
stale cookie request could still be served as the previous user
through those paths.

Extract matchesProxyIdentity / resolveProxyIdentity /
normalizeProxyIdentity / clearTokenPairCookie into a shared
proxy-identity.util.ts. Invoke from both hydrateGraphqlRequest and
hydrateRestRequest in MiddlewareService. JwtAuthGuard now consumes
the same utility so logic can't drift across call sites.

Bidirectional normalisation (`.toLowerCase().trim()` on BOTH the
proxy header and the JWT user email) is required by
openspec/specs/proxy-auth-middleware/spec.md "Match is case- and
whitespace-insensitive". Asymmetric normalisation would falsely
trigger mismatch on whitespace-padded legacy DB rows. Added a
specific regression-guard test covering both case- and whitespace-
variants on either side.

Gating preserved: AUTH_TYPE !== 'SSO' skips entirely (non-SSO
deploys untouched); jwtEmail absent skips (API keys / app tokens
have no User); proxy headers absent passes through (not a logout
signal per spec).

Tests: 12 new cases covering match, mismatch, header absence,
AUTH_TYPE=PASSWORD, API key, no-token short-circuit, user-header
fallback, fail-closed on bare username + missing
DEFAULT_EMAIL_DOMAIN, missing response object, and the case/
whitespace regression guards.
@awais786
Copy link
Copy Markdown
Collaborator Author

@copilot review

Final pre-merge review request. Branch is at e709fa6886. Confirmed working end-to-end on the local FOSS bundle:

  • Logged in as user A → added a note/task → switched to user B via portal logout + re-login → new records correctly attributed to B's workspaceMember, not A's. The mismatch detection + tokenPair cookie clear fires on the switch and the frontend bootstrap re-auths cleanly as the new user.

Changes since the previous review:

  • Extracted matchesProxyIdentity, resolveProxyIdentity, normalizeProxyIdentity, clearTokenPairCookie into shared packages/twenty-server/src/engine/utils/proxy-identity.util.ts.
  • Added the same mismatch check to both MiddlewareService.hydrateRestRequest and hydrateGraphqlRequest via assertProxyIdentityMatchesUser. Previously the check was only in JwtAuthGuard — REST and GraphQL hydration would still serve the previous user on a stale cookie.
  • Bidirectional .toLowerCase().trim() on BOTH the proxy header and the JWT user email side — asymmetric normalisation would falsely trigger mismatch on whitespace-padded legacy DB rows. Mirrors Plane and Outline's bidirectional patterns. Required by openspec/specs/proxy-auth-middleware/spec.md "Match is case- and whitespace-insensitive".
  • New 12-case test suite in middleware.service.spec.ts covering match, mismatch, header absence, AUTH_TYPE=PASSWORD skip, API key skip, no-token short-circuit, user-header fallback, fail-closed on bare username + missing DEFAULT_EMAIL_DOMAIN, missing response object, and the case+whitespace regression guards.

Please confirm no remaining leaks across the auth surface.

Copy link
Copy Markdown

Copilot AI commented May 18, 2026

@copilot review

Final pre-merge review request. Branch is at e709fa6886. Confirmed working end-to-end on the local FOSS bundle:...

Reviewed at e709fa6. I don’t see another stale-session leak on the browser auth surface: the proxy-identity mismatch check now covers the guarded REST/controller path plus both GraphQL and REST hydration paths, and the remaining direct validateTokenByRequest() call sites are either already behind those entry points or are bearer-only/non-session paths rather than tokenPair-cookie rehydration paths. I also re-ran the targeted guard, middleware, and SSO proxy-login specs locally (43 passing).

@awais786 awais786 merged commit b5c2878 into foss-main May 18, 2026
79 of 81 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants