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 }; +};