Skip to content

Commit

Permalink
Basic atoms in barebone example platform apps (#13006)
Browse files Browse the repository at this point in the history
* example app

* example app

* dev move

* fix: more entry points

* fixup! fix: more entry points

* refactor: v2 API (#12913)

* Use Boolean only instead of git add src/modules/auth/guard/organization-roles/organization-roles.guard.ts

* move tests next to files they test

* replace .. in import paths with absolute path

* camelCase instead of snake_case for access and refresh token variables

* user sanitize function Typescript friendly

* restructure oAuth clients folder: example for other folders

* restructure bookings module

* organize modules in auth, endpoints, repositories, services

* organize auth module

* organize repositories

* organize inputs

* rename OAuthClientGuard to OAuthClientCredentialsGuard

* add error messages

* add error messages

* clientId as param in oauth-flow & schema mapping

* camelCase instead of snake_case for clientId and clientSecret

* access token guard as passport strategy

* folder structure as features

* get rid of index files

* feat: endpoint for deleting oAuth users & oAuth users returned data (#12912)

* feat: delete oAuth users

* check if access token matches userId in parameter

* driveby: return only user id and email in oauth users endpoints

* Connect CalProvider and GCal

* Connect CalProvider and GCal

* return response interceptor to handle failed requests

* handle failed requests using axios intercepter

* cal provider refresh tokens, external gcal

* external gcal

* cal provider refresh and retries

* remove console.log

* refactor

* ignore built atoms css

* remove change to token repo

* refactor

* refactor

* downdgrade vite of unrelated packages

* move gcal endpoints to platform

* gcal service

* refactor: use atoms provider

---------

Co-authored-by: Lauris Skraucis <[email protected]>
Co-authored-by: Ryukemeister <[email protected]>
  • Loading branch information
3 people authored Jan 8, 2024
1 parent 158ac7d commit f6c9447
Show file tree
Hide file tree
Showing 63 changed files with 1,188 additions and 320 deletions.
5 changes: 2 additions & 3 deletions apps/api/v2/src/app.logger.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,13 @@ export class AppLoggerMiddleware implements NestMiddleware {
private logger = new Logger("HTTP");

use(request: Request, response: Response, next: NextFunction): void {
const { ip, method, path: url } = request;
const { ip, method, protocol, originalUrl, path: url } = request;
const userAgent = request.get("user-agent") || "";

response.on("close", () => {
const { statusCode } = response;
const contentLength = response.get("content-length");

this.logger.log(`${method} ${url} ${statusCode} ${contentLength} - ${userAgent} ${ip}`);
this.logger.log(`${method} ${originalUrl} ${statusCode} ${contentLength} - ${userAgent} ${ip}`);
});
next();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";

describe("OAuth Gcal App Endpoints", () => {
describe("Platform Gcal Endpoints", () => {
let app: INestApplication;

let oAuthClient: PlatformOAuthClient;
Expand Down Expand Up @@ -44,7 +44,7 @@ describe("OAuth Gcal App Endpoints", () => {
credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.createOAuthManagedUser("managed-user-e2e@gmail.com", oAuthClient.id);
user = await userRepositoryFixture.createOAuthManagedUser("gcal-connect@gmail.com", oAuthClient.id);
const tokens = await tokensRepositoryFixture.createTokens(user.id, oAuthClient.id);
accessTokenSecret = tokens.accessToken;
refreshTokenSecret = tokens.refreshToken;
Expand Down Expand Up @@ -73,75 +73,74 @@ describe("OAuth Gcal App Endpoints", () => {
expect(user).toBeDefined();
});

it(`/GET/apps/gcal/oauth/redirect: it should respond 401 with invalid access token`, async () => {
it(`/GET/platform/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/apps/gcal/oauth/redirect`)
.get(`/api/v2/platform/gcal/oauth/auth-url`)
.set("Authorization", `Bearer invalid_access_token`)
.expect(401);
});

it(`/GET/apps/gcal/oauth/redirect: it should redirect to google oauth with valid access token `, async () => {
it(`/GET/platform/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/apps/gcal/oauth/redirect`)
.get(`/api/v2/platform/gcal/oauth/auth-url`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("origin", "http://localhost:5555")
.expect(301);
const redirectUrl = response.get("location");
expect(redirectUrl).toBeDefined();
expect(redirectUrl).toContain("https://accounts.google.com/o/oauth2/v2/auth");
.expect(200);
const data = response.body.data;
expect(data.authUrl).toBeDefined();
});

it(`/GET/apps/gcal/oauth/save: without oauth code`, async () => {
it(`/GET/platform/gcal/oauth/save: without oauth code`, async () => {
await request(app.getHttpServer())
.get(
`/api/v2/apps/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3Dhttp://localhost:5555&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
`/api/v2/platform/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3Dhttp://localhost:5555&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
});

it(`/GET/apps/gcal/oauth/save: without access token`, async () => {
it(`/GET/platform/gcal/oauth/save: without access token`, async () => {
await request(app.getHttpServer())
.get(
`/api/v2/apps/gcal/oauth/save?state=origin%3Dhttp://localhost:5555&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
`/api/v2/platform/gcal/oauth/save?state=origin%3Dhttp://localhost:5555&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
});

it(`/GET/apps/gcal/oauth/save: without origin`, async () => {
it(`/GET/platform/gcal/oauth/save: without origin`, async () => {
await request(app.getHttpServer())
.get(
`/api/v2/apps/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
`/api/v2/platform/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
});

it(`/GET/apps/gcal/oauth/check with access token`, async () => {
it(`/GET/platform/gcal/check with access token`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/apps/gcal/oauth/check`)
.get(`/api/v2/platform/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.expect(400);
});

it(`/GET/apps/gcal/oauth/check without access token`, async () => {
await request(app.getHttpServer()).get(`/api/v2/apps/gcal/oauth/check`).expect(401);
it(`/GET/platform/gcal/check without access token`, async () => {
await request(app.getHttpServer()).get(`/api/v2/platform/gcal/check`).expect(401);
});

it(`/GET/apps/gcal/oauth/check with access token but no credentials`, async () => {
it(`/GET/platform/gcal/check with access token but no credentials`, async () => {
await request(app.getHttpServer())
.get(`/api/v2/apps/gcal/oauth/check`)
.get(`/api/v2/platform/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.expect(400);
});

it(`/GET/apps/gcal/oauth/check with access token and gcal credentials`, async () => {
it(`/GET/platform/gcal/check with access token and gcal credentials`, async () => {
gcalCredentials = await credentialsRepositoryFixture.create(
"google_calendar",
{},
user.id,
"google-calendar"
);
await request(app.getHttpServer())
.get(`/api/v2/apps/gcal/oauth/check`)
.get(`/api/v2/platform/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.expect(200);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { GcalService } from "@/modules/apps/services/gcal.service";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
Expand All @@ -11,19 +12,19 @@ import {
HttpCode,
HttpStatus,
Logger,
NotFoundException,
Query,
Redirect,
Req,
UnauthorizedException,
UseGuards,
Headers,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Request } from "express";
import { google } from "googleapis";
import { z } from "zod";

import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GOOGLE_CALENDAR_ID, GOOGLE_CALENDAR_TYPE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiRedirectResponseType, ApiResponse } from "@calcom/platform-types";

const CALENDAR_SCOPES = [
Expand All @@ -32,44 +33,40 @@ const CALENDAR_SCOPES = [
];

@Controller({
path: "apps/gcal",
path: "platform/gcal",
version: "2",
})
export class GoogleCalendarOAuthController {
private readonly logger = new Logger("Apps: Gcal Controller");
export class GcalController {
private readonly logger = new Logger("Platform Gcal Provider");

constructor(
private readonly appRepository: AppsRepository,
private readonly credentialRepository: CredentialsRepository,
private readonly tokensRepository: TokensRepository,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly config: ConfigService
private readonly config: ConfigService,
private readonly gcalService: GcalService
) {}

@Get("/oauth/redirect")
@Redirect(undefined, 301)
@UseGuards(AccessTokenGuard)
async redirect(@Req() req: Request): Promise<ApiRedirectResponseType> {
const app = await this.appRepository.getAppBySlug("google-calendar");

if (!app) {
throw new NotFoundException();
}
private redirectUri = `${this.config.get("api.url")}/platform/gcal/oauth/save`;

const { client_id, client_secret } = z
.object({ client_id: z.string(), client_secret: z.string() })
.parse(app.keys);
const redirect_uri = `${this.config.get("api.url")}/apps/gcal/oauth/save`;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const accessToken = req.get("Authorization")?.replace("Bearer ", "");
@Get("/oauth/auth-url")
@HttpCode(HttpStatus.OK)
@UseGuards(AccessTokenGuard)
async redirect(
@Headers("Authorization") authorization: string,
@Req() req: Request
): Promise<ApiResponse<{ authUrl: string }>> {
const oAuth2Client = await this.gcalService.getOAuthClient(this.redirectUri);
const accessToken = authorization.replace("Bearer ", "");
const origin = req.get("origin") ?? req.get("host");
const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline",
scope: CALENDAR_SCOPES,
prompt: "consent",
state: `accessToken=${accessToken}&origin=${origin}`,
});
return { url: authUrl };
return { status: SUCCESS_STATUS, data: { authUrl } };
}

@Get("/oauth/save")
Expand All @@ -79,7 +76,14 @@ export class GoogleCalendarOAuthController {
const stateParams = new URLSearchParams(state);
const { accessToken, origin } = z
.object({ accessToken: z.string(), origin: z.string() })
.parse(stateParams);
.parse({ accessToken: stateParams.get("accessToken"), origin: stateParams.get("origin") });

// User chose not to authorize your app or didn't authorize your app
// redirect directly without oauth code
if (!code) {
return { url: origin };
}

const parsedCode = z.string().parse(code);

const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
Expand All @@ -88,24 +92,13 @@ export class GoogleCalendarOAuthController {
throw new UnauthorizedException("Invalid Access token.");
}

const app = await this.appRepository.getAppBySlug("google-calendar");

if (!app) {
throw new NotFoundException();
}

const { client_id, client_secret } = z
.object({ client_id: z.string(), client_secret: z.string() })
.parse(app.keys);
const redirect_uri = `${this.config.get("api.url")}/apps/gcal/oauth/save`;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const oAuth2Client = await this.gcalService.getOAuthClient(this.redirectUri);
const token = await oAuth2Client.getToken(parsedCode);
const key = token.res?.data;
const credential = await this.credentialRepository.createAppCredential(
"google_calendar",
GOOGLE_CALENDAR_TYPE,
key,
ownerId,
"google-calendar"
ownerId
);

oAuth2Client.setCredentials(key);
Expand All @@ -124,14 +117,14 @@ export class GoogleCalendarOAuthController {
primaryCal.id,
credential.id,
ownerId,
"google_calendar"
GOOGLE_CALENDAR_ID
);
}

return { url: origin };
}

@Get("/oauth/check")
@Get("/check")
@HttpCode(HttpStatus.OK)
@UseGuards(AccessTokenGuard)
async check(@GetUser("id") userId: number): Promise<ApiResponse> {
Expand Down
17 changes: 17 additions & 0 deletions apps/api/v2/src/ee/gcal/gcal.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { GcalController } from "@/ee/gcal/gcal.controller";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { GcalService } from "@/modules/apps/services/gcal.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";

@Module({
imports: [PrismaModule, TokensModule, OAuthClientModule],
providers: [AppsRepository, ConfigService, CredentialsRepository, SelectedCalendarsRepository, GcalService],
controllers: [GcalController],
})
export class GcalModule {}
14 changes: 14 additions & 0 deletions apps/api/v2/src/ee/platform-endpoints-module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { GcalModule } from "@/ee/gcal/gcal.module";
import { ProviderModule } from "@/ee/provider/provider.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";

@Module({
imports: [GcalModule, ProviderModule],
})
export class PlatformEndpointsModule implements NestModule {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
configure(_consumer: MiddlewareConsumer) {
// TODO: apply ratelimits
}
}
68 changes: 68 additions & 0 deletions apps/api/v2/src/ee/provider/provider.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { UserReturned } from "@/modules/oauth-clients/controllers/oauth-client-users/oauth-client-users.controller";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import {
BadRequestException,
Controller,
Get,
HttpCode,
HttpStatus,
Logger,
NotFoundException,
Param,
UnauthorizedException,
UseGuards,
} from "@nestjs/common";

import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiResponse } from "@calcom/platform-types";

@Controller({
path: "platform/provider",
version: "2",
})
export class CalProviderController {
private readonly logger = new Logger("Platform Provider Controller");

constructor(
private readonly tokensRepository: TokensRepository,
private readonly oauthClientRepository: OAuthClientRepository
) {}

@Get("/:clientId")
@HttpCode(HttpStatus.OK)
async verifyClientId(@Param("clientId") clientId: string): Promise<ApiResponse> {
if (!clientId) {
throw new NotFoundException();
}
const oAuthClient = await this.oauthClientRepository.getOAuthClient(clientId);

if (!oAuthClient) throw new UnauthorizedException();

return {
status: SUCCESS_STATUS,
};
}

@Get("/:clientId/access-token")
@HttpCode(HttpStatus.OK)
@UseGuards(AccessTokenGuard)
async verifyAccessToken(
@Param("clientId") clientId: string,
@GetUser() user: UserReturned
): Promise<ApiResponse> {
if (!clientId) {
throw new BadRequestException();
}

if (!user) {
throw new UnauthorizedException();
}

return {
status: SUCCESS_STATUS,
};
}
}
13 changes: 13 additions & 0 deletions apps/api/v2/src/ee/provider/provider.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { CalProviderController } from "@/ee/provider/provider.controller";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { Module } from "@nestjs/common";

@Module({
imports: [PrismaModule, TokensModule, OAuthClientModule],
providers: [CredentialsRepository],
controllers: [CalProviderController],
})
export class ProviderModule {}
Loading

0 comments on commit f6c9447

Please sign in to comment.