From 56245a35af545a823a3f85a9bbe64317fa506a8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:58:42 +0200 Subject: [PATCH] Stop leaking the refresh token in the social SSO redirect URL (#23061) The Google/Microsoft callback for a sign-in with no target workspace redirected to `/sign-in-up?tokenPair={...}`, putting a 60-day refresh token in a query string. Those persist in browser history, `Referer` headers and access logs. It now carries a single-use, 5-minute opaque token in the URL fragment, which the frontend exchanges over POST. Browsers never send the fragment on the wire, so the token stays out of access logs, proxies and `Referer` headers entirely. Redemption claims the row with a `DELETE` guarded on `revokedAt`/`deletedAt` being null, so concurrent requests cannot each mint a refresh token and a revoked token cannot redeem. Enterprise SSO (OIDC/SAML) already used a POST exchange and is unchanged. ```mermaid sequenceDiagram participant Browser participant Server participant DB Note over Browser,Server: before, the redirect carried access + 60-day refresh in ?tokenPair Browser->>Server: GET /auth/google/redirect Server->>DB: store sha256(token), expires in 5 min Server-->>Browser: 302 /sign-in-up#ssoExchangeToken=opaque Note over Browser: fragment never sent back to any server Browser->>Server: POST getAuthTokensFromSSOExchangeToken Server->>DB: guarded DELETE, single-use claim Server-->>Browser: access + refresh token, in the response body ``` Since the token is single-use, the refresh token is minted at redemption instead of at callback, so an abandoned redirect leaves an inert expired hash rather than a live credential. Redemption lives in its own `SignInUpSSOExchangeTokenEffect` + `useRedeemSSOExchangeToken`, mirroring the existing `VerifyLoginTokenEffect` + `useVerifyLogin` pair, so `SignInUpGlobalScopeFormEffect` only loses the vulnerable branch. Like `useVerifyLogin`, the hook clears any stale token pair before exchanging. The effect reads `window.location.hash` live and strips it synchronously, which doubles as the StrictMode double-invocation latch. Remaining exposure is the browser itself (history until the synchronous strip, client-side scripts), same as any fragment-based OAuth response. `loginToken` on the workspace-targeted branch still travels as `/verify?loginToken=` and is replayable for 15 minutes; moving it to the fragment too is a separate change. A fast instance command adds a unique partial index on `("type", "value")` for live SSO exchange tokens, so redemption is an index lookup instead of a full scan of the shared token table and at most one row can ever match. --- .../src/metadata/generated/schema.graphql | 1 + .../src/metadata/generated/schema.ts | 2 + .../src/metadata/generated/types.ts | 9 + .../src/generated-metadata/graphql.ts | 14 ++ .../getAuthTokensFromSSOExchangeToken.ts | 11 ++ .../useRedeemSSOExchangeToken.test.ts | 140 +++++++++++++ .../src/modules/auth/hooks/useAuth.ts | 1 - .../auth/hooks/useRedeemSSOExchangeToken.ts | 57 ++++++ .../SignInUpGlobalScopeFormEffect.tsx | 17 +- .../SignInUpSSOExchangeTokenEffect.tsx | 30 +++ .../SignInUpSSOExchangeTokenEffect.test.tsx | 73 +++++++ .../twenty-front/src/pages/auth/SignInUp.tsx | 3 + ...586000-add-app-token-sso-exchange-index.ts | 21 ++ .../instance-commands.constant.ts | 2 + .../app-token/app-token.entity.ts | 8 + .../core-modules/auth/auth.resolver.spec.ts | 5 + .../engine/core-modules/auth/auth.resolver.ts | 32 +++ ...th-tokens-from-sso-exchange-token.input.ts | 11 ++ .../auth/services/auth.service.spec.ts | 87 +++++++- .../auth/services/auth.service.ts | 27 ++- .../sso-exchange-token.service.spec.ts | 185 ++++++++++++++++++ .../services/sso-exchange-token.service.ts | 118 +++++++++++ .../core-modules/auth/token/token.module.ts | 3 + .../services/domain-server-config.service.ts | 3 + ...use-sso-exchange-token.integration-spec.ts | 171 ++++++++++++++++ ...uth-tokens-from-sso-exchange-token.util.ts | 62 ++++++ 26 files changed, 1053 insertions(+), 40 deletions(-) create mode 100644 packages/twenty-front/src/modules/auth/graphql/mutations/getAuthTokensFromSSOExchangeToken.ts create mode 100644 packages/twenty-front/src/modules/auth/hooks/__tests__/useRedeemSSOExchangeToken.test.ts create mode 100644 packages/twenty-front/src/modules/auth/hooks/useRedeemSSOExchangeToken.ts create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpSSOExchangeTokenEffect.tsx create mode 100644 packages/twenty-front/src/modules/auth/sign-in-up/components/internal/__tests__/SignInUpSSOExchangeTokenEffect.test.tsx create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-25/2-25-instance-command-fast-1785143586000-add-app-token-sso-exchange-index.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/dto/get-auth-tokens-from-sso-exchange-token.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/sso-exchange-token.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/auth/token/services/sso-exchange-token.service.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/auth/sso-exchange-token/single-use-sso-exchange-token.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/get-auth-tokens-from-sso-exchange-token.util.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index d465a1c03c..76fd27a98b 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -3526,6 +3526,7 @@ type Mutation { uploadNewWorkspaceLogo(workspaceId: String!, file: Upload!): FileWithSignedUrl! generateTransientToken: TransientToken! getAuthTokensFromLoginToken(loginToken: String!, origin: String!): AuthTokens! + getAuthTokensFromSSOExchangeToken(ssoExchangeToken: String!): AuthTokens! authorizeApp(clientId: String!, codeChallenge: String, redirectUrl: String!, state: String, scope: String): AuthorizeApp! renewToken(appToken: String!): AuthTokens! generateApiKeyToken(apiKeyId: UUID!, expiresAt: String!): ApiKeyToken! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 8b68d86075..bb15fc28e5 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -3056,6 +3056,7 @@ export interface Mutation { uploadNewWorkspaceLogo: FileWithSignedUrl generateTransientToken: TransientToken getAuthTokensFromLoginToken: AuthTokens + getAuthTokensFromSSOExchangeToken: AuthTokens authorizeApp: AuthorizeApp renewToken: AuthTokens generateApiKeyToken: ApiKeyToken @@ -6331,6 +6332,7 @@ export interface MutationGenqlSelection{ uploadNewWorkspaceLogo?: (FileWithSignedUrlGenqlSelection & { __args: {workspaceId: Scalars['String'], file: Scalars['Upload']} }) generateTransientToken?: TransientTokenGenqlSelection getAuthTokensFromLoginToken?: (AuthTokensGenqlSelection & { __args: {loginToken: Scalars['String'], origin: Scalars['String']} }) + getAuthTokensFromSSOExchangeToken?: (AuthTokensGenqlSelection & { __args: {ssoExchangeToken: Scalars['String']} }) authorizeApp?: (AuthorizeAppGenqlSelection & { __args: {clientId: Scalars['String'], codeChallenge?: (Scalars['String'] | null), redirectUrl: Scalars['String'], state?: (Scalars['String'] | null), scope?: (Scalars['String'] | null)} }) renewToken?: (AuthTokensGenqlSelection & { __args: {appToken: Scalars['String']} }) generateApiKeyToken?: (ApiKeyTokenGenqlSelection & { __args: {apiKeyId: Scalars['UUID'], expiresAt: Scalars['String']} }) diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index fd6266af13..43e0a32eb2 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -9102,6 +9102,15 @@ export default { ] } ], + "getAuthTokensFromSSOExchangeToken": [ + 258, + { + "ssoExchangeToken": [ + 1, + "String!" + ] + } + ], "authorizeApp": [ 244, { diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index dddc94b764..97b36417b6 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -2664,6 +2664,7 @@ export type Mutation = { generateTransientToken: TransientToken; getAuthTokensFromLoginToken: AuthTokens; getAuthTokensFromOTP: AuthTokens; + getAuthTokensFromSSOExchangeToken: AuthTokens; getAuthorizationUrlForSSO: GetAuthorizationUrlForSso; getLoginTokenFromCredentials: LoginToken; impersonate: Impersonate; @@ -3342,6 +3343,11 @@ export type MutationGetAuthTokensFromOtpArgs = { }; +export type MutationGetAuthTokensFromSsoExchangeTokenArgs = { + ssoExchangeToken: Scalars['String']['input']; +}; + + export type MutationGetAuthorizationUrlForSsoArgs = { input: GetAuthorizationUrlForSsoInput; }; @@ -6947,6 +6953,13 @@ export type GetAuthTokensFromOtpMutationVariables = Exact<{ export type GetAuthTokensFromOtpMutation = { __typename?: 'Mutation', getAuthTokensFromOTP: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessOrWorkspaceAgnosticToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type GetAuthTokensFromSsoExchangeTokenMutationVariables = Exact<{ + ssoExchangeToken: Scalars['String']['input']; +}>; + + +export type GetAuthTokensFromSsoExchangeTokenMutation = { __typename?: 'Mutation', getAuthTokensFromSSOExchangeToken: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessOrWorkspaceAgnosticToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; + export type GetAuthorizationUrlForSsoMutationVariables = Exact<{ input: GetAuthorizationUrlForSsoInput; }>; @@ -9080,6 +9093,7 @@ export const GeneratePlaygroundTokenDocument = {"kind":"Document","definitions": export const GenerateTransientTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"generateTransientToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"generateTransientToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"transientToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]}}]}}]} as unknown as DocumentNode; export const GetAuthTokensFromLoginTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"getAuthTokensFromLoginToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"loginToken"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"origin"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAuthTokensFromLoginToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"loginToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"loginToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"origin"},"value":{"kind":"Variable","name":{"kind":"Name","value":"origin"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenPairFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthToken"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenPairFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthTokenPair"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"accessOrWorkspaceAgnosticToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"refreshToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}}]}}]} as unknown as DocumentNode; export const GetAuthTokensFromOtpDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"getAuthTokensFromOTP"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"loginToken"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"otp"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"origin"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAuthTokensFromOTP"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"loginToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"loginToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"otp"},"value":{"kind":"Variable","name":{"kind":"Name","value":"otp"}}},{"kind":"Argument","name":{"kind":"Name","value":"captchaToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"origin"},"value":{"kind":"Variable","name":{"kind":"Name","value":"origin"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenPairFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthToken"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenPairFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthTokenPair"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"accessOrWorkspaceAgnosticToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"refreshToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}}]}}]} as unknown as DocumentNode; +export const GetAuthTokensFromSsoExchangeTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"getAuthTokensFromSSOExchangeToken"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ssoExchangeToken"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAuthTokensFromSSOExchangeToken"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ssoExchangeToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ssoExchangeToken"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tokens"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenPairFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthToken"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenPairFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthTokenPair"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"accessOrWorkspaceAgnosticToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"refreshToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}}]}}]} as unknown as DocumentNode; export const GetAuthorizationUrlForSsoDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"GetAuthorizationUrlForSSO"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"GetAuthorizationUrlForSSOInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAuthorizationUrlForSSO"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"authorizationURL"}}]}}]}}]} as unknown as DocumentNode; export const GetLoginTokenFromCredentialsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"GetLoginTokenFromCredentials"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"email"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"password"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"origin"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getLoginTokenFromCredentials"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"email"},"value":{"kind":"Variable","name":{"kind":"Name","value":"email"}}},{"kind":"Argument","name":{"kind":"Name","value":"password"},"value":{"kind":"Variable","name":{"kind":"Name","value":"password"}}},{"kind":"Argument","name":{"kind":"Name","value":"captchaToken"},"value":{"kind":"Variable","name":{"kind":"Name","value":"captchaToken"}}},{"kind":"Argument","name":{"kind":"Name","value":"origin"},"value":{"kind":"Variable","name":{"kind":"Name","value":"origin"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"loginToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthToken"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]} as unknown as DocumentNode; export const ImpersonateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"Impersonate"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"userId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"impersonate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"userId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"userId"}}},{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceUrls"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WorkspaceUrlsFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"loginToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"AuthTokenFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WorkspaceUrlsFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceUrls"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"subdomainUrl"}},{"kind":"Field","name":{"kind":"Name","value":"customUrl"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"AuthTokenFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AuthToken"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"token"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/getAuthTokensFromSSOExchangeToken.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/getAuthTokensFromSSOExchangeToken.ts new file mode 100644 index 0000000000..d2e91cca8e --- /dev/null +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/getAuthTokensFromSSOExchangeToken.ts @@ -0,0 +1,11 @@ +import { gql } from '@apollo/client'; + +export const GET_AUTH_TOKENS_FROM_SSO_EXCHANGE_TOKEN = gql` + mutation getAuthTokensFromSSOExchangeToken($ssoExchangeToken: String!) { + getAuthTokensFromSSOExchangeToken(ssoExchangeToken: $ssoExchangeToken) { + tokens { + ...AuthTokenPairFragment + } + } + } +`; diff --git a/packages/twenty-front/src/modules/auth/hooks/__tests__/useRedeemSSOExchangeToken.test.ts b/packages/twenty-front/src/modules/auth/hooks/__tests__/useRedeemSSOExchangeToken.test.ts new file mode 100644 index 0000000000..fe5963227b --- /dev/null +++ b/packages/twenty-front/src/modules/auth/hooks/__tests__/useRedeemSSOExchangeToken.test.ts @@ -0,0 +1,140 @@ +import { renderHook } from '@testing-library/react'; +import { Provider as JotaiProvider } from 'jotai'; + +import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState'; +import { useRedeemSSOExchangeToken } from '@/auth/hooks/useRedeemSSOExchangeToken'; +import { tokenPairState } from '@/auth/states/tokenPairState'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { + jotaiStore, + resetJotaiStore, +} from '@/ui/utilities/state/jotai/jotaiStore'; + +const mockGetAuthTokensFromSSOExchangeToken = jest.fn(); + +jest.mock('@apollo/client/react', () => ({ + ...jest.requireActual('@apollo/client/react'), + useMutation: () => [mockGetAuthTokensFromSSOExchangeToken], +})); + +jest.mock('@/ui/feedback/snack-bar-manager/hooks/useSnackBar', () => ({ + useSnackBar: jest.fn(), +})); + +const renderHooks = () => { + const { result } = renderHook(() => useRedeemSSOExchangeToken(), { + wrapper: ({ children }) => JotaiProvider({ store: jotaiStore, children }), + }); + + return { result }; +}; + +const staleTokenPair = { + accessOrWorkspaceAgnosticToken: { + token: 'stale-access-token', + expiresAt: '2020-01-01T00:00:00.000Z', + }, + refreshToken: { + token: 'stale-refresh-token', + expiresAt: '2020-01-01T00:00:00.000Z', + }, +}; + +const freshTokenPair = { + accessOrWorkspaceAgnosticToken: { + token: 'fresh-access-token', + expiresAt: '2100-01-01T00:00:00.000Z', + }, + refreshToken: { + token: 'fresh-refresh-token', + expiresAt: '2100-01-01T00:00:00.000Z', + }, +}; + +describe('useRedeemSSOExchangeToken', () => { + const mockEnqueueErrorSnackBar = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + localStorage.clear(); + resetJotaiStore(); + + (useSnackBar as jest.Mock).mockReturnValue({ + enqueueErrorSnackBar: mockEnqueueErrorSnackBar, + }); + + mockGetAuthTokensFromSSOExchangeToken.mockResolvedValue({ + data: { + getAuthTokensFromSSOExchangeToken: { tokens: freshTokenPair }, + }, + }); + }); + + it('should store the redeemed token pair', async () => { + const { result } = renderHooks(); + + await result.current.redeemSSOExchangeToken('sso-exchange-token'); + + expect(mockGetAuthTokensFromSSOExchangeToken).toHaveBeenCalledWith({ + variables: { ssoExchangeToken: 'sso-exchange-token' }, + }); + expect(jotaiStore.get(tokenPairState.atom)).toEqual(freshTokenPair); + }); + + it('should clear the existing token pair before exchanging', async () => { + jotaiStore.set(tokenPairState.atom, staleTokenPair); + + const tokenPairsAtExchangeTime: unknown[] = []; + + mockGetAuthTokensFromSSOExchangeToken.mockImplementation(() => { + tokenPairsAtExchangeTime.push(jotaiStore.get(tokenPairState.atom)); + + return Promise.resolve({ + data: { getAuthTokensFromSSOExchangeToken: { tokens: freshTokenPair } }, + }); + }); + + const { result } = renderHooks(); + + await result.current.redeemSSOExchangeToken('sso-exchange-token'); + + expect(tokenPairsAtExchangeTime).toEqual([null]); + }); + + it('should disable the redirect effect while exchanging and restore it after', async () => { + const redirectFlagsAtExchangeTime: unknown[] = []; + + mockGetAuthTokensFromSSOExchangeToken.mockImplementation(() => { + redirectFlagsAtExchangeTime.push( + jotaiStore.get(isAppEffectRedirectEnabledState.atom), + ); + + return Promise.resolve({ + data: { getAuthTokensFromSSOExchangeToken: { tokens: freshTokenPair } }, + }); + }); + + const { result } = renderHooks(); + + await result.current.redeemSSOExchangeToken('sso-exchange-token'); + + expect(redirectFlagsAtExchangeTime).toEqual([false]); + expect(jotaiStore.get(isAppEffectRedirectEnabledState.atom)).toBe(true); + }); + + it('should snackbar and leave no token pair when redemption fails', async () => { + mockGetAuthTokensFromSSOExchangeToken.mockRejectedValueOnce( + new Error('Invalid SSO exchange token'), + ); + + const { result } = renderHooks(); + + await result.current.redeemSSOExchangeToken('sso-exchange-token'); + + expect(mockEnqueueErrorSnackBar).toHaveBeenCalledWith({ + message: 'Invalid SSO exchange token', + }); + expect(jotaiStore.get(tokenPairState.atom)).toBeNull(); + expect(jotaiStore.get(isAppEffectRedirectEnabledState.atom)).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index 6cb4077883..f7511f15d0 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -638,7 +638,6 @@ export const useAuth = () => { signInWithCredentials: handleCredentialsSignIn, signInWithGoogle: handleGoogleLogin, signInWithMicrosoft: handleMicrosoftLogin, - setAuthTokens: handleSetAuthTokens, getAuthTokensFromOTP: handleGetAuthTokensFromOTP, navigateAfterMultiWorkspaceSignInUp, }; diff --git a/packages/twenty-front/src/modules/auth/hooks/useRedeemSSOExchangeToken.ts b/packages/twenty-front/src/modules/auth/hooks/useRedeemSSOExchangeToken.ts new file mode 100644 index 0000000000..06959dd2ec --- /dev/null +++ b/packages/twenty-front/src/modules/auth/hooks/useRedeemSSOExchangeToken.ts @@ -0,0 +1,57 @@ +import { isAppEffectRedirectEnabledState } from '@/app/states/isAppEffectRedirectEnabledState'; +import { tokenPairState } from '@/auth/states/tokenPairState'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; +import { CombinedGraphQLErrors } from '@apollo/client/errors'; +import { useMutation } from '@apollo/client/react'; +import { useCallback } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { GetAuthTokensFromSsoExchangeTokenDocument } from '~/generated-metadata/graphql'; + +export const useRedeemSSOExchangeToken = () => { + const { enqueueErrorSnackBar } = useSnackBar(); + const setTokenPair = useSetAtomState(tokenPairState); + const setIsAppEffectRedirectEnabled = useSetAtomState( + isAppEffectRedirectEnabledState, + ); + const [getAuthTokensFromSSOExchangeToken] = useMutation( + GetAuthTokensFromSsoExchangeTokenDocument, + ); + + const redeemSSOExchangeToken = useCallback( + async (ssoExchangeToken: string) => { + // Keeps PageChangeEffect from consuming returnToPath mid token swap, and + // drops any stale pair so the resume waits for the one being redeemed + setIsAppEffectRedirectEnabled(false); + setTokenPair(null); + + try { + const { data } = await getAuthTokensFromSSOExchangeToken({ + variables: { ssoExchangeToken }, + }); + + if (!isDefined(data?.getAuthTokensFromSSOExchangeToken)) { + throw new Error('No getAuthTokensFromSSOExchangeToken result'); + } + + setTokenPair(data.getAuthTokensFromSSOExchangeToken.tokens); + } catch (error: unknown) { + enqueueErrorSnackBar( + CombinedGraphQLErrors.is(error) + ? { apolloError: error } + : { message: error instanceof Error ? error.message : undefined }, + ); + } finally { + setIsAppEffectRedirectEnabled(true); + } + }, + [ + getAuthTokensFromSSOExchangeToken, + setTokenPair, + setIsAppEffectRedirectEnabled, + enqueueErrorSnackBar, + ], + ); + + return { redeemSSOExchangeToken }; +}; 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 0dd7d33c73..e1877ff1ff 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 @@ -7,13 +7,10 @@ import { import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser'; import { useEffect } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { isDefined } from 'twenty-shared/utils'; export const SignInUpGlobalScopeFormEffect = () => { const signInUpStep = useAtomStateValue(signInUpStepState); - const [searchParams, setSearchParams] = useSearchParams(); - const { setAuthTokens, navigateAfterMultiWorkspaceSignInUp } = useAuth(); + const { navigateAfterMultiWorkspaceSignInUp } = useAuth(); const { loadCurrentUser } = useLoadCurrentUser(); const hasAccessTokenPair = useHasAccessTokenPair(); @@ -26,24 +23,12 @@ export const SignInUpGlobalScopeFormEffect = () => { ); }; - const tokenPairFromUrl = searchParams.get('tokenPair'); - if (isDefined(tokenPairFromUrl)) { - setAuthTokens(JSON.parse(tokenPairFromUrl)); - searchParams.delete('tokenPair'); - setSearchParams(searchParams); - void resumeOnCentralDomain(); - return; - } - if (signInUpStep !== SignInUpStep.Init) return; if (!hasAccessTokenPair) return; void resumeOnCentralDomain(); }, [ - searchParams, - setSearchParams, loadCurrentUser, - setAuthTokens, signInUpStep, hasAccessTokenPair, navigateAfterMultiWorkspaceSignInUp, diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpSSOExchangeTokenEffect.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpSSOExchangeTokenEffect.tsx new file mode 100644 index 0000000000..424e8110e4 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/SignInUpSSOExchangeTokenEffect.tsx @@ -0,0 +1,30 @@ +import { useRedeemSSOExchangeToken } from '@/auth/hooks/useRedeemSSOExchangeToken'; +import { useEffect } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +export const SignInUpSSOExchangeTokenEffect = () => { + const { redeemSSOExchangeToken } = useRedeemSSOExchangeToken(); + + useEffect(() => { + const ssoExchangeToken = new URLSearchParams( + window.location.hash.substring(1), + ).get('ssoExchangeToken'); + + if (!isDefined(ssoExchangeToken)) { + return; + } + + // Stripping synchronously through window.history rather than the router + // (whose data-router navigations defer the replace) latches re-invoked and + // remounted effects out: they re-read window.location and find no token + window.history.replaceState( + window.history.state, + '', + window.location.pathname + window.location.search, + ); + + void redeemSSOExchangeToken(ssoExchangeToken); + }, [redeemSSOExchangeToken]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/__tests__/SignInUpSSOExchangeTokenEffect.test.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/__tests__/SignInUpSSOExchangeTokenEffect.test.tsx new file mode 100644 index 0000000000..6013512e0c --- /dev/null +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/internal/__tests__/SignInUpSSOExchangeTokenEffect.test.tsx @@ -0,0 +1,73 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { StrictMode } from 'react'; +import { BrowserRouter, useSearchParams } from 'react-router-dom'; + +import { SignInUpSSOExchangeTokenEffect } from '@/auth/sign-in-up/components/internal/SignInUpSSOExchangeTokenEffect'; + +const redeemSSOExchangeTokenMock = jest.fn(); + +jest.mock('@/auth/hooks/useRedeemSSOExchangeToken', () => ({ + useRedeemSSOExchangeToken: () => ({ + redeemSSOExchangeToken: redeemSSOExchangeTokenMock, + }), +})); + +const SearchParamsProbe = () => { + const [searchParams] = useSearchParams(); + + return
{searchParams.toString()}
; +}; + +// BrowserRouter because the effect reads and strips window.location, which +// MemoryRouter never touches +const renderEffect = (initialUrl: string) => { + window.history.replaceState(null, '', initialUrl); + + return render( + + + + + + , + ); +}; + +const getSearchParams = () => screen.getByTestId('search-params').textContent; + +describe('SignInUpSSOExchangeTokenEffect', () => { + beforeEach(() => { + jest.clearAllMocks(); + window.history.replaceState(null, '', '/'); + }); + + it('redeems the single use token at most once', async () => { + renderEffect('/sign-in-up#ssoExchangeToken=sso-exchange-token'); + + await waitFor(() => { + expect(redeemSSOExchangeTokenMock).toHaveBeenCalledWith( + 'sso-exchange-token', + ); + }); + expect(redeemSSOExchangeTokenMock).toHaveBeenCalledTimes(1); + }); + + it('strips the token from the url while keeping returnToPath', async () => { + renderEffect( + '/sign-in-up?returnToPath=%2Fsettings%2Fprofile#ssoExchangeToken=sso-exchange-token', + ); + + await waitFor(() => { + expect(window.location.hash).toBe(''); + }); + expect(getSearchParams()).toBe('returnToPath=%2Fsettings%2Fprofile'); + expect(redeemSSOExchangeTokenMock).toHaveBeenCalledTimes(1); + }); + + it('does nothing when the url carries no token', () => { + renderEffect('/sign-in-up'); + + expect(redeemSSOExchangeTokenMock).not.toHaveBeenCalled(); + expect(getSearchParams()).toBe(''); + }); +}); diff --git a/packages/twenty-front/src/pages/auth/SignInUp.tsx b/packages/twenty-front/src/pages/auth/SignInUp.tsx index 683fa1bbd8..4dbe37987b 100644 --- a/packages/twenty-front/src/pages/auth/SignInUp.tsx +++ b/packages/twenty-front/src/pages/auth/SignInUp.tsx @@ -27,6 +27,7 @@ import { useIsCurrentLocationOnDefaultDomain } from '@/domain-manager/hooks/useI import { useMemo } from 'react'; import { SignInUpGlobalScopeFormEffect } from '@/auth/sign-in-up/components/internal/SignInUpGlobalScopeFormEffect'; +import { SignInUpSSOExchangeTokenEffect } from '@/auth/sign-in-up/components/internal/SignInUpSSOExchangeTokenEffect'; import { SignInUpTwoFactorAuthenticationProvision } from '@/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationProvision'; import { SignInUpTOTPVerification } from '@/auth/sign-in-up/components/internal/SignInUpTwoFactorAuthenticationVerification'; import { useWorkspaceFromInviteHash } from '@/auth/sign-in-up/hooks/useWorkspaceFromInviteHash'; @@ -154,6 +155,7 @@ export const SignInUp = () => { if (isDefaultDomain && isMultiWorkspaceEnabled) { return ( <> + @@ -186,6 +188,7 @@ export const SignInUp = () => { return ( <> + diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-25/2-25-instance-command-fast-1785143586000-add-app-token-sso-exchange-index.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-25/2-25-instance-command-fast-1785143586000-add-app-token-sso-exchange-index.ts new file mode 100644 index 0000000000..4e746b2c78 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-25/2-25-instance-command-fast-1785143586000-add-app-token-sso-exchange-index.ts @@ -0,0 +1,21 @@ +import { type QueryRunner } from 'typeorm'; + +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; + +@RegisteredInstanceCommand('2.25.0', 1785143586000) +export class AddAppTokenSsoExchangeIndexFastInstanceCommand + implements FastInstanceCommand +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_APP_TOKEN_TYPE_VALUE_SSO_EXCHANGE_UNIQUE" ON "core"."appToken" ("type", "value") WHERE "type" = 'SSO_EXCHANGE_TOKEN' AND "deletedAt" IS NULL AND "revokedAt" IS NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS "core"."IDX_APP_TOKEN_TYPE_VALUE_SSO_EXCHANGE_UNIQUE"`, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index e8456d0958..0718617a1f 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -125,6 +125,7 @@ import { AddStatusesToBillingSubscriptionIndexSlowInstanceCommand } from './2-23 import { AddOnConnectLogicFunctionToConnectionProviderFastInstanceCommand } from './2-24/2-24-instance-command-fast-1784712843602-add-on-connect-logic-function-to-connection-provider'; import { RepairKeyValuePairApplicationIdFastInstanceCommand } from './2-24/2-24-instance-command-fast-1784897347051-repair-key-value-pair-application-id'; import { AddAgentForeignKeyToRoleTargetFastInstanceCommand } from './2-25/2-25-instance-command-fast-1784820332810-add-agent-foreign-key-to-role-target'; +import { AddAppTokenSsoExchangeIndexFastInstanceCommand } from './2-25/2-25-instance-command-fast-1785143586000-add-app-token-sso-exchange-index'; import { AddPageLayoutCascadeDeleteIndexesFastInstanceCommand } from './2-25/2-25-instance-command-fast-1784904030251-add-page-layout-cascade-delete-indexes'; export const INSTANCE_COMMANDS = [ @@ -253,5 +254,6 @@ export const INSTANCE_COMMANDS = [ AddOnConnectLogicFunctionToConnectionProviderFastInstanceCommand, RepairKeyValuePairApplicationIdFastInstanceCommand, AddAgentForeignKeyToRoleTargetFastInstanceCommand, + AddAppTokenSsoExchangeIndexFastInstanceCommand, AddPageLayoutCascadeDeleteIndexesFastInstanceCommand, ]; diff --git a/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts b/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts index f2beb0e7ac..444bb7687d 100644 --- a/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/app-token/app-token.entity.ts @@ -5,6 +5,7 @@ import { Column, CreateDateColumn, Entity, + Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn, @@ -13,6 +14,7 @@ import { } from 'typeorm'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; +import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; export enum AppTokenType { @@ -24,9 +26,14 @@ export enum AppTokenType { OnboardingInvitationToken = 'ONBOARDING_INVITATION_TOKEN', EmailVerificationToken = 'EMAIL_VERIFICATION_TOKEN', EnterpriseValidityToken = 'ENTERPRISE_VALIDITY_TOKEN', + SSOExchangeToken = 'SSO_EXCHANGE_TOKEN', } @Entity({ name: 'appToken', schema: 'core' }) +@Index('IDX_APP_TOKEN_TYPE_VALUE_SSO_EXCHANGE_UNIQUE', ['type', 'value'], { + unique: true, + where: `"type" = 'SSO_EXCHANGE_TOKEN' AND "deletedAt" IS NULL AND "revokedAt" IS NULL`, +}) export class AppTokenEntity { @PrimaryGeneratedColumn('uuid') id: string; @@ -86,5 +93,6 @@ export class AppTokenEntity { clientId?: string; codeChallenge?: string; scope?: string; + authProvider?: AuthProviderEnum; } | null; } diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts index 8b5b650be0..05c10509c7 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.spec.ts @@ -9,6 +9,7 @@ import { ImpersonationAuthorizationService } from 'src/engine/core-modules/imper import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; +import { SSOExchangeTokenService } from 'src/engine/core-modules/auth/token/services/sso-exchange-token.service'; import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard'; import { SubdomainManagerService } from 'src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service'; @@ -114,6 +115,10 @@ describe('AuthResolver', () => { provide: WorkspaceAgnosticTokenService, useValue: {}, }, + { + provide: SSOExchangeTokenService, + useValue: {}, + }, { provide: TransientTokenService, useValue: {}, diff --git a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts index 28d0e39519..4c81e86e93 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/auth.resolver.ts @@ -48,6 +48,7 @@ import { EmailVerificationTokenService } from 'src/engine/core-modules/auth/toke import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service'; +import { SSOExchangeTokenService } from 'src/engine/core-modules/auth/token/services/sso-exchange-token.service'; import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service'; import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; import { AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type'; @@ -97,6 +98,7 @@ import { ApiKeyToken } from './dto/api-key-token.dto'; import { AuthToken } from './dto/auth-token.dto'; import { AuthTokens } from './dto/auth-tokens.dto'; import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input'; +import { GetAuthTokensFromSSOExchangeTokenInput } from './dto/get-auth-tokens-from-sso-exchange-token.input'; import { LoginTokenDTO } from './dto/login-token.dto'; import { SignUpInNewWorkspaceInput } from './dto/sign-up-in-new-workspace.input'; import { SignUpInput } from './dto/sign-up.input'; @@ -133,6 +135,7 @@ export class AuthResolver { private resetPasswordService: ResetPasswordService, private loginTokenService: LoginTokenService, private workspaceAgnosticTokenService: WorkspaceAgnosticTokenService, + private ssoExchangeTokenService: SSOExchangeTokenService, private refreshTokenService: RefreshTokenService, private signInUpService: SignInUpService, private transientTokenService: TransientTokenService, @@ -672,6 +675,35 @@ export class AuthResolver { } } + @Mutation(() => AuthTokens) + @UseGuards(PublicEndpointGuard, NoPermissionGuard) + async getAuthTokensFromSSOExchangeToken( + @Args() + { ssoExchangeToken }: GetAuthTokensFromSSOExchangeTokenInput, + ): Promise { + const { userId, authProvider } = + await this.ssoExchangeTokenService.validateAndConsumeSSOExchangeTokenOrThrow( + ssoExchangeToken, + ); + + return { + tokens: { + accessOrWorkspaceAgnosticToken: + await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken( + { + userId, + authProvider, + }, + ), + refreshToken: await this.refreshTokenService.generateRefreshToken({ + userId, + authProvider, + targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC, + }), + }, + }; + } + private async validateAndDecodeLoginToken( loginToken: string, ): Promise { diff --git a/packages/twenty-server/src/engine/core-modules/auth/dto/get-auth-tokens-from-sso-exchange-token.input.ts b/packages/twenty-server/src/engine/core-modules/auth/dto/get-auth-tokens-from-sso-exchange-token.input.ts new file mode 100644 index 0000000000..f9421a78d7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/dto/get-auth-tokens-from-sso-exchange-token.input.ts @@ -0,0 +1,11 @@ +import { ArgsType, Field } from '@nestjs/graphql'; + +import { IsNotEmpty, IsString } from 'class-validator'; + +@ArgsType() +export class GetAuthTokensFromSSOExchangeTokenInput { + @Field(() => String) + @IsNotEmpty() + @IsString() + ssoExchangeToken: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts index 44024b2a92..e0cae930ff 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.spec.ts @@ -3,6 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import bcrypt from 'bcrypt'; import { type Repository } from 'typeorm'; +import { AppPath } from 'twenty-shared/types'; import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity'; import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service'; @@ -12,12 +13,14 @@ import { } from 'src/engine/core-modules/auth/auth.exception'; import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service'; +import { type GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; -import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; +import { SSOExchangeTokenService } from 'src/engine/core-modules/auth/token/services/sso-exchange-token.service'; import { type ExistingUserOrNewUser } from 'src/engine/core-modules/auth/types/signInUp.type'; import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service'; +import { buildUrlWithPathnameAndSearchParams } from 'src/engine/core-modules/domain/domain-server-config/utils/build-url-with-pathname-and-search-params.util'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { EmailService } from 'src/engine/core-modules/email/email.service'; import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service'; @@ -49,8 +52,9 @@ describe('AuthService', () => { let userWorkspaceService: UserWorkspaceService; let workspaceInvitationService: WorkspaceInvitationService; let permissionsService: PermissionsService; + let refreshTokenService: RefreshTokenService; let signInUpServiceMock: jest.Mocked< - Pick + Pick >; beforeEach(async () => { @@ -90,11 +94,25 @@ describe('AuthService', () => { }, { provide: DomainServerConfigService, - useValue: {}, + useValue: { + buildBaseUrl: jest.fn(({ pathname, searchParams, hash }) => + buildUrlWithPathnameAndSearchParams({ + baseUrl: new URL('https://app.twenty.com'), + pathname, + searchParams, + hash, + }), + ), + }, }, { - provide: WorkspaceAgnosticTokenService, - useValue: {}, + provide: SSOExchangeTokenService, + useValue: { + generateSSOExchangeToken: jest.fn().mockResolvedValue({ + token: 'sso-exchange-token', + expiresAt: new Date(), + }), + }, }, { provide: GuardRedirectService, @@ -105,6 +123,7 @@ describe('AuthService', () => { useValue: { validatePassword: jest.fn().mockResolvedValue(undefined), generateHash: jest.fn(), + signUpWithoutWorkspace: jest.fn(), }, }, { @@ -123,7 +142,9 @@ describe('AuthService', () => { }, { provide: RefreshTokenService, - useValue: {}, + useValue: { + generateRefreshToken: jest.fn(), + }, }, { provide: UserWorkspaceService, @@ -137,6 +158,7 @@ describe('AuthService', () => { provide: UserService, useValue: { hasUserAccessToWorkspaceOrThrow: jest.fn(), + findUserByEmailWithWorkspaces: jest.fn(), }, }, { @@ -208,8 +230,9 @@ describe('AuthService', () => { getRepositoryToken(UserEntity), ); permissionsService = module.get(PermissionsService); + refreshTokenService = module.get(RefreshTokenService); signInUpServiceMock = module.get(SignInUpService) as jest.Mocked< - Pick + Pick >; }); @@ -676,4 +699,54 @@ describe('AuthService', () => { expect(spyAuthSsoService).toHaveBeenCalledTimes(1); }); }); + + describe('signInUpWithSocialSSO - redirect without a target workspace', () => { + const socialSSOUser: GoogleRequest['user'] = { + firstName: 'John', + lastName: 'Doe', + email: 'John.Doe@twenty.com', + picture: 'picture', + action: 'list-available-workspaces', + returnToPath: '/settings/profile', + }; + + beforeEach(() => { + jest + .spyOn(userService, 'findUserByEmailWithWorkspaces') + .mockResolvedValue({ id: 'user-id' } as UserEntity); + }); + + it('should not mint a refresh token nor put credentials in the query string', async () => { + const url = await service.signInUpWithSocialSSO( + socialSSOUser, + AuthProviderEnum.Google, + ); + + expect(refreshTokenService.generateRefreshToken).not.toHaveBeenCalled(); + expect([...new URL(url).searchParams.keys()]).toEqual(['returnToPath']); + }); + + it('should redirect with the sso exchange token in the url fragment', async () => { + const url = new URL( + await service.signInUpWithSocialSSO( + socialSSOUser, + AuthProviderEnum.Google, + ), + ); + + expect( + new URLSearchParams(url.hash.substring(1)).get('ssoExchangeToken'), + ).toBe('sso-exchange-token'); + expect(url.pathname).toBe(AppPath.SignInUp); + }); + + it('should not sign the user up again when they already exist', async () => { + await service.signInUpWithSocialSSO( + socialSSOUser, + AuthProviderEnum.Google, + ); + + expect(signInUpServiceMock.signUpWithoutWorkspace).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts index 7bc3e43806..69cf0be929 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts @@ -45,7 +45,7 @@ import { type MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/m import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; -import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; +import { SSOExchangeTokenService } from 'src/engine/core-modules/auth/token/services/sso-exchange-token.service'; import { AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type'; import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { @@ -79,7 +79,7 @@ import { getDomainFromEmail } from 'src/utils/get-domain-from-email'; export class AuthService { constructor( private readonly accessTokenService: AccessTokenService, - private readonly workspaceAgnosticTokenService: WorkspaceAgnosticTokenService, + private readonly ssoExchangeTokenService: SSOExchangeTokenService, private readonly workspaceDomainsService: WorkspaceDomainsService, private readonly domainServerConfigService: DomainServerConfigService, private readonly refreshTokenService: RefreshTokenService, @@ -981,27 +981,22 @@ export class AuthService { }, )); + const ssoExchangeToken = + await this.ssoExchangeTokenService.generateSSOExchangeToken({ + userId: user.id, + authProvider, + }); + + // The token rides in the fragment so it never reaches access logs, + // proxies or Referer headers: browsers keep it out of the request line. const url = this.domainServerConfigService.buildBaseUrl({ pathname: AppPath.SignInUp, searchParams: { - tokenPair: JSON.stringify({ - accessOrWorkspaceAgnosticToken: - await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken( - { - userId: user.id, - authProvider, - }, - ), - refreshToken: await this.refreshTokenService.generateRefreshToken({ - userId: user.id, - authProvider, - targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC, - }), - }), ...(isNonEmptyString(returnToPath) && returnToPath.startsWith('/') ? { returnToPath } : {}), }, + hash: `ssoExchangeToken=${ssoExchangeToken.token}`, }); return url.toString(); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/sso-exchange-token.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/sso-exchange-token.service.spec.ts new file mode 100644 index 0000000000..3a6f40a25c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/sso-exchange-token.service.spec.ts @@ -0,0 +1,185 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import crypto from 'crypto'; + +import { IsNull, Repository } from 'typeorm'; + +import { + AppTokenEntity, + AppTokenType, +} from 'src/engine/core-modules/app-token/app-token.entity'; +import { AuthException } from 'src/engine/core-modules/auth/auth.exception'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; + +import { SSOExchangeTokenService } from './sso-exchange-token.service'; + +const USER_ID = '20202020-9e3b-46d4-a556-88b9ddc2b034'; + +const sha256 = (value: string) => + crypto.createHash('sha256').update(value).digest('hex'); + +describe('SSOExchangeTokenService', () => { + let service: SSOExchangeTokenService; + let twentyConfigService: TwentyConfigService; + let appTokenRepository: Repository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SSOExchangeTokenService, + { + provide: TwentyConfigService, + useValue: { get: jest.fn().mockReturnValue('5m') }, + }, + { + provide: getRepositoryToken(AppTokenEntity), + useClass: Repository, + }, + ], + }).compile(); + + service = module.get(SSOExchangeTokenService); + twentyConfigService = module.get(TwentyConfigService); + appTokenRepository = module.get>( + getRepositoryToken(AppTokenEntity), + ); + + jest + .spyOn(appTokenRepository, 'create') + .mockImplementation((entity) => entity as AppTokenEntity); + jest + .spyOn(appTokenRepository, 'save') + .mockImplementation(async (entity) => entity as AppTokenEntity); + }); + + describe('generateSSOExchangeToken', () => { + it('should persist only the hash of the token, never the plaintext', async () => { + const { token } = await service.generateSSOExchangeToken({ + userId: USER_ID, + authProvider: AuthProviderEnum.Google, + }); + + const savedToken = jest.mocked(appTokenRepository.save).mock + .calls[0][0] as AppTokenEntity; + + expect(savedToken.value).toBe(sha256(token)); + expect(savedToken.value).not.toBe(token); + expect(savedToken.type).toBe(AppTokenType.SSOExchangeToken); + expect(savedToken.userId).toBe(USER_ID); + expect(savedToken.context).toEqual({ + authProvider: AuthProviderEnum.Google, + }); + }); + + it('should use the short term token expiration', async () => { + await service.generateSSOExchangeToken({ + userId: USER_ID, + authProvider: AuthProviderEnum.Microsoft, + }); + + expect(twentyConfigService.get).toHaveBeenCalledWith( + 'SHORT_TERM_TOKEN_EXPIRES_IN', + ); + }); + + it('should generate a different token on every call', async () => { + const first = await service.generateSSOExchangeToken({ + userId: USER_ID, + authProvider: AuthProviderEnum.Google, + }); + const second = await service.generateSSOExchangeToken({ + userId: USER_ID, + authProvider: AuthProviderEnum.Google, + }); + + expect(first.token).not.toBe(second.token); + }); + }); + + describe('validateAndConsumeSSOExchangeTokenOrThrow', () => { + const buildAppToken = (): AppTokenEntity => + ({ + id: 'app-token-id', + userId: USER_ID, + type: AppTokenType.SSOExchangeToken, + value: sha256('plain-token'), + expiresAt: new Date(Date.now() + 60_000), + context: { authProvider: AuthProviderEnum.Google }, + }) as AppTokenEntity; + + const mockLookup = (appToken: AppTokenEntity | null) => { + jest + .spyOn(appTokenRepository, 'findOneBy') + .mockResolvedValue(appToken as never); + }; + + const mockClaim = (affected: number) => { + jest + .spyOn(appTokenRepository, 'delete') + .mockResolvedValue({ affected, raw: [] } as never); + }; + + it('should return the user and auth provider of the claimed token', async () => { + mockLookup(buildAppToken()); + mockClaim(1); + + const result = + await service.validateAndConsumeSSOExchangeTokenOrThrow('plain-token'); + + expect(result).toEqual({ + userId: USER_ID, + authProvider: AuthProviderEnum.Google, + }); + expect(appTokenRepository.delete).toHaveBeenCalledWith({ + id: 'app-token-id', + revokedAt: IsNull(), + deletedAt: IsNull(), + }); + }); + + it('should throw when the token cannot be found', async () => { + mockLookup(null); + + await expect( + service.validateAndConsumeSSOExchangeTokenOrThrow('plain-token'), + ).rejects.toThrow(AuthException); + }); + + it('should throw when the delete does not claim the row', async () => { + mockLookup(buildAppToken()); + mockClaim(0); + + await expect( + service.validateAndConsumeSSOExchangeTokenOrThrow('plain-token'), + ).rejects.toThrow(AuthException); + }); + + it('should throw when the claimed token has expired', async () => { + const expiredToken = buildAppToken(); + + expiredToken.expiresAt = new Date(Date.now() - 1); + + mockLookup(expiredToken); + mockClaim(1); + + await expect( + service.validateAndConsumeSSOExchangeTokenOrThrow('plain-token'), + ).rejects.toThrow(AuthException); + }); + + it('should throw when the auth provider is missing from the token context', async () => { + const tokenWithoutProvider = buildAppToken(); + + tokenWithoutProvider.context = null; + + mockLookup(tokenWithoutProvider); + mockClaim(1); + + await expect( + service.validateAndConsumeSSOExchangeTokenOrThrow('plain-token'), + ).rejects.toThrow(AuthException); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/services/sso-exchange-token.service.ts b/packages/twenty-server/src/engine/core-modules/auth/token/services/sso-exchange-token.service.ts new file mode 100644 index 0000000000..debdc56fcf --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/token/services/sso-exchange-token.service.ts @@ -0,0 +1,118 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import crypto from 'crypto'; + +import { msg } from '@lingui/core/macro'; +import { addMilliseconds } from 'date-fns'; +import ms from 'ms'; +import { IsNull, Repository } from 'typeorm'; +import { isDefined } from 'twenty-shared/utils'; + +import { + AppTokenEntity, + AppTokenType, +} from 'src/engine/core-modules/app-token/app-token.entity'; +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { type AuthToken } from 'src/engine/core-modules/auth/dto/auth-token.dto'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { type AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; + +const hashSSOExchangeToken = (ssoExchangeToken: string) => + crypto.createHash('sha256').update(ssoExchangeToken).digest('hex'); + +// A single opaque error for missing, expired and already-consumed tokens: +// distinguishing them would turn this endpoint into a redemption oracle. +const buildInvalidSSOExchangeTokenException = () => + new AuthException( + 'Invalid SSO exchange token', + AuthExceptionCode.INVALID_INPUT, + { userFriendlyMessage: msg`Authentication failed, please sign in again.` }, + ); + +@Injectable() +export class SSOExchangeTokenService { + constructor( + @InjectRepository(AppTokenEntity) + private readonly appTokenRepository: Repository, + private readonly twentyConfigService: TwentyConfigService, + ) {} + + async generateSSOExchangeToken({ + userId, + authProvider, + }: { + userId: string; + authProvider: AuthProviderEnum; + }): Promise { + const expiresIn = this.twentyConfigService.get( + 'SHORT_TERM_TOKEN_EXPIRES_IN', + ); + const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn)); + + const plainToken = crypto.randomBytes(32).toString('hex'); + + await this.appTokenRepository.save( + this.appTokenRepository.create({ + userId, + expiresAt, + type: AppTokenType.SSOExchangeToken, + value: hashSSOExchangeToken(plainToken), + context: { authProvider }, + }), + ); + + return { + token: plainToken, + expiresAt, + }; + } + + async validateAndConsumeSSOExchangeTokenOrThrow( + ssoExchangeToken: string, + ): Promise<{ userId: string; authProvider: AuthProviderEnum }> { + const appToken = await this.appTokenRepository.findOneBy({ + value: hashSSOExchangeToken(ssoExchangeToken), + type: AppTokenType.SSOExchangeToken, + revokedAt: IsNull(), + deletedAt: IsNull(), + }); + + if (!isDefined(appToken)) { + throw buildInvalidSSOExchangeTokenException(); + } + + // Deleting the row is the single-use claim: under concurrent redemption + // only the request whose delete affects the row proceeds to mint a token. + // Re-checking revokedAt/deletedAt here keeps the claim atomic with + // revocation: a token revoked after the lookup cannot redeem. + const { affected } = await this.appTokenRepository.delete({ + id: appToken.id, + revokedAt: IsNull(), + deletedAt: IsNull(), + }); + + if (affected !== 1) { + throw buildInvalidSSOExchangeTokenException(); + } + + if (new Date() > appToken.expiresAt) { + throw buildInvalidSSOExchangeTokenException(); + } + + if ( + !isDefined(appToken.userId) || + !isDefined(appToken.context?.authProvider) + ) { + throw buildInvalidSSOExchangeTokenException(); + } + + return { + userId: appToken.userId, + authProvider: appToken.context.authProvider, + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts index 8fd700d027..6d8699dc74 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/token/token.module.ts @@ -10,6 +10,7 @@ import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/serv import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service'; import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service'; +import { SSOExchangeTokenService } from 'src/engine/core-modules/auth/token/services/sso-exchange-token.service'; import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service'; import { ImpersonationAuthorizationModule } from 'src/engine/core-modules/impersonation/impersonation-authorization.module'; import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; @@ -44,6 +45,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache RefreshTokenService, WorkspaceAgnosticTokenService, ApplicationTokenService, + SSOExchangeTokenService, ], exports: [ RenewTokenService, @@ -52,6 +54,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache RefreshTokenService, WorkspaceAgnosticTokenService, ApplicationTokenService, + SSOExchangeTokenService, ], }) export class TokenModule {} diff --git a/packages/twenty-server/src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service.ts b/packages/twenty-server/src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service.ts index 2134e6d80a..b04edcd871 100644 --- a/packages/twenty-server/src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service.ts +++ b/packages/twenty-server/src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service.ts @@ -46,14 +46,17 @@ export class DomainServerConfigService { buildBaseUrl({ pathname, searchParams, + hash, }: { pathname?: string; searchParams?: Record; + hash?: string; }) { return buildUrlWithPathnameAndSearchParams({ baseUrl: this.getBaseUrl(), pathname, searchParams, + hash, }); } diff --git a/packages/twenty-server/test/integration/graphql/suites/auth/sso-exchange-token/single-use-sso-exchange-token.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/auth/sso-exchange-token/single-use-sso-exchange-token.integration-spec.ts new file mode 100644 index 0000000000..cf7c78d6ca --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/auth/sso-exchange-token/single-use-sso-exchange-token.integration-spec.ts @@ -0,0 +1,171 @@ +import crypto from 'crypto'; + +import { type DataSource } from 'typeorm'; +import { getAuthTokensFromSSOExchangeToken } from 'test/integration/graphql/utils/get-auth-tokens-from-sso-exchange-token.util'; + +import { AppTokenType } from 'src/engine/core-modules/app-token/app-token.entity'; +import { AuthExceptionCode } from 'src/engine/core-modules/auth/auth.exception'; +import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util'; +import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type'; + +const hashToken = (token: string) => + crypto.createHash('sha256').update(token).digest('hex'); + +const expectUniformInvalidTokenError = (errors: { message: string }[]) => { + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + message: 'Invalid SSO exchange token', + extensions: { subCode: AuthExceptionCode.INVALID_INPUT }, + }); +}; + +describe('SSO exchange token redemption (integration)', () => { + let dataSource: DataSource; + + const seedSSOExchangeToken = async ({ + expiresAt = new Date(Date.now() + 5 * 60 * 1000), + type = AppTokenType.SSOExchangeToken, + revokedAt = null, + }: { + expiresAt?: Date; + type?: AppTokenType; + revokedAt?: Date | null; + } = {}): Promise => { + const plainToken = crypto.randomBytes(32).toString('hex'); + + await dataSource.query( + `INSERT INTO core."appToken" ("userId", "type", "value", "expiresAt", "revokedAt", "context") + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + USER_DATA_SEED_IDS.JANE, + type, + hashToken(plainToken), + expiresAt, + revokedAt, + JSON.stringify({ authProvider: AuthProviderEnum.Google }), + ], + ); + + return plainToken; + }; + + const countRemainingRows = async ( + plainToken: string, + type: AppTokenType = AppTokenType.SSOExchangeToken, + ): Promise => { + const rows = await dataSource.query( + `SELECT 1 FROM core."appToken" WHERE "value" = $1 AND "type" = $2`, + [hashToken(plainToken), type], + ); + + return rows.length; + }; + + beforeAll(() => { + dataSource = global.testDataSource; + }); + + it('should exchange a valid token for a token pair and consume it', async () => { + const plainToken = await seedSSOExchangeToken(); + + const { data } = await getAuthTokensFromSSOExchangeToken({ + ssoExchangeToken: plainToken, + expectToFail: false, + }); + + expect( + data.getAuthTokensFromSSOExchangeToken.tokens + .accessOrWorkspaceAgnosticToken.token, + ).toBeDefined(); + expect( + data.getAuthTokensFromSSOExchangeToken.tokens.refreshToken.token, + ).toBeDefined(); + expect(await countRemainingRows(plainToken)).toBe(0); + }); + + it('should reject a second redemption of the same token', async () => { + const plainToken = await seedSSOExchangeToken(); + + await getAuthTokensFromSSOExchangeToken({ + ssoExchangeToken: plainToken, + expectToFail: false, + }); + + const { errors } = await getAuthTokensFromSSOExchangeToken({ + ssoExchangeToken: plainToken, + expectToFail: true, + }); + + expectUniformInvalidTokenError(errors); + }); + + it('should reject an expired token', async () => { + const plainToken = await seedSSOExchangeToken({ + expiresAt: new Date(Date.now() - 1000), + }); + + const { errors } = await getAuthTokensFromSSOExchangeToken({ + ssoExchangeToken: plainToken, + expectToFail: true, + }); + + expectUniformInvalidTokenError(errors); + expect(await countRemainingRows(plainToken)).toBe(0); + }); + + it('should reject a revoked token without consuming it', async () => { + const plainToken = await seedSSOExchangeToken({ + revokedAt: new Date(), + }); + + const { errors } = await getAuthTokensFromSSOExchangeToken({ + ssoExchangeToken: plainToken, + expectToFail: true, + }); + + expectUniformInvalidTokenError(errors); + expect(await countRemainingRows(plainToken)).toBe(1); + }); + + it('should not redeem an app token of another type sharing the same value', async () => { + const plainToken = await seedSSOExchangeToken({ + type: AppTokenType.EmailVerificationToken, + }); + + const { errors } = await getAuthTokensFromSSOExchangeToken({ + ssoExchangeToken: plainToken, + expectToFail: true, + }); + + expectUniformInvalidTokenError(errors); + expect( + await countRemainingRows(plainToken, AppTokenType.EmailVerificationToken), + ).toBe(1); + }); + + it('should reject an unknown token', async () => { + const { errors } = await getAuthTokensFromSSOExchangeToken({ + ssoExchangeToken: crypto.randomBytes(32).toString('hex'), + expectToFail: true, + }); + + expectUniformInvalidTokenError(errors); + }); + + it('should let exactly one of several concurrent redemptions succeed', async () => { + const plainToken = await seedSSOExchangeToken(); + + const responses = await Promise.all( + Array.from({ length: 5 }, () => + getAuthTokensFromSSOExchangeToken({ ssoExchangeToken: plainToken }), + ), + ); + + const succeeded = responses.filter( + (response) => response.data?.getAuthTokensFromSSOExchangeToken, + ); + + expect(succeeded).toHaveLength(1); + expect(await countRemainingRows(plainToken)).toBe(0); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/get-auth-tokens-from-sso-exchange-token.util.ts b/packages/twenty-server/test/integration/graphql/utils/get-auth-tokens-from-sso-exchange-token.util.ts new file mode 100644 index 0000000000..824df23e8a --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/get-auth-tokens-from-sso-exchange-token.util.ts @@ -0,0 +1,62 @@ +import gql from 'graphql-tag'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type AuthTokens } from 'src/engine/core-modules/auth/dto/auth-tokens.dto'; + +type GetAuthTokensFromSSOExchangeTokenUtilArgs = { + ssoExchangeToken: string; + expectToFail?: boolean; +}; + +export const getAuthTokensFromSSOExchangeToken = async ({ + ssoExchangeToken, + expectToFail, +}: GetAuthTokensFromSSOExchangeTokenUtilArgs): CommonResponseBody<{ + getAuthTokensFromSSOExchangeToken: AuthTokens; +}> => { + const mutation = gql` + mutation GetAuthTokensFromSSOExchangeToken($ssoExchangeToken: String!) { + getAuthTokensFromSSOExchangeToken(ssoExchangeToken: $ssoExchangeToken) { + tokens { + accessOrWorkspaceAgnosticToken { + token + expiresAt + } + refreshToken { + token + expiresAt + } + } + } + } + `; + + const response = await makeMetadataAPIRequest( + { + query: mutation, + variables: { ssoExchangeToken }, + }, + undefined, // Public endpoint - no authentication required + ); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: + 'Get auth tokens from sso exchange token should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: + 'Get auth tokens from sso exchange token has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +};