Merge branch 'main' into feat/add-sub-field-filtering

This commit is contained in:
Lucas Bordeau 2024-10-30 14:15:26 +01:00
commit 8c44c4772c
127 changed files with 3304 additions and 2627 deletions

View File

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

View File

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

View File

@ -26,4 +26,5 @@ Your turn 👇
» 28-October-2024 by [adityadeshlahre](https://oss.gg/adityadeshlahre) video link: [video](https://youtu.be/65sOHce1gjw)
» 30-October-2024 by [harshsbhat](https://oss.gg/harshsbhat) video link: [video](https://x.com/HarshBhatX/status/1851481457761370559)
---

View File

@ -59,4 +59,7 @@ Your turn 👇
» 23-October-2024 by Rajeev Dewangan
» Link to Tweet: https://x.com/rajeevdew/status/1849109074685907374
» 30-October-2024 by Atharva Deshmukh
» Link to Tweet: https://x.com/0x_atharva/status/1851503532840566919

View File

@ -37,3 +37,6 @@ Your turn 👇
» 23-October-2024 by Rajeev Dewangan
» Link to Tweet: https://x.com/rajeevdew/status/1849110473272442991
» 30-October-2024 byAtharva Deshmukh
» Link to Tweet: https://x.com/0x_atharva/status/1851501634662039582

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,6 @@ export type Attachment = {
type: AttachmentType;
companyId: string;
personId: string;
activityId: string;
authorId: string;
createdAt: string;
__typename: string;

View File

@ -18,7 +18,6 @@ const mockActivityTarget = {
updatedAt: '2021-08-03T19:20:06.000Z',
createdAt: '2021-08-03T19:20:06.000Z',
personId: '1',
activityId: '234',
companyId: '1',
id: '123',
};

View File

@ -37,7 +37,6 @@ const mocks: MockedResponse[] = [
edges {
node {
__typename
activityId
authorId
companyId
createdAt

View File

@ -51,7 +51,6 @@ const mocks: MockedResponse[] = [
edges {
node {
__typename
activityId
authorId
companyId
createdAt
@ -138,6 +137,9 @@ const mocks: MockedResponse[] = [
rocketId
taskId
updatedAt
workflowId
workflowRunId
workflowVersionId
workspaceMemberId
}
}

View File

@ -317,6 +317,219 @@ export const mocks = [
workspaceMemberId
}
}
companyId
createdAt
deletedAt
id
note {
__typename
body
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
id
position
title
updatedAt
}
noteId
opportunity {
__typename
amount {
amountMicros
currencyCode
}
closeDate
companyId
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
id
name
pointOfContactId
position
stage
updatedAt
}
opportunityId
person {
__typename
avatarUrl
city
companyId
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
emails {
primaryEmail
additionalEmails
}
id
intro
jobTitle
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
name {
firstName
lastName
}
performanceRating
phones {
primaryPhoneNumber
primaryPhoneCountryCode
additionalPhones
}
position
updatedAt
whatsapp {
primaryPhoneNumber
primaryPhoneCountryCode
additionalPhones
}
workPreference
xLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
}
personId
position
rocket {
__typename
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
id
name
position
updatedAt
}
rocketId
task {
__typename
assigneeId
body
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
dueAt
id
position
status
title
updatedAt
}
taskId
updatedAt
view {
__typename
createdAt
deletedAt
icon
id
isCompact
kanbanFieldMetadataId
key
name
objectMetadataId
position
type
updatedAt
}
viewId
workflow {
__typename
createdAt
deletedAt
id
lastPublishedVersionId
name
position
statuses
updatedAt
}
workflowId
workflowRun {
__typename
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
endedAt
id
name
output
position
startedAt
status
updatedAt
workflowId
workflowVersionId
}
workflowRunId
workflowVersion {
__typename
createdAt
deletedAt
id
name
position
status
steps
trigger
updatedAt
workflowId
}
workflowVersionId
workspaceMember {
__typename
avatarUrl
colorScheme
createdAt
dateFormat
deletedAt
id
locale
name {
firstName
lastName
}
timeFormat
timeZone
updatedAt
userEmail
userId
}
workspaceMemberId
}
}
`,
variables: {
input: {
@ -575,6 +788,41 @@ export const mocks = [
updatedAt
}
workflowId
workflowRun {
__typename
createdAt
createdBy {
source
workspaceMemberId
name
}
deletedAt
endedAt
id
name
output
position
startedAt
status
updatedAt
workflowId
workflowVersionId
}
workflowRunId
workflowVersion {
__typename
createdAt
deletedAt
id
name
position
status
steps
trigger
updatedAt
workflowId
}
workflowVersionId
workspaceMember {
__typename
avatarUrl

View File

@ -3,7 +3,7 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
export const mapSoftDeleteFieldsToGraphQLQuery = (
objectMetadataItem: Pick<ObjectMetadataItem, 'fields'>,
): string => {
const softDeleteFields = ['id', 'deletedAt'];
const softDeleteFields = ['deletedAt', 'id'];
const fieldsThatShouldBeQueried = objectMetadataItem.fields.filter(
(field) => field.isActive && softDeleteFields.includes(field.name),

View File

@ -17,7 +17,7 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS = `
id
intro
jobTitle
linkedinLink{
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
@ -49,27 +49,10 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS = `
export const PERSON_FRAGMENT_WITH_DEPTH_ONE_RELATIONS = `
__typename
activityTargets {
edges {
node {
__typename
activityId
companyId
createdAt
deletedAt
id
opportunityId
personId
rocketId
updatedAt
}
}
}
attachments {
edges {
node {
__typename
activityId
authorId
companyId
createdAt
@ -190,6 +173,8 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ONE_RELATIONS = `
updatedAt
viewId
workflowId
workflowRunId
workflowVersionId
workspaceMemberId
}
}
@ -308,6 +293,9 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ONE_RELATIONS = `
rocketId
taskId
updatedAt
workflowId
workflowRunId
workflowVersionId
workspaceMemberId
}
}

View File

@ -15,5 +15,7 @@ export const variables = {
};
export const responseData = {
__typename: 'Person',
deletedAt: '2024-02-14T09:45:00Z',
id: 'a7286b9a-c039-4a89-9567-2dfa7953cda9',
};

View File

@ -99,20 +99,6 @@ export const query = gql`
}
city
email
activityTargets {
edges {
node {
__typename
id
updatedAt
createdAt
personId
activityId
companyId
id
}
}
}
jobTitle
favorites {
edges {
@ -137,7 +123,6 @@ export const query = gql`
createdAt
name
personId
activityId
companyId
id
authorId

View File

@ -8,8 +8,8 @@ const expectedQueryTemplate = `
mutation DeleteOnePerson($idToDelete: ID!) {
deletePerson(id: $idToDelete) {
__typename
deletedAt
id
deletedAt
}
}
`.replace(/\s/g, '');

View File

@ -19,6 +19,7 @@ import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectab
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useGetCurrentView } from '@/views/hooks/useGetCurrentView';
import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
@ -139,10 +140,15 @@ export const ObjectFilterDropdownFilterSelect = ({
const { currentViewId, currentViewWithCombinedFiltersAndSorts } =
useGetCurrentView();
const isAdvancedFiltersEnabled = useIsFeatureEnabled(
'IS_ADVANCED_FILTERS_ENABLED',
);
const shouldShowAdvancedFilterButton =
isDefined(currentViewId) &&
isDefined(currentViewWithCombinedFiltersAndSorts?.objectMetadataId) &&
isAdvancedFilterButtonVisible;
isAdvancedFilterButtonVisible &&
isAdvancedFiltersEnabled;
return (
<>

View File

@ -47,22 +47,6 @@ const mocks: MockedResponse[] = [
userId
}
accountOwnerId
activityTargets {
edges {
node {
__typename
activityId
companyId
createdAt
deletedAt
id
opportunityId
personId
rocketId
updatedAt
}
}
}
address {
addressStreet1
addressStreet2
@ -81,7 +65,6 @@ const mocks: MockedResponse[] = [
edges {
node {
__typename
activityId
authorId
companyId
createdAt
@ -129,6 +112,8 @@ const mocks: MockedResponse[] = [
updatedAt
viewId
workflowId
workflowRunId
workflowVersionId
workspaceMemberId
}
}
@ -278,6 +263,9 @@ const mocks: MockedResponse[] = [
rocketId
taskId
updatedAt
workflowId
workflowRunId
workflowVersionId
workspaceMemberId
}
}

View File

@ -46,22 +46,6 @@ const companyMocks = [
userId
}
accountOwnerId
activityTargets {
edges {
node {
__typename
activityId
companyId
createdAt
deletedAt
id
opportunityId
personId
rocketId
updatedAt
}
}
}
address {
addressStreet1
addressStreet2
@ -80,7 +64,6 @@ const companyMocks = [
edges {
node {
__typename
activityId
authorId
companyId
createdAt

View File

@ -88,19 +88,6 @@ export const query = gql`
}
city
email
activityTargets {
edges {
node {
id
updatedAt
createdAt
personId
activityId
companyId
id
}
}
}
jobTitle
favorites {
edges {
@ -124,7 +111,6 @@ export const query = gql`
createdAt
name
personId
activityId
companyId
id
authorId

View File

@ -88,35 +88,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [
secondaryLinks: null,
__typename: 'Links',
},
activityTargets: {
__typename: 'ActivityTargetConnection',
edges: [
{
__typename: 'ActivityTargetEdge',
node: {
__typename: 'ActivityTarget',
id: '97114d7e-2a80-4401-af58-36c88e13e852',
activityId: '737a6c31-610a-457b-b087-791ac700fa46',
createdAt: '2023-11-24T13:15:03.523Z',
updatedAt: '2023-11-24T13:15:03.523Z',
companyId: '04b2e9f5-0713-40a5-8216-82802401d33e',
personId: null,
},
},
{
__typename: 'ActivityTargetEdge',
node: {
__typename: 'ActivityTarget',
id: 'cb29d37a-3d5e-4efb-afa3-38f4bff69912',
activityId: '3c6ea4a3-f71d-4c31-9dfa-f868a5de4091',
createdAt: '2023-11-24T13:14:57.628Z',
updatedAt: '2023-11-24T13:14:57.628Z',
companyId: '04b2e9f5-0713-40a5-8216-82802401d33e',
personId: null,
},
},
],
},
annualRecurringRevenue: {
__typename: 'Currency',
amountMicros: null,
@ -339,10 +310,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [
secondaryLinks: [],
__typename: 'Links',
},
activityTargets: {
__typename: 'ActivityTargetConnection',
edges: [],
},
annualRecurringRevenue: {
__typename: 'Currency',
amountMicros: null,
@ -489,10 +456,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [
secondaryLinks: [],
__typename: 'Links',
},
activityTargets: {
__typename: 'ActivityTargetConnection',
edges: [],
},
annualRecurringRevenue: {
__typename: 'Currency',
amountMicros: null,
@ -554,10 +517,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [
secondaryLinks: [],
__typename: 'Links',
},
activityTargets: {
__typename: 'ActivityTargetConnection',
edges: [],
},
annualRecurringRevenue: {
__typename: 'Currency',
amountMicros: null,
@ -755,10 +714,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [
secondaryLinks: [],
__typename: 'Links',
},
activityTargets: {
__typename: 'ActivityTargetConnection',
edges: [],
},
annualRecurringRevenue: {
__typename: 'Currency',
amountMicros: null,
@ -820,10 +775,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [
secondaryLinks: [],
__typename: 'Links',
},
activityTargets: {
__typename: 'ActivityTargetConnection',
edges: [],
},
annualRecurringRevenue: {
__typename: 'Currency',
amountMicros: null,
@ -885,10 +836,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [
secondaryLinks: [],
__typename: 'Links',
},
activityTargets: {
__typename: 'ActivityTargetConnection',
edges: [],
},
annualRecurringRevenue: {
__typename: 'Currency',
amountMicros: null,
@ -950,10 +897,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [
secondaryLinks: [],
__typename: 'Links',
},
activityTargets: {
__typename: 'ActivityTargetConnection',
edges: [],
},
annualRecurringRevenue: {
__typename: 'Currency',
amountMicros: null,
@ -1048,10 +991,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [
secondaryLinks: [],
__typename: 'Links',
},
activityTargets: {
__typename: 'ActivityTargetConnection',
edges: [],
},
annualRecurringRevenue: {
__typename: 'Currency',
amountMicros: null,
@ -1146,10 +1085,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [
secondaryLinks: [],
__typename: 'Links',
},
activityTargets: {
__typename: 'ActivityTargetConnection',
edges: [],
},
annualRecurringRevenue: {
__typename: 'Currency',
amountMicros: null,
@ -1211,10 +1146,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [
secondaryLinks: [],
__typename: 'Links',
},
activityTargets: {
__typename: 'ActivityTargetConnection',
edges: [],
},
annualRecurringRevenue: {
__typename: 'Currency',
amountMicros: null,
@ -1276,10 +1207,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [
secondaryLinks: [],
__typename: 'Links',
},
activityTargets: {
__typename: 'ActivityTargetConnection',
edges: [],
},
annualRecurringRevenue: {
__typename: 'Currency',
amountMicros: null,
@ -1426,10 +1353,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [
secondaryLinks: [],
__typename: 'Links',
},
activityTargets: {
__typename: 'ActivityTargetConnection',
edges: [],
},
annualRecurringRevenue: {
__typename: 'Currency',
amountMicros: null,

View File

@ -15,4 +15,5 @@ export type FeatureFlagKey =
| 'IS_ANALYTICS_V2_ENABLED'
| 'IS_SSO_ENABLED'
| 'IS_UNIQUE_INDEXES_ENABLED'
| 'IS_ARRAY_AND_JSON_FILTER_ENABLED';
| 'IS_ARRAY_AND_JSON_FILTER_ENABLED'
| 'IS_ADVANCED_FILTERS_ENABLED';

View File

@ -40,21 +40,6 @@ const meta: Meta<PageDecoratorArgs> = {
},
});
}),
graphql.query('FindManyActivityTargets', () => {
return HttpResponse.json({
data: {
activityTargets: {
edges: [],
pageInfo: {
hasNextPage: false,
startCursor: '',
endCursor: '',
},
totalCount: 0,
},
},
});
}),
graphql.query('FindOneworkspaceMember', () => {
return HttpResponse.json({
data: {

View File

@ -259,9 +259,13 @@ export const graphqlMocks = {
edges: [],
__typename: 'OpportunityConnection',
},
activityTargets: {
taskTargets: {
edges: [],
__typename: 'ActivityTargetConnection',
__typename: 'TaskTargetConnection',
},
noteTargets: {
edges: [],
__typename: 'NoteTargetConnection',
},
},
cursor: null,
@ -301,9 +305,13 @@ export const graphqlMocks = {
edges: [],
__typename: 'OpportunityConnection',
},
activityTargets: {
taskTargets: {
edges: [],
__typename: 'ActivityTargetConnection',
__typename: 'TaskTargetConnection',
},
noteTargets: {
edges: [],
__typename: 'NoteTargetConnection',
},
},
cursor: null,

View File

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

View File

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

View File

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

View File

@ -75,6 +75,11 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: false,
},
{
key: FeatureFlagKey.IsAdvancedFiltersEnabled,
workspaceId: workspaceId,
value: false,
},
])
.execute();
};

View File

@ -9,11 +9,11 @@ import { EnvironmentService } from 'src/engine/core-modules/environment/environm
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
@Injectable()
export class TypeORMService implements OnModuleInit, OnModuleDestroy {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,31 +9,37 @@ import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-
import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controllers/google-apis-auth.controller';
import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/google-auth.controller';
import { MicrosoftAuthController } from 'src/engine/core-modules/auth/controllers/microsoft-auth.controller';
import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller';
import { VerifyAuthController } from 'src/engine/core-modules/auth/controllers/verify-auth.controller';
import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service';
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service';
import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { User } from 'src/engine/core-modules/user/user.entity';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
import { SSOAuthController } from 'src/engine/core-modules/auth/controllers/sso-auth.controller';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { SamlAuthStrategy } from 'src/engine/core-modules/auth/strategies/saml.auth.strategy';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.module';
import { AuthResolver } from './auth.resolver';
@ -83,10 +89,16 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
JwtAuthStrategy,
SamlAuthStrategy,
AuthResolver,
TokenService,
GoogleAPIsService,
AppTokenService,
AccessTokenService,
LoginTokenService,
ResetPasswordService,
SwitchWorkspaceService,
TransientTokenService,
ApiKeyService,
OAuthService,
],
exports: [TokenService],
exports: [AccessTokenService, LoginTokenService],
})
export class AuthModule {}

View File

@ -10,8 +10,14 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthResolver } from './auth.resolver';
import { ApiKeyService } from './services/api-key.service';
import { AuthService } from './services/auth.service';
import { TokenService } from './token/services/token.service';
import { OAuthService } from './services/oauth.service';
import { ResetPasswordService } from './services/reset-password.service';
import { SwitchWorkspaceService } from './services/switch-workspace.service';
import { LoginTokenService } from './token/services/login-token.service';
import { RenewTokenService } from './token/services/renew-token.service';
import { TransientTokenService } from './token/services/transient-token.service';
describe('AuthResolver', () => {
let resolver: AuthResolver;
@ -33,10 +39,6 @@ describe('AuthResolver', () => {
provide: AuthService,
useValue: {},
},
{
provide: TokenService,
useValue: {},
},
{
provide: UserService,
useValue: {},
@ -45,6 +47,34 @@ describe('AuthResolver', () => {
provide: UserWorkspaceService,
useValue: {},
},
{
provide: RenewTokenService,
useValue: {},
},
{
provide: ApiKeyService,
useValue: {},
},
{
provide: ResetPasswordService,
useValue: {},
},
{
provide: LoginTokenService,
useValue: {},
},
{
provide: SwitchWorkspaceService,
useValue: {},
},
{
provide: TransientTokenService,
useValue: {},
},
{
provide: OAuthService,
useValue: {},
},
],
})
.overrideGuard(CaptchaGuard)

View File

@ -10,12 +10,24 @@ import { EmailPasswordResetLinkInput } from 'src/engine/core-modules/auth/dto/em
import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity';
import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input';
import { GenerateJwtInput } from 'src/engine/core-modules/auth/dto/generate-jwt.input';
import {
GenerateJWTOutput,
GenerateJWTOutputWithAuthTokens,
GenerateJWTOutputWithSSOAUTH,
} from 'src/engine/core-modules/auth/dto/generateJWT.output';
import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity';
import { TransientToken } from 'src/engine/core-modules/auth/dto/transient-token.entity';
import { UpdatePasswordViaResetTokenInput } from 'src/engine/core-modules/auth/dto/update-password-via-reset-token.input';
import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.entity';
import { ValidatePasswordResetTokenInput } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.input';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ApiKeyService } from 'src/engine/core-modules/auth/services/api-key.service';
import { OAuthService } from 'src/engine/core-modules/auth/services/oauth.service';
import { ResetPasswordService } from 'src/engine/core-modules/auth/services/reset-password.service';
import { SwitchWorkspaceService } from 'src/engine/core-modules/auth/services/switch-workspace.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service';
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { CaptchaGuard } from 'src/engine/core-modules/captcha/captcha.guard';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
@ -24,11 +36,6 @@ import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import {
GenerateJWTOutput,
GenerateJWTOutputWithAuthTokens,
GenerateJWTOutputWithSSOAUTH,
} from 'src/engine/core-modules/auth/dto/generateJWT.output';
import { ChallengeInput } from './dto/challenge.input';
import { ImpersonateInput } from './dto/impersonate.input';
@ -42,15 +49,20 @@ import { VerifyInput } from './dto/verify.input';
import { WorkspaceInviteHashValid } from './dto/workspace-invite-hash-valid.entity';
import { WorkspaceInviteHashValidInput } from './dto/workspace-invite-hash.input';
import { AuthService } from './services/auth.service';
import { TokenService } from './token/services/token.service';
@Resolver()
@UseFilters(AuthGraphqlApiExceptionFilter)
export class AuthResolver {
constructor(
private authService: AuthService,
private tokenService: TokenService,
private renewTokenService: RenewTokenService,
private userService: UserService,
private apiKeyService: ApiKeyService,
private resetPasswordService: ResetPasswordService,
private loginTokenService: LoginTokenService,
private switchWorkspaceService: SwitchWorkspaceService,
private transientTokenService: TransientTokenService,
private oauthService: OAuthService,
) {}
@UseGuards(CaptchaGuard)
@ -87,7 +99,9 @@ export class AuthResolver {
@Mutation(() => LoginToken)
async challenge(@Args() challengeInput: ChallengeInput): Promise<LoginToken> {
const user = await this.authService.challenge(challengeInput);
const loginToken = await this.tokenService.generateLoginToken(user.email);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
);
return { loginToken };
}
@ -100,7 +114,9 @@ export class AuthResolver {
fromSSO: false,
});
const loginToken = await this.tokenService.generateLoginToken(user.email);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
);
return { loginToken };
}
@ -109,7 +125,7 @@ export class AuthResolver {
async exchangeAuthorizationCode(
@Args() exchangeAuthCodeInput: ExchangeAuthCodeInput,
) {
const tokens = await this.tokenService.verifyAuthorizationCode(
const tokens = await this.oauthService.verifyAuthorizationCode(
exchangeAuthCodeInput,
);
@ -130,18 +146,19 @@ export class AuthResolver {
if (!workspaceMember) {
return;
}
const transientToken = await this.tokenService.generateTransientToken(
workspaceMember.id,
user.id,
user.defaultWorkspaceId,
);
const transientToken =
await this.transientTokenService.generateTransientToken(
workspaceMember.id,
user.id,
user.defaultWorkspaceId,
);
return { transientToken };
}
@Mutation(() => Verify)
async verify(@Args() verifyInput: VerifyInput): Promise<Verify> {
const email = await this.tokenService.verifyLoginToken(
const email = await this.loginTokenService.verifyLoginToken(
verifyInput.loginToken,
);
@ -170,7 +187,7 @@ export class AuthResolver {
@AuthUser() user: User,
@Args() args: GenerateJwtInput,
): Promise<GenerateJWTOutputWithAuthTokens | GenerateJWTOutputWithSSOAUTH> {
const result = await this.tokenService.switchWorkspace(
const result = await this.switchWorkspaceService.switchWorkspace(
user,
args.workspaceId,
);
@ -194,16 +211,17 @@ export class AuthResolver {
return {
success: true,
reason: 'WORKSPACE_AVAILABLE_FOR_SWITCH',
authTokens: await this.tokenService.generateSwitchWorkspaceToken(
user,
result.workspace,
),
authTokens:
await this.switchWorkspaceService.generateSwitchWorkspaceToken(
user,
result.workspace,
),
};
}
@Mutation(() => AuthTokens)
async renewToken(@Args() args: AppTokenInput): Promise<AuthTokens> {
const tokens = await this.tokenService.generateTokensFromRefreshToken(
const tokens = await this.renewTokenService.generateTokensFromRefreshToken(
args.appToken,
);
@ -225,7 +243,7 @@ export class AuthResolver {
@Args() args: ApiKeyTokenInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
): Promise<ApiKeyToken | undefined> {
return await this.tokenService.generateApiKeyToken(
return await this.apiKeyService.generateApiKeyToken(
workspaceId,
args.apiKeyId,
args.expiresAt,
@ -236,11 +254,12 @@ export class AuthResolver {
async emailPasswordResetLink(
@Args() emailPasswordResetInput: EmailPasswordResetLinkInput,
): Promise<EmailPasswordResetLink> {
const resetToken = await this.tokenService.generatePasswordResetToken(
emailPasswordResetInput.email,
);
const resetToken =
await this.resetPasswordService.generatePasswordResetToken(
emailPasswordResetInput.email,
);
return await this.tokenService.sendEmailPasswordResetLink(
return await this.resetPasswordService.sendEmailPasswordResetLink(
resetToken,
emailPasswordResetInput.email,
);
@ -252,18 +271,20 @@ export class AuthResolver {
{ passwordResetToken, newPassword }: UpdatePasswordViaResetTokenInput,
): Promise<InvalidatePassword> {
const { id } =
await this.tokenService.validatePasswordResetToken(passwordResetToken);
await this.resetPasswordService.validatePasswordResetToken(
passwordResetToken,
);
await this.authService.updatePassword(id, newPassword);
return await this.tokenService.invalidatePasswordResetToken(id);
return await this.resetPasswordService.invalidatePasswordResetToken(id);
}
@Query(() => ValidatePasswordResetToken)
async validatePasswordResetToken(
@Args() args: ValidatePasswordResetTokenInput,
): Promise<ValidatePasswordResetToken> {
return this.tokenService.validatePasswordResetToken(
return this.resetPasswordService.validatePasswordResetToken(
args.passwordResetToken,
);
}

View File

@ -17,7 +17,7 @@ import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters
import { GoogleAPIsOauthExchangeCodeForTokenGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-exchange-code-for-token.guard';
import { GoogleAPIsOauthRequestCodeGuard } from 'src/engine/core-modules/auth/guards/google-apis-oauth-request-code.guard';
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { GoogleAPIsRequest } from 'src/engine/core-modules/auth/types/google-api-request.type';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
@ -27,7 +27,7 @@ import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding
export class GoogleAPIsAuthController {
constructor(
private readonly googleAPIsService: GoogleAPIsService,
private readonly tokenService: TokenService,
private readonly transientTokenService: TransientTokenService,
private readonly environmentService: EnvironmentService,
private readonly onboardingService: OnboardingService,
) {}
@ -58,7 +58,7 @@ export class GoogleAPIsAuthController {
} = user;
const { workspaceMemberId, userId, workspaceId } =
await this.tokenService.verifyTransientToken(transientToken);
await this.transientTokenService.verifyTransientToken(transientToken);
const demoWorkspaceIds = this.environmentService.get('DEMO_WORKSPACE_IDS');

View File

@ -15,13 +15,13 @@ import { GoogleOauthGuard } from 'src/engine/core-modules/auth/guards/google-oau
import { GoogleProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/google-provider-enabled.guard';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { GoogleRequest } from 'src/engine/core-modules/auth/strategies/google.auth.strategy';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
@Controller('auth/google')
@UseFilters(AuthRestApiExceptionFilter)
export class GoogleAuthController {
constructor(
private readonly tokenService: TokenService,
private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService,
) {}
@ -55,8 +55,10 @@ export class GoogleAuthController {
fromSSO: true,
});
const loginToken = await this.tokenService.generateLoginToken(user.email);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
);
return res.redirect(this.tokenService.computeRedirectURI(loginToken.token));
return res.redirect(this.authService.computeRedirectURI(loginToken.token));
}
}

View File

@ -9,20 +9,18 @@ import {
import { Response } from 'express';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { MicrosoftOAuthGuard } from 'src/engine/core-modules/auth/guards/microsoft-oauth.guard';
import { MicrosoftProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/microsoft-provider-enabled.guard';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { MicrosoftRequest } from 'src/engine/core-modules/auth/strategies/microsoft.auth.strategy';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
@Controller('auth/microsoft')
@UseFilters(AuthRestApiExceptionFilter)
export class MicrosoftAuthController {
constructor(
private readonly tokenService: TokenService,
private readonly typeORMService: TypeORMService,
private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService,
) {}
@ -58,8 +56,10 @@ export class MicrosoftAuthController {
fromSSO: true,
});
const loginToken = await this.tokenService.generateLoginToken(user.email);
const loginToken = await this.loginTokenService.generateLoginToken(
user.email,
);
return res.redirect(this.tokenService.computeRedirectURI(loginToken.token));
return res.redirect(this.authService.computeRedirectURI(loginToken.token));
}
}

View File

@ -24,7 +24,7 @@ import { OIDCAuthGuard } from 'src/engine/core-modules/auth/guards/oidc-auth.gua
import { SAMLAuthGuard } from 'src/engine/core-modules/auth/guards/saml-auth.guard';
import { SSOProviderEnabledGuard } from 'src/engine/core-modules/auth/guards/sso-provider-enabled.guard';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import {
@ -38,7 +38,7 @@ import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-in
@UseFilters(AuthRestApiExceptionFilter)
export class SSOAuthController {
constructor(
private readonly tokenService: TokenService,
private readonly loginTokenService: LoginTokenService,
private readonly authService: AuthService,
private readonly workspaceInvitationService: WorkspaceInvitationService,
private readonly environmentService: EnvironmentService,
@ -84,7 +84,7 @@ export class SSOAuthController {
const loginToken = await this.generateLoginToken(req.user);
return res.redirect(
this.tokenService.computeRedirectURI(loginToken.token),
this.authService.computeRedirectURI(loginToken.token),
);
} catch (err) {
// TODO: improve error management
@ -99,7 +99,7 @@ export class SSOAuthController {
const loginToken = await this.generateLoginToken(req.user);
return res.redirect(
this.tokenService.computeRedirectURI(loginToken.token),
this.authService.computeRedirectURI(loginToken.token),
);
} catch (err) {
// TODO: improve error management
@ -156,6 +156,6 @@ export class SSOAuthController {
);
}
return this.tokenService.generateLoginToken(user.email);
return this.loginTokenService.generateLoginToken(user.email);
}
}

View File

@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { VerifyAuthController } from './verify-auth.controller';
@ -17,7 +17,7 @@ describe('VerifyAuthController', () => {
useValue: {},
},
{
provide: TokenService,
provide: LoginTokenService,
useValue: {},
},
],

View File

@ -4,19 +4,19 @@ import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity';
import { VerifyInput } from 'src/engine/core-modules/auth/dto/verify.input';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { AuthService } from 'src/engine/core-modules/auth/services/auth.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
@Controller('auth/verify')
@UseFilters(AuthRestApiExceptionFilter)
export class VerifyAuthController {
constructor(
private readonly authService: AuthService,
private readonly tokenService: TokenService,
private readonly loginTokenService: LoginTokenService,
) {}
@Post()
async verify(@Body() verifyInput: VerifyInput): Promise<Verify> {
const email = await this.tokenService.verifyLoginToken(
const email = await this.loginTokenService.verifyLoginToken(
verifyInput.loginToken,
);
const result = await this.authService.verify(email);

View File

@ -6,11 +6,11 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { GoogleAPIsOauthExchangeCodeForTokenStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-exchange-code-for-token.auth.strategy';
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
@Injectable()
export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
@ -19,7 +19,7 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
constructor(
private readonly environmentService: EnvironmentService,
private readonly featureFlagService: FeatureFlagService,
private readonly tokenService: TokenService,
private readonly transientTokenService: TransientTokenService,
) {
super();
}
@ -27,9 +27,10 @@ export class GoogleAPIsOauthExchangeCodeForTokenGuard extends AuthGuard(
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const state = JSON.parse(request.query.state);
const { workspaceId } = await this.tokenService.verifyTransientToken(
state.transientToken,
);
const { workspaceId } =
await this.transientTokenService.verifyTransientToken(
state.transientToken,
);
const isGmailSendEmailScopeEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsGmailSendEmailScopeEnabled,

View File

@ -6,18 +6,18 @@ import {
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { GoogleAPIsOauthRequestCodeStrategy } from 'src/engine/core-modules/auth/strategies/google-apis-oauth-request-code.auth.strategy';
import { TransientTokenService } from 'src/engine/core-modules/auth/token/services/transient-token.service';
import { setRequestExtraParams } from 'src/engine/core-modules/auth/utils/google-apis-set-request-extra-params.util';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
@Injectable()
export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
constructor(
private readonly environmentService: EnvironmentService,
private readonly featureFlagService: FeatureFlagService,
private readonly tokenService: TokenService,
private readonly transientTokenService: TransientTokenService,
) {
super({
prompt: 'select_account',
@ -27,9 +27,10 @@ export class GoogleAPIsOauthRequestCodeGuard extends AuthGuard('google-apis') {
async canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const { workspaceId } = await this.tokenService.verifyTransientToken(
request.query.transientToken,
);
const { workspaceId } =
await this.transientTokenService.verifyTransientToken(
request.query.transientToken,
);
const isGmailSendEmailScopeEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IsGmailSendEmailScopeEnabled,

View File

@ -0,0 +1,96 @@
import { Test, TestingModule } from '@nestjs/testing';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { ApiKeyService } from './api-key.service';
describe('ApiKeyService', () => {
let service: ApiKeyService;
let jwtWrapperService: JwtWrapperService;
let environmentService: EnvironmentService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ApiKeyService,
{
provide: JwtWrapperService,
useValue: {
sign: jest.fn(),
generateAppSecret: jest.fn().mockReturnValue('mocked-secret'),
},
},
{
provide: EnvironmentService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();
service = module.get<ApiKeyService>(ApiKeyService);
jwtWrapperService = module.get<JwtWrapperService>(JwtWrapperService);
environmentService = module.get<EnvironmentService>(EnvironmentService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('generateApiKeyToken', () => {
it('should return undefined if apiKeyId is not provided', async () => {
const result = await service.generateApiKeyToken('workspace-id');
expect(result).toBeUndefined();
});
it('should generate an API key token successfully', async () => {
const workspaceId = 'workspace-id';
const apiKeyId = 'api-key-id';
const mockToken = 'mock-token';
jest.spyOn(environmentService, 'get').mockReturnValue('1h');
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest
.spyOn(jwtWrapperService, 'generateAppSecret')
.mockReturnValue('mocked-secret');
const result = await service.generateApiKeyToken(workspaceId, apiKeyId);
expect(result).toEqual({ token: mockToken });
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
{ sub: workspaceId },
expect.objectContaining({
secret: 'mocked-secret',
expiresIn: '1h',
jwtid: apiKeyId,
}),
);
});
it('should use custom expiration time if provided', async () => {
const workspaceId = 'workspace-id';
const apiKeyId = 'api-key-id';
const expiresAt = new Date(Date.now() + 3600000); // 1 hour from now
const mockToken = 'mock-token';
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest
.spyOn(jwtWrapperService, 'generateAppSecret')
.mockReturnValue('mocked-secret');
await service.generateApiKeyToken(workspaceId, apiKeyId, expiresAt);
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
{ sub: workspaceId },
expect.objectContaining({
secret: 'mocked-secret',
expiresIn: expect.any(Number),
jwtid: apiKeyId,
}),
);
});
});
});

View File

@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { ApiKeyToken } from 'src/engine/core-modules/auth/dto/token.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@Injectable()
export class ApiKeyService {
constructor(
private readonly jwtWrapperService: JwtWrapperService,
private readonly environmentService: EnvironmentService,
) {}
async generateApiKeyToken(
workspaceId: string,
apiKeyId?: string,
expiresAt?: Date | string,
): Promise<Pick<ApiKeyToken, 'token'> | undefined> {
if (!apiKeyId) {
return;
}
const jwtPayload = {
sub: workspaceId,
};
const secret = this.jwtWrapperService.generateAppSecret(
'ACCESS',
workspaceId,
);
let expiresIn: string | number;
if (expiresAt) {
expiresIn = Math.floor(
(new Date(expiresAt).getTime() - new Date().getTime()) / 1000,
);
} else {
expiresIn = this.environmentService.get('API_TOKEN_EXPIRES_IN');
}
const token = this.jwtWrapperService.sign(jwtPayload, {
secret,
expiresIn,
jwtid: apiKeyId,
});
return { token };
}
}

View File

@ -3,13 +3,12 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { UserService } from 'src/engine/core-modules/user/services/user.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { AuthService } from './auth.service';
@ -20,22 +19,6 @@ describe('AuthService', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{
provide: TokenService,
useValue: {},
},
{
provide: UserService,
useValue: {},
},
{
provide: SignInUpService,
useValue: {},
},
{
provide: WorkspaceManagerService,
useValue: {},
},
{
provide: getRepositoryToken(Workspace, 'core'),
useValue: {},
@ -48,6 +31,10 @@ describe('AuthService', () => {
provide: getRepositoryToken(AppToken, 'core'),
useValue: {},
},
{
provide: SignInUpService,
useValue: {},
},
{
provide: EnvironmentService,
useValue: {},
@ -56,6 +43,14 @@ describe('AuthService', () => {
provide: EmailService,
useValue: {},
},
{
provide: AccessTokenService,
useValue: {},
},
{
provide: RefreshTokenService,
useValue: {},
},
],
}).compile();

View File

@ -32,7 +32,8 @@ import { UserExists } from 'src/engine/core-modules/auth/dto/user-exists.entity'
import { Verify } from 'src/engine/core-modules/auth/dto/verify.entity';
import { WorkspaceInviteHashValid } from 'src/engine/core-modules/auth/dto/workspace-invite-hash-valid.entity';
import { SignInUpService } from 'src/engine/core-modules/auth/services/sign-in-up.service';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { User } from 'src/engine/core-modules/user/user.entity';
@ -41,7 +42,8 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class AuthService {
constructor(
private readonly tokenService: TokenService,
private readonly accessTokenService: AccessTokenService,
private readonly refreshTokenService: RefreshTokenService,
private readonly signInUpService: SignInUpService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
@ -150,8 +152,14 @@ export class AuthService {
// passwordHash is hidden for security reasons
user.passwordHash = '';
const accessToken = await this.tokenService.generateAccessToken(user.id);
const refreshToken = await this.tokenService.generateRefreshToken(user.id);
const accessToken = await this.accessTokenService.generateAccessToken(
user.id,
user.defaultWorkspaceId,
);
const refreshToken = await this.refreshTokenService.generateRefreshToken(
user.id,
user.defaultWorkspaceId,
);
return {
user,
@ -209,8 +217,14 @@ export class AuthService {
);
}
const accessToken = await this.tokenService.generateAccessToken(user.id);
const refreshToken = await this.tokenService.generateRefreshToken(user.id);
const accessToken = await this.accessTokenService.generateAccessToken(
user.id,
user.defaultWorkspaceId,
);
const refreshToken = await this.refreshTokenService.generateRefreshToken(
user.id,
user.defaultWorkspaceId,
);
return {
user,
@ -384,4 +398,10 @@ export class AuthService {
return workspace;
}
computeRedirectURI(loginToken: string): string {
return `${this.environmentService.get(
'FRONT_BASE_URL',
)}/verify?loginToken=${loginToken}`;
}
}

View File

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

View File

@ -0,0 +1,217 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { addMilliseconds } from 'date-fns';
import { Repository } from 'typeorm';
import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ResetPasswordService } from './reset-password.service';
describe('ResetPasswordService', () => {
let service: ResetPasswordService;
let userRepository: Repository<User>;
let appTokenRepository: Repository<AppToken>;
let emailService: EmailService;
let environmentService: EnvironmentService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ResetPasswordService,
{
provide: getRepositoryToken(User, 'core'),
useClass: Repository,
},
{
provide: getRepositoryToken(AppToken, 'core'),
useClass: Repository,
},
{
provide: getRepositoryToken(Workspace, 'core'),
useClass: Repository,
},
{
provide: EmailService,
useValue: {
send: jest.fn().mockResolvedValue({ success: true }),
},
},
{
provide: EnvironmentService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();
service = module.get<ResetPasswordService>(ResetPasswordService);
userRepository = module.get<Repository<User>>(
getRepositoryToken(User, 'core'),
);
appTokenRepository = module.get<Repository<AppToken>>(
getRepositoryToken(AppToken, 'core'),
);
emailService = module.get<EmailService>(EmailService);
environmentService = module.get<EnvironmentService>(EnvironmentService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('generatePasswordResetToken', () => {
it('should generate a password reset token for a valid user', async () => {
const mockUser = { id: '1', email: 'test@example.com' };
jest
.spyOn(userRepository, 'findOneBy')
.mockResolvedValue(mockUser as User);
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
jest.spyOn(appTokenRepository, 'save').mockResolvedValue({} as AppToken);
jest.spyOn(environmentService, 'get').mockReturnValue('1h');
const result =
await service.generatePasswordResetToken('test@example.com');
expect(result.passwordResetToken).toBeDefined();
expect(result.passwordResetTokenExpiresAt).toBeDefined();
expect(appTokenRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
userId: '1',
type: AppTokenType.PasswordResetToken,
}),
);
});
it('should throw an error if user is not found', async () => {
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null);
await expect(
service.generatePasswordResetToken('nonexistent@example.com'),
).rejects.toThrow(AuthException);
});
it('should throw an error if a token already exists', async () => {
const mockUser = { id: '1', email: 'test@example.com' };
const mockExistingToken = {
userId: '1',
type: AppTokenType.PasswordResetToken,
expiresAt: addMilliseconds(new Date(), 3600000),
};
jest
.spyOn(userRepository, 'findOneBy')
.mockResolvedValue(mockUser as User);
jest
.spyOn(appTokenRepository, 'findOne')
.mockResolvedValue(mockExistingToken as AppToken);
await expect(
service.generatePasswordResetToken('test@example.com'),
).rejects.toThrow(AuthException);
});
});
describe('sendEmailPasswordResetLink', () => {
it('should send a password reset email', async () => {
const mockUser = { id: '1', email: 'test@example.com' };
const mockToken = {
passwordResetToken: 'token123',
passwordResetTokenExpiresAt: new Date(),
};
jest
.spyOn(userRepository, 'findOneBy')
.mockResolvedValue(mockUser as User);
jest
.spyOn(environmentService, 'get')
.mockReturnValue('http://localhost:3000');
const result = await service.sendEmailPasswordResetLink(
mockToken,
'test@example.com',
);
expect(result.success).toBe(true);
expect(emailService.send).toHaveBeenCalled();
});
it('should throw an error if user is not found', async () => {
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null);
await expect(
service.sendEmailPasswordResetLink(
{} as any,
'nonexistent@example.com',
),
).rejects.toThrow(AuthException);
});
});
describe('validatePasswordResetToken', () => {
it('should validate a correct password reset token', async () => {
const mockToken = {
userId: '1',
type: AppTokenType.PasswordResetToken,
expiresAt: addMilliseconds(new Date(), 3600000),
};
const mockUser = { id: '1', email: 'test@example.com' };
jest
.spyOn(appTokenRepository, 'findOne')
.mockResolvedValue(mockToken as AppToken);
jest
.spyOn(userRepository, 'findOneBy')
.mockResolvedValue(mockUser as User);
const result = await service.validatePasswordResetToken('validToken');
expect(result).toEqual({ id: '1', email: 'test@example.com' });
});
it('should throw an error for an invalid token', async () => {
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
await expect(
service.validatePasswordResetToken('invalidToken'),
).rejects.toThrow(AuthException);
});
});
describe('invalidatePasswordResetToken', () => {
it('should invalidate an existing password reset token', async () => {
const mockUser = { id: '1', email: 'test@example.com' };
jest
.spyOn(userRepository, 'findOneBy')
.mockResolvedValue(mockUser as User);
jest.spyOn(appTokenRepository, 'update').mockResolvedValue({} as any);
const result = await service.invalidatePasswordResetToken('1');
expect(result.success).toBe(true);
expect(appTokenRepository.update).toHaveBeenCalledWith(
{ userId: '1', type: AppTokenType.PasswordResetToken },
{ revokedAt: expect.any(Date) },
);
});
it('should throw an error if user is not found', async () => {
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null);
await expect(
service.invalidatePasswordResetToken('nonexistent'),
).rejects.toThrow(AuthException);
});
});
});

View File

@ -0,0 +1,224 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import { render } from '@react-email/render';
import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
import ms from 'ms';
import { PasswordResetLinkEmail } from 'twenty-emails';
import { IsNull, MoreThan, Repository } from 'typeorm';
import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-password-reset-link.entity';
import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity';
import { PasswordResetToken } from 'src/engine/core-modules/auth/dto/token.entity';
import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.entity';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { User } from 'src/engine/core-modules/user/user.entity';
@Injectable()
export class ResetPasswordService {
constructor(
private readonly environmentService: EnvironmentService,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@InjectRepository(AppToken, 'core')
private readonly appTokenRepository: Repository<AppToken>,
private readonly emailService: EmailService,
) {}
async generatePasswordResetToken(email: string): Promise<PasswordResetToken> {
const user = await this.userRepository.findOneBy({
email,
});
if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
const expiresIn = this.environmentService.get(
'PASSWORD_RESET_TOKEN_EXPIRES_IN',
);
if (!expiresIn) {
throw new AuthException(
'PASSWORD_RESET_TOKEN_EXPIRES_IN constant value not found',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const existingToken = await this.appTokenRepository.findOne({
where: {
userId: user.id,
type: AppTokenType.PasswordResetToken,
expiresAt: MoreThan(new Date()),
revokedAt: IsNull(),
},
});
if (existingToken) {
const timeToWait = ms(
differenceInMilliseconds(existingToken.expiresAt, new Date()),
{ long: true },
);
throw new AuthException(
`Token has already been generated. Please wait for ${timeToWait} to generate again.`,
AuthExceptionCode.INVALID_INPUT,
);
}
const plainResetToken = crypto.randomBytes(32).toString('hex');
const hashedResetToken = crypto
.createHash('sha256')
.update(plainResetToken)
.digest('hex');
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
await this.appTokenRepository.save({
userId: user.id,
value: hashedResetToken,
expiresAt,
type: AppTokenType.PasswordResetToken,
});
return {
passwordResetToken: plainResetToken,
passwordResetTokenExpiresAt: expiresAt,
};
}
async sendEmailPasswordResetLink(
resetToken: PasswordResetToken,
email: string,
): Promise<EmailPasswordResetLink> {
const user = await this.userRepository.findOneBy({
email,
});
if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
const frontBaseURL = this.environmentService.get('FRONT_BASE_URL');
const resetLink = `${frontBaseURL}/reset-password/${resetToken.passwordResetToken}`;
const emailData = {
link: resetLink,
duration: ms(
differenceInMilliseconds(
resetToken.passwordResetTokenExpiresAt,
new Date(),
),
{
long: true,
},
),
};
const emailTemplate = PasswordResetLinkEmail(emailData);
const html = render(emailTemplate, {
pretty: true,
});
const text = render(emailTemplate, {
plainText: true,
});
this.emailService.send({
from: `${this.environmentService.get(
'EMAIL_FROM_NAME',
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
to: email,
subject: 'Action Needed to Reset Password',
text,
html,
});
return { success: true };
}
async validatePasswordResetToken(
resetToken: string,
): Promise<ValidatePasswordResetToken> {
const hashedResetToken = crypto
.createHash('sha256')
.update(resetToken)
.digest('hex');
const token = await this.appTokenRepository.findOne({
where: {
value: hashedResetToken,
type: AppTokenType.PasswordResetToken,
expiresAt: MoreThan(new Date()),
revokedAt: IsNull(),
},
});
if (!token || !token.userId) {
throw new AuthException(
'Token is invalid',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const user = await this.userRepository.findOneBy({
id: token.userId,
});
if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
return {
id: user.id,
email: user.email,
};
}
async invalidatePasswordResetToken(
userId: string,
): Promise<InvalidatePassword> {
const user = await this.userRepository.findOneBy({
id: userId,
});
if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
await this.appTokenRepository.update(
{
userId,
type: AppTokenType.PasswordResetToken,
},
{
revokedAt: new Date(),
},
);
return { success: true };
}
}

View File

@ -0,0 +1,217 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { SwitchWorkspaceService } from './switch-workspace.service';
describe('SwitchWorkspaceService', () => {
let service: SwitchWorkspaceService;
let userRepository: Repository<User>;
let workspaceRepository: Repository<Workspace>;
let ssoService: SSOService;
let accessTokenService: AccessTokenService;
let refreshTokenService: RefreshTokenService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SwitchWorkspaceService,
{
provide: getRepositoryToken(User, 'core'),
useClass: Repository,
},
{
provide: getRepositoryToken(Workspace, 'core'),
useClass: Repository,
},
{
provide: SSOService,
useValue: {
listSSOIdentityProvidersByWorkspaceId: jest.fn(),
},
},
{
provide: AccessTokenService,
useValue: {
generateAccessToken: jest.fn(),
},
},
{
provide: RefreshTokenService,
useValue: {
generateRefreshToken: jest.fn(),
},
},
],
}).compile();
service = module.get<SwitchWorkspaceService>(SwitchWorkspaceService);
userRepository = module.get<Repository<User>>(
getRepositoryToken(User, 'core'),
);
workspaceRepository = module.get<Repository<Workspace>>(
getRepositoryToken(Workspace, 'core'),
);
ssoService = module.get<SSOService>(SSOService);
accessTokenService = module.get<AccessTokenService>(AccessTokenService);
refreshTokenService = module.get<RefreshTokenService>(RefreshTokenService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('switchWorkspace', () => {
it('should throw an error if user does not exist', async () => {
jest.spyOn(userRepository, 'findBy').mockResolvedValue([]);
jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null);
await expect(
service.switchWorkspace(
{ id: 'non-existent-user' } as User,
'workspace-id',
),
).rejects.toThrow(AuthException);
});
it('should throw an error if workspace does not exist', async () => {
jest
.spyOn(userRepository, 'findBy')
.mockResolvedValue([{ id: 'user-id' } as User]);
jest.spyOn(workspaceRepository, 'findOne').mockResolvedValue(null);
await expect(
service.switchWorkspace(
{ id: 'user-id' } as User,
'non-existent-workspace',
),
).rejects.toThrow(AuthException);
});
it('should throw an error if user does not belong to workspace', async () => {
const mockUser = { id: 'user-id' };
const mockWorkspace = {
id: 'workspace-id',
workspaceUsers: [{ userId: 'other-user-id' }],
workspaceSSOIdentityProviders: [],
};
jest
.spyOn(userRepository, 'findBy')
.mockResolvedValue([mockUser as User]);
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace as any);
await expect(
service.switchWorkspace(mockUser as User, 'workspace-id'),
).rejects.toThrow(AuthException);
});
it('should return SSO auth info if workspace has SSO providers', async () => {
const mockUser = { id: 'user-id' };
const mockWorkspace = {
id: 'workspace-id',
workspaceUsers: [{ userId: 'user-id' }],
workspaceSSOIdentityProviders: [{}],
};
const mockSSOProviders = [{ id: 'sso-provider-id' }];
jest
.spyOn(userRepository, 'findBy')
.mockResolvedValue([mockUser as User]);
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace as any);
jest
.spyOn(ssoService, 'listSSOIdentityProvidersByWorkspaceId')
.mockResolvedValue(mockSSOProviders as any);
const result = await service.switchWorkspace(
mockUser as User,
'workspace-id',
);
expect(result).toEqual({
useSSOAuth: true,
workspace: mockWorkspace,
availableSSOIdentityProviders: mockSSOProviders,
});
});
it('should return workspace info if workspace does not have SSO providers', async () => {
const mockUser = { id: 'user-id' };
const mockWorkspace = {
id: 'workspace-id',
workspaceUsers: [{ userId: 'user-id' }],
workspaceSSOIdentityProviders: [],
};
jest
.spyOn(userRepository, 'findBy')
.mockResolvedValue([mockUser as User]);
jest
.spyOn(workspaceRepository, 'findOne')
.mockResolvedValue(mockWorkspace as any);
const result = await service.switchWorkspace(
mockUser as User,
'workspace-id',
);
expect(result).toEqual({
useSSOAuth: false,
workspace: mockWorkspace,
});
});
});
describe('generateSwitchWorkspaceToken', () => {
it('should generate and return auth tokens', async () => {
const mockUser = { id: 'user-id' };
const mockWorkspace = { id: 'workspace-id' };
const mockAccessToken = { token: 'access-token', expiresAt: new Date() };
const mockRefreshToken = 'refresh-token';
jest.spyOn(userRepository, 'save').mockResolvedValue({} as User);
jest
.spyOn(accessTokenService, 'generateAccessToken')
.mockResolvedValue(mockAccessToken);
jest
.spyOn(refreshTokenService, 'generateRefreshToken')
.mockResolvedValue(mockRefreshToken as any);
const result = await service.generateSwitchWorkspaceToken(
mockUser as User,
mockWorkspace as Workspace,
);
expect(result).toEqual({
tokens: {
accessToken: mockAccessToken,
refreshToken: mockRefreshToken,
},
});
expect(userRepository.save).toHaveBeenCalledWith({
id: mockUser.id,
defaultWorkspace: mockWorkspace,
});
expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith(
mockUser.id,
mockWorkspace.id,
);
expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith(
mockUser.id,
mockWorkspace.id,
);
});
});
});

View File

@ -0,0 +1,115 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { AuthTokens } from 'src/engine/core-modules/auth/dto/token.entity';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class SwitchWorkspaceService {
constructor(
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
private readonly ssoService: SSOService,
private readonly accessTokenService: AccessTokenService,
private readonly refreshTokenService: RefreshTokenService,
) {}
async switchWorkspace(user: User, workspaceId: string) {
const userExists = await this.userRepository.findBy({ id: user.id });
if (!userExists) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
relations: ['workspaceUsers', 'workspaceSSOIdentityProviders'],
});
if (!workspace) {
throw new AuthException(
'workspace doesnt exist',
AuthExceptionCode.INVALID_INPUT,
);
}
if (
!workspace.workspaceUsers
.map((userWorkspace) => userWorkspace.userId)
.includes(user.id)
) {
throw new AuthException(
'user does not belong to workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
if (workspace.workspaceSSOIdentityProviders.length > 0) {
return {
useSSOAuth: true,
workspace,
availableSSOIdentityProviders:
await this.ssoService.listSSOIdentityProvidersByWorkspaceId(
workspaceId,
),
} as {
useSSOAuth: true;
workspace: Workspace;
availableSSOIdentityProviders: Awaited<
ReturnType<
typeof this.ssoService.listSSOIdentityProvidersByWorkspaceId
>
>;
};
}
return {
useSSOAuth: false,
workspace,
} as {
useSSOAuth: false;
workspace: Workspace;
};
}
async generateSwitchWorkspaceToken(
user: User,
workspace: Workspace,
): Promise<AuthTokens> {
await this.userRepository.save({
id: user.id,
defaultWorkspace: workspace,
});
const token = await this.accessTokenService.generateAccessToken(
user.id,
workspace.id,
);
const refreshToken = await this.refreshTokenService.generateRefreshToken(
user.id,
workspace.id,
);
return {
tokens: {
accessToken: token,
refreshToken,
},
};
}
}

View File

@ -12,6 +12,7 @@ import {
} from 'src/engine/core-modules/auth/auth.exception';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
@ -28,6 +29,7 @@ export type JwtPayload = {
export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
private readonly environmentService: EnvironmentService,
private readonly jwtWrapperService: JwtWrapperService,
private readonly typeORMService: TypeORMService,
private readonly dataSourceService: DataSourceService,
@InjectRepository(Workspace, 'core')
@ -38,7 +40,22 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: environmentService.get('ACCESS_TOKEN_SECRET'),
secretOrKeyProvider: async (request, rawJwtToken, done) => {
try {
const decodedToken = this.jwtWrapperService.decode(
rawJwtToken,
) as JwtPayload;
const workspaceId = decodedToken.workspaceId;
const secret = this.jwtWrapperService.generateAppSecret(
'ACCESS',
workspaceId,
);
done(null, secret);
} catch (error) {
done(error, null);
}
},
});
}

View File

@ -0,0 +1,192 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Request } from 'express';
import { Repository } from 'typeorm';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { AccessTokenService } from './access-token.service';
describe('AccessTokenService', () => {
let service: AccessTokenService;
let jwtWrapperService: JwtWrapperService;
let environmentService: EnvironmentService;
let userRepository: Repository<User>;
let twentyORMGlobalManager: TwentyORMGlobalManager;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AccessTokenService,
{
provide: JwtWrapperService,
useValue: {
sign: jest.fn(),
verifyWorkspaceToken: jest.fn(),
decode: jest.fn(),
generateAppSecret: jest.fn(),
},
},
{
provide: JwtAuthStrategy,
useValue: {
validate: jest.fn(),
},
},
{
provide: EnvironmentService,
useValue: {
get: jest.fn(),
},
},
{
provide: getRepositoryToken(User, 'core'),
useClass: Repository,
},
{
provide: getRepositoryToken(AppToken, 'core'),
useClass: Repository,
},
{
provide: getRepositoryToken(Workspace, 'core'),
useClass: Repository,
},
{
provide: EmailService,
useValue: {},
},
{
provide: SSOService,
useValue: {},
},
{
provide: TwentyORMGlobalManager,
useValue: {
getRepositoryForWorkspace: jest.fn(),
},
},
],
}).compile();
service = module.get<AccessTokenService>(AccessTokenService);
jwtWrapperService = module.get<JwtWrapperService>(JwtWrapperService);
environmentService = module.get<EnvironmentService>(EnvironmentService);
userRepository = module.get<Repository<User>>(
getRepositoryToken(User, 'core'),
);
twentyORMGlobalManager = module.get<TwentyORMGlobalManager>(
TwentyORMGlobalManager,
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('generateAccessToken', () => {
it('should generate an access token successfully', async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
const mockUser = {
id: userId,
defaultWorkspace: { id: workspaceId, activationStatus: 'ACTIVE' },
defaultWorkspaceId: workspaceId,
};
const mockWorkspaceMember = { id: 'workspace-member-id' };
const mockToken = 'mock-token';
jest.spyOn(environmentService, 'get').mockReturnValue('1h');
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as User);
jest
.spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace')
.mockResolvedValue({
findOne: jest.fn().mockResolvedValue(mockWorkspaceMember),
} as any);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
const result = await service.generateAccessToken(userId, workspaceId);
expect(result).toEqual({
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect.objectContaining({
sub: userId,
workspaceId: workspaceId,
workspaceMemberId: mockWorkspaceMember.id,
}),
expect.any(Object),
);
});
it('should throw an error if user is not found', async () => {
jest.spyOn(environmentService, 'get').mockReturnValue('1h');
jest.spyOn(userRepository, 'findOne').mockResolvedValue(null);
await expect(
service.generateAccessToken('non-existent-user', 'workspace-id'),
).rejects.toThrow(AuthException);
});
});
describe('validateToken', () => {
it('should validate a token successfully', async () => {
const mockToken = 'valid-token';
const mockRequest = {
headers: {
authorization: `Bearer ${mockToken}`,
},
} as Request;
const mockDecodedToken = { sub: 'user-id', workspaceId: 'workspace-id' };
const mockAuthContext = {
user: { id: 'user-id' },
apiKey: null,
workspace: { id: 'workspace-id' },
workspaceMemberId: 'workspace-member-id',
};
jest
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
.mockResolvedValue(undefined);
jest
.spyOn(jwtWrapperService, 'decode')
.mockReturnValue(mockDecodedToken as any);
jest
.spyOn(service['jwtStrategy'], 'validate')
.mockReturnValue(mockAuthContext as any);
const result = await service.validateToken(mockRequest);
expect(result).toEqual(mockAuthContext);
expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith(
mockToken,
'ACCESS',
);
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken);
expect(service['jwtStrategy'].validate).toHaveBeenCalledWith(
mockDecodedToken,
);
});
it('should throw an error if token is missing', async () => {
const mockRequest = {
headers: {},
} as Request;
await expect(service.validateToken(mockRequest)).rejects.toThrow(
AuthException,
);
});
});
});

View File

@ -0,0 +1,134 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { addMilliseconds } from 'date-fns';
import { Request } from 'express';
import ms from 'ms';
import { ExtractJwt } from 'passport-jwt';
import { Repository } from 'typeorm';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
import {
JwtAuthStrategy,
JwtPayload,
} from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceActivationStatus } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class AccessTokenService {
constructor(
private readonly jwtWrapperService: JwtWrapperService,
private readonly jwtStrategy: JwtAuthStrategy,
private readonly environmentService: EnvironmentService,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
async generateAccessToken(
userId: string,
workspaceId: string,
): Promise<AuthToken> {
const expiresIn = this.environmentService.get('ACCESS_TOKEN_EXPIRES_IN');
if (!expiresIn) {
throw new AuthException(
'Expiration time for access token is not set',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['defaultWorkspace'],
});
if (!user) {
throw new AuthException(
'User is not found',
AuthExceptionCode.INVALID_INPUT,
);
}
if (!user.defaultWorkspace) {
throw new AuthException(
'User does not have a default workspace',
AuthExceptionCode.INVALID_DATA,
);
}
const tokenWorkspaceId = workspaceId ?? user.defaultWorkspaceId;
let tokenWorkspaceMemberId: string | undefined;
if (
user.defaultWorkspace.activationStatus ===
WorkspaceActivationStatus.ACTIVE
) {
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
tokenWorkspaceId,
'workspaceMember',
);
const workspaceMember = await workspaceMemberRepository.findOne({
where: {
userId: user.id,
},
});
if (!workspaceMember) {
throw new AuthException(
'User is not a member of the workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
tokenWorkspaceMemberId = workspaceMember.id;
}
const jwtPayload: JwtPayload = {
sub: user.id,
workspaceId: workspaceId ? workspaceId : user.defaultWorkspaceId,
workspaceMemberId: tokenWorkspaceMemberId,
};
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret: this.jwtWrapperService.generateAppSecret('ACCESS', workspaceId),
}),
expiresAt,
};
}
async validateToken(request: Request): Promise<AuthContext> {
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
if (!token) {
throw new AuthException(
'missing authentication token',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
await this.jwtWrapperService.verifyWorkspaceToken(token, 'ACCESS');
const decoded = await this.jwtWrapperService.decode(token);
const { user, apiKey, workspace, workspaceMemberId } =
await this.jwtStrategy.validate(decoded as JwtPayload);
return { user, apiKey, workspace, workspaceMemberId };
}
}

View File

@ -0,0 +1,117 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { LoginTokenService } from './login-token.service';
describe('LoginTokenService', () => {
let service: LoginTokenService;
let jwtWrapperService: JwtWrapperService;
let environmentService: EnvironmentService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
LoginTokenService,
{
provide: JwtWrapperService,
useValue: {
generateAppSecret: jest.fn(),
sign: jest.fn(),
verifyWorkspaceToken: jest.fn(),
decode: jest.fn(),
},
},
{
provide: EnvironmentService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();
service = module.get<LoginTokenService>(LoginTokenService);
jwtWrapperService = module.get<JwtWrapperService>(JwtWrapperService);
environmentService = module.get<EnvironmentService>(EnvironmentService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('generateLoginToken', () => {
it('should generate a login token successfully', async () => {
const email = 'test@example.com';
const mockSecret = 'mock-secret';
const mockExpiresIn = '1h';
const mockToken = 'mock-token';
jest
.spyOn(jwtWrapperService, 'generateAppSecret')
.mockReturnValue(mockSecret);
jest.spyOn(environmentService, 'get').mockReturnValue(mockExpiresIn);
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
const result = await service.generateLoginToken(email);
expect(result).toEqual({
token: mockToken,
expiresAt: expect.any(Date),
});
expect(jwtWrapperService.generateAppSecret).toHaveBeenCalledWith('LOGIN');
expect(environmentService.get).toHaveBeenCalledWith(
'LOGIN_TOKEN_EXPIRES_IN',
);
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
{ sub: email },
{ secret: mockSecret, expiresIn: mockExpiresIn },
);
});
it('should throw an error if LOGIN_TOKEN_EXPIRES_IN is not set', async () => {
jest.spyOn(environmentService, 'get').mockReturnValue(undefined);
await expect(
service.generateLoginToken('test@example.com'),
).rejects.toThrow(AuthException);
});
});
describe('verifyLoginToken', () => {
it('should verify a login token successfully', async () => {
const mockToken = 'valid-token';
const mockEmail = 'test@example.com';
jest
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
.mockResolvedValue(undefined);
jest
.spyOn(jwtWrapperService, 'decode')
.mockReturnValue({ sub: mockEmail });
const result = await service.verifyLoginToken(mockToken);
expect(result).toEqual(mockEmail);
expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith(
mockToken,
'LOGIN',
);
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken, {
json: true,
});
});
it('should throw an error if token verification fails', async () => {
const mockToken = 'invalid-token';
jest
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
.mockRejectedValue(new Error('Invalid token'));
await expect(service.verifyLoginToken(mockToken)).rejects.toThrow();
});
});
});

View File

@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@Injectable()
export class LoginTokenService {
constructor(
private readonly jwtWrapperService: JwtWrapperService,
private readonly environmentService: EnvironmentService,
) {}
async generateLoginToken(email: string): Promise<AuthToken> {
const secret = this.jwtWrapperService.generateAppSecret('LOGIN');
const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN');
if (!expiresIn) {
throw new AuthException(
'Expiration time for access token is not set',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const jwtPayload = {
sub: email,
};
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret,
expiresIn,
}),
expiresAt,
};
}
async verifyLoginToken(loginToken: string): Promise<string> {
await this.jwtWrapperService.verifyWorkspaceToken(loginToken, 'LOGIN');
return this.jwtWrapperService.decode(loginToken, {
json: true,
}).sub;
}
}

View File

@ -0,0 +1,156 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { RefreshTokenService } from './refresh-token.service';
describe('RefreshTokenService', () => {
let service: RefreshTokenService;
let jwtWrapperService: JwtWrapperService;
let environmentService: EnvironmentService;
let appTokenRepository: Repository<AppToken>;
let userRepository: Repository<User>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RefreshTokenService,
{
provide: JwtWrapperService,
useValue: {
verifyWorkspaceToken: jest.fn(),
decode: jest.fn(),
sign: jest.fn(),
generateAppSecret: jest.fn(),
},
},
{
provide: EnvironmentService,
useValue: {
get: jest.fn(),
},
},
{
provide: getRepositoryToken(AppToken, 'core'),
useClass: Repository,
},
{
provide: getRepositoryToken(User, 'core'),
useClass: Repository,
},
],
}).compile();
service = module.get<RefreshTokenService>(RefreshTokenService);
jwtWrapperService = module.get<JwtWrapperService>(JwtWrapperService);
environmentService = module.get<EnvironmentService>(EnvironmentService);
appTokenRepository = module.get<Repository<AppToken>>(
getRepositoryToken(AppToken, 'core'),
);
userRepository = module.get<Repository<User>>(
getRepositoryToken(User, 'core'),
);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('verifyRefreshToken', () => {
it('should verify a refresh token successfully', async () => {
const mockToken = 'valid-refresh-token';
const mockJwtPayload = { jti: 'token-id', sub: 'user-id' };
const mockAppToken = { id: 'token-id', revokedAt: null };
const mockUser: Partial<User> = {
id: 'some-id',
firstName: 'John',
lastName: 'Doe',
email: 'john.doe@example.com',
defaultAvatarUrl: '',
};
jest
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
.mockResolvedValue(undefined);
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockJwtPayload);
jest
.spyOn(appTokenRepository, 'findOneBy')
.mockResolvedValue(mockAppToken as AppToken);
jest.spyOn(userRepository, 'findOne').mockResolvedValue(mockUser as User);
jest.spyOn(environmentService, 'get').mockReturnValue('1h');
const result = await service.verifyRefreshToken(mockToken);
expect(result).toEqual({ user: mockUser, token: mockAppToken });
expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith(
mockToken,
'REFRESH',
);
});
it('should throw an error if the token is malformed', async () => {
const mockToken = 'invalid-token';
jest
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
.mockResolvedValue(undefined);
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue({});
await expect(service.verifyRefreshToken(mockToken)).rejects.toThrow(
AuthException,
);
});
});
describe('generateRefreshToken', () => {
it('should generate a refresh token successfully', async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
const mockToken = 'mock-refresh-token';
const mockExpiresIn = '7d';
jest.spyOn(environmentService, 'get').mockReturnValue(mockExpiresIn);
jest
.spyOn(jwtWrapperService, 'generateAppSecret')
.mockReturnValue('mock-secret');
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
jest
.spyOn(appTokenRepository, 'create')
.mockReturnValue({ id: 'new-token-id' } as AppToken);
jest
.spyOn(appTokenRepository, 'save')
.mockResolvedValue({ id: 'new-token-id' } as AppToken);
const result = await service.generateRefreshToken(userId, workspaceId);
expect(result).toEqual({
token: mockToken,
expiresAt: expect.any(Date),
});
expect(appTokenRepository.save).toHaveBeenCalled();
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
{ sub: userId },
expect.objectContaining({
secret: 'mock-secret',
expiresIn: mockExpiresIn,
jwtid: 'new-token-id',
}),
);
});
it('should throw an error if expiration time is not set', async () => {
jest.spyOn(environmentService, 'get').mockReturnValue(undefined);
await expect(
service.generateRefreshToken('user-id', 'workspace-id'),
).rejects.toThrow(AuthException);
});
});
});

View File

@ -0,0 +1,138 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { Repository } from 'typeorm';
import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { User } from 'src/engine/core-modules/user/user.entity';
@Injectable()
export class RefreshTokenService {
constructor(
private readonly jwtWrapperService: JwtWrapperService,
private readonly environmentService: EnvironmentService,
@InjectRepository(AppToken, 'core')
private readonly appTokenRepository: Repository<AppToken>,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
) {}
async verifyRefreshToken(refreshToken: string) {
const coolDown = this.environmentService.get('REFRESH_TOKEN_COOL_DOWN');
await this.jwtWrapperService.verifyWorkspaceToken(refreshToken, 'REFRESH');
const jwtPayload = await this.jwtWrapperService.decode(refreshToken);
if (!(jwtPayload.jti && jwtPayload.sub)) {
throw new AuthException(
'This refresh token is malformed',
AuthExceptionCode.INVALID_INPUT,
);
}
const token = await this.appTokenRepository.findOneBy({
id: jwtPayload.jti,
});
if (!token) {
throw new AuthException(
"This refresh token doesn't exist",
AuthExceptionCode.INVALID_INPUT,
);
}
const user = await this.userRepository.findOne({
where: { id: jwtPayload.sub },
relations: ['appTokens'],
});
if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
// Check if revokedAt is less than coolDown
if (
token.revokedAt &&
token.revokedAt.getTime() <= Date.now() - ms(coolDown)
) {
// Revoke all user refresh tokens
await Promise.all(
user.appTokens.map(async ({ id, type }) => {
if (type === AppTokenType.RefreshToken) {
await this.appTokenRepository.update(
{ id },
{
revokedAt: new Date(),
},
);
}
}),
);
throw new AuthException(
'Suspicious activity detected, this refresh token has been revoked. All tokens have been revoked.',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
return { user, token };
}
async generateRefreshToken(
userId: string,
workspaceId: string,
): Promise<AuthToken> {
const secret = this.jwtWrapperService.generateAppSecret(
'REFRESH',
workspaceId,
);
const expiresIn = this.environmentService.get('REFRESH_TOKEN_EXPIRES_IN');
if (!expiresIn) {
throw new AuthException(
'Expiration time for access token is not set',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const refreshTokenPayload = {
userId,
expiresAt,
type: AppTokenType.RefreshToken,
};
const jwtPayload = {
sub: userId,
};
const refreshToken = this.appTokenRepository.create(refreshTokenPayload);
await this.appTokenRepository.save(refreshToken);
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret,
expiresIn,
// Jwtid will be used to link RefreshToken entity to this token
jwtid: refreshToken.id,
}),
expiresAt,
};
}
}

View File

@ -0,0 +1,119 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { RenewTokenService } from './renew-token.service';
describe('RenewTokenService', () => {
let service: RenewTokenService;
let appTokenRepository: Repository<AppToken>;
let accessTokenService: AccessTokenService;
let refreshTokenService: RefreshTokenService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
RenewTokenService,
{
provide: getRepositoryToken(AppToken, 'core'),
useClass: Repository,
},
{
provide: AccessTokenService,
useValue: {
generateAccessToken: jest.fn(),
},
},
{
provide: RefreshTokenService,
useValue: {
verifyRefreshToken: jest.fn(),
generateRefreshToken: jest.fn(),
},
},
],
}).compile();
service = module.get<RenewTokenService>(RenewTokenService);
appTokenRepository = module.get<Repository<AppToken>>(
getRepositoryToken(AppToken, 'core'),
);
accessTokenService = module.get<AccessTokenService>(AccessTokenService);
refreshTokenService = module.get<RefreshTokenService>(RefreshTokenService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('generateTokensFromRefreshToken', () => {
it('should generate new access and refresh tokens', async () => {
const mockRefreshToken = 'valid-refresh-token';
const mockUser = { id: 'user-id' } as User;
const mockWorkspaceId = 'workspace-id';
const mockTokenId = 'token-id';
const mockAccessToken = {
token: 'new-access-token',
expiresAt: new Date(),
};
const mockNewRefreshToken = {
token: 'new-refresh-token',
expiresAt: new Date(),
};
const mockAppToken: Partial<AppToken> = {
id: mockTokenId,
workspaceId: mockWorkspaceId,
user: mockUser,
userId: mockUser.id,
};
jest.spyOn(refreshTokenService, 'verifyRefreshToken').mockResolvedValue({
user: mockUser,
token: mockAppToken as AppToken,
});
jest.spyOn(appTokenRepository, 'update').mockResolvedValue({} as any);
jest
.spyOn(accessTokenService, 'generateAccessToken')
.mockResolvedValue(mockAccessToken);
jest
.spyOn(refreshTokenService, 'generateRefreshToken')
.mockResolvedValue(mockNewRefreshToken);
const result =
await service.generateTokensFromRefreshToken(mockRefreshToken);
expect(result).toEqual({
accessToken: mockAccessToken,
refreshToken: mockNewRefreshToken,
});
expect(refreshTokenService.verifyRefreshToken).toHaveBeenCalledWith(
mockRefreshToken,
);
expect(appTokenRepository.update).toHaveBeenCalledWith(
{ id: mockTokenId },
{ revokedAt: expect.any(Date) },
);
expect(accessTokenService.generateAccessToken).toHaveBeenCalledWith(
mockUser.id,
mockWorkspaceId,
);
expect(refreshTokenService.generateRefreshToken).toHaveBeenCalledWith(
mockUser.id,
mockWorkspaceId,
);
});
it('should throw an error if refresh token is not provided', async () => {
await expect(service.generateTokensFromRefreshToken('')).rejects.toThrow(
AuthException,
);
});
});
});

View File

@ -0,0 +1,64 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
@Injectable()
export class RenewTokenService {
constructor(
@InjectRepository(AppToken, 'core')
private readonly appTokenRepository: Repository<AppToken>,
private readonly accessTokenService: AccessTokenService,
private readonly refreshTokenService: RefreshTokenService,
) {}
async generateTokensFromRefreshToken(token: string): Promise<{
accessToken: AuthToken;
refreshToken: AuthToken;
}> {
if (!token) {
throw new AuthException(
'Refresh token not found',
AuthExceptionCode.INVALID_INPUT,
);
}
const {
user,
token: { id, workspaceId },
} = await this.refreshTokenService.verifyRefreshToken(token);
// Revoke old refresh token
await this.appTokenRepository.update(
{
id,
},
{
revokedAt: new Date(),
},
);
const accessToken = await this.accessTokenService.generateAccessToken(
user.id,
workspaceId,
);
const refreshToken = await this.refreshTokenService.generateRefreshToken(
user.id,
workspaceId,
);
return {
accessToken,
refreshToken,
};
}
}

View File

@ -1,248 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import crypto from 'crypto';
import { IsNull, MoreThan, Repository } from 'typeorm';
import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
import { TokenService } from './token.service';
describe('TokenService', () => {
let service: TokenService;
let environmentService: EnvironmentService;
let userRepository: Repository<User>;
let appTokenRepository: Repository<AppToken>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TokenService,
{
provide: JwtWrapperService,
useValue: {},
},
{
provide: JwtAuthStrategy,
useValue: {},
},
{
provide: EnvironmentService,
useValue: {
get: jest.fn().mockReturnValue('some-value'),
},
},
{
provide: EmailService,
useValue: {
send: jest.fn(),
},
},
{
provide: SSOService,
useValue: {
send: jest.fn(),
},
},
{
provide: getRepositoryToken(User, 'core'),
useValue: {
findOneBy: jest.fn(),
},
},
{
provide: getRepositoryToken(AppToken, 'core'),
useValue: {
findOne: jest.fn(),
save: jest.fn(),
},
},
{
provide: getRepositoryToken(Workspace, 'core'),
useValue: {},
},
{
provide: TwentyORMGlobalManager,
useValue: {},
},
],
}).compile();
service = module.get<TokenService>(TokenService);
environmentService = module.get<EnvironmentService>(EnvironmentService);
userRepository = module.get(getRepositoryToken(User, 'core'));
appTokenRepository = module.get(getRepositoryToken(AppToken, 'core'));
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('generatePasswordResetToken', () => {
it('should generate a new password reset token when no existing token is found', async () => {
const mockUser = { id: '1', email: 'test@example.com' } as User;
const expiresIn = '3600000'; // 1 hour in ms
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser);
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
jest.spyOn(environmentService, 'get').mockReturnValue(expiresIn);
jest
.spyOn(appTokenRepository, 'save')
.mockImplementation(async (token) => token as AppToken);
const result = await service.generatePasswordResetToken(mockUser.email);
expect(userRepository.findOneBy).toHaveBeenCalledWith({
email: mockUser.email,
});
expect(appTokenRepository.findOne).toHaveBeenCalled();
expect(appTokenRepository.save).toHaveBeenCalled();
expect(result.passwordResetToken).toBeDefined();
expect(result.passwordResetTokenExpiresAt).toBeDefined();
});
it('should throw AuthException if an existing valid token is found', async () => {
const mockUser = { id: '1', email: 'test@example.com' } as User;
const mockToken = {
userId: '1',
type: AppTokenType.PasswordResetToken,
expiresAt: new Date(Date.now() + 10000), // expires 10 seconds in the future
} as AppToken;
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser);
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(mockToken);
jest.spyOn(environmentService, 'get').mockReturnValue('3600000');
await expect(
service.generatePasswordResetToken(mockUser.email),
).rejects.toThrow(AuthException);
});
it('should throw AuthException if no user is found', async () => {
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null);
await expect(
service.generatePasswordResetToken('nonexistent@example.com'),
).rejects.toThrow(AuthException);
});
it('should throw AuthException if environment variable is not found', async () => {
const mockUser = { id: '1', email: 'test@example.com' } as User;
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(mockUser);
jest.spyOn(environmentService, 'get').mockReturnValue(''); // No environment variable set
await expect(
service.generatePasswordResetToken(mockUser.email),
).rejects.toThrow(AuthException);
});
});
describe('validatePasswordResetToken', () => {
it('should return user data for a valid and active token', async () => {
const resetToken = 'valid-reset-token';
const hashedToken = crypto
.createHash('sha256')
.update(resetToken)
.digest('hex');
const mockToken = {
userId: '1',
value: hashedToken,
type: AppTokenType.PasswordResetToken,
expiresAt: new Date(Date.now() + 10000), // Valid future date
};
const mockUser = { id: '1', email: 'user@example.com' };
jest
.spyOn(appTokenRepository, 'findOne')
.mockResolvedValue(mockToken as AppToken);
jest
.spyOn(userRepository, 'findOneBy')
.mockResolvedValue(mockUser as User);
const result = await service.validatePasswordResetToken(resetToken);
expect(appTokenRepository.findOne).toHaveBeenCalledWith({
where: {
value: hashedToken,
type: AppTokenType.PasswordResetToken,
expiresAt: MoreThan(new Date()),
revokedAt: IsNull(),
},
});
expect(userRepository.findOneBy).toHaveBeenCalledWith({
id: mockToken.userId,
});
expect(result).toEqual({ id: mockUser.id, email: mockUser.email });
});
it('should throw AuthException if token is invalid or expired', async () => {
const resetToken = 'invalid-reset-token';
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
await expect(
service.validatePasswordResetToken(resetToken),
).rejects.toThrow(AuthException);
});
it('should throw AuthException if user does not exist for a valid token', async () => {
const resetToken = 'orphan-token';
const hashedToken = crypto
.createHash('sha256')
.update(resetToken)
.digest('hex');
const mockToken = {
userId: 'nonexistent-user',
value: hashedToken,
type: AppTokenType.PasswordResetToken,
expiresAt: new Date(Date.now() + 10000), // Valid future date
revokedAt: null,
};
jest
.spyOn(appTokenRepository, 'findOne')
.mockResolvedValue(mockToken as AppToken);
jest.spyOn(userRepository, 'findOneBy').mockResolvedValue(null);
await expect(
service.validatePasswordResetToken(resetToken),
).rejects.toThrow(AuthException);
});
it('should throw AuthException if token is revoked', async () => {
const resetToken = 'revoked-token';
const hashedToken = crypto
.createHash('sha256')
.update(resetToken)
.digest('hex');
const mockToken = {
userId: '1',
value: hashedToken,
type: AppTokenType.PasswordResetToken,
expiresAt: new Date(Date.now() + 10000),
revokedAt: new Date(),
};
jest
.spyOn(appTokenRepository, 'findOne')
.mockResolvedValue(mockToken as AppToken);
await expect(
service.validatePasswordResetToken(resetToken),
).rejects.toThrow(AuthException);
});
});
});

View File

@ -1,861 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import { render } from '@react-email/render';
import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
import { Request } from 'express';
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import ms from 'ms';
import { ExtractJwt } from 'passport-jwt';
import { PasswordResetLinkEmail } from 'twenty-emails';
import { IsNull, MoreThan, Repository } from 'typeorm';
import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { EmailPasswordResetLink } from 'src/engine/core-modules/auth/dto/email-password-reset-link.entity';
import { ExchangeAuthCode } from 'src/engine/core-modules/auth/dto/exchange-auth-code.entity';
import { ExchangeAuthCodeInput } from 'src/engine/core-modules/auth/dto/exchange-auth-code.input';
import { InvalidatePassword } from 'src/engine/core-modules/auth/dto/invalidate-password.entity';
import {
ApiKeyToken,
AuthToken,
AuthTokens,
PasswordResetToken,
} from 'src/engine/core-modules/auth/dto/token.entity';
import { ValidatePasswordResetToken } from 'src/engine/core-modules/auth/dto/validate-password-reset-token.entity';
import {
JwtAuthStrategy,
JwtPayload,
} from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import {
Workspace,
WorkspaceActivationStatus,
} from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { SSOService } from 'src/engine/core-modules/sso/services/sso.service';
@Injectable()
export class TokenService {
constructor(
private readonly jwtWrapperService: JwtWrapperService,
private readonly jwtStrategy: JwtAuthStrategy,
private readonly environmentService: EnvironmentService,
@InjectRepository(User, 'core')
private readonly userRepository: Repository<User>,
@InjectRepository(AppToken, 'core')
private readonly appTokenRepository: Repository<AppToken>,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
private readonly emailService: EmailService,
private readonly sSSOService: SSOService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
async generateAccessToken(
userId: string,
workspaceId?: string,
): Promise<AuthToken> {
const expiresIn = this.environmentService.get('ACCESS_TOKEN_EXPIRES_IN');
if (!expiresIn) {
throw new AuthException(
'Expiration time for access token is not set',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['defaultWorkspace'],
});
if (!user) {
throw new AuthException(
'User is not found',
AuthExceptionCode.INVALID_INPUT,
);
}
if (!user.defaultWorkspace) {
throw new AuthException(
'User does not have a default workspace',
AuthExceptionCode.INVALID_DATA,
);
}
const tokenWorkspaceId = workspaceId ?? user.defaultWorkspaceId;
let tokenWorkspaceMemberId: string | undefined;
if (
user.defaultWorkspace.activationStatus ===
WorkspaceActivationStatus.ACTIVE
) {
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
tokenWorkspaceId,
'workspaceMember',
);
const workspaceMember = await workspaceMemberRepository.findOne({
where: {
userId: user.id,
},
});
if (!workspaceMember) {
throw new AuthException(
'User is not a member of the workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
tokenWorkspaceMemberId = workspaceMember.id;
}
const jwtPayload: JwtPayload = {
sub: user.id,
workspaceId: workspaceId ? workspaceId : user.defaultWorkspaceId,
workspaceMemberId: tokenWorkspaceMemberId,
};
return {
token: this.jwtWrapperService.sign(jwtPayload),
expiresAt,
};
}
async generateRefreshToken(userId: string): Promise<AuthToken> {
const secret = this.environmentService.get('REFRESH_TOKEN_SECRET');
const expiresIn = this.environmentService.get('REFRESH_TOKEN_EXPIRES_IN');
if (!expiresIn) {
throw new AuthException(
'Expiration time for access token is not set',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const refreshTokenPayload = {
userId,
expiresAt,
type: AppTokenType.RefreshToken,
};
const jwtPayload = {
sub: userId,
};
const refreshToken = this.appTokenRepository.create(refreshTokenPayload);
await this.appTokenRepository.save(refreshToken);
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret,
expiresIn,
// Jwtid will be used to link RefreshToken entity to this token
jwtid: refreshToken.id,
}),
expiresAt,
};
}
async generateInvitationToken(workspaceId: string, email: string) {
const expiresIn = this.environmentService.get(
'INVITATION_TOKEN_EXPIRES_IN',
);
if (!expiresIn) {
throw new AuthException(
'Expiration time for invitation token is not set',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const invitationToken = this.appTokenRepository.create({
workspaceId,
expiresAt,
type: AppTokenType.InvitationToken,
value: crypto.randomBytes(32).toString('hex'),
context: {
email,
},
});
return this.appTokenRepository.save(invitationToken);
}
async generateLoginToken(email: string): Promise<AuthToken> {
const secret = this.environmentService.get('LOGIN_TOKEN_SECRET');
const expiresIn = this.environmentService.get('LOGIN_TOKEN_EXPIRES_IN');
if (!expiresIn) {
throw new AuthException(
'Expiration time for access token is not set',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const jwtPayload = {
sub: email,
};
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret,
expiresIn,
}),
expiresAt,
};
}
async generateTransientToken(
workspaceMemberId: string,
userId: string,
workspaceId: string,
): Promise<AuthToken> {
const secret = this.environmentService.get('LOGIN_TOKEN_SECRET');
const expiresIn = this.environmentService.get(
'SHORT_TERM_TOKEN_EXPIRES_IN',
);
if (!expiresIn) {
throw new AuthException(
'Expiration time for access token is not set',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const jwtPayload = {
sub: workspaceMemberId,
userId,
workspaceId,
};
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret,
expiresIn,
}),
expiresAt,
};
}
async generateApiKeyToken(
workspaceId: string,
apiKeyId?: string,
expiresAt?: Date | string,
): Promise<Pick<ApiKeyToken, 'token'> | undefined> {
if (!apiKeyId) {
return;
}
const jwtPayload = {
sub: workspaceId,
};
const secret = this.environmentService.get('ACCESS_TOKEN_SECRET');
let expiresIn: string | number;
if (expiresAt) {
expiresIn = Math.floor(
(new Date(expiresAt).getTime() - new Date().getTime()) / 1000,
);
} else {
expiresIn = this.environmentService.get('API_TOKEN_EXPIRES_IN');
}
const token = this.jwtWrapperService.sign(jwtPayload, {
secret,
expiresIn,
jwtid: apiKeyId,
});
return { token };
}
isTokenPresent(request: Request): boolean {
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
return !!token;
}
async validateToken(request: Request): Promise<AuthContext> {
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
if (!token) {
throw new AuthException(
'missing authentication token',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const decoded = await this.verifyJwt(
token,
this.environmentService.get('ACCESS_TOKEN_SECRET'),
);
const { user, apiKey, workspace, workspaceMemberId } =
await this.jwtStrategy.validate(decoded as JwtPayload);
return { user, apiKey, workspace, workspaceMemberId };
}
async verifyLoginToken(loginToken: string): Promise<string> {
const loginTokenSecret = this.environmentService.get('LOGIN_TOKEN_SECRET');
const payload = await this.verifyJwt(loginToken, loginTokenSecret);
return payload.sub;
}
async verifyTransientToken(transientToken: string): Promise<{
workspaceMemberId: string;
userId: string;
workspaceId: string;
}> {
const transientTokenSecret =
this.environmentService.get('LOGIN_TOKEN_SECRET');
const payload = await this.verifyJwt(transientToken, transientTokenSecret);
return {
workspaceMemberId: payload.sub,
userId: payload.userId,
workspaceId: payload.workspaceId,
};
}
async switchWorkspace(user: User, workspaceId: string) {
const userExists = await this.userRepository.findBy({ id: user.id });
if (!userExists) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
const workspace = await this.workspaceRepository.findOne({
where: { id: workspaceId },
relations: ['workspaceUsers', 'workspaceSSOIdentityProviders'],
});
if (!workspace) {
throw new AuthException(
'workspace doesnt exist',
AuthExceptionCode.INVALID_INPUT,
);
}
if (
!workspace.workspaceUsers
.map((userWorkspace) => userWorkspace.userId)
.includes(user.id)
) {
throw new AuthException(
'user does not belong to workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
if (workspace.workspaceSSOIdentityProviders.length > 0) {
return {
useSSOAuth: true,
workspace,
availableSSOIdentityProviders:
await this.sSSOService.listSSOIdentityProvidersByWorkspaceId(
workspaceId,
),
} as {
useSSOAuth: true;
workspace: Workspace;
availableSSOIdentityProviders: Awaited<
ReturnType<
typeof this.sSSOService.listSSOIdentityProvidersByWorkspaceId
>
>;
};
}
return {
useSSOAuth: false,
workspace,
} as {
useSSOAuth: false;
workspace: Workspace;
};
}
async generateSwitchWorkspaceToken(
user: User,
workspace: Workspace,
): Promise<AuthTokens> {
await this.userRepository.save({
id: user.id,
defaultWorkspace: workspace,
});
const token = await this.generateAccessToken(user.id, workspace.id);
const refreshToken = await this.generateRefreshToken(user.id);
return {
tokens: {
accessToken: token,
refreshToken,
},
};
}
async verifyAuthorizationCode(
exchangeAuthCodeInput: ExchangeAuthCodeInput,
): Promise<ExchangeAuthCode> {
const { authorizationCode, codeVerifier } = exchangeAuthCodeInput;
if (!authorizationCode) {
throw new AuthException(
'Authorization code not found',
AuthExceptionCode.INVALID_INPUT,
);
}
let userId = '';
if (codeVerifier) {
const authorizationCodeAppToken = await this.appTokenRepository.findOne({
where: {
value: authorizationCode,
},
});
if (!authorizationCodeAppToken) {
throw new AuthException(
'Authorization code does not exist',
AuthExceptionCode.INVALID_INPUT,
);
}
if (!(authorizationCodeAppToken.expiresAt.getTime() >= Date.now())) {
throw new AuthException(
'Authorization code expired.',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest()
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
const codeChallengeAppToken = await this.appTokenRepository.findOne({
where: {
value: codeChallenge,
},
});
if (!codeChallengeAppToken || !codeChallengeAppToken.userId) {
throw new AuthException(
'code verifier doesnt match the challenge',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
if (!(codeChallengeAppToken.expiresAt.getTime() >= Date.now())) {
throw new AuthException(
'code challenge expired.',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
if (codeChallengeAppToken.userId !== authorizationCodeAppToken.userId) {
throw new AuthException(
'authorization code / code verifier was not created by same client',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
if (codeChallengeAppToken.revokedAt) {
throw new AuthException(
'Token has been revoked.',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
await this.appTokenRepository.save({
id: codeChallengeAppToken.id,
revokedAt: new Date(),
});
userId = codeChallengeAppToken.userId;
}
const user = await this.userRepository.findOne({
where: { id: userId },
relations: ['defaultWorkspace'],
});
if (!user) {
throw new AuthException(
'User who generated the token does not exist',
AuthExceptionCode.INVALID_INPUT,
);
}
if (!user.defaultWorkspace) {
throw new AuthException(
'User does not have a default workspace',
AuthExceptionCode.INVALID_DATA,
);
}
const accessToken = await this.generateAccessToken(
user.id,
user.defaultWorkspaceId,
);
const refreshToken = await this.generateRefreshToken(user.id);
const loginToken = await this.generateLoginToken(user.email);
return {
accessToken,
refreshToken,
loginToken,
};
}
async verifyRefreshToken(refreshToken: string) {
const secret = this.environmentService.get('REFRESH_TOKEN_SECRET');
const coolDown = this.environmentService.get('REFRESH_TOKEN_COOL_DOWN');
const jwtPayload = await this.verifyJwt(refreshToken, secret);
if (!(jwtPayload.jti && jwtPayload.sub)) {
throw new AuthException(
'This refresh token is malformed',
AuthExceptionCode.INVALID_INPUT,
);
}
const token = await this.appTokenRepository.findOneBy({
id: jwtPayload.jti,
});
if (!token) {
throw new AuthException(
"This refresh token doesn't exist",
AuthExceptionCode.INVALID_INPUT,
);
}
const user = await this.userRepository.findOne({
where: { id: jwtPayload.sub },
relations: ['appTokens'],
});
if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
// Check if revokedAt is less than coolDown
if (
token.revokedAt &&
token.revokedAt.getTime() <= Date.now() - ms(coolDown)
) {
// Revoke all user refresh tokens
await Promise.all(
user.appTokens.map(async ({ id, type }) => {
if (type === AppTokenType.RefreshToken) {
await this.appTokenRepository.update(
{ id },
{
revokedAt: new Date(),
},
);
}
}),
);
throw new AuthException(
'Suspicious activity detected, this refresh token has been revoked. All tokens have been revoked.',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
return { user, token };
}
async generateTokensFromRefreshToken(token: string): Promise<{
accessToken: AuthToken;
refreshToken: AuthToken;
}> {
if (!token) {
throw new AuthException(
'Refresh token not found',
AuthExceptionCode.INVALID_INPUT,
);
}
const {
user,
token: { id },
} = await this.verifyRefreshToken(token);
// Revoke old refresh token
await this.appTokenRepository.update(
{
id,
},
{
revokedAt: new Date(),
},
);
const accessToken = await this.generateAccessToken(user.id);
const refreshToken = await this.generateRefreshToken(user.id);
return {
accessToken,
refreshToken,
};
}
computeRedirectURI(loginToken: string): string {
return `${this.environmentService.get(
'FRONT_BASE_URL',
)}/verify?loginToken=${loginToken}`;
}
async verifyJwt(token: string, secret?: string) {
try {
return this.jwtWrapperService.verify(
token,
secret ? { secret } : undefined,
);
} catch (error) {
if (error instanceof TokenExpiredError) {
throw new AuthException(
'Token has expired.',
AuthExceptionCode.UNAUTHENTICATED,
);
} else if (error instanceof JsonWebTokenError) {
throw new AuthException(
'Token invalid.',
AuthExceptionCode.UNAUTHENTICATED,
);
} else {
throw new AuthException(
'Unknown token error.',
AuthExceptionCode.INVALID_INPUT,
);
}
}
}
async generatePasswordResetToken(email: string): Promise<PasswordResetToken> {
const user = await this.userRepository.findOneBy({
email,
});
if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
const expiresIn = this.environmentService.get(
'PASSWORD_RESET_TOKEN_EXPIRES_IN',
);
if (!expiresIn) {
throw new AuthException(
'PASSWORD_RESET_TOKEN_EXPIRES_IN constant value not found',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const existingToken = await this.appTokenRepository.findOne({
where: {
userId: user.id,
type: AppTokenType.PasswordResetToken,
expiresAt: MoreThan(new Date()),
revokedAt: IsNull(),
},
});
if (existingToken) {
const timeToWait = ms(
differenceInMilliseconds(existingToken.expiresAt, new Date()),
{ long: true },
);
throw new AuthException(
`Token has already been generated. Please wait for ${timeToWait} to generate again.`,
AuthExceptionCode.INVALID_INPUT,
);
}
const plainResetToken = crypto.randomBytes(32).toString('hex');
const hashedResetToken = crypto
.createHash('sha256')
.update(plainResetToken)
.digest('hex');
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
await this.appTokenRepository.save({
userId: user.id,
value: hashedResetToken,
expiresAt,
type: AppTokenType.PasswordResetToken,
});
return {
passwordResetToken: plainResetToken,
passwordResetTokenExpiresAt: expiresAt,
};
}
async sendEmailPasswordResetLink(
resetToken: PasswordResetToken,
email: string,
): Promise<EmailPasswordResetLink> {
const user = await this.userRepository.findOneBy({
email,
});
if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
const frontBaseURL = this.environmentService.get('FRONT_BASE_URL');
const resetLink = `${frontBaseURL}/reset-password/${resetToken.passwordResetToken}`;
const emailData = {
link: resetLink,
duration: ms(
differenceInMilliseconds(
resetToken.passwordResetTokenExpiresAt,
new Date(),
),
{
long: true,
},
),
};
const emailTemplate = PasswordResetLinkEmail(emailData);
const html = render(emailTemplate, {
pretty: true,
});
const text = render(emailTemplate, {
plainText: true,
});
this.emailService.send({
from: `${this.environmentService.get(
'EMAIL_FROM_NAME',
)} <${this.environmentService.get('EMAIL_FROM_ADDRESS')}>`,
to: email,
subject: 'Action Needed to Reset Password',
text,
html,
});
return { success: true };
}
async validatePasswordResetToken(
resetToken: string,
): Promise<ValidatePasswordResetToken> {
const hashedResetToken = crypto
.createHash('sha256')
.update(resetToken)
.digest('hex');
const token = await this.appTokenRepository.findOne({
where: {
value: hashedResetToken,
type: AppTokenType.PasswordResetToken,
expiresAt: MoreThan(new Date()),
revokedAt: IsNull(),
},
});
if (!token || !token.userId) {
throw new AuthException(
'Token is invalid',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
);
}
const user = await this.userRepository.findOneBy({
id: token.userId,
});
if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
return {
id: user.id,
email: user.email,
};
}
async invalidatePasswordResetToken(
userId: string,
): Promise<InvalidatePassword> {
const user = await this.userRepository.findOneBy({
id: userId,
});
if (!user) {
throw new AuthException(
'User not found',
AuthExceptionCode.INVALID_INPUT,
);
}
await this.appTokenRepository.update(
{
userId,
type: AppTokenType.PasswordResetToken,
},
{
revokedAt: new Date(),
},
);
return { success: true };
}
}

View File

@ -0,0 +1,133 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { TransientTokenService } from './transient-token.service';
describe('TransientTokenService', () => {
let service: TransientTokenService;
let jwtWrapperService: JwtWrapperService;
let environmentService: EnvironmentService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TransientTokenService,
{
provide: JwtWrapperService,
useValue: {
sign: jest.fn(),
verifyWorkspaceToken: jest.fn(),
decode: jest.fn(),
generateAppSecret: jest.fn().mockReturnValue('mocked-secret'),
},
},
{
provide: EnvironmentService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();
service = module.get<TransientTokenService>(TransientTokenService);
jwtWrapperService = module.get<JwtWrapperService>(JwtWrapperService);
environmentService = module.get<EnvironmentService>(EnvironmentService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('generateTransientToken', () => {
it('should generate a transient token successfully', async () => {
const workspaceMemberId = 'workspace-member-id';
const userId = 'user-id';
const workspaceId = 'workspace-id';
const mockExpiresIn = '15m';
const mockToken = 'mock-token';
jest.spyOn(environmentService, 'get').mockImplementation((key) => {
if (key === 'SHORT_TERM_TOKEN_EXPIRES_IN') return mockExpiresIn;
return undefined;
});
jest.spyOn(jwtWrapperService, 'sign').mockReturnValue(mockToken);
const result = await service.generateTransientToken(
workspaceMemberId,
userId,
workspaceId,
);
expect(result).toEqual({
token: mockToken,
expiresAt: expect.any(Date),
});
expect(environmentService.get).toHaveBeenCalledWith(
'SHORT_TERM_TOKEN_EXPIRES_IN',
);
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
{
sub: workspaceMemberId,
userId,
workspaceId,
},
expect.objectContaining({
secret: 'mocked-secret',
expiresIn: mockExpiresIn,
}),
);
});
it('should throw an error if SHORT_TERM_TOKEN_EXPIRES_IN is not set', async () => {
jest.spyOn(environmentService, 'get').mockReturnValue(undefined);
await expect(
service.generateTransientToken('member-id', 'user-id', 'workspace-id'),
).rejects.toThrow(AuthException);
});
});
describe('verifyTransientToken', () => {
it('should verify a transient token successfully', async () => {
const mockToken = 'valid-token';
const mockPayload = {
sub: 'workspace-member-id',
userId: 'user-id',
workspaceId: 'workspace-id',
};
jest
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
.mockResolvedValue(undefined);
jest.spyOn(jwtWrapperService, 'decode').mockReturnValue(mockPayload);
const result = await service.verifyTransientToken(mockToken);
expect(result).toEqual({
workspaceMemberId: mockPayload.sub,
userId: mockPayload.userId,
workspaceId: mockPayload.workspaceId,
});
expect(jwtWrapperService.verifyWorkspaceToken).toHaveBeenCalledWith(
mockToken,
'LOGIN',
);
expect(jwtWrapperService.decode).toHaveBeenCalledWith(mockToken);
});
it('should throw an error if token verification fails', async () => {
const mockToken = 'invalid-token';
jest
.spyOn(jwtWrapperService, 'verifyWorkspaceToken')
.mockRejectedValue(new Error('Invalid token'));
await expect(service.verifyTransientToken(mockToken)).rejects.toThrow();
});
});
});

View File

@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { AuthToken } from 'src/engine/core-modules/auth/dto/token.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@Injectable()
export class TransientTokenService {
constructor(
private readonly jwtWrapperService: JwtWrapperService,
private readonly environmentService: EnvironmentService,
) {}
async generateTransientToken(
workspaceMemberId: string,
userId: string,
workspaceId: string,
): Promise<AuthToken> {
const secret = this.jwtWrapperService.generateAppSecret(
'LOGIN',
workspaceId,
);
const expiresIn = this.environmentService.get(
'SHORT_TERM_TOKEN_EXPIRES_IN',
);
if (!expiresIn) {
throw new AuthException(
'Expiration time for access token is not set',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const jwtPayload = {
sub: workspaceMemberId,
userId,
workspaceId,
};
return {
token: this.jwtWrapperService.sign(jwtPayload, {
secret,
expiresIn,
}),
expiresAt,
};
}
async verifyTransientToken(transientToken: string): Promise<{
workspaceMemberId: string;
userId: string;
workspaceId: string;
}> {
await this.jwtWrapperService.verifyWorkspaceToken(transientToken, 'LOGIN');
const payload = await this.jwtWrapperService.decode(transientToken);
return {
workspaceMemberId: payload.sub,
userId: payload.userId,
workspaceId: payload.workspaceId,
};
}
}

View File

@ -5,13 +5,16 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { JwtAuthStrategy } from 'src/engine/core-modules/auth/strategies/jwt.auth.strategy';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service';
import { RefreshTokenService } from 'src/engine/core-modules/auth/token/services/refresh-token.service';
import { RenewTokenService } from 'src/engine/core-modules/auth/token/services/renew-token.service';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
@Module({
imports: [
@ -22,7 +25,18 @@ import { WorkspaceSSOModule } from 'src/engine/core-modules/sso/sso.module';
EmailModule,
WorkspaceSSOModule,
],
providers: [TokenService, JwtAuthStrategy],
exports: [TokenService],
providers: [
RenewTokenService,
JwtAuthStrategy,
AccessTokenService,
LoginTokenService,
RefreshTokenService,
],
exports: [
RenewTokenService,
AccessTokenService,
LoginTokenService,
RefreshTokenService,
],
})
export class TokenModule {}

View File

@ -134,18 +134,13 @@ export class EnvironmentVariables {
@IsOptional()
SERVER_URL: string;
// Json Web Token
@IsString()
ACCESS_TOKEN_SECRET: string;
APP_SECRET: string;
@IsDuration()
@IsOptional()
ACCESS_TOKEN_EXPIRES_IN = '30m';
@IsString()
REFRESH_TOKEN_SECRET: string;
@IsDuration()
@IsOptional()
REFRESH_TOKEN_EXPIRES_IN = '60d';
@ -153,17 +148,10 @@ export class EnvironmentVariables {
@IsOptional()
REFRESH_TOKEN_COOL_DOWN = '1m';
@IsString()
LOGIN_TOKEN_SECRET = '30m';
@IsDuration()
@IsOptional()
LOGIN_TOKEN_EXPIRES_IN = '15m';
@IsString()
@IsOptional()
FILE_TOKEN_SECRET = 'random_string';
@IsDuration()
@IsOptional()
FILE_TOKEN_EXPIRES_IN = '1d';

View File

@ -14,4 +14,5 @@ export enum FeatureFlagKey {
IsGmailSendEmailScopeEnabled = 'IS_GMAIL_SEND_EMAIL_SCOPE_ENABLED',
IsAnalyticsV2Enabled = 'IS_ANALYTICS_V2_ENABLED',
IsUniqueIndexesEnabled = 'IS_UNIQUE_INDEXES_ENABLED',
IsAdvancedFiltersEnabled = 'IS_ADVANCED_FILTERS_ENABLED',
}

View File

@ -8,8 +8,8 @@ import { v4 as uuidV4 } from 'uuid';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { settings } from 'src/engine/constants/settings';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { getCropSize } from 'src/utils/image';
@Injectable()
@ -83,7 +83,7 @@ export class FileUploadService {
});
const signedPayload = await this.fileService.encodeFileToken({
workspace_id: workspaceId,
workspaceId: workspaceId,
});
return {

View File

@ -7,40 +7,43 @@ import {
} from '@nestjs/common';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
@Injectable()
export class FilePathGuard implements CanActivate {
constructor(
private readonly jwtWrapperService: JwtWrapperService,
private readonly environmentService: EnvironmentService,
) {}
constructor(private readonly jwtWrapperService: JwtWrapperService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const query = request.query;
if (query && query['token']) {
const payloadToDecode = query['token'];
const decodedPayload = await this.jwtWrapperService.decode(
payloadToDecode,
{
secret: this.environmentService.get('FILE_TOKEN_SECRET'),
} as any,
);
const expirationDate = decodedPayload?.['expiration_date'];
const workspaceId = decodedPayload?.['workspace_id'];
const isExpired = await this.isExpired(expirationDate);
if (isExpired) {
return false;
}
request.workspaceId = workspaceId;
if (!query || !query['token']) {
return false;
}
const payload = await this.jwtWrapperService.verifyWorkspaceToken(
query['token'],
'FILE',
);
if (!payload.workspaceId) {
return false;
}
const decodedPayload = await this.jwtWrapperService.decode(query['token'], {
json: true,
});
const expirationDate = decodedPayload?.['expirationDate'];
const workspaceId = decodedPayload?.['workspaceId'];
const isExpired = await this.isExpired(expirationDate);
if (isExpired) {
return false;
}
request.workspaceId = workspaceId;
return true;
}

View File

@ -5,9 +5,9 @@ import { Stream } from 'stream';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
@Injectable()
export class FileService {
@ -34,13 +34,16 @@ export class FileService {
const fileTokenExpiresIn = this.environmentService.get(
'FILE_TOKEN_EXPIRES_IN',
);
const secret = this.environmentService.get('FILE_TOKEN_SECRET');
const secret = this.jwtWrapperService.generateAppSecret(
'FILE',
payloadToEncode.workspaceId,
);
const expirationDate = addMilliseconds(new Date(), ms(fileTokenExpiresIn));
const signedPayload = this.jwtWrapperService.sign(
{
expiration_date: expirationDate,
expirationDate: expirationDate,
...payloadToEncode,
},
{

View File

@ -2,14 +2,14 @@
import { Module } from '@nestjs/common';
import { JwtModule as NestJwtModule } from '@nestjs/jwt';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
const InternalJwtModule = NestJwtModule.registerAsync({
useFactory: async (environmentService: EnvironmentService) => {
return {
secret: environmentService.get('ACCESS_TOKEN_SECRET'),
secret: environmentService.get('APP_SECRET'),
signOptions: {
expiresIn: environmentService.get('ACCESS_TOKEN_EXPIRES_IN'),
},

View File

@ -1,11 +1,30 @@
import { Injectable } from '@nestjs/common';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService, JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt';
import { createHash } from 'crypto';
import * as jwt from 'jsonwebtoken';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
type WorkspaceTokenType =
| 'ACCESS'
| 'LOGIN'
| 'REFRESH'
| 'FILE'
| 'POSTGRES_PROXY'
| 'REMOTE_SERVER';
@Injectable()
export class JwtWrapperService {
constructor(private readonly jwtService: JwtService) {}
constructor(
private readonly jwtService: JwtService,
private readonly environmentService: EnvironmentService,
) {}
sign(payload: string | object, options?: JwtSignOptions): string {
// Typescript does not handle well the overloads of the sign method, helping it a little bit
@ -20,7 +39,58 @@ export class JwtWrapperService {
return this.jwtService.verify(token, options);
}
decode<T = any>(payload: string, options: jwt.DecodeOptions): T {
decode<T = any>(payload: string, options?: jwt.DecodeOptions): T {
return this.jwtService.decode(payload, options);
}
verifyWorkspaceToken(
token: string,
type: WorkspaceTokenType,
options?: JwtVerifyOptions,
) {
const payload = this.decode(token, {
json: true,
});
// TODO: check if this is really needed
if (type !== 'FILE' && !payload.sub) {
throw new UnauthorizedException('No payload sub');
}
try {
return this.jwtService.verify(token, {
...options,
secret: this.generateAppSecret(type, payload.workspaceId),
});
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
throw new AuthException(
'Token has expired.',
AuthExceptionCode.UNAUTHENTICATED,
);
} else if (error instanceof jwt.JsonWebTokenError) {
throw new AuthException(
'Token invalid.',
AuthExceptionCode.UNAUTHENTICATED,
);
} else {
throw new AuthException(
'Unknown token error.',
AuthExceptionCode.INVALID_INPUT,
);
}
}
}
generateAppSecret(type: WorkspaceTokenType, workspaceId?: string): string {
const appSecret = this.environmentService.get('APP_SECRET');
if (!appSecret) {
throw new Error('APP_SECRET is not set');
}
return createHash('sha256')
.update(`${appSecret}${workspaceId}${type}`)
.digest('hex');
}
}

View File

@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { OpenApiService } from 'src/engine/core-modules/open-api/open-api.service';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
@ -13,7 +13,7 @@ describe('OpenApiService', () => {
providers: [
OpenApiService,
{
provide: TokenService,
provide: AccessTokenService,
useValue: {},
},
{

View File

@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
import { Request } from 'express';
import { OpenAPIV3_1 } from 'openapi-types';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { baseSchema } from 'src/engine/core-modules/open-api/utils/base-schema.utils';
import {
@ -41,7 +41,7 @@ import { getServerUrl } from 'src/utils/get-server-url';
@Injectable()
export class OpenApiService {
constructor(
private readonly tokenService: TokenService,
private readonly accessTokenService: AccessTokenService,
private readonly environmentService: EnvironmentService,
private readonly objectMetadataService: ObjectMetadataService,
) {}
@ -57,7 +57,8 @@ export class OpenApiService {
let objectMetadataItems;
try {
const { workspace } = await this.tokenService.validateToken(request);
const { workspace } =
await this.accessTokenService.validateToken(request);
objectMetadataItems =
await this.objectMetadataService.findManyWithinWorkspace(workspace.id);

View File

@ -1,16 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
import { PostgresCredentialsResolver } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.resolver';
import { PostgresCredentialsService } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.service';
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
@Module({
imports: [
TypeOrmModule.forFeature([PostgresCredentials], 'core'),
EnvironmentModule,
],
imports: [JwtModule, TypeOrmModule.forFeature([PostgresCredentials], 'core')],
providers: [
PostgresCredentialsResolver,
PostgresCredentialsService,

View File

@ -10,15 +10,15 @@ import {
encryptText,
} from 'src/engine/core-modules/auth/auth.util';
import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { PostgresCredentialsDTO } from 'src/engine/core-modules/postgres-credentials/dtos/postgres-credentials.dto';
import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
export class PostgresCredentialsService {
constructor(
@InjectRepository(PostgresCredentials, 'core')
private readonly postgresCredentialsRepository: Repository<PostgresCredentials>,
private readonly environmentService: EnvironmentService,
private readonly jwtWrapperService: JwtWrapperService,
) {}
async enablePostgresProxy(
@ -27,7 +27,10 @@ export class PostgresCredentialsService {
const user = `user_${randomBytes(4).toString('hex')}`;
const password = randomBytes(16).toString('hex');
const key = this.environmentService.get('LOGIN_TOKEN_SECRET');
const key = this.jwtWrapperService.generateAppSecret(
'POSTGRES_PROXY',
workspaceId,
);
const passwordHash = encryptText(password, key);
const existingCredentials =
@ -81,7 +84,10 @@ export class PostgresCredentialsService {
id: postgresCredentials.id,
});
const key = this.environmentService.get('LOGIN_TOKEN_SECRET');
const key = this.jwtWrapperService.generateAppSecret(
'POSTGRES_PROXY',
workspaceId,
);
return {
id: postgresCredentials.id,
@ -105,7 +111,10 @@ export class PostgresCredentialsService {
return null;
}
const key = this.environmentService.get('LOGIN_TOKEN_SECRET');
const key = this.jwtWrapperService.generateAppSecret(
'POSTGRES_PROXY',
workspaceId,
);
return {
id: postgresCredentials.id,

View File

@ -111,8 +111,8 @@ export class UserResolver {
if (workspaceMember && workspaceMember.avatarUrl) {
const avatarUrlToken = await this.fileService.encodeFileToken({
workspace_member_id: workspaceMember.id,
workspace_id: user.defaultWorkspaceId,
workspaceMemberId: workspaceMember.id,
workspaceId: user.defaultWorkspaceId,
});
workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`;
@ -133,8 +133,8 @@ export class UserResolver {
for (const workspaceMember of workspaceMembers) {
if (workspaceMember.avatarUrl) {
const avatarUrlToken = await this.fileService.encodeFileToken({
workspace_member_id: workspaceMember.id,
workspace_id: user.defaultWorkspaceId,
workspaceMemberId: workspaceMember.id,
workspaceId: user.defaultWorkspaceId,
});
workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`;
@ -190,7 +190,7 @@ export class UserResolver {
});
const fileToken = await this.fileService.encodeFileToken({
workspace_id: workspaceId,
workspaceId: workspaceId,
});
return `${paths[0]}?token=${fileToken}`;

View File

@ -1,17 +1,29 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AppToken } from 'src/engine/core-modules/app-token/app-token.entity';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import { Repository } from 'typeorm';
import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceInvitationException } from 'src/engine/core-modules/workspace-invitation/workspace-invitation.exception';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceInvitationService } from './workspace-invitation.service';
describe('WorkspaceInvitationService', () => {
let service: WorkspaceInvitationService;
let appTokenRepository: Repository<AppToken>;
let userWorkspaceRepository: Repository<UserWorkspace>;
let environmentService: EnvironmentService;
let emailService: EmailService;
let onboardingService: OnboardingService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@ -19,27 +31,29 @@ describe('WorkspaceInvitationService', () => {
WorkspaceInvitationService,
{
provide: getRepositoryToken(AppToken, 'core'),
useValue: {},
},
{
provide: EnvironmentService,
useValue: {},
},
{
provide: EmailService,
useValue: {},
},
{
provide: TokenService,
useValue: {},
useClass: Repository,
},
{
provide: getRepositoryToken(UserWorkspace, 'core'),
useValue: {},
useClass: Repository,
},
{
provide: EnvironmentService,
useValue: {
get: jest.fn(),
},
},
{
provide: EmailService,
useValue: {
send: jest.fn(),
},
},
{
provide: OnboardingService,
useValue: {},
useValue: {
setOnboardingInviteTeamPending: jest.fn(),
},
},
],
}).compile();
@ -47,9 +61,96 @@ describe('WorkspaceInvitationService', () => {
service = module.get<WorkspaceInvitationService>(
WorkspaceInvitationService,
);
appTokenRepository = module.get<Repository<AppToken>>(
getRepositoryToken(AppToken, 'core'),
);
userWorkspaceRepository = module.get<Repository<UserWorkspace>>(
getRepositoryToken(UserWorkspace, 'core'),
);
environmentService = module.get<EnvironmentService>(EnvironmentService);
emailService = module.get<EmailService>(EmailService);
onboardingService = module.get<OnboardingService>(OnboardingService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('createWorkspaceInvitation', () => {
it('should create a workspace invitation successfully', async () => {
const email = 'test@example.com';
const workspace = { id: 'workspace-id' } as Workspace;
jest.spyOn(appTokenRepository, 'createQueryBuilder').mockReturnValue({
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(null),
} as any);
jest.spyOn(userWorkspaceRepository, 'exists').mockResolvedValue(false);
jest
.spyOn(service, 'generateInvitationToken')
.mockResolvedValue({} as AppToken);
await expect(
service.createWorkspaceInvitation(email, workspace),
).resolves.not.toThrow();
});
it('should throw an exception if invitation already exists', async () => {
const email = 'test@example.com';
const workspace = { id: 'workspace-id' } as Workspace;
jest.spyOn(appTokenRepository, 'createQueryBuilder').mockReturnValue({
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue({}),
} as any);
await expect(
service.createWorkspaceInvitation(email, workspace),
).rejects.toThrow(WorkspaceInvitationException);
});
});
describe('sendInvitations', () => {
it('should send invitations successfully', async () => {
const emails = ['test1@example.com', 'test2@example.com'];
const workspace = {
id: 'workspace-id',
inviteHash: 'invite-hash',
displayName: 'Test Workspace',
} as Workspace;
const sender = { email: 'sender@example.com', firstName: 'Sender' };
jest.spyOn(service, 'createWorkspaceInvitation').mockResolvedValue({
context: { email: 'test@example.com' },
value: 'token-value',
type: AppTokenType.InvitationToken,
} as AppToken);
jest
.spyOn(environmentService, 'get')
.mockReturnValue('http://localhost:3000');
jest.spyOn(emailService, 'send').mockResolvedValue({} as any);
jest
.spyOn(onboardingService, 'setOnboardingInviteTeamPending')
.mockResolvedValue({} as any);
const result = await service.sendInvitations(
emails,
workspace,
sender as User,
);
expect(result.success).toBe(true);
expect(result.result.length).toBe(2);
expect(emailService.send).toHaveBeenCalledTimes(2);
expect(
onboardingService.setOnboardingInviteTeamPending,
).toHaveBeenCalledWith({
workspaceId: workspace.id,
value: false,
});
});
});
});

View File

@ -1,7 +1,11 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import { render } from '@react-email/render';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
import { SendInviteLinkEmail } from 'twenty-emails';
import { IsNull, Repository } from 'typeorm';
@ -9,7 +13,10 @@ import {
AppToken,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { TokenService } from 'src/engine/core-modules/auth/token/services/token.service';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service';
@ -30,7 +37,6 @@ export class WorkspaceInvitationService {
private readonly appTokenRepository: Repository<AppToken>,
private readonly environmentService: EnvironmentService,
private readonly emailService: EmailService,
private readonly tokenService: TokenService,
@InjectRepository(UserWorkspace, 'core')
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
private readonly onboardingService: OnboardingService,
@ -103,7 +109,7 @@ export class WorkspaceInvitationService {
);
}
return this.tokenService.generateInvitationToken(workspace.id, email);
return this.generateInvitationToken(workspace.id, email);
}
async loadWorkspaceInvitations(workspace: Workspace) {
@ -290,4 +296,31 @@ export class WorkspaceInvitationService {
...result,
};
}
async generateInvitationToken(workspaceId: string, email: string) {
const expiresIn = this.environmentService.get(
'INVITATION_TOKEN_EXPIRES_IN',
);
if (!expiresIn) {
throw new AuthException(
'Expiration time for invitation token is not set',
AuthExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const invitationToken = this.appTokenRepository.create({
workspaceId,
expiresAt,
type: AppTokenType.InvitationToken,
value: crypto.randomBytes(32).toString('hex'),
context: {
email,
},
});
return this.appTokenRepository.save(invitationToken);
}
}

View File

@ -95,7 +95,7 @@ export class WorkspaceResolver {
});
const workspaceLogoToken = await this.fileService.encodeFileToken({
workspace_id: id,
workspaceId: id,
});
return `${paths[0]}?token=${workspaceLogoToken}`;
@ -128,7 +128,7 @@ export class WorkspaceResolver {
if (workspace.logo) {
try {
const workspaceLogoToken = await this.fileService.encodeFileToken({
workspace_id: workspace.id,
workspaceId: workspace.id,
});
return `${workspace.logo}?token=${workspaceLogoToken}`;

View File

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

View File

@ -386,7 +386,6 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
) {
const relatedObjectTypes = [
'timelineActivity',
'activityTarget',
'favorite',
'attachment',
'noteTarget',

View File

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

View File

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

View File

@ -1,28 +1,27 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
import {
FieldMetadataEntity,
FieldMetadataType,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { createForeignKeyDeterministicUuid } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/create-deterministic-uuid.util';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { createRelationForeignKeyFieldMetadataName } from 'src/engine/metadata-modules/relation-metadata/utils/create-relation-foreign-key-field-metadata-name.util';
import { buildMigrationsToCreateRemoteTableRelations } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/utils/build-migrations-to-create-remote-table-relations.util';
import { buildMigrationsToRemoveRemoteTableRelations } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/utils/build-migrations-to-remove-remote-table-relations.util';
import { mapUdtNameToFieldType } from 'src/engine/metadata-modules/remote-server/remote-table/utils/udt-name-mapper.util';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import { WorkspaceMigrationService } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.service';
import {
ACTIVITY_TARGET_STANDARD_FIELD_IDS,
ATTACHMENT_STANDARD_FIELD_IDS,
FAVORITE_STANDARD_FIELD_IDS,
TIMELINE_ACTIVITY_STANDARD_FIELD_IDS,
} from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { buildMigrationsToCreateRemoteTableRelations } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/utils/build-migrations-to-create-remote-table-relations.util';
import { buildMigrationsToRemoveRemoteTableRelations } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/utils/build-migrations-to-remove-remote-table-relations.util';
import { mapUdtNameToFieldType } from 'src/engine/metadata-modules/remote-server/remote-table/utils/udt-name-mapper.util';
import { createRelationForeignKeyFieldMetadataName } from 'src/engine/metadata-modules/relation-metadata/utils/create-relation-foreign-key-field-metadata-name.util';
import { createForeignKeyDeterministicUuid } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/create-deterministic-uuid.util';
@Injectable()
export class RemoteTableRelationsService {
@ -54,14 +53,6 @@ export class RemoteTableRelationsService {
objectPrimaryKeyFieldSettings,
);
const activityTargetObjectMetadata =
await this.createActivityTargetRelation(
workspaceId,
remoteObjectMetadata,
objectPrimaryKeyFieldType,
objectPrimaryKeyFieldSettings,
);
const attachmentObjectMetadata = await this.createAttachmentRelation(
workspaceId,
remoteObjectMetadata,
@ -87,7 +78,6 @@ export class RemoteTableRelationsService {
remoteObjectMetadata.nameSingular,
[
favoriteObjectMetadata,
activityTargetObjectMetadata,
attachmentObjectMetadata,
timelineActivityObjectMetadata,
],
@ -107,12 +97,6 @@ export class RemoteTableRelationsService {
workspaceId: workspaceId,
});
const activityTargetObjectMetadata =
await this.objectMetadataRepository.findOneByOrFail({
nameSingular: 'activityTarget',
workspaceId: workspaceId,
});
const attachmentObjectMetadata =
await this.objectMetadataRepository.findOneByOrFail({
nameSingular: 'attachment',
@ -136,7 +120,6 @@ export class RemoteTableRelationsService {
name: targetColumnName,
objectMetadataId: In([
favoriteObjectMetadata.id,
activityTargetObjectMetadata.id,
attachmentObjectMetadata.id,
timelineActivityObjectMetadata.id,
]),
@ -158,53 +141,12 @@ export class RemoteTableRelationsService {
workspaceId,
buildMigrationsToRemoveRemoteTableRelations(targetColumnName, [
favoriteObjectMetadata,
activityTargetObjectMetadata,
attachmentObjectMetadata,
timelineActivityObjectMetadata,
]),
);
}
private async createActivityTargetRelation(
workspaceId: string,
createdObjectMetadata: ObjectMetadataEntity,
objectPrimaryKeyType: FieldMetadataType,
objectPrimaryKeyFieldSettings:
| FieldMetadataSettings<FieldMetadataType | 'default'>
| undefined,
) {
const activityTargetObjectMetadata =
await this.objectMetadataRepository.findOneByOrFail({
nameSingular: 'activityTarget',
workspaceId: workspaceId,
});
await this.fieldMetadataRepository.save(
// Foreign key
{
standardId: createForeignKeyDeterministicUuid({
objectId: createdObjectMetadata.id,
standardId: ACTIVITY_TARGET_STANDARD_FIELD_IDS.custom,
}),
objectMetadataId: activityTargetObjectMetadata.id,
workspaceId: workspaceId,
isCustom: false,
isActive: true,
type: objectPrimaryKeyType,
name: `${createdObjectMetadata.nameSingular}Id`,
label: `${createdObjectMetadata.labelSingular} ID (foreign key)`,
description: `ActivityTarget ${createdObjectMetadata.labelSingular} id foreign key`,
icon: undefined,
isNullable: true,
isSystem: true,
defaultValue: undefined,
settings: { ...objectPrimaryKeyFieldSettings, isForeignKey: true },
},
);
return activityTargetObjectMetadata;
}
private async createAttachmentRelation(
workspaceId: string,
createdObjectMetadata: ObjectMetadataEntity,

View File

@ -1,9 +1,9 @@
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import {
WorkspaceMigrationTableAction,
WorkspaceMigrationTableActionType,
WorkspaceMigrationColumnActionType,
WorkspaceMigrationColumnDrop,
WorkspaceMigrationTableAction,
WorkspaceMigrationTableActionType,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { computeTableName } from 'src/engine/utils/compute-table-name.util';

View File

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

View File

@ -22,7 +22,6 @@ import {
FieldTypeAndNameMetadata,
getTsVectorColumnExpressionFromFields,
} from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
import { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-objects/activity-target.workspace-entity';
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
import { NoteTargetWorkspaceEntity } from 'src/modules/note/standard-objects/note-target.workspace-entity';
@ -71,19 +70,6 @@ export class CustomWorkspaceEntity extends BaseWorkspaceEntity {
})
createdBy: ActorMetadata;
@WorkspaceRelation({
standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.activityTargets,
label: 'Activities',
type: RelationMetadataType.ONE_TO_MANY,
description: (objectMetadata) =>
`Activities tied to the ${objectMetadata.labelSingular}`,
icon: 'IconCheckbox',
inverseSideTarget: () => ActivityTargetWorkspaceEntity,
onDelete: RelationOnDeleteAction.CASCADE,
})
@WorkspaceIsNullable()
activityTargets: ActivityTargetWorkspaceEntity[];
@WorkspaceRelation({
standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.noteTargets,
label: 'Notes',

View File

@ -67,7 +67,7 @@ export const notesAllView = (
TODO: Add later, since we don't have real-time it probably doesn't work well?
{
fieldMetadataId:
objectMetadataMap[STANDARD_OBJECT_IDS.activity].fields[
objectMetadataMap[STANDARD_OBJECT_IDS.note].fields[
BASE_OBJECT_STANDARD_FIELD_IDS.updatedAt
],
position: 0,

View File

@ -4,15 +4,16 @@ import { WorkspaceMigrationBuilderAction } from 'src/engine/workspace-manager/wo
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { RelationMetadataEntity } from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import {
WorkspaceMigrationColumnActionType,
WorkspaceMigrationEntity,
WorkspaceMigrationTableAction,
WorkspaceMigrationTableActionType,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { WorkspaceMigrationFactory } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.factory';
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
export interface ObjectMetadataUpdate {
current: ObjectMetadataEntity;
@ -27,9 +28,7 @@ export class WorkspaceMigrationObjectFactory {
async create(
objectMetadataCollection: ObjectMetadataEntity[],
action:
| WorkspaceMigrationBuilderAction.CREATE
| WorkspaceMigrationBuilderAction.DELETE,
action: WorkspaceMigrationBuilderAction.CREATE,
): Promise<Partial<WorkspaceMigrationEntity>[]>;
async create(
@ -37,11 +36,24 @@ export class WorkspaceMigrationObjectFactory {
action: WorkspaceMigrationBuilderAction.UPDATE,
): Promise<Partial<WorkspaceMigrationEntity>[]>;
async create(
objectMetadataCollection: ObjectMetadataEntity[],
action: WorkspaceMigrationBuilderAction.DELETE,
relationMetadataByFromObjectMetadataId: Record<
string,
RelationMetadataEntity[]
>,
): Promise<Partial<WorkspaceMigrationEntity>[]>;
async create(
objectMetadataCollectionOrObjectMetadataUpdateCollection:
| ObjectMetadataEntity[]
| ObjectMetadataUpdate[],
action: WorkspaceMigrationBuilderAction,
relationMetadataByFromObjectMetadataId?: Record<
string,
RelationMetadataEntity[]
>,
): Promise<Partial<WorkspaceMigrationEntity>[]> {
switch (action) {
case WorkspaceMigrationBuilderAction.CREATE:
@ -55,6 +67,10 @@ export class WorkspaceMigrationObjectFactory {
case WorkspaceMigrationBuilderAction.DELETE:
return this.deleteObjectMigration(
objectMetadataCollectionOrObjectMetadataUpdateCollection as ObjectMetadataEntity[],
relationMetadataByFromObjectMetadataId as Record<
string,
RelationMetadataEntity[]
>,
);
default:
return [];
@ -136,22 +152,43 @@ export class WorkspaceMigrationObjectFactory {
private async deleteObjectMigration(
objectMetadataCollection: ObjectMetadataEntity[],
relationMetadataByFromObjectMetadataId: Record<
string,
RelationMetadataEntity[]
>,
): Promise<Partial<WorkspaceMigrationEntity>[]> {
const workspaceMigrations: Partial<WorkspaceMigrationEntity>[] = [];
for (const objectMetadata of objectMetadataCollection) {
const migrations: WorkspaceMigrationTableAction[] = [
{
name: computeObjectTargetTable(objectMetadata),
action: WorkspaceMigrationTableActionType.DROP,
},
];
const relationMetadataCollection =
relationMetadataByFromObjectMetadataId[objectMetadata.id];
workspaceMigrations.push({
workspaceId: objectMetadata.workspaceId,
name: generateMigrationName(`delete-${objectMetadata.nameSingular}`),
isCustom: false,
migrations,
migrations: [
...(relationMetadataCollection ?? []).map(
(relationMetadata) =>
({
name: computeObjectTargetTable(
relationMetadata.toObjectMetadata,
),
action: WorkspaceMigrationTableActionType.ALTER,
columns: [
{
action: WorkspaceMigrationColumnActionType.DROP_FOREIGN_KEY,
columnName: `${relationMetadata.toFieldMetadata.name}Id`,
},
],
}) satisfies WorkspaceMigrationTableAction,
),
{
name: computeObjectTargetTable(objectMetadata),
action: WorkspaceMigrationTableActionType.DROP,
columns: [],
} satisfies WorkspaceMigrationTableAction,
],
});
}

Some files were not shown because too many files have changed in this diff Show More