fix(auth): preserve returnToPath across Google/Microsoft SSO redirects (#20537)

## Summary

Fixes the consent-modal-not-reopening half of
[#20535](https://github.com/twentyhq/twenty/issues/20535): when a
signed-out user opens an OAuth `/authorize?...` URL (e.g. ChatGPT
connecting to `api.twenty.com/mcp`) and signs in with **Google or
Microsoft**, the original `/authorize` request was lost and the consent
screen never reopened.

### Root cause

`PageChangeEffect` already saves the deep link as `returnToPath` (Jotai
atom) before navigating to `/welcome`. That atom is in-memory: it
survives SPA navigation, and the cross-subdomain workspace hop is
handled by `useBuildSearchParamsFromUrlSyncedStates` round-tripping the
value through the URL.

But the social-SSO path leaves `app.twenty.com` entirely —
`app.twenty.com/welcome` → `api.twenty.com/auth/google` → Google →
`api.twenty.com/auth/google/redirect` → frontend — so the atom is wiped.
None of the existing code paths plumbed `returnToPath` through that hop:
- `useAuth.buildRedirectUrl` packed `workspaceInviteHash`/`action`/etc.
but not `returnToPath`.
- `SocialSSOState` / the Google + Microsoft strategies didn't carry it
through the OAuth `state` blob.
- `signInUpWithSocialSSO` + `computeRedirectURI` didn't re-emit it on
the redirect back to the frontend.

The email path worked because all transitions stay on the default
frontend domain, so the atom survives until `SignInUpGlobalScopeForm`
bakes it into the workspace URL.

### What changed

Plumb `returnToPath` through the SSO state the same way
`workspaceInviteHash` and `action` already flow:

- **Frontend** (`useAuth.buildRedirectUrl`): read `returnToPath` from
the Jotai store and append it to `/auth/google` / `/auth/microsoft` when
set and structurally valid.
- **Server types** (`SocialSSOState`, `GoogleRequest['user']`,
`MicrosoftRequest['user']`): add optional `returnToPath`.
- **Strategies** (`google.auth.strategy.ts`,
`microsoft.auth.strategy.ts`): include `returnToPath:
req.query.returnToPath` in the JSON `state` and read it back in
`validate`.
- **auth.service.ts** (`signInUpWithSocialSSO`, `computeRedirectURI`):
forward `returnToPath` on both branches — the multi-workspace redirect
to `AppPath.SignInUp?tokenPair=...` and the single-workspace redirect to
`<workspace>/verify?loginToken=...`. Validated via a new
`isValidReturnToPath` helper so a tampered query value can't become an
open-redirect vector.

After the round-trip, `useInitializeQueryParamState` rehydrates the atom
from the URL and `usePageChangeEffectNavigateLocation` resolves it as
the post-auth destination — same mechanism the email path already relied
on.

Out of scope: the OAuth `resource` parameter handling tracked in
[#20296](https://github.com/twentyhq/twenty/issues/20296) is independent
and not addressed here.

## Test plan

- [x] `npx jest src/engine/core-modules/auth` (twenty-server) — 27
suites / 183 tests pass, including new
`is-valid-return-to-path.util.spec.ts`.
- [x] `npx jest src/modules/auth` (twenty-front) — 13 suites / 52 tests
pass, including two new cases in `useAuth.test.tsx` covering the happy
path and the protocol-relative open-redirect guard.
- [x] `npx nx typecheck twenty-server` / `twenty-front` — clean.
- [x] `npx oxlint` + `prettier --check` on touched files — clean.
- [ ] Manual: signed-out user opens
`https://app.twenty.com/authorize?client_id=...` → Continue with Google
→ completes Google → selects workspace → consent screen renders.
- [ ] Manual: same flow, single workspace — lands on consent screen
directly after Verify.
- [ ] Manual: email path still works (regression).
- [ ] Manual: tamper `returnToPath=//evil.com` on the `/auth/google` URL
→ server validation rejects, user lands at default home, not at
`evil.com`.

E2E note: existing `return-to-path.spec.ts` already covers deep links
with query params through the email path. A mock OAuth provider would be
needed to cover the SSO path end-to-end; unit coverage stands in for
now.
This commit is contained in:
Félix Malfait
2026-05-13 21:54:54 +02:00
committed by GitHub
parent 2a9fef2341
commit 49b9660420
6 changed files with 72 additions and 1 deletions
@@ -10,6 +10,7 @@ import ms from 'ms';
import { PasswordUpdateNotifyEmail } from 'twenty-emails';
import { PermissionFlagType } from 'twenty-shared/constants';
import { AppPath, ConnectedAccountProvider } from 'twenty-shared/types';
import { isNonEmptyString } from '@sniptt/guards';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
@@ -761,10 +762,12 @@ export class AuthService {
loginToken,
workspace,
billingCheckoutSessionState,
returnToPath,
}: {
loginToken: string;
workspace: WorkspaceDomainConfig;
billingCheckoutSessionState?: string;
returnToPath?: string;
}) {
const url = this.workspaceDomainsService.buildWorkspaceURL({
workspace,
@@ -772,6 +775,9 @@ export class AuthService {
searchParams: {
loginToken,
...(billingCheckoutSessionState ? { billingCheckoutSessionState } : {}),
...(isNonEmptyString(returnToPath) && returnToPath.startsWith('/')
? { returnToPath }
: {}),
},
});
@@ -944,6 +950,7 @@ export class AuthService {
billingCheckoutSessionState,
action,
locale,
returnToPath,
}: MicrosoftRequest['user'] | GoogleRequest['user'],
authProvider: AuthProviderEnum.Google | AuthProviderEnum.Microsoft,
): Promise<string> {
@@ -995,6 +1002,9 @@ export class AuthService {
targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
}),
}),
...(isNonEmptyString(returnToPath) && returnToPath.startsWith('/')
? { returnToPath }
: {}),
},
});
@@ -1066,6 +1076,7 @@ export class AuthService {
loginToken: loginToken.token,
workspace,
billingCheckoutSessionState,
returnToPath,
});
} catch (error) {
return this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({
@@ -33,6 +33,7 @@ export type GoogleRequest = Omit<
action: SocialSSOSignInUpActionType;
workspaceId?: string;
billingCheckoutSessionState?: string;
returnToPath?: string;
};
};
@@ -59,6 +60,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
workspacePersonalInviteToken: req.query.workspacePersonalInviteToken,
action: req.query.action,
locale: req.query.locale,
returnToPath: req.query.returnToPath,
}),
};
@@ -97,6 +99,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
billingCheckoutSessionState: state?.billingCheckoutSessionState,
action: state?.action ?? 'list-available-workspaces',
locale: state?.locale,
returnToPath: state?.returnToPath,
};
done(null, user);
@@ -31,6 +31,7 @@ export type MicrosoftRequest = Omit<
workspaceId?: string;
billingCheckoutSessionState?: string;
action: SocialSSOSignInUpActionType;
returnToPath?: string;
};
};
@@ -58,6 +59,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
billingCheckoutSessionState: req.query.billingCheckoutSessionState,
workspacePersonalInviteToken: req.query.workspacePersonalInviteToken,
action: req.query.action,
returnToPath: req.query.returnToPath,
oauthRetryCount: req.query.oauthRetryCount
? Number(req.query.oauthRetryCount)
: undefined,
@@ -95,6 +97,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
billingCheckoutSessionState: state?.billingCheckoutSessionState,
locale: state?.locale,
action: state?.action ?? 'list-available-workspaces',
returnToPath: state?.returnToPath,
};
done(null, user);
@@ -9,4 +9,5 @@ export type SocialSSOState = {
workspacePersonalInviteToken?: string;
action?: SocialSSOSignInUpActionType;
locale?: keyof typeof APP_LOCALES;
returnToPath?: string;
};