feat: CRUD for oAuth users (#12853)

* user controller with oauth guard

* crud repository and controller operations

* add user controller to user module

* refactor: oauth guard

* connect user with oauth client upon creation

* fix: test

* wip: e2e test

* refactor: findUniqueOrThrow -> findUnique user

* test: POST user request

* feat: permissions guard

* e2e tests

* e2e

* e2e refactor

* e2e refactor

* reflector decorator check refactor

* refactor oauth guard

* remov unused imports

* log message

* delete permissions decorator and guard

* remove delete user endpoint

* remove delete user endpoint

* refactor: route structure

* remove get oauth client decorator

* delete unecessary e2e config changes

* remove set header in post test

* fix: oauth guard test on empty db

* revert: add previously removed constant to fulfill merge platform

* fix: import to satisfy platform branch merge

* use real implementation of access token guard

* generate access & refresh tokens and fix e2e

* fix: oauth client e2e test

* refactor: variable naming

* refactor

* rename test file

* remove oauth client from request
This commit is contained in:
Lauris Skraucis 2023-12-21 14:01:41 +02:00 committed by GitHub
parent 9ef24864b8
commit e18eb6bc4b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 417 additions and 44 deletions

View File

@ -2,7 +2,8 @@
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"moduleNameMapper": {
"@/(.*)": "<rootDir>/src/$1"
"@/(.*)": "<rootDir>/src/$1",
"test/(.*)": "<rootDir>/test/$1"
},
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",

View File

@ -9,7 +9,7 @@ export class AccessTokenGuard implements CanActivate {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
const bearer = authHeader.replace("Bearer ", "").trim();
const bearer = authHeader?.replace("Bearer ", "").trim();
if (!bearer) {
throw new UnauthorizedException();
}

View File

@ -9,7 +9,8 @@ export class OrganizationRolesGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredRoles = this.reflector.get(Roles, context.getHandler());
if (!requiredRoles) {
if (!requiredRoles.length || !Object.keys(requiredRoles).length) {
return true;
}

View File

@ -1,6 +1,6 @@
import { ApiKeyService } from "@/modules/api-key/api-key.service";
import { UserRepository } from "@/modules/user/user.repository";
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { Injectable, NotFoundException, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import type { Request } from "express";
@ -31,6 +31,10 @@ export class ApiKeyAuthStrategy extends PassportStrategy(BaseStrategy, "api-key"
}
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);

View File

@ -1,19 +1,22 @@
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 { ExecutionContext, UnauthorizedException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient } from "@prisma/client";
import { PlatformOAuthClient, Team } from "@prisma/client";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import { X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import { OAuthClientGuard } from "./oauth-client.guard";
describe("OAuthClientGuard", () => {
let guard: OAuthClientGuard;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let oauthClient: PlatformOAuthClient;
let organization: Team;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
@ -21,9 +24,11 @@ describe("OAuthClientGuard", () => {
}).compile();
guard = module.get<OAuthClientGuard>(OAuthClientGuard);
teamRepositoryFixture = new TeamRepositoryFixture(module);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
const organizationId = 1;
organization = await teamRepositoryFixture.create({ name: "organization" });
const data = {
logo: "logo-url",
name: "name",
@ -32,7 +37,7 @@ describe("OAuthClientGuard", () => {
};
const secret = "secret";
oauthClient = await oauthClientRepositoryFixture.create(organizationId, data, secret);
oauthClient = await oauthClientRepositoryFixture.create(organization.id, data, secret);
});
it("should be defined", () => {
@ -41,41 +46,46 @@ describe("OAuthClientGuard", () => {
});
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,
});
const mockContext = createMockExecutionContext(
{ [X_CAL_SECRET_KEY]: oauthClient.secret },
{ clientId: oauthClient.id }
);
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,
});
const mockContext = createMockExecutionContext(
{ [X_CAL_SECRET_KEY]: oauthClient.secret },
{ clientId: "invalid id" }
);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException);
});
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",
});
const mockContext = createMockExecutionContext(
{ [X_CAL_SECRET_KEY]: "invalid secret" },
{ clientId: oauthClient.id }
);
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
await expect(guard.canActivate(mockContext)).rejects.toThrow(UnauthorizedException);
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oauthClient.id);
await teamRepositoryFixture.delete(organization.id);
});
function createMockExecutionContext(headers: Record<string, string>): ExecutionContext {
function createMockExecutionContext(
headers: Record<string, string>,
params: Record<string, string>
): ExecutionContext {
return createMock<ExecutionContext>({
switchToHttp: () => ({
getRequest: () => ({
headers,
params,
}),
}),
});

View File

@ -1,27 +1,31 @@
import { OAuthClientRepository } from "@/modules/oauth/oauth-client.repository";
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import { X_CAL_SECRET_KEY } from "@calcom/platform-constants";
@Injectable()
export class OAuthClientGuard implements CanActivate {
constructor(private readonly oauthRepository: OAuthClientRepository) {}
canActivate(context: ExecutionContext): Promise<boolean> {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const { headers } = request;
const { headers, params } = request;
const oauthClientId = headers[X_CAL_CLIENT_ID];
const oauthClientId = params.clientId;
const oauthClientSecret = headers[X_CAL_SECRET_KEY];
return this.validateOauthClient(oauthClientId, oauthClientSecret);
}
if (!oauthClientId) {
throw new UnauthorizedException("Missing client ID");
}
private async validateOauthClient(oauthClientId: string, oauthClientSecret: string): Promise<boolean> {
const oauthClient = await this.oauthRepository.getOAuthClient(oauthClientId);
if (!oauthClientSecret) {
throw new UnauthorizedException("Missing client secret");
}
if (!oauthClient || oauthClient.secret !== oauthClientSecret) {
return false;
const client = await this.oauthRepository.getOAuthClient(oauthClientId);
if (!client || client.secret !== oauthClientSecret) {
throw new UnauthorizedException();
}
return true;

View File

@ -1,10 +1,13 @@
import { getEnv } from "@/env";
import { AuthModule } from "@/modules/auth/auth.module";
import { MembershipModule } from "@/modules/membership/membership.module";
import { OAuthFlowModule } from "@/modules/oauth/flow/oauth-flow.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 { OAuthUserController } from "@/modules/oauth/user/oauth-user.controller";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UserModule } from "@/modules/user/user.module";
import { Global, Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
@ -17,9 +20,11 @@ import { JwtModule } from "@nestjs/jwt";
UserModule,
MembershipModule,
JwtModule.register({ secret: getEnv("JWT_SECRET") }),
OAuthFlowModule,
TokensModule,
],
providers: [OAuthClientRepository, OAuthClientGuard],
controllers: [OAuthClientController],
controllers: [OAuthClientController, OAuthUserController],
exports: [OAuthClientRepository, OAuthClientGuard],
})
export class OAuthClientModule {}

View File

@ -0,0 +1,187 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { CreateUserResponse } from "@/modules/oauth/user/oauth-user.controller";
import { CreateUserInput } from "@/modules/user/input/create-user";
import { UpdateUserInput } from "@/modules/user/input/update-user";
import { UserModule } from "@/modules/user/user.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import * as request from "supertest";
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 { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiSuccessResponse } from "@calcom/platform-types";
describe("User Endpoints", () => {
describe("Not authenticated", () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UserModule],
}).compile();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
describe("secret header not set", () => {
it(`/POST`, () => {
return request(app.getHttpServer())
.post("/api/v2/oauth-clients/100/users")
.send({ email: "bob@gmail.com" })
.expect(401);
});
});
describe("Bearer access token not set", () => {
it(`/GET/:id`, () => {
return request(app.getHttpServer()).get("/api/v2/oauth-clients/100/users/200").expect(401);
});
it(`/PUT/:id`, () => {
return request(app.getHttpServer()).put("/api/v2/oauth-clients/100/users/200").expect(401);
});
});
afterAll(async () => {
await app.close();
});
});
describe("User Authenticated", () => {
let app: INestApplication;
let oAuthClient: PlatformOAuthClient;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let postResponseData: CreateUserResponse;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UserModule],
}).compile();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirect_uris: ["redirect-uri"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should be defined", () => {
expect(oauthClientRepositoryFixture).toBeDefined();
expect(userRepositoryFixture).toBeDefined();
expect(oAuthClient).toBeDefined();
});
it(`/POST`, async () => {
const requestBody: CreateUserInput = {
email: "user-e2e-spec@gmail.com",
};
const response = await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
.set("x-cal-secret-key", oAuthClient.secret)
.send(requestBody)
.expect(201);
const responseBody: ApiSuccessResponse<{
user: Omit<User, "password">;
accessToken: string;
refreshToken: string;
}> = response.body;
postResponseData = responseBody.data;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.user.email).toEqual(requestBody.email);
expect(responseBody.data.accessToken).toBeDefined();
expect(responseBody.data.refreshToken).toBeDefined();
await userConnectedToOAuth(responseBody.data.user.email);
});
async function userConnectedToOAuth(userEmail: string) {
const oAuthUsers = await oauthClientRepositoryFixture.getUsers(oAuthClient.id);
const newOAuthUser = oAuthUsers?.find((user) => user.email === userEmail);
expect(oAuthUsers?.length).toEqual(1);
expect(newOAuthUser?.email).toEqual(userEmail);
}
it(`/GET/:id`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`)
.set("Authorization", `Bearer ${postResponseData.accessToken}`)
.expect(200);
const responseBody: ApiSuccessResponse<Omit<User, "password">> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.email).toEqual(postResponseData.user.email);
});
it(`/PUT/:id`, async () => {
const userUpdatedEmail = "pineapple-pizza@gmail.com";
const body: UpdateUserInput = { email: userUpdatedEmail };
const response = await request(app.getHttpServer())
.put(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`)
.set("Authorization", `Bearer ${postResponseData.accessToken}`)
.send(body)
.expect(200);
const responseBody: ApiSuccessResponse<Omit<User, "password">> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.email).toEqual(userUpdatedEmail);
});
afterAll(async () => {
if (postResponseData?.user.id) {
await userRepositoryFixture.delete(postResponseData.user.id);
}
if (oAuthClient) {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
}
if (organization) {
await teamRepositoryFixture.delete(organization.id);
}
await app.close();
});
});
});

View File

@ -0,0 +1,96 @@
import { AccessTokenGuard } from "@/modules/auth/guard/oauth/access-token.guard";
import { OAuthClientGuard } from "@/modules/oauth/guard/oauth-client/oauth-client.guard";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { CreateUserInput } from "@/modules/user/input/create-user";
import { UpdateUserInput } from "@/modules/user/input/update-user";
import { UserRepository } from "@/modules/user/user.repository";
import {
Body,
Controller,
Post,
Logger,
UseGuards,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Param,
Put,
BadRequestException,
} from "@nestjs/common";
import { User } from "@prisma/client";
import { DUPLICATE_RESOURCE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "oauth-clients/:clientId/users",
version: "2",
})
export class OAuthUserController {
private readonly logger = new Logger("UserController");
constructor(
private readonly userRepository: UserRepository,
private readonly tokensRepository: TokensRepository
) {}
@Post("/")
@UseGuards(OAuthClientGuard)
async createUser(
@Param("clientId") oAuthClientId: string,
@Body() body: CreateUserInput
): Promise<ApiResponse<CreateUserResponse>> {
this.logger.log(
`Creating user with data: ${JSON.stringify(body, null, 2)} for OAuth Client with ID ${oAuthClientId}`
);
const existingUser = await this.userRepository.findByEmail(body.email);
if (existingUser) {
throw new BadRequestException(DUPLICATE_RESOURCE);
}
const user = await this.userRepository.create(body, oAuthClientId);
const { access_token, refresh_token } = await this.tokensRepository.createOAuthTokens(
oAuthClientId,
user.id!
);
return {
status: SUCCESS_STATUS,
data: {
user,
accessToken: access_token,
refreshToken: refresh_token,
},
};
}
@Get("/:userId")
@HttpCode(HttpStatus.OK)
@UseGuards(AccessTokenGuard)
async getUserById(@Param("userId") userId: number): Promise<ApiResponse<Partial<User>>> {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new NotFoundException("User not found");
}
return { status: SUCCESS_STATUS, data: user };
}
@Put("/:userId")
@HttpCode(HttpStatus.OK)
@UseGuards(AccessTokenGuard)
async updateUser(
@Param("userId") userId: number,
@Body() body: UpdateUserInput
): Promise<ApiResponse<Partial<User>>> {
this.logger.log(`Updating user with ID ${userId}: ${JSON.stringify(body, null, 2)}`);
const user = await this.userRepository.update(userId, body);
return { status: SUCCESS_STATUS, data: user };
}
}
export type CreateUserResponse = { user: Partial<User>; accessToken: string; refreshToken: string };

View File

@ -0,0 +1,6 @@
import { IsString } from "class-validator";
export class CreateUserInput {
@IsString()
email!: string;
}

View File

@ -0,0 +1,7 @@
import { IsOptional, IsString } from "class-validator";
export class UpdateUserInput {
@IsString()
@IsOptional()
email?: string;
}

View File

@ -1,27 +1,67 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { CreateUserInput } from "@/modules/user/input/create-user";
import { UpdateUserInput } from "@/modules/user/input/update-user";
import { Injectable } from "@nestjs/common";
import type { User } from "@prisma/client";
@Injectable()
export class UserRepository {
constructor(private readonly dbRead: PrismaReadService) {}
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async create(user: CreateUserInput, oAuthClientId: string) {
const newUser = await this.dbRead.prisma.user.create({
data: {
...user,
platformOAuthClients: {
connect: { id: oAuthClientId },
},
},
});
return this.sanitize(newUser);
}
async findById(userId: number) {
const user = await this.dbRead.prisma.user.findUniqueOrThrow({
const user = await this.dbRead.prisma.user.findUnique({
where: {
id: userId,
},
});
return this.sanitize(user);
if (user) {
return this.sanitize(user);
}
return null;
}
async findByEmail(email: string) {
const user = await this.dbRead.prisma.user.findUniqueOrThrow({
const user = await this.dbRead.prisma.user.findUnique({
where: {
email,
},
});
return this.sanitize(user);
if (user) {
return this.sanitize(user);
}
return null;
}
async update(userId: number, updateData: UpdateUserInput) {
const updatedUser = await this.dbWrite.prisma.user.update({
where: { id: userId },
data: updateData,
});
return this.sanitize(updatedUser);
}
async delete(userId: number): Promise<User> {
return this.dbWrite.prisma.user.delete({
where: { id: userId },
});
}
sanitize(user: User): Partial<User> {

View File

@ -1,9 +1,10 @@
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";
import { CreateOAuthClientInput } from "@calcom/platform-types";
export class OAuthClientRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
@ -17,6 +18,17 @@ export class OAuthClientRepositoryFixture {
return this.prismaReadClient.platformOAuthClient.findFirst({ where: { id: clientId } });
}
async getUsers(clientId: PlatformOAuthClient["id"]) {
const response = await this.prismaReadClient.platformOAuthClient.findFirst({
where: { id: clientId },
include: {
users: true,
},
});
return response?.users;
}
async create(organizationId: number, data: CreateOAuthClientInput, secret: string) {
return this.prismaWriteClient.platformOAuthClient.create({
data: {

View File

@ -213,7 +213,7 @@ describe("OAuth Client Endpoints", () => {
});
});
it(`/DELETE/:id`, () => {
return request(app.getHttpServer()).delete(`/api/v2/oauth-clients/${client.client_id}`).expect(204);
return request(app.getHttpServer()).delete(`/api/v2/oauth-clients/${client.client_id}`).expect(200);
});
afterAll(async () => {
@ -295,7 +295,7 @@ describe("OAuth Client Endpoints", () => {
});
});
it(`/DELETE/:id`, () => {
return request(app.getHttpServer()).delete(`/api/v2/oauth-clients/${client.client_id}`).expect(204);
return request(app.getHttpServer()).delete(`/api/v2/oauth-clients/${client.client_id}`).expect(200);
});
afterAll(async () => {