feat: generate secret function and replaced few instances (#7810)
This PR fixes #4588 --------- Co-authored-by: Félix Malfait <felix@twenty.com> Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@ -9,31 +9,37 @@ import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-
|
||||
import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controllers/google-apis-auth.controller';
|
||||
import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/google-auth.controller';
|
||||
import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-auth.controller';
|
||||
import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller';
|
||||
import { VerifyAuthController } from 'src/engine/core-modules/auth/controllers/verify-auth.controller';
|
||||
import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service';
|
||||
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
|
||||
import { 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 { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service';
|
||||
import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy';
|
||||
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 { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
||||
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
|
||||
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
|
||||
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { UserModule } from 'src/engine/core-modules/user/user.module';
|
||||
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
|
||||
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
|
||||
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
|
||||
import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller';
|
||||
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
|
||||
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
|
||||
import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
|
||||
|
||||
import { AuthResolver } from './auth.resolver';
|
||||
|
||||
@ -83,10 +89,16 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
||||
JwtAuthStrategy,
|
||||
SamlAuthStrategy,
|
||||
AuthResolver,
|
||||
TokenService,
|
||||
GoogleAPIsService,
|
||||
AppTokenService,
|
||||
AccessTokenService,
|
||||
LoginTokenService,
|
||||
ResetPasswordService,
|
||||
SwitchWorkspaceService,
|
||||
TransientTokenService,
|
||||
ApiKeyService,
|
||||
OAuthService,
|
||||
],
|
||||
exports: [TokenService],
|
||||
exports: [AccessTokenService, LoginTokenService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@ -10,8 +10,14 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { AuthResolver } from './auth.resolver';
|
||||
|
||||
import { ApiKeyService } from './services/api-key.service';
|
||||
import { AuthService } from './services/auth.service';
|
||||
import { TokenService } from './token/services/token.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';
|
||||
import { RenewTokenService } from './token/services/renew-token.service';
|
||||
import { TransientTokenService } from './token/services/transient-token.service';
|
||||
|
||||
describe('AuthResolver', () => {
|
||||
let resolver: AuthResolver;
|
||||
@ -33,10 +39,6 @@ describe('AuthResolver', () => {
|
||||
provide: AuthService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: TokenService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: UserService,
|
||||
useValue: {},
|
||||
@ -45,6 +47,34 @@ describe('AuthResolver', () => {
|
||||
provide: UserWorkspaceService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: RenewTokenService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: ApiKeyService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: ResetPasswordService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: LoginTokenService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: SwitchWorkspaceService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: TransientTokenService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: OAuthService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(CaptchaGuard)
|
||||
|
||||
@ -10,12 +10,24 @@ import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/em
|
||||
import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity';
|
||||
import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input';
|
||||
import { GenerateJwtInput } from 'src/engine/core-modules/auth/dto/generate-jwt.input';
|
||||
import {
|
||||
GenerateJWTOutput,
|
||||
GenerateJWTOutputWithAuthTokens,
|
||||
GenerateJWTOutputWithSSOAUTH,
|
||||
} from 'src/engine/core-modules/auth/dto/generateJWT.output';
|
||||
import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity';
|
||||
import { TransientToken } from 'src/engine/core-modules/auth/dto/transient-token.entity';
|
||||
import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input';
|
||||
import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.entity';
|
||||
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 { 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';
|
||||
import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service';
|
||||
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
|
||||
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
@ -24,11 +36,6 @@ import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import {
|
||||
GenerateJWTOutput,
|
||||
GenerateJWTOutputWithAuthTokens,
|
||||
GenerateJWTOutputWithSSOAUTH,
|
||||
} from 'src/engine/core-modules/auth/dto/generateJWT.output';
|
||||
|
||||
import { ChallengeInput } from './dto/challenge.input';
|
||||
import { ImpersonateInput } from './dto/impersonate.input';
|
||||
@ -42,15 +49,20 @@ import { VerifyInput } from './dto/verify.input';
|
||||
import { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity';
|
||||
import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input';
|
||||
import { AuthService } from './services/auth.service';
|
||||
import { TokenService } from './token/services/token.service';
|
||||
|
||||
@Resolver()
|
||||
@UseFilters(AuthGraphqlApiExceptionFilter)
|
||||
export class AuthResolver {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private tokenService: TokenService,
|
||||
private renewTokenService: RenewTokenService,
|
||||
private userService: UserService,
|
||||
private apiKeyService: ApiKeyService,
|
||||
private resetPasswordService: ResetPasswordService,
|
||||
private loginTokenService: LoginTokenService,
|
||||
private switchWorkspaceService: SwitchWorkspaceService,
|
||||
private transientTokenService: TransientTokenService,
|
||||
private oauthService: OAuthService,
|
||||
) {}
|
||||
|
||||
@UseGuards(CaptchaGuard)
|
||||
@ -87,7 +99,9 @@ export class AuthResolver {
|
||||
@Mutation(() => LoginToken)
|
||||
async challenge(@Args() challengeInput: ChallengeInput): Promise<LoginToken> {
|
||||
const user = await this.authService.challenge(challengeInput);
|
||||
const loginToken = await this.tokenService.generateLoginToken(user.email);
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
);
|
||||
|
||||
return { loginToken };
|
||||
}
|
||||
@ -100,7 +114,9 @@ export class AuthResolver {
|
||||
fromSSO: false,
|
||||
});
|
||||
|
||||
const loginToken = await this.tokenService.generateLoginToken(user.email);
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
);
|
||||
|
||||
return { loginToken };
|
||||
}
|
||||
@ -109,7 +125,7 @@ export class AuthResolver {
|
||||
async exchangeAuthorizationCode(
|
||||
@Args() exchangeAuthCodeInput: ExchangeAuthCodeInput,
|
||||
) {
|
||||
const tokens = await this.tokenService.verifyAuthorizationCode(
|
||||
const tokens = await this.oauthService.verifyAuthorizationCode(
|
||||
exchangeAuthCodeInput,
|
||||
);
|
||||
|
||||
@ -130,18 +146,19 @@ export class AuthResolver {
|
||||
if (!workspaceMember) {
|
||||
return;
|
||||
}
|
||||
const transientToken = await this.tokenService.generateTransientToken(
|
||||
workspaceMember.id,
|
||||
user.id,
|
||||
user.defaultWorkspaceId,
|
||||
);
|
||||
const transientToken =
|
||||
await this.transientTokenService.generateTransientToken(
|
||||
workspaceMember.id,
|
||||
user.id,
|
||||
user.defaultWorkspaceId,
|
||||
);
|
||||
|
||||
return { transientToken };
|
||||
}
|
||||
|
||||
@Mutation(() => Verify)
|
||||
async verify(@Args() verifyInput: VerifyInput): Promise<Verify> {
|
||||
const email = await this.tokenService.verifyLoginToken(
|
||||
const email = await this.loginTokenService.verifyLoginToken(
|
||||
verifyInput.loginToken,
|
||||
);
|
||||
|
||||
@ -170,7 +187,7 @@ export class AuthResolver {
|
||||
@AuthUser() user: User,
|
||||
@Args() args: GenerateJwtInput,
|
||||
): Promise<GenerateJWTOutputWithAuthTokens | GenerateJWTOutputWithSSOAUTH> {
|
||||
const result = await this.tokenService.switchWorkspace(
|
||||
const result = await this.switchWorkspaceService.switchWorkspace(
|
||||
user,
|
||||
args.workspaceId,
|
||||
);
|
||||
@ -194,16 +211,17 @@ export class AuthResolver {
|
||||
return {
|
||||
success: true,
|
||||
reason: 'WORKSPACE_AVAILABLE_FOR_SWITCH',
|
||||
authTokens: await this.tokenService.generateSwitchWorkspaceToken(
|
||||
user,
|
||||
result.workspace,
|
||||
),
|
||||
authTokens:
|
||||
await this.switchWorkspaceService.generateSwitchWorkspaceToken(
|
||||
user,
|
||||
result.workspace,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Mutation(() => AuthTokens)
|
||||
async renewToken(@Args() args: AppTokenInput): Promise<AuthTokens> {
|
||||
const tokens = await this.tokenService.generateTokensFromRefreshToken(
|
||||
const tokens = await this.renewTokenService.generateTokensFromRefreshToken(
|
||||
args.appToken,
|
||||
);
|
||||
|
||||
@ -225,7 +243,7 @@ export class AuthResolver {
|
||||
@Args() args: ApiKeyTokenInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
): Promise<ApiKeyToken | undefined> {
|
||||
return await this.tokenService.generateApiKeyToken(
|
||||
return await this.apiKeyService.generateApiKeyToken(
|
||||
workspaceId,
|
||||
args.apiKeyId,
|
||||
args.expiresAt,
|
||||
@ -236,11 +254,12 @@ export class AuthResolver {
|
||||
async emailPasswordResetLink(
|
||||
@Args() emailPasswordResetInput: EmailPasswordResetLinkInput,
|
||||
): Promise<EmailPasswordResetLink> {
|
||||
const resetToken = await this.tokenService.generatePasswordResetToken(
|
||||
emailPasswordResetInput.email,
|
||||
);
|
||||
const resetToken =
|
||||
await this.resetPasswordService.generatePasswordResetToken(
|
||||
emailPasswordResetInput.email,
|
||||
);
|
||||
|
||||
return await this.tokenService.sendEmailPasswordResetLink(
|
||||
return await this.resetPasswordService.sendEmailPasswordResetLink(
|
||||
resetToken,
|
||||
emailPasswordResetInput.email,
|
||||
);
|
||||
@ -252,18 +271,20 @@ export class AuthResolver {
|
||||
{ passwordResetToken, newPassword }: UpdatePasswordViaResetTokenInput,
|
||||
): Promise<InvalidatePassword> {
|
||||
const { id } =
|
||||
await this.tokenService.validatePasswordResetToken(passwordResetToken);
|
||||
await this.resetPasswordService.validatePasswordResetToken(
|
||||
passwordResetToken,
|
||||
);
|
||||
|
||||
await this.authService.updatePassword(id, newPassword);
|
||||
|
||||
return await this.tokenService.invalidatePasswordResetToken(id);
|
||||
return await this.resetPasswordService.invalidatePasswordResetToken(id);
|
||||
}
|
||||
|
||||
@Query(() => ValidatePasswordResetToken)
|
||||
async validatePasswordResetToken(
|
||||
@Args() args: ValidatePasswordResetTokenInput,
|
||||
): Promise<ValidatePasswordResetToken> {
|
||||
return this.tokenService.validatePasswordResetToken(
|
||||
return this.resetPasswordService.validatePasswordResetToken(
|
||||
args.passwordResetToken,
|
||||
);
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters
|
||||
import { GoogleAPIsOauthExchangeCodeForTokenGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard';
|
||||
import { GoogleAPIsOauthRequestCodeGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard';
|
||||
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
|
||||
import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
@ -27,7 +27,7 @@ import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding
|
||||
export class GoogleAPIsAuthController {
|
||||
constructor(
|
||||
private readonly googleAPIsService: GoogleAPIsService,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly transientTokenService: TransientTokenService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly onboardingService: OnboardingService,
|
||||
) {}
|
||||
@ -58,7 +58,7 @@ export class GoogleAPIsAuthController {
|
||||
} = user;
|
||||
|
||||
const { workspaceMemberId, userId, workspaceId } =
|
||||
await this.tokenService.verifyTransientToken(transientToken);
|
||||
await this.transientTokenService.verifyTransientToken(transientToken);
|
||||
|
||||
const demoWorkspaceIds = this.environmentService.get('DEMO_WORKSPACE_IDS');
|
||||
|
||||
|
||||
@ -15,13 +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 { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
|
||||
@Controller('auth/google')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
export class GoogleAuthController {
|
||||
constructor(
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
@ -55,8 +55,10 @@ export class GoogleAuthController {
|
||||
fromSSO: true,
|
||||
});
|
||||
|
||||
const loginToken = await this.tokenService.generateLoginToken(user.email);
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
);
|
||||
|
||||
return res.redirect(this.tokenService.computeRedirectURI(loginToken.token));
|
||||
return res.redirect(this.authService.computeRedirectURI(loginToken.token));
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,20 +9,18 @@ import {
|
||||
|
||||
import { Response } from 'express';
|
||||
|
||||
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
|
||||
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 { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
|
||||
@Controller('auth/microsoft')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
export class MicrosoftAuthController {
|
||||
constructor(
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly typeORMService: TypeORMService,
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
@ -58,8 +56,10 @@ export class MicrosoftAuthController {
|
||||
fromSSO: true,
|
||||
});
|
||||
|
||||
const loginToken = await this.tokenService.generateLoginToken(user.email);
|
||||
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||
user.email,
|
||||
);
|
||||
|
||||
return res.redirect(this.tokenService.computeRedirectURI(loginToken.token));
|
||||
return res.redirect(this.authService.computeRedirectURI(loginToken.token));
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,7 +24,7 @@ import { OIDCAuthGuard } from 'src/engine/core-modules/auth/guards/oidc-auth.gua
|
||||
import { SAMLAuthGuard } from 'src/engine/core-modules/auth/guards/saml-auth.guard';
|
||||
import { SSOProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/sso-provider-enabled.guard';
|
||||
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
|
||||
import {
|
||||
@ -38,7 +38,7 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
export class SSOAuthController {
|
||||
constructor(
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@ -84,7 +84,7 @@ export class SSOAuthController {
|
||||
const loginToken = await this.generateLoginToken(req.user);
|
||||
|
||||
return res.redirect(
|
||||
this.tokenService.computeRedirectURI(loginToken.token),
|
||||
this.authService.computeRedirectURI(loginToken.token),
|
||||
);
|
||||
} catch (err) {
|
||||
// TODO: improve error management
|
||||
@ -99,7 +99,7 @@ export class SSOAuthController {
|
||||
const loginToken = await this.generateLoginToken(req.user);
|
||||
|
||||
return res.redirect(
|
||||
this.tokenService.computeRedirectURI(loginToken.token),
|
||||
this.authService.computeRedirectURI(loginToken.token),
|
||||
);
|
||||
} catch (err) {
|
||||
// TODO: improve error management
|
||||
@ -156,6 +156,6 @@ export class SSOAuthController {
|
||||
);
|
||||
}
|
||||
|
||||
return this.tokenService.generateLoginToken(user.email);
|
||||
return this.loginTokenService.generateLoginToken(user.email);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
|
||||
import { VerifyAuthController } from './verify-auth.controller';
|
||||
|
||||
@ -17,7 +17,7 @@ describe('VerifyAuthController', () => {
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: TokenService,
|
||||
provide: LoginTokenService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
|
||||
@ -4,19 +4,19 @@ import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity';
|
||||
import { VerifyInput } from 'src/engine/core-modules/auth/dto/verify.input';
|
||||
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
|
||||
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
|
||||
|
||||
@Controller('auth/verify')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
export class VerifyAuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly loginTokenService: LoginTokenService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
async verify(@Body() verifyInput: VerifyInput): Promise<Verify> {
|
||||
const email = await this.tokenService.verifyLoginToken(
|
||||
const email = await this.loginTokenService.verifyLoginToken(
|
||||
verifyInput.loginToken,
|
||||
);
|
||||
const result = await this.authService.verify(email);
|
||||
|
||||
@ -6,11 +6,11 @@ import {
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { GoogleAPIsOauthExchangeCodeForTokenStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy';
|
||||
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
|
||||
import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
|
||||
@ -19,7 +19,7 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
|
||||
constructor(
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly transientTokenService: TransientTokenService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@ -27,9 +27,10 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const state = JSON.parse(request.query.state);
|
||||
const { workspaceId } = await this.tokenService.verifyTransientToken(
|
||||
state.transientToken,
|
||||
);
|
||||
const { workspaceId } =
|
||||
await this.transientTokenService.verifyTransientToken(
|
||||
state.transientToken,
|
||||
);
|
||||
const isGmailSendEmailScopeEnabled =
|
||||
await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IsGmailSendEmailScopeEnabled,
|
||||
|
||||
@ -6,18 +6,18 @@ import {
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { GoogleAPIsOauthRequestCodeStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy';
|
||||
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
|
||||
import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
|
||||
constructor(
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly transientTokenService: TransientTokenService,
|
||||
) {
|
||||
super({
|
||||
prompt: 'select_account',
|
||||
@ -27,9 +27,10 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
const { workspaceId } = await this.tokenService.verifyTransientToken(
|
||||
request.query.transientToken,
|
||||
);
|
||||
const { workspaceId } =
|
||||
await this.transientTokenService.verifyTransientToken(
|
||||
request.query.transientToken,
|
||||
);
|
||||
const isGmailSendEmailScopeEnabled =
|
||||
await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IsGmailSendEmailScopeEnabled,
|
||||
|
||||
@ -0,0 +1,96 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
|
||||
describe('ApiKeyService', () => {
|
||||
let service: ApiKeyService;
|
||||
let jwtWrapperService: JwtWrapperService;
|
||||
let environmentService: EnvironmentService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ApiKeyService,
|
||||
{
|
||||
provide: JwtWrapperService,
|
||||
useValue: {
|
||||
sign: jest.fn(),
|
||||
generateAppSecret: jest.fn().mockReturnValue('mocked-secret'),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ApiKeyService>(ApiKeyService);
|
||||
jwtWrapperService = module.get<JwtWrapperService>(JwtWrapperService);
|
||||
environmentService = module.get<EnvironmentService>(EnvironmentService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('generateApiKeyToken', () => {
|
||||
it('should return undefined if apiKeyId is not provided', async () => {
|
||||
const result = await service.generateApiKeyToken('workspace-id');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should generate an API key token successfully', async () => {
|
||||
const workspaceId = 'workspace-id';
|
||||
const apiKeyId = 'api-key-id';
|
||||
const mockToken = 'mock-token';
|
||||
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue('1h');
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'generateAppSecret')
|
||||
.mockReturnValue('mocked-secret');
|
||||
|
||||
const result = await service.generateApiKeyToken(workspaceId, apiKeyId);
|
||||
|
||||
expect(result).toEqual({ token: mockToken });
|
||||
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
{ sub: workspaceId },
|
||||
expect.objectContaining({
|
||||
secret: 'mocked-secret',
|
||||
expiresIn: '1h',
|
||||
jwtid: apiKeyId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom expiration time if provided', async () => {
|
||||
const workspaceId = 'workspace-id';
|
||||
const apiKeyId = 'api-key-id';
|
||||
const expiresAt = new Date(Date.now() + 3600000); // 1 hour from now
|
||||
const mockToken = 'mock-token';
|
||||
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'generateAppSecret')
|
||||
.mockReturnValue('mocked-secret');
|
||||
|
||||
await service.generateApiKeyToken(workspaceId, apiKeyId, expiresAt);
|
||||
|
||||
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
{ sub: workspaceId },
|
||||
expect.objectContaining({
|
||||
secret: 'mocked-secret',
|
||||
expiresIn: expect.any(Number),
|
||||
jwtid: apiKeyId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,46 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ApiKeyToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyService {
|
||||
constructor(
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
async generateApiKeyToken(
|
||||
workspaceId: string,
|
||||
apiKeyId?: string,
|
||||
expiresAt?: Date | string,
|
||||
): Promise<Pick<ApiKeyToken, 'token'> | undefined> {
|
||||
if (!apiKeyId) {
|
||||
return;
|
||||
}
|
||||
const jwtPayload = {
|
||||
sub: workspaceId,
|
||||
};
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
'ACCESS',
|
||||
workspaceId,
|
||||
);
|
||||
let expiresIn: string | number;
|
||||
|
||||
if (expiresAt) {
|
||||
expiresIn = Math.floor(
|
||||
(new Date(expiresAt).getTime() - new Date().getTime()) / 1000,
|
||||
);
|
||||
} else {
|
||||
expiresIn = this.environmentService.get('API_TOKEN_EXPIRES_IN');
|
||||
}
|
||||
const token = this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret,
|
||||
expiresIn,
|
||||
jwtid: apiKeyId,
|
||||
});
|
||||
|
||||
return { token };
|
||||
}
|
||||
}
|
||||
@ -3,13 +3,12 @@ import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@ -20,22 +19,6 @@ describe('AuthService', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{
|
||||
provide: TokenService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: UserService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: SignInUpService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceManagerService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Workspace, 'core'),
|
||||
useValue: {},
|
||||
@ -48,6 +31,10 @@ describe('AuthService', () => {
|
||||
provide: getRepositoryToken(AppToken, 'core'),
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: SignInUpService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {},
|
||||
@ -56,6 +43,14 @@ describe('AuthService', () => {
|
||||
provide: EmailService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: AccessTokenService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: RefreshTokenService,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@ -32,7 +32,8 @@ import { UserExists } 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 { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
@ -41,7 +42,8 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly accessTokenService: AccessTokenService,
|
||||
private readonly refreshTokenService: RefreshTokenService,
|
||||
private readonly signInUpService: SignInUpService,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
@ -150,8 +152,14 @@ export class AuthService {
|
||||
// passwordHash is hidden for security reasons
|
||||
user.passwordHash = '';
|
||||
|
||||
const accessToken = await this.tokenService.generateAccessToken(user.id);
|
||||
const refreshToken = await this.tokenService.generateRefreshToken(user.id);
|
||||
const accessToken = await this.accessTokenService.generateAccessToken(
|
||||
user.id,
|
||||
user.defaultWorkspaceId,
|
||||
);
|
||||
const refreshToken = await this.refreshTokenService.generateRefreshToken(
|
||||
user.id,
|
||||
user.defaultWorkspaceId,
|
||||
);
|
||||
|
||||
return {
|
||||
user,
|
||||
@ -209,8 +217,14 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
const accessToken = await this.tokenService.generateAccessToken(user.id);
|
||||
const refreshToken = await this.tokenService.generateRefreshToken(user.id);
|
||||
const accessToken = await this.accessTokenService.generateAccessToken(
|
||||
user.id,
|
||||
user.defaultWorkspaceId,
|
||||
);
|
||||
const refreshToken = await this.refreshTokenService.generateRefreshToken(
|
||||
user.id,
|
||||
user.defaultWorkspaceId,
|
||||
);
|
||||
|
||||
return {
|
||||
user,
|
||||
@ -384,4 +398,10 @@ export class AuthService {
|
||||
|
||||
return workspace;
|
||||
}
|
||||
|
||||
computeRedirectURI(loginToken: string): string {
|
||||
return `${this.environmentService.get(
|
||||
'FRONT_BASE_URL',
|
||||
)}/verify?loginToken=${loginToken}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,155 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,217 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { addMilliseconds } from 'date-fns';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AppToken,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { ResetPasswordService } from './reset-password.service';
|
||||
|
||||
describe('ResetPasswordService', () => {
|
||||
let service: ResetPasswordService;
|
||||
let userRepository: Repository<User>;
|
||||
let appTokenRepository: Repository<AppToken>;
|
||||
let emailService: EmailService;
|
||||
let environmentService: EnvironmentService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ResetPasswordService,
|
||||
{
|
||||
provide: getRepositoryToken(User, 'core'),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AppToken, 'core'),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Workspace, 'core'),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: EmailService,
|
||||
useValue: {
|
||||
send: jest.fn().mockResolvedValue({ success: true }),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ResetPasswordService>(ResetPasswordService);
|
||||
userRepository = module.get<Repository<User>>(
|
||||
getRepositoryToken(User, 'core'),
|
||||
);
|
||||
appTokenRepository = module.get<Repository<AppToken>>(
|
||||
getRepositoryToken(AppToken, 'core'),
|
||||
);
|
||||
emailService = module.get<EmailService>(EmailService);
|
||||
environmentService = module.get<EnvironmentService>(EnvironmentService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('generatePasswordResetToken', () => {
|
||||
it('should generate a password reset token for a valid user', async () => {
|
||||
const mockUser = { id: '1', email: 'test@example.com' };
|
||||
|
||||
jest
|
||||
.spyOn(userRepository, 'findOneBy')
|
||||
.mockResolvedValue(mockUser as User);
|
||||
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
|
||||
jest.spyOn(appTokenRepository, 'save').mockResolvedValue({} as AppToken);
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue('1h');
|
||||
|
||||
const result =
|
||||
await service.generatePasswordResetToken('test@example.com');
|
||||
|
||||
expect(result.passwordResetToken).toBeDefined();
|
||||
expect(result.passwordResetTokenExpiresAt).toBeDefined();
|
||||
expect(appTokenRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: '1',
|
||||
type: AppTokenType.PasswordResetToken,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if user is not found', async () => {
|
||||
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.generatePasswordResetToken('nonexistent@example.com'),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
|
||||
it('should throw an error if a token already exists', async () => {
|
||||
const mockUser = { id: '1', email: 'test@example.com' };
|
||||
const mockExistingToken = {
|
||||
userId: '1',
|
||||
type: AppTokenType.PasswordResetToken,
|
||||
expiresAt: addMilliseconds(new Date(), 3600000),
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(userRepository, 'findOneBy')
|
||||
.mockResolvedValue(mockUser as User);
|
||||
jest
|
||||
.spyOn(appTokenRepository, 'findOne')
|
||||
.mockResolvedValue(mockExistingToken as AppToken);
|
||||
|
||||
await expect(
|
||||
service.generatePasswordResetToken('test@example.com'),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendEmailPasswordResetLink', () => {
|
||||
it('should send a password reset email', async () => {
|
||||
const mockUser = { id: '1', email: 'test@example.com' };
|
||||
const mockToken = {
|
||||
passwordResetToken: 'token123',
|
||||
passwordResetTokenExpiresAt: new Date(),
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(userRepository, 'findOneBy')
|
||||
.mockResolvedValue(mockUser as User);
|
||||
jest
|
||||
.spyOn(environmentService, 'get')
|
||||
.mockReturnValue('http://localhost:3000');
|
||||
|
||||
const result = await service.sendEmailPasswordResetLink(
|
||||
mockToken,
|
||||
'test@example.com',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(emailService.send).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if user is not found', async () => {
|
||||
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.sendEmailPasswordResetLink(
|
||||
{} as any,
|
||||
'nonexistent@example.com',
|
||||
),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePasswordResetToken', () => {
|
||||
it('should validate a correct password reset token', async () => {
|
||||
const mockToken = {
|
||||
userId: '1',
|
||||
type: AppTokenType.PasswordResetToken,
|
||||
expiresAt: addMilliseconds(new Date(), 3600000),
|
||||
};
|
||||
const mockUser = { id: '1', email: 'test@example.com' };
|
||||
|
||||
jest
|
||||
.spyOn(appTokenRepository, 'findOne')
|
||||
.mockResolvedValue(mockToken as AppToken);
|
||||
jest
|
||||
.spyOn(userRepository, 'findOneBy')
|
||||
.mockResolvedValue(mockUser as User);
|
||||
|
||||
const result = await service.validatePasswordResetToken('validToken');
|
||||
|
||||
expect(result).toEqual({ id: '1', email: 'test@example.com' });
|
||||
});
|
||||
|
||||
it('should throw an error for an invalid token', async () => {
|
||||
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.validatePasswordResetToken('invalidToken'),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidatePasswordResetToken', () => {
|
||||
it('should invalidate an existing password reset token', async () => {
|
||||
const mockUser = { id: '1', email: 'test@example.com' };
|
||||
|
||||
jest
|
||||
.spyOn(userRepository, 'findOneBy')
|
||||
.mockResolvedValue(mockUser as User);
|
||||
jest.spyOn(appTokenRepository, 'update').mockResolvedValue({} as any);
|
||||
|
||||
const result = await service.invalidatePasswordResetToken('1');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(appTokenRepository.update).toHaveBeenCalledWith(
|
||||
{ userId: '1', type: AppTokenType.PasswordResetToken },
|
||||
{ revokedAt: expect.any(Date) },
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if user is not found', async () => {
|
||||
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.invalidatePasswordResetToken('nonexistent'),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,224 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { render } from '@react-email/render';
|
||||
import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
import { PasswordResetLinkEmail } from 'twenty-emails';
|
||||
import { IsNull, MoreThan, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AppToken,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-password-reset-link.entity';
|
||||
import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity';
|
||||
import { PasswordResetToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.entity';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ResetPasswordService {
|
||||
constructor(
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(AppToken, 'core')
|
||||
private readonly appTokenRepository: Repository<AppToken>,
|
||||
private readonly emailService: EmailService,
|
||||
) {}
|
||||
|
||||
async generatePasswordResetToken(email: string): Promise<PasswordResetToken> {
|
||||
const user = await this.userRepository.findOneBy({
|
||||
email,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const expiresIn = this.environmentService.get(
|
||||
'PASSWORD_RESET_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
|
||||
if (!expiresIn) {
|
||||
throw new AuthException(
|
||||
'PASSWORD_RESET_TOKEN_EXPIRES_IN constant value not found',
|
||||
AuthExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
const existingToken = await this.appTokenRepository.findOne({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: AppTokenType.PasswordResetToken,
|
||||
expiresAt: MoreThan(new Date()),
|
||||
revokedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
if (existingToken) {
|
||||
const timeToWait = ms(
|
||||
differenceInMilliseconds(existingToken.expiresAt, new Date()),
|
||||
{ long: true },
|
||||
);
|
||||
|
||||
throw new AuthException(
|
||||
`Token has already been generated. Please wait for ${timeToWait} to generate again.`,
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const plainResetToken = crypto.randomBytes(32).toString('hex');
|
||||
const hashedResetToken = crypto
|
||||
.createHash('sha256')
|
||||
.update(plainResetToken)
|
||||
.digest('hex');
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
|
||||
await this.appTokenRepository.save({
|
||||
userId: user.id,
|
||||
value: hashedResetToken,
|
||||
expiresAt,
|
||||
type: AppTokenType.PasswordResetToken,
|
||||
});
|
||||
|
||||
return {
|
||||
passwordResetToken: plainResetToken,
|
||||
passwordResetTokenExpiresAt: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async sendEmailPasswordResetLink(
|
||||
resetToken: PasswordResetToken,
|
||||
email: string,
|
||||
): Promise<EmailPasswordResetLink> {
|
||||
const user = await this.userRepository.findOneBy({
|
||||
email,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const frontBaseURL = this.environmentService.get('FRONT_BASE_URL');
|
||||
const resetLink = `${frontBaseURL}/reset-password/${resetToken.passwordResetToken}`;
|
||||
|
||||
const emailData = {
|
||||
link: resetLink,
|
||||
duration: ms(
|
||||
differenceInMilliseconds(
|
||||
resetToken.passwordResetTokenExpiresAt,
|
||||
new Date(),
|
||||
),
|
||||
{
|
||||
long: true,
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
const emailTemplate = PasswordResetLinkEmail(emailData);
|
||||
const html = render(emailTemplate, {
|
||||
pretty: true,
|
||||
});
|
||||
|
||||
const text = render(emailTemplate, {
|
||||
plainText: true,
|
||||
});
|
||||
|
||||
this.emailService.send({
|
||||
from: `${this.environmentService.get(
|
||||
'EMAIL_FROM_NAME',
|
||||
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
|
||||
to: email,
|
||||
subject: 'Action Needed to Reset Password',
|
||||
text,
|
||||
html,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async validatePasswordResetToken(
|
||||
resetToken: string,
|
||||
): Promise<ValidatePasswordResetToken> {
|
||||
const hashedResetToken = crypto
|
||||
.createHash('sha256')
|
||||
.update(resetToken)
|
||||
.digest('hex');
|
||||
|
||||
const token = await this.appTokenRepository.findOne({
|
||||
where: {
|
||||
value: hashedResetToken,
|
||||
type: AppTokenType.PasswordResetToken,
|
||||
expiresAt: MoreThan(new Date()),
|
||||
revokedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!token || !token.userId) {
|
||||
throw new AuthException(
|
||||
'Token is invalid',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOneBy({
|
||||
id: token.userId,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
};
|
||||
}
|
||||
|
||||
async invalidatePasswordResetToken(
|
||||
userId: string,
|
||||
): Promise<InvalidatePassword> {
|
||||
const user = await this.userRepository.findOneBy({
|
||||
id: userId,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
await this.appTokenRepository.update(
|
||||
{
|
||||
userId,
|
||||
type: AppTokenType.PasswordResetToken,
|
||||
},
|
||||
{
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,217 @@
|
||||
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 { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
|
||||
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { SwitchWorkspaceService } from './switch-workspace.service';
|
||||
|
||||
describe('SwitchWorkspaceService', () => {
|
||||
let service: SwitchWorkspaceService;
|
||||
let userRepository: Repository<User>;
|
||||
let workspaceRepository: Repository<Workspace>;
|
||||
let ssoService: SSOService;
|
||||
let accessTokenService: AccessTokenService;
|
||||
let refreshTokenService: RefreshTokenService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SwitchWorkspaceService,
|
||||
{
|
||||
provide: getRepositoryToken(User, 'core'),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Workspace, 'core'),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: SSOService,
|
||||
useValue: {
|
||||
listSSOIdentityProvidersByWorkspaceId: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: AccessTokenService,
|
||||
useValue: {
|
||||
generateAccessToken: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RefreshTokenService,
|
||||
useValue: {
|
||||
generateRefreshToken: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SwitchWorkspaceService>(SwitchWorkspaceService);
|
||||
userRepository = module.get<Repository<User>>(
|
||||
getRepositoryToken(User, 'core'),
|
||||
);
|
||||
workspaceRepository = module.get<Repository<Workspace>>(
|
||||
getRepositoryToken(Workspace, 'core'),
|
||||
);
|
||||
ssoService = module.get<SSOService>(SSOService);
|
||||
accessTokenService = module.get<AccessTokenService>(AccessTokenService);
|
||||
refreshTokenService = module.get<RefreshTokenService>(RefreshTokenService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('switchWorkspace', () => {
|
||||
it('should throw an error if user does not exist', async () => {
|
||||
jest.spyOn(userRepository, 'findBy').mockResolvedValue([]);
|
||||
jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.switchWorkspace(
|
||||
{ id: 'non-existent-user' } as User,
|
||||
'workspace-id',
|
||||
),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
|
||||
it('should throw an error if workspace does not exist', async () => {
|
||||
jest
|
||||
.spyOn(userRepository, 'findBy')
|
||||
.mockResolvedValue([{ id: 'user-id' } as User]);
|
||||
jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.switchWorkspace(
|
||||
{ id: 'user-id' } as User,
|
||||
'non-existent-workspace',
|
||||
),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
|
||||
it('should throw an error if user does not belong to workspace', async () => {
|
||||
const mockUser = { id: 'user-id' };
|
||||
const mockWorkspace = {
|
||||
id: 'workspace-id',
|
||||
workspaceUsers: [{ userId: 'other-user-id' }],
|
||||
workspaceSSOIdentityProviders: [],
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(userRepository, 'findBy')
|
||||
.mockResolvedValue([mockUser as User]);
|
||||
jest
|
||||
.spyOn(workspaceRepository, 'findOne')
|
||||
.mockResolvedValue(mockWorkspace as any);
|
||||
|
||||
await expect(
|
||||
service.switchWorkspace(mockUser as User, 'workspace-id'),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
|
||||
it('should return SSO auth info if workspace has SSO providers', async () => {
|
||||
const mockUser = { id: 'user-id' };
|
||||
const mockWorkspace = {
|
||||
id: 'workspace-id',
|
||||
workspaceUsers: [{ userId: 'user-id' }],
|
||||
workspaceSSOIdentityProviders: [{}],
|
||||
};
|
||||
const mockSSOProviders = [{ id: 'sso-provider-id' }];
|
||||
|
||||
jest
|
||||
.spyOn(userRepository, 'findBy')
|
||||
.mockResolvedValue([mockUser as User]);
|
||||
jest
|
||||
.spyOn(workspaceRepository, 'findOne')
|
||||
.mockResolvedValue(mockWorkspace as any);
|
||||
jest
|
||||
.spyOn(ssoService, 'listSSOIdentityProvidersByWorkspaceId')
|
||||
.mockResolvedValue(mockSSOProviders as any);
|
||||
|
||||
const result = await service.switchWorkspace(
|
||||
mockUser as User,
|
||||
'workspace-id',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
useSSOAuth: true,
|
||||
workspace: mockWorkspace,
|
||||
availableSSOIdentityProviders: mockSSOProviders,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return workspace info if workspace does not have SSO providers', async () => {
|
||||
const mockUser = { id: 'user-id' };
|
||||
const mockWorkspace = {
|
||||
id: 'workspace-id',
|
||||
workspaceUsers: [{ userId: 'user-id' }],
|
||||
workspaceSSOIdentityProviders: [],
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(userRepository, 'findBy')
|
||||
.mockResolvedValue([mockUser as User]);
|
||||
jest
|
||||
.spyOn(workspaceRepository, 'findOne')
|
||||
.mockResolvedValue(mockWorkspace as any);
|
||||
|
||||
const result = await service.switchWorkspace(
|
||||
mockUser as User,
|
||||
'workspace-id',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
useSSOAuth: false,
|
||||
workspace: mockWorkspace,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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(userRepository.save).toHaveBeenCalledWith({
|
||||
id: mockUser.id,
|
||||
defaultWorkspace: mockWorkspace,
|
||||
});
|
||||
expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
mockWorkspace.id,
|
||||
);
|
||||
expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
mockWorkspace.id,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,115 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
|
||||
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Injectable()
|
||||
export class SwitchWorkspaceService {
|
||||
constructor(
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
private readonly ssoService: SSOService,
|
||||
private readonly accessTokenService: AccessTokenService,
|
||||
private readonly refreshTokenService: RefreshTokenService,
|
||||
) {}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!workspace.workspaceUsers
|
||||
.map((userWorkspace) => userWorkspace.userId)
|
||||
.includes(user.id)
|
||||
) {
|
||||
throw new AuthException(
|
||||
'user does not belong to workspace',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
if (workspace.workspaceSSOIdentityProviders.length > 0) {
|
||||
return {
|
||||
useSSOAuth: true,
|
||||
workspace,
|
||||
availableSSOIdentityProviders:
|
||||
await this.ssoService.listSSOIdentityProvidersByWorkspaceId(
|
||||
workspaceId,
|
||||
),
|
||||
} as {
|
||||
useSSOAuth: true;
|
||||
workspace: Workspace;
|
||||
availableSSOIdentityProviders: Awaited<
|
||||
ReturnType<
|
||||
typeof this.ssoService.listSSOIdentityProvidersByWorkspaceId
|
||||
>
|
||||
>;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
useSSOAuth: false,
|
||||
workspace,
|
||||
} as {
|
||||
useSSOAuth: false;
|
||||
workspace: Workspace;
|
||||
};
|
||||
}
|
||||
|
||||
async generateSwitchWorkspaceToken(
|
||||
user: User,
|
||||
workspace: Workspace,
|
||||
): Promise<AuthTokens> {
|
||||
await this.userRepository.save({
|
||||
id: user.id,
|
||||
defaultWorkspace: workspace,
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ import {
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
@ -28,6 +29,7 @@ export type JwtPayload = {
|
||||
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
private readonly typeORMService: TypeORMService,
|
||||
private readonly dataSourceService: DataSourceService,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
@ -38,7 +40,22 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: environmentService.get('ACCESS_TOKEN_SECRET'),
|
||||
secretOrKeyProvider: async (request, rawJwtToken, done) => {
|
||||
try {
|
||||
const decodedToken = this.jwtWrapperService.decode(
|
||||
rawJwtToken,
|
||||
) as JwtPayload;
|
||||
const workspaceId = decodedToken.workspaceId;
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
'ACCESS',
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
done(null, secret);
|
||||
} catch (error) {
|
||||
done(error, null);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,192 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { Request } from 'express';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
|
||||
import { AccessTokenService } from './access-token.service';
|
||||
|
||||
describe('AccessTokenService', () => {
|
||||
let service: AccessTokenService;
|
||||
let jwtWrapperService: JwtWrapperService;
|
||||
let environmentService: EnvironmentService;
|
||||
let userRepository: Repository<User>;
|
||||
let twentyORMGlobalManager: TwentyORMGlobalManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AccessTokenService,
|
||||
{
|
||||
provide: JwtWrapperService,
|
||||
useValue: {
|
||||
sign: jest.fn(),
|
||||
verifyWorkspaceToken: jest.fn(),
|
||||
decode: jest.fn(),
|
||||
generateAppSecret: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: JwtAuthStrategy,
|
||||
useValue: {
|
||||
validate: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User, 'core'),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AppToken, 'core'),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Workspace, 'core'),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: EmailService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: SSOService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: TwentyORMGlobalManager,
|
||||
useValue: {
|
||||
getRepositoryForWorkspace: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AccessTokenService>(AccessTokenService);
|
||||
jwtWrapperService = module.get<JwtWrapperService>(JwtWrapperService);
|
||||
environmentService = module.get<EnvironmentService>(EnvironmentService);
|
||||
userRepository = module.get<Repository<User>>(
|
||||
getRepositoryToken(User, 'core'),
|
||||
);
|
||||
twentyORMGlobalManager = module.get<TwentyORMGlobalManager>(
|
||||
TwentyORMGlobalManager,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('generateAccessToken', () => {
|
||||
it('should generate an access token successfully', async () => {
|
||||
const userId = 'user-id';
|
||||
const workspaceId = 'workspace-id';
|
||||
const mockUser = {
|
||||
id: userId,
|
||||
defaultWorkspace: { id: workspaceId, activationStatus: 'ACTIVE' },
|
||||
defaultWorkspaceId: 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(twentyORMGlobalManager, 'getRepositoryForWorkspace')
|
||||
.mockResolvedValue({
|
||||
findOne: jest.fn().mockResolvedValue(mockWorkspaceMember),
|
||||
} as any);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
|
||||
const result = await service.generateAccessToken(userId, workspaceId);
|
||||
|
||||
expect(result).toEqual({
|
||||
token: mockToken,
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sub: userId,
|
||||
workspaceId: workspaceId,
|
||||
workspaceMemberId: mockWorkspaceMember.id,
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if user is not found', async () => {
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue('1h');
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.generateAccessToken('non-existent-user', 'workspace-id'),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateToken', () => {
|
||||
it('should validate a token successfully', async () => {
|
||||
const mockToken = 'valid-token';
|
||||
const mockRequest = {
|
||||
headers: {
|
||||
authorization: `Bearer ${mockToken}`,
|
||||
},
|
||||
} as Request;
|
||||
const mockDecodedToken = { sub: 'user-id', workspaceId: 'workspace-id' };
|
||||
const mockAuthContext = {
|
||||
user: { id: 'user-id' },
|
||||
apiKey: null,
|
||||
workspace: { id: 'workspace-id' },
|
||||
workspaceMemberId: 'workspace-member-id',
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
|
||||
.mockResolvedValue(undefined);
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'decode')
|
||||
.mockReturnValue(mockDecodedToken as any);
|
||||
jest
|
||||
.spyOn(service['jwtStrategy'], 'validate')
|
||||
.mockReturnValue(mockAuthContext as any);
|
||||
|
||||
const result = await service.validateToken(mockRequest);
|
||||
|
||||
expect(result).toEqual(mockAuthContext);
|
||||
expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith(
|
||||
mockToken,
|
||||
'ACCESS',
|
||||
);
|
||||
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken);
|
||||
expect(service['jwtStrategy'].validate).toHaveBeenCalledWith(
|
||||
mockDecodedToken,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if token is missing', async () => {
|
||||
const mockRequest = {
|
||||
headers: {},
|
||||
} as Request;
|
||||
|
||||
await expect(service.validateToken(mockRequest)).rejects.toThrow(
|
||||
AuthException,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,134 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { addMilliseconds } from 'date-fns';
|
||||
import { Request } from 'express';
|
||||
import ms from 'ms';
|
||||
import { ExtractJwt } from 'passport-jwt';
|
||||
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 {
|
||||
JwtAuthStrategy,
|
||||
JwtPayload,
|
||||
} from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
|
||||
@Injectable()
|
||||
export class AccessTokenService {
|
||||
constructor(
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
private readonly jwtStrategy: JwtAuthStrategy,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
) {}
|
||||
|
||||
async generateAccessToken(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<AuthToken> {
|
||||
const expiresIn = this.environmentService.get('ACCESS_TOKEN_EXPIRES_IN');
|
||||
|
||||
if (!expiresIn) {
|
||||
throw new AuthException(
|
||||
'Expiration time for access token is not set',
|
||||
AuthExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
relations: ['defaultWorkspace'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw 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 workspaceMemberRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
|
||||
tokenWorkspaceId,
|
||||
'workspaceMember',
|
||||
);
|
||||
|
||||
const workspaceMember = await workspaceMemberRepository.findOne({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!workspaceMember) {
|
||||
throw new AuthException(
|
||||
'User is not a member of the workspace',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
tokenWorkspaceMemberId = workspaceMember.id;
|
||||
}
|
||||
|
||||
const jwtPayload: JwtPayload = {
|
||||
sub: user.id,
|
||||
workspaceId: workspaceId ? workspaceId : user.defaultWorkspaceId,
|
||||
workspaceMemberId: tokenWorkspaceMemberId,
|
||||
};
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret: this.jwtWrapperService.generateAppSecret('ACCESS', workspaceId),
|
||||
}),
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async validateToken(request: Request): Promise<AuthContext> {
|
||||
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
|
||||
|
||||
if (!token) {
|
||||
throw new AuthException(
|
||||
'missing authentication token',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
await this.jwtWrapperService.verifyWorkspaceToken(token, 'ACCESS');
|
||||
|
||||
const decoded = await this.jwtWrapperService.decode(token);
|
||||
|
||||
const { user, apiKey, workspace, workspaceMemberId } =
|
||||
await this.jwtStrategy.validate(decoded as JwtPayload);
|
||||
|
||||
return { user, apiKey, workspace, workspaceMemberId };
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,117 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
|
||||
import { LoginTokenService } from './login-token.service';
|
||||
|
||||
describe('LoginTokenService', () => {
|
||||
let service: LoginTokenService;
|
||||
let jwtWrapperService: JwtWrapperService;
|
||||
let environmentService: EnvironmentService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
LoginTokenService,
|
||||
{
|
||||
provide: JwtWrapperService,
|
||||
useValue: {
|
||||
generateAppSecret: jest.fn(),
|
||||
sign: jest.fn(),
|
||||
verifyWorkspaceToken: jest.fn(),
|
||||
decode: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<LoginTokenService>(LoginTokenService);
|
||||
jwtWrapperService = module.get<JwtWrapperService>(JwtWrapperService);
|
||||
environmentService = module.get<EnvironmentService>(EnvironmentService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('generateLoginToken', () => {
|
||||
it('should generate a login token successfully', async () => {
|
||||
const email = 'test@example.com';
|
||||
const mockSecret = 'mock-secret';
|
||||
const mockExpiresIn = '1h';
|
||||
const mockToken = 'mock-token';
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'generateAppSecret')
|
||||
.mockReturnValue(mockSecret);
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue(mockExpiresIn);
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
|
||||
const result = await service.generateLoginToken(email);
|
||||
|
||||
expect(result).toEqual({
|
||||
token: mockToken,
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith('LOGIN');
|
||||
expect(environmentService.get).toHaveBeenCalledWith(
|
||||
'LOGIN_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
{ sub: email },
|
||||
{ secret: mockSecret, expiresIn: mockExpiresIn },
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if LOGIN_TOKEN_EXPIRES_IN is not set', async () => {
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue(undefined);
|
||||
|
||||
await expect(
|
||||
service.generateLoginToken('test@example.com'),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyLoginToken', () => {
|
||||
it('should verify a login token successfully', async () => {
|
||||
const mockToken = 'valid-token';
|
||||
const mockEmail = 'test@example.com';
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
|
||||
.mockResolvedValue(undefined);
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'decode')
|
||||
.mockReturnValue({ sub: mockEmail });
|
||||
|
||||
const result = await service.verifyLoginToken(mockToken);
|
||||
|
||||
expect(result).toEqual(mockEmail);
|
||||
expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith(
|
||||
mockToken,
|
||||
'LOGIN',
|
||||
);
|
||||
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken, {
|
||||
json: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error if token verification fails', async () => {
|
||||
const mockToken = 'invalid-token';
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
|
||||
.mockRejectedValue(new Error('Invalid token'));
|
||||
|
||||
await expect(service.verifyLoginToken(mockToken)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,53 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { addMilliseconds } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
|
||||
@Injectable()
|
||||
export class LoginTokenService {
|
||||
constructor(
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
async generateLoginToken(email: string): Promise<AuthToken> {
|
||||
const secret = this.jwtWrapperService.generateAppSecret('LOGIN');
|
||||
const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN');
|
||||
|
||||
if (!expiresIn) {
|
||||
throw new AuthException(
|
||||
'Expiration time for access token is not set',
|
||||
AuthExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
const jwtPayload = {
|
||||
sub: email,
|
||||
};
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret,
|
||||
expiresIn,
|
||||
}),
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyLoginToken(loginToken: string): Promise<string> {
|
||||
await this.jwtWrapperService.verifyWorkspaceToken(loginToken, 'LOGIN');
|
||||
|
||||
return this.jwtWrapperService.decode(loginToken, {
|
||||
json: true,
|
||||
}).sub;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,156 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
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 { RefreshTokenService } from './refresh-token.service';
|
||||
|
||||
describe('RefreshTokenService', () => {
|
||||
let service: RefreshTokenService;
|
||||
let jwtWrapperService: JwtWrapperService;
|
||||
let environmentService: EnvironmentService;
|
||||
let appTokenRepository: Repository<AppToken>;
|
||||
let userRepository: Repository<User>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RefreshTokenService,
|
||||
{
|
||||
provide: JwtWrapperService,
|
||||
useValue: {
|
||||
verifyWorkspaceToken: jest.fn(),
|
||||
decode: jest.fn(),
|
||||
sign: jest.fn(),
|
||||
generateAppSecret: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AppToken, 'core'),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User, 'core'),
|
||||
useClass: Repository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<RefreshTokenService>(RefreshTokenService);
|
||||
jwtWrapperService = module.get<JwtWrapperService>(JwtWrapperService);
|
||||
environmentService = module.get<EnvironmentService>(EnvironmentService);
|
||||
appTokenRepository = module.get<Repository<AppToken>>(
|
||||
getRepositoryToken(AppToken, 'core'),
|
||||
);
|
||||
userRepository = module.get<Repository<User>>(
|
||||
getRepositoryToken(User, 'core'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('verifyRefreshToken', () => {
|
||||
it('should verify a refresh token successfully', async () => {
|
||||
const mockToken = 'valid-refresh-token';
|
||||
const mockJwtPayload = { jti: 'token-id', sub: 'user-id' };
|
||||
const mockAppToken = { id: 'token-id', revokedAt: null };
|
||||
const mockUser: Partial<User> = {
|
||||
id: 'some-id',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
email: 'john.doe@example.com',
|
||||
defaultAvatarUrl: '',
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
|
||||
.mockResolvedValue(undefined);
|
||||
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockJwtPayload);
|
||||
jest
|
||||
.spyOn(appTokenRepository, 'findOneBy')
|
||||
.mockResolvedValue(mockAppToken as AppToken);
|
||||
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as User);
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue('1h');
|
||||
|
||||
const result = await service.verifyRefreshToken(mockToken);
|
||||
|
||||
expect(result).toEqual({ user: mockUser, token: mockAppToken });
|
||||
expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith(
|
||||
mockToken,
|
||||
'REFRESH',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the token is malformed', async () => {
|
||||
const mockToken = 'invalid-token';
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
|
||||
.mockResolvedValue(undefined);
|
||||
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue({});
|
||||
|
||||
await expect(service.verifyRefreshToken(mockToken)).rejects.toThrow(
|
||||
AuthException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateRefreshToken', () => {
|
||||
it('should generate a refresh token successfully', async () => {
|
||||
const userId = 'user-id';
|
||||
const workspaceId = 'workspace-id';
|
||||
const mockToken = 'mock-refresh-token';
|
||||
const mockExpiresIn = '7d';
|
||||
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue(mockExpiresIn);
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'generateAppSecret')
|
||||
.mockReturnValue('mock-secret');
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
jest
|
||||
.spyOn(appTokenRepository, 'create')
|
||||
.mockReturnValue({ id: 'new-token-id' } as AppToken);
|
||||
jest
|
||||
.spyOn(appTokenRepository, 'save')
|
||||
.mockResolvedValue({ id: 'new-token-id' } as AppToken);
|
||||
|
||||
const result = await service.generateRefreshToken(userId, workspaceId);
|
||||
|
||||
expect(result).toEqual({
|
||||
token: mockToken,
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
expect(appTokenRepository.save).toHaveBeenCalled();
|
||||
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
{ sub: userId },
|
||||
expect.objectContaining({
|
||||
secret: 'mock-secret',
|
||||
expiresIn: mockExpiresIn,
|
||||
jwtid: 'new-token-id',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if expiration time is not set', async () => {
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue(undefined);
|
||||
|
||||
await expect(
|
||||
service.generateRefreshToken('user-id', 'workspace-id'),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,138 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { addMilliseconds } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AppToken,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class RefreshTokenService {
|
||||
constructor(
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@InjectRepository(AppToken, 'core')
|
||||
private readonly appTokenRepository: Repository<AppToken>,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async verifyRefreshToken(refreshToken: string) {
|
||||
const coolDown = this.environmentService.get('REFRESH_TOKEN_COOL_DOWN');
|
||||
|
||||
await this.jwtWrapperService.verifyWorkspaceToken(refreshToken, 'REFRESH');
|
||||
const jwtPayload = await this.jwtWrapperService.decode(refreshToken);
|
||||
|
||||
if (!(jwtPayload.jti && jwtPayload.sub)) {
|
||||
throw new AuthException(
|
||||
'This refresh token is malformed',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const token = await this.appTokenRepository.findOneBy({
|
||||
id: jwtPayload.jti,
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
throw new AuthException(
|
||||
"This refresh token doesn't exist",
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: jwtPayload.sub },
|
||||
relations: ['appTokens'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if revokedAt is less than coolDown
|
||||
if (
|
||||
token.revokedAt &&
|
||||
token.revokedAt.getTime() <= Date.now() - ms(coolDown)
|
||||
) {
|
||||
// Revoke all user refresh tokens
|
||||
await Promise.all(
|
||||
user.appTokens.map(async ({ id, type }) => {
|
||||
if (type === AppTokenType.RefreshToken) {
|
||||
await this.appTokenRepository.update(
|
||||
{ id },
|
||||
{
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
throw new AuthException(
|
||||
'Suspicious activity detected, this refresh token has been revoked. All tokens have been revoked.',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
return { user, token };
|
||||
}
|
||||
|
||||
async generateRefreshToken(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<AuthToken> {
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
'REFRESH',
|
||||
workspaceId,
|
||||
);
|
||||
const expiresIn = this.environmentService.get('REFRESH_TOKEN_EXPIRES_IN');
|
||||
|
||||
if (!expiresIn) {
|
||||
throw new AuthException(
|
||||
'Expiration time for access token is not set',
|
||||
AuthExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
|
||||
const refreshTokenPayload = {
|
||||
userId,
|
||||
expiresAt,
|
||||
type: AppTokenType.RefreshToken,
|
||||
};
|
||||
const jwtPayload = {
|
||||
sub: userId,
|
||||
};
|
||||
|
||||
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,
|
||||
}),
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
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 { RenewTokenService } from './renew-token.service';
|
||||
|
||||
describe('RenewTokenService', () => {
|
||||
let service: RenewTokenService;
|
||||
let appTokenRepository: Repository<AppToken>;
|
||||
let accessTokenService: AccessTokenService;
|
||||
let refreshTokenService: RefreshTokenService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RenewTokenService,
|
||||
{
|
||||
provide: getRepositoryToken(AppToken, 'core'),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: AccessTokenService,
|
||||
useValue: {
|
||||
generateAccessToken: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: RefreshTokenService,
|
||||
useValue: {
|
||||
verifyRefreshToken: jest.fn(),
|
||||
generateRefreshToken: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<RenewTokenService>(RenewTokenService);
|
||||
appTokenRepository = module.get<Repository<AppToken>>(
|
||||
getRepositoryToken(AppToken, 'core'),
|
||||
);
|
||||
accessTokenService = module.get<AccessTokenService>(AccessTokenService);
|
||||
refreshTokenService = module.get<RefreshTokenService>(RefreshTokenService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('generateTokensFromRefreshToken', () => {
|
||||
it('should generate new access and refresh tokens', async () => {
|
||||
const mockRefreshToken = 'valid-refresh-token';
|
||||
const mockUser = { id: 'user-id' } as User;
|
||||
const mockWorkspaceId = 'workspace-id';
|
||||
const mockTokenId = 'token-id';
|
||||
const mockAccessToken = {
|
||||
token: 'new-access-token',
|
||||
expiresAt: new Date(),
|
||||
};
|
||||
const mockNewRefreshToken = {
|
||||
token: 'new-refresh-token',
|
||||
expiresAt: new Date(),
|
||||
};
|
||||
const mockAppToken: Partial<AppToken> = {
|
||||
id: mockTokenId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
user: mockUser,
|
||||
userId: mockUser.id,
|
||||
};
|
||||
|
||||
jest.spyOn(refreshTokenService, 'verifyRefreshToken').mockResolvedValue({
|
||||
user: mockUser,
|
||||
token: mockAppToken as AppToken,
|
||||
});
|
||||
jest.spyOn(appTokenRepository, 'update').mockResolvedValue({} as any);
|
||||
jest
|
||||
.spyOn(accessTokenService, 'generateAccessToken')
|
||||
.mockResolvedValue(mockAccessToken);
|
||||
jest
|
||||
.spyOn(refreshTokenService, 'generateRefreshToken')
|
||||
.mockResolvedValue(mockNewRefreshToken);
|
||||
|
||||
const result =
|
||||
await service.generateTokensFromRefreshToken(mockRefreshToken);
|
||||
|
||||
expect(result).toEqual({
|
||||
accessToken: mockAccessToken,
|
||||
refreshToken: mockNewRefreshToken,
|
||||
});
|
||||
expect(refreshTokenService.verifyRefreshToken).toHaveBeenCalledWith(
|
||||
mockRefreshToken,
|
||||
);
|
||||
expect(appTokenRepository.update).toHaveBeenCalledWith(
|
||||
{ id: mockTokenId },
|
||||
{ revokedAt: expect.any(Date) },
|
||||
);
|
||||
expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
mockWorkspaceId,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if refresh token is not provided', async () => {
|
||||
await expect(service.generateTokensFromRefreshToken('')).rejects.toThrow(
|
||||
AuthException,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,64 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
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 { AuthToken } 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';
|
||||
|
||||
@Injectable()
|
||||
export class RenewTokenService {
|
||||
constructor(
|
||||
@InjectRepository(AppToken, 'core')
|
||||
private readonly appTokenRepository: Repository<AppToken>,
|
||||
private readonly accessTokenService: AccessTokenService,
|
||||
private readonly refreshTokenService: RefreshTokenService,
|
||||
) {}
|
||||
|
||||
async generateTokensFromRefreshToken(token: string): Promise<{
|
||||
accessToken: AuthToken;
|
||||
refreshToken: AuthToken;
|
||||
}> {
|
||||
if (!token) {
|
||||
throw new AuthException(
|
||||
'Refresh token not found',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
user,
|
||||
token: { id, workspaceId },
|
||||
} = await this.refreshTokenService.verifyRefreshToken(token);
|
||||
|
||||
// Revoke old refresh token
|
||||
await this.appTokenRepository.update(
|
||||
{
|
||||
id,
|
||||
},
|
||||
{
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
);
|
||||
|
||||
const accessToken = await this.accessTokenService.generateAccessToken(
|
||||
user.id,
|
||||
workspaceId,
|
||||
);
|
||||
const refreshToken = await this.refreshTokenService.generateRefreshToken(
|
||||
user.id,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,248 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { IsNull, MoreThan, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AppToken,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
|
||||
import { JwtWrapperService } 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 { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
|
||||
|
||||
import { TokenService } from './token.service';
|
||||
|
||||
describe('TokenService', () => {
|
||||
let service: TokenService;
|
||||
let environmentService: EnvironmentService;
|
||||
let userRepository: Repository<User>;
|
||||
let appTokenRepository: Repository<AppToken>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
TokenService,
|
||||
{
|
||||
provide: JwtWrapperService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: JwtAuthStrategy,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
get: jest.fn().mockReturnValue('some-value'),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EmailService,
|
||||
useValue: {
|
||||
send: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SSOService,
|
||||
useValue: {
|
||||
send: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(User, 'core'),
|
||||
useValue: {
|
||||
findOneBy: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AppToken, 'core'),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Workspace, 'core'),
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: TwentyORMGlobalManager,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<TokenService>(TokenService);
|
||||
environmentService = module.get<EnvironmentService>(EnvironmentService);
|
||||
userRepository = module.get(getRepositoryToken(User, 'core'));
|
||||
appTokenRepository = module.get(getRepositoryToken(AppToken, 'core'));
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('generatePasswordResetToken', () => {
|
||||
it('should generate a new password reset token when no existing token is found', async () => {
|
||||
const mockUser = { id: '1', email: 'test@example.com' } as User;
|
||||
const expiresIn = '3600000'; // 1 hour in ms
|
||||
|
||||
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser);
|
||||
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue(expiresIn);
|
||||
jest
|
||||
.spyOn(appTokenRepository, 'save')
|
||||
.mockImplementation(async (token) => token as AppToken);
|
||||
|
||||
const result = await service.generatePasswordResetToken(mockUser.email);
|
||||
|
||||
expect(userRepository.findOneBy).toHaveBeenCalledWith({
|
||||
email: mockUser.email,
|
||||
});
|
||||
expect(appTokenRepository.findOne).toHaveBeenCalled();
|
||||
expect(appTokenRepository.save).toHaveBeenCalled();
|
||||
expect(result.passwordResetToken).toBeDefined();
|
||||
expect(result.passwordResetTokenExpiresAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw AuthException if an existing valid token is found', async () => {
|
||||
const mockUser = { id: '1', email: 'test@example.com' } as User;
|
||||
const mockToken = {
|
||||
userId: '1',
|
||||
type: AppTokenType.PasswordResetToken,
|
||||
expiresAt: new Date(Date.now() + 10000), // expires 10 seconds in the future
|
||||
} as AppToken;
|
||||
|
||||
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser);
|
||||
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(mockToken);
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue('3600000');
|
||||
|
||||
await expect(
|
||||
service.generatePasswordResetToken(mockUser.email),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
|
||||
it('should throw AuthException if no user is found', async () => {
|
||||
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.generatePasswordResetToken('nonexistent@example.com'),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
|
||||
it('should throw AuthException if environment variable is not found', async () => {
|
||||
const mockUser = { id: '1', email: 'test@example.com' } as User;
|
||||
|
||||
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser);
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue(''); // No environment variable set
|
||||
|
||||
await expect(
|
||||
service.generatePasswordResetToken(mockUser.email),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePasswordResetToken', () => {
|
||||
it('should return user data for a valid and active token', async () => {
|
||||
const resetToken = 'valid-reset-token';
|
||||
const hashedToken = crypto
|
||||
.createHash('sha256')
|
||||
.update(resetToken)
|
||||
.digest('hex');
|
||||
const mockToken = {
|
||||
userId: '1',
|
||||
value: hashedToken,
|
||||
type: AppTokenType.PasswordResetToken,
|
||||
expiresAt: new Date(Date.now() + 10000), // Valid future date
|
||||
};
|
||||
const mockUser = { id: '1', email: 'user@example.com' };
|
||||
|
||||
jest
|
||||
.spyOn(appTokenRepository, 'findOne')
|
||||
.mockResolvedValue(mockToken as AppToken);
|
||||
jest
|
||||
.spyOn(userRepository, 'findOneBy')
|
||||
.mockResolvedValue(mockUser as User);
|
||||
|
||||
const result = await service.validatePasswordResetToken(resetToken);
|
||||
|
||||
expect(appTokenRepository.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
value: hashedToken,
|
||||
type: AppTokenType.PasswordResetToken,
|
||||
expiresAt: MoreThan(new Date()),
|
||||
revokedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
expect(userRepository.findOneBy).toHaveBeenCalledWith({
|
||||
id: mockToken.userId,
|
||||
});
|
||||
expect(result).toEqual({ id: mockUser.id, email: mockUser.email });
|
||||
});
|
||||
|
||||
it('should throw AuthException if token is invalid or expired', async () => {
|
||||
const resetToken = 'invalid-reset-token';
|
||||
|
||||
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.validatePasswordResetToken(resetToken),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
|
||||
it('should throw AuthException if user does not exist for a valid token', async () => {
|
||||
const resetToken = 'orphan-token';
|
||||
const hashedToken = crypto
|
||||
.createHash('sha256')
|
||||
.update(resetToken)
|
||||
.digest('hex');
|
||||
const mockToken = {
|
||||
userId: 'nonexistent-user',
|
||||
value: hashedToken,
|
||||
type: AppTokenType.PasswordResetToken,
|
||||
expiresAt: new Date(Date.now() + 10000), // Valid future date
|
||||
revokedAt: null,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(appTokenRepository, 'findOne')
|
||||
.mockResolvedValue(mockToken as AppToken);
|
||||
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.validatePasswordResetToken(resetToken),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
|
||||
it('should throw AuthException if token is revoked', async () => {
|
||||
const resetToken = 'revoked-token';
|
||||
const hashedToken = crypto
|
||||
.createHash('sha256')
|
||||
.update(resetToken)
|
||||
.digest('hex');
|
||||
const mockToken = {
|
||||
userId: '1',
|
||||
value: hashedToken,
|
||||
type: AppTokenType.PasswordResetToken,
|
||||
expiresAt: new Date(Date.now() + 10000),
|
||||
revokedAt: new Date(),
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(appTokenRepository, 'findOne')
|
||||
.mockResolvedValue(mockToken as AppToken);
|
||||
await expect(
|
||||
service.validatePasswordResetToken(resetToken),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,861 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { render } from '@react-email/render';
|
||||
import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
|
||||
import { Request } from 'express';
|
||||
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
|
||||
import ms from 'ms';
|
||||
import { ExtractJwt } from 'passport-jwt';
|
||||
import { PasswordResetLinkEmail } from 'twenty-emails';
|
||||
import { IsNull, MoreThan, Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AppToken,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-password-reset-link.entity';
|
||||
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 {
|
||||
ApiKeyToken,
|
||||
AuthToken,
|
||||
AuthTokens,
|
||||
PasswordResetToken,
|
||||
} from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.entity';
|
||||
import {
|
||||
JwtAuthStrategy,
|
||||
JwtPayload,
|
||||
} from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
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 {
|
||||
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 { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
|
||||
|
||||
@Injectable()
|
||||
export class TokenService {
|
||||
constructor(
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
private readonly jwtStrategy: JwtAuthStrategy,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@InjectRepository(User, 'core')
|
||||
private readonly userRepository: Repository<User>,
|
||||
@InjectRepository(AppToken, 'core')
|
||||
private readonly appTokenRepository: Repository<AppToken>,
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly sSSOService: SSOService,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
) {}
|
||||
|
||||
async generateAccessToken(
|
||||
userId: string,
|
||||
workspaceId?: string,
|
||||
): Promise<AuthToken> {
|
||||
const expiresIn = this.environmentService.get('ACCESS_TOKEN_EXPIRES_IN');
|
||||
|
||||
if (!expiresIn) {
|
||||
throw new AuthException(
|
||||
'Expiration time for access token is not set',
|
||||
AuthExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: userId },
|
||||
relations: ['defaultWorkspace'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw 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 workspaceMemberRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
|
||||
tokenWorkspaceId,
|
||||
'workspaceMember',
|
||||
);
|
||||
|
||||
const workspaceMember = await workspaceMemberRepository.findOne({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!workspaceMember) {
|
||||
throw new AuthException(
|
||||
'User is not a member of the workspace',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
tokenWorkspaceMemberId = workspaceMember.id;
|
||||
}
|
||||
|
||||
const jwtPayload: JwtPayload = {
|
||||
sub: user.id,
|
||||
workspaceId: workspaceId ? workspaceId : user.defaultWorkspaceId,
|
||||
workspaceMemberId: tokenWorkspaceMemberId,
|
||||
};
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload),
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async generateRefreshToken(userId: string): Promise<AuthToken> {
|
||||
const secret = this.environmentService.get('REFRESH_TOKEN_SECRET');
|
||||
const expiresIn = this.environmentService.get('REFRESH_TOKEN_EXPIRES_IN');
|
||||
|
||||
if (!expiresIn) {
|
||||
throw new AuthException(
|
||||
'Expiration time for access token is not set',
|
||||
AuthExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
|
||||
const refreshTokenPayload = {
|
||||
userId,
|
||||
expiresAt,
|
||||
type: AppTokenType.RefreshToken,
|
||||
};
|
||||
const jwtPayload = {
|
||||
sub: userId,
|
||||
};
|
||||
|
||||
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,
|
||||
}),
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async generateInvitationToken(workspaceId: string, email: string) {
|
||||
const expiresIn = this.environmentService.get(
|
||||
'INVITATION_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
|
||||
if (!expiresIn) {
|
||||
throw new AuthException(
|
||||
'Expiration time for invitation token is not set',
|
||||
AuthExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
|
||||
const invitationToken = this.appTokenRepository.create({
|
||||
workspaceId,
|
||||
expiresAt,
|
||||
type: AppTokenType.InvitationToken,
|
||||
value: crypto.randomBytes(32).toString('hex'),
|
||||
context: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
return this.appTokenRepository.save(invitationToken);
|
||||
}
|
||||
|
||||
async generateLoginToken(email: string): Promise<AuthToken> {
|
||||
const secret = this.environmentService.get('LOGIN_TOKEN_SECRET');
|
||||
const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN');
|
||||
|
||||
if (!expiresIn) {
|
||||
throw new AuthException(
|
||||
'Expiration time for access token is not set',
|
||||
AuthExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
const jwtPayload = {
|
||||
sub: email,
|
||||
};
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret,
|
||||
expiresIn,
|
||||
}),
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async generateTransientToken(
|
||||
workspaceMemberId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<AuthToken> {
|
||||
const secret = this.environmentService.get('LOGIN_TOKEN_SECRET');
|
||||
const expiresIn = this.environmentService.get(
|
||||
'SHORT_TERM_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
|
||||
if (!expiresIn) {
|
||||
throw new AuthException(
|
||||
'Expiration time for access token is not set',
|
||||
AuthExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
const jwtPayload = {
|
||||
sub: workspaceMemberId,
|
||||
userId,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret,
|
||||
expiresIn,
|
||||
}),
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async generateApiKeyToken(
|
||||
workspaceId: string,
|
||||
apiKeyId?: string,
|
||||
expiresAt?: Date | string,
|
||||
): Promise<Pick<ApiKeyToken, 'token'> | undefined> {
|
||||
if (!apiKeyId) {
|
||||
return;
|
||||
}
|
||||
const jwtPayload = {
|
||||
sub: workspaceId,
|
||||
};
|
||||
const secret = this.environmentService.get('ACCESS_TOKEN_SECRET');
|
||||
let expiresIn: string | number;
|
||||
|
||||
if (expiresAt) {
|
||||
expiresIn = Math.floor(
|
||||
(new Date(expiresAt).getTime() - new Date().getTime()) / 1000,
|
||||
);
|
||||
} else {
|
||||
expiresIn = this.environmentService.get('API_TOKEN_EXPIRES_IN');
|
||||
}
|
||||
const token = this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret,
|
||||
expiresIn,
|
||||
jwtid: apiKeyId,
|
||||
});
|
||||
|
||||
return { token };
|
||||
}
|
||||
|
||||
isTokenPresent(request: Request): boolean {
|
||||
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
|
||||
|
||||
return !!token;
|
||||
}
|
||||
|
||||
async validateToken(request: Request): Promise<AuthContext> {
|
||||
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
|
||||
|
||||
if (!token) {
|
||||
throw new AuthException(
|
||||
'missing authentication token',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
const decoded = await this.verifyJwt(
|
||||
token,
|
||||
this.environmentService.get('ACCESS_TOKEN_SECRET'),
|
||||
);
|
||||
|
||||
const { user, apiKey, workspace, workspaceMemberId } =
|
||||
await this.jwtStrategy.validate(decoded as JwtPayload);
|
||||
|
||||
return { user, apiKey, workspace, workspaceMemberId };
|
||||
}
|
||||
|
||||
async verifyLoginToken(loginToken: string): Promise<string> {
|
||||
const loginTokenSecret = this.environmentService.get('LOGIN_TOKEN_SECRET');
|
||||
|
||||
const payload = await this.verifyJwt(loginToken, loginTokenSecret);
|
||||
|
||||
return payload.sub;
|
||||
}
|
||||
|
||||
async verifyTransientToken(transientToken: string): Promise<{
|
||||
workspaceMemberId: string;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}> {
|
||||
const transientTokenSecret =
|
||||
this.environmentService.get('LOGIN_TOKEN_SECRET');
|
||||
|
||||
const payload = await this.verifyJwt(transientToken, transientTokenSecret);
|
||||
|
||||
return {
|
||||
workspaceMemberId: payload.sub,
|
||||
userId: payload.userId,
|
||||
workspaceId: payload.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!workspace.workspaceUsers
|
||||
.map((userWorkspace) => userWorkspace.userId)
|
||||
.includes(user.id)
|
||||
) {
|
||||
throw new AuthException(
|
||||
'user does not belong to workspace',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
if (workspace.workspaceSSOIdentityProviders.length > 0) {
|
||||
return {
|
||||
useSSOAuth: true,
|
||||
workspace,
|
||||
availableSSOIdentityProviders:
|
||||
await this.sSSOService.listSSOIdentityProvidersByWorkspaceId(
|
||||
workspaceId,
|
||||
),
|
||||
} as {
|
||||
useSSOAuth: true;
|
||||
workspace: Workspace;
|
||||
availableSSOIdentityProviders: Awaited<
|
||||
ReturnType<
|
||||
typeof this.sSSOService.listSSOIdentityProvidersByWorkspaceId
|
||||
>
|
||||
>;
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
useSSOAuth: false,
|
||||
workspace,
|
||||
} as {
|
||||
useSSOAuth: false;
|
||||
workspace: Workspace;
|
||||
};
|
||||
}
|
||||
|
||||
async generateSwitchWorkspaceToken(
|
||||
user: User,
|
||||
workspace: Workspace,
|
||||
): Promise<AuthTokens> {
|
||||
await this.userRepository.save({
|
||||
id: user.id,
|
||||
defaultWorkspace: workspace,
|
||||
});
|
||||
|
||||
const token = await this.generateAccessToken(user.id, workspace.id);
|
||||
const refreshToken = await this.generateRefreshToken(user.id);
|
||||
|
||||
return {
|
||||
tokens: {
|
||||
accessToken: token,
|
||||
refreshToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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.generateAccessToken(
|
||||
user.id,
|
||||
user.defaultWorkspaceId,
|
||||
);
|
||||
const refreshToken = await this.generateRefreshToken(user.id);
|
||||
const loginToken = await this.generateLoginToken(user.email);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
loginToken,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyRefreshToken(refreshToken: string) {
|
||||
const secret = this.environmentService.get('REFRESH_TOKEN_SECRET');
|
||||
const coolDown = this.environmentService.get('REFRESH_TOKEN_COOL_DOWN');
|
||||
const jwtPayload = await this.verifyJwt(refreshToken, secret);
|
||||
|
||||
if (!(jwtPayload.jti && jwtPayload.sub)) {
|
||||
throw new AuthException(
|
||||
'This refresh token is malformed',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const token = await this.appTokenRepository.findOneBy({
|
||||
id: jwtPayload.jti,
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
throw new AuthException(
|
||||
"This refresh token doesn't exist",
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOne({
|
||||
where: { id: jwtPayload.sub },
|
||||
relations: ['appTokens'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if revokedAt is less than coolDown
|
||||
if (
|
||||
token.revokedAt &&
|
||||
token.revokedAt.getTime() <= Date.now() - ms(coolDown)
|
||||
) {
|
||||
// Revoke all user refresh tokens
|
||||
await Promise.all(
|
||||
user.appTokens.map(async ({ id, type }) => {
|
||||
if (type === AppTokenType.RefreshToken) {
|
||||
await this.appTokenRepository.update(
|
||||
{ id },
|
||||
{
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
throw new AuthException(
|
||||
'Suspicious activity detected, this refresh token has been revoked. All tokens have been revoked.',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
return { user, token };
|
||||
}
|
||||
|
||||
async generateTokensFromRefreshToken(token: string): Promise<{
|
||||
accessToken: AuthToken;
|
||||
refreshToken: AuthToken;
|
||||
}> {
|
||||
if (!token) {
|
||||
throw new AuthException(
|
||||
'Refresh token not found',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
user,
|
||||
token: { id },
|
||||
} = await this.verifyRefreshToken(token);
|
||||
|
||||
// Revoke old refresh token
|
||||
await this.appTokenRepository.update(
|
||||
{
|
||||
id,
|
||||
},
|
||||
{
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
);
|
||||
|
||||
const accessToken = await this.generateAccessToken(user.id);
|
||||
const refreshToken = await this.generateRefreshToken(user.id);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
computeRedirectURI(loginToken: string): string {
|
||||
return `${this.environmentService.get(
|
||||
'FRONT_BASE_URL',
|
||||
)}/verify?loginToken=${loginToken}`;
|
||||
}
|
||||
|
||||
async verifyJwt(token: string, secret?: string) {
|
||||
try {
|
||||
return this.jwtWrapperService.verify(
|
||||
token,
|
||||
secret ? { secret } : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
throw new AuthException(
|
||||
'Token has expired.',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
} else if (error instanceof JsonWebTokenError) {
|
||||
throw new AuthException(
|
||||
'Token invalid.',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
} else {
|
||||
throw new AuthException(
|
||||
'Unknown token error.',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async generatePasswordResetToken(email: string): Promise<PasswordResetToken> {
|
||||
const user = await this.userRepository.findOneBy({
|
||||
email,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const expiresIn = this.environmentService.get(
|
||||
'PASSWORD_RESET_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
|
||||
if (!expiresIn) {
|
||||
throw new AuthException(
|
||||
'PASSWORD_RESET_TOKEN_EXPIRES_IN constant value not found',
|
||||
AuthExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
const existingToken = await this.appTokenRepository.findOne({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: AppTokenType.PasswordResetToken,
|
||||
expiresAt: MoreThan(new Date()),
|
||||
revokedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
if (existingToken) {
|
||||
const timeToWait = ms(
|
||||
differenceInMilliseconds(existingToken.expiresAt, new Date()),
|
||||
{ long: true },
|
||||
);
|
||||
|
||||
throw new AuthException(
|
||||
`Token has already been generated. Please wait for ${timeToWait} to generate again.`,
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const plainResetToken = crypto.randomBytes(32).toString('hex');
|
||||
const hashedResetToken = crypto
|
||||
.createHash('sha256')
|
||||
.update(plainResetToken)
|
||||
.digest('hex');
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
|
||||
await this.appTokenRepository.save({
|
||||
userId: user.id,
|
||||
value: hashedResetToken,
|
||||
expiresAt,
|
||||
type: AppTokenType.PasswordResetToken,
|
||||
});
|
||||
|
||||
return {
|
||||
passwordResetToken: plainResetToken,
|
||||
passwordResetTokenExpiresAt: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async sendEmailPasswordResetLink(
|
||||
resetToken: PasswordResetToken,
|
||||
email: string,
|
||||
): Promise<EmailPasswordResetLink> {
|
||||
const user = await this.userRepository.findOneBy({
|
||||
email,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const frontBaseURL = this.environmentService.get('FRONT_BASE_URL');
|
||||
const resetLink = `${frontBaseURL}/reset-password/${resetToken.passwordResetToken}`;
|
||||
|
||||
const emailData = {
|
||||
link: resetLink,
|
||||
duration: ms(
|
||||
differenceInMilliseconds(
|
||||
resetToken.passwordResetTokenExpiresAt,
|
||||
new Date(),
|
||||
),
|
||||
{
|
||||
long: true,
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
const emailTemplate = PasswordResetLinkEmail(emailData);
|
||||
const html = render(emailTemplate, {
|
||||
pretty: true,
|
||||
});
|
||||
|
||||
const text = render(emailTemplate, {
|
||||
plainText: true,
|
||||
});
|
||||
|
||||
this.emailService.send({
|
||||
from: `${this.environmentService.get(
|
||||
'EMAIL_FROM_NAME',
|
||||
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
|
||||
to: email,
|
||||
subject: 'Action Needed to Reset Password',
|
||||
text,
|
||||
html,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async validatePasswordResetToken(
|
||||
resetToken: string,
|
||||
): Promise<ValidatePasswordResetToken> {
|
||||
const hashedResetToken = crypto
|
||||
.createHash('sha256')
|
||||
.update(resetToken)
|
||||
.digest('hex');
|
||||
|
||||
const token = await this.appTokenRepository.findOne({
|
||||
where: {
|
||||
value: hashedResetToken,
|
||||
type: AppTokenType.PasswordResetToken,
|
||||
expiresAt: MoreThan(new Date()),
|
||||
revokedAt: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!token || !token.userId) {
|
||||
throw new AuthException(
|
||||
'Token is invalid',
|
||||
AuthExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findOneBy({
|
||||
id: token.userId,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
};
|
||||
}
|
||||
|
||||
async invalidatePasswordResetToken(
|
||||
userId: string,
|
||||
): Promise<InvalidatePassword> {
|
||||
const user = await this.userRepository.findOneBy({
|
||||
id: userId,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AuthException(
|
||||
'User not found',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
await this.appTokenRepository.update(
|
||||
{
|
||||
userId,
|
||||
type: AppTokenType.PasswordResetToken,
|
||||
},
|
||||
{
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,133 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
|
||||
import { TransientTokenService } from './transient-token.service';
|
||||
|
||||
describe('TransientTokenService', () => {
|
||||
let service: TransientTokenService;
|
||||
let jwtWrapperService: JwtWrapperService;
|
||||
let environmentService: EnvironmentService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
TransientTokenService,
|
||||
{
|
||||
provide: JwtWrapperService,
|
||||
useValue: {
|
||||
sign: jest.fn(),
|
||||
verifyWorkspaceToken: jest.fn(),
|
||||
decode: jest.fn(),
|
||||
generateAppSecret: jest.fn().mockReturnValue('mocked-secret'),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<TransientTokenService>(TransientTokenService);
|
||||
jwtWrapperService = module.get<JwtWrapperService>(JwtWrapperService);
|
||||
environmentService = module.get<EnvironmentService>(EnvironmentService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('generateTransientToken', () => {
|
||||
it('should generate a transient token successfully', async () => {
|
||||
const workspaceMemberId = 'workspace-member-id';
|
||||
const userId = 'user-id';
|
||||
const workspaceId = 'workspace-id';
|
||||
const mockExpiresIn = '15m';
|
||||
const mockToken = 'mock-token';
|
||||
|
||||
jest.spyOn(environmentService, 'get').mockImplementation((key) => {
|
||||
if (key === 'SHORT_TERM_TOKEN_EXPIRES_IN') return mockExpiresIn;
|
||||
|
||||
return undefined;
|
||||
});
|
||||
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
|
||||
|
||||
const result = await service.generateTransientToken(
|
||||
workspaceMemberId,
|
||||
userId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
token: mockToken,
|
||||
expiresAt: expect.any(Date),
|
||||
});
|
||||
expect(environmentService.get).toHaveBeenCalledWith(
|
||||
'SHORT_TERM_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
|
||||
{
|
||||
sub: workspaceMemberId,
|
||||
userId,
|
||||
workspaceId,
|
||||
},
|
||||
expect.objectContaining({
|
||||
secret: 'mocked-secret',
|
||||
expiresIn: mockExpiresIn,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if SHORT_TERM_TOKEN_EXPIRES_IN is not set', async () => {
|
||||
jest.spyOn(environmentService, 'get').mockReturnValue(undefined);
|
||||
|
||||
await expect(
|
||||
service.generateTransientToken('member-id', 'user-id', 'workspace-id'),
|
||||
).rejects.toThrow(AuthException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyTransientToken', () => {
|
||||
it('should verify a transient token successfully', async () => {
|
||||
const mockToken = 'valid-token';
|
||||
const mockPayload = {
|
||||
sub: 'workspace-member-id',
|
||||
userId: 'user-id',
|
||||
workspaceId: 'workspace-id',
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
|
||||
.mockResolvedValue(undefined);
|
||||
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
|
||||
|
||||
const result = await service.verifyTransientToken(mockToken);
|
||||
|
||||
expect(result).toEqual({
|
||||
workspaceMemberId: mockPayload.sub,
|
||||
userId: mockPayload.userId,
|
||||
workspaceId: mockPayload.workspaceId,
|
||||
});
|
||||
expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith(
|
||||
mockToken,
|
||||
'LOGIN',
|
||||
);
|
||||
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken);
|
||||
});
|
||||
|
||||
it('should throw an error if token verification fails', async () => {
|
||||
const mockToken = 'invalid-token';
|
||||
|
||||
jest
|
||||
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
|
||||
.mockRejectedValue(new Error('Invalid token'));
|
||||
|
||||
await expect(service.verifyTransientToken(mockToken)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,72 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { addMilliseconds } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
|
||||
@Injectable()
|
||||
export class TransientTokenService {
|
||||
constructor(
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
async generateTransientToken(
|
||||
workspaceMemberId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<AuthToken> {
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
'LOGIN',
|
||||
workspaceId,
|
||||
);
|
||||
const expiresIn = this.environmentService.get(
|
||||
'SHORT_TERM_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
|
||||
if (!expiresIn) {
|
||||
throw new AuthException(
|
||||
'Expiration time for access token is not set',
|
||||
AuthExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
const jwtPayload = {
|
||||
sub: workspaceMemberId,
|
||||
userId,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
return {
|
||||
token: this.jwtWrapperService.sign(jwtPayload, {
|
||||
secret,
|
||||
expiresIn,
|
||||
}),
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyTransientToken(transientToken: string): Promise<{
|
||||
workspaceMemberId: string;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
}> {
|
||||
await this.jwtWrapperService.verifyWorkspaceToken(transientToken, 'LOGIN');
|
||||
|
||||
const payload = await this.jwtWrapperService.decode(transientToken);
|
||||
|
||||
return {
|
||||
workspaceMemberId: payload.sub,
|
||||
userId: payload.userId,
|
||||
workspaceId: payload.workspaceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -5,13 +5,16 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
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 { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service';
|
||||
import { EmailModule } from 'src/engine/core-modules/email/email.module';
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
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 { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -22,7 +25,18 @@ import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
|
||||
EmailModule,
|
||||
WorkspaceSSOModule,
|
||||
],
|
||||
providers: [TokenService, JwtAuthStrategy],
|
||||
exports: [TokenService],
|
||||
providers: [
|
||||
RenewTokenService,
|
||||
JwtAuthStrategy,
|
||||
AccessTokenService,
|
||||
LoginTokenService,
|
||||
RefreshTokenService,
|
||||
],
|
||||
exports: [
|
||||
RenewTokenService,
|
||||
AccessTokenService,
|
||||
LoginTokenService,
|
||||
RefreshTokenService,
|
||||
],
|
||||
})
|
||||
export class TokenModule {}
|
||||
|
||||
@ -134,18 +134,13 @@ export class EnvironmentVariables {
|
||||
@IsOptional()
|
||||
SERVER_URL: string;
|
||||
|
||||
// Json Web Token
|
||||
@IsString()
|
||||
ACCESS_TOKEN_SECRET: string;
|
||||
APP_SECRET: string;
|
||||
|
||||
@IsDuration()
|
||||
@IsOptional()
|
||||
ACCESS_TOKEN_EXPIRES_IN = '30m';
|
||||
|
||||
@IsString()
|
||||
REFRESH_TOKEN_SECRET: string;
|
||||
|
||||
@IsDuration()
|
||||
@IsOptional()
|
||||
REFRESH_TOKEN_EXPIRES_IN = '60d';
|
||||
|
||||
@ -153,17 +148,10 @@ export class EnvironmentVariables {
|
||||
@IsOptional()
|
||||
REFRESH_TOKEN_COOL_DOWN = '1m';
|
||||
|
||||
@IsString()
|
||||
LOGIN_TOKEN_SECRET = '30m';
|
||||
|
||||
@IsDuration()
|
||||
@IsOptional()
|
||||
LOGIN_TOKEN_EXPIRES_IN = '15m';
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
FILE_TOKEN_SECRET = 'random_string';
|
||||
|
||||
@IsDuration()
|
||||
@IsOptional()
|
||||
FILE_TOKEN_EXPIRES_IN = '1d';
|
||||
|
||||
@ -8,8 +8,8 @@ import { v4 as uuidV4 } from 'uuid';
|
||||
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
|
||||
|
||||
import { settings } from 'src/engine/constants/settings';
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
import { getCropSize } from 'src/utils/image';
|
||||
|
||||
@Injectable()
|
||||
@ -83,7 +83,7 @@ export class FileUploadService {
|
||||
});
|
||||
|
||||
const signedPayload = await this.fileService.encodeFileToken({
|
||||
workspace_id: workspaceId,
|
||||
workspaceId: workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@ -7,40 +7,43 @@ import {
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
|
||||
@Injectable()
|
||||
export class FilePathGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
constructor(private readonly jwtWrapperService: JwtWrapperService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const query = request.query;
|
||||
|
||||
if (query && query['token']) {
|
||||
const payloadToDecode = query['token'];
|
||||
const decodedPayload = await this.jwtWrapperService.decode(
|
||||
payloadToDecode,
|
||||
{
|
||||
secret: this.environmentService.get('FILE_TOKEN_SECRET'),
|
||||
} as any,
|
||||
);
|
||||
|
||||
const expirationDate = decodedPayload?.['expiration_date'];
|
||||
const workspaceId = decodedPayload?.['workspace_id'];
|
||||
|
||||
const isExpired = await this.isExpired(expirationDate);
|
||||
|
||||
if (isExpired) {
|
||||
return false;
|
||||
}
|
||||
|
||||
request.workspaceId = workspaceId;
|
||||
if (!query || !query['token']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = await this.jwtWrapperService.verifyWorkspaceToken(
|
||||
query['token'],
|
||||
'FILE',
|
||||
);
|
||||
|
||||
if (!payload.workspaceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const decodedPayload = await this.jwtWrapperService.decode(query['token'], {
|
||||
json: true,
|
||||
});
|
||||
|
||||
const expirationDate = decodedPayload?.['expirationDate'];
|
||||
const workspaceId = decodedPayload?.['workspaceId'];
|
||||
|
||||
const isExpired = await this.isExpired(expirationDate);
|
||||
|
||||
if (isExpired) {
|
||||
return false;
|
||||
}
|
||||
|
||||
request.workspaceId = workspaceId;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@ -5,9 +5,9 @@ import { Stream } from 'stream';
|
||||
import { addMilliseconds } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
|
||||
@Injectable()
|
||||
export class FileService {
|
||||
@ -34,13 +34,16 @@ export class FileService {
|
||||
const fileTokenExpiresIn = this.environmentService.get(
|
||||
'FILE_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
const secret = this.environmentService.get('FILE_TOKEN_SECRET');
|
||||
const secret = this.jwtWrapperService.generateAppSecret(
|
||||
'FILE',
|
||||
payloadToEncode.workspaceId,
|
||||
);
|
||||
|
||||
const expirationDate = addMilliseconds(new Date(), ms(fileTokenExpiresIn));
|
||||
|
||||
const signedPayload = this.jwtWrapperService.sign(
|
||||
{
|
||||
expiration_date: expirationDate,
|
||||
expirationDate: expirationDate,
|
||||
...payloadToEncode,
|
||||
},
|
||||
{
|
||||
|
||||
@ -2,14 +2,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule as NestJwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
|
||||
const InternalJwtModule = NestJwtModule.registerAsync({
|
||||
useFactory: async (environmentService: EnvironmentService) => {
|
||||
return {
|
||||
secret: environmentService.get('ACCESS_TOKEN_SECRET'),
|
||||
secret: environmentService.get('APP_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: environmentService.get('ACCESS_TOKEN_EXPIRES_IN'),
|
||||
},
|
||||
|
||||
@ -1,11 +1,30 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService, JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
|
||||
type WorkspaceTokenType =
|
||||
| 'ACCESS'
|
||||
| 'LOGIN'
|
||||
| 'REFRESH'
|
||||
| 'FILE'
|
||||
| 'POSTGRES_PROXY'
|
||||
| 'REMOTE_SERVER';
|
||||
|
||||
@Injectable()
|
||||
export class JwtWrapperService {
|
||||
constructor(private readonly jwtService: JwtService) {}
|
||||
constructor(
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
sign(payload: string | object, options?: JwtSignOptions): string {
|
||||
// Typescript does not handle well the overloads of the sign method, helping it a little bit
|
||||
@ -20,7 +39,58 @@ export class JwtWrapperService {
|
||||
return this.jwtService.verify(token, options);
|
||||
}
|
||||
|
||||
decode<T = any>(payload: string, options: jwt.DecodeOptions): T {
|
||||
decode<T = any>(payload: string, options?: jwt.DecodeOptions): T {
|
||||
return this.jwtService.decode(payload, options);
|
||||
}
|
||||
|
||||
verifyWorkspaceToken(
|
||||
token: string,
|
||||
type: WorkspaceTokenType,
|
||||
options?: JwtVerifyOptions,
|
||||
) {
|
||||
const payload = this.decode(token, {
|
||||
json: true,
|
||||
});
|
||||
|
||||
// TODO: check if this is really needed
|
||||
if (type !== 'FILE' && !payload.sub) {
|
||||
throw new UnauthorizedException('No payload sub');
|
||||
}
|
||||
|
||||
try {
|
||||
return this.jwtService.verify(token, {
|
||||
...options,
|
||||
secret: this.generateAppSecret(type, payload.workspaceId),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof jwt.TokenExpiredError) {
|
||||
throw new AuthException(
|
||||
'Token has expired.',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
} else if (error instanceof jwt.JsonWebTokenError) {
|
||||
throw new AuthException(
|
||||
'Token invalid.',
|
||||
AuthExceptionCode.UNAUTHENTICATED,
|
||||
);
|
||||
} else {
|
||||
throw new AuthException(
|
||||
'Unknown token error.',
|
||||
AuthExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
generateAppSecret(type: WorkspaceTokenType, workspaceId?: string): string {
|
||||
const appSecret = this.environmentService.get('APP_SECRET');
|
||||
|
||||
if (!appSecret) {
|
||||
throw new Error('APP_SECRET is not set');
|
||||
}
|
||||
|
||||
return createHash('sha256')
|
||||
.update(`${appSecret}${workspaceId}${type}`)
|
||||
.digest('hex');
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { OpenApiService } from 'src/engine/core-modules/open-api/open-api.service';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
@ -13,7 +13,7 @@ describe('OpenApiService', () => {
|
||||
providers: [
|
||||
OpenApiService,
|
||||
{
|
||||
provide: TokenService,
|
||||
provide: AccessTokenService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
|
||||
@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { OpenAPIV3_1 } from 'openapi-types';
|
||||
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { baseSchema } from 'src/engine/core-modules/open-api/utils/base-schema.utils';
|
||||
import {
|
||||
@ -41,7 +41,7 @@ import { getServerUrl } from 'src/utils/get-server-url';
|
||||
@Injectable()
|
||||
export class OpenApiService {
|
||||
constructor(
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly accessTokenService: AccessTokenService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly objectMetadataService: ObjectMetadataService,
|
||||
) {}
|
||||
@ -57,7 +57,8 @@ export class OpenApiService {
|
||||
let objectMetadataItems;
|
||||
|
||||
try {
|
||||
const { workspace } = await this.tokenService.validateToken(request);
|
||||
const { workspace } =
|
||||
await this.accessTokenService.validateToken(request);
|
||||
|
||||
objectMetadataItems =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(workspace.id);
|
||||
|
||||
@ -1,16 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
|
||||
import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
|
||||
import { PostgresCredentialsResolver } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.resolver';
|
||||
import { PostgresCredentialsService } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.service';
|
||||
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([PostgresCredentials], 'core'),
|
||||
EnvironmentModule,
|
||||
],
|
||||
imports: [JwtModule, TypeOrmModule.forFeature([PostgresCredentials], 'core')],
|
||||
providers: [
|
||||
PostgresCredentialsResolver,
|
||||
PostgresCredentialsService,
|
||||
|
||||
@ -10,15 +10,15 @@ import {
|
||||
encryptText,
|
||||
} from 'src/engine/core-modules/auth/auth.util';
|
||||
import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
|
||||
export class PostgresCredentialsService {
|
||||
constructor(
|
||||
@InjectRepository(PostgresCredentials, 'core')
|
||||
private readonly postgresCredentialsRepository: Repository<PostgresCredentials>,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
) {}
|
||||
|
||||
async enablePostgresProxy(
|
||||
@ -27,7 +27,10 @@ export class PostgresCredentialsService {
|
||||
const user = `user_${randomBytes(4).toString('hex')}`;
|
||||
const password = randomBytes(16).toString('hex');
|
||||
|
||||
const key = this.environmentService.get('LOGIN_TOKEN_SECRET');
|
||||
const key = this.jwtWrapperService.generateAppSecret(
|
||||
'POSTGRES_PROXY',
|
||||
workspaceId,
|
||||
);
|
||||
const passwordHash = encryptText(password, key);
|
||||
|
||||
const existingCredentials =
|
||||
@ -81,7 +84,10 @@ export class PostgresCredentialsService {
|
||||
id: postgresCredentials.id,
|
||||
});
|
||||
|
||||
const key = this.environmentService.get('LOGIN_TOKEN_SECRET');
|
||||
const key = this.jwtWrapperService.generateAppSecret(
|
||||
'POSTGRES_PROXY',
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return {
|
||||
id: postgresCredentials.id,
|
||||
@ -105,7 +111,10 @@ export class PostgresCredentialsService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = this.environmentService.get('LOGIN_TOKEN_SECRET');
|
||||
const key = this.jwtWrapperService.generateAppSecret(
|
||||
'POSTGRES_PROXY',
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return {
|
||||
id: postgresCredentials.id,
|
||||
|
||||
@ -111,8 +111,8 @@ export class UserResolver {
|
||||
|
||||
if (workspaceMember && workspaceMember.avatarUrl) {
|
||||
const avatarUrlToken = await this.fileService.encodeFileToken({
|
||||
workspace_member_id: workspaceMember.id,
|
||||
workspace_id: user.defaultWorkspaceId,
|
||||
workspaceMemberId: workspaceMember.id,
|
||||
workspaceId: user.defaultWorkspaceId,
|
||||
});
|
||||
|
||||
workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`;
|
||||
@ -133,8 +133,8 @@ export class UserResolver {
|
||||
for (const workspaceMember of workspaceMembers) {
|
||||
if (workspaceMember.avatarUrl) {
|
||||
const avatarUrlToken = await this.fileService.encodeFileToken({
|
||||
workspace_member_id: workspaceMember.id,
|
||||
workspace_id: user.defaultWorkspaceId,
|
||||
workspaceMemberId: workspaceMember.id,
|
||||
workspaceId: user.defaultWorkspaceId,
|
||||
});
|
||||
|
||||
workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`;
|
||||
@ -190,7 +190,7 @@ export class UserResolver {
|
||||
});
|
||||
|
||||
const fileToken = await this.fileService.encodeFileToken({
|
||||
workspace_id: workspaceId,
|
||||
workspaceId: workspaceId,
|
||||
});
|
||||
|
||||
return `${paths[0]}?token=${fileToken}`;
|
||||
|
||||
@ -1,17 +1,29 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
AppToken,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceInvitationException } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
import { WorkspaceInvitationService } from './workspace-invitation.service';
|
||||
|
||||
describe('WorkspaceInvitationService', () => {
|
||||
let service: WorkspaceInvitationService;
|
||||
let appTokenRepository: Repository<AppToken>;
|
||||
let userWorkspaceRepository: Repository<UserWorkspace>;
|
||||
let environmentService: EnvironmentService;
|
||||
let emailService: EmailService;
|
||||
let onboardingService: OnboardingService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@ -19,27 +31,29 @@ describe('WorkspaceInvitationService', () => {
|
||||
WorkspaceInvitationService,
|
||||
{
|
||||
provide: getRepositoryToken(AppToken, 'core'),
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: EmailService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: TokenService,
|
||||
useValue: {},
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserWorkspace, 'core'),
|
||||
useValue: {},
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EmailService,
|
||||
useValue: {
|
||||
send: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: OnboardingService,
|
||||
useValue: {},
|
||||
useValue: {
|
||||
setOnboardingInviteTeamPending: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
@ -47,9 +61,96 @@ describe('WorkspaceInvitationService', () => {
|
||||
service = module.get<WorkspaceInvitationService>(
|
||||
WorkspaceInvitationService,
|
||||
);
|
||||
appTokenRepository = module.get<Repository<AppToken>>(
|
||||
getRepositoryToken(AppToken, 'core'),
|
||||
);
|
||||
userWorkspaceRepository = module.get<Repository<UserWorkspace>>(
|
||||
getRepositoryToken(UserWorkspace, 'core'),
|
||||
);
|
||||
environmentService = module.get<EnvironmentService>(EnvironmentService);
|
||||
emailService = module.get<EmailService>(EmailService);
|
||||
onboardingService = module.get<OnboardingService>(OnboardingService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('createWorkspaceInvitation', () => {
|
||||
it('should create a workspace invitation successfully', async () => {
|
||||
const email = 'test@example.com';
|
||||
const workspace = { id: 'workspace-id' } as Workspace;
|
||||
|
||||
jest.spyOn(appTokenRepository, 'createQueryBuilder').mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getOne: jest.fn().mockResolvedValue(null),
|
||||
} as any);
|
||||
|
||||
jest.spyOn(userWorkspaceRepository, 'exists').mockResolvedValue(false);
|
||||
jest
|
||||
.spyOn(service, 'generateInvitationToken')
|
||||
.mockResolvedValue({} as AppToken);
|
||||
|
||||
await expect(
|
||||
service.createWorkspaceInvitation(email, workspace),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw an exception if invitation already exists', async () => {
|
||||
const email = 'test@example.com';
|
||||
const workspace = { id: 'workspace-id' } as Workspace;
|
||||
|
||||
jest.spyOn(appTokenRepository, 'createQueryBuilder').mockReturnValue({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getOne: jest.fn().mockResolvedValue({}),
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
service.createWorkspaceInvitation(email, workspace),
|
||||
).rejects.toThrow(WorkspaceInvitationException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendInvitations', () => {
|
||||
it('should send invitations successfully', async () => {
|
||||
const emails = ['test1@example.com', 'test2@example.com'];
|
||||
const workspace = {
|
||||
id: 'workspace-id',
|
||||
inviteHash: 'invite-hash',
|
||||
displayName: 'Test Workspace',
|
||||
} as Workspace;
|
||||
const sender = { email: 'sender@example.com', firstName: 'Sender' };
|
||||
|
||||
jest.spyOn(service, 'createWorkspaceInvitation').mockResolvedValue({
|
||||
context: { email: 'test@example.com' },
|
||||
value: 'token-value',
|
||||
type: AppTokenType.InvitationToken,
|
||||
} as AppToken);
|
||||
jest
|
||||
.spyOn(environmentService, 'get')
|
||||
.mockReturnValue('http://localhost:3000');
|
||||
jest.spyOn(emailService, 'send').mockResolvedValue({} as any);
|
||||
jest
|
||||
.spyOn(onboardingService, 'setOnboardingInviteTeamPending')
|
||||
.mockResolvedValue({} as any);
|
||||
|
||||
const result = await service.sendInvitations(
|
||||
emails,
|
||||
workspace,
|
||||
sender as User,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.result.length).toBe(2);
|
||||
expect(emailService.send).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
onboardingService.setOnboardingInviteTeamPending,
|
||||
).toHaveBeenCalledWith({
|
||||
workspaceId: workspace.id,
|
||||
value: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { render } from '@react-email/render';
|
||||
import { addMilliseconds } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
import { SendInviteLinkEmail } from 'twenty-emails';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
|
||||
@ -9,7 +13,10 @@ import {
|
||||
AppToken,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
} from 'src/engine/core-modules/auth/auth.exception';
|
||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
@ -30,7 +37,6 @@ export class WorkspaceInvitationService {
|
||||
private readonly appTokenRepository: Repository<AppToken>,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly tokenService: TokenService,
|
||||
@InjectRepository(UserWorkspace, 'core')
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
private readonly onboardingService: OnboardingService,
|
||||
@ -103,7 +109,7 @@ export class WorkspaceInvitationService {
|
||||
);
|
||||
}
|
||||
|
||||
return this.tokenService.generateInvitationToken(workspace.id, email);
|
||||
return this.generateInvitationToken(workspace.id, email);
|
||||
}
|
||||
|
||||
async loadWorkspaceInvitations(workspace: Workspace) {
|
||||
@ -290,4 +296,31 @@ export class WorkspaceInvitationService {
|
||||
...result,
|
||||
};
|
||||
}
|
||||
|
||||
async generateInvitationToken(workspaceId: string, email: string) {
|
||||
const expiresIn = this.environmentService.get(
|
||||
'INVITATION_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
|
||||
if (!expiresIn) {
|
||||
throw new AuthException(
|
||||
'Expiration time for invitation token is not set',
|
||||
AuthExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
|
||||
|
||||
const invitationToken = this.appTokenRepository.create({
|
||||
workspaceId,
|
||||
expiresAt,
|
||||
type: AppTokenType.InvitationToken,
|
||||
value: crypto.randomBytes(32).toString('hex'),
|
||||
context: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
return this.appTokenRepository.save(invitationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,7 +95,7 @@ export class WorkspaceResolver {
|
||||
});
|
||||
|
||||
const workspaceLogoToken = await this.fileService.encodeFileToken({
|
||||
workspace_id: id,
|
||||
workspaceId: id,
|
||||
});
|
||||
|
||||
return `${paths[0]}?token=${workspaceLogoToken}`;
|
||||
@ -128,7 +128,7 @@ export class WorkspaceResolver {
|
||||
if (workspace.logo) {
|
||||
try {
|
||||
const workspaceLogoToken = await this.fileService.encodeFileToken({
|
||||
workspace_id: workspace.id,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
return `${workspace.logo}?token=${workspaceLogoToken}`;
|
||||
|
||||
Reference in New Issue
Block a user