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:
ZiaCodes 2024-10-30 16:07:11 +05:00 committed by GitHub
parent 1782865ff8
commit 57d9b8e8b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
75 changed files with 2860 additions and 1531 deletions

View File

@ -41,10 +41,7 @@ jobs:
cp .env.example .env
echo "Generating secrets..."
echo "# === Randomly generated secrets ===" >>.env
echo "ACCESS_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env
echo "LOGIN_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env
echo "REFRESH_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env
echo "FILE_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env
echo "APP_SECRET=$(openssl rand -base64 32)" >>.env
echo "POSTGRES_ADMIN_PASSWORD=$(openssl rand -base64 32)" >>.env
echo "Starting server..."

View File

@ -91,10 +91,7 @@ fi
# Generate random strings for secrets
echo "# === Randomly generated secrets ===" >>.env
echo "ACCESS_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env
echo "LOGIN_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env
echo "REFRESH_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env
echo "FILE_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env
echo "APP_SECRET=$(openssl rand -base64 32)" >>.env
echo "" >>.env
echo "POSTGRES_ADMIN_PASSWORD=$(openssl rand -base64 32)" >>.env

View File

@ -8,10 +8,7 @@ REDIS_URL=redis://redis:6379
SERVER_URL=http://localhost:3000
# Use openssl rand -base64 32 for each secret
# ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access
# LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login
# REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh
# FILE_TOKEN_SECRET=replace_me_with_a_random_string_refresh
# APP_SECRET=replace_me_with_a_random_string
SIGN_IN_PREFILLED=true

View File

@ -35,10 +35,7 @@ services:
STORAGE_S3_NAME: ${STORAGE_S3_NAME}
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT}
ACCESS_TOKEN_SECRET: ${ACCESS_TOKEN_SECRET}
LOGIN_TOKEN_SECRET: ${LOGIN_TOKEN_SECRET}
REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET}
FILE_TOKEN_SECRET: ${FILE_TOKEN_SECRET}
APP_SECRET: ${APP_SECRET}
depends_on:
change-vol-ownership:
condition: service_completed_successfully
@ -67,10 +64,7 @@ services:
STORAGE_S3_NAME: ${STORAGE_S3_NAME}
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT}
ACCESS_TOKEN_SECRET: ${ACCESS_TOKEN_SECRET}
LOGIN_TOKEN_SECRET: ${LOGIN_TOKEN_SECRET}
REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET}
FILE_TOKEN_SECRET: ${FILE_TOKEN_SECRET}
APP_SECRET: ${APP_SECRET}
depends_on:
db:
condition: service_healthy

View File

@ -55,26 +55,11 @@ spec:
value: "7d"
- name: "LOGIN_TOKEN_EXPIRES_IN"
value: "1h"
- name: ACCESS_TOKEN_SECRET
- name: APP_SECRET
valueFrom:
secretKeyRef:
name: tokens
key: accessToken
- name: LOGIN_TOKEN_SECRET
valueFrom:
secretKeyRef:
name: tokens
key: loginToken
- name: REFRESH_TOKEN_SECRET
valueFrom:
secretKeyRef:
name: tokens
key: refreshToken
- name: FILE_TOKEN_SECRET
valueFrom:
secretKeyRef:
name: tokens
key: fileToken
ports:
- containerPort: 3000
name: http-tcp

View File

@ -42,26 +42,11 @@ spec:
value: "redis"
- name: "REDIS_URL"
value: "redis://twentycrm-redis.twentycrm.svc.cluster.local:6379"
- name: ACCESS_TOKEN_SECRET
- name: APP_SECRET
valueFrom:
secretKeyRef:
name: tokens
key: accessToken
- name: LOGIN_TOKEN_SECRET
valueFrom:
secretKeyRef:
name: tokens
key: loginToken
- name: REFRESH_TOKEN_SECRET
valueFrom:
secretKeyRef:
name: tokens
key: refreshToken
- name: FILE_TOKEN_SECRET
valueFrom:
secretKeyRef:
name: tokens
key: fileToken
command:
- yarn
- worker:prod

View File

@ -91,7 +91,7 @@ resource "kubernetes_deployment" "twentycrm_server" {
value = "1h"
}
env {
name = "ACCESS_TOKEN_SECRET"
name = "APP_SECRET"
value_from {
secret_key_ref {
name = "tokens"
@ -100,36 +100,6 @@ resource "kubernetes_deployment" "twentycrm_server" {
}
}
env {
name = "LOGIN_TOKEN_SECRET"
value_from {
secret_key_ref {
name = "tokens"
key = "loginToken"
}
}
}
env {
name = "REFRESH_TOKEN_SECRET"
value_from {
secret_key_ref {
name = "tokens"
key = "refreshToken"
}
}
}
env {
name = "FILE_TOKEN_SECRET"
value_from {
secret_key_ref {
name = "tokens"
key = "fileToken"
}
}
}
port {
container_port = 3000
protocol = "TCP"

View File

@ -78,7 +78,7 @@ resource "kubernetes_deployment" "twentycrm_worker" {
}
env {
name = "ACCESS_TOKEN_SECRET"
name = "APP_SECRET"
value_from {
secret_key_ref {
name = "tokens"
@ -87,36 +87,6 @@ resource "kubernetes_deployment" "twentycrm_worker" {
}
}
env {
name = "LOGIN_TOKEN_SECRET"
value_from {
secret_key_ref {
name = "tokens"
key = "loginToken"
}
}
}
env {
name = "REFRESH_TOKEN_SECRET"
value_from {
secret_key_ref {
name = "tokens"
key = "refreshToken"
}
}
}
env {
name = "FILE_TOKEN_SECRET"
value_from {
secret_key_ref {
name = "tokens"
key = "fileToken"
}
}
}
resources {
requests = {
cpu = "250m"

View File

@ -1,14 +1,11 @@
# Use this for local setup
PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default
REDIS_URL=redis://localhost:6379
FRONT_BASE_URL=http://localhost:3001
ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access
LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login
REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh
FILE_TOKEN_SECRET=replace_me_with_a_random_string_refresh
APP_SECRET=replace_me_with_a_random_string
SIGN_IN_PREFILLED=true
REDIS_URL=redis://localhost:6379
# ———————— Optional ————————

View File

@ -1,11 +1,10 @@
PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/test
REDIS_URL=redis://localhost:6379
DEBUG_MODE=true
DEBUG_PORT=9000
FRONT_BASE_URL=http://localhost:3001
ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access
LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login
REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh
APP_SECRET=replace_me_with_a_random_string
SIGN_IN_PREFILLED=true
EXCEPTION_HANDLER_DRIVER=console
SENTRY_DSN=https://ba869cb8fd72d5faeb6643560939cee0@o4505516959793152.ingest.sentry.io/4506660900306944
@ -13,7 +12,6 @@ DEMO_WORKSPACE_IDS=63db4589-590f-42b3-bdf1-85268b3da02f,8de58f3f-7e86-4a0b-998d-
MUTATION_MAXIMUM_RECORD_AFFECTED=100
MESSAGE_QUEUE_TYPE=bull-mq
CACHE_STORAGE_TYPE=redis
REDIS_URL=redis://localhost:6379
AUTH_GOOGLE_ENABLED=false
MESSAGING_PROVIDER_GMAIL_ENABLED=false

View File

@ -30,7 +30,7 @@ const jestConfig: JestConfigWithTsJest = {
globals: {
APP_PORT: 4000,
ACCESS_TOKEN:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwiaWF0IjoxNzI2NDkyNTAyLCJleHAiOjEzMjQ1MDE2NTAyfQ.zM6TbfeOqYVH5Sgryc2zf02hd9uqUOSL1-iJlMgwzsI',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwiaWF0IjoxNzI2NDkyNTAyLCJleHAiOjEzMjQ1MDE2NTAyfQ._ISjY_dlVWskeQ6wkE0-kOn641G_mee5GiqoZTQFIfE',
},
};

View File

@ -14,7 +14,6 @@ import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import { useThrottler } from 'src/engine/api/graphql/graphql-config/hooks/use-throttler';
import { WorkspaceSchemaFactory } from 'src/engine/api/graphql/workspace-schema.factory';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { CoreEngineModule } from 'src/engine/core-modules/core-engine.module';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@ -36,7 +35,6 @@ export class GraphQLConfigService
implements GqlOptionsFactory<YogaDriverConfig<'express'>>
{
constructor(
private readonly tokenService: TokenService,
private readonly exceptionHandlerService: ExceptionHandlerService,
private readonly environmentService: EnvironmentService,
private readonly moduleRef: ModuleRef,

View File

@ -35,8 +35,8 @@ export class ActivityQueryResultGetterHandler
imageUrl.searchParams.delete('token');
const signedPayload = await this.fileService.encodeFileToken({
note_block_id: block.id,
workspace_id: workspaceId,
noteBlockId: block.id,
workspaceId: workspaceId,
});
return {

View File

@ -17,8 +17,8 @@ export class AttachmentQueryResultGetterHandler
}
const signedPayload = await this.fileService.encodeFileToken({
attachment_id: attachment.id,
workspace_id: workspaceId,
attachmentId: attachment.id,
workspaceId: workspaceId,
});
return {

View File

@ -17,8 +17,8 @@ export class PersonQueryResultGetterHandler
}
const signedPayload = await this.fileService.encodeFileToken({
person_id: person.id,
workspace_id: workspaceId,
personId: person.id,
workspaceId: workspaceId,
});
return {

View File

@ -17,8 +17,8 @@ export class WorkspaceMemberQueryResultGetterHandler
}
const signedPayload = await this.fileService.encodeFileToken({
workspace_member_id: workspaceMember.id,
workspace_id: workspaceId,
workspaceMemberId: workspaceMember.id,
workspaceId: workspaceId,
});
return {

View File

@ -18,7 +18,7 @@ import { computeDepth } from 'src/engine/api/rest/core/query-builder/utils/compu
import { parseCoreBatchPath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-batch-path.utils';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { Query } from 'src/engine/api/rest/core/types/query.type';
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 { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
@ -39,7 +39,7 @@ export class CoreQueryBuilderFactory {
private readonly getVariablesFactory: GetVariablesFactory,
private readonly findDuplicatesVariablesFactory: FindDuplicatesVariablesFactory,
private readonly objectMetadataService: ObjectMetadataService,
private readonly tokenService: TokenService,
private readonly accessTokenService: AccessTokenService,
private readonly environmentService: EnvironmentService,
) {}
@ -50,7 +50,7 @@ export class CoreQueryBuilderFactory {
objectMetadataItems: ObjectMetadataEntity[];
objectMetadataItem: ObjectMetadataEntity;
}> {
const { workspace } = await this.tokenService.validateToken(request);
const { workspace } = await this.accessTokenService.validateToken(request);
const objectMetadataItems =
await this.objectMetadataService.findManyWithinWorkspace(workspace.id);

View File

@ -7,18 +7,18 @@ import {
GraphqlApiType,
RestApiService,
} from 'src/engine/api/rest/rest-api.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';
@Injectable()
export class RestApiMetadataService {
constructor(
private readonly tokenService: TokenService,
private readonly accessTokenService: AccessTokenService,
private readonly metadataQueryBuilderFactory: MetadataQueryBuilderFactory,
private readonly restApiService: RestApiService,
) {}
async get(request: Request) {
await this.tokenService.validateToken(request);
await this.accessTokenService.validateToken(request);
const data = await this.metadataQueryBuilderFactory.get(request);
return await this.restApiService.call(
@ -29,7 +29,7 @@ export class RestApiMetadataService {
}
async create(request: Request) {
await this.tokenService.validateToken(request);
await this.accessTokenService.validateToken(request);
const data = await this.metadataQueryBuilderFactory.create(request);
return await this.restApiService.call(
@ -40,7 +40,7 @@ export class RestApiMetadataService {
}
async update(request: Request) {
await this.tokenService.validateToken(request);
await this.accessTokenService.validateToken(request);
const data = await this.metadataQueryBuilderFactory.update(request);
return await this.restApiService.call(
@ -51,7 +51,7 @@ export class RestApiMetadataService {
}
async delete(request: Request) {
await this.tokenService.validateToken(request);
await this.accessTokenService.validateToken(request);
const data = await this.metadataQueryBuilderFactory.delete(request);
return await this.restApiService.call(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,12 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
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 { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly tokenService: TokenService,
private readonly accessTokenService: AccessTokenService,
private readonly workspaceStorageCacheService: WorkspaceCacheStorageService,
) {}
@ -14,7 +14,7 @@ export class JwtAuthGuard implements CanActivate {
const request = context.switchToHttp().getRequest();
try {
const data = await this.tokenService.validateToken(request);
const data = await this.accessTokenService.validateToken(request);
const metadataVersion =
await this.workspaceStorageCacheService.getMetadataVersion(
data.workspace.id,

View File

@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ForeignDataWrapperServerQueryFactory } from 'src/engine/api/graphql/workspace-query-builder/factories/foreign-data-wrapper-server-query.factory';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { RemoteServerEntity } from 'src/engine/metadata-modules/remote-server/remote-server.entity';
import { RemoteServerResolver } from 'src/engine/metadata-modules/remote-server/remote-server.resolver';
import { RemoteServerService } from 'src/engine/metadata-modules/remote-server/remote-server.service';
@ -11,6 +12,7 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
@Module({
imports: [
JwtModule,
TypeOrmModule.forFeature([RemoteServerEntity], 'metadata'),
RemoteTableModule,
WorkspaceDataSourceModule,

View File

@ -2,31 +2,31 @@ import { Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import isEmpty from 'lodash.isempty';
import { v4 } from 'uuid';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ForeignDataWrapperServerQueryFactory } from 'src/engine/api/graphql/workspace-query-builder/factories/foreign-data-wrapper-server-query.factory';
import { encryptText } from 'src/engine/core-modules/auth/auth.util';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { CreateRemoteServerInput } from 'src/engine/metadata-modules/remote-server/dtos/create-remote-server.input';
import { UpdateRemoteServerInput } from 'src/engine/metadata-modules/remote-server/dtos/update-remote-server.input';
import {
RemoteServerEntity,
RemoteServerType,
} from 'src/engine/metadata-modules/remote-server/remote-server.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { encryptText } from 'src/engine/core-modules/auth/auth.util';
import {
validateObjectAgainstInjections,
validateStringAgainstInjections,
} from 'src/engine/metadata-modules/remote-server/utils/validate-remote-server-input.utils';
import { ForeignDataWrapperServerQueryFactory } from 'src/engine/api/graphql/workspace-query-builder/factories/foreign-data-wrapper-server-query.factory';
import { RemoteTableService } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table.service';
import { UpdateRemoteServerInput } from 'src/engine/metadata-modules/remote-server/dtos/update-remote-server.input';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { buildUpdateRemoteServerRawQuery } from 'src/engine/metadata-modules/remote-server/utils/build-update-remote-server-raw-query.utils';
import { validateRemoteServerType } from 'src/engine/metadata-modules/remote-server/utils/validate-remote-server-type.util';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import {
RemoteServerException,
RemoteServerExceptionCode,
} from 'src/engine/metadata-modules/remote-server/remote-server.exception';
import { RemoteTableService } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table.service';
import { buildUpdateRemoteServerRawQuery } from 'src/engine/metadata-modules/remote-server/utils/build-update-remote-server-raw-query.utils';
import {
validateObjectAgainstInjections,
validateStringAgainstInjections,
} from 'src/engine/metadata-modules/remote-server/utils/validate-remote-server-input.utils';
import { validateRemoteServerType } from 'src/engine/metadata-modules/remote-server/utils/validate-remote-server-type.util';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
@Injectable()
export class RemoteServerService<T extends RemoteServerType> {
@ -37,7 +37,7 @@ export class RemoteServerService<T extends RemoteServerType> {
>,
@InjectDataSource('metadata')
private readonly metadataDataSource: DataSource,
private readonly environmentService: EnvironmentService,
private readonly jwtWrapperService: JwtWrapperService,
private readonly foreignDataWrapperServerQueryFactory: ForeignDataWrapperServerQueryFactory,
private readonly remoteTableService: RemoteTableService,
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
@ -72,6 +72,7 @@ export class RemoteServerService<T extends RemoteServerType> {
...remoteServerInput.userMappingOptions,
password: this.encryptPassword(
remoteServerInput.userMappingOptions.password,
workspaceId,
),
},
};
@ -156,6 +157,7 @@ export class RemoteServerService<T extends RemoteServerType> {
...partialRemoteServerWithUpdates.userMappingOptions,
password: this.encryptPassword(
partialRemoteServerWithUpdates.userMappingOptions.password,
workspaceId,
),
},
};
@ -252,8 +254,11 @@ export class RemoteServerService<T extends RemoteServerType> {
});
}
private encryptPassword(password: string) {
const key = this.environmentService.get('LOGIN_TOKEN_SECRET');
private encryptPassword(password: string, workspaceId: string) {
const key = this.jwtWrapperService.generateAppSecret(
'REMOTE_SERVER',
workspaceId,
);
return encryptText(password, key);
}

View File

@ -1,23 +1,24 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { ExtractJwt } from 'passport-jwt';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
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 { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { handleExceptionAndConvertToGraphQLError } from 'src/engine/utils/global-exception-handler.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
class GraphqlTokenValidationProxy {
private tokenService: TokenService;
private accessTokenService: AccessTokenService;
constructor(tokenService: TokenService) {
this.tokenService = tokenService;
constructor(accessTokenService: AccessTokenService) {
this.accessTokenService = accessTokenService;
}
async validateToken(req: Request) {
try {
return await this.tokenService.validateToken(req);
return await this.accessTokenService.validateToken(req);
} catch (error) {
const authGraphqlApiExceptionFilter = new AuthGraphqlApiExceptionFilter();
@ -31,7 +32,7 @@ export class GraphQLHydrateRequestFromTokenMiddleware
implements NestMiddleware
{
constructor(
private readonly tokenService: TokenService,
private readonly accessTokenService: AccessTokenService,
private readonly workspaceStorageCacheService: WorkspaceCacheStorageService,
private readonly exceptionHandlerService: ExceptionHandlerService,
) {}
@ -59,7 +60,7 @@ export class GraphQLHydrateRequestFromTokenMiddleware
];
if (
!this.tokenService.isTokenPresent(req) &&
!this.isTokenPresent(req) &&
(!body?.operationName || excludedOperations.includes(body.operationName))
) {
return next();
@ -69,7 +70,7 @@ export class GraphQLHydrateRequestFromTokenMiddleware
try {
const graphqlTokenValidationProxy = new GraphqlTokenValidationProxy(
this.tokenService,
this.accessTokenService,
);
data = await graphqlTokenValidationProxy.validateToken(req);
@ -103,4 +104,10 @@ export class GraphQLHydrateRequestFromTokenMiddleware
next();
}
isTokenPresent(request: Request): boolean {
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
return !!token;
}
}

View File

@ -5,10 +5,9 @@ image: /images/user-guide/notes/notes_header.png
---
<ArticleWarning>
This document is maintained by the community. It might contain issues.
This document is maintained by the community. It might contain issues.
</ArticleWarning>
## Kubernetes via Terraform and Manifests
Community-led documentation for Kubernetes deployment is available [here](https://github.com/twentyhq/twenty/tree/main/packages/twenty-docker/k8s)
@ -19,14 +18,12 @@ Community-led, might not be up to date
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/twentyhq/twenty)
## RepoCloud
## RepoCloud
Community-led, might not be up to date
[![Deploy on RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploy.png)](https://repocloud.io/details/?app_id=259)
## Azure Container Apps
Community-led, might not be up to date
@ -271,11 +268,8 @@ resource "azapi_update_resource" "cors" {
```hcl
# backend.tf
# Create three random UUIDs
resource "random_uuid" "access_token_secret" {}
resource "random_uuid" "login_token_secret" {}
resource "random_uuid" "refresh_token_secret" {}
resource "random_uuid" "file_token_secret" {}
# Create a random UUID
resource "random_uuid" "app_secret" {}
resource "azurerm_container_app" "twenty_server" {
name = local.server_name
@ -343,20 +337,8 @@ resource "azurerm_container_app" "twenty_server" {
value = "https://${local.front_app_name}"
}
env {
name = "ACCESS_TOKEN_SECRET"
value = random_uuid.access_token_secret.result
}
env {
name = "LOGIN_TOKEN_SECRET"
value = random_uuid.login_token_secret.result
}
env {
name = "REFRESH_TOKEN_SECRET"
value = random_uuid.refresh_token_secret.result
}
env {
name = "FILE_TOKEN_SECRET"
value = random_uuid.file_token_secret.result
name = "APP_SECRET"
value = random_uuid.app_secret.result
}
}
}
@ -446,4 +428,4 @@ resource "azurerm_container_app" "twenty_db" {
Please feel free to Open a PR to add more Cloud Provider options.
<ArticleEditContent></ArticleEditContent>
<ArticleEditContent></ArticleEditContent>

View File

@ -50,23 +50,19 @@ Follow these steps for a manual setup.
2. **Generate Secret Tokens**
Run the following command four times to generate four unique random strings:
Run the following command to generate a unique random string:
```bash
openssl rand -base64 32
```
**Important:** Keep these tokens secure and do not share them.
**Important:** Keep this value secret / do not share it.
3. **Update the `.env`**
Replace the placeholder values in your .env file with the generated tokens:
Replace the placeholder value in your .env file with the generated token:
```ini
ACCESS_TOKEN_SECRET=first_random_string
LOGIN_TOKEN_SECRET=second_random_string
REFRESH_TOKEN_SECRET=third_random_string
FILE_TOKEN_SECRET=fourth_random_string
APP_SECRET=first_random_string
```
**Note:** Only modify these lines unless instructed otherwise.
4. **Set the Postgres Password**

View File

@ -51,14 +51,11 @@ yarn command:prod cron:calendar:calendar-event-list-fetch
### Tokens
<ArticleTable options={[
['ACCESS_TOKEN_SECRET', '<random>', 'Secret used for the access tokens'],
['APP_SECRET', '<random>', 'Secret used for encryption across the app'],
['ACCESS_TOKEN_EXPIRES_IN', '30m', 'Access token expiration time'],
['LOGIN_TOKEN_SECRET', '<random>', 'Secret used for the login tokens'],
['LOGIN_TOKEN_EXPIRES_IN', '15m', 'Login token expiration time'],
['REFRESH_TOKEN_SECRET', '<random>', 'Secret used for the refresh tokens'],
['REFRESH_TOKEN_EXPIRES_IN', '90d', 'Refresh token expiration time'],
['REFRESH_TOKEN_COOL_DOWN', '1m', 'Refresh token cooldown'],
['FILE_TOKEN_SECRET', '<random>', 'Secret used for the file tokens'],
['FILE_TOKEN_EXPIRES_IN', '1d', 'File token expiration time'],
['API_TOKEN_EXPIRES_IN', '1000y', 'API token expiration time'],
]}></ArticleTable>

View File

@ -103,7 +103,7 @@ The `yarn command:prod upgrade-0.31` takes care of the data migration of all wor
### Environment Variables
The following environment variables have been changed:
We have updated the way we handle the Redis connection.
- Removed: `REDIS_HOST`, `REDIS_PORT`, `REDIS_USERNAME`, `REDIS_PASSWORD`
- Added: `REDIS_URL`
@ -111,3 +111,10 @@ The following environment variables have been changed:
Update your `.env` file to use the new `REDIS_URL` variable instead of the individual Redis connection parameters.
<ArticleEditContent></ArticleEditContent>
We have also simplifed the way we handle the JWT tokens.
- Removed: `ACCESS_TOKEN_SECRET`, `LOGIN_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`, `FILE_TOKEN_SECRET`
- Added: `APP_SECRET`
Update your `.env` file to use the new `APP_SECRET` variable instead of the individual tokens secrets (you can use the same secret as before or generate a new random string)

View File

@ -18,13 +18,7 @@ services:
name: server
type: web
envVarKey: RENDER_EXTERNAL_URL
- key: ACCESS_TOKEN_SECRET
generateValue: true
- key: LOGIN_TOKEN_SECRET
generateValue: true
- key: REFRESH_TOKEN_SECRET
generateValue: true
- key: FILE_TOKEN_SECRET
- key: APP_SECRET
generateValue: true
- key: PG_DATABASE_HOST
fromService:
@ -55,13 +49,7 @@ services:
name: server
type: web
envVarKey: RENDER_EXTERNAL_URL
- key: ACCESS_TOKEN_SECRET
generateValue: true
- key: LOGIN_TOKEN_SECRET
generateValue: true
- key: REFRESH_TOKEN_SECRET
generateValue: true
- key: FILE_TOKEN_SECRET
- key: APP_SECRET
generateValue: true
- key: PG_DATABASE_HOST
fromService: