fix: api v2 booking controller with api key (#16358)

* fix: api v2 booking controller with api key

* add test

* fixup! add test

* fixup! Merge branch 'fix-booking-controller-api-key' of github.com:calcom/cal.com into fix-booking-controller-api-key

* fixup! Merge branch 'fix-booking-controller-api-key' of github.com:calcom/cal.com into fix-booking-controller-api-key
This commit is contained in:
Morgan
2024-08-28 16:05:14 +03:00
committed by GitHub
parent 6b244fc5b5
commit ee8cc81862
6 changed files with 118 additions and 14 deletions
@@ -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 {}
@@ -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>(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<Awaited<ReturnType<typeof handleNewBooking>>> =
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);
}
});
});
@@ -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<number | undefined> {
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<string>("api.apiKeyPrefix") ?? "cal_")) {
const strippedApiKey = stripApiKey(bearerToken, this.config.get<string>("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);
@@ -0,0 +1,6 @@
export enum AuthMethods {
"API_KEY" = "api-key",
"ACCESS_TOKEN" = "access-token",
"OAUTH_CLIENT" = "oauth-client",
"NEXT_AUTH" = "next-auth",
}
@@ -0,0 +1,13 @@
import { AuthMethods } from "@/lib/enums/auth-methods";
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
export const GetAuthMethod = createParamDecorator<unknown, ExecutionContext>((_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;
});
@@ -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<string>("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);
}