refactor(auth): add workspaces selection (#12098)
This commit is contained in:
@ -11,6 +11,7 @@ import { ActorMetadata } from 'src/engine/metadata-modules/field-metadata/compos
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type CreateInput = Record<string, any>;
|
||||
@ -30,6 +31,10 @@ export class CreatedByFromAuthContextService {
|
||||
objectMetadataNameSingular: string,
|
||||
authContext: AuthContext,
|
||||
): Promise<CreateInput[]> {
|
||||
const workspace = authContext.workspace;
|
||||
|
||||
workspaceValidator.assertIsDefinedOrThrow(workspace);
|
||||
|
||||
// TODO: Once all objects have it, we can remove this check
|
||||
const createdByFieldMetadata = await this.fieldMetadataRepository.findOne({
|
||||
where: {
|
||||
@ -37,7 +42,7 @@ export class CreatedByFromAuthContextService {
|
||||
nameSingular: objectMetadataNameSingular,
|
||||
},
|
||||
name: 'createdBy',
|
||||
workspaceId: authContext.workspace.id,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
@ -78,6 +83,8 @@ export class CreatedByFromAuthContextService {
|
||||
): Promise<ActorMetadata> {
|
||||
const { workspace, workspaceMemberId, user, apiKey } = authContext;
|
||||
|
||||
workspaceValidator.assertIsDefinedOrThrow(workspace);
|
||||
|
||||
// TODO: remove that code once we have the workspace member id in all tokens
|
||||
if (isDefined(workspaceMemberId) && isDefined(user)) {
|
||||
return buildCreatedByFromFullNameMetadata({
|
||||
|
||||
@ -26,7 +26,6 @@ export enum AppTokenType {
|
||||
AuthorizationCode = 'AUTHORIZATION_CODE',
|
||||
PasswordResetToken = 'PASSWORD_RESET_TOKEN',
|
||||
InvitationToken = 'INVITATION_TOKEN',
|
||||
OIDCCodeVerifier = 'OIDC_CODE_VERIFIER',
|
||||
EmailVerificationToken = 'EMAIL_VERIFICATION_TOKEN',
|
||||
}
|
||||
|
||||
|
||||
@ -203,4 +203,20 @@ export class ApprovedAccessDomainService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findValidatedApprovedAccessDomainWithWorkspacesAndSSOIdentityProvidersDomain(
|
||||
domain: string,
|
||||
) {
|
||||
return await this.approvedAccessDomainRepository.find({
|
||||
relations: [
|
||||
'workspace',
|
||||
'workspace.workspaceSSOIdentityProviders',
|
||||
'workspace.approvedAccessDomains',
|
||||
],
|
||||
where: {
|
||||
domain,
|
||||
isValidated: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,4 +26,5 @@ export enum AuthExceptionCode {
|
||||
GOOGLE_API_AUTH_DISABLED = 'GOOGLE_API_AUTH_DISABLED',
|
||||
MICROSOFT_API_AUTH_DISABLED = 'MICROSOFT_API_AUTH_DISABLED',
|
||||
MISSING_ENVIRONMENT_VARIABLE = 'MISSING_ENVIRONMENT_VARIABLE',
|
||||
INVALID_JWT_TOKEN_TYPE = 'INVALID_JWT_TOKEN_TYPE',
|
||||
}
|
||||
|
||||
@ -13,6 +13,8 @@ import { PermissionsService } from 'src/engine/metadata-modules/permissions/perm
|
||||
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
|
||||
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
|
||||
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
|
||||
|
||||
import { AuthResolver } from './auth.resolver';
|
||||
|
||||
@ -45,6 +47,10 @@ describe('AuthResolver', () => {
|
||||
provide: AuthService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: RefreshTokenService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: UserService,
|
||||
useValue: {},
|
||||
@ -81,6 +87,10 @@ describe('AuthResolver', () => {
|
||||
provide: LoginTokenService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceAgnosticTokenService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: TransientTokenService,
|
||||
useValue: {},
|
||||
|
||||
@ -28,7 +28,6 @@ import {
|
||||
import { GetAuthorizationUrlForSSOInput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.input';
|
||||
import { GetAuthorizationUrlForSSOOutput } from 'src/engine/core-modules/auth/dto/get-authorization-url-for-sso.output';
|
||||
import { GetLoginTokenFromEmailVerificationTokenInput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.input';
|
||||
import { GetLoginTokenFromEmailVerificationTokenOutput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.output';
|
||||
import { SignUpOutput } from 'src/engine/core-modules/auth/dto/sign-up.output';
|
||||
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
|
||||
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
|
||||
@ -55,14 +54,21 @@ import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
|
||||
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
|
||||
import { GetLoginTokenFromEmailVerificationTokenOutput } from 'src/engine/core-modules/auth/dto/get-login-token-from-email-verification-token.output';
|
||||
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
|
||||
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
|
||||
import { AvailableWorkspacesAndAccessTokensOutput } from 'src/engine/core-modules/auth/dto/available-workspaces-and-access-tokens.output';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { AuthProvider } from 'src/engine/decorators/auth/auth-provider.decorator';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
|
||||
import { GetAuthTokensFromLoginTokenInput } from './dto/get-auth-tokens-from-login-token.input';
|
||||
import { GetLoginTokenFromCredentialsInput } from './dto/get-login-token-from-credentials.input';
|
||||
import { UserCredentialsInput } from './dto/user-credentials.input';
|
||||
import { LoginToken } from './dto/login-token.entity';
|
||||
import { SignUpInput } from './dto/sign-up.input';
|
||||
import { ApiKeyToken, AuthTokens } from './dto/token.entity';
|
||||
import { UserExistsOutput } from './dto/user-exists.entity';
|
||||
import { CheckUserExistsInput } from './dto/user-exists.input';
|
||||
import { CheckUserExistOutput } from './dto/user-exists.entity';
|
||||
import { EmailAndCaptchaInput } from './dto/user-exists.input';
|
||||
import { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity';
|
||||
import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input';
|
||||
import { AuthService } from './services/auth.service';
|
||||
@ -85,6 +91,8 @@ export class AuthResolver {
|
||||
private apiKeyService: ApiKeyService,
|
||||
private resetPasswordService: ResetPasswordService,
|
||||
private loginTokenService: LoginTokenService,
|
||||
private workspaceAgnosticTokenService: WorkspaceAgnosticTokenService,
|
||||
private refreshTokenService: RefreshTokenService,
|
||||
private signInUpService: SignInUpService,
|
||||
private transientTokenService: TransientTokenService,
|
||||
private emailVerificationService: EmailVerificationService,
|
||||
@ -96,10 +104,10 @@ export class AuthResolver {
|
||||
) {}
|
||||
|
||||
@UseGuards(CaptchaGuard, PublicEndpointGuard)
|
||||
@Query(() => UserExistsOutput)
|
||||
@Query(() => CheckUserExistOutput)
|
||||
async checkUserExists(
|
||||
@Args() checkUserExistsInput: CheckUserExistsInput,
|
||||
): Promise<typeof UserExistsOutput> {
|
||||
@Args() checkUserExistsInput: EmailAndCaptchaInput,
|
||||
): Promise<CheckUserExistOutput> {
|
||||
return await this.authService.checkUserExists(
|
||||
checkUserExistsInput.email.toLowerCase(),
|
||||
);
|
||||
@ -136,11 +144,11 @@ export class AuthResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(CaptchaGuard, PublicEndpointGuard)
|
||||
@Mutation(() => LoginToken)
|
||||
@UseGuards(CaptchaGuard, PublicEndpointGuard)
|
||||
async getLoginTokenFromCredentials(
|
||||
@Args()
|
||||
getLoginTokenFromCredentialsInput: GetLoginTokenFromCredentialsInput,
|
||||
getLoginTokenFromCredentialsInput: UserCredentialsInput,
|
||||
@Args('origin') origin: string,
|
||||
): Promise<LoginToken> {
|
||||
const workspace =
|
||||
@ -156,7 +164,7 @@ export class AuthResolver {
|
||||
),
|
||||
);
|
||||
|
||||
const user = await this.authService.getLoginTokenFromCredentials(
|
||||
const user = await this.authService.validateLoginWithPassword(
|
||||
getLoginTokenFromCredentialsInput,
|
||||
workspace,
|
||||
);
|
||||
@ -164,17 +172,58 @@ export class AuthResolver {
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
workspace.id,
|
||||
// email validation is active only for password flow
|
||||
AuthProviderEnum.Password,
|
||||
);
|
||||
|
||||
return { loginToken };
|
||||
}
|
||||
|
||||
@Mutation(() => AvailableWorkspacesAndAccessTokensOutput)
|
||||
@UseGuards(CaptchaGuard, PublicEndpointGuard)
|
||||
async signIn(
|
||||
@Args()
|
||||
userCredentials: UserCredentialsInput,
|
||||
): Promise<AvailableWorkspacesAndAccessTokensOutput> {
|
||||
const user =
|
||||
await this.authService.validateLoginWithPassword(userCredentials);
|
||||
|
||||
const availableWorkspaces =
|
||||
await this.userWorkspaceService.findAvailableWorkspacesByEmail(
|
||||
user.email,
|
||||
);
|
||||
|
||||
return {
|
||||
availableWorkspaces:
|
||||
await this.userWorkspaceService.setLoginTokenToAvailableWorkspacesWhenAuthProviderMatch(
|
||||
availableWorkspaces,
|
||||
user,
|
||||
AuthProviderEnum.Password,
|
||||
),
|
||||
tokens: {
|
||||
accessToken:
|
||||
await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken(
|
||||
{
|
||||
userId: user.id,
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
},
|
||||
),
|
||||
refreshToken: await this.refreshTokenService.generateRefreshToken({
|
||||
userId: user.id,
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => GetLoginTokenFromEmailVerificationTokenOutput)
|
||||
@UseGuards(PublicEndpointGuard)
|
||||
async getLoginTokenFromEmailVerificationToken(
|
||||
@Args()
|
||||
getLoginTokenFromEmailVerificationTokenInput: GetLoginTokenFromEmailVerificationTokenInput,
|
||||
@Args('origin') origin: string,
|
||||
@AuthProvider() authProvider: AuthProviderEnum,
|
||||
) {
|
||||
const appToken =
|
||||
await this.emailVerificationTokenService.validateEmailVerificationTokenOrThrow(
|
||||
@ -195,6 +244,7 @@ export class AuthResolver {
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
appToken.user.email,
|
||||
workspace.id,
|
||||
authProvider,
|
||||
);
|
||||
|
||||
const workspaceUrls = this.domainManagerService.getWorkspaceUrls(workspace);
|
||||
@ -202,12 +252,59 @@ export class AuthResolver {
|
||||
return { loginToken, workspaceUrls };
|
||||
}
|
||||
|
||||
@Mutation(() => AvailableWorkspacesAndAccessTokensOutput)
|
||||
@UseGuards(CaptchaGuard, PublicEndpointGuard)
|
||||
async signUp(
|
||||
@Args() signUpInput: UserCredentialsInput,
|
||||
): Promise<AvailableWorkspacesAndAccessTokensOutput> {
|
||||
const user = await this.signInUpService.signUpWithoutWorkspace(
|
||||
{
|
||||
email: signUpInput.email,
|
||||
},
|
||||
{
|
||||
provider: AuthProviderEnum.Password,
|
||||
password: signUpInput.password,
|
||||
},
|
||||
);
|
||||
|
||||
const availableWorkspaces =
|
||||
await this.userWorkspaceService.findAvailableWorkspacesByEmail(
|
||||
user.email,
|
||||
);
|
||||
|
||||
return {
|
||||
availableWorkspaces:
|
||||
await this.userWorkspaceService.setLoginTokenToAvailableWorkspacesWhenAuthProviderMatch(
|
||||
availableWorkspaces,
|
||||
user,
|
||||
AuthProviderEnum.Password,
|
||||
),
|
||||
tokens: {
|
||||
accessToken:
|
||||
await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken(
|
||||
{
|
||||
userId: user.id,
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
},
|
||||
),
|
||||
refreshToken: await this.refreshTokenService.generateRefreshToken({
|
||||
userId: user.id,
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => SignUpOutput)
|
||||
async signUp(@Args() signUpInput: SignUpInput): Promise<SignUpOutput> {
|
||||
@UseGuards(CaptchaGuard, PublicEndpointGuard)
|
||||
async signUpInWorkspace(
|
||||
@Args() signUpInput: SignUpInput,
|
||||
@AuthProvider() authProvider: AuthProviderEnum,
|
||||
): Promise<SignUpOutput> {
|
||||
const currentWorkspace = await this.authService.findWorkspaceForSignInUp({
|
||||
workspaceInviteHash: signUpInput.workspaceInviteHash,
|
||||
authProvider: 'password',
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
workspaceId: signUpInput.workspaceId,
|
||||
});
|
||||
|
||||
@ -246,7 +343,7 @@ export class AuthResolver {
|
||||
workspace: currentWorkspace,
|
||||
invitation,
|
||||
authParams: {
|
||||
provider: 'password',
|
||||
provider: AuthProviderEnum.Password,
|
||||
password: signUpInput.password,
|
||||
},
|
||||
});
|
||||
@ -262,6 +359,7 @@ export class AuthResolver {
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
workspace.id,
|
||||
authProvider,
|
||||
);
|
||||
|
||||
return {
|
||||
@ -277,6 +375,7 @@ export class AuthResolver {
|
||||
@UseGuards(UserAuthGuard)
|
||||
async signUpInNewWorkspace(
|
||||
@AuthUser() currentUser: User,
|
||||
@AuthProvider() authProvider: AuthProviderEnum,
|
||||
): Promise<SignUpOutput> {
|
||||
const { user, workspace } = await this.signInUpService.signUpOnNewWorkspace(
|
||||
{ type: 'existingUser', existingUser: currentUser },
|
||||
@ -285,6 +384,7 @@ export class AuthResolver {
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
workspace.id,
|
||||
authProvider,
|
||||
);
|
||||
|
||||
return {
|
||||
@ -320,11 +420,11 @@ export class AuthResolver {
|
||||
return;
|
||||
}
|
||||
const transientToken =
|
||||
await this.transientTokenService.generateTransientToken(
|
||||
workspaceMember.id,
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
await this.transientTokenService.generateTransientToken({
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
workspaceMemberId: workspaceMember.id,
|
||||
});
|
||||
|
||||
return { transientToken };
|
||||
}
|
||||
@ -335,6 +435,14 @@ export class AuthResolver {
|
||||
@Args() getAuthTokensFromLoginTokenInput: GetAuthTokensFromLoginTokenInput,
|
||||
@Args('origin') origin: string,
|
||||
): Promise<AuthTokens> {
|
||||
const {
|
||||
sub: email,
|
||||
workspaceId,
|
||||
authProvider,
|
||||
} = await this.loginTokenService.verifyLoginToken(
|
||||
getAuthTokensFromLoginTokenInput.loginToken,
|
||||
);
|
||||
|
||||
const workspace =
|
||||
await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
|
||||
origin,
|
||||
@ -342,11 +450,6 @@ export class AuthResolver {
|
||||
|
||||
workspaceValidator.assertIsDefinedOrThrow(workspace);
|
||||
|
||||
const { sub: email, workspaceId } =
|
||||
await this.loginTokenService.verifyLoginToken(
|
||||
getAuthTokensFromLoginTokenInput.loginToken,
|
||||
);
|
||||
|
||||
if (workspaceId !== workspace.id) {
|
||||
throw new AuthException(
|
||||
'Token is not valid for this workspace',
|
||||
@ -354,7 +457,7 @@ export class AuthResolver {
|
||||
);
|
||||
}
|
||||
|
||||
return await this.authService.verify(email, workspace.id);
|
||||
return await this.authService.verify(email, workspace.id, authProvider);
|
||||
}
|
||||
|
||||
@Mutation(() => AuthorizeApp)
|
||||
|
||||
@ -6,10 +6,8 @@ import {
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Response } from 'express';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AuthOAuthExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-oauth-exception.filter';
|
||||
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
|
||||
@ -17,23 +15,13 @@ import { GoogleOauthGuard } from 'src/engine/core-modules/auth/guards/google-oau
|
||||
import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/google-provider-enabled.guard';
|
||||
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
||||
import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
|
||||
@Controller('auth/google')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
export class GoogleAuthController {
|
||||
constructor(
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard, PublicEndpointGuard)
|
||||
@ -46,90 +34,11 @@ export class GoogleAuthController {
|
||||
@UseGuards(GoogleProviderEnabledGuard, GoogleOauthGuard, PublicEndpointGuard)
|
||||
@UseFilters(AuthOAuthExceptionFilter)
|
||||
async googleAuthRedirect(@Req() req: GoogleRequest, @Res() res: Response) {
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
email: rawEmail,
|
||||
picture,
|
||||
workspaceInviteHash,
|
||||
workspaceId,
|
||||
billingCheckoutSessionState,
|
||||
locale,
|
||||
} = req.user;
|
||||
|
||||
const email = rawEmail.toLowerCase();
|
||||
|
||||
const currentWorkspace = await this.authService.findWorkspaceForSignInUp({
|
||||
workspaceId,
|
||||
workspaceInviteHash,
|
||||
email,
|
||||
authProvider: 'google',
|
||||
});
|
||||
|
||||
try {
|
||||
const invitation =
|
||||
currentWorkspace && email
|
||||
? await this.authService.findInvitationForSignInUp({
|
||||
currentWorkspace,
|
||||
email,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
const { userData } = this.authService.formatUserDataPayload(
|
||||
{
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
picture,
|
||||
locale,
|
||||
},
|
||||
existingUser,
|
||||
);
|
||||
|
||||
await this.authService.checkAccessForSignIn({
|
||||
userData,
|
||||
invitation,
|
||||
workspaceInviteHash,
|
||||
workspace: currentWorkspace,
|
||||
});
|
||||
|
||||
const { user, workspace } = await this.authService.signInUp({
|
||||
invitation,
|
||||
workspace: currentWorkspace,
|
||||
userData,
|
||||
authParams: {
|
||||
provider: 'google',
|
||||
},
|
||||
billingCheckoutSessionState,
|
||||
});
|
||||
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return res.redirect(
|
||||
this.authService.computeRedirectURI({
|
||||
loginToken: loginToken.token,
|
||||
workspace,
|
||||
billingCheckoutSessionState,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
return res.redirect(
|
||||
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({
|
||||
error,
|
||||
workspace:
|
||||
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
|
||||
currentWorkspace,
|
||||
),
|
||||
pathname: '/verify',
|
||||
}),
|
||||
);
|
||||
}
|
||||
return res.redirect(
|
||||
await this.authService.signInUpWithSocialSSO(
|
||||
req.user,
|
||||
AuthProviderEnum.Google,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,33 +6,21 @@ import {
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Response } from 'express';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
|
||||
import { MicrosoftOAuthGuard } from 'src/engine/core-modules/auth/guards/microsoft-oauth.guard';
|
||||
import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/microsoft-provider-enabled.guard';
|
||||
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
||||
import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
|
||||
@Controller('auth/microsoft')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
export class MicrosoftAuthController {
|
||||
constructor(
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
) {}
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(
|
||||
@ -55,90 +43,11 @@ export class MicrosoftAuthController {
|
||||
@Req() req: MicrosoftRequest,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
email: rawEmail,
|
||||
picture,
|
||||
workspaceInviteHash,
|
||||
workspaceId,
|
||||
billingCheckoutSessionState,
|
||||
locale,
|
||||
} = req.user;
|
||||
|
||||
const email = rawEmail.toLowerCase();
|
||||
|
||||
const currentWorkspace = await this.authService.findWorkspaceForSignInUp({
|
||||
workspaceId,
|
||||
workspaceInviteHash,
|
||||
email,
|
||||
authProvider: 'microsoft',
|
||||
});
|
||||
|
||||
try {
|
||||
const invitation =
|
||||
currentWorkspace && email
|
||||
? await this.authService.findInvitationForSignInUp({
|
||||
currentWorkspace,
|
||||
email,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
const { userData } = this.authService.formatUserDataPayload(
|
||||
{
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
picture,
|
||||
locale,
|
||||
},
|
||||
existingUser,
|
||||
);
|
||||
|
||||
await this.authService.checkAccessForSignIn({
|
||||
userData,
|
||||
invitation,
|
||||
workspaceInviteHash,
|
||||
workspace: currentWorkspace,
|
||||
});
|
||||
|
||||
const { user, workspace } = await this.authService.signInUp({
|
||||
invitation,
|
||||
workspace: currentWorkspace,
|
||||
userData,
|
||||
authParams: {
|
||||
provider: 'microsoft',
|
||||
},
|
||||
billingCheckoutSessionState,
|
||||
});
|
||||
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return res.redirect(
|
||||
this.authService.computeRedirectURI({
|
||||
loginToken: loginToken.token,
|
||||
workspace,
|
||||
billingCheckoutSessionState,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
return res.redirect(
|
||||
this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({
|
||||
error,
|
||||
workspace:
|
||||
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
|
||||
currentWorkspace,
|
||||
),
|
||||
pathname: '/verify',
|
||||
}),
|
||||
);
|
||||
}
|
||||
return res.redirect(
|
||||
await this.authService.signInUpWithSocialSSO(
|
||||
req.user,
|
||||
AuthProviderEnum.Microsoft,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,6 +39,7 @@ import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
|
||||
@Controller('auth')
|
||||
export class SSOAuthController {
|
||||
@ -136,7 +137,7 @@ export class SSOAuthController {
|
||||
workspaceId: workspaceIdentityProvider.workspaceId,
|
||||
workspaceInviteHash: req.user.workspaceInviteHash,
|
||||
email: req.user.email,
|
||||
authProvider: 'sso',
|
||||
authProvider: AuthProviderEnum.SSO,
|
||||
});
|
||||
|
||||
workspaceValidator.assertIsDefinedOrThrow(
|
||||
@ -206,7 +207,7 @@ export class SSOAuthController {
|
||||
workspace: currentWorkspace,
|
||||
invitation,
|
||||
authParams: {
|
||||
provider: 'sso',
|
||||
provider: AuthProviderEnum.SSO,
|
||||
},
|
||||
});
|
||||
|
||||
@ -215,6 +216,7 @@ export class SSOAuthController {
|
||||
loginToken: await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
workspace.id,
|
||||
AuthProviderEnum.SSO,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { AvailableWorkspaces } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
|
||||
|
||||
import { AuthTokenPair } from './token.entity';
|
||||
|
||||
@ObjectType()
|
||||
export class AvailableWorkspacesAndAccessTokensOutput {
|
||||
@Field(() => AuthTokenPair)
|
||||
tokens: AuthTokenPair;
|
||||
|
||||
@Field(() => AvailableWorkspaces)
|
||||
availableWorkspaces: AvailableWorkspaces;
|
||||
}
|
||||
@ -28,13 +28,22 @@ class SSOConnection {
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class AvailableWorkspaceOutput {
|
||||
export class AvailableWorkspace {
|
||||
@Field(() => String)
|
||||
id: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
displayName?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
loginToken?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
personalInviteToken?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
inviteHash?: string;
|
||||
|
||||
@Field(() => WorkspaceUrls)
|
||||
workspaceUrls: WorkspaceUrls;
|
||||
|
||||
@ -44,3 +53,12 @@ export class AvailableWorkspaceOutput {
|
||||
@Field(() => [SSOConnection])
|
||||
sso: SSOConnection[];
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class AvailableWorkspaces {
|
||||
@Field(() => [AvailableWorkspace])
|
||||
availableWorkspacesForSignIn: Array<AvailableWorkspace>;
|
||||
|
||||
@Field(() => [AvailableWorkspace])
|
||||
availableWorkspacesForSignUp: Array<AvailableWorkspace>;
|
||||
}
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
@ArgsType()
|
||||
export class CreateUserAndWorkspaceInput {
|
||||
@Field(() => String)
|
||||
@IsNotEmpty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
firstName?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
lastName?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
picture?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
locale?: keyof typeof APP_LOCALES;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
captchaToken?: string;
|
||||
}
|
||||
@ -41,3 +41,9 @@ export class PasswordResetToken {
|
||||
@Field(() => String)
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class WorkspaceAgnosticToken {
|
||||
@Field(() => AuthToken)
|
||||
token: AuthToken;
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import { ArgsType, Field } from '@nestjs/graphql';
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class GetLoginTokenFromCredentialsInput {
|
||||
export class UserCredentialsInput {
|
||||
@Field(() => String)
|
||||
@IsNotEmpty()
|
||||
@IsEmail()
|
||||
@ -1,33 +1,13 @@
|
||||
import { Field, ObjectType, createUnionType } from '@nestjs/graphql';
|
||||
|
||||
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class UserExists {
|
||||
export class CheckUserExistOutput {
|
||||
@Field(() => Boolean)
|
||||
exists: true;
|
||||
exists: boolean;
|
||||
|
||||
@Field(() => [AvailableWorkspaceOutput])
|
||||
availableWorkspaces: Array<AvailableWorkspaceOutput>;
|
||||
@Field(() => Number)
|
||||
availableWorkspacesCount: number;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isEmailVerified: boolean;
|
||||
}
|
||||
|
||||
@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;
|
||||
},
|
||||
});
|
||||
|
||||
@ -3,7 +3,7 @@ import { ArgsType, Field } from '@nestjs/graphql';
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class CheckUserExistsInput {
|
||||
export class EmailAndCaptchaInput {
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
@ -28,6 +28,7 @@ export class AuthGraphqlApiExceptionFilter implements ExceptionFilter {
|
||||
case AuthExceptionCode.GOOGLE_API_AUTH_DISABLED:
|
||||
case AuthExceptionCode.MICROSOFT_API_AUTH_DISABLED:
|
||||
case AuthExceptionCode.MISSING_ENVIRONMENT_VARIABLE:
|
||||
case AuthExceptionCode.INVALID_JWT_TOKEN_TYPE:
|
||||
throw new ForbiddenError(exception.message);
|
||||
case AuthExceptionCode.EMAIL_NOT_VERIFIED:
|
||||
case AuthExceptionCode.INVALID_DATA:
|
||||
|
||||
@ -2,14 +2,11 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ApiKeyToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyService {
|
||||
constructor(
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
constructor(private readonly jwtWrapperService: JwtWrapperService) {}
|
||||
|
||||
async generateApiKeyToken(
|
||||
workspaceId: string,
|
||||
@ -19,13 +16,8 @@ export class ApiKeyService {
|
||||
if (!apiKeyId) {
|
||||
return;
|
||||
}
|
||||
const jwtPayload = {
|
||||
sub: workspaceId,
|
||||
type: 'API_KEY',
|
||||
workspaceId,
|
||||
};
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
'ACCESS',
|
||||
JwtTokenTypeEnum.ACCESS,
|
||||
workspaceId,
|
||||
);
|
||||
let expiresIn: string | number;
|
||||
@ -37,11 +29,18 @@ export class ApiKeyService {
|
||||
} else {
|
||||
expiresIn = '100y';
|
||||
}
|
||||
const token = this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret,
|
||||
expiresIn,
|
||||
jwtid: apiKeyId,
|
||||
});
|
||||
const token = this.jwtWrapperService.sign(
|
||||
{
|
||||
sub: workspaceId,
|
||||
type: JwtTokenTypeEnum.API_KEY,
|
||||
workspaceId,
|
||||
},
|
||||
{
|
||||
secret,
|
||||
expiresIn,
|
||||
jwtid: apiKeyId,
|
||||
},
|
||||
);
|
||||
|
||||
return { token };
|
||||
}
|
||||
|
||||
@ -4,10 +4,11 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
export class AuthSsoService {
|
||||
constructor(
|
||||
@InjectRepository(Workspace, 'core')
|
||||
@ -15,18 +16,16 @@ export class AuthSsoService {
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
private getAuthProviderColumnNameByProvider(
|
||||
authProvider: WorkspaceAuthProvider,
|
||||
) {
|
||||
if (authProvider === 'google') {
|
||||
private getAuthProviderColumnNameByProvider(authProvider: AuthProviderEnum) {
|
||||
if (authProvider === AuthProviderEnum.Google) {
|
||||
return 'isGoogleAuthEnabled';
|
||||
}
|
||||
|
||||
if (authProvider === 'microsoft') {
|
||||
if (authProvider === AuthProviderEnum.Microsoft) {
|
||||
return 'isMicrosoftAuthEnabled';
|
||||
}
|
||||
|
||||
if (authProvider === 'password') {
|
||||
if (authProvider === AuthProviderEnum.Password) {
|
||||
return 'isPasswordAuthEnabled';
|
||||
}
|
||||
|
||||
@ -34,10 +33,7 @@ export class AuthSsoService {
|
||||
}
|
||||
|
||||
async findWorkspaceFromWorkspaceIdOrAuthProvider(
|
||||
{
|
||||
authProvider,
|
||||
email,
|
||||
}: { authProvider: WorkspaceAuthProvider; email: string },
|
||||
{ authProvider, email }: { authProvider: AuthProviderEnum; email: string },
|
||||
workspaceId?: string,
|
||||
) {
|
||||
if (
|
||||
|
||||
@ -6,6 +6,7 @@ import { Repository } from 'typeorm';
|
||||
import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
|
||||
describe('AuthSsoService', () => {
|
||||
let authSsoService: AuthSsoService;
|
||||
@ -49,7 +50,7 @@ describe('AuthSsoService', () => {
|
||||
|
||||
const result =
|
||||
await authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider(
|
||||
{ authProvider: 'google', email: 'test@example.com' },
|
||||
{ authProvider: AuthProviderEnum.Google, email: 'test@example.com' },
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
@ -63,7 +64,7 @@ describe('AuthSsoService', () => {
|
||||
});
|
||||
|
||||
it('should return a workspace from authProvider and email when multi-workspace mode is enabled', async () => {
|
||||
const authProvider = 'google';
|
||||
const authProvider = AuthProviderEnum.Google;
|
||||
const email = 'test@example.com';
|
||||
const mockWorkspace = { id: 'workspace-id-456' } as Workspace;
|
||||
|
||||
@ -102,7 +103,7 @@ describe('AuthSsoService', () => {
|
||||
|
||||
const result =
|
||||
await authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider({
|
||||
authProvider: 'google',
|
||||
authProvider: AuthProviderEnum.Google,
|
||||
email: 'notfound@example.com',
|
||||
});
|
||||
|
||||
|
||||
@ -14,7 +14,6 @@ import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-u
|
||||
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 { ExistingUserOrNewUser } from 'src/engine/core-modules/auth/types/signInUp.type';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
@ -22,26 +21,26 @@ import { UserService } from 'src/engine/core-modules/user/services/user.service'
|
||||
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 { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
jest.mock('bcrypt');
|
||||
|
||||
const UserFindOneMock = jest.fn();
|
||||
const UserWorkspacefindOneMock = jest.fn();
|
||||
|
||||
const userWorkspaceServiceCheckUserWorkspaceExistsMock = jest.fn();
|
||||
const workspaceInvitationGetOneWorkspaceInvitationMock = jest.fn();
|
||||
const workspaceInvitationValidatePersonalInvitationMock = jest.fn();
|
||||
const userWorkspaceAddUserToWorkspaceMock = jest.fn();
|
||||
|
||||
const twentyConfigServiceGetMock = jest.fn();
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let userService: UserService;
|
||||
let workspaceRepository: Repository<Workspace>;
|
||||
let userRepository: Repository<User>;
|
||||
let authSsoService: AuthSsoService;
|
||||
let userWorkspaceService: UserWorkspaceService;
|
||||
let workspaceInvitationService: WorkspaceInvitationService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@ -56,7 +55,7 @@ describe('AuthService', () => {
|
||||
{
|
||||
provide: getRepositoryToken(User, 'core'),
|
||||
useValue: {
|
||||
findOne: UserFindOneMock,
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -70,6 +69,22 @@ describe('AuthService', () => {
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: LoginTokenService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: DomainManagerService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceAgnosticTokenService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: GuardRedirectService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: SignInUpService,
|
||||
useValue: {},
|
||||
@ -80,10 +95,6 @@ describe('AuthService', () => {
|
||||
get: twentyConfigServiceGetMock,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DomainManagerService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: EmailService,
|
||||
useValue: {},
|
||||
@ -99,10 +110,9 @@ describe('AuthService', () => {
|
||||
{
|
||||
provide: UserWorkspaceService,
|
||||
useValue: {
|
||||
checkUserWorkspaceExists:
|
||||
userWorkspaceServiceCheckUserWorkspaceExistsMock,
|
||||
addUserToWorkspaceIfUserNotInWorkspace:
|
||||
userWorkspaceAddUserToWorkspaceMock,
|
||||
checkUserWorkspaceExists: jest.fn(),
|
||||
addUserToWorkspaceIfUserNotInWorkspace: jest.fn(),
|
||||
findAvailableWorkspacesByEmail: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -114,10 +124,8 @@ describe('AuthService', () => {
|
||||
{
|
||||
provide: WorkspaceInvitationService,
|
||||
useValue: {
|
||||
getOneWorkspaceInvitation:
|
||||
workspaceInvitationGetOneWorkspaceInvitationMock,
|
||||
validatePersonalInvitation:
|
||||
workspaceInvitationValidatePersonalInvitationMock,
|
||||
getOneWorkspaceInvitation: jest.fn(),
|
||||
validatePersonalInvitation: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -131,10 +139,18 @@ describe('AuthService', () => {
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
userService = module.get<UserService>(UserService);
|
||||
workspaceInvitationService = module.get<WorkspaceInvitationService>(
|
||||
WorkspaceInvitationService,
|
||||
);
|
||||
authSsoService = module.get<AuthSsoService>(AuthSsoService);
|
||||
userWorkspaceService =
|
||||
module.get<UserWorkspaceService>(UserWorkspaceService);
|
||||
workspaceRepository = module.get<Repository<Workspace>>(
|
||||
getRepositoryToken(Workspace, 'core'),
|
||||
);
|
||||
userRepository = module.get<Repository<User>>(
|
||||
getRepositoryToken(User, 'core'),
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@ -155,17 +171,17 @@ describe('AuthService', () => {
|
||||
|
||||
(bcrypt.compare as jest.Mock).mockReturnValueOnce(true);
|
||||
|
||||
UserFindOneMock.mockReturnValueOnce({
|
||||
jest.spyOn(userRepository, 'findOne').mockReturnValueOnce({
|
||||
email: user.email,
|
||||
passwordHash: 'passwordHash',
|
||||
captchaToken: user.captchaToken,
|
||||
});
|
||||
} as unknown as Promise<User>);
|
||||
|
||||
UserWorkspacefindOneMock.mockReturnValueOnce({});
|
||||
jest
|
||||
.spyOn(userWorkspaceService, 'checkUserWorkspaceExists')
|
||||
.mockReturnValueOnce({} as any);
|
||||
|
||||
userWorkspaceServiceCheckUserWorkspaceExistsMock.mockReturnValueOnce({});
|
||||
|
||||
const response = await service.getLoginTokenFromCredentials(
|
||||
const response = await service.validateLoginWithPassword(
|
||||
{
|
||||
email: 'email',
|
||||
password: 'password',
|
||||
@ -188,20 +204,32 @@ describe('AuthService', () => {
|
||||
captchaToken: 'captchaToken',
|
||||
};
|
||||
|
||||
UserFindOneMock.mockReturnValueOnce({
|
||||
email: user.email,
|
||||
passwordHash: 'passwordHash',
|
||||
captchaToken: user.captchaToken,
|
||||
});
|
||||
const UserFindOneSpy = jest
|
||||
.spyOn(userRepository, 'findOne')
|
||||
.mockReturnValueOnce({
|
||||
email: user.email,
|
||||
passwordHash: 'passwordHash',
|
||||
captchaToken: user.captchaToken,
|
||||
} as unknown as Promise<User>);
|
||||
|
||||
(bcrypt.compare as jest.Mock).mockReturnValueOnce(true);
|
||||
userWorkspaceServiceCheckUserWorkspaceExistsMock.mockReturnValueOnce(false);
|
||||
jest
|
||||
.spyOn(userWorkspaceService, 'checkUserWorkspaceExists')
|
||||
.mockReturnValueOnce(null as any);
|
||||
|
||||
workspaceInvitationGetOneWorkspaceInvitationMock.mockReturnValueOnce({});
|
||||
workspaceInvitationValidatePersonalInvitationMock.mockReturnValueOnce({});
|
||||
userWorkspaceAddUserToWorkspaceMock.mockReturnValueOnce({});
|
||||
const getOneWorkspaceInvitationSpy = jest
|
||||
.spyOn(workspaceInvitationService, 'getOneWorkspaceInvitation')
|
||||
.mockReturnValueOnce({} as any);
|
||||
|
||||
const response = await service.getLoginTokenFromCredentials(
|
||||
const workspaceInvitationValidatePersonalInvitationSpy = jest
|
||||
.spyOn(workspaceInvitationService, 'validatePersonalInvitation')
|
||||
.mockReturnValueOnce({} as any);
|
||||
|
||||
const addUserToWorkspaceIfUserNotInWorkspaceSpy = jest
|
||||
.spyOn(userWorkspaceService, 'addUserToWorkspaceIfUserNotInWorkspace')
|
||||
.mockReturnValueOnce({} as any);
|
||||
|
||||
const response = await service.validateLoginWithPassword(
|
||||
{
|
||||
email: 'email',
|
||||
password: 'password',
|
||||
@ -218,14 +246,12 @@ describe('AuthService', () => {
|
||||
captchaToken: user.captchaToken,
|
||||
});
|
||||
|
||||
expect(getOneWorkspaceInvitationSpy).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
workspaceInvitationGetOneWorkspaceInvitationMock,
|
||||
workspaceInvitationValidatePersonalInvitationSpy,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
workspaceInvitationValidatePersonalInvitationMock,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(userWorkspaceAddUserToWorkspaceMock).toHaveBeenCalledTimes(1);
|
||||
expect(UserFindOneMock).toHaveBeenCalledTimes(1);
|
||||
expect(addUserToWorkspaceIfUserNotInWorkspaceSpy).toHaveBeenCalledTimes(1);
|
||||
expect(UserFindOneSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('checkAccessForSignIn', () => {
|
||||
@ -418,7 +444,7 @@ describe('AuthService', () => {
|
||||
);
|
||||
|
||||
const result = await service.findWorkspaceForSignInUp({
|
||||
authProvider: 'password',
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
workspaceId: 'workspaceId',
|
||||
});
|
||||
|
||||
@ -438,7 +464,7 @@ describe('AuthService', () => {
|
||||
);
|
||||
|
||||
const result = await service.findWorkspaceForSignInUp({
|
||||
authProvider: 'password',
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
workspaceId: 'workspaceId',
|
||||
workspaceInviteHash: 'workspaceInviteHash',
|
||||
});
|
||||
@ -459,7 +485,7 @@ describe('AuthService', () => {
|
||||
);
|
||||
|
||||
const result = await service.findWorkspaceForSignInUp({
|
||||
authProvider: 'password',
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
workspaceId: 'workspaceId',
|
||||
workspaceInviteHash: 'workspaceInviteHash',
|
||||
});
|
||||
@ -476,7 +502,7 @@ describe('AuthService', () => {
|
||||
.mockResolvedValue({} as Workspace);
|
||||
|
||||
const result = await service.findWorkspaceForSignInUp({
|
||||
authProvider: 'google',
|
||||
authProvider: AuthProviderEnum.Google,
|
||||
workspaceId: 'workspaceId',
|
||||
email: 'email',
|
||||
});
|
||||
@ -493,7 +519,7 @@ describe('AuthService', () => {
|
||||
.mockResolvedValue({} as Workspace);
|
||||
|
||||
const result = await service.findWorkspaceForSignInUp({
|
||||
authProvider: 'sso',
|
||||
authProvider: AuthProviderEnum.SSO,
|
||||
workspaceId: 'workspaceId',
|
||||
email: 'email',
|
||||
});
|
||||
|
||||
@ -30,13 +30,9 @@ import {
|
||||
} from 'src/engine/core-modules/auth/auth.util';
|
||||
import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.entity';
|
||||
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
|
||||
import { GetLoginTokenFromCredentialsInput } from 'src/engine/core-modules/auth/dto/get-login-token-from-credentials.input';
|
||||
import { UserCredentialsInput } from 'src/engine/core-modules/auth/dto/user-credentials.input';
|
||||
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { UpdatePassword } from 'src/engine/core-modules/auth/dto/update-password.entity';
|
||||
import {
|
||||
UserExists,
|
||||
UserNotExists,
|
||||
} from 'src/engine/core-modules/auth/dto/user-exists.entity';
|
||||
import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity';
|
||||
import { AuthSsoService } from 'src/engine/core-modules/auth/services/auth-sso.service';
|
||||
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
|
||||
@ -57,17 +53,27 @@ import { UserService } from 'src/engine/core-modules/user/services/user.service'
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { userValidator } from 'src/engine/core-modules/user/user.validate';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { CheckUserExistOutput } from 'src/engine/core-modules/auth/dto/user-exists.entity';
|
||||
import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy';
|
||||
import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
|
||||
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly accessTokenService: AccessTokenService,
|
||||
private readonly workspaceAgnosticTokenService: WorkspaceAgnosticTokenService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly refreshTokenService: RefreshTokenService,
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
private readonly guardRedirectService: GuardRedirectService,
|
||||
private readonly userWorkspaceService: UserWorkspaceService,
|
||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||
private readonly authSsoService: AuthSsoService,
|
||||
@ -121,11 +127,11 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
async getLoginTokenFromCredentials(
|
||||
input: GetLoginTokenFromCredentialsInput,
|
||||
targetWorkspace: Workspace,
|
||||
async validateLoginWithPassword(
|
||||
input: UserCredentialsInput,
|
||||
targetWorkspace?: Workspace,
|
||||
) {
|
||||
if (!targetWorkspace.isPasswordAuthEnabled) {
|
||||
if (targetWorkspace && !targetWorkspace.isPasswordAuthEnabled) {
|
||||
throw new AuthException(
|
||||
'Email/Password auth is not enabled for this workspace',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
@ -146,7 +152,9 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.checkAccessAndUseInvitationOrThrow(targetWorkspace, user);
|
||||
if (targetWorkspace) {
|
||||
await this.checkAccessAndUseInvitationOrThrow(targetWorkspace, user);
|
||||
}
|
||||
|
||||
if (!user.passwordHash) {
|
||||
throw new AuthException(
|
||||
@ -182,7 +190,7 @@ export class AuthService {
|
||||
userData: ExistingUserOrNewUser['userData'],
|
||||
authParams: Extract<
|
||||
AuthProviderWithPasswordType['authParams'],
|
||||
{ provider: 'password' }
|
||||
{ provider: AuthProviderEnum.Password }
|
||||
>,
|
||||
) {
|
||||
if (userData.type === 'newUser') {
|
||||
@ -203,7 +211,7 @@ export class AuthService {
|
||||
authParams: AuthProviderWithPasswordType['authParams'],
|
||||
workspace: Workspace | undefined | null,
|
||||
) {
|
||||
if (authParams.provider === 'password') {
|
||||
if (authParams.provider === AuthProviderEnum.Password) {
|
||||
await this.validatePassword(userData, authParams);
|
||||
}
|
||||
|
||||
@ -248,7 +256,11 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
async verify(email: string, workspaceId: string): Promise<AuthTokens> {
|
||||
async verify(
|
||||
email: string,
|
||||
workspaceId: string,
|
||||
authProvider: AuthProviderEnum,
|
||||
): Promise<AuthTokens> {
|
||||
if (!email) {
|
||||
throw new AuthException(
|
||||
'Email is required',
|
||||
@ -268,14 +280,17 @@ export class AuthService {
|
||||
// passwordHash is hidden for security reasons
|
||||
user.passwordHash = '';
|
||||
|
||||
const accessToken = await this.accessTokenService.generateAccessToken(
|
||||
user.id,
|
||||
const accessToken = await this.accessTokenService.generateAccessToken({
|
||||
userId: user.id,
|
||||
workspaceId,
|
||||
);
|
||||
const refreshToken = await this.refreshTokenService.generateRefreshToken(
|
||||
user.id,
|
||||
authProvider,
|
||||
});
|
||||
const refreshToken = await this.refreshTokenService.generateRefreshToken({
|
||||
userId: user.id,
|
||||
workspaceId,
|
||||
);
|
||||
authProvider,
|
||||
targetedTokenType: JwtTokenTypeEnum.ACCESS,
|
||||
});
|
||||
|
||||
return {
|
||||
tokens: {
|
||||
@ -285,21 +300,25 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async checkUserExists(email: string): Promise<UserExists | UserNotExists> {
|
||||
async countAvailableWorkspacesByEmail(email: string): Promise<number> {
|
||||
return Object.values(
|
||||
await this.userWorkspaceService.findAvailableWorkspacesByEmail(email),
|
||||
).flat(2).length;
|
||||
}
|
||||
|
||||
async checkUserExists(email: string): Promise<CheckUserExistOutput> {
|
||||
const user = await this.userRepository.findOneBy({
|
||||
email,
|
||||
});
|
||||
|
||||
if (userValidator.isDefined(user)) {
|
||||
return {
|
||||
exists: true,
|
||||
availableWorkspaces:
|
||||
await this.userWorkspaceService.findAvailableWorkspacesByEmail(email),
|
||||
isEmailVerified: user.isEmailVerified,
|
||||
};
|
||||
}
|
||||
const isUserExist = userValidator.isDefined(user);
|
||||
|
||||
return { exists: false };
|
||||
return {
|
||||
exists: isUserExist,
|
||||
availableWorkspacesCount:
|
||||
await this.countAvailableWorkspacesByEmail(email),
|
||||
isEmailVerified: isUserExist ? user.isEmailVerified : false,
|
||||
};
|
||||
}
|
||||
|
||||
async checkWorkspaceInviteHashIsValid(
|
||||
@ -533,10 +552,10 @@ export class AuthService {
|
||||
workspaceInviteHash?: string;
|
||||
} & (
|
||||
| {
|
||||
authProvider: Exclude<WorkspaceAuthProvider, 'password'>;
|
||||
authProvider: Exclude<AuthProviderEnum, AuthProviderEnum.Password>;
|
||||
email: string;
|
||||
}
|
||||
| { authProvider: Extract<WorkspaceAuthProvider, 'password'> }
|
||||
| { authProvider: Extract<AuthProviderEnum, AuthProviderEnum.Password> }
|
||||
),
|
||||
) {
|
||||
if (params.workspaceInviteHash) {
|
||||
@ -550,7 +569,7 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
if (params.authProvider !== 'password') {
|
||||
if (params.authProvider !== AuthProviderEnum.Password) {
|
||||
return (
|
||||
(await this.authSsoService.findWorkspaceFromWorkspaceIdOrAuthProvider(
|
||||
{
|
||||
@ -649,4 +668,142 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async signInUpWithSocialSSO(
|
||||
{
|
||||
firstName,
|
||||
lastName,
|
||||
email: rawEmail,
|
||||
picture,
|
||||
workspaceInviteHash,
|
||||
workspaceId,
|
||||
billingCheckoutSessionState,
|
||||
action,
|
||||
locale,
|
||||
}: MicrosoftRequest['user'] | GoogleRequest['user'],
|
||||
authProvider: AuthProviderEnum.Google | AuthProviderEnum.Microsoft,
|
||||
): Promise<string> {
|
||||
const email = rawEmail.toLowerCase();
|
||||
|
||||
const availableWorkspacesCount =
|
||||
action === 'list-available-workspaces'
|
||||
? await this.countAvailableWorkspacesByEmail(email)
|
||||
: 0;
|
||||
|
||||
const existingUser = await this.userRepository.findOne({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (
|
||||
!workspaceId &&
|
||||
!workspaceInviteHash &&
|
||||
action === 'list-available-workspaces' &&
|
||||
availableWorkspacesCount !== 0
|
||||
) {
|
||||
const user =
|
||||
existingUser ??
|
||||
(await this.signInUpService.signUpWithoutWorkspace(
|
||||
{
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
picture,
|
||||
},
|
||||
{
|
||||
provider: authProvider,
|
||||
},
|
||||
));
|
||||
|
||||
const url = this.domainManagerService.buildBaseUrl({
|
||||
pathname: '/welcome',
|
||||
searchParams: {
|
||||
tokenPair: JSON.stringify({
|
||||
accessToken:
|
||||
await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken(
|
||||
{
|
||||
userId: user.id,
|
||||
authProvider,
|
||||
},
|
||||
),
|
||||
refreshToken: await this.refreshTokenService.generateRefreshToken({
|
||||
userId: user.id,
|
||||
authProvider,
|
||||
targetedTokenType: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
const currentWorkspace =
|
||||
action === 'create-new-workspace'
|
||||
? undefined
|
||||
: await this.findWorkspaceForSignInUp({
|
||||
workspaceId,
|
||||
workspaceInviteHash,
|
||||
email,
|
||||
authProvider,
|
||||
});
|
||||
|
||||
try {
|
||||
const invitation =
|
||||
currentWorkspace && email
|
||||
? await this.findInvitationForSignInUp({
|
||||
currentWorkspace,
|
||||
email,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const { userData } = this.formatUserDataPayload(
|
||||
{
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
picture,
|
||||
locale,
|
||||
},
|
||||
existingUser,
|
||||
);
|
||||
|
||||
await this.checkAccessForSignIn({
|
||||
userData,
|
||||
invitation,
|
||||
workspaceInviteHash,
|
||||
workspace: currentWorkspace,
|
||||
});
|
||||
|
||||
const { user, workspace } = await this.signInUp({
|
||||
invitation,
|
||||
workspace: currentWorkspace,
|
||||
userData,
|
||||
authParams: {
|
||||
provider: AuthProviderEnum.Google,
|
||||
},
|
||||
billingCheckoutSessionState,
|
||||
});
|
||||
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
workspace.id,
|
||||
authProvider,
|
||||
);
|
||||
|
||||
return this.computeRedirectURI({
|
||||
loginToken: loginToken.token,
|
||||
workspace,
|
||||
billingCheckoutSessionState,
|
||||
});
|
||||
} catch (error) {
|
||||
return this.guardRedirectService.getRedirectErrorUrlAndCaptureExceptions({
|
||||
error,
|
||||
workspace:
|
||||
this.domainManagerService.getSubdomainAndCustomDomainFromWorkspaceFallbackOnDefaultSubdomain(
|
||||
currentWorkspace,
|
||||
),
|
||||
pathname: '/verify',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import {
|
||||
AuthException,
|
||||
@ -28,6 +29,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 { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
|
||||
jest.mock('src/utils/image', () => {
|
||||
return {
|
||||
@ -96,6 +98,10 @@ describe('SignInUpService', () => {
|
||||
provide: HttpService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: LoginTokenService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: {
|
||||
@ -155,7 +161,10 @@ describe('SignInUpService', () => {
|
||||
id: 'workspaceId',
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
} as Workspace,
|
||||
authParams: { provider: 'password', password: 'validPassword' },
|
||||
authParams: {
|
||||
provider: AuthProviderEnum.Password,
|
||||
password: 'validPassword',
|
||||
},
|
||||
userData: {
|
||||
type: 'existingUser',
|
||||
existingUser: { email: 'test@example.com' } as User,
|
||||
@ -206,7 +215,10 @@ describe('SignInUpService', () => {
|
||||
id: 'workspaceId',
|
||||
activationStatus: WorkspaceActivationStatus.ACTIVE,
|
||||
} as Workspace,
|
||||
authParams: { provider: 'password', password: 'validPassword' },
|
||||
authParams: {
|
||||
provider: AuthProviderEnum.Password,
|
||||
password: 'validPassword',
|
||||
},
|
||||
userData: {
|
||||
type: 'existingUser',
|
||||
existingUser: { email: 'test@example.com' } as User,
|
||||
@ -230,7 +242,10 @@ describe('SignInUpService', () => {
|
||||
const params: SignInUpBaseParams &
|
||||
ExistingUserOrPartialUserWithPicture &
|
||||
AuthProviderWithPasswordType = {
|
||||
authParams: { provider: 'password', password: 'validPassword' },
|
||||
authParams: {
|
||||
provider: AuthProviderEnum.Password,
|
||||
password: 'validPassword',
|
||||
},
|
||||
userData: {
|
||||
type: 'newUserWithPicture',
|
||||
newUserWithPicture: {
|
||||
@ -283,7 +298,10 @@ describe('SignInUpService', () => {
|
||||
id: 'workspaceId',
|
||||
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
|
||||
} as Workspace,
|
||||
authParams: { provider: 'password', password: 'validPassword' },
|
||||
authParams: {
|
||||
provider: AuthProviderEnum.Password,
|
||||
password: 'validPassword',
|
||||
},
|
||||
userData: {
|
||||
type: 'existingUser',
|
||||
existingUser: { email: 'test@example.com' } as User,
|
||||
@ -315,7 +333,10 @@ describe('SignInUpService', () => {
|
||||
id: 'workspaceId',
|
||||
activationStatus: WorkspaceActivationStatus.PENDING_CREATION,
|
||||
} as Workspace,
|
||||
authParams: { provider: 'password', password: 'validPassword' },
|
||||
authParams: {
|
||||
provider: AuthProviderEnum.Password,
|
||||
password: 'validPassword',
|
||||
},
|
||||
userData: {
|
||||
type: 'existingUser',
|
||||
existingUser: { email: 'test@example.com' } as User,
|
||||
@ -340,7 +361,10 @@ describe('SignInUpService', () => {
|
||||
ExistingUserOrPartialUserWithPicture &
|
||||
AuthProviderWithPasswordType = {
|
||||
workspace: null,
|
||||
authParams: { provider: 'password', password: 'validPassword' },
|
||||
authParams: {
|
||||
provider: AuthProviderEnum.Password,
|
||||
password: 'validPassword',
|
||||
},
|
||||
userData: {
|
||||
type: 'existingUser',
|
||||
existingUser: { email: 'existinguser@example.com' } as User,
|
||||
|
||||
@ -25,8 +25,6 @@ import {
|
||||
SignInUpNewUserPayload,
|
||||
} from 'src/engine/core-modules/auth/types/signInUp.type';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.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 { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
@ -34,9 +32,10 @@ import { UserService } from 'src/engine/core-modules/user/services/user.service'
|
||||
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 { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
|
||||
import { isWorkEmail } from 'src/utils/is-work-email';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
|
||||
@Injectable()
|
||||
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
|
||||
@ -46,16 +45,14 @@ export class SignInUpService {
|
||||
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 loginTokenService: LoginTokenService,
|
||||
private readonly httpService: HttpService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly userService: UserService,
|
||||
private readonly userRoleService: UserRoleService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
) {}
|
||||
|
||||
async computeParamsForNewUser(
|
||||
@ -72,7 +69,7 @@ export class SignInUpService {
|
||||
);
|
||||
}
|
||||
|
||||
if (authParams.provider === 'password') {
|
||||
if (authParams.provider === AuthProviderEnum.Password) {
|
||||
newUserParams.passwordHash = await this.generateHash(authParams.password);
|
||||
}
|
||||
|
||||
@ -293,11 +290,27 @@ export class SignInUpService {
|
||||
return await this.userRepository.save(userCreated);
|
||||
}
|
||||
|
||||
private async setDefaultImpersonateAndAccessFullAdminPanel() {
|
||||
if (!this.twentyConfigService.get('IS_MULTIWORKSPACE_ENABLED')) {
|
||||
const workspacesCount = await this.workspaceRepository.count();
|
||||
|
||||
// let the creation of the first workspace
|
||||
if (workspacesCount > 0) {
|
||||
throw new AuthException(
|
||||
'New workspace setup is disabled',
|
||||
AuthExceptionCode.SIGNUP_DISABLED,
|
||||
);
|
||||
}
|
||||
|
||||
return { canImpersonate: true, canAccessFullAdminPanel: true };
|
||||
}
|
||||
|
||||
return { canImpersonate: false, canAccessFullAdminPanel: false };
|
||||
}
|
||||
|
||||
async signUpOnNewWorkspace(
|
||||
userData: ExistingUserOrPartialUserWithPicture['userData'],
|
||||
) {
|
||||
let canImpersonate = false;
|
||||
let canAccessFullAdminPanel = false;
|
||||
const email =
|
||||
userData.type === 'newUserWithPicture'
|
||||
? userData.newUserWithPicture.email
|
||||
@ -310,21 +323,8 @@ export class SignInUpService {
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.twentyConfigService.get('IS_MULTIWORKSPACE_ENABLED')) {
|
||||
const workspacesCount = await this.workspaceRepository.count();
|
||||
|
||||
// if the workspace doesn't exist it means it's the first user of the workspace
|
||||
canImpersonate = true;
|
||||
canAccessFullAdminPanel = true;
|
||||
|
||||
// let the creation of the first workspace
|
||||
if (workspacesCount > 0) {
|
||||
throw new AuthException(
|
||||
'New workspace setup is disabled',
|
||||
AuthExceptionCode.SIGNUP_DISABLED,
|
||||
);
|
||||
}
|
||||
}
|
||||
const { canImpersonate, canAccessFullAdminPanel } =
|
||||
await this.setDefaultImpersonateAndAccessFullAdminPanel();
|
||||
|
||||
const logoUrl = `${TWENTY_ICONS_BASE_URL}/${getDomainNameByEmail(email)}`;
|
||||
const isLogoUrlValid = async () => {
|
||||
@ -380,4 +380,14 @@ export class SignInUpService {
|
||||
|
||||
return { user, workspace };
|
||||
}
|
||||
|
||||
async signUpWithoutWorkspace(
|
||||
newUserParams: SignInUpNewUserPayload,
|
||||
authParams: AuthProviderWithPasswordType['authParams'],
|
||||
) {
|
||||
return this.saveNewUser(
|
||||
await this.computeParamsForNewUser(newUserParams, authParams),
|
||||
await this.setDefaultImpersonateAndAccessFullAdminPanel(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import { Strategy, VerifyCallback } from 'passport-google-oauth20';
|
||||
import { APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { SocialSSOSignInUpActionType } from 'src/engine/core-modules/auth/types/signInUp.type';
|
||||
|
||||
export type GoogleRequest = Omit<
|
||||
Request,
|
||||
@ -19,6 +20,7 @@ export type GoogleRequest = Omit<
|
||||
locale?: keyof typeof APP_LOCALES | null;
|
||||
workspaceInviteHash?: string;
|
||||
workspacePersonalInviteToken?: string;
|
||||
action: SocialSSOSignInUpActionType;
|
||||
workspaceId?: string;
|
||||
billingCheckoutSessionState?: string;
|
||||
};
|
||||
@ -45,6 +47,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
workspaceId: req.params.workspaceId,
|
||||
billingCheckoutSessionState: req.query.billingCheckoutSessionState,
|
||||
workspacePersonalInviteToken: req.query.workspacePersonalInviteToken,
|
||||
action: req.query.action,
|
||||
}),
|
||||
};
|
||||
|
||||
@ -53,8 +56,8 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
|
||||
async validate(
|
||||
request: GoogleRequest,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
_accessToken: string,
|
||||
_refreshToken: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
profile: any,
|
||||
done: VerifyCallback,
|
||||
@ -74,6 +77,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
workspacePersonalInviteToken: state.workspacePersonalInviteToken,
|
||||
workspaceId: state.workspaceId,
|
||||
billingCheckoutSessionState: state.billingCheckoutSessionState,
|
||||
action: state.action,
|
||||
locale: state.locale,
|
||||
};
|
||||
|
||||
|
||||
@ -155,12 +155,12 @@ describe('JwtAuthStrategy', () => {
|
||||
);
|
||||
|
||||
await expect(strategy.validate(payload as JwtPayload)).rejects.toThrow(
|
||||
new AuthException('User not found', expect.any(String)),
|
||||
new AuthException('UserWorkspace not found', expect.any(String)),
|
||||
);
|
||||
try {
|
||||
await strategy.validate(payload as JwtPayload);
|
||||
} catch (e) {
|
||||
expect(e.code).toBe(AuthExceptionCode.USER_NOT_FOUND);
|
||||
expect(e.code).toBe(AuthExceptionCode.USER_WORKSPACE_NOT_FOUND);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -10,8 +10,12 @@ import {
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import {
|
||||
AccessTokenJwtPayload,
|
||||
ApiKeyTokenJwtPayload,
|
||||
AuthContext,
|
||||
FileTokenJwtPayload,
|
||||
JwtPayload,
|
||||
WorkspaceAgnosticTokenJwtPayload,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
@ -19,6 +23,9 @@ import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
|
||||
import { userWorkspaceValidator } from 'src/engine/core-modules/user-workspace/user-workspace.validate';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { userValidator } from 'src/engine/core-modules/user/user.validate';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
@ -36,13 +43,20 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
const secretOrKeyProviderFunction = async (_request, rawJwtToken, done) => {
|
||||
try {
|
||||
const decodedToken = jwtWrapperService.decode(
|
||||
rawJwtToken,
|
||||
) as JwtPayload;
|
||||
const workspaceId = decodedToken.workspaceId;
|
||||
const decodedToken = jwtWrapperService.decode<
|
||||
| FileTokenJwtPayload
|
||||
| AccessTokenJwtPayload
|
||||
| WorkspaceAgnosticTokenJwtPayload
|
||||
>(rawJwtToken);
|
||||
|
||||
const appSecretBody =
|
||||
decodedToken.type === 'WORKSPACE_AGNOSTIC'
|
||||
? decodedToken.userId
|
||||
: decodedToken.workspaceId;
|
||||
|
||||
const secret = jwtWrapperService.generateAppSecret(
|
||||
'ACCESS',
|
||||
workspaceId,
|
||||
decodedToken.type,
|
||||
appSecretBody,
|
||||
);
|
||||
|
||||
done(null, secret);
|
||||
@ -58,19 +72,20 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
});
|
||||
}
|
||||
|
||||
private async validateAPIKey(payload: JwtPayload): Promise<AuthContext> {
|
||||
let apiKey: ApiKeyWorkspaceEntity | null = null;
|
||||
|
||||
private async validateAPIKey(
|
||||
payload: ApiKeyTokenJwtPayload,
|
||||
): Promise<AuthContext> {
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
id: payload['sub'],
|
||||
id: payload.sub,
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
throw new AuthException(
|
||||
workspaceValidator.assertIsDefinedOrThrow(
|
||||
workspace,
|
||||
new AuthException(
|
||||
'Workspace not found',
|
||||
AuthExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
|
||||
const apiKeyRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ApiKeyWorkspaceEntity>(
|
||||
@ -78,7 +93,7 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
'apiKey',
|
||||
);
|
||||
|
||||
apiKey = await apiKeyRepository.findOne({
|
||||
const apiKey = await apiKeyRepository.findOne({
|
||||
where: {
|
||||
id: payload.jti,
|
||||
},
|
||||
@ -91,13 +106,15 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
);
|
||||
}
|
||||
|
||||
return { apiKey, workspace };
|
||||
return { apiKey, workspace, workspaceMemberId: payload.workspaceMemberId };
|
||||
}
|
||||
|
||||
private async validateAccessToken(payload: JwtPayload): Promise<AuthContext> {
|
||||
private async validateAccessToken(
|
||||
payload: AccessTokenJwtPayload,
|
||||
): Promise<AuthContext> {
|
||||
let user: User | null = null;
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
id: payload['workspaceId'],
|
||||
id: payload.workspaceId,
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
@ -107,16 +124,19 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
);
|
||||
}
|
||||
|
||||
user = await this.userRepository.findOne({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
if (!user) {
|
||||
const userId = payload.sub ?? payload.userId;
|
||||
|
||||
if (!userId) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.USER_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!payload.userWorkspaceId) {
|
||||
throw new AuthException(
|
||||
'UserWorkspace not found',
|
||||
@ -130,27 +150,62 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
},
|
||||
});
|
||||
|
||||
if (!userWorkspace) {
|
||||
throw new AuthException(
|
||||
userWorkspaceValidator.assertIsDefinedOrThrow(
|
||||
userWorkspace,
|
||||
new AuthException(
|
||||
'UserWorkspace not found',
|
||||
AuthExceptionCode.USER_WORKSPACE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
|
||||
return { user, workspace, userWorkspaceId: userWorkspace.id };
|
||||
return {
|
||||
user,
|
||||
workspace,
|
||||
authProvider: payload.authProvider,
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
workspaceMemberId: payload.workspaceMemberId,
|
||||
};
|
||||
}
|
||||
|
||||
private async validateWorkspaceAgnosticToken(
|
||||
payload: WorkspaceAgnosticTokenJwtPayload,
|
||||
) {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: payload.sub },
|
||||
});
|
||||
|
||||
userValidator.assertIsDefinedOrThrow(
|
||||
user,
|
||||
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
return { user, authProvider: payload.authProvider };
|
||||
}
|
||||
|
||||
private isLegacyApiKeyPayload(
|
||||
payload: JwtPayload,
|
||||
): payload is ApiKeyTokenJwtPayload {
|
||||
return !payload.type && !('workspaceId' in payload);
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload): Promise<AuthContext> {
|
||||
const workspaceMemberId = payload.workspaceMemberId;
|
||||
|
||||
if (!payload.type && !payload.workspaceId) {
|
||||
return { ...(await this.validateAPIKey(payload)), workspaceMemberId };
|
||||
// Support legacy api keys
|
||||
if (payload.type === 'API_KEY' || this.isLegacyApiKeyPayload(payload)) {
|
||||
return await this.validateAPIKey(payload);
|
||||
}
|
||||
|
||||
if (payload.type === 'API_KEY') {
|
||||
return { ...(await this.validateAPIKey(payload)), workspaceMemberId };
|
||||
if (payload.type === 'WORKSPACE_AGNOSTIC') {
|
||||
return await this.validateWorkspaceAgnosticToken(payload);
|
||||
}
|
||||
|
||||
return { ...(await this.validateAccessToken(payload)), workspaceMemberId };
|
||||
// `!payload.type` is here to support legacy token
|
||||
if (payload.type === 'ACCESS' || !payload.type) {
|
||||
return await this.validateAccessToken(payload);
|
||||
}
|
||||
|
||||
throw new AuthException(
|
||||
'Invalid token',
|
||||
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { SocialSSOSignInUpActionType } from 'src/engine/core-modules/auth/types/signInUp.type';
|
||||
|
||||
export type MicrosoftRequest = Omit<
|
||||
Request,
|
||||
@ -25,6 +26,7 @@ export type MicrosoftRequest = Omit<
|
||||
workspacePersonalInviteToken?: string;
|
||||
workspaceId?: string;
|
||||
billingCheckoutSessionState?: string;
|
||||
action: SocialSSOSignInUpActionType;
|
||||
};
|
||||
};
|
||||
|
||||
@ -50,6 +52,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
|
||||
locale: req.query.locale,
|
||||
billingCheckoutSessionState: req.query.billingCheckoutSessionState,
|
||||
workspacePersonalInviteToken: req.query.workspacePersonalInviteToken,
|
||||
action: req.query.action,
|
||||
}),
|
||||
};
|
||||
|
||||
@ -58,8 +61,8 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
|
||||
|
||||
async validate(
|
||||
request: MicrosoftRequest,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
_accessToken: string,
|
||||
_refreshToken: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
profile: any,
|
||||
done: VerifyCallback,
|
||||
@ -90,6 +93,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') {
|
||||
workspaceId: state.workspaceId,
|
||||
billingCheckoutSessionState: state.billingCheckoutSessionState,
|
||||
locale: state.locale,
|
||||
action: state.action,
|
||||
};
|
||||
|
||||
done(null, user);
|
||||
|
||||
@ -36,7 +36,7 @@ describe('AccessTokenService', () => {
|
||||
provide: JwtWrapperService,
|
||||
useValue: {
|
||||
sign: jest.fn(),
|
||||
verifyWorkspaceToken: jest.fn(),
|
||||
verifyJwtToken: jest.fn(),
|
||||
decode: jest.fn(),
|
||||
generateAppSecret: jest.fn(),
|
||||
extractJwtFromRequest: jest.fn(),
|
||||
@ -138,7 +138,7 @@ describe('AccessTokenService', () => {
|
||||
} as any);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
|
||||
const result = await service.generateAccessToken(userId, workspaceId);
|
||||
const result = await service.generateAccessToken({ userId, workspaceId });
|
||||
|
||||
expect(result).toEqual({
|
||||
token: mockToken,
|
||||
@ -159,7 +159,10 @@ describe('AccessTokenService', () => {
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.generateAccessToken('non-existent-user', 'workspace-id'),
|
||||
service.generateAccessToken({
|
||||
userId: 'non-existent-user',
|
||||
workspaceId: 'workspace-id',
|
||||
}),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
});
|
||||
@ -184,7 +187,7 @@ describe('AccessTokenService', () => {
|
||||
.spyOn(jwtWrapperService, 'extractJwtFromRequest')
|
||||
.mockReturnValue(() => mockToken);
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
|
||||
.spyOn(jwtWrapperService, 'verifyJwtToken')
|
||||
.mockResolvedValue(undefined);
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'decode')
|
||||
@ -196,7 +199,7 @@ describe('AccessTokenService', () => {
|
||||
const result = await service.validateTokenByRequest(mockRequest);
|
||||
|
||||
expect(result).toEqual(mockAuthContext);
|
||||
expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith(
|
||||
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(
|
||||
mockToken,
|
||||
'ACCESS',
|
||||
);
|
||||
|
||||
@ -14,8 +14,9 @@ import {
|
||||
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
|
||||
import {
|
||||
AccessTokenJwtPayload,
|
||||
AuthContext,
|
||||
JwtPayload,
|
||||
JwtTokenTypeEnum,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
@ -26,6 +27,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
import { userWorkspaceValidator } from 'src/engine/core-modules/user-workspace/user-workspace.validate';
|
||||
|
||||
@Injectable()
|
||||
export class AccessTokenService {
|
||||
@ -42,10 +44,14 @@ export class AccessTokenService {
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
) {}
|
||||
|
||||
async generateAccessToken(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<AuthToken> {
|
||||
async generateAccessToken({
|
||||
userId,
|
||||
workspaceId,
|
||||
authProvider,
|
||||
}: Omit<
|
||||
AccessTokenJwtPayload,
|
||||
'type' | 'workspaceMemberId' | 'userWorkspaceId' | 'sub'
|
||||
>): Promise<AuthToken> {
|
||||
const expiresIn = this.twentyConfigService.get('ACCESS_TOKEN_EXPIRES_IN');
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
@ -99,16 +105,24 @@ export class AccessTokenService {
|
||||
},
|
||||
});
|
||||
|
||||
const jwtPayload: JwtPayload = {
|
||||
userWorkspaceValidator.assertIsDefinedOrThrow(userWorkspace);
|
||||
|
||||
const jwtPayload: AccessTokenJwtPayload = {
|
||||
sub: user.id,
|
||||
userId: user.id,
|
||||
workspaceId,
|
||||
workspaceMemberId: tokenWorkspaceMemberId,
|
||||
userWorkspaceId: userWorkspace?.id,
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
type: JwtTokenTypeEnum.ACCESS,
|
||||
authProvider,
|
||||
};
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret: this.jwtWrapperService.generateAppSecret('ACCESS', workspaceId),
|
||||
secret: this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.ACCESS,
|
||||
workspaceId,
|
||||
),
|
||||
expiresIn,
|
||||
}),
|
||||
expiresAt,
|
||||
@ -116,14 +130,27 @@ export class AccessTokenService {
|
||||
}
|
||||
|
||||
async validateToken(token: string): Promise<AuthContext> {
|
||||
await this.jwtWrapperService.verifyWorkspaceToken(token, 'ACCESS');
|
||||
await this.jwtWrapperService.verifyJwtToken(token, JwtTokenTypeEnum.ACCESS);
|
||||
|
||||
const decoded = await this.jwtWrapperService.decode(token);
|
||||
const decoded = this.jwtWrapperService.decode<AccessTokenJwtPayload>(token);
|
||||
|
||||
const { user, apiKey, workspace, workspaceMemberId, userWorkspaceId } =
|
||||
await this.jwtStrategy.validate(decoded as JwtPayload);
|
||||
const {
|
||||
user,
|
||||
apiKey,
|
||||
workspace,
|
||||
workspaceMemberId,
|
||||
userWorkspaceId,
|
||||
authProvider,
|
||||
} = await this.jwtStrategy.validate(decoded);
|
||||
|
||||
return { user, apiKey, workspace, workspaceMemberId, userWorkspaceId };
|
||||
return {
|
||||
user,
|
||||
apiKey,
|
||||
workspace,
|
||||
workspaceMemberId,
|
||||
userWorkspaceId,
|
||||
authProvider,
|
||||
};
|
||||
}
|
||||
|
||||
async validateTokenByRequest(request: Request): Promise<AuthContext> {
|
||||
|
||||
@ -19,7 +19,7 @@ describe('LoginTokenService', () => {
|
||||
useValue: {
|
||||
generateAppSecret: jest.fn(),
|
||||
sign: jest.fn(),
|
||||
verifyWorkspaceToken: jest.fn(),
|
||||
verifyJwtToken: jest.fn(),
|
||||
decode: jest.fn(),
|
||||
},
|
||||
},
|
||||
@ -69,7 +69,7 @@ describe('LoginTokenService', () => {
|
||||
'LOGIN_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
{ sub: email, workspaceId },
|
||||
{ sub: email, workspaceId, type: 'LOGIN' },
|
||||
{ secret: mockSecret, expiresIn: mockExpiresIn },
|
||||
);
|
||||
});
|
||||
@ -81,7 +81,7 @@ describe('LoginTokenService', () => {
|
||||
const mockEmail = 'test@example.com';
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
|
||||
.spyOn(jwtWrapperService, 'verifyJwtToken')
|
||||
.mockResolvedValue(undefined);
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'decode')
|
||||
@ -90,7 +90,7 @@ describe('LoginTokenService', () => {
|
||||
const result = await service.verifyLoginToken(mockToken);
|
||||
|
||||
expect(result).toEqual({ sub: mockEmail });
|
||||
expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith(
|
||||
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(
|
||||
mockToken,
|
||||
'LOGIN',
|
||||
);
|
||||
@ -103,7 +103,7 @@ describe('LoginTokenService', () => {
|
||||
const mockToken = 'invalid-token';
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
|
||||
.spyOn(jwtWrapperService, 'verifyJwtToken')
|
||||
.mockRejectedValue(new Error('Invalid token'));
|
||||
|
||||
await expect(service.verifyLoginToken(mockToken)).rejects.toThrow();
|
||||
|
||||
@ -6,6 +6,11 @@ import ms from 'ms';
|
||||
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import {
|
||||
LoginTokenJwtPayload,
|
||||
JwtTokenTypeEnum,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
|
||||
@Injectable()
|
||||
export class LoginTokenService {
|
||||
@ -17,19 +22,23 @@ export class LoginTokenService {
|
||||
async generateLoginToken(
|
||||
email: string,
|
||||
workspaceId: string,
|
||||
authProvider?: AuthProviderEnum,
|
||||
): Promise<AuthToken> {
|
||||
const jwtPayload: LoginTokenJwtPayload = {
|
||||
type: JwtTokenTypeEnum.LOGIN,
|
||||
sub: email,
|
||||
workspaceId,
|
||||
authProvider,
|
||||
};
|
||||
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
'LOGIN',
|
||||
jwtPayload.type,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const expiresIn = this.twentyConfigService.get('LOGIN_TOKEN_EXPIRES_IN');
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
const jwtPayload = {
|
||||
sub: email,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
@ -40,10 +49,15 @@ export class LoginTokenService {
|
||||
};
|
||||
}
|
||||
|
||||
async verifyLoginToken(
|
||||
loginToken: string,
|
||||
): Promise<{ sub: string; workspaceId: string }> {
|
||||
await this.jwtWrapperService.verifyWorkspaceToken(loginToken, 'LOGIN');
|
||||
async verifyLoginToken(loginToken: string): Promise<{
|
||||
sub: string;
|
||||
workspaceId: string;
|
||||
authProvider: AuthProviderEnum;
|
||||
}> {
|
||||
await this.jwtWrapperService.verifyJwtToken(
|
||||
loginToken,
|
||||
JwtTokenTypeEnum.LOGIN,
|
||||
);
|
||||
|
||||
return this.jwtWrapperService.decode(loginToken, {
|
||||
json: true,
|
||||
|
||||
@ -8,6 +8,7 @@ import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
|
||||
import { RefreshTokenService } from './refresh-token.service';
|
||||
|
||||
@ -25,7 +26,7 @@ describe('RefreshTokenService', () => {
|
||||
{
|
||||
provide: JwtWrapperService,
|
||||
useValue: {
|
||||
verifyWorkspaceToken: jest.fn(),
|
||||
verifyJwtToken: jest.fn(),
|
||||
decode: jest.fn(),
|
||||
sign: jest.fn(),
|
||||
generateAppSecret: jest.fn(),
|
||||
@ -84,7 +85,7 @@ describe('RefreshTokenService', () => {
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
|
||||
.spyOn(jwtWrapperService, 'verifyJwtToken')
|
||||
.mockResolvedValue(undefined);
|
||||
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockJwtPayload);
|
||||
jest
|
||||
@ -96,7 +97,7 @@ describe('RefreshTokenService', () => {
|
||||
const result = await service.verifyRefreshToken(mockToken);
|
||||
|
||||
expect(result).toEqual({ user: mockUser, token: mockAppToken });
|
||||
expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith(
|
||||
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(
|
||||
mockToken,
|
||||
'REFRESH',
|
||||
);
|
||||
@ -106,7 +107,7 @@ describe('RefreshTokenService', () => {
|
||||
const mockToken = 'invalid-token';
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
|
||||
.spyOn(jwtWrapperService, 'verifyJwtToken')
|
||||
.mockResolvedValue(undefined);
|
||||
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue({});
|
||||
|
||||
@ -135,7 +136,11 @@ describe('RefreshTokenService', () => {
|
||||
.spyOn(appTokenRepository, 'save')
|
||||
.mockResolvedValue({ id: 'new-token-id' } as AppToken);
|
||||
|
||||
const result = await service.generateRefreshToken(userId, workspaceId);
|
||||
const result = await service.generateRefreshToken({
|
||||
userId,
|
||||
workspaceId,
|
||||
targetedTokenType: JwtTokenTypeEnum.ACCESS,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
token: mockToken,
|
||||
@ -143,7 +148,13 @@ describe('RefreshTokenService', () => {
|
||||
});
|
||||
expect(appTokenRepository.save).toHaveBeenCalled();
|
||||
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
{ sub: userId, workspaceId },
|
||||
{
|
||||
sub: userId,
|
||||
workspaceId,
|
||||
type: 'REFRESH',
|
||||
userId: 'user-id',
|
||||
targetedTokenType: 'ACCESS',
|
||||
},
|
||||
expect.objectContaining({
|
||||
secret: 'mock-secret',
|
||||
expiresIn: mockExpiresIn,
|
||||
@ -156,7 +167,11 @@ describe('RefreshTokenService', () => {
|
||||
jest.spyOn(twentyConfigService, 'get').mockReturnValue(undefined);
|
||||
|
||||
await expect(
|
||||
service.generateRefreshToken('user-id', 'workspace-id'),
|
||||
service.generateRefreshToken({
|
||||
userId: 'user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
targetedTokenType: JwtTokenTypeEnum.ACCESS,
|
||||
}),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
});
|
||||
|
||||
@ -17,6 +17,10 @@ import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import {
|
||||
RefreshTokenJwtPayload,
|
||||
JwtTokenTypeEnum,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
|
||||
@Injectable()
|
||||
export class RefreshTokenService {
|
||||
@ -32,8 +36,12 @@ export class RefreshTokenService {
|
||||
async verifyRefreshToken(refreshToken: string) {
|
||||
const coolDown = this.twentyConfigService.get('REFRESH_TOKEN_COOL_DOWN');
|
||||
|
||||
await this.jwtWrapperService.verifyWorkspaceToken(refreshToken, 'REFRESH');
|
||||
const jwtPayload = await this.jwtWrapperService.decode(refreshToken);
|
||||
await this.jwtWrapperService.verifyJwtToken(
|
||||
refreshToken,
|
||||
JwtTokenTypeEnum.REFRESH,
|
||||
);
|
||||
const jwtPayload =
|
||||
this.jwtWrapperService.decode<RefreshTokenJwtPayload>(refreshToken);
|
||||
|
||||
if (!(jwtPayload.jti && jwtPayload.sub)) {
|
||||
throw new AuthException(
|
||||
@ -90,24 +98,20 @@ export class RefreshTokenService {
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Delete this useless condition and error after March 31st 2025
|
||||
if (!token.workspaceId) {
|
||||
throw new AuthException(
|
||||
'This refresh token is malformed',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
return { user, token };
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
authProvider: jwtPayload.authProvider,
|
||||
targetedTokenType: jwtPayload.targetedTokenType,
|
||||
};
|
||||
}
|
||||
|
||||
async generateRefreshToken(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
payload: Omit<RefreshTokenJwtPayload, 'type' | 'sub' | 'jti'>,
|
||||
): Promise<AuthToken> {
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
'REFRESH',
|
||||
workspaceId,
|
||||
JwtTokenTypeEnum.REFRESH,
|
||||
payload.workspaceId ?? payload.userId,
|
||||
);
|
||||
const expiresIn = this.twentyConfigService.get('REFRESH_TOKEN_EXPIRES_IN');
|
||||
|
||||
@ -120,28 +124,27 @@ export class RefreshTokenService {
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
|
||||
const refreshTokenPayload = {
|
||||
userId,
|
||||
const refreshToken = this.appTokenRepository.create({
|
||||
...payload,
|
||||
expiresAt,
|
||||
workspaceId,
|
||||
type: AppTokenType.RefreshToken,
|
||||
};
|
||||
const jwtPayload = {
|
||||
sub: userId,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
const refreshToken = this.appTokenRepository.create(refreshTokenPayload);
|
||||
});
|
||||
|
||||
await this.appTokenRepository.save(refreshToken);
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret,
|
||||
expiresIn,
|
||||
// Jwtid will be used to link RefreshToken entity to this token
|
||||
jwtid: refreshToken.id,
|
||||
}),
|
||||
token: this.jwtWrapperService.sign(
|
||||
{
|
||||
...payload,
|
||||
sub: payload.userId,
|
||||
type: JwtTokenTypeEnum.REFRESH,
|
||||
},
|
||||
{
|
||||
secret,
|
||||
expiresIn,
|
||||
jwtid: refreshToken.id,
|
||||
},
|
||||
),
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
@ -3,11 +3,13 @@ import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
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 { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
|
||||
|
||||
import { RenewTokenService } from './renew-token.service';
|
||||
|
||||
@ -31,6 +33,12 @@ describe('RenewTokenService', () => {
|
||||
generateAccessToken: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceAgnosticTokenService,
|
||||
useValue: {
|
||||
generateWorkspaceAgnosticToken: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RefreshTokenService,
|
||||
useValue: {
|
||||
@ -66,6 +74,7 @@ describe('RenewTokenService', () => {
|
||||
const mockNewRefreshToken = {
|
||||
token: 'new-refresh-token',
|
||||
expiresAt: new Date(),
|
||||
targetedTokenType: JwtTokenTypeEnum.ACCESS,
|
||||
};
|
||||
const mockAppToken: Partial<AppToken> = {
|
||||
id: mockTokenId,
|
||||
@ -77,6 +86,8 @@ describe('RenewTokenService', () => {
|
||||
jest.spyOn(refreshTokenService, 'verifyRefreshToken').mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: mockAppToken as AppToken,
|
||||
authProvider: undefined,
|
||||
targetedTokenType: JwtTokenTypeEnum.ACCESS,
|
||||
});
|
||||
jest.spyOn(appTokenRepository, 'update').mockResolvedValue({} as any);
|
||||
jest
|
||||
@ -100,14 +111,16 @@ describe('RenewTokenService', () => {
|
||||
{ id: mockTokenId },
|
||||
{ revokedAt: expect.any(Date) },
|
||||
);
|
||||
expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith({
|
||||
userId: mockUser.id,
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith({
|
||||
authProvider: undefined,
|
||||
targetedTokenType: JwtTokenTypeEnum.ACCESS,
|
||||
userId: mockUser.id,
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if refresh token is not provided', async () => {
|
||||
|
||||
@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import {
|
||||
@ -10,7 +11,9 @@ import {
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
|
||||
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
|
||||
@Injectable()
|
||||
export class RenewTokenService {
|
||||
@ -18,6 +21,7 @@ export class RenewTokenService {
|
||||
@InjectRepository(AppToken, 'core')
|
||||
private readonly appTokenRepository: Repository<AppToken>,
|
||||
private readonly accessTokenService: AccessTokenService,
|
||||
private readonly workspaceAgnosticTokenService: WorkspaceAgnosticTokenService,
|
||||
private readonly refreshTokenService: RefreshTokenService,
|
||||
) {}
|
||||
|
||||
@ -35,6 +39,8 @@ export class RenewTokenService {
|
||||
const {
|
||||
user,
|
||||
token: { id, workspaceId },
|
||||
authProvider,
|
||||
targetedTokenType: targetedTokenTypeFromPayload,
|
||||
} = await this.refreshTokenService.verifyRefreshToken(token);
|
||||
|
||||
// Revoke old refresh token
|
||||
@ -47,14 +53,31 @@ export class RenewTokenService {
|
||||
},
|
||||
);
|
||||
|
||||
const accessToken = await this.accessTokenService.generateAccessToken(
|
||||
user.id,
|
||||
// Support legacy token when targetedTokenType is undefined.
|
||||
const targetedTokenType =
|
||||
targetedTokenTypeFromPayload ?? JwtTokenTypeEnum.ACCESS;
|
||||
|
||||
const accessToken =
|
||||
isDefined(authProvider) &&
|
||||
targetedTokenType === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC
|
||||
? await this.workspaceAgnosticTokenService.generateWorkspaceAgnosticToken(
|
||||
{
|
||||
userId: user.id,
|
||||
authProvider,
|
||||
},
|
||||
)
|
||||
: await this.accessTokenService.generateAccessToken({
|
||||
userId: user.id,
|
||||
workspaceId,
|
||||
authProvider,
|
||||
});
|
||||
|
||||
const refreshToken = await this.refreshTokenService.generateRefreshToken({
|
||||
userId: user.id,
|
||||
workspaceId,
|
||||
);
|
||||
const refreshToken = await this.refreshTokenService.generateRefreshToken(
|
||||
user.id,
|
||||
workspaceId,
|
||||
);
|
||||
authProvider,
|
||||
targetedTokenType,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
|
||||
@ -18,7 +18,7 @@ describe('TransientTokenService', () => {
|
||||
provide: JwtWrapperService,
|
||||
useValue: {
|
||||
sign: jest.fn(),
|
||||
verifyWorkspaceToken: jest.fn(),
|
||||
verifyJwtToken: jest.fn(),
|
||||
decode: jest.fn(),
|
||||
generateAppSecret: jest.fn().mockReturnValue('mocked-secret'),
|
||||
},
|
||||
@ -56,11 +56,11 @@ describe('TransientTokenService', () => {
|
||||
});
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
|
||||
const result = await service.generateTransientToken(
|
||||
const result = await service.generateTransientToken({
|
||||
workspaceMemberId,
|
||||
userId,
|
||||
workspaceId,
|
||||
);
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
token: mockToken,
|
||||
@ -72,8 +72,10 @@ describe('TransientTokenService', () => {
|
||||
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
{
|
||||
sub: workspaceMemberId,
|
||||
type: 'LOGIN',
|
||||
userId,
|
||||
workspaceId,
|
||||
workspaceMemberId,
|
||||
},
|
||||
expect.objectContaining({
|
||||
secret: 'mocked-secret',
|
||||
@ -90,21 +92,23 @@ describe('TransientTokenService', () => {
|
||||
sub: 'workspace-member-id',
|
||||
userId: 'user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
workspaceMemberId: 'workspace-member-id',
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
|
||||
.spyOn(jwtWrapperService, 'verifyJwtToken')
|
||||
.mockResolvedValue(undefined);
|
||||
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
|
||||
|
||||
const result = await service.verifyTransientToken(mockToken);
|
||||
|
||||
expect(result).toEqual({
|
||||
workspaceMemberId: mockPayload.sub,
|
||||
workspaceMemberId: mockPayload.workspaceMemberId,
|
||||
sub: mockPayload.sub,
|
||||
userId: mockPayload.userId,
|
||||
workspaceId: mockPayload.workspaceId,
|
||||
});
|
||||
expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith(
|
||||
expect(jwtWrapperService.verifyJwtToken).toHaveBeenCalledWith(
|
||||
mockToken,
|
||||
'LOGIN',
|
||||
);
|
||||
@ -115,7 +119,7 @@ describe('TransientTokenService', () => {
|
||||
const mockToken = 'invalid-token';
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
|
||||
.spyOn(jwtWrapperService, 'verifyJwtToken')
|
||||
.mockRejectedValue(new Error('Invalid token'));
|
||||
|
||||
await expect(service.verifyTransientToken(mockToken)).rejects.toThrow();
|
||||
|
||||
@ -6,6 +6,10 @@ import ms from 'ms';
|
||||
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import {
|
||||
TransientTokenJwtPayload,
|
||||
JwtTokenTypeEnum,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
|
||||
@Injectable()
|
||||
export class TransientTokenService {
|
||||
@ -14,13 +18,21 @@ export class TransientTokenService {
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
async generateTransientToken(
|
||||
workspaceMemberId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<AuthToken> {
|
||||
async generateTransientToken({
|
||||
workspaceMemberId,
|
||||
workspaceId,
|
||||
userId,
|
||||
}: Omit<TransientTokenJwtPayload, 'type' | 'sub'>): Promise<AuthToken> {
|
||||
const jwtPayload: TransientTokenJwtPayload = {
|
||||
sub: workspaceMemberId,
|
||||
userId: userId,
|
||||
workspaceId: workspaceId,
|
||||
workspaceMemberId: workspaceMemberId,
|
||||
type: JwtTokenTypeEnum.LOGIN,
|
||||
};
|
||||
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
'LOGIN',
|
||||
jwtPayload.type,
|
||||
workspaceId,
|
||||
);
|
||||
const expiresIn = this.twentyConfigService.get(
|
||||
@ -28,11 +40,6 @@ export class TransientTokenService {
|
||||
);
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
const jwtPayload = {
|
||||
sub: workspaceMemberId,
|
||||
userId,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
@ -43,19 +50,17 @@ export class TransientTokenService {
|
||||
};
|
||||
}
|
||||
|
||||
async verifyTransientToken(transientToken: string): Promise<{
|
||||
workspaceMemberId: string;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}> {
|
||||
await this.jwtWrapperService.verifyWorkspaceToken(transientToken, 'LOGIN');
|
||||
async verifyTransientToken(
|
||||
transientToken: string,
|
||||
): Promise<Omit<TransientTokenJwtPayload, 'type' | 'sub'>> {
|
||||
await this.jwtWrapperService.verifyJwtToken(
|
||||
transientToken,
|
||||
JwtTokenTypeEnum.LOGIN,
|
||||
);
|
||||
|
||||
const payload = await this.jwtWrapperService.decode(transientToken);
|
||||
const { type: _type, ...payload } =
|
||||
this.jwtWrapperService.decode<TransientTokenJwtPayload>(transientToken);
|
||||
|
||||
return {
|
||||
workspaceMemberId: payload.sub,
|
||||
userId: payload.userId,
|
||||
workspaceId: payload.workspaceId,
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,188 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
|
||||
describe('WorkspaceAgnosticToken', () => {
|
||||
let service: WorkspaceAgnosticTokenService;
|
||||
let jwtWrapperService: JwtWrapperService;
|
||||
let twentyConfigService: TwentyConfigService;
|
||||
let userRepository: Repository<User>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WorkspaceAgnosticTokenService,
|
||||
{
|
||||
provide: JwtWrapperService,
|
||||
useValue: {
|
||||
sign: jest.fn(),
|
||||
verify: jest.fn(),
|
||||
decode: jest.fn(),
|
||||
generateAppSecret: jest.fn().mockReturnValue('mocked-secret'),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User, 'core'),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WorkspaceAgnosticTokenService>(
|
||||
WorkspaceAgnosticTokenService,
|
||||
);
|
||||
jwtWrapperService = module.get<JwtWrapperService>(JwtWrapperService);
|
||||
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
|
||||
userRepository = module.get<Repository<User>>(
|
||||
getRepositoryToken(User, 'core'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('generateWorkspaceAgnosticToken', () => {
|
||||
it('should generate a workspace agnostic token successfully', async () => {
|
||||
const userId = 'user-id';
|
||||
const mockExpiresIn = '15m';
|
||||
const mockToken = 'mock-token';
|
||||
const mockUser = { id: userId };
|
||||
|
||||
jest.spyOn(twentyConfigService, 'get').mockImplementation((key) => {
|
||||
if (key === 'WORKSPACE_AGNOSTIC_TOKEN_EXPIRES_IN') return mockExpiresIn;
|
||||
|
||||
return undefined;
|
||||
});
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as User);
|
||||
|
||||
const result = await service.generateWorkspaceAgnosticToken({
|
||||
userId,
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
token: mockToken,
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
expect(twentyConfigService.get).toHaveBeenCalledWith(
|
||||
'WORKSPACE_AGNOSTIC_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
expect(userRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
});
|
||||
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
{
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
sub: userId,
|
||||
userId: userId,
|
||||
type: 'WORKSPACE_AGNOSTIC',
|
||||
},
|
||||
expect.objectContaining({
|
||||
secret: 'mocked-secret',
|
||||
expiresIn: mockExpiresIn,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if user is not found', async () => {
|
||||
const userId = 'non-existent-user-id';
|
||||
const mockExpiresIn = '15m';
|
||||
|
||||
jest.spyOn(twentyConfigService, 'get').mockImplementation((key) => {
|
||||
if (key === 'WORKSPACE_AGNOSTIC_TOKEN_EXPIRES_IN') return mockExpiresIn;
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.generateWorkspaceAgnosticToken({
|
||||
userId,
|
||||
authProvider: AuthProviderEnum.Password,
|
||||
}),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateToken', () => {
|
||||
it('should validate a token successfully', async () => {
|
||||
const mockToken = 'valid-token';
|
||||
const userId = 'user-id';
|
||||
const mockPayload = {
|
||||
sub: userId,
|
||||
userId: userId,
|
||||
type: 'WORKSPACE_AGNOSTIC',
|
||||
};
|
||||
const mockUser = { id: userId };
|
||||
|
||||
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
|
||||
jest.spyOn(jwtWrapperService, 'verify').mockReturnValue({});
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as User);
|
||||
|
||||
const result = await service.validateToken(mockToken);
|
||||
|
||||
expect(result).toEqual({
|
||||
user: mockUser,
|
||||
});
|
||||
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken);
|
||||
expect(jwtWrapperService.verify).toHaveBeenCalledWith(
|
||||
mockToken,
|
||||
expect.objectContaining({
|
||||
secret: 'mocked-secret',
|
||||
}),
|
||||
);
|
||||
expect(userRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if token verification fails', async () => {
|
||||
const mockToken = 'invalid-token';
|
||||
|
||||
jest.spyOn(jwtWrapperService, 'verify').mockImplementation(() => {
|
||||
throw new Error('Invalid token');
|
||||
});
|
||||
|
||||
await expect(service.validateToken(mockToken)).rejects.toThrow(
|
||||
AuthException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if user is not found', async () => {
|
||||
const mockToken = 'valid-token';
|
||||
const userId = 'user-id';
|
||||
const mockPayload = {
|
||||
sub: userId,
|
||||
userId: userId,
|
||||
type: 'WORKSPACE_AGNOSTIC',
|
||||
};
|
||||
|
||||
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
|
||||
jest.spyOn(jwtWrapperService, 'verify').mockReturnValue({});
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(service.validateToken(mockToken)).rejects.toThrow(
|
||||
AuthException,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,103 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { addMilliseconds } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import {
|
||||
AuthContext,
|
||||
JwtTokenTypeEnum,
|
||||
WorkspaceAgnosticTokenJwtPayload,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { userValidator } from 'src/engine/core-modules/user/user.validate';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceAgnosticTokenService {
|
||||
constructor(
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async generateWorkspaceAgnosticToken({
|
||||
userId,
|
||||
authProvider,
|
||||
}: {
|
||||
userId: string;
|
||||
authProvider: WorkspaceAgnosticTokenJwtPayload['authProvider'];
|
||||
}): Promise<AuthToken> {
|
||||
const expiresIn = this.twentyConfigService.get(
|
||||
'WORKSPACE_AGNOSTIC_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
userValidator.assertIsDefinedOrThrow(
|
||||
user,
|
||||
new AuthException('User is not found', AuthExceptionCode.INVALID_INPUT),
|
||||
);
|
||||
|
||||
const jwtPayload: WorkspaceAgnosticTokenJwtPayload = {
|
||||
sub: user.id,
|
||||
userId: user.id,
|
||||
authProvider,
|
||||
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
|
||||
};
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret: this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
|
||||
user.id,
|
||||
),
|
||||
expiresIn,
|
||||
}),
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async validateToken(token: string): Promise<AuthContext> {
|
||||
try {
|
||||
const decoded =
|
||||
this.jwtWrapperService.decode<WorkspaceAgnosticTokenJwtPayload>(token);
|
||||
|
||||
this.jwtWrapperService.verify(token, {
|
||||
secret: this.jwtWrapperService.generateAppSecret(
|
||||
JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
|
||||
decoded.userId,
|
||||
),
|
||||
});
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: decoded.sub },
|
||||
});
|
||||
|
||||
userValidator.assertIsDefinedOrThrow(user);
|
||||
|
||||
return { user };
|
||||
} catch (error) {
|
||||
if (error instanceof AuthException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AuthException(
|
||||
'Invalid token',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,11 +9,10 @@ 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 { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service';
|
||||
import { EmailModule } from 'src/engine/core-modules/email/email.module';
|
||||
import { WorkspaceAgnosticTokenService } from 'src/engine/core-modules/auth/token/services/workspace-agnostic-token.service';
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.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 { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
@ -27,9 +26,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
|
||||
),
|
||||
TypeORMModule,
|
||||
DataSourceModule,
|
||||
EmailModule,
|
||||
WorkspaceSSOModule,
|
||||
UserWorkspaceModule,
|
||||
],
|
||||
providers: [
|
||||
RenewTokenService,
|
||||
@ -37,12 +34,14 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
|
||||
AccessTokenService,
|
||||
LoginTokenService,
|
||||
RefreshTokenService,
|
||||
WorkspaceAgnosticTokenService,
|
||||
],
|
||||
exports: [
|
||||
RenewTokenService,
|
||||
AccessTokenService,
|
||||
LoginTokenService,
|
||||
RefreshTokenService,
|
||||
WorkspaceAgnosticTokenService,
|
||||
],
|
||||
})
|
||||
export class TokenModule {}
|
||||
|
||||
@ -1,21 +1,101 @@
|
||||
import { WorkspaceTokenType } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { ApiKeyWorkspaceEntity } from 'src/modules/api-key/standard-objects/api-key.workspace-entity';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
|
||||
export type AuthContext = {
|
||||
user?: User | null | undefined;
|
||||
apiKey?: ApiKeyWorkspaceEntity | null | undefined;
|
||||
workspaceMemberId?: string;
|
||||
workspace: Workspace;
|
||||
workspace?: Workspace;
|
||||
userWorkspaceId?: string;
|
||||
authProvider?: AuthProviderEnum;
|
||||
};
|
||||
|
||||
export type JwtPayload = {
|
||||
export enum JwtTokenTypeEnum {
|
||||
ACCESS = 'ACCESS',
|
||||
REFRESH = 'REFRESH',
|
||||
WORKSPACE_AGNOSTIC = 'WORKSPACE_AGNOSTIC',
|
||||
LOGIN = 'LOGIN',
|
||||
FILE = 'FILE',
|
||||
API_KEY = 'API_KEY',
|
||||
POSTGRES_PROXY = 'POSTGRES_PROXY',
|
||||
REMOTE_SERVER = 'REMOTE_SERVER',
|
||||
}
|
||||
|
||||
type CommonPropertiesJwtPayload = {
|
||||
sub: string;
|
||||
};
|
||||
|
||||
export type FileTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
type: JwtTokenTypeEnum.FILE;
|
||||
workspaceId: string;
|
||||
filename: string;
|
||||
workspaceMemberId?: string;
|
||||
noteBlockId?: string;
|
||||
attachmentId?: string;
|
||||
personId?: string;
|
||||
};
|
||||
|
||||
export type LoginTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
type: JwtTokenTypeEnum.LOGIN;
|
||||
workspaceId: string;
|
||||
authProvider?: AuthProviderEnum;
|
||||
};
|
||||
|
||||
export type TransientTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
type: JwtTokenTypeEnum.LOGIN;
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
workspaceMemberId: string;
|
||||
};
|
||||
|
||||
export type RefreshTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
type: JwtTokenTypeEnum.REFRESH;
|
||||
workspaceId?: string;
|
||||
userId: string;
|
||||
jti?: string;
|
||||
authProvider?: AuthProviderEnum;
|
||||
targetedTokenType: JwtTokenTypeEnum;
|
||||
};
|
||||
|
||||
export type WorkspaceAgnosticTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC;
|
||||
userId: string;
|
||||
authProvider: AuthProviderEnum;
|
||||
};
|
||||
|
||||
export type ApiKeyTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
type: JwtTokenTypeEnum.API_KEY;
|
||||
workspaceId: string;
|
||||
workspaceMemberId?: string;
|
||||
jti?: string;
|
||||
type?: WorkspaceTokenType;
|
||||
userWorkspaceId?: string;
|
||||
};
|
||||
|
||||
export type AccessTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
type: JwtTokenTypeEnum.ACCESS;
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
workspaceMemberId?: string;
|
||||
userWorkspaceId: string;
|
||||
authProvider?: AuthProviderEnum;
|
||||
};
|
||||
|
||||
export type PostgresProxyTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
type: JwtTokenTypeEnum.POSTGRES_PROXY;
|
||||
};
|
||||
|
||||
export type RemoteServerTokenJwtPayload = CommonPropertiesJwtPayload & {
|
||||
type: JwtTokenTypeEnum.REMOTE_SERVER;
|
||||
};
|
||||
|
||||
export type JwtPayload =
|
||||
| AccessTokenJwtPayload
|
||||
| ApiKeyTokenJwtPayload
|
||||
| WorkspaceAgnosticTokenJwtPayload
|
||||
| LoginTokenJwtPayload
|
||||
| TransientTokenJwtPayload
|
||||
| RefreshTokenJwtPayload
|
||||
| FileTokenJwtPayload
|
||||
| PostgresProxyTokenJwtPayload
|
||||
| RemoteServerTokenJwtPayload;
|
||||
|
||||
@ -2,9 +2,14 @@ import { APP_LOCALES } from 'twenty-shared/translations';
|
||||
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
export type SocialSSOSignInUpActionType =
|
||||
| 'create-new-workspace'
|
||||
| 'list-available-workspaces'
|
||||
| 'join-workspace';
|
||||
|
||||
export type SignInUpBaseParams = {
|
||||
invitation?: AppToken;
|
||||
workspace?: Workspace | null;
|
||||
@ -45,10 +50,10 @@ export type ExistingUserOrPartialUserWithPicture = {
|
||||
export type AuthProviderWithPasswordType = {
|
||||
authParams:
|
||||
| {
|
||||
provider: Extract<WorkspaceAuthProvider, 'password'>;
|
||||
provider: Extract<AuthProviderEnum, AuthProviderEnum.Password>;
|
||||
password: string;
|
||||
}
|
||||
| {
|
||||
provider: Exclude<WorkspaceAuthProvider, 'password'>;
|
||||
provider: Exclude<AuthProviderEnum, AuthProviderEnum.Password>;
|
||||
};
|
||||
};
|
||||
|
||||
@ -19,6 +19,7 @@ export const getAuthExceptionRestStatus = (exception: AuthException) => {
|
||||
case AuthExceptionCode.MICROSOFT_API_AUTH_DISABLED:
|
||||
case AuthExceptionCode.MISSING_ENVIRONMENT_VARIABLE:
|
||||
case AuthExceptionCode.EMAIL_NOT_VERIFIED:
|
||||
case AuthExceptionCode.INVALID_JWT_TOKEN_TYPE:
|
||||
return 403;
|
||||
case AuthExceptionCode.INVALID_DATA:
|
||||
case AuthExceptionCode.UNAUTHENTICATED:
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { FilePayloadToEncode } from 'src/engine/core-modules/file/services/file.service';
|
||||
import { extractFileInfoFromRequest } from 'src/engine/core-modules/file/utils/extract-file-info-from-request.utils';
|
||||
import {
|
||||
FileTokenJwtPayload,
|
||||
JwtTokenTypeEnum,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
|
||||
@Injectable()
|
||||
export class FilePathGuard implements CanActivate {
|
||||
@ -19,11 +22,11 @@ export class FilePathGuard implements CanActivate {
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = (await this.jwtWrapperService.verifyWorkspaceToken(
|
||||
const payload = await this.jwtWrapperService.verifyJwtToken(
|
||||
fileSignature,
|
||||
'FILE',
|
||||
JwtTokenTypeEnum.FILE,
|
||||
ignoreExpirationToken ? { ignoreExpiration: true } : {},
|
||||
)) as FilePayloadToEncode;
|
||||
);
|
||||
|
||||
if (
|
||||
!payload.workspaceId ||
|
||||
@ -36,9 +39,12 @@ export class FilePathGuard implements CanActivate {
|
||||
return false;
|
||||
}
|
||||
|
||||
const decodedPayload = (await this.jwtWrapperService.decode(fileSignature, {
|
||||
json: true,
|
||||
})) as FilePayloadToEncode;
|
||||
const decodedPayload = this.jwtWrapperService.decode<FileTokenJwtPayload>(
|
||||
fileSignature,
|
||||
{
|
||||
json: true,
|
||||
},
|
||||
);
|
||||
|
||||
request.workspaceId = decodedPayload.workspaceId;
|
||||
|
||||
|
||||
@ -11,11 +11,10 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { extractFilenameFromPath } from 'src/engine/core-modules/file/utils/extract-file-id-from-path.utils';
|
||||
|
||||
export type FilePayloadToEncode = {
|
||||
workspaceId: string;
|
||||
filename: string;
|
||||
};
|
||||
import {
|
||||
FileTokenJwtPayload,
|
||||
JwtTokenTypeEnum,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
|
||||
@Injectable()
|
||||
export class FileService {
|
||||
@ -52,26 +51,26 @@ export class FileService {
|
||||
});
|
||||
}
|
||||
|
||||
encodeFileToken(payloadToEncode: FilePayloadToEncode) {
|
||||
encodeFileToken(payloadToEncode: Omit<FileTokenJwtPayload, 'type' | 'sub'>) {
|
||||
const fileTokenExpiresIn = this.twentyConfigService.get(
|
||||
'FILE_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
|
||||
const payload: FileTokenJwtPayload = {
|
||||
...payloadToEncode,
|
||||
sub: payloadToEncode.workspaceId,
|
||||
type: JwtTokenTypeEnum.FILE,
|
||||
};
|
||||
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
'FILE',
|
||||
payload.type,
|
||||
payloadToEncode.workspaceId,
|
||||
);
|
||||
|
||||
const signedPayload = this.jwtWrapperService.sign(
|
||||
{
|
||||
...payloadToEncode,
|
||||
},
|
||||
{
|
||||
secret,
|
||||
expiresIn: fileTokenExpiresIn,
|
||||
},
|
||||
);
|
||||
|
||||
return signedPayload;
|
||||
return this.jwtWrapperService.sign(payload, {
|
||||
secret,
|
||||
expiresIn: fileTokenExpiresIn,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteFile({
|
||||
|
||||
@ -13,15 +13,15 @@ import {
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
export type WorkspaceTokenType =
|
||||
| 'ACCESS'
|
||||
| 'LOGIN'
|
||||
| 'REFRESH'
|
||||
| 'FILE'
|
||||
| 'POSTGRES_PROXY'
|
||||
| 'REMOTE_SERVER'
|
||||
| 'API_KEY';
|
||||
import {
|
||||
JwtPayload,
|
||||
JwtTokenTypeEnum,
|
||||
TransientTokenJwtPayload,
|
||||
RefreshTokenJwtPayload,
|
||||
WorkspaceAgnosticTokenJwtPayload,
|
||||
AccessTokenJwtPayload,
|
||||
FileTokenJwtPayload,
|
||||
} from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
|
||||
@Injectable()
|
||||
export class JwtWrapperService {
|
||||
@ -30,17 +30,16 @@ export class JwtWrapperService {
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
sign(payload: string | object, options?: JwtSignOptions): string {
|
||||
sign(payload: JwtPayload, options?: JwtSignOptions): string {
|
||||
// Typescript does not handle well the overloads of the sign method, helping it a little bit
|
||||
if (typeof payload === 'object') {
|
||||
return this.jwtService.sign(payload, options);
|
||||
}
|
||||
|
||||
return this.jwtService.sign(payload, options);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
verify<T extends object = any>(token: string, options?: JwtVerifyOptions): T {
|
||||
verify<T extends object = any>(
|
||||
token: string,
|
||||
options?: { secret: string },
|
||||
): T {
|
||||
return this.jwtService.verify(token, options);
|
||||
}
|
||||
|
||||
@ -49,12 +48,18 @@ export class JwtWrapperService {
|
||||
return this.jwtService.decode(payload, options);
|
||||
}
|
||||
|
||||
verifyWorkspaceToken(
|
||||
verifyJwtToken(
|
||||
token: string,
|
||||
type: WorkspaceTokenType,
|
||||
type: JwtTokenTypeEnum,
|
||||
options?: JwtVerifyOptions,
|
||||
) {
|
||||
const payload = this.decode(token, {
|
||||
const payload = this.decode<
|
||||
| TransientTokenJwtPayload
|
||||
| RefreshTokenJwtPayload
|
||||
| WorkspaceAgnosticTokenJwtPayload
|
||||
| AccessTokenJwtPayload
|
||||
| FileTokenJwtPayload
|
||||
>(token, {
|
||||
json: true,
|
||||
});
|
||||
|
||||
@ -62,6 +67,12 @@ export class JwtWrapperService {
|
||||
throw new AuthException('No payload', AuthExceptionCode.UNAUTHENTICATED);
|
||||
}
|
||||
|
||||
// @TODO: Migrate to use type from payload instead of parameter
|
||||
type =
|
||||
payload.type === JwtTokenTypeEnum.WORKSPACE_AGNOSTIC
|
||||
? JwtTokenTypeEnum.WORKSPACE_AGNOSTIC
|
||||
: type;
|
||||
|
||||
// TODO: check if this is really needed
|
||||
if (type !== 'FILE' && !payload.sub) {
|
||||
throw new AuthException(
|
||||
@ -72,16 +83,26 @@ export class JwtWrapperService {
|
||||
|
||||
try {
|
||||
// TODO: Deprecate this once old API KEY tokens are no longer in use
|
||||
if (!payload.type && !payload.workspaceId && type === 'ACCESS') {
|
||||
if (!payload.type && !('workspaceId' in payload) && type === 'ACCESS') {
|
||||
return this.jwtService.verify(token, {
|
||||
...options,
|
||||
secret: this.generateAppSecretLegacy(),
|
||||
});
|
||||
}
|
||||
|
||||
const appSecretBody =
|
||||
'workspaceId' in payload ? payload.workspaceId : payload.userId;
|
||||
|
||||
if (!isDefined(appSecretBody)) {
|
||||
throw new AuthException(
|
||||
'Invalid token type',
|
||||
AuthExceptionCode.INVALID_JWT_TOKEN_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
return this.jwtService.verify(token, {
|
||||
...options,
|
||||
secret: this.generateAppSecret(type, payload.workspaceId),
|
||||
secret: this.generateAppSecret(type, appSecretBody),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.TokenExpiredError) {
|
||||
@ -103,7 +124,7 @@ export class JwtWrapperService {
|
||||
}
|
||||
}
|
||||
|
||||
generateAppSecret(type: WorkspaceTokenType, workspaceId?: string): string {
|
||||
generateAppSecret(type: JwtTokenTypeEnum, appSecretBody: string): string {
|
||||
const appSecret = this.twentyConfigService.get('APP_SECRET');
|
||||
|
||||
if (!appSecret) {
|
||||
@ -111,7 +132,7 @@ export class JwtWrapperService {
|
||||
}
|
||||
|
||||
return createHash('sha256')
|
||||
.update(`${appSecret}${workspaceId}${type}`)
|
||||
.update(`${appSecret}${appSecretBody}${type}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
|
||||
@ -38,6 +38,7 @@ import {
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { getServerUrl } from 'src/utils/get-server-url';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
|
||||
@Injectable()
|
||||
export class OpenApiService {
|
||||
@ -61,6 +62,8 @@ export class OpenApiService {
|
||||
const { workspace } =
|
||||
await this.accessTokenService.validateTokenByRequest(request);
|
||||
|
||||
workspaceValidator.assertIsDefinedOrThrow(workspace);
|
||||
|
||||
objectMetadataItems =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(workspace.id, {
|
||||
order: {
|
||||
|
||||
@ -13,6 +13,7 @@ import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-err
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { PostgresCredentialsDTO } from 'src/engine/core-modules/postgres-credentials/dtos/postgres-credentials.dto';
|
||||
import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
|
||||
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
|
||||
export class PostgresCredentialsService {
|
||||
constructor(
|
||||
@ -28,7 +29,7 @@ export class PostgresCredentialsService {
|
||||
const password = randomBytes(16).toString('hex');
|
||||
|
||||
const key = this.jwtWrapperService.generateAppSecret(
|
||||
'POSTGRES_PROXY',
|
||||
JwtTokenTypeEnum.POSTGRES_PROXY,
|
||||
workspaceId,
|
||||
);
|
||||
const passwordHash = encryptText(password, key);
|
||||
@ -85,7 +86,7 @@ export class PostgresCredentialsService {
|
||||
});
|
||||
|
||||
const key = this.jwtWrapperService.generateAppSecret(
|
||||
'POSTGRES_PROXY',
|
||||
JwtTokenTypeEnum.POSTGRES_PROXY,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
@ -112,7 +113,7 @@ export class PostgresCredentialsService {
|
||||
}
|
||||
|
||||
const key = this.jwtWrapperService.generateAppSecret(
|
||||
'POSTGRES_PROXY',
|
||||
JwtTokenTypeEnum.POSTGRES_PROXY,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
|
||||
@ -223,6 +223,15 @@ export class ConfigVariables {
|
||||
@IsOptional()
|
||||
ACCESS_TOKEN_EXPIRES_IN = '30m';
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.TokensDuration,
|
||||
description: 'Duration for which the workspace agnostic token is valid',
|
||||
type: ConfigVariableType.STRING,
|
||||
})
|
||||
@IsDuration()
|
||||
@IsOptional()
|
||||
WORKSPACE_AGNOSTIC_TOKEN_EXPIRES_IN = '30m';
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.TokensDuration,
|
||||
description: 'Duration for which the refresh token is valid',
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export class UserWorkspaceException extends CustomException {
|
||||
declare code: UserWorkspaceExceptionCode;
|
||||
constructor(message: string, code: UserWorkspaceExceptionCode) {
|
||||
super(message, code);
|
||||
}
|
||||
}
|
||||
|
||||
export enum UserWorkspaceExceptionCode {
|
||||
USER_WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
|
||||
}
|
||||
@ -19,6 +19,8 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
|
||||
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
|
||||
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -32,12 +34,14 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
|
||||
TypeORMModule,
|
||||
DataSourceModule,
|
||||
WorkspaceDataSourceModule,
|
||||
ApprovedAccessDomainModule,
|
||||
WorkspaceInvitationModule,
|
||||
DomainManagerModule,
|
||||
TwentyORMModule,
|
||||
UserRoleModule,
|
||||
FileUploadModule,
|
||||
FileModule,
|
||||
TokenModule,
|
||||
],
|
||||
services: [UserWorkspaceService],
|
||||
}),
|
||||
|
||||
@ -28,6 +28,9 @@ import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service';
|
||||
import { ApprovedAccessDomain } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.entity';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
|
||||
describe('UserWorkspaceService', () => {
|
||||
let service: UserWorkspaceService;
|
||||
@ -37,7 +40,7 @@ describe('UserWorkspaceService', () => {
|
||||
let typeORMService: TypeORMService;
|
||||
let workspaceInvitationService: WorkspaceInvitationService;
|
||||
let workspaceEventEmitter: WorkspaceEventEmitter;
|
||||
let domainManagerService: DomainManagerService;
|
||||
let approvedAccessDomainService: ApprovedAccessDomainService;
|
||||
let twentyORMGlobalManager: TwentyORMGlobalManager;
|
||||
let userRoleService: UserRoleService;
|
||||
let fileService: FileService;
|
||||
@ -87,6 +90,7 @@ describe('UserWorkspaceService', () => {
|
||||
provide: WorkspaceInvitationService,
|
||||
useValue: {
|
||||
invalidateWorkspaceInvitation: jest.fn(),
|
||||
findInvitationsByEmail: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -102,6 +106,13 @@ describe('UserWorkspaceService', () => {
|
||||
getWorkspaceUrls: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ApprovedAccessDomainService,
|
||||
useValue: {
|
||||
findValidatedApprovedAccessDomainWithWorkspacesAndSSOIdentityProvidersDomain:
|
||||
jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TwentyORMGlobalManager,
|
||||
useValue: {
|
||||
@ -120,6 +131,10 @@ describe('UserWorkspaceService', () => {
|
||||
copy: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: LoginTokenService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: FileUploadService,
|
||||
useValue: {
|
||||
@ -151,8 +166,9 @@ describe('UserWorkspaceService', () => {
|
||||
workspaceEventEmitter = module.get<WorkspaceEventEmitter>(
|
||||
WorkspaceEventEmitter,
|
||||
);
|
||||
domainManagerService =
|
||||
module.get<DomainManagerService>(DomainManagerService);
|
||||
approvedAccessDomainService = module.get<ApprovedAccessDomainService>(
|
||||
ApprovedAccessDomainService,
|
||||
);
|
||||
twentyORMGlobalManager = module.get<TwentyORMGlobalManager>(
|
||||
TwentyORMGlobalManager,
|
||||
);
|
||||
@ -657,15 +673,15 @@ describe('UserWorkspaceService', () => {
|
||||
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(user);
|
||||
jest
|
||||
.spyOn(domainManagerService, 'getWorkspaceUrls')
|
||||
.mockReturnValueOnce({
|
||||
customUrl: 'https://crm.custom1.com',
|
||||
subdomainUrl: 'https://workspace1.twenty.com',
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
customUrl: 'https://crm.custom2.com',
|
||||
subdomainUrl: 'https://workspace2.twenty.com',
|
||||
});
|
||||
.spyOn(
|
||||
approvedAccessDomainService,
|
||||
'findValidatedApprovedAccessDomainWithWorkspacesAndSSOIdentityProvidersDomain',
|
||||
)
|
||||
.mockResolvedValue([]);
|
||||
|
||||
jest
|
||||
.spyOn(workspaceInvitationService, 'findInvitationsByEmail')
|
||||
.mockResolvedValue([]);
|
||||
|
||||
const result = await service.findAvailableWorkspacesByEmail(email);
|
||||
|
||||
@ -677,18 +693,26 @@ describe('UserWorkspaceService', () => {
|
||||
'workspaces',
|
||||
'workspaces.workspace',
|
||||
'workspaces.workspace.workspaceSSOIdentityProviders',
|
||||
'workspaces.workspace.approvedAccessDomains',
|
||||
],
|
||||
});
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
id: workspace1.id,
|
||||
displayName: workspace1.displayName,
|
||||
workspaceUrls: {
|
||||
customUrl: 'https://crm.custom1.com',
|
||||
subdomainUrl: 'https://workspace1.twenty.com',
|
||||
},
|
||||
logo: workspace1.logo,
|
||||
sso: [
|
||||
|
||||
expect(result).toEqual({
|
||||
availableWorkspacesForSignIn: [
|
||||
{ workspace: workspace1 },
|
||||
{ workspace: workspace2 },
|
||||
],
|
||||
availableWorkspacesForSignUp: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should find available workspaces including approved domain workspace for an email', async () => {
|
||||
const email = 'test@example.com';
|
||||
const workspace1 = {
|
||||
id: 'workspace-id-1',
|
||||
displayName: 'Workspace 1',
|
||||
logo: 'logo1.png',
|
||||
workspaceSSOIdentityProviders: [
|
||||
{
|
||||
id: 'sso-id-1',
|
||||
name: 'SSO Provider 1',
|
||||
@ -696,28 +720,106 @@ describe('UserWorkspaceService', () => {
|
||||
type: 'type1',
|
||||
status: 'Active',
|
||||
},
|
||||
{
|
||||
id: 'sso-id-2',
|
||||
name: 'SSO Provider 2',
|
||||
issuer: 'issuer2',
|
||||
type: 'type2',
|
||||
status: 'Inactive',
|
||||
},
|
||||
],
|
||||
} as unknown as Workspace;
|
||||
const workspace2 = {
|
||||
id: 'workspace-id-2',
|
||||
displayName: 'Workspace 2',
|
||||
logo: 'logo2.png',
|
||||
workspaceSSOIdentityProviders: [],
|
||||
} as unknown as Workspace;
|
||||
|
||||
const user = {
|
||||
email,
|
||||
workspaces: [
|
||||
{
|
||||
workspaceId: workspace1.id,
|
||||
workspace: workspace1,
|
||||
},
|
||||
],
|
||||
} as User;
|
||||
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(user);
|
||||
jest
|
||||
.spyOn(
|
||||
approvedAccessDomainService,
|
||||
'findValidatedApprovedAccessDomainWithWorkspacesAndSSOIdentityProvidersDomain',
|
||||
)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'domain-id-2',
|
||||
workspaceId: workspace2.id,
|
||||
workspace: workspace2,
|
||||
isValidated: true,
|
||||
} as unknown as ApprovedAccessDomain,
|
||||
]);
|
||||
|
||||
jest
|
||||
.spyOn(workspaceInvitationService, 'findInvitationsByEmail')
|
||||
.mockResolvedValue([]);
|
||||
|
||||
const result = await service.findAvailableWorkspacesByEmail(email);
|
||||
|
||||
expect(userRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
relations: [
|
||||
'workspaces',
|
||||
'workspaces.workspace',
|
||||
'workspaces.workspace.workspaceSSOIdentityProviders',
|
||||
'workspaces.workspace.approvedAccessDomains',
|
||||
],
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
id: workspace2.id,
|
||||
displayName: workspace2.displayName,
|
||||
workspaceUrls: {
|
||||
customUrl: 'https://crm.custom2.com',
|
||||
subdomainUrl: 'https://workspace2.twenty.com',
|
||||
},
|
||||
logo: workspace2.logo,
|
||||
sso: [],
|
||||
|
||||
expect(result).toEqual({
|
||||
availableWorkspacesForSignIn: [{ workspace: workspace1 }],
|
||||
availableWorkspacesForSignUp: [{ workspace: workspace2 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an exception if user is not found', async () => {
|
||||
it('should return workspace with approved access domain if user is not found', async () => {
|
||||
const email = 'nonexistent@example.com';
|
||||
const workspace1 = {
|
||||
id: 'workspace-id-1',
|
||||
displayName: 'Workspace 1',
|
||||
logo: 'logo1.png',
|
||||
workspaceSSOIdentityProviders: [],
|
||||
} as unknown as Workspace;
|
||||
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.findAvailableWorkspacesByEmail(email),
|
||||
).rejects.toThrow(AuthException);
|
||||
jest
|
||||
.spyOn(
|
||||
approvedAccessDomainService,
|
||||
'findValidatedApprovedAccessDomainWithWorkspacesAndSSOIdentityProvidersDomain',
|
||||
)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'domain-id-1',
|
||||
workspaceId: workspace1.id,
|
||||
workspace: workspace1,
|
||||
isValidated: true,
|
||||
} as unknown as ApprovedAccessDomain,
|
||||
]);
|
||||
|
||||
jest
|
||||
.spyOn(workspaceInvitationService, 'findInvitationsByEmail')
|
||||
.mockResolvedValue([]);
|
||||
|
||||
const result = await service.findAvailableWorkspacesByEmail(email);
|
||||
|
||||
expect(result).toEqual({
|
||||
availableWorkspacesForSignIn: [],
|
||||
availableWorkspacesForSignUp: [{ workspace: workspace1 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -14,13 +14,11 @@ import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
|
||||
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
|
||||
import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service';
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { userValidator } from 'src/engine/core-modules/user/user.validate';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
|
||||
@ -35,6 +33,12 @@ import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
import { assert } from 'src/utils/assert';
|
||||
import { ApprovedAccessDomainService } from 'src/engine/core-modules/approved-access-domain/services/approved-access-domain.service';
|
||||
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
|
||||
import { AvailableWorkspace } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
|
||||
export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
|
||||
constructor(
|
||||
@ -44,10 +48,11 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(ObjectMetadataEntity, 'core')
|
||||
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
|
||||
|
||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||
private readonly domainManagerService: DomainManagerService,
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
private readonly approvedAccessDomainService: ApprovedAccessDomainService,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
private readonly userRoleService: UserRoleService,
|
||||
private readonly fileUploadService: FileUploadService,
|
||||
@ -255,39 +260,53 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
|
||||
'workspaces',
|
||||
'workspaces.workspace',
|
||||
'workspaces.workspace.workspaceSSOIdentityProviders',
|
||||
'workspaces.workspace.approvedAccessDomains',
|
||||
],
|
||||
});
|
||||
|
||||
userValidator.assertIsDefinedOrThrow(
|
||||
user,
|
||||
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
|
||||
const alreadyMemberWorkspaces = user
|
||||
? user.workspaces.map(({ workspace }) => ({ workspace }))
|
||||
: [];
|
||||
|
||||
const alreadyMemberWorkspacesIds = alreadyMemberWorkspaces.map(
|
||||
({ workspace }) => workspace.id,
|
||||
);
|
||||
|
||||
return user.workspaces.map<AvailableWorkspaceOutput>((userWorkspace) => ({
|
||||
id: userWorkspace.workspaceId,
|
||||
displayName: userWorkspace.workspace.displayName,
|
||||
workspaceUrls: this.domainManagerService.getWorkspaceUrls(
|
||||
userWorkspace.workspace,
|
||||
),
|
||||
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'],
|
||||
),
|
||||
}));
|
||||
const workspacesFromApprovedAccessDomain = (
|
||||
await this.approvedAccessDomainService.findValidatedApprovedAccessDomainWithWorkspacesAndSSOIdentityProvidersDomain(
|
||||
getDomainNameByEmail(email),
|
||||
)
|
||||
)
|
||||
.filter(
|
||||
({ workspace }) => !alreadyMemberWorkspacesIds.includes(workspace.id),
|
||||
)
|
||||
.map(({ workspace }) => ({ workspace }));
|
||||
|
||||
const workspacesFromApprovedAccessDomainIds =
|
||||
workspacesFromApprovedAccessDomain.map(({ workspace }) => workspace.id);
|
||||
|
||||
const workspacesFromInvitations = (
|
||||
await this.workspaceInvitationService.findInvitationsByEmail(email)
|
||||
)
|
||||
.filter(
|
||||
({ workspaceId }) =>
|
||||
![
|
||||
...alreadyMemberWorkspacesIds,
|
||||
...workspacesFromApprovedAccessDomainIds,
|
||||
].includes(workspaceId),
|
||||
)
|
||||
.map((appToken) => ({
|
||||
workspace: appToken.workspace,
|
||||
appToken,
|
||||
}));
|
||||
|
||||
return {
|
||||
availableWorkspacesForSignIn: alreadyMemberWorkspaces,
|
||||
availableWorkspacesForSignUp: [
|
||||
...workspacesFromApprovedAccessDomain,
|
||||
...workspacesFromInvitations,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async getUserWorkspaceForUserOrThrow({
|
||||
@ -380,4 +399,78 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspace> {
|
||||
|
||||
return files[0].path;
|
||||
}
|
||||
|
||||
castWorkspaceToAvailableWorkspace(workspace: Workspace) {
|
||||
return {
|
||||
id: workspace.id,
|
||||
displayName: workspace.displayName,
|
||||
workspaceUrls: this.domainManagerService.getWorkspaceUrls(workspace),
|
||||
logo: workspace.logo,
|
||||
sso: 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 AvailableWorkspace['sso'],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async setLoginTokenToAvailableWorkspacesWhenAuthProviderMatch(
|
||||
availableWorkspaces: {
|
||||
availableWorkspacesForSignUp: Array<{
|
||||
workspace: Workspace;
|
||||
appToken?: AppToken;
|
||||
}>;
|
||||
availableWorkspacesForSignIn: Array<{
|
||||
workspace: Workspace;
|
||||
appToken?: AppToken;
|
||||
}>;
|
||||
},
|
||||
user: User,
|
||||
authProvider: AuthProviderEnum,
|
||||
) {
|
||||
return {
|
||||
availableWorkspacesForSignUp:
|
||||
availableWorkspaces.availableWorkspacesForSignUp.map(
|
||||
({ workspace, appToken }) => {
|
||||
return {
|
||||
...this.castWorkspaceToAvailableWorkspace(workspace),
|
||||
...(appToken ? { personalInviteToken: appToken.value } : {}),
|
||||
};
|
||||
},
|
||||
),
|
||||
availableWorkspacesForSignIn: await Promise.all(
|
||||
availableWorkspaces.availableWorkspacesForSignIn.map(
|
||||
async ({ workspace }) => {
|
||||
return {
|
||||
...this.castWorkspaceToAvailableWorkspace(workspace),
|
||||
loginToken: workspaceValidator.isAuthEnabled(
|
||||
authProvider,
|
||||
workspace,
|
||||
)
|
||||
? (
|
||||
await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
workspace.id,
|
||||
AuthProviderEnum.Password,
|
||||
)
|
||||
).token
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import {
|
||||
UserWorkspaceException,
|
||||
UserWorkspaceExceptionCode,
|
||||
} from 'src/engine/core-modules/user-workspace/user-workspace.exception';
|
||||
|
||||
const assertIsDefinedOrThrow = (
|
||||
userWorkspace: UserWorkspace | undefined | null,
|
||||
exceptionToThrow: CustomException = new UserWorkspaceException(
|
||||
'User Workspace not found',
|
||||
UserWorkspaceExceptionCode.USER_WORKSPACE_NOT_FOUND,
|
||||
),
|
||||
): asserts userWorkspace is UserWorkspace => {
|
||||
if (!userWorkspace) {
|
||||
throw exceptionToThrow;
|
||||
}
|
||||
};
|
||||
|
||||
export const userWorkspaceValidator: {
|
||||
assertIsDefinedOrThrow: typeof assertIsDefinedOrThrow;
|
||||
} = {
|
||||
assertIsDefinedOrThrow: assertIsDefinedOrThrow,
|
||||
};
|
||||
@ -118,7 +118,7 @@ export class User {
|
||||
onboardingStatus: OnboardingStatus;
|
||||
|
||||
@Field(() => Workspace, { nullable: true })
|
||||
currentWorkspace: Relation<Workspace>;
|
||||
currentWorkspace?: Relation<Workspace>;
|
||||
|
||||
@Field(() => UserWorkspace, { nullable: true })
|
||||
currentUserWorkspace?: Relation<UserWorkspace>;
|
||||
|
||||
@ -8,7 +8,6 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
|
||||
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';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
@ -47,8 +46,8 @@ import { UserService } from './services/user.service';
|
||||
OnboardingModule,
|
||||
TypeOrmModule.forFeature([KeyValuePair, UserWorkspace], 'core'),
|
||||
UserVarsModule,
|
||||
UserWorkspaceModule,
|
||||
AuditModule,
|
||||
DomainManagerModule,
|
||||
UserRoleModule,
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
|
||||
@ -57,6 +57,10 @@ import { fromUserWorkspacePermissionsToUserWorkspacePermissionsDto } from 'src/e
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { AccountsToReconnectKeys } from 'src/modules/connected-account/types/accounts-to-reconnect-key-value.type';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
import { AvailableWorkspaces } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
|
||||
import { AuthProvider } from 'src/engine/decorators/auth/auth-provider.decorator';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
|
||||
const getHMACKey = (email?: string, key?: string | null) => {
|
||||
if (!email || !key) return null;
|
||||
@ -66,7 +70,6 @@ const getHMACKey = (email?: string, key?: string | null) => {
|
||||
return hmac.update(email).digest('hex');
|
||||
};
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@Resolver(() => User)
|
||||
@UseFilters(PermissionsGraphqlApiExceptionFilter)
|
||||
export class UserResolver {
|
||||
@ -123,15 +126,16 @@ export class UserResolver {
|
||||
}
|
||||
|
||||
@Query(() => User)
|
||||
@UseGuards(UserAuthGuard)
|
||||
async currentUser(
|
||||
@AuthUser() { id: userId }: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@AuthWorkspace({ allowUndefined: true }) workspace: Workspace,
|
||||
): Promise<User> {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
relations: ['workspaces', 'workspaces.workspace'],
|
||||
relations: ['workspaces'],
|
||||
});
|
||||
|
||||
userValidator.assertIsDefinedOrThrow(
|
||||
@ -139,8 +143,12 @@ export class UserResolver {
|
||||
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
|
||||
);
|
||||
|
||||
if (!workspace) {
|
||||
return user;
|
||||
}
|
||||
|
||||
const currentUserWorkspace = user.workspaces.find(
|
||||
(userWorkspace) => userWorkspace.workspace.id === workspace.id,
|
||||
(userWorkspace) => userWorkspace.workspaceId === workspace.id,
|
||||
);
|
||||
|
||||
if (!isDefined(currentUserWorkspace)) {
|
||||
@ -165,11 +173,14 @@ export class UserResolver {
|
||||
};
|
||||
}
|
||||
|
||||
@ResolveField(() => GraphQLJSONObject)
|
||||
@ResolveField(() => GraphQLJSONObject, {
|
||||
nullable: true,
|
||||
})
|
||||
async userVars(
|
||||
@Parent() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@AuthWorkspace({ allowUndefined: true }) workspace: Workspace | undefined,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (!workspace) return {};
|
||||
const userVars = await this.userVarService.getAll({
|
||||
userId: user.id,
|
||||
workspaceId: workspace.id,
|
||||
@ -193,8 +204,10 @@ export class UserResolver {
|
||||
})
|
||||
async workspaceMember(
|
||||
@Parent() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@AuthWorkspace({ allowUndefined: true }) workspace: Workspace | undefined,
|
||||
): Promise<WorkspaceMember | null> {
|
||||
if (!workspace) return null;
|
||||
|
||||
const workspaceMemberEntity = await this.userService.loadWorkspaceMember(
|
||||
user,
|
||||
workspace,
|
||||
@ -235,8 +248,10 @@ export class UserResolver {
|
||||
})
|
||||
async workspaceMembers(
|
||||
@Parent() _user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@AuthWorkspace({ allowUndefined: true }) workspace: Workspace | undefined,
|
||||
): Promise<WorkspaceMember[]> {
|
||||
if (!workspace) return [];
|
||||
|
||||
const workspaceMemberEntities = await this.userService.loadWorkspaceMembers(
|
||||
workspace,
|
||||
false,
|
||||
@ -301,8 +316,10 @@ export class UserResolver {
|
||||
})
|
||||
async deletedWorkspaceMembers(
|
||||
@Parent() _user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@AuthWorkspace({ allowUndefined: true }) workspace: Workspace | undefined,
|
||||
): Promise<DeletedWorkspaceMember[]> {
|
||||
if (!workspace) return [];
|
||||
|
||||
const workspaceMemberEntities =
|
||||
await this.userService.loadDeletedWorkspaceMembersOnly(workspace);
|
||||
|
||||
@ -327,9 +344,10 @@ export class UserResolver {
|
||||
}
|
||||
|
||||
@Mutation(() => SignedFileDTO)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
async uploadProfilePicture(
|
||||
@AuthUser() { id }: User,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
@AuthWorkspace({ allowUndefined: true }) { id: workspaceId }: Workspace,
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
{ createReadStream, filename, mimetype }: FileUpload,
|
||||
): Promise<SignedFileDTO> {
|
||||
@ -357,20 +375,43 @@ export class UserResolver {
|
||||
}
|
||||
|
||||
@Mutation(() => User)
|
||||
@UseGuards(UserAuthGuard)
|
||||
async deleteUser(@AuthUser() { id: userId }: User) {
|
||||
return this.userService.deleteUser(userId);
|
||||
}
|
||||
|
||||
@ResolveField(() => OnboardingStatus)
|
||||
@ResolveField(() => OnboardingStatus, {
|
||||
nullable: true,
|
||||
})
|
||||
async onboardingStatus(
|
||||
@Parent() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<OnboardingStatus> {
|
||||
@AuthWorkspace({ allowUndefined: true }) workspace: Workspace | undefined,
|
||||
): Promise<OnboardingStatus | null> {
|
||||
if (!workspace) return null;
|
||||
|
||||
return this.onboardingService.getOnboardingStatus(user, workspace);
|
||||
}
|
||||
|
||||
@ResolveField(() => Workspace)
|
||||
async currentWorkspace(@AuthWorkspace() workspace: Workspace) {
|
||||
@ResolveField(() => Workspace, {
|
||||
nullable: true,
|
||||
})
|
||||
async currentWorkspace(
|
||||
@AuthWorkspace({ allowUndefined: true }) workspace: Workspace | undefined,
|
||||
) {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
@ResolveField(() => AvailableWorkspaces)
|
||||
async availableWorkspaces(
|
||||
@AuthUser() user: User,
|
||||
@AuthProvider() authProvider: AuthProviderEnum,
|
||||
): Promise<AvailableWorkspaces> {
|
||||
return this.userWorkspaceService.setLoginTokenToAvailableWorkspacesWhenAuthProviderMatch(
|
||||
await this.userWorkspaceService.findAvailableWorkspacesByEmail(
|
||||
user.email,
|
||||
),
|
||||
user,
|
||||
authProvider,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,6 +102,21 @@ export class WorkspaceInvitationService {
|
||||
return await this.getOneWorkspaceInvitation(workspace.id, email);
|
||||
}
|
||||
|
||||
async findInvitationsByEmail(email: string) {
|
||||
return await this.appTokenRepository
|
||||
.createQueryBuilder('appToken')
|
||||
.where('"appToken".type = :type', {
|
||||
type: AppTokenType.InvitationToken,
|
||||
})
|
||||
.andWhere('"appToken".context->>\'email\' = :email', { email })
|
||||
.andWhere('appToken.deletedAt IS NULL')
|
||||
.andWhere('appToken.expiresAt > :now', {
|
||||
now: new Date(),
|
||||
})
|
||||
.leftJoinAndSelect('appToken.workspace', 'workspace')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async getOneWorkspaceInvitation(workspaceId: string, email: string) {
|
||||
return await this.appTokenRepository
|
||||
.createQueryBuilder('appToken')
|
||||
|
||||
@ -1 +1,6 @@
|
||||
export type WorkspaceAuthProvider = 'google' | 'microsoft' | 'password' | 'sso';
|
||||
export enum AuthProviderEnum {
|
||||
Google = 'google',
|
||||
Microsoft = 'microsoft',
|
||||
Password = 'password',
|
||||
SSO = 'sso',
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import {
|
||||
WorkspaceException,
|
||||
@ -23,25 +23,47 @@ const assertIsDefinedOrThrow = (
|
||||
};
|
||||
|
||||
const isAuthEnabledOrThrow = (
|
||||
provider: WorkspaceAuthProvider,
|
||||
provider: AuthProviderEnum,
|
||||
workspace: Workspace,
|
||||
exceptionToThrowCustom: AuthException = new AuthException(
|
||||
`${provider} auth is not enabled for this workspace`,
|
||||
AuthExceptionCode.OAUTH_ACCESS_DENIED,
|
||||
),
|
||||
) => {
|
||||
if (provider === 'google' && workspace.isGoogleAuthEnabled) return true;
|
||||
if (provider === 'microsoft' && workspace.isMicrosoftAuthEnabled) return true;
|
||||
if (provider === 'password' && workspace.isPasswordAuthEnabled) return true;
|
||||
if (provider === 'sso') return true;
|
||||
if (provider === AuthProviderEnum.Google && workspace.isGoogleAuthEnabled)
|
||||
return true;
|
||||
if (
|
||||
provider === AuthProviderEnum.Microsoft &&
|
||||
workspace.isMicrosoftAuthEnabled
|
||||
)
|
||||
return true;
|
||||
if (provider === AuthProviderEnum.Password && workspace.isPasswordAuthEnabled)
|
||||
return true;
|
||||
if (provider === AuthProviderEnum.SSO) return true;
|
||||
|
||||
throw exceptionToThrowCustom;
|
||||
};
|
||||
|
||||
const isAuthEnabled = (provider: AuthProviderEnum, workspace: Workspace) => {
|
||||
if (provider === AuthProviderEnum.Google && workspace.isGoogleAuthEnabled)
|
||||
return true;
|
||||
if (
|
||||
provider === AuthProviderEnum.Microsoft &&
|
||||
workspace.isMicrosoftAuthEnabled
|
||||
)
|
||||
return true;
|
||||
if (provider === AuthProviderEnum.Password && workspace.isPasswordAuthEnabled)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const workspaceValidator: {
|
||||
assertIsDefinedOrThrow: typeof assertIsDefinedOrThrow;
|
||||
isAuthEnabledOrThrow: typeof isAuthEnabledOrThrow;
|
||||
isAuthEnabled: typeof isAuthEnabled;
|
||||
} = {
|
||||
assertIsDefinedOrThrow: assertIsDefinedOrThrow,
|
||||
isAuthEnabledOrThrow: isAuthEnabledOrThrow,
|
||||
isAuthEnabled: isAuthEnabled,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user