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