From 61a932f8f51415f4126ea06dc4469e217fc381b4 Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:05:21 +0200 Subject: [PATCH] feat: OAuth2 controller api v2 + refactor oAuth Trpc handlers (#25989) * feat(api-v2): add OAuth2 controller skeleton for auth module - Add new OAuth2 module under /auth with controller, service, repository, and DTOs - Implement skeleton endpoints: - GET /v2/auth/oauth2/clients/:clientId - Get OAuth client info - POST /v2/auth/oauth2/clients/:clientId/authorize - Generate authorization code - POST /v2/auth/oauth2/clients/:clientId/exchange - Exchange code for tokens - POST /v2/auth/oauth2/clients/:clientId/refresh - Refresh access token - Create input DTOs for authorize, exchange, and refresh operations - Create output DTOs for client info, authorization code, and tokens - Register OAuth2Module in endpoints.module.ts Co-Authored-By: morgan@cal.com * chore: add missing functions to platform libraries * feat(api-v2): integrate OAuthService from platform-libraries into OAuth2 controller - Add OAuthService export to platform-libraries - Refactor OAuth2Service to use OAuthService.validateClient() and OAuthService.verifyPKCE() - Remove duplicate local implementations of validateClient and verifyPKCE methods Co-Authored-By: morgan@cal.com * feat(api-v2): use local validateClient and verifyPKCE implementations - Remove OAuthService export from platform-libraries (will be deprecated) - Reimplement validateClient and verifyPKCE methods locally in OAuth2Service - Use verifyCodeChallenge and generateSecret from platform-libraries directly Co-Authored-By: morgan@cal.com * refactor(api-v2): separate repositories for OAuth2, access codes, and teams - Create AccessCodeRepository for access code Prisma operations - Add findTeamBySlugWithAdminRole method to TeamsRepository - Move generateAuthorizationCode, createTokens, verifyRefreshToken to OAuth2Service - Update OAuth2Repository to only contain OAuth client operations - Update OAuth2Module to import TeamsModule and provide AccessCodeRepository Addresses PR review comments to properly separate concerns Co-Authored-By: morgan@cal.com * feat(api-v2): implement JWT token creation and verification for OAuth2 - Implement createTokens method using jsonwebtoken to sign access and refresh tokens - Implement verifyRefreshToken method to verify JWT refresh tokens - Add ConfigService injection for CALENDSO_ENCRYPTION_KEY access - Import ConfigModule in OAuth2Module for dependency injection Based on logic from apps/web/app/api/auth/oauth/token/route.ts and apps/web/app/api/auth/oauth/refreshToken/route.ts Co-Authored-By: morgan@cal.com * fix: refactor and dayjs import fix * feat(api-v2): add ApiAuthGuard to getClient endpoint and e2e tests for OAuth2 - Add @UseGuards(ApiAuthGuard) to getClient endpoint for authentication - Add comprehensive e2e tests for all OAuth2 endpoints: - GET /v2/auth/oauth2/clients/:clientId - POST /v2/auth/oauth2/clients/:clientId/authorize - POST /v2/auth/oauth2/clients/:clientId/exchange - POST /v2/auth/oauth2/clients/:clientId/refresh - Tests cover happy paths and error cases (invalid client, invalid code, etc.) Co-Authored-By: morgan@cal.com * chore(api-v2): hide OAuth2 endpoints from Swagger documentation Co-Authored-By: morgan@cal.com * hide endpoints from doc * fix(api-v2): address PR feedback for OAuth2 controller - Fix P0 critical bug: token_type field name mismatch in DecodedRefreshToken interface - Add @Equals('authorization_code') validation to exchange.input.ts grantType - Add @Equals('refresh_token') validation to refresh.input.ts grantType - Add @IsNotEmpty() validation to get-client.input.ts clientId - Add @Expose() decorators to all output DTOs for proper serialization - Add security test assertion to verify clientSecret is not returned in response Co-Authored-By: morgan@cal.com * feat(api-v2): implement redirect behavior for OAuth2 authorize endpoint - Update authorize endpoint to return HTTP 303 redirect with authorization code - Add exact match validation for redirect URI (security requirement) - Implement error handling with redirect to redirect URI and error query params - Add state parameter support for CSRF protection - Update e2e tests to verify redirect behavior (303 status, Location header, error redirects) Co-Authored-By: morgan@cal.com * refactor(api-v2): address PR feedback for OAuth2 authorize endpoint - Make redirectUri a required input parameter (remove optional fallback) - Move buildRedirectUrl and mapErrorToOAuthError to oauth2Service - Add return statements to res.redirect() calls - Simplify controller by delegating redirect URL building to service Co-Authored-By: morgan@cal.com * wip move code outside of api v2 * feat(oauth): wire up dependency injection for OAuthService and repositories Co-Authored-By: morgan@cal.com * refactor: oAuthService used in routes * fix imports * fix(api-v2): return 404 for invalid client ID instead of redirect in authorize endpoint Co-Authored-By: morgan@cal.com * fix(api-v2): fix E2E test URL paths and remove bootstrap() in authenticated section - Remove bootstrap() call in authenticated section to match working test pattern - Change URL paths from /api/v2/... to /v2/... in authenticated section - Keep bootstrap() and /api/v2/... paths in unauthenticated section Co-Authored-By: morgan@cal.com * fix(api-v2): mock getToken from next-auth/jwt for E2E tests - Add jest.mock for next-auth/jwt getToken function - Mock returns null for unauthenticated tests - Mock returns { email: userEmail } for authenticated tests - Remove withNextAuth helper since ApiAuthGuard uses ApiAuthStrategy which calls getToken directly, not NextAuthStrategy Co-Authored-By: morgan@cal.com * fix tests and code review * cleanup generate secrets * cleanup controller * chore: remove console.log from OAuthService.validateClient Co-Authored-By: morgan@cal.com * Update apps/web/app/api/auth/oauth/refreshToken/route.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * fix(api-v2): add bootstrap() to authenticated E2E tests and update URL paths to /api/v2/ Co-Authored-By: morgan@cal.com * Merge remote-tracking branch 'origin/devin/oauth2-controller-skeleton-1765988792' and remove console.log from token route Co-Authored-By: morgan@cal.com * chore: remove dead code * refactor * refactor: generateAuthCode trpc handler and getClient trpc handler * remove pkce check for refreshToken endpoint * Update packages/trpc/server/routers/viewer/oAuth/getClient.handler.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * refactor: error handling * refactor: error handling * refactor: error handling * remove console log * provide redirectUri in generateAuthCodeHandler * provide redirectUri in authorize view * refactor: replace HttpError with ErrorWithCode in OAuth files - Update OAuthService to use ErrorWithCode instead of HttpError - Update mapErrorToOAuthError to check ErrorCode instead of status codes - Update token/route.ts and refreshToken/route.ts to use ErrorWithCode - Add ErrorWithCode export to platform-libraries - Use getHttpStatusCode to map ErrorCode to HTTP status codes Co-Authored-By: morgan@cal.com * fix: update generateAuthCode handler to use ErrorWithCode instead of HttpError The handler was still checking for HttpError in its catch block, but OAuthService now throws ErrorWithCode. This caused the error messages to be lost and replaced with 'server_error' instead of the specific error messages. Co-Authored-By: morgan@cal.com * fix: update OAuth2 controller to use ErrorWithCode instead of HttpError The controller was still checking for HttpError in its catch blocks, but OAuthService now throws ErrorWithCode. This caused all errors to return 500 Internal Server Error instead of the correct HTTP status codes (400, 401, 404). Also added getHttpStatusCode export to platform-libraries. Co-Authored-By: morgan@cal.com * fix: add explicit unknown type annotation to catch blocks in OAuth2 controller Co-Authored-By: morgan@cal.com * fix: change NotFoundException to HttpException in authorize endpoint Co-Authored-By: morgan@cal.com * fix: handle err with ErrorWithCode in authorize endpoint catch block Co-Authored-By: morgan@cal.com * fix: move errorWithCode * fix: error code rfc * fix: no need to handle errors there is a middleware * refactor errors * refactor errors * refactor redirecturi and state from api * refactor: address PR comments for OAuth2 controller - Remove unused GetOAuth2ClientInput class - Change API tag from 'Auth / OAuth2' to 'OAuth2' - Move OAuthClientFixture to separate fixture file (oauth2-client.repository.fixture.ts) - Add 'type' property to OAuth2ClientDto for confidential/public client type - Rename 'clientId' to 'id' in OAuth2ClientDto (using @Expose mapping) - Clean up duplicate provider declarations in oauth2.module.ts - Update e2e tests to use new fixture and verify type property Co-Authored-By: Volnei Munhoz --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Peer Richelsen Co-authored-by: Volnei Munhoz Co-authored-by: Volnei Munhoz --- .../controllers/bookings.controller.ts | 5 +- apps/api/v2/src/lib/modules/oauth.module.ts | 13 + .../prisma-access-code.repository.ts | 12 + .../prisma-oauth-client.repository.ts | 12 + apps/api/v2/src/lib/services/oauth.service.ts | 21 + .../controllers/oauth2.controller.e2e-spec.ts | 319 ++++++++++++ .../oauth2/controllers/oauth2.controller.ts | 168 +++++++ .../auth/oauth2/inputs/authorize.input.ts | 53 ++ .../auth/oauth2/inputs/exchange.input.ts | 39 ++ .../auth/oauth2/inputs/refresh.input.ts | 24 + .../src/modules/auth/oauth2/oauth2.module.ts | 9 + .../oauth2/outputs/oauth2-client.output.ts | 74 +++ .../oauth2/outputs/oauth2-tokens.output.ts | 55 +++ apps/api/v2/src/modules/endpoints.module.ts | 4 +- .../oauth2-client.repository.fixture.ts | 43 ++ apps/api/v2/tsconfig.json | 3 +- .../app/api/auth/oauth/refreshToken/route.ts | 109 +---- .../auth/oauth/token/__tests__/route.test.ts | 50 +- apps/web/app/api/auth/oauth/token/route.ts | 115 ++--- .../modules/auth/oauth2/authorize-view.tsx | 7 +- apps/web/playwright/oauth-provider.e2e.ts | 5 +- packages/features/di/tokens.ts | 2 + .../ee/teams/repositories/TeamRepository.ts | 17 + .../oauth/di/AccessCodeRepository.module.ts | 24 + .../oauth/di/OAuthClientRepository.module.ts | 24 + .../oauth/di/OAuthService.container.ts | 11 + .../features/oauth/di/OAuthService.module.ts | 30 ++ .../oauth/di/TeamRepository.module.ts | 20 + packages/features/oauth/di/tokens.ts | 8 + .../repositories/AccessCodeRepository.ts | 36 +- .../repositories/OAuthClientRepository.ts | 15 + .../features/oauth/services/OAuthService.ts | 463 ++++++++++++++++-- .../features/oauth/utils/generateSecret.ts | 6 + packages/platform/libraries/errors.ts | 4 + packages/platform/libraries/index.ts | 6 +- packages/platform/libraries/package.json | 8 + packages/platform/libraries/repositories.ts | 2 + packages/platform/libraries/vite.config.js | 1 + packages/trpc/server/lib/toTRPCError.ts | 2 +- .../routers/viewer/oAuth/addClient.handler.ts | 8 +- .../oAuth/generateAuthCode.handler.test.ts | 56 ++- .../viewer/oAuth/generateAuthCode.handler.ts | 117 ++--- .../viewer/oAuth/generateAuthCode.schema.ts | 4 + .../routers/viewer/oAuth/getClient.handler.ts | 45 +- 44 files changed, 1683 insertions(+), 366 deletions(-) create mode 100644 apps/api/v2/src/lib/modules/oauth.module.ts create mode 100644 apps/api/v2/src/lib/repositories/prisma-access-code.repository.ts create mode 100644 apps/api/v2/src/lib/repositories/prisma-oauth-client.repository.ts create mode 100644 apps/api/v2/src/lib/services/oauth.service.ts create mode 100644 apps/api/v2/src/modules/auth/oauth2/controllers/oauth2.controller.e2e-spec.ts create mode 100644 apps/api/v2/src/modules/auth/oauth2/controllers/oauth2.controller.ts create mode 100644 apps/api/v2/src/modules/auth/oauth2/inputs/authorize.input.ts create mode 100644 apps/api/v2/src/modules/auth/oauth2/inputs/exchange.input.ts create mode 100644 apps/api/v2/src/modules/auth/oauth2/inputs/refresh.input.ts create mode 100644 apps/api/v2/src/modules/auth/oauth2/oauth2.module.ts create mode 100644 apps/api/v2/src/modules/auth/oauth2/outputs/oauth2-client.output.ts create mode 100644 apps/api/v2/src/modules/auth/oauth2/outputs/oauth2-tokens.output.ts create mode 100644 apps/api/v2/test/fixtures/repository/oauth2-client.repository.fixture.ts create mode 100644 packages/features/oauth/di/AccessCodeRepository.module.ts create mode 100644 packages/features/oauth/di/OAuthClientRepository.module.ts create mode 100644 packages/features/oauth/di/OAuthService.container.ts create mode 100644 packages/features/oauth/di/OAuthService.module.ts create mode 100644 packages/features/oauth/di/TeamRepository.module.ts create mode 100644 packages/features/oauth/di/tokens.ts create mode 100644 packages/features/oauth/utils/generateSecret.ts create mode 100644 packages/platform/libraries/errors.ts diff --git a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts index 5b6223c58a..8ee9cff134 100644 --- a/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts +++ b/apps/api/v2/src/ee/bookings/2024-04-15/controllers/bookings.controller.ts @@ -58,16 +58,15 @@ import { X_CAL_CLIENT_ID, X_CAL_PLATFORM_EMBED } from "@calcom/platform-constant import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants"; import { BookingResponse, - HttpError, handleMarkNoShow, getAllUserBookings, getBookingInfo, handleCancelBooking, getBookingForReschedule, - ErrorCode, } from "@calcom/platform-libraries"; import { CreationSource } from "@calcom/platform-libraries"; import { type InstantBookingCreateResult } from "@calcom/platform-libraries/bookings"; +import { HttpError, ErrorCode } from "@calcom/platform-libraries/errors"; import { GetBookingsInput_2024_04_15, CancelBookingInput_2024_04_15, @@ -125,7 +124,7 @@ export class BookingsController_2024_04_15 { private readonly instantBookingCreateService: InstantBookingCreateService, private readonly eventTypeRepository: PrismaEventTypeRepository, private readonly teamRepository: PrismaTeamRepository - ) { } + ) {} @Get("/") @UseGuards(ApiAuthGuard) diff --git a/apps/api/v2/src/lib/modules/oauth.module.ts b/apps/api/v2/src/lib/modules/oauth.module.ts new file mode 100644 index 0000000000..9434cdb622 --- /dev/null +++ b/apps/api/v2/src/lib/modules/oauth.module.ts @@ -0,0 +1,13 @@ +import { PrismaAccessCodeRepository } from "@/lib/repositories/prisma-access-code.repository"; +import { PrismaOAuthClientRepository } from "@/lib/repositories/prisma-oauth-client.repository"; +import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository"; +import { OAuthService } from "@/lib/services/oauth.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [PrismaModule], + providers: [PrismaAccessCodeRepository, PrismaOAuthClientRepository, PrismaTeamRepository, OAuthService], + exports: [OAuthService], +}) +export class oAuthServiceModule {} diff --git a/apps/api/v2/src/lib/repositories/prisma-access-code.repository.ts b/apps/api/v2/src/lib/repositories/prisma-access-code.repository.ts new file mode 100644 index 0000000000..a3287a654a --- /dev/null +++ b/apps/api/v2/src/lib/repositories/prisma-access-code.repository.ts @@ -0,0 +1,12 @@ +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +import { PrismaAccessCodeRepository as PrismaAccessCodeRepositoryLib } from "@calcom/platform-libraries/repositories"; +import type { PrismaClient } from "@calcom/prisma"; + +@Injectable() +export class PrismaAccessCodeRepository extends PrismaAccessCodeRepositoryLib { + constructor(private readonly dbWrite: PrismaWriteService) { + super(dbWrite.prisma as unknown as PrismaClient); + } +} diff --git a/apps/api/v2/src/lib/repositories/prisma-oauth-client.repository.ts b/apps/api/v2/src/lib/repositories/prisma-oauth-client.repository.ts new file mode 100644 index 0000000000..3ec24231c7 --- /dev/null +++ b/apps/api/v2/src/lib/repositories/prisma-oauth-client.repository.ts @@ -0,0 +1,12 @@ +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; + +import { PrismaOAuthClientRepository as PrismaOAuthClientRepositoryLib } from "@calcom/platform-libraries/repositories"; +import type { PrismaClient } from "@calcom/prisma"; + +@Injectable() +export class PrismaOAuthClientRepository extends PrismaOAuthClientRepositoryLib { + constructor(private readonly dbWrite: PrismaWriteService) { + super(dbWrite.prisma as unknown as PrismaClient); + } +} diff --git a/apps/api/v2/src/lib/services/oauth.service.ts b/apps/api/v2/src/lib/services/oauth.service.ts new file mode 100644 index 0000000000..7114f3c4ad --- /dev/null +++ b/apps/api/v2/src/lib/services/oauth.service.ts @@ -0,0 +1,21 @@ +import { PrismaAccessCodeRepository } from "@/lib/repositories/prisma-access-code.repository"; +import { PrismaOAuthClientRepository } from "@/lib/repositories/prisma-oauth-client.repository"; +import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository"; +import { Injectable } from "@nestjs/common"; + +import { OAuthService as BaseOAuthService } from "@calcom/platform-libraries"; + +@Injectable() +export class OAuthService extends BaseOAuthService { + constructor( + accessCodeRepository: PrismaAccessCodeRepository, + oAuthClientRepository: PrismaOAuthClientRepository, + teamsRepository: PrismaTeamRepository + ) { + super({ + accessCodeRepository: accessCodeRepository, + oAuthClientRepository: oAuthClientRepository, + teamsRepository: teamsRepository, + }); + } +} diff --git a/apps/api/v2/src/modules/auth/oauth2/controllers/oauth2.controller.e2e-spec.ts b/apps/api/v2/src/modules/auth/oauth2/controllers/oauth2.controller.e2e-spec.ts new file mode 100644 index 0000000000..ab79e6ab33 --- /dev/null +++ b/apps/api/v2/src/modules/auth/oauth2/controllers/oauth2.controller.e2e-spec.ts @@ -0,0 +1,319 @@ +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 { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +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 * as nextAuthJwt from "next-auth/jwt"; +import * as request from "supertest"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OAuth2ClientRepositoryFixture } from "test/fixtures/repository/oauth2-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; + +import { generateSecret } from "@calcom/platform-libraries"; +import type { Membership, Team, User } from "@calcom/prisma/client"; +import { AccessScope, OAuthClientType } from "@calcom/prisma/enums"; + +// Mock next-auth/jwt getToken for ApiAuthStrategy NEXT_AUTH authentication +jest.mock("next-auth/jwt", () => ({ + getToken: jest.fn(), +})); +const mockGetToken = nextAuthJwt.getToken as jest.MockedFunction; + +describe("OAuth2 Controller Endpoints", () => { + describe("User Not Authenticated", () => { + let appWithoutAuth: INestApplication; + + beforeAll(async () => { + // Mock getToken to return null for unauthenticated tests + mockGetToken.mockResolvedValue(null); + + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter], + imports: [AppModule, UsersModule, AuthModule, PrismaModule], + }).compile(); + appWithoutAuth = moduleRef.createNestApplication(); + bootstrap(appWithoutAuth as NestExpressApplication); + await appWithoutAuth.init(); + }); + + it("GET /v2/auth/oauth2/clients/:clientId should return 401 without auth", () => { + return request(appWithoutAuth.getHttpServer()) + .get("/api/v2/auth/oauth2/clients/test-client-id") + .expect(401); + }); + + it("POST /v2/auth/oauth2/clients/:clientId/authorize should return 401 without auth", () => { + return request(appWithoutAuth.getHttpServer()) + .post("/api/v2/auth/oauth2/clients/test-client-id/authorize") + .send({ + redirectUri: "https://example.com/callback", + scopes: ["READ_BOOKING"], + }) + .expect(401); + }); + + afterAll(async () => { + await appWithoutAuth.close(); + }); + }); + + describe("User Authenticated", () => { + let app: INestApplication; + let moduleRef: TestingModule; + + let userRepositoryFixture: UserRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let membershipRepositoryFixture: MembershipRepositoryFixture; + let oAuthClientFixture: OAuth2ClientRepositoryFixture; + + let user: User; + let team: Team; + let membership: Membership; + let oAuthClient: { clientId: string }; + let authorizationCode: string; + let refreshToken: string; + + const testClientId = `test-oauth-client-${randomString()}`; + const testClientSecret = "test-secret-123"; + const testRedirectUri = "https://example.com/callback"; + + beforeAll(async () => { + const userEmail = `oauth2-e2e-user-${randomString()}@api.com`; + + // Mock getToken to return the user email for authenticated tests + // This is needed because ApiAuthGuard uses ApiAuthStrategy which calls getToken from next-auth/jwt + mockGetToken.mockResolvedValue({ email: userEmail }); + + moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter], + imports: [AppModule, UsersModule, AuthModule, PrismaModule], + }).compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + oAuthClientFixture = new OAuth2ClientRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + }); + + team = await teamRepositoryFixture.create({ + name: `oauth2-e2e-team-${randomString()}`, + slug: `oauth2-e2e-team-${randomString()}`, + }); + + membership = await membershipRepositoryFixture.create({ + user: { connect: { id: user.id } }, + team: { connect: { id: team.id } }, + role: "OWNER", + accepted: true, + }); + + const [hashedSecret] = generateSecret(testClientSecret); + oAuthClient = await oAuthClientFixture.create({ + clientId: testClientId, + name: "Test OAuth Client", + redirectUri: testRedirectUri, + clientSecret: hashedSecret, + clientType: OAuthClientType.CONFIDENTIAL, + }); + }); + + describe("GET /api/v2/auth/oauth2/clients/:clientId", () => { + it("should return OAuth client info for valid client ID", async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v2/auth/oauth2/clients/${testClientId}`) + .expect(200); + + expect(response.body.status).toBe("success"); + expect(response.body.data.id).toBe(testClientId); + expect(response.body.data.name).toBe("Test OAuth Client"); + expect(response.body.data.redirectUri).toBe(testRedirectUri); + expect(response.body.data.type).toBe(OAuthClientType.CONFIDENTIAL); + expect(response.body.data.clientSecret).toBeUndefined(); + }); + + it("should return 404 for non-existent client ID", async () => { + await request(app.getHttpServer()) + .get("/api/v2/auth/oauth2/clients/non-existent-client-id") + .expect(404); + }); + }); + + describe("POST /api/v2/auth/oauth2/clients/:clientId/authorize", () => { + it("should redirect with authorization code for valid request", async () => { + const response = await request(app.getHttpServer()) + .post(`/api/v2/auth/oauth2/clients/${testClientId}/authorize`) + .send({ + redirectUri: testRedirectUri, + scopes: [AccessScope.READ_BOOKING, AccessScope.READ_PROFILE], + teamSlug: team.slug, + state: "test-state-123", + }) + .expect(303); + + expect(response.headers.location).toBeDefined(); + const redirectUrl = new URL(response.headers.location); + expect(redirectUrl.origin + redirectUrl.pathname).toBe(testRedirectUri); + expect(redirectUrl.searchParams.get("code")).toBeDefined(); + expect(redirectUrl.searchParams.get("state")).toBe("test-state-123"); + + authorizationCode = redirectUrl.searchParams.get("code") as string; + }); + + it("should redirect with error for invalid client ID", async () => { + await request(app.getHttpServer()) + .post("/api/v2/auth/oauth2/clients/invalid-client-id/authorize") + .send({ + redirectUri: testRedirectUri, + scopes: [AccessScope.READ_BOOKING], + }) + .expect(404); + }); + + it("should redirect with error for invalid team slug", async () => { + const response = await request(app.getHttpServer()) + .post(`/api/v2/auth/oauth2/clients/${testClientId}/authorize`) + .send({ + redirectUri: testRedirectUri, + scopes: [AccessScope.READ_BOOKING], + teamSlug: "non-existent-team-slug", + state: "test-state-456", + }) + .expect(303); + + expect(response.headers.location).toBeDefined(); + const redirectUrl = new URL(response.headers.location); + expect(redirectUrl.searchParams.get("error")).toBe("access_denied"); + expect(redirectUrl.searchParams.get("state")).toBe("test-state-456"); + }); + + it("should throw error 400 for mismatched redirect URI", async () => { + await request(app.getHttpServer()) + .post(`/api/v2/auth/oauth2/clients/${testClientId}/authorize`) + .send({ + redirectUri: "https://wrong-domain.com/callback", + scopes: [AccessScope.READ_BOOKING], + state: "test-state-789", + }) + .expect(400); + }); + }); + + describe("POST /api/v2/auth/oauth2/clients/:clientId/exchange", () => { + it("should exchange authorization code for tokens", async () => { + const response = await request(app.getHttpServer()) + .post(`/api/v2/auth/oauth2/clients/${testClientId}/exchange`) + .send({ + code: authorizationCode, + clientSecret: testClientSecret, + redirectUri: testRedirectUri, + }) + .expect(200); + + expect(response.body.status).toBe("success"); + expect(response.body.data.access_token).toBeDefined(); + expect(response.body.data.refresh_token).toBeDefined(); + expect(response.body.data.token_type).toBe("bearer"); + expect(response.body.data.expires_in).toBe(1800); + + refreshToken = response.body.data.refresh_token; + }); + + it("should return 400 for invalid/used authorization code", async () => { + await request(app.getHttpServer()) + .post(`/api/v2/auth/oauth2/clients/${testClientId}/exchange`) + .send({ + code: authorizationCode, + clientSecret: testClientSecret, + redirectUri: testRedirectUri, + }) + .expect(400); + }); + + it("should return 401 for invalid client secret", async () => { + const newAuthResponse = await request(app.getHttpServer()) + .post(`/api/v2/auth/oauth2/clients/${testClientId}/authorize`) + .send({ + redirectUri: testRedirectUri, + scopes: [AccessScope.READ_BOOKING], + teamSlug: team.slug, + }) + .expect(303); + + const newRedirectUrl = new URL(newAuthResponse.headers.location); + const newAuthCode = newRedirectUrl.searchParams.get("code") as string; + + await request(app.getHttpServer()) + .post(`/api/v2/auth/oauth2/clients/${testClientId}/exchange`) + .send({ + code: newAuthCode, + clientSecret: "wrong-secret", + redirectUri: testRedirectUri, + }) + .expect(401); + }); + }); + + describe("POST /api/v2/auth/oauth2/clients/:clientId/refresh", () => { + it("should refresh access token with valid refresh token", async () => { + const response = await request(app.getHttpServer()) + .post(`/api/v2/auth/oauth2/clients/${testClientId}/refresh`) + .send({ + refreshToken: refreshToken, + clientSecret: testClientSecret, + }) + .expect(200); + + expect(response.body.status).toBe("success"); + expect(response.body.data.access_token).toBeDefined(); + expect(response.body.data.refresh_token).toBeDefined(); + expect(response.body.data.token_type).toBe("bearer"); + expect(response.body.data.expires_in).toBe(1800); + }); + + it("should return 400 for invalid refresh token", async () => { + await request(app.getHttpServer()) + .post(`/api/v2/auth/oauth2/clients/${testClientId}/refresh`) + .send({ + refreshToken: "invalid-refresh-token", + clientSecret: testClientSecret, + }) + .expect(400); + }); + + it("should return 401 for wrong client ID with valid refresh token", async () => { + await request(app.getHttpServer()) + .post("/api/v2/auth/oauth2/clients/wrong-client-id/refresh") + .send({ + refreshToken: refreshToken, + clientSecret: testClientSecret, + }) + .expect(401); + }); + }); + + afterAll(async () => { + await oAuthClientFixture.delete(oAuthClient.clientId); + await membershipRepositoryFixture.delete(membership.id); + await teamRepositoryFixture.delete(team.id); + await userRepositoryFixture.delete(user.id); + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/auth/oauth2/controllers/oauth2.controller.ts b/apps/api/v2/src/modules/auth/oauth2/controllers/oauth2.controller.ts new file mode 100644 index 0000000000..977062d81c --- /dev/null +++ b/apps/api/v2/src/modules/auth/oauth2/controllers/oauth2.controller.ts @@ -0,0 +1,168 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { OAuthService } from "@/lib/services/oauth.service"; +import { ApiAuthGuardOnlyAllow } from "@/modules/auth/decorators/api-auth-guard-only-allow.decorator"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { OAuth2AuthorizeInput } from "@/modules/auth/oauth2/inputs/authorize.input"; +import { OAuth2ExchangeInput } from "@/modules/auth/oauth2/inputs/exchange.input"; +import { OAuth2RefreshInput } from "@/modules/auth/oauth2/inputs/refresh.input"; +import { OAuth2ClientDto, OAuth2ClientResponseDto } from "@/modules/auth/oauth2/outputs/oauth2-client.output"; +import { OAuth2TokensDto, OAuth2TokensResponseDto } from "@/modules/auth/oauth2/outputs/oauth2-tokens.output"; +import { + Body, + Controller, + Get, + HttpCode, + HttpException, + HttpStatus, + InternalServerErrorException, + Logger, + Param, + Post, + Res, + UseGuards, +} from "@nestjs/common"; +import { ApiExcludeController, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { plainToInstance } from "class-transformer"; +import type { Response } from "express"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { ErrorWithCode, getHttpStatusCode } from "@calcom/platform-libraries/errors"; + +@Controller({ + path: "/v2/auth/oauth2/clients/:clientId", + version: API_VERSIONS_VALUES, +}) +@ApiExcludeController(true) +@ApiTags("OAuth2") +export class OAuth2Controller { + private readonly logger = new Logger("OAuth2Controller"); + + constructor(private readonly oAuthService: OAuthService) {} + + @Get("/") + @HttpCode(HttpStatus.OK) + @UseGuards(ApiAuthGuard) + @ApiOperation({ + summary: "Get OAuth2 client", + description: "Returns the OAuth2 client information for the given client ID", + }) + async getClient(@Param("clientId") clientId: string): Promise { + try { + const client = await this.oAuthService.getClient(clientId); + + return { + status: SUCCESS_STATUS, + data: plainToInstance(OAuth2ClientDto, client, { strategy: "excludeAll" }), + }; + } catch (err: unknown) { + if (err instanceof ErrorWithCode) { + const statusCode = getHttpStatusCode(err); + throw new HttpException(err.message, statusCode); + } + this.logger.error(err); + throw new InternalServerErrorException("Could not get oAuthClient"); + } + } + + @Post("/authorize") + @UseGuards(ApiAuthGuard) + @ApiAuthGuardOnlyAllow(["NEXT_AUTH"]) + @ApiOperation({ + summary: "Generate authorization code", + description: + "Generates an authorization code for the OAuth2 flow and redirects to the redirect URI with the code. Requires user authentication.", + }) + async authorize( + @Param("clientId") clientId: string, + @Body() body: OAuth2AuthorizeInput, + @GetUser("id") userId: number, + @Res() res: Response + ): Promise { + try { + const client = await this.oAuthService.getClient(clientId); + const result = await this.oAuthService.generateAuthorizationCode( + client.clientId, + userId, + body.redirectUri, + body.scopes, + body.state, + body.teamSlug, + body.codeChallenge, + body.codeChallengeMethod + ); + return res.redirect(303, result.redirectUrl); + } catch (err: unknown) { + if (err instanceof ErrorWithCode) { + if (err.message === "unauthorized_client" || err?.data?.["reason"] === "redirect_uri_mismatch") { + const statusCode = getHttpStatusCode(err); + throw new HttpException(err.message, statusCode); + } + } + const errorRedirectUrl = this.oAuthService.buildErrorRedirectUrl(body.redirectUri, err, body.state); + return res.redirect(303, errorRedirectUrl); + } + } + + @Post("/exchange") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Exchange authorization code for tokens", + description: "Exchanges an authorization code for access and refresh tokens", + }) + async exchange( + @Param("clientId") clientId: string, + @Body() body: OAuth2ExchangeInput + ): Promise { + try { + const tokens = await this.oAuthService.exchangeCodeForTokens( + clientId, + body.code, + body.clientSecret, + body.redirectUri, + body.codeVerifier + ); + return { + status: SUCCESS_STATUS, + data: plainToInstance(OAuth2TokensDto, tokens, { strategy: "excludeAll" }), + }; + } catch (err: unknown) { + if (err instanceof ErrorWithCode) { + const statusCode = getHttpStatusCode(err); + throw new HttpException(err.message, statusCode); + } + this.logger.error(err); + throw new InternalServerErrorException("Could not exchange code for tokens"); + } + } + + @Post("/refresh") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Refresh access token", + description: "Refreshes an access token using a refresh token", + }) + async refresh( + @Param("clientId") clientId: string, + @Body() body: OAuth2RefreshInput + ): Promise { + try { + const tokens = await this.oAuthService.refreshAccessToken( + clientId, + body.refreshToken, + body.clientSecret + ); + return { + status: SUCCESS_STATUS, + data: plainToInstance(OAuth2TokensDto, tokens, { strategy: "excludeAll" }), + }; + } catch (err: unknown) { + if (err instanceof ErrorWithCode) { + const statusCode = getHttpStatusCode(err); + throw new HttpException(err.message, statusCode); + } + this.logger.error(err); + throw new InternalServerErrorException("Could not refresh tokens"); + } + } +} diff --git a/apps/api/v2/src/modules/auth/oauth2/inputs/authorize.input.ts b/apps/api/v2/src/modules/auth/oauth2/inputs/authorize.input.ts new file mode 100644 index 0000000000..823a35bfe3 --- /dev/null +++ b/apps/api/v2/src/modules/auth/oauth2/inputs/authorize.input.ts @@ -0,0 +1,53 @@ +import { ApiHideProperty, ApiProperty } from "@nestjs/swagger"; +import { Equals, IsArray, IsEnum, IsOptional, IsString } from "class-validator"; + +import { AccessScope } from "@calcom/prisma/enums"; + +export class OAuth2AuthorizeInput { + @ApiProperty({ + description: + "The redirect URI to redirect to after authorization. Must exactly match the registered redirect URI.", + example: "https://example.com/callback", + }) + @IsString() + redirectUri!: string; + + @ApiProperty({ + description: "OAuth state parameter for CSRF protection. Will be included in the redirect.", + required: false, + }) + @IsOptional() + @IsString() + state?: string; + + @ApiProperty({ + description: "The scopes to request", + enum: AccessScope, + isArray: true, + example: ["READ_BOOKING", "READ_PROFILE"], + }) + @IsArray() + @IsEnum(AccessScope, { each: true }) + scopes: AccessScope[] = []; + + @ApiProperty({ + description: "The team slug to authorize for (optional)", + required: false, + }) + @IsOptional() + @IsString() + teamSlug?: string; + + @ApiProperty({ + description: "PKCE code challenge (required for public clients)", + required: false, + }) + @IsOptional() + @IsString() + codeChallenge?: string; + + @ApiHideProperty() + @IsString() + @Equals("S256") + codeChallengeMethod: string = "S256"; +} diff --git a/apps/api/v2/src/modules/auth/oauth2/inputs/exchange.input.ts b/apps/api/v2/src/modules/auth/oauth2/inputs/exchange.input.ts new file mode 100644 index 0000000000..d4d840523d --- /dev/null +++ b/apps/api/v2/src/modules/auth/oauth2/inputs/exchange.input.ts @@ -0,0 +1,39 @@ +import { ApiHideProperty, ApiProperty } from "@nestjs/swagger"; +import { Equals, IsOptional, IsString } from "class-validator"; + +export class OAuth2ExchangeInput { + @ApiProperty({ + description: "The authorization code received from the authorize endpoint", + example: "abc123", + }) + @IsString() + code!: string; + + @ApiProperty({ + description: "The client secret (required for confidential clients)", + required: false, + }) + @IsOptional() + @IsString() + clientSecret?: string; + + @ApiProperty({ + description: "The redirect URI used in the authorization request", + example: "https://example.com/callback", + }) + @IsString() + redirectUri!: string; + + @ApiHideProperty() + @IsString() + @Equals("authorization_code") + grantType: string = "authorization_code"; + + @ApiProperty({ + description: "PKCE code verifier (required if code_challenge was used)", + required: false, + }) + @IsOptional() + @IsString() + codeVerifier?: string; +} diff --git a/apps/api/v2/src/modules/auth/oauth2/inputs/refresh.input.ts b/apps/api/v2/src/modules/auth/oauth2/inputs/refresh.input.ts new file mode 100644 index 0000000000..3abc212f4b --- /dev/null +++ b/apps/api/v2/src/modules/auth/oauth2/inputs/refresh.input.ts @@ -0,0 +1,24 @@ +import { ApiHideProperty, ApiProperty } from "@nestjs/swagger"; +import { Equals, IsOptional, IsString } from "class-validator"; + +export class OAuth2RefreshInput { + @ApiProperty({ + description: "The refresh token", + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + }) + @IsString() + refreshToken!: string; + + @ApiProperty({ + description: "The client secret (required for confidential clients)", + required: false, + }) + @IsOptional() + @IsString() + clientSecret?: string; + + @ApiHideProperty() + @IsString() + @Equals("refresh_token") + grantType: string = "refresh_token"; +} diff --git a/apps/api/v2/src/modules/auth/oauth2/oauth2.module.ts b/apps/api/v2/src/modules/auth/oauth2/oauth2.module.ts new file mode 100644 index 0000000000..ed2907e3c6 --- /dev/null +++ b/apps/api/v2/src/modules/auth/oauth2/oauth2.module.ts @@ -0,0 +1,9 @@ +import { oAuthServiceModule } from "@/lib/modules/oauth.module"; +import { OAuth2Controller } from "@/modules/auth/oauth2/controllers/oauth2.controller"; +import { Module } from "@nestjs/common"; + +@Module({ + imports: [oAuthServiceModule], + controllers: [OAuth2Controller], +}) +export class OAuth2Module {} diff --git a/apps/api/v2/src/modules/auth/oauth2/outputs/oauth2-client.output.ts b/apps/api/v2/src/modules/auth/oauth2/outputs/oauth2-client.output.ts new file mode 100644 index 0000000000..5ab1f925f8 --- /dev/null +++ b/apps/api/v2/src/modules/auth/oauth2/outputs/oauth2-client.output.ts @@ -0,0 +1,74 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsBoolean, IsEnum, IsNotEmptyObject, IsOptional, IsString, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; +import { OAuthClientType } from "@calcom/prisma/enums"; + +export class OAuth2ClientDto { + @ApiProperty({ + description: "The OAuth client ID", + example: "clxxxxxxxxxxxxxxxx", + }) + @IsString() + @Expose({ name: "clientId" }) + id!: string; + + @ApiProperty({ + description: "The redirect URI for the OAuth client", + example: "https://example.com/callback", + }) + @IsString() + @Expose() + redirectUri!: string; + + @ApiProperty({ + description: "The name of the OAuth client", + example: "My App", + }) + @IsString() + @Expose() + name!: string; + + @ApiProperty({ + description: "The logo URL of the OAuth client", + required: false, + }) + @IsOptional() + @IsString() + @Expose() + logo?: string | null; + + @ApiProperty({ + description: "Whether the OAuth client is trusted", + example: false, + }) + @IsBoolean() + @Expose() + isTrusted!: boolean; + + @ApiProperty({ + description: "The type of OAuth client (CONFIDENTIAL or PUBLIC)", + example: "CONFIDENTIAL", + enum: OAuthClientType, + }) + @IsEnum(OAuthClientType) + @Expose({ name: "clientType" }) + type!: OAuthClientType; +} + +export class OAuth2ClientResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + @Expose() + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: OAuth2ClientDto, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => OAuth2ClientDto) + @Expose() + data!: OAuth2ClientDto; +} diff --git a/apps/api/v2/src/modules/auth/oauth2/outputs/oauth2-tokens.output.ts b/apps/api/v2/src/modules/auth/oauth2/outputs/oauth2-tokens.output.ts new file mode 100644 index 0000000000..d06598ba45 --- /dev/null +++ b/apps/api/v2/src/modules/auth/oauth2/outputs/oauth2-tokens.output.ts @@ -0,0 +1,55 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsEnum, IsNotEmptyObject, IsNumber, IsString, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class OAuth2TokensDto { + @ApiProperty({ + description: "The access token", + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + }) + @IsString() + @Expose({ name: "accessToken" }) + access_token!: string; + + @ApiProperty({ + description: "The token type", + example: "bearer", + }) + @IsString() + @Expose({ name: "tokenType" }) + token_type!: string; + + @ApiProperty({ + description: "The refresh token", + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + }) + @IsString() + @Expose({ name: "refreshToken" }) + refresh_token!: string; + + @ApiProperty({ + description: "The number of seconds until the access token expires", + example: 1800, + }) + @IsNumber() + @Expose({ name: "expiresIn" }) + expires_in!: number; +} + +export class OAuth2TokensResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + @Expose() + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @ApiProperty({ + type: OAuth2TokensDto, + }) + @IsNotEmptyObject() + @ValidateNested() + @Type(() => OAuth2TokensDto) + @Expose() + data!: OAuth2TokensDto; +} diff --git a/apps/api/v2/src/modules/endpoints.module.ts b/apps/api/v2/src/modules/endpoints.module.ts index c1c259b66c..e8f94732b8 100644 --- a/apps/api/v2/src/modules/endpoints.module.ts +++ b/apps/api/v2/src/modules/endpoints.module.ts @@ -1,5 +1,6 @@ import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module"; import { AtomsModule } from "@/modules/atoms/atoms.module"; +import { OAuth2Module } from "@/modules/auth/oauth2/oauth2.module"; import { BillingModule } from "@/modules/billing/billing.module"; import { CalUnifiedCalendarsModule } from "@/modules/cal-unified-calendars/cal-unified-calendars.module"; import { ConferencingModule } from "@/modules/conferencing/conferencing.module"; @@ -22,6 +23,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module"; @Module({ imports: [ + OAuth2Module, OAuthClientModule, BillingModule, PlatformEndpointsModule, @@ -43,7 +45,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module"; ], }) export class EndpointsModule implements NestModule { - // eslint-disable-next-line @typescript-eslint/no-unused-vars + configure(_consumer: MiddlewareConsumer) { // TODO: apply ratelimits } diff --git a/apps/api/v2/test/fixtures/repository/oauth2-client.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/oauth2-client.repository.fixture.ts new file mode 100644 index 0000000000..66da1594b7 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/oauth2-client.repository.fixture.ts @@ -0,0 +1,43 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; + +import { OAuthClientType } from "@calcom/prisma/enums"; + +export class OAuth2ClientRepositoryFixture { + private prismaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(module: TestingModule) { + this.prismaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async create(data: { + clientId: string; + name: string; + redirectUri: string; + clientSecret?: string; + clientType?: OAuthClientType; + logo?: string; + isTrusted?: boolean; + }) { + return this.prismaWriteClient.oAuthClient.create({ + data: { + clientId: data.clientId, + name: data.name, + redirectUri: data.redirectUri, + clientSecret: data.clientSecret, + clientType: data.clientType || OAuthClientType.CONFIDENTIAL, + logo: data.logo, + isTrusted: data.isTrusted || false, + }, + }); + } + + async delete(clientId: string) { + return this.prismaWriteClient.oAuthClient.delete({ + where: { clientId }, + }); + } +} diff --git a/apps/api/v2/tsconfig.json b/apps/api/v2/tsconfig.json index de9060cfa2..97665ab1db 100644 --- a/apps/api/v2/tsconfig.json +++ b/apps/api/v2/tsconfig.json @@ -30,7 +30,8 @@ "@calcom/platform-libraries/repositories": ["../../../packages/platform/libraries/repositories.ts"], "@calcom/platform-libraries/bookings": ["../../../packages/platform/libraries/bookings.ts"], "@calcom/platform-libraries/private-links": ["../../../packages/platform/libraries/private-links.ts"], - "@calcom/platform-libraries/organizations": ["../../../packages/platform/libraries/organizations.ts"] + "@calcom/platform-libraries/organizations": ["../../../packages/platform/libraries/organizations.ts"], + "@calcom/platform-libraries/errors": ["../../../packages/platform/libraries/errors.ts"] }, "incremental": true, "skipLibCheck": true, diff --git a/apps/web/app/api/auth/oauth/refreshToken/route.ts b/apps/web/app/api/auth/oauth/refreshToken/route.ts index 64c6f518ec..72d59f22b1 100644 --- a/apps/web/app/api/auth/oauth/refreshToken/route.ts +++ b/apps/web/app/api/auth/oauth/refreshToken/route.ts @@ -1,19 +1,18 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; import { parseUrlFormData } from "app/api/parseRequestData"; -import jwt from "jsonwebtoken"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository"; -import { OAuthService } from "@calcom/features/oauth/services/OAuthService"; -import prisma from "@calcom/prisma"; -import type { OAuthTokenPayload } from "@calcom/types/oauth"; +import { getOAuthService } from "@calcom/features/oauth/di/OAuthService.container"; +import { OAUTH_ERROR_REASONS } from "@calcom/features/oauth/services/OAuthService"; +import { ErrorWithCode } from "@calcom/lib/errors"; +import { getHttpStatusCode } from "@calcom/lib/server/getServerErrorFromUnknown"; async function handler(req: NextRequest) { const { client_id, client_secret, grant_type, refresh_token } = await parseUrlFormData(req); if (!process.env.CALENDSO_ENCRYPTION_KEY) { - return NextResponse.json({ message: "CALENDSO_ENCRYPTION_KEY is not set" }, { status: 500 }); + return NextResponse.json({ message: OAUTH_ERROR_REASONS["encryption_key_missing"] }, { status: 500 }); } if (!client_id) { @@ -23,87 +22,33 @@ async function handler(req: NextRequest) { if (grant_type !== "refresh_token") { return NextResponse.json({ error: "invalid_request" }, { status: 400 }); } - - const oAuthClientRepository = new OAuthClientRepository(prisma); - - const client = await oAuthClientRepository.findByClientId(client_id); - - if (!client) { - return NextResponse.json({ error: "invalid_client" }, { status: 401 }); - } - - const isValidClient = OAuthService.validateClient(client, client_secret); - - if (!isValidClient) { - return NextResponse.json({ error: "invalid_client" }, { status: 401 }); - } - - const secretKey = process.env.CALENDSO_ENCRYPTION_KEY; - - let decodedRefreshToken: OAuthTokenPayload; - try { + const oAuthService = getOAuthService(); const refreshTokenValue = refresh_token || req.headers.get("authorization")?.split(" ")[1] || ""; + const tokens = await oAuthService.refreshAccessToken(client_id, refreshTokenValue, client_secret); - if (!refreshTokenValue) { - return NextResponse.json({ error: "invalid_request" }, { status: 400 }); - } - - decodedRefreshToken = jwt.verify(refreshTokenValue, secretKey) as OAuthTokenPayload; - } catch { - return NextResponse.json({ error: "invalid_grant" }, { status: 400 }); - } - - if (!decodedRefreshToken || decodedRefreshToken.token_type !== "Refresh Token") { - return NextResponse.json({ error: "invalid_grant" }, { status: 400 }); - } - - if (decodedRefreshToken.clientId !== client_id) { - return NextResponse.json({ error: "invalid_grant" }, { status: 400 }); - } - - const accessTokenExpiresIn = 1800; // 30 minutes - - const payloadAuthToken: OAuthTokenPayload = { - userId: decodedRefreshToken.userId, - teamId: decodedRefreshToken.teamId, - scope: decodedRefreshToken.scope, - token_type: "Access Token", - clientId: client_id, - }; - - const payloadRefreshToken: OAuthTokenPayload = { - userId: decodedRefreshToken.userId, - teamId: decodedRefreshToken.teamId, - scope: decodedRefreshToken.scope, - token_type: "Refresh Token", - clientId: client_id, - }; - - const access_token = jwt.sign(payloadAuthToken, secretKey, { - expiresIn: accessTokenExpiresIn, - }); - - const refresh_token_new = jwt.sign(payloadRefreshToken, secretKey, { - expiresIn: 30 * 24 * 60 * 60, // 30 days - }); - - return NextResponse.json( - { - access_token, - token_type: "bearer", - refresh_token: refresh_token_new, - expires_in: accessTokenExpiresIn, - }, - { - status: 200, - headers: { - "Content-Type": "application/json;charset=UTF-8", - "Cache-Control": "no-store", - Pragma: "no-cache", + return NextResponse.json( + { + access_token: tokens.accessToken, + token_type: "bearer", + refresh_token: tokens.refreshToken, + expires_in: tokens.expiresIn, }, + { + status: 200, + headers: { + "Content-Type": "application/json;charset=UTF-8", + "Cache-Control": "no-store", + Pragma: "no-cache", + }, + } + ); + } catch (err) { + if (err instanceof ErrorWithCode) { + return NextResponse.json({ error: err.message }, { status: getHttpStatusCode(err) }); } - ); + return NextResponse.json({ error: "server_error" }, { status: 500 }); + } } export const POST = defaultResponderForAppDir(handler); diff --git a/apps/web/app/api/auth/oauth/token/__tests__/route.test.ts b/apps/web/app/api/auth/oauth/token/__tests__/route.test.ts index aa58439d63..b7ad93001c 100644 --- a/apps/web/app/api/auth/oauth/token/__tests__/route.test.ts +++ b/apps/web/app/api/auth/oauth/token/__tests__/route.test.ts @@ -5,8 +5,8 @@ import jwt from "jsonwebtoken"; import { NextRequest } from "next/server"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { generateSecret } from "@calcom/features/oauth/utils/generateSecret"; import { verifyCodeChallenge } from "@calcom/lib/pkce"; -import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler"; import { POST } from "../route"; @@ -55,7 +55,7 @@ vi.mock("@calcom/lib/pkce", () => ({ verifyCodeChallenge: vi.fn(), })); -vi.mock("@calcom/trpc/server/routers/viewer/oAuth/addClient.handler", () => ({ +vi.mock("@calcom/features/oauth/utils/generateSecret", () => ({ generateSecret: vi.fn(), })); @@ -160,8 +160,8 @@ describe("POST /api/auth/oauth/token", () => { } as const; it("should exchange authorization code for tokens with valid PKCE", async () => { - prismaMock.oAuthClient.findFirst.mockResolvedValue( - mockPublicClient as Awaited> + prismaMock.oAuthClient.findUnique.mockResolvedValue( + mockPublicClient as Awaited> ); prismaMock.accessCode.findFirst.mockResolvedValue( mockAccessCode as unknown as Awaited> @@ -195,8 +195,8 @@ describe("POST /api/auth/oauth/token", () => { }); it("should reject PUBLIC client without code_verifier", async () => { - prismaMock.oAuthClient.findFirst.mockResolvedValue( - mockPublicClient as Awaited> + prismaMock.oAuthClient.findUnique.mockResolvedValue( + mockPublicClient as Awaited> ); prismaMock.accessCode.findFirst.mockResolvedValue( mockAccessCode as unknown as Awaited> @@ -219,8 +219,8 @@ describe("POST /api/auth/oauth/token", () => { }); it("should reject PUBLIC client with missing code challenge in access code", async () => { - prismaMock.oAuthClient.findFirst.mockResolvedValue( - mockPublicClient as Awaited> + prismaMock.oAuthClient.findUnique.mockResolvedValue( + mockPublicClient as Awaited> ); prismaMock.accessCode.findFirst.mockResolvedValue({ ...mockAccessCode, @@ -246,8 +246,8 @@ describe("POST /api/auth/oauth/token", () => { }); it("should reject PUBLIC client with invalid code_verifier", async () => { - prismaMock.oAuthClient.findFirst.mockResolvedValue( - mockPublicClient as Awaited> + prismaMock.oAuthClient.findUnique.mockResolvedValue( + mockPublicClient as Awaited> ); prismaMock.accessCode.findFirst.mockResolvedValue( mockAccessCode as unknown as Awaited> @@ -290,8 +290,8 @@ describe("POST /api/auth/oauth/token", () => { } as const; it("should exchange authorization code for tokens with valid client secret", async () => { - prismaMock.oAuthClient.findFirst.mockResolvedValue( - mockConfidentialClient as Awaited> + prismaMock.oAuthClient.findUnique.mockResolvedValue( + mockConfidentialClient as Awaited> ); prismaMock.accessCode.findFirst.mockResolvedValue( mockAccessCode as unknown as Awaited> @@ -325,8 +325,8 @@ describe("POST /api/auth/oauth/token", () => { }); it("should reject CONFIDENTIAL client without client_secret", async () => { - prismaMock.oAuthClient.findFirst.mockResolvedValue( - mockConfidentialClient as Awaited> + prismaMock.oAuthClient.findUnique.mockResolvedValue( + mockConfidentialClient as Awaited> ); const tokenRequest = createTokenRequest({ @@ -346,8 +346,8 @@ describe("POST /api/auth/oauth/token", () => { }); it("should reject CONFIDENTIAL client with invalid client_secret", async () => { - prismaMock.oAuthClient.findFirst.mockResolvedValue( - mockConfidentialClient as Awaited> + prismaMock.oAuthClient.findUnique.mockResolvedValue( + mockConfidentialClient as Awaited> ); mockGenerateSecret.mockReturnValue(["different_hashed_secret", "wrong_secret"]); @@ -377,8 +377,8 @@ describe("POST /api/auth/oauth/token", () => { codeChallengeMethod: "S256", } as const; - prismaMock.oAuthClient.findFirst.mockResolvedValue( - mockConfidentialClient as Awaited> + prismaMock.oAuthClient.findUnique.mockResolvedValue( + mockConfidentialClient as Awaited> ); prismaMock.accessCode.findFirst.mockResolvedValue( mockAccessCodeWithPKCE as unknown as Awaited> @@ -422,8 +422,8 @@ describe("POST /api/auth/oauth/token", () => { codeChallengeMethod: "S256", } as const; - prismaMock.oAuthClient.findFirst.mockResolvedValue( - mockConfidentialClient as Awaited> + prismaMock.oAuthClient.findUnique.mockResolvedValue( + mockConfidentialClient as Awaited> ); prismaMock.accessCode.findFirst.mockResolvedValue( mockAccessCodeWithPKCE as unknown as Awaited> @@ -471,7 +471,7 @@ describe("POST /api/auth/oauth/token", () => { }); it("should reject invalid client_id", async () => { - prismaMock.oAuthClient.findFirst.mockResolvedValue(null); + prismaMock.oAuthClient.findUnique.mockResolvedValue(null); const tokenRequest = createTokenRequest({ grant_type: "authorization_code", @@ -490,11 +490,11 @@ describe("POST /api/auth/oauth/token", () => { }); it("should reject mismatched redirect_uri", async () => { - prismaMock.oAuthClient.findFirst.mockResolvedValue({ + prismaMock.oAuthClient.findUnique.mockResolvedValue({ redirectUri: "https://app.example.com/callback", clientSecret: null, clientType: "PUBLIC" as const, - } as Awaited>); + } as Awaited>); const tokenRequest = createTokenRequest({ grant_type: "authorization_code", @@ -513,11 +513,11 @@ describe("POST /api/auth/oauth/token", () => { }); it("should reject expired authorization code", async () => { - prismaMock.oAuthClient.findFirst.mockResolvedValue({ + prismaMock.oAuthClient.findUnique.mockResolvedValue({ redirectUri: "https://app.example.com/callback", clientSecret: null, clientType: "PUBLIC" as const, - } as Awaited>); + } as Awaited>); prismaMock.accessCode.findFirst.mockResolvedValue(null); const tokenRequest = createTokenRequest({ diff --git a/apps/web/app/api/auth/oauth/token/route.ts b/apps/web/app/api/auth/oauth/token/route.ts index 6577ef18c9..564e5317fc 100644 --- a/apps/web/app/api/auth/oauth/token/route.ts +++ b/apps/web/app/api/auth/oauth/token/route.ts @@ -1,14 +1,12 @@ import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; import { parseUrlFormData } from "app/api/parseRequestData"; -import jwt from "jsonwebtoken"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; -import { AccessCodeRepository } from "@calcom/features/oauth/repositories/AccessCodeRepository"; -import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository"; -import { OAuthService } from "@calcom/features/oauth/services/OAuthService"; -import prisma from "@calcom/prisma"; -import type { OAuthTokenPayload } from "@calcom/types/oauth"; +import { getOAuthService } from "@calcom/features/oauth/di/OAuthService.container"; +import { OAUTH_ERROR_REASONS } from "@calcom/features/oauth/services/OAuthService"; +import { ErrorWithCode } from "@calcom/lib/errors"; +import { getHttpStatusCode } from "@calcom/lib/server/getServerErrorFromUnknown"; async function handler(req: NextRequest) { const { code, client_id, client_secret, grant_type, redirect_uri, code_verifier } = await parseUrlFormData( @@ -16,7 +14,7 @@ async function handler(req: NextRequest) { ); if (!process.env.CALENDSO_ENCRYPTION_KEY) { - return NextResponse.json({ message: "CALENDSO_ENCRYPTION_KEY is not set" }, { status: 500 }); + return NextResponse.json({ message: OAUTH_ERROR_REASONS["encryption_key_missing"] }, { status: 500 }); } if (!client_id || !code || !redirect_uri) { @@ -27,83 +25,38 @@ async function handler(req: NextRequest) { return NextResponse.json({ error: "invalid_request" }, { status: 400 }); } - const oAuthClientRepository = new OAuthClientRepository(prisma); - const accessCodeRepository = new AccessCodeRepository(prisma); + try { + const oAuthService = getOAuthService(); + const tokens = await oAuthService.exchangeCodeForTokens( + client_id, + code, + client_secret, + redirect_uri, + code_verifier + ); - const client = await oAuthClientRepository.findByClientId(client_id); - - if (!client) { - return NextResponse.json({ error: "invalid_client" }, { status: 401 }); - } - - if (client.redirectUri !== redirect_uri) { - return NextResponse.json({ error: "invalid_grant" }, { status: 400 }); - } - - const isValidClient = OAuthService.validateClient(client, client_secret); - - if (!isValidClient) { - return NextResponse.json({ error: "invalid_client" }, { status: 401 }); - } - - const accessCode = await accessCodeRepository.findValidCode(code, client_id); - - // Delete all expired accessCodes + the one that is used here - await accessCodeRepository.deleteExpiredAndUsedCodes(code, client_id); - - if (!accessCode) { - return NextResponse.json({ error: "invalid_grant" }, { status: 400 }); - } - - const pkceError = OAuthService.verifyPKCE(client, accessCode, code_verifier); - if (pkceError) { - return NextResponse.json({ error: pkceError.error }, { status: pkceError.status }); - } - - const secretKey = process.env.CALENDSO_ENCRYPTION_KEY; - - const payloadAuthToken: OAuthTokenPayload = { - userId: accessCode.userId, - teamId: accessCode.teamId, - scope: accessCode.scopes, - token_type: "Access Token", - clientId: client_id, - }; - - const payloadRefreshToken: OAuthTokenPayload = { - userId: accessCode.userId, - teamId: accessCode.teamId, - scope: accessCode.scopes, - token_type: "Refresh Token", - clientId: client_id, - // Include PKCE information for clients that used PKCE (PUBLIC mandatory, CONFIDENTIAL optional) - ...(accessCode.codeChallenge && { - codeChallenge: accessCode.codeChallenge, - codeChallengeMethod: accessCode.codeChallengeMethod, - }), - }; - - const accessTokenExpiresIn = 1800; // 30 minutes - - const access_token = jwt.sign(payloadAuthToken, secretKey, { - expiresIn: accessTokenExpiresIn, - }); - - const refresh_token = jwt.sign(payloadRefreshToken, secretKey, { - expiresIn: 30 * 24 * 60 * 60, // 30 days - }); - - return NextResponse.json( - { access_token, token_type: "bearer", refresh_token, expires_in: accessTokenExpiresIn }, - { - status: 200, - headers: { - "Content-Type": "application/json;charset=UTF-8", - "Cache-Control": "no-store", - Pragma: "no-cache", + return NextResponse.json( + { + access_token: tokens.accessToken, + token_type: "bearer", + refresh_token: tokens.refreshToken, + expires_in: tokens.expiresIn, }, + { + status: 200, + headers: { + "Content-Type": "application/json;charset=UTF-8", + "Cache-Control": "no-store", + Pragma: "no-cache", + }, + } + ); + } catch (err) { + if (err instanceof ErrorWithCode) { + return NextResponse.json({ error: err.message }, { status: getHttpStatusCode(err) }); } - ); + } + return NextResponse.json({ error: "error_code_exchange" }, { status: 500 }); } export const POST = defaultResponderForAppDir(handler); diff --git a/apps/web/modules/auth/oauth2/authorize-view.tsx b/apps/web/modules/auth/oauth2/authorize-view.tsx index 332d8e8dda..613db378bf 100644 --- a/apps/web/modules/auth/oauth2/authorize-view.tsx +++ b/apps/web/modules/auth/oauth2/authorize-view.tsx @@ -46,7 +46,8 @@ export default function Authorize() { const generateAuthCodeMutation = trpc.viewer.oAuth.generateAuthCode.useMutation({ onSuccess: (data) => { - window.location.href = `${client?.redirectUri}?code=${data.authorizationCode}&state=${state}`; + window.location.href = + data.redirectUrl ?? `${client?.redirectUri}?code=${data.authorizationCode}&state=${state}`; }, onError: (error) => { let oauthError = "server_error"; @@ -96,9 +97,11 @@ export default function Authorize() { if (client?.isTrusted && selectedAccount) { generateAuthCodeMutation.mutate({ clientId: client_id as string, + redirectUri: client.redirectUri, scopes, codeChallenge: code_challenge || undefined, codeChallengeMethod: (code_challenge_method as "S256") || undefined, + state, }); } }, [client?.isTrusted, selectedAccount]); @@ -223,11 +226,13 @@ export default function Authorize() { generateAuthCodeMutation.mutate({ clientId: client_id as string, scopes, + redirectUri: client.redirectUri, teamSlug: selectedAccount?.value.startsWith("team/") ? selectedAccount?.value.substring(5) : undefined, // team account starts with /team/ codeChallenge: code_challenge || undefined, codeChallengeMethod: (code_challenge_method as "S256") || undefined, + state, }); }} data-testid="allow-button"> diff --git a/apps/web/playwright/oauth-provider.e2e.ts b/apps/web/playwright/oauth-provider.e2e.ts index 3612919317..1f700a9bc3 100644 --- a/apps/web/playwright/oauth-provider.e2e.ts +++ b/apps/web/playwright/oauth-provider.e2e.ts @@ -1,9 +1,10 @@ import { expect } from "@playwright/test"; import { createHash, randomBytes } from "node:crypto"; +import { OAUTH_ERROR_REASONS } from "@calcom/features/oauth/services/OAuthService"; +import { generateSecret } from "@calcom/features/oauth/utils/generateSecret"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { prisma } from "@calcom/prisma"; -import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler"; import { test } from "./lib/fixtures"; @@ -417,7 +418,7 @@ test.describe("OAuth Provider - PKCE (Public Clients)", () => { const url = new URL(page.url()); expect(url.searchParams.get("error")).toBe("invalid_request"); - expect(url.searchParams.get("error_description")).toBe("code_challenge required for public clients"); + expect(url.searchParams.get("error_description")).toBe(OAUTH_ERROR_REASONS["pkce_required"]); expect(url.searchParams.get("state")).toBe("1234"); // Should not contain authorization code expect(url.searchParams.get("code")).toBeNull(); diff --git a/packages/features/di/tokens.ts b/packages/features/di/tokens.ts index 6302614fed..f7b7c2343e 100644 --- a/packages/features/di/tokens.ts +++ b/packages/features/di/tokens.ts @@ -1,6 +1,7 @@ import { BOOKING_DI_TOKENS } from "@calcom/features/bookings/di/tokens"; import { BOOKING_AUDIT_DI_TOKENS } from "@calcom/features/booking-audit/di/tokens"; import { HASHED_LINK_DI_TOKENS } from "@calcom/features/hashedLink/di/tokens"; +import { OAUTH_DI_TOKENS } from "@calcom/features/oauth/di/tokens"; import { ORGANIZATION_DI_TOKENS } from "@calcom/features/ee/organizations/di/tokens"; import { WATCHLIST_DI_TOKENS } from "./watchlist/Watchlist.tokens"; @@ -71,6 +72,7 @@ export const DI_TOKENS = { ...BOOKING_AUDIT_DI_TOKENS, ...BOOKING_DI_TOKENS, ...HASHED_LINK_DI_TOKENS, + ...OAUTH_DI_TOKENS, ...WATCHLIST_DI_TOKENS, ...ORGANIZATION_DI_TOKENS, }; diff --git a/packages/features/ee/teams/repositories/TeamRepository.ts b/packages/features/ee/teams/repositories/TeamRepository.ts index 4a8fd96231..6218cdde01 100644 --- a/packages/features/ee/teams/repositories/TeamRepository.ts +++ b/packages/features/ee/teams/repositories/TeamRepository.ts @@ -599,4 +599,21 @@ export class TeamRepository { }, }); } + + async findTeamBySlugWithAdminRole(teamSlug: string, userId: number) { + return this.prismaClient.team.findFirst({ + select: { id: true }, + where: { + slug: teamSlug, + members: { + some: { + userId, + role: { + in: ["OWNER", "ADMIN"], + }, + }, + }, + }, + }); + } } diff --git a/packages/features/oauth/di/AccessCodeRepository.module.ts b/packages/features/oauth/di/AccessCodeRepository.module.ts new file mode 100644 index 0000000000..5153e4fcee --- /dev/null +++ b/packages/features/oauth/di/AccessCodeRepository.module.ts @@ -0,0 +1,24 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; +import { AccessCodeRepository } from "@calcom/features/oauth/repositories/AccessCodeRepository"; + +import { OAUTH_DI_TOKENS } from "./tokens"; + +const thisModule = createModule(); +const token = OAUTH_DI_TOKENS.ACCESS_CODE_REPOSITORY; +const moduleToken = OAUTH_DI_TOKENS.ACCESS_CODE_REPOSITORY_MODULE; + +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: AccessCodeRepository, + dep: prismaModuleLoader, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { AccessCodeRepository }; diff --git a/packages/features/oauth/di/OAuthClientRepository.module.ts b/packages/features/oauth/di/OAuthClientRepository.module.ts new file mode 100644 index 0000000000..8ab675dddc --- /dev/null +++ b/packages/features/oauth/di/OAuthClientRepository.module.ts @@ -0,0 +1,24 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; +import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository"; + +import { OAUTH_DI_TOKENS } from "./tokens"; + +const thisModule = createModule(); +const token = OAUTH_DI_TOKENS.OAUTH_CLIENT_REPOSITORY; +const moduleToken = OAUTH_DI_TOKENS.OAUTH_CLIENT_REPOSITORY_MODULE; + +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: OAuthClientRepository, + dep: prismaModuleLoader, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { OAuthClientRepository }; diff --git a/packages/features/oauth/di/OAuthService.container.ts b/packages/features/oauth/di/OAuthService.container.ts new file mode 100644 index 0000000000..9c3b5b5cf8 --- /dev/null +++ b/packages/features/oauth/di/OAuthService.container.ts @@ -0,0 +1,11 @@ +import { createContainer } from "@calcom/features/di/di"; + +import { type OAuthService, moduleLoader as oAuthServiceModuleLoader } from "./OAuthService.module"; + +const oAuthServiceContainer = createContainer(); + +export function getOAuthService(): OAuthService { + oAuthServiceModuleLoader.loadModule(oAuthServiceContainer); + + return oAuthServiceContainer.get(oAuthServiceModuleLoader.token); +} diff --git a/packages/features/oauth/di/OAuthService.module.ts b/packages/features/oauth/di/OAuthService.module.ts new file mode 100644 index 0000000000..170e3f954b --- /dev/null +++ b/packages/features/oauth/di/OAuthService.module.ts @@ -0,0 +1,30 @@ +import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { OAuthService } from "@calcom/features/oauth/services/OAuthService"; + +import { moduleLoader as accessCodeRepositoryModuleLoader } from "./AccessCodeRepository.module"; +import { moduleLoader as oAuthClientRepositoryModuleLoader } from "./OAuthClientRepository.module"; +import { moduleLoader as teamRepositoryModuleLoader } from "./TeamRepository.module"; +import { OAUTH_DI_TOKENS } from "./tokens"; + +const thisModule = createModule(); +const token = OAUTH_DI_TOKENS.OAUTH_SERVICE; +const moduleToken = OAUTH_DI_TOKENS.OAUTH_SERVICE_MODULE; + +const loadModule = bindModuleToClassOnToken({ + module: thisModule, + moduleToken, + token, + classs: OAuthService, + depsMap: { + oAuthClientRepository: oAuthClientRepositoryModuleLoader, + accessCodeRepository: accessCodeRepositoryModuleLoader, + teamsRepository: teamRepositoryModuleLoader, + }, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; + +export type { OAuthService }; diff --git a/packages/features/oauth/di/TeamRepository.module.ts b/packages/features/oauth/di/TeamRepository.module.ts new file mode 100644 index 0000000000..7b8aaf8f52 --- /dev/null +++ b/packages/features/oauth/di/TeamRepository.module.ts @@ -0,0 +1,20 @@ +import { type Container, createModule, type ModuleLoader } from "@calcom/features/di/di"; +import { DI_TOKENS } from "@calcom/features/di/tokens"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; +import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; + +const thisModule = createModule(); +const token = DI_TOKENS.TEAM_REPOSITORY; +const moduleToken = DI_TOKENS.TEAM_REPOSITORY_MODULE; + +thisModule.bind(token).toClass(TeamRepository, [DI_TOKENS.PRISMA_CLIENT]); + +export const moduleLoader: ModuleLoader = { + token, + loadModule: (container: Container) => { + prismaModuleLoader.loadModule(container); + container.load(moduleToken, thisModule); + }, +}; + +export type { TeamRepository }; diff --git a/packages/features/oauth/di/tokens.ts b/packages/features/oauth/di/tokens.ts new file mode 100644 index 0000000000..2885b1fcb4 --- /dev/null +++ b/packages/features/oauth/di/tokens.ts @@ -0,0 +1,8 @@ +export const OAUTH_DI_TOKENS = { + OAUTH_CLIENT_REPOSITORY: Symbol("OAuthClientRepository"), + OAUTH_CLIENT_REPOSITORY_MODULE: Symbol("OAuthClientRepositoryModule"), + ACCESS_CODE_REPOSITORY: Symbol("AccessCodeRepository"), + ACCESS_CODE_REPOSITORY_MODULE: Symbol("AccessCodeRepositoryModule"), + OAUTH_SERVICE: Symbol("OAuthService"), + OAUTH_SERVICE_MODULE: Symbol("OAuthServiceModule"), +}; diff --git a/packages/features/oauth/repositories/AccessCodeRepository.ts b/packages/features/oauth/repositories/AccessCodeRepository.ts index 58a9cf3abc..e9c6da7729 100644 --- a/packages/features/oauth/repositories/AccessCodeRepository.ts +++ b/packages/features/oauth/repositories/AccessCodeRepository.ts @@ -1,13 +1,39 @@ +import dayjs from "@calcom/dayjs"; import type { PrismaClient } from "@calcom/prisma"; +import type { AccessScope } from "@calcom/prisma/enums"; +interface CreateAccessCodeInput { + code: string; + clientId: string; + userId?: number; + teamId?: number; + scopes: AccessScope[]; + codeChallenge?: string; + codeChallengeMethod?: string; +} export class AccessCodeRepository { constructor(private readonly prisma: PrismaClient) {} + async create(input: CreateAccessCodeInput): Promise { + await this.prisma.accessCode.create({ + data: { + code: input.code, + clientId: input.clientId, + userId: input.userId, + teamId: input.teamId, + expiresAt: dayjs().add(10, "minutes").toDate(), + scopes: input.scopes, + codeChallenge: input.codeChallenge, + codeChallengeMethod: input.codeChallengeMethod, + }, + }); + } + async findValidCode(code: string, clientId: string) { - return await this.prisma.accessCode.findFirst({ + return this.prisma.accessCode.findFirst({ where: { - code: code, - clientId: clientId, + code, + clientId, expiresAt: { gt: new Date(), }, @@ -32,8 +58,8 @@ export class AccessCodeRepository { }, }, { - code: code, - clientId: clientId, + code, + clientId, }, ], }, diff --git a/packages/features/oauth/repositories/OAuthClientRepository.ts b/packages/features/oauth/repositories/OAuthClientRepository.ts index b6580ea76a..3181b1fb22 100644 --- a/packages/features/oauth/repositories/OAuthClientRepository.ts +++ b/packages/features/oauth/repositories/OAuthClientRepository.ts @@ -9,6 +9,21 @@ export class OAuthClientRepository { clientId: clientId, }, select: { + redirectUri: true, + clientType: true, + name: true, + logo: true, + clientId: true, + isTrusted: true, + }, + }); + } + + async findByClientIdWithSecret(clientId: string) { + return this.prisma.oAuthClient.findUnique({ + where: { clientId }, + select: { + clientId: true, redirectUri: true, clientSecret: true, clientType: true, diff --git a/packages/features/oauth/services/OAuthService.ts b/packages/features/oauth/services/OAuthService.ts index 2c3261dede..cce38157be 100644 --- a/packages/features/oauth/services/OAuthService.ts +++ b/packages/features/oauth/services/OAuthService.ts @@ -1,46 +1,331 @@ -import { verifyCodeChallenge } from "@calcom/lib/pkce"; -import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler"; +import { randomBytes } from "crypto"; +import jwt from "jsonwebtoken"; -interface OAuthClient { - clientType: "CONFIDENTIAL" | "PUBLIC"; - clientSecret?: string | null; +import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; +import { AccessCodeRepository } from "@calcom/features/oauth/repositories/AccessCodeRepository"; +import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository"; +import { generateSecret } from "@calcom/features/oauth/utils/generateSecret"; +import { ErrorCode } from "@calcom/lib/errorCodes"; +import { ErrorWithCode } from "@calcom/lib/errors"; +import { verifyCodeChallenge } from "@calcom/lib/pkce"; +import type { AccessScope, OAuthClientType } from "@calcom/prisma/enums"; + +export interface OAuth2Client { + clientId: string; + redirectUri: string; + name: string; + logo: string | null; + isTrusted: boolean; + clientType: OAuthClientType; } -type OAuthErrorCode = "invalid_request" | "invalid_grant"; +export interface OAuth2Tokens { + accessToken: string; + tokenType: string; + refreshToken: string; + expiresIn: number; +} -interface PKCESource { +export interface AuthorizeResult { + redirectUrl: string; + authorizationCode: string; + client: OAuth2Client; +} + +export interface OAuthError { + error: string; + errorDescription?: string; +} + +interface DecodedRefreshToken { + userId?: number | null; + teamId?: number | null; + scope: AccessScope[]; + token_type: string; + clientId: string; codeChallenge?: string | null; codeChallengeMethod?: string | null; } -interface OAuthErrorResult { - error: OAuthErrorCode; - status: number; -} - export class OAuthService { - static validateClient(client: OAuthClient, client_secret?: string): boolean { - if (client.clientType === "CONFIDENTIAL") { - if (!client_secret) return false; - - const [hashedSecret] = generateSecret(client_secret); - if (client.clientSecret !== hashedSecret) return false; + private readonly accessCodeRepository: AccessCodeRepository; + private readonly teamsRepository: TeamRepository; + private readonly oAuthClientRepository: OAuthClientRepository; + constructor( + private readonly deps: { + oAuthClientRepository: OAuthClientRepository; + accessCodeRepository: AccessCodeRepository; + teamsRepository: TeamRepository; } - - return true; // PUBLIC has no client_secret + ) { + this.accessCodeRepository = deps.accessCodeRepository; + this.teamsRepository = deps.teamsRepository; + this.oAuthClientRepository = deps.oAuthClientRepository; } - /** - * PKCE validator for BOTH: - * - exchanging authorization code - * - exchanging refresh token - */ - static verifyPKCE( - client: OAuthClient, - source: PKCESource, - code_verifier?: string - ): OAuthErrorResult | null { - // Determine if PKCE should be enforced + async getClient(clientId: string): Promise { + const client = await this.oAuthClientRepository.findByClientId(clientId); + + if (!client) { + throw new ErrorWithCode(ErrorCode.NotFound, "unauthorized_client"); + } + + return { + clientId: client.clientId, + redirectUri: client.redirectUri, + name: client.name, + logo: client.logo, + isTrusted: client.isTrusted, + clientType: client.clientType, + }; + } + + async generateAuthorizationCode( + clientId: string, + userId: number, + redirectUri: string, + scopes: AccessScope[], + state?: string, + teamSlug?: string, + codeChallenge?: string, + codeChallengeMethod?: string + ): Promise { + const client = await this.oAuthClientRepository.findByClientId(clientId); + + if (!client) { + throw new ErrorWithCode(ErrorCode.Unauthorized, "unauthorized_client", { reason: "client_not_found" }); + } + + // RFC 6749 4.1.2.1: Redirect URI mismatch on Auth step is 'invalid_request' + this.validateRedirectUri(client.redirectUri, redirectUri); + + if (client.clientType === "PUBLIC") { + if (!codeChallenge) { + throw new ErrorWithCode(ErrorCode.BadRequest, "invalid_request", { reason: "pkce_required" }); + } + if (!codeChallengeMethod || codeChallengeMethod !== "S256") { + throw new ErrorWithCode(ErrorCode.BadRequest, "invalid_request", { + reason: "invalid_code_challenge_method", + }); + } + } else if (client.clientType === "CONFIDENTIAL") { + if (codeChallenge && (!codeChallengeMethod || codeChallengeMethod !== "S256")) { + throw new ErrorWithCode(ErrorCode.BadRequest, "invalid_request", { + reason: "invalid_code_challenge_method", + }); + } + } + + let teamId: number | undefined; + if (teamSlug) { + const team = await this.teamsRepository.findTeamBySlugWithAdminRole(teamSlug, userId); + if (!team) { + // Specific OAuth error for user denying or failing permission + throw new ErrorWithCode(ErrorCode.Unauthorized, "access_denied", { + reason: "team_not_found_or_no_access", + }); + } + teamId = team.id; + } + + const authorizationCode = this.generateAuthorizationCodeString(); + + await this.accessCodeRepository.create({ + code: authorizationCode, + clientId, + userId: teamSlug ? undefined : userId, + teamId, + scopes, + codeChallenge, + codeChallengeMethod, + }); + + const redirectUrl = this.buildRedirectUrl(redirectUri, { + code: authorizationCode, + state, + }); + + return { redirectUrl, authorizationCode, client }; + } + + private validateRedirectUri(registeredUri: string, providedUri: string): void { + if (providedUri !== registeredUri) { + throw new ErrorWithCode(ErrorCode.BadRequest, "invalid_request", { reason: "redirect_uri_mismatch" }); + } + } + + buildRedirectUrl(baseUrl: string, params: Record): string { + const url = new URL(baseUrl); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, value); + } + } + return url.toString(); + } + + buildErrorRedirectUrl(redirectUri: string, error: unknown, state?: string): string { + const oauthError = this.mapErrorToOAuthError(error); + return this.buildRedirectUrl(redirectUri, { + error: oauthError.error, + error_description: oauthError.errorDescription, + state, + }); + } + + private mapErrorToOAuthError(error: unknown): OAuthError { + const validOAuthErrors = [ + "invalid_request", + "unauthorized_client", + "access_denied", + "unsupported_response_type", + "invalid_scope", + "server_error", + "temporarily_unavailable", + "invalid_client", + "invalid_grant", + ]; + + if (error instanceof ErrorWithCode) { + if (validOAuthErrors.includes(error.message)) { + return { + error: error.message, + errorDescription: (error.data?.cause as string | undefined) ?? error.message, + }; + } + + switch (error.code) { + case ErrorCode.BadRequest: + return { + error: "invalid_request", + errorDescription: (error.data?.cause as string | undefined) ?? error.message, + }; + case ErrorCode.Unauthorized: + return { + error: "unauthorized_client", + errorDescription: (error.data?.cause as string | undefined) ?? error.message, + }; + default: + return { + error: "server_error", + errorDescription: (error.data?.cause as string | undefined) ?? error.message, + }; + } + } + + return { + error: "server_error", + errorDescription: "An unexpected error occurred", + }; + } + + async exchangeCodeForTokens( + clientId: string, + code: string, + clientSecret?: string, + redirectUri?: string, + codeVerifier?: string + ): Promise { + const client = await this.oAuthClientRepository.findByClientIdWithSecret(clientId); + if (!client) { + throw new ErrorWithCode(ErrorCode.Unauthorized, "invalid_client", { reason: "client_not_found" }); + } + + // RFC 6749 5.2: Redirect URI mismatch during Token exchange is 'invalid_grant' + if (redirectUri && client.redirectUri !== redirectUri) { + throw new ErrorWithCode(ErrorCode.BadRequest, "invalid_grant", { reason: "redirect_uri_mismatch" }); + } + + if (!this.validateClient(client, clientSecret)) { + throw new ErrorWithCode(ErrorCode.Unauthorized, "invalid_client", { + reason: "invalid_client_credentials", + }); + } + + const accessCode = await this.accessCodeRepository.findValidCode(code, clientId); + + await this.accessCodeRepository.deleteExpiredAndUsedCodes(code, clientId); + + if (!accessCode) { + throw new ErrorWithCode(ErrorCode.BadRequest, "invalid_grant", { reason: "code_invalid_or_expired" }); + } + + const pkceError = this.verifyPKCE(client, accessCode, codeVerifier); + if (pkceError) { + // RFC 7636 4.4.1: If verification fails, return 'invalid_grant' + throw new ErrorWithCode(ErrorCode.BadRequest, pkceError.error, { reason: pkceError.reason }); + } + + const tokens = this.createTokens({ + clientId, + userId: accessCode.userId, + teamId: accessCode.teamId, + scopes: accessCode.scopes, + codeChallenge: accessCode.codeChallenge, + codeChallengeMethod: accessCode.codeChallengeMethod, + }); + + return tokens; + } + + async refreshAccessToken( + clientId: string, + refreshToken: string, + clientSecret?: string + ): Promise { + const client = await this.oAuthClientRepository.findByClientIdWithSecret(clientId); + + if (!client) { + throw new ErrorWithCode(ErrorCode.Unauthorized, "invalid_client", { reason: "client_not_found" }); + } + + if (!this.validateClient(client, clientSecret)) { + throw new ErrorWithCode(ErrorCode.Unauthorized, "invalid_client", { + reason: "invalid_client_credentials", + }); + } + + const decodedToken = this.verifyRefreshToken(refreshToken); + + if (!decodedToken || decodedToken.token_type !== "Refresh Token") { + throw new ErrorWithCode(ErrorCode.BadRequest, "invalid_grant", { reason: "invalid_refresh_token" }); + } + + if (decodedToken.clientId !== clientId) { + throw new ErrorWithCode(ErrorCode.BadRequest, "invalid_grant", { reason: "client_id_mismatch" }); + } + + const tokens = this.createTokens({ + clientId, + userId: decodedToken.userId, + teamId: decodedToken.teamId, + scopes: decodedToken.scope, + }); + + return tokens; + } + + private validateClient( + client: { clientType: string; clientSecret?: string | null }, + clientSecret?: string + ): boolean { + if (client.clientType === "CONFIDENTIAL") { + if (!clientSecret) return false; + + const [hashedSecret] = generateSecret(clientSecret); + if (client.clientSecret !== hashedSecret) return false; + } + return true; + } + + private verifyPKCE( + client: { clientType: string }, + source: { codeChallenge?: string | null; codeChallengeMethod?: string | null }, + codeVerifier?: string + ): { + error: "invalid_request" | "invalid_grant"; + reason: "pkce_missing_parameters_or_invalid_method" | "pkce_verification_failed"; + } | null { const shouldEnforcePKCE = client.clientType === "PUBLIC" || (client.clientType === "CONFIDENTIAL" && source.codeChallenge); @@ -48,14 +333,122 @@ export class OAuthService { const method = source.codeChallengeMethod || "S256"; - if (!source.codeChallenge || !code_verifier || method !== "S256") { - return { error: "invalid_request", status: 400 }; + // Structural missing params + if (!source.codeChallenge || !codeVerifier || method !== "S256") { + return { error: "invalid_request", reason: "pkce_missing_parameters_or_invalid_method" }; } - if (!verifyCodeChallenge(code_verifier, source.codeChallenge, method)) { - return { error: "invalid_grant", status: 400 }; + // Logical mismatch + if (!verifyCodeChallenge(codeVerifier, source.codeChallenge, method)) { + return { error: "invalid_grant", reason: "pkce_verification_failed" }; } return null; } + + private generateAuthorizationCodeString(): string { + const randomBytesValue = randomBytes(40); + return randomBytesValue.toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); + } + + private createTokens(input: { + clientId: string; + userId?: number | null; + teamId?: number | null; + scopes: AccessScope[]; + codeChallenge?: string | null; + codeChallengeMethod?: string | null; + }): OAuth2Tokens { + const secretKey = process.env.CALENDSO_ENCRYPTION_KEY; + if (!secretKey) { + throw new ErrorWithCode(ErrorCode.InternalServerError, "server_error", { + reason: "encryption_key_missing", + }); + } + + const accessTokenPayload = { + userId: input.userId, + teamId: input.teamId, + scope: input.scopes, + token_type: "Access Token", + clientId: input.clientId, + }; + + const refreshTokenPayload = { + userId: input.userId, + teamId: input.teamId, + scope: input.scopes, + token_type: "Refresh Token", + clientId: input.clientId, + ...(input.codeChallenge && { + codeChallenge: input.codeChallenge, + codeChallengeMethod: input.codeChallengeMethod, + }), + }; + + const accessTokenExpiresIn = 1800; // 30 minutes + + const accessToken = jwt.sign(accessTokenPayload, secretKey, { + expiresIn: accessTokenExpiresIn, + }); + + const refreshToken = jwt.sign(refreshTokenPayload, secretKey, { + expiresIn: 30 * 24 * 60 * 60, // 30 days + }); + + return { + accessToken, + tokenType: "bearer", + refreshToken, + expiresIn: accessTokenExpiresIn, + }; + } + + private verifyRefreshToken(refreshToken: string): DecodedRefreshToken | null { + const secretKey = process.env.CALENDSO_ENCRYPTION_KEY; + if (!secretKey) { + throw new ErrorWithCode(ErrorCode.InternalServerError, "server_error", { + reason: "encryption_key_missing", + }); + } + + try { + const decoded = jwt.verify(refreshToken, secretKey) as DecodedRefreshToken; + return decoded; + } catch { + return null; + } + } } + +export type OAuthErrorReason = + | "client_not_found" + | "redirect_uri_mismatch" + | "pkce_required" + | "invalid_code_challenge_method" + | "team_not_found_or_no_access" + | "access_denied" + | "invalid_client_credentials" + | "code_invalid_or_expired" + | "pkce_missing_parameters_or_invalid_method" + | "pkce_verification_failed" + | "invalid_refresh_token" + | "client_id_mismatch" + | "encryption_key_missing"; + +// Mapping of OAuth error reasons to descriptive messages, keeping previous messages for compatibility +export const OAUTH_ERROR_REASONS: Record = { + client_not_found: "OAuth client with ID not found", + redirect_uri_mismatch: "redirect_uri does not match registered redirect URI", + pkce_required: "code_challenge required for public clients", + invalid_code_challenge_method: "code_challenge_method must be S256", + team_not_found_or_no_access: "Team not found or user is not an admin/owner", + access_denied: "The resource owner or authorization server denied the request.", + invalid_client_credentials: "invalid_client", + code_invalid_or_expired: "invalid_grant", + pkce_missing_parameters_or_invalid_method: "invalid_request", + pkce_verification_failed: "invalid_grant", + invalid_refresh_token: "invalid_grant", + client_id_mismatch: "invalid_grant", + encryption_key_missing: "CALENDSO_ENCRYPTION_KEY is not set", +}; diff --git a/packages/features/oauth/utils/generateSecret.ts b/packages/features/oauth/utils/generateSecret.ts new file mode 100644 index 0000000000..dae2695624 --- /dev/null +++ b/packages/features/oauth/utils/generateSecret.ts @@ -0,0 +1,6 @@ +import { randomBytes, createHash } from "crypto"; + +const hashSecretKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex"); + +// Generate a random secret +export const generateSecret = (secret = randomBytes(32).toString("hex")) => [hashSecretKey(secret), secret]; diff --git a/packages/platform/libraries/errors.ts b/packages/platform/libraries/errors.ts new file mode 100644 index 0000000000..ebe60fe75c --- /dev/null +++ b/packages/platform/libraries/errors.ts @@ -0,0 +1,4 @@ +export { ErrorCode } from "@calcom/lib/errorCodes"; +export { ErrorWithCode } from "@calcom/lib/errors"; +export { getHttpStatusCode } from "@calcom/lib/server/getServerErrorFromUnknown"; +export { HttpError } from "@calcom/lib/http-error"; diff --git a/packages/platform/libraries/index.ts b/packages/platform/libraries/index.ts index d2e505a2e9..ada43662c5 100644 --- a/packages/platform/libraries/index.ts +++ b/packages/platform/libraries/index.ts @@ -53,7 +53,6 @@ export { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/f export { getBusyCalendarTimes } from "@calcom/features/calendars/lib/CalendarManager"; export type { BookingCreateBody, BookingResponse } from "@calcom/features/bookings/types"; -export { HttpError } from "@calcom/lib/http-error"; export { MINUTES_TO_BOOK, ENABLE_ASYNC_TASKER } from "@calcom/lib/constants"; @@ -81,8 +80,6 @@ export { getTranslation }; export { roundRobinReassignment } from "@calcom/features/ee/round-robin/roundRobinReassignment"; export { roundRobinManualReassignment } from "@calcom/features/ee/round-robin/roundRobinManualReassignment"; -export { ErrorCode } from "@calcom/lib/errorCodes"; - export { validateCustomEventName } from "@calcom/features/eventtypes/lib/eventNaming"; export type TeamQuery = Prisma.TeamGetPayload<{ @@ -138,3 +135,6 @@ export { TeamService } from "@calcom/features/ee/teams/services/teamService"; export { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService"; export { getTasker } from "@calcom/features/tasker/tasker-factory"; export type { Tasker } from "@calcom/features/tasker/tasker"; +export { verifyCodeChallenge } from "@calcom/lib/pkce"; +export { generateSecret } from "@calcom/features/oauth/utils/generateSecret"; +export { OAuthService } from "@calcom/features/oauth/services/OAuthService"; diff --git a/packages/platform/libraries/package.json b/packages/platform/libraries/package.json index f899595a21..85cd7cb936 100644 --- a/packages/platform/libraries/package.json +++ b/packages/platform/libraries/package.json @@ -99,6 +99,11 @@ "import": "./dist/pbac.js", "require": "./dist/pbac.cjs", "types": "./dist/pbac.d.ts" + }, + "./errors": { + "import": "./dist/errors.js", + "require": "./dist/errors.cjs", + "types": "./dist/errors.d.ts" } }, "typesVersions": { @@ -138,6 +143,9 @@ ], "pbac": [ "dist/pbac.d.ts" + ], + "errors": [ + "dist/errors.d.ts" ] } } diff --git a/packages/platform/libraries/repositories.ts b/packages/platform/libraries/repositories.ts index 6e92bed445..8b3107162a 100644 --- a/packages/platform/libraries/repositories.ts +++ b/packages/platform/libraries/repositories.ts @@ -11,3 +11,5 @@ export { UserRepository as PrismaUserRepository } from "@calcom/features/users/r export { FeaturesRepository as PrismaFeaturesRepository } from "@calcom/features/flags/features.repository"; export { MembershipRepository as PrismaMembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository"; export { HostRepository as PrismaHostRepository } from "@calcom/features/host/repositories/HostRepository"; +export { AccessCodeRepository as PrismaAccessCodeRepository } from "@calcom/features/oauth/repositories/AccessCodeRepository"; +export { OAuthClientRepository as PrismaOAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository"; diff --git a/packages/platform/libraries/vite.config.js b/packages/platform/libraries/vite.config.js index b8e0647bb4..2e9c06fa91 100644 --- a/packages/platform/libraries/vite.config.js +++ b/packages/platform/libraries/vite.config.js @@ -38,6 +38,7 @@ export default defineConfig({ organizations: resolve(__dirname, "./organizations.ts"), "private-links": resolve(__dirname, "./private-links.ts"), pbac: resolve(__dirname, "./pbac.ts"), + errors: resolve(__dirname, "./errors.ts"), }, name: "calcom-lib", fileName: "calcom-lib", diff --git a/packages/trpc/server/lib/toTRPCError.ts b/packages/trpc/server/lib/toTRPCError.ts index d83839dcca..ebf9f031b9 100644 --- a/packages/trpc/server/lib/toTRPCError.ts +++ b/packages/trpc/server/lib/toTRPCError.ts @@ -21,7 +21,7 @@ type TRPCErrorCode = | "TOO_MANY_REQUESTS" | "CLIENT_CLOSED_REQUEST"; -function httpStatusToTrpcCode(status: number): TRPCErrorCode { +export function httpStatusToTrpcCode(status: number): TRPCErrorCode { switch (status) { case 400: return "BAD_REQUEST"; diff --git a/packages/trpc/server/routers/viewer/oAuth/addClient.handler.ts b/packages/trpc/server/routers/viewer/oAuth/addClient.handler.ts index 815d230f0a..f515b00a80 100644 --- a/packages/trpc/server/routers/viewer/oAuth/addClient.handler.ts +++ b/packages/trpc/server/routers/viewer/oAuth/addClient.handler.ts @@ -1,5 +1,6 @@ -import { randomBytes, createHash } from "node:crypto"; +import { randomBytes } from "node:crypto"; +import { generateSecret } from "@calcom/features/oauth/utils/generateSecret"; import { prisma } from "@calcom/prisma"; import { Prisma } from "@calcom/prisma/client"; @@ -44,8 +45,3 @@ export const addClientHandler = async ({ input }: AddClientOptions) => { isPkceEnabled: enablePkce, }; }; - -const hashSecretKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex"); - -// Generate a random secret -export const generateSecret = (secret = randomBytes(32).toString("hex")) => [hashSecretKey(secret), secret]; diff --git a/packages/trpc/server/routers/viewer/oAuth/generateAuthCode.handler.test.ts b/packages/trpc/server/routers/viewer/oAuth/generateAuthCode.handler.test.ts index f783f4a3f9..d5dabe207a 100644 --- a/packages/trpc/server/routers/viewer/oAuth/generateAuthCode.handler.test.ts +++ b/packages/trpc/server/routers/viewer/oAuth/generateAuthCode.handler.test.ts @@ -2,6 +2,8 @@ import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock"; import { describe, expect, it, vi, beforeEach } from "vitest"; +import { OAUTH_ERROR_REASONS } from "@calcom/features/oauth/services/OAuthService"; + import { TRPCError } from "@trpc/server"; import { generateAuthCodeHandler } from "./generateAuthCode.handler"; @@ -30,7 +32,7 @@ describe("generateAuthCodeHandler", () => { }; it("should generate authorization code for PUBLIC client with valid PKCE", async () => { - prismaMock.oAuthClient.findUnique.mockResolvedValue(mockPublicClient); + prismaMock.oAuthClient.findFirst.mockResolvedValue(mockPublicClient); prismaMock.accessCode.create.mockResolvedValue({ id: 1, code: "test_auth_code", @@ -50,6 +52,7 @@ describe("generateAuthCodeHandler", () => { codeChallenge: "test_challenge", codeChallengeMethod: "S256" as const, scopes: [], + redirectUri: "https://app.example.com/callback", }; const result = await generateAuthCodeHandler({ ctx: mockCtx, input }); @@ -69,7 +72,7 @@ describe("generateAuthCodeHandler", () => { }); it("should reject PUBLIC client without code_challenge", async () => { - prismaMock.oAuthClient.findUnique.mockResolvedValue(mockPublicClient); + prismaMock.oAuthClient.findFirst.mockResolvedValue(mockPublicClient); const input = { clientId: "public_client_123", @@ -77,12 +80,13 @@ describe("generateAuthCodeHandler", () => { teamSlug: undefined, codeChallenge: undefined, codeChallengeMethod: "S256" as const, + redirectUri: "https://app.example.com/callback", }; await expect(generateAuthCodeHandler({ ctx: mockCtx, input })).rejects.toThrow( new TRPCError({ code: "BAD_REQUEST", - message: "code_challenge required for public clients", + message: OAUTH_ERROR_REASONS["pkce_required"], }) ); @@ -90,7 +94,7 @@ describe("generateAuthCodeHandler", () => { }); it("should reject PUBLIC client without code_challenge_method", async () => { - prismaMock.oAuthClient.findUnique.mockResolvedValue(mockPublicClient); + prismaMock.oAuthClient.findFirst.mockResolvedValue(mockPublicClient); const input = { clientId: "public_client_123", @@ -98,12 +102,13 @@ describe("generateAuthCodeHandler", () => { teamSlug: undefined, codeChallenge: "test_challenge", codeChallengeMethod: undefined, + redirectUri: "https://app.example.com/callback", }; await expect(generateAuthCodeHandler({ ctx: mockCtx, input })).rejects.toThrow( new TRPCError({ code: "BAD_REQUEST", - message: "code_challenge_method must be S256 for public clients", + message: OAUTH_ERROR_REASONS["invalid_code_challenge_method"], }) ); @@ -111,7 +116,7 @@ describe("generateAuthCodeHandler", () => { }); it("should reject PUBLIC client with invalid code_challenge_method", async () => { - prismaMock.oAuthClient.findUnique.mockResolvedValue(mockPublicClient); + prismaMock.oAuthClient.findFirst.mockResolvedValue(mockPublicClient); // Test with MD5 (invalid method) const inputMD5 = { @@ -120,12 +125,13 @@ describe("generateAuthCodeHandler", () => { teamSlug: undefined, codeChallenge: "test_challenge", codeChallengeMethod: "MD5", + redirectUri: "https://app.example.com/callback", }; await expect(generateAuthCodeHandler({ ctx: mockCtx, input: inputMD5 })).rejects.toThrow( new TRPCError({ code: "BAD_REQUEST", - message: "code_challenge_method must be S256 for public clients", + message: OAUTH_ERROR_REASONS["invalid_code_challenge_method"], }) ); @@ -136,12 +142,13 @@ describe("generateAuthCodeHandler", () => { teamSlug: undefined, codeChallenge: "test_challenge", codeChallengeMethod: "plain", + redirectUri: "https://app.example.com/callback", }; await expect(generateAuthCodeHandler({ ctx: mockCtx, input: inputPlain })).rejects.toThrow( new TRPCError({ code: "BAD_REQUEST", - message: "code_challenge_method must be S256 for public clients", + message: OAUTH_ERROR_REASONS["invalid_code_challenge_method"], }) ); @@ -155,10 +162,12 @@ describe("generateAuthCodeHandler", () => { redirectUri: "https://app.example.com/callback", name: "Test Confidential Client", clientType: "CONFIDENTIAL" as const, + isTrusted: undefined, + logo: undefined, }; it("should generate authorization code for CONFIDENTIAL client without PKCE", async () => { - prismaMock.oAuthClient.findUnique.mockResolvedValue(mockConfidentialClient); + prismaMock.oAuthClient.findFirst.mockResolvedValue(mockConfidentialClient); prismaMock.accessCode.create.mockResolvedValue({ id: 1, code: "test_auth_code", @@ -179,6 +188,7 @@ describe("generateAuthCodeHandler", () => { teamSlug: undefined, codeChallenge: undefined, codeChallengeMethod: undefined, + redirectUri: "https://app.example.com/callback", }; const result = await generateAuthCodeHandler({ ctx: mockCtx, input }); @@ -198,7 +208,7 @@ describe("generateAuthCodeHandler", () => { }); it("should accept CONFIDENTIAL client with valid PKCE for enhanced security", async () => { - prismaMock.oAuthClient.findUnique.mockResolvedValue(mockConfidentialClient); + prismaMock.oAuthClient.findFirst.mockResolvedValue(mockConfidentialClient); prismaMock.accessCode.create.mockResolvedValue({ id: 1, code: "test_auth_code", @@ -217,11 +227,16 @@ describe("generateAuthCodeHandler", () => { teamSlug: undefined, codeChallenge: "test_challenge", codeChallengeMethod: "S256" as const, + redirectUri: "https://app.example.com/callback", + state: "1234", }; const result = await generateAuthCodeHandler({ ctx: mockCtx, input }); expect(result.authorizationCode).toBeDefined(); + expect(result.redirectUrl).toEqual( + `https://app.example.com/callback?code=${result.authorizationCode}&state=1234` + ); expect(prismaMock.accessCode.create).toHaveBeenCalledWith({ data: { code: expect.any(String), @@ -237,7 +252,7 @@ describe("generateAuthCodeHandler", () => { }); it("should reject CONFIDENTIAL client with code_challenge but missing method", async () => { - prismaMock.oAuthClient.findUnique.mockResolvedValue(mockConfidentialClient); + prismaMock.oAuthClient.findFirst.mockResolvedValue(mockConfidentialClient); const input = { clientId: "confidential_client_456", @@ -245,12 +260,13 @@ describe("generateAuthCodeHandler", () => { teamSlug: undefined, codeChallenge: "test_challenge", codeChallengeMethod: undefined, + redirectUri: "https://app.example.com/callback", }; await expect(generateAuthCodeHandler({ ctx: mockCtx, input })).rejects.toThrow( new TRPCError({ code: "BAD_REQUEST", - message: "code_challenge_method must be S256 when PKCE is used", + message: OAUTH_ERROR_REASONS["invalid_code_challenge_method"], }) ); @@ -260,7 +276,7 @@ describe("generateAuthCodeHandler", () => { describe("Client validation", () => { it("should reject invalid client ID", async () => { - prismaMock.oAuthClient.findUnique.mockResolvedValue(null); + prismaMock.oAuthClient.findFirst.mockResolvedValue(null); const input = { clientId: "invalid_client", @@ -268,12 +284,13 @@ describe("generateAuthCodeHandler", () => { teamSlug: undefined, codeChallenge: undefined, codeChallengeMethod: undefined, + redirectUri: "https://app.example.com/callback", }; await expect(generateAuthCodeHandler({ ctx: mockCtx, input })).rejects.toThrow( new TRPCError({ code: "UNAUTHORIZED", - message: "Client ID not valid", + message: OAUTH_ERROR_REASONS["client_not_found"], }) ); @@ -288,7 +305,7 @@ describe("generateAuthCodeHandler", () => { clientType: "PUBLIC" as const, }; - prismaMock.oAuthClient.findUnique.mockResolvedValue(mockPublicClient); + prismaMock.oAuthClient.findFirst.mockResolvedValue(mockPublicClient); prismaMock.accessCode.create.mockResolvedValue({ id: 1, code: "test_auth_code", @@ -306,6 +323,7 @@ describe("generateAuthCodeHandler", () => { // Test S256 const inputS256 = { clientId: "public_client_123", + redirectUri: "https://app.example.com/callback", scopes: [], teamSlug: undefined, codeChallenge: "test_challenge", @@ -316,11 +334,12 @@ describe("generateAuthCodeHandler", () => { // Reset mocks for next test vi.clearAllMocks(); - prismaMock.oAuthClient.findUnique.mockResolvedValue(mockPublicClient); + prismaMock.oAuthClient.findFirst.mockResolvedValue(mockPublicClient); // Test invalid method const inputInvalid = { clientId: "public_client_123", + redirectUri: "https://app.example.com/callback", scopes: [], teamSlug: undefined, codeChallenge: "test_challenge", @@ -330,7 +349,7 @@ describe("generateAuthCodeHandler", () => { await expect(generateAuthCodeHandler({ ctx: mockCtx, input: inputInvalid })).rejects.toThrow( new TRPCError({ code: "BAD_REQUEST", - message: "code_challenge_method must be S256 for public clients", + message: OAUTH_ERROR_REASONS["invalid_code_challenge_method"], }) ); @@ -341,12 +360,13 @@ describe("generateAuthCodeHandler", () => { teamSlug: undefined, codeChallenge: "test_challenge", codeChallengeMethod: "plain", + redirectUri: "https://app.example.com/callback", }; await expect(generateAuthCodeHandler({ ctx: mockCtx, input: inputPlain })).rejects.toThrow( new TRPCError({ code: "BAD_REQUEST", - message: "code_challenge_method must be S256 for public clients", + message: OAUTH_ERROR_REASONS["invalid_code_challenge_method"], }) ); }); diff --git a/packages/trpc/server/routers/viewer/oAuth/generateAuthCode.handler.ts b/packages/trpc/server/routers/viewer/oAuth/generateAuthCode.handler.ts index 173ce716aa..712e758d1c 100644 --- a/packages/trpc/server/routers/viewer/oAuth/generateAuthCode.handler.ts +++ b/packages/trpc/server/routers/viewer/oAuth/generateAuthCode.handler.ts @@ -1,8 +1,9 @@ -import { randomBytes } from "node:crypto"; - -import dayjs from "@calcom/dayjs"; -import { prisma } from "@calcom/prisma"; +import { getOAuthService } from "@calcom/features/oauth/di/OAuthService.container"; +import { OAuthErrorReason, OAUTH_ERROR_REASONS } from "@calcom/features/oauth/services/OAuthService"; +import { ErrorWithCode } from "@calcom/lib/errors"; +import { getHttpStatusCode } from "@calcom/lib/server/getServerErrorFromUnknown"; import type { AccessScope } from "@calcom/prisma/enums"; +import { httpStatusToTrpcCode } from "@calcom/trpc/server/lib/toTRPCError"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import { TRPCError } from "@trpc/server"; @@ -17,89 +18,31 @@ type AddClientOptions = { }; export const generateAuthCodeHandler = async ({ ctx, input }: AddClientOptions) => { - const { clientId, scopes, teamSlug, codeChallenge, codeChallengeMethod } = input; - const client = await prisma.oAuthClient.findUnique({ - where: { + try { + const { clientId, scopes, teamSlug, codeChallenge, codeChallengeMethod, state, redirectUri } = input; + const oAuthService = getOAuthService(); + + const oAuthClientRedirectUri = redirectUri; + const { authorizationCode, client, redirectUrl } = await oAuthService.generateAuthorizationCode( clientId, - }, - select: { - clientId: true, - redirectUri: true, - name: true, - clientType: true, - }, - }); - - if (!client) { - throw new TRPCError({ code: "UNAUTHORIZED", message: "Client ID not valid" }); - } - - if (client.clientType === "PUBLIC") { - if (!codeChallenge) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "code_challenge required for public clients", - }); - } - if (!codeChallengeMethod || codeChallengeMethod !== "S256") { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "code_challenge_method must be S256 for public clients", - }); - } - } else if (client.clientType === "CONFIDENTIAL") { - // Optional PKCE validation for CONFIDENTIAL clients - if (codeChallenge && (!codeChallengeMethod || codeChallengeMethod !== "S256")) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "code_challenge_method must be S256 when PKCE is used", - }); - } - } - - const authorizationCode = generateAuthorizationCode(); - - const team = teamSlug - ? await prisma.team.findFirst({ - where: { - slug: teamSlug, - members: { - some: { - userId: ctx.user.id, - role: { - in: ["OWNER", "ADMIN"], - }, - }, - }, - }, - }) - : undefined; - - if (teamSlug && !team) { - throw new TRPCError({ code: "UNAUTHORIZED" }); - } - - await prisma.accessCode.create({ - data: { - code: authorizationCode, - clientId, - userId: !teamSlug ? ctx.user.id : undefined, - teamId: team ? team.id : undefined, - expiresAt: dayjs().add(10, "minutes").toDate(), - scopes: scopes as [AccessScope], + ctx.user.id, + oAuthClientRedirectUri, + scopes as AccessScope[], + state, + teamSlug, codeChallenge, - codeChallengeMethod, - }, - }); - return { client, authorizationCode }; -}; + codeChallengeMethod + ); + return { client, authorizationCode: authorizationCode, redirectUrl: redirectUrl }; + } catch (error) { + if (error instanceof ErrorWithCode) { + throw new TRPCError({ + code: httpStatusToTrpcCode(getHttpStatusCode(error)), + message: OAUTH_ERROR_REASONS[error?.data?.reason as OAuthErrorReason] ?? error.message, + cause: error, + }); + } -function generateAuthorizationCode() { - const randomBytesValue = randomBytes(40); - const authorizationCode = randomBytesValue - .toString("base64") - .replace(/=/g, "") - .replace(/\+/g, "-") - .replace(/\//g, "_"); - return authorizationCode; -} + throw error; + } +}; diff --git a/packages/trpc/server/routers/viewer/oAuth/generateAuthCode.schema.ts b/packages/trpc/server/routers/viewer/oAuth/generateAuthCode.schema.ts index c1b3ae14c7..349fb0f857 100644 --- a/packages/trpc/server/routers/viewer/oAuth/generateAuthCode.schema.ts +++ b/packages/trpc/server/routers/viewer/oAuth/generateAuthCode.schema.ts @@ -3,15 +3,19 @@ import { z } from "zod"; export type TGenerateAuthCodeInputSchema = { clientId: string; scopes: string[]; + redirectUri: string; teamSlug?: string; + state?: string; codeChallenge?: string; codeChallengeMethod?: "S256"; }; export const ZGenerateAuthCodeInputSchema: z.ZodType = z.object({ clientId: z.string(), + redirectUri: z.string(), scopes: z.array(z.string()), teamSlug: z.string().optional(), + state: z.string().optional(), codeChallenge: z.string().optional(), codeChallengeMethod: z.enum(["S256"]).optional(), }); diff --git a/packages/trpc/server/routers/viewer/oAuth/getClient.handler.ts b/packages/trpc/server/routers/viewer/oAuth/getClient.handler.ts index 2b5119ac18..9df2a31cea 100644 --- a/packages/trpc/server/routers/viewer/oAuth/getClient.handler.ts +++ b/packages/trpc/server/routers/viewer/oAuth/getClient.handler.ts @@ -1,4 +1,10 @@ -import { prisma } from "@calcom/prisma"; +import { getOAuthService } from "@calcom/features/oauth/di/OAuthService.container"; +import { OAUTH_ERROR_REASONS, OAuthErrorReason } from "@calcom/features/oauth/services/OAuthService"; +import { ErrorWithCode } from "@calcom/lib/errors"; +import { getHttpStatusCode } from "@calcom/lib/server/getServerErrorFromUnknown"; +import { httpStatusToTrpcCode } from "@calcom/trpc/server/lib/toTRPCError"; + +import { TRPCError } from "@trpc/server"; import type { TGetClientInputSchema } from "./getClient.schema"; @@ -7,19 +13,28 @@ type GetClientOptions = { }; export const getClientHandler = async ({ input }: GetClientOptions) => { - const { clientId } = input; + try { + const { clientId } = input; - const client = await prisma.oAuthClient.findUnique({ - where: { - clientId, - }, - select: { - clientId: true, - redirectUri: true, - name: true, - logo: true, - isTrusted: true, - }, - }); - return client; + const oAuthService = getOAuthService(); + + const oAuthClient = await oAuthService.getClient(clientId); + + return { + clientId: oAuthClient.clientId, + redirectUri: oAuthClient.redirectUri, + name: oAuthClient.name, + logo: oAuthClient.logo, + isTrusted: oAuthClient.isTrusted, + }; + } catch (error) { + if (error instanceof ErrorWithCode) { + throw new TRPCError({ + code: httpStatusToTrpcCode(getHttpStatusCode(error)), + message: OAUTH_ERROR_REASONS[error?.data?.reason as OAuthErrorReason] ?? error.message, + cause: error, + }); + } + throw error; + } };