diff --git a/apps/api/v2/src/app.module.ts b/apps/api/v2/src/app.module.ts index 71f05f6cd4..5042292833 100644 --- a/apps/api/v2/src/app.module.ts +++ b/apps/api/v2/src/app.module.ts @@ -13,6 +13,7 @@ import { RedirectsMiddleware } from "@/middleware/app.redirects.middleware"; import { RewriterMiddleware } from "@/middleware/app.rewrites.middleware"; import { JsonBodyMiddleware } from "@/middleware/body/json.body.middleware"; import { RawBodyMiddleware } from "@/middleware/body/raw.body.middleware"; +import { UrlencodedBodyMiddleware } from "@/middleware/body/urlencoded.body.middleware"; import { ResponseInterceptor } from "@/middleware/request-ids/request-id.interceptor"; import { RequestIdMiddleware } from "@/middleware/request-ids/request-id.middleware"; import { AuthModule } from "@/modules/auth/auth.module"; @@ -100,6 +101,17 @@ export class AppModule implements NestModule { method: RequestMethod.POST, } ) + .apply(UrlencodedBodyMiddleware) + .forRoutes( + { + path: "/v2/auth/oauth2/token", + method: RequestMethod.POST, + }, + { + path: "/api/v2/auth/oauth2/token", + method: RequestMethod.POST, + } + ) .apply(JsonBodyMiddleware) .forRoutes("*") .apply(RequestIdMiddleware) diff --git a/apps/api/v2/src/lib/services/oauth.service.ts b/apps/api/v2/src/lib/services/oauth.service.ts index 7114f3c4ad..7c5b2987ca 100644 --- a/apps/api/v2/src/lib/services/oauth.service.ts +++ b/apps/api/v2/src/lib/services/oauth.service.ts @@ -1,9 +1,14 @@ +import type { OAuth2Tokens } from "@calcom/platform-libraries"; +import { OAuthService as BaseOAuthService } from "@calcom/platform-libraries"; +import { Injectable } from "@nestjs/common"; 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"; +import type { OAuth2ExchangeInput } from "@/modules/auth/oauth2/inputs/exchange.input"; +import { OAuth2ExchangeConfidentialInput } from "@/modules/auth/oauth2/inputs/exchange.input"; +import type { OAuth2RefreshInput } from "@/modules/auth/oauth2/inputs/refresh.input"; +import { OAuth2RefreshConfidentialInput } from "@/modules/auth/oauth2/inputs/refresh.input"; +import type { OAuth2TokenInput } from "@/modules/auth/oauth2/inputs/token.input.pipe"; @Injectable() export class OAuthService extends BaseOAuthService { @@ -18,4 +23,32 @@ export class OAuthService extends BaseOAuthService { teamsRepository: teamsRepository, }); } + + async handleTokenRequest(clientId: string, body: OAuth2TokenInput): Promise { + switch (body.grant_type) { + case "authorization_code": + return this.exchangeAuthorizationCode(clientId, body); + case "refresh_token": + return this.refreshToken(clientId, body); + } + } + + private async exchangeAuthorizationCode( + clientId: string, + body: OAuth2ExchangeInput + ): Promise { + if (body instanceof OAuth2ExchangeConfidentialInput) { + return this.exchangeCodeForTokens(clientId, body.code, body.client_secret, body.redirect_uri); + } + + return this.exchangeCodeForTokens(clientId, body.code, undefined, body.redirect_uri, body.code_verifier); + } + + private async refreshToken(clientId: string, body: OAuth2RefreshInput): Promise { + if (body instanceof OAuth2RefreshConfidentialInput) { + return this.refreshAccessToken(clientId, body.refresh_token, body.client_secret); + } + + return this.refreshAccessToken(clientId, body.refresh_token); + } } diff --git a/apps/api/v2/src/middleware/body/urlencoded.body.middleware.ts b/apps/api/v2/src/middleware/body/urlencoded.body.middleware.ts new file mode 100644 index 0000000000..0e84d98c19 --- /dev/null +++ b/apps/api/v2/src/middleware/body/urlencoded.body.middleware.ts @@ -0,0 +1,10 @@ +import { Injectable, NestMiddleware } from "@nestjs/common"; +import { urlencoded } from "body-parser"; +import type { Request, Response } from "express"; + +@Injectable() +export class UrlencodedBodyMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: () => any): void { + urlencoded({ extended: true })(req, res, next); + } +} diff --git a/apps/api/v2/src/modules/auth/oauth2/controllers/oauth2.controller.e2e-spec.ts b/apps/api/v2/src/modules/auth/oauth2/controllers/oauth2.controller.e2e-spec.ts index 4a823fd29b..6669500439 100644 --- a/apps/api/v2/src/modules/auth/oauth2/controllers/oauth2.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/auth/oauth2/controllers/oauth2.controller.e2e-spec.ts @@ -16,6 +16,7 @@ import { bootstrap } from "@/bootstrap"; import { HttpExceptionFilter } from "@/filters/http-exception.filter"; import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; import { ZodExceptionFilter } from "@/filters/zod-exception.filter"; +import { OAuthService } from "@/lib/services/oauth.service"; import { AuthModule } from "@/modules/auth/auth.module"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { UsersModule } from "@/modules/users/users.module"; @@ -49,12 +50,16 @@ describe("OAuth2 Controller Endpoints", () => { .expect(401); }); - it("POST /v2/auth/oauth2/clients/:clientId/authorize should return 401 without auth", () => { + it("POST /v2/auth/oauth2/token should return 401 without auth", () => { return request(appWithoutAuth.getHttpServer()) - .post("/api/v2/auth/oauth2/clients/test-client-id/authorize") + .post("/api/v2/auth/oauth2/token") + .type("form") .send({ - redirectUri: "https://example.com/callback", - scopes: ["READ_BOOKING"], + client_id: "test-client-id", + grant_type: "authorization_code", + code: "test-code", + client_secret: "test-secret", + redirect_uri: "https://example.com/callback", }) .expect(401); }); @@ -72,18 +77,35 @@ describe("OAuth2 Controller Endpoints", () => { let teamRepositoryFixture: TeamRepositoryFixture; let membershipRepositoryFixture: MembershipRepositoryFixture; let oAuthClientFixture: OAuth2ClientRepositoryFixture; + let oAuthService: OAuthService; 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"; + /** Generate an authorization code directly via the service (bypasses HTTP layer). */ + async function generateAuthCode( + scopes: AccessScope[] = [AccessScope.READ_BOOKING], + teamSlug?: string + ): Promise { + const result = await oAuthService.generateAuthorizationCode( + testClientId, + user.id, + testRedirectUri, + scopes, + undefined, + teamSlug ?? team.slug + ); + const redirectUrl = new URL(result.redirectUrl); + return redirectUrl.searchParams.get("code") as string; + } + beforeAll(async () => { const userEmail = `oauth2-e2e-user-${randomString()}@api.com`; @@ -104,6 +126,7 @@ describe("OAuth2 Controller Endpoints", () => { teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef); oAuthClientFixture = new OAuth2ClientRepositoryFixture(moduleRef); + oAuthService = moduleRef.get(OAuthService); user = await userRepositoryFixture.create({ email: userEmail, @@ -132,176 +155,278 @@ describe("OAuth2 Controller Endpoints", () => { }); 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); + describe("Positive tests", () => { + 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(); + expect(response.body.status).toBe("success"); + expect(response.body.data.client_id).toBe(testClientId); + expect(response.body.data.name).toBe("Test OAuth Client"); + expect(response.body.data.redirect_uri).toBe(testRedirectUri); + expect(response.body.data.client_type).toBe(OAuthClientType.CONFIDENTIAL); + expect(response.body.data.client_secret).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("Negative tests", () => { + 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); + describe("POST /api/v2/auth/oauth2/token (authorization_code grant)", () => { + describe("Positive tests", () => { + it("should exchange authorization code for tokens", async () => { + const authorizationCode = await generateAuthCode([ + AccessScope.READ_BOOKING, + AccessScope.READ_PROFILE, + ]); - 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"); + const response = await request(app.getHttpServer()) + .post("/api/v2/auth/oauth2/token") + .type("form") + .send({ + client_id: testClientId, + grant_type: "authorization_code", + code: authorizationCode, + client_secret: testClientSecret, + redirect_uri: testRedirectUri, + }) + .expect(200); - authorizationCode = redirectUrl.searchParams.get("code") as string; + expect(response.body.access_token).toBeDefined(); + expect(response.body.refresh_token).toBeDefined(); + expect(response.body.token_type).toBe("bearer"); + expect(response.body.expires_in).toBe(1800); + expect(response.headers["cache-control"]).toBe("no-store"); + expect(response.headers["pragma"]).toBe("no-cache"); + + refreshToken = response.body.refresh_token; + }); + + it("should exchange authorization code for tokens with JSON body", async () => { + const code = await generateAuthCode(); + + const response = await request(app.getHttpServer()) + .post("/api/v2/auth/oauth2/token") + .send({ + client_id: testClientId, + grant_type: "authorization_code", + code, + client_secret: testClientSecret, + redirect_uri: testRedirectUri, + }) + .expect(200); + + expect(response.body.access_token).toBeDefined(); + expect(response.body.refresh_token).toBeDefined(); + expect(response.body.token_type).toBe("bearer"); + }); + + it("should exchange authorization code for tokens with application/x-www-form-urlencoded body", async () => { + const code = await generateAuthCode(); + + const response = await request(app.getHttpServer()) + .post("/api/v2/auth/oauth2/token") + .type("form") + .send({ + client_id: testClientId, + grant_type: "authorization_code", + code, + client_secret: testClientSecret, + redirect_uri: testRedirectUri, + }) + .expect(200); + + expect(response.body.access_token).toBeDefined(); + expect(response.body.refresh_token).toBeDefined(); + expect(response.body.token_type).toBe("bearer"); + expect(response.body.expires_in).toBe(1800); + }); }); - 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); - }); + describe("Negative tests", () => { + it("should return 400 with RFC 6749 error for invalid/used authorization code", async () => { + const code = await generateAuthCode(); - 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); + // Use the code once + await request(app.getHttpServer()) + .post("/api/v2/auth/oauth2/token") + .type("form") + .send({ + client_id: testClientId, + grant_type: "authorization_code", + code, + client_secret: testClientSecret, + redirect_uri: testRedirectUri, + }) + .expect(200); - 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"); - }); + // Try to use the same code again + const response = await request(app.getHttpServer()) + .post("/api/v2/auth/oauth2/token") + .type("form") + .send({ + client_id: testClientId, + grant_type: "authorization_code", + code, + client_secret: testClientSecret, + redirect_uri: testRedirectUri, + }) + .expect(400); - 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); + expect(response.body.error).toBe("invalid_grant"); + expect(response.body.error_description).toBe("code_invalid_or_expired"); + }); + + it("should return 401 for invalid client secret", async () => { + const code = await generateAuthCode(); + + const response = await request(app.getHttpServer()) + .post("/api/v2/auth/oauth2/token") + .type("form") + .send({ + client_id: testClientId, + grant_type: "authorization_code", + code, + client_secret: "wrong-secret", + redirect_uri: testRedirectUri, + }) + .expect(401); + + expect(response.body.error).toBe("invalid_client"); + expect(response.body.error_description).toBe("invalid_client_credentials"); + }); + + it("should return 400 for invalid grant type", async () => { + const response = await request(app.getHttpServer()) + .post("/api/v2/auth/oauth2/token") + .type("form") + .send({ + client_id: testClientId, + grant_type: "invalid_grant", + code: "some-code", + redirect_uri: testRedirectUri, + }) + .expect(400); + + expect(response.body.error).toBe("invalid_request"); + expect(response.body.error_description).toBe( + "grant_type must be 'authorization_code' or 'refresh_token'" + ); + }); + + it("should return 401 with RFC 6749 error for non-existent client_id", async () => { + const code = await generateAuthCode(); + + const response = await request(app.getHttpServer()) + .post("/api/v2/auth/oauth2/token") + .type("form") + .send({ + client_id: "non-existent-client-id", + grant_type: "authorization_code", + code, + client_secret: testClientSecret, + redirect_uri: testRedirectUri, + }) + .expect(401); + + expect(response.body.error).toBe("invalid_client"); + expect(response.body.error_description).toBe("client_not_found"); + }); + + it("should return 400 with RFC 6749 error when client_id is missing", async () => { + const response = await request(app.getHttpServer()) + .post("/api/v2/auth/oauth2/token") + .type("form") + .send({ + grant_type: "authorization_code", + code: "some-code", + client_secret: testClientSecret, + redirect_uri: testRedirectUri, + }) + .expect(400); + + expect(response.body.error).toBe("invalid_request"); + expect(response.body.error_description).toBe("client_id is required"); + }); }); }); - 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); + describe("POST /api/v2/auth/oauth2/token (refresh_token grant)", () => { + describe("Positive tests", () => { + it("should refresh access token with valid refresh token", async () => { + const response = await request(app.getHttpServer()) + .post("/api/v2/auth/oauth2/token") + .type("form") + .send({ + client_id: testClientId, + grant_type: "refresh_token", + refresh_token: refreshToken, + client_secret: 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); - - refreshToken = response.body.data.refresh_token; + expect(response.body.access_token).toBeDefined(); + expect(response.body.refresh_token).toBeDefined(); + expect(response.body.token_type).toBe("bearer"); + expect(response.body.expires_in).toBe(1800); + }); }); - 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); - }); + describe("Negative tests", () => { + it("should return 400 with RFC 6749 error for invalid refresh token", async () => { + const response = await request(app.getHttpServer()) + .post("/api/v2/auth/oauth2/token") + .type("form") + .send({ + client_id: testClientId, + grant_type: "refresh_token", + refresh_token: "invalid-refresh-token", + client_secret: testClientSecret, + }) + .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); + expect(response.body.error).toBe("invalid_grant"); + expect(response.body.error_description).toBe("invalid_refresh_token"); + }); - const newRedirectUrl = new URL(newAuthResponse.headers.location); - const newAuthCode = newRedirectUrl.searchParams.get("code") as string; + it("should return 401 for invalid client secret", async () => { + const response = await request(app.getHttpServer()) + .post("/api/v2/auth/oauth2/token") + .type("form") + .send({ + client_id: testClientId, + grant_type: "refresh_token", + refresh_token: refreshToken, + client_secret: "wrong-secret", + }) + .expect(401); - await request(app.getHttpServer()) - .post(`/api/v2/auth/oauth2/clients/${testClientId}/exchange`) - .send({ - code: newAuthCode, - clientSecret: "wrong-secret", - redirectUri: testRedirectUri, - }) - .expect(401); - }); - }); + expect(response.body.error).toBe("invalid_client"); + expect(response.body.error_description).toBe("invalid_client_credentials"); + }); - 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); + it("should return 401 with RFC 6749 error for non-existent client_id", async () => { + const response = await request(app.getHttpServer()) + .post("/api/v2/auth/oauth2/token") + .type("form") + .send({ + client_id: "wrong-client-id", + grant_type: "refresh_token", + refresh_token: refreshToken, + client_secret: testClientSecret, + }) + .expect(401); - 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); + expect(response.body.error).toBe("invalid_client"); + expect(response.body.error_description).toBe("client_not_found"); + }); }); }); diff --git a/apps/api/v2/src/modules/auth/oauth2/controllers/oauth2.controller.ts b/apps/api/v2/src/modules/auth/oauth2/controllers/oauth2.controller.ts index 977062d81c..b2e9dc70e4 100644 --- a/apps/api/v2/src/modules/auth/oauth2/controllers/oauth2.controller.ts +++ b/apps/api/v2/src/modules/auth/oauth2/controllers/oauth2.controller.ts @@ -1,46 +1,48 @@ -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 { SUCCESS_STATUS } from "@calcom/platform-constants"; import { Body, Controller, Get, + Header, HttpCode, - HttpException, HttpStatus, - InternalServerErrorException, - Logger, Param, Post, - Res, + UseFilters, UseGuards, } from "@nestjs/common"; -import { ApiExcludeController, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { ApiBody, ApiExtraModels, ApiOperation, ApiTags, getSchemaPath } 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"; +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { OAuthService } from "@/lib/services/oauth.service"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { OAuth2HttpExceptionFilter } from "@/modules/auth/oauth2/filters/oauth2-http-exception.filter"; +import { + OAuth2ExchangeConfidentialInput, + OAuth2ExchangePublicInput, +} from "@/modules/auth/oauth2/inputs/exchange.input"; +import { + OAuth2RefreshConfidentialInput, + OAuth2RefreshPublicInput, +} from "@/modules/auth/oauth2/inputs/refresh.input"; +import type { OAuth2TokenInput } from "@/modules/auth/oauth2/inputs/token.input.pipe"; +import { OAuth2TokenInputPipe } from "@/modules/auth/oauth2/inputs/token.input.pipe"; +import { OAuth2ClientDto, OAuth2ClientResponseDto } from "@/modules/auth/oauth2/outputs/oauth2-client.output"; +import { OAuth2TokensDto } from "@/modules/auth/oauth2/outputs/oauth2-tokens.output"; +import { OAuth2ErrorService } from "@/modules/auth/oauth2/services/oauth2-error.service"; @Controller({ - path: "/v2/auth/oauth2/clients/:clientId", + path: "/v2/auth/oauth2", version: API_VERSIONS_VALUES, }) -@ApiExcludeController(true) @ApiTags("OAuth2") export class OAuth2Controller { - private readonly logger = new Logger("OAuth2Controller"); + constructor( + private readonly oAuthService: OAuthService, + private readonly errorHandler: OAuth2ErrorService + ) {} - constructor(private readonly oAuthService: OAuthService) {} - - @Get("/") + @Get("/clients/:clientId") @HttpCode(HttpStatus.OK) @UseGuards(ApiAuthGuard) @ApiOperation({ @@ -55,114 +57,50 @@ export class OAuth2Controller { 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"); + } catch (err) { + this.errorHandler.handleClientError(err, "Failed to retrieve OAuth client"); } } - @Post("/authorize") - @UseGuards(ApiAuthGuard) - @ApiAuthGuardOnlyAllow(["NEXT_AUTH"]) + @Post("/token") + @HttpCode(HttpStatus.OK) @ApiOperation({ - summary: "Generate authorization code", + summary: "Exchange authorization code or refresh token for tokens", description: - "Generates an authorization code for the OAuth2 flow and redirects to the redirect URI with the code. Requires user authentication.", + "RFC 6749-compliant token endpoint. Pass client_id in the request body (Section 2.3.1). " + + "Use grant_type 'authorization_code' to exchange an auth code for tokens, or 'refresh_token' to refresh an access token. " + + "Accepts both application/x-www-form-urlencoded (standard per RFC 6749 Section 4.1.3) and application/json content types.", }) - async authorize( - @Param("clientId") clientId: string, - @Body() body: OAuth2AuthorizeInput, - @GetUser("id") userId: number, - @Res() res: Response - ): Promise { + @ApiBody({ + schema: { + oneOf: [ + { $ref: getSchemaPath(OAuth2ExchangeConfidentialInput) }, + { $ref: getSchemaPath(OAuth2ExchangePublicInput) }, + { $ref: getSchemaPath(OAuth2RefreshConfidentialInput) }, + { $ref: getSchemaPath(OAuth2RefreshPublicInput) }, + ], + }, + description: + "Token request body. client_id is required. " + + "Accepts application/x-www-form-urlencoded (RFC 6749 standard) or application/json. " + + "Use grant_type 'authorization_code' with client_secret (confidential) or code_verifier (public/PKCE), or grant_type 'refresh_token' with client_secret (confidential) or just the refresh_token (public).", + }) + @ApiExtraModels( + OAuth2ExchangeConfidentialInput, + OAuth2ExchangePublicInput, + OAuth2RefreshConfidentialInput, + OAuth2RefreshPublicInput + ) + @UseFilters(OAuth2HttpExceptionFilter) + @Header("Cache-Control", "no-store") + @Header("Pragma", "no-cache") + async token(@Body(new OAuth2TokenInputPipe()) body: OAuth2TokenInput): Promise { try { - const client = await this.oAuthService.getClient(clientId); - const result = await this.oAuthService.generateAuthorizationCode( - client.clientId, - userId, - body.redirectUri, - body.scopes, - body.state, - body.teamSlug, - body.codeChallenge, - body.codeChallengeMethod - ); - return res.redirect(303, result.redirectUrl); - } catch (err: unknown) { - if (err instanceof ErrorWithCode) { - if (err.message === "unauthorized_client" || err?.data?.["reason"] === "redirect_uri_mismatch") { - const statusCode = getHttpStatusCode(err); - throw new HttpException(err.message, statusCode); - } - } - const errorRedirectUrl = this.oAuthService.buildErrorRedirectUrl(body.redirectUri, err, body.state); - return res.redirect(303, errorRedirectUrl); - } - } + const tokens = await this.oAuthService.handleTokenRequest(body.client_id, body); - @Post("/exchange") - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: "Exchange authorization code for tokens", - description: "Exchanges an authorization code for access and refresh tokens", - }) - async exchange( - @Param("clientId") clientId: string, - @Body() body: OAuth2ExchangeInput - ): Promise { - try { - const tokens = await this.oAuthService.exchangeCodeForTokens( - clientId, - body.code, - body.clientSecret, - body.redirectUri, - body.codeVerifier - ); - return { - status: SUCCESS_STATUS, - data: plainToInstance(OAuth2TokensDto, tokens, { strategy: "excludeAll" }), - }; - } catch (err: unknown) { - if (err instanceof ErrorWithCode) { - const statusCode = getHttpStatusCode(err); - throw new HttpException(err.message, statusCode); - } - this.logger.error(err); - throw new InternalServerErrorException("Could not exchange code for tokens"); - } - } - - @Post("/refresh") - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: "Refresh access token", - description: "Refreshes an access token using a refresh token", - }) - async refresh( - @Param("clientId") clientId: string, - @Body() body: OAuth2RefreshInput - ): Promise { - try { - const tokens = await this.oAuthService.refreshAccessToken( - clientId, - body.refreshToken, - body.clientSecret - ); - return { - status: SUCCESS_STATUS, - data: plainToInstance(OAuth2TokensDto, tokens, { strategy: "excludeAll" }), - }; - } catch (err: unknown) { - if (err instanceof ErrorWithCode) { - const statusCode = getHttpStatusCode(err); - throw new HttpException(err.message, statusCode); - } - this.logger.error(err); - throw new InternalServerErrorException("Could not refresh tokens"); + return plainToInstance(OAuth2TokensDto, tokens, { strategy: "excludeAll" }); + } catch (err) { + this.errorHandler.handleTokenError(err); } } } diff --git a/apps/api/v2/src/modules/auth/oauth2/filters/oauth2-http-exception.filter.ts b/apps/api/v2/src/modules/auth/oauth2/filters/oauth2-http-exception.filter.ts new file mode 100644 index 0000000000..49e76beee8 --- /dev/null +++ b/apps/api/v2/src/modules/auth/oauth2/filters/oauth2-http-exception.filter.ts @@ -0,0 +1,32 @@ +import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common"; +import { Catch, Logger } from "@nestjs/common"; +import type { Request, Response } from "express"; +import { extractUserContext } from "@/lib/extract-user-context"; +import { filterReqHeaders } from "@/lib/filterReqHeaders"; +import { OAuth2HttpException } from "@/modules/auth/oauth2/filters/oauth2-http.exception"; + +@Catch(OAuth2HttpException) +export class OAuth2HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger("OAuth2HttpExceptionFilter"); + + catch(exception: OAuth2HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const requestId = request.headers["x-request-id"] ?? "unknown-request-id"; + response.setHeader("X-Request-Id", requestId.toString()); + + const userContext = extractUserContext(request); + this.logger.error(`OAuth2 Http Exception: ${exception.oAuthErrorData.error}`, { + exception, + body: "[REDACTED]", + headers: filterReqHeaders(request.headers), + url: request.url, + method: request.method, + requestId, + ...userContext, + }); + + response.status(exception.getStatus()).json(exception.oAuthErrorData); + } +} diff --git a/apps/api/v2/src/modules/auth/oauth2/filters/oauth2-http.exception.ts b/apps/api/v2/src/modules/auth/oauth2/filters/oauth2-http.exception.ts new file mode 100644 index 0000000000..03b5169923 --- /dev/null +++ b/apps/api/v2/src/modules/auth/oauth2/filters/oauth2-http.exception.ts @@ -0,0 +1,10 @@ +import { HttpException } from "@nestjs/common"; + +export class OAuth2HttpException extends HttpException { + constructor( + public readonly oAuthErrorData: { error: string; error_description: string }, + statusCode: number + ) { + super(oAuthErrorData, statusCode); + } +} diff --git a/apps/api/v2/src/modules/auth/oauth2/filters/oauth2-redirect-exception.filter.ts b/apps/api/v2/src/modules/auth/oauth2/filters/oauth2-redirect-exception.filter.ts new file mode 100644 index 0000000000..d2d8516457 --- /dev/null +++ b/apps/api/v2/src/modules/auth/oauth2/filters/oauth2-redirect-exception.filter.ts @@ -0,0 +1,14 @@ +import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common"; +import { Catch } from "@nestjs/common"; +import type { Response } from "express"; +import { OAuth2RedirectException } from "@/modules/auth/oauth2/filters/oauth2-redirect.exception"; + +@Catch(OAuth2RedirectException) +export class OAuth2RedirectExceptionFilter implements ExceptionFilter { + catch(exception: OAuth2RedirectException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + response.redirect(303, exception.redirectUrl); + } +} diff --git a/apps/api/v2/src/modules/auth/oauth2/filters/oauth2-redirect.exception.ts b/apps/api/v2/src/modules/auth/oauth2/filters/oauth2-redirect.exception.ts new file mode 100644 index 0000000000..e49711b7cc --- /dev/null +++ b/apps/api/v2/src/modules/auth/oauth2/filters/oauth2-redirect.exception.ts @@ -0,0 +1,10 @@ +import { HttpException, HttpStatus } from "@nestjs/common"; + +export class OAuth2RedirectException extends HttpException { + readonly redirectUrl: string; + + constructor(redirectUrl: string) { + super("OAuth2 redirect", HttpStatus.SEE_OTHER); + this.redirectUrl = redirectUrl; + } +} diff --git a/apps/api/v2/src/modules/auth/oauth2/inputs/authorize.input.ts b/apps/api/v2/src/modules/auth/oauth2/inputs/authorize.input.ts index 823a35bfe3..3e6c0c1684 100644 --- a/apps/api/v2/src/modules/auth/oauth2/inputs/authorize.input.ts +++ b/apps/api/v2/src/modules/auth/oauth2/inputs/authorize.input.ts @@ -1,8 +1,7 @@ +import { AccessScope } from "@calcom/prisma/enums"; 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: @@ -10,7 +9,7 @@ export class OAuth2AuthorizeInput { example: "https://example.com/callback", }) @IsString() - redirectUri!: string; + redirect_uri!: string; @ApiProperty({ description: "OAuth state parameter for CSRF protection. Will be included in the redirect.", @@ -36,7 +35,7 @@ export class OAuth2AuthorizeInput { }) @IsOptional() @IsString() - teamSlug?: string; + team_slug?: string; @ApiProperty({ description: "PKCE code challenge (required for public clients)", @@ -44,10 +43,10 @@ export class OAuth2AuthorizeInput { }) @IsOptional() @IsString() - codeChallenge?: string; + code_challenge?: string; @ApiHideProperty() @IsString() @Equals("S256") - codeChallengeMethod: string = "S256"; + code_challenge_method: string = "S256"; } diff --git a/apps/api/v2/src/modules/auth/oauth2/inputs/exchange.input.ts b/apps/api/v2/src/modules/auth/oauth2/inputs/exchange.input.ts index d4d840523d..d892e5fc1b 100644 --- a/apps/api/v2/src/modules/auth/oauth2/inputs/exchange.input.ts +++ b/apps/api/v2/src/modules/auth/oauth2/inputs/exchange.input.ts @@ -1,7 +1,23 @@ -import { ApiHideProperty, ApiProperty } from "@nestjs/swagger"; -import { Equals, IsOptional, IsString } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { Equals, IsString } from "class-validator"; + +export class OAuth2ExchangeConfidentialInput { + @ApiProperty({ + description: "The client identifier", + example: "my-client-id", + }) + @IsString() + client_id!: string; + + @ApiProperty({ + description: "The grant type — must be 'authorization_code'", + example: "authorization_code", + enum: ["authorization_code"], + }) + @IsString() + @Equals("authorization_code") + grant_type!: "authorization_code"; -export class OAuth2ExchangeInput { @ApiProperty({ description: "The authorization code received from the authorize endpoint", example: "abc123", @@ -10,30 +26,55 @@ export class OAuth2ExchangeInput { code!: string; @ApiProperty({ - description: "The client secret (required for confidential clients)", - required: false, + description: "The redirect URI used in the authorization request", + example: "https://example.com/callback", }) - @IsOptional() @IsString() - clientSecret?: string; + redirect_uri!: string; + + @ApiProperty({ + description: "The client secret for confidential clients", + }) + @IsString() + client_secret!: string; +} + +export class OAuth2ExchangePublicInput { + @ApiProperty({ + description: "The client identifier", + example: "my-client-id", + }) + @IsString() + client_id!: string; + + @ApiProperty({ + description: "The grant type — must be 'authorization_code'", + example: "authorization_code", + enum: ["authorization_code"], + }) + @IsString() + @Equals("authorization_code") + grant_type!: "authorization_code"; + + @ApiProperty({ + description: "The authorization code received from the authorize endpoint", + example: "abc123", + }) + @IsString() + code!: 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"; + redirect_uri!: string; @ApiProperty({ - description: "PKCE code verifier (required if code_challenge was used)", - required: false, + description: "PKCE code verifier (required for public clients that used code_challenge)", }) - @IsOptional() @IsString() - codeVerifier?: string; + code_verifier!: string; } + +export type OAuth2ExchangeInput = OAuth2ExchangeConfidentialInput | OAuth2ExchangePublicInput; diff --git a/apps/api/v2/src/modules/auth/oauth2/inputs/refresh.input.ts b/apps/api/v2/src/modules/auth/oauth2/inputs/refresh.input.ts index 3abc212f4b..51ddf76253 100644 --- a/apps/api/v2/src/modules/auth/oauth2/inputs/refresh.input.ts +++ b/apps/api/v2/src/modules/auth/oauth2/inputs/refresh.input.ts @@ -1,24 +1,60 @@ -import { ApiHideProperty, ApiProperty } from "@nestjs/swagger"; -import { Equals, IsOptional, IsString } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { Equals, IsString } from "class-validator"; + +export class OAuth2RefreshConfidentialInput { + @ApiProperty({ + description: "The client identifier", + example: "my-client-id", + }) + @IsString() + client_id!: string; + + @ApiProperty({ + description: "The grant type — must be 'refresh_token'", + example: "refresh_token", + enum: ["refresh_token"], + }) + @IsString() + @Equals("refresh_token") + grant_type!: "refresh_token"; -export class OAuth2RefreshInput { @ApiProperty({ description: "The refresh token", example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", }) @IsString() - refreshToken!: string; + refresh_token!: string; @ApiProperty({ - description: "The client secret (required for confidential clients)", - required: false, + description: "The client secret for confidential clients", }) - @IsOptional() @IsString() - clientSecret?: string; + client_secret!: string; +} - @ApiHideProperty() +export class OAuth2RefreshPublicInput { + @ApiProperty({ + description: "The client identifier", + example: "my-client-id", + }) + @IsString() + client_id!: string; + + @ApiProperty({ + description: "The grant type — must be 'refresh_token'", + example: "refresh_token", + enum: ["refresh_token"], + }) @IsString() @Equals("refresh_token") - grantType: string = "refresh_token"; + grant_type!: "refresh_token"; + + @ApiProperty({ + description: "The refresh token", + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + }) + @IsString() + refresh_token!: string; } + +export type OAuth2RefreshInput = OAuth2RefreshConfidentialInput | OAuth2RefreshPublicInput; diff --git a/apps/api/v2/src/modules/auth/oauth2/inputs/token.input.pipe.ts b/apps/api/v2/src/modules/auth/oauth2/inputs/token.input.pipe.ts new file mode 100644 index 0000000000..9970fcfd18 --- /dev/null +++ b/apps/api/v2/src/modules/auth/oauth2/inputs/token.input.pipe.ts @@ -0,0 +1,107 @@ +import type { PipeTransform } from "@nestjs/common"; +import { HttpStatus, Injectable } from "@nestjs/common"; +import { plainToClass } from "class-transformer"; +import type { ValidationError } from "class-validator"; +import { validateSync } from "class-validator"; +import { OAuth2ExchangeConfidentialInput, OAuth2ExchangePublicInput } from "./exchange.input"; +import { OAuth2RefreshConfidentialInput, OAuth2RefreshPublicInput } from "./refresh.input"; +import { OAuth2HttpException } from "@/modules/auth/oauth2/filters/oauth2-http.exception"; + +export type OAuth2TokenInput = + | OAuth2ExchangeConfidentialInput + | OAuth2ExchangePublicInput + | OAuth2RefreshConfidentialInput + | OAuth2RefreshPublicInput; + +@Injectable() +export class OAuth2TokenInputPipe implements PipeTransform { + // eslint-disable-next-line @typescript-eslint/no-empty-function + constructor() {} + + transform(value: OAuth2TokenInput): OAuth2TokenInput { + if (!value) { + this.throwOAuthError("Body is required"); + } + if (typeof value !== "object") { + this.throwOAuthError("Body should be an object"); + } + + const raw = value as unknown as Record; + + if (!raw.client_id || typeof raw.client_id !== "string") { + this.throwOAuthError("client_id is required"); + } + + const grantType = raw.grant_type; + + if (grantType === "authorization_code") { + return this.validateExchange(value); + } + + if (grantType === "refresh_token") { + return this.validateRefresh(value); + } + + this.throwOAuthError("grant_type must be 'authorization_code' or 'refresh_token'"); + } + + private validateExchange( + value: OAuth2TokenInput + ): OAuth2ExchangeConfidentialInput | OAuth2ExchangePublicInput { + if (this.isConfidentialExchange(value)) { + return this.validate(OAuth2ExchangeConfidentialInput, value); + } + return this.validate(OAuth2ExchangePublicInput, value); + } + + private validateRefresh( + value: OAuth2TokenInput + ): OAuth2RefreshConfidentialInput | OAuth2RefreshPublicInput { + if (this.isConfidentialRefresh(value)) { + return this.validate(OAuth2RefreshConfidentialInput, value); + } + return this.validate(OAuth2RefreshPublicInput, value); + } + + private validate(cls: new () => T, value: OAuth2TokenInput): T { + const object = plainToClass(cls, value); + + const errors = validateSync(object, { + whitelist: true, + forbidNonWhitelisted: true, + skipMissingProperties: false, + }); + + if (errors.length > 0) { + this.throwOAuthError(this.formatErrors(errors)); + } + + return object; + } + + private formatErrors(errors: ValidationError[]): string { + return errors + .map((err) => { + const constraints = err.constraints ? Object.values(err.constraints).join(", ") : ""; + const childrenErrors = + err.children && err.children.length > 0 ? `${this.formatErrors(err.children)}` : ""; + return `${err.property} property is wrong,${constraints} ${childrenErrors}`; + }) + .join(", "); + } + + private isConfidentialExchange(value: OAuth2TokenInput): value is OAuth2ExchangeConfidentialInput { + return "client_secret" in value; + } + + private isConfidentialRefresh(value: OAuth2TokenInput): value is OAuth2RefreshConfidentialInput { + return "client_secret" in value; + } + + private throwOAuthError(description: string): never { + throw new OAuth2HttpException( + { error: "invalid_request", error_description: description }, + HttpStatus.BAD_REQUEST + ); + } +} diff --git a/apps/api/v2/src/modules/auth/oauth2/oauth2.module.ts b/apps/api/v2/src/modules/auth/oauth2/oauth2.module.ts index 3d86a23b98..8838b1a03c 100644 --- a/apps/api/v2/src/modules/auth/oauth2/oauth2.module.ts +++ b/apps/api/v2/src/modules/auth/oauth2/oauth2.module.ts @@ -1,10 +1,12 @@ +import { Module } from "@nestjs/common"; import { oAuthServiceModule } from "@/lib/modules/oauth.module"; import { AtomsOAuth2Controller } from "@/modules/auth/oauth2/controllers/atoms-oauth2.controller"; import { OAuth2Controller } from "@/modules/auth/oauth2/controllers/oauth2.controller"; -import { Module } from "@nestjs/common"; +import { OAuth2ErrorService } from "@/modules/auth/oauth2/services/oauth2-error.service"; @Module({ imports: [oAuthServiceModule], controllers: [OAuth2Controller, AtomsOAuth2Controller], + providers: [OAuth2ErrorService], }) export class OAuth2Module {} diff --git a/apps/api/v2/src/modules/auth/oauth2/outputs/oauth2-client.output.ts b/apps/api/v2/src/modules/auth/oauth2/outputs/oauth2-client.output.ts index 5ab1f925f8..905a61e592 100644 --- a/apps/api/v2/src/modules/auth/oauth2/outputs/oauth2-client.output.ts +++ b/apps/api/v2/src/modules/auth/oauth2/outputs/oauth2-client.output.ts @@ -1,10 +1,9 @@ +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; +import { OAuthClientType } from "@calcom/prisma/enums"; 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", @@ -12,15 +11,15 @@ export class OAuth2ClientDto { }) @IsString() @Expose({ name: "clientId" }) - id!: string; + client_id!: string; @ApiProperty({ description: "The redirect URI for the OAuth client", example: "https://example.com/callback", }) @IsString() - @Expose() - redirectUri!: string; + @Expose({ name: "redirectUri" }) + redirect_uri!: string; @ApiProperty({ description: "The name of the OAuth client", @@ -44,8 +43,8 @@ export class OAuth2ClientDto { example: false, }) @IsBoolean() - @Expose() - isTrusted!: boolean; + @Expose({ name: "isTrusted" }) + is_trusted!: boolean; @ApiProperty({ description: "The type of OAuth client (CONFIDENTIAL or PUBLIC)", @@ -54,7 +53,7 @@ export class OAuth2ClientDto { }) @IsEnum(OAuthClientType) @Expose({ name: "clientType" }) - type!: OAuthClientType; + client_type!: OAuthClientType; } export class OAuth2ClientResponseDto { diff --git a/apps/api/v2/src/modules/auth/oauth2/outputs/oauth2-tokens.output.ts b/apps/api/v2/src/modules/auth/oauth2/outputs/oauth2-tokens.output.ts index d06598ba45..6b333f039a 100644 --- a/apps/api/v2/src/modules/auth/oauth2/outputs/oauth2-tokens.output.ts +++ b/apps/api/v2/src/modules/auth/oauth2/outputs/oauth2-tokens.output.ts @@ -1,8 +1,6 @@ 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"; +import { Expose } from "class-transformer"; +import { IsNumber, IsString } from "class-validator"; export class OAuth2TokensDto { @ApiProperty({ @@ -37,19 +35,3 @@ export class OAuth2TokensDto { @Expose({ name: "expiresIn" }) expires_in!: number; } - -export class OAuth2TokensResponseDto { - @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) - @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) - @Expose() - status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; - - @ApiProperty({ - type: OAuth2TokensDto, - }) - @IsNotEmptyObject() - @ValidateNested() - @Type(() => OAuth2TokensDto) - @Expose() - data!: OAuth2TokensDto; -} diff --git a/apps/api/v2/src/modules/auth/oauth2/services/oauth2-error.service.ts b/apps/api/v2/src/modules/auth/oauth2/services/oauth2-error.service.ts new file mode 100644 index 0000000000..30bbb16d4f --- /dev/null +++ b/apps/api/v2/src/modules/auth/oauth2/services/oauth2-error.service.ts @@ -0,0 +1,72 @@ +import { ErrorWithCode, getHttpStatusCode } from "@calcom/platform-libraries/errors"; +import { HttpException, Injectable, InternalServerErrorException, Logger } from "@nestjs/common"; +import { OAuthService } from "@/lib/services/oauth.service"; +import { OAuth2HttpException } from "@/modules/auth/oauth2/filters/oauth2-http.exception"; +import { OAuth2RedirectException } from "@/modules/auth/oauth2/filters/oauth2-redirect.exception"; + +const NON_REDIRECTABLE_REASONS = new Set([ + "client_not_found", + "client_not_approved", + "redirect_uri_mismatch", +]); + +@Injectable() +export class OAuth2ErrorService { + private readonly logger = new Logger("OAuth2ErrorService"); + + constructor(private readonly oAuthService: OAuthService) {} + + handleAuthorizeError(err: unknown, redirectUri: string, state?: string): never { + if (err instanceof ErrorWithCode) { + const reason = err.data?.["reason"] as string | undefined; + + if (reason && NON_REDIRECTABLE_REASONS.has(reason)) { + const statusCode = getHttpStatusCode(err); + throw new OAuth2HttpException( + { + error: err.message, + error_description: reason, + }, + statusCode + ); + } + } + + const errorRedirectUrl = this.oAuthService.buildErrorRedirectUrl(redirectUri, err, state); + throw new OAuth2RedirectException(errorRedirectUrl); + } + + handleTokenError(err: unknown): never { + if (err instanceof ErrorWithCode) { + const statusCode = getHttpStatusCode(err); + const reason = err.data?.["reason"] as string | undefined; + throw new OAuth2HttpException( + { + error: err.message, + error_description: reason ?? err.message, + }, + statusCode + ); + } + this.logger.error(err); + throw new OAuth2HttpException( + { + error: "server_error", + error_description: "An unexpected error occurred", + }, + 500 + ); + } + + handleClientError(err: unknown, fallbackMessage: string): never { + if (err instanceof ErrorWithCode) { + const statusCode = getHttpStatusCode(err); + if (statusCode >= 500) { + this.logger.error(err); + } + throw new HttpException(err.message, statusCode); + } + this.logger.error(err); + throw new InternalServerErrorException(fallbackMessage); + } +} diff --git a/apps/api/v2/test/fixtures/repository/oauth2-client.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/oauth2-client.repository.fixture.ts index 66da1594b7..d36caf72eb 100644 --- a/apps/api/v2/test/fixtures/repository/oauth2-client.repository.fixture.ts +++ b/apps/api/v2/test/fixtures/repository/oauth2-client.repository.fixture.ts @@ -1,8 +1,7 @@ +import { OAuthClientStatus, OAuthClientType } from "@calcom/prisma/enums"; +import { TestingModule } from "@nestjs/testing"; 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"]; @@ -19,6 +18,7 @@ export class OAuth2ClientRepositoryFixture { redirectUri: string; clientSecret?: string; clientType?: OAuthClientType; + status?: OAuthClientStatus; logo?: string; isTrusted?: boolean; }) { @@ -29,6 +29,7 @@ export class OAuth2ClientRepositoryFixture { redirectUri: data.redirectUri, clientSecret: data.clientSecret, clientType: data.clientType || OAuthClientType.CONFIDENTIAL, + status: data.status || OAuthClientStatus.APPROVED, logo: data.logo, isTrusted: data.isTrusted || false, }, diff --git a/docs/api-reference/v2/oauth.mdx b/docs/api-reference/v2/oauth.mdx index 7a79d638e0..e1189cb94e 100644 --- a/docs/api-reference/v2/oauth.mdx +++ b/docs/api-reference/v2/oauth.mdx @@ -9,7 +9,7 @@ As an example, you can view our OAuth flow in action on Zapier. Try to connect y ![](/images/oauth-zapier.png) -### Get your OAuth "Continue with [Cal.com](http://Cal.com)" Badge +#### Get your OAuth "Continue with [Cal.com](http://Cal.com)" Badge - https://app.cal.com/continue-with-calcom-coss-ui.svg - https://app.cal.com/continue-with-calcom-dark-rounded.svg @@ -20,7 +20,7 @@ As an example, you can view our OAuth flow in action on Zapier. Try to connect y - https://app.cal.com/continue-with-calcom-light-squared.svg -### OAuth Client Credentials +## 1. OAuth Client Credentials You can create an OAuth client via the following page https://app.cal.com/settings/developer/oauth. The OAuth client will be in a "pending" state and not yet ready to use. @@ -28,66 +28,424 @@ and not yet ready to use. An admin from Cal.com will then review your OAuth client and you will receive an email if it was accepted or rejected. If it was accepted then your OAuth client is ready to be used. -### Authorization URL +## 2. Authorize To initiate the OAuth flow, direct users to the following authorization URL: -- `https://app.cal.com/auth/oauth2/authorize` -- URL Parameters: - - _client_id_ - - _state_: A securely generated random string to mitigate CSRF attacks - - _redirect_uri_: This is where users will be redirected after authorization +`https://app.cal.com/auth/oauth2/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URI&state=YOUR_STATE` -After users click _Allow_, they will be redirected to the redirect_uri with the code (authorization code) and state as URL parameters. +**URL Parameters:** -### Access Token Request +| Parameter | Required | Description | +|-----------|----------|-------------| +| `client_id` | Yes | Your OAuth client ID | +| `redirect_uri` | Yes | Where users will be redirected after authorization. Must exactly match the registered redirect URI. | +| `state` | Recommended | A securely generated random string to mitigate CSRF attacks | +| `code_challenge` | For public clients | PKCE code challenge (S256 method) | -Endpoint: `POST https://app.cal.com/api/auth/oauth/token` - -Request Body: - -- _code_: The authorization code received in the redirect URI -- _client_id_ -- _client_secret_ -- _grant_type_: "authorization_code" -- _redirect_uri_ - -Response: +After users click **Allow**, they will be redirected to the `redirect_uri` with `code` (authorization code) and `state` as URL parameters: ``` +https://your-app.com/callback?code=AUTHORIZATION_CODE&state=YOUR_STATE +``` + +#### Error Handling + +Errors during the authorization step are displayed directly to the user on the Cal.com authorization page. Your application will not receive a JSON error response for these cases: + +- **Client not found**: No OAuth client exists with the provided `client_id`. +- **Client not approved**: The OAuth client has not been approved by a Cal.com admin yet. +- **Mismatched redirect URI**: The `redirect_uri` does not match the one registered for the OAuth client. + +If an error occurs after the client is validated (e.g., the user denies access or has insufficient permissions), the user is redirected to the `redirect_uri` with an error: + +``` +https://your-app.com/callback?error=access_denied&error_description=team_not_found_or_no_access&state=YOUR_STATE +``` + +## 3. Exchange Token + +Exchange an authorization code for access and refresh tokens. The token endpoint also accepts `application/x-www-form-urlencoded` content type. + +**Endpoint:** `POST https://api.cal.com/v2/auth/oauth2/token` + +### 3.1 Confidential Clients + +Confidential clients authenticate with a `client_secret`. All parameters are required: + +| Parameter | Description | +|-----------|-------------| +| `client_id` | Your OAuth client ID | +| `client_secret` | Your OAuth client secret | +| `grant_type` | Must be `authorization_code` | +| `code` | The authorization code received in the redirect URI | +| `redirect_uri` | Must match the redirect URI used in the authorization request | + + + + ```bash + curl -X POST https://api.cal.com/v2/auth/oauth2/token \ + -H "Content-Type: application/json" \ + -d '{ + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "grant_type": "authorization_code", + "code": "AUTHORIZATION_CODE", + "redirect_uri": "https://your-app.com/callback" + }' + ``` + + + ```typescript + const response = await fetch("https://api.cal.com/v2/auth/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: "YOUR_CLIENT_ID", + client_secret: "YOUR_CLIENT_SECRET", + grant_type: "authorization_code", + code: "AUTHORIZATION_CODE", + redirect_uri: "https://your-app.com/callback", + }), + }); + + const tokens = await response.json(); + ``` + + + ```typescript + import axios from "axios"; + + const { data } = await axios.post( + "https://api.cal.com/v2/auth/oauth2/token", + { + client_id: "YOUR_CLIENT_ID", + client_secret: "YOUR_CLIENT_SECRET", + grant_type: "authorization_code", + code: "AUTHORIZATION_CODE", + redirect_uri: "https://your-app.com/callback", + } + ); + ``` + + + +### 3.2 Public Clients (PKCE) + +Public clients (e.g. single-page apps, mobile apps) use PKCE instead of a `client_secret`. You must have sent a `code_challenge` during the authorization step. All parameters are required: + +| Parameter | Description | +|-----------|-------------| +| `client_id` | Your OAuth client ID | +| `grant_type` | Must be `authorization_code` | +| `code` | The authorization code received in the redirect URI | +| `redirect_uri` | Must match the redirect URI used in the authorization request | +| `code_verifier` | The original PKCE code verifier used to generate the `code_challenge` | + + + + ```bash + curl -X POST https://api.cal.com/v2/auth/oauth2/token \ + -H "Content-Type: application/json" \ + -d '{ + "client_id": "YOUR_CLIENT_ID", + "grant_type": "authorization_code", + "code": "AUTHORIZATION_CODE", + "redirect_uri": "https://your-app.com/callback", + "code_verifier": "YOUR_CODE_VERIFIER" + }' + ``` + + + ```typescript + const response = await fetch("https://api.cal.com/v2/auth/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: "YOUR_CLIENT_ID", + grant_type: "authorization_code", + code: "AUTHORIZATION_CODE", + redirect_uri: "https://your-app.com/callback", + code_verifier: "YOUR_CODE_VERIFIER", + }), + }); + + const tokens = await response.json(); + ``` + + + ```typescript + import axios from "axios"; + + const { data } = await axios.post( + "https://api.cal.com/v2/auth/oauth2/token", + { + client_id: "YOUR_CLIENT_ID", + grant_type: "authorization_code", + code: "AUTHORIZATION_CODE", + redirect_uri: "https://your-app.com/callback", + code_verifier: "YOUR_CODE_VERIFIER", + } + ); + ``` + + + +#### Success Response (200) + +```json { - access_token: “exampleAccessToken” - refresh_token: “exampleRefreshToken” + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "refresh_token": "eyJhbGciOiJIUzI1NiIs...", + "token_type": "bearer", + "expires_in": 1800 } ``` -### Refresh Token Request + + Access tokens expire after 30 minutes (`expires_in: 1800`). Use the refresh token to obtain a new access token. + -Endpoint: `POST https://app.cal.com/api/auth/oauth/refreshToken` +#### Error Responses -Headers: +Error responses include `error` and `error_description` fields. -- Authorization: Bearer _exampleRefreshToken_ + + + ```json + { + "error": "invalid_grant", + "error_description": "code_invalid_or_expired" + } + ``` + The authorization code has already been used, has expired, or is invalid. Request a new authorization code. + + + ```json + { + "error": "invalid_client", + "error_description": "invalid_client_credentials" + } + ``` + The `client_secret` does not match the `client_id`. Verify your credentials. + + + ```json + { + "error": "invalid_client", + "error_description": "client_not_found" + } + ``` + No OAuth client exists with the provided `client_id`. + + + ```json + { + "error": "invalid_request", + "error_description": "client_id is required" + } + ``` + The `client_id` field is missing from the request body. + + + ```json + { + "error": "invalid_request", + "error_description": "grant_type must be 'authorization_code' or 'refresh_token'" + } + ``` + The `grant_type` field must be either `authorization_code` or `refresh_token`. + + -Request Body: +## 4. Refresh Token -- _grant_type_: "refresh_token" -- _client_id_ -- _client_secret_ +Refresh an expired access token using a refresh token. -Response: +**Endpoint:** `POST https://api.cal.com/v2/auth/oauth2/token` -``` +### 4.1 Confidential Clients + +Confidential clients authenticate with a `client_secret`. All parameters are required: + +| Parameter | Description | +|-----------|-------------| +| `client_id` | Your OAuth client ID | +| `client_secret` | Your OAuth client secret | +| `grant_type` | Must be `refresh_token` | +| `refresh_token` | The refresh token received from a previous token response | + + + + ```bash + curl -X POST https://api.cal.com/v2/auth/oauth2/token \ + -H "Content-Type: application/json" \ + -d '{ + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "grant_type": "refresh_token", + "refresh_token": "YOUR_REFRESH_TOKEN" + }' + ``` + + + ```typescript + const response = await fetch("https://api.cal.com/v2/auth/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: "YOUR_CLIENT_ID", + client_secret: "YOUR_CLIENT_SECRET", + grant_type: "refresh_token", + refresh_token: "YOUR_REFRESH_TOKEN", + }), + }); + + const tokens = await response.json(); + ``` + + + ```typescript + import axios from "axios"; + + const { data } = await axios.post( + "https://api.cal.com/v2/auth/oauth2/token", + { + client_id: "YOUR_CLIENT_ID", + client_secret: "YOUR_CLIENT_SECRET", + grant_type: "refresh_token", + refresh_token: "YOUR_REFRESH_TOKEN", + } + ); + ``` + + + +### 4.2 Public Clients + +Public clients do not use a `client_secret`. All parameters are required: + +| Parameter | Description | +|-----------|-------------| +| `client_id` | Your OAuth client ID | +| `grant_type` | Must be `refresh_token` | +| `refresh_token` | The refresh token received from a previous token response | + + + + ```bash + curl -X POST https://api.cal.com/v2/auth/oauth2/token \ + -H "Content-Type: application/json" \ + -d '{ + "client_id": "YOUR_CLIENT_ID", + "grant_type": "refresh_token", + "refresh_token": "YOUR_REFRESH_TOKEN" + }' + ``` + + + ```typescript + const response = await fetch("https://api.cal.com/v2/auth/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: "YOUR_CLIENT_ID", + grant_type: "refresh_token", + refresh_token: "YOUR_REFRESH_TOKEN", + }), + }); + + const tokens = await response.json(); + ``` + + + ```typescript + import axios from "axios"; + + const { data } = await axios.post( + "https://api.cal.com/v2/auth/oauth2/token", + { + client_id: "YOUR_CLIENT_ID", + grant_type: "refresh_token", + refresh_token: "YOUR_REFRESH_TOKEN", + } + ); + ``` + + + +#### Success Response (200) + +```json { - access_token: “exampleAccessToken”, - refresh_token: "exampleRefreshToken" + "access_token": "eyJhbGciOiJIUzI1NiIs...", + "refresh_token": "eyJhbGciOiJIUzI1NiIs...", + "token_type": "bearer", + "expires_in": 1800 } ``` -### Testing OAuth Credentials +#### Error Responses -To verify the correct setup and functionality of OAuth credentials you can use the following endpoint: `GET https://api.cal.com/v2/me` + + + ```json + { + "error": "invalid_grant", + "error_description": "invalid_refresh_token" + } + ``` + The refresh token is invalid, expired, or malformed. The user must re-authorize. + + + ```json + { + "error": "invalid_client", + "error_description": "invalid_client_credentials" + } + ``` + The `client_secret` does not match the `client_id`. + + + ```json + { + "error": "invalid_client", + "error_description": "client_not_found" + } + ``` + No OAuth client exists with the provided `client_id`. + + -Headers: +## 5. Verify Access Token -- Authorization: Bearer _exampleAccessToken_ +To verify the correct setup and functionality of OAuth credentials, use the following endpoint: + +**Endpoint:** `GET https://api.cal.com/v2/me` + + + + ```bash + curl -X GET https://api.cal.com/v2/me \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" + ``` + + + ```typescript + const response = await fetch("https://api.cal.com/v2/me", { + headers: { Authorization: "Bearer YOUR_ACCESS_TOKEN" }, + }); + + const user = await response.json(); + ``` + + + ```typescript + import axios from "axios"; + + const { data } = await axios.get("https://api.cal.com/v2/me", { + headers: { Authorization: "Bearer YOUR_ACCESS_TOKEN" }, + }); + ``` + + diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 99249ff663..674a910605 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -28,11 +28,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -41,10 +41,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -523,8 +523,8 @@ "required": false, "in": "query", "description": "Filter by assigned attribute option ids. ids must be separated by a comma.", - "example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", "schema": { + "example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", "type": "array", "items": { "type": "string" @@ -536,8 +536,8 @@ "required": false, "in": "query", "description": "Filter by teamIds. Team ids must be separated by a comma.", - "example": "?teamIds=100,200", "schema": { + "example": "?teamIds=100,200", "type": "array", "items": { "type": "number" @@ -613,8 +613,8 @@ "required": false, "in": "query", "description": "Filter by assigned attribute option ids. ids must be separated by a comma.", - "example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", "schema": { + "example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", "type": "array", "items": { "type": "string" @@ -626,8 +626,8 @@ "required": false, "in": "query", "description": "Filter by teamIds. Team ids must be separated by a comma.", - "example": "?teamIds=100,200", "schema": { + "example": "?teamIds=100,200", "type": "array", "items": { "type": "number" @@ -841,8 +841,8 @@ "required": false, "in": "query", "description": "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.", - "example": "?status=upcoming,past", "schema": { + "example": "?status=upcoming,past", "type": "array", "items": { "type": "string", @@ -855,8 +855,8 @@ "required": false, "in": "query", "description": "Filter bookings by the attendee's email address.", - "example": "example@domain.com", "schema": { + "example": "example@domain.com", "type": "string" } }, @@ -865,8 +865,8 @@ "required": false, "in": "query", "description": "Filter bookings by the attendee's name.", - "example": "John Doe", "schema": { + "example": "John Doe", "type": "string" } }, @@ -875,8 +875,8 @@ "required": false, "in": "query", "description": "Filter bookings by the booking Uid.", - "example": "2NtaeaVcKfpmSZ4CthFdfk", "schema": { + "example": "2NtaeaVcKfpmSZ4CthFdfk", "type": "string" } }, @@ -885,8 +885,8 @@ "required": false, "in": "query", "description": "Filter bookings by event type ids belonging to the user. Event type ids must be separated by a comma.", - "example": "?eventTypeIds=100,200", "schema": { + "example": "?eventTypeIds=100,200", "type": "string" } }, @@ -895,8 +895,8 @@ "required": false, "in": "query", "description": "Filter bookings by event type id belonging to the user.", - "example": "?eventTypeId=100", "schema": { + "example": "?eventTypeId=100", "type": "string" } }, @@ -905,8 +905,8 @@ "required": false, "in": "query", "description": "Filter bookings by team ids that user is part of. Team ids must be separated by a comma.", - "example": "?teamIds=50,60", "schema": { + "example": "?teamIds=50,60", "type": "string" } }, @@ -915,8 +915,8 @@ "required": false, "in": "query", "description": "Filter bookings by team id that user is part of", - "example": "?teamId=50", "schema": { + "example": "?teamId=50", "type": "string" } }, @@ -925,8 +925,8 @@ "required": false, "in": "query", "description": "Filter bookings with start after this date string.", - "example": "?afterStart=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterStart=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -935,8 +935,8 @@ "required": false, "in": "query", "description": "Filter bookings with end before this date string.", - "example": "?beforeEnd=2025-03-07T11:00:00.000Z", "schema": { + "example": "?beforeEnd=2025-03-07T11:00:00.000Z", "type": "string" } }, @@ -945,8 +945,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been created after this date string.", - "example": "?afterCreatedAt=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterCreatedAt=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -955,8 +955,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been created before this date string.", - "example": "?beforeCreatedAt=2025-03-14T11:00:00.000Z", "schema": { + "example": "?beforeCreatedAt=2025-03-14T11:00:00.000Z", "type": "string" } }, @@ -965,8 +965,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been updated after this date string.", - "example": "?afterUpdatedAt=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterUpdatedAt=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -975,8 +975,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been updated before this date string.", - "example": "?beforeUpdatedAt=2025-03-14T11:00:00.000Z", "schema": { + "example": "?beforeUpdatedAt=2025-03-14T11:00:00.000Z", "type": "string" } }, @@ -985,8 +985,8 @@ "required": false, "in": "query", "description": "Sort results by their start time in ascending or descending order.", - "example": "?sortStart=asc OR ?sortStart=desc", "schema": { + "example": "?sortStart=asc OR ?sortStart=desc", "enum": ["asc", "desc"], "type": "string" } @@ -996,8 +996,8 @@ "required": false, "in": "query", "description": "Sort results by their end time in ascending or descending order.", - "example": "?sortEnd=asc OR ?sortEnd=desc", "schema": { + "example": "?sortEnd=asc OR ?sortEnd=desc", "enum": ["asc", "desc"], "type": "string" } @@ -1007,8 +1007,8 @@ "required": false, "in": "query", "description": "Sort results by their creation time (when booking was made) in ascending or descending order.", - "example": "?sortCreated=asc OR ?sortCreated=desc", "schema": { + "example": "?sortCreated=asc OR ?sortCreated=desc", "enum": ["asc", "desc"], "type": "string" } @@ -1018,8 +1018,8 @@ "required": false, "in": "query", "description": "Sort results by their updated time (for example when booking status changes) in ascending or descending order.", - "example": "?sortUpdated=asc OR ?sortUpdated=desc", "schema": { + "example": "?sortUpdated=asc OR ?sortUpdated=desc", "enum": ["asc", "desc"], "type": "string" } @@ -1029,9 +1029,9 @@ "required": false, "in": "query", "description": "The number of items to return", - "example": 10, "schema": { "default": 100, + "example": 10, "type": "number" } }, @@ -1040,9 +1040,9 @@ "required": false, "in": "query", "description": "The number of items to skip", - "example": 0, "schema": { "default": 0, + "example": 0, "type": "number" } }, @@ -1051,8 +1051,8 @@ "required": false, "in": "query", "description": "Filter bookings by ids of users within your organization.", - "example": "?userIds=100,200", "schema": { + "example": "?userIds=100,200", "type": "string" } }, @@ -1265,11 +1265,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -1278,10 +1278,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -1672,11 +1672,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -1685,10 +1685,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -2165,8 +2165,8 @@ "required": false, "in": "query", "description": "Permissions to remove (format: resource.action). Supports comma-separated values as well as repeated query params.", - "example": "?permissions=eventType.read,booking.read", "schema": { + "example": "?permissions=eventType.read,booking.read", "type": "array", "items": { "type": "string", @@ -2422,8 +2422,8 @@ "required": false, "in": "query", "description": "Filter by teamIds. Team ids must be separated by a comma.", - "example": "?teamIds=100,200", "schema": { + "example": "?teamIds=100,200", "type": "array", "items": { "type": "number" @@ -2612,8 +2612,8 @@ "required": true, "in": "query", "description": "\n Time starting from which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to start of day or specify hours:\n 2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z\n ", - "example": "2050-09-05", "schema": { + "example": "2050-09-05", "type": "string" } }, @@ -2622,8 +2622,8 @@ "required": true, "in": "query", "description": "\n Time until which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to end of day or specify hours:\n 2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z", - "example": "2050-09-06", "schema": { + "example": "2050-09-06", "type": "string" } }, @@ -2632,8 +2632,8 @@ "required": false, "in": "query", "description": "Time zone in which the available slots should be returned. Defaults to UTC.", - "example": "Europe/Rome", "schema": { + "example": "Europe/Rome", "type": "string" } }, @@ -2642,8 +2642,8 @@ "required": false, "in": "query", "description": "If event type has multiple possible durations then you can specify the desired duration here. Also, if you are fetching slots for a dynamic event then you can specify the duration her which defaults to 30, meaning that returned slots will be each 30 minutes long.", - "example": "60", "schema": { + "example": "60", "type": "number" } }, @@ -2652,8 +2652,8 @@ "required": false, "in": "query", "description": "Format of slot times in response. Use 'range' to get start and end times.", - "example": "range", "schema": { + "example": "range", "enum": ["range", "time"], "type": "string" } @@ -2663,8 +2663,8 @@ "required": false, "in": "query", "description": "The unique identifier of the booking being rescheduled. When provided will ensure that the original booking time appears within the returned available slots when rescheduling.", - "example": "abc123def456", "schema": { + "example": "abc123def456", "type": "string" } }, @@ -2673,8 +2673,8 @@ "required": false, "in": "query", "description": "Whether to queue the form response.", - "example": true, "schema": { + "example": true, "type": "boolean" } } @@ -2803,11 +2803,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -2816,10 +2816,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -2884,11 +2884,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -2897,10 +2897,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -3029,11 +3029,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -3042,10 +3042,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -3284,8 +3284,8 @@ "required": false, "in": "query", "description": "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.", - "example": "?status=upcoming,past", "schema": { + "example": "?status=upcoming,past", "type": "array", "items": { "type": "string", @@ -3298,8 +3298,8 @@ "required": false, "in": "query", "description": "Filter bookings by the attendee's email address.", - "example": "example@domain.com", "schema": { + "example": "example@domain.com", "type": "string" } }, @@ -3308,8 +3308,8 @@ "required": false, "in": "query", "description": "Filter bookings by the attendee's name.", - "example": "John Doe", "schema": { + "example": "John Doe", "type": "string" } }, @@ -3318,8 +3318,8 @@ "required": false, "in": "query", "description": "Filter bookings by the booking Uid.", - "example": "2NtaeaVcKfpmSZ4CthFdfk", "schema": { + "example": "2NtaeaVcKfpmSZ4CthFdfk", "type": "string" } }, @@ -3328,8 +3328,8 @@ "required": false, "in": "query", "description": "Filter bookings by event type ids belonging to the team. Event type ids must be separated by a comma.", - "example": "?eventTypeIds=100,200", "schema": { + "example": "?eventTypeIds=100,200", "type": "string" } }, @@ -3338,8 +3338,8 @@ "required": false, "in": "query", "description": "Filter bookings by event type id belonging to the team.", - "example": "?eventTypeId=100", "schema": { + "example": "?eventTypeId=100", "type": "string" } }, @@ -3348,8 +3348,8 @@ "required": false, "in": "query", "description": "Filter bookings with start after this date string.", - "example": "?afterStart=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterStart=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -3358,8 +3358,8 @@ "required": false, "in": "query", "description": "Filter bookings with end before this date string.", - "example": "?beforeEnd=2025-03-07T11:00:00.000Z", "schema": { + "example": "?beforeEnd=2025-03-07T11:00:00.000Z", "type": "string" } }, @@ -3368,8 +3368,8 @@ "required": false, "in": "query", "description": "Sort results by their start time in ascending or descending order.", - "example": "?sortStart=asc OR ?sortStart=desc", "schema": { + "example": "?sortStart=asc OR ?sortStart=desc", "enum": ["asc", "desc"], "type": "string" } @@ -3379,8 +3379,8 @@ "required": false, "in": "query", "description": "Sort results by their end time in ascending or descending order.", - "example": "?sortEnd=asc OR ?sortEnd=desc", "schema": { + "example": "?sortEnd=asc OR ?sortEnd=desc", "enum": ["asc", "desc"], "type": "string" } @@ -3390,8 +3390,8 @@ "required": false, "in": "query", "description": "Sort results by their creation time (when booking was made) in ascending or descending order.", - "example": "?sortCreated=asc OR ?sortCreated=desc", "schema": { + "example": "?sortCreated=asc OR ?sortCreated=desc", "enum": ["asc", "desc"], "type": "string" } @@ -3401,10 +3401,10 @@ "required": false, "in": "query", "description": "The number of items to return", - "example": 10, "schema": { "minimum": 1, "maximum": 250, + "example": 10, "type": "number" } }, @@ -3413,9 +3413,9 @@ "required": false, "in": "query", "description": "The number of items to skip", - "example": 0, "schema": { "minimum": 0, + "example": 0, "type": "number" } }, @@ -3496,8 +3496,8 @@ "required": false, "in": "query", "description": "Filter booking references by type", - "example": "google_calendar", "schema": { + "example": "google_calendar", "enum": [ "google_calendar", "office365_calendar", @@ -4318,11 +4318,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -4331,10 +4331,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -4759,11 +4759,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -4772,10 +4772,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -5214,11 +5214,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -5227,10 +5227,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -5763,8 +5763,8 @@ "required": false, "in": "query", "description": "Permissions to remove (format: resource.action). Supports comma-separated values as well as repeated query params.", - "example": "?permissions=eventType.read,booking.read", "schema": { + "example": "?permissions=eventType.read,booking.read", "type": "array", "items": { "type": "string", @@ -6207,8 +6207,8 @@ "required": true, "in": "query", "description": "\n Time starting from which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to start of day or specify hours:\n 2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z\n ", - "example": "2050-09-05", "schema": { + "example": "2050-09-05", "type": "string" } }, @@ -6217,8 +6217,8 @@ "required": true, "in": "query", "description": "\n Time until which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to end of day or specify hours:\n 2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z", - "example": "2050-09-06", "schema": { + "example": "2050-09-06", "type": "string" } }, @@ -6227,8 +6227,8 @@ "required": false, "in": "query", "description": "Time zone in which the available slots should be returned. Defaults to UTC.", - "example": "Europe/Rome", "schema": { + "example": "Europe/Rome", "type": "string" } }, @@ -6237,8 +6237,8 @@ "required": false, "in": "query", "description": "If event type has multiple possible durations then you can specify the desired duration here. Also, if you are fetching slots for a dynamic event then you can specify the duration her which defaults to 30, meaning that returned slots will be each 30 minutes long.", - "example": "60", "schema": { + "example": "60", "type": "number" } }, @@ -6247,8 +6247,8 @@ "required": false, "in": "query", "description": "Format of slot times in response. Use 'range' to get start and end times.", - "example": "range", "schema": { + "example": "range", "enum": ["range", "time"], "type": "string" } @@ -6258,8 +6258,8 @@ "required": false, "in": "query", "description": "The unique identifier of the booking being rescheduled. When provided will ensure that the original booking time appears within the returned available slots when rescheduling.", - "example": "abc123def456", "schema": { + "example": "abc123def456", "type": "string" } }, @@ -6268,8 +6268,8 @@ "required": false, "in": "query", "description": "Whether to queue the form response.", - "example": true, "schema": { + "example": true, "type": "boolean" } } @@ -6406,11 +6406,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -6419,10 +6419,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -6431,8 +6431,8 @@ "required": false, "in": "query", "description": "Filter schedules by event type ID", - "example": 1, "schema": { + "example": 1, "type": "number" } } @@ -6648,8 +6648,8 @@ "required": false, "in": "query", "description": "Filter schedules by event type ID", - "example": 1, "schema": { + "example": 1, "type": "number" } } @@ -6722,11 +6722,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -6735,10 +6735,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -6875,11 +6875,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -6888,10 +6888,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -7402,10 +7402,10 @@ "required": false, "in": "query", "description": "The number of items to return", - "example": 10, "schema": { "minimum": 1, "maximum": 1000, + "example": 10, "type": "number" } }, @@ -7414,9 +7414,9 @@ "required": false, "in": "query", "description": "The number of items to skip", - "example": 0, "schema": { "minimum": 0, + "example": 0, "type": "number" } }, @@ -7425,8 +7425,8 @@ "required": false, "in": "query", "description": "The email address or an array of email addresses to filter by", - "example": ["user1@example.com", "user2@example.com"], "schema": { + "example": ["user1@example.com", "user2@example.com"], "type": "array", "items": { "type": "string" @@ -7438,8 +7438,8 @@ "required": false, "in": "query", "description": "Filter by assigned attribute option ids. ids must be separated by a comma.", - "example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", "schema": { + "example": "?assignedOptionIds=aaaaaaaa-bbbb-cccc-dddd-eeeeee1eee,aaaaaaaa-bbbb-cccc-dddd-eeeeee2eee", "type": "array", "items": { "type": "string" @@ -7451,9 +7451,9 @@ "required": false, "in": "query", "description": "Query operator used to filter assigned options, AND by default.", - "example": "NONE", "schema": { "default": "AND", + "example": "NONE", "enum": ["OR", "AND", "NONE"], "type": "string" } @@ -7463,8 +7463,8 @@ "required": false, "in": "query", "description": "Filter by teamIds. Team ids must be separated by a comma.", - "example": "?teamIds=100,200", "schema": { + "example": "?teamIds=100,200", "type": "array", "items": { "type": "number" @@ -7732,8 +7732,8 @@ "required": false, "in": "query", "description": "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.", - "example": "?status=upcoming,past", "schema": { + "example": "?status=upcoming,past", "type": "array", "items": { "type": "string", @@ -7746,8 +7746,8 @@ "required": false, "in": "query", "description": "Filter bookings by the attendee's email address.", - "example": "example@domain.com", "schema": { + "example": "example@domain.com", "type": "string" } }, @@ -7756,8 +7756,8 @@ "required": false, "in": "query", "description": "Filter bookings by the attendee's name.", - "example": "John Doe", "schema": { + "example": "John Doe", "type": "string" } }, @@ -7766,8 +7766,8 @@ "required": false, "in": "query", "description": "Filter bookings by the booking Uid.", - "example": "2NtaeaVcKfpmSZ4CthFdfk", "schema": { + "example": "2NtaeaVcKfpmSZ4CthFdfk", "type": "string" } }, @@ -7776,8 +7776,8 @@ "required": false, "in": "query", "description": "Filter bookings by event type ids belonging to the user. Event type ids must be separated by a comma.", - "example": "?eventTypeIds=100,200", "schema": { + "example": "?eventTypeIds=100,200", "type": "string" } }, @@ -7786,8 +7786,8 @@ "required": false, "in": "query", "description": "Filter bookings by event type id belonging to the user.", - "example": "?eventTypeId=100", "schema": { + "example": "?eventTypeId=100", "type": "string" } }, @@ -7796,8 +7796,8 @@ "required": false, "in": "query", "description": "Filter bookings by team ids that user is part of. Team ids must be separated by a comma.", - "example": "?teamIds=50,60", "schema": { + "example": "?teamIds=50,60", "type": "string" } }, @@ -7806,8 +7806,8 @@ "required": false, "in": "query", "description": "Filter bookings by team id that user is part of", - "example": "?teamId=50", "schema": { + "example": "?teamId=50", "type": "string" } }, @@ -7816,8 +7816,8 @@ "required": false, "in": "query", "description": "Filter bookings with start after this date string.", - "example": "?afterStart=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterStart=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -7826,8 +7826,8 @@ "required": false, "in": "query", "description": "Filter bookings with end before this date string.", - "example": "?beforeEnd=2025-03-07T11:00:00.000Z", "schema": { + "example": "?beforeEnd=2025-03-07T11:00:00.000Z", "type": "string" } }, @@ -7836,8 +7836,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been created after this date string.", - "example": "?afterCreatedAt=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterCreatedAt=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -7846,8 +7846,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been created before this date string.", - "example": "?beforeCreatedAt=2025-03-14T11:00:00.000Z", "schema": { + "example": "?beforeCreatedAt=2025-03-14T11:00:00.000Z", "type": "string" } }, @@ -7856,8 +7856,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been updated after this date string.", - "example": "?afterUpdatedAt=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterUpdatedAt=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -7866,8 +7866,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been updated before this date string.", - "example": "?beforeUpdatedAt=2025-03-14T11:00:00.000Z", "schema": { + "example": "?beforeUpdatedAt=2025-03-14T11:00:00.000Z", "type": "string" } }, @@ -7876,8 +7876,8 @@ "required": false, "in": "query", "description": "Sort results by their start time in ascending or descending order.", - "example": "?sortStart=asc OR ?sortStart=desc", "schema": { + "example": "?sortStart=asc OR ?sortStart=desc", "enum": ["asc", "desc"], "type": "string" } @@ -7887,8 +7887,8 @@ "required": false, "in": "query", "description": "Sort results by their end time in ascending or descending order.", - "example": "?sortEnd=asc OR ?sortEnd=desc", "schema": { + "example": "?sortEnd=asc OR ?sortEnd=desc", "enum": ["asc", "desc"], "type": "string" } @@ -7898,8 +7898,8 @@ "required": false, "in": "query", "description": "Sort results by their creation time (when booking was made) in ascending or descending order.", - "example": "?sortCreated=asc OR ?sortCreated=desc", "schema": { + "example": "?sortCreated=asc OR ?sortCreated=desc", "enum": ["asc", "desc"], "type": "string" } @@ -7909,8 +7909,8 @@ "required": false, "in": "query", "description": "Sort results by their updated time (for example when booking status changes) in ascending or descending order.", - "example": "?sortUpdated=asc OR ?sortUpdated=desc", "schema": { + "example": "?sortUpdated=asc OR ?sortUpdated=desc", "enum": ["asc", "desc"], "type": "string" } @@ -7920,9 +7920,9 @@ "required": false, "in": "query", "description": "The number of items to return", - "example": 10, "schema": { "default": 100, + "example": 10, "type": "number" } }, @@ -7931,9 +7931,9 @@ "required": false, "in": "query", "description": "The number of items to skip", - "example": 0, "schema": { "default": 0, + "example": 0, "type": "number" } } @@ -7991,11 +7991,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -8004,10 +8004,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -8016,8 +8016,8 @@ "required": false, "in": "query", "description": "Sort results by their start time in ascending or descending order.", - "example": "?sortStart=asc OR ?sortStart=desc", "schema": { + "example": "?sortStart=asc OR ?sortStart=desc", "enum": ["asc", "desc"], "type": "string" } @@ -8027,8 +8027,8 @@ "required": false, "in": "query", "description": "Sort results by their end time in ascending or descending order.", - "example": "?sortEnd=asc OR ?sortEnd=desc", "schema": { + "example": "?sortEnd=asc OR ?sortEnd=desc", "enum": ["asc", "desc"], "type": "string" } @@ -8258,11 +8258,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -8271,10 +8271,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -8283,8 +8283,8 @@ "required": false, "in": "query", "description": "Sort results by their start time in ascending or descending order.", - "example": "?sortStart=asc OR ?sortStart=desc", "schema": { + "example": "?sortStart=asc OR ?sortStart=desc", "enum": ["asc", "desc"], "type": "string" } @@ -8294,8 +8294,8 @@ "required": false, "in": "query", "description": "Sort results by their end time in ascending or descending order.", - "example": "?sortEnd=asc OR ?sortEnd=desc", "schema": { + "example": "?sortEnd=asc OR ?sortEnd=desc", "enum": ["asc", "desc"], "type": "string" } @@ -8305,8 +8305,8 @@ "required": false, "in": "query", "description": "Filter ooo entries by the user email address. user must be within your organization.", - "example": "example@domain.com", "schema": { + "example": "example@domain.com", "type": "string" } } @@ -8682,11 +8682,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -8695,10 +8695,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -9096,8 +9096,8 @@ "required": false, "in": "query", "description": "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.", - "example": "?status=upcoming,past", "schema": { + "example": "?status=upcoming,past", "type": "array", "items": { "type": "string", @@ -9110,8 +9110,8 @@ "required": false, "in": "query", "description": "Filter bookings by the attendee's email address.", - "example": "example@domain.com", "schema": { + "example": "example@domain.com", "type": "string" } }, @@ -9120,8 +9120,8 @@ "required": false, "in": "query", "description": "Filter bookings by the attendee's name.", - "example": "John Doe", "schema": { + "example": "John Doe", "type": "string" } }, @@ -9130,8 +9130,8 @@ "required": false, "in": "query", "description": "Filter bookings by the booking Uid.", - "example": "2NtaeaVcKfpmSZ4CthFdfk", "schema": { + "example": "2NtaeaVcKfpmSZ4CthFdfk", "type": "string" } }, @@ -9140,8 +9140,8 @@ "required": false, "in": "query", "description": "Filter bookings by event type ids belonging to the user. Event type ids must be separated by a comma.", - "example": "?eventTypeIds=100,200", "schema": { + "example": "?eventTypeIds=100,200", "type": "string" } }, @@ -9150,8 +9150,8 @@ "required": false, "in": "query", "description": "Filter bookings by event type id belonging to the user.", - "example": "?eventTypeId=100", "schema": { + "example": "?eventTypeId=100", "type": "string" } }, @@ -9160,8 +9160,8 @@ "required": false, "in": "query", "description": "Filter bookings by team ids that user is part of. Team ids must be separated by a comma.", - "example": "?teamIds=50,60", "schema": { + "example": "?teamIds=50,60", "type": "string" } }, @@ -9170,8 +9170,8 @@ "required": false, "in": "query", "description": "Filter bookings by team id that user is part of", - "example": "?teamId=50", "schema": { + "example": "?teamId=50", "type": "string" } }, @@ -9180,8 +9180,8 @@ "required": false, "in": "query", "description": "Filter bookings with start after this date string.", - "example": "?afterStart=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterStart=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -9190,8 +9190,8 @@ "required": false, "in": "query", "description": "Filter bookings with end before this date string.", - "example": "?beforeEnd=2025-03-07T11:00:00.000Z", "schema": { + "example": "?beforeEnd=2025-03-07T11:00:00.000Z", "type": "string" } }, @@ -9200,8 +9200,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been created after this date string.", - "example": "?afterCreatedAt=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterCreatedAt=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -9210,8 +9210,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been created before this date string.", - "example": "?beforeCreatedAt=2025-03-14T11:00:00.000Z", "schema": { + "example": "?beforeCreatedAt=2025-03-14T11:00:00.000Z", "type": "string" } }, @@ -9220,8 +9220,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been updated after this date string.", - "example": "?afterUpdatedAt=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterUpdatedAt=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -9230,8 +9230,8 @@ "required": false, "in": "query", "description": "Filter bookings that have been updated before this date string.", - "example": "?beforeUpdatedAt=2025-03-14T11:00:00.000Z", "schema": { + "example": "?beforeUpdatedAt=2025-03-14T11:00:00.000Z", "type": "string" } }, @@ -9240,8 +9240,8 @@ "required": false, "in": "query", "description": "Sort results by their start time in ascending or descending order.", - "example": "?sortStart=asc OR ?sortStart=desc", "schema": { + "example": "?sortStart=asc OR ?sortStart=desc", "enum": ["asc", "desc"], "type": "string" } @@ -9251,8 +9251,8 @@ "required": false, "in": "query", "description": "Sort results by their end time in ascending or descending order.", - "example": "?sortEnd=asc OR ?sortEnd=desc", "schema": { + "example": "?sortEnd=asc OR ?sortEnd=desc", "enum": ["asc", "desc"], "type": "string" } @@ -9262,8 +9262,8 @@ "required": false, "in": "query", "description": "Sort results by their creation time (when booking was made) in ascending or descending order.", - "example": "?sortCreated=asc OR ?sortCreated=desc", "schema": { + "example": "?sortCreated=asc OR ?sortCreated=desc", "enum": ["asc", "desc"], "type": "string" } @@ -9273,8 +9273,8 @@ "required": false, "in": "query", "description": "Sort results by their updated time (for example when booking status changes) in ascending or descending order.", - "example": "?sortUpdated=asc OR ?sortUpdated=desc", "schema": { + "example": "?sortUpdated=asc OR ?sortUpdated=desc", "enum": ["asc", "desc"], "type": "string" } @@ -9284,9 +9284,9 @@ "required": false, "in": "query", "description": "The number of items to return", - "example": 10, "schema": { "default": 100, + "example": 10, "type": "number" } }, @@ -9295,9 +9295,9 @@ "required": false, "in": "query", "description": "The number of items to skip", - "example": 0, "schema": { "default": 0, + "example": 0, "type": "number" } }, @@ -10022,8 +10022,8 @@ "required": false, "in": "query", "description": "Filter booking references by type", - "example": "google_calendar", "schema": { + "example": "google_calendar", "enum": [ "google_calendar", "office365_calendar", @@ -10415,8 +10415,8 @@ "in": "query", "deprecated": true, "description": "Deprecated: Use timeZone instead. The timezone of the user represented as a string", - "example": "America/New_York", "schema": { + "example": "America/New_York", "type": "string" } }, @@ -10425,8 +10425,8 @@ "required": false, "in": "query", "description": "The timezone for the busy times query represented as a string", - "example": "America/New_York", "schema": { + "example": "America/New_York", "type": "string" } }, @@ -10435,8 +10435,8 @@ "required": false, "in": "query", "description": "The starting date for the busy times query", - "example": "2023-10-01", "schema": { + "example": "2023-10-01", "type": "string" } }, @@ -10445,8 +10445,8 @@ "required": false, "in": "query", "description": "The ending date for the busy times query", - "example": "2023-10-31", "schema": { + "example": "2023-10-31", "type": "string" } }, @@ -11050,8 +11050,8 @@ "required": false, "in": "query", "description": "The number of items to return", - "example": 10, "schema": { + "example": 10, "type": "number" } }, @@ -11060,8 +11060,8 @@ "required": false, "in": "query", "description": "The number of items to skip", - "example": 0, "schema": { + "example": 0, "type": "number" } }, @@ -11070,8 +11070,8 @@ "required": false, "in": "query", "description": "Filter managed users by email. If you want to filter by multiple emails, separate them with a comma.", - "example": "?emails=email1@example.com,email2@example.com", "schema": { + "example": "?emails=email1@example.com,email2@example.com", "type": "array", "items": { "type": "string" @@ -11459,11 +11459,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -11472,10 +11472,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -12261,11 +12261,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -12274,10 +12274,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -12714,11 +12714,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -12727,10 +12727,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -12738,9 +12738,9 @@ "name": "slug", "required": false, "in": "query", - "example": "organization-slug", "description": "The slug of the managed organization", "schema": { + "example": "organization-slug", "type": "string" } }, @@ -12748,9 +12748,9 @@ "name": "metadataKey", "required": false, "in": "query", - "example": "metadata-key", "description": "The key of the metadata - it is case sensitive so provide exactly as stored. If you provide it then you must also provide metadataValue", "schema": { + "example": "metadata-key", "type": "string" } }, @@ -12758,9 +12758,9 @@ "name": "metadataValue", "required": false, "in": "query", - "example": "metadata-value", "description": "The value of the metadata - it is case sensitive so provide exactly as stored. If you provide it then you must also provide metadataKey", "schema": { + "example": "metadata-value", "type": "string" } } @@ -13007,6 +13007,81 @@ "tags": ["Me"] } }, + "/v2/auth/oauth2/clients/{clientId}": { + "get": { + "operationId": "OAuth2Controller_getClient", + "summary": "Get OAuth2 client", + "description": "Returns the OAuth2 client information for the given client ID", + "parameters": [ + { + "name": "clientId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuth2ClientResponseDto" + } + } + } + } + }, + "tags": ["OAuth2"] + } + }, + "/v2/auth/oauth2/token": { + "post": { + "operationId": "OAuth2Controller_token", + "summary": "Exchange authorization code or refresh token for tokens", + "description": "RFC 6749-compliant token endpoint. Pass client_id in the request body (Section 2.3.1). Use grant_type 'authorization_code' to exchange an auth code for tokens, or 'refresh_token' to refresh an access token. Accepts both application/x-www-form-urlencoded (standard per RFC 6749 Section 4.1.3) and application/json content types.", + "parameters": [], + "requestBody": { + "required": true, + "description": "Token request body. client_id is required. Accepts application/x-www-form-urlencoded (RFC 6749 standard) or application/json. Use grant_type 'authorization_code' with client_secret (confidential) or code_verifier (public/PKCE), or grant_type 'refresh_token' with client_secret (confidential) or just the refresh_token (public).", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/OAuth2ExchangeConfidentialInput" + }, + { + "$ref": "#/components/schemas/OAuth2ExchangePublicInput" + }, + { + "$ref": "#/components/schemas/OAuth2RefreshConfidentialInput" + }, + { + "$ref": "#/components/schemas/OAuth2RefreshPublicInput" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuth2TokensDto" + } + } + } + } + }, + "tags": ["OAuth2"] + } + }, "/v2/organizations/{orgId}/teams/{teamId}/verified-resources/emails/verification-code/request": { "post": { "operationId": "OrgTeamsVerifiedResourcesController_requestEmailVerificationCode", @@ -13205,11 +13280,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -13218,10 +13293,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -13268,11 +13343,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -13281,10 +13356,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -13425,8 +13500,8 @@ "required": true, "in": "query", "description": "\n Time starting from which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to start of day or specify hours:\n 2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z\n ", - "example": "2050-09-05", "schema": { + "example": "2050-09-05", "type": "string" } }, @@ -13435,8 +13510,8 @@ "required": true, "in": "query", "description": "\n Time until which available slots should be checked.\n \n Must be in UTC timezone as ISO 8601 datestring.\n \n You can pass date without hours which defaults to end of day or specify hours:\n 2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z", - "example": "2050-09-06", "schema": { + "example": "2050-09-06", "type": "string" } }, @@ -13445,8 +13520,8 @@ "required": false, "in": "query", "description": "Time zone in which the available slots should be returned. Defaults to UTC.", - "example": "Europe/Rome", "schema": { + "example": "Europe/Rome", "type": "string" } }, @@ -13455,8 +13530,8 @@ "required": false, "in": "query", "description": "If event type has multiple possible durations then you can specify the desired duration here. Also, if you are fetching slots for a dynamic event then you can specify the duration her which defaults to 30, meaning that returned slots will be each 30 minutes long.", - "example": "60", "schema": { + "example": "60", "type": "number" } }, @@ -13465,8 +13540,8 @@ "required": false, "in": "query", "description": "Format of slot times in response. Use 'range' to get start and end times.", - "example": "range", "schema": { + "example": "range", "enum": ["range", "time"], "type": "string" } @@ -13476,8 +13551,8 @@ "required": false, "in": "query", "description": "The unique identifier of the booking being rescheduled. When provided will ensure that the original booking time appears within the returned available slots when rescheduling.", - "example": "abc123def456", "schema": { + "example": "abc123def456", "type": "string" } }, @@ -13910,8 +13985,8 @@ "required": false, "in": "query", "description": "The unique identifier of the booking being rescheduled. When provided will ensure that the original booking time appears within the returned available slots when rescheduling.", - "example": "abc123def456", "schema": { + "example": "abc123def456", "type": "string" } }, @@ -13920,8 +13995,8 @@ "required": true, "in": "query", "description": "\n Time starting from which available slots should be checked.\n\n Must be in UTC timezone as ISO 8601 datestring.\n\n You can pass date without hours which defaults to start of day or specify hours:\n 2024-08-13 (will have hours 00:00:00 aka at very beginning of the date) or you can specify hours manually like 2024-08-13T09:00:00Z.", - "example": "2050-09-05", "schema": { + "example": "2050-09-05", "type": "string" } }, @@ -13930,8 +14005,8 @@ "required": true, "in": "query", "description": "\n Time until which available slots should be checked.\n\n Must be in UTC timezone as ISO 8601 datestring.\n\n You can pass date without hours which defaults to end of day or specify hours:\n 2024-08-20 (will have hours 23:59:59 aka at the very end of the date) or you can specify hours manually like 2024-08-20T18:00:00Z.", - "example": "2050-09-06", "schema": { + "example": "2050-09-06", "type": "string" } }, @@ -13940,8 +14015,8 @@ "required": false, "in": "query", "description": "The slug of the organization to which user with username belongs or team with teamSlug belongs.", - "example": "org-slug", "schema": { + "example": "org-slug", "type": "string" } }, @@ -13950,8 +14025,8 @@ "required": false, "in": "query", "description": "The slug of the team who owns event type with eventTypeSlug - used when slots are checked for team event type.", - "example": "team-slug", "schema": { + "example": "team-slug", "type": "string" } }, @@ -13960,8 +14035,8 @@ "required": false, "in": "query", "description": "The username of the user who owns event type with eventTypeSlug - used when slots are checked for individual user event type.", - "example": "bob", "schema": { + "example": "bob", "type": "string" } }, @@ -13970,8 +14045,8 @@ "required": false, "in": "query", "description": "The slug of the event type for which available slots should be checked. If slug is provided then username or teamSlug must be provided too and if relevant organizationSlug too.", - "example": "event-type-slug", "schema": { + "example": "event-type-slug", "type": "string" } }, @@ -13980,8 +14055,8 @@ "required": false, "in": "query", "description": "The ID of the event type for which available slots should be checked.", - "example": "100", "schema": { + "example": "100", "type": "number" } }, @@ -13990,8 +14065,8 @@ "required": false, "in": "query", "description": "The usernames for which available slots should be checked separated by a comma.\n\n Checking slots by usernames is used mainly for dynamic events where there is no specific event but we just want to know when 2 or more people are available.\n\n Must contain at least 2 usernames.", - "example": "alice,bob", "schema": { + "example": "alice,bob", "type": "string" } }, @@ -14000,8 +14075,8 @@ "required": false, "in": "query", "description": "Format of slot times in response. Use 'range' to get start and end times. Use 'time' or omit this query parameter to get only start time.", - "example": "range", "schema": { + "example": "range", "type": "string" } }, @@ -14010,8 +14085,8 @@ "required": false, "in": "query", "description": "If event type has multiple possible durations then you can specify the desired duration here. Also, if you are fetching slots for a dynamic event then you can specify the duration her which defaults to 30, meaning that returned slots will be each 30 minutes long.", - "example": "60", "schema": { + "example": "60", "type": "number" } }, @@ -14020,8 +14095,8 @@ "required": false, "in": "query", "description": "Time zone in which the available slots should be returned. Defaults to UTC.", - "example": "Europe/Rome", "schema": { + "example": "Europe/Rome", "type": "string" } } @@ -14609,8 +14684,8 @@ "required": false, "in": "query", "description": "Filter bookings by status. If you want to filter by multiple statuses, separate them with a comma.", - "example": "?status=upcoming,past", "schema": { + "example": "?status=upcoming,past", "type": "array", "items": { "type": "string", @@ -14623,8 +14698,8 @@ "required": false, "in": "query", "description": "Filter bookings by the attendee's email address.", - "example": "example@domain.com", "schema": { + "example": "example@domain.com", "type": "string" } }, @@ -14633,8 +14708,8 @@ "required": false, "in": "query", "description": "Filter bookings by the attendee's name.", - "example": "John Doe", "schema": { + "example": "John Doe", "type": "string" } }, @@ -14643,8 +14718,8 @@ "required": false, "in": "query", "description": "Filter bookings by the booking Uid.", - "example": "2NtaeaVcKfpmSZ4CthFdfk", "schema": { + "example": "2NtaeaVcKfpmSZ4CthFdfk", "type": "string" } }, @@ -14653,8 +14728,8 @@ "required": false, "in": "query", "description": "Filter bookings by event type ids belonging to the team. Event type ids must be separated by a comma.", - "example": "?eventTypeIds=100,200", "schema": { + "example": "?eventTypeIds=100,200", "type": "string" } }, @@ -14663,8 +14738,8 @@ "required": false, "in": "query", "description": "Filter bookings by event type id belonging to the team.", - "example": "?eventTypeId=100", "schema": { + "example": "?eventTypeId=100", "type": "string" } }, @@ -14673,8 +14748,8 @@ "required": false, "in": "query", "description": "Filter bookings with start after this date string.", - "example": "?afterStart=2025-03-07T10:00:00.000Z", "schema": { + "example": "?afterStart=2025-03-07T10:00:00.000Z", "type": "string" } }, @@ -14683,8 +14758,8 @@ "required": false, "in": "query", "description": "Filter bookings with end before this date string.", - "example": "?beforeEnd=2025-03-07T11:00:00.000Z", "schema": { + "example": "?beforeEnd=2025-03-07T11:00:00.000Z", "type": "string" } }, @@ -14693,8 +14768,8 @@ "required": false, "in": "query", "description": "Sort results by their start time in ascending or descending order.", - "example": "?sortStart=asc OR ?sortStart=desc", "schema": { + "example": "?sortStart=asc OR ?sortStart=desc", "enum": ["asc", "desc"], "type": "string" } @@ -14704,8 +14779,8 @@ "required": false, "in": "query", "description": "Sort results by their end time in ascending or descending order.", - "example": "?sortEnd=asc OR ?sortEnd=desc", "schema": { + "example": "?sortEnd=asc OR ?sortEnd=desc", "enum": ["asc", "desc"], "type": "string" } @@ -14715,8 +14790,8 @@ "required": false, "in": "query", "description": "Sort results by their creation time (when booking was made) in ascending or descending order.", - "example": "?sortCreated=asc OR ?sortCreated=desc", "schema": { + "example": "?sortCreated=asc OR ?sortCreated=desc", "enum": ["asc", "desc"], "type": "string" } @@ -14726,10 +14801,10 @@ "required": false, "in": "query", "description": "The number of items to return", - "example": 10, "schema": { "minimum": 1, "maximum": 250, + "example": 10, "type": "number" } }, @@ -14738,9 +14813,9 @@ "required": false, "in": "query", "description": "The number of items to skip", - "example": 0, "schema": { "minimum": 0, + "example": 0, "type": "number" } }, @@ -15145,11 +15220,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -15158,10 +15233,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -15433,11 +15508,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -15446,10 +15521,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -15458,8 +15533,8 @@ "required": false, "in": "query", "description": "Filter team memberships by email addresses. If you want to filter by multiple emails, separate them with a comma (max 20 emails for performance).", - "example": "?emails=user1@example.com,user2@example.com", "schema": { + "example": "?emails=user1@example.com,user2@example.com", "type": "array", "items": { "type": "string" @@ -15653,11 +15728,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -15666,10 +15741,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -15887,11 +15962,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -15900,10 +15975,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -15950,11 +16025,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -15963,10 +16038,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -16261,11 +16336,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -16274,10 +16349,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -16316,11 +16391,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -16329,10 +16404,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } }, @@ -16495,11 +16570,11 @@ "required": false, "in": "query", "description": "Maximum number of items to return", - "example": 25, "schema": { "minimum": 1, "maximum": 250, "default": 250, + "example": 25, "type": "number" } }, @@ -16508,10 +16583,10 @@ "required": false, "in": "query", "description": "Number of items to skip", - "example": 0, "schema": { "minimum": 0, "default": 0, + "example": 0, "type": "number" } } @@ -16654,6 +16729,193 @@ "servers": [], "components": { "schemas": { + "OAuth2ClientDto": { + "type": "object", + "properties": { + "client_id": { + "type": "string", + "description": "The OAuth client ID", + "example": "clxxxxxxxxxxxxxxxx" + }, + "redirect_uri": { + "type": "string", + "description": "The redirect URI for the OAuth client", + "example": "https://example.com/callback" + }, + "name": { + "type": "string", + "description": "The name of the OAuth client", + "example": "My App" + }, + "logo": { + "type": "string", + "nullable": true, + "description": "The logo URL of the OAuth client" + }, + "is_trusted": { + "type": "boolean", + "description": "Whether the OAuth client is trusted", + "example": false + }, + "client_type": { + "type": "string", + "description": "The type of OAuth client (CONFIDENTIAL or PUBLIC)", + "example": "CONFIDENTIAL", + "enum": ["CONFIDENTIAL", "PUBLIC"] + } + }, + "required": ["client_id", "redirect_uri", "name", "is_trusted", "client_type"] + }, + "OAuth2ClientResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": ["success", "error"] + }, + "data": { + "$ref": "#/components/schemas/OAuth2ClientDto" + } + }, + "required": ["status", "data"] + }, + "OAuth2ExchangeConfidentialInput": { + "type": "object", + "properties": { + "client_id": { + "type": "string", + "description": "The client identifier", + "example": "my-client-id" + }, + "grant_type": { + "type": "string", + "description": "The grant type — must be 'authorization_code'", + "example": "authorization_code", + "enum": ["authorization_code"] + }, + "code": { + "type": "string", + "description": "The authorization code received from the authorize endpoint", + "example": "abc123" + }, + "redirect_uri": { + "type": "string", + "description": "The redirect URI used in the authorization request", + "example": "https://example.com/callback" + }, + "client_secret": { + "type": "string", + "description": "The client secret for confidential clients" + } + }, + "required": ["client_id", "grant_type", "code", "redirect_uri", "client_secret"] + }, + "OAuth2ExchangePublicInput": { + "type": "object", + "properties": { + "client_id": { + "type": "string", + "description": "The client identifier", + "example": "my-client-id" + }, + "grant_type": { + "type": "string", + "description": "The grant type — must be 'authorization_code'", + "example": "authorization_code", + "enum": ["authorization_code"] + }, + "code": { + "type": "string", + "description": "The authorization code received from the authorize endpoint", + "example": "abc123" + }, + "redirect_uri": { + "type": "string", + "description": "The redirect URI used in the authorization request", + "example": "https://example.com/callback" + }, + "code_verifier": { + "type": "string", + "description": "PKCE code verifier (required for public clients that used code_challenge)" + } + }, + "required": ["client_id", "grant_type", "code", "redirect_uri", "code_verifier"] + }, + "OAuth2RefreshConfidentialInput": { + "type": "object", + "properties": { + "client_id": { + "type": "string", + "description": "The client identifier", + "example": "my-client-id" + }, + "grant_type": { + "type": "string", + "description": "The grant type — must be 'refresh_token'", + "example": "refresh_token", + "enum": ["refresh_token"] + }, + "refresh_token": { + "type": "string", + "description": "The refresh token", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + }, + "client_secret": { + "type": "string", + "description": "The client secret for confidential clients" + } + }, + "required": ["client_id", "grant_type", "refresh_token", "client_secret"] + }, + "OAuth2RefreshPublicInput": { + "type": "object", + "properties": { + "client_id": { + "type": "string", + "description": "The client identifier", + "example": "my-client-id" + }, + "grant_type": { + "type": "string", + "description": "The grant type — must be 'refresh_token'", + "example": "refresh_token", + "enum": ["refresh_token"] + }, + "refresh_token": { + "type": "string", + "description": "The refresh token", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } + }, + "required": ["client_id", "grant_type", "refresh_token"] + }, + "OAuth2TokensDto": { + "type": "object", + "properties": { + "access_token": { + "type": "string", + "description": "The access token", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + }, + "token_type": { + "type": "string", + "description": "The token type", + "example": "bearer" + }, + "refresh_token": { + "type": "string", + "description": "The refresh token", + "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + }, + "expires_in": { + "type": "number", + "description": "The number of seconds until the access token expires", + "example": 1800 + } + }, + "required": ["access_token", "token_type", "refresh_token", "expires_in"] + }, "ManagedUserOutput": { "type": "object", "properties": { diff --git a/packages/features/oauth/services/OAuthService.ts b/packages/features/oauth/services/OAuthService.ts index 613b5473f8..f0d408ad89 100644 --- a/packages/features/oauth/services/OAuthService.ts +++ b/packages/features/oauth/services/OAuthService.ts @@ -221,7 +221,7 @@ export class OAuthService { if (validOAuthErrors.includes(error.message)) { return { error: error.message, - errorDescription: (error.data?.cause as string | undefined) ?? error.message, + errorDescription: (error.data?.reason as string | undefined) ?? error.message, }; } @@ -229,17 +229,17 @@ export class OAuthService { case ErrorCode.BadRequest: return { error: "invalid_request", - errorDescription: (error.data?.cause as string | undefined) ?? error.message, + errorDescription: (error.data?.reason as string | undefined) ?? error.message, }; case ErrorCode.Unauthorized: return { error: "unauthorized_client", - errorDescription: (error.data?.cause as string | undefined) ?? error.message, + errorDescription: (error.data?.reason as string | undefined) ?? error.message, }; default: return { error: "server_error", - errorDescription: (error.data?.cause as string | undefined) ?? error.message, + errorDescription: (error.data?.reason as string | undefined) ?? error.message, }; } } diff --git a/packages/platform/libraries/index.ts b/packages/platform/libraries/index.ts index 58578f5eda..553ba99aea 100644 --- a/packages/platform/libraries/index.ts +++ b/packages/platform/libraries/index.ts @@ -49,16 +49,13 @@ export type { BookingCreateBody, BookingResponse, } from "@calcom/features/bookings/types"; - -export { getBusyCalendarTimes } from "@calcom/features/calendars/lib/CalendarManager"; export type { ConnectedCalendar } from "@calcom/features/calendars/lib/CalendarManager"; -export { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars"; +export { getBusyCalendarTimes } from "@calcom/features/calendars/lib/CalendarManager"; export type { ConnectedDestinationCalendars } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars"; +export { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars"; export type { CityTimezones } from "@calcom/features/cityTimezones/cityTimezonesHandler"; - export { cityTimezonesHandler } from "@calcom/features/cityTimezones/cityTimezonesHandler"; export { ENABLE_ASYNC_TASKER, MINUTES_TO_BOOK } from "@calcom/lib/constants"; - export { TRPCError } from "@trpc/server"; export { createNewUsersConnectToOrgIfExists }; @@ -67,9 +64,7 @@ export { getBookingInfo }; export { handleCancelBooking }; export { dynamicEvent } from "@calcom/features/eventtypes/lib/defaultEvents"; - export { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits"; - export { parseRecurringEvent } from "@calcom/lib/isRecurringEvent"; export { bookingMetadataSchema, teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils"; @@ -79,7 +74,6 @@ export { getTranslation }; export { roundRobinManualReassignment } from "@calcom/features/ee/round-robin/roundRobinManualReassignment"; export { roundRobinReassignment } from "@calcom/features/ee/round-robin/roundRobinReassignment"; - export { validateCustomEventName } from "@calcom/features/eventtypes/lib/eventNaming"; export type TeamQuery = Prisma.TeamGetPayload<{ @@ -125,8 +119,8 @@ export { verifyCodeUnAuthenticated } from "@calcom/features/auth/lib/verifyCodeU export { sendEmailVerificationByCode } from "@calcom/features/auth/lib/verifyEmail"; export { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService"; export { StripeBillingService } from "@calcom/features/ee/billing/service/billingProvider/StripeBillingService"; - export { TeamService } from "@calcom/features/ee/teams/services/teamService"; +export type { OAuth2Tokens } from "@calcom/features/oauth/services/OAuthService"; export { OAuthService } from "@calcom/features/oauth/services/OAuthService"; export { generateSecret } from "@calcom/features/oauth/utils/generateSecret"; export { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";