feat(*): allow to select auth providers + add multiworkspace with subdomain management (#8656)

## Summary
Add support for multi-workspace feature and adjust configurations and
states accordingly.
- Introduced new state isMultiWorkspaceEnabledState.
- Updated ClientConfigProviderEffect component to handle
multi-workspace.
- Modified GraphQL schema and queries to include multi-workspace related
configurations.
- Adjusted server environment variables and their respective
documentation to support multi-workspace toggle.
- Updated server-side logic to handle new multi-workspace configurations
and conditions.
This commit is contained in:
Antoine Moreaux
2024-12-03 19:06:28 +01:00
committed by GitHub
parent 9a65e80566
commit 7943141d03
167 changed files with 5180 additions and 1901 deletions

View File

@ -1,6 +1,6 @@
/* eslint-disable no-restricted-imports */
import { HttpModule } from '@nestjs/axios';
import { forwardRef, Module } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
@ -11,7 +11,6 @@ import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/g
import { MicrosoftAPIsAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-apis-auth.controller';
import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-auth.controller';
import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller';
import { VerifyAuthController } from 'src/engine/core-modules/auth/controllers/verify-auth.controller';
import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service';
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/microsoft-apis.service';
@ -24,7 +23,6 @@ import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
@ -36,13 +34,15 @@ import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/worksp
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { User } from 'src/engine/core-modules/user/user.entity';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
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 { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { AuthResolver } from './auth.resolver';
@ -54,7 +54,9 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
JwtModule,
FileUploadModule,
DataSourceModule,
forwardRef(() => UserModule),
DomainManagerModule,
TokenModule,
UserModule,
WorkspaceManagerModule,
TypeORMModule,
TypeOrmModule.forFeature(
@ -69,22 +71,20 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
'core',
),
HttpModule,
TokenModule,
UserWorkspaceModule,
WorkspaceModule,
OnboardingModule,
WorkspaceDataSourceModule,
WorkspaceInvitationModule,
ConnectedAccountModule,
WorkspaceSSOModule,
FeatureFlagModule,
WorkspaceInvitationModule,
],
controllers: [
GoogleAuthController,
MicrosoftAuthController,
GoogleAPIsAuthController,
MicrosoftAPIsAuthController,
VerifyAuthController,
SSOAuthController,
],
providers: [

View File

@ -7,6 +7,7 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { AuthResolver } from './auth.resolver';
@ -43,6 +44,14 @@ describe('AuthResolver', () => {
provide: UserService,
useValue: {},
},
{
provide: DomainManagerService,
useValue: {
buildWorkspaceURL: jest
.fn()
.mockResolvedValue(new URL('http://localhost:3001')),
},
},
{
provide: UserWorkspaceService,
useValue: {},

View File

@ -9,12 +9,6 @@ import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-p
import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.input';
import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity';
import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input';
import { GenerateJwtInput } from 'src/engine/core-modules/auth/dto/generate-jwt.input';
import {
GenerateJWTOutput,
GenerateJWTOutputWithAuthTokens,
GenerateJWTOutputWithSSOAUTH,
} from 'src/engine/core-modules/auth/dto/generateJWT.output';
import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity';
import { TransientToken } from 'src/engine/core-modules/auth/dto/transient-token.entity';
import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input';
@ -36,12 +30,22 @@ import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { SwitchWorkspaceInput } from 'src/engine/core-modules/auth/dto/switch-workspace.input';
import { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.output';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { ChallengeInput } from './dto/challenge.input';
import { LoginToken } from './dto/login-token.entity';
import { SignUpInput } from './dto/sign-up.input';
import { ApiKeyToken, AuthTokens } from './dto/token.entity';
import { UserExists } from './dto/user-exists.entity';
import { UserExistsOutput } from './dto/user-exists.entity';
import { CheckUserExistsInput } from './dto/user-exists.input';
import { Verify } from './dto/verify.entity';
import { VerifyInput } from './dto/verify.input';
@ -62,18 +66,15 @@ export class AuthResolver {
private switchWorkspaceService: SwitchWorkspaceService,
private transientTokenService: TransientTokenService,
private oauthService: OAuthService,
private domainManagerService: DomainManagerService,
) {}
@UseGuards(CaptchaGuard)
@Query(() => UserExists)
@Query(() => UserExistsOutput)
async checkUserExists(
@Args() checkUserExistsInput: CheckUserExistsInput,
): Promise<UserExists> {
const { exists } = await this.authService.checkUserExists(
checkUserExistsInput.email,
);
return { exists };
): Promise<typeof UserExistsOutput> {
return await this.authService.checkUserExists(checkUserExistsInput.email);
}
@Query(() => WorkspaceInviteHashValid)
@ -96,8 +97,20 @@ export class AuthResolver {
@UseGuards(CaptchaGuard)
@Mutation(() => LoginToken)
async challenge(@Args() challengeInput: ChallengeInput): Promise<LoginToken> {
const user = await this.authService.challenge(challengeInput);
async challenge(
@Args() challengeInput: ChallengeInput,
@OriginHeader() origin: string,
): Promise<LoginToken> {
const workspace =
await this.domainManagerService.getWorkspaceByOrigin(origin);
if (!workspace) {
throw 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,
);
@ -107,10 +120,22 @@ export class AuthResolver {
@UseGuards(CaptchaGuard)
@Mutation(() => LoginToken)
async signUp(@Args() signUpInput: SignUpInput): Promise<LoginToken> {
async signUp(
@Args() signUpInput: SignUpInput,
@OriginHeader() origin: string,
): Promise<LoginToken> {
const user = await this.authService.signInUp({
...signUpInput,
targetWorkspaceSubdomain:
this.domainManagerService.getWorkspaceSubdomainByOrigin(origin),
fromSSO: false,
isAuthEnabled: workspaceValidator.isAuthEnabled(
'password',
new AuthException(
'Password auth is not enabled for this workspace',
AuthExceptionCode.OAUTH_ACCESS_DENIED,
),
),
});
const loginToken = await this.loginTokenService.generateLoginToken(
@ -124,11 +149,9 @@ export class AuthResolver {
async exchangeAuthorizationCode(
@Args() exchangeAuthCodeInput: ExchangeAuthCodeInput,
) {
const tokens = await this.oauthService.verifyAuthorizationCode(
return await this.oauthService.verifyAuthorizationCode(
exchangeAuthCodeInput,
);
return tokens;
}
@Mutation(() => TransientToken)
@ -156,14 +179,18 @@ export class AuthResolver {
}
@Mutation(() => Verify)
async verify(@Args() verifyInput: VerifyInput): Promise<Verify> {
const email = await this.loginTokenService.verifyLoginToken(
async verify(
@Args() verifyInput: VerifyInput,
@OriginHeader() origin: string,
): Promise<Verify> {
const workspace =
await this.domainManagerService.getWorkspaceByOrigin(origin);
const { sub: email } = await this.loginTokenService.verifyLoginToken(
verifyInput.loginToken,
);
const result = await this.authService.verify(email);
return result;
return await this.authService.verify(email, workspace?.id);
}
@Mutation(() => AuthorizeApp)
@ -172,50 +199,22 @@ export class AuthResolver {
@Args() authorizeAppInput: AuthorizeAppInput,
@AuthUser() user: User,
): Promise<AuthorizeApp> {
const authorizedApp = await this.authService.generateAuthorizationCode(
return await this.authService.generateAuthorizationCode(
authorizeAppInput,
user,
);
return authorizedApp;
}
@Mutation(() => GenerateJWTOutput)
@Mutation(() => PublicWorkspaceDataOutput)
@UseGuards(WorkspaceAuthGuard, UserAuthGuard)
async generateJWT(
async switchWorkspace(
@AuthUser() user: User,
@Args() args: GenerateJwtInput,
): Promise<GenerateJWTOutputWithAuthTokens | GenerateJWTOutputWithSSOAUTH> {
const result = await this.switchWorkspaceService.switchWorkspace(
@Args() args: SwitchWorkspaceInput,
): Promise<PublicWorkspaceDataOutput> {
return await this.switchWorkspaceService.switchWorkspace(
user,
args.workspaceId,
);
if (result.useSSOAuth) {
return {
success: true,
reason: 'WORKSPACE_USE_SSO_AUTH',
availableSSOIDPs: result.availableSSOIdentityProviders.map(
(identityProvider) => ({
...identityProvider,
workspace: {
id: result.workspace.id,
displayName: result.workspace.displayName,
},
}),
),
};
}
return {
success: true,
reason: 'WORKSPACE_AVAILABLE_FOR_SWITCH',
authTokens:
await this.switchWorkspaceService.generateSwitchWorkspaceToken(
user,
result.workspace,
),
};
}
@Mutation(() => AuthTokens)
@ -278,4 +277,11 @@ export class AuthResolver {
args.passwordResetToken,
);
}
@Query(() => [AvailableWorkspaceOutput])
async findAvailableWorkspacesByEmail(
@Args('email') email: string,
): Promise<AvailableWorkspaceOutput[]> {
return this.authService.findAvailableWorkspacesByEmail(email);
}
}

View File

@ -6,8 +6,10 @@ import {
UseFilters,
UseGuards,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Response } from 'express';
import { Repository } from 'typeorm';
import {
AuthException,
@ -21,6 +23,8 @@ import { TransientTokenService } from 'src/engine/core-modules/auth/token/servic
import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
@Controller('auth/google-apis')
@UseFilters(AuthRestApiExceptionFilter)
@ -30,6 +34,9 @@ export class GoogleAPIsAuthController {
private readonly transientTokenService: TransientTokenService,
private readonly environmentService: EnvironmentService,
private readonly onboardingService: OnboardingService,
private readonly domainManagerService: DomainManagerService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {}
@Get()
@ -96,10 +103,24 @@ export class GoogleAPIsAuthController {
});
}
const workspace = await this.workspaceRepository.findOneBy({
id: workspaceId,
});
if (!workspace) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
return res.redirect(
`${this.environmentService.get('FRONT_BASE_URL')}${
redirectLocation || '/settings/accounts'
}`,
this.domainManagerService
.buildWorkspaceURL({
subdomain: workspace.subdomain,
pathname: redirectLocation || '/settings/accounts',
})
.toString(),
);
}
}

View File

@ -6,7 +6,9 @@ import {
UseFilters,
UseGuards,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Response } from 'express';
import { AuthOAuthExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-oauth-exception.filter';
@ -16,6 +18,14 @@ import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/
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 {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Controller('auth/google')
@UseFilters(AuthRestApiExceptionFilter)
@ -23,6 +33,10 @@ export class GoogleAuthController {
constructor(
private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService,
private readonly domainManagerService: DomainManagerService,
private readonly environmentService: EnvironmentService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {}
@Get()
@ -36,29 +50,81 @@ export class GoogleAuthController {
@UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard)
@UseFilters(AuthOAuthExceptionFilter)
async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) {
const {
firstName,
lastName,
email,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
} = req.user;
try {
const {
firstName,
lastName,
email,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
targetWorkspaceSubdomain,
} = req.user;
const user = await this.authService.signInUp({
email,
firstName,
lastName,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
fromSSO: true,
});
const signInUpParams = {
email,
firstName,
lastName,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
targetWorkspaceSubdomain,
fromSSO: true,
isAuthEnabled: workspaceValidator.isAuthEnabled(
'google',
new AuthException(
'Google auth is not enabled for this workspace',
AuthExceptionCode.OAUTH_ACCESS_DENIED,
),
),
};
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
);
if (
this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') &&
targetWorkspaceSubdomain ===
this.environmentService.get('DEFAULT_SUBDOMAIN')
) {
const workspaceWithGoogleAuthActive =
await this.workspaceRepository.findOne({
where: {
isGoogleAuthEnabled: true,
workspaceUsers: {
user: {
email,
},
},
},
relations: ['userWorkspaces', 'userWorkspaces.user'],
});
return res.redirect(this.authService.computeRedirectURI(loginToken.token));
if (workspaceWithGoogleAuthActive) {
signInUpParams.targetWorkspaceSubdomain =
workspaceWithGoogleAuthActive.subdomain;
}
}
const user = await this.authService.signInUp(signInUpParams);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
);
return res.redirect(
await this.authService.computeRedirectURI(
loginToken.token,
user.defaultWorkspace.subdomain,
),
);
} catch (err) {
if (err instanceof AuthException) {
return res.redirect(
this.domainManagerService.computeRedirectErrorUrl({
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
errorMessage: err.message,
}),
);
}
throw err;
}
}
}

View File

@ -6,8 +6,10 @@ import {
UseFilters,
UseGuards,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Response } from 'express';
import { Repository } from 'typeorm';
import {
AuthException,
@ -21,6 +23,9 @@ import { TransientTokenService } from 'src/engine/core-modules/auth/token/servic
import { MicrosoftAPIsRequest } from 'src/engine/core-modules/auth/types/microsoft-api-request.type';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
@Controller('auth/microsoft-apis')
@UseFilters(AuthRestApiExceptionFilter)
@ -29,7 +34,11 @@ export class MicrosoftAPIsAuthController {
private readonly microsoftAPIsService: MicrosoftAPIsService,
private readonly transientTokenService: TransientTokenService,
private readonly environmentService: EnvironmentService,
private readonly workspaceService: WorkspaceService,
private readonly domainManagerService: DomainManagerService,
private readonly onboardingService: OnboardingService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {}
@Get()
@ -96,10 +105,24 @@ export class MicrosoftAPIsAuthController {
});
}
const workspace = await this.workspaceRepository.findOneBy({
id: workspaceId,
});
if (!workspace) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
return res.redirect(
`${this.environmentService.get('FRONT_BASE_URL')}${
redirectLocation || '/settings/accounts'
}`,
this.domainManagerService
.buildWorkspaceURL({
subdomain: workspace.subdomain,
pathname: redirectLocation || '/settings/accounts',
})
.toString(),
);
}
}

View File

@ -15,6 +15,13 @@ import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guar
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 {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
@Controller('auth/microsoft')
@UseFilters(AuthRestApiExceptionFilter)
@ -22,6 +29,8 @@ export class MicrosoftAuthController {
constructor(
private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService,
private readonly domainManagerService: DomainManagerService,
private readonly environmentService: EnvironmentService,
) {}
@Get()
@ -37,29 +46,55 @@ export class MicrosoftAuthController {
@Req() req: MicrosoftRequest,
@Res() res: Response,
) {
const {
firstName,
lastName,
email,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
} = req.user;
try {
const {
firstName,
lastName,
email,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
targetWorkspaceSubdomain,
} = req.user;
const user = await this.authService.signInUp({
email,
firstName,
lastName,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
fromSSO: true,
});
const user = await this.authService.signInUp({
email,
firstName,
lastName,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
targetWorkspaceSubdomain,
fromSSO: true,
isAuthEnabled: workspaceValidator.isAuthEnabled(
'microsoft',
new AuthException(
'Microsoft auth is not enabled for this workspace',
AuthExceptionCode.OAUTH_ACCESS_DENIED,
),
),
});
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
);
return res.redirect(this.authService.computeRedirectURI(loginToken.token));
return res.redirect(
await this.authService.computeRedirectURI(
loginToken.token,
user.defaultWorkspace.subdomain,
),
);
} catch (err) {
if (err instanceof AuthException) {
return res.redirect(
this.domainManagerService.computeRedirectErrorUrl({
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
errorMessage: err.message,
}),
);
}
throw err;
}
}
}

View File

@ -25,14 +25,14 @@ import { SAMLAuthGuard } from 'src/engine/core-modules/auth/guards/saml-auth.gua
import { SSOProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/sso-provider-enabled.guard';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
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 { 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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Controller('auth')
@UseFilters(AuthRestApiExceptionFilter)
@ -40,9 +40,9 @@ export class SSOAuthController {
constructor(
private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService,
private readonly workspaceInvitationService: WorkspaceInvitationService,
private readonly environmentService: EnvironmentService,
private readonly domainManagerService: DomainManagerService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly environmentService: EnvironmentService,
private readonly ssoService: SSOService,
@InjectRepository(WorkspaceSSOIdentityProvider, 'core')
private readonly workspaceSSOIdentityProviderRepository: Repository<WorkspaceSSOIdentityProvider>,
@ -50,7 +50,7 @@ export class SSOAuthController {
@Get('saml/metadata/:identityProviderId')
@UseGuards(SSOProviderEnabledGuard)
async generateMetadata(@Req() req: any): Promise<string> {
async generateMetadata(@Req() req: any): Promise<string | void> {
return generateServiceProviderMetadata({
wantAssertionsSigned: false,
issuer: this.ssoService.buildIssuerURL({
@ -81,14 +81,26 @@ export class SSOAuthController {
@UseGuards(SSOProviderEnabledGuard, OIDCAuthGuard)
async oidcAuthCallback(@Req() req: any, @Res() res: Response) {
try {
const loginToken = await this.generateLoginToken(req.user);
const { loginToken, identityProvider } = await this.generateLoginToken(
req.user,
);
return res.redirect(
this.authService.computeRedirectURI(loginToken.token),
await this.authService.computeRedirectURI(
loginToken.token,
identityProvider.workspace.subdomain,
),
);
} catch (err) {
// TODO: improve error management
res.status(403).send(err.message);
if (err instanceof AuthException) {
return res.redirect(
this.domainManagerService.computeRedirectErrorUrl({
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
errorMessage: err.message,
}),
);
}
throw err;
}
}
@ -96,16 +108,26 @@ export class SSOAuthController {
@UseGuards(SSOProviderEnabledGuard, SAMLAuthGuard)
async samlAuthCallback(@Req() req: any, @Res() res: Response) {
try {
const loginToken = await this.generateLoginToken(req.user);
const { loginToken, identityProvider } = await this.generateLoginToken(
req.user,
);
return res.redirect(
this.authService.computeRedirectURI(loginToken.token),
await this.authService.computeRedirectURI(
loginToken.token,
identityProvider.workspace.subdomain,
),
);
} catch (err) {
// TODO: improve error management
res
.status(403)
.redirect(`${this.environmentService.get('FRONT_BASE_URL')}/verify`);
if (err instanceof AuthException) {
return res.redirect(
this.domainManagerService.computeRedirectErrorUrl({
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
errorMessage: err.message,
}),
);
}
throw err;
}
}
@ -116,6 +138,13 @@ export class SSOAuthController {
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 },
@ -129,20 +158,15 @@ export class SSOAuthController {
);
}
const invitation =
await this.workspaceInvitationService.getOneWorkspaceInvitation(
identityProvider.workspaceId,
user.email,
);
if (invitation) {
await this.authService.signInUp({
...user,
workspacePersonalInviteToken: invitation.value,
workspaceInviteHash: identityProvider.workspace.inviteHash,
fromSSO: true,
});
}
await this.authService.signInUp({
...user,
...(this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')
? {
targetWorkspaceSubdomain: identityProvider.workspace.subdomain,
}
: {}),
fromSSO: true,
});
const isUserExistInWorkspace =
await this.userWorkspaceService.checkUserWorkspaceExistsByEmail(
@ -157,6 +181,9 @@ export class SSOAuthController {
);
}
return this.loginTokenService.generateLoginToken(user.email);
return {
identityProvider,
loginToken: await this.loginTokenService.generateLoginToken(user.email),
};
}
}

View File

@ -1,32 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { VerifyAuthController } from './verify-auth.controller';
describe('VerifyAuthController', () => {
let controller: VerifyAuthController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [VerifyAuthController],
providers: [
{
provide: AuthService,
useValue: {},
},
{
provide: LoginTokenService,
useValue: {},
},
],
}).compile();
controller = module.get<VerifyAuthController>(VerifyAuthController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -1,26 +0,0 @@
import { Body, Controller, Post, UseFilters } from '@nestjs/common';
import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity';
import { VerifyInput } from 'src/engine/core-modules/auth/dto/verify.input';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
@Controller('auth/verify')
@UseFilters(AuthRestApiExceptionFilter)
export class VerifyAuthController {
constructor(
private readonly authService: AuthService,
private readonly loginTokenService: LoginTokenService,
) {}
@Post()
async verify(@Body() verifyInput: VerifyInput): Promise<Verify> {
const email = await this.loginTokenService.verifyLoginToken(
verifyInput.loginToken,
);
const result = await this.authService.verify(email);
return result;
}
}

View File

@ -0,0 +1,45 @@
/* @license Enterprise */
import { Field, ObjectType } from '@nestjs/graphql';
import { SSOConfiguration } from 'src/engine/core-modules/sso/types/SSOConfigurations.type';
import {
IdentityProviderType,
SSOIdentityProviderStatus,
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
@ObjectType()
class SSOConnection {
@Field(() => IdentityProviderType)
type: SSOConfiguration['type'];
@Field(() => String)
id: string;
@Field(() => String)
issuer: string;
@Field(() => String)
name: string;
@Field(() => SSOIdentityProviderStatus)
status: SSOConfiguration['status'];
}
@ObjectType()
export class AvailableWorkspaceOutput {
@Field(() => String)
id: string;
@Field(() => String, { nullable: true })
displayName?: string;
@Field(() => String)
subdomain: string;
@Field(() => String, { nullable: true })
logo?: string;
@Field(() => [SSOConnection])
sso: SSOConnection[];
}

View File

@ -1,43 +0,0 @@
import { Field, ObjectType, createUnionType } from '@nestjs/graphql';
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output';
@ObjectType()
export class GenerateJWTOutputWithAuthTokens {
@Field(() => Boolean)
success: boolean;
@Field(() => String)
reason: 'WORKSPACE_AVAILABLE_FOR_SWITCH';
@Field(() => AuthTokens)
authTokens: AuthTokens;
}
@ObjectType()
export class GenerateJWTOutputWithSSOAUTH {
@Field(() => Boolean)
success: boolean;
@Field(() => String)
reason: 'WORKSPACE_USE_SSO_AUTH';
@Field(() => [FindAvailableSSOIDPOutput])
availableSSOIDPs: Array<FindAvailableSSOIDPOutput>;
}
export const GenerateJWTOutput = createUnionType({
name: 'GenerateJWT',
types: () => [GenerateJWTOutputWithAuthTokens, GenerateJWTOutputWithSSOAUTH],
resolveType(value) {
if (value.reason === 'WORKSPACE_AVAILABLE_FOR_SWITCH') {
return GenerateJWTOutputWithAuthTokens;
}
if (value.reason === 'WORKSPACE_USE_SSO_AUTH') {
return GenerateJWTOutputWithSSOAUTH;
}
return null;
},
});

View File

@ -3,7 +3,7 @@ import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@ArgsType()
export class GenerateJwtInput {
export class SwitchWorkspaceInput {
@Field(() => String)
@IsNotEmpty()
@IsString()

View File

@ -1,7 +1,30 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Field, ObjectType, createUnionType } from '@nestjs/graphql';
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
@ObjectType()
export class UserExists {
@Field(() => Boolean)
exists: boolean;
exists: true;
@Field(() => [AvailableWorkspaceOutput])
availableWorkspaces: Array<AvailableWorkspaceOutput>;
}
@ObjectType()
export class UserNotExists {
@Field(() => Boolean)
exists: false;
}
export const UserExistsOutput = createUnionType({
name: 'UserExistsOutput',
types: () => [UserExists, UserNotExists] as const,
resolveType(value) {
if (value.exists === true) {
return UserExists;
}
return UserNotExists;
},
});

View File

@ -11,11 +11,11 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
@Catch(AuthException)
export class AuthOAuthExceptionFilter implements ExceptionFilter {
constructor(private readonly environmentService: EnvironmentService) {}
constructor(private readonly domainManagerService: DomainManagerService) {}
catch(exception: AuthException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
@ -25,7 +25,7 @@ export class AuthOAuthExceptionFilter implements ExceptionFilter {
case AuthExceptionCode.OAUTH_ACCESS_DENIED:
response
.status(403)
.redirect(this.environmentService.get('FRONT_BASE_URL'));
.redirect(this.domainManagerService.getBaseUrl().toString());
break;
default:
throw new InternalServerErrorException(exception.message);

View File

@ -38,6 +38,13 @@ export class GoogleOauthGuard extends AuthGuard('google') {
workspacePersonalInviteToken;
}
if (
request.query.workspaceSubdomain &&
typeof request.query.workspaceSubdomain === 'string'
) {
request.params.workspaceSubdomain = request.query.workspaceSubdomain;
}
return (await super.canActivate(context)) as boolean;
}
}

View File

@ -26,6 +26,13 @@ export class MicrosoftOAuthGuard extends AuthGuard('microsoft') {
workspacePersonalInviteToken;
}
if (
request.query.workspaceSubdomain &&
typeof request.query.workspaceSubdomain === 'string'
) {
request.params.workspaceSubdomain = request.query.workspaceSubdomain;
}
return (await super.canActivate(context)) as boolean;
}
}

View File

@ -1,17 +1,34 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import bcrypt from 'bcrypt';
import { expect, jest } from '@jest/globals';
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 { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { EmailService } from 'src/engine/core-modules/email/email.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';
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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { 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 { AuthService } from './auth.service';
jest.mock('bcrypt');
const UserFindOneMock = jest.fn();
const UserWorkspaceFindOneByMock = jest.fn();
const userWorkspaceServiceCheckUserWorkspaceExistsMock = jest.fn();
const workspaceInvitationGetOneWorkspaceInvitationMock = jest.fn();
const workspaceInvitationValidateInvitationMock = jest.fn();
const userWorkspaceAddUserToWorkspaceMock = jest.fn();
describe('AuthService', () => {
let service: AuthService;
@ -25,7 +42,9 @@ describe('AuthService', () => {
},
{
provide: getRepositoryToken(User, 'core'),
useValue: {},
useValue: {
findOne: UserFindOneMock,
},
},
{
provide: getRepositoryToken(AppToken, 'core'),
@ -39,6 +58,10 @@ describe('AuthService', () => {
provide: EnvironmentService,
useValue: {},
},
{
provide: DomainManagerService,
useValue: {},
},
{
provide: EmailService,
useValue: {},
@ -51,13 +74,114 @@ describe('AuthService', () => {
provide: RefreshTokenService,
useValue: {},
},
{
provide: UserWorkspaceService,
useValue: {
checkUserWorkspaceExists:
userWorkspaceServiceCheckUserWorkspaceExistsMock,
addUserToWorkspace: userWorkspaceAddUserToWorkspaceMock,
},
},
{
provide: UserService,
useValue: {},
},
{
provide: WorkspaceInvitationService,
useValue: {
getOneWorkspaceInvitation:
workspaceInvitationGetOneWorkspaceInvitationMock,
validateInvitation: workspaceInvitationValidateInvitationMock,
},
},
],
}).compile();
service = module.get<AuthService>(AuthService);
});
it('should be defined', () => {
it('should be defined', async () => {
expect(service).toBeDefined();
});
it('challenge - user already member of workspace', async () => {
const workspace = { isPasswordAuthEnabled: true } as Workspace;
const user = {
email: 'email',
password: 'password',
captchaToken: 'captchaToken',
};
(bcrypt.compare as jest.Mock).mockReturnValueOnce(true);
UserFindOneMock.mockReturnValueOnce({
email: user.email,
passwordHash: 'passwordHash',
captchaToken: user.captchaToken,
});
UserWorkspaceFindOneByMock.mockReturnValueOnce({});
userWorkspaceServiceCheckUserWorkspaceExistsMock.mockReturnValueOnce({});
const response = await service.challenge(
{
email: 'email',
password: 'password',
captchaToken: 'captchaToken',
},
workspace,
);
expect(response).toStrictEqual({
email: user.email,
passwordHash: 'passwordHash',
captchaToken: user.captchaToken,
});
});
it('challenge - user who have an invitation', async () => {
const user = {
email: 'email',
password: 'password',
captchaToken: 'captchaToken',
};
UserFindOneMock.mockReturnValueOnce({
email: user.email,
passwordHash: 'passwordHash',
captchaToken: user.captchaToken,
});
(bcrypt.compare as jest.Mock).mockReturnValueOnce(true);
userWorkspaceServiceCheckUserWorkspaceExistsMock.mockReturnValueOnce(false);
workspaceInvitationGetOneWorkspaceInvitationMock.mockReturnValueOnce({});
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({});
userWorkspaceAddUserToWorkspaceMock.mockReturnValueOnce({});
const response = await service.challenge(
{
email: 'email',
password: 'password',
captchaToken: 'captchaToken',
},
{
isPasswordAuthEnabled: true,
} as Workspace,
);
expect(response).toStrictEqual({
email: user.email,
passwordHash: 'passwordHash',
captchaToken: user.captchaToken,
});
expect(
workspaceInvitationGetOneWorkspaceInvitationMock,
).toHaveBeenCalledTimes(1);
expect(workspaceInvitationValidateInvitationMock).toHaveBeenCalledTimes(1);
expect(userWorkspaceAddUserToWorkspaceMock).toHaveBeenCalledTimes(1);
expect(UserFindOneMock).toHaveBeenCalledTimes(1);
});
});

View File

@ -28,7 +28,10 @@ import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.ent
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
import { ChallengeInput } from 'src/engine/core-modules/auth/dto/challenge.input';
import { UpdatePassword } from 'src/engine/core-modules/auth/dto/update-password.entity';
import { UserExists } from 'src/engine/core-modules/auth/dto/user-exists.entity';
import {
UserExists,
UserNotExists,
} from 'src/engine/core-modules/auth/dto/user-exists.entity';
import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity';
import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
@ -38,12 +41,24 @@ import { EmailService } from 'src/engine/core-modules/email/email.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';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class AuthService {
constructor(
private readonly accessTokenService: AccessTokenService,
private readonly domainManagerService: DomainManagerService,
private readonly refreshTokenService: RefreshTokenService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly userService: UserService,
private readonly workspaceInvitationService: WorkspaceInvitationService,
private readonly signInUpService: SignInUpService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@ -55,9 +70,54 @@ export class AuthService {
private readonly appTokenRepository: Repository<AppToken>,
) {}
async challenge(challengeInput: ChallengeInput) {
const user = await this.userRepository.findOneBy({
email: challengeInput.email,
private async checkAccessAndUseInvitationOrThrow(
workspace: Workspace,
user: User,
) {
if (
await this.userWorkspaceService.checkUserWorkspaceExists(
user.id,
workspace.id,
)
) {
return;
}
const invitation =
await this.workspaceInvitationService.getOneWorkspaceInvitation(
workspace.id,
user.email,
);
if (invitation) {
await this.workspaceInvitationService.validateInvitation({
workspacePersonalInviteToken: invitation.value,
email: user.email,
});
await this.userWorkspaceService.addUserToWorkspace(user, workspace);
return;
}
throw new AuthException(
"You're not member of this workspace.",
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
async challenge(challengeInput: ChallengeInput, targetWorkspace: Workspace) {
if (!targetWorkspace.isPasswordAuthEnabled) {
throw new AuthException(
'Email/Password auth is not enabled for this workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const user = await this.userRepository.findOne({
where: {
email: challengeInput.email,
},
relations: ['workspaces'],
});
if (!user) {
@ -67,6 +127,8 @@ export class AuthService {
);
}
await this.checkAccessAndUseInvitationOrThrow(targetWorkspace, user);
if (!user.passwordHash) {
throw new AuthException(
'Incorrect login method',
@ -94,19 +156,23 @@ export class AuthService {
password,
workspaceInviteHash,
workspacePersonalInviteToken,
targetWorkspaceSubdomain,
firstName,
lastName,
picture,
fromSSO,
isAuthEnabled,
}: {
email: string;
password?: string;
firstName?: string | null;
lastName?: string | null;
workspaceInviteHash?: string | null;
workspacePersonalInviteToken?: string | null;
workspaceInviteHash?: string;
workspacePersonalInviteToken?: string;
picture?: string | null;
fromSSO: boolean;
targetWorkspaceSubdomain?: string;
isAuthEnabled?: ReturnType<(typeof workspaceValidator)['isAuthEnabled']>;
}) {
return await this.signInUpService.signInUp({
email,
@ -115,12 +181,14 @@ export class AuthService {
lastName,
workspaceInviteHash,
workspacePersonalInviteToken,
targetWorkspaceSubdomain,
picture,
fromSSO,
isAuthEnabled,
});
}
async verify(email: string): Promise<Verify> {
async verify(email: string, workspaceId?: string): Promise<Verify> {
if (!email) {
throw new AuthException(
'Email is required',
@ -128,6 +196,26 @@ export class AuthService {
);
}
const userWithIdAndDefaultWorkspaceId = await this.userRepository.findOne({
select: ['defaultWorkspaceId', 'id'],
where: { email },
});
userValidator.assertIsExist(
userWithIdAndDefaultWorkspaceId,
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
);
if (
workspaceId &&
userWithIdAndDefaultWorkspaceId.defaultWorkspaceId !== workspaceId
) {
await this.userService.saveDefaultWorkspace(
userWithIdAndDefaultWorkspaceId.id,
workspaceId,
);
}
const user = await this.userRepository.findOne({
where: {
email,
@ -135,19 +223,10 @@ export class AuthService {
relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'],
});
if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.USER_NOT_FOUND,
);
}
if (!user.defaultWorkspace) {
throw new AuthException(
'User has no default workspace',
AuthExceptionCode.INVALID_DATA,
);
}
userValidator.assertIsExist(
user,
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
);
// passwordHash is hidden for security reasons
user.passwordHash = '';
@ -170,12 +249,19 @@ export class AuthService {
};
}
async checkUserExists(email: string): Promise<UserExists> {
async checkUserExists(email: string): Promise<UserExists | UserNotExists> {
const user = await this.userRepository.findOneBy({
email,
});
return { exists: !!user };
if (userValidator.isExist(user)) {
return {
exists: true,
availableWorkspaces: await this.findAvailableWorkspacesByEmail(email),
};
}
return { exists: false };
}
async checkWorkspaceInviteHashIsValid(
@ -312,7 +398,7 @@ export class AuthService {
const emailTemplate = PasswordUpdateNotifyEmail({
userName: `${user.firstName} ${user.lastName}`,
email: user.email,
link: this.environmentService.get('FRONT_BASE_URL'),
link: this.domainManagerService.getBaseUrl().toString(),
});
const html = render(emailTemplate, {
@ -352,9 +438,55 @@ export class AuthService {
return workspace;
}
computeRedirectURI(loginToken: string): string {
return `${this.environmentService.get(
'FRONT_BASE_URL',
)}/verify?loginToken=${loginToken}`;
async computeRedirectURI(loginToken: string, subdomain?: string) {
const url = this.domainManagerService.buildWorkspaceURL({
subdomain,
pathname: '/verify',
searchParams: { loginToken },
});
return url.toString();
}
async findAvailableWorkspacesByEmail(email: string) {
const user = await this.userRepository.findOne({
where: {
email,
},
relations: [
'workspaces',
'workspaces.workspace',
'workspaces.workspace.workspaceSSOIdentityProviders',
],
});
userValidator.assertIsExist(
user,
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
);
return user.workspaces.map<AvailableWorkspaceOutput>((userWorkspace) => ({
id: userWorkspace.workspaceId,
displayName: userWorkspace.workspace.displayName,
subdomain: userWorkspace.workspace.subdomain,
logo: userWorkspace.workspace.logo,
sso: userWorkspace.workspace.workspaceSSOIdentityProviders.reduce(
(acc, identityProvider) =>
acc.concat(
identityProvider.status === 'Inactive'
? []
: [
{
id: identityProvider.id,
name: identityProvider.name,
issuer: identityProvider.issuer,
type: identityProvider.type,
status: identityProvider.status,
},
],
),
[] as AvailableWorkspaceOutput['sso'],
),
}));
}
}

View File

@ -13,6 +13,7 @@ import { EmailService } from 'src/engine/core-modules/email/email.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';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { ResetPasswordService } from './reset-password.service';
@ -45,6 +46,14 @@ describe('ResetPasswordService', () => {
send: jest.fn().mockResolvedValue({ success: true }),
},
},
{
provide: DomainManagerService,
useValue: {
getBaseUrl: jest
.fn()
.mockResolvedValue(new URL('http://localhost:3001')),
},
},
{
provide: EnvironmentService,
useValue: {

View File

@ -24,11 +24,13 @@ import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/val
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
@Injectable()
export class ResetPasswordService {
constructor(
private readonly environmentService: EnvironmentService,
private readonly domainManagerService: DomainManagerService,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@InjectRepository(AppToken, 'core')
@ -116,11 +118,12 @@ export class ResetPasswordService {
);
}
const frontBaseURL = this.environmentService.get('FRONT_BASE_URL');
const resetLink = `${frontBaseURL}/reset-password/${resetToken.passwordResetToken}`;
const frontBaseURL = this.domainManagerService.getBaseUrl();
frontBaseURL.pathname = `/reset-password/${resetToken.passwordResetToken}`;
const emailData = {
link: resetLink,
link: frontBaseURL.toString(),
duration: ms(
differenceInMilliseconds(
resetToken.passwordResetTokenExpiresAt,

View File

@ -2,18 +2,45 @@ import { HttpService } from '@nestjs/axios';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import bcrypt from 'bcrypt';
import { expect, jest } from '@jest/globals';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.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 { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
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();
describe('SignInUpService', () => {
let service: SignInUpService;
afterEach(() => {
jest.clearAllMocks();
});
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
@ -24,23 +51,48 @@ describe('SignInUpService', () => {
},
{
provide: getRepositoryToken(Workspace, 'core'),
useValue: {},
useValue: {
count: WorkspaceCountMock,
create: WorkspaceCreateMock,
save: WorkspaceSaveMock,
},
},
{
provide: getRepositoryToken(User, 'core'),
useValue: {},
useValue: {
findOne: UserFindOneMock,
create: UserCreateMock,
save: UserSaveMock,
},
},
{
provide: getRepositoryToken(AppToken, 'core'),
useValue: {},
},
{
provide: UserWorkspaceService,
provide: WorkspaceInvitationService,
useValue: {},
},
{
provide: WorkspaceService,
useValue: {
generateSubdomain: jest.fn().mockReturnValue('tartanpion'),
},
},
{
provide: UserWorkspaceService,
useValue: {
addUserToWorkspace: userWorkspaceServiceAddUserToWorkspaceMock,
create: jest.fn(),
},
},
{
provide: OnboardingService,
useValue: {},
useValue: {
setOnboardingConnectAccountPending: jest.fn(),
setOnboardingInviteTeamPending: jest.fn(),
setOnboardingCreateProfilePending: jest.fn(),
},
},
{
provide: HttpService,
@ -48,7 +100,19 @@ describe('SignInUpService', () => {
},
{
provide: EnvironmentService,
useValue: {},
useValue: {
get: EnvironmentServiceGetMock,
},
},
{
provide: WorkspaceInvitationService,
useValue: {
validateInvitation: workspaceInvitationValidateInvitationMock,
invalidateWorkspaceInvitation:
workspaceInvitationInvalidateWorkspaceInvitationMock,
findInvitationByWorkspaceSubdomainAndUserEmail:
workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock,
},
},
],
}).compile();
@ -59,4 +123,363 @@ describe('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,
);
const spy = jest
.spyOn(service, 'signUpOnNewWorkspace')
.mockResolvedValueOnce({} as User);
await service.signInUp({
email: 'test@test.com',
fromSSO: true,
targetWorkspaceSubdomain: 'tartanpion',
});
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,
defaultWorkspace: { id: 'workspace-id' },
};
UserFindOneMock.mockReturnValueOnce(existingUser);
workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce(
undefined,
);
const result = await service.signInUp({
email,
fromSSO: true,
targetWorkspaceSubdomain: 'tartanpion',
});
expect(result).toEqual(existingUser);
});
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: 'tartanpion',
});
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);
});
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,
defaultWorkspace: { id: 'workspace-id' },
};
UserFindOneMock.mockReturnValueOnce(existingUser);
workspaceInvitationFindInvitationByWorkspaceSubdomainAndUserEmailMock.mockReturnValueOnce(
{
value: 'personal-token-value',
},
);
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
isValid: true,
workspace: {
id: workspaceId,
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
});
workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce(
true,
);
userWorkspaceServiceAddUserToWorkspaceMock.mockReturnValueOnce({});
const result = await service.signInUp({
email,
fromSSO: true,
targetWorkspaceSubdomain: 'tartanpion',
});
expect(result).toEqual(existingUser);
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';
UserFindOneMock.mockReturnValueOnce(null);
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
isValid: true,
workspace: {
id: workspaceId,
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
});
workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce(
true,
);
const spySignInUpOnExistingWorkspace = jest
.spyOn(service, 'signInUpOnExistingWorkspace')
.mockResolvedValueOnce(
{} as Awaited<
ReturnType<(typeof service)['signInUpOnExistingWorkspace']>
>,
);
await service.signInUp({
email,
fromSSO: true,
workspacePersonalInviteToken,
targetWorkspaceSubdomain: 'tartanpion',
});
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,
defaultWorkspace: { id: 'workspace-id' },
};
UserFindOneMock.mockReturnValueOnce(existingUser);
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
isValid: true,
workspace: {
id: workspaceId,
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
});
workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce(
true,
);
await service.signInUp({
email,
fromSSO: true,
workspacePersonalInviteToken,
targetWorkspaceSubdomain: 'tartanpion',
});
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',
defaultWorkspace: { id: 'workspace-id' },
};
UserFindOneMock.mockReturnValueOnce(existingUser);
EnvironmentServiceGetMock.mockReturnValueOnce(false);
(bcrypt.compare as jest.Mock).mockReturnValueOnce(true);
await service.signInUp({
email,
password,
fromSSO: false,
targetWorkspaceSubdomain: 'tartanpion',
});
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: 'tartanpion',
});
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: 'tartanpion',
});
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,
workspaceInviteHash,
});
expect(UserCreateMock).toHaveBeenCalledTimes(1);
expect(UserSaveMock).toHaveBeenCalledTimes(1);
expect(
workspaceInvitationInvalidateWorkspaceInvitationMock,
).toHaveBeenCalledWith(workspaceId, email);
});
});

View File

@ -9,20 +9,15 @@ import { v4 } from 'uuid';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import {
PASSWORD_REGEX,
compareHash,
hashPassword,
PASSWORD_REGEX,
} from 'src/engine/core-modules/auth/auth.util';
import {
EnvironmentException,
EnvironmentExceptionCode,
} from 'src/engine/core-modules/environment/environment.exception';
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';
@ -33,44 +28,56 @@ import {
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { getImageBufferFromUrl } from 'src/utils/image';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import {
EnvironmentException,
EnvironmentExceptionCode,
} from 'src/engine/core-modules/environment/environment.exception';
export type SignInUpServiceInput = {
email: string;
password?: string;
firstName?: string | null;
lastName?: string | null;
workspaceInviteHash?: string | null;
workspacePersonalInviteToken?: string | null;
workspaceInviteHash?: string;
workspacePersonalInviteToken?: string;
picture?: string | null;
fromSSO: boolean;
targetWorkspaceSubdomain?: string;
isAuthEnabled?: ReturnType<(typeof workspaceValidator)['isAuthEnabled']>;
};
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class SignInUpService {
constructor(
private readonly fileUploadService: FileUploadService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(AppToken, 'core')
private readonly appTokenRepository: Repository<AppToken>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
private readonly fileUploadService: FileUploadService,
private readonly workspaceInvitationService: WorkspaceInvitationService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly onboardingService: OnboardingService,
private readonly httpService: HttpService,
private readonly environmentService: EnvironmentService,
private readonly domainManagerService: DomainManagerService,
) {}
async signInUp({
email,
workspaceInviteHash,
workspacePersonalInviteToken,
workspaceInviteHash,
password,
firstName,
lastName,
picture,
fromSSO,
targetWorkspaceSubdomain,
isAuthEnabled,
}: SignInUpServiceInput) {
if (!firstName) firstName = '';
if (!lastName) lastName = '';
@ -96,9 +103,7 @@ export class SignInUpService {
const passwordHash = password ? await hashPassword(password) : undefined;
const existingUser = await this.userRepository.findOne({
where: {
email: email,
},
where: { email },
relations: ['defaultWorkspace'],
});
@ -116,18 +121,55 @@ export class SignInUpService {
}
}
if (workspaceInviteHash) {
return await this.signInUpOnExistingWorkspace({
email,
passwordHash,
workspaceInviteHash,
workspacePersonalInviteToken,
firstName,
lastName,
picture,
existingUser,
});
const maybeInvitation =
fromSSO && !workspacePersonalInviteToken && !workspaceInviteHash
? await this.workspaceInvitationService.findInvitationByWorkspaceSubdomainAndUserEmail(
{
subdomain: targetWorkspaceSubdomain,
email,
},
)
: undefined;
if (
workspacePersonalInviteToken ||
workspaceInviteHash ||
maybeInvitation
) {
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({
email,
passwordHash,
workspace: invitationValidation.workspace,
firstName,
lastName,
picture,
existingUser,
isAuthEnabled,
});
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
invitationValidation.workspace.id,
email,
);
return updatedUser;
}
}
if (!existingUser) {
return await this.signUpOnNewWorkspace({
email,
@ -141,47 +183,46 @@ export class SignInUpService {
return existingUser;
}
private async signInUpOnExistingWorkspace({
async signInUpOnExistingWorkspace({
email,
passwordHash,
workspaceInviteHash,
workspacePersonalInviteToken,
workspace,
firstName,
lastName,
picture,
existingUser,
isAuthEnabled,
}: {
email: string;
passwordHash: string | undefined;
workspaceInviteHash: string | null;
workspacePersonalInviteToken: string | null | undefined;
workspace: Workspace;
firstName: string;
lastName: string;
picture: SignInUpServiceInput['picture'];
existingUser: User | null;
isAuthEnabled?: ReturnType<(typeof workspaceValidator)['isAuthEnabled']>;
}) {
const isNewUser = !isDefined(existingUser);
let user = existingUser;
const workspace = await this.findWorkspaceAndValidateInvitation({
workspacePersonalInviteToken,
workspaceInviteHash,
email,
});
if (!workspace) {
throw new AuthException(
workspaceValidator.assertIsExist(
workspace,
new AuthException(
'Workspace not found',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
),
);
if (!(workspace.activationStatus === WorkspaceActivationStatus.ACTIVE)) {
throw new AuthException(
workspaceValidator.assertIsActive(
workspace,
new AuthException(
'Workspace is not ready to welcome new members',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
),
);
if (isAuthEnabled)
workspaceValidator.validateAuth(isAuthEnabled, workspace);
if (isNewUser) {
const imagePath = await this.uploadPicture(picture, workspace.id);
@ -199,19 +240,18 @@ export class SignInUpService {
user = await this.userRepository.save(userToCreate);
}
if (!user) {
throw new AuthException(
userValidator.assertIsExist(
user,
new AuthException(
'User not found',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
),
);
const updatedUser = workspacePersonalInviteToken
? await this.userWorkspaceService.addUserToWorkspaceByInviteToken(
workspacePersonalInviteToken,
user,
)
: await this.userWorkspaceService.addUserToWorkspace(user, workspace);
const updatedUser = await this.userWorkspaceService.addUserToWorkspace(
user,
workspace,
);
await this.activateOnboardingForUser(user, workspace, {
firstName,
@ -221,53 +261,6 @@ export class SignInUpService {
return Object.assign(user, updatedUser);
}
private async findWorkspaceAndValidateInvitation({
workspacePersonalInviteToken,
workspaceInviteHash,
email,
}) {
if (!workspacePersonalInviteToken && !workspaceInviteHash) {
throw new AuthException(
'No invite token or hash provided',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const workspace = await this.workspaceRepository.findOneBy({
inviteHash: workspaceInviteHash,
});
if (!workspace) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
if (!workspacePersonalInviteToken && !workspace.isPublicInviteLinkEnabled) {
throw new AuthException(
'Workspace does not allow public invites',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
if (workspacePersonalInviteToken && workspace.isPublicInviteLinkEnabled) {
try {
await this.userWorkspaceService.validateInvitation(
workspacePersonalInviteToken,
email,
);
} catch (err) {
throw new AuthException(
err.message,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
}
return workspace;
}
private async activateOnboardingForUser(
user: User,
workspace: Workspace,
@ -288,7 +281,7 @@ export class SignInUpService {
}
}
private async signUpOnNewWorkspace({
async signUpOnNewWorkspace({
email,
passwordHash,
firstName,
@ -301,14 +294,20 @@ export class SignInUpService {
lastName: string;
picture: SignInUpServiceInput['picture'];
}) {
if (this.environmentService.get('IS_SIGN_UP_DISABLED')) {
throw new EnvironmentException(
'Sign up is disabled',
EnvironmentExceptionCode.ENVIRONMENT_VARIABLES_NOT_FOUND,
);
if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) {
const workspacesCount = await this.workspaceRepository.count();
// let the creation of the first workspace
if (workspacesCount > 0) {
throw new EnvironmentException(
'New workspace setup is disabled',
EnvironmentExceptionCode.ENVIRONMENT_VARIABLES_NOT_FOUND,
);
}
}
const workspaceToCreate = this.workspaceRepository.create({
subdomain: await this.domainManagerService.generateSubdomain(),
displayName: '',
domainName: '',
inviteHash: v4(),

View File

@ -6,9 +6,9 @@ import { Repository } from 'typeorm';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { SwitchWorkspaceService } from './switch-workspace.service';
@ -16,7 +16,7 @@ describe('SwitchWorkspaceService', () => {
let service: SwitchWorkspaceService;
let userRepository: Repository<User>;
let workspaceRepository: Repository<Workspace>;
let ssoService: SSOService;
let userService: UserService;
let accessTokenService: AccessTokenService;
let refreshTokenService: RefreshTokenService;
@ -32,12 +32,6 @@ describe('SwitchWorkspaceService', () => {
provide: getRepositoryToken(Workspace, 'core'),
useClass: Repository,
},
{
provide: SSOService,
useValue: {
listSSOIdentityProvidersByWorkspaceId: jest.fn(),
},
},
{
provide: AccessTokenService,
useValue: {
@ -50,6 +44,12 @@ describe('SwitchWorkspaceService', () => {
generateRefreshToken: jest.fn(),
},
},
{
provide: UserService,
useValue: {
saveDefaultWorkspace: jest.fn(),
},
},
],
}).compile();
@ -60,9 +60,9 @@ describe('SwitchWorkspaceService', () => {
workspaceRepository = module.get<Repository<Workspace>>(
getRepositoryToken(Workspace, 'core'),
);
ssoService = module.get<SSOService>(SSOService);
accessTokenService = module.get<AccessTokenService>(AccessTokenService);
refreshTokenService = module.get<RefreshTokenService>(RefreshTokenService);
userService = module.get<UserService>(UserService);
});
it('should be defined', () => {
@ -101,7 +101,6 @@ describe('SwitchWorkspaceService', () => {
const mockWorkspace = {
id: 'workspace-id',
workspaceUsers: [{ userId: 'other-user-id' }],
workspaceSSOIdentityProviders: [],
};
jest
@ -121,19 +120,25 @@ describe('SwitchWorkspaceService', () => {
const mockWorkspace = {
id: 'workspace-id',
workspaceUsers: [{ userId: 'user-id' }],
workspaceSSOIdentityProviders: [{}],
logo: 'logo',
displayName: 'displayName',
isGoogleAuthEnabled: true,
isPasswordAuthEnabled: true,
isMicrosoftAuthEnabled: false,
workspaceSSOIdentityProviders: [
{
id: 'sso-id',
},
],
};
const mockSSOProviders = [{ id: 'sso-provider-id' }];
jest
.spyOn(userRepository, 'findBy')
.mockResolvedValue([mockUser as User]);
jest.spyOn(userRepository, 'save').mockResolvedValue(mockUser as User);
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace as any);
jest
.spyOn(ssoService, 'listSSOIdentityProvidersByWorkspaceId')
.mockResolvedValue(mockSSOProviders as any);
const result = await service.switchWorkspace(
mockUser as User,
@ -141,9 +146,10 @@ describe('SwitchWorkspaceService', () => {
);
expect(result).toEqual({
useSSOAuth: true,
workspace: mockWorkspace,
availableSSOIdentityProviders: mockSSOProviders,
id: mockWorkspace.id,
logo: expect.any(String),
displayName: expect.any(String),
authProviders: expect.any(Object),
});
});
@ -153,6 +159,8 @@ describe('SwitchWorkspaceService', () => {
id: 'workspace-id',
workspaceUsers: [{ userId: 'user-id' }],
workspaceSSOIdentityProviders: [],
logo: 'logo',
displayName: 'displayName',
};
jest
@ -161,6 +169,7 @@ describe('SwitchWorkspaceService', () => {
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace as any);
jest.spyOn(userRepository, 'save').mockResolvedValue({} as User);
const result = await service.switchWorkspace(
mockUser as User,
@ -168,8 +177,10 @@ describe('SwitchWorkspaceService', () => {
);
expect(result).toEqual({
useSSOAuth: false,
workspace: mockWorkspace,
id: mockWorkspace.id,
logo: expect.any(String),
displayName: expect.any(String),
authProviders: expect.any(Object),
});
});
});
@ -200,10 +211,10 @@ describe('SwitchWorkspaceService', () => {
refreshToken: mockRefreshToken,
},
});
expect(userRepository.save).toHaveBeenCalledWith({
id: mockUser.id,
defaultWorkspace: mockWorkspace,
});
expect(userService.saveDefaultWorkspace).toHaveBeenCalledWith(
mockUser.id,
mockWorkspace.id,
);
expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith(
mockUser.id,
mockWorkspace.id,

View File

@ -7,12 +7,13 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/getAuthProvidersByWorkspace';
@Injectable()
export class SwitchWorkspaceService {
@ -21,7 +22,7 @@ export class SwitchWorkspaceService {
private readonly userRepository: Repository<User>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
private readonly ssoService: SSOService,
private readonly userService: UserService,
private readonly accessTokenService: AccessTokenService,
private readonly refreshTokenService: RefreshTokenService,
) {}
@ -59,31 +60,17 @@ export class SwitchWorkspaceService {
);
}
if (workspace.workspaceSSOIdentityProviders.length > 0) {
return {
useSSOAuth: true,
workspace,
availableSSOIdentityProviders:
await this.ssoService.listSSOIdentityProvidersByWorkspaceId(
workspaceId,
),
} as {
useSSOAuth: true;
workspace: Workspace;
availableSSOIdentityProviders: Awaited<
ReturnType<
typeof this.ssoService.listSSOIdentityProvidersByWorkspaceId
>
>;
};
}
await this.userRepository.save({
id: user.id,
defaultWorkspace: workspace,
});
return {
useSSOAuth: false,
workspace,
} as {
useSSOAuth: false;
workspace: Workspace;
id: workspace.id,
subdomain: workspace.subdomain,
logo: workspace.logo,
displayName: workspace.displayName,
authProviders: getAuthProvidersByWorkspace(workspace),
};
}
@ -91,10 +78,7 @@ export class SwitchWorkspaceService {
user: User,
workspace: Workspace,
): Promise<AuthTokens> {
await this.userRepository.save({
id: user.id,
defaultWorkspace: workspace,
});
await this.userService.saveDefaultWorkspace(user.id, workspace.id);
const token = await this.accessTokenService.generateAccessToken(
user.id,

View File

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

View File

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

View File

@ -69,30 +69,34 @@ export class SamlAuthStrategy extends PassportStrategy(
}
validate: VerifyWithRequest = async (request, profile, done) => {
if (!profile) {
return done(new Error('Profile is must be provided'));
try {
if (!profile) {
return done(new Error('Profile is must be provided'));
}
const email = profile.email ?? profile.mail ?? profile.nameID;
if (!isEmail(email)) {
return done(new Error('Invalid email'));
}
const result: {
user: Record<string, string>;
identityProviderId?: string;
} = { user: { email } };
if (
'RelayState' in request.body &&
typeof request.body.RelayState === 'string'
) {
const RelayState = JSON.parse(request.body.RelayState);
result.identityProviderId = RelayState.identityProviderId;
}
done(null, result);
} catch (err) {
done(err);
}
const email = profile.email ?? profile.mail ?? profile.nameID;
if (!isEmail(email)) {
return done(new Error('Invalid email'));
}
const result: {
user: Record<string, string>;
identityProviderId?: string;
} = { user: { email } };
if (
'RelayState' in request.body &&
typeof request.body.RelayState === 'string'
) {
const RelayState = JSON.parse(request.body.RelayState);
result.identityProviderId = RelayState.identityProviderId;
}
done(null, result);
};
}

View File

@ -166,7 +166,7 @@ describe('AccessTokenService', () => {
.spyOn(service['jwtStrategy'], 'validate')
.mockReturnValue(mockAuthContext as any);
const result = await service.validateToken(mockRequest);
const result = await service.validateTokenByRequest(mockRequest);
expect(result).toEqual(mockAuthContext);
expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith(
@ -184,7 +184,7 @@ describe('AccessTokenService', () => {
headers: {},
} as Request;
await expect(service.validateToken(mockRequest)).rejects.toThrow(
await expect(service.validateTokenByRequest(mockRequest)).rejects.toThrow(
AuthException,
);
});

View File

@ -116,16 +116,7 @@ export class AccessTokenService {
};
}
async validateToken(request: Request): Promise<AuthContext> {
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
if (!token) {
throw new AuthException(
'missing authentication token',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
async validateToken(token: string): Promise<AuthContext> {
await this.jwtWrapperService.verifyWorkspaceToken(token, 'ACCESS');
const decoded = await this.jwtWrapperService.decode(token);
@ -135,4 +126,17 @@ export class AccessTokenService {
return { user, apiKey, workspace, workspaceMemberId };
}
async validateTokenByRequest(request: Request): Promise<AuthContext> {
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
if (!token) {
throw new AuthException(
'missing authentication token',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
return this.validateToken(token);
}
}

View File

@ -94,7 +94,7 @@ describe('LoginTokenService', () => {
const result = await service.verifyLoginToken(mockToken);
expect(result).toEqual(mockEmail);
expect(result).toEqual({ sub: mockEmail });
expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith(
mockToken,
'LOGIN',

View File

@ -20,6 +20,7 @@ export class LoginTokenService {
async generateLoginToken(email: string): Promise<AuthToken> {
const secret = this.jwtWrapperService.generateAppSecret('LOGIN');
const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN');
if (!expiresIn) {
@ -43,11 +44,11 @@ export class LoginTokenService {
};
}
async verifyLoginToken(loginToken: string): Promise<string> {
async verifyLoginToken(loginToken: string): Promise<{ sub: string }> {
await this.jwtWrapperService.verifyWorkspaceToken(loginToken, 'LOGIN');
return this.jwtWrapperService.decode(loginToken, {
json: true,
}).sub;
});
}
}

View File

@ -16,11 +16,13 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
@Module({
imports: [
FeatureFlagModule,
StripeModule,
DomainManagerModule,
TypeOrmModule.forFeature(
[
BillingSubscription,

View File

@ -11,12 +11,14 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { assert } from 'src/utils/assert';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
@Injectable()
export class BillingPortalWorkspaceService {
protected readonly logger = new Logger(BillingPortalWorkspaceService.name);
constructor(
private readonly stripeService: StripeService,
private readonly domainManagerService: DomainManagerService,
private readonly environmentService: EnvironmentService,
@InjectRepository(BillingSubscription, 'core')
private readonly billingSubscriptionRepository: Repository<BillingSubscription>,
@ -31,7 +33,7 @@ export class BillingPortalWorkspaceService {
priceId: string,
successUrlPath?: string,
): Promise<string> {
const frontBaseUrl = this.environmentService.get('FRONT_BASE_URL');
const frontBaseUrl = this.domainManagerService.getBaseUrl().toString();
const successUrl = successUrlPath
? frontBaseUrl + successUrlPath
: frontBaseUrl;
@ -81,7 +83,7 @@ export class BillingPortalWorkspaceService {
throw new Error('Error: missing stripeCustomerId');
}
const frontBaseUrl = this.environmentService.get('FRONT_BASE_URL');
const frontBaseUrl = this.domainManagerService.getBaseUrl().toString();
const returnUrl = returnUrlPath
? frontBaseUrl + returnUrlPath
: frontBaseUrl;

View File

@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { StripeService } from 'src/engine/core-modules/billing/stripe/stripe.service';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
@Module({
imports: [DomainManagerModule],
providers: [StripeService],
exports: [StripeService],
})

View File

@ -7,17 +7,20 @@ import { BillingSubscriptionItem } from 'src/engine/core-modules/billing/entitie
import { AvailableProduct } from 'src/engine/core-modules/billing/enums/billing-available-product.enum';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
@Injectable()
export class StripeService {
protected readonly logger = new Logger(StripeService.name);
private readonly stripe: Stripe;
constructor(private readonly environmentService: EnvironmentService) {
constructor(
private readonly environmentService: EnvironmentService,
private readonly domainManagerService: DomainManagerService,
) {
if (!this.environmentService.get('IS_BILLING_ENABLED')) {
return;
}
this.stripe = new Stripe(
this.environmentService.get('BILLING_STRIPE_API_KEY'),
{},
@ -74,7 +77,8 @@ export class StripeService {
): Promise<Stripe.BillingPortal.Session> {
return await this.stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: returnUrl ?? this.environmentService.get('FRONT_BASE_URL'),
return_url:
returnUrl ?? this.domainManagerService.getBaseUrl().toString(),
});
}

View File

@ -2,30 +2,6 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
@ObjectType()
class AuthProviders {
@Field(() => Boolean)
google: boolean;
@Field(() => Boolean)
magicLink: boolean;
@Field(() => Boolean)
password: boolean;
@Field(() => Boolean)
microsoft: boolean;
@Field(() => Boolean)
sso: boolean;
}
@ObjectType()
class Telemetry {
@Field(() => Boolean)
enabled: boolean;
}
@ObjectType()
class Billing {
@Field(() => Boolean)
@ -76,9 +52,6 @@ class ApiConfig {
@ObjectType()
export class ClientConfig {
@Field(() => AuthProviders, { nullable: false })
authProviders: AuthProviders;
@Field(() => Billing, { nullable: false })
billing: Billing;
@ -86,7 +59,16 @@ export class ClientConfig {
signInPrefilled: boolean;
@Field(() => Boolean)
signUpDisabled: boolean;
isMultiWorkspaceEnabled: boolean;
@Field(() => Boolean)
isSSOEnabled: boolean;
@Field(() => String, { nullable: true })
defaultSubdomain: string;
@Field(() => String)
frontDomain: string;
@Field(() => Boolean)
debugMode: boolean;

View File

@ -11,13 +11,6 @@ export class ClientConfigResolver {
@Query(() => ClientConfig)
async clientConfig(): Promise<ClientConfig> {
const clientConfig: ClientConfig = {
authProviders: {
google: this.environmentService.get('AUTH_GOOGLE_ENABLED'),
magicLink: false,
password: this.environmentService.get('AUTH_PASSWORD_ENABLED'),
microsoft: this.environmentService.get('AUTH_MICROSOFT_ENABLED'),
sso: this.environmentService.get('AUTH_SSO_ENABLED'),
},
billing: {
isBillingEnabled: this.environmentService.get('IS_BILLING_ENABLED'),
billingUrl: this.environmentService.get('BILLING_PLAN_REQUIRED_LINK'),
@ -25,8 +18,13 @@ export class ClientConfigResolver {
'BILLING_FREE_TRIAL_DURATION_IN_DAYS',
),
},
isSSOEnabled: this.environmentService.get('AUTH_SSO_ENABLED'),
signInPrefilled: this.environmentService.get('SIGN_IN_PREFILLED'),
signUpDisabled: this.environmentService.get('IS_SIGN_UP_DISABLED'),
isMultiWorkspaceEnabled: this.environmentService.get(
'IS_MULTIWORKSPACE_ENABLED',
),
defaultSubdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
frontDomain: this.environmentService.get('FRONT_DOMAIN'),
debugMode: this.environmentService.get('DEBUG_MODE'),
support: {
supportDriver: this.environmentService.get('SUPPORT_DRIVER'),

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Module({
imports: [NestjsQueryTypeOrmModule.forFeature([Workspace], 'core')],
providers: [DomainManagerService],
exports: [DomainManagerService],
})
export class DomainManagerModule {}

View File

@ -0,0 +1,157 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } 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 { DomainManagerService } from './domain-manager.service';
describe('DomainManagerService', () => {
let domainManagerService: DomainManagerService;
let environmentService: EnvironmentService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DomainManagerService,
{
provide: getRepositoryToken(Workspace, 'core'),
useClass: Repository,
},
{
provide: EnvironmentService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();
domainManagerService =
module.get<DomainManagerService>(DomainManagerService);
environmentService = module.get<EnvironmentService>(EnvironmentService);
});
describe('buildBaseUrl', () => {
it('should build the base URL with protocol and domain from environment variables', () => {
jest
.spyOn(environmentService, 'get')
.mockImplementation((key: string) => {
const env = {
FRONT_PROTOCOL: 'https',
FRONT_DOMAIN: 'example.com',
};
return env[key];
});
const result = domainManagerService.getBaseUrl();
expect(result.toString()).toBe('https://example.com/');
});
it('should append default subdomain if multiworkspace is enabled', () => {
jest
.spyOn(environmentService, 'get')
.mockImplementation((key: string) => {
const env = {
FRONT_PROTOCOL: 'https',
FRONT_DOMAIN: 'example.com',
IS_MULTIWORKSPACE_ENABLED: true,
DEFAULT_SUBDOMAIN: 'test',
};
return env[key];
});
const result = domainManagerService.getBaseUrl();
expect(result.toString()).toBe('https://test.example.com/');
});
it('should append port if FRONT_PORT is set', () => {
jest
.spyOn(environmentService, 'get')
.mockImplementation((key: string) => {
const env = {
FRONT_PROTOCOL: 'https',
FRONT_DOMAIN: 'example.com',
FRONT_PORT: '8080',
};
return env[key];
});
const result = domainManagerService.getBaseUrl();
expect(result.toString()).toBe('https://example.com:8080/');
});
});
describe('buildWorkspaceURL', () => {
it('should build workspace URL with given subdomain', () => {
jest
.spyOn(environmentService, 'get')
.mockImplementation((key: string) => {
const env = {
FRONT_PROTOCOL: 'https',
FRONT_DOMAIN: 'example.com',
IS_MULTIWORKSPACE_ENABLED: true,
DEFAULT_SUBDOMAIN: 'default',
};
return env[key];
});
const result = domainManagerService.buildWorkspaceURL({
subdomain: 'test',
});
expect(result.toString()).toBe('https://test.example.com/');
});
it('should set the pathname if provided', () => {
jest
.spyOn(environmentService, 'get')
.mockImplementation((key: string) => {
const env = {
FRONT_PROTOCOL: 'https',
FRONT_DOMAIN: 'example.com',
};
return env[key];
});
const result = domainManagerService.buildWorkspaceURL({
pathname: '/path/to/resource',
});
expect(result.pathname).toBe('/path/to/resource');
});
it('should set the search parameters if provided', () => {
jest
.spyOn(environmentService, 'get')
.mockImplementation((key: string) => {
const env = {
FRONT_PROTOCOL: 'https',
FRONT_DOMAIN: 'example.com',
};
return env[key];
});
const result = domainManagerService.buildWorkspaceURL({
searchParams: {
foo: 'bar',
baz: 123,
},
});
expect(result.searchParams.get('foo')).toBe('bar');
expect(result.searchParams.get('baz')).toBe('123');
});
});
});

View File

@ -0,0 +1,264 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { isDefined } from 'src/utils/is-defined';
import {
WorkspaceException,
WorkspaceExceptionCode,
} from 'src/engine/core-modules/workspace/workspace.exception';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { isWorkEmail } from 'src/utils/is-work-email';
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class DomainManagerService {
constructor(
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
private readonly environmentService: EnvironmentService,
) {}
getBaseUrl() {
const baseUrl = new URL(
`${this.environmentService.get('FRONT_PROTOCOL')}://${this.environmentService.get('FRONT_DOMAIN')}`,
);
if (
this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') &&
this.environmentService.get('DEFAULT_SUBDOMAIN')
) {
baseUrl.hostname = `${this.environmentService.get('DEFAULT_SUBDOMAIN')}.${baseUrl.hostname}`;
}
if (this.environmentService.get('FRONT_PORT')) {
baseUrl.port = this.environmentService.get('FRONT_PORT').toString();
}
return baseUrl;
}
buildWorkspaceURL({
subdomain,
pathname,
searchParams,
}: {
subdomain?: string;
pathname?: string;
searchParams?: Record<string, string | number>;
}) {
const url = this.getBaseUrl();
if (
this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') &&
!subdomain
) {
throw new Error('subdomain is required when multiworkspace is enable');
}
if (
subdomain &&
subdomain.length > 0 &&
this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')
) {
url.hostname = url.hostname.replace(
this.environmentService.get('DEFAULT_SUBDOMAIN'),
subdomain,
);
}
if (pathname) {
url.pathname = pathname;
}
if (searchParams) {
Object.entries(searchParams).forEach(([key, value]) => {
if (isDefined(value)) {
url.searchParams.set(key, value.toString());
}
});
}
return url;
}
getWorkspaceSubdomainByOrigin = (origin: string) => {
const { hostname: originHostname } = new URL(origin);
const subdomain = originHostname.replace(
`.${this.environmentService.get('FRONT_DOMAIN')}`,
'',
);
if (this.isDefaultSubdomain(subdomain)) {
return;
}
return subdomain;
};
isDefaultSubdomain(subdomain: string) {
return subdomain === this.environmentService.get('DEFAULT_SUBDOMAIN');
}
computeRedirectErrorUrl({
errorMessage,
subdomain,
}: {
errorMessage: string;
subdomain?: string;
}) {
const url = this.buildWorkspaceURL({
subdomain,
pathname: '/verify',
searchParams: { errorMessage },
});
return url.toString();
}
async getDefaultWorkspace() {
if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) {
const workspaces = await this.workspaceRepository.find({
order: {
createdAt: 'DESC',
},
});
if (workspaces.length > 1) {
// TODO AMOREAUX: this logger is trigger twice and the second time the message is undefined for an unknown reason
Logger.warn(
`In single-workspace mode, there should be only one workspace. Today there are ${workspaces.length} workspaces`,
);
}
return workspaces[0];
}
throw new Error(
'Default workspace not exist when multi-workspace is enabled',
);
}
async getWorkspaceByOrigin(origin: string) {
try {
if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) {
return this.getDefaultWorkspace();
}
const subdomain = this.getWorkspaceSubdomainByOrigin(origin);
if (!isDefined(subdomain)) return;
return this.workspaceRepository.findOneBy({ subdomain });
} catch (e) {
throw new WorkspaceException(
'Workspace not found',
WorkspaceExceptionCode.SUBDOMAIN_NOT_FOUND,
);
}
}
private generateRandomSubdomain(): string {
const prefixes = [
'cool',
'smart',
'fast',
'bright',
'shiny',
'happy',
'funny',
'clever',
'brave',
'kind',
'gentle',
'quick',
'sharp',
'calm',
'silent',
'lucky',
'fierce',
'swift',
'mighty',
'noble',
'bold',
'wise',
'eager',
'joyful',
'glad',
'zany',
'witty',
'bouncy',
'graceful',
'colorful',
];
const suffixes = [
'raccoon',
'panda',
'whale',
'tiger',
'dolphin',
'eagle',
'penguin',
'owl',
'fox',
'wolf',
'lion',
'bear',
'hawk',
'shark',
'sparrow',
'moose',
'lynx',
'falcon',
'rabbit',
'hedgehog',
'monkey',
'horse',
'koala',
'kangaroo',
'elephant',
'giraffe',
'panther',
'crocodile',
'seal',
'octopus',
];
const randomPrefix = prefixes[Math.floor(Math.random() * prefixes.length)];
const randomSuffix = suffixes[Math.floor(Math.random() * suffixes.length)];
return `${randomPrefix}-${randomSuffix}`;
}
private getSubdomainNameByEmail(email?: string) {
if (!isDefined(email) || !isWorkEmail(email)) return;
return getDomainNameByEmail(email);
}
private getSubdomainNameByDisplayName(displayName?: string) {
if (!isDefined(displayName)) return;
const displayNameWords = displayName.match(/(\w| |\d)+/g);
if (displayNameWords) {
return displayNameWords.join('-').replace(/ /g, '').toLowerCase();
}
}
async generateSubdomain(params?: { email?: string; displayName?: string }) {
const subdomain =
this.getSubdomainNameByEmail(params?.email) ??
this.getSubdomainNameByDisplayName(params?.displayName) ??
this.generateRandomSubdomain();
const existingWorkspaceCount = await this.workspaceRepository.countBy({
subdomain,
});
return `${subdomain}${existingWorkspaceCount > 0 ? `-${Math.random().toString(36).substring(2, 10)}` : ''}`;
}
}

View File

@ -127,8 +127,22 @@ export class EnvironmentVariables {
PG_SSL_ALLOW_SELF_SIGNED = false;
// Frontend URL
@IsUrl({ require_tld: false, require_protocol: true })
FRONT_BASE_URL: string;
@IsString()
@IsOptional()
FRONT_DOMAIN = 'localhost';
@IsString()
@ValidateIf((env) => env.IS_MULTIWORKSPACE_ENABLED)
DEFAULT_SUBDOMAIN = 'app';
@IsString()
@IsOptional()
FRONT_PROTOCOL: 'http' | 'https' = 'http';
@CastToPositiveNumber()
@IsNumber()
@IsOptional()
FRONT_PORT = 3001;
@IsUrl({ require_tld: false, require_protocol: true })
@IsOptional()
@ -227,6 +241,11 @@ export class EnvironmentVariables {
@IsOptional()
ENTERPRISE_KEY: string;
@CastToBoolean()
@IsOptional()
@IsBoolean()
IS_MULTIWORKSPACE_ENABLED = false;
// Custom Code Engine
@IsEnum(ServerlessDriverType)
@IsOptional()
@ -363,11 +382,6 @@ export class EnvironmentVariables {
@ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION > 0)
WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION = 60;
@CastToBoolean()
@IsOptional()
@IsBoolean()
IS_SIGN_UP_DISABLED = false;
@IsEnum(CaptchaDriverType)
@IsOptional()
CAPTCHA_DRIVER?: CaptchaDriverType;

View File

@ -59,7 +59,7 @@ export class OpenApiService {
try {
const { workspace } =
await this.accessTokenService.validateToken(request);
await this.accessTokenService.validateTokenByRequest(request);
objectMetadataItems =
await this.objectMetadataService.findManyWithinWorkspace(workspace.id);

View File

@ -11,7 +11,6 @@ import { BillingService } from 'src/engine/core-modules/billing/services/billing
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output';
import {
SSOException,
SSOExceptionCode,
@ -149,44 +148,6 @@ export class SSOService {
};
}
async findAvailableSSOIdentityProviders(email: string) {
const user = await this.userRepository.findOne({
where: { email },
relations: [
'workspaces',
'workspaces.workspace',
'workspaces.workspace.workspaceSSOIdentityProviders',
],
});
if (!user) {
throw new SSOException('User not found', SSOExceptionCode.USER_NOT_FOUND);
}
return user.workspaces.flatMap((userWorkspace) =>
(
userWorkspace.workspace
.workspaceSSOIdentityProviders as Array<SSOConfiguration>
).reduce((acc, identityProvider) => {
if (identityProvider.status === 'Inactive') return acc;
acc.push({
id: identityProvider.id,
name: identityProvider.name ?? 'Unknown',
issuer: identityProvider.issuer,
type: identityProvider.type,
status: identityProvider.status,
workspace: {
id: userWorkspace.workspaceId,
displayName: userWorkspace.workspace.displayName,
},
});
return acc;
}, [] as Array<FindAvailableSSOIDPOutput>),
);
}
async findSSOIdentityProviderById(identityProviderId?: string) {
// if identityProviderId is not provide, typeorm return a random idp instead of undefined
if (!identityProviderId) return undefined;

View File

@ -8,7 +8,6 @@ import { DeleteSsoInput } from 'src/engine/core-modules/sso/dtos/delete-sso.inpu
import { DeleteSsoOutput } from 'src/engine/core-modules/sso/dtos/delete-sso.output';
import { EditSsoInput } from 'src/engine/core-modules/sso/dtos/edit-sso.input';
import { EditSsoOutput } from 'src/engine/core-modules/sso/dtos/edit-sso.output';
import { FindAvailableSSOIDPInput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.input';
import { FindAvailableSSOIDPOutput } from 'src/engine/core-modules/sso/dtos/find-available-SSO-IDP.output';
import { GetAuthorizationUrlInput } from 'src/engine/core-modules/sso/dtos/get-authorization-url.input';
import { GetAuthorizationUrlOutput } from 'src/engine/core-modules/sso/dtos/get-authorization-url.output';
@ -39,14 +38,6 @@ export class SSOResolver {
);
}
@UseGuards(SSOProviderEnabledGuard)
@Mutation(() => [FindAvailableSSOIDPOutput])
async findAvailableSSOIdentityProviders(
@Args('input') input: FindAvailableSSOIDPInput,
): Promise<Array<FindAvailableSSOIDPOutput>> {
return this.sSOService.findAvailableSSOIdentityProviders(input.email);
}
@UseGuards(SSOProviderEnabledGuard)
@Query(() => [FindAvailableSSOIDPOutput])
async listSSOIdentityProvidersByWorkspaceId(

View File

@ -31,7 +31,7 @@ export enum OIDCResponseType {
}
registerEnumType(IdentityProviderType, {
name: 'IdpType',
name: 'IdentityProviderType',
});
export enum SSOIdentityProviderStatus {

View File

@ -9,16 +9,17 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { User } from 'src/engine/core-modules/user/user.entity';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { UserWorkspaceResolver } from 'src/engine/core-modules/user-workspace/user-workspace.resolver';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Module({
imports: [
NestjsQueryGraphQLModule.forFeature({
imports: [
NestjsQueryTypeOrmModule.forFeature(
[User, UserWorkspace, AppToken],
[User, UserWorkspace, Workspace],
'core',
),
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity], 'metadata'),
@ -31,6 +32,6 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
}),
],
exports: [UserWorkspaceService],
providers: [UserWorkspaceService],
providers: [UserWorkspaceService, UserWorkspaceResolver],
})
export class UserWorkspaceModule {}

View File

@ -5,10 +5,6 @@ import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { Repository } from 'typeorm';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
@ -26,14 +22,12 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@InjectRepository(AppToken, 'core')
private readonly appTokenRepository: Repository<AppToken>,
@InjectRepository(ObjectMetadataEntity, 'metadata')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly dataSourceService: DataSourceService,
private readonly typeORMService: TypeORMService,
private readonly workspaceInvitationService: WorkspaceInvitationService,
private workspaceEventEmitter: WorkspaceEventEmitter,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
) {
super(userWorkspaceRepository);
}
@ -116,39 +110,25 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
await this.createWorkspaceMember(workspace.id, user);
}
return await this.userRepository.save({
const savedUser = await this.userRepository.save({
id: user.id,
defaultWorkspace: workspace,
updatedAt: new Date().toISOString(),
});
}
async validateInvitation(inviteToken: string, email: string) {
const appToken = await this.appTokenRepository.findOne({
where: {
value: inviteToken,
type: AppTokenType.InvitationToken,
},
relations: ['workspace'],
});
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
workspace.id,
user.email,
);
if (!appToken) {
throw new Error('Invalid invitation token');
}
if (!appToken.context?.email && appToken.context?.email !== email) {
throw new Error('Email does not match the invitation');
}
if (new Date(appToken.expiresAt) < new Date()) {
throw new Error('Invitation expired');
}
return appToken;
return savedUser;
}
async addUserToWorkspaceByInviteToken(inviteToken: string, user: User) {
const appToken = await this.validateInvitation(inviteToken, user.email);
const appToken = await this.workspaceInvitationService.validateInvitation({
workspacePersonalInviteToken: inviteToken,
email: user.email,
});
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
appToken.workspace.id,
@ -158,7 +138,7 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
return await this.addUserToWorkspace(user, appToken.workspace);
}
public async getUserCount(workspaceId): Promise<number | undefined> {
public async getUserCount(workspaceId: string): Promise<number | undefined> {
return await this.userWorkspaceRepository.countBy({
workspaceId,
});

View File

@ -18,6 +18,11 @@ import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/worksp
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class UserService extends TypeOrmQueryService<User> {
@ -64,9 +69,7 @@ export class UserService extends TypeOrmQueryService<User> {
'workspaceMember',
);
const workspaceMembers = workspaceMemberRepository.find();
return workspaceMembers;
return workspaceMemberRepository.find();
}
async deleteUser(userId: string): Promise<User> {
@ -131,4 +134,29 @@ export class UserService extends TypeOrmQueryService<User> {
return user;
}
async saveDefaultWorkspace(userId: string, workspaceId: string) {
const user = await this.userRepository.findOne({
where: {
id: userId,
workspaces: {
workspaceId,
},
},
relations: ['workspaces'],
});
userValidator.assertIsExist(
user,
new AuthException(
'User does not have access to this workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
);
return await this.userRepository.save({
id: userId,
defaultWorkspaceId: workspaceId,
});
}
}

View File

@ -9,7 +9,6 @@ import {
} from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm';
import assert from 'assert';
import crypto from 'crypto';
import { GraphQLJSONObject } from 'graphql-type-json';
@ -40,6 +39,11 @@ import { DemoEnvGuard } from 'src/engine/guards/demo.env.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { AccountsToReconnectKeys } from 'src/modules/connected-account/types/accounts-to-reconnect-key-value.type';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
const getHMACKey = (email?: string, key?: string | null) => {
if (!email || !key) return null;
@ -65,7 +69,17 @@ export class UserResolver {
) {}
@Query(() => User)
async currentUser(@AuthUser() { id: userId }: User): Promise<User> {
async currentUser(
@AuthUser() { id: userId }: User,
@AuthWorkspace() { id: workspaceId }: Workspace,
): Promise<User> {
if (
this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') &&
workspaceId
) {
await this.userService.saveDefaultWorkspace(userId, workspaceId);
}
const user = await this.userRepository.findOne({
where: {
id: userId,
@ -73,7 +87,10 @@ export class UserResolver {
relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'],
});
assert(user, 'User not found');
userValidator.assertIsExist(
user,
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
);
return user;
}

View File

@ -0,0 +1,34 @@
import { User } from 'src/engine/core-modules/user/user.entity';
import { CustomException } from 'src/utils/custom-exception';
const assertIsExist = (
user: User | undefined | null,
exceptionToThrow: CustomException,
): asserts user is User => {
if (!user) {
throw exceptionToThrow;
}
};
const isExist = (user: User | undefined | null): user is User => {
return !!user;
};
const assertHasDefaultWorkspace = (
user: User,
exceptionToThrow?: CustomException,
): asserts user is User & { defaultWorkspaceId: string } => {
if (!user.defaultWorkspaceId) {
throw exceptionToThrow;
}
};
export const userValidator: {
assertIsExist: typeof assertIsExist;
assertHasDefaultWorkspace: typeof assertHasDefaultWorkspace;
isExist: typeof isExist;
} = {
assertIsExist,
assertHasDefaultWorkspace,
isExist,
};

View File

@ -14,9 +14,14 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationException } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { WorkspaceInvitationService } from './workspace-invitation.service';
// To fix a circular dependency issue
jest.mock('src/engine/core-modules/workspace/services/workspace.service');
describe('WorkspaceInvitationService', () => {
let service: WorkspaceInvitationService;
let appTokenRepository: Repository<AppToken>;
@ -37,6 +42,18 @@ describe('WorkspaceInvitationService', () => {
provide: getRepositoryToken(UserWorkspace, 'core'),
useClass: Repository,
},
{
provide: getRepositoryToken(Workspace, 'core'),
useClass: Repository,
},
{
provide: DomainManagerService,
useValue: {
buildWorkspaceURL: jest
.fn()
.mockResolvedValue(new URL('http://localhost:3001')),
},
},
{
provide: EnvironmentService,
useValue: {
@ -55,6 +72,16 @@ describe('WorkspaceInvitationService', () => {
setOnboardingInviteTeamPending: jest.fn(),
},
},
{
provide: WorkspaceService,
useValue: {
// Mock methods you expect WorkspaceInvitationService to call
getDefaultWorkspace: jest
.fn()
.mockResolvedValue({ id: 'default-workspace-id' }),
// Add other methods as needed
},
},
],
}).compile();

View File

@ -28,6 +28,8 @@ import {
WorkspaceInvitationExceptionCode,
} from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { castAppTokenToWorkspaceInvitationUtil } from 'src/engine/core-modules/workspace-invitation/utils/cast-app-token-to-workspace-invitation.util';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
@ -35,13 +37,122 @@ export class WorkspaceInvitationService {
constructor(
@InjectRepository(AppToken, 'core')
private readonly appTokenRepository: Repository<AppToken>,
private readonly environmentService: EnvironmentService,
private readonly emailService: EmailService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
private readonly environmentService: EnvironmentService,
private readonly emailService: EmailService,
private readonly onboardingService: OnboardingService,
private readonly domainManagerService: DomainManagerService,
) {}
// VALIDATIONS METHODS
private async validatePublicInvitation(workspaceInviteHash: string) {
const workspace = await this.workspaceRepository.findOne({
where: {
inviteHash: workspaceInviteHash,
},
});
if (!workspace) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
if (!workspace.isPublicInviteLinkEnabled) {
throw new AuthException(
'Workspace does not allow public invites',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
return { isValid: true, workspace };
}
private async validatePersonalInvitation({
workspacePersonalInviteToken,
email,
}: {
workspacePersonalInviteToken?: string;
email: string;
}) {
try {
const appToken = await this.appTokenRepository.findOne({
where: {
value: workspacePersonalInviteToken,
type: AppTokenType.InvitationToken,
},
relations: ['workspace'],
});
if (!appToken) {
throw new Error('Invalid invitation token');
}
if (!appToken.context?.email || appToken.context?.email !== email) {
throw new Error('Email does not match the invitation');
}
if (new Date(appToken.expiresAt) < new Date()) {
throw new Error('Invitation expired');
}
return { isValid: true, workspace: appToken.workspace };
} catch (err) {
throw new AuthException(
err.message,
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
}
async validateInvitation({
workspacePersonalInviteToken,
workspaceInviteHash,
email,
}: {
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
email: string;
}) {
if (workspacePersonalInviteToken) {
return await this.validatePersonalInvitation({
workspacePersonalInviteToken,
email,
});
}
if (workspaceInviteHash) {
return await this.validatePublicInvitation(workspaceInviteHash);
}
throw new AuthException(
'Invitation invalid',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
async findInvitationByWorkspaceSubdomainAndUserEmail({
subdomain,
email,
}: {
subdomain?: string;
email: string;
}) {
const workspace = this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')
? await this.workspaceRepository.findOneBy({
subdomain,
})
: await this.domainManagerService.getDefaultWorkspace();
if (!workspace) return;
return await this.getOneWorkspaceInvitation(workspace.id, email);
}
async getOneWorkspaceInvitation(workspaceId: string, email: string) {
return await this.appTokenRepository
.createQueryBuilder('appToken')
@ -55,26 +166,38 @@ export class WorkspaceInvitationService {
.getOne();
}
castAppTokenToWorkspaceInvitation(appToken: AppToken) {
if (appToken.type !== AppTokenType.InvitationToken) {
async getAppTokenByInvitationToken(invitationToken: string) {
const appToken = await this.appTokenRepository.findOne({
where: {
value: invitationToken,
type: AppTokenType.InvitationToken,
},
relations: ['workspace'],
});
if (!appToken) {
throw new WorkspaceInvitationException(
`Token type must be "${AppTokenType.InvitationToken}"`,
WorkspaceInvitationExceptionCode.INVALID_APP_TOKEN_TYPE,
'Invalid invitation token',
WorkspaceInvitationExceptionCode.INVALID_INVITATION,
);
}
if (!appToken.context?.email) {
throw new WorkspaceInvitationException(
`Invitation corrupted: Missing email in context`,
WorkspaceInvitationExceptionCode.INVITATION_CORRUPTED,
);
}
return appToken;
}
return {
id: appToken.id,
email: appToken.context.email,
expiresAt: appToken.expiresAt,
};
async loadWorkspaceInvitations(workspace: Workspace) {
const appTokens = await this.appTokenRepository.find({
where: {
workspaceId: workspace.id,
type: AppTokenType.InvitationToken,
deletedAt: IsNull(),
},
select: {
value: false,
},
});
return appTokens.map(castAppTokenToWorkspaceInvitationUtil);
}
async createWorkspaceInvitation(email: string, workspace: Workspace) {
@ -112,21 +235,6 @@ export class WorkspaceInvitationService {
return this.generateInvitationToken(workspace.id, email);
}
async loadWorkspaceInvitations(workspace: Workspace) {
const appTokens = await this.appTokenRepository.find({
where: {
workspaceId: workspace.id,
type: AppTokenType.InvitationToken,
deletedAt: IsNull(),
},
select: {
value: false,
},
});
return appTokens.map(this.castAppTokenToWorkspaceInvitation);
}
async deleteWorkspaceInvitation(appTokenId: string, workspaceId: string) {
const appToken = await this.appTokenRepository.findOne({
where: {
@ -221,16 +329,18 @@ export class WorkspaceInvitationService {
}),
);
const frontBaseURL = this.environmentService.get('FRONT_BASE_URL');
for (const invitation of invitationsPr) {
if (invitation.status === 'fulfilled') {
const link = new URL(`${frontBaseURL}/invite/${workspace?.inviteHash}`);
if (invitation.value.isPersonalInvitation) {
link.searchParams.set('inviteToken', invitation.value.appToken.value);
link.searchParams.set('email', invitation.value.email);
}
const link = this.domainManagerService.buildWorkspaceURL({
subdomain: workspace.subdomain,
pathname: `invite/${workspace?.inviteHash}`,
searchParams: invitation.value.isPersonalInvitation
? {
inviteToken: invitation.value.appToken.value,
email: invitation.value.email,
}
: {},
});
const emailData = {
link: link.toString(),
workspace: { name: workspace.displayName, logo: workspace.logo },
@ -280,9 +390,7 @@ export class WorkspaceInvitationService {
} else {
acc.result.push(
invitation.value.isPersonalInvitation
? this.castAppTokenToWorkspaceInvitation(
invitation.value.appToken,
)
? castAppTokenToWorkspaceInvitationUtil(invitation.value.appToken)
: { email: invitation.value.email },
);
}

View File

@ -0,0 +1,61 @@
import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import {
WorkspaceInvitationException,
WorkspaceInvitationExceptionCode,
} from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception';
import { castAppTokenToWorkspaceInvitationUtil } from './cast-app-token-to-workspace-invitation.util';
describe('castAppTokenToWorkspaceInvitation', () => {
it('should throw an error if token type is not InvitationToken', () => {
const appToken = {
id: '1',
type: AppTokenType.RefreshToken,
context: { email: 'test@example.com' },
expiresAt: new Date(),
} as AppToken;
expect(() => castAppTokenToWorkspaceInvitationUtil(appToken)).toThrowError(
new WorkspaceInvitationException(
`Token type must be "${AppTokenType.InvitationToken}"`,
WorkspaceInvitationExceptionCode.INVALID_APP_TOKEN_TYPE,
),
);
});
it('should throw an error if context email is missing', () => {
const appToken = {
id: '1',
type: AppTokenType.InvitationToken,
context: null,
expiresAt: new Date(),
} as AppToken;
expect(() => castAppTokenToWorkspaceInvitationUtil(appToken)).toThrowError(
new WorkspaceInvitationException(
`Invitation corrupted: Missing email in context`,
WorkspaceInvitationExceptionCode.INVITATION_CORRUPTED,
),
);
});
it('should return the correct invitation object for valid inputs', () => {
const appToken = {
id: '1',
type: AppTokenType.InvitationToken,
context: { email: 'test@example.com' },
expiresAt: new Date(),
} as AppToken;
const invitation = castAppTokenToWorkspaceInvitationUtil(appToken);
expect(invitation).toEqual({
id: '1',
email: 'test@example.com',
expiresAt: appToken.expiresAt,
});
});
});

View File

@ -0,0 +1,30 @@
import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import {
WorkspaceInvitationException,
WorkspaceInvitationExceptionCode,
} from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception';
export const castAppTokenToWorkspaceInvitationUtil = (appToken: AppToken) => {
if (appToken.type !== AppTokenType.InvitationToken) {
throw new WorkspaceInvitationException(
`Token type must be "${AppTokenType.InvitationToken}"`,
WorkspaceInvitationExceptionCode.INVALID_APP_TOKEN_TYPE,
);
}
if (!appToken.context?.email) {
throw new WorkspaceInvitationException(
`Invitation corrupted: Missing email in context`,
WorkspaceInvitationExceptionCode.INVITATION_CORRUPTED,
);
}
return {
id: appToken.id,
email: appToken.context.email,
expiresAt: appToken.expiresAt,
};
};

View File

@ -1,7 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class WorkspaceInvitationException extends CustomException {
code: WorkspaceInvitationExceptionCode;
constructor(message: string, code: WorkspaceInvitationExceptionCode) {
super(message, code);
}

View File

@ -3,16 +3,20 @@ import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { WorkspaceInvitationResolver } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.resolver';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
@Module({
imports: [
NestjsQueryTypeOrmModule.forFeature([AppToken, UserWorkspace], 'core'),
TokenModule,
DomainManagerModule,
NestjsQueryTypeOrmModule.forFeature(
[AppToken, UserWorkspace, Workspace],
'core',
),
OnboardingModule,
],
exports: [WorkspaceInvitationService],

View File

@ -0,0 +1,13 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
@ObjectType()
export class ActivateWorkspaceOutput {
@Field(() => Workspace)
workspace: Workspace;
@Field(() => AuthToken)
loginToken: AuthToken;
}

View File

@ -0,0 +1,61 @@
import { ObjectType, Field } from '@nestjs/graphql';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import {
IdentityProviderType,
SSOIdentityProviderStatus,
} from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
@ObjectType()
export class SSOIdentityProvider {
@Field(() => String)
id: string;
@Field(() => String)
name: string;
@Field(() => IdentityProviderType)
type: IdentityProviderType;
@Field(() => SSOIdentityProviderStatus)
status: SSOIdentityProviderStatus;
@Field(() => String)
issuer: string;
}
@ObjectType()
export class AuthProviders {
@Field(() => [SSOIdentityProvider])
sso: Array<SSOIdentityProvider>;
@Field(() => Boolean)
google: boolean;
@Field(() => Boolean)
magicLink: boolean;
@Field(() => Boolean)
password: boolean;
@Field(() => Boolean)
microsoft: boolean;
}
@ObjectType()
export class PublicWorkspaceDataOutput {
@Field(() => String)
id: string;
@Field(() => AuthProviders)
authProviders: AuthProviders;
@Field(() => String, { nullable: true })
logo: Workspace['logo'];
@Field(() => String, { nullable: true })
displayName: Workspace['displayName'];
@Field(() => String)
subdomain: Workspace['subdomain'];
}

View File

@ -9,6 +9,11 @@ export class UpdateWorkspaceInput {
@IsOptional()
domainName?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
subdomain?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
@ -33,4 +38,19 @@ export class UpdateWorkspaceInput {
@IsBoolean()
@IsOptional()
allowImpersonation?: boolean;
@Field({ nullable: true })
@IsBoolean()
@IsOptional()
isGoogleAuthEnabled?: boolean;
@Field({ nullable: true })
@IsBoolean()
@IsOptional()
isMicrosoftAuthEnabled?: boolean;
@Field({ nullable: true })
@IsBoolean()
@IsOptional()
isPasswordAuthEnabled?: boolean;
}

View File

@ -13,6 +13,7 @@ import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { WorkspaceService } from './workspace.service';
@ -47,6 +48,10 @@ describe('WorkspaceService', () => {
provide: UserService,
useValue: {},
},
{
provide: DomainManagerService,
useValue: {},
},
{
provide: BillingSubscriptionService,
useValue: {},

View File

@ -1,5 +1,4 @@
import { BadRequestException } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import assert from 'assert';
@ -20,9 +19,9 @@ import {
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class WorkspaceService extends TypeOrmQueryService<Workspace> {
private userWorkspaceService: UserWorkspaceService;
constructor(
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@ -33,12 +32,9 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
private readonly workspaceManagerService: WorkspaceManagerService,
private readonly featureFlagService: FeatureFlagService,
private readonly billingSubscriptionService: BillingSubscriptionService,
private moduleRef: ModuleRef,
private readonly userWorkspaceService: UserWorkspaceService,
) {
super(workspaceRepository);
this.userWorkspaceService = this.moduleRef.get(UserWorkspaceService, {
strict: false,
});
}
async activateWorkspace(user: User, data: ActivateWorkspaceInput) {
@ -82,12 +78,15 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
user.defaultWorkspaceId,
user,
);
await this.workspaceRepository.update(user.defaultWorkspaceId, {
displayName: data.displayName,
activationStatus: WorkspaceActivationStatus.ACTIVE,
});
return existingWorkspace;
return await this.workspaceRepository.findOneBy({
id: user.defaultWorkspaceId,
});
}
async softDeleteWorkspace(id: string) {

View File

@ -0,0 +1,80 @@
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { getAuthProvidersByWorkspace } from './getAuthProvidersByWorkspace';
describe('getAuthProvidersByWorkspace', () => {
const mockWorkspace = {
isGoogleAuthEnabled: true,
isPasswordAuthEnabled: true,
isMicrosoftAuthEnabled: false,
workspaceSSOIdentityProviders: [
{
id: 'sso1',
name: 'SSO Provider 1',
type: 'SAML',
status: 'active',
issuer: 'sso1.example.com',
},
],
} as unknown as Workspace;
it('should return correct auth providers for given workspace', () => {
const result = getAuthProvidersByWorkspace({
...mockWorkspace,
});
expect(result).toEqual({
google: true,
magicLink: false,
password: true,
microsoft: false,
sso: [
{
id: 'sso1',
name: 'SSO Provider 1',
type: 'SAML',
status: 'active',
issuer: 'sso1.example.com',
},
],
});
});
it('should handle workspace with no SSO providers', () => {
const result = getAuthProvidersByWorkspace({
...mockWorkspace,
workspaceSSOIdentityProviders: [],
});
expect(result).toEqual({
google: true,
magicLink: false,
password: true,
microsoft: false,
sso: [],
});
});
it('should disable Microsoft auth if isMicrosoftAuthEnabled is false', () => {
const result = getAuthProvidersByWorkspace({
...mockWorkspace,
isMicrosoftAuthEnabled: false,
});
expect(result).toEqual({
google: true,
magicLink: false,
password: true,
microsoft: false,
sso: [
{
id: 'sso1',
name: 'SSO Provider 1',
type: 'SAML',
status: 'active',
issuer: 'sso1.example.com',
},
],
});
});
});

View File

@ -0,0 +1,17 @@
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
export const getAuthProvidersByWorkspace = (workspace: Workspace) => {
return {
google: workspace.isGoogleAuthEnabled,
magicLink: false,
password: workspace.isPasswordAuthEnabled,
microsoft: workspace.isMicrosoftAuthEnabled,
sso: workspace.workspaceSSOIdentityProviders.map((identityProvider) => ({
id: identityProvider.id,
name: identityProvider.name,
type: identityProvider.type,
status: identityProvider.status,
issuer: identityProvider.issuer,
})),
};
};

View File

@ -150,4 +150,20 @@ export class Workspace {
@Field()
@Column({ default: '' })
databaseSchema: string;
@Field()
@Column()
subdomain: string;
@Field()
@Column({ default: true })
isGoogleAuthEnabled: boolean;
@Field()
@Column({ default: true })
isPasswordAuthEnabled: boolean;
@Field()
@Column({ default: false })
isMicrosoftAuthEnabled: boolean;
}

View File

@ -0,0 +1,12 @@
import { CustomException } from 'src/utils/custom-exception';
export class WorkspaceException extends CustomException {
constructor(message: string, code: WorkspaceExceptionCode) {
super(message, code);
}
}
export enum WorkspaceExceptionCode {
SUBDOMAIN_NOT_FOUND = 'SUBDOMAIN_NOT_FOUND',
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
}

View File

@ -11,14 +11,14 @@ import { FileModule } from 'src/engine/core-modules/file/file.module';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { UserWorkspaceResolver } from 'src/engine/core-modules/user-workspace/user-workspace.resolver';
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
import { WorkspaceWorkspaceMemberListener } from 'src/engine/core-modules/workspace/workspace-workspace-member.listener';
import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.resolver';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts';
import { Workspace } from './workspace.entity';
@ -30,8 +30,10 @@ import { WorkspaceService } from './services/workspace.service';
TypeORMModule,
NestjsQueryGraphQLModule.forFeature({
imports: [
DomainManagerModule,
BillingModule,
FileModule,
TokenModule,
FileUploadModule,
WorkspaceMetadataCacheModule,
NestjsQueryTypeOrmModule.forFeature(
@ -44,7 +46,6 @@ import { WorkspaceService } from './services/workspace.service';
DataSourceModule,
OnboardingModule,
TypeORMModule,
WorkspaceInvitationModule,
],
services: [WorkspaceService],
resolvers: workspaceAutoResolverOpts,
@ -54,7 +55,6 @@ import { WorkspaceService } from './services/workspace.service';
providers: [
WorkspaceResolver,
WorkspaceService,
UserWorkspaceResolver,
WorkspaceWorkspaceMemberListener,
],
})

View File

@ -29,16 +29,28 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { assert } from 'src/utils/assert';
import { isDefined } from 'src/utils/is-defined';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import {
WorkspaceException,
WorkspaceExceptionCode,
} from 'src/engine/core-modules/workspace/workspace.exception';
import { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.output';
import { ActivateWorkspaceOutput } from 'src/engine/core-modules/workspace/dtos/activate-workspace-output';
import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
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 { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/getAuthProvidersByWorkspace';
import { Workspace } from './workspace.entity';
import { WorkspaceService } from './services/workspace.service';
@UseGuards(WorkspaceAuthGuard)
@Resolver(() => Workspace)
export class WorkspaceResolver {
constructor(
private readonly workspaceService: WorkspaceService,
private readonly loginTokenService: LoginTokenService,
private readonly domainManagerService: DomainManagerService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly environmentService: EnvironmentService,
private readonly fileUploadService: FileUploadService,
@ -47,6 +59,7 @@ export class WorkspaceResolver {
) {}
@Query(() => Workspace)
@UseGuards(WorkspaceAuthGuard)
async currentWorkspace(@AuthWorkspace() { id }: Workspace) {
const workspace = await this.workspaceService.findById(id);
@ -55,16 +68,25 @@ export class WorkspaceResolver {
return workspace;
}
@Mutation(() => Workspace)
@Mutation(() => ActivateWorkspaceOutput)
@UseGuards(UserAuthGuard)
async activateWorkspace(
@Args('data') data: ActivateWorkspaceInput,
@AuthUser() user: User,
) {
return await this.workspaceService.activateWorkspace(user, data);
const workspace = await this.workspaceService.activateWorkspace(user, data);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
);
return {
workspace,
loginToken,
};
}
@Mutation(() => Workspace)
@UseGuards(WorkspaceAuthGuard)
async updateWorkspace(
@Args('data') data: UpdateWorkspaceInput,
@AuthWorkspace() workspace: Workspace,
@ -73,6 +95,7 @@ export class WorkspaceResolver {
}
@Mutation(() => String)
@UseGuards(WorkspaceAuthGuard)
async uploadWorkspaceLogo(
@AuthWorkspace() { id }: Workspace,
@Args({ name: 'file', type: () => GraphQLUpload })
@ -101,8 +124,8 @@ export class WorkspaceResolver {
return `${paths[0]}?token=${workspaceLogoToken}`;
}
@UseGuards(DemoEnvGuard)
@Mutation(() => Workspace)
@UseGuards(DemoEnvGuard, WorkspaceAuthGuard)
async deleteCurrentWorkspace(@AuthWorkspace() { id }: Workspace) {
return this.workspaceService.deleteWorkspace(id);
}
@ -144,4 +167,26 @@ export class WorkspaceResolver {
hasValidEntrepriseKey(): boolean {
return isDefined(this.environmentService.get('ENTERPRISE_KEY'));
}
@Query(() => PublicWorkspaceDataOutput)
async getPublicWorkspaceDataBySubdomain(@OriginHeader() origin: string) {
const workspace =
await this.domainManagerService.getWorkspaceByOrigin(origin);
workspaceValidator.assertIsExist(
workspace,
new WorkspaceException(
'Workspace not found',
WorkspaceExceptionCode.WORKSPACE_NOT_FOUND,
),
);
return {
id: workspace.id,
logo: workspace.logo,
displayName: workspace.displayName,
subdomain: workspace.subdomain,
authProviders: getAuthProvidersByWorkspace(workspace),
};
}
}

View File

@ -0,0 +1,64 @@
import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { CustomException } from 'src/utils/custom-exception';
type WorkspaceAuthProvider = 'google' | 'microsoft' | 'password';
const assertIsExist = (
workspace: Workspace | undefined | null,
exceptionToThrow?: CustomException,
): asserts workspace is Workspace => {
if (!workspace) {
throw exceptionToThrow;
}
};
const assertIsActive = (
workspace: Workspace,
exceptionToThrow: CustomException,
): asserts workspace is Workspace & {
activationStatus: WorkspaceActivationStatus.ACTIVE;
} => {
if (workspace.activationStatus === WorkspaceActivationStatus.ACTIVE) return;
throw exceptionToThrow;
};
type IsAuthEnabled = <P extends WorkspaceAuthProvider>(
provider: P,
exceptionToThrow: CustomException,
) => (
workspace: Workspace,
exceptionToThrowCustom?: CustomException,
) => boolean;
const isAuthEnabled: IsAuthEnabled = (provider, exceptionToThrow) => {
return (workspace, exceptionToThrowCustom = exceptionToThrow) => {
if (provider === 'google' && workspace.isGoogleAuthEnabled) return true;
if (provider === 'microsoft' && workspace.isMicrosoftAuthEnabled)
return true;
if (provider === 'password' && workspace.isPasswordAuthEnabled) return true;
if (exceptionToThrowCustom) {
throw exceptionToThrowCustom;
}
return false;
};
};
const validateAuth = (fn: ReturnType<IsAuthEnabled>, workspace: Workspace) =>
fn(workspace);
export const workspaceValidator: {
assertIsExist: typeof assertIsExist;
assertIsActive: typeof assertIsActive;
isAuthEnabled: IsAuthEnabled;
validateAuth: typeof validateAuth;
} = {
assertIsExist: assertIsExist,
assertIsActive: assertIsActive,
isAuthEnabled,
validateAuth,
};