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 * 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] };
@ -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']>;
};
@ -1977,6 +1978,7 @@ export type SignUpMutationVariables = Exact<{
workspaceInviteHash?: InputMaybe<Scalars['String']>;
workspacePersonalInviteToken?: 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 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
@ -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'
* },
* });
*/

View File

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

View File

@ -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';
@ -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);
@ -82,7 +82,8 @@ export const useAuth = () => {
const { isOnAWorkspaceSubdomain } =
useIsCurrentLocationOnAWorkspaceSubdomain();
const { workspaceSubdomain } = useReadWorkspaceSubdomainFromCurrentLocation();
const workspacePublicData = useRecoilValue(workspacePublicDataState);
const { setLastAuthenticateWorkspaceDomain } =
useLastAuthenticatedWorkspaceDomain();
@ -328,6 +329,9 @@ export const useAuth = () => {
workspaceInviteHash,
workspacePersonalInviteToken,
captchaToken,
...(workspacePublicData?.id
? { workspaceId: workspacePublicData.id }
: {}),
},
});
@ -354,6 +358,7 @@ export const useAuth = () => {
[
setIsVerifyPendingState,
signUp,
workspacePublicData,
isMultiWorkspaceEnabled,
handleVerify,
redirectToWorkspaceDomain,
@ -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(

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 { 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';
@ -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,
],

View File

@ -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';
@ -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,
@ -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,
@ -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(

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 { 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)
@ -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()
@ -49,57 +47,61 @@ 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,
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,
} = 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(
user.email,
@ -107,20 +109,17 @@ export class GoogleAuthController {
);
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,
}),
);
}

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 { 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/microsoft')
@UseFilters(AuthRestApiExceptionFilter)
@ -29,9 +28,8 @@ export class MicrosoftAuthController {
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()
@ -47,38 +45,60 @@ export class MicrosoftAuthController {
@Req() req: MicrosoftRequest,
@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 {
const signInUpParams = req.user;
const invitation = await this.authService.findInvitationForSignInUp({
currentWorkspace,
workspacePersonalInviteToken,
email,
});
if (
this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') &&
(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'],
});
const existingUser = await this.userRepository.findOne({
where: { email },
});
if (workspaceWithGoogleAuthActive) {
signInUpParams.targetWorkspaceSubdomain =
workspaceWithGoogleAuthActive.subdomain;
}
}
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({
...signInUpParams,
fromSSO: true,
authProvider: 'microsoft',
invitation,
workspace: currentWorkspace,
userData,
authParams: {
provider: 'microsoft',
},
billingCheckoutSessionState,
});
const loginToken = await this.loginTokenService.generateLoginToken(
@ -87,20 +107,18 @@ export class MicrosoftAuthController {
);
return res.redirect(
this.authService.computeRedirectURI(
loginToken.token,
workspace.subdomain,
signInUpParams.billingCheckoutSessionState,
),
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,
}),
);
}

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import bcrypt from 'bcrypt';
import { expect, jest } from '@jest/globals';
import { Repository } from 'typeorm';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.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 { 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 { SocialSsoService } from 'src/engine/core-modules/auth/services/social-sso.service';
import { AuthService } from './auth.service';
@ -31,6 +33,7 @@ const userWorkspaceAddUserToWorkspaceMock = jest.fn();
describe('AuthService', () => {
let service: AuthService;
let appTokenRepository: Repository<AppToken>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@ -48,7 +51,14 @@ describe('AuthService', () => {
},
{
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,
@ -94,10 +104,18 @@ describe('AuthService', () => {
validateInvitation: workspaceInvitationValidateInvitationMock,
},
},
{
provide: SocialSsoService,
useValue: {},
},
],
}).compile();
service = module.get<AuthService>(AuthService);
appTokenRepository = module.get<Repository<AppToken>>(
getRepositoryToken(AppToken, 'core'),
);
});
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 { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type';
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()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
@ -57,6 +66,8 @@ export class AuthService {
private readonly refreshTokenService: RefreshTokenService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly workspaceInvitationService: WorkspaceInvitationService,
private readonly socialSsoService: SocialSsoService,
private readonly userService: UserService,
private readonly signInUpService: SignInUpService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@ -149,41 +160,58 @@ export class AuthService {
return user;
}
async signInUp({
email,
password,
workspaceInviteHash,
workspacePersonalInviteToken,
targetWorkspaceSubdomain,
firstName,
lastName,
picture,
fromSSO,
authProvider,
}: {
email: string;
password?: string;
firstName?: string | null;
lastName?: string | null;
workspaceInviteHash?: string;
workspacePersonalInviteToken?: string;
picture?: string | null;
fromSSO: boolean;
targetWorkspaceSubdomain?: string;
authProvider?: WorkspaceAuthProvider;
billingCheckoutSessionState?: string;
}) {
async signInUp(
params: SignInUpBaseParams &
ExistingUserOrNewUser &
AuthProviderWithPasswordType,
) {
if (
params.authParams.provider === 'password' &&
params.userData.type === 'newUser'
) {
params.userData.newUserPayload.passwordHash =
await this.signInUpService.generateHash(params.authParams.password);
}
if (
params.authParams.provider === 'password' &&
params.userData.type === 'existingUser'
) {
await this.signInUpService.validatePassword({
password: params.authParams.password,
passwordHash: params.userData.existingUser.passwordHash,
});
}
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({
email,
password,
firstName,
lastName,
workspaceInviteHash,
workspacePersonalInviteToken,
targetWorkspaceSubdomain,
picture,
fromSSO,
authProvider,
...params,
userData: {
type: 'existingUser',
existingUser: params.userData.existingUser,
},
});
}
@ -414,11 +442,15 @@ export class AuthService {
return workspace;
}
computeRedirectURI(
loginToken: string,
subdomain?: string,
billingCheckoutSessionState?: string,
) {
computeRedirectURI({
loginToken,
subdomain,
billingCheckoutSessionState,
}: {
loginToken: string;
subdomain?: string;
billingCheckoutSessionState?: string;
}) {
const url = this.domainManagerService.buildWorkspaceURL({
subdomain,
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 { HttpService } from '@nestjs/axios';
import { getRepositoryToken } from '@nestjs/typeorm';
import { expect, jest } from '@jest/globals';
import bcrypt from 'bcrypt';
import { Repository } from 'typeorm';
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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.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 { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
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 { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.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 {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
jest.mock('bcrypt');
const UserFindOneMock = jest.fn();
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();
jest.mock('src/utils/image', () => {
return {
getImageBufferFromUrl: () => Promise.resolve(Buffer.from('')),
};
});
describe('SignInUpService', () => {
let service: SignInUpService;
afterEach(() => {
jest.clearAllMocks();
});
let UserRepository: Repository<User>;
let WorkspaceRepository: Repository<Workspace>;
let fileUploadService: FileUploadService;
let workspaceInvitationService: WorkspaceInvitationService;
let userWorkspaceService: UserWorkspaceService;
let onboardingService: OnboardingService;
let httpService: HttpService;
let environmentService: EnvironmentService;
let domainManagerService: DomainManagerService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SignInUpService,
{
provide: FileUploadService,
useValue: {},
provide: getRepositoryToken(User, 'core'),
useValue: {
create: jest.fn(),
save: jest.fn(),
},
},
{
provide: getRepositoryToken(Workspace, 'core'),
useValue: {
count: WorkspaceCountMock,
create: WorkspaceCreateMock,
save: WorkspaceSaveMock,
findOne: WorkspaceFindOneMock,
save: jest.fn(),
create: jest.fn(),
get: jest.fn(),
count: jest.fn(),
},
},
{
provide: getRepositoryToken(User, 'core'),
provide: FileUploadService,
useValue: {
findOne: UserFindOneMock,
create: UserCreateMock,
save: UserSaveMock,
uploadImage: jest.fn(),
},
},
{
provide: getRepositoryToken(AppToken, 'core'),
useValue: {},
},
{
provide: WorkspaceInvitationService,
useValue: {},
},
{
provide: WorkspaceService,
useValue: {},
useValue: {
validateInvitation: jest.fn(),
invalidateWorkspaceInvitation: jest.fn(),
},
},
{
provide: UserWorkspaceService,
useValue: {
addUserToWorkspace: userWorkspaceServiceAddUserToWorkspaceMock,
addUserToWorkspace: jest.fn(),
create: jest.fn(),
},
},
@ -93,7 +86,6 @@ describe('SignInUpService', () => {
useValue: {
setOnboardingConnectAccountPending: jest.fn(),
setOnboardingInviteTeamPending: jest.fn(),
setOnboardingCreateProfilePending: jest.fn(),
},
},
{
@ -103,410 +95,150 @@ describe('SignInUpService', () => {
{
provide: EnvironmentService,
useValue: {
get: EnvironmentServiceGetMock,
},
},
{
provide: WorkspaceInvitationService,
useValue: {
validateInvitation: workspaceInvitationValidateInvitationMock,
invalidateWorkspaceInvitation:
workspaceInvitationInvalidateWorkspaceInvitationMock,
findInvitationByWorkspaceSubdomainAndUserEmail:
workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock,
get: jest.fn(),
},
},
{
provide: DomainManagerService,
useValue: {
generateSubdomain: jest.fn().mockReturnValue('testSubDomain'),
getWorkspaceBySubdomainOrDefaultWorkspace: jest
.fn()
.mockReturnValue({}),
},
},
{
provide: UserService,
useValue: {
hasUserAccessToWorkspaceOrThrow: jest.fn(),
generateSubdomain: jest.fn(),
},
},
],
}).compile();
service = module.get<SignInUpService>(SignInUpService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('signInUp - sso - new user', async () => {
const email = 'test@test.com';
UserFindOneMock.mockReturnValueOnce(false);
workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce(
undefined,
UserRepository = module.get(getRepositoryToken(User, 'core'));
WorkspaceRepository = module.get(getRepositoryToken(Workspace, 'core'));
fileUploadService = module.get<FileUploadService>(FileUploadService);
workspaceInvitationService = module.get<WorkspaceInvitationService>(
WorkspaceInvitationService,
);
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
.spyOn(service, 'signUpOnNewWorkspace')
.mockResolvedValueOnce({ user: {}, workspace: {} } as {
user: User;
workspace: Workspace;
it('should handle signInUp with valid personal invitation', async () => {
const params: SignInUpBaseParams &
ExistingUserOrPartialUserWithPicture &
AuthProviderWithPasswordType = {
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({
email: 'test@test.com',
fromSSO: true,
targetWorkspaceSubdomain: 'testSubDomain',
jest
.spyOn(workspaceInvitationService, 'invalidateWorkspaceInvitation')
.mockResolvedValue(undefined);
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(
workspaceInvitationInvalidateWorkspaceInvitationMock,
).toHaveBeenCalledWith(workspaceId, email);
workspaceInvitationService.invalidateWorkspaceInvitation,
).toHaveBeenCalledWith(
(params.workspace as Workspace).id,
'test@example.com',
);
});
it('signInUp - sso - existing user - existing invitation', async () => {
const email = 'existinguser@test.com';
const workspaceId = 'workspace-id';
const existingUser = {
id: 'user-id',
email,
passwordHash: undefined,
it('should handle signInUp on existing workspace without invitation', async () => {
const params: SignInUpBaseParams &
ExistingUserOrPartialUserWithPicture &
AuthProviderWithPasswordType = {
workspace: {
id: 'workspaceId',
activationStatus: WorkspaceActivationStatus.ACTIVE,
} as Workspace,
authParams: { provider: 'password', password: 'validPassword' },
userData: {
type: 'existingUser',
existingUser: { email: 'test@example.com' } as User,
},
};
const workspace = {
id: workspaceId,
jest
.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,
};
UserFindOneMock.mockReturnValueOnce(existingUser);
workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce(
{
value: 'personal-token-value',
},
);
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
isValid: true,
workspace,
} as Workspace);
jest.spyOn(fileUploadService, 'uploadImage').mockResolvedValue({
id: '',
mimeType: '',
paths: ['path/to/image'],
});
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(
true,
);
const result = await service.signInUp(params);
userWorkspaceServiceAddUserToWorkspaceMock.mockReturnValueOnce({});
const result = await service.signInUp({
email,
fromSSO: true,
targetWorkspaceSubdomain: 'testSubDomain',
});
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);
expect(result.workspace).toBeDefined();
expect(result.user).toBeDefined();
expect(WorkspaceRepository.create).toHaveBeenCalled();
expect(WorkspaceRepository.save).toHaveBeenCalled();
expect(UserRepository.create).toHaveBeenCalled();
expect(UserRepository.save).toHaveBeenCalled();
expect(fileUploadService.uploadImage).toHaveBeenCalled();
});
});

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 { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.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 { userValidator } from 'src/engine/core-modules/user/user.validate';
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 {
Workspace,
WorkspaceActivationStatus,
@ -35,21 +32,15 @@ import {
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
import { getImageBufferFromUrl } from 'src/utils/image';
import { isDefined } from 'src/utils/is-defined';
import { isWorkEmail } from 'src/utils/is-work-email';
export type SignInUpServiceInput = {
email: string;
password?: string;
firstName?: string | null;
lastName?: string | null;
workspaceInviteHash?: string;
workspacePersonalInviteToken?: string;
picture?: string | null;
fromSSO: boolean;
targetWorkspaceSubdomain?: string;
authProvider?: WorkspaceAuthProvider;
};
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import {
AuthProviderWithPasswordType,
ExistingUserOrPartialUserWithPicture,
PartialUserWithPicture,
SignInUpBaseParams,
SignInUpNewUserPayload,
} from 'src/engine/core-modules/auth/types/signInUp.type';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
@ -66,23 +57,112 @@ export class SignInUpService {
private readonly httpService: HttpService,
private readonly environmentService: EnvironmentService,
private readonly domainManagerService: DomainManagerService,
private readonly userService: UserService,
) {}
async signInUp({
email,
workspacePersonalInviteToken,
workspaceInviteHash,
async computeParamsForNewUser(
newUserParams: SignInUpNewUserPayload,
authParams: AuthProviderWithPasswordType['authParams'],
) {
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,
firstName,
lastName,
picture,
fromSSO,
targetWorkspaceSubdomain,
authProvider,
}: SignInUpServiceInput) {
if (!firstName) firstName = '';
if (!lastName) lastName = '';
passwordHash,
}: {
password: string;
passwordHash: string;
}) {
const isValid = await compareHash(
await this.generateHash(password),
passwordHash,
);
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) {
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 =
workspacePersonalInviteToken || workspaceInviteHash || maybeInvitation
? await this.workspaceInvitationService.validateInvitation({
workspacePersonalInviteToken:
workspacePersonalInviteToken ?? maybeInvitation?.value,
workspaceInviteHash,
email,
})
: null;
if (
invitationValidation?.isValid === true &&
invitationValidation.workspace
) {
const updatedUser = await this.signInUpOnExistingWorkspace({
await this.workspaceInvitationService.validateInvitation({
workspacePersonalInviteToken: params.invitation.value,
email,
passwordHash,
workspace: invitationValidation.workspace,
firstName,
lastName,
picture,
existingUser,
authProvider,
});
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
invitationValidation.workspace.id,
email,
);
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',
if (!invitationValidation?.isValid) {
throw new AuthException(
'Invitation not found',
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(
workspace,
params.workspace,
new AuthException(
'Workspace is not ready to welcome new members',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
if (authProvider) {
workspaceValidator.isAuthEnabledOrThrow(authProvider, workspace);
}
if (isNewUser) {
const imagePath = await this.uploadPicture(picture, workspace.id);
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 currentUser =
params.userData.type === 'newUserWithPicture'
? await this.persistNewUser(
params.userData.newUserWithPicture,
params.workspace,
)
: params.userData.existingUser;
const updatedUser = await this.userWorkspaceService.addUserToWorkspace(
user,
workspace,
currentUser,
params.workspace,
);
await this.activateOnboardingForUser(user, workspace, {
firstName,
lastName,
});
const user = Object.assign(currentUser, updatedUser);
return Object.assign(user, updatedUser);
await this.activateOnboardingForUser(user, params.workspace);
return user;
}
private async activateOnboardingForUser(
user: User,
workspace: Workspace,
{ firstName, lastName }: { firstName: string; lastName: string },
) {
private async activateOnboardingForUser(user: User, workspace: Workspace) {
await this.onboardingService.setOnboardingConnectAccountPending({
userId: user.id,
workspaceId: workspace.id,
value: true,
});
if (firstName === '' && lastName === '') {
if (user.firstName === '' && user.lastName === '') {
await this.onboardingService.setOnboardingCreateProfilePending({
userId: user.id,
workspaceId: workspace.id,
@ -357,27 +263,19 @@ export class SignInUpService {
}
}
async signUpOnNewWorkspace({
email,
passwordHash,
firstName,
lastName,
picture,
}: {
email: string;
passwordHash: string | undefined;
firstName: string;
lastName: string;
picture: SignInUpServiceInput['picture'];
}) {
const user: Partial<User> = {
email,
firstName,
lastName,
async signUpOnNewWorkspace(partialUserWithPicture: PartialUserWithPicture) {
const user: PartialUserWithPicture = {
...partialUserWithPicture,
canImpersonate: false,
passwordHash,
};
if (!user.email) {
throw new AuthException(
'Email is required',
AuthExceptionCode.INVALID_INPUT,
);
}
if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) {
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 () => {
try {
return (
@ -406,7 +304,7 @@ export class SignInUpService {
};
const logo =
isWorkEmail(email) && (await isLogoUrlValid()) ? logoUrl : undefined;
isWorkEmail(user.email) && (await isLogoUrlValid()) ? logoUrl : undefined;
const workspaceToCreate = this.workspaceRepository.create({
subdomain: await this.domainManagerService.generateSubdomain(),
@ -419,7 +317,10 @@ export class SignInUpService {
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);
@ -427,10 +328,7 @@ export class SignInUpService {
await this.userWorkspaceService.create(newUser.id, workspace.id);
await this.activateOnboardingForUser(newUser, workspace, {
firstName,
lastName,
});
await this.activateOnboardingForUser(newUser, workspace);
await this.onboardingService.setOnboardingInviteTeamPending({
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;
workspaceInviteHash?: string;
workspacePersonalInviteToken?: string;
targetWorkspaceSubdomain?: string;
workspaceId?: string;
billingCheckoutSessionState?: string;
};
};
@ -39,7 +39,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
...options,
state: JSON.stringify({
workspaceInviteHash: req.params.workspaceInviteHash,
workspaceSubdomain: req.params.workspaceSubdomain,
workspaceId: req.params.workspaceId,
...(req.params.billingCheckoutSessionState
? {
billingCheckoutSessionState:
@ -78,7 +78,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
picture: photos?.[0]?.value,
workspaceInviteHash: state.workspaceInviteHash,
workspacePersonalInviteToken: state.workspacePersonalInviteToken,
targetWorkspaceSubdomain: state.workspaceSubdomain,
workspaceId: state.workspaceId,
billingCheckoutSessionState: state.billingCheckoutSessionState,
};

View File

@ -21,7 +21,7 @@ export type MicrosoftRequest = Omit<
picture: string | null;
workspaceInviteHash?: string;
workspacePersonalInviteToken?: string;
targetWorkspaceSubdomain?: string;
workspaceId?: string;
billingCheckoutSessionState?: string;
};
};
@ -43,7 +43,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
...options,
state: JSON.stringify({
workspaceInviteHash: req.params.workspaceInviteHash,
workspaceSubdomain: req.params.workspaceSubdomain,
workspaceId: req.params.workspaceId,
...(req.params.billingCheckoutSessionState
? {
billingCheckoutSessionState:
@ -92,7 +92,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
picture: photos?.[0]?.value,
workspaceInviteHash: state.workspaceInviteHash,
workspacePersonalInviteToken: state.workspacePersonalInviteToken,
targetWorkspaceSubdomain: state.workspaceSubdomain,
workspaceId: state.workspaceId,
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');
}
computeRedirectErrorUrl({
errorMessage,
subdomain,
}: {
errorMessage: string;
subdomain?: string;
}) {
const url = this.buildWorkspaceURL({
computeRedirectErrorUrl(
errorMessage: string,
{
subdomain,
}: {
subdomain?: string;
},
) {
const url = this.buildWorkspaceURL({
subdomain: subdomain ?? this.environmentService.get('DEFAULT_SUBDOMAIN'),
pathname: '/verify',
searchParams: { errorMessage },
});
@ -193,10 +194,12 @@ export class DomainManagerService {
if (!isDefined(subdomain)) return;
return await this.workspaceRepository.findOne({
where: { subdomain },
relations: ['workspaceSSOIdentityProviders'],
});
return (
(await this.workspaceRepository.findOne({
where: { subdomain },
relations: ['workspaceSSOIdentityProviders'],
})) ?? undefined
);
} catch (e) {
throw new WorkspaceException(
'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 === 'microsoft' && workspace.isMicrosoftAuthEnabled) return true;
if (provider === 'password' && workspace.isPasswordAuthEnabled) return true;
if (provider === 'sso') return true;
throw exceptionToThrowCustom;
};