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; + } };