Skip to content

Commit

Permalink
feat: oauth client guard (#12814)
Browse files Browse the repository at this point in the history
* feat: oAuth client guard

* refactor test

* refactor: move oauth-client guard to oauth module

* refactor: separate jest config from package.json

* fix: resolving paths in jest tests

* fix: tests

* jest setup file

* fix: jest test warnings about .js platform constants imports

* refactor: test repository fixtures

* remove allowjs

* ignore js files in ts-jest

* make oauth client module global

* make oauth client module global
  • Loading branch information
supalarry authored Dec 18, 2023
1 parent f1c7f5d commit 64e1658
Show file tree
Hide file tree
Showing 11 changed files with 185 additions and 31 deletions.
14 changes: 14 additions & 0 deletions apps/api/v2/jest.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"moduleNameMapper": {
"@/(.*)": "<rootDir>/src/$1",
"test/(.*)": "<rootDir>/test/$1"
},
"testEnvironment": "node",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.ts$": "ts-jest"
},
"setupFiles": ["<rootDir>/test/setEnvVars.ts"]
}
18 changes: 1 addition & 17 deletions apps/api/v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
"@golevelup/ts-jest": "^0.4.0",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
Expand Down Expand Up @@ -68,23 +69,6 @@
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"prisma": {
"schema": "../../../packages/prisma/schema.prisma"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { AppModule } from "@/app.module";
import { OAuthClientModule } from "@/modules/oauth/oauth-client.module";
import { createMock } from "@golevelup/ts-jest";
import { ExecutionContext } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient } from "@prisma/client";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";

import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";

import { OAuthClientGuard } from "./oauth-client.guard";

describe("OAuthClientGuard", () => {
let guard: OAuthClientGuard;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oauthClient: PlatformOAuthClient;

beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [AppModule, OAuthClientModule],
}).compile();

guard = module.get<OAuthClientGuard>(OAuthClientGuard);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);

const organizationId = 1;
const data = {
logo: "logo-url",
name: "name",
redirect_uris: ["redirect-uri"],
permissions: 32,
};
const secret = "secret";

oauthClient = await oauthClientRepositoryFixture.create(organizationId, data, secret);
});

it("should be defined", () => {
expect(guard).toBeDefined();
expect(oauthClient).toBeDefined();
});

it("should return true if client ID and secret are valid", async () => {
const mockContext = createMockExecutionContext({
[X_CAL_CLIENT_ID]: oauthClient.id,
[X_CAL_SECRET_KEY]: oauthClient.secret,
});

await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});

it("should return false if client ID is invalid", async () => {
const mockContext = createMockExecutionContext({
[X_CAL_CLIENT_ID]: "invalid id",
[X_CAL_SECRET_KEY]: oauthClient.secret,
});

await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});

it("should return false if secret key is invalid", async () => {
const mockContext = createMockExecutionContext({
[X_CAL_CLIENT_ID]: oauthClient.id,
[X_CAL_SECRET_KEY]: "invalid secret",
});

await expect(guard.canActivate(mockContext)).resolves.toBe(false);
});

afterAll(async () => {
await oauthClientRepositoryFixture.delete(oauthClient.id);
});

function createMockExecutionContext(headers: Record<string, string>): ExecutionContext {
return createMock<ExecutionContext>({
switchToHttp: () => ({
getRequest: () => ({
headers,
}),
}),
});
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { OAuthClientRepository } from "@/modules/oauth/oauth-client.repository";
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";

import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";

@Injectable()
export class OAuthClientGuard implements CanActivate {
constructor(private readonly oauthRepository: OAuthClientRepository) {}

canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const { headers } = request;

const oauthClientId = headers[X_CAL_CLIENT_ID];
const oauthClientSecret = headers[X_CAL_SECRET_KEY];

return this.validateOauthClient(oauthClientId, oauthClientSecret);
}

private async validateOauthClient(oauthClientId: string, oauthClientSecret: string): Promise<boolean> {
const oauthClient = await this.oauthRepository.getOAuthClient(oauthClientId);

if (!oauthClient || oauthClient.secret !== oauthClientSecret) {
return false;
}

return true;
}
}
7 changes: 5 additions & 2 deletions apps/api/v2/src/modules/oauth/oauth-client.module.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { getEnv } from "@/env";
import { AuthModule } from "@/modules/auth/auth.module";
import { MembershipModule } from "@/modules/membership/membership.module";
import { OAuthClientGuard } from "@/modules/oauth/guard/oauth-client/oauth-client.guard";
import { OAuthClientController } from "@/modules/oauth/oauth-client.controller";
import { OAuthClientRepository } from "@/modules/oauth/oauth-client.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UserModule } from "@/modules/user/user.module";
import { Module } from "@nestjs/common";
import { Global, Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";

@Global()
@Module({
imports: [
PrismaModule,
Expand All @@ -16,7 +18,8 @@ import { JwtModule } from "@nestjs/jwt";
MembershipModule,
JwtModule.register({ secret: getEnv("JWT_SECRET") }),
],
providers: [OAuthClientRepository],
providers: [OAuthClientRepository, OAuthClientGuard],
controllers: [OAuthClientController],
exports: [OAuthClientRepository, OAuthClientGuard],
})
export class OAuthClientModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Membership, MembershipRole, Prisma, Team, User } from "@prisma/client";

export class MembershipFixtures {
export class MembershipRepositoryFixture {
private primaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { CreateOAuthClientInput } from "@/modules/oauth/input/create-oauth-client";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient } from "@prisma/client";

export class OAuthClientRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];

constructor(private readonly module: TestingModule) {
this.prismaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}

async get(clientId: PlatformOAuthClient["id"]) {
return this.prismaReadClient.platformOAuthClient.findFirst({ where: { id: clientId } });
}

async create(organizationId: number, data: CreateOAuthClientInput, secret: string) {
return this.prismaWriteClient.platformOAuthClient.create({
data: {
...data,
secret,
organizationId,
},
});
}

async delete(clientId: PlatformOAuthClient["id"]) {
return this.prismaWriteClient.platformOAuthClient.delete({ where: { id: clientId } });
}

async deleteByClientId(clientId: PlatformOAuthClient["id"]) {
return this.prismaWriteClient.platformOAuthClient.delete({ where: { id: clientId } });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma, Team } from "@prisma/client";

export class TeamFixtures {
export class TeamRepositoryFixture {
private primaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma, User } from "@prisma/client";

export class UsersFixtures {
export class UserRepositoryFixture {
private primaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];

Expand Down
18 changes: 9 additions & 9 deletions apps/api/v2/test/oauth.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiSuccessResponse } from "@calcom/platform-types";

import { bootstrap } from "../src/app";
import { MembershipFixtures } from "./fixtures/membership.fixtures";
import { TeamFixtures } from "./fixtures/team.fixtures";
import { UsersFixtures } from "./fixtures/users.fixtures";
import { MembershipRepositoryFixture } from "./fixtures/repository/membership.repository.fixture";
import { TeamRepositoryFixture } from "./fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "./fixtures/repository/users.repository.fixture";
import { NextAuthMockStrategy } from "./mocks/next-auth-mock.strategy";
import { withNextAuth } from "./utils/withNextAuth";

Expand Down Expand Up @@ -60,9 +60,9 @@ describe("OAuth Client Endpoints", () => {
});

describe("User Is Authenticated", () => {
let usersFixtures: UsersFixtures;
let membershipFixtures: MembershipFixtures;
let teamFixtures: TeamFixtures;
let usersFixtures: UserRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let teamFixtures: TeamRepositoryFixture;
let user: User;
let org: Team;
let app: INestApplication;
Expand All @@ -78,9 +78,9 @@ describe("OAuth Client Endpoints", () => {
).compile();
const strategy = moduleRef.get(NextAuthStrategy);
expect(strategy).toBeInstanceOf(NextAuthMockStrategy);
usersFixtures = new UsersFixtures(moduleRef);
membershipFixtures = new MembershipFixtures(moduleRef);
teamFixtures = new TeamFixtures(moduleRef);
usersFixtures = new UserRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
teamFixtures = new TeamRepositoryFixture(moduleRef);
user = await usersFixtures.create({
email: userEmail,
});
Expand Down
4 changes: 4 additions & 0 deletions packages/platform/constants/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,7 @@ export const API_ERROR_CODES = [
RESOURCE_NOT_FOUND,
DUPLICATE_RESOURCE,
] as const;

// Request headers
export const X_CAL_CLIENT_ID = "x-cal-client-id";
export const X_CAL_SECRET_KEY = "x-cal-secret-key";

0 comments on commit 64e1658

Please sign in to comment.