refactor(auth): add workspaces selection (#12098)

This commit is contained in:
Antoine Moreaux
2025-06-13 16:17:35 +02:00
committed by GitHub
parent 836e2f792c
commit b1af98f93d
162 changed files with 3542 additions and 1340 deletions

View File

@ -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({

View File

@ -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',
}

View File

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

View File

@ -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',
}

View File

@ -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: {},

View File

@ -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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -41,3 +41,9 @@ export class PasswordResetToken {
@Field(() => String)
workspaceId: string;
}
@ObjectType()
export class WorkspaceAgnosticToken {
@Field(() => AuthToken)
token: AuthToken;
}

View File

@ -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()

View File

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

View File

@ -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()

View File

@ -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:

View File

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

View File

@ -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 (

View File

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

View File

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

View File

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

View File

@ -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,

View File

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

View File

@ -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,
};

View File

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

View File

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

View File

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

View File

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

View File

@ -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> {

View File

@ -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();

View File

@ -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,

View File

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

View File

@ -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,
};
}

View File

@ -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 () => {

View File

@ -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,

View File

@ -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();

View File

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

View File

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

View File

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

View File

@ -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 {}

View File

@ -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;

View File

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

View File

@ -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:

View File

@ -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;

View File

@ -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({

View File

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

View File

@ -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: {

View File

@ -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,
);

View File

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

View File

@ -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',
}

View File

@ -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],
}),

View File

@ -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 }],
});
});
});

View File

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

View File

@ -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,
};

View File

@ -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>;

View File

@ -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,

View File

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

View File

@ -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')

View File

@ -1 +1,6 @@
export type WorkspaceAuthProvider = 'google' | 'microsoft' | 'password' | 'sso';
export enum AuthProviderEnum {
Google = 'google',
Microsoft = 'microsoft',
Password = 'password',
SSO = 'sso',
}

View File

@ -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,
};