From 9a473d5b55f2ef2c3fb7951b7fe7802072bc2f31 Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Wed, 19 Jun 2024 19:00:22 +0300 Subject: [PATCH] chore: auth on api-v2 with api-key (#15455) * chore: enable apiv2 auth with api keys * chore: enable apiv2 auth with api keys * chore: enable apiv2 auth with api keys * chore: enable apiv2 auth with api keys * chore: enable apiv2 auth with api keys * fixup! chore: enable apiv2 auth with api keys * fixup! Merge branch 'chore-apiv2-auth-api-key' of github.com:calcom/cal.com into chore-apiv2-auth-api-key * fixup! fixup! Merge branch 'chore-apiv2-auth-api-key' of github.com:calcom/cal.com into chore-apiv2-auth-api-key * fixup! fixup! fixup! Merge branch 'chore-apiv2-auth-api-key' of github.com:calcom/cal.com into chore-apiv2-auth-api-key --- apps/api/v2/.env.example | 4 + apps/api/v2/README.md | 10 + apps/api/v2/src/app.ts | 2 - apps/api/v2/src/config/app.ts | 4 + apps/api/v2/src/config/type.ts | 4 + .../bookings.controller.e2e-spec.ts | 4 +- .../controllers/bookings.controller.ts | 4 +- .../controllers/calendars.controller.ts | 10 +- .../event-types.controller.e2e-spec.ts | 16 +- .../controllers/event-types.controller.ts | 12 +- apps/api/v2/src/ee/gcal/gcal.controller.ts | 6 +- .../v2/src/ee/me/me.controller.e2e-spec.ts | 4 +- apps/api/v2/src/ee/me/me.controller.ts | 4 +- .../v2/src/ee/provider/provider.controller.ts | 4 +- .../schedules.controller.e2e-spec.ts | 4 +- .../controllers/schedules.controller.ts | 4 +- .../schedules.controller.e2e-spec.ts | 4 +- .../controllers/schedules.controller.ts | 4 +- apps/api/v2/src/env.ts | 4 + apps/api/v2/src/lib/api-key/index.ts | 5 + apps/api/v2/src/lib/api-versions.ts | 8 +- .../src/modules/api-key/api-key-repository.ts | 16 ++ .../v2/src/modules/api-key/api-key.module.ts | 6 +- .../v2/src/modules/api-key/api-key.service.ts | 25 -- apps/api/v2/src/modules/auth/auth.module.ts | 25 +- .../decorators/get-user/get-user.decorator.ts | 17 +- .../api-auth.guard.ts} | 2 +- .../token-expired.exception.ts | 0 .../permissions/permissions.guard.spec.ts | 3 +- .../guards/permissions/permissions.guard.ts | 19 +- .../access-token/access-token.strategy.ts | 63 ----- .../api-auth/api-auth.strategy.spec.ts | 183 ++++++++++++++ .../strategies/api-auth/api-auth.strategy.ts | 109 +++++++++ .../api-key-auth/api-key-auth.strategy.ts | 39 --- .../next-auth/next-auth.strategy.ts | 5 +- .../modules/deployments/deployments.module.ts | 13 + .../deployments/deployments.repository.ts | 12 + .../deployments/deployments.service.ts | 47 ++++ .../services/oauth-flow.service.ts | 2 +- .../slots/controllers/slots.controller.ts | 2 +- .../users/inputs/update-managed-user.input.ts | 4 +- apps/api/v2/swagger/documentation.json | 224 ++---------------- .../repository/api-keys.repository.fixture.ts | 28 +++ ....strategy.ts => api-auth-mock.strategy.ts} | 2 +- apps/api/v2/test/mocks/mock-redis-service.ts | 15 ++ apps/api/v2/test/setEnvVars.ts | 8 +- apps/api/v2/test/utils/withAccessTokenAuth.ts | 10 - apps/api/v2/test/utils/withApiAuth.ts | 10 + packages/platform/constants/api.ts | 3 +- 49 files changed, 583 insertions(+), 430 deletions(-) create mode 100644 apps/api/v2/src/modules/api-key/api-key-repository.ts delete mode 100644 apps/api/v2/src/modules/api-key/api-key.service.ts rename apps/api/v2/src/modules/auth/guards/{access-token/access-token.guard.ts => api-auth/api-auth.guard.ts} (56%) rename apps/api/v2/src/modules/auth/guards/{access-token => api-auth}/token-expired.exception.ts (100%) delete mode 100644 apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts create mode 100644 apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.spec.ts create mode 100644 apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts delete mode 100644 apps/api/v2/src/modules/auth/strategies/api-key-auth/api-key-auth.strategy.ts create mode 100644 apps/api/v2/src/modules/deployments/deployments.module.ts create mode 100644 apps/api/v2/src/modules/deployments/deployments.repository.ts create mode 100644 apps/api/v2/src/modules/deployments/deployments.service.ts create mode 100644 apps/api/v2/test/fixtures/repository/api-keys.repository.fixture.ts rename apps/api/v2/test/mocks/{access-token-mock.strategy.ts => api-auth-mock.strategy.ts} (88%) create mode 100644 apps/api/v2/test/mocks/mock-redis-service.ts delete mode 100644 apps/api/v2/test/utils/withAccessTokenAuth.ts create mode 100644 apps/api/v2/test/utils/withApiAuth.ts diff --git a/apps/api/v2/.env.example b/apps/api/v2/.env.example index 634317d3d6..0826ed44ce 100644 --- a/apps/api/v2/.env.example +++ b/apps/api/v2/.env.example @@ -20,3 +20,7 @@ STRIPE_API_KEY= STRIPE_WEBHOOK_SECRET= WEB_APP_URL=http://localhost:3000/ +CALCOM_LICENSE_KEY= +API_KEY_PREFIX=cal_ +GET_LICENSE_KEY_URL="https://console.cal.com/api/license" +IS_E2E=false \ No newline at end of file diff --git a/apps/api/v2/README.md b/apps/api/v2/README.md index 64af00f7fb..92c3f76482 100644 --- a/apps/api/v2/README.md +++ b/apps/api/v2/README.md @@ -42,6 +42,16 @@ $ yarn prisma generate Copy `.env.example` to `.env` and fill values. +## Add license Key to deployments table in DB + +id, logo theme licenseKey agreedLicenseAt +1, null, null, 'c4234812-12ab-42s6-a1e3-55bedd4a5bb7', '2023-05-15 21:39:47.611' + +your CALCOM_LICENSE_KEY env var need to contain the same value + +.env +CALCOM_LICENSE_KEY=c4234812-12ab-42s6-a1e3-55bedd4a5bb + ## Running the app ```bash diff --git a/apps/api/v2/src/app.ts b/apps/api/v2/src/app.ts index 79fd943efc..3ce349268c 100644 --- a/apps/api/v2/src/app.ts +++ b/apps/api/v2/src/app.ts @@ -30,9 +30,7 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication = type: VersioningType.CUSTOM, extractor: (request: unknown) => { const headerVersion = (request as Request)?.headers[CAL_API_VERSION_HEADER] as string | undefined; - console.log("asap header headerVersion", headerVersion); if (headerVersion && API_VERSIONS.includes(headerVersion as API_VERSIONS_ENUM)) { - console.log("asap return header headerVersion", headerVersion); return headerVersion; } return VERSION_2024_04_15; diff --git a/apps/api/v2/src/config/app.ts b/apps/api/v2/src/config/app.ts index eee500895f..12919c52bf 100644 --- a/apps/api/v2/src/config/app.ts +++ b/apps/api/v2/src/config/app.ts @@ -15,6 +15,9 @@ const loadConfig = (): AppConfig => { ? `:${Number(getEnv("API_PORT", "5555"))}` : "" }/v2`, + keyPrefix: getEnv("API_KEY_PREFIX", "cal_"), + licenseKey: getEnv("CALCOM_LICENSE_KEY", ""), + licenceKeyUrl: getEnv("GET_LICENSE_KEY_URL", "https://console.cal.com/api/license"), }, db: { readUrl: getEnv("DATABASE_READ_URL"), @@ -31,6 +34,7 @@ const loadConfig = (): AppConfig => { app: { baseUrl: getEnv("WEB_APP_URL", "https://app.cal.com"), }, + e2e: getEnv("IS_E2E", false), }; }; diff --git a/apps/api/v2/src/config/type.ts b/apps/api/v2/src/config/type.ts index d9a77bd354..9d55271021 100644 --- a/apps/api/v2/src/config/type.ts +++ b/apps/api/v2/src/config/type.ts @@ -6,6 +6,9 @@ export type AppConfig = { port: number; path: string; url: string; + keyPrefix: string; + licenseKey: string; + licenceKeyUrl: string; }; db: { readUrl: string; @@ -22,4 +25,5 @@ export type AppConfig = { app: { baseUrl: string; }; + e2e: boolean; }; diff --git a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.e2e-spec.ts b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.e2e-spec.ts index 52acb3970d..b635c29d10 100644 --- a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.e2e-spec.ts @@ -16,7 +16,7 @@ import * as request from "supertest"; import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; -import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth"; +import { withApiAuth } from "test/utils/withApiAuth"; import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; import { handleNewBooking } from "@calcom/platform-libraries-0.0.2"; @@ -39,7 +39,7 @@ describe("Bookings Endpoints", () => { let createdBooking: Awaited>; beforeAll(async () => { - const moduleRef = await withAccessTokenAuth( + const moduleRef = await withApiAuth( userEmail, Test.createTestingModule({ imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15], diff --git a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts index 32b48bb6a6..3831d8fc72 100644 --- a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts @@ -5,7 +5,7 @@ import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output"; import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; -import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { BillingService } from "@/modules/billing/services/billing.service"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; @@ -89,7 +89,7 @@ export class BookingsController { ) {} @Get("/") - @UseGuards(AccessTokenGuard) + @UseGuards(ApiAuthGuard) @Permissions([BOOKING_READ]) @ApiQuery({ name: "filters[status]", enum: Status, required: true }) @ApiQuery({ name: "limit", type: "number", required: false }) diff --git a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts index 995492fb64..b6dfe97844 100644 --- a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts +++ b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts @@ -6,7 +6,7 @@ import { OutlookService } from "@/ee/calendars/services/outlook.service"; import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; -import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { UserWithProfile } from "@/modules/users/users.repository"; import { @@ -42,7 +42,7 @@ export class CalendarsController { private readonly googleCalendarService: GoogleCalendarService ) {} - @UseGuards(AccessTokenGuard) + @UseGuards(ApiAuthGuard) @Get("/busy-times") async getBusyTimes( @Query() queryParams: CalendarBusyTimesInput, @@ -71,7 +71,7 @@ export class CalendarsController { } @Get("/") - @UseGuards(AccessTokenGuard) + @UseGuards(ApiAuthGuard) async getCalendars(@GetUser("id") userId: number): Promise { const calendars = await this.calendarsService.getCalendars(userId); @@ -81,7 +81,7 @@ export class CalendarsController { }; } - @UseGuards(AccessTokenGuard) + @UseGuards(ApiAuthGuard) @Get("/:calendar/connect") @HttpCode(HttpStatus.OK) async redirect( @@ -135,7 +135,7 @@ export class CalendarsController { @Get("/:calendar/check") @HttpCode(HttpStatus.OK) - @UseGuards(AccessTokenGuard, PermissionsGuard) + @UseGuards(ApiAuthGuard, PermissionsGuard) @Permissions([APPS_READ]) async check(@GetUser("id") userId: number, @Param("calendar") calendar: string): Promise { switch (calendar) { diff --git a/apps/api/v2/src/ee/event-types/controllers/event-types.controller.e2e-spec.ts b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.e2e-spec.ts index 30966a5e0c..8536414e26 100644 --- a/apps/api/v2/src/ee/event-types/controllers/event-types.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.e2e-spec.ts @@ -22,7 +22,7 @@ import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-type import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; -import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth"; +import { withApiAuth } from "test/utils/withApiAuth"; import { SUCCESS_STATUS } from "@calcom/platform-constants"; import { @@ -79,7 +79,7 @@ describe("Event types Endpoints", () => { let user: User; beforeAll(async () => { - const moduleRef = await withAccessTokenAuth( + const moduleRef = await withApiAuth( userEmail, Test.createTestingModule({ providers: [PrismaExceptionFilter, HttpExceptionFilter], @@ -288,7 +288,7 @@ describe("Event types Endpoints", () => { it(`/GET/:id`, async () => { const response = await request(app.getHttpServer()) .get(`/api/v2/event-types/${eventType.id}`) - // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + // note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above .set("Authorization", `Bearer whatever`) .expect(200); @@ -305,7 +305,7 @@ describe("Event types Endpoints", () => { it(`/GET/:username/public`, async () => { const response = await request(app.getHttpServer()) .get(`/api/v2/event-types/${username}/public`) - // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + // note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above .set("Authorization", `Bearer whatever`) .expect(200); @@ -323,7 +323,7 @@ describe("Event types Endpoints", () => { it(`/GET/:username/:eventSlug/public`, async () => { const response = await request(app.getHttpServer()) .get(`/api/v2/event-types/${username}/${eventType.slug}/public`) - // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + // note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above .set("Authorization", `Bearer whatever`) .expect(200); @@ -340,7 +340,7 @@ describe("Event types Endpoints", () => { it(`/GET/`, async () => { const response = await request(app.getHttpServer()) .get(`/api/v2/event-types`) - // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + // note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above .set("Authorization", `Bearer whatever`) .expect(200); @@ -359,7 +359,7 @@ describe("Event types Endpoints", () => { it(`/GET/public/:username/`, async () => { const response = await request(app.getHttpServer()) .get(`/api/v2/event-types/${username}/public`) - // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + // note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above .set("Authorization", `Bearer whatever`) .expect(200); @@ -375,7 +375,7 @@ describe("Event types Endpoints", () => { it(`/GET/:id not existing`, async () => { await request(app.getHttpServer()) .get(`/api/v2/event-types/1000`) - // note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above + // note: bearer token value mocked using "withApiAuth" for user which id is used when creating event type above .set("Authorization", `Bearer whatever`) .expect(404); }); diff --git a/apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts index 319a7868b2..32ae9dd9fd 100644 --- a/apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts +++ b/apps/api/v2/src/ee/event-types/controllers/event-types.controller.ts @@ -13,7 +13,7 @@ import { EventTypesService } from "@/ee/event-types/services/event-types.service import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; -import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { UserWithProfile } from "@/modules/users/users.repository"; @@ -54,7 +54,7 @@ export class EventTypesController { @Post("/") @Permissions([EVENT_TYPE_WRITE]) - @UseGuards(AccessTokenGuard) + @UseGuards(ApiAuthGuard) async createEventType( @Body() body: CreateEventTypeInput, @GetUser() user: UserWithProfile @@ -69,7 +69,7 @@ export class EventTypesController { @Get("/:eventTypeId") @Permissions([EVENT_TYPE_READ]) - @UseGuards(AccessTokenGuard) + @UseGuards(ApiAuthGuard) async getEventType( @Param() params: EventTypeIdParams, @Param("eventTypeId", ParseIntPipe) eventTypeId: number, @@ -90,7 +90,7 @@ export class EventTypesController { @Get("/") @Permissions([EVENT_TYPE_READ]) - @UseGuards(AccessTokenGuard) + @UseGuards(ApiAuthGuard) async getEventTypes(@GetUser() user: UserWithProfile): Promise { const eventTypes = await getEventTypesByViewer({ id: user.id, @@ -146,7 +146,7 @@ export class EventTypesController { @Patch("/:eventTypeId") @Permissions([EVENT_TYPE_WRITE]) - @UseGuards(AccessTokenGuard) + @UseGuards(ApiAuthGuard) @HttpCode(HttpStatus.OK) async updateEventType( @Param() params: EventTypeIdParams, @@ -164,7 +164,7 @@ export class EventTypesController { @Delete("/:eventTypeId") @Permissions([EVENT_TYPE_WRITE]) - @UseGuards(AccessTokenGuard) + @UseGuards(ApiAuthGuard) async deleteEventType( @Param() params: EventTypeIdParams, @Param("eventTypeId", ParseIntPipe) eventTypeId: number, diff --git a/apps/api/v2/src/ee/gcal/gcal.controller.ts b/apps/api/v2/src/ee/gcal/gcal.controller.ts index b2a363de45..c9614446c0 100644 --- a/apps/api/v2/src/ee/gcal/gcal.controller.ts +++ b/apps/api/v2/src/ee/gcal/gcal.controller.ts @@ -6,7 +6,7 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { GCalService } from "@/modules/apps/services/gcal.service"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; -import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; @@ -61,7 +61,7 @@ export class GcalController { @Get("/oauth/auth-url") @HttpCode(HttpStatus.OK) - @UseGuards(AccessTokenGuard) + @UseGuards(ApiAuthGuard) async redirect( @Headers("Authorization") authorization: string, @Req() req: Request @@ -90,7 +90,7 @@ export class GcalController { @Get("/check") @HttpCode(HttpStatus.OK) - @UseGuards(AccessTokenGuard, PermissionsGuard) + @UseGuards(ApiAuthGuard, PermissionsGuard) @Permissions([APPS_READ]) async check(@GetUser("id") userId: number): Promise { const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId); diff --git a/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts b/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts index 4acc86b17e..a4e1ca574e 100644 --- a/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/me/me.controller.e2e-spec.ts @@ -13,7 +13,7 @@ import { User } from "@prisma/client"; import * as request from "supertest"; import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; -import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth"; +import { withApiAuth } from "test/utils/withApiAuth"; import { SUCCESS_STATUS } from "@calcom/platform-constants"; import { UserResponse } from "@calcom/platform-types"; @@ -30,7 +30,7 @@ describe("Me Endpoints", () => { let user: User; beforeAll(async () => { - const moduleRef = await withAccessTokenAuth( + const moduleRef = await withApiAuth( userEmail, Test.createTestingModule({ imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15], diff --git a/apps/api/v2/src/ee/me/me.controller.ts b/apps/api/v2/src/ee/me/me.controller.ts index 3f2b4c5287..9d9fdb5262 100644 --- a/apps/api/v2/src/ee/me/me.controller.ts +++ b/apps/api/v2/src/ee/me/me.controller.ts @@ -4,7 +4,7 @@ import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15 import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; -import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input"; import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; @@ -18,7 +18,7 @@ import { userSchemaResponse } from "@calcom/platform-types"; path: "/v2/me", version: API_VERSIONS_VALUES, }) -@UseGuards(AccessTokenGuard, PermissionsGuard) +@UseGuards(ApiAuthGuard, PermissionsGuard) @DocsTags("Me") export class MeController { constructor( diff --git a/apps/api/v2/src/ee/provider/provider.controller.ts b/apps/api/v2/src/ee/provider/provider.controller.ts index 3059f6818c..5df78e7273 100644 --- a/apps/api/v2/src/ee/provider/provider.controller.ts +++ b/apps/api/v2/src/ee/provider/provider.controller.ts @@ -2,7 +2,7 @@ import { ProviderVerifyAccessTokenOutput } from "@/ee/provider/outputs/verify-ac import { ProviderVerifyClientOutput } from "@/ee/provider/outputs/verify-client.output"; import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; -import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { UserWithProfile } from "@/modules/users/users.repository"; import { @@ -45,7 +45,7 @@ export class CalProviderController { @Get("/:clientId/access-token") @HttpCode(HttpStatus.OK) - @UseGuards(AccessTokenGuard) + @UseGuards(ApiAuthGuard) async verifyAccessToken( @Param("clientId") clientId: string, @GetUser() user: UserWithProfile diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.e2e-spec.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.e2e-spec.ts index 8083886f73..c20768bd0b 100644 --- a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.e2e-spec.ts @@ -16,7 +16,7 @@ import { User } from "@prisma/client"; import * as request from "supertest"; import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; -import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth"; +import { withApiAuth } from "test/utils/withApiAuth"; import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_04_15 } from "@calcom/platform-constants"; import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types"; @@ -37,7 +37,7 @@ describe("Schedules Endpoints", () => { const defaultAvailabilityEndTime = "1970-01-01T17:00:00.000Z"; beforeAll(async () => { - const moduleRef = await withAccessTokenAuth( + const moduleRef = await withApiAuth( userEmail, Test.createTestingModule({ imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15], diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.ts b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.ts index 2bf7eae8da..ad6d31c06a 100644 --- a/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.ts +++ b/apps/api/v2/src/ee/schedules/schedules_2024_04_15/controllers/schedules.controller.ts @@ -8,7 +8,7 @@ import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15 import { VERSION_2024_04_15_VALUE } from "@/lib/api-versions"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; -import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { UserWithProfile } from "@/modules/users/users.repository"; import { @@ -35,7 +35,7 @@ import { CreateScheduleInput_2024_04_15 } from "../inputs/create-schedule.input" path: "/v2/schedules", version: VERSION_2024_04_15_VALUE, }) -@UseGuards(AccessTokenGuard, PermissionsGuard) +@UseGuards(ApiAuthGuard, PermissionsGuard) @DocsTags("Schedules") export class SchedulesController_2024_04_15 { constructor(private readonly schedulesService: SchedulesService_2024_04_15) {} diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.e2e-spec.ts b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.e2e-spec.ts index 79e03b74cd..4c40fab807 100644 --- a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.e2e-spec.ts @@ -12,7 +12,7 @@ import { User } from "@prisma/client"; import * as request from "supertest"; import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; -import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth"; +import { withApiAuth } from "test/utils/withApiAuth"; import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_11 } from "@calcom/platform-constants"; import { @@ -52,7 +52,7 @@ describe("Schedules Endpoints", () => { let createdSchedule: CreateScheduleOutput_2024_06_11["data"]; beforeAll(async () => { - const moduleRef = await withAccessTokenAuth( + const moduleRef = await withApiAuth( userEmail, Test.createTestingModule({ imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_06_11], diff --git a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.ts b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.ts index 3efd3647cd..fe89e47f7b 100644 --- a/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.ts +++ b/apps/api/v2/src/ee/schedules/schedules_2024_06_11/controllers/schedules.controller.ts @@ -2,7 +2,7 @@ import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11 import { VERSION_2024_06_11_VALUE } from "@/lib/api-versions"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; -import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { UserWithProfile } from "@/modules/users/users.repository"; import { @@ -36,7 +36,7 @@ import { path: "/v2/schedules", version: VERSION_2024_06_11_VALUE, }) -@UseGuards(AccessTokenGuard, PermissionsGuard) +@UseGuards(ApiAuthGuard, PermissionsGuard) @DocsTags("Schedules") export class SchedulesController_2024_06_11 { constructor(private readonly schedulesService: SchedulesService_2024_06_11) {} diff --git a/apps/api/v2/src/env.ts b/apps/api/v2/src/env.ts index 1af1955ed8..85bd516582 100644 --- a/apps/api/v2/src/env.ts +++ b/apps/api/v2/src/env.ts @@ -15,6 +15,10 @@ export type Environment = { STRIPE_API_KEY: string; STRIPE_WEBHOOK_SECRET: string; WEB_APP_URL: string; + IS_E2E: boolean; + CALCOM_LICENSE_KEY: string; + GET_LICENSE_KEY_URL: string; + API_KEY_PREFIX: string; }; export const getEnv = (key: K, fallback?: Environment[K]): Environment[K] => { diff --git a/apps/api/v2/src/lib/api-key/index.ts b/apps/api/v2/src/lib/api-key/index.ts index d292bd06e8..ad7ec0d2a9 100644 --- a/apps/api/v2/src/lib/api-key/index.ts +++ b/apps/api/v2/src/lib/api-key/index.ts @@ -1,3 +1,8 @@ import { createHash } from "crypto"; export const hashAPIKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex"); + +export const isApiKey = (authString: string, prefix: string): boolean => + authString?.startsWith(prefix ?? "cal_"); + +export const stripApiKey = (apiKey: string, prefix?: string): string => apiKey.replace(prefix ?? "cal_", ""); diff --git a/apps/api/v2/src/lib/api-versions.ts b/apps/api/v2/src/lib/api-versions.ts index c220d9c997..4d079367ae 100644 --- a/apps/api/v2/src/lib/api-versions.ts +++ b/apps/api/v2/src/lib/api-versions.ts @@ -1,13 +1,7 @@ import { VersionValue } from "@nestjs/common/interfaces"; -import { - API_VERSIONS, - VERSION_2024_04_15, - VERSION_2024_05_21, - VERSION_2024_06_11, -} from "@calcom/platform-constants"; +import { API_VERSIONS, VERSION_2024_04_15, VERSION_2024_06_11 } from "@calcom/platform-constants"; export const API_VERSIONS_VALUES: VersionValue = API_VERSIONS as unknown as VersionValue; export const VERSION_2024_06_11_VALUE: VersionValue = VERSION_2024_06_11 as unknown as VersionValue; -export const VERSION_2024_05_21_VALUE: VersionValue = VERSION_2024_05_21 as unknown as VersionValue; export const VERSION_2024_04_15_VALUE: VersionValue = VERSION_2024_04_15 as unknown as VersionValue; diff --git a/apps/api/v2/src/modules/api-key/api-key-repository.ts b/apps/api/v2/src/modules/api-key/api-key-repository.ts new file mode 100644 index 0000000000..66af5f68c4 --- /dev/null +++ b/apps/api/v2/src/modules/api-key/api-key-repository.ts @@ -0,0 +1,16 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class ApiKeyRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async getApiKeyFromHash(hashedKey: string) { + return this.dbRead.prisma.apiKey.findUnique({ + where: { + hashedKey, + }, + }); + } +} diff --git a/apps/api/v2/src/modules/api-key/api-key.module.ts b/apps/api/v2/src/modules/api-key/api-key.module.ts index 6c3d86ba05..993210e07a 100644 --- a/apps/api/v2/src/modules/api-key/api-key.module.ts +++ b/apps/api/v2/src/modules/api-key/api-key.module.ts @@ -1,10 +1,10 @@ -import { ApiKeyService } from "@/modules/api-key/api-key.service"; +import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { Module } from "@nestjs/common"; @Module({ imports: [PrismaModule], - providers: [ApiKeyService], - exports: [ApiKeyService], + providers: [ApiKeyRepository], + exports: [ApiKeyRepository], }) export class ApiKeyModule {} diff --git a/apps/api/v2/src/modules/api-key/api-key.service.ts b/apps/api/v2/src/modules/api-key/api-key.service.ts deleted file mode 100644 index c381fa99be..0000000000 --- a/apps/api/v2/src/modules/api-key/api-key.service.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { hashAPIKey } from "@/lib/api-key"; -import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; -import { Injectable } from "@nestjs/common"; -import type { Request } from "express"; - -@Injectable() -export class ApiKeyService { - constructor(private readonly dbRead: PrismaReadService) {} - - async retrieveApiKey(request: Request) { - const apiKey = request.get("Authorization")?.replace("Bearer ", ""); - - if (!apiKey) { - return null; - } - - const hashedKey = hashAPIKey(apiKey.replace("cal_", "")); - - return this.dbRead.prisma.apiKey.findUniqueOrThrow({ - where: { - hashedKey, - }, - }); - } -} diff --git a/apps/api/v2/src/modules/auth/auth.module.ts b/apps/api/v2/src/modules/auth/auth.module.ts index 06ef41ecae..b97a5aceb5 100644 --- a/apps/api/v2/src/modules/auth/auth.module.ts +++ b/apps/api/v2/src/modules/auth/auth.module.ts @@ -1,9 +1,9 @@ import { ApiKeyModule } from "@/modules/api-key/api-key.module"; -import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard"; -import { AccessTokenStrategy } from "@/modules/auth/strategies/access-token/access-token.strategy"; -import { ApiKeyAuthStrategy } from "@/modules/auth/strategies/api-key-auth/api-key-auth.strategy"; +import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy"; +import { DeploymentsModule } from "@/modules/deployments/deployments.module"; import { MembershipsModule } from "@/modules/memberships/memberships.module"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; import { RedisModule } from "@/modules/redis/redis.module"; @@ -13,15 +13,16 @@ import { Module } from "@nestjs/common"; import { PassportModule } from "@nestjs/passport"; @Module({ - imports: [PassportModule, RedisModule, ApiKeyModule, UsersModule, MembershipsModule, TokensModule], - providers: [ - ApiKeyAuthStrategy, - NextAuthGuard, - NextAuthStrategy, - AccessTokenGuard, - AccessTokenStrategy, - OAuthFlowService, + imports: [ + PassportModule, + RedisModule, + ApiKeyModule, + UsersModule, + MembershipsModule, + TokensModule, + DeploymentsModule, ], - exports: [NextAuthGuard, AccessTokenGuard], + providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService], + exports: [NextAuthGuard, ApiAuthGuard], }) export class AuthModule {} diff --git a/apps/api/v2/src/modules/auth/decorators/get-user/get-user.decorator.ts b/apps/api/v2/src/modules/auth/decorators/get-user/get-user.decorator.ts index 28ec9d5e3a..ebdd75e395 100644 --- a/apps/api/v2/src/modules/auth/decorators/get-user/get-user.decorator.ts +++ b/apps/api/v2/src/modules/auth/decorators/get-user/get-user.decorator.ts @@ -1,26 +1,33 @@ +import { UserWithProfile } from "@/modules/users/users.repository"; import { ExecutionContext } from "@nestjs/common"; import { createParamDecorator } from "@nestjs/common"; -import { User } from "@prisma/client"; -export const GetUser = createParamDecorator((data, ctx) => { +export type GetUserReturnType = UserWithProfile & { isSystemAdmin: boolean }; + +export const GetUser = createParamDecorator< + keyof GetUserReturnType | (keyof GetUserReturnType)[], + ExecutionContext +>((data, ctx) => { const request = ctx.switchToHttp().getRequest(); - const user = request.user as User; + const user = request.user as GetUserReturnType; if (!user) { throw new Error("GetUser decorator : User not found"); } + user.isSystemAdmin = user.role === "ADMIN"; + if (Array.isArray(data)) { return data.reduce((prev, curr) => { return { ...prev, - [curr]: request.user[curr], + [curr]: user[curr], }; }, {}); } if (data) { - return request.user[data]; + return user[data]; } return user; diff --git a/apps/api/v2/src/modules/auth/guards/access-token/access-token.guard.ts b/apps/api/v2/src/modules/auth/guards/api-auth/api-auth.guard.ts similarity index 56% rename from apps/api/v2/src/modules/auth/guards/access-token/access-token.guard.ts rename to apps/api/v2/src/modules/auth/guards/api-auth/api-auth.guard.ts index 2543c64454..bfc6e240df 100644 --- a/apps/api/v2/src/modules/auth/guards/access-token/access-token.guard.ts +++ b/apps/api/v2/src/modules/auth/guards/api-auth/api-auth.guard.ts @@ -1,6 +1,6 @@ import { AuthGuard } from "@nestjs/passport"; -export class AccessTokenGuard extends AuthGuard("access-token") { +export class ApiAuthGuard extends AuthGuard("api-auth") { constructor() { super(); } diff --git a/apps/api/v2/src/modules/auth/guards/access-token/token-expired.exception.ts b/apps/api/v2/src/modules/auth/guards/api-auth/token-expired.exception.ts similarity index 100% rename from apps/api/v2/src/modules/auth/guards/access-token/token-expired.exception.ts rename to apps/api/v2/src/modules/auth/guards/api-auth/token-expired.exception.ts diff --git a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts index 0bd07651de..debc1b343b 100644 --- a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts +++ b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts @@ -1,6 +1,7 @@ import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { createMock } from "@golevelup/ts-jest"; import { ExecutionContext } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { Reflector } from "@nestjs/core"; import { APPS_WRITE, SCHEDULE_READ, SCHEDULE_WRITE } from "@calcom/platform-constants"; @@ -13,7 +14,7 @@ describe("PermissionsGuard", () => { beforeEach(async () => { reflector = new Reflector(); - guard = new PermissionsGuard(reflector, createMock()); + guard = new PermissionsGuard(reflector, createMock(), createMock()); }); it("should be defined", () => { diff --git a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts index 4fec7f8700..ee213059fc 100644 --- a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts +++ b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts @@ -1,13 +1,19 @@ +import { isApiKey } from "@/lib/api-key"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { Reflector } from "@nestjs/core"; import { hasPermissions } from "@calcom/platform-utils"; @Injectable() export class PermissionsGuard implements CanActivate { - constructor(private reflector: Reflector, private tokensRepository: TokensRepository) {} + constructor( + private reflector: Reflector, + private tokensRepository: TokensRepository, + private readonly config: ConfigService + ) {} async canActivate(context: ExecutionContext): Promise { const requiredPermissions = this.reflector.get(Permissions, context.getHandler()); @@ -17,13 +23,18 @@ export class PermissionsGuard implements CanActivate { } const request = context.switchToHttp().getRequest(); - const accessToken = request.get("Authorization")?.replace("Bearer ", ""); + const authString = request.get("Authorization")?.replace("Bearer ", ""); - if (!accessToken) { + if (!authString) { return false; } - const oAuthClientPermissions = await this.getOAuthClientPermissions(accessToken); + // only check permissions for accessTokens attached to an oAuth Client + if (isApiKey(authString, this.config.get("api.apiKeyPrefix") ?? "cal_")) { + return true; + } + + const oAuthClientPermissions = await this.getOAuthClientPermissions(authString); if (!oAuthClientPermissions) { return false; diff --git a/apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts b/apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts deleted file mode 100644 index 72846cdfad..0000000000 --- a/apps/api/v2/src/modules/auth/strategies/access-token/access-token.strategy.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { BaseStrategy } from "@/lib/passport/strategies/types"; -import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; -import { TokensRepository } from "@/modules/tokens/tokens.repository"; -import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; -import { Injectable, UnauthorizedException } from "@nestjs/common"; -import { PassportStrategy } from "@nestjs/passport"; -import type { Request } from "express"; - -import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants"; - -@Injectable() -export class AccessTokenStrategy extends PassportStrategy(BaseStrategy, "access-token") { - constructor( - private readonly oauthFlowService: OAuthFlowService, - private readonly tokensRepository: TokensRepository, - private readonly userRepository: UsersRepository - ) { - super(); - } - - async authenticate(request: Request) { - try { - const accessToken = request.get("Authorization")?.replace("Bearer ", ""); - const requestOrigin = request.get("Origin"); - - if (!accessToken) { - throw new UnauthorizedException(INVALID_ACCESS_TOKEN); - } - - const accessTokenValid = await this.oauthFlowService.validateAccessToken(accessToken); - if (!accessTokenValid) { - throw new UnauthorizedException(INVALID_ACCESS_TOKEN); - } - - const client = await this.tokensRepository.getAccessTokenClient(accessToken); - if (!client) { - throw new UnauthorizedException("OAuth client not found given the access token"); - } - - if (requestOrigin && !client.redirectUris.some((uri) => uri.startsWith(requestOrigin))) { - throw new UnauthorizedException( - `Invalid request origin - please open https://app.cal.com/settings/platform and add the origin '${requestOrigin}' to the 'Redirect uris' of your OAuth client.` - ); - } - - const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken); - - if (!ownerId) { - throw new UnauthorizedException(INVALID_ACCESS_TOKEN); - } - - const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(ownerId); - - if (!user) { - throw new UnauthorizedException(INVALID_ACCESS_TOKEN); - } - - return this.success(user); - } catch (error) { - if (error instanceof Error) return this.error(error); - } - } -} diff --git a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.spec.ts b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.spec.ts new file mode 100644 index 0000000000..2f2921999b --- /dev/null +++ b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.spec.ts @@ -0,0 +1,183 @@ +import appConfig from "@/config/app"; +import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; +import { DeploymentsRepository } from "@/modules/deployments/deployments.repository"; +import { DeploymentsService } from "@/modules/deployments/deployments.service"; +import { JwtService } from "@/modules/jwt/jwt.service"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { ExecutionContext, HttpException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { ConfigModule } from "@nestjs/config"; +import { JwtService as NestJwtService } from "@nestjs/jwt"; +import { Test, TestingModule } from "@nestjs/testing"; +import { PlatformOAuthClient, Team, User } from "@prisma/client"; +import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { MockedRedisService } from "test/mocks/mock-redis-service"; + +import { ApiAuthStrategy } from "./api-auth.strategy"; + +describe("ApiAuthStrategy", () => { + let strategy: ApiAuthStrategy; + let userRepositoryFixture: UserRepositoryFixture; + let tokensRepositoryFixture: TokensRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let organization: Team; + let oAuthClient: PlatformOAuthClient; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; + let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture; + const validApiKeyEmail = "api-key-user-email@example.com"; + const validAccessTokenEmail = "access-token-user-email@example.com"; + let validApiKeyUser: User; + let validAccessTokenUser: User; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + ignoreEnvFile: true, + isGlobal: true, + load: [appConfig], + }), + ], + providers: [ + MockedRedisService, + ApiAuthStrategy, + ConfigService, + OAuthFlowService, + UsersRepository, + ApiKeyRepository, + DeploymentsService, + OAuthClientRepository, + PrismaReadService, + PrismaWriteService, + TokensRepository, + JwtService, + DeploymentsRepository, + NestJwtService, + ], + }).compile(); + + strategy = module.get(ApiAuthStrategy); + userRepositoryFixture = new UserRepositoryFixture(module); + tokensRepositoryFixture = new TokensRepositoryFixture(module); + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(module); + teamRepositoryFixture = new TeamRepositoryFixture(module); + oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module); + organization = await teamRepositoryFixture.create({ name: "organization" }); + validApiKeyUser = await userRepositoryFixture.create({ + email: validApiKeyEmail, + }); + validAccessTokenUser = await userRepositoryFixture.create({ + email: validAccessTokenEmail, + }); + const data = { + logo: "logo-url", + name: "name", + redirectUris: ["http://localhost:3000"], + permissions: 32, + }; + oAuthClient = await oAuthClientRepositoryFixture.create(organization.id, data, "secret"); + }); + + describe("authenticate with strategy", () => { + it("should return user associated with valid access token", async () => { + console.log("HERERERER"); + const { accessToken } = await tokensRepositoryFixture.createTokens( + validAccessTokenUser.id, + oAuthClient.id + ); + + console.log("Access token", accessToken); + + const context: ExecutionContext = { + switchToHttp: () => ({ + getRequest: () => ({ + headers: { + authorization: `Bearer ${accessToken}`, + }, + get: (key: string) => + ({ Authorization: `Bearer ${accessToken}`, origin: "http://localhost:3000" }[key]), + }), + }), + } as ExecutionContext; + const request = context.switchToHttp().getRequest(); + + const user = await strategy.authenticate(request); + + await expect(user.id).toEqual(validAccessTokenUser.id); + }); + + it("should return user associated with valid api key", async () => { + const now = new Date(); + now.setDate(now.getDate() + 1); + const { keyString } = await apiKeysRepositoryFixture.createApiKey(validApiKeyUser.id, now); + + const context: ExecutionContext = { + switchToHttp: () => ({ + getRequest: () => ({ + headers: { + authorization: `Bearer cal_test_${keyString}`, + }, + get: (key: string) => + ({ Authorization: `Bearer cal_test_${keyString}`, origin: "http://localhost:3000" }[key]), + }), + }), + } as ExecutionContext; + const request = context.switchToHttp().getRequest(); + + const user = await strategy.authenticate(request); + + expect(user.id).toEqual(validApiKeyUser.id); + }); + + it("should throw 401 if api key is invalid", async () => { + const context: ExecutionContext = { + switchToHttp: () => ({ + getRequest: () => ({ + headers: { + authorization: `Bearer cal_test_}`, + }, + get: (key: string) => + ({ Authorization: `Bearer cal_test_badkey1234`, origin: "http://localhost:3000" }[key]), + }), + }), + } as ExecutionContext; + const request = context.switchToHttp().getRequest(); + + try { + await strategy.authenticate(request); + } catch (error) { + if (error instanceof HttpException) { + expect(error.getStatus()).toEqual(401); + } + } + }); + + it("should throw 401 if Authorization header does not contain auth token", async () => { + const context: ExecutionContext = { + switchToHttp: () => ({ + getRequest: () => ({ + get: (key: string) => ({ Authorization: ``, origin: "http://localhost:3000" }[key]), + }), + }), + } as ExecutionContext; + const request = context.switchToHttp().getRequest(); + + try { + await strategy.authenticate(request); + } catch (error) { + if (error instanceof HttpException) { + expect(error.getStatus()).toEqual(401); + } + } + }); + }); +}); diff --git a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts new file mode 100644 index 0000000000..d05824382c --- /dev/null +++ b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts @@ -0,0 +1,109 @@ +import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; +import { BaseStrategy } from "@/lib/passport/strategies/types"; +import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; +import { DeploymentsService } from "@/modules/deployments/deployments.service"; +import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; +import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PassportStrategy } from "@nestjs/passport"; +import type { Request } from "express"; + +import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants"; + +@Injectable() +export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") { + constructor( + private readonly deploymentsService: DeploymentsService, + private readonly config: ConfigService, + private readonly oauthFlowService: OAuthFlowService, + private readonly tokensRepository: TokensRepository, + private readonly userRepository: UsersRepository, + private readonly apiKeyRepository: ApiKeyRepository + ) { + super(); + } + + async authenticate(request: Request) { + const authString = request.get("Authorization")?.replace("Bearer ", ""); + if (!authString) { + return this.error(new UnauthorizedException("No Authorization header provided")); + } + + const requestOrigin = request.get("Origin"); + + try { + const user = isApiKey(authString, this.config.get("api.apiKeyPrefix") ?? "cal_") + ? await this.apiKeyStrategy(authString) + : await this.accessTokenStrategy(authString, requestOrigin); + + if (!user) { + return this.error(new UnauthorizedException("No user associated with the provided token")); + } + + return this.success(user); + } catch (err) { + if (err instanceof Error) { + return this.error(err); + } + return this.error( + new InternalServerErrorException("An error occurred while authenticating the request") + ); + } + } + + async apiKeyStrategy(apiKey: string) { + const isLicenseValid = await this.deploymentsService.checkLicense(); + if (!isLicenseValid) { + throw new UnauthorizedException("Invalid or missing CALCOM_LICENSE_KEY environment variable"); + } + const strippedApiKey = stripApiKey(apiKey, this.config.get("api.keyPrefix")); + const apiKeyHash = hashAPIKey(strippedApiKey); + const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash); + if (!keyData) { + throw new UnauthorizedException("Your api key is not valid"); + } + + const isKeyExpired = + keyData.expiresAt && new Date().setHours(0, 0, 0, 0) > keyData.expiresAt.setHours(0, 0, 0, 0); + if (isKeyExpired) { + throw new UnauthorizedException("Your api key is expired"); + } + + const apiKeyOwnerId = keyData.userId; + if (!apiKeyOwnerId) { + throw new UnauthorizedException("No user tied to this apiKey"); + } + + const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(apiKeyOwnerId); + return user; + } + + async accessTokenStrategy(accessToken: string, origin?: string) { + const accessTokenValid = await this.oauthFlowService.validateAccessToken(accessToken); + if (!accessTokenValid) { + throw new UnauthorizedException(INVALID_ACCESS_TOKEN); + } + + const client = await this.tokensRepository.getAccessTokenClient(accessToken); + if (!client) { + throw new UnauthorizedException("OAuth client not found given the access token"); + } + + if (origin && !client.redirectUris.some((uri) => uri.startsWith(origin))) { + throw new UnauthorizedException( + `Invalid request origin - please open https://app.cal.com/settings/platform and add the origin '${origin}' to the 'Redirect uris' of your OAuth client.` + ); + } + + const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken); + + if (!ownerId) { + throw new UnauthorizedException(INVALID_ACCESS_TOKEN); + } + + const user: UserWithProfile | null = await this.userRepository.findByIdWithProfile(ownerId); + return user; + } +} diff --git a/apps/api/v2/src/modules/auth/strategies/api-key-auth/api-key-auth.strategy.ts b/apps/api/v2/src/modules/auth/strategies/api-key-auth/api-key-auth.strategy.ts deleted file mode 100644 index 40c7f1c970..0000000000 --- a/apps/api/v2/src/modules/auth/strategies/api-key-auth/api-key-auth.strategy.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { BaseStrategy } from "@/lib/passport/strategies/types"; -import { ApiKeyService } from "@/modules/api-key/api-key.service"; -import { UsersRepository } from "@/modules/users/users.repository"; -import { Injectable, NotFoundException, UnauthorizedException } from "@nestjs/common"; -import { PassportStrategy } from "@nestjs/passport"; -import type { Request } from "express"; - -@Injectable() -export class ApiKeyAuthStrategy extends PassportStrategy(BaseStrategy, "api-key") { - constructor( - private readonly apiKeyService: ApiKeyService, - private readonly userRepository: UsersRepository - ) { - super(); - } - - async authenticate(req: Request) { - try { - const apiKey = await this.apiKeyService.retrieveApiKey(req); - - if (!apiKey) { - throw new UnauthorizedException("Authorization token is missing."); - } - - if (apiKey.expiresAt && new Date() > apiKey.expiresAt) { - throw new UnauthorizedException("The API key is expired."); - } - - const user = await this.userRepository.findById(apiKey.userId); - if (!user) { - throw new NotFoundException("User not found."); - } - - this.success(user); - } catch (error) { - if (error instanceof Error) return this.error(error); - } - } -} diff --git a/apps/api/v2/src/modules/auth/strategies/next-auth/next-auth.strategy.ts b/apps/api/v2/src/modules/auth/strategies/next-auth/next-auth.strategy.ts index e6f7b57265..a34b060882 100644 --- a/apps/api/v2/src/modules/auth/strategies/next-auth/next-auth.strategy.ts +++ b/apps/api/v2/src/modules/auth/strategies/next-auth/next-auth.strategy.ts @@ -1,6 +1,6 @@ import { NextAuthPassportStrategy } from "@/lib/passport/strategies/types"; import { UsersRepository } from "@/modules/users/users.repository"; -import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { PassportStrategy } from "@nestjs/passport"; import type { Request } from "express"; @@ -33,6 +33,9 @@ export class NextAuthStrategy extends PassportStrategy(NextAuthPassportStrategy, return this.success(user); } catch (error) { if (error instanceof Error) return this.error(error); + return this.error( + new InternalServerErrorException("An error occurred while authenticating the request") + ); } } } diff --git a/apps/api/v2/src/modules/deployments/deployments.module.ts b/apps/api/v2/src/modules/deployments/deployments.module.ts new file mode 100644 index 0000000000..1017f72c7e --- /dev/null +++ b/apps/api/v2/src/modules/deployments/deployments.module.ts @@ -0,0 +1,13 @@ +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { RedisService } from "@/modules/redis/redis.service"; +import { Module } from "@nestjs/common"; + +import { DeploymentsRepository } from "./deployments.repository"; +import { DeploymentsService } from "./deployments.service"; + +@Module({ + imports: [PrismaModule], + providers: [DeploymentsRepository, DeploymentsService, RedisService], + exports: [DeploymentsRepository, DeploymentsService], +}) +export class DeploymentsModule {} diff --git a/apps/api/v2/src/modules/deployments/deployments.repository.ts b/apps/api/v2/src/modules/deployments/deployments.repository.ts new file mode 100644 index 0000000000..ec5168e3e8 --- /dev/null +++ b/apps/api/v2/src/modules/deployments/deployments.repository.ts @@ -0,0 +1,12 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +@Injectable() +export class DeploymentsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async getDeployment() { + return this.dbRead.prisma.deployment.findFirst({ where: { id: 1 } }); + } +} diff --git a/apps/api/v2/src/modules/deployments/deployments.service.ts b/apps/api/v2/src/modules/deployments/deployments.service.ts new file mode 100644 index 0000000000..56c77c7ae0 --- /dev/null +++ b/apps/api/v2/src/modules/deployments/deployments.service.ts @@ -0,0 +1,47 @@ +import { DeploymentsRepository } from "@/modules/deployments/deployments.repository"; +import { RedisService } from "@/modules/redis/redis.service"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +const CACHING_TIME = 86400000; // 24 hours in milliseconds + +const getLicenseCacheKey = (key: string) => `api-v2-license-key-url-${key}`; + +type LicenseCheckResponse = { + valid: boolean; +}; +@Injectable() +export class DeploymentsService { + constructor( + private readonly deploymentsRepository: DeploymentsRepository, + private readonly configService: ConfigService, + private readonly redisService: RedisService + ) {} + + async checkLicense() { + if (this.configService.get("e2e")) { + return true; + } + let licenseKey = this.configService.get("api.licenseKey"); + + if (!licenseKey) { + /** We try to check on DB only if env is undefined */ + const deployment = await this.deploymentsRepository.getDeployment(); + licenseKey = deployment?.licenseKey ?? undefined; + } + + if (!licenseKey) { + return false; + } + const licenseKeyUrl = this.configService.get("api.licenseKeyUrl"); + const cachedData = await this.redisService.redis.get(getLicenseCacheKey(licenseKey)); + if (cachedData) { + return (JSON.parse(cachedData) as LicenseCheckResponse)?.valid; + } + const response = await fetch(licenseKeyUrl, { mode: "cors" }); + const data = (await response.json()) as LicenseCheckResponse; + const cacheKey = getLicenseCacheKey(licenseKey); + this.redisService.redis.set(cacheKey, JSON.stringify(data), "EX", CACHING_TIME); + return data.valid; + } +} diff --git a/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts b/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts index e65cab8bc0..ac0791ea2b 100644 --- a/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts +++ b/apps/api/v2/src/modules/oauth-clients/services/oauth-flow.service.ts @@ -1,4 +1,4 @@ -import { TokenExpiredException } from "@/modules/auth/guards/access-token/token-expired.exception"; +import { TokenExpiredException } from "@/modules/auth/guards/api-auth/token-expired.exception"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { RedisService } from "@/modules/redis/redis.service"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; diff --git a/apps/api/v2/src/modules/slots/controllers/slots.controller.ts b/apps/api/v2/src/modules/slots/controllers/slots.controller.ts index 4ab18d70ae..17ba7bb70d 100644 --- a/apps/api/v2/src/modules/slots/controllers/slots.controller.ts +++ b/apps/api/v2/src/modules/slots/controllers/slots.controller.ts @@ -1,5 +1,5 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions"; -import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { SlotsService } from "@/modules/slots/services/slots.service"; import { Query, Body, Controller, Get, Delete, Post, Req, Res, UseGuards } from "@nestjs/common"; import { ApiTags as DocsTags } from "@nestjs/swagger"; diff --git a/apps/api/v2/src/modules/users/inputs/update-managed-user.input.ts b/apps/api/v2/src/modules/users/inputs/update-managed-user.input.ts index fd938c511c..b224198995 100644 --- a/apps/api/v2/src/modules/users/inputs/update-managed-user.input.ts +++ b/apps/api/v2/src/modules/users/inputs/update-managed-user.input.ts @@ -1,6 +1,6 @@ import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input"; import { ApiProperty } from "@nestjs/swagger"; -import { IsNumber, IsOptional, IsString, IsTimeZone } from "class-validator"; +import { IsIn, IsNumber, IsOptional, IsString, IsTimeZone } from "class-validator"; export class UpdateManagedUserInput { @IsString() @@ -12,6 +12,7 @@ export class UpdateManagedUserInput { name?: string; @IsOptional() + @IsIn(["12", "24"]) @ApiProperty({ example: 12, enum: [12, 24], description: "Must be 12 or 24" }) timeFormat?: TimeFormat; @@ -21,6 +22,7 @@ export class UpdateManagedUserInput { @IsOptional() @IsString() + @IsIn(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]) @ApiProperty({ example: "Monday", enum: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 78bdca87a6..3b8089a753 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -810,16 +810,6 @@ "post": { "operationId": "SchedulesController_2024_06_11_createSchedule", "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateScheduleInput_2024_06_11" - } - } - } - }, "responses": { "201": { "description": "", @@ -862,14 +852,7 @@ "parameters": [], "responses": { "200": { - "description": "Returns the default schedule", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/GetDefaultScheduleOutput_2024_06_11" - } - } - } + "description": "Returns the default schedule" } }, "tags": [ @@ -918,16 +901,6 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateScheduleInput_2024_06_11" - } - } - } - }, "responses": { "200": { "description": "", @@ -1890,16 +1863,15 @@ "properties": { "timeFormat": { "type": "number", - "example": 12, "enum": [ 12, 24 ], + "example": 12, "description": "Must be 12 or 24" }, "weekStart": { "type": "string", - "example": "Monday", "enum": [ "Monday", "Tuesday", @@ -1908,7 +1880,8 @@ "Friday", "Saturday", "Sunday" - ] + ], + "example": "Monday" }, "email": { "type": "string" @@ -1934,11 +1907,15 @@ "refreshToken": { "type": "string", "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + }, + "accessTokenExpiresAt": { + "type": "number" } }, "required": [ "accessToken", - "refreshToken" + "refreshToken", + "accessTokenExpiresAt" ] }, "KeysResponseDto": { @@ -3303,66 +3280,6 @@ "data" ] }, - "UpdateScheduleInput_2024_04_15": { - "type": "object", - "properties": { - "timeZone": { - "type": "string" - }, - "name": { - "type": "string" - }, - "isDefault": { - "type": "boolean" - }, - "schedule": { - "example": [ - [], - [ - { - "start": "2022-01-01T00:00:00.000Z", - "end": "2022-01-02T00:00:00.000Z" - } - ], - [], - [], - [], - [], - [] - ], - "items": { - "type": "array" - }, - "type": "array" - }, - "dateOverrides": { - "example": [ - [], - [ - { - "start": "2022-01-01T00:00:00.000Z", - "end": "2022-01-02T00:00:00.000Z" - } - ], - [], - [], - [], - [], - [] - ], - "items": { - "type": "array" - }, - "type": "array" - } - }, - "required": [ - "timeZone", - "name", - "isDefault", - "schedule" - ] - }, "EventTypeModel_2024_04_15": { "type": "object", "properties": { @@ -3618,15 +3535,17 @@ ], "type": "array", "items": { - "type": "string" + "type": "object" } }, "startTime": { "type": "string", + "pattern": "TIME_FORMAT_HH_MM", "example": "09:00" }, "endTime": { "type": "string", + "pattern": "TIME_FORMAT_HH_MM", "example": "10:00" } }, @@ -3645,10 +3564,12 @@ }, "startTime": { "type": "string", + "pattern": "TIME_FORMAT_HH_MM", "example": "12:00" }, "endTime": { "type": "string", + "pattern": "TIME_FORMAT_HH_MM", "example": "13:00" } }, @@ -3658,57 +3579,6 @@ "endTime" ] }, - "CreateScheduleInput_2024_06_11": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "One-on-one coaching" - }, - "timeZone": { - "type": "string", - "example": "Europe/Rome" - }, - "availability": { - "example": [ - { - "days": [ - "Monday", - "Tuesday" - ], - "startTime": "09:00", - "endTime": "10:00" - } - ], - "type": "array", - "items": { - "$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11" - } - }, - "isDefault": { - "type": "boolean", - "example": true - }, - "overrides": { - "example": [ - { - "date": "2024-05-20", - "startTime": "12:00", - "endTime": "14:00" - } - ], - "type": "array", - "items": { - "$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11" - } - } - }, - "required": [ - "name", - "timeZone", - "isDefault" - ] - }, "ScheduleOutput_2024_06_11": { "type": "object", "properties": { @@ -3792,26 +3662,6 @@ "data" ] }, - "GetDefaultScheduleOutput_2024_06_11": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "success", - "enum": [ - "success", - "error" - ] - }, - "data": { - "$ref": "#/components/schemas/ScheduleOutput_2024_06_11" - } - }, - "required": [ - "status", - "data" - ] - }, "GetScheduleOutput_2024_06_11": { "type": "object", "properties": { @@ -3866,52 +3716,6 @@ "data" ] }, - "UpdateScheduleInput_2024_06_11": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "One-on-one coaching" - }, - "timeZone": { - "type": "string", - "example": "Europe/Rome" - }, - "availability": { - "example": [ - { - "days": [ - "Monday", - "Tuesday" - ], - "startTime": "09:00", - "endTime": "10:00" - } - ], - "type": "array", - "items": { - "$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11" - } - }, - "isDefault": { - "type": "boolean", - "example": true - }, - "overrides": { - "example": [ - { - "date": "2024-05-20", - "startTime": "12:00", - "endTime": "14:00" - } - ], - "type": "array", - "items": { - "$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11" - } - } - } - }, "UpdateScheduleOutput_2024_06_11": { "type": "object", "properties": { diff --git a/apps/api/v2/test/fixtures/repository/api-keys.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/api-keys.repository.fixture.ts new file mode 100644 index 0000000000..ab10f52ec9 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/api-keys.repository.fixture.ts @@ -0,0 +1,28 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { randomBytes, createHash } from "crypto"; + +export class ApiKeysRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async createApiKey(userId: number, expiresAt: Date | null, teamId?: number) { + const keyString = randomBytes(16).toString("hex"); + const apiKey = await this.prismaWriteClient.apiKey.create({ + data: { + userId, + teamId, + hashedKey: createHash("sha256").update(keyString).digest("hex"), + expiresAt: expiresAt, + }, + }); + + return { apiKey, keyString }; + } +} diff --git a/apps/api/v2/test/mocks/access-token-mock.strategy.ts b/apps/api/v2/test/mocks/api-auth-mock.strategy.ts similarity index 88% rename from apps/api/v2/test/mocks/access-token-mock.strategy.ts rename to apps/api/v2/test/mocks/api-auth-mock.strategy.ts index f7c89a2beb..76bb3a0e62 100644 --- a/apps/api/v2/test/mocks/access-token-mock.strategy.ts +++ b/apps/api/v2/test/mocks/api-auth-mock.strategy.ts @@ -4,7 +4,7 @@ import { Injectable } from "@nestjs/common"; import { PassportStrategy } from "@nestjs/passport"; @Injectable() -export class AccessTokenMockStrategy extends PassportStrategy(BaseStrategy, "access-token") { +export class ApiAuthMockStrategy extends PassportStrategy(BaseStrategy, "api-auth") { constructor(private readonly email: string, private readonly usersRepository: UsersRepository) { super(); } diff --git a/apps/api/v2/test/mocks/mock-redis-service.ts b/apps/api/v2/test/mocks/mock-redis-service.ts new file mode 100644 index 0000000000..1239c3c540 --- /dev/null +++ b/apps/api/v2/test/mocks/mock-redis-service.ts @@ -0,0 +1,15 @@ +import { RedisService } from "@/modules/redis/redis.service"; +import { Provider } from "@nestjs/common"; + +export const MockedRedisService = { + provide: RedisService, + useValue: { + redis: { + get: jest.fn(), + hgetall: jest.fn(), + set: jest.fn(), + hmset: jest.fn(), + expireat: jest.fn(), + }, + }, +} as Provider; diff --git a/apps/api/v2/test/setEnvVars.ts b/apps/api/v2/test/setEnvVars.ts index 5760cb6eb1..e7a2393878 100644 --- a/apps/api/v2/test/setEnvVars.ts +++ b/apps/api/v2/test/setEnvVars.ts @@ -1,15 +1,21 @@ import type { Environment } from "@/env"; const env: Partial> = { + API_URL: "http://localhost", API_PORT: "5555", + DATABASE_URL: "postgresql://postgres:@localhost:5450/calendso", DATABASE_READ_URL: "postgresql://postgres:@localhost:5450/calendso", DATABASE_WRITE_URL: "postgresql://postgres:@localhost:5450/calendso", NEXTAUTH_SECRET: "XF+Hws3A5g2eyWA5uGYYVJ74X+wrCWJ8oWo6kAfU6O8=", JWT_SECRET: "XF+Hws3A5g2eyWA5uGYYVJ74X+wrCWJ8oWo6kAfU6O8=", LOG_LEVEL: "trace", - REDIS_URL: "redis://localhost:9199", + REDIS_URL: "redis://localhost:6379", STRIPE_API_KEY: "sk_test_51J4", STRIPE_WEBHOOK_SECRET: "whsec_51J4", + IS_E2E: true, + API_KEY_PREFIX: "cal_test_", + GET_LICENSE_KEY_URL: " https://console.cal.com/api/license", + CALCOM_LICENSE_KEY: "c4234812-12ab-42s6-a1e3-55bedd4a5bb7", }; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore diff --git a/apps/api/v2/test/utils/withAccessTokenAuth.ts b/apps/api/v2/test/utils/withAccessTokenAuth.ts deleted file mode 100644 index 809de3c683..0000000000 --- a/apps/api/v2/test/utils/withAccessTokenAuth.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { AccessTokenStrategy } from "@/modules/auth/strategies/access-token/access-token.strategy"; -import { UsersRepository } from "@/modules/users/users.repository"; -import { TestingModuleBuilder } from "@nestjs/testing"; -import { AccessTokenMockStrategy } from "test/mocks/access-token-mock.strategy"; - -export const withAccessTokenAuth = (email: string, module: TestingModuleBuilder) => - module.overrideProvider(AccessTokenStrategy).useFactory({ - factory: (usersRepository: UsersRepository) => new AccessTokenMockStrategy(email, usersRepository), - inject: [UsersRepository], - }); diff --git a/apps/api/v2/test/utils/withApiAuth.ts b/apps/api/v2/test/utils/withApiAuth.ts new file mode 100644 index 0000000000..bc5a4904b8 --- /dev/null +++ b/apps/api/v2/test/utils/withApiAuth.ts @@ -0,0 +1,10 @@ +import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy"; +import { UsersRepository } from "@/modules/users/users.repository"; +import { TestingModuleBuilder } from "@nestjs/testing"; +import { ApiAuthMockStrategy } from "test/mocks/api-auth-mock.strategy"; + +export const withApiAuth = (email: string, module: TestingModuleBuilder) => + module.overrideProvider(ApiAuthStrategy).useFactory({ + factory: (usersRepository: UsersRepository) => new ApiAuthMockStrategy(email, usersRepository), + inject: [UsersRepository], + }); diff --git a/packages/platform/constants/api.ts b/packages/platform/constants/api.ts index 32ff91fa8e..adbf719ef6 100644 --- a/packages/platform/constants/api.ts +++ b/packages/platform/constants/api.ts @@ -52,9 +52,8 @@ export const X_CAL_CLIENT_ID = "x-cal-client-id"; export const HTTP_CODE_TOKEN_EXPIRED = 498; export const VERSION_2024_06_11 = "2024-06-11"; -export const VERSION_2024_05_21 = "2024-05-21"; export const VERSION_2024_04_15 = "2024-04-15"; -export const API_VERSIONS = [VERSION_2024_06_11, VERSION_2024_04_15, VERSION_2024_05_21] as const; +export const API_VERSIONS = [VERSION_2024_06_11, VERSION_2024_04_15] as const; export type API_VERSIONS_ENUM = (typeof API_VERSIONS)[number]; export type API_VERSIONS_TYPE = typeof API_VERSIONS; export const CAL_API_VERSION_HEADER = "cal-api-version";