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