refacto(*): remove everything about default workspace (#9157)

## Summary
- [x] Remove defaultWorkspace in user
- [x] Remove all occurrence of defaultWorkspace and defaultWorkspaceId
- [x] Improve activate workspace flow
- [x] Improve security on social login
- [x] Add `ImpersonateGuard`
- [x] Allow to use impersonation with couple `User/Workspace`
- [x] Prevent unexpected reload on activate workspace
- [x] Scope login token with workspaceId 

Fix https://github.com/twentyhq/twenty/issues/9033#event-15714863042
This commit is contained in:
Antoine Moreaux
2024-12-24 12:47:41 +01:00
committed by GitHub
parent fe6948ba0b
commit cd2946b670
78 changed files with 1150 additions and 1244 deletions

View File

@ -14,7 +14,7 @@ import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-
import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service';
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/microsoft-apis.service';
import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
// import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
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';
import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service';
@ -103,7 +103,8 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
SwitchWorkspaceService,
TransientTokenService,
ApiKeyService,
OAuthService,
// reenable when working on: https://github.com/twentyhq/twenty/issues/9143
// OAuthService,
],
exports: [AccessTokenService, LoginTokenService, RefreshTokenService],
})

View File

@ -13,7 +13,7 @@ import { AuthResolver } from './auth.resolver';
import { ApiKeyService } from './services/api-key.service';
import { AuthService } from './services/auth.service';
import { OAuthService } from './services/oauth.service';
// import { OAuthService } from './services/oauth.service';
import { ResetPasswordService } from './services/reset-password.service';
import { SwitchWorkspaceService } from './services/switch-workspace.service';
import { LoginTokenService } from './token/services/login-token.service';
@ -80,10 +80,10 @@ describe('AuthResolver', () => {
provide: TransientTokenService,
useValue: {},
},
{
provide: OAuthService,
useValue: {},
},
// {
// provide: OAuthService,
// useValue: {},
// },
],
})
.overrideGuard(CaptchaGuard)

View File

@ -7,8 +7,6 @@ import { AuthorizeApp } from 'src/engine/core-modules/auth/dto/authorize-app.ent
import { AuthorizeAppInput } from 'src/engine/core-modules/auth/dto/authorize-app.input';
import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-password-reset-link.entity';
import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/email-password-reset-link.input';
import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity';
import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input';
import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity';
import { TransientToken } from 'src/engine/core-modules/auth/dto/transient-token.entity';
import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input';
@ -16,7 +14,7 @@ import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/val
import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.input';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service';
import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
// import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
@ -31,7 +29,7 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { SwitchWorkspaceInput } from 'src/engine/core-modules/auth/dto/switch-workspace.input';
import { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.output';
import { PublicWorkspaceDataOutput } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
import {
AuthException,
AuthExceptionCode,
@ -39,6 +37,8 @@ import {
import { OriginHeader } from 'src/engine/decorators/auth/origin-header.decorator';
import { AvailableWorkspaceOutput } from 'src/engine/core-modules/auth/dto/available-workspaces.output';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { SignUpOutput } from 'src/engine/core-modules/auth/dto/sign-up.output';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { ChallengeInput } from './dto/challenge.input';
import { LoginToken } from './dto/login-token.entity';
@ -46,7 +46,6 @@ 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 { Verify } from './dto/verify.entity';
import { VerifyInput } from './dto/verify.input';
import { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity';
import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input';
@ -64,7 +63,7 @@ export class AuthResolver {
private loginTokenService: LoginTokenService,
private switchWorkspaceService: SwitchWorkspaceService,
private transientTokenService: TransientTokenService,
private oauthService: OAuthService,
// private oauthService: OAuthService,
private domainManagerService: DomainManagerService,
) {}
@ -101,7 +100,9 @@ export class AuthResolver {
@OriginHeader() origin: string,
): Promise<LoginToken> {
const workspace =
await this.domainManagerService.getWorkspaceByOrigin(origin);
await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
origin,
);
if (!workspace) {
throw new AuthException(
@ -112,18 +113,19 @@ export class AuthResolver {
const user = await this.authService.challenge(challengeInput, workspace);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
);
return { loginToken };
}
@UseGuards(CaptchaGuard)
@Mutation(() => LoginToken)
@Mutation(() => SignUpOutput)
async signUp(
@Args() signUpInput: SignUpInput,
@OriginHeader() origin: string,
): Promise<LoginToken> {
const user = await this.authService.signInUp({
): Promise<SignUpOutput> {
const { user, workspace } = await this.authService.signInUp({
...signUpInput,
targetWorkspaceSubdomain:
this.domainManagerService.getWorkspaceSubdomainByOrigin(origin),
@ -133,19 +135,26 @@ export class AuthResolver {
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
);
return { loginToken };
return {
loginToken,
workspace: {
id: workspace.id,
subdomain: workspace.subdomain,
},
};
}
@Mutation(() => ExchangeAuthCode)
async exchangeAuthorizationCode(
@Args() exchangeAuthCodeInput: ExchangeAuthCodeInput,
) {
return await this.oauthService.verifyAuthorizationCode(
exchangeAuthCodeInput,
);
}
// @Mutation(() => ExchangeAuthCode)
// async exchangeAuthorizationCode(
// @Args() exchangeAuthCodeInput: ExchangeAuthCodeInput,
// ) {
// return await this.oauthService.verifyAuthorizationCode(
// exchangeAuthCodeInput,
// );
// }
@Mutation(() => TransientToken)
@UseGuards(WorkspaceAuthGuard, UserAuthGuard)
@ -165,25 +174,35 @@ export class AuthResolver {
await this.transientTokenService.generateTransientToken(
workspaceMember.id,
user.id,
user.defaultWorkspaceId,
workspace.id,
);
return { transientToken };
}
@Mutation(() => Verify)
@Mutation(() => AuthTokens)
async verify(
@Args() verifyInput: VerifyInput,
@OriginHeader() origin: string,
): Promise<Verify> {
): Promise<AuthTokens> {
const workspace =
await this.domainManagerService.getWorkspaceByOrigin(origin);
await this.domainManagerService.getWorkspaceByOriginOrDefaultWorkspace(
origin,
);
const { sub: email } = await this.loginTokenService.verifyLoginToken(
verifyInput.loginToken,
);
workspaceValidator.assertIsDefinedOrThrow(workspace);
return await this.authService.verify(email, workspace?.id);
const { sub: email, workspaceId } =
await this.loginTokenService.verifyLoginToken(verifyInput.loginToken);
if (workspaceId !== workspace.id) {
throw new AuthException(
'Token is not valid for this workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
return await this.authService.verify(email, workspace.id);
}
@Mutation(() => AuthorizeApp)
@ -191,10 +210,12 @@ export class AuthResolver {
async authorizeApp(
@Args() authorizeAppInput: AuthorizeAppInput,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<AuthorizeApp> {
return await this.authService.generateAuthorizationCode(
authorizeAppInput,
user,
workspace,
);
}

View File

@ -71,8 +71,9 @@ export class GoogleAuthController {
if (
this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') &&
targetWorkspaceSubdomain ===
this.environmentService.get('DEFAULT_SUBDOMAIN')
(targetWorkspaceSubdomain ===
this.environmentService.get('DEFAULT_SUBDOMAIN') ||
!targetWorkspaceSubdomain)
) {
const workspaceWithGoogleAuthActive =
await this.workspaceRepository.findOne({
@ -84,7 +85,7 @@ export class GoogleAuthController {
},
},
},
relations: ['userWorkspaces', 'userWorkspaces.user'],
relations: ['workspaceUsers', 'workspaceUsers.user'],
});
if (workspaceWithGoogleAuthActive) {
@ -93,16 +94,18 @@ export class GoogleAuthController {
}
}
const user = await this.authService.signInUp(signInUpParams);
const { user, workspace } =
await this.authService.signInUp(signInUpParams);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
);
return res.redirect(
await this.authService.computeRedirectURI(
this.authService.computeRedirectURI(
loginToken.token,
user.defaultWorkspace.subdomain,
workspace.subdomain,
),
);
} catch (err) {

View File

@ -6,8 +6,10 @@ import {
UseFilters,
UseGuards,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Response } from 'express';
import { Repository } from 'typeorm';
import { 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';
@ -18,6 +20,7 @@ import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/l
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/service/domain-manager.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Controller('auth/microsoft')
@UseFilters(AuthRestApiExceptionFilter)
@ -27,6 +30,8 @@ export class MicrosoftAuthController {
private readonly authService: AuthService,
private readonly domainManagerService: DomainManagerService,
private readonly environmentService: EnvironmentService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {}
@Get()
@ -43,43 +48,57 @@ export class MicrosoftAuthController {
@Res() res: Response,
) {
try {
const {
firstName,
lastName,
email,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
targetWorkspaceSubdomain,
} = req.user;
const signInUpParams = req.user;
const user = await this.authService.signInUp({
email,
firstName,
lastName,
picture,
workspaceInviteHash,
workspacePersonalInviteToken,
targetWorkspaceSubdomain,
if (
this.environmentService.get('IS_MULTIWORKSPACE_ENABLED') &&
(signInUpParams.targetWorkspaceSubdomain ===
this.environmentService.get('DEFAULT_SUBDOMAIN') ||
!signInUpParams.targetWorkspaceSubdomain)
) {
const workspaceWithGoogleAuthActive =
await this.workspaceRepository.findOne({
where: {
isMicrosoftAuthEnabled: true,
workspaceUsers: {
user: {
email: signInUpParams.email,
},
},
},
relations: ['userWorkspaces', 'userWorkspaces.user'],
});
if (workspaceWithGoogleAuthActive) {
signInUpParams.targetWorkspaceSubdomain =
workspaceWithGoogleAuthActive.subdomain;
}
}
const { user, workspace } = await this.authService.signInUp({
...signInUpParams,
fromSSO: true,
authProvider: 'microsoft',
});
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
workspace.id,
);
return res.redirect(
await this.authService.computeRedirectURI(
this.authService.computeRedirectURI(
loginToken.token,
user.defaultWorkspace.subdomain,
workspace.subdomain,
),
);
} catch (err) {
if (err instanceof AuthException) {
return res.redirect(
this.domainManagerService.computeRedirectErrorUrl({
subdomain: this.environmentService.get('DEFAULT_SUBDOMAIN'),
subdomain:
req.user.targetWorkspaceSubdomain ??
this.environmentService.get('DEFAULT_SUBDOMAIN'),
errorMessage: err.message,
}),
);

View File

@ -86,7 +86,7 @@ export class SSOAuthController {
);
return res.redirect(
await this.authService.computeRedirectURI(
this.authService.computeRedirectURI(
loginToken.token,
identityProvider.workspace.subdomain,
),
@ -113,7 +113,7 @@ export class SSOAuthController {
);
return res.redirect(
await this.authService.computeRedirectURI(
this.authService.computeRedirectURI(
loginToken.token,
identityProvider.workspace.subdomain,
),
@ -183,7 +183,10 @@ export class SSOAuthController {
return {
identityProvider,
loginToken: await this.loginTokenService.generateLoginToken(user.email),
loginToken: await this.loginTokenService.generateLoginToken(
user.email,
identityProvider.workspace.id,
),
};
}
}

View File

@ -0,0 +1,14 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { WorkspaceSubdomainAndId } from 'src/engine/core-modules/workspace/dtos/workspace-subdomain-id.dto';
import { AuthToken } from './token.entity';
@ObjectType()
export class SignUpOutput {
@Field(() => AuthToken)
loginToken: AuthToken;
@Field(() => WorkspaceSubdomainAndId)
workspace: WorkspaceSubdomainAndId;
}

View File

@ -7,9 +7,6 @@ export class UserExists {
@Field(() => Boolean)
exists: true;
@Field(() => String)
defaultWorkspaceId: string;
@Field(() => [AvailableWorkspaceOutput])
availableWorkspaces: Array<AvailableWorkspaceOutput>;
}

View File

@ -1,11 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { User } from 'src/engine/core-modules/user/user.entity';
import { AuthTokens } from './token.entity';
@ObjectType()
export class Verify extends AuthTokens {
@Field(() => User)
user: DeepPartial<User>;
}

View File

@ -33,7 +33,6 @@ import {
UserExists,
UserNotExists,
} from 'src/engine/core-modules/auth/dto/user-exists.entity';
import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity';
import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
@ -42,12 +41,12 @@ import { DomainManagerService } from 'src/engine/core-modules/domain-manager/ser
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { WorkspaceAuthProvider } from 'src/engine/core-modules/workspace/types/workspace.type';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
@ -57,7 +56,6 @@ export class AuthService {
private readonly domainManagerService: DomainManagerService,
private readonly refreshTokenService: RefreshTokenService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly userService: UserService,
private readonly workspaceInvitationService: WorkspaceInvitationService,
private readonly signInUpService: SignInUpService,
@InjectRepository(Workspace, 'core')
@ -188,7 +186,7 @@ export class AuthService {
});
}
async verify(email: string, workspaceId?: string): Promise<Verify> {
async verify(email: string, workspaceId: string): Promise<AuthTokens> {
if (!email) {
throw new AuthException(
'Email is required',
@ -196,31 +194,8 @@ export class AuthService {
);
}
const userWithIdAndDefaultWorkspaceId = await this.userRepository.findOne({
select: ['defaultWorkspaceId', 'id'],
where: { email },
});
userValidator.assertIsDefinedOrThrow(
userWithIdAndDefaultWorkspaceId,
new AuthException('User not found', AuthExceptionCode.USER_NOT_FOUND),
);
if (
workspaceId &&
userWithIdAndDefaultWorkspaceId.defaultWorkspaceId !== workspaceId
) {
await this.userService.saveDefaultWorkspaceIfUserHasAccessOrThrow(
userWithIdAndDefaultWorkspaceId.id,
workspaceId,
);
}
const user = await this.userRepository.findOne({
where: {
email,
},
relations: ['defaultWorkspace', 'workspaces', 'workspaces.workspace'],
where: { email },
});
userValidator.assertIsDefinedOrThrow(
@ -233,15 +208,14 @@ export class AuthService {
const accessToken = await this.accessTokenService.generateAccessToken(
user.id,
user.defaultWorkspaceId,
workspaceId,
);
const refreshToken = await this.refreshTokenService.generateRefreshToken(
user.id,
user.defaultWorkspaceId,
workspaceId,
);
return {
user,
tokens: {
accessToken,
refreshToken,
@ -257,7 +231,6 @@ export class AuthService {
if (userValidator.isDefined(user)) {
return {
exists: true,
defaultWorkspaceId: user.defaultWorkspaceId,
availableWorkspaces: await this.findAvailableWorkspacesByEmail(email),
};
}
@ -278,6 +251,7 @@ export class AuthService {
async generateAuthorizationCode(
authorizeAppInput: AuthorizeAppInput,
user: User,
workspace: Workspace,
): Promise<AuthorizeApp> {
// TODO: replace with db call to - third party app table
const apps = [
@ -329,14 +303,14 @@ export class AuthService {
value: codeChallenge,
type: AppTokenType.CodeChallenge,
userId: user.id,
workspaceId: user.defaultWorkspaceId,
workspaceId: workspace.id,
expiresAt,
},
{
value: authorizationCode,
type: AppTokenType.AuthorizationCode,
userId: user.id,
workspaceId: user.defaultWorkspaceId,
workspaceId: workspace.id,
expiresAt,
},
]);
@ -347,7 +321,7 @@ export class AuthService {
value: authorizationCode,
type: AppTokenType.AuthorizationCode,
userId: user.id,
workspaceId: user.defaultWorkspaceId,
workspaceId: workspace.id,
expiresAt,
});
@ -439,7 +413,7 @@ export class AuthService {
return workspace;
}
async computeRedirectURI(loginToken: string, subdomain?: string) {
computeRedirectURI(loginToken: string, subdomain?: string) {
const url = this.domainManagerService.buildWorkspaceURL({
subdomain,
pathname: '/verify',

View File

@ -1,155 +1,157 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import { Repository } from 'typeorm';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity';
import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
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 { User } from 'src/engine/core-modules/user/user.entity';
@Injectable()
export class OAuthService {
constructor(
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@InjectRepository(AppToken, 'core')
private readonly appTokenRepository: Repository<AppToken>,
private readonly accessTokenService: AccessTokenService,
private readonly refreshTokenService: RefreshTokenService,
private readonly loginTokenService: LoginTokenService,
) {}
async verifyAuthorizationCode(
exchangeAuthCodeInput: ExchangeAuthCodeInput,
): Promise<ExchangeAuthCode> {
const { authorizationCode, codeVerifier } = exchangeAuthCodeInput;
if (!authorizationCode) {
throw new AuthException(
'Authorization code not found',
AuthExceptionCode.INVALID_INPUT,
);
}
let userId = '';
if (codeVerifier) {
const authorizationCodeAppToken = await this.appTokenRepository.findOne({
where: {
value: authorizationCode,
},
});
if (!authorizationCodeAppToken) {
throw new AuthException(
'Authorization code does not exist',
AuthExceptionCode.INVALID_INPUT,
);
}
if (!(authorizationCodeAppToken.expiresAt.getTime() >= Date.now())) {
throw new AuthException(
'Authorization code expired.',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest()
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
const codeChallengeAppToken = await this.appTokenRepository.findOne({
where: {
value: codeChallenge,
},
});
if (!codeChallengeAppToken || !codeChallengeAppToken.userId) {
throw new AuthException(
'code verifier doesnt match the challenge',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
if (!(codeChallengeAppToken.expiresAt.getTime() >= Date.now())) {
throw new AuthException(
'code challenge expired.',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
if (codeChallengeAppToken.userId !== authorizationCodeAppToken.userId) {
throw new AuthException(
'authorization code / code verifier was not created by same client',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
if (codeChallengeAppToken.revokedAt) {
throw new AuthException(
'Token has been revoked.',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
await this.appTokenRepository.save({
id: codeChallengeAppToken.id,
revokedAt: new Date(),
});
userId = codeChallengeAppToken.userId;
}
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['defaultWorkspace'],
});
if (!user) {
throw new AuthException(
'User who generated the token does not exist',
AuthExceptionCode.INVALID_INPUT,
);
}
if (!user.defaultWorkspace) {
throw new AuthException(
'User does not have a default workspace',
AuthExceptionCode.INVALID_DATA,
);
}
const accessToken = await this.accessTokenService.generateAccessToken(
user.id,
user.defaultWorkspaceId,
);
const refreshToken = await this.refreshTokenService.generateRefreshToken(
user.id,
user.defaultWorkspaceId,
);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
);
return {
accessToken,
refreshToken,
loginToken,
};
}
}
// import { Injectable } from '@nestjs/common';
// import { InjectRepository } from '@nestjs/typeorm';
//
// import crypto from 'crypto';
//
// import { Repository } from 'typeorm';
//
// import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
// import {
// AuthException,
// AuthExceptionCode,
// } from 'src/engine/core-modules/auth/auth.exception';
// import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity';
// import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input';
// import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
// 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 { User } from 'src/engine/core-modules/user/user.entity';
// import { userValidator } from 'src/engine/core-modules/user/user.validate';
//
// @Injectable()
// export class OAuthService {
// constructor(
// @InjectRepository(User, 'core')
// private readonly userRepository: Repository<User>,
// @InjectRepository(AppToken, 'core')
// private readonly appTokenRepository: Repository<AppToken>,
// private readonly accessTokenService: AccessTokenService,
// private readonly refreshTokenService: RefreshTokenService,
// private readonly loginTokenService: LoginTokenService,
// ) {}
//
// async verifyAuthorizationCode(
// exchangeAuthCodeInput: ExchangeAuthCodeInput,
// ): Promise<ExchangeAuthCode> {
// const { authorizationCode, codeVerifier } = exchangeAuthCodeInput;
//
// if (!authorizationCode) {
// throw new AuthException(
// 'Authorization code not found',
// AuthExceptionCode.INVALID_INPUT,
// );
// }
//
// let userId = '';
//
// if (codeVerifier) {
// const authorizationCodeAppToken = await this.appTokenRepository.findOne({
// where: {
// value: authorizationCode,
// },
// });
//
// if (!authorizationCodeAppToken) {
// throw new AuthException(
// 'Authorization code does not exist',
// AuthExceptionCode.INVALID_INPUT,
// );
// }
//
// if (!(authorizationCodeAppToken.expiresAt.getTime() >= Date.now())) {
// throw new AuthException(
// 'Authorization code expired.',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// const codeChallenge = crypto
// .createHash('sha256')
// .update(codeVerifier)
// .digest()
// .toString('base64')
// .replace(/\+/g, '-')
// .replace(/\//g, '_')
// .replace(/=/g, '');
//
// const codeChallengeAppToken = await this.appTokenRepository.findOne({
// where: {
// value: codeChallenge,
// },
// });
//
// if (!codeChallengeAppToken || !codeChallengeAppToken.userId) {
// throw new AuthException(
// 'code verifier doesnt match the challenge',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// if (!(codeChallengeAppToken.expiresAt.getTime() >= Date.now())) {
// throw new AuthException(
// 'code challenge expired.',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// if (codeChallengeAppToken.userId !== authorizationCodeAppToken.userId) {
// throw new AuthException(
// 'authorization code / code verifier was not created by same client',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// if (codeChallengeAppToken.revokedAt) {
// throw new AuthException(
// 'Token has been revoked.',
// AuthExceptionCode.FORBIDDEN_EXCEPTION,
// );
// }
//
// await this.appTokenRepository.save({
// id: codeChallengeAppToken.id,
// revokedAt: new Date(),
// });
//
// userId = codeChallengeAppToken.userId;
// }
//
// const user = await this.userRepository.findOne({
// where: { id: userId },
// relations: ['defaultWorkspace'],
// });
//
// userValidator.assertIsDefinedOrThrow(
// user,
// new AuthException(
// 'User who generated the token does not exist',
// AuthExceptionCode.INVALID_INPUT,
// ),
// );
//
// if (!user.defaultWorkspace) {
// throw new AuthException(
// 'User does not have a default workspace',
// AuthExceptionCode.INVALID_DATA,
// );
// }
//
// const accessToken = await this.accessTokenService.generateAccessToken(
// user.id,
// user.defaultWorkspaceId,
// );
// const refreshToken = await this.refreshTokenService.generateRefreshToken(
// user.id,
// user.defaultWorkspaceId,
// );
// const loginToken = await this.loginTokenService.generateLoginToken(
// user.email,
// );
//
// return {
// accessToken,
// refreshToken,
// loginToken,
// };
// }
// }

View File

@ -15,11 +15,11 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
jest.mock('bcrypt');
@ -120,12 +120,15 @@ describe('SignInUpService', () => {
provide: DomainManagerService,
useValue: {
generateSubdomain: jest.fn().mockReturnValue('testSubDomain'),
getWorkspaceBySubdomainOrDefaultWorkspace: jest
.fn()
.mockReturnValue({}),
},
},
{
provide: UserService,
useValue: {
saveDefaultWorkspaceIfUserHasAccessOrThrow: jest.fn(),
hasUserAccessToWorkspaceOrThrow: jest.fn(),
},
},
],
@ -148,7 +151,10 @@ describe('SignInUpService', () => {
const spy = jest
.spyOn(service, 'signUpOnNewWorkspace')
.mockResolvedValueOnce({} as User);
.mockResolvedValueOnce({ user: {}, workspace: {} } as {
user: User;
workspace: Workspace;
});
await service.signInUp({
email: 'test@test.com',
@ -172,7 +178,6 @@ describe('SignInUpService', () => {
id: 'user-id',
email,
passwordHash: undefined,
defaultWorkspace: { id: 'workspace-id' },
};
UserFindOneMock.mockReturnValueOnce(existingUser);
@ -189,7 +194,7 @@ describe('SignInUpService', () => {
targetWorkspaceSubdomain: 'testSubDomain',
});
expect(result).toEqual(existingUser);
expect(result).toEqual({ user: existingUser, workspace: {} });
});
it('signInUp - sso - new user - existing invitation', async () => {
const email = 'newuser@test.com';
@ -248,7 +253,11 @@ describe('SignInUpService', () => {
id: 'user-id',
email,
passwordHash: undefined,
defaultWorkspace: { id: 'workspace-id' },
};
const workspace = {
id: workspaceId,
activationStatus: WorkspaceActivationStatus.ACTIVE,
};
UserFindOneMock.mockReturnValueOnce(existingUser);
@ -259,10 +268,7 @@ describe('SignInUpService', () => {
);
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
isValid: true,
workspace: {
id: workspaceId,
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
workspace,
});
workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce(
@ -277,7 +283,7 @@ describe('SignInUpService', () => {
targetWorkspaceSubdomain: 'testSubDomain',
});
expect(result).toEqual(existingUser);
expect(result).toEqual({ user: existingUser, workspace });
expect(userWorkspaceServiceAddUserToWorkspaceMock).toHaveBeenCalledTimes(1);
expect(
workspaceInvitationInvalidateWorkspaceInvitationMock,
@ -287,15 +293,16 @@ describe('SignInUpService', () => {
const email = 'newuser@test.com';
const workspaceId = 'workspace-id';
const workspacePersonalInviteToken = 'personal-token-value';
const workspace = {
id: workspaceId,
activationStatus: WorkspaceActivationStatus.ACTIVE,
};
UserFindOneMock.mockReturnValueOnce(null);
workspaceInvitationValidateInvitationMock.mockReturnValueOnce({
isValid: true,
workspace: {
id: workspaceId,
activationStatus: WorkspaceActivationStatus.ACTIVE,
},
workspace,
});
workspaceInvitationInvalidateWorkspaceInvitationMock.mockReturnValueOnce(
@ -345,7 +352,6 @@ describe('SignInUpService', () => {
id: 'user-id',
email,
passwordHash: undefined,
defaultWorkspace: { id: 'workspace-id' },
};
UserFindOneMock.mockReturnValueOnce(existingUser);
@ -379,7 +385,6 @@ describe('SignInUpService', () => {
id: 'user-id',
email,
passwordHash: 'hash-of-validPassword123',
defaultWorkspace: { id: 'workspace-id' },
};
UserFindOneMock.mockReturnValueOnce(existingUser);

View File

@ -2,7 +2,6 @@ import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'class-validator';
import FileType from 'file-type';
import { TWENTY_ICONS_BASE_URL } from 'twenty-shared';
import { Repository } from 'typeorm';
@ -37,6 +36,7 @@ import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
import { getImageBufferFromUrl } from 'src/utils/image';
import { isWorkEmail } from 'src/utils/is-work-email';
import { isDefined } from 'src/utils/is-defined';
export type SignInUpServiceInput = {
email: string;
@ -106,7 +106,6 @@ export class SignInUpService {
const existingUser = await this.userRepository.findOne({
where: { email },
relations: ['defaultWorkspace'],
});
if (existingUser && !fromSSO) {
@ -123,53 +122,22 @@ export class SignInUpService {
}
}
const maybeInvitation =
fromSSO && !workspacePersonalInviteToken && !workspaceInviteHash
? await this.workspaceInvitationService.findInvitationByWorkspaceSubdomainAndUserEmail(
{
subdomain: targetWorkspaceSubdomain,
email,
},
)
: undefined;
const signInUpWithInvitationResult = await this.signInUpWithInvitation({
email,
workspacePersonalInviteToken,
workspaceInviteHash,
targetWorkspaceSubdomain,
fromSSO,
firstName,
lastName,
picture,
authProvider,
passwordHash,
existingUser,
});
if (
workspacePersonalInviteToken ||
workspaceInviteHash ||
maybeInvitation
) {
const invitationValidation =
workspacePersonalInviteToken || workspaceInviteHash || maybeInvitation
? await this.workspaceInvitationService.validateInvitation({
workspacePersonalInviteToken:
workspacePersonalInviteToken ?? maybeInvitation?.value,
workspaceInviteHash,
email,
})
: null;
if (
invitationValidation?.isValid === true &&
invitationValidation.workspace
) {
const updatedUser = await this.signInUpOnExistingWorkspace({
email,
passwordHash,
workspace: invitationValidation.workspace,
firstName,
lastName,
picture,
existingUser,
authProvider,
});
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
invitationValidation.workspace.id,
email,
);
return updatedUser;
}
if (isDefined(signInUpWithInvitationResult)) {
return signInUpWithInvitationResult;
}
if (!existingUser) {
@ -182,27 +150,111 @@ export class SignInUpService {
});
}
if (targetWorkspaceSubdomain) {
const workspace = await this.workspaceRepository.findOne({
where: { subdomain: targetWorkspaceSubdomain },
select: ['id'],
const workspace =
await this.domainManagerService.getWorkspaceBySubdomainOrDefaultWorkspace(
targetWorkspaceSubdomain,
);
workspaceValidator.assertIsDefinedOrThrow(workspace);
return await this.validateSignIn({
user: existingUser,
workspace,
authProvider,
});
}
private async signInUpWithInvitation({
email,
workspacePersonalInviteToken,
workspaceInviteHash,
firstName,
lastName,
picture,
fromSSO,
targetWorkspaceSubdomain,
authProvider,
passwordHash,
existingUser,
}: {
email: string;
workspacePersonalInviteToken?: string;
workspaceInviteHash?: string;
firstName: string;
lastName: string;
picture?: string | null;
authProvider?: WorkspaceAuthProvider;
passwordHash?: string;
existingUser: User | null;
fromSSO: boolean;
targetWorkspaceSubdomain?: string;
}) {
const maybeInvitation =
fromSSO && !workspacePersonalInviteToken && !workspaceInviteHash
? await this.workspaceInvitationService.findInvitationByWorkspaceSubdomainAndUserEmail(
{
subdomain: targetWorkspaceSubdomain,
email,
},
)
: undefined;
const invitationValidation =
workspacePersonalInviteToken || workspaceInviteHash || maybeInvitation
? await this.workspaceInvitationService.validateInvitation({
workspacePersonalInviteToken:
workspacePersonalInviteToken ?? maybeInvitation?.value,
workspaceInviteHash,
email,
})
: null;
if (
invitationValidation?.isValid === true &&
invitationValidation.workspace
) {
const updatedUser = await this.signInUpOnExistingWorkspace({
email,
passwordHash,
workspace: invitationValidation.workspace,
firstName,
lastName,
picture,
existingUser,
authProvider,
});
workspaceValidator.assertIsExist(
workspace,
new AuthException(
'Workspace not found',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
),
await this.workspaceInvitationService.invalidateWorkspaceInvitation(
invitationValidation.workspace.id,
email,
);
await this.userService.saveDefaultWorkspaceIfUserHasAccessOrThrow(
existingUser.id,
workspace.id,
);
return {
user: updatedUser,
workspace: invitationValidation.workspace,
};
}
}
private async validateSignIn({
user,
workspace,
authProvider,
}: {
user: User;
workspace: Workspace;
authProvider: SignInUpServiceInput['authProvider'];
}) {
if (authProvider) {
workspaceValidator.isAuthEnabledOrThrow(authProvider, workspace);
}
return existingUser;
await this.userService.hasUserAccessToWorkspaceOrThrow(
user.id,
workspace.id,
);
return { user, workspace };
}
async signInUpOnExistingWorkspace({
@ -227,7 +279,7 @@ export class SignInUpService {
const isNewUser = !isDefined(existingUser);
let user = existingUser;
workspaceValidator.assertIsExist(
workspaceValidator.assertIsDefinedOrThrow(
workspace,
new AuthException(
'Workspace not found',
@ -244,14 +296,7 @@ export class SignInUpService {
);
if (authProvider) {
workspaceValidator.isAuthEnabledOrThrow(
authProvider,
workspace,
new AuthException(
`${authProvider} auth is not enabled for this workspace`,
AuthExceptionCode.OAUTH_ACCESS_DENIED,
),
);
workspaceValidator.isAuthEnabledOrThrow(authProvider, workspace);
}
if (isNewUser) {
@ -264,7 +309,6 @@ export class SignInUpService {
defaultAvatarUrl: imagePath,
canImpersonate: false,
passwordHash,
defaultWorkspace: workspace,
});
user = await this.userRepository.save(userToCreate);
@ -364,10 +408,7 @@ export class SignInUpService {
user.defaultAvatarUrl = await this.uploadPicture(picture, workspace.id);
const userCreated = this.userRepository.create({
...user,
defaultWorkspace: workspace,
});
const userCreated = this.userRepository.create(user);
const newUser = await this.userRepository.save(userCreated);
@ -383,7 +424,7 @@ export class SignInUpService {
value: true,
});
return newUser;
return { user: newUser, workspace };
}
async uploadPicture(

View File

@ -17,9 +17,6 @@ describe('SwitchWorkspaceService', () => {
let service: SwitchWorkspaceService;
let userRepository: Repository<User>;
let workspaceRepository: Repository<Workspace>;
let userService: UserService;
let accessTokenService: AccessTokenService;
let refreshTokenService: RefreshTokenService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@ -45,18 +42,16 @@ describe('SwitchWorkspaceService', () => {
generateRefreshToken: jest.fn(),
},
},
{
provide: UserService,
useValue: {
saveDefaultWorkspaceIfUserHasAccessOrThrow: jest.fn(),
},
},
{
provide: EnvironmentService,
useValue: {
get: jest.fn(),
},
},
{
provide: UserService,
useValue: {},
},
],
}).compile();
@ -67,9 +62,6 @@ describe('SwitchWorkspaceService', () => {
workspaceRepository = module.get<Repository<Workspace>>(
getRepositoryToken(Workspace, 'core'),
);
accessTokenService = module.get<AccessTokenService>(AccessTokenService);
refreshTokenService = module.get<RefreshTokenService>(RefreshTokenService);
userService = module.get<UserService>(UserService);
});
it('should be defined', () => {
@ -191,44 +183,4 @@ describe('SwitchWorkspaceService', () => {
});
});
});
describe('generateSwitchWorkspaceToken', () => {
it('should generate and return auth tokens', async () => {
const mockUser = { id: 'user-id' };
const mockWorkspace = { id: 'workspace-id' };
const mockAccessToken = { token: 'access-token', expiresAt: new Date() };
const mockRefreshToken = 'refresh-token';
jest.spyOn(userRepository, 'save').mockResolvedValue({} as User);
jest
.spyOn(accessTokenService, 'generateAccessToken')
.mockResolvedValue(mockAccessToken);
jest
.spyOn(refreshTokenService, 'generateRefreshToken')
.mockResolvedValue(mockRefreshToken as any);
const result = await service.generateSwitchWorkspaceToken(
mockUser as User,
mockWorkspace as Workspace,
);
expect(result).toEqual({
tokens: {
accessToken: mockAccessToken,
refreshToken: mockRefreshToken,
},
});
expect(
userService.saveDefaultWorkspaceIfUserHasAccessOrThrow,
).toHaveBeenCalledWith(mockUser.id, mockWorkspace.id);
expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith(
mockUser.id,
mockWorkspace.id,
);
expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith(
mockUser.id,
mockWorkspace.id,
);
});
});
});

View File

@ -7,50 +7,31 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { AuthProviders } from 'src/engine/core-modules/workspace/dtos/public-workspace-data.output';
import { AuthProviders } from 'src/engine/core-modules/workspace/dtos/public-workspace-data-output';
import { getAuthProvidersByWorkspace } from 'src/engine/core-modules/workspace/utils/get-auth-providers-by-workspace.util';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
@Injectable()
export class SwitchWorkspaceService {
constructor(
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
private readonly userService: UserService,
private readonly accessTokenService: AccessTokenService,
private readonly refreshTokenService: RefreshTokenService,
private readonly environmentService: EnvironmentService,
) {}
async switchWorkspace(user: User, workspaceId: string) {
const userExists = await this.userRepository.findBy({ id: user.id });
if (!userExists) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
relations: ['workspaceUsers', 'workspaceSSOIdentityProviders'],
});
if (!workspace) {
throw new AuthException(
'workspace doesnt exist',
AuthExceptionCode.INVALID_INPUT,
);
}
workspaceValidator.assertIsDefinedOrThrow(
workspace,
new AuthException('Workspace not found', AuthExceptionCode.INVALID_INPUT),
);
if (
!workspace.workspaceUsers
@ -63,11 +44,6 @@ export class SwitchWorkspaceService {
);
}
await this.userRepository.save({
id: user.id,
defaultWorkspace: workspace,
});
const systemEnabledProviders: AuthProviders = {
google: this.environmentService.get('AUTH_GOOGLE_ENABLED'),
magicLink: false,
@ -87,30 +63,4 @@ export class SwitchWorkspaceService {
}),
};
}
async generateSwitchWorkspaceToken(
user: User,
workspace: Workspace,
): Promise<AuthTokens> {
await this.userService.saveDefaultWorkspaceIfUserHasAccessOrThrow(
user.id,
workspace.id,
);
const token = await this.accessTokenService.generateAccessToken(
user.id,
workspace.id,
);
const refreshToken = await this.refreshTokenService.generateRefreshToken(
user.id,
workspace.id,
);
return {
tokens: {
accessToken: token,
refreshToken,
},
};
}
}

View File

@ -12,7 +12,10 @@ import { EnvironmentService } from 'src/engine/core-modules/environment/environm
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { AccessTokenService } from './access-token.service';
@ -22,6 +25,7 @@ describe('AccessTokenService', () => {
let jwtWrapperService: JwtWrapperService;
let environmentService: EnvironmentService;
let userRepository: Repository<User>;
let workspaceRepository: Repository<Workspace>;
let twentyORMGlobalManager: TwentyORMGlobalManager;
beforeEach(async () => {
@ -84,6 +88,9 @@ describe('AccessTokenService', () => {
userRepository = module.get<Repository<User>>(
getRepositoryToken(User, 'core'),
);
workspaceRepository = module.get<Repository<Workspace>>(
getRepositoryToken(Workspace, 'core'),
);
twentyORMGlobalManager = module.get<TwentyORMGlobalManager>(
TwentyORMGlobalManager,
);
@ -99,14 +106,19 @@ describe('AccessTokenService', () => {
const workspaceId = 'workspace-id';
const mockUser = {
id: userId,
defaultWorkspace: { id: workspaceId, activationStatus: 'ACTIVE' },
defaultWorkspaceId: workspaceId,
};
const mockWorkspace = {
activationStatus: WorkspaceActivationStatus.ACTIVE,
id: workspaceId,
};
const mockWorkspaceMember = { id: 'workspace-member-id' };
const mockToken = 'mock-token';
jest.spyOn(environmentService, 'get').mockReturnValue('1h');
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as User);
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace as Workspace);
jest
.spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace')
.mockResolvedValue({

View File

@ -20,9 +20,14 @@ import {
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceActivationStatus } from 'src/engine/core-modules/workspace/workspace.entity';
import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.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';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
@Injectable()
export class AccessTokenService {
@ -32,6 +37,8 @@ export class AccessTokenService {
private readonly environmentService: EnvironmentService,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
@ -45,33 +52,25 @@ export class AccessTokenService {
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['defaultWorkspace'],
});
if (!user) {
throw new AuthException(
'User is not found',
AuthExceptionCode.INVALID_INPUT,
);
}
userValidator.assertIsDefinedOrThrow(
user,
new AuthException('User is not found', AuthExceptionCode.INVALID_INPUT),
);
if (!user.defaultWorkspace) {
throw new AuthException(
'User does not have a default workspace',
AuthExceptionCode.INVALID_DATA,
);
}
const tokenWorkspaceId = workspaceId ?? user.defaultWorkspaceId;
let tokenWorkspaceMemberId: string | undefined;
if (
user.defaultWorkspace.activationStatus ===
WorkspaceActivationStatus.ACTIVE
) {
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
});
workspaceValidator.assertIsDefinedOrThrow(workspace);
if (workspace.activationStatus === WorkspaceActivationStatus.ACTIVE) {
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
tokenWorkspaceId,
workspaceId,
'workspaceMember',
);
@ -93,7 +92,7 @@ export class AccessTokenService {
const jwtPayload: JwtPayload = {
sub: user.id,
workspaceId: workspaceId ? workspaceId : user.defaultWorkspaceId,
workspaceId,
workspaceMemberId: tokenWorkspaceMemberId,
};

View File

@ -47,6 +47,7 @@ describe('LoginTokenService', () => {
const mockSecret = 'mock-secret';
const mockExpiresIn = '1h';
const mockToken = 'mock-token';
const workspaceId = 'workspace-id';
jest
.spyOn(jwtWrapperService, 'generateAppSecret')
@ -54,18 +55,21 @@ describe('LoginTokenService', () => {
jest.spyOn(environmentService, 'get').mockReturnValue(mockExpiresIn);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
const result = await service.generateLoginToken(email);
const result = await service.generateLoginToken(email, workspaceId);
expect(result).toEqual({
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith('LOGIN');
expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith(
'LOGIN',
workspaceId,
);
expect(environmentService.get).toHaveBeenCalledWith(
'LOGIN_TOKEN_EXPIRES_IN',
);
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
{ sub: email },
{ sub: email, workspaceId },
{ secret: mockSecret, expiresIn: mockExpiresIn },
);
});

View File

@ -14,14 +14,21 @@ export class LoginTokenService {
private readonly environmentService: EnvironmentService,
) {}
async generateLoginToken(email: string): Promise<AuthToken> {
const secret = this.jwtWrapperService.generateAppSecret('LOGIN');
async generateLoginToken(
email: string,
workspaceId: string,
): Promise<AuthToken> {
const secret = this.jwtWrapperService.generateAppSecret(
'LOGIN',
workspaceId,
);
const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN');
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const jwtPayload = {
sub: email,
workspaceId,
};
return {
@ -33,7 +40,9 @@ export class LoginTokenService {
};
}
async verifyLoginToken(loginToken: string): Promise<{ sub: string }> {
async verifyLoginToken(
loginToken: string,
): Promise<{ sub: string; workspaceId: string }> {
await this.jwtWrapperService.verifyWorkspaceToken(loginToken, 'LOGIN');
return this.jwtWrapperService.decode(loginToken, {

View File

@ -15,6 +15,7 @@ import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.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';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
@Module({
imports: [
@ -24,6 +25,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
DataSourceModule,
EmailModule,
WorkspaceSSOModule,
UserWorkspaceModule,
],
providers: [
RenewTokenService,