diff --git a/apps/api/v2/src/ee/bookings/bookings.module.ts b/apps/api/v2/src/ee/bookings/bookings.module.ts index 8e1be0f3e7..b91c65392e 100644 --- a/apps/api/v2/src/ee/bookings/bookings.module.ts +++ b/apps/api/v2/src/ee/bookings/bookings.module.ts @@ -1,4 +1,5 @@ import { BookingsController } from "@/ee/bookings/controllers/bookings.controller"; +import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; import { BillingModule } from "@/modules/billing/billing.module"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; @@ -10,7 +11,7 @@ import { Module } from "@nestjs/common"; @Module({ imports: [PrismaModule, RedisModule, TokensModule, BillingModule], - providers: [TokensRepository, OAuthFlowService, OAuthClientRepository], + providers: [TokensRepository, OAuthFlowService, OAuthClientRepository, ApiKeyRepository], controllers: [BookingsController], }) export class BookingsModule {} 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 c8731539f5..cec587562a 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 @@ -14,6 +14,7 @@ import { NestExpressApplication } from "@nestjs/platform-express"; import { Test } from "@nestjs/testing"; import { User } from "@prisma/client"; import * as request from "supertest"; +import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; 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"; @@ -31,6 +32,8 @@ describe("Bookings Endpoints", () => { let bookingsRepositoryFixture: BookingsRepositoryFixture; let schedulesService: SchedulesService_2024_04_15; let eventTypesRepositoryFixture: EventTypesRepositoryFixture; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; + let apiKeyString: string; const userEmail = "bookings-controller-e2e@api.com"; let user: User; @@ -56,11 +59,13 @@ describe("Bookings Endpoints", () => { bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); schedulesService = moduleRef.get(SchedulesService_2024_04_15); - + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); user = await userRepositoryFixture.create({ email: userEmail, }); + const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null); + apiKeyString = keyString; const userSchedule: CreateScheduleInput_2024_04_15 = { name: "working time", timeZone: "Europe/Rome", @@ -139,6 +144,62 @@ describe("Bookings Endpoints", () => { }); }); + it("should create a booking with api key to get owner id", async () => { + const bookingStart = "2040-05-22T09:30:00.000Z"; + const bookingEnd = "2040-05-22T10:30:00.000Z"; + const bookingEventTypeId = eventTypeId; + const bookingTimeZone = "Europe/London"; + const bookingLanguage = "en"; + const bookingHashedLink = ""; + const bookingMetadata = { + timeFormat: "12", + meetingType: "organizer-phone", + }; + const bookingResponses = { + name: "tester", + email: "tester@example.com", + location: { + value: "link", + optionValue: "", + }, + notes: "test", + guests: [], + }; + + const body: CreateBookingInput = { + start: bookingStart, + end: bookingEnd, + eventTypeId: bookingEventTypeId, + timeZone: bookingTimeZone, + language: bookingLanguage, + metadata: bookingMetadata, + hashedLink: bookingHashedLink, + responses: bookingResponses, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set({ Authorization: `Bearer cal_test_${apiKeyString}` }) + .expect(201) + .then(async (response) => { + const responseBody: ApiSuccessResponse>> = + response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.userPrimaryEmail).toBeDefined(); + expect(responseBody.data.userPrimaryEmail).toEqual(userEmail); + expect(responseBody.data.id).toBeDefined(); + expect(responseBody.data.uid).toBeDefined(); + expect(responseBody.data.startTime).toEqual(bookingStart); + expect(responseBody.data.eventTypeId).toEqual(bookingEventTypeId); + expect(responseBody.data.user.timeZone).toEqual(bookingTimeZone); + expect(responseBody.data.metadata).toEqual(bookingMetadata); + + createdBooking = responseBody.data; + }); + }); + describe("should reschedule a booking", () => { it("should reschedule with updated start time, end time & metadata", async () => { const newBookingStart = "2040-05-21T12:30:00.000Z"; @@ -202,18 +263,22 @@ describe("Bookings Endpoints", () => { .get("/v2/bookings?filters[status]=upcoming") .then((response) => { const responseBody: GetBookingsOutput = response.body; - const fetchedBooking = responseBody.data.bookings[0]; - expect(responseBody.data.bookings.length).toEqual(1); + expect(responseBody.data.bookings.length).toEqual(2); + const fetchedBooking = responseBody.data.bookings.find( + (booking) => booking.id === createdBooking.id + ); expect(responseBody.status).toEqual(SUCCESS_STATUS); expect(responseBody.data).toBeDefined(); expect(fetchedBooking).toBeDefined(); - expect(fetchedBooking.id).toEqual(createdBooking.id); - expect(fetchedBooking.uid).toEqual(createdBooking.uid); - expect(fetchedBooking.startTime).toEqual(createdBooking.startTime); - expect(fetchedBooking.endTime).toEqual(createdBooking.endTime); - expect(fetchedBooking.user?.email).toEqual(userEmail); + if (fetchedBooking) { + expect(fetchedBooking.id).toEqual(createdBooking.id); + expect(fetchedBooking.uid).toEqual(createdBooking.uid); + expect(fetchedBooking.startTime).toEqual(createdBooking.startTime); + expect(fetchedBooking.endTime).toEqual(createdBooking.endTime); + expect(fetchedBooking.user?.email).toEqual(userEmail); + } }); }); 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 dfe7b26c70..0ce1889610 100644 --- a/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/controllers/bookings.controller.ts @@ -4,7 +4,9 @@ import { MarkNoShowInput } from "@/ee/bookings/inputs/mark-no-show.input"; import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output"; import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output"; import { MarkNoShowOutput } from "@/ee/bookings/outputs/mark-no-show.output"; +import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; @@ -28,6 +30,7 @@ import { NotFoundException, UseGuards, } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger"; import { User } from "@prisma/client"; import { Request } from "express"; @@ -86,7 +89,9 @@ export class BookingsController { private readonly oAuthFlowService: OAuthFlowService, private readonly prismaReadService: PrismaReadService, private readonly oAuthClientRepository: OAuthClientRepository, - private readonly billingService: BillingService + private readonly billingService: BillingService, + private readonly config: ConfigService, + private readonly apiKeyRepository: ApiKeyRepository ) {} @Get("/") @@ -296,9 +301,17 @@ export class BookingsController { private async getOwnerId(req: Request): Promise { try { - const accessToken = req.get("Authorization")?.replace("Bearer ", ""); - if (accessToken) { - return this.oAuthFlowService.getOwnerId(accessToken); + const bearerToken = req.get("Authorization")?.replace("Bearer ", ""); + if (bearerToken) { + if (isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_")) { + const strippedApiKey = stripApiKey(bearerToken, this.config.get("api.keyPrefix")); + const apiKeyHash = hashAPIKey(strippedApiKey); + const keyData = await this.apiKeyRepository.getApiKeyFromHash(apiKeyHash); + return keyData?.userId; + } else { + // Access Token + return this.oAuthFlowService.getOwnerId(bearerToken); + } } } catch (err) { this.logger.error(err); diff --git a/apps/api/v2/src/lib/enums/auth-methods.ts b/apps/api/v2/src/lib/enums/auth-methods.ts new file mode 100644 index 0000000000..282aa7462d --- /dev/null +++ b/apps/api/v2/src/lib/enums/auth-methods.ts @@ -0,0 +1,6 @@ +export enum AuthMethods { + "API_KEY" = "api-key", + "ACCESS_TOKEN" = "access-token", + "OAUTH_CLIENT" = "oauth-client", + "NEXT_AUTH" = "next-auth", +} diff --git a/apps/api/v2/src/modules/auth/decorators/methods/get-auth-methods.decorator.ts b/apps/api/v2/src/modules/auth/decorators/methods/get-auth-methods.decorator.ts new file mode 100644 index 0000000000..0b12370f99 --- /dev/null +++ b/apps/api/v2/src/modules/auth/decorators/methods/get-auth-methods.decorator.ts @@ -0,0 +1,13 @@ +import { AuthMethods } from "@/lib/enums/auth-methods"; +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; + +export const GetAuthMethod = createParamDecorator((_data, ctx) => { + const request = ctx.switchToHttp().getRequest(); + const authMethod = request.authMethod as AuthMethods; + + if (!authMethod) { + throw new Error("GetAuthMethod decorator : auth method not set"); + } + + return authMethod; +}); 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 index 921e8cef83..b159f0baee 100644 --- 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 @@ -1,4 +1,5 @@ import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key"; +import { AuthMethods } from "@/lib/enums/auth-methods"; import { BaseStrategy } from "@/lib/passport/strategies/types"; import { ApiKeyRepository } from "@/modules/api-key/api-key-repository"; import { DeploymentsService } from "@/modules/deployments/deployments.service"; @@ -30,7 +31,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") super(); } - async authenticate(request: Request) { + async authenticate(request: Request & { authMethod: AuthMethods }) { try { const { params } = request; const oAuthClientSecret = request.get(X_CAL_SECRET_KEY); @@ -38,11 +39,15 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") const bearerToken = request.get("Authorization")?.replace("Bearer ", ""); if (oAuthClientId && oAuthClientSecret) { + request.authMethod = AuthMethods["OAUTH_CLIENT"]; return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret); } if (bearerToken) { const requestOrigin = request.get("Origin"); + request.authMethod = isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_") + ? AuthMethods["API_KEY"] + : AuthMethods["ACCESS_TOKEN"]; return await this.authenticateBearerToken(bearerToken, requestOrigin); } @@ -50,6 +55,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret }); if (nextAuthToken) { + request.authMethod = AuthMethods["NEXT_AUTH"]; return await this.authenticateNextAuth(nextAuthToken); }