feat: OAuth2 controller api v2 + refactor oAuth Trpc handlers (#25989)
* feat(api-v2): add OAuth2 controller skeleton for auth module - Add new OAuth2 module under /auth with controller, service, repository, and DTOs - Implement skeleton endpoints: - GET /v2/auth/oauth2/clients/:clientId - Get OAuth client info - POST /v2/auth/oauth2/clients/:clientId/authorize - Generate authorization code - POST /v2/auth/oauth2/clients/:clientId/exchange - Exchange code for tokens - POST /v2/auth/oauth2/clients/:clientId/refresh - Refresh access token - Create input DTOs for authorize, exchange, and refresh operations - Create output DTOs for client info, authorization code, and tokens - Register OAuth2Module in endpoints.module.ts Co-Authored-By: morgan@cal.com <morgan@cal.com> * chore: add missing functions to platform libraries * feat(api-v2): integrate OAuthService from platform-libraries into OAuth2 controller - Add OAuthService export to platform-libraries - Refactor OAuth2Service to use OAuthService.validateClient() and OAuthService.verifyPKCE() - Remove duplicate local implementations of validateClient and verifyPKCE methods Co-Authored-By: morgan@cal.com <morgan@cal.com> * feat(api-v2): use local validateClient and verifyPKCE implementations - Remove OAuthService export from platform-libraries (will be deprecated) - Reimplement validateClient and verifyPKCE methods locally in OAuth2Service - Use verifyCodeChallenge and generateSecret from platform-libraries directly Co-Authored-By: morgan@cal.com <morgan@cal.com> * refactor(api-v2): separate repositories for OAuth2, access codes, and teams - Create AccessCodeRepository for access code Prisma operations - Add findTeamBySlugWithAdminRole method to TeamsRepository - Move generateAuthorizationCode, createTokens, verifyRefreshToken to OAuth2Service - Update OAuth2Repository to only contain OAuth client operations - Update OAuth2Module to import TeamsModule and provide AccessCodeRepository Addresses PR review comments to properly separate concerns Co-Authored-By: morgan@cal.com <morgan@cal.com> * feat(api-v2): implement JWT token creation and verification for OAuth2 - Implement createTokens method using jsonwebtoken to sign access and refresh tokens - Implement verifyRefreshToken method to verify JWT refresh tokens - Add ConfigService injection for CALENDSO_ENCRYPTION_KEY access - Import ConfigModule in OAuth2Module for dependency injection Based on logic from apps/web/app/api/auth/oauth/token/route.ts and apps/web/app/api/auth/oauth/refreshToken/route.ts Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: refactor and dayjs import fix * feat(api-v2): add ApiAuthGuard to getClient endpoint and e2e tests for OAuth2 - Add @UseGuards(ApiAuthGuard) to getClient endpoint for authentication - Add comprehensive e2e tests for all OAuth2 endpoints: - GET /v2/auth/oauth2/clients/:clientId - POST /v2/auth/oauth2/clients/:clientId/authorize - POST /v2/auth/oauth2/clients/:clientId/exchange - POST /v2/auth/oauth2/clients/:clientId/refresh - Tests cover happy paths and error cases (invalid client, invalid code, etc.) Co-Authored-By: morgan@cal.com <morgan@cal.com> * chore(api-v2): hide OAuth2 endpoints from Swagger documentation Co-Authored-By: morgan@cal.com <morgan@cal.com> * hide endpoints from doc * fix(api-v2): address PR feedback for OAuth2 controller - Fix P0 critical bug: token_type field name mismatch in DecodedRefreshToken interface - Add @Equals('authorization_code') validation to exchange.input.ts grantType - Add @Equals('refresh_token') validation to refresh.input.ts grantType - Add @IsNotEmpty() validation to get-client.input.ts clientId - Add @Expose() decorators to all output DTOs for proper serialization - Add security test assertion to verify clientSecret is not returned in response Co-Authored-By: morgan@cal.com <morgan@cal.com> * feat(api-v2): implement redirect behavior for OAuth2 authorize endpoint - Update authorize endpoint to return HTTP 303 redirect with authorization code - Add exact match validation for redirect URI (security requirement) - Implement error handling with redirect to redirect URI and error query params - Add state parameter support for CSRF protection - Update e2e tests to verify redirect behavior (303 status, Location header, error redirects) Co-Authored-By: morgan@cal.com <morgan@cal.com> * refactor(api-v2): address PR feedback for OAuth2 authorize endpoint - Make redirectUri a required input parameter (remove optional fallback) - Move buildRedirectUrl and mapErrorToOAuthError to oauth2Service - Add return statements to res.redirect() calls - Simplify controller by delegating redirect URL building to service Co-Authored-By: morgan@cal.com <morgan@cal.com> * wip move code outside of api v2 * feat(oauth): wire up dependency injection for OAuthService and repositories Co-Authored-By: morgan@cal.com <morgan@cal.com> * refactor: oAuthService used in routes * fix imports * fix(api-v2): return 404 for invalid client ID instead of redirect in authorize endpoint Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix(api-v2): fix E2E test URL paths and remove bootstrap() in authenticated section - Remove bootstrap() call in authenticated section to match working test pattern - Change URL paths from /api/v2/... to /v2/... in authenticated section - Keep bootstrap() and /api/v2/... paths in unauthenticated section Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix(api-v2): mock getToken from next-auth/jwt for E2E tests - Add jest.mock for next-auth/jwt getToken function - Mock returns null for unauthenticated tests - Mock returns { email: userEmail } for authenticated tests - Remove withNextAuth helper since ApiAuthGuard uses ApiAuthStrategy which calls getToken directly, not NextAuthStrategy Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix tests and code review * cleanup generate secrets * cleanup controller * chore: remove console.log from OAuthService.validateClient Co-Authored-By: morgan@cal.com <morgan@cal.com> * Update apps/web/app/api/auth/oauth/refreshToken/route.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * fix(api-v2): add bootstrap() to authenticated E2E tests and update URL paths to /api/v2/ Co-Authored-By: morgan@cal.com <morgan@cal.com> * Merge remote-tracking branch 'origin/devin/oauth2-controller-skeleton-1765988792' and remove console.log from token route Co-Authored-By: morgan@cal.com <morgan@cal.com> * chore: remove dead code * refactor * refactor: generateAuthCode trpc handler and getClient trpc handler * remove pkce check for refreshToken endpoint * Update packages/trpc/server/routers/viewer/oAuth/getClient.handler.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * refactor: error handling * refactor: error handling * refactor: error handling * remove console log * provide redirectUri in generateAuthCodeHandler * provide redirectUri in authorize view * refactor: replace HttpError with ErrorWithCode in OAuth files - Update OAuthService to use ErrorWithCode instead of HttpError - Update mapErrorToOAuthError to check ErrorCode instead of status codes - Update token/route.ts and refreshToken/route.ts to use ErrorWithCode - Add ErrorWithCode export to platform-libraries - Use getHttpStatusCode to map ErrorCode to HTTP status codes Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: update generateAuthCode handler to use ErrorWithCode instead of HttpError The handler was still checking for HttpError in its catch block, but OAuthService now throws ErrorWithCode. This caused the error messages to be lost and replaced with 'server_error' instead of the specific error messages. Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: update OAuth2 controller to use ErrorWithCode instead of HttpError The controller was still checking for HttpError in its catch blocks, but OAuthService now throws ErrorWithCode. This caused all errors to return 500 Internal Server Error instead of the correct HTTP status codes (400, 401, 404). Also added getHttpStatusCode export to platform-libraries. Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: add explicit unknown type annotation to catch blocks in OAuth2 controller Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: change NotFoundException to HttpException in authorize endpoint Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: handle err with ErrorWithCode in authorize endpoint catch block Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: move errorWithCode * fix: error code rfc * fix: no need to handle errors there is a middleware * refactor errors * refactor errors * refactor redirecturi and state from api * refactor: address PR comments for OAuth2 controller - Remove unused GetOAuth2ClientInput class - Change API tag from 'Auth / OAuth2' to 'OAuth2' - Move OAuthClientFixture to separate fixture file (oauth2-client.repository.fixture.ts) - Add 'type' property to OAuth2ClientDto for confidential/public client type - Rename 'clientId' to 'id' in OAuth2ClientDto (using @Expose mapping) - Clean up duplicate provider declarations in oauth2.module.ts - Update e2e tests to use new fixture and verify type property Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com> Co-authored-by: Volnei Munhoz <volnei@cal.com>
This commit is contained in:
co-authored by
Volnei Munhoz
Devin AI
cubic-dev-ai[bot]
Peer Richelsen
Volnei Munhoz
parent
ca32f04cc1
commit
61a932f8f5
@@ -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)
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<typeof nextAuthJwt.getToken>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<OAuth2ClientResponseDto> {
|
||||
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<void> {
|
||||
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<OAuth2TokensResponseDto> {
|
||||
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<OAuth2TokensResponseDto> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
|
||||
prismaMock.oAuthClient.findUnique.mockResolvedValue(
|
||||
mockPublicClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findUnique>>
|
||||
);
|
||||
prismaMock.accessCode.findFirst.mockResolvedValue(
|
||||
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
|
||||
@@ -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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
|
||||
prismaMock.oAuthClient.findUnique.mockResolvedValue(
|
||||
mockPublicClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findUnique>>
|
||||
);
|
||||
prismaMock.accessCode.findFirst.mockResolvedValue(
|
||||
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
|
||||
@@ -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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
|
||||
prismaMock.oAuthClient.findUnique.mockResolvedValue(
|
||||
mockPublicClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findUnique>>
|
||||
);
|
||||
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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
|
||||
prismaMock.oAuthClient.findUnique.mockResolvedValue(
|
||||
mockPublicClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findUnique>>
|
||||
);
|
||||
prismaMock.accessCode.findFirst.mockResolvedValue(
|
||||
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
|
||||
@@ -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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
|
||||
prismaMock.oAuthClient.findUnique.mockResolvedValue(
|
||||
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findUnique>>
|
||||
);
|
||||
prismaMock.accessCode.findFirst.mockResolvedValue(
|
||||
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
|
||||
@@ -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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
|
||||
prismaMock.oAuthClient.findUnique.mockResolvedValue(
|
||||
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findUnique>>
|
||||
);
|
||||
|
||||
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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
|
||||
prismaMock.oAuthClient.findUnique.mockResolvedValue(
|
||||
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findUnique>>
|
||||
);
|
||||
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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
|
||||
prismaMock.oAuthClient.findUnique.mockResolvedValue(
|
||||
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findUnique>>
|
||||
);
|
||||
prismaMock.accessCode.findFirst.mockResolvedValue(
|
||||
mockAccessCodeWithPKCE as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
|
||||
@@ -422,8 +422,8 @@ describe("POST /api/auth/oauth/token", () => {
|
||||
codeChallengeMethod: "S256",
|
||||
} as const;
|
||||
|
||||
prismaMock.oAuthClient.findFirst.mockResolvedValue(
|
||||
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
|
||||
prismaMock.oAuthClient.findUnique.mockResolvedValue(
|
||||
mockConfidentialClient as Awaited<ReturnType<typeof prismaMock.oAuthClient.findUnique>>
|
||||
);
|
||||
prismaMock.accessCode.findFirst.mockResolvedValue(
|
||||
mockAccessCodeWithPKCE as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
|
||||
@@ -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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>);
|
||||
} as Awaited<ReturnType<typeof prismaMock.oAuthClient.findUnique>>);
|
||||
|
||||
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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>);
|
||||
} as Awaited<ReturnType<typeof prismaMock.oAuthClient.findUnique>>);
|
||||
prismaMock.accessCode.findFirst.mockResolvedValue(null);
|
||||
|
||||
const tokenRequest = createTokenRequest({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/<slug>
|
||||
codeChallenge: code_challenge || undefined,
|
||||
codeChallengeMethod: (code_challenge_method as "S256") || undefined,
|
||||
state,
|
||||
});
|
||||
}}
|
||||
data-testid="allow-button">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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<OAuthService>(oAuthServiceModuleLoader.token);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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<void> {
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<OAuth2Client> {
|
||||
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<AuthorizeResult> {
|
||||
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, string | undefined>): 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<OAuth2Tokens> {
|
||||
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<OAuth2Tokens> {
|
||||
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<OAuthErrorReason, string> = {
|
||||
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",
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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"],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<TGenerateAuthCodeInputSchema> = 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(),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user