Skip to content

Commit

Permalink
fix(auth): handle missing invitation during sign-up (#9572)
Browse files Browse the repository at this point in the history
Add validation to throw an exception when a sign-up is attempted without
a valid invitation. Updated the test suite to cover this case and ensure
proper error handling with appropriate exceptions.

Fix #9566
#9564
  • Loading branch information
AMoreaux authored Jan 15, 2025
1 parent f828e75 commit 4fdea61
Show file tree
Hide file tree
Showing 21 changed files with 910 additions and 937 deletions.
8 changes: 6 additions & 2 deletions packages/twenty-front/src/generated/graphql.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as Apollo from '@apollo/client';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
Expand Down Expand Up @@ -715,6 +715,7 @@ export type MutationSignUpArgs = {
captchaToken?: InputMaybe<Scalars['String']>;
email: Scalars['String'];
password: Scalars['String'];
workspaceId?: InputMaybe<Scalars['String']>;
workspaceInviteHash?: InputMaybe<Scalars['String']>;
workspacePersonalInviteToken?: InputMaybe<Scalars['String']>;
};
Expand Down Expand Up @@ -1977,6 +1978,7 @@ export type SignUpMutationVariables = Exact<{
workspaceInviteHash?: InputMaybe<Scalars['String']>;
workspacePersonalInviteToken?: InputMaybe<Scalars['String']>;
captchaToken?: InputMaybe<Scalars['String']>;
workspaceId?: InputMaybe<Scalars['String']>;
}>;


Expand Down Expand Up @@ -2989,13 +2991,14 @@ export type RenewTokenMutationHookResult = ReturnType<typeof useRenewTokenMutati
export type RenewTokenMutationResult = Apollo.MutationResult<RenewTokenMutation>;
export type RenewTokenMutationOptions = Apollo.BaseMutationOptions<RenewTokenMutation, RenewTokenMutationVariables>;
export const SignUpDocument = gql`
mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $workspacePersonalInviteToken: String = null, $captchaToken: String) {
mutation SignUp($email: String!, $password: String!, $workspaceInviteHash: String, $workspacePersonalInviteToken: String = null, $captchaToken: String, $workspaceId: String) {
signUp(
email: $email
password: $password
workspaceInviteHash: $workspaceInviteHash
workspacePersonalInviteToken: $workspacePersonalInviteToken
captchaToken: $captchaToken
workspaceId: $workspaceId
) {
loginToken {
...AuthTokenFragment
Expand Down Expand Up @@ -3027,6 +3030,7 @@ export type SignUpMutationFn = Apollo.MutationFunction<SignUpMutation, SignUpMut
* workspaceInviteHash: // value for 'workspaceInviteHash'
* workspacePersonalInviteToken: // value for 'workspacePersonalInviteToken'
* captchaToken: // value for 'captchaToken'
* workspaceId: // value for 'workspaceId'
* },
* });
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ export const SIGN_UP = gql`
$workspaceInviteHash: String
$workspacePersonalInviteToken: String = null
$captchaToken: String
$workspaceId: String
) {
signUp(
email: $email
password: $password
workspaceInviteHash: $workspaceInviteHash
workspacePersonalInviteToken: $workspacePersonalInviteToken
captchaToken: $captchaToken
workspaceId: $workspaceId
) {
loginToken {
...AuthTokenFragment
Expand Down
17 changes: 11 additions & 6 deletions packages/twenty-front/src/modules/auth/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
useSetRecoilState,
} from 'recoil';
import { iconsState } from 'twenty-ui';
import { AppPath } from '@/types/AppPath';

import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
Expand Down Expand Up @@ -47,13 +48,12 @@ import { BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useIsCurrentLocationOnAWorkspaceSubdomain } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain';
import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain';
import { useReadWorkspaceSubdomainFromCurrentLocation } from '@/domain-manager/hooks/useReadWorkspaceSubdomainFromCurrentLocation';
import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
import { AppPath } from '@/types/AppPath';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';

export const useAuth = () => {
const setTokenPair = useSetRecoilState(tokenPairState);
Expand Down Expand Up @@ -82,7 +82,8 @@ export const useAuth = () => {

const { isOnAWorkspaceSubdomain } =
useIsCurrentLocationOnAWorkspaceSubdomain();
const { workspaceSubdomain } = useReadWorkspaceSubdomainFromCurrentLocation();

const workspacePublicData = useRecoilValue(workspacePublicDataState);

const { setLastAuthenticateWorkspaceDomain } =
useLastAuthenticatedWorkspaceDomain();
Expand Down Expand Up @@ -328,6 +329,9 @@ export const useAuth = () => {
workspaceInviteHash,
workspacePersonalInviteToken,
captchaToken,
...(workspacePublicData?.id
? { workspaceId: workspacePublicData.id }
: {}),
},
});

Expand All @@ -354,6 +358,7 @@ export const useAuth = () => {
[
setIsVerifyPendingState,
signUp,
workspacePublicData,
isMultiWorkspaceEnabled,
handleVerify,
redirectToWorkspaceDomain,
Expand Down Expand Up @@ -386,13 +391,13 @@ export const useAuth = () => {
);
}

if (isDefined(workspaceSubdomain)) {
url.searchParams.set('workspaceSubdomain', workspaceSubdomain);
if (isDefined(workspacePublicData)) {
url.searchParams.set('workspaceId', workspacePublicData.id);
}

return url.toString();
},
[workspaceSubdomain],
[workspacePublicData],
);

const handleGoogleLogin = useCallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service';

import { AuthResolver } from './auth.resolver';

Expand Down Expand Up @@ -103,6 +104,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
SwitchWorkspaceService,
TransientTokenService,
ApiKeyService,
SocialSsoService,
// reenable when working on: https://github.com/twentyhq/twenty/issues/9143
// OAuthService,
],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm';

import { Repository } from 'typeorm';

import { ApiKeyTokenInput } from 'src/engine/core-modules/auth/dto/api-key-token.input';
import { AppTokenInput } from 'src/engine/core-modules/auth/dto/app-token.input';
Expand Down Expand Up @@ -55,6 +58,8 @@ import { AuthService } from './services/auth.service';
@UseFilters(AuthGraphqlApiExceptionFilter)
export class AuthResolver {
constructor(
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
private authService: AuthService,
private renewTokenService: RenewTokenService,
private userService: UserService,
Expand Down Expand Up @@ -104,12 +109,14 @@ export class AuthResolver {
origin,
);

if (!workspace) {
throw new AuthException(
workspaceValidator.assertIsDefinedOrThrow(
workspace,
new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
),
);

const user = await this.authService.challenge(challengeInput, workspace);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
Expand All @@ -121,16 +128,46 @@ export class AuthResolver {

@UseGuards(CaptchaGuard)
@Mutation(() => SignUpOutput)
async signUp(
@Args() signUpInput: SignUpInput,
@OriginHeader() origin: string,
): Promise<SignUpOutput> {
const { user, workspace } = await this.authService.signInUp({
...signUpInput,
targetWorkspaceSubdomain:
this.domainManagerService.getWorkspaceSubdomainByOrigin(origin),
fromSSO: false,
async signUp(@Args() signUpInput: SignUpInput): Promise<SignUpOutput> {
const currentWorkspace = await this.authService.findWorkspaceForSignInUp({
workspaceInviteHash: signUpInput.workspaceInviteHash,
authProvider: 'password',
workspaceId: signUpInput.workspaceId,
});

const invitation = await this.authService.findInvitationForSignInUp({
currentWorkspace,
workspacePersonalInviteToken: signUpInput.workspacePersonalInviteToken,
});

const existingUser = await this.userRepository.findOne({
where: {
email: signUpInput.email,
},
});

const { userData } = this.authService.formatUserDataPayload(
{
email: signUpInput.email,
},
existingUser,
);

await this.authService.checkAccessForSignIn({
userData,
invitation,
workspaceInviteHash: signUpInput.workspaceInviteHash,
workspace: currentWorkspace,
});

const { user, workspace } = await this.authService.signInUp({
userData,
workspace: currentWorkspace,
invitation,
authParams: {
provider: 'password',
password: signUpInput.password,
},
});

const loginToken = await this.loginTokenService.generateLoginToken(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'
import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';

@Controller('auth/google')
@UseFilters(AuthRestApiExceptionFilter)
Expand All @@ -33,9 +32,8 @@ export class GoogleAuthController {
private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService,
private readonly domainManagerService: DomainManagerService,
private readonly environmentService: EnvironmentService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
) {}

@Get()
Expand All @@ -49,78 +47,79 @@ export class GoogleAuthController {
@UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard)
@UseFilters(AuthOAuthExceptionFilter)
async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) {
const {
firstName,
lastName,
email,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
workspaceId,
billingCheckoutSessionState,
} = req.user;

const currentWorkspace = await this.authService.findWorkspaceForSignInUp({
workspaceId,
workspaceInviteHash,
email,
authProvider: 'google',
});

try {
const {
firstName,
lastName,
email,
picture,
workspaceInviteHash,
const invitation = await this.authService.findInvitationForSignInUp({
currentWorkspace,
workspacePersonalInviteToken,
targetWorkspaceSubdomain,
billingCheckoutSessionState,
} = req.user;

const signInUpParams = {
email,
firstName,
lastName,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
targetWorkspaceSubdomain,
fromSSO: true,
isAuthEnabled: 'google',
};
});

if (
this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') &&
(targetWorkspaceSubdomain ===
this.environmentService.get('DEFAULT_SUBDOMAIN') ||
!targetWorkspaceSubdomain)
) {
const workspaceWithGoogleAuthActive =
await this.workspaceRepository.findOne({
where: {
isGoogleAuthEnabled: true,
workspaceUsers: {
user: {
email,
},
},
},
relations: ['workspaceUsers', 'workspaceUsers.user'],
});
const existingUser = await this.userRepository.findOne({
where: { email },
});

if (workspaceWithGoogleAuthActive) {
signInUpParams.targetWorkspaceSubdomain =
workspaceWithGoogleAuthActive.subdomain;
}
}
const { userData } = this.authService.formatUserDataPayload(
{
firstName,
lastName,
email,
picture,
},
existingUser,
);

const { user, workspace } =
await this.authService.signInUp(signInUpParams);
await this.authService.checkAccessForSignIn({
userData,
invitation,
workspaceInviteHash,
workspace: currentWorkspace,
});

const { user, workspace } = await this.authService.signInUp({
invitation,
workspace: currentWorkspace,
userData,
authParams: {
provider: 'google',
},
billingCheckoutSessionState,
});

const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
);

return res.redirect(
this.authService.computeRedirectURI(
loginToken.token,
workspace.subdomain,
this.authService.computeRedirectURI({
loginToken: loginToken.token,
subdomain: workspace.subdomain,
billingCheckoutSessionState,
),
}),
);
} catch (err) {
if (err instanceof AuthException) {
return res.redirect(
this.domainManagerService.computeRedirectErrorUrl({
subdomain:
req.user.targetWorkspaceSubdomain ??
this.environmentService.get('DEFAULT_SUBDOMAIN'),
errorMessage: err.message,
this.domainManagerService.computeRedirectErrorUrl(err.message, {
subdomain: currentWorkspace?.subdomain,
}),
);
}
Expand Down
Loading

0 comments on commit 4fdea61

Please sign in to comment.