fix(auth): handle missing invitation during sign-up (#9572)

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 https://github.com/twentyhq/twenty/issues/9566
https://github.com/twentyhq/twenty/issues/9564
This commit is contained in:
Antoine Moreaux
2025-01-15 15:26:51 +01:00
committed by GitHub
parent f828e75b72
commit 4fdea61f1d
21 changed files with 949 additions and 976 deletions

View File

@ -1,5 +1,5 @@
import * as Apollo from '@apollo/client';
import { gql } from '@apollo/client'; import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
export type Maybe<T> = T | null; export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>; export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] }; export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
@ -715,6 +715,7 @@ export type MutationSignUpArgs = {
captchaToken?: InputMaybe<Scalars['String']>; captchaToken?: InputMaybe<Scalars['String']>;
email: Scalars['String']; email: Scalars['String'];
password: Scalars['String']; password: Scalars['String'];
workspaceId?: InputMaybe<Scalars['String']>;
workspaceInviteHash?: InputMaybe<Scalars['String']>; workspaceInviteHash?: InputMaybe<Scalars['String']>;
workspacePersonalInviteToken?: InputMaybe<Scalars['String']>; workspacePersonalInviteToken?: InputMaybe<Scalars['String']>;
}; };
@ -1977,6 +1978,7 @@ export type SignUpMutationVariables = Exact<{
workspaceInviteHash?: InputMaybe<Scalars['String']>; workspaceInviteHash?: InputMaybe<Scalars['String']>;
workspacePersonalInviteToken?: InputMaybe<Scalars['String']>; workspacePersonalInviteToken?: InputMaybe<Scalars['String']>;
captchaToken?: InputMaybe<Scalars['String']>; captchaToken?: InputMaybe<Scalars['String']>;
workspaceId?: InputMaybe<Scalars['String']>;
}>; }>;
@ -2989,13 +2991,14 @@ export type RenewTokenMutationHookResult = ReturnType<typeof useRenewTokenMutati
export type RenewTokenMutationResult = Apollo.MutationResult<RenewTokenMutation>; export type RenewTokenMutationResult = Apollo.MutationResult<RenewTokenMutation>;
export type RenewTokenMutationOptions = Apollo.BaseMutationOptions<RenewTokenMutation, RenewTokenMutationVariables>; export type RenewTokenMutationOptions = Apollo.BaseMutationOptions<RenewTokenMutation, RenewTokenMutationVariables>;
export const SignUpDocument = gql` 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( signUp(
email: $email email: $email
password: $password password: $password
workspaceInviteHash: $workspaceInviteHash workspaceInviteHash: $workspaceInviteHash
workspacePersonalInviteToken: $workspacePersonalInviteToken workspacePersonalInviteToken: $workspacePersonalInviteToken
captchaToken: $captchaToken captchaToken: $captchaToken
workspaceId: $workspaceId
) { ) {
loginToken { loginToken {
...AuthTokenFragment ...AuthTokenFragment
@ -3027,6 +3030,7 @@ export type SignUpMutationFn = Apollo.MutationFunction<SignUpMutation, SignUpMut
* workspaceInviteHash: // value for 'workspaceInviteHash' * workspaceInviteHash: // value for 'workspaceInviteHash'
* workspacePersonalInviteToken: // value for 'workspacePersonalInviteToken' * workspacePersonalInviteToken: // value for 'workspacePersonalInviteToken'
* captchaToken: // value for 'captchaToken' * captchaToken: // value for 'captchaToken'
* workspaceId: // value for 'workspaceId'
* }, * },
* }); * });
*/ */

View File

@ -7,6 +7,7 @@ export const SIGN_UP = gql`
$workspaceInviteHash: String $workspaceInviteHash: String
$workspacePersonalInviteToken: String = null $workspacePersonalInviteToken: String = null
$captchaToken: String $captchaToken: String
$workspaceId: String
) { ) {
signUp( signUp(
email: $email email: $email
@ -14,6 +15,7 @@ export const SIGN_UP = gql`
workspaceInviteHash: $workspaceInviteHash workspaceInviteHash: $workspaceInviteHash
workspacePersonalInviteToken: $workspacePersonalInviteToken workspacePersonalInviteToken: $workspacePersonalInviteToken
captchaToken: $captchaToken captchaToken: $captchaToken
workspaceId: $workspaceId
) { ) {
loginToken { loginToken {
...AuthTokenFragment ...AuthTokenFragment

View File

@ -8,6 +8,7 @@ import {
useSetRecoilState, useSetRecoilState,
} from 'recoil'; } from 'recoil';
import { iconsState } from 'twenty-ui'; import { iconsState } from 'twenty-ui';
import { AppPath } from '@/types/AppPath';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
@ -47,13 +48,12 @@ import { BillingCheckoutSession } from '@/auth/types/billingCheckoutSession.type
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState'; import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { useIsCurrentLocationOnAWorkspaceSubdomain } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain'; import { useIsCurrentLocationOnAWorkspaceSubdomain } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspaceSubdomain';
import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain'; import { useLastAuthenticatedWorkspaceDomain } from '@/domain-manager/hooks/useLastAuthenticatedWorkspaceDomain';
import { useReadWorkspaceSubdomainFromCurrentLocation } from '@/domain-manager/hooks/useReadWorkspaceSubdomainFromCurrentLocation';
import { useRedirect } from '@/domain-manager/hooks/useRedirect'; import { useRedirect } from '@/domain-manager/hooks/useRedirect';
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState'; import { isAppWaitingForFreshObjectMetadataState } from '@/object-metadata/states/isAppWaitingForFreshObjectMetadataState';
import { AppPath } from '@/types/AppPath';
import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState'; import { workspaceAuthProvidersState } from '@/workspace/states/workspaceAuthProvidersState';
import { workspacePublicDataState } from '@/auth/states/workspacePublicDataState';
export const useAuth = () => { export const useAuth = () => {
const setTokenPair = useSetRecoilState(tokenPairState); const setTokenPair = useSetRecoilState(tokenPairState);
@ -82,7 +82,8 @@ export const useAuth = () => {
const { isOnAWorkspaceSubdomain } = const { isOnAWorkspaceSubdomain } =
useIsCurrentLocationOnAWorkspaceSubdomain(); useIsCurrentLocationOnAWorkspaceSubdomain();
const { workspaceSubdomain } = useReadWorkspaceSubdomainFromCurrentLocation();
const workspacePublicData = useRecoilValue(workspacePublicDataState);
const { setLastAuthenticateWorkspaceDomain } = const { setLastAuthenticateWorkspaceDomain } =
useLastAuthenticatedWorkspaceDomain(); useLastAuthenticatedWorkspaceDomain();
@ -328,6 +329,9 @@ export const useAuth = () => {
workspaceInviteHash, workspaceInviteHash,
workspacePersonalInviteToken, workspacePersonalInviteToken,
captchaToken, captchaToken,
...(workspacePublicData?.id
? { workspaceId: workspacePublicData.id }
: {}),
}, },
}); });
@ -354,6 +358,7 @@ export const useAuth = () => {
[ [
setIsVerifyPendingState, setIsVerifyPendingState,
signUp, signUp,
workspacePublicData,
isMultiWorkspaceEnabled, isMultiWorkspaceEnabled,
handleVerify, handleVerify,
redirectToWorkspaceDomain, redirectToWorkspaceDomain,
@ -386,13 +391,13 @@ export const useAuth = () => {
); );
} }
if (isDefined(workspaceSubdomain)) { if (isDefined(workspacePublicData)) {
url.searchParams.set('workspaceSubdomain', workspaceSubdomain); url.searchParams.set('workspaceId', workspacePublicData.id);
} }
return url.toString(); return url.toString();
}, },
[workspaceSubdomain], [workspacePublicData],
); );
const handleGoogleLogin = useCallback( const handleGoogleLogin = useCallback(

View File

@ -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 { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module'; import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.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'; import { AuthResolver } from './auth.resolver';
@ -103,6 +104,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
SwitchWorkspaceService, SwitchWorkspaceService,
TransientTokenService, TransientTokenService,
ApiKeyService, ApiKeyService,
SocialSsoService,
// reenable when working on: https://github.com/twentyhq/twenty/issues/9143 // reenable when working on: https://github.com/twentyhq/twenty/issues/9143
// OAuthService, // OAuthService,
], ],

View File

@ -1,5 +1,8 @@
import { UseFilters, UseGuards } from '@nestjs/common'; import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; 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 { ApiKeyTokenInput } from 'src/engine/core-modules/auth/dto/api-key-token.input';
import { AppTokenInput } from 'src/engine/core-modules/auth/dto/app-token.input'; import { AppTokenInput } from 'src/engine/core-modules/auth/dto/app-token.input';
@ -55,6 +58,8 @@ import { AuthService } from './services/auth.service';
@UseFilters(AuthGraphqlApiExceptionFilter) @UseFilters(AuthGraphqlApiExceptionFilter)
export class AuthResolver { export class AuthResolver {
constructor( constructor(
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
private authService: AuthService, private authService: AuthService,
private renewTokenService: RenewTokenService, private renewTokenService: RenewTokenService,
private userService: UserService, private userService: UserService,
@ -104,12 +109,14 @@ export class AuthResolver {
origin, origin,
); );
if (!workspace) { workspaceValidator.assertIsDefinedOrThrow(
throw new AuthException( workspace,
new AuthException(
'Workspace not found', 'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND, AuthExceptionCode.WORKSPACE_NOT_FOUND,
); ),
} );
const user = await this.authService.challenge(challengeInput, workspace); const user = await this.authService.challenge(challengeInput, workspace);
const loginToken = await this.loginTokenService.generateLoginToken( const loginToken = await this.loginTokenService.generateLoginToken(
user.email, user.email,
@ -121,16 +128,46 @@ export class AuthResolver {
@UseGuards(CaptchaGuard) @UseGuards(CaptchaGuard)
@Mutation(() => SignUpOutput) @Mutation(() => SignUpOutput)
async signUp( async signUp(@Args() signUpInput: SignUpInput): Promise<SignUpOutput> {
@Args() signUpInput: SignUpInput, const currentWorkspace = await this.authService.findWorkspaceForSignInUp({
@OriginHeader() origin: string, workspaceInviteHash: signUpInput.workspaceInviteHash,
): Promise<SignUpOutput> {
const { user, workspace } = await this.authService.signInUp({
...signUpInput,
targetWorkspaceSubdomain:
this.domainManagerService.getWorkspaceSubdomainByOrigin(origin),
fromSSO: false,
authProvider: 'password', 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( const loginToken = await this.loginTokenService.generateLoginToken(

View File

@ -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 { 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 { 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 { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Controller('auth/google') @Controller('auth/google')
@UseFilters(AuthRestApiExceptionFilter) @UseFilters(AuthRestApiExceptionFilter)
@ -33,9 +32,8 @@ export class GoogleAuthController {
private readonly loginTokenService: LoginTokenService, private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly domainManagerService: DomainManagerService, private readonly domainManagerService: DomainManagerService,
private readonly environmentService: EnvironmentService, @InjectRepository(User, 'core')
@InjectRepository(Workspace, 'core') private readonly userRepository: Repository<User>,
private readonly workspaceRepository: Repository<Workspace>,
) {} ) {}
@Get() @Get()
@ -49,57 +47,61 @@ export class GoogleAuthController {
@UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard) @UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard)
@UseFilters(AuthOAuthExceptionFilter) @UseFilters(AuthOAuthExceptionFilter)
async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) { 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 { try {
const { const invitation = await this.authService.findInvitationForSignInUp({
firstName, currentWorkspace,
lastName,
email,
picture,
workspaceInviteHash,
workspacePersonalInviteToken, workspacePersonalInviteToken,
targetWorkspaceSubdomain, email,
});
const existingUser = await this.userRepository.findOne({
where: { email },
});
const { userData } = this.authService.formatUserDataPayload(
{
firstName,
lastName,
email,
picture,
},
existingUser,
);
await this.authService.checkAccessForSignIn({
userData,
invitation,
workspaceInviteHash,
workspace: currentWorkspace,
});
const { user, workspace } = await this.authService.signInUp({
invitation,
workspace: currentWorkspace,
userData,
authParams: {
provider: 'google',
},
billingCheckoutSessionState, 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'],
});
if (workspaceWithGoogleAuthActive) {
signInUpParams.targetWorkspaceSubdomain =
workspaceWithGoogleAuthActive.subdomain;
}
}
const { user, workspace } =
await this.authService.signInUp(signInUpParams);
const loginToken = await this.loginTokenService.generateLoginToken( const loginToken = await this.loginTokenService.generateLoginToken(
user.email, user.email,
@ -107,20 +109,17 @@ export class GoogleAuthController {
); );
return res.redirect( return res.redirect(
this.authService.computeRedirectURI( this.authService.computeRedirectURI({
loginToken.token, loginToken: loginToken.token,
workspace.subdomain, subdomain: workspace.subdomain,
billingCheckoutSessionState, billingCheckoutSessionState,
), }),
); );
} catch (err) { } catch (err) {
if (err instanceof AuthException) { if (err instanceof AuthException) {
return res.redirect( return res.redirect(
this.domainManagerService.computeRedirectErrorUrl({ this.domainManagerService.computeRedirectErrorUrl(err.message, {
subdomain: subdomain: currentWorkspace?.subdomain,
req.user.targetWorkspaceSubdomain ??
this.environmentService.get('DEFAULT_SUBDOMAIN'),
errorMessage: err.message,
}), }),
); );
} }

View File

@ -19,8 +19,7 @@ import { AuthService } from 'src/engine/core-modules/auth/services/auth.service'
import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy'; import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; 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 { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Controller('auth/microsoft') @Controller('auth/microsoft')
@UseFilters(AuthRestApiExceptionFilter) @UseFilters(AuthRestApiExceptionFilter)
@ -29,9 +28,8 @@ export class MicrosoftAuthController {
private readonly loginTokenService: LoginTokenService, private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly domainManagerService: DomainManagerService, private readonly domainManagerService: DomainManagerService,
private readonly environmentService: EnvironmentService, @InjectRepository(User, 'core')
@InjectRepository(Workspace, 'core') private readonly userRepository: Repository<User>,
private readonly workspaceRepository: Repository<Workspace>,
) {} ) {}
@Get() @Get()
@ -47,38 +45,60 @@ export class MicrosoftAuthController {
@Req() req: MicrosoftRequest, @Req() req: MicrosoftRequest,
@Res() res: Response, @Res() res: Response,
) { ) {
const {
firstName,
lastName,
email,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
workspaceId,
billingCheckoutSessionState,
} = req.user;
const currentWorkspace = await this.authService.findWorkspaceForSignInUp({
workspaceId,
workspaceInviteHash,
email,
authProvider: 'microsoft',
});
try { try {
const signInUpParams = req.user; const invitation = await this.authService.findInvitationForSignInUp({
currentWorkspace,
workspacePersonalInviteToken,
email,
});
if ( const existingUser = await this.userRepository.findOne({
this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') && where: { email },
(signInUpParams.targetWorkspaceSubdomain === });
this.environmentService.get('DEFAULT_SUBDOMAIN') ||
!signInUpParams.targetWorkspaceSubdomain)
) {
const workspaceWithGoogleAuthActive =
await this.workspaceRepository.findOne({
where: {
isMicrosoftAuthEnabled: true,
workspaceUsers: {
user: {
email: signInUpParams.email,
},
},
},
relations: ['userWorkspaces', 'userWorkspaces.user'],
});
if (workspaceWithGoogleAuthActive) { const { userData } = this.authService.formatUserDataPayload(
signInUpParams.targetWorkspaceSubdomain = {
workspaceWithGoogleAuthActive.subdomain; firstName,
} lastName,
} email,
picture,
},
existingUser,
);
await this.authService.checkAccessForSignIn({
userData,
invitation,
workspaceInviteHash,
workspace: currentWorkspace,
});
const { user, workspace } = await this.authService.signInUp({ const { user, workspace } = await this.authService.signInUp({
...signInUpParams, invitation,
fromSSO: true, workspace: currentWorkspace,
authProvider: 'microsoft', userData,
authParams: {
provider: 'microsoft',
},
billingCheckoutSessionState,
}); });
const loginToken = await this.loginTokenService.generateLoginToken( const loginToken = await this.loginTokenService.generateLoginToken(
@ -87,20 +107,18 @@ export class MicrosoftAuthController {
); );
return res.redirect( return res.redirect(
this.authService.computeRedirectURI( this.authService.computeRedirectURI({
loginToken.token, loginToken: loginToken.token,
workspace.subdomain, subdomain: workspace.subdomain,
signInUpParams.billingCheckoutSessionState,
), billingCheckoutSessionState,
}),
); );
} catch (err) { } catch (err) {
if (err instanceof AuthException) { if (err instanceof AuthException) {
return res.redirect( return res.redirect(
this.domainManagerService.computeRedirectErrorUrl({ this.domainManagerService.computeRedirectErrorUrl(err.message, {
subdomain: subdomain: currentWorkspace?.subdomain,
req.user.targetWorkspaceSubdomain ??
this.environmentService.get('DEFAULT_SUBDOMAIN'),
errorMessage: err.message,
}), }),
); );
} }

View File

@ -30,9 +30,9 @@ import {
IdentityProviderType, IdentityProviderType,
WorkspaceSSOIdentityProvider, WorkspaceSSOIdentityProvider,
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity'; } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.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 { User } from 'src/engine/core-modules/user/user.entity';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
@Controller('auth') @Controller('auth')
@UseFilters(AuthRestApiExceptionFilter) @UseFilters(AuthRestApiExceptionFilter)
@ -41,9 +41,10 @@ export class SSOAuthController {
private readonly loginTokenService: LoginTokenService, private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly domainManagerService: DomainManagerService, private readonly domainManagerService: DomainManagerService,
private readonly userWorkspaceService: UserWorkspaceService, private readonly userService: UserService,
private readonly environmentService: EnvironmentService,
private readonly ssoService: SSOService, private readonly ssoService: SSOService,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@InjectRepository(WorkspaceSSOIdentityProvider, 'core') @InjectRepository(WorkspaceSSOIdentityProvider, 'core')
private readonly workspaceSSOIdentityProviderRepository: Repository<WorkspaceSSOIdentityProvider>, private readonly workspaceSSOIdentityProviderRepository: Repository<WorkspaceSSOIdentityProvider>,
) {} ) {}
@ -81,50 +82,44 @@ export class SSOAuthController {
@Get('oidc/callback') @Get('oidc/callback')
@UseGuards(SSOProviderEnabledGuard, OIDCAuthGuard) @UseGuards(SSOProviderEnabledGuard, OIDCAuthGuard)
async oidcAuthCallback(@Req() req: any, @Res() res: Response) { async oidcAuthCallback(@Req() req: any, @Res() res: Response) {
try { return this.authCallback(req, res);
const { loginToken, identityProvider } = await this.generateLoginToken(
req.user,
);
return res.redirect(
this.authService.computeRedirectURI(
loginToken.token,
identityProvider.workspace.subdomain,
),
);
} catch (err) {
if (err instanceof AuthException) {
return res.redirect(
this.domainManagerService.computeRedirectErrorUrl({
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
errorMessage: err.message,
}),
);
}
throw err;
}
} }
@Post('saml/callback/:identityProviderId') @Post('saml/callback/:identityProviderId')
@UseGuards(SSOProviderEnabledGuard, SAMLAuthGuard) @UseGuards(SSOProviderEnabledGuard, SAMLAuthGuard)
async samlAuthCallback(@Req() req: any, @Res() res: Response) { async samlAuthCallback(@Req() req: any, @Res() res: Response) {
return this.authCallback(req, res);
}
private async authCallback(req: any, res: Response) {
const workspaceIdentityProvider =
await this.findWorkspaceIdentityProviderByIdentityProviderId(
req.user.identityProviderId,
);
if (!workspaceIdentityProvider) {
throw new AuthException(
'Identity provider not found',
AuthExceptionCode.INVALID_DATA,
);
}
try { try {
const { loginToken, identityProvider } = await this.generateLoginToken( const { loginToken, identityProvider } = await this.generateLoginToken(
req.user, req.user,
workspaceIdentityProvider,
); );
return res.redirect( return res.redirect(
this.authService.computeRedirectURI( this.authService.computeRedirectURI({
loginToken.token, loginToken: loginToken.token,
identityProvider.workspace.subdomain, subdomain: identityProvider.workspace.subdomain,
), }),
); );
} catch (err) { } catch (err) {
if (err instanceof AuthException) { if (err instanceof AuthException) {
return res.redirect( return res.redirect(
this.domainManagerService.computeRedirectErrorUrl({ this.domainManagerService.computeRedirectErrorUrl(err.message, {
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'), subdomain: workspaceIdentityProvider.workspace.subdomain,
errorMessage: err.message,
}), }),
); );
} }
@ -132,26 +127,19 @@ export class SSOAuthController {
} }
} }
private async generateLoginToken({ private async findWorkspaceIdentityProviderByIdentityProviderId(
user, identityProviderId: string,
identityProviderId, ) {
}: { return await this.workspaceSSOIdentityProviderRepository.findOne({
identityProviderId?: string; where: { id: identityProviderId },
user: { email: string } & Record<string, string>; relations: ['workspace'],
}) { });
if (!identityProviderId) { }
throw new AuthException(
'Identity provider ID is required',
AuthExceptionCode.INVALID_DATA,
);
}
const identityProvider =
await this.workspaceSSOIdentityProviderRepository.findOne({
where: { id: identityProviderId },
relations: ['workspace'],
});
private async generateLoginToken(
user: { email: string } & Record<string, string>,
identityProvider: WorkspaceSSOIdentityProvider,
) {
if (!identityProvider) { if (!identityProvider) {
throw new AuthException( throw new AuthException(
'Identity provider not found', 'Identity provider not found',
@ -159,28 +147,24 @@ export class SSOAuthController {
); );
} }
await this.authService.signInUp({ const existingUser = await this.userRepository.findOne({
...user, where: {
...(this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') email: user.email,
? { },
targetWorkspaceSubdomain: identityProvider.workspace.subdomain,
}
: {}),
fromSSO: true,
}); });
const isUserExistInWorkspace = const { userData } = this.authService.formatUserDataPayload(
await this.userWorkspaceService.checkUserWorkspaceExistsByEmail( user,
user.email, existingUser,
identityProvider.workspaceId, );
);
if (!isUserExistInWorkspace) { await this.authService.signInUp({
throw new AuthException( userData,
'User not found in workspace', workspace: identityProvider.workspace,
AuthExceptionCode.FORBIDDEN_EXCEPTION, authParams: {
); provider: 'sso',
} },
});
return { return {
identityProvider, identityProvider,

View File

@ -14,6 +14,11 @@ export class SignUpInput {
@IsString() @IsString()
password: string; password: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
workspaceId?: string;
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
@IsString() @IsString()
@IsOptional() @IsOptional()

View File

@ -39,10 +39,10 @@ export class GoogleOauthGuard extends AuthGuard('google') {
} }
if ( if (
request.query.workspaceSubdomain && request.query.workspaceId &&
typeof request.query.workspaceSubdomain === 'string' typeof request.query.workspaceId === 'string'
) { ) {
request.params.workspaceSubdomain = request.query.workspaceSubdomain; request.params.workspaceId = request.query.workspaceId;
} }
if ( if (

View File

@ -3,6 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import { expect, jest } from '@jest/globals'; import { expect, jest } from '@jest/globals';
import { Repository } from 'typeorm';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity'; import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
@ -16,6 +17,7 @@ import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services
import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@ -31,6 +33,7 @@ const userWorkspaceAddUserToWorkspaceMock = jest.fn();
describe('AuthService', () => { describe('AuthService', () => {
let service: AuthService; let service: AuthService;
let appTokenRepository: Repository<AppToken>;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
@ -48,7 +51,14 @@ describe('AuthService', () => {
}, },
{ {
provide: getRepositoryToken(AppToken, 'core'), provide: getRepositoryToken(AppToken, 'core'),
useValue: {}, useValue: {
createQueryBuilder: jest.fn().mockReturnValue({
leftJoin: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getOne: jest.fn().mockImplementation(() => null),
}),
},
}, },
{ {
provide: SignInUpService, provide: SignInUpService,
@ -94,10 +104,18 @@ describe('AuthService', () => {
validateInvitation: workspaceInvitationValidateInvitationMock, validateInvitation: workspaceInvitationValidateInvitationMock,
}, },
}, },
{
provide: SocialSsoService,
useValue: {},
},
], ],
}).compile(); }).compile();
service = module.get<AuthService>(AuthService); service = module.get<AuthService>(AuthService);
appTokenRepository = module.get<Repository<AppToken>>(
getRepositoryToken(AppToken, 'core'),
);
}); });
it('should be defined', async () => { it('should be defined', async () => {

View File

@ -47,6 +47,15 @@ import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type'; import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import {
AuthProviderWithPasswordType,
ExistingUserOrNewUser,
SignInUpBaseParams,
SignInUpNewUserPayload,
} from 'src/engine/core-modules/auth/types/signInUp.type';
@Injectable() @Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository // eslint-disable-next-line @nx/workspace-inject-workspace-repository
@ -57,6 +66,8 @@ export class AuthService {
private readonly refreshTokenService: RefreshTokenService, private readonly refreshTokenService: RefreshTokenService,
private readonly userWorkspaceService: UserWorkspaceService, private readonly userWorkspaceService: UserWorkspaceService,
private readonly workspaceInvitationService: WorkspaceInvitationService, private readonly workspaceInvitationService: WorkspaceInvitationService,
private readonly socialSsoService: SocialSsoService,
private readonly userService: UserService,
private readonly signInUpService: SignInUpService, private readonly signInUpService: SignInUpService,
@InjectRepository(Workspace, 'core') @InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>, private readonly workspaceRepository: Repository<Workspace>,
@ -149,41 +160,58 @@ export class AuthService {
return user; return user;
} }
async signInUp({ async signInUp(
email, params: SignInUpBaseParams &
password, ExistingUserOrNewUser &
workspaceInviteHash, AuthProviderWithPasswordType,
workspacePersonalInviteToken, ) {
targetWorkspaceSubdomain, if (
firstName, params.authParams.provider === 'password' &&
lastName, params.userData.type === 'newUser'
picture, ) {
fromSSO, params.userData.newUserPayload.passwordHash =
authProvider, await this.signInUpService.generateHash(params.authParams.password);
}: { }
email: string;
password?: string; if (
firstName?: string | null; params.authParams.provider === 'password' &&
lastName?: string | null; params.userData.type === 'existingUser'
workspaceInviteHash?: string; ) {
workspacePersonalInviteToken?: string; await this.signInUpService.validatePassword({
picture?: string | null; password: params.authParams.password,
fromSSO: boolean; passwordHash: params.userData.existingUser.passwordHash,
targetWorkspaceSubdomain?: string; });
authProvider?: WorkspaceAuthProvider; }
billingCheckoutSessionState?: string;
}) { if (params.workspace) {
workspaceValidator.isAuthEnabledOrThrow(
params.authParams.provider,
params.workspace,
);
}
if (params.userData.type === 'newUser') {
const partialUserWithPicture =
await this.signInUpService.computeParamsForNewUser(
params.userData.newUserPayload,
params.authParams,
);
return await this.signInUpService.signInUp({
...params,
userData: {
type: 'newUserWithPicture',
newUserWithPicture: partialUserWithPicture,
},
});
}
return await this.signInUpService.signInUp({ return await this.signInUpService.signInUp({
email, ...params,
password, userData: {
firstName, type: 'existingUser',
lastName, existingUser: params.userData.existingUser,
workspaceInviteHash, },
workspacePersonalInviteToken,
targetWorkspaceSubdomain,
picture,
fromSSO,
authProvider,
}); });
} }
@ -414,11 +442,15 @@ export class AuthService {
return workspace; return workspace;
} }
computeRedirectURI( computeRedirectURI({
loginToken: string, loginToken,
subdomain?: string, subdomain,
billingCheckoutSessionState?: string, billingCheckoutSessionState,
) { }: {
loginToken: string;
subdomain?: string;
billingCheckoutSessionState?: string;
}) {
const url = this.domainManagerService.buildWorkspaceURL({ const url = this.domainManagerService.buildWorkspaceURL({
subdomain, subdomain,
pathname: '/verify', pathname: '/verify',
@ -472,4 +504,118 @@ export class AuthService {
), ),
})); }));
} }
async findInvitationForSignInUp({
currentWorkspace,
workspacePersonalInviteToken,
email,
}: {
currentWorkspace?: Workspace | null;
workspacePersonalInviteToken?: string;
email?: string;
}) {
if (!currentWorkspace) return undefined;
const qr = this.appTokenRepository.createQueryBuilder('appToken');
qr.where('"appToken"."workspaceId" = :workspaceId', {
workspaceId: currentWorkspace.id,
}).andWhere('"appToken".type = :type', {
type: AppTokenType.InvitationToken,
});
if (email) {
qr.andWhere('"appToken".context->>\'email\' = :email', {
email,
});
}
if (workspacePersonalInviteToken) {
qr.andWhere('"appToken".value = :personalInviteToken', {
personalInviteToken: workspacePersonalInviteToken,
});
}
return (await qr.getOne()) ?? undefined;
}
async findWorkspaceForSignInUp(
params: {
workspaceId?: string;
workspaceInviteHash?: string;
} & (
| {
authProvider: Exclude<WorkspaceAuthProvider, 'password'>;
email: string;
}
| { authProvider: Extract<WorkspaceAuthProvider, 'password'> }
),
) {
if (params.workspaceInviteHash) {
return (
(await this.workspaceRepository.findOneBy({
inviteHash: params.workspaceInviteHash,
})) ?? undefined
);
}
if (params.authProvider !== 'password') {
return (
(await this.socialSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider(
{
email: params.email,
authProvider: params.authProvider,
},
params.workspaceId,
)) ?? undefined
);
}
if (params.authProvider === 'password' && params.workspaceId) {
return (
(await this.workspaceRepository.findOneBy({
id: params.workspaceId,
})) ?? undefined
);
}
return undefined;
}
formatUserDataPayload(
newUserPayload: SignInUpNewUserPayload,
existingUser?: User | null,
): ExistingUserOrNewUser {
return {
userData: existingUser
? { type: 'existingUser', existingUser }
: {
type: 'newUser',
newUserPayload,
},
};
}
async checkAccessForSignIn({
userData,
invitation,
workspaceInviteHash,
workspace,
}: {
workspaceInviteHash?: string;
} & ExistingUserOrNewUser &
SignInUpBaseParams) {
if (!invitation && !workspaceInviteHash && workspace) {
if (userData.type === 'existingUser') {
await this.userService.hasUserAccessToWorkspaceOrThrow(
userData.existingUser.id,
workspace.id,
);
}
throw new AuthException(
'User does not have access to this workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
}
} }

View File

@ -1,90 +1,83 @@
import { HttpService } from '@nestjs/axios';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { HttpService } from '@nestjs/axios';
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from '@nestjs/typeorm';
import { expect, jest } from '@jest/globals'; import { Repository } from 'typeorm';
import bcrypt from 'bcrypt';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import {
AuthProviderWithPasswordType,
ExistingUserOrPartialUserWithPicture,
SignInUpBaseParams,
} from 'src/engine/core-modules/auth/types/signInUp.type';
import { import {
Workspace, Workspace,
WorkspaceActivationStatus, WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity'; } from 'src/engine/core-modules/workspace/workspace.entity';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
jest.mock('bcrypt'); jest.mock('src/utils/image', () => {
return {
const UserFindOneMock = jest.fn(); getImageBufferFromUrl: () => Promise.resolve(Buffer.from('')),
const workspaceInvitationValidateInvitationMock = jest.fn(); };
const workspaceInvitationInvalidateWorkspaceInvitationMock = jest.fn(); });
const workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock =
jest.fn();
const userWorkspaceServiceAddUserToWorkspaceMock = jest.fn();
const UserCreateMock = jest.fn();
const UserSaveMock = jest.fn();
const EnvironmentServiceGetMock = jest.fn();
const WorkspaceCountMock = jest.fn();
const WorkspaceCreateMock = jest.fn();
const WorkspaceSaveMock = jest.fn();
const WorkspaceFindOneMock = jest.fn();
describe('SignInUpService', () => { describe('SignInUpService', () => {
let service: SignInUpService; let service: SignInUpService;
let UserRepository: Repository<User>;
afterEach(() => { let WorkspaceRepository: Repository<Workspace>;
jest.clearAllMocks(); let fileUploadService: FileUploadService;
}); let workspaceInvitationService: WorkspaceInvitationService;
let userWorkspaceService: UserWorkspaceService;
let onboardingService: OnboardingService;
let httpService: HttpService;
let environmentService: EnvironmentService;
let domainManagerService: DomainManagerService;
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
SignInUpService, SignInUpService,
{ {
provide: FileUploadService, provide: getRepositoryToken(User, 'core'),
useValue: {}, useValue: {
create: jest.fn(),
save: jest.fn(),
},
}, },
{ {
provide: getRepositoryToken(Workspace, 'core'), provide: getRepositoryToken(Workspace, 'core'),
useValue: { useValue: {
count: WorkspaceCountMock, save: jest.fn(),
create: WorkspaceCreateMock, create: jest.fn(),
save: WorkspaceSaveMock, get: jest.fn(),
findOne: WorkspaceFindOneMock, count: jest.fn(),
}, },
}, },
{ {
provide: getRepositoryToken(User, 'core'), provide: FileUploadService,
useValue: { useValue: {
findOne: UserFindOneMock, uploadImage: jest.fn(),
create: UserCreateMock,
save: UserSaveMock,
}, },
}, },
{
provide: getRepositoryToken(AppToken, 'core'),
useValue: {},
},
{ {
provide: WorkspaceInvitationService, provide: WorkspaceInvitationService,
useValue: {}, useValue: {
}, validateInvitation: jest.fn(),
{ invalidateWorkspaceInvitation: jest.fn(),
provide: WorkspaceService, },
useValue: {},
}, },
{ {
provide: UserWorkspaceService, provide: UserWorkspaceService,
useValue: { useValue: {
addUserToWorkspace: userWorkspaceServiceAddUserToWorkspaceMock, addUserToWorkspace: jest.fn(),
create: jest.fn(), create: jest.fn(),
}, },
}, },
@ -93,7 +86,6 @@ describe('SignInUpService', () => {
useValue: { useValue: {
setOnboardingConnectAccountPending: jest.fn(), setOnboardingConnectAccountPending: jest.fn(),
setOnboardingInviteTeamPending: jest.fn(), setOnboardingInviteTeamPending: jest.fn(),
setOnboardingCreateProfilePending: jest.fn(),
}, },
}, },
{ {
@ -103,410 +95,150 @@ describe('SignInUpService', () => {
{ {
provide: EnvironmentService, provide: EnvironmentService,
useValue: { useValue: {
get: EnvironmentServiceGetMock, get: jest.fn(),
},
},
{
provide: WorkspaceInvitationService,
useValue: {
validateInvitation: workspaceInvitationValidateInvitationMock,
invalidateWorkspaceInvitation:
workspaceInvitationInvalidateWorkspaceInvitationMock,
findInvitationByWorkspaceSubdomainAndUserEmail:
workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock,
}, },
}, },
{ {
provide: DomainManagerService, provide: DomainManagerService,
useValue: { useValue: {
generateSubdomain: jest.fn().mockReturnValue('testSubDomain'), generateSubdomain: jest.fn(),
getWorkspaceBySubdomainOrDefaultWorkspace: jest
.fn()
.mockReturnValue({}),
},
},
{
provide: UserService,
useValue: {
hasUserAccessToWorkspaceOrThrow: jest.fn(),
}, },
}, },
], ],
}).compile(); }).compile();
service = module.get<SignInUpService>(SignInUpService); service = module.get<SignInUpService>(SignInUpService);
}); UserRepository = module.get(getRepositoryToken(User, 'core'));
WorkspaceRepository = module.get(getRepositoryToken(Workspace, 'core'));
it('should be defined', () => { fileUploadService = module.get<FileUploadService>(FileUploadService);
expect(service).toBeDefined(); workspaceInvitationService = module.get<WorkspaceInvitationService>(
}); WorkspaceInvitationService,
it('signInUp - sso - new user', async () => {
const email = 'test@test.com';
UserFindOneMock.mockReturnValueOnce(false);
workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce(
undefined,
); );
userWorkspaceService =
module.get<UserWorkspaceService>(UserWorkspaceService);
onboardingService = module.get<OnboardingService>(OnboardingService);
httpService = module.get<HttpService>(HttpService);
environmentService = module.get<EnvironmentService>(EnvironmentService);
domainManagerService =
module.get<DomainManagerService>(DomainManagerService);
});
const spy = jest it('should handle signInUp with valid personal invitation', async () => {
.spyOn(service, 'signUpOnNewWorkspace') const params: SignInUpBaseParams &
.mockResolvedValueOnce({ user: {}, workspace: {} } as { ExistingUserOrPartialUserWithPicture &
user: User; AuthProviderWithPasswordType = {
workspace: Workspace; invitation: { value: 'invitationToken' } as AppToken,
workspace: {
id: 'workspaceId',
activationStatus: WorkspaceActivationStatus.ACTIVE,
} as Workspace,
authParams: { provider: 'password', password: 'validPassword' },
userData: {
type: 'existingUser',
existingUser: { email: 'test@example.com' } as User,
},
};
jest
.spyOn(workspaceInvitationService, 'validateInvitation')
.mockResolvedValue({
isValid: true,
workspace: params.workspace as Workspace,
}); });
await service.signInUp({ jest
email: 'test@test.com', .spyOn(workspaceInvitationService, 'invalidateWorkspaceInvitation')
fromSSO: true, .mockResolvedValue(undefined);
targetWorkspaceSubdomain: 'testSubDomain',
jest
.spyOn(userWorkspaceService, 'addUserToWorkspace')
.mockResolvedValue({} as User);
const result = await service.signInUp(params);
expect(result.workspace).toEqual(params.workspace);
expect(result.user).toBeDefined();
expect(workspaceInvitationService.validateInvitation).toHaveBeenCalledWith({
workspacePersonalInviteToken: 'invitationToken',
email: 'test@example.com',
}); });
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
email,
passwordHash: undefined,
firstName: expect.any(String),
lastName: expect.any(String),
picture: undefined,
}),
);
});
it('signInUp - sso - existing user', async () => {
const email = 'existing@test.com';
const existingUser = {
id: 'user-id',
email,
passwordHash: undefined,
};
UserFindOneMock.mockReturnValueOnce(existingUser);
workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce(
undefined,
);
WorkspaceFindOneMock.mockReturnValueOnce({
id: 'another-workspace',
});
const result = await service.signInUp({
email,
fromSSO: true,
targetWorkspaceSubdomain: 'testSubDomain',
});
expect(result).toEqual({ user: existingUser, workspace: {} });
});
it('signInUp - sso - new user - existing invitation', async () => {
const email = 'newuser@test.com';
const workspaceId = 'workspace-id';
UserFindOneMock.mockReturnValueOnce(null);
workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce(
{
value: 'personal-token-value',
},
);
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
isValid: true,
workspace: { id: workspaceId },
});
workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce(
true,
);
const spySignInUpOnExistingWorkspace = jest
.spyOn(service, 'signInUpOnExistingWorkspace')
.mockResolvedValueOnce(
{} as Awaited<
ReturnType<(typeof service)['signInUpOnExistingWorkspace']>
>,
);
await service.signInUp({
email,
fromSSO: true,
targetWorkspaceSubdomain: 'testSubDomain',
});
expect(spySignInUpOnExistingWorkspace).toHaveBeenCalledWith(
expect.objectContaining({
email,
passwordHash: undefined,
workspace: expect.objectContaining({
id: workspaceId,
}),
firstName: expect.any(String),
lastName: expect.any(String),
picture: undefined,
}),
);
expect( expect(
workspaceInvitationInvalidateWorkspaceInvitationMock, workspaceInvitationService.invalidateWorkspaceInvitation,
).toHaveBeenCalledWith(workspaceId, email); ).toHaveBeenCalledWith(
(params.workspace as Workspace).id,
'test@example.com',
);
}); });
it('signInUp - sso - existing user - existing invitation', async () => {
const email = 'existinguser@test.com'; it('should handle signInUp on existing workspace without invitation', async () => {
const workspaceId = 'workspace-id'; const params: SignInUpBaseParams &
const existingUser = { ExistingUserOrPartialUserWithPicture &
id: 'user-id', AuthProviderWithPasswordType = {
email, workspace: {
passwordHash: undefined, id: 'workspaceId',
activationStatus: WorkspaceActivationStatus.ACTIVE,
} as Workspace,
authParams: { provider: 'password', password: 'validPassword' },
userData: {
type: 'existingUser',
existingUser: { email: 'test@example.com' } as User,
},
}; };
const workspace = { jest
id: workspaceId, .spyOn(userWorkspaceService, 'addUserToWorkspace')
.mockResolvedValue({} as User);
const result = await service.signInUp(params);
expect(result.workspace).toEqual(params.workspace);
expect(result.user).toBeDefined();
expect(userWorkspaceService.addUserToWorkspace).toHaveBeenCalled();
});
it('should handle signUp on new workspace for a new user', async () => {
const params: SignInUpBaseParams &
ExistingUserOrPartialUserWithPicture &
AuthProviderWithPasswordType = {
authParams: { provider: 'password', password: 'validPassword' },
userData: {
type: 'newUserWithPicture',
newUserWithPicture: {
email: 'newuser@example.com',
picture: 'pictureUrl',
},
},
};
jest.spyOn(environmentService, 'get').mockReturnValue(false);
jest.spyOn(WorkspaceRepository, 'count').mockResolvedValue(0);
jest.spyOn(WorkspaceRepository, 'create').mockReturnValue({} as Workspace);
jest.spyOn(WorkspaceRepository, 'save').mockResolvedValue({
id: 'newWorkspaceId',
activationStatus: WorkspaceActivationStatus.ACTIVE, activationStatus: WorkspaceActivationStatus.ACTIVE,
}; } as Workspace);
jest.spyOn(fileUploadService, 'uploadImage').mockResolvedValue({
UserFindOneMock.mockReturnValueOnce(existingUser); id: '',
workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce( mimeType: '',
{ paths: ['path/to/image'],
value: 'personal-token-value',
},
);
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
isValid: true,
workspace,
}); });
jest.spyOn(UserRepository, 'create').mockReturnValue({} as User);
jest
.spyOn(domainManagerService, 'generateSubdomain')
.mockResolvedValue('a-subdomain');
jest
.spyOn(UserRepository, 'save')
.mockResolvedValue({ id: 'newUserId' } as User);
jest.spyOn(userWorkspaceService, 'create').mockResolvedValue({} as any);
workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce( const result = await service.signInUp(params);
true,
);
userWorkspaceServiceAddUserToWorkspaceMock.mockReturnValueOnce({}); expect(result.workspace).toBeDefined();
expect(result.user).toBeDefined();
const result = await service.signInUp({ expect(WorkspaceRepository.create).toHaveBeenCalled();
email, expect(WorkspaceRepository.save).toHaveBeenCalled();
fromSSO: true, expect(UserRepository.create).toHaveBeenCalled();
targetWorkspaceSubdomain: 'testSubDomain', expect(UserRepository.save).toHaveBeenCalled();
}); expect(fileUploadService.uploadImage).toHaveBeenCalled();
expect(result).toEqual({ user: existingUser, workspace });
expect(userWorkspaceServiceAddUserToWorkspaceMock).toHaveBeenCalledTimes(1);
expect(
workspaceInvitationInvalidateWorkspaceInvitationMock,
).toHaveBeenCalledWith(workspaceId, email);
});
it('signInUp - sso - new user - personal invitation token', async () => {
const email = 'newuser@test.com';
const workspaceId = 'workspace-id';
const workspacePersonalInviteToken = 'personal-token-value';
const workspace = {
id: workspaceId,
activationStatus: WorkspaceActivationStatus.ACTIVE,
};
UserFindOneMock.mockReturnValueOnce(null);
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
isValid: true,
workspace,
});
workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce(
true,
);
const spySignInUpOnExistingWorkspace = jest
.spyOn(service, 'signInUpOnExistingWorkspace')
.mockResolvedValueOnce(
{} as Awaited<
ReturnType<(typeof service)['signInUpOnExistingWorkspace']>
>,
);
await service.signInUp({
email,
fromSSO: true,
workspacePersonalInviteToken,
targetWorkspaceSubdomain: 'testSubDomain',
});
expect(spySignInUpOnExistingWorkspace).toHaveBeenCalledWith(
expect.objectContaining({
email,
passwordHash: undefined,
workspace: expect.objectContaining({
id: workspaceId,
}),
firstName: expect.any(String),
lastName: expect.any(String),
picture: undefined,
}),
);
expect(
workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock,
).not.toHaveBeenCalled();
expect(
workspaceInvitationInvalidateWorkspaceInvitationMock,
).toHaveBeenCalledWith(workspaceId, email);
});
it('signInUp - sso - existing user - personal invitation token', async () => {
const email = 'existinguser@test.com';
const workspaceId = 'workspace-id';
const workspacePersonalInviteToken = 'personal-token-value';
const existingUser = {
id: 'user-id',
email,
passwordHash: undefined,
};
UserFindOneMock.mockReturnValueOnce(existingUser);
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
isValid: true,
workspace: {
id: workspaceId,
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
});
workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce(
true,
);
await service.signInUp({
email,
fromSSO: true,
workspacePersonalInviteToken,
targetWorkspaceSubdomain: 'testSubDomain',
});
expect(
workspaceInvitationInvalidateWorkspaceInvitationMock,
).toHaveBeenCalledWith(workspaceId, email);
});
it('signInUp - credentials - existing user', async () => {
const email = 'existinguser@test.com';
const password = 'validPassword123';
const existingUser = {
id: 'user-id',
email,
passwordHash: 'hash-of-validPassword123',
};
UserFindOneMock.mockReturnValueOnce(existingUser);
EnvironmentServiceGetMock.mockReturnValueOnce(false);
WorkspaceFindOneMock.mockReturnValueOnce({
id: 'another-workspace',
});
(bcrypt.compare as jest.Mock).mockReturnValueOnce(true);
await service.signInUp({
email,
password,
fromSSO: false,
targetWorkspaceSubdomain: 'testSubDomain',
});
expect(
workspaceInvitationInvalidateWorkspaceInvitationMock,
).not.toHaveBeenCalled();
});
it('signInUp - credentials - new user', async () => {
const email = 'newuser@test.com';
const password = 'validPassword123';
UserFindOneMock.mockReturnValueOnce(null);
UserCreateMock.mockReturnValueOnce({} as User);
UserSaveMock.mockReturnValueOnce({} as User);
EnvironmentServiceGetMock.mockReturnValueOnce(true);
WorkspaceCreateMock.mockReturnValueOnce({});
WorkspaceSaveMock.mockReturnValueOnce({});
await service.signInUp({
email,
password,
fromSSO: false,
targetWorkspaceSubdomain: 'testSubDomain',
});
expect(UserCreateMock).toHaveBeenCalledTimes(1);
expect(UserSaveMock).toHaveBeenCalledTimes(1);
expect(WorkspaceSaveMock).toHaveBeenCalledTimes(1);
expect(WorkspaceCreateMock).toHaveBeenCalledTimes(1);
});
it('signInUp - credentials - new user - personal invitation token', async () => {
const email = 'newuser@test.com';
const password = 'validPassword123';
const workspaceId = 'workspace-id';
const workspacePersonalInviteToken = 'personal-token-value';
UserFindOneMock.mockReturnValueOnce(null);
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
isValid: true,
workspace: {
id: workspaceId,
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
});
workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce(
true,
);
UserCreateMock.mockReturnValueOnce({} as User);
UserSaveMock.mockReturnValueOnce({} as User);
await service.signInUp({
email,
password,
fromSSO: false,
workspacePersonalInviteToken,
targetWorkspaceSubdomain: 'testSubDomain',
});
expect(UserCreateMock).toHaveBeenCalledTimes(1);
expect(UserSaveMock).toHaveBeenCalledTimes(1);
expect(
workspaceInvitationInvalidateWorkspaceInvitationMock,
).toHaveBeenCalledWith(workspaceId, email);
});
it('signInUp - credentials - new user - public invitation token', async () => {
const email = 'newuser@test.com';
const password = 'validPassword123';
const workspaceId = 'workspace-id';
const workspaceInviteHash = 'public-token-value';
UserFindOneMock.mockReturnValueOnce(null);
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
isValid: true,
workspace: {
id: workspaceId,
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
});
workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce(
true,
);
UserCreateMock.mockReturnValueOnce({} as User);
UserSaveMock.mockReturnValueOnce({} as User);
await service.signInUp({
email,
password,
fromSSO: false,
targetWorkspaceSubdomain: 'testSubDomain',
workspaceInviteHash,
});
expect(UserCreateMock).toHaveBeenCalledTimes(1);
expect(UserSaveMock).toHaveBeenCalledTimes(1);
expect(
workspaceInvitationInvalidateWorkspaceInvitationMock,
).toHaveBeenCalledWith(workspaceId, email);
}); });
}); });

View File

@ -23,11 +23,8 @@ import { EnvironmentService } from 'src/engine/core-modules/environment/environm
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity'; import { User } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service'; import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type';
import { import {
Workspace, Workspace,
WorkspaceActivationStatus, WorkspaceActivationStatus,
@ -35,21 +32,15 @@ import {
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email'; import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
import { getImageBufferFromUrl } from 'src/utils/image'; import { getImageBufferFromUrl } from 'src/utils/image';
import { isDefined } from 'src/utils/is-defined';
import { isWorkEmail } from 'src/utils/is-work-email'; import { isWorkEmail } from 'src/utils/is-work-email';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
export type SignInUpServiceInput = { import {
email: string; AuthProviderWithPasswordType,
password?: string; ExistingUserOrPartialUserWithPicture,
firstName?: string | null; PartialUserWithPicture,
lastName?: string | null; SignInUpBaseParams,
workspaceInviteHash?: string; SignInUpNewUserPayload,
workspacePersonalInviteToken?: string; } from 'src/engine/core-modules/auth/types/signInUp.type';
picture?: string | null;
fromSSO: boolean;
targetWorkspaceSubdomain?: string;
authProvider?: WorkspaceAuthProvider;
};
@Injectable() @Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository // eslint-disable-next-line @nx/workspace-inject-workspace-repository
@ -66,23 +57,112 @@ export class SignInUpService {
private readonly httpService: HttpService, private readonly httpService: HttpService,
private readonly environmentService: EnvironmentService, private readonly environmentService: EnvironmentService,
private readonly domainManagerService: DomainManagerService, private readonly domainManagerService: DomainManagerService,
private readonly userService: UserService,
) {} ) {}
async signInUp({ async computeParamsForNewUser(
email, newUserParams: SignInUpNewUserPayload,
workspacePersonalInviteToken, authParams: AuthProviderWithPasswordType['authParams'],
workspaceInviteHash, ) {
if (!newUserParams.firstName) newUserParams.firstName = '';
if (!newUserParams.lastName) newUserParams.lastName = '';
if (!newUserParams?.email) {
throw new AuthException(
'Email is required',
AuthExceptionCode.INVALID_INPUT,
);
}
if (authParams.provider === 'password') {
newUserParams.passwordHash = await this.generateHash(authParams.password);
}
return newUserParams as PartialUserWithPicture;
}
async signInUp(
params: SignInUpBaseParams &
ExistingUserOrPartialUserWithPicture &
AuthProviderWithPasswordType,
) {
// with personal invitation flow
if (params.workspace && params.invitation) {
return {
workspace: params.workspace,
user: await this.signInUpWithPersonalInvitation({
invitation: params.invitation,
userData: params.userData,
}),
};
}
// with global invitation flow
if (params.workspace) {
const updatedUser = await this.signInUpOnExistingWorkspace({
workspace: params.workspace,
userData: params.userData,
});
return { user: updatedUser, workspace: params.workspace };
}
if (params.userData.type === 'newUserWithPicture') {
return await this.signUpOnNewWorkspace(
params.userData.newUserWithPicture,
);
}
// should never happen.
throw new Error('Invalid sign in up params');
}
async generateHash(password: string) {
const isPasswordValid = PASSWORD_REGEX.test(password);
if (!isPasswordValid) {
throw new AuthException(
'Password too weak',
AuthExceptionCode.INVALID_INPUT,
);
}
return await hashPassword(password);
}
async validatePassword({
password, password,
firstName, passwordHash,
lastName, }: {
picture, password: string;
fromSSO, passwordHash: string;
targetWorkspaceSubdomain, }) {
authProvider, const isValid = await compareHash(
}: SignInUpServiceInput) { await this.generateHash(password),
if (!firstName) firstName = ''; passwordHash,
if (!lastName) lastName = ''; );
if (!isValid) {
throw new AuthException(
'Wrong password',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
}
private async signInUpWithPersonalInvitation(
params: { invitation: AppToken } & ExistingUserOrPartialUserWithPicture,
) {
if (!params.invitation) {
throw new AuthException(
'Invitation not found',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const email =
params.userData.type === 'newUserWithPicture'
? params.userData.newUserWithPicture.email
: params.userData.existingUser.email;
if (!email) { if (!email) {
throw new AuthException( throw new AuthException(
@ -91,264 +171,90 @@ export class SignInUpService {
); );
} }
if (password) {
const isPasswordValid = PASSWORD_REGEX.test(password);
if (!isPasswordValid) {
throw new AuthException(
'Password too weak',
AuthExceptionCode.INVALID_INPUT,
);
}
}
const passwordHash = password ? await hashPassword(password) : undefined;
const existingUser = await this.userRepository.findOne({
where: { email },
});
if (existingUser && !fromSSO) {
const isValid = await compareHash(
password || '',
existingUser.passwordHash,
);
if (!isValid) {
throw new AuthException(
'Wrong password',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
}
const signInUpWithInvitationResult = targetWorkspaceSubdomain
? await this.signInUpWithInvitation({
email,
workspacePersonalInviteToken,
workspaceInviteHash,
targetWorkspaceSubdomain,
fromSSO,
firstName,
lastName,
picture,
authProvider,
passwordHash,
existingUser,
})
: undefined;
if (isDefined(signInUpWithInvitationResult)) {
return signInUpWithInvitationResult;
}
if (!existingUser) {
return await this.signUpOnNewWorkspace({
email,
passwordHash,
firstName,
lastName,
picture,
});
}
const workspace =
await this.domainManagerService.getWorkspaceBySubdomainOrDefaultWorkspace(
targetWorkspaceSubdomain,
);
workspaceValidator.assertIsDefinedOrThrow(workspace);
return await this.validateSignIn({
user: existingUser,
workspace,
authProvider,
});
}
private async signInUpWithInvitation({
email,
workspacePersonalInviteToken,
workspaceInviteHash,
firstName,
lastName,
picture,
fromSSO,
targetWorkspaceSubdomain,
authProvider,
passwordHash,
existingUser,
}: {
email: string;
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
firstName: string;
lastName: string;
picture?: string | null;
authProvider?: WorkspaceAuthProvider;
passwordHash?: string;
existingUser: User | null;
fromSSO: boolean;
targetWorkspaceSubdomain: string;
}) {
const maybeInvitation =
fromSSO && !workspacePersonalInviteToken && !workspaceInviteHash
? await this.workspaceInvitationService.findInvitationByWorkspaceSubdomainAndUserEmail(
{
subdomain: targetWorkspaceSubdomain,
email,
},
)
: undefined;
const invitationValidation = const invitationValidation =
workspacePersonalInviteToken || workspaceInviteHash || maybeInvitation await this.workspaceInvitationService.validateInvitation({
? await this.workspaceInvitationService.validateInvitation({ workspacePersonalInviteToken: params.invitation.value,
workspacePersonalInviteToken:
workspacePersonalInviteToken ?? maybeInvitation?.value,
workspaceInviteHash,
email,
})
: null;
if (
invitationValidation?.isValid === true &&
invitationValidation.workspace
) {
const updatedUser = await this.signInUpOnExistingWorkspace({
email, email,
passwordHash,
workspace: invitationValidation.workspace,
firstName,
lastName,
picture,
existingUser,
authProvider,
}); });
await this.workspaceInvitationService.invalidateWorkspaceInvitation( if (!invitationValidation?.isValid) {
invitationValidation.workspace.id, throw new AuthException(
email, 'Invitation not found',
);
return {
user: updatedUser,
workspace: invitationValidation.workspace,
};
}
}
private async validateSignIn({
user,
workspace,
authProvider,
}: {
user: User;
workspace: Workspace;
authProvider: SignInUpServiceInput['authProvider'];
}) {
if (authProvider) {
workspaceValidator.isAuthEnabledOrThrow(authProvider, workspace);
}
await this.userService.hasUserAccessToWorkspaceOrThrow(
user.id,
workspace.id,
);
return { user, workspace };
}
async signInUpOnExistingWorkspace({
email,
passwordHash,
workspace,
firstName,
lastName,
picture,
existingUser,
authProvider,
}: {
email: string;
passwordHash: string | undefined;
workspace: Workspace;
firstName: string;
lastName: string;
picture: SignInUpServiceInput['picture'];
existingUser: User | null;
authProvider?: WorkspaceAuthProvider;
}) {
const isNewUser = !isDefined(existingUser);
let user = existingUser;
workspaceValidator.assertIsDefinedOrThrow(
workspace,
new AuthException(
'Workspace not found',
AuthExceptionCode.FORBIDDEN_EXCEPTION, AuthExceptionCode.FORBIDDEN_EXCEPTION,
), );
}
const updatedUser = await this.signInUpOnExistingWorkspace({
workspace: invitationValidation.workspace,
userData: params.userData,
});
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
invitationValidation.workspace.id,
email,
); );
return updatedUser;
}
private async persistNewUser(
newUser: PartialUserWithPicture,
workspace: Workspace,
) {
const imagePath = await this.uploadPicture(newUser.picture, workspace.id);
delete newUser.picture;
const userToCreate = this.userRepository.create({
...newUser,
defaultAvatarUrl: imagePath,
canImpersonate: false,
} as Partial<User>);
return await this.userRepository.save(userToCreate);
}
async signInUpOnExistingWorkspace(
params: {
workspace: Workspace;
} & ExistingUserOrPartialUserWithPicture,
) {
workspaceValidator.assertIsActive( workspaceValidator.assertIsActive(
workspace, params.workspace,
new AuthException( new AuthException(
'Workspace is not ready to welcome new members', 'Workspace is not ready to welcome new members',
AuthExceptionCode.FORBIDDEN_EXCEPTION, AuthExceptionCode.FORBIDDEN_EXCEPTION,
), ),
); );
if (authProvider) { const currentUser =
workspaceValidator.isAuthEnabledOrThrow(authProvider, workspace); params.userData.type === 'newUserWithPicture'
} ? await this.persistNewUser(
params.userData.newUserWithPicture,
if (isNewUser) { params.workspace,
const imagePath = await this.uploadPicture(picture, workspace.id); )
: params.userData.existingUser;
const userToCreate = this.userRepository.create({
email: email,
firstName: firstName,
lastName: lastName,
defaultAvatarUrl: imagePath,
canImpersonate: false,
passwordHash,
});
user = await this.userRepository.save(userToCreate);
}
userValidator.assertIsDefinedOrThrow(
user,
new AuthException(
'User not found',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
const updatedUser = await this.userWorkspaceService.addUserToWorkspace( const updatedUser = await this.userWorkspaceService.addUserToWorkspace(
user, currentUser,
workspace, params.workspace,
); );
await this.activateOnboardingForUser(user, workspace, { const user = Object.assign(currentUser, updatedUser);
firstName,
lastName,
});
return Object.assign(user, updatedUser); await this.activateOnboardingForUser(user, params.workspace);
return user;
} }
private async activateOnboardingForUser( private async activateOnboardingForUser(user: User, workspace: Workspace) {
user: User,
workspace: Workspace,
{ firstName, lastName }: { firstName: string; lastName: string },
) {
await this.onboardingService.setOnboardingConnectAccountPending({ await this.onboardingService.setOnboardingConnectAccountPending({
userId: user.id, userId: user.id,
workspaceId: workspace.id, workspaceId: workspace.id,
value: true, value: true,
}); });
if (firstName === '' && lastName === '') { if (user.firstName === '' && user.lastName === '') {
await this.onboardingService.setOnboardingCreateProfilePending({ await this.onboardingService.setOnboardingCreateProfilePending({
userId: user.id, userId: user.id,
workspaceId: workspace.id, workspaceId: workspace.id,
@ -357,27 +263,19 @@ export class SignInUpService {
} }
} }
async signUpOnNewWorkspace({ async signUpOnNewWorkspace(partialUserWithPicture: PartialUserWithPicture) {
email, const user: PartialUserWithPicture = {
passwordHash, ...partialUserWithPicture,
firstName,
lastName,
picture,
}: {
email: string;
passwordHash: string | undefined;
firstName: string;
lastName: string;
picture: SignInUpServiceInput['picture'];
}) {
const user: Partial<User> = {
email,
firstName,
lastName,
canImpersonate: false, canImpersonate: false,
passwordHash,
}; };
if (!user.email) {
throw new AuthException(
'Email is required',
AuthExceptionCode.INVALID_INPUT,
);
}
if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) { if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) {
const workspacesCount = await this.workspaceRepository.count(); const workspacesCount = await this.workspaceRepository.count();
@ -393,7 +291,7 @@ export class SignInUpService {
} }
} }
const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(email)}`; const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(user.email)}`;
const isLogoUrlValid = async () => { const isLogoUrlValid = async () => {
try { try {
return ( return (
@ -406,7 +304,7 @@ export class SignInUpService {
}; };
const logo = const logo =
isWorkEmail(email) && (await isLogoUrlValid()) ? logoUrl : undefined; isWorkEmail(user.email) && (await isLogoUrlValid()) ? logoUrl : undefined;
const workspaceToCreate = this.workspaceRepository.create({ const workspaceToCreate = this.workspaceRepository.create({
subdomain: await this.domainManagerService.generateSubdomain(), subdomain: await this.domainManagerService.generateSubdomain(),
@ -419,7 +317,10 @@ export class SignInUpService {
const workspace = await this.workspaceRepository.save(workspaceToCreate); const workspace = await this.workspaceRepository.save(workspaceToCreate);
user.defaultAvatarUrl = await this.uploadPicture(picture, workspace.id); user.defaultAvatarUrl = await this.uploadPicture(
partialUserWithPicture.picture,
workspace.id,
);
const userCreated = this.userRepository.create(user); const userCreated = this.userRepository.create(user);
@ -427,10 +328,7 @@ export class SignInUpService {
await this.userWorkspaceService.create(newUser.id, workspace.id); await this.userWorkspaceService.create(newUser.id, workspace.id);
await this.activateOnboardingForUser(newUser, workspace, { await this.activateOnboardingForUser(newUser, workspace);
firstName,
lastName,
});
await this.onboardingService.setOnboardingInviteTeamPending({ await this.onboardingService.setOnboardingInviteTeamPending({
workspaceId: workspace.id, workspaceId: workspace.id,

View File

@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type';
@Injectable()
export class SocialSsoService {
constructor(
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
private readonly environmentService: EnvironmentService,
) {}
private getAuthProviderColumnNameByProvider(
authProvider: WorkspaceAuthProvider,
) {
if (authProvider === 'google') {
return 'isGoogleAuthEnabled';
}
if (authProvider === 'microsoft') {
return 'isMicrosoftAuthEnabled';
}
if (authProvider === 'password') {
return 'isPasswordAuthEnabled';
}
throw new Error(`${authProvider} is not a valid auth provider.`);
}
async findWorkspaceFromWorkspaceIdOrAuthProvider(
{
authProvider,
email,
}: { authProvider: WorkspaceAuthProvider; email: string },
workspaceId?: string,
) {
if (
this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') &&
!workspaceId
) {
// Multi-workspace enable mode but on non workspace url.
// so get the first workspace with the current auth method enable
const workspace = await this.workspaceRepository.findOne({
where: {
[this.getAuthProviderColumnNameByProvider(authProvider)]: true,
workspaceUsers: {
user: {
email,
},
},
},
relations: ['workspaceUsers', 'workspaceUsers.user'],
});
return workspace ?? undefined;
}
return await this.workspaceRepository.findOneBy({
id: workspaceId,
});
}
}

View File

@ -17,7 +17,7 @@ export type GoogleRequest = Omit<
picture: string | null; picture: string | null;
workspaceInviteHash?: string; workspaceInviteHash?: string;
workspacePersonalInviteToken?: string; workspacePersonalInviteToken?: string;
targetWorkspaceSubdomain?: string; workspaceId?: string;
billingCheckoutSessionState?: string; billingCheckoutSessionState?: string;
}; };
}; };
@ -39,7 +39,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
...options, ...options,
state: JSON.stringify({ state: JSON.stringify({
workspaceInviteHash: req.params.workspaceInviteHash, workspaceInviteHash: req.params.workspaceInviteHash,
workspaceSubdomain: req.params.workspaceSubdomain, workspaceId: req.params.workspaceId,
...(req.params.billingCheckoutSessionState ...(req.params.billingCheckoutSessionState
? { ? {
billingCheckoutSessionState: billingCheckoutSessionState:
@ -78,7 +78,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
picture: photos?.[0]?.value, picture: photos?.[0]?.value,
workspaceInviteHash: state.workspaceInviteHash, workspaceInviteHash: state.workspaceInviteHash,
workspacePersonalInviteToken: state.workspacePersonalInviteToken, workspacePersonalInviteToken: state.workspacePersonalInviteToken,
targetWorkspaceSubdomain: state.workspaceSubdomain, workspaceId: state.workspaceId,
billingCheckoutSessionState: state.billingCheckoutSessionState, billingCheckoutSessionState: state.billingCheckoutSessionState,
}; };

View File

@ -21,7 +21,7 @@ export type MicrosoftRequest = Omit<
picture: string | null; picture: string | null;
workspaceInviteHash?: string; workspaceInviteHash?: string;
workspacePersonalInviteToken?: string; workspacePersonalInviteToken?: string;
targetWorkspaceSubdomain?: string; workspaceId?: string;
billingCheckoutSessionState?: string; billingCheckoutSessionState?: string;
}; };
}; };
@ -43,7 +43,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
...options, ...options,
state: JSON.stringify({ state: JSON.stringify({
workspaceInviteHash: req.params.workspaceInviteHash, workspaceInviteHash: req.params.workspaceInviteHash,
workspaceSubdomain: req.params.workspaceSubdomain, workspaceId: req.params.workspaceId,
...(req.params.billingCheckoutSessionState ...(req.params.billingCheckoutSessionState
? { ? {
billingCheckoutSessionState: billingCheckoutSessionState:
@ -92,7 +92,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
picture: photos?.[0]?.value, picture: photos?.[0]?.value,
workspaceInviteHash: state.workspaceInviteHash, workspaceInviteHash: state.workspaceInviteHash,
workspacePersonalInviteToken: state.workspacePersonalInviteToken, workspacePersonalInviteToken: state.workspacePersonalInviteToken,
targetWorkspaceSubdomain: state.workspaceSubdomain, workspaceId: state.workspaceId,
billingCheckoutSessionState: state.billingCheckoutSessionState, billingCheckoutSessionState: state.billingCheckoutSessionState,
}; };

View File

@ -0,0 +1,51 @@
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
export type SignInUpBaseParams = {
invitation?: AppToken;
workspace?: Workspace | null;
billingCheckoutSessionState?: string | null;
};
export type SignInUpNewUserPayload = {
email: string;
firstName?: string | null;
lastName?: string | null;
picture?: string | null;
passwordHash?: string | null;
};
export type PartialUserWithPicture = {
picture?: string;
} & Partial<User>;
export type ExistingUserOrNewUser = {
userData:
| { type: 'existingUser'; existingUser: User }
| {
type: 'newUser';
newUserPayload: SignInUpNewUserPayload;
};
};
export type ExistingUserOrPartialUserWithPicture = {
userData:
| { type: 'existingUser'; existingUser: User }
| {
type: 'newUserWithPicture';
newUserWithPicture: PartialUserWithPicture;
};
};
export type AuthProviderWithPasswordType = {
authParams:
| {
provider: Extract<WorkspaceAuthProvider, 'password'>;
password: string;
}
| {
provider: Exclude<WorkspaceAuthProvider, 'password'>;
};
};

View File

@ -129,15 +129,16 @@ export class DomainManagerService {
return subdomain === this.environmentService.get('DEFAULT_SUBDOMAIN'); return subdomain === this.environmentService.get('DEFAULT_SUBDOMAIN');
} }
computeRedirectErrorUrl({ computeRedirectErrorUrl(
errorMessage, errorMessage: string,
subdomain, {
}: {
errorMessage: string;
subdomain?: string;
}) {
const url = this.buildWorkspaceURL({
subdomain, subdomain,
}: {
subdomain?: string;
},
) {
const url = this.buildWorkspaceURL({
subdomain: subdomain ?? this.environmentService.get('DEFAULT_SUBDOMAIN'),
pathname: '/verify', pathname: '/verify',
searchParams: { errorMessage }, searchParams: { errorMessage },
}); });
@ -193,10 +194,12 @@ export class DomainManagerService {
if (!isDefined(subdomain)) return; if (!isDefined(subdomain)) return;
return await this.workspaceRepository.findOne({ return (
where: { subdomain }, (await this.workspaceRepository.findOne({
relations: ['workspaceSSOIdentityProviders'], where: { subdomain },
}); relations: ['workspaceSSOIdentityProviders'],
})) ?? undefined
);
} catch (e) { } catch (e) {
throw new WorkspaceException( throw new WorkspaceException(
'Workspace not found', 'Workspace not found',

View File

@ -1 +1 @@
export type WorkspaceAuthProvider = 'google' | 'microsoft' | 'password'; export type WorkspaceAuthProvider = 'google' | 'microsoft' | 'password' | 'sso';

View File

@ -46,6 +46,7 @@ const isAuthEnabledOrThrow = (
if (provider === 'google' && workspace.isGoogleAuthEnabled) return true; if (provider === 'google' && workspace.isGoogleAuthEnabled) return true;
if (provider === 'microsoft' && workspace.isMicrosoftAuthEnabled) return true; if (provider === 'microsoft' && workspace.isMicrosoftAuthEnabled) return true;
if (provider === 'password' && workspace.isPasswordAuthEnabled) return true; if (provider === 'password' && workspace.isPasswordAuthEnabled) return true;
if (provider === 'sso') return true;
throw exceptionToThrowCustom; throw exceptionToThrowCustom;
}; };