From 663ef332ad1225992f800ee8def219f772f0a821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 14 May 2026 17:02:07 +0200 Subject: [PATCH] feat(auth): resume workspace selection on /welcome with valid tokenPair cookie (#20575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary After a user completes a multi-workspace social-SSO sign-in, [auth.service.ts:988-1011](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts#L988-L1011) issues a **workspace-agnostic** access + refresh token pair and lands them on `app.twenty.com/welcome?tokenPair=…`. [SignInUpGlobalScopeFormEffect.tsx](packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx) reads the URL param, writes the cookie, pushes them to `SignInUpStep.WorkspaceSelection`. The problem: if the user revisits `app.twenty.com/welcome` later (e.g. ChatGPT pings `/authorize` and the global page-change effect redirects them to `/welcome` with `returnToPath=/authorize?…`), the existing branch is a no-op — the URL param is gone. The user sees the regular email/SSO form and has to re-authenticate, even though the workspace-agnostic cookie is still valid. This PR adds a second branch in the same `useEffect` that handles the "valid cookie, no URL param" case: ```ts if (signInUpStep !== SignInUpStep.Init) return; if (!hasAccessTokenPair) return; loadCurrentUser(); setSignInUpStep(SignInUpStep.WorkspaceSelection); ``` Single `useEffect`, no `useRef`, no async then/catch. The synchronous `setSignInUpStep(WorkspaceSelection)` is the gate — once the step transitions, subsequent effect runs early-return. Mirrors the existing URL-param branch's pattern exactly. If the cookie is stale, `loadCurrentUser` triggers Apollo's renewal middleware. Renewal of a workspace-agnostic refresh token is supported end-to-end (verified in audit, see below) — if it succeeds the user sees their workspaces; if both tokens are expired, `onUnauthenticatedError` clears the cookie and the next render lands them on the regular sign-in form. Same fallback as if the cookie had never been there. ## Behavior matrix | State on /welcome mount | Before | After | |---|---|---| | No tokenPair anywhere | Show sign-in form | Show sign-in form | | tokenPair in URL (just bounced from SSO) | Set tokens → WorkspaceSelection | (unchanged) Set tokens → WorkspaceSelection | | tokenPair in cookie, access valid | Show sign-in form ❌ | **→ WorkspaceSelection ✓** | | tokenPair in cookie, access expired, refresh valid | Show sign-in form (Apollo eventually 401s on a query) | Renewal succeeds silently → WorkspaceSelection ✓ | | tokenPair in cookie, both expired | Show sign-in form | `onUnauthenticatedError` clears cookie → fall back to sign-in form | ## Workspace-agnostic renewal: confirmed working end-to-end Audit summary: - **Refresh token carries the type**: [refresh-token.service.ts:104](packages/twenty-server/src/engine/core-modules/auth/token/services/refresh-token.service.ts) preserves `targetedTokenType` in the JWT payload and returns it from `verifyRefreshToken`. - **Renewal branches on type** ([renew-token.service.ts:70-87](packages/twenty-server/src/engine/core-modules/auth/token/services/renew-token.service.ts)): ```ts const accessToken = isDefined(authProvider) && targetedTokenType === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC && !isDefined(workspaceId) ? await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken({...}) : await this.accessTokenService.generateAccessToken({...}); ``` Renewed refresh token preserves `targetedTokenType` (line 93). - **Resolver is workspace-agnostic**: `@UseGuards(PublicEndpointGuard, NoPermissionGuard)` on `renewToken` ([auth.resolver.ts:796-804](packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts)) — no `@AuthWorkspace()` requirement, callable from `app.twenty.com`. - **Frontend middleware is type-agnostic**: [apollo.factory.ts:180-209](packages/twenty-front/src/modules/apollo/services/apollo.factory.ts) just passes the refresh token blob. Net: no backend change needed. The full workspace-agnostic lifecycle (issue → cookie → renew → re-issue) already works. ## Test plan - [x] `npx oxlint` + `prettier --check` — clean. - [x] `npx nx typecheck twenty-front` — clean. - [ ] Manual: complete one full SSO flow ending on a workspace subdomain. Visit `https://app.twenty.com/welcome` directly — expect the workspace picker, not the sign-in form. - [ ] Manual: same but with tokenPair cookie cleared — expect the regular sign-in form (no regression). - [ ] Manual: sign-out from a workspace, then visit `app.twenty.com/welcome` — expect the regular form (sign-out clears the cookie via full page reload). - [ ] Manual: stale/expired tokenPair cookie — Apollo renewal kicks in transparently; if renewal fails, regular form (no infinite loop, no crash). - [ ] Manual: pair with #20572 — visit `app.twenty.com/authorize?…` with a stale workspace-agnostic cookie. Expected chain: `/authorize` renders → `PageChangeEffect` redirects to `/welcome?returnToPath=/authorize?…` → this effect lands the user on WorkspaceSelection → picking a workspace bounces to `/authorize?…` where consent renders. ## Out of scope - Fixing `lastAuthenticatedWorkspaceDomain` for custom-domain users (separate cookie-scoping issue, tracked separately). --- .../SignInUpGlobalScopeFormEffect.tsx | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx index 1b22299c54..f553de2cc5 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect.tsx @@ -1,35 +1,56 @@ import { useAuth } from '@/auth/hooks/useAuth'; +import { useHasAccessTokenPair } from '@/auth/hooks/useHasAccessTokenPair'; import { SignInUpStep, signInUpStepState, } from '@/auth/states/signInUpStepState'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser'; import { useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { isDefined } from 'twenty-shared/utils'; export const SignInUpGlobalScopeFormEffect = () => { const setSignInUpStep = useSetAtomState(signInUpStepState); + const signInUpStep = useAtomStateValue(signInUpStepState); const [searchParams, setSearchParams] = useSearchParams(); const { setAuthTokens } = useAuth(); const { loadCurrentUser } = useLoadCurrentUser(); + const hasAccessTokenPair = useHasAccessTokenPair(); useEffect(() => { - const tokenPair = searchParams.get('tokenPair'); - if (isDefined(tokenPair)) { - setAuthTokens(JSON.parse(tokenPair)); + // Path 1: user just bounced back from social SSO with a workspace-agnostic + // tokenPair in the URL. Honor it unconditionally. + const tokenPairFromUrl = searchParams.get('tokenPair'); + if (isDefined(tokenPairFromUrl)) { + setAuthTokens(JSON.parse(tokenPairFromUrl)); searchParams.delete('tokenPair'); setSearchParams(searchParams); loadCurrentUser(); setSignInUpStep(SignInUpStep.WorkspaceSelection); + return; } + + // Path 2: user revisits /welcome with a still-valid workspace-agnostic + // tokenPair cookie left over from a prior SSO landing. Resume straight + // to workspace selection instead of forcing them through the sign-in + // form again. The step transition gates re-entry; if the cookie is + // stale, loadCurrentUser triggers Apollo's renewal -> onUnauthenticatedError + // path which clears the cookie and falls back to the normal form. + if (signInUpStep !== SignInUpStep.Init) return; + if (!hasAccessTokenPair) return; + + loadCurrentUser(); + setSignInUpStep(SignInUpStep.WorkspaceSelection); }, [ searchParams, setSearchParams, setSignInUpStep, loadCurrentUser, setAuthTokens, + signInUpStep, + hasAccessTokenPair, ]); return <>;