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
This commit is contained in:
Morgan
2024-06-19 16:00:22 +00:00
committed by GitHub
parent c5a83f4df2
commit 9a473d5b55
49 changed files with 583 additions and 430 deletions
+4
View File
@@ -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
+10
View File
@@ -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
-2
View File
@@ -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;
+4
View File
@@ -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),
};
};
+4
View File
@@ -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;
};
@@ -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<ReturnType<typeof handleNewBooking>>;
beforeAll(async () => {
const moduleRef = await withAccessTokenAuth(
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
@@ -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 })
@@ -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<ConnectedCalendarsOutput> {
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<ApiResponse> {
switch (calendar) {
@@ -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);
});
@@ -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<GetEventTypesOutput> {
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,
+3 -3
View File
@@ -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<GcalCheckOutput> {
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
@@ -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],
+2 -2
View File
@@ -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(
@@ -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
@@ -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],
@@ -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) {}
@@ -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],
@@ -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) {}
+4
View File
@@ -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 = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
+5
View File
@@ -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_", "");
+1 -7
View File
@@ -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;
@@ -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,
},
});
}
}
@@ -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 {}
@@ -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,
},
});
}
}
+13 -12
View File
@@ -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 {}
@@ -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<keyof User | (keyof User)[], ExecutionContext>((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;
@@ -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();
}
@@ -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<TokensRepository>());
guard = new PermissionsGuard(reflector, createMock<TokensRepository>(), createMock<ConfigService>());
});
it("should be defined", () => {
@@ -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<boolean> {
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;
@@ -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);
}
}
}
@@ -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>(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);
}
}
});
});
});
@@ -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<string>("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<string>("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;
}
}
@@ -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);
}
}
}
@@ -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")
);
}
}
}
@@ -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 {}
@@ -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 } });
}
}
@@ -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;
}
}
@@ -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";
@@ -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";
@@ -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"],
+14 -210
View File
@@ -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": {
@@ -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 };
}
}
@@ -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();
}
@@ -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;
+7 -1
View File
@@ -1,15 +1,21 @@
import type { Environment } from "@/env";
const env: Partial<Omit<Environment, "NODE_ENV">> = {
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
@@ -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],
});
+10
View File
@@ -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],
});
+1 -2
View File
@@ -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";