mirror of
https://github.com/lingble/twenty.git
synced 2025-10-29 20:02:29 +00: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:
@@ -41,10 +41,7 @@ jobs:
|
|||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
echo "Generating secrets..."
|
echo "Generating secrets..."
|
||||||
echo "# === Randomly generated secrets ===" >>.env
|
echo "# === Randomly generated secrets ===" >>.env
|
||||||
echo "ACCESS_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env
|
echo "APP_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 "POSTGRES_ADMIN_PASSWORD=$(openssl rand -base64 32)" >>.env
|
echo "POSTGRES_ADMIN_PASSWORD=$(openssl rand -base64 32)" >>.env
|
||||||
|
|
||||||
echo "Starting server..."
|
echo "Starting server..."
|
||||||
|
|||||||
@@ -91,10 +91,7 @@ fi
|
|||||||
|
|
||||||
# Generate random strings for secrets
|
# Generate random strings for secrets
|
||||||
echo "# === Randomly generated secrets ===" >>.env
|
echo "# === Randomly generated secrets ===" >>.env
|
||||||
echo "ACCESS_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env
|
echo "APP_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 "" >>.env
|
echo "" >>.env
|
||||||
echo "POSTGRES_ADMIN_PASSWORD=$(openssl rand -base64 32)" >>.env
|
echo "POSTGRES_ADMIN_PASSWORD=$(openssl rand -base64 32)" >>.env
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ REDIS_URL=redis://redis:6379
|
|||||||
SERVER_URL=http://localhost:3000
|
SERVER_URL=http://localhost:3000
|
||||||
|
|
||||||
# Use openssl rand -base64 32 for each secret
|
# Use openssl rand -base64 32 for each secret
|
||||||
# ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access
|
# APP_SECRET=replace_me_with_a_random_string
|
||||||
# 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
|
|
||||||
|
|
||||||
SIGN_IN_PREFILLED=true
|
SIGN_IN_PREFILLED=true
|
||||||
|
|
||||||
|
|||||||
@@ -35,10 +35,7 @@ services:
|
|||||||
STORAGE_S3_NAME: ${STORAGE_S3_NAME}
|
STORAGE_S3_NAME: ${STORAGE_S3_NAME}
|
||||||
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT}
|
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT}
|
||||||
|
|
||||||
ACCESS_TOKEN_SECRET: ${ACCESS_TOKEN_SECRET}
|
APP_SECRET: ${APP_SECRET}
|
||||||
LOGIN_TOKEN_SECRET: ${LOGIN_TOKEN_SECRET}
|
|
||||||
REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET}
|
|
||||||
FILE_TOKEN_SECRET: ${FILE_TOKEN_SECRET}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
change-vol-ownership:
|
change-vol-ownership:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
@@ -67,10 +64,7 @@ services:
|
|||||||
STORAGE_S3_NAME: ${STORAGE_S3_NAME}
|
STORAGE_S3_NAME: ${STORAGE_S3_NAME}
|
||||||
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT}
|
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT}
|
||||||
|
|
||||||
ACCESS_TOKEN_SECRET: ${ACCESS_TOKEN_SECRET}
|
APP_SECRET: ${APP_SECRET}
|
||||||
LOGIN_TOKEN_SECRET: ${LOGIN_TOKEN_SECRET}
|
|
||||||
REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET}
|
|
||||||
FILE_TOKEN_SECRET: ${FILE_TOKEN_SECRET}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -55,26 +55,11 @@ spec:
|
|||||||
value: "7d"
|
value: "7d"
|
||||||
- name: "LOGIN_TOKEN_EXPIRES_IN"
|
- name: "LOGIN_TOKEN_EXPIRES_IN"
|
||||||
value: "1h"
|
value: "1h"
|
||||||
- name: ACCESS_TOKEN_SECRET
|
- name: APP_SECRET
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: tokens
|
name: tokens
|
||||||
key: accessToken
|
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:
|
ports:
|
||||||
- containerPort: 3000
|
- containerPort: 3000
|
||||||
name: http-tcp
|
name: http-tcp
|
||||||
|
|||||||
@@ -42,26 +42,11 @@ spec:
|
|||||||
value: "redis"
|
value: "redis"
|
||||||
- name: "REDIS_URL"
|
- name: "REDIS_URL"
|
||||||
value: "redis://twentycrm-redis.twentycrm.svc.cluster.local:6379"
|
value: "redis://twentycrm-redis.twentycrm.svc.cluster.local:6379"
|
||||||
- name: ACCESS_TOKEN_SECRET
|
- name: APP_SECRET
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: tokens
|
name: tokens
|
||||||
key: accessToken
|
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:
|
command:
|
||||||
- yarn
|
- yarn
|
||||||
- worker:prod
|
- worker:prod
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ resource "kubernetes_deployment" "twentycrm_server" {
|
|||||||
value = "1h"
|
value = "1h"
|
||||||
}
|
}
|
||||||
env {
|
env {
|
||||||
name = "ACCESS_TOKEN_SECRET"
|
name = "APP_SECRET"
|
||||||
value_from {
|
value_from {
|
||||||
secret_key_ref {
|
secret_key_ref {
|
||||||
name = "tokens"
|
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 {
|
port {
|
||||||
container_port = 3000
|
container_port = 3000
|
||||||
protocol = "TCP"
|
protocol = "TCP"
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ resource "kubernetes_deployment" "twentycrm_worker" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
env {
|
env {
|
||||||
name = "ACCESS_TOKEN_SECRET"
|
name = "APP_SECRET"
|
||||||
value_from {
|
value_from {
|
||||||
secret_key_ref {
|
secret_key_ref {
|
||||||
name = "tokens"
|
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 {
|
resources {
|
||||||
requests = {
|
requests = {
|
||||||
cpu = "250m"
|
cpu = "250m"
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
# Use this for local setup
|
# Use this for local setup
|
||||||
PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default
|
PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
FRONT_BASE_URL=http://localhost:3001
|
FRONT_BASE_URL=http://localhost:3001
|
||||||
|
|
||||||
ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access
|
APP_SECRET=replace_me_with_a_random_string
|
||||||
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
|
|
||||||
SIGN_IN_PREFILLED=true
|
SIGN_IN_PREFILLED=true
|
||||||
REDIS_URL=redis://localhost:6379
|
|
||||||
|
|
||||||
|
|
||||||
# ———————— Optional ————————
|
# ———————— Optional ————————
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/test
|
PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/test
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
DEBUG_MODE=true
|
DEBUG_MODE=true
|
||||||
DEBUG_PORT=9000
|
DEBUG_PORT=9000
|
||||||
FRONT_BASE_URL=http://localhost:3001
|
FRONT_BASE_URL=http://localhost:3001
|
||||||
ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access
|
APP_SECRET=replace_me_with_a_random_string
|
||||||
LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login
|
|
||||||
REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh
|
|
||||||
SIGN_IN_PREFILLED=true
|
SIGN_IN_PREFILLED=true
|
||||||
EXCEPTION_HANDLER_DRIVER=console
|
EXCEPTION_HANDLER_DRIVER=console
|
||||||
SENTRY_DSN=https://ba869cb8fd72d5faeb6643560939cee0@o4505516959793152.ingest.sentry.io/4506660900306944
|
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
|
MUTATION_MAXIMUM_RECORD_AFFECTED=100
|
||||||
MESSAGE_QUEUE_TYPE=bull-mq
|
MESSAGE_QUEUE_TYPE=bull-mq
|
||||||
CACHE_STORAGE_TYPE=redis
|
CACHE_STORAGE_TYPE=redis
|
||||||
REDIS_URL=redis://localhost:6379
|
|
||||||
|
|
||||||
AUTH_GOOGLE_ENABLED=false
|
AUTH_GOOGLE_ENABLED=false
|
||||||
MESSAGING_PROVIDER_GMAIL_ENABLED=false
|
MESSAGING_PROVIDER_GMAIL_ENABLED=false
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const jestConfig: JestConfigWithTsJest = {
|
|||||||
globals: {
|
globals: {
|
||||||
APP_PORT: 4000,
|
APP_PORT: 4000,
|
||||||
ACCESS_TOKEN:
|
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 { useThrottler } from 'src/engine/api/graphql/graphql-config/hooks/use-throttler';
|
||||||
import { WorkspaceSchemaFactory } from 'src/engine/api/graphql/workspace-schema.factory';
|
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 { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||||
import { CoreEngineModule } from 'src/engine/core-modules/core-engine.module';
|
import { CoreEngineModule } from 'src/engine/core-modules/core-engine.module';
|
||||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||||
@@ -36,7 +35,6 @@ export class GraphQLConfigService
|
|||||||
implements GqlOptionsFactory<YogaDriverConfig<'express'>>
|
implements GqlOptionsFactory<YogaDriverConfig<'express'>>
|
||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
private readonly tokenService: TokenService,
|
|
||||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
private readonly moduleRef: ModuleRef,
|
private readonly moduleRef: ModuleRef,
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ export class ActivityQueryResultGetterHandler
|
|||||||
imageUrl.searchParams.delete('token');
|
imageUrl.searchParams.delete('token');
|
||||||
|
|
||||||
const signedPayload = await this.fileService.encodeFileToken({
|
const signedPayload = await this.fileService.encodeFileToken({
|
||||||
note_block_id: block.id,
|
noteBlockId: block.id,
|
||||||
workspace_id: workspaceId,
|
workspaceId: workspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export class AttachmentQueryResultGetterHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
const signedPayload = await this.fileService.encodeFileToken({
|
const signedPayload = await this.fileService.encodeFileToken({
|
||||||
attachment_id: attachment.id,
|
attachmentId: attachment.id,
|
||||||
workspace_id: workspaceId,
|
workspaceId: workspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export class PersonQueryResultGetterHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
const signedPayload = await this.fileService.encodeFileToken({
|
const signedPayload = await this.fileService.encodeFileToken({
|
||||||
person_id: person.id,
|
personId: person.id,
|
||||||
workspace_id: workspaceId,
|
workspaceId: workspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export class WorkspaceMemberQueryResultGetterHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
const signedPayload = await this.fileService.encodeFileToken({
|
const signedPayload = await this.fileService.encodeFileToken({
|
||||||
workspace_member_id: workspaceMember.id,
|
workspaceMemberId: workspaceMember.id,
|
||||||
workspace_id: workspaceId,
|
workspaceId: workspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
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 { 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 { 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 { 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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
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 getVariablesFactory: GetVariablesFactory,
|
||||||
private readonly findDuplicatesVariablesFactory: FindDuplicatesVariablesFactory,
|
private readonly findDuplicatesVariablesFactory: FindDuplicatesVariablesFactory,
|
||||||
private readonly objectMetadataService: ObjectMetadataService,
|
private readonly objectMetadataService: ObjectMetadataService,
|
||||||
private readonly tokenService: TokenService,
|
private readonly accessTokenService: AccessTokenService,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ export class CoreQueryBuilderFactory {
|
|||||||
objectMetadataItems: ObjectMetadataEntity[];
|
objectMetadataItems: ObjectMetadataEntity[];
|
||||||
objectMetadataItem: ObjectMetadataEntity;
|
objectMetadataItem: ObjectMetadataEntity;
|
||||||
}> {
|
}> {
|
||||||
const { workspace } = await this.tokenService.validateToken(request);
|
const { workspace } = await this.accessTokenService.validateToken(request);
|
||||||
|
|
||||||
const objectMetadataItems =
|
const objectMetadataItems =
|
||||||
await this.objectMetadataService.findManyWithinWorkspace(workspace.id);
|
await this.objectMetadataService.findManyWithinWorkspace(workspace.id);
|
||||||
|
|||||||
@@ -7,18 +7,18 @@ import {
|
|||||||
GraphqlApiType,
|
GraphqlApiType,
|
||||||
RestApiService,
|
RestApiService,
|
||||||
} from 'src/engine/api/rest/rest-api.service';
|
} 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()
|
@Injectable()
|
||||||
export class RestApiMetadataService {
|
export class RestApiMetadataService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly tokenService: TokenService,
|
private readonly accessTokenService: AccessTokenService,
|
||||||
private readonly metadataQueryBuilderFactory: MetadataQueryBuilderFactory,
|
private readonly metadataQueryBuilderFactory: MetadataQueryBuilderFactory,
|
||||||
private readonly restApiService: RestApiService,
|
private readonly restApiService: RestApiService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async get(request: Request) {
|
async get(request: Request) {
|
||||||
await this.tokenService.validateToken(request);
|
await this.accessTokenService.validateToken(request);
|
||||||
const data = await this.metadataQueryBuilderFactory.get(request);
|
const data = await this.metadataQueryBuilderFactory.get(request);
|
||||||
|
|
||||||
return await this.restApiService.call(
|
return await this.restApiService.call(
|
||||||
@@ -29,7 +29,7 @@ export class RestApiMetadataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(request: Request) {
|
async create(request: Request) {
|
||||||
await this.tokenService.validateToken(request);
|
await this.accessTokenService.validateToken(request);
|
||||||
const data = await this.metadataQueryBuilderFactory.create(request);
|
const data = await this.metadataQueryBuilderFactory.create(request);
|
||||||
|
|
||||||
return await this.restApiService.call(
|
return await this.restApiService.call(
|
||||||
@@ -40,7 +40,7 @@ export class RestApiMetadataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(request: Request) {
|
async update(request: Request) {
|
||||||
await this.tokenService.validateToken(request);
|
await this.accessTokenService.validateToken(request);
|
||||||
const data = await this.metadataQueryBuilderFactory.update(request);
|
const data = await this.metadataQueryBuilderFactory.update(request);
|
||||||
|
|
||||||
return await this.restApiService.call(
|
return await this.restApiService.call(
|
||||||
@@ -51,7 +51,7 @@ export class RestApiMetadataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async delete(request: Request) {
|
async delete(request: Request) {
|
||||||
await this.tokenService.validateToken(request);
|
await this.accessTokenService.validateToken(request);
|
||||||
const data = await this.metadataQueryBuilderFactory.delete(request);
|
const data = await this.metadataQueryBuilderFactory.delete(request);
|
||||||
|
|
||||||
return await this.restApiService.call(
|
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 { 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 { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/google-auth.controller';
|
||||||
import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-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 { 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 { 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 { 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 { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
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 { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
|
||||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.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 { 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 { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
|
||||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||||
import { UserModule } from 'src/engine/core-modules/user/user.module';
|
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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||||
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||||
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
|
||||||
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
|
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
|
||||||
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.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';
|
import { AuthResolver } from './auth.resolver';
|
||||||
|
|
||||||
@@ -83,10 +89,16 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
|||||||
JwtAuthStrategy,
|
JwtAuthStrategy,
|
||||||
SamlAuthStrategy,
|
SamlAuthStrategy,
|
||||||
AuthResolver,
|
AuthResolver,
|
||||||
TokenService,
|
|
||||||
GoogleAPIsService,
|
GoogleAPIsService,
|
||||||
AppTokenService,
|
AppTokenService,
|
||||||
|
AccessTokenService,
|
||||||
|
LoginTokenService,
|
||||||
|
ResetPasswordService,
|
||||||
|
SwitchWorkspaceService,
|
||||||
|
TransientTokenService,
|
||||||
|
ApiKeyService,
|
||||||
|
OAuthService,
|
||||||
],
|
],
|
||||||
exports: [TokenService],
|
exports: [AccessTokenService, LoginTokenService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -10,8 +10,14 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
|||||||
|
|
||||||
import { AuthResolver } from './auth.resolver';
|
import { AuthResolver } from './auth.resolver';
|
||||||
|
|
||||||
|
import { ApiKeyService } from './services/api-key.service';
|
||||||
import { AuthService } from './services/auth.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', () => {
|
describe('AuthResolver', () => {
|
||||||
let resolver: AuthResolver;
|
let resolver: AuthResolver;
|
||||||
@@ -33,10 +39,6 @@ describe('AuthResolver', () => {
|
|||||||
provide: AuthService,
|
provide: AuthService,
|
||||||
useValue: {},
|
useValue: {},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
provide: TokenService,
|
|
||||||
useValue: {},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
provide: UserService,
|
provide: UserService,
|
||||||
useValue: {},
|
useValue: {},
|
||||||
@@ -45,6 +47,34 @@ describe('AuthResolver', () => {
|
|||||||
provide: UserWorkspaceService,
|
provide: UserWorkspaceService,
|
||||||
useValue: {},
|
useValue: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: RenewTokenService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ApiKeyService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ResetPasswordService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: LoginTokenService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: SwitchWorkspaceService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: TransientTokenService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: OAuthService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.overrideGuard(CaptchaGuard)
|
.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 { 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 { 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 { 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 { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity';
|
||||||
import { TransientToken } from 'src/engine/core-modules/auth/dto/transient-token.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 { 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 { 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 { 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 { 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 { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
|
||||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
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 { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-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 { ChallengeInput } from './dto/challenge.input';
|
||||||
import { ImpersonateInput } from './dto/impersonate.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 { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity';
|
||||||
import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input';
|
import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input';
|
||||||
import { AuthService } from './services/auth.service';
|
import { AuthService } from './services/auth.service';
|
||||||
import { TokenService } from './token/services/token.service';
|
|
||||||
|
|
||||||
@Resolver()
|
@Resolver()
|
||||||
@UseFilters(AuthGraphqlApiExceptionFilter)
|
@UseFilters(AuthGraphqlApiExceptionFilter)
|
||||||
export class AuthResolver {
|
export class AuthResolver {
|
||||||
constructor(
|
constructor(
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
private tokenService: TokenService,
|
private renewTokenService: RenewTokenService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
|
private apiKeyService: ApiKeyService,
|
||||||
|
private resetPasswordService: ResetPasswordService,
|
||||||
|
private loginTokenService: LoginTokenService,
|
||||||
|
private switchWorkspaceService: SwitchWorkspaceService,
|
||||||
|
private transientTokenService: TransientTokenService,
|
||||||
|
private oauthService: OAuthService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@UseGuards(CaptchaGuard)
|
@UseGuards(CaptchaGuard)
|
||||||
@@ -87,7 +99,9 @@ export class AuthResolver {
|
|||||||
@Mutation(() => LoginToken)
|
@Mutation(() => LoginToken)
|
||||||
async challenge(@Args() challengeInput: ChallengeInput): Promise<LoginToken> {
|
async challenge(@Args() challengeInput: ChallengeInput): Promise<LoginToken> {
|
||||||
const user = await this.authService.challenge(challengeInput);
|
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 };
|
return { loginToken };
|
||||||
}
|
}
|
||||||
@@ -100,7 +114,9 @@ export class AuthResolver {
|
|||||||
fromSSO: false,
|
fromSSO: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const loginToken = await this.tokenService.generateLoginToken(user.email);
|
const loginToken = await this.loginTokenService.generateLoginToken(
|
||||||
|
user.email,
|
||||||
|
);
|
||||||
|
|
||||||
return { loginToken };
|
return { loginToken };
|
||||||
}
|
}
|
||||||
@@ -109,7 +125,7 @@ export class AuthResolver {
|
|||||||
async exchangeAuthorizationCode(
|
async exchangeAuthorizationCode(
|
||||||
@Args() exchangeAuthCodeInput: ExchangeAuthCodeInput,
|
@Args() exchangeAuthCodeInput: ExchangeAuthCodeInput,
|
||||||
) {
|
) {
|
||||||
const tokens = await this.tokenService.verifyAuthorizationCode(
|
const tokens = await this.oauthService.verifyAuthorizationCode(
|
||||||
exchangeAuthCodeInput,
|
exchangeAuthCodeInput,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -130,7 +146,8 @@ export class AuthResolver {
|
|||||||
if (!workspaceMember) {
|
if (!workspaceMember) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const transientToken = await this.tokenService.generateTransientToken(
|
const transientToken =
|
||||||
|
await this.transientTokenService.generateTransientToken(
|
||||||
workspaceMember.id,
|
workspaceMember.id,
|
||||||
user.id,
|
user.id,
|
||||||
user.defaultWorkspaceId,
|
user.defaultWorkspaceId,
|
||||||
@@ -141,7 +158,7 @@ export class AuthResolver {
|
|||||||
|
|
||||||
@Mutation(() => Verify)
|
@Mutation(() => Verify)
|
||||||
async verify(@Args() verifyInput: VerifyInput): Promise<Verify> {
|
async verify(@Args() verifyInput: VerifyInput): Promise<Verify> {
|
||||||
const email = await this.tokenService.verifyLoginToken(
|
const email = await this.loginTokenService.verifyLoginToken(
|
||||||
verifyInput.loginToken,
|
verifyInput.loginToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -170,7 +187,7 @@ export class AuthResolver {
|
|||||||
@AuthUser() user: User,
|
@AuthUser() user: User,
|
||||||
@Args() args: GenerateJwtInput,
|
@Args() args: GenerateJwtInput,
|
||||||
): Promise<GenerateJWTOutputWithAuthTokens | GenerateJWTOutputWithSSOAUTH> {
|
): Promise<GenerateJWTOutputWithAuthTokens | GenerateJWTOutputWithSSOAUTH> {
|
||||||
const result = await this.tokenService.switchWorkspace(
|
const result = await this.switchWorkspaceService.switchWorkspace(
|
||||||
user,
|
user,
|
||||||
args.workspaceId,
|
args.workspaceId,
|
||||||
);
|
);
|
||||||
@@ -194,7 +211,8 @@ export class AuthResolver {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
reason: 'WORKSPACE_AVAILABLE_FOR_SWITCH',
|
reason: 'WORKSPACE_AVAILABLE_FOR_SWITCH',
|
||||||
authTokens: await this.tokenService.generateSwitchWorkspaceToken(
|
authTokens:
|
||||||
|
await this.switchWorkspaceService.generateSwitchWorkspaceToken(
|
||||||
user,
|
user,
|
||||||
result.workspace,
|
result.workspace,
|
||||||
),
|
),
|
||||||
@@ -203,7 +221,7 @@ export class AuthResolver {
|
|||||||
|
|
||||||
@Mutation(() => AuthTokens)
|
@Mutation(() => AuthTokens)
|
||||||
async renewToken(@Args() args: AppTokenInput): Promise<AuthTokens> {
|
async renewToken(@Args() args: AppTokenInput): Promise<AuthTokens> {
|
||||||
const tokens = await this.tokenService.generateTokensFromRefreshToken(
|
const tokens = await this.renewTokenService.generateTokensFromRefreshToken(
|
||||||
args.appToken,
|
args.appToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -225,7 +243,7 @@ export class AuthResolver {
|
|||||||
@Args() args: ApiKeyTokenInput,
|
@Args() args: ApiKeyTokenInput,
|
||||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||||
): Promise<ApiKeyToken | undefined> {
|
): Promise<ApiKeyToken | undefined> {
|
||||||
return await this.tokenService.generateApiKeyToken(
|
return await this.apiKeyService.generateApiKeyToken(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
args.apiKeyId,
|
args.apiKeyId,
|
||||||
args.expiresAt,
|
args.expiresAt,
|
||||||
@@ -236,11 +254,12 @@ export class AuthResolver {
|
|||||||
async emailPasswordResetLink(
|
async emailPasswordResetLink(
|
||||||
@Args() emailPasswordResetInput: EmailPasswordResetLinkInput,
|
@Args() emailPasswordResetInput: EmailPasswordResetLinkInput,
|
||||||
): Promise<EmailPasswordResetLink> {
|
): Promise<EmailPasswordResetLink> {
|
||||||
const resetToken = await this.tokenService.generatePasswordResetToken(
|
const resetToken =
|
||||||
|
await this.resetPasswordService.generatePasswordResetToken(
|
||||||
emailPasswordResetInput.email,
|
emailPasswordResetInput.email,
|
||||||
);
|
);
|
||||||
|
|
||||||
return await this.tokenService.sendEmailPasswordResetLink(
|
return await this.resetPasswordService.sendEmailPasswordResetLink(
|
||||||
resetToken,
|
resetToken,
|
||||||
emailPasswordResetInput.email,
|
emailPasswordResetInput.email,
|
||||||
);
|
);
|
||||||
@@ -252,18 +271,20 @@ export class AuthResolver {
|
|||||||
{ passwordResetToken, newPassword }: UpdatePasswordViaResetTokenInput,
|
{ passwordResetToken, newPassword }: UpdatePasswordViaResetTokenInput,
|
||||||
): Promise<InvalidatePassword> {
|
): Promise<InvalidatePassword> {
|
||||||
const { id } =
|
const { id } =
|
||||||
await this.tokenService.validatePasswordResetToken(passwordResetToken);
|
await this.resetPasswordService.validatePasswordResetToken(
|
||||||
|
passwordResetToken,
|
||||||
|
);
|
||||||
|
|
||||||
await this.authService.updatePassword(id, newPassword);
|
await this.authService.updatePassword(id, newPassword);
|
||||||
|
|
||||||
return await this.tokenService.invalidatePasswordResetToken(id);
|
return await this.resetPasswordService.invalidatePasswordResetToken(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Query(() => ValidatePasswordResetToken)
|
@Query(() => ValidatePasswordResetToken)
|
||||||
async validatePasswordResetToken(
|
async validatePasswordResetToken(
|
||||||
@Args() args: ValidatePasswordResetTokenInput,
|
@Args() args: ValidatePasswordResetTokenInput,
|
||||||
): Promise<ValidatePasswordResetToken> {
|
): Promise<ValidatePasswordResetToken> {
|
||||||
return this.tokenService.validatePasswordResetToken(
|
return this.resetPasswordService.validatePasswordResetToken(
|
||||||
args.passwordResetToken,
|
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 { 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 { 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 { 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 { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type';
|
||||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.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 {
|
export class GoogleAPIsAuthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly googleAPIsService: GoogleAPIsService,
|
private readonly googleAPIsService: GoogleAPIsService,
|
||||||
private readonly tokenService: TokenService,
|
private readonly transientTokenService: TransientTokenService,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
private readonly onboardingService: OnboardingService,
|
private readonly onboardingService: OnboardingService,
|
||||||
) {}
|
) {}
|
||||||
@@ -58,7 +58,7 @@ export class GoogleAPIsAuthController {
|
|||||||
} = user;
|
} = user;
|
||||||
|
|
||||||
const { workspaceMemberId, userId, workspaceId } =
|
const { workspaceMemberId, userId, workspaceId } =
|
||||||
await this.tokenService.verifyTransientToken(transientToken);
|
await this.transientTokenService.verifyTransientToken(transientToken);
|
||||||
|
|
||||||
const demoWorkspaceIds = this.environmentService.get('DEMO_WORKSPACE_IDS');
|
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 { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/google-provider-enabled.guard';
|
||||||
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
||||||
import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
|
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')
|
@Controller('auth/google')
|
||||||
@UseFilters(AuthRestApiExceptionFilter)
|
@UseFilters(AuthRestApiExceptionFilter)
|
||||||
export class GoogleAuthController {
|
export class GoogleAuthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly tokenService: TokenService,
|
private readonly loginTokenService: LoginTokenService,
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -55,8 +55,10 @@ export class GoogleAuthController {
|
|||||||
fromSSO: true,
|
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 { 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 { 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 { 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 { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/microsoft-provider-enabled.guard';
|
||||||
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
||||||
import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy';
|
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')
|
@Controller('auth/microsoft')
|
||||||
@UseFilters(AuthRestApiExceptionFilter)
|
@UseFilters(AuthRestApiExceptionFilter)
|
||||||
export class MicrosoftAuthController {
|
export class MicrosoftAuthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly tokenService: TokenService,
|
private readonly loginTokenService: LoginTokenService,
|
||||||
private readonly typeORMService: TypeORMService,
|
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -58,8 +56,10 @@ export class MicrosoftAuthController {
|
|||||||
fromSSO: true,
|
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 { 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 { SSOProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/sso-provider-enabled.guard';
|
||||||
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||||
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
|
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
|
||||||
import {
|
import {
|
||||||
@@ -38,7 +38,7 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in
|
|||||||
@UseFilters(AuthRestApiExceptionFilter)
|
@UseFilters(AuthRestApiExceptionFilter)
|
||||||
export class SSOAuthController {
|
export class SSOAuthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly tokenService: TokenService,
|
private readonly loginTokenService: LoginTokenService,
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
private readonly workspaceInvitationService: WorkspaceInvitationService,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
@@ -84,7 +84,7 @@ export class SSOAuthController {
|
|||||||
const loginToken = await this.generateLoginToken(req.user);
|
const loginToken = await this.generateLoginToken(req.user);
|
||||||
|
|
||||||
return res.redirect(
|
return res.redirect(
|
||||||
this.tokenService.computeRedirectURI(loginToken.token),
|
this.authService.computeRedirectURI(loginToken.token),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// TODO: improve error management
|
// TODO: improve error management
|
||||||
@@ -99,7 +99,7 @@ export class SSOAuthController {
|
|||||||
const loginToken = await this.generateLoginToken(req.user);
|
const loginToken = await this.generateLoginToken(req.user);
|
||||||
|
|
||||||
return res.redirect(
|
return res.redirect(
|
||||||
this.tokenService.computeRedirectURI(loginToken.token),
|
this.authService.computeRedirectURI(loginToken.token),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// TODO: improve error management
|
// 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 { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
|
||||||
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
|
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';
|
import { VerifyAuthController } from './verify-auth.controller';
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ describe('VerifyAuthController', () => {
|
|||||||
useValue: {},
|
useValue: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: TokenService,
|
provide: LoginTokenService,
|
||||||
useValue: {},
|
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 { 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 { 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 { 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')
|
@Controller('auth/verify')
|
||||||
@UseFilters(AuthRestApiExceptionFilter)
|
@UseFilters(AuthRestApiExceptionFilter)
|
||||||
export class VerifyAuthController {
|
export class VerifyAuthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly tokenService: TokenService,
|
private readonly loginTokenService: LoginTokenService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async verify(@Body() verifyInput: VerifyInput): Promise<Verify> {
|
async verify(@Body() verifyInput: VerifyInput): Promise<Verify> {
|
||||||
const email = await this.tokenService.verifyLoginToken(
|
const email = await this.loginTokenService.verifyLoginToken(
|
||||||
verifyInput.loginToken,
|
verifyInput.loginToken,
|
||||||
);
|
);
|
||||||
const result = await this.authService.verify(email);
|
const result = await this.authService.verify(email);
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import {
|
|||||||
AuthExceptionCode,
|
AuthExceptionCode,
|
||||||
} from 'src/engine/core-modules/auth/auth.exception';
|
} 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 { 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 { 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 { 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 { 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()
|
@Injectable()
|
||||||
export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
|
export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
|
||||||
@@ -19,7 +19,7 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
private readonly featureFlagService: FeatureFlagService,
|
private readonly featureFlagService: FeatureFlagService,
|
||||||
private readonly tokenService: TokenService,
|
private readonly transientTokenService: TransientTokenService,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,8 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
|
|||||||
async canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext) {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const state = JSON.parse(request.query.state);
|
const state = JSON.parse(request.query.state);
|
||||||
const { workspaceId } = await this.tokenService.verifyTransientToken(
|
const { workspaceId } =
|
||||||
|
await this.transientTokenService.verifyTransientToken(
|
||||||
state.transientToken,
|
state.transientToken,
|
||||||
);
|
);
|
||||||
const isGmailSendEmailScopeEnabled =
|
const isGmailSendEmailScopeEnabled =
|
||||||
|
|||||||
@@ -6,18 +6,18 @@ import {
|
|||||||
AuthExceptionCode,
|
AuthExceptionCode,
|
||||||
} from 'src/engine/core-modules/auth/auth.exception';
|
} 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 { 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 { 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 { 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 { 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()
|
@Injectable()
|
||||||
export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
|
export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
private readonly featureFlagService: FeatureFlagService,
|
private readonly featureFlagService: FeatureFlagService,
|
||||||
private readonly tokenService: TokenService,
|
private readonly transientTokenService: TransientTokenService,
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
prompt: 'select_account',
|
prompt: 'select_account',
|
||||||
@@ -27,7 +27,8 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
|
|||||||
async canActivate(context: ExecutionContext) {
|
async canActivate(context: ExecutionContext) {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
const { workspaceId } = await this.tokenService.verifyTransientToken(
|
const { workspaceId } =
|
||||||
|
await this.transientTokenService.verifyTransientToken(
|
||||||
request.query.transientToken,
|
request.query.transientToken,
|
||||||
);
|
);
|
||||||
const isGmailSendEmailScopeEnabled =
|
const 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 { 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 { 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 { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.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 { User } from 'src/engine/core-modules/user/user.entity';
|
||||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.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';
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
@@ -20,22 +19,6 @@ describe('AuthService', () => {
|
|||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
AuthService,
|
AuthService,
|
||||||
{
|
|
||||||
provide: TokenService,
|
|
||||||
useValue: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: UserService,
|
|
||||||
useValue: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: SignInUpService,
|
|
||||||
useValue: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: WorkspaceManagerService,
|
|
||||||
useValue: {},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
provide: getRepositoryToken(Workspace, 'core'),
|
provide: getRepositoryToken(Workspace, 'core'),
|
||||||
useValue: {},
|
useValue: {},
|
||||||
@@ -48,6 +31,10 @@ describe('AuthService', () => {
|
|||||||
provide: getRepositoryToken(AppToken, 'core'),
|
provide: getRepositoryToken(AppToken, 'core'),
|
||||||
useValue: {},
|
useValue: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: SignInUpService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: EnvironmentService,
|
provide: EnvironmentService,
|
||||||
useValue: {},
|
useValue: {},
|
||||||
@@ -56,6 +43,14 @@ describe('AuthService', () => {
|
|||||||
provide: EmailService,
|
provide: EmailService,
|
||||||
useValue: {},
|
useValue: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: AccessTokenService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: RefreshTokenService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).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 { 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 { 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 { 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 { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
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()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly tokenService: TokenService,
|
private readonly accessTokenService: AccessTokenService,
|
||||||
|
private readonly refreshTokenService: RefreshTokenService,
|
||||||
private readonly signInUpService: SignInUpService,
|
private readonly signInUpService: SignInUpService,
|
||||||
@InjectRepository(Workspace, 'core')
|
@InjectRepository(Workspace, 'core')
|
||||||
private readonly workspaceRepository: Repository<Workspace>,
|
private readonly workspaceRepository: Repository<Workspace>,
|
||||||
@@ -150,8 +152,14 @@ export class AuthService {
|
|||||||
// passwordHash is hidden for security reasons
|
// passwordHash is hidden for security reasons
|
||||||
user.passwordHash = '';
|
user.passwordHash = '';
|
||||||
|
|
||||||
const accessToken = await this.tokenService.generateAccessToken(user.id);
|
const accessToken = await this.accessTokenService.generateAccessToken(
|
||||||
const refreshToken = await this.tokenService.generateRefreshToken(user.id);
|
user.id,
|
||||||
|
user.defaultWorkspaceId,
|
||||||
|
);
|
||||||
|
const refreshToken = await this.refreshTokenService.generateRefreshToken(
|
||||||
|
user.id,
|
||||||
|
user.defaultWorkspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
@@ -209,8 +217,14 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = await this.tokenService.generateAccessToken(user.id);
|
const accessToken = await this.accessTokenService.generateAccessToken(
|
||||||
const refreshToken = await this.tokenService.generateRefreshToken(user.id);
|
user.id,
|
||||||
|
user.defaultWorkspaceId,
|
||||||
|
);
|
||||||
|
const refreshToken = await this.refreshTokenService.generateRefreshToken(
|
||||||
|
user.id,
|
||||||
|
user.defaultWorkspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
@@ -384,4 +398,10 @@ export class AuthService {
|
|||||||
|
|
||||||
return workspace;
|
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';
|
} from 'src/engine/core-modules/auth/auth.exception';
|
||||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.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 { User } from 'src/engine/core-modules/user/user.entity';
|
||||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
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') {
|
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
|
private readonly jwtWrapperService: JwtWrapperService,
|
||||||
private readonly typeORMService: TypeORMService,
|
private readonly typeORMService: TypeORMService,
|
||||||
private readonly dataSourceService: DataSourceService,
|
private readonly dataSourceService: DataSourceService,
|
||||||
@InjectRepository(Workspace, 'core')
|
@InjectRepository(Workspace, 'core')
|
||||||
@@ -38,7 +40,22 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
ignoreExpiration: false,
|
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 { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
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 { 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 { EmailModule } from 'src/engine/core-modules/email/email.module';
|
||||||
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.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 { User } from 'src/engine/core-modules/user/user.entity';
|
||||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||||
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -22,7 +25,18 @@ import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
|
|||||||
EmailModule,
|
EmailModule,
|
||||||
WorkspaceSSOModule,
|
WorkspaceSSOModule,
|
||||||
],
|
],
|
||||||
providers: [TokenService, JwtAuthStrategy],
|
providers: [
|
||||||
exports: [TokenService],
|
RenewTokenService,
|
||||||
|
JwtAuthStrategy,
|
||||||
|
AccessTokenService,
|
||||||
|
LoginTokenService,
|
||||||
|
RefreshTokenService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
RenewTokenService,
|
||||||
|
AccessTokenService,
|
||||||
|
LoginTokenService,
|
||||||
|
RefreshTokenService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class TokenModule {}
|
export class TokenModule {}
|
||||||
|
|||||||
@@ -134,18 +134,13 @@ export class EnvironmentVariables {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
SERVER_URL: string;
|
SERVER_URL: string;
|
||||||
|
|
||||||
// Json Web Token
|
|
||||||
@IsString()
|
@IsString()
|
||||||
ACCESS_TOKEN_SECRET: string;
|
APP_SECRET: string;
|
||||||
|
|
||||||
@IsDuration()
|
@IsDuration()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
ACCESS_TOKEN_EXPIRES_IN = '30m';
|
ACCESS_TOKEN_EXPIRES_IN = '30m';
|
||||||
|
|
||||||
@IsString()
|
|
||||||
REFRESH_TOKEN_SECRET: string;
|
|
||||||
|
|
||||||
@IsDuration()
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
REFRESH_TOKEN_EXPIRES_IN = '60d';
|
REFRESH_TOKEN_EXPIRES_IN = '60d';
|
||||||
|
|
||||||
@@ -153,17 +148,10 @@ export class EnvironmentVariables {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
REFRESH_TOKEN_COOL_DOWN = '1m';
|
REFRESH_TOKEN_COOL_DOWN = '1m';
|
||||||
|
|
||||||
@IsString()
|
|
||||||
LOGIN_TOKEN_SECRET = '30m';
|
|
||||||
|
|
||||||
@IsDuration()
|
@IsDuration()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
LOGIN_TOKEN_EXPIRES_IN = '15m';
|
LOGIN_TOKEN_EXPIRES_IN = '15m';
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
FILE_TOKEN_SECRET = 'random_string';
|
|
||||||
|
|
||||||
@IsDuration()
|
@IsDuration()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
FILE_TOKEN_EXPIRES_IN = '1d';
|
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 { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
|
||||||
|
|
||||||
import { settings } from 'src/engine/constants/settings';
|
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 { 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';
|
import { getCropSize } from 'src/utils/image';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -83,7 +83,7 @@ export class FileUploadService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const signedPayload = await this.fileService.encodeFileToken({
|
const signedPayload = await this.fileService.encodeFileToken({
|
||||||
workspace_id: workspaceId,
|
workspaceId: workspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,30 +7,34 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
|
||||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FilePathGuard implements CanActivate {
|
export class FilePathGuard implements CanActivate {
|
||||||
constructor(
|
constructor(private readonly jwtWrapperService: JwtWrapperService) {}
|
||||||
private readonly jwtWrapperService: JwtWrapperService,
|
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
const query = request.query;
|
const query = request.query;
|
||||||
|
|
||||||
if (query && query['token']) {
|
if (!query || !query['token']) {
|
||||||
const payloadToDecode = query['token'];
|
return false;
|
||||||
const decodedPayload = await this.jwtWrapperService.decode(
|
}
|
||||||
payloadToDecode,
|
|
||||||
{
|
const payload = await this.jwtWrapperService.verifyWorkspaceToken(
|
||||||
secret: this.environmentService.get('FILE_TOKEN_SECRET'),
|
query['token'],
|
||||||
} as any,
|
'FILE',
|
||||||
);
|
);
|
||||||
|
|
||||||
const expirationDate = decodedPayload?.['expiration_date'];
|
if (!payload.workspaceId) {
|
||||||
const workspaceId = decodedPayload?.['workspace_id'];
|
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);
|
const isExpired = await this.isExpired(expirationDate);
|
||||||
|
|
||||||
@@ -39,7 +43,6 @@ export class FilePathGuard implements CanActivate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
request.workspaceId = workspaceId;
|
request.workspaceId = workspaceId;
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { Stream } from 'stream';
|
|||||||
import { addMilliseconds } from 'date-fns';
|
import { addMilliseconds } from 'date-fns';
|
||||||
import ms from 'ms';
|
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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.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()
|
@Injectable()
|
||||||
export class FileService {
|
export class FileService {
|
||||||
@@ -34,13 +34,16 @@ export class FileService {
|
|||||||
const fileTokenExpiresIn = this.environmentService.get(
|
const fileTokenExpiresIn = this.environmentService.get(
|
||||||
'FILE_TOKEN_EXPIRES_IN',
|
'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 expirationDate = addMilliseconds(new Date(), ms(fileTokenExpiresIn));
|
||||||
|
|
||||||
const signedPayload = this.jwtWrapperService.sign(
|
const signedPayload = this.jwtWrapperService.sign(
|
||||||
{
|
{
|
||||||
expiration_date: expirationDate,
|
expirationDate: expirationDate,
|
||||||
...payloadToEncode,
|
...payloadToEncode,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule as NestJwtModule } from '@nestjs/jwt';
|
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 { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
|
||||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
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({
|
const InternalJwtModule = NestJwtModule.registerAsync({
|
||||||
useFactory: async (environmentService: EnvironmentService) => {
|
useFactory: async (environmentService: EnvironmentService) => {
|
||||||
return {
|
return {
|
||||||
secret: environmentService.get('ACCESS_TOKEN_SECRET'),
|
secret: environmentService.get('APP_SECRET'),
|
||||||
signOptions: {
|
signOptions: {
|
||||||
expiresIn: environmentService.get('ACCESS_TOKEN_EXPIRES_IN'),
|
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 { JwtService, JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
|
||||||
|
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
import * as jwt from 'jsonwebtoken';
|
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()
|
@Injectable()
|
||||||
export class JwtWrapperService {
|
export class JwtWrapperService {
|
||||||
constructor(private readonly jwtService: JwtService) {}
|
constructor(
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly environmentService: EnvironmentService,
|
||||||
|
) {}
|
||||||
|
|
||||||
sign(payload: string | object, options?: JwtSignOptions): string {
|
sign(payload: string | object, options?: JwtSignOptions): string {
|
||||||
// Typescript does not handle well the overloads of the sign method, helping it a little bit
|
// 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);
|
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);
|
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 { 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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||||
import { OpenApiService } from 'src/engine/core-modules/open-api/open-api.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';
|
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||||
@@ -13,7 +13,7 @@ describe('OpenApiService', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
OpenApiService,
|
OpenApiService,
|
||||||
{
|
{
|
||||||
provide: TokenService,
|
provide: AccessTokenService,
|
||||||
useValue: {},
|
useValue: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { OpenAPIV3_1 } from 'openapi-types';
|
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 { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||||
import { baseSchema } from 'src/engine/core-modules/open-api/utils/base-schema.utils';
|
import { baseSchema } from 'src/engine/core-modules/open-api/utils/base-schema.utils';
|
||||||
import {
|
import {
|
||||||
@@ -41,7 +41,7 @@ import { getServerUrl } from 'src/utils/get-server-url';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class OpenApiService {
|
export class OpenApiService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly tokenService: TokenService,
|
private readonly accessTokenService: AccessTokenService,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
private readonly objectMetadataService: ObjectMetadataService,
|
private readonly objectMetadataService: ObjectMetadataService,
|
||||||
) {}
|
) {}
|
||||||
@@ -57,7 +57,8 @@ export class OpenApiService {
|
|||||||
let objectMetadataItems;
|
let objectMetadataItems;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { workspace } = await this.tokenService.validateToken(request);
|
const { workspace } =
|
||||||
|
await this.accessTokenService.validateToken(request);
|
||||||
|
|
||||||
objectMetadataItems =
|
objectMetadataItems =
|
||||||
await this.objectMetadataService.findManyWithinWorkspace(workspace.id);
|
await this.objectMetadataService.findManyWithinWorkspace(workspace.id);
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
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 { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
|
||||||
import { PostgresCredentialsResolver } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.resolver';
|
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 { PostgresCredentialsService } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.service';
|
||||||
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [JwtModule, TypeOrmModule.forFeature([PostgresCredentials], 'core')],
|
||||||
TypeOrmModule.forFeature([PostgresCredentials], 'core'),
|
|
||||||
EnvironmentModule,
|
|
||||||
],
|
|
||||||
providers: [
|
providers: [
|
||||||
PostgresCredentialsResolver,
|
PostgresCredentialsResolver,
|
||||||
PostgresCredentialsService,
|
PostgresCredentialsService,
|
||||||
|
|||||||
@@ -10,15 +10,15 @@ import {
|
|||||||
encryptText,
|
encryptText,
|
||||||
} from 'src/engine/core-modules/auth/auth.util';
|
} from 'src/engine/core-modules/auth/auth.util';
|
||||||
import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.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 { 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 { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
|
||||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
|
||||||
|
|
||||||
export class PostgresCredentialsService {
|
export class PostgresCredentialsService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(PostgresCredentials, 'core')
|
@InjectRepository(PostgresCredentials, 'core')
|
||||||
private readonly postgresCredentialsRepository: Repository<PostgresCredentials>,
|
private readonly postgresCredentialsRepository: Repository<PostgresCredentials>,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly jwtWrapperService: JwtWrapperService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async enablePostgresProxy(
|
async enablePostgresProxy(
|
||||||
@@ -27,7 +27,10 @@ export class PostgresCredentialsService {
|
|||||||
const user = `user_${randomBytes(4).toString('hex')}`;
|
const user = `user_${randomBytes(4).toString('hex')}`;
|
||||||
const password = randomBytes(16).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 passwordHash = encryptText(password, key);
|
||||||
|
|
||||||
const existingCredentials =
|
const existingCredentials =
|
||||||
@@ -81,7 +84,10 @@ export class PostgresCredentialsService {
|
|||||||
id: postgresCredentials.id,
|
id: postgresCredentials.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const key = this.environmentService.get('LOGIN_TOKEN_SECRET');
|
const key = this.jwtWrapperService.generateAppSecret(
|
||||||
|
'POSTGRES_PROXY',
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: postgresCredentials.id,
|
id: postgresCredentials.id,
|
||||||
@@ -105,7 +111,10 @@ export class PostgresCredentialsService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = this.environmentService.get('LOGIN_TOKEN_SECRET');
|
const key = this.jwtWrapperService.generateAppSecret(
|
||||||
|
'POSTGRES_PROXY',
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: postgresCredentials.id,
|
id: postgresCredentials.id,
|
||||||
|
|||||||
@@ -111,8 +111,8 @@ export class UserResolver {
|
|||||||
|
|
||||||
if (workspaceMember && workspaceMember.avatarUrl) {
|
if (workspaceMember && workspaceMember.avatarUrl) {
|
||||||
const avatarUrlToken = await this.fileService.encodeFileToken({
|
const avatarUrlToken = await this.fileService.encodeFileToken({
|
||||||
workspace_member_id: workspaceMember.id,
|
workspaceMemberId: workspaceMember.id,
|
||||||
workspace_id: user.defaultWorkspaceId,
|
workspaceId: user.defaultWorkspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`;
|
workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`;
|
||||||
@@ -133,8 +133,8 @@ export class UserResolver {
|
|||||||
for (const workspaceMember of workspaceMembers) {
|
for (const workspaceMember of workspaceMembers) {
|
||||||
if (workspaceMember.avatarUrl) {
|
if (workspaceMember.avatarUrl) {
|
||||||
const avatarUrlToken = await this.fileService.encodeFileToken({
|
const avatarUrlToken = await this.fileService.encodeFileToken({
|
||||||
workspace_member_id: workspaceMember.id,
|
workspaceMemberId: workspaceMember.id,
|
||||||
workspace_id: user.defaultWorkspaceId,
|
workspaceId: user.defaultWorkspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`;
|
workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`;
|
||||||
@@ -190,7 +190,7 @@ export class UserResolver {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fileToken = await this.fileService.encodeFileToken({
|
const fileToken = await this.fileService.encodeFileToken({
|
||||||
workspace_id: workspaceId,
|
workspaceId: workspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return `${paths[0]}?token=${fileToken}`;
|
return `${paths[0]}?token=${fileToken}`;
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
|
import { Repository } from 'typeorm';
|
||||||
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
|
|
||||||
|
import {
|
||||||
|
AppToken,
|
||||||
|
AppTokenType,
|
||||||
|
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||||
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
import { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
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';
|
import { WorkspaceInvitationService } from './workspace-invitation.service';
|
||||||
|
|
||||||
describe('WorkspaceInvitationService', () => {
|
describe('WorkspaceInvitationService', () => {
|
||||||
let service: WorkspaceInvitationService;
|
let service: WorkspaceInvitationService;
|
||||||
|
let appTokenRepository: Repository<AppToken>;
|
||||||
|
let userWorkspaceRepository: Repository<UserWorkspace>;
|
||||||
|
let environmentService: EnvironmentService;
|
||||||
|
let emailService: EmailService;
|
||||||
|
let onboardingService: OnboardingService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@@ -19,27 +31,29 @@ describe('WorkspaceInvitationService', () => {
|
|||||||
WorkspaceInvitationService,
|
WorkspaceInvitationService,
|
||||||
{
|
{
|
||||||
provide: getRepositoryToken(AppToken, 'core'),
|
provide: getRepositoryToken(AppToken, 'core'),
|
||||||
useValue: {},
|
useClass: Repository,
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: EnvironmentService,
|
|
||||||
useValue: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: EmailService,
|
|
||||||
useValue: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: TokenService,
|
|
||||||
useValue: {},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: getRepositoryToken(UserWorkspace, 'core'),
|
provide: getRepositoryToken(UserWorkspace, 'core'),
|
||||||
useValue: {},
|
useClass: Repository,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: EnvironmentService,
|
||||||
|
useValue: {
|
||||||
|
get: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: EmailService,
|
||||||
|
useValue: {
|
||||||
|
send: jest.fn(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: OnboardingService,
|
provide: OnboardingService,
|
||||||
useValue: {},
|
useValue: {
|
||||||
|
setOnboardingInviteTeamPending: jest.fn(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
@@ -47,9 +61,96 @@ describe('WorkspaceInvitationService', () => {
|
|||||||
service = module.get<WorkspaceInvitationService>(
|
service = module.get<WorkspaceInvitationService>(
|
||||||
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', () => {
|
it('should be defined', () => {
|
||||||
expect(service).toBeDefined();
|
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 { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
import { render } from '@react-email/render';
|
import { render } from '@react-email/render';
|
||||||
|
import { addMilliseconds } from 'date-fns';
|
||||||
|
import ms from 'ms';
|
||||||
import { SendInviteLinkEmail } from 'twenty-emails';
|
import { SendInviteLinkEmail } from 'twenty-emails';
|
||||||
import { IsNull, Repository } from 'typeorm';
|
import { IsNull, Repository } from 'typeorm';
|
||||||
|
|
||||||
@@ -9,7 +13,10 @@ import {
|
|||||||
AppToken,
|
AppToken,
|
||||||
AppTokenType,
|
AppTokenType,
|
||||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
} 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 { EmailService } from 'src/engine/core-modules/email/email.service';
|
||||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||||
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.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 appTokenRepository: Repository<AppToken>,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
private readonly emailService: EmailService,
|
private readonly emailService: EmailService,
|
||||||
private readonly tokenService: TokenService,
|
|
||||||
@InjectRepository(UserWorkspace, 'core')
|
@InjectRepository(UserWorkspace, 'core')
|
||||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||||
private readonly onboardingService: OnboardingService,
|
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) {
|
async loadWorkspaceInvitations(workspace: Workspace) {
|
||||||
@@ -290,4 +296,31 @@ export class WorkspaceInvitationService {
|
|||||||
...result,
|
...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({
|
const workspaceLogoToken = await this.fileService.encodeFileToken({
|
||||||
workspace_id: id,
|
workspaceId: id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return `${paths[0]}?token=${workspaceLogoToken}`;
|
return `${paths[0]}?token=${workspaceLogoToken}`;
|
||||||
@@ -128,7 +128,7 @@ export class WorkspaceResolver {
|
|||||||
if (workspace.logo) {
|
if (workspace.logo) {
|
||||||
try {
|
try {
|
||||||
const workspaceLogoToken = await this.fileService.encodeFileToken({
|
const workspaceLogoToken = await this.fileService.encodeFileToken({
|
||||||
workspace_id: workspace.id,
|
workspaceId: workspace.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return `${workspace.logo}?token=${workspaceLogoToken}`;
|
return `${workspace.logo}?token=${workspaceLogoToken}`;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
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';
|
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard implements CanActivate {
|
export class JwtAuthGuard implements CanActivate {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly tokenService: TokenService,
|
private readonly accessTokenService: AccessTokenService,
|
||||||
private readonly workspaceStorageCacheService: WorkspaceCacheStorageService,
|
private readonly workspaceStorageCacheService: WorkspaceCacheStorageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export class JwtAuthGuard implements CanActivate {
|
|||||||
const request = context.switchToHttp().getRequest();
|
const request = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await this.tokenService.validateToken(request);
|
const data = await this.accessTokenService.validateToken(request);
|
||||||
const metadataVersion =
|
const metadataVersion =
|
||||||
await this.workspaceStorageCacheService.getMetadataVersion(
|
await this.workspaceStorageCacheService.getMetadataVersion(
|
||||||
data.workspace.id,
|
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 { 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 { 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 { RemoteServerEntity } from 'src/engine/metadata-modules/remote-server/remote-server.entity';
|
||||||
import { RemoteServerResolver } from 'src/engine/metadata-modules/remote-server/remote-server.resolver';
|
import { RemoteServerResolver } from 'src/engine/metadata-modules/remote-server/remote-server.resolver';
|
||||||
import { RemoteServerService } from 'src/engine/metadata-modules/remote-server/remote-server.service';
|
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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
JwtModule,
|
||||||
TypeOrmModule.forFeature([RemoteServerEntity], 'metadata'),
|
TypeOrmModule.forFeature([RemoteServerEntity], 'metadata'),
|
||||||
RemoteTableModule,
|
RemoteTableModule,
|
||||||
WorkspaceDataSourceModule,
|
WorkspaceDataSourceModule,
|
||||||
|
|||||||
@@ -2,31 +2,31 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import isEmpty from 'lodash.isempty';
|
import isEmpty from 'lodash.isempty';
|
||||||
import { v4 } from 'uuid';
|
|
||||||
import { DataSource, EntityManager, Repository } from 'typeorm';
|
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 { 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 {
|
import {
|
||||||
RemoteServerEntity,
|
RemoteServerEntity,
|
||||||
RemoteServerType,
|
RemoteServerType,
|
||||||
} from 'src/engine/metadata-modules/remote-server/remote-server.entity';
|
} 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 {
|
import {
|
||||||
RemoteServerException,
|
RemoteServerException,
|
||||||
RemoteServerExceptionCode,
|
RemoteServerExceptionCode,
|
||||||
} from 'src/engine/metadata-modules/remote-server/remote-server.exception';
|
} 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()
|
@Injectable()
|
||||||
export class RemoteServerService<T extends RemoteServerType> {
|
export class RemoteServerService<T extends RemoteServerType> {
|
||||||
@@ -37,7 +37,7 @@ export class RemoteServerService<T extends RemoteServerType> {
|
|||||||
>,
|
>,
|
||||||
@InjectDataSource('metadata')
|
@InjectDataSource('metadata')
|
||||||
private readonly metadataDataSource: DataSource,
|
private readonly metadataDataSource: DataSource,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly jwtWrapperService: JwtWrapperService,
|
||||||
private readonly foreignDataWrapperServerQueryFactory: ForeignDataWrapperServerQueryFactory,
|
private readonly foreignDataWrapperServerQueryFactory: ForeignDataWrapperServerQueryFactory,
|
||||||
private readonly remoteTableService: RemoteTableService,
|
private readonly remoteTableService: RemoteTableService,
|
||||||
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
|
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
|
||||||
@@ -72,6 +72,7 @@ export class RemoteServerService<T extends RemoteServerType> {
|
|||||||
...remoteServerInput.userMappingOptions,
|
...remoteServerInput.userMappingOptions,
|
||||||
password: this.encryptPassword(
|
password: this.encryptPassword(
|
||||||
remoteServerInput.userMappingOptions.password,
|
remoteServerInput.userMappingOptions.password,
|
||||||
|
workspaceId,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -156,6 +157,7 @@ export class RemoteServerService<T extends RemoteServerType> {
|
|||||||
...partialRemoteServerWithUpdates.userMappingOptions,
|
...partialRemoteServerWithUpdates.userMappingOptions,
|
||||||
password: this.encryptPassword(
|
password: this.encryptPassword(
|
||||||
partialRemoteServerWithUpdates.userMappingOptions.password,
|
partialRemoteServerWithUpdates.userMappingOptions.password,
|
||||||
|
workspaceId,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -252,8 +254,11 @@ export class RemoteServerService<T extends RemoteServerType> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private encryptPassword(password: string) {
|
private encryptPassword(password: string, workspaceId: string) {
|
||||||
const key = this.environmentService.get('LOGIN_TOKEN_SECRET');
|
const key = this.jwtWrapperService.generateAppSecret(
|
||||||
|
'REMOTE_SERVER',
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
return encryptText(password, key);
|
return encryptText(password, key);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
|
|
||||||
import { NextFunction, Request, Response } from 'express';
|
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 { 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 { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||||
import { handleExceptionAndConvertToGraphQLError } from 'src/engine/utils/global-exception-handler.util';
|
import { handleExceptionAndConvertToGraphQLError } from 'src/engine/utils/global-exception-handler.util';
|
||||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||||
class GraphqlTokenValidationProxy {
|
class GraphqlTokenValidationProxy {
|
||||||
private tokenService: TokenService;
|
private accessTokenService: AccessTokenService;
|
||||||
|
|
||||||
constructor(tokenService: TokenService) {
|
constructor(accessTokenService: AccessTokenService) {
|
||||||
this.tokenService = tokenService;
|
this.accessTokenService = accessTokenService;
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateToken(req: Request) {
|
async validateToken(req: Request) {
|
||||||
try {
|
try {
|
||||||
return await this.tokenService.validateToken(req);
|
return await this.accessTokenService.validateToken(req);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const authGraphqlApiExceptionFilter = new AuthGraphqlApiExceptionFilter();
|
const authGraphqlApiExceptionFilter = new AuthGraphqlApiExceptionFilter();
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ export class GraphQLHydrateRequestFromTokenMiddleware
|
|||||||
implements NestMiddleware
|
implements NestMiddleware
|
||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
private readonly tokenService: TokenService,
|
private readonly accessTokenService: AccessTokenService,
|
||||||
private readonly workspaceStorageCacheService: WorkspaceCacheStorageService,
|
private readonly workspaceStorageCacheService: WorkspaceCacheStorageService,
|
||||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||||
) {}
|
) {}
|
||||||
@@ -59,7 +60,7 @@ export class GraphQLHydrateRequestFromTokenMiddleware
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!this.tokenService.isTokenPresent(req) &&
|
!this.isTokenPresent(req) &&
|
||||||
(!body?.operationName || excludedOperations.includes(body.operationName))
|
(!body?.operationName || excludedOperations.includes(body.operationName))
|
||||||
) {
|
) {
|
||||||
return next();
|
return next();
|
||||||
@@ -69,7 +70,7 @@ export class GraphQLHydrateRequestFromTokenMiddleware
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const graphqlTokenValidationProxy = new GraphqlTokenValidationProxy(
|
const graphqlTokenValidationProxy = new GraphqlTokenValidationProxy(
|
||||||
this.tokenService,
|
this.accessTokenService,
|
||||||
);
|
);
|
||||||
|
|
||||||
data = await graphqlTokenValidationProxy.validateToken(req);
|
data = await graphqlTokenValidationProxy.validateToken(req);
|
||||||
@@ -103,4 +104,10 @@ export class GraphQLHydrateRequestFromTokenMiddleware
|
|||||||
|
|
||||||
next();
|
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>
|
<ArticleWarning>
|
||||||
This document is maintained by the community. It might contain issues.
|
This document is maintained by the community. It might contain issues.
|
||||||
</ArticleWarning>
|
</ArticleWarning>
|
||||||
|
|
||||||
|
|
||||||
## Kubernetes via Terraform and Manifests
|
## 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)
|
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
|
|||||||
|
|
||||||
[](https://render.com/deploy?repo=https://github.com/twentyhq/twenty)
|
[](https://render.com/deploy?repo=https://github.com/twentyhq/twenty)
|
||||||
|
|
||||||
|
|
||||||
## RepoCloud
|
## RepoCloud
|
||||||
|
|
||||||
Community-led, might not be up to date
|
Community-led, might not be up to date
|
||||||
|
|
||||||
[](https://repocloud.io/details/?app_id=259)
|
[](https://repocloud.io/details/?app_id=259)
|
||||||
|
|
||||||
|
|
||||||
## Azure Container Apps
|
## Azure Container Apps
|
||||||
|
|
||||||
Community-led, might not be up to date
|
Community-led, might not be up to date
|
||||||
@@ -271,11 +268,8 @@ resource "azapi_update_resource" "cors" {
|
|||||||
```hcl
|
```hcl
|
||||||
# backend.tf
|
# backend.tf
|
||||||
|
|
||||||
# Create three random UUIDs
|
# Create a random UUID
|
||||||
resource "random_uuid" "access_token_secret" {}
|
resource "random_uuid" "app_secret" {}
|
||||||
resource "random_uuid" "login_token_secret" {}
|
|
||||||
resource "random_uuid" "refresh_token_secret" {}
|
|
||||||
resource "random_uuid" "file_token_secret" {}
|
|
||||||
|
|
||||||
resource "azurerm_container_app" "twenty_server" {
|
resource "azurerm_container_app" "twenty_server" {
|
||||||
name = local.server_name
|
name = local.server_name
|
||||||
@@ -343,20 +337,8 @@ resource "azurerm_container_app" "twenty_server" {
|
|||||||
value = "https://${local.front_app_name}"
|
value = "https://${local.front_app_name}"
|
||||||
}
|
}
|
||||||
env {
|
env {
|
||||||
name = "ACCESS_TOKEN_SECRET"
|
name = "APP_SECRET"
|
||||||
value = random_uuid.access_token_secret.result
|
value = random_uuid.app_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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,23 +50,19 @@ Follow these steps for a manual setup.
|
|||||||
|
|
||||||
2. **Generate Secret Tokens**
|
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
|
```bash
|
||||||
openssl rand -base64 32
|
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`**
|
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
|
```ini
|
||||||
ACCESS_TOKEN_SECRET=first_random_string
|
APP_SECRET=first_random_string
|
||||||
LOGIN_TOKEN_SECRET=second_random_string
|
|
||||||
REFRESH_TOKEN_SECRET=third_random_string
|
|
||||||
FILE_TOKEN_SECRET=fourth_random_string
|
|
||||||
```
|
```
|
||||||
**Note:** Only modify these lines unless instructed otherwise.
|
|
||||||
|
|
||||||
4. **Set the Postgres Password**
|
4. **Set the Postgres Password**
|
||||||
|
|
||||||
|
|||||||
@@ -51,14 +51,11 @@ yarn command:prod cron:calendar:calendar-event-list-fetch
|
|||||||
### Tokens
|
### Tokens
|
||||||
|
|
||||||
<ArticleTable options={[
|
<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'],
|
['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'],
|
['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_EXPIRES_IN', '90d', 'Refresh token expiration time'],
|
||||||
['REFRESH_TOKEN_COOL_DOWN', '1m', 'Refresh token cooldown'],
|
['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'],
|
['FILE_TOKEN_EXPIRES_IN', '1d', 'File token expiration time'],
|
||||||
['API_TOKEN_EXPIRES_IN', '1000y', 'API token expiration time'],
|
['API_TOKEN_EXPIRES_IN', '1000y', 'API token expiration time'],
|
||||||
]}></ArticleTable>
|
]}></ArticleTable>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ The `yarn command:prod upgrade-0.31` takes care of the data migration of all wor
|
|||||||
|
|
||||||
### Environment Variables
|
### 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`
|
- Removed: `REDIS_HOST`, `REDIS_PORT`, `REDIS_USERNAME`, `REDIS_PASSWORD`
|
||||||
- Added: `REDIS_URL`
|
- 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.
|
Update your `.env` file to use the new `REDIS_URL` variable instead of the individual Redis connection parameters.
|
||||||
|
|
||||||
<ArticleEditContent></ArticleEditContent>
|
<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
|
name: server
|
||||||
type: web
|
type: web
|
||||||
envVarKey: RENDER_EXTERNAL_URL
|
envVarKey: RENDER_EXTERNAL_URL
|
||||||
- key: ACCESS_TOKEN_SECRET
|
- key: APP_SECRET
|
||||||
generateValue: true
|
|
||||||
- key: LOGIN_TOKEN_SECRET
|
|
||||||
generateValue: true
|
|
||||||
- key: REFRESH_TOKEN_SECRET
|
|
||||||
generateValue: true
|
|
||||||
- key: FILE_TOKEN_SECRET
|
|
||||||
generateValue: true
|
generateValue: true
|
||||||
- key: PG_DATABASE_HOST
|
- key: PG_DATABASE_HOST
|
||||||
fromService:
|
fromService:
|
||||||
@@ -55,13 +49,7 @@ services:
|
|||||||
name: server
|
name: server
|
||||||
type: web
|
type: web
|
||||||
envVarKey: RENDER_EXTERNAL_URL
|
envVarKey: RENDER_EXTERNAL_URL
|
||||||
- key: ACCESS_TOKEN_SECRET
|
- key: APP_SECRET
|
||||||
generateValue: true
|
|
||||||
- key: LOGIN_TOKEN_SECRET
|
|
||||||
generateValue: true
|
|
||||||
- key: REFRESH_TOKEN_SECRET
|
|
||||||
generateValue: true
|
|
||||||
- key: FILE_TOKEN_SECRET
|
|
||||||
generateValue: true
|
generateValue: true
|
||||||
- key: PG_DATABASE_HOST
|
- key: PG_DATABASE_HOST
|
||||||
fromService:
|
fromService:
|
||||||
|
|||||||
Reference in New Issue
Block a user