Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: e2e tests for oAuth flow #13005

Merged
merged 11 commits into from
Jan 3, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export const GetUser = createParamDecorator<keyof User | (keyof User)[], Executi
const request = ctx.switchToHttp().getRequest();
const user = request.user as User;

if (!user) {
throw new Error("GetUser decorator : User not found");
}

if (Array.isArray(data)) {
return data.reduce((prev, curr) => {
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export class OrganizationRolesGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredRoles = this.reflector.get(Roles, context.getHandler());

if (!requiredRoles.length || !Object.keys(requiredRoles).length) {
if (!requiredRoles?.length || !Object.keys(requiredRoles)?.length) {
return true;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { ZodExceptionFilter } from "@/filters/zod-exception.filter";
import { AuthModule } from "@/modules/auth/auth.module";
import { OAuthAuthorizeInput } from "@/modules/oauth-clients/inputs/authorize.input";
import { ExchangeAuthorizationCodeInput } from "@/modules/oauth-clients/inputs/exchange-code.input";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test, TestingModule } 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 { withNextAuth } from "test/utils/withNextAuth";

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

describe("OAuthFlow Endpoints", () => {
describe("User Not Authenticated", () => {
let appWithoutAuth: INestApplication;

beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
imports: [AppModule, OAuthClientModule, UsersModule, AuthModule, PrismaModule],
}).compile();
appWithoutAuth = moduleRef.createNestApplication();
bootstrap(appWithoutAuth as NestExpressApplication);
await appWithoutAuth.init();
});

it(`POST /oauth/:clientId/authorize missing Cookie with user`, () => {
return request(appWithoutAuth.getHttpServer()).post("/api/v2/oauth/100/authorize").expect(401);
});

it(`POST /oauth/:clientId/exchange missing Authorization Bearer token`, () => {
return request(appWithoutAuth.getHttpServer()).post("/api/v2/oauth/100/exchange").expect(400);
});

it(`POST /oauth/:clientId/refresh missing ${X_CAL_SECRET_KEY} header with secret`, () => {
return request(appWithoutAuth.getHttpServer()).post("/api/v2/oauth/100/refresh").expect(401);
});

afterAll(async () => {
await appWithoutAuth.close();
});
});

describe("User Authenticated", () => {
let app: INestApplication;

let usersRepositoryFixtures: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture;

let user: User;
let organization: Team;
let oAuthClient: PlatformOAuthClient;

let authorizationCode: string | null;
let refreshToken: string;

beforeAll(async () => {
const userEmail = "[email protected]";

const moduleRef: TestingModule = await withNextAuth(
userEmail,
Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
imports: [AppModule, OAuthClientModule, UsersModule, AuthModule, PrismaModule],
})
).compile();

app = moduleRef.createNestApplication();
await app.init();

oAuthClientsRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
usersRepositoryFixtures = new UserRepositoryFixture(moduleRef);

user = await usersRepositoryFixtures.create({
email: userEmail,
});
organization = await organizationsRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
});

async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["redirect-uri.com"],
permissions: 32,
};
const secret = "secret";

const client = await oAuthClientsRepositoryFixture.create(organizationId, data, secret);
return client;
}

describe("Authorize Endpoint", () => {
it("POST /oauth/:clientId/authorize", async () => {
const body: OAuthAuthorizeInput = {
redirectUri: oAuthClient.redirectUris[0],
};

const REDIRECT_STATUS = 302;

const response = await request(app.getHttpServer())
.post(`/oauth/${oAuthClient.id}/authorize`)
.send(body)
.expect(REDIRECT_STATUS);

const baseUrl = "http://www.localhost/";
const redirectUri = new URL(response.header.location, baseUrl);
authorizationCode = redirectUri.searchParams.get("code");

expect(authorizationCode).toBeDefined();
});
});

describe("Exchange Endpoint", () => {
it("POST /oauth/:clientId/exchange", async () => {
const authorizationToken = `Bearer ${authorizationCode}`;
const body: ExchangeAuthorizationCodeInput = {
clientSecret: oAuthClient.secret,
};

const response = await request(app.getHttpServer())
.post(`/oauth/${oAuthClient.id}/exchange`)
.set("Authorization", authorizationToken)
.send(body)
.expect(200);

expect(response.body?.data?.accessToken).toBeDefined();
expect(response.body?.data?.refreshToken).toBeDefined();

refreshToken = response.body.data.refreshToken;
});
});

describe("Refresh Token Endpoint", () => {
it("POST /oauth/:clientId/refresh", () => {
const secretKey = oAuthClient.secret;
const body = {
refreshToken,
};

return request(app.getHttpServer())
.post(`/oauth/${oAuthClient.id}/refresh`)
.set("x-cal-secret-key", secretKey)
.send(body)
.expect(200)
.then((response) => {
expect(response.body?.data?.accessToken).toBeDefined();
expect(response.body?.data?.refreshToken).toBeDefined();
});
});
});

afterAll(async () => {
await oAuthClientsRepositoryFixture.delete(oAuthClient.id);
await organizationsRepositoryFixture.delete(organization.id);
await usersRepositoryFixtures.delete(user.id);

await app.close();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ export class OAuthFlowController {
throw new BadRequestException("Invalid 'redirect_uri' value.");
}

const alreadyAuthorized = await this.tokensRepository.getAuthorizationTokenByClientUserIds(
clientId,
userId
);

if (alreadyAuthorized) {
throw new BadRequestException(
`User with id=${userId} has already authorized client with id=${clientId}.`
);
}

const { id } = await this.tokensRepository.createAuthorizationToken(clientId, userId);

return res.redirect(`${body.redirectUri}?code=${id}`);
Expand All @@ -65,13 +76,16 @@ export class OAuthFlowController {
@Param("clientId") clientId: string,
@Body() body: ExchangeAuthorizationCodeInput
): Promise<ApiResponse<{ accessToken: string; refreshToken: string }>> {
const bearerToken = authorization.replace("Bearer ", "").trim();
if (!bearerToken) {
const authorizeEndpointCode = authorization.replace("Bearer ", "").trim();
if (!authorizeEndpointCode) {
throw new BadRequestException("Missing 'Bearer' Authorization header.");
}

const { accessToken: accessToken, refreshToken: refreshToken } =
await this.oAuthFlowService.exchangeAuthorizationToken(bearerToken, clientId, body.clientSecret);
const { accessToken, refreshToken } = await this.oAuthFlowService.exchangeAuthorizationToken(
authorizeEndpointCode,
clientId,
body.clientSecret
);

return {
status: SUCCESS_STATUS,
Expand Down
29 changes: 25 additions & 4 deletions apps/api/v2/src/modules/tokens/tokens.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,22 +30,37 @@ export class TokensRepository {
});
}

async getAuthorizationTokenByClientUserIds(clientId: string, userId: number) {
return this.dbRead.prisma.platformAuthorizationToken.findFirst({
where: {
platformOAuthClientId: clientId,
userId: userId,
},
});
}

async createOAuthTokens(clientId: string, ownerId: number) {
const accessExpiry = DateTime.now().plus({ days: 1 }).startOf("day").toJSDate();
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();

const issuedAtTime = Math.floor(Date.now() / 1000);
supalarry marked this conversation as resolved.
Show resolved Hide resolved

const [accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
this.dbWrite.prisma.accessToken.create({
data: {
secret: this.jwtService.sign(JSON.stringify({ type: "access_token", clientId })),
secret: this.jwtService.sign(
JSON.stringify({ type: "access_token", clientId, ownerId, iat: issuedAtTime })
),
expiresAt: accessExpiry,
client: { connect: { id: clientId } },
owner: { connect: { id: ownerId } },
},
}),
this.dbWrite.prisma.refreshToken.create({
data: {
secret: this.jwtService.sign(JSON.stringify({ type: "refresh_token", clientId })),
secret: this.jwtService.sign(
JSON.stringify({ type: "refresh_token", clientId, ownerId, iat: issuedAtTime })
),
expiresAt: refreshExpiry,
client: { connect: { id: clientId } },
owner: { connect: { id: ownerId } },
Expand Down Expand Up @@ -88,6 +103,8 @@ export class TokensRepository {
const accessExpiry = DateTime.now().plus({ days: 1 }).startOf("day").toJSDate();
const refreshExpiry = DateTime.now().plus({ year: 1 }).startOf("day").toJSDate();

const issuedAtTime = Math.floor(Date.now() / 1000);

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, _refresh, accessToken, refreshToken] = await this.dbWrite.prisma.$transaction([
this.dbWrite.prisma.accessToken.deleteMany({
Expand All @@ -96,15 +113,19 @@ export class TokensRepository {
this.dbWrite.prisma.refreshToken.delete({ where: { secret: refreshTokenSecret } }),
this.dbWrite.prisma.accessToken.create({
data: {
secret: this.jwtService.sign(JSON.stringify({ type: "access_token", clientId: clientId })),
secret: this.jwtService.sign(
JSON.stringify({ type: "access_token", clientId, userId: tokenUserId, iat: issuedAtTime })
),
expiresAt: accessExpiry,
client: { connect: { id: clientId } },
owner: { connect: { id: tokenUserId } },
},
}),
this.dbWrite.prisma.refreshToken.create({
data: {
secret: this.jwtService.sign(JSON.stringify({ type: "refresh_token", clientId: clientId })),
secret: this.jwtService.sign(
JSON.stringify({ type: "refresh_token", clientId, userId: tokenUserId, iat: issuedAtTime })
),
expiresAt: refreshExpiry,
client: { connect: { id: clientId } },
owner: { connect: { id: tokenUserId } },
Expand Down
4 changes: 2 additions & 2 deletions packages/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1067,8 +1067,8 @@ model PlatformOAuthClient {
model PlatformAuthorizationToken {
id String @id @default(cuid())

owner User @relation(fields: [userId], references: [id])
client PlatformOAuthClient @relation(fields: [platformOAuthClientId], references: [id])
owner User @relation(fields: [userId], references: [id], onDelete: Cascade)
client PlatformOAuthClient @relation(fields: [platformOAuthClientId], references: [id], onDelete: Cascade)

platformOAuthClientId String @map("platform_oauth_client_id")
userId Int @map("user_id")
Expand Down
Loading