diff --git a/apps/api/v2/src/app.module.ts b/apps/api/v2/src/app.module.ts index 5042292833171f..5985e5f3e6a201 100644 --- a/apps/api/v2/src/app.module.ts +++ b/apps/api/v2/src/app.module.ts @@ -17,11 +17,13 @@ import { UrlencodedBodyMiddleware } from "@/middleware/body/urlencoded.body.midd 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"; +import { ThirdPartyPermissionsGuard } from "@/modules/auth/guards/third-party-permissions/third-party-permissions.guard"; import { EndpointsModule } from "@/modules/endpoints.module"; import { JwtModule } from "@/modules/jwt/jwt.module"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { RedisModule } from "@/modules/redis/redis.module"; import { RedisService } from "@/modules/redis/redis.service"; +import { TokensModule } from "@/modules/tokens/tokens.module"; import { VercelWebhookController } from "@/vercel-webhook.controller"; @Module({ @@ -59,6 +61,7 @@ import { VercelWebhookController } from "@/vercel-webhook.controller"; EndpointsModule, AuthModule, JwtModule, + TokensModule, ], controllers: [AppController, VercelWebhookController], providers: [ @@ -81,6 +84,10 @@ import { VercelWebhookController } from "@/vercel-webhook.controller"; provide: APP_GUARD, useClass: CustomThrottlerGuard, }, + { + provide: APP_GUARD, + useClass: ThirdPartyPermissionsGuard, + }, ], }) export class AppModule implements NestModule { diff --git a/apps/api/v2/src/lib/docs/headers.ts b/apps/api/v2/src/lib/docs/headers.ts index 97983f9a068957..c0c6d2e097d1db 100644 --- a/apps/api/v2/src/lib/docs/headers.ts +++ b/apps/api/v2/src/lib/docs/headers.ts @@ -1,6 +1,5 @@ -import { ApiHeaderOptions } from "@nestjs/swagger"; - import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; +import { ApiHeaderOptions } from "@nestjs/swagger"; export const OPTIONAL_X_CAL_CLIENT_ID_HEADER: ApiHeaderOptions = { name: X_CAL_CLIENT_ID, @@ -42,19 +41,20 @@ export const API_KEY_HEADER: ApiHeaderOptions = { export const API_KEY_OR_ACCESS_TOKEN_HEADER: ApiHeaderOptions = { name: "Authorization", description: - "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", required: true, }; export const OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER: ApiHeaderOptions = { name: "Authorization", description: - "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", required: false, }; export const ACCESS_TOKEN_HEADER: ApiHeaderOptions = { name: "Authorization", - description: "value must be `Bearer ` where `` is managed user access token", + description: + "value must be `Bearer ` where `` is managed user access token or OAuth access token", required: true, }; diff --git a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts index e2baca38459cba..f130244c3b7494 100644 --- a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts +++ b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.spec.ts @@ -1,15 +1,13 @@ -import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; -import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service"; -import { TokensRepository } from "@/modules/tokens/tokens.repository"; -import { TokensService } from "@/modules/tokens/tokens.service"; +import { APPS_WRITE, SCHEDULE_READ, SCHEDULE_WRITE } from "@calcom/platform-constants"; import { createMock } from "@golevelup/ts-jest"; import { ExecutionContext } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Reflector } from "@nestjs/core"; - -import { APPS_WRITE, SCHEDULE_READ, SCHEDULE_WRITE } from "@calcom/platform-constants"; - import { PermissionsGuard } from "./permissions.guard"; +import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; +import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { TokensService } from "@/modules/tokens/tokens.service"; describe("PermissionsGuard", () => { let guard: PermissionsGuard; @@ -76,6 +74,7 @@ describe("PermissionsGuard", () => { it("should return true for valid permissions", async () => { const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]); + jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue(null); let oAuthClientPermissions = 0; oAuthClientPermissions |= SCHEDULE_WRITE; @@ -88,6 +87,7 @@ describe("PermissionsGuard", () => { it("should return true for multiple valid permissions", async () => { const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]); + jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue(null); let oAuthClientPermissions = 0; oAuthClientPermissions |= SCHEDULE_WRITE; @@ -143,15 +143,40 @@ describe("PermissionsGuard", () => { ); }); - it("should return true for 3rd party access token", async () => { - const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); - jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue({ - scope: ["scope"], - token_type: "Bearer", + describe("delegates 3rd party access token validation to ThirdPartyPermissionsGuard", () => { + it("should return true for 3rd party access token with legacy scopes for backward compatibility", async () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]); + jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue({ + scope: ["READ_BOOKING"], + token_type: "Bearer", + }); + + await expect(guard.canActivate(mockContext)).resolves.toBe(true); }); - - await expect(guard.canActivate(mockContext)).resolves.toBe(true); - }); + + it("should return true for 3rd party access token with empty scopes for backward compatibility", async () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]); + jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue({ + scope: [], + token_type: "Bearer", + }); + + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + + it("should return true for 3rd party access token and delegate to ThirdPartyPermissionsGuard", async () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]); + jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue({ + scope: ["BOOKING_READ"], + token_type: "Bearer", + }); + + await expect(guard.canActivate(mockContext)).resolves.toBe(true); + }); + }) }); describe("when OAuth id is provided", () => { diff --git a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts index ac14f182d178e0..4eaca80d4d824e 100644 --- a/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts +++ b/apps/api/v2/src/modules/auth/guards/permissions/permissions.guard.ts @@ -1,17 +1,16 @@ +import { X_CAL_CLIENT_ID } from "@calcom/platform-constants"; +import { hasPermissions } from "@calcom/platform-utils"; +import type { PlatformOAuthClient } from "@calcom/prisma/client"; +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Reflector } from "@nestjs/core"; +import { getToken } from "next-auth/jwt"; import { isApiKey } from "@/lib/api-key"; import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { TokensService } from "@/modules/tokens/tokens.service"; -import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { Reflector } from "@nestjs/core"; -import { getToken } from "next-auth/jwt"; - -import { X_CAL_CLIENT_ID } from "@calcom/platform-constants"; -import { hasPermissions } from "@calcom/platform-utils"; -import type { PlatformOAuthClient } from "@calcom/prisma/client"; @Injectable() export class PermissionsGuard implements CanActivate { @@ -37,10 +36,9 @@ export class PermissionsGuard implements CanActivate { const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret }); const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID); const apiKey = bearerToken && isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_"); - const isThirdPartyBearerToken = bearerToken && this.getDecodedThirdPartyAccessToken(bearerToken); + const decodedThirdPartyToken = bearerToken ? this.getDecodedThirdPartyAccessToken(bearerToken) : null; - // only check permissions for accessTokens attached to platform oAuth Client or platform oAuth credentials, not for next token or api key or third party oauth client - if (nextAuthToken || apiKey || isThirdPartyBearerToken) { + if (nextAuthToken || apiKey || decodedThirdPartyToken) { return true; } diff --git a/apps/api/v2/src/modules/auth/guards/third-party-permissions/third-party-permissions.guard.spec.ts b/apps/api/v2/src/modules/auth/guards/third-party-permissions/third-party-permissions.guard.spec.ts new file mode 100644 index 00000000000000..5b21c69caf9f44 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/third-party-permissions/third-party-permissions.guard.spec.ts @@ -0,0 +1,216 @@ +import { + APPS_READ, + APPS_WRITE, + BOOKING_READ, + BOOKING_WRITE, + EVENT_TYPE_READ, + PROFILE_WRITE, + SCHEDULE_WRITE, +} from "@calcom/platform-constants"; +import { createMock } from "@golevelup/ts-jest"; +import { ExecutionContext } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { ThirdPartyPermissionsGuard } from "./third-party-permissions.guard"; +import { TokensService } from "@/modules/tokens/tokens.service"; + +describe("ThirdPartyPermissionsGuard", () => { + let guard: ThirdPartyPermissionsGuard; + let reflector: Reflector; + let tokensService: TokensService; + + beforeEach(() => { + reflector = new Reflector(); + tokensService = createMock(); + guard = new ThirdPartyPermissionsGuard(reflector, tokensService); + }); + + it("should be defined", () => { + expect(guard).toBeDefined(); + }); + + function createMockExecutionContext(headers: Record): ExecutionContext { + return createMock({ + switchToHttp: () => ({ + getRequest: () => ({ + headers, + get: (headerName: string) => headers[headerName], + }), + }), + }); + } + + describe("when handler has no @Permissions decorator", () => { + it("should deny third-party token with new scopes when no @Permissions on handler", () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(tokensService, "getDecodedThirdPartyAccessToken").mockReturnValue({ + scope: ["BOOKING_READ"], + token_type: "Access Token", + }); + jest.spyOn(reflector, "get").mockReturnValue(undefined); + + expect(() => guard.canActivate(mockContext)).toThrow( + "insufficient_scope: this endpoint is not available for third-party OAuth tokens" + ); + }); + + it("should allow legacy token (empty scopes) when no @Permissions on handler", () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(tokensService, "getDecodedThirdPartyAccessToken").mockReturnValue({ + scope: [], + token_type: "Access Token", + }); + jest.spyOn(reflector, "get").mockReturnValue(undefined); + + expect(guard.canActivate(mockContext)).toBe(true); + }); + + it("should allow legacy token (old scope names only) when no @Permissions on handler", () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(tokensService, "getDecodedThirdPartyAccessToken").mockReturnValue({ + scope: ["READ_BOOKING", "READ_PROFILE"], + token_type: "Access Token", + }); + jest.spyOn(reflector, "get").mockReturnValue(undefined); + + expect(guard.canActivate(mockContext)).toBe(true); + }); + + it("should allow non-third-party requests when no @Permissions on handler", () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(tokensService, "getDecodedThirdPartyAccessToken").mockReturnValue(null); + + expect(guard.canActivate(mockContext)).toBe(true); + }); + + it("should allow requests with no bearer token", () => { + const mockContext = createMockExecutionContext({}); + + expect(guard.canActivate(mockContext)).toBe(true); + }); + }); + + describe("scope enforcement via @Permissions", () => { + it("should allow third-party token with matching scope", () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(tokensService, "getDecodedThirdPartyAccessToken").mockReturnValue({ + scope: ["BOOKING_READ", "BOOKING_WRITE"], + token_type: "Access Token", + }); + jest.spyOn(reflector, "get").mockReturnValue([BOOKING_READ]); + + expect(guard.canActivate(mockContext)).toBe(true); + }); + + it("should deny third-party token with insufficient scope", () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(tokensService, "getDecodedThirdPartyAccessToken").mockReturnValue({ + scope: ["BOOKING_READ"], + token_type: "Access Token", + }); + jest.spyOn(reflector, "get").mockReturnValue([BOOKING_WRITE]); + + expect(() => guard.canActivate(mockContext)).toThrow("insufficient_scope"); + }); + + it("should allow when @Permissions is empty array", () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(tokensService, "getDecodedThirdPartyAccessToken").mockReturnValue({ + scope: ["BOOKING_READ"], + token_type: "Access Token", + }); + jest.spyOn(reflector, "get").mockReturnValue([]); + + expect(guard.canActivate(mockContext)).toBe(true); + }); + + it("should allow when token has all required scopes from multiple permissions", () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(tokensService, "getDecodedThirdPartyAccessToken").mockReturnValue({ + scope: ["EVENT_TYPE_READ", "SCHEDULE_WRITE"], + token_type: "Access Token", + }); + jest.spyOn(reflector, "get").mockReturnValue([EVENT_TYPE_READ, SCHEDULE_WRITE]); + + expect(guard.canActivate(mockContext)).toBe(true); + }); + + it("should deny when token is missing one of multiple required scopes", () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(tokensService, "getDecodedThirdPartyAccessToken").mockReturnValue({ + scope: ["EVENT_TYPE_READ"], + token_type: "Access Token", + }); + jest.spyOn(reflector, "get").mockReturnValue([EVENT_TYPE_READ, SCHEDULE_WRITE]); + + expect(() => guard.canActivate(mockContext)).toThrow("insufficient_scope"); + }); + + it("should allow third-party token with APPS_READ scope", () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(tokensService, "getDecodedThirdPartyAccessToken").mockReturnValue({ + scope: ["APPS_READ"], + token_type: "Access Token", + }); + jest.spyOn(reflector, "get").mockReturnValue([APPS_READ]); + + expect(guard.canActivate(mockContext)).toBe(true); + }); + + it("should allow third-party token with APPS_WRITE scope", () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(tokensService, "getDecodedThirdPartyAccessToken").mockReturnValue({ + scope: ["APPS_WRITE"], + token_type: "Access Token", + }); + jest.spyOn(reflector, "get").mockReturnValue([APPS_WRITE]); + + expect(guard.canActivate(mockContext)).toBe(true); + }); + + it("should allow third-party token with PROFILE_WRITE scope", () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(tokensService, "getDecodedThirdPartyAccessToken").mockReturnValue({ + scope: ["PROFILE_WRITE"], + token_type: "Access Token", + }); + jest.spyOn(reflector, "get").mockReturnValue([PROFILE_WRITE]); + + expect(guard.canActivate(mockContext)).toBe(true); + }); + }); + + describe("Legacy OAuth client backward compatibility", () => { + it("should allow token with empty scopes even when handler requires BOOKING_READ", () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(tokensService, "getDecodedThirdPartyAccessToken").mockReturnValue({ + scope: [], + token_type: "Access Token", + }); + jest.spyOn(reflector, "get").mockReturnValue([BOOKING_READ]); + + expect(guard.canActivate(mockContext)).toBe(true); + }); + + it("should allow token with legacy scope names even when handler requires BOOKING_READ", () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(tokensService, "getDecodedThirdPartyAccessToken").mockReturnValue({ + scope: ["READ_BOOKING", "READ_PROFILE"], + token_type: "Access Token", + }); + jest.spyOn(reflector, "get").mockReturnValue([BOOKING_READ]); + + expect(guard.canActivate(mockContext)).toBe(true); + }); + + it("should allow token with no scope field at all (pre-scope tokens)", () => { + const mockContext = createMockExecutionContext({ Authorization: "Bearer token" }); + jest.spyOn(tokensService, "getDecodedThirdPartyAccessToken").mockReturnValue({ + scope: undefined, + token_type: "Access Token", + }); + jest.spyOn(reflector, "get").mockReturnValue([BOOKING_READ]); + + expect(guard.canActivate(mockContext)).toBe(true); + }); + }); +}); diff --git a/apps/api/v2/src/modules/auth/guards/third-party-permissions/third-party-permissions.guard.ts b/apps/api/v2/src/modules/auth/guards/third-party-permissions/third-party-permissions.guard.ts new file mode 100644 index 00000000000000..41a3e12b9f05f6 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/third-party-permissions/third-party-permissions.guard.ts @@ -0,0 +1,86 @@ +import { + type NewAccessScope, + PERMISSION_TO_SCOPE, + SCOPE_TO_PERMISSION, +} from "@calcom/platform-libraries"; +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; +import { TokensService } from "@/modules/tokens/tokens.service"; + +@Injectable() +export class ThirdPartyPermissionsGuard implements CanActivate { + constructor( + private reflector: Reflector, + private tokensService: TokensService + ) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const bearerToken = request.get("Authorization")?.replace("Bearer ", ""); + + if (!bearerToken) { + return true; + } + + const decodedToken = this.tokensService.getDecodedThirdPartyAccessToken(bearerToken); + + if (!decodedToken) { + return true; + } + + const tokenScopes: string[] = decodedToken.scope ?? []; + + // note(Lauris): legacy access tokens could have no scopes defined so allow access for backward compatibility. + if (tokenScopes.length === 0) { + return true; + } + + const tokenPermissions = this.resolveTokenPermissions(tokenScopes); + + // note(Lauris): legacy access tokens could have legacy scopes defined that were never enforce + // so allow acceess for backward compatibility. + if (tokenPermissions.size === 0) { + return true; + } + + // note(Lauris): read the @Permissions decorator from the handler to determine which scopes are needed. + // Endpoints with @Permissions are accessible to third-party tokens; those without are denied. + const requiredPermissions = this.reflector.get(Permissions, context.getHandler()); + + if (!requiredPermissions) { + throw new ForbiddenException( + "insufficient_scope: this endpoint is not available for third-party OAuth tokens" + ); + } + + if (requiredPermissions.length === 0) { + return true; + } + + const missingPermissions = requiredPermissions.filter( + (permission: number) => !tokenPermissions.has(permission) + ); + + if (missingPermissions.length > 0) { + const missingScopeNames = missingPermissions + .map((permission: number) => PERMISSION_TO_SCOPE[permission]) + .filter(Boolean); + throw new ForbiddenException( + `insufficient_scope: token does not have the required scopes. Required: ${missingScopeNames.join(", ")}. Token has: ${tokenScopes.join(", ")}` + ); + } + + return true; + } + + private resolveTokenPermissions(scopes: string[]): Set { + const permissions = new Set(); + for (const scope of scopes) { + if (scope in SCOPE_TO_PERMISSION) { + permissions.add(SCOPE_TO_PERMISSION[scope as NewAccessScope]); + } + } + return permissions; + } +} 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 9e800f9d2feb48..fbc977027ba2d3 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 @@ -208,6 +208,7 @@ describe("OAuth2 Controller Endpoints", () => { expect(response.body.refresh_token).toBeDefined(); expect(response.body.token_type).toBe("bearer"); expect(response.body.expires_in).toBe(1800); + expect(response.body.scope).toBe("READ_BOOKING READ_PROFILE"); expect(response.headers["cache-control"]).toBe("no-store"); expect(response.headers["pragma"]).toBe("no-cache"); @@ -231,6 +232,7 @@ describe("OAuth2 Controller Endpoints", () => { expect(response.body.access_token).toBeDefined(); expect(response.body.refresh_token).toBeDefined(); expect(response.body.token_type).toBe("bearer"); + expect(response.body.scope).toBe("READ_BOOKING"); }); it("should exchange authorization code for tokens with application/x-www-form-urlencoded body", async () => { @@ -252,6 +254,7 @@ describe("OAuth2 Controller Endpoints", () => { expect(response.body.refresh_token).toBeDefined(); expect(response.body.token_type).toBe("bearer"); expect(response.body.expires_in).toBe(1800); + expect(response.body.scope).toBe("READ_BOOKING"); }); }); @@ -381,6 +384,7 @@ describe("OAuth2 Controller Endpoints", () => { expect(response.body.refresh_token).toBeDefined(); expect(response.body.token_type).toBe("bearer"); expect(response.body.expires_in).toBe(1800); + expect(response.body.scope).toBe("READ_BOOKING READ_PROFILE"); refreshToken = response.body.refresh_token; }); @@ -459,6 +463,7 @@ describe("OAuth2 Controller Endpoints", () => { expect(firstResponse.body.access_token).toBeDefined(); expect(firstResponse.body.refresh_token).toBeDefined(); + expect(firstResponse.body.scope).toBe("READ_BOOKING"); const newRefreshToken = firstResponse.body.refresh_token; @@ -678,6 +683,7 @@ describe("OAuth2 Controller Endpoints", () => { expect(response.body.access_token).toBeDefined(); expect(response.body.refresh_token).toBeDefined(); expect(response.body.token_type).toBe("bearer"); + expect(response.body.scope).toBe("READ_BOOKING"); }); it("should refresh tokens with PENDING client owned by user", async () => { @@ -709,6 +715,7 @@ describe("OAuth2 Controller Endpoints", () => { expect(response.body.access_token).toBeDefined(); expect(response.body.refresh_token).toBeDefined(); expect(response.body.token_type).toBe("bearer"); + expect(response.body.scope).toBe("READ_BOOKING"); }); it("should reject authorization code generation for REJECTED client even as owner", async () => { @@ -781,4 +788,166 @@ describe("OAuth2 Controller Endpoints", () => { await app.close(); }); }); + + describe("Legacy OAuth client access token scopes", () => { + let app: INestApplication; + let moduleRef: TestingModule; + + let userRepositoryFixture: UserRepositoryFixture; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; + let oAuthClientFixture: OAuth2ClientRepositoryFixture; + let oAuthService: OAuthService; + + let testUser: User; + + const testClientSecret = "test-legacy-scopes-secret"; + const testRedirectUri = "https://example.com/callback"; + + const nullScopesClientId = `test-legacy-null-scopes-${randomString()}`; + const emptyScopesClientId = `test-legacy-empty-scopes-${randomString()}`; + const readBookingClientId = `test-legacy-read-booking-${randomString()}`; + const readBookingReadProfileClientId = `test-legacy-read-booking-read-profile-${randomString()}`; + + async function generateAuthCode(clientId: string, scopes: AccessScope[] = []): Promise { + const result = await oAuthService.generateAuthorizationCode( + clientId, + testUser.id, + testRedirectUri, + scopes + ); + const redirectUrl = new URL(result.redirectUrl); + return redirectUrl.searchParams.get("code") as string; + } + + async function exchangeCode(clientId: string, code: string) { + return request(app.getHttpServer()) + .post("/api/v2/auth/oauth2/token") + .type("form") + .send({ + client_id: clientId, + grant_type: "authorization_code", + code, + client_secret: testClientSecret, + redirect_uri: testRedirectUri, + }) + .expect(200); + } + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter], + imports: [AppModule, UsersModule, AuthModule, PrismaModule], + }).compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + await app.init(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); + oAuthClientFixture = new OAuth2ClientRepositoryFixture(moduleRef); + oAuthService = moduleRef.get(OAuthService); + + testUser = await userRepositoryFixture.create({ + email: `oauth2-legacy-scopes-${randomString()}@api.com`, + }); + + const [hashedSecret] = generateSecret(testClientSecret); + + await oAuthClientFixture.create({ + clientId: nullScopesClientId, + name: "Legacy Client NULL Scopes", + redirectUri: testRedirectUri, + clientSecret: hashedSecret, + clientType: OAuthClientType.CONFIDENTIAL, + }); + + await oAuthClientFixture.create({ + clientId: emptyScopesClientId, + name: "Legacy Client Empty Scopes", + redirectUri: testRedirectUri, + clientSecret: hashedSecret, + clientType: OAuthClientType.CONFIDENTIAL, + scopes: [], + }); + + await oAuthClientFixture.create({ + clientId: readBookingClientId, + name: "Legacy Client READ_BOOKING", + redirectUri: testRedirectUri, + clientSecret: hashedSecret, + clientType: OAuthClientType.CONFIDENTIAL, + scopes: [AccessScope.READ_BOOKING], + }); + + await oAuthClientFixture.create({ + clientId: readBookingReadProfileClientId, + name: "Legacy Client READ_BOOKING READ_PROFILE", + redirectUri: testRedirectUri, + clientSecret: hashedSecret, + clientType: OAuthClientType.CONFIDENTIAL, + scopes: [AccessScope.READ_BOOKING, AccessScope.READ_PROFILE], + }); + }); + + it("NULL client scopes + empty requested scopes → token scope is empty", async () => { + const code = await generateAuthCode(nullScopesClientId); + const response = await exchangeCode(nullScopesClientId, code); + expect(response.body.scope).toBe(""); + }); + + it("empty [] client scopes + empty requested scopes → token scope is empty", async () => { + const code = await generateAuthCode(emptyScopesClientId); + const response = await exchangeCode(emptyScopesClientId, code); + expect(response.body.scope).toBe(""); + }); + + it("[READ_BOOKING] client scopes + empty requested scopes → token scope is empty", async () => { + const code = await generateAuthCode(readBookingClientId); + const response = await exchangeCode(readBookingClientId, code); + expect(response.body.scope).toBe(""); + }); + + it("[READ_BOOKING, READ_PROFILE] client scopes + empty requested scopes → token scope is empty", async () => { + const code = await generateAuthCode(readBookingReadProfileClientId); + const response = await exchangeCode(readBookingReadProfileClientId, code); + expect(response.body.scope).toBe(""); + }); + + it("NULL client scopes + [BOOKING_READ] requested → token scope contains BOOKING_READ", async () => { + const code = await generateAuthCode(nullScopesClientId, [AccessScope.BOOKING_READ]); + const response = await exchangeCode(nullScopesClientId, code); + expect(response.body.scope).toBe("BOOKING_READ"); + }); + + it("empty [] client scopes + [BOOKING_READ] requested → token scope contains BOOKING_READ", async () => { + const code = await generateAuthCode(emptyScopesClientId, [AccessScope.BOOKING_READ]); + const response = await exchangeCode(emptyScopesClientId, code); + expect(response.body.scope).toBe("BOOKING_READ"); + }); + + it("[READ_BOOKING] client scopes + [BOOKING_READ] requested → token scope contains BOOKING_READ", async () => { + const code = await generateAuthCode(readBookingClientId, [AccessScope.BOOKING_READ]); + const response = await exchangeCode(readBookingClientId, code); + expect(response.body.scope).toBe("BOOKING_READ"); + }); + + it("[READ_BOOKING, READ_PROFILE] client scopes + [BOOKING_READ, BOOKING_WRITE] requested → token scope contains BOOKING_READ BOOKING_WRITE", async () => { + const code = await generateAuthCode(readBookingReadProfileClientId, [ + AccessScope.BOOKING_READ, + AccessScope.BOOKING_WRITE, + ]); + const response = await exchangeCode(readBookingReadProfileClientId, code); + expect(response.body.scope).toBe("BOOKING_READ BOOKING_WRITE"); + }); + + afterAll(async () => { + await oAuthClientFixture.delete(nullScopesClientId); + await oAuthClientFixture.delete(emptyScopesClientId); + await oAuthClientFixture.delete(readBookingClientId); + await oAuthClientFixture.delete(readBookingReadProfileClientId); + await userRepositoryFixture.delete(testUser.id); + await app.close(); + }); + }); }); 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 deleted file mode 100644 index 3e6c0c1684ff9a..00000000000000 --- a/apps/api/v2/src/modules/auth/oauth2/inputs/authorize.input.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { AccessScope } from "@calcom/prisma/enums"; -import { ApiHideProperty, ApiProperty } from "@nestjs/swagger"; -import { Equals, IsArray, IsEnum, IsOptional, IsString } from "class-validator"; - -export class OAuth2AuthorizeInput { - @ApiProperty({ - description: - "The redirect URI to redirect to after authorization. Must exactly match the registered redirect URI.", - example: "https://example.com/callback", - }) - @IsString() - redirect_uri!: string; - - @ApiProperty({ - description: "OAuth state parameter for CSRF protection. Will be included in the redirect.", - required: false, - }) - @IsOptional() - @IsString() - state?: string; - - @ApiProperty({ - description: "The scopes to request", - enum: AccessScope, - isArray: true, - example: ["READ_BOOKING", "READ_PROFILE"], - }) - @IsArray() - @IsEnum(AccessScope, { each: true }) - scopes: AccessScope[] = []; - - @ApiProperty({ - description: "The team slug to authorize for (optional)", - required: false, - }) - @IsOptional() - @IsString() - team_slug?: string; - - @ApiProperty({ - description: "PKCE code challenge (required for public clients)", - required: false, - }) - @IsOptional() - @IsString() - code_challenge?: string; - - @ApiHideProperty() - @IsString() - @Equals("S256") - code_challenge_method: string = "S256"; -} 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 6b333f039a3b22..c4e420b97d9a40 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 @@ -34,4 +34,12 @@ export class OAuth2TokensDto { @IsNumber() @Expose({ name: "expiresIn" }) expires_in!: number; + + @ApiProperty({ + description: "The granted scopes (space-delimited per RFC 6749)", + example: "BOOKING_READ BOOKING_WRITE", + }) + @IsString() + @Expose() + scope!: string; } 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 c2a7f180d57bc0..d94e3508029d5a 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,4 +1,4 @@ -import { OAuthClientStatus, OAuthClientType } from "@calcom/prisma/enums"; +import { AccessScope, 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"; @@ -22,6 +22,7 @@ export class OAuth2ClientRepositoryFixture { logo?: string; isTrusted?: boolean; userId?: number; + scopes?: AccessScope[]; }) { return this.prismaWriteClient.oAuthClient.create({ data: { @@ -34,6 +35,7 @@ export class OAuth2ClientRepositoryFixture { logo: data.logo, isTrusted: data.isTrusted || false, ...(data.userId && { user: { connect: { id: data.userId } } }), + ...(data.scopes !== undefined && { scopes: data.scopes }), }, }); } diff --git a/apps/web/app/api/auth/oauth/refreshToken/route.ts b/apps/web/app/api/auth/oauth/refreshToken/route.ts index d4bde9670ef7ea..8bd65dcf82b243 100644 --- a/apps/web/app/api/auth/oauth/refreshToken/route.ts +++ b/apps/web/app/api/auth/oauth/refreshToken/route.ts @@ -1,12 +1,12 @@ -import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; -import { parseUrlFormData } from "app/api/parseRequestData"; -import { NextResponse } from "next/server"; -import type { NextRequest } from "next/server"; - +import process from "node:process"; import { getOAuthService } from "@calcom/features/oauth/di/OAuthService.container"; import { OAUTH_ERROR_REASONS } from "@calcom/features/oauth/services/OAuthService"; import { ErrorWithCode } from "@calcom/lib/errors"; import { getHttpStatusCode } from "@calcom/lib/server/getServerErrorFromUnknown"; +import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; +import { parseUrlFormData } from "app/api/parseRequestData"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; async function handler(req: NextRequest) { const { client_id, client_secret, grant_type, refresh_token } = await parseUrlFormData(req); @@ -33,6 +33,7 @@ async function handler(req: NextRequest) { token_type: "bearer", refresh_token: tokens.refreshToken, expires_in: tokens.expiresIn, + scope: tokens.scope, }, { status: 200, diff --git a/apps/web/app/api/auth/oauth/token/route.ts b/apps/web/app/api/auth/oauth/token/route.ts index 81b7f5901909ac..621363ebb1b4e3 100644 --- a/apps/web/app/api/auth/oauth/token/route.ts +++ b/apps/web/app/api/auth/oauth/token/route.ts @@ -1,12 +1,12 @@ -import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; -import { parseUrlFormData } from "app/api/parseRequestData"; -import { NextResponse } from "next/server"; -import type { NextRequest } from "next/server"; - +import process from "node:process"; import { getOAuthService } from "@calcom/features/oauth/di/OAuthService.container"; import { OAUTH_ERROR_REASONS } from "@calcom/features/oauth/services/OAuthService"; import { ErrorWithCode } from "@calcom/lib/errors"; import { getHttpStatusCode } from "@calcom/lib/server/getServerErrorFromUnknown"; +import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir"; +import { parseUrlFormData } from "app/api/parseRequestData"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; async function handler(req: NextRequest) { const { code, client_id, client_secret, grant_type, redirect_uri, code_verifier } = @@ -40,6 +40,7 @@ async function handler(req: NextRequest) { token_type: "bearer", refresh_token: tokens.refreshToken, expires_in: tokens.expiresIn, + scope: tokens.scope, }, { status: 200, diff --git a/apps/web/modules/auth/oauth2/authorize-view.tsx b/apps/web/modules/auth/oauth2/authorize-view.tsx index 93330ef03f29c1..0cac7203d6d253 100644 --- a/apps/web/modules/auth/oauth2/authorize-view.tsx +++ b/apps/web/modules/auth/oauth2/authorize-view.tsx @@ -1,10 +1,7 @@ "use client"; -/* eslint-disable react-hooks/exhaustive-deps */ -import { useSession } from "next-auth/react"; -import { useRouter } from "next/navigation"; -import { useState, useEffect } from "react"; - +import { isLegacyClient, parseScopeParam, SCOPE_EXCEEDS_CLIENT_REGISTRATION_ERROR } from "@calcom/features/oauth/constants"; +import { OAUTH_ERROR_REASONS } from "@calcom/features/oauth/services/OAuthService"; import { APP_NAME } from "@calcom/lib/constants"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -15,6 +12,11 @@ import { Button } from "@calcom/ui/components/button"; import { Select } from "@calcom/ui/components/form"; import { InfoIcon, PlusIcon } from "@coss/ui/icons"; import { Tooltip } from "@calcom/ui/components/tooltip"; +import { useRouter } from "next/navigation"; +/* eslint-disable react-hooks/exhaustive-deps */ +import { useSession } from "next-auth/react"; +import { useEffect, useState } from "react"; +import { getScopeDisplayItems } from "./scopes"; export function Authorize() { const { t } = useLocale(); @@ -38,7 +40,6 @@ export function Authorize() { value: string; label: string; } | null>(); - const scopes = scope ? scope.toString().split(",") : []; const { data: client, @@ -48,6 +49,7 @@ export function Authorize() { { clientId: client_id as string, redirectUri: redirect_uri, + scope: scope || undefined, }, { enabled: status === "authenticated" && !!redirect_uri, @@ -90,13 +92,15 @@ export function Authorize() { } }, [isPendingProfiles, show_account_selector]); - // Auto-authorize trusted clients + const isLegacy = isLegacyClient(client?.scopes ?? []); + const effectiveScopes = parseScopeParam(scope); + useEffect(() => { if (client?.isTrusted) { generateAuthCodeMutation.mutate({ clientId: client_id as string, redirectUri: client.redirectUri, - scopes, + scopes: effectiveScopes, codeChallenge: code_challenge || undefined, codeChallengeMethod: (code_challenge_method as "S256") || undefined, state, @@ -118,7 +122,20 @@ export function Authorize() { } }, [status]); + useEffect(() => { + if (getClientError && redirect_uri && isScopeError(getClientError.message)) { + redirectToOAuthError({ + redirectUri: redirect_uri, + trpcError: getClientError, + state, + }); + } + }, [getClientError, redirect_uri, state]); + if (getClientError) { + if (isScopeError(getClientError.message)) { + return <>; + } return
{getClientError.message}
; } @@ -200,35 +217,47 @@ export function Authorize() {
{t("allow_client_to", { clientName: client.name })}
-
    -
  • - {" "} - {t("associate_with_cal_account", { clientName: client.name })} -
  • -
  • - - {t("see_personal_info")} -
  • -
  • - - {t("see_primary_email_address")} -
  • -
  • - -
  • -
  • - - {t("access_event_type")} -
  • -
  • - - {t("access_availability")} -
  • -
  • - - {t("access_bookings")} -
  • -
+ {isLegacy && effectiveScopes.length === 0 ? ( +
    +
  • + {" "} + {t("associate_with_cal_account", { clientName: client.name })} +
  • +
  • + + {t("see_personal_info")} +
  • +
  • + + {t("see_primary_email_address")} +
  • +
  • + + {t("connect_installed_apps")} +
  • +
  • + + {t("access_event_type")} +
  • +
  • + + {t("access_availability")} +
  • +
  • + + {t("access_bookings")} +
  • +
+ ) : ( +
    + {getScopeDisplayItems(effectiveScopes, t).map((label, idx) => ( +
  • + + {label} +
  • + ))} +
+ )}
@@ -260,7 +289,7 @@ export function Authorize() { onClick={() => { generateAuthCodeMutation.mutate({ clientId: client_id as string, - scopes, + scopes: effectiveScopes, redirectUri: client.redirectUri, teamSlug: selectedAccount?.value.startsWith("team/") ? selectedAccount?.value.substring(5) @@ -279,7 +308,15 @@ export function Authorize() { ); } -function mapTrpcCodeToOAuthError(code: string | undefined) { +function isScopeError(errorMessage: string): boolean { + return ( + errorMessage.includes(SCOPE_EXCEEDS_CLIENT_REGISTRATION_ERROR) || + errorMessage.includes(OAUTH_ERROR_REASONS["unknown_scope"]) + ); +} + +function mapTrpcCodeToOAuthError(code: string | undefined, message?: string) { + if (message === OAUTH_ERROR_REASONS["unknown_scope"]) return "invalid_scope"; if (code === "BAD_REQUEST") return "invalid_request"; if (code === "UNAUTHORIZED") return "unauthorized_client"; return "server_error"; @@ -322,7 +359,7 @@ function redirectToOAuthError({ }) { const redirectUrl = buildOAuthErrorRedirectUrl({ redirectUri, - error: mapTrpcCodeToOAuthError(trpcError.data?.code), + error: mapTrpcCodeToOAuthError(trpcError.data?.code, trpcError.message), errorDescription: trpcError.message, state, }); diff --git a/apps/web/modules/auth/oauth2/scopes.ts b/apps/web/modules/auth/oauth2/scopes.ts new file mode 100644 index 00000000000000..0a36997af49c40 --- /dev/null +++ b/apps/web/modules/auth/oauth2/scopes.ts @@ -0,0 +1,25 @@ +const SCOPE_RESOURCE_PREFIXES = ["PROFILE", "EVENT_TYPE", "BOOKING", "SCHEDULE", "APPS"] as const; + +export function getScopeDisplayItems(scopes: string[], t: (key: string) => string): string[] { + const scopeSet = new Set(scopes); + const items: string[] = []; + + for (const resource of SCOPE_RESOURCE_PREFIXES) { + const hasRead = scopeSet.has(`${resource}_READ`); + const hasWrite = scopeSet.has(`${resource}_WRITE`); + + if (hasRead && hasWrite) { + items.push(t(scopeTranslationKey(`${resource}_READ_WRITE`))); + } else if (hasRead) { + items.push(t(scopeTranslationKey(`${resource}_READ`))); + } else if (hasWrite) { + items.push(t(scopeTranslationKey(`${resource}_WRITE`))); + } + } + + return items; +} + +export function scopeTranslationKey(scope: string): string { + return `oauth_scope_${scope.toLowerCase()}`; +} diff --git a/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx b/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx index 6fc559900685cd..d756912899b4f9 100644 --- a/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx +++ b/apps/web/modules/settings/admin/oauth-clients-admin-view.tsx @@ -1,19 +1,16 @@ "use client"; -import { useState } from "react"; - +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; - -import { OAuthClientsAdminSkeleton } from "./oauth-clients-admin-skeleton"; import { showToast } from "@calcom/ui/components/toast"; -import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; - +import { useState } from "react"; import type { OAuthClientCreateFormValues } from "../oauth/create/OAuthClientCreateModal"; import { OAuthClientCreateDialog } from "../oauth/create/OAuthClientCreateModal"; import { OAuthClientPreviewDialog } from "../oauth/create/OAuthClientPreviewDialog"; -import { OAuthClientDetailsDialog, type OAuthClientDetails } from "../oauth/view/OAuthClientDetailsDialog"; import { OAuthClientsList } from "../oauth/OAuthClientsList"; +import { type OAuthClientDetails, OAuthClientDetailsDialog } from "../oauth/view/OAuthClientDetailsDialog"; +import { OAuthClientsAdminSkeleton } from "./oauth-clients-admin-skeleton"; export default function OAuthClientsAdminView() { const { t } = useLocale(); @@ -84,6 +81,7 @@ export default function OAuthClientsAdminView() { websiteUrl: values.websiteUrl, logo: values.logo, enablePkce: values.enablePkce, + scopes: values.scopes, }); }; @@ -163,6 +161,7 @@ export default function OAuthClientsAdminView() { )} !open && handleCloseClientDialog()} client={selectedClient} diff --git a/apps/web/modules/settings/developer/oauth-clients-view.tsx b/apps/web/modules/settings/developer/oauth-clients-view.tsx index 35a6711e85bd3d..4ee691be63c385 100644 --- a/apps/web/modules/settings/developer/oauth-clients-view.tsx +++ b/apps/web/modules/settings/developer/oauth-clients-view.tsx @@ -1,19 +1,17 @@ "use client"; -import { useState } from "react"; +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { EmptyScreen } from "@calcom/ui/components/empty-screen"; import { showToast } from "@calcom/ui/components/toast"; -import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; - +import { useState } from "react"; +import { NewOAuthClientButton } from "../oauth/create/NewOAuthClientButton"; import type { OAuthClientCreateFormValues } from "../oauth/create/OAuthClientCreateModal"; import { OAuthClientCreateDialog } from "../oauth/create/OAuthClientCreateModal"; import { OAuthClientPreviewDialog } from "../oauth/create/OAuthClientPreviewDialog"; -import { OAuthClientDetailsDialog, type OAuthClientDetails } from "../oauth/view/OAuthClientDetailsDialog"; import { OAuthClientsList } from "../oauth/OAuthClientsList"; -import { NewOAuthClientButton } from "../oauth/create/NewOAuthClientButton"; - +import { type OAuthClientDetails, OAuthClientDetailsDialog } from "../oauth/view/OAuthClientDetailsDialog"; import { OAuthClientsSkeleton } from "./oauth-clients-skeleton"; const OAuthClientsView = () => { @@ -88,6 +86,7 @@ const OAuthClientsView = () => { websiteUrl: values.websiteUrl, logo: values.logo, enablePkce: values.enablePkce, + scopes: values.scopes, }); }; @@ -131,6 +130,7 @@ const OAuthClientsView = () => { status: client.status, rejectionReason: client.rejectionReason, clientType: client.clientType, + scopes: client.scopes, }))} onSelectClient={(client) => setSelectedClient(client)} /> @@ -166,6 +166,7 @@ const OAuthClientsView = () => { )} !open && handleCloseDetailsDialog()} client={selectedClient} @@ -177,6 +178,7 @@ const OAuthClientsView = () => { redirectUri: values.redirectUri, websiteUrl: values.websiteUrl, logo: values.logo, + scopes: values.scopes, }); }} onDelete={(clientId) => { diff --git a/apps/web/modules/settings/oauth/create/OAuthClientCreateModal.tsx b/apps/web/modules/settings/oauth/create/OAuthClientCreateModal.tsx index 4d4857e740d197..2f18c81d404118 100644 --- a/apps/web/modules/settings/oauth/create/OAuthClientCreateModal.tsx +++ b/apps/web/modules/settings/oauth/create/OAuthClientCreateModal.tsx @@ -1,16 +1,15 @@ "use client"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; - -import { useLocale } from "@calcom/lib/hooks/useLocale"; - -import { OAuthClientFormFields } from "../view/OAuthClientFormFields"; - import { Dialog } from "@calcom/features/components/controlled-dialog"; +import { OAUTH_SCOPES } from "@calcom/features/oauth/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { AccessScope } from "@calcom/prisma/enums"; import { Button } from "@calcom/ui/components/button"; import { DialogClose, DialogContent, DialogFooter } from "@calcom/ui/components/dialog"; import { Form } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/components/toast"; +import { useForm } from "react-hook-form"; +import { OAuthClientFormFields } from "../view/OAuthClientFormFields"; export type OAuthClientCreateFormValues = { name: string; @@ -19,6 +18,7 @@ export type OAuthClientCreateFormValues = { websiteUrl: string; logo: string; enablePkce: boolean; + scopes: AccessScope[]; }; export type OAuthClientCreateDialogProps = { @@ -37,7 +37,6 @@ export function OAuthClientCreateDialog({ onClose, }: OAuthClientCreateDialogProps) { const { t } = useLocale(); - const [logo, setLogo] = useState(""); const form = useForm({ defaultValues: { @@ -47,12 +46,12 @@ export function OAuthClientCreateDialog({ websiteUrl: "", logo: "", enablePkce: false, + scopes: [], }, }); const handleClose = () => { onClose(); - setLogo(""); form.reset(); }; @@ -74,6 +73,10 @@ export function OAuthClientCreateDialog({
{ + if (!values.scopes || values.scopes.length === 0) { + showToast(t("oauth_client_scope_required"), "error"); + return; + } onSubmit({ name: values.name.trim() || "", purpose: values.purpose.trim() || "", @@ -81,11 +84,12 @@ export function OAuthClientCreateDialog({ websiteUrl: values.websiteUrl.trim() || "", logo: values.logo, enablePkce: values.enablePkce, + scopes: values.scopes, }); }} className="space-y-4" data-testid="oauth-client-create-form"> - + {t("close")} diff --git a/apps/web/modules/settings/oauth/view/OAuthClientDetailsDialog.tsx b/apps/web/modules/settings/oauth/view/OAuthClientDetailsDialog.tsx index ab3a930c828049..13c0b5f832b814 100644 --- a/apps/web/modules/settings/oauth/view/OAuthClientDetailsDialog.tsx +++ b/apps/web/modules/settings/oauth/view/OAuthClientDetailsDialog.tsx @@ -4,8 +4,10 @@ import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { Dialog } from "@calcom/features/components/controlled-dialog"; +import { isLegacyClient, OAUTH_SCOPES } from "@calcom/features/oauth/constants"; import { useCopy } from "@calcom/lib/hooks/useCopy"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { AccessScope } from "@calcom/prisma/enums"; import { Alert } from "@calcom/ui/components/alert"; import { Badge } from "@calcom/ui/components/badge"; @@ -36,6 +38,7 @@ type OAuthClientDetails = { clientSecret?: string; isPkceEnabled?: boolean; clientType?: string; + scopes?: AccessScope[]; user?: { email: string; } | null; @@ -65,6 +68,7 @@ const OAuthClientDetailsDialog = ({ redirectUri: string; websiteUrl: string; logo: string; + scopes: AccessScope[] | undefined; }) => void; onDelete?: (clientId: string) => void; isStatusChangePending?: boolean; @@ -74,19 +78,25 @@ const OAuthClientDetailsDialog = ({ const { t } = useLocale(); const { copyToClipboard } = useCopy(); - const [logo, setLogo] = useState(""); const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); const [isRejectConfirmOpen, setIsRejectConfirmOpen] = useState(false); const [rejectionReason, setRejectionReason] = useState(""); const [showRejectionReasonError, setShowRejectionReasonError] = useState(false); + + const formEnablePkce = + client?.isPkceEnabled ?? (client?.clientType ? client.clientType.toUpperCase() === "PUBLIC" : false); + const isLegacy = isLegacyClient(client?.scopes ?? []); + const formClientScopes = client?.scopes ?? []; + const form = useForm({ defaultValues: { - name: "", - purpose: "", - redirectUri: "", - websiteUrl: "", - logo: "", - enablePkce: false, + name: client?.name ?? "", + purpose: client?.purpose ?? "", + redirectUri: client?.redirectUri ?? "", + websiteUrl: client?.websiteUrl ?? "", + logo: client?.logo ?? "", + enablePkce: formEnablePkce, + scopes: formClientScopes, }, }); @@ -98,24 +108,6 @@ const OAuthClientDetailsDialog = ({ setShowRejectionReasonError(false); }, [open]); - useEffect(() => { - if (!client) return; - - const enablePkce = - client.isPkceEnabled ?? (client.clientType ? client.clientType.toUpperCase() === "PUBLIC" : false); - const nextLogo = client.logo ?? ""; - - setLogo(nextLogo); - form.reset({ - name: client.name ?? "", - purpose: client.purpose ?? "", - redirectUri: client.redirectUri ?? "", - websiteUrl: client.websiteUrl ?? "", - logo: nextLogo, - enablePkce, - }); - }, [client, form]); - const status = client?.status; const showAdminActions = Boolean(onApprove) || Boolean(onReject); @@ -210,6 +202,10 @@ const OAuthClientDetailsDialog = ({ className="space-y-4" onSubmit={form.handleSubmit((values) => { if (!canEdit) return; + if (!isLegacy && !values.scopes?.length) { + showToast(t("oauth_client_scope_required"), "error"); + return; + } onUpdate?.({ clientId: client.clientId, name: values.name.trim() || "", @@ -217,6 +213,8 @@ const OAuthClientDetailsDialog = ({ redirectUri: values.redirectUri.trim() || "", websiteUrl: values.websiteUrl.trim() || "", logo: values.logo, + // note(Lauris): for legacy clients with no scopes selected, omit scopes to leave the DB unchanged. + scopes: isLegacy && !values.scopes?.length ? undefined : values.scopes, }); })}> {status ? ( @@ -285,13 +283,7 @@ const OAuthClientDetailsDialog = ({
) : null} - + {canDelete ? (
diff --git a/apps/web/modules/settings/oauth/view/OAuthClientFormFields.tsx b/apps/web/modules/settings/oauth/view/OAuthClientFormFields.tsx index 260138a4f31b6f..01d36b399340b0 100644 --- a/apps/web/modules/settings/oauth/view/OAuthClientFormFields.tsx +++ b/apps/web/modules/settings/oauth/view/OAuthClientFormFields.tsx @@ -1,31 +1,31 @@ "use client"; -import { useMemo } from "react"; -import type { Dispatch, SetStateAction } from "react"; -import type { RegisterOptions, UseFormReturn } from "react-hook-form"; - +import { OAUTH_SCOPES } from "@calcom/features/oauth/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; - +import type { AccessScope } from "@calcom/prisma/enums"; +import { Alert } from "@calcom/ui/components/alert"; import { Avatar } from "@calcom/ui/components/avatar"; -import { Label, Switch, TextArea, TextField } from "@calcom/ui/components/form"; +import { CheckboxField, Label, Switch, TextArea, TextField } from "@calcom/ui/components/form"; +import { Icon } from "@calcom/ui/components/icon"; import { ImageUploader } from "@calcom/ui/components/image-uploader"; import { InfoIcon, KeyIcon } from "@coss/ui/icons"; import { Tooltip } from "@calcom/ui/components/tooltip"; - +import { useMemo } from "react"; +import type { RegisterOptions, UseFormReturn } from "react-hook-form"; +import { Controller } from "react-hook-form"; +import { scopeTranslationKey } from "../../../auth/oauth2/scopes"; import type { OAuthClientCreateFormValues } from "../create/OAuthClientCreateModal"; export const OAuthClientFormFields = ({ form, - logo, - setLogo, isClientReadOnly, isPkceLocked, + isLegacyOAuthClient, }: { form: UseFormReturn; - logo: string; - setLogo: Dispatch>; isClientReadOnly?: boolean; isPkceLocked?: boolean; + isLegacyOAuthClient?: boolean; }) => { const { t } = useLocale(); const isFormDisabled = Boolean(isClientReadOnly); @@ -131,6 +131,8 @@ export const OAuthClientFormFields = ({
+ +
@@ -138,7 +140,7 @@ export const OAuthClientFormFields = ({ } - imageSrc={logo} + imageSrc={form.watch("logo")} size="lg" /> {allowUploadingLogo ? ( @@ -148,10 +150,9 @@ export const OAuthClientFormFields = ({ buttonMsg={t("upload_logo")} testId="oauth-client-logo" handleAvatarChange={(newLogo: string) => { - setLogo(newLogo); form.setValue("logo", newLogo); }} - imageSrc={logo} + imageSrc={form.watch("logo")} disabled={isFormDisabled} /> ) : null} @@ -161,3 +162,72 @@ export const OAuthClientFormFields = ({ ); }; + +function OAuthScopeCheckboxes({ + form, + disabled, + isLegacy, +}: { + form: UseFormReturn; + disabled: boolean; + isLegacy?: boolean; +}) { + const { t } = useLocale(); + + return ( + { + const scopes = field.value || []; + return ( +
+ + {isLegacy && ( + + {t("legacy_oauth_client_scopes_warning")}{" "} + + https://cal.com/docs/api-reference/v2/oauth#legacy-client-migration + + + } + /> + )} +
+ {OAUTH_SCOPES.map((scope) => ( + { + const newScopes = e.target.checked + ? [...scopes, scope] + : scopes.filter((s) => s !== scope); + field.onChange(newScopes); + }} + /> + ))} +
+
+ ); + }} + /> + ); +} diff --git a/apps/web/playwright/oauth/oauth-authorize-approval-status.e2e.ts b/apps/web/playwright/oauth/oauth-authorize-approval-status.e2e.ts index 84a1e386babf28..1da5972335b594 100644 --- a/apps/web/playwright/oauth/oauth-authorize-approval-status.e2e.ts +++ b/apps/web/playwright/oauth/oauth-authorize-approval-status.e2e.ts @@ -1,6 +1,7 @@ import { randomBytes } from "node:crypto"; import { OAUTH_ERROR_REASONS } from "@calcom/features/oauth/services/OAuthService"; import type { PrismaClient } from "@calcom/prisma"; +import type { AccessScope } from "@calcom/prisma/enums"; import { expect } from "@playwright/test"; import { test } from "../lib/fixtures"; @@ -24,11 +25,13 @@ test.describe("OAuth authorize - client approval status", () => { name, status, userId, + scopes, }: { prisma: PrismaClient; name: string; status: "PENDING" | "APPROVED" | "REJECTED"; userId?: number; + scopes?: AccessScope[]; }) { const clientId = randomBytes(32).toString("hex"); @@ -41,6 +44,7 @@ test.describe("OAuth authorize - client approval status", () => { clientType: "CONFIDENTIAL", status, ...(userId && { user: { connect: { id: userId } } }), + ...(scopes && { scopes }), }, }); @@ -67,7 +71,6 @@ test.describe("OAuth authorize - client approval status", () => { ); await expect(page).not.toHaveURL(/^https:\/\/example\.com/); - await expect(page.getByText(OAUTH_ERROR_REASONS["client_not_approved"])).toBeVisible(); }); @@ -228,4 +231,304 @@ test.describe("OAuth authorize - client approval status", () => { await expect(page.getByText(OAUTH_ERROR_REASONS["client_rejected"])).toBeVisible(); }); + + test("scope exceeding client registration redirects with invalid_scope error", async ({ + page, + users, + prisma, + }, testInfo) => { + const user = await users.create({ username: "oauth-authorize-scope-exceed" }); + await user.apiLogin(); + + const testPrefix = `e2e-oauth-authorize-status-${testInfo.testId}-`; + const client = await createOAuthClient({ + prisma, + name: `${testPrefix}scope-exceed-${Date.now()}`, + status: "APPROVED", + scopes: ["BOOKING_READ"], + }); + + await page.goto( + `auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&scope=BOOKING_READ,SCHEDULE_WRITE&state=1234` + ); + + await page.waitForFunction(() => { + return window.location.href.startsWith("https://example.com"); + }); + + await expect(page).toHaveURL(/^https:\/\/example\.com/); + + const url = new URL(page.url()); + expect(url.searchParams.get("error")).toBe("invalid_request"); + expect(url.searchParams.get("error_description")).toBe( + OAUTH_ERROR_REASONS["scope_exceeds_client_registration"] + ); + expect(url.searchParams.get("state")).toBe("1234"); + expect(url.searchParams.get("code")).toBeNull(); + }); + + test("scope exceeding client registration with space delimiter redirects with invalid_scope error", async ({ + page, + users, + prisma, + }, testInfo) => { + const user = await users.create({ username: "oauth-authorize-scope-space" }); + await user.apiLogin(); + + const testPrefix = `e2e-oauth-authorize-status-${testInfo.testId}-`; + const client = await createOAuthClient({ + prisma, + name: `${testPrefix}scope-space-${Date.now()}`, + status: "APPROVED", + scopes: ["BOOKING_READ"], + }); + + await page.goto( + `auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&scope=BOOKING_READ%20SCHEDULE_WRITE&state=1234` + ); + + await page.waitForFunction(() => { + return window.location.href.startsWith("https://example.com"); + }); + + await expect(page).toHaveURL(/^https:\/\/example\.com/); + + const url = new URL(page.url()); + expect(url.searchParams.get("error")).toBe("invalid_request"); + expect(url.searchParams.get("error_description")).toBe( + OAUTH_ERROR_REASONS["scope_exceeds_client_registration"] + ); + expect(url.searchParams.get("state")).toBe("1234"); + expect(url.searchParams.get("code")).toBeNull(); + }); + + test("scope within client registration succeeds with authorization code", async ({ + page, + users, + prisma, + }, testInfo) => { + const user = await users.create({ username: "oauth-authorize-scope-valid" }); + await user.apiLogin(); + + const testPrefix = `e2e-oauth-authorize-status-${testInfo.testId}-`; + const client = await createOAuthClient({ + prisma, + name: `${testPrefix}scope-valid-${Date.now()}`, + status: "APPROVED", + scopes: ["BOOKING_READ", "BOOKING_WRITE", "SCHEDULE_READ"], + }); + + await page.goto( + `auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&scope=BOOKING_READ&state=1234` + ); + + await page.waitForSelector('[data-testid="allow-button"]'); + await page.getByTestId("allow-button").click(); + + await page.waitForFunction(() => { + return window.location.href.startsWith("https://example.com"); + }); + + await expect(page).toHaveURL(/^https:\/\/example\.com/); + + const url = new URL(page.url()); + expect(url.searchParams.get("code")).toBeTruthy(); + expect(url.searchParams.get("state")).toBe("1234"); + expect(url.searchParams.get("error")).toBeNull(); + }); + + test("consent screen displays correct scope labels for requested permissions", async ({ + page, + users, + prisma, + }, testInfo) => { + const user = await users.create({ username: "oauth-authorize-scope-display" }); + await user.apiLogin(); + + const testPrefix = `e2e-oauth-authorize-status-${testInfo.testId}-`; + const client = await createOAuthClient({ + prisma, + name: `${testPrefix}scope-display-${Date.now()}`, + status: "APPROVED", + scopes: ["BOOKING_READ", "BOOKING_WRITE", "SCHEDULE_READ", "PROFILE_READ"], + }); + + await page.goto( + `auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&scope=BOOKING_READ,SCHEDULE_READ&state=1234` + ); + + await page.waitForSelector('[data-testid="allow-button"]'); + + await expect(page.getByTestId("scope-permissions-list")).toBeVisible(); + await expect(page.getByTestId("legacy-permissions-list")).not.toBeVisible(); + + await expect(page.getByText("View bookings")).toBeVisible(); + await expect(page.getByText("View availability")).toBeVisible(); + + await expect(page.getByText("Create, edit, and delete bookings")).not.toBeVisible(); + await expect(page.getByText("View personal info and primary email address")).not.toBeVisible(); + + await page.getByTestId("allow-button").click(); + + await page.waitForFunction(() => { + return window.location.href.startsWith("https://example.com"); + }); + + const url = new URL(page.url()); + expect(url.searchParams.get("code")).toBeTruthy(); + }); + + test("legacy OAuth client renders legacy permissions list", async ({ + page, + users, + prisma, + }, testInfo) => { + const user = await users.create({ username: "oauth-authorize-legacy-permissions" }); + await user.apiLogin(); + + const testPrefix = `e2e-oauth-authorize-status-${testInfo.testId}-`; + const client = await createOAuthClient({ + prisma, + name: `${testPrefix}legacy-${Date.now()}`, + status: "APPROVED", + }); + + await page.goto( + `auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&state=1234` + ); + + await page.waitForSelector('[data-testid="allow-button"]'); + + await expect(page.getByTestId("legacy-permissions-list")).toBeVisible(); + await expect(page.getByTestId("scope-permissions-list")).not.toBeVisible(); + }); + + test("new OAuth client without scope param renders error on authorize page (no redirect)", async ({ + page, + users, + prisma, + }, testInfo) => { + const user = await users.create({ username: "oauth-authorize-scope-required" }); + await user.apiLogin(); + + const testPrefix = `e2e-oauth-authorize-status-${testInfo.testId}-`; + const client = await createOAuthClient({ + prisma, + name: `${testPrefix}scope-required-${Date.now()}`, + status: "APPROVED", + scopes: ["BOOKING_READ"], + }); + + await page.goto( + `auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&state=1234` + ); + + await expect(page).toHaveURL(/\/auth\/oauth2\/authorize/); + await expect(page.getByText(OAUTH_ERROR_REASONS["scope_required"])).toBeVisible(); + }); + + test("new OAuth client with unknown scope redirects with invalid_scope error", async ({ + page, + users, + prisma, + }, testInfo) => { + const user = await users.create({ username: "oauth-authorize-unknown-scope" }); + await user.apiLogin(); + + const testPrefix = `e2e-oauth-authorize-status-${testInfo.testId}-`; + const client = await createOAuthClient({ + prisma, + name: `${testPrefix}unknown-scope-${Date.now()}`, + status: "APPROVED", + scopes: ["BOOKING_READ"], + }); + + await page.goto( + `auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&scope=blab_blab&state=1234` + ); + + await page.waitForFunction(() => { + return window.location.href.startsWith("https://example.com"); + }); + + await expect(page).toHaveURL(/^https:\/\/example\.com/); + + const url = new URL(page.url()); + expect(url.searchParams.get("error")).toBe("invalid_scope"); + expect(url.searchParams.get("error_description")).toBe(OAUTH_ERROR_REASONS["unknown_scope"]); + expect(url.searchParams.get("state")).toBe("1234"); + expect(url.searchParams.get("code")).toBeNull(); + }); + + test("legacy OAuth client with unknown scope redirects with invalid_scope error", async ({ + page, + users, + prisma, + }, testInfo) => { + const user = await users.create({ username: "oauth-authorize-legacy-unknown-scope" }); + await user.apiLogin(); + + const testPrefix = `e2e-oauth-authorize-status-${testInfo.testId}-`; + const client = await createOAuthClient({ + prisma, + name: `${testPrefix}legacy-unknown-scope-${Date.now()}`, + status: "APPROVED", + }); + + await page.goto( + `auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&scope=blab_blab&state=1234` + ); + + await page.waitForFunction(() => { + return window.location.href.startsWith("https://example.com"); + }); + + await expect(page).toHaveURL(/^https:\/\/example\.com/); + + const url = new URL(page.url()); + expect(url.searchParams.get("error")).toBe("invalid_scope"); + expect(url.searchParams.get("error_description")).toBe(OAUTH_ERROR_REASONS["unknown_scope"]); + expect(url.searchParams.get("state")).toBe("1234"); + expect(url.searchParams.get("code")).toBeNull(); + }); + + test("consent screen displays merged scope labels when both read and write are requested", async ({ + page, + users, + prisma, + }, testInfo) => { + const user = await users.create({ username: "oauth-authorize-scope-merged" }); + await user.apiLogin(); + + const testPrefix = `e2e-oauth-authorize-status-${testInfo.testId}-`; + const client = await createOAuthClient({ + prisma, + name: `${testPrefix}scope-merged-${Date.now()}`, + status: "APPROVED", + scopes: ["BOOKING_READ", "BOOKING_WRITE", "SCHEDULE_READ", "SCHEDULE_WRITE"], + }); + + await page.goto( + `auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&scope=BOOKING_READ,BOOKING_WRITE&state=1234` + ); + + await page.waitForSelector('[data-testid="allow-button"]'); + + await expect(page.getByTestId("scope-permissions-list")).toBeVisible(); + await expect(page.getByTestId("legacy-permissions-list")).not.toBeVisible(); + + await expect(page.getByText("Create, read, update, and delete bookings")).toBeVisible(); + + await expect(page.getByText("View bookings")).not.toBeVisible(); + await expect(page.getByText("Create, edit, and delete bookings")).not.toBeVisible(); + + await page.getByTestId("allow-button").click(); + + await page.waitForFunction(() => { + return window.location.href.startsWith("https://example.com"); + }); + + const url = new URL(page.url()); + expect(url.searchParams.get("code")).toBeTruthy(); + }); }); diff --git a/apps/web/playwright/oauth/oauth-client-owner-crud.e2e.ts b/apps/web/playwright/oauth/oauth-client-owner-crud.e2e.ts index adb0d319419934..d4baf01794f98a 100644 --- a/apps/web/playwright/oauth/oauth-client-owner-crud.e2e.ts +++ b/apps/web/playwright/oauth/oauth-client-owner-crud.e2e.ts @@ -1,13 +1,14 @@ import path from "node:path"; + +import { OAUTH_SCOPES } from "@calcom/features/oauth/constants"; import type { PrismaClient } from "@calcom/prisma"; -import type { OAuthClientType } from "@calcom/prisma/enums"; +import type { AccessScope, OAuthClientType } from "@calcom/prisma/enums"; import { expect, type Locator, type Page } from "@playwright/test"; import { test } from "../lib/fixtures"; async function loginAsSeededAdminAndGoToOAuthSettings(page: Page) { await page.goto("/auth/login"); - // Seeded admin user from scripts/seed.ts await page.getByTestId("login-form").locator("#email").fill("admin@example.com"); await page.getByTestId("login-form").locator("#password").fill("ADMINadmin2022!"); @@ -25,6 +26,7 @@ type CreateOAuthClientInput = { websiteUrl?: string; enablePkce: boolean; logoFileName?: string; + scopes?: AccessScope[]; }; type CreateOAuthClientResult = { @@ -39,6 +41,7 @@ type UpdateOAuthClientInput = { redirectUri: string; websiteUrl: string; logoFileName?: string; + scopes?: AccessScope[]; }; type ExpectedOAuthClientDetails = { @@ -50,6 +53,7 @@ type ExpectedOAuthClientDetails = { statusLabel: "Approved" | "Rejected" | "Pending"; pkceEnabled: boolean; hasLogo: boolean; + scopes?: AccessScope[]; }; type TruthyExpectation = { kind: "truthy" }; @@ -64,6 +68,7 @@ type OAuthClientDbExpectations = { clientType?: OAuthClientType; clientSecret?: string | null | TruthyExpectation; logo?: string | null | TruthyExpectation; + scopes?: AccessScope[]; }; const oAuthClientSelect = { @@ -76,6 +81,7 @@ const oAuthClientSelect = { clientType: true, clientSecret: true, logo: true, + scopes: true, } as const; async function createOAuthClient( @@ -106,6 +112,17 @@ async function createOAuthClient( await expect(pkceToggle).toHaveAttribute("data-state", "unchecked"); } + if (input.scopes) { + for (const scope of OAUTH_SCOPES) { + const scopeCheckbox = page.getByTestId(`oauth-scope-checkbox-${scope}`); + const isChecked = await scopeCheckbox.isChecked(); + const shouldBeChecked = input.scopes.includes(scope); + if (isChecked !== shouldBeChecked) { + await scopeCheckbox.click(); + } + } + } + await page.getByTestId("oauth-client-create-submit").click(); const submitted = getOAuthClientSubmittedModal(page); @@ -145,6 +162,17 @@ async function updateOAuthClient(page: Page, input: UpdateOAuthClientInput): Pro await uploadOAuthClientLogo(page, input.logoFileName); } + if (input.scopes) { + for (const scope of OAUTH_SCOPES) { + const scopeCheckbox = page.getByTestId(`oauth-scope-checkbox-${scope}`); + const isChecked = await scopeCheckbox.isChecked(); + const shouldBeChecked = input.scopes.includes(scope); + if (isChecked !== shouldBeChecked) { + await scopeCheckbox.click(); + } + } + } + const updateResponsePromise = page.waitForResponse((res) => res.url().includes("/api/trpc/oAuth/updateClient") ); @@ -245,6 +273,10 @@ async function expectOAuthClientInDb( expect(dbClient.logo).toBe(expected.logo); } } + + if (expected.scopes !== undefined) { + expect(dbClient.scopes.sort()).toEqual(expected.scopes.sort()); + } } async function expectOAuthClientDetails( @@ -262,6 +294,18 @@ async function expectOAuthClientDetails( await expect(pkceToggle).toHaveAttribute("data-state", expected.pkceEnabled ? "checked" : "unchecked"); await expect(details.locator('img[alt="Logo"][src]')).toHaveCount(expected.hasLogo ? 1 : 0); + + if (expected.scopes) { + for (const scope of OAUTH_SCOPES) { + const scopeCheckbox = details.page().getByTestId(`oauth-scope-checkbox-${scope}`); + const shouldBeChecked = expected.scopes.includes(scope); + if (shouldBeChecked) { + await expect(scopeCheckbox).toBeChecked(); + } else { + await expect(scopeCheckbox).not.toBeChecked(); + } + } + } } async function expectOAuthClientInList(page: Page, input: { clientId: string; name: string }): Promise { @@ -291,7 +335,6 @@ test.describe("OAuth client creation", () => { test.afterEach(async ({ users, prisma }, testInfo) => { const testPrefix = `e2e-oauth-client-creation-${testInfo.testId}-`; - // Clean up any clients created by the e2e tests (by naming convention) await prisma.oAuthClient.deleteMany({ where: { name: { @@ -311,23 +354,18 @@ test.describe("OAuth client creation", () => { const form = page.getByTestId("oauth-client-create-form"); await expect(form).toBeVisible(); - // Submit immediately - should trigger native browser required field validation await page.getByTestId("oauth-client-create-submit").click(); - // Playwright can assert native validation message via validationMessage. - // This matches Chrome's default: "Please fill out this field." const nameInput = form.locator("#name"); const purposeInput = form.locator("#purpose"); const redirectUriInput = form.locator("#redirectUri"); await expect(nameInput).toHaveJSProperty("validationMessage", "Please fill out this field."); - // Fill name, submit -> purpose should be flagged await nameInput.fill("Test OAuth Client"); await page.getByTestId("oauth-client-create-submit").click(); await expect(purposeInput).toHaveJSProperty("validationMessage", "Please fill out this field."); - // Fill purpose, submit -> redirect URI should be flagged await purposeInput.fill("Test purpose"); await page.getByTestId("oauth-client-create-submit").click(); await expect(redirectUriInput).toHaveJSProperty("validationMessage", "Please fill out this field."); @@ -344,7 +382,6 @@ test.describe("OAuth client creation", () => { const purpose = "Used for E2E testing (minimal)"; const redirectUri = "https://example.com/callback"; - // Ensure PKCE is off (private/confidential) const { clientId, clientSecret } = await createOAuthClient(page, { name: clientName, purpose, @@ -532,8 +569,6 @@ test.describe("OAuth client creation", () => { const redirectUri = "https://example.com/callback"; const websiteUrl = "https://example.com"; - // Upload logo using ImageUploader testIds - // Ensure PKCE is off (private/confidential) const { clientId, clientSecret } = await createOAuthClient(page, { name: clientName, purpose, @@ -636,8 +671,6 @@ test.describe("OAuth client creation", () => { const redirectUri = "https://example.com/callback"; const websiteUrl = "https://example.com"; - // Enable PKCE - // For public (PKCE) clients we should not show a client secret const { clientId } = await createOAuthClient(page, { name: clientName, purpose, @@ -725,6 +758,148 @@ test.describe("OAuth client creation", () => { await expectOAuthClientDeletedInDb(prisma, clientId); }); + + test("creates OAuth client with single scope correctly stored in database", async ({ page, prisma }, testInfo) => { + await loginAsSeededAdminAndGoToOAuthSettings(page); + + const testPrefix = `e2e-oauth-client-creation-${testInfo.testId}-`; + const clientName = `${testPrefix}single-scope-${Date.now()}`; + const purpose = "Testing single scope selection"; + const redirectUri = "https://example.com/single-scope-callback"; + const selectedScope: AccessScope = "BOOKING_READ"; + + const { clientId } = await createOAuthClient(page, { + name: clientName, + purpose, + redirectUri, + enablePkce: true, + scopes: [selectedScope], + }); + + await expectOAuthClientInDb(prisma, clientId, { + name: clientName, + purpose, + redirectUri, + status: "PENDING", + clientType: "PUBLIC", + scopes: [selectedScope], + }); + + await deleteOAuthClient(page, clientId, clientName); + await expectOAuthClientDeletedInDb(prisma, clientId); + }); + + test("creates OAuth client with all scopes correctly stored in database", async ({ page, prisma }, testInfo) => { + await loginAsSeededAdminAndGoToOAuthSettings(page); + + const testPrefix = `e2e-oauth-client-creation-${testInfo.testId}-`; + const clientName = `${testPrefix}all-scopes-${Date.now()}`; + const purpose = "Testing all scopes selection"; + const redirectUri = "https://example.com/all-scopes-callback"; + const { clientId } = await createOAuthClient(page, { + name: clientName, + purpose, + redirectUri, + enablePkce: true, + scopes: [...OAUTH_SCOPES], + }); + + await expectOAuthClientInDb(prisma, clientId, { + name: clientName, + purpose, + redirectUri, + status: "PENDING", + clientType: "PUBLIC", + scopes: [...OAUTH_SCOPES], + }); + + await deleteOAuthClient(page, clientId, clientName); + await expectOAuthClientDeletedInDb(prisma, clientId); + }); + + test("updates OAuth client scopes correctly; UI checkboxes reflect state; DB stores updated scopes", async ({ page, prisma }, testInfo) => { + await loginAsSeededAdminAndGoToOAuthSettings(page); + + const testPrefix = `e2e-oauth-client-creation-${testInfo.testId}-`; + const clientName = `${testPrefix}update-scopes-${Date.now()}`; + const purpose = "Testing scope updates"; + const redirectUri = "https://example.com/update-scopes-callback"; + const initialScopes: AccessScope[] = ["EVENT_TYPE_READ", "BOOKING_READ"]; + + const { clientId } = await createOAuthClient(page, { + name: clientName, + purpose, + redirectUri, + enablePkce: true, + scopes: initialScopes, + }); + + await expectOAuthClientInDb(prisma, clientId, { + scopes: initialScopes, + }); + + const details = await openOAuthClientDetails(page, clientId); + await expectOAuthClientDetails(details, { + clientId, + name: clientName, + purpose, + redirectUri, + websiteUrl: "", + statusLabel: "Pending", + pkceEnabled: true, + hasLogo: false, + scopes: initialScopes, + }); + await closeOAuthClientDetails(page); + + const updatedScopes: AccessScope[] = ["SCHEDULE_READ", "SCHEDULE_WRITE", "PROFILE_READ"]; + + await updateOAuthClient(page, { + clientId, + name: clientName, + purpose, + redirectUri, + websiteUrl: "", + scopes: updatedScopes, + }); + + await expectOAuthClientInDb(prisma, clientId, { + scopes: updatedScopes, + }); + + const detailsAfterUpdate = await openOAuthClientDetails(page, clientId); + await expectOAuthClientDetails(detailsAfterUpdate, { + clientId, + name: clientName, + purpose, + redirectUri, + websiteUrl: "", + statusLabel: "Pending", + pkceEnabled: true, + hasLogo: false, + scopes: updatedScopes, + }); + await closeOAuthClientDetails(page); + + await page.reload(); + + const detailsAfterReload = await openOAuthClientDetails(page, clientId); + await expectOAuthClientDetails(detailsAfterReload, { + clientId, + name: clientName, + purpose, + redirectUri, + websiteUrl: "", + statusLabel: "Pending", + pkceEnabled: true, + hasLogo: false, + scopes: updatedScopes, + }); + await closeOAuthClientDetails(page); + + await deleteOAuthClient(page, clientId, clientName); + await expectOAuthClientDeletedInDb(prisma, clientId); + }); }); function getFixturePath(fileName: string): string { diff --git a/docs/api-reference/v2/oauth.mdx b/docs/api-reference/v2/oauth.mdx index e1189cb94ed2c5..f2e70b90dd0004 100644 --- a/docs/api-reference/v2/oauth.mdx +++ b/docs/api-reference/v2/oauth.mdx @@ -23,16 +23,37 @@ As an example, you can view our OAuth flow in action on Zapier. Try to connect y ## 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. +and not yet ready to use. You must select at least one scope when creating the OAuth client. 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. +### Available Scopes + +Scopes control which API endpoints the OAuth token can access. Once a user authorizes your client with a given set of scopes, the issued access token can only be used to call endpoints covered by those scopes — any request to an endpoint outside the granted scopes will be rejected. The following scopes are available: + +| Scope | Description | Endpoints | +|-------|-------------|-----------| +| `EVENT_TYPE_READ` | View event types | [Get all event types](https://cal.com/docs/api-reference/v2/event-types/get-all-event-types),
[Get an event type](https://cal.com/docs/api-reference/v2/event-types/get-an-event-type),
[Get event type private links](https://cal.com/docs/api-reference/v2/event-types-private-links/get-all-private-links-for-an-event-type) | +| `EVENT_TYPE_WRITE` | Create, edit, and delete event types | [Create an event type](https://cal.com/docs/api-reference/v2/event-types/create-an-event-type),
[Update an event type](https://cal.com/docs/api-reference/v2/event-types/update-an-event-type),
[Delete an event type](https://cal.com/docs/api-reference/v2/event-types/delete-an-event-type),
[Create a private link](https://cal.com/docs/api-reference/v2/event-types-private-links/create-a-private-link-for-an-event-type),
[Update a private link](https://cal.com/docs/api-reference/v2/event-types-private-links/update-a-private-link-for-an-event-type),
[Delete a private link](https://cal.com/docs/api-reference/v2/event-types-private-links/delete-a-private-link-for-an-event-type) | +| `BOOKING_READ` | View bookings | [Get all bookings](https://cal.com/docs/api-reference/v2/bookings/get-all-bookings),
[Get booking recordings](https://cal.com/docs/api-reference/v2/bookings/get-all-the-recordings-for-the-booking),
[Get transcript download links](https://cal.com/docs/api-reference/v2/bookings/get-cal-video-real-time-transcript-download-links-for-the-booking),
[Get calendar links](https://cal.com/docs/api-reference/v2/bookings/get-add-to-calendar-links-for-a-booking),
[Get booking references](https://cal.com/docs/api-reference/v2/bookings/get-booking-references),
[Get conferencing sessions](https://cal.com/docs/api-reference/v2/bookings/get-video-meeting-sessions-only-supported-for-cal-video) | +| `BOOKING_WRITE` | Create, edit, and delete bookings | [Add guests to a booking](https://cal.com/docs/api-reference/v2/bookings-guests/add-guests-to-an-existing-booking),
[Update booking location](https://cal.com/docs/api-reference/v2/bookings/update-booking-location-for-an-existing-booking),
[Mark a booking absence](https://cal.com/docs/api-reference/v2/bookings/mark-a-booking-absence),
[Reassign to auto-selected host](https://cal.com/docs/api-reference/v2/bookings/reassign-a-booking-to-auto-selected-host),
[Reassign to a specific host](https://cal.com/docs/api-reference/v2/bookings/reassign-a-booking-to-a-specific-host),
[Confirm a booking](https://cal.com/docs/api-reference/v2/bookings/confirm-a-booking),
[Decline a booking](https://cal.com/docs/api-reference/v2/bookings/decline-a-booking) | +| `SCHEDULE_READ` | View availability | [Get all schedules](https://cal.com/docs/api-reference/v2/schedules/get-all-schedules),
[Get a schedule](https://cal.com/docs/api-reference/v2/schedules/get-a-schedule),
[Get default schedule](https://cal.com/docs/api-reference/v2/schedules/get-default-schedule) | +| `SCHEDULE_WRITE` | Create, edit, and delete availability | [Create a schedule](https://cal.com/docs/api-reference/v2/schedules/create-a-schedule),
[Update a schedule](https://cal.com/docs/api-reference/v2/schedules/update-a-schedule),
[Delete a schedule](https://cal.com/docs/api-reference/v2/schedules/delete-a-schedule) | +| `APPS_READ` | View connected apps | [Check a calendar connection](https://cal.com/docs/api-reference/v2/calendars/check-a-calendar-connection) | +| `APPS_WRITE` | Connect and disconnect apps | No endpoints currently use this scope | +| `PROFILE_READ` | View personal info | [Get my profile](https://cal.com/docs/api-reference/v2/me/get-my-profile) | +| `PROFILE_WRITE` | Edit personal info | [Update my profile](https://cal.com/docs/api-reference/v2/me/update-my-profile) | + + + Some endpoints like `POST /v2/bookings` (create), `POST /v2/bookings/:bookingUid/cancel` (cancel), and `POST /v2/bookings/:bookingUid/reschedule` (reschedule) are public endpoints that do not require any scope. + + ## 2. Authorize To initiate the OAuth flow, direct users to the following authorization URL: -`https://app.cal.com/auth/oauth2/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URI&state=YOUR_STATE` +`https://app.cal.com/auth/oauth2/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URI&state=YOUR_STATE&scope=BOOKING_READ%20BOOKING_WRITE` **URL Parameters:** @@ -41,6 +62,7 @@ To initiate the OAuth flow, direct users to the following authorization URL: | `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 | +| `scope` | Yes | Space or comma-separated list of scopes to request (e.g. `BOOKING_READ BOOKING_WRITE` or `BOOKING_READ,BOOKING_WRITE`). Must be a subset of scopes enabled on the OAuth client. | | `code_challenge` | For public clients | PKCE code challenge (S256 method) | After users click **Allow**, they will be redirected to the `redirect_uri` with `code` (authorization code) and `state` as URL parameters: @@ -57,10 +79,15 @@ Errors during the authorization step are displayed directly to the user on the C - **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: +If an error occurs after the client is validated, the user is redirected to the `redirect_uri` with an error: + +- **Scope required**: If the `scope` parameter is missing, the error `scope parameter is required for this OAuth client` is displayed on the authorization page. +- **Unknown scope**: If the `scope` parameter includes scope values that do not exist, the user is redirected with `error=invalid_scope` and `error_description=Requested scope is not a recognized scope`. This applies to both regular and legacy clients. +- **Invalid scope**: If the `scope` parameter includes scopes not enabled on the OAuth client, the user is redirected with `error=invalid_request` and `error_description=Requested scope exceeds the client's registered scopes`. +- **Access denied**: If the user denies access or has insufficient permissions, the user is redirected with an error. ``` -https://your-app.com/callback?error=access_denied&error_description=team_not_found_or_no_access&state=YOUR_STATE +https://your-app.com/callback?error=invalid_request&error_description=Requested+scope+exceeds+the+client%27s+registered+scopes&state=YOUR_STATE ``` ## 3. Exchange Token @@ -198,12 +225,13 @@ Public clients (e.g. single-page apps, mobile apps) use PKCE instead of a `clien "access_token": "eyJhbGciOiJIUzI1NiIs...", "refresh_token": "eyJhbGciOiJIUzI1NiIs...", "token_type": "bearer", - "expires_in": 1800 + "expires_in": 1800, + "scope": "BOOKING_READ BOOKING_WRITE" } ``` - Access tokens expire after 30 minutes (`expires_in: 1800`). Use the refresh token to obtain a new access token. + Access tokens expire after 30 minutes (`expires_in: 1800`). Use the refresh token to obtain a new access token. The `scope` field contains the granted scopes as a space-separated string. #### Error Responses @@ -381,10 +409,15 @@ Public clients do not use a `client_secret`. All parameters are required: "access_token": "eyJhbGciOiJIUzI1NiIs...", "refresh_token": "eyJhbGciOiJIUzI1NiIs...", "token_type": "bearer", - "expires_in": 1800 + "expires_in": 1800, + "scope": "BOOKING_READ BOOKING_WRITE" } ``` + + Scopes are preserved from the original authorization. You do not need to re-request scopes when refreshing tokens. + + #### Error Responses @@ -417,6 +450,42 @@ Public clients do not use a `client_secret`. All parameters are required: +## Legacy Client Migration + +If your OAuth client was created before scopes were introduced, it is considered a **legacy client**. A client is treated as legacy if it has no scopes configured, or if it only has the old legacy scope values (`READ_BOOKING` and/or `READ_PROFILE`). Access tokens issued by legacy clients can access any resource on behalf of the authorizing user — scopes are not enforced. + +You can migrate a legacy client to use explicit scopes without creating a new client. **Order matters** — follow these steps to avoid breaking existing integrations: + +### Step 1: Update your authorization URL + +Add a `scope` query parameter to your authorization URL **before** changing any client settings. Legacy clients skip scope validation during authorization, so users can already authorize with a scope parameter even while the client is still in legacy mode. + +``` +https://app.cal.com/auth/oauth2/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URI&state=YOUR_STATE&scope=BOOKING_READ%20BOOKING_WRITE +``` + +New access tokens issued after this change will carry only the scopes you specified. For the full list of available scopes, see [Available Scopes](#available-scopes). + +### Step 2: Update client scopes in settings + +Once your authorization URL is updated and you have verified that new tokens are being issued with the correct scopes, open your OAuth client settings and select the matching scopes. Save the client. + +After this step, the client is no longer treated as a legacy client. Scope validation is enforced for all new authorization requests. + + + Do **not** update the client scopes before updating your authorization URL. Doing so will immediately break the authorization flow for any user who visits the old URL without a `scope` parameter. + + +### Re-approval + +Updating scopes on a legacy client does **not** trigger re-approval as long as you only add user-level scopes (such as `BOOKING_READ`, `EVENT_TYPE_READ`, etc.). The client stays in its current approval status and no admin review is required. + +### Existing tokens + +Tokens issued before the migration continue to work until users re-authorize. There is no forced invalidation of existing tokens during the migration. + +--- + ## 5. Verify Access Token To verify the correct setup and functionality of OAuth credentials, use the following endpoint: diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index 007be244f68aa1..afeb9e5b513d23 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -812,7 +812,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": false, "schema": { "type": "string" @@ -7687,7 +7687,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": false, "schema": { "type": "string" @@ -9016,7 +9016,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": false, "schema": { "type": "string" @@ -9304,7 +9304,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -9353,7 +9353,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": false, "schema": { "type": "string" @@ -9420,7 +9420,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": false, "schema": { "type": "string" @@ -9487,7 +9487,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -9536,7 +9536,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -9585,7 +9585,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": false, "schema": { "type": "string" @@ -9670,7 +9670,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": false, "schema": { "type": "string" @@ -9755,7 +9755,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -9814,7 +9814,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -9871,7 +9871,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -9930,7 +9930,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -9979,7 +9979,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -10038,7 +10038,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -10105,7 +10105,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -10154,7 +10154,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -10202,7 +10202,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -10260,7 +10260,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -10319,7 +10319,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -10368,7 +10368,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -10408,7 +10408,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -10448,7 +10448,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -10536,7 +10536,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -10566,7 +10566,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -10597,7 +10597,7 @@ "name": "Authorization", "required": true, "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "schema": { "type": "string" } @@ -10700,7 +10700,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -10742,7 +10742,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -10781,7 +10781,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -10831,7 +10831,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -10862,7 +10862,7 @@ "name": "Authorization", "required": true, "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "schema": { "type": "string" } @@ -10957,7 +10957,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -10997,7 +10997,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -11027,7 +11027,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -11067,7 +11067,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -11923,7 +11923,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -11974,7 +11974,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -12078,7 +12078,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": false, "schema": { "type": "string" @@ -12145,7 +12145,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -12192,7 +12192,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -12249,7 +12249,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -12279,7 +12279,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -12325,7 +12325,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -12386,7 +12386,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -12424,7 +12424,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -12470,7 +12470,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -12506,7 +12506,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -12552,7 +12552,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -12598,7 +12598,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -12644,7 +12644,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -12698,7 +12698,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -13045,7 +13045,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -13074,7 +13074,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -13190,7 +13190,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -13231,7 +13231,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -13280,7 +13280,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -13329,7 +13329,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -13402,7 +13402,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -13465,7 +13465,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -13511,7 +13511,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -13557,7 +13557,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -13688,7 +13688,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -13737,7 +13737,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -13778,7 +13778,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -13819,7 +13819,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -13866,7 +13866,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -13923,7 +13923,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -13971,7 +13971,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -14009,7 +14009,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -14309,7 +14309,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": false, "schema": { "type": "string" @@ -14488,7 +14488,7 @@ "name": "Authorization", "required": true, "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "schema": { "type": "string" } @@ -14554,7 +14554,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -15824,7 +15824,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -15888,7 +15888,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -15929,7 +15929,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -15978,7 +15978,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -16027,7 +16027,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -16100,7 +16100,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -16163,7 +16163,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -16209,7 +16209,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -16255,7 +16255,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -16286,7 +16286,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -16327,7 +16327,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -16368,7 +16368,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -16409,7 +16409,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -16474,7 +16474,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -16529,7 +16529,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -16567,7 +16567,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -16605,7 +16605,7 @@ { "name": "Authorization", "in": "header", - "description": "value must be `Bearer ` where `` is api key prefixed with cal_ or managed user access token", + "description": "value must be `Bearer ` where `` is api key prefixed with cal_, managed user access token, or OAuth access token", "required": true, "schema": { "type": "string" @@ -17035,9 +17035,14 @@ "type": "number", "description": "The number of seconds until the access token expires", "example": 1800 + }, + "scope": { + "type": "string", + "description": "The granted scopes (space-delimited per RFC 6749)", + "example": "BOOKING_READ BOOKING_WRITE" } }, - "required": ["access_token", "token_type", "refresh_token", "expires_in"] + "required": ["access_token", "token_type", "refresh_token", "expires_in", "scope"] }, "ManagedUserOutput": { "type": "object", diff --git a/packages/features/oauth/constants.ts b/packages/features/oauth/constants.ts new file mode 100644 index 00000000000000..628d179afa5519 --- /dev/null +++ b/packages/features/oauth/constants.ts @@ -0,0 +1,72 @@ +import { + APPS_READ, + APPS_WRITE, + BOOKING_READ, + BOOKING_WRITE, + EVENT_TYPE_READ, + EVENT_TYPE_WRITE, + PROFILE_READ, + PROFILE_WRITE, + SCHEDULE_READ, + SCHEDULE_WRITE, +} from "@calcom/platform-constants"; +import type { AccessScope } from "@calcom/prisma/enums"; + +export const OAUTH_SCOPES: AccessScope[] = [ + "EVENT_TYPE_READ", + "EVENT_TYPE_WRITE", + "BOOKING_READ", + "BOOKING_WRITE", + "SCHEDULE_READ", + "SCHEDULE_WRITE", + "APPS_READ", + "APPS_WRITE", + "PROFILE_READ", + "PROFILE_WRITE", +]; + +export const SCOPE_EXCEEDS_CLIENT_REGISTRATION_ERROR = + "Requested scope exceeds the client's registered scopes"; + +export function parseScopeParam(scopeParam: string | null | undefined): string[] { + if (!scopeParam) { + return []; + } + return scopeParam.split(/[, ]+/).filter(Boolean); +} + +export function isLegacyScope(scope: string): boolean { + return scope === "READ_BOOKING" || scope === "READ_PROFILE"; +} + +export function isLegacyClient(clientScopes: string[]): boolean { + return clientScopes.length === 0 || clientScopes.every(isLegacyScope); +} + +export type NewAccessScope = Exclude; + +export const SCOPE_TO_PERMISSION: Record = { + EVENT_TYPE_READ: EVENT_TYPE_READ, + EVENT_TYPE_WRITE: EVENT_TYPE_WRITE, + BOOKING_READ: BOOKING_READ, + BOOKING_WRITE: BOOKING_WRITE, + SCHEDULE_READ: SCHEDULE_READ, + SCHEDULE_WRITE: SCHEDULE_WRITE, + APPS_READ: APPS_READ, + APPS_WRITE: APPS_WRITE, + PROFILE_READ: PROFILE_READ, + PROFILE_WRITE: PROFILE_WRITE, +}; + +export const PERMISSION_TO_SCOPE: Record = { + [EVENT_TYPE_READ]: "EVENT_TYPE_READ", + [EVENT_TYPE_WRITE]: "EVENT_TYPE_WRITE", + [BOOKING_READ]: "BOOKING_READ", + [BOOKING_WRITE]: "BOOKING_WRITE", + [SCHEDULE_READ]: "SCHEDULE_READ", + [SCHEDULE_WRITE]: "SCHEDULE_WRITE", + [APPS_READ]: "APPS_READ", + [APPS_WRITE]: "APPS_WRITE", + [PROFILE_READ]: "PROFILE_READ", + [PROFILE_WRITE]: "PROFILE_WRITE", +}; diff --git a/packages/features/oauth/repositories/OAuthClientRepository.ts b/packages/features/oauth/repositories/OAuthClientRepository.ts index 91cfde2affccce..93c9469a6b7744 100644 --- a/packages/features/oauth/repositories/OAuthClientRepository.ts +++ b/packages/features/oauth/repositories/OAuthClientRepository.ts @@ -1,7 +1,7 @@ import { randomBytes } from "node:crypto"; import type { PrismaClient } from "@calcom/prisma"; -import type { OAuthClientStatus } from "@calcom/prisma/enums"; +import type { AccessScope, OAuthClientStatus } from "@calcom/prisma/enums"; export class OAuthClientRepository { constructor(private readonly prisma: PrismaClient) {} @@ -23,6 +23,7 @@ export class OAuthClientRepository { rejectionReason: true, status: true, userId: true, + scopes: true, createdAt: true, }, }); @@ -57,6 +58,7 @@ export class OAuthClientRepository { isTrusted: true, status: true, userId: true, + scopes: true, createdAt: true, user: { select: { @@ -83,6 +85,7 @@ export class OAuthClientRepository { clientType: true, status: true, userId: true, + scopes: true, createdAt: true, }, orderBy: { createdAt: "desc" }, @@ -109,6 +112,7 @@ export class OAuthClientRepository { clientType: true, status: true, userId: true, + scopes: true, createdAt: true, user: { select: { @@ -136,6 +140,7 @@ export class OAuthClientRepository { clientType: true, status: true, userId: true, + scopes: true, createdAt: true, user: { select: { @@ -157,10 +162,12 @@ export class OAuthClientRepository { logo?: string; websiteUrl?: string; enablePkce?: boolean; + scopes?: AccessScope[]; userId?: number; status: OAuthClientStatus; }) { - const { name, purpose, redirectUri, clientSecret, logo, websiteUrl, enablePkce, userId, status } = data; + const { name, purpose, redirectUri, clientSecret, logo, websiteUrl, enablePkce, scopes, userId, status } = + data; const clientId = randomBytes(32).toString("hex"); @@ -175,6 +182,7 @@ export class OAuthClientRepository { websiteUrl, status, clientSecret, + ...(scopes && { scopes }), ...(userId && { user: { connect: { id: userId }, diff --git a/packages/features/oauth/services/OAuthService.ts b/packages/features/oauth/services/OAuthService.ts index b482e87e8a1dbc..e61c83f16df99b 100644 --- a/packages/features/oauth/services/OAuthService.ts +++ b/packages/features/oauth/services/OAuthService.ts @@ -1,17 +1,18 @@ import { randomBytes } from "node:crypto"; import process from "node:process"; import type { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository"; +import { isLegacyClient, OAUTH_SCOPES, parseScopeParam, SCOPE_EXCEEDS_CLIENT_REGISTRATION_ERROR } from "@calcom/features/oauth/constants"; import type { AccessCodeRepository } from "@calcom/features/oauth/repositories/AccessCodeRepository"; import type { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository"; import type { OAuthRefreshTokenRepository } from "@calcom/features/oauth/repositories/OAuthRefreshTokenRepository"; import { generateSecret } from "@calcom/features/oauth/utils/generateSecret"; import { ErrorCode } from "@calcom/lib/errorCodes"; import { ErrorWithCode } from "@calcom/lib/errors"; +import logger from "@calcom/lib/logger"; import { verifyCodeChallenge } from "@calcom/lib/pkce"; import type { AccessScope, OAuthClientType } from "@calcom/prisma/enums"; import { OAuthClientStatus } from "@calcom/prisma/enums"; import jwt from "jsonwebtoken"; -import logger from "@calcom/lib/logger"; export interface OAuth2Client { clientId: string; @@ -20,6 +21,7 @@ export interface OAuth2Client { logo: string | null; isTrusted: boolean; clientType: OAuthClientType; + scopes: AccessScope[]; } export interface OAuth2Tokens { @@ -27,6 +29,7 @@ export interface OAuth2Tokens { tokenType: string; refreshToken: string; expiresIn: number; + scope: string; } export interface AuthorizeResult { @@ -85,13 +88,15 @@ export class OAuthService { logo: client.logo, isTrusted: client.isTrusted, clientType: client.clientType, + scopes: client.scopes, }; } async getClientForAuthorization( clientId: string, redirectUri: string, - loggedInUserId?: number + loggedInUserId?: number, + scopeParam?: string ): Promise { const client = await this.oAuthClientRepository.findByClientId(clientId); @@ -103,6 +108,19 @@ export class OAuthService { this.ensureClientAccessAllowed(client, loggedInUserId); + if (!isLegacyClient(client.scopes)) { + const requestedScopes = parseScopeParam(scopeParam); + if (requestedScopes.length === 0) { + throw new ErrorWithCode(ErrorCode.BadRequest, "invalid_scope", { reason: "scope_required" }); + } + this.validateRequestedScopes(client.scopes, requestedScopes); + } else { + const requestedScopes = parseScopeParam(scopeParam); + if (requestedScopes.length > 0) { + this.validateScopesAreKnown(requestedScopes); + } + } + return { clientId: client.clientId, redirectUri: client.redirectUri, @@ -110,6 +128,7 @@ export class OAuthService { logo: client.logo, isTrusted: client.isTrusted, clientType: client.clientType, + scopes: client.scopes, }; } @@ -117,7 +136,7 @@ export class OAuthService { clientId: string, loggedInUserId: number, redirectUri: string, - scopes: AccessScope[], + requestedScopes: AccessScope[], state?: string, teamSlug?: string, codeChallenge?: string, @@ -131,9 +150,14 @@ export class OAuthService { this.ensureClientAccessAllowed(client, loggedInUserId); - // RFC 6749 4.1.2.1: Redirect URI mismatch on Auth step is 'invalid_request' this.validateRedirectUri(client.redirectUri, redirectUri); + if (!isLegacyClient(client.scopes)) { + this.validateRequestedScopes(client.scopes, requestedScopes); + } else if (requestedScopes.length > 0) { + this.validateScopesAreKnown(requestedScopes); + } + if (client.clientType === "PUBLIC") { if (!codeChallenge) { throw new ErrorWithCode(ErrorCode.BadRequest, "invalid_request", { reason: "pkce_required" }); @@ -155,7 +179,6 @@ export class OAuthService { if (teamSlug) { const team = await this.teamsRepository.findTeamBySlugWithAdminRole(teamSlug, loggedInUserId); if (!team) { - // Specific OAuth error for user denying or failing permission throw new ErrorWithCode(ErrorCode.Unauthorized, "access_denied", { reason: "team_not_found_or_no_access", }); @@ -170,7 +193,7 @@ export class OAuthService { clientId, userId: teamSlug ? undefined : loggedInUserId, teamId, - scopes, + scopes: requestedScopes, codeChallenge, codeChallengeMethod, }); @@ -180,7 +203,19 @@ export class OAuthService { state, }); - return { redirectUrl, authorizationCode, client }; + return { + redirectUrl, + authorizationCode, + client: { + clientId: client.clientId, + redirectUri: client.redirectUri, + name: client.name, + logo: client.logo, + isTrusted: client.isTrusted, + clientType: client.clientType, + scopes: client.scopes, + }, + }; } private ensureClientAccessAllowed( @@ -213,6 +248,26 @@ export class OAuthService { } } + private validateScopesAreKnown(requestedScopes: string[]): void { + const unknownScopes = requestedScopes.filter( + (scope) => !OAUTH_SCOPES.includes(scope as AccessScope) + ); + if (unknownScopes.length > 0) { + throw new ErrorWithCode(ErrorCode.BadRequest, "invalid_scope", { reason: "unknown_scope" }); + } + } + + private validateRequestedScopes(clientScopes: AccessScope[], requestedScopes: string[]): void { + const invalidScopes = requestedScopes.filter( + (requestedScope) => !clientScopes.includes(requestedScope as AccessScope) + ); + if (invalidScopes.length > 0) { + throw new ErrorWithCode(ErrorCode.BadRequest, "invalid_scope", { + reason: "scope_exceeds_client_registration", + }); + } + } + buildRedirectUrl(baseUrl: string, params: Record): string { const url = new URL(baseUrl); for (const [key, value] of Object.entries(params)) { @@ -290,7 +345,6 @@ export class OAuthService { throw new ErrorWithCode(ErrorCode.Unauthorized, "invalid_client", { reason: "client_not_found" }); } - // RFC 6749 5.2: Redirect URI mismatch during Token exchange is 'invalid_grant' if (redirectUri && client.redirectUri !== redirectUri) { throw new ErrorWithCode(ErrorCode.BadRequest, "invalid_grant", { reason: "redirect_uri_mismatch" }); } @@ -313,7 +367,6 @@ export class OAuthService { const pkceError = this.verifyPKCE(client, accessCode, codeVerifier); if (pkceError) { - // RFC 7636 4.4.1: If verification fails, return 'invalid_grant' throw new ErrorWithCode(ErrorCode.BadRequest, pkceError.error, { reason: pkceError.reason }); } @@ -431,12 +484,10 @@ export class OAuthService { const method = source.codeChallengeMethod || "S256"; - // Structural missing params if (!source.codeChallenge || !codeVerifier || method !== "S256") { return { error: "invalid_request", reason: "pkce_missing_parameters_or_invalid_method" }; } - // Logical mismatch if (!verifyCodeChallenge(codeVerifier, source.codeChallenge, method)) { return { error: "invalid_grant", reason: "pkce_verification_failed" }; } @@ -503,6 +554,7 @@ export class OAuthService { tokenType: "bearer", refreshToken, expiresIn: accessTokenExpiresIn, + scope: input.scopes.join(" "), refreshTokenSecret, refreshTokenExpiresIn, }; @@ -541,7 +593,10 @@ export type OAuthErrorReason = | "invalid_refresh_token" | "refresh_token_revoked" | "client_id_mismatch" - | "encryption_key_missing"; + | "encryption_key_missing" + | "scope_exceeds_client_registration" + | "scope_required" + | "unknown_scope"; // Mapping of OAuth error reasons to descriptive messages, keeping previous messages for compatibility export const OAUTH_ERROR_REASONS: Record = { @@ -561,4 +616,7 @@ export const OAUTH_ERROR_REASONS: Record = { refresh_token_revoked: "invalid_grant", client_id_mismatch: "invalid_grant", encryption_key_missing: "CALENDSO_ENCRYPTION_KEY is not set", + scope_exceeds_client_registration: SCOPE_EXCEEDS_CLIENT_REGISTRATION_ERROR, + scope_required: "scope parameter is required for this OAuth client", + unknown_scope: "Requested scope is not a recognized scope", }; diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index 1d146450766a8e..6c72f3a199a0ae 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -1097,6 +1097,7 @@ "oauth_client_submitted": "OAuth Client Submitted", "oauth_client_submitted_description": "Your OAuth client has been submitted for approval. You will receive an email if it is approved or rejected. The OAuth client can't be used unless approved.", "oauth_client_submit_error": "Failed to submit OAuth client", + "oauth_client_scope_required": "At least one scope must be selected", "oauth_client_created": "OAuth Client Created", "oauth_client_created_description": "Your OAuth client has been created successfully", "oauth_client_create_error": "Failed to create OAuth client", @@ -1126,6 +1127,24 @@ "website_url_tooltip": "For development, you can use a localhost URL (e.g. http://localhost:3000).", "authentication_mode": "Authentication Mode", "use_pkce": "Use PKCE (recommended for mobile/SPA applications)", + "oauth_scopes": "Scopes", + "oauth_scopes_description": "Select the permissions your OAuth client needs", + "legacy_oauth_client_scopes_warning": "This is a legacy OAuth client — scopes are not enforced and access tokens can access any resource. Do not modify scopes here before reading the migration guide:", + "oauth_scope_event_type_read": "View event types", + "oauth_scope_event_type_write": "Create, edit, and delete event types", + "oauth_scope_booking_read": "View bookings", + "oauth_scope_booking_write": "Create, edit, and delete bookings", + "oauth_scope_schedule_read": "View availability", + "oauth_scope_schedule_write": "Create, edit, and delete availability", + "oauth_scope_profile_read": "View personal info and primary email address", + "oauth_scope_event_type_read_write": "Create, read, update, and delete event types", + "oauth_scope_booking_read_write": "Create, read, update, and delete bookings", + "oauth_scope_schedule_read_write": "Create, read, update, and delete availability", + "oauth_scope_apps_read": "View connected apps", + "oauth_scope_apps_write": "Connect and disconnect apps", + "oauth_scope_apps_read_write": "View, connect, and disconnect apps", + "oauth_scope_profile_write": "Edit personal info", + "oauth_scope_profile_read_write": "View and edit personal info", "upload_logo": "Upload Logo", "client_id": "Client ID", "client_secret": "Client Secret", diff --git a/packages/platform/libraries/index.ts b/packages/platform/libraries/index.ts index 96e9dd0c3ab292..6de96c6b2f78cf 100644 --- a/packages/platform/libraries/index.ts +++ b/packages/platform/libraries/index.ts @@ -126,6 +126,8 @@ 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 type { NewAccessScope } from "@calcom/features/oauth/constants"; +export { SCOPE_TO_PERMISSION, PERMISSION_TO_SCOPE } from "@calcom/features/oauth/constants"; export { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository"; export type { Tasker } from "@calcom/features/tasker/tasker"; export { getTasker } from "@calcom/features/tasker/tasker-factory"; diff --git a/packages/prisma/migrations/20260205131043_add_oauth_client_scopes/migration.sql b/packages/prisma/migrations/20260205131043_add_oauth_client_scopes/migration.sql new file mode 100644 index 00000000000000..8c8d2465b15a4c --- /dev/null +++ b/packages/prisma/migrations/20260205131043_add_oauth_client_scopes/migration.sql @@ -0,0 +1,21 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "public"."AccessScope" ADD VALUE 'EVENT_TYPE_READ'; +ALTER TYPE "public"."AccessScope" ADD VALUE 'EVENT_TYPE_WRITE'; +ALTER TYPE "public"."AccessScope" ADD VALUE 'BOOKING_READ'; +ALTER TYPE "public"."AccessScope" ADD VALUE 'BOOKING_WRITE'; +ALTER TYPE "public"."AccessScope" ADD VALUE 'SCHEDULE_READ'; +ALTER TYPE "public"."AccessScope" ADD VALUE 'SCHEDULE_WRITE'; +ALTER TYPE "public"."AccessScope" ADD VALUE 'APPS_READ'; +ALTER TYPE "public"."AccessScope" ADD VALUE 'APPS_WRITE'; +ALTER TYPE "public"."AccessScope" ADD VALUE 'PROFILE_READ'; +ALTER TYPE "public"."AccessScope" ADD VALUE 'PROFILE_WRITE'; + +-- AlterTable +ALTER TABLE "public"."OAuthClient" ADD COLUMN "scopes" "public"."AccessScope"[]; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index c17f14f13b8f76..d9f842b1c29b97 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -1853,6 +1853,7 @@ model OAuthClient { status OAuthClientStatus @default(APPROVED) userId Int? user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + scopes AccessScope[] createdAt DateTime @default(now()) refreshTokens OAuthRefreshToken[] @@ -1893,8 +1894,20 @@ model OAuthRefreshToken { } enum AccessScope { + // note(Lauris): legacy values (kept for backward compatibility with existing tokens/access codes) READ_BOOKING READ_PROFILE + // note(Lauris): new scope values + EVENT_TYPE_READ + EVENT_TYPE_WRITE + BOOKING_READ + BOOKING_WRITE + SCHEDULE_READ + SCHEDULE_WRITE + APPS_READ + APPS_WRITE + PROFILE_READ + PROFILE_WRITE } view BookingTimeStatus { diff --git a/packages/trpc/server/routers/viewer/oAuth/createClient.handler.ts b/packages/trpc/server/routers/viewer/oAuth/createClient.handler.ts index c61550c18d00d0..2e7be93b693846 100644 --- a/packages/trpc/server/routers/viewer/oAuth/createClient.handler.ts +++ b/packages/trpc/server/routers/viewer/oAuth/createClient.handler.ts @@ -1,6 +1,6 @@ import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository"; -import type { PrismaClient } from "@calcom/prisma"; import { generateSecret } from "@calcom/features/oauth/utils/generateSecret"; +import type { PrismaClient } from "@calcom/prisma"; import type { TCreateClientInputSchema } from "./createClient.schema"; type AddClientOptions = { @@ -11,7 +11,7 @@ type AddClientOptions = { }; export const createClientHandler = async ({ ctx, input }: AddClientOptions) => { - const { name, purpose, redirectUri, logo, websiteUrl, enablePkce } = input; + const { name, purpose, redirectUri, logo, websiteUrl, enablePkce, scopes } = input; const oAuthClientRepository = new OAuthClientRepository(ctx.prisma); @@ -31,6 +31,7 @@ export const createClientHandler = async ({ ctx, input }: AddClientOptions) => { logo, websiteUrl, enablePkce, + scopes, status: "APPROVED", }); diff --git a/packages/trpc/server/routers/viewer/oAuth/createClient.schema.ts b/packages/trpc/server/routers/viewer/oAuth/createClient.schema.ts index 2f070f277102e8..81597f26d0bea3 100644 --- a/packages/trpc/server/routers/viewer/oAuth/createClient.schema.ts +++ b/packages/trpc/server/routers/viewer/oAuth/createClient.schema.ts @@ -1,3 +1,4 @@ +import { AccessScopeSchema } from "@calcom/prisma/zod/inputTypeSchemas"; import { z } from "zod"; export type TCreateClientInputSchemaInput = { @@ -7,6 +8,7 @@ export type TCreateClientInputSchemaInput = { logo?: string; websiteUrl?: string; enablePkce?: boolean; + scopes: z.infer[]; }; export type TCreateClientInputSchema = { @@ -16,6 +18,7 @@ export type TCreateClientInputSchema = { logo?: string; websiteUrl?: string; enablePkce: boolean; + scopes: z.infer[]; }; export const ZCreateClientInputSchema: z.ZodType< @@ -35,4 +38,5 @@ export const ZCreateClientInputSchema: z.ZodType< .optional() .transform((value) => (value === "" ? undefined : value)), enablePkce: z.boolean().optional().default(false), + scopes: z.array(AccessScopeSchema).min(1, "At least one scope is required"), }); diff --git a/packages/trpc/server/routers/viewer/oAuth/getClientForAuthorization.handler.ts b/packages/trpc/server/routers/viewer/oAuth/getClientForAuthorization.handler.ts index 2b9abb8b7bbf15..8928a7381a6e1a 100644 --- a/packages/trpc/server/routers/viewer/oAuth/getClientForAuthorization.handler.ts +++ b/packages/trpc/server/routers/viewer/oAuth/getClientForAuthorization.handler.ts @@ -17,11 +17,16 @@ type GetClientForAuthorizationOptions = { export const getClientForAuthorizationHandler = async ({ ctx, input }: GetClientForAuthorizationOptions) => { try { - const { clientId, redirectUri } = input; + const { clientId, redirectUri, scope } = input; const oAuthService = getOAuthService(); - const oAuthClient = await oAuthService.getClientForAuthorization(clientId, redirectUri, ctx.user.id); + const oAuthClient = await oAuthService.getClientForAuthorization( + clientId, + redirectUri, + ctx.user.id, + scope + ); return { clientId: oAuthClient.clientId, @@ -29,6 +34,7 @@ export const getClientForAuthorizationHandler = async ({ ctx, input }: GetClient name: oAuthClient.name, logo: oAuthClient.logo, isTrusted: oAuthClient.isTrusted, + scopes: oAuthClient.scopes, }; } catch (error) { if (error instanceof ErrorWithCode) { diff --git a/packages/trpc/server/routers/viewer/oAuth/getClientForAuthorization.schema.ts b/packages/trpc/server/routers/viewer/oAuth/getClientForAuthorization.schema.ts index 53cefa1cc52cb0..3d6fc5dc088f14 100644 --- a/packages/trpc/server/routers/viewer/oAuth/getClientForAuthorization.schema.ts +++ b/packages/trpc/server/routers/viewer/oAuth/getClientForAuthorization.schema.ts @@ -3,6 +3,7 @@ import { z } from "zod"; export const ZGetClientForAuthorizationInputSchema = z.object({ clientId: z.string(), redirectUri: z.string().url("Must be a valid URL"), + scope: z.string().optional(), }); export type TGetClientForAuthorizationInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/oAuth/hasScopeExpansion.test.ts b/packages/trpc/server/routers/viewer/oAuth/hasScopeExpansion.test.ts new file mode 100644 index 00000000000000..3b176062039eb9 --- /dev/null +++ b/packages/trpc/server/routers/viewer/oAuth/hasScopeExpansion.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from "vitest"; + +import { AccessScope } from "@calcom/prisma/enums"; + +import { hasScopeExpansion } from "./hasScopeExpansion"; + +describe("hasScopeExpansion", () => { + describe("identical scopes", () => { + it("returns false when both arrays are empty", () => { + expect(hasScopeExpansion([], [])).toBe(false); + }); + + it("returns false when new scopes are identical to current scopes", () => { + expect( + hasScopeExpansion( + [AccessScope.BOOKING_READ, AccessScope.SCHEDULE_READ], + [AccessScope.BOOKING_READ, AccessScope.SCHEDULE_READ] + ) + ).toBe(false); + }); + + it("returns false when new scopes are identical but in different order", () => { + expect( + hasScopeExpansion( + [AccessScope.SCHEDULE_READ, AccessScope.BOOKING_READ], + [AccessScope.BOOKING_READ, AccessScope.SCHEDULE_READ] + ) + ).toBe(false); + }); + }); + + describe("adding new scopes", () => { + it("returns true when adding a scope to an empty set", () => { + expect(hasScopeExpansion([], [AccessScope.BOOKING_READ])).toBe(true); + }); + + it("returns true when adding a new READ scope for a different resource", () => { + expect( + hasScopeExpansion([AccessScope.BOOKING_READ], [AccessScope.BOOKING_READ, AccessScope.SCHEDULE_READ]) + ).toBe(true); + }); + + it("returns true when adding a new WRITE scope for a different resource", () => { + expect( + hasScopeExpansion([AccessScope.BOOKING_READ], [AccessScope.BOOKING_READ, AccessScope.SCHEDULE_WRITE]) + ).toBe(true); + }); + + it("returns true when adding an entirely unrelated scope", () => { + expect( + hasScopeExpansion([AccessScope.EVENT_TYPE_READ], [AccessScope.EVENT_TYPE_READ, AccessScope.APPS_READ]) + ).toBe(true); + }); + }); + + describe("removing scopes", () => { + it("returns false when removing a scope", () => { + expect( + hasScopeExpansion( + [AccessScope.BOOKING_READ, AccessScope.SCHEDULE_READ], + [AccessScope.BOOKING_READ] + ) + ).toBe(false); + }); + + it("returns false when removing all scopes", () => { + expect(hasScopeExpansion([AccessScope.BOOKING_READ, AccessScope.SCHEDULE_WRITE], [])).toBe(false); + }); + + it("returns false when removing multiple scopes", () => { + expect( + hasScopeExpansion( + [AccessScope.BOOKING_READ, AccessScope.SCHEDULE_READ, AccessScope.APPS_READ], + [AccessScope.BOOKING_READ] + ) + ).toBe(false); + }); + }); + + describe("READ/WRITE demotion (same resource)", () => { + it("returns false when demoting BOOKING_WRITE to BOOKING_READ", () => { + expect(hasScopeExpansion([AccessScope.BOOKING_WRITE], [AccessScope.BOOKING_READ])).toBe(false); + }); + + it("returns false when demoting EVENT_TYPE_WRITE to EVENT_TYPE_READ", () => { + expect(hasScopeExpansion([AccessScope.EVENT_TYPE_WRITE], [AccessScope.EVENT_TYPE_READ])).toBe(false); + }); + + it("returns false when demoting SCHEDULE_WRITE to SCHEDULE_READ", () => { + expect(hasScopeExpansion([AccessScope.SCHEDULE_WRITE], [AccessScope.SCHEDULE_READ])).toBe(false); + }); + + it("returns false when demoting APPS_WRITE to APPS_READ", () => { + expect(hasScopeExpansion([AccessScope.APPS_WRITE], [AccessScope.APPS_READ])).toBe(false); + }); + + it("returns false when demoting PROFILE_WRITE to PROFILE_READ", () => { + expect(hasScopeExpansion([AccessScope.PROFILE_WRITE], [AccessScope.PROFILE_READ])).toBe(false); + }); + }); + + describe("adding READ alongside existing WRITE (same resource)", () => { + it("returns false when adding BOOKING_READ alongside BOOKING_WRITE", () => { + expect( + hasScopeExpansion([AccessScope.BOOKING_WRITE], [AccessScope.BOOKING_WRITE, AccessScope.BOOKING_READ]) + ).toBe(false); + }); + + it("returns false when adding SCHEDULE_READ alongside SCHEDULE_WRITE", () => { + expect( + hasScopeExpansion( + [AccessScope.SCHEDULE_WRITE], + [AccessScope.SCHEDULE_WRITE, AccessScope.SCHEDULE_READ] + ) + ).toBe(false); + }); + + it("returns false when adding APPS_READ alongside APPS_WRITE", () => { + expect( + hasScopeExpansion([AccessScope.APPS_WRITE], [AccessScope.APPS_WRITE, AccessScope.APPS_READ]) + ).toBe(false); + }); + }); + + describe("READ to WRITE upgrade (same resource)", () => { + it("returns true when upgrading BOOKING_READ to BOOKING_WRITE", () => { + expect(hasScopeExpansion([AccessScope.BOOKING_READ], [AccessScope.BOOKING_WRITE])).toBe(true); + }); + + it("returns true when upgrading SCHEDULE_READ to SCHEDULE_WRITE", () => { + expect(hasScopeExpansion([AccessScope.SCHEDULE_READ], [AccessScope.SCHEDULE_WRITE])).toBe(true); + }); + + it("returns true when adding WRITE alongside existing READ", () => { + expect( + hasScopeExpansion( + [AccessScope.BOOKING_READ], + [AccessScope.BOOKING_READ, AccessScope.BOOKING_WRITE] + ) + ).toBe(true); + }); + }); + + describe("mixed operations", () => { + it("returns false when demoting WRITE to READ and removing another scope", () => { + expect( + hasScopeExpansion( + [AccessScope.BOOKING_WRITE, AccessScope.SCHEDULE_READ], + [AccessScope.BOOKING_READ] + ) + ).toBe(false); + }); + + it("returns true when demoting one scope but adding an entirely new one", () => { + expect( + hasScopeExpansion( + [AccessScope.BOOKING_WRITE], + [AccessScope.BOOKING_READ, AccessScope.SCHEDULE_READ] + ) + ).toBe(true); + }); + + it("returns true when removing one scope but adding a different new one", () => { + expect( + hasScopeExpansion( + [AccessScope.BOOKING_READ, AccessScope.SCHEDULE_READ], + [AccessScope.BOOKING_READ, AccessScope.APPS_READ] + ) + ).toBe(true); + }); + + it("returns false when demoting multiple WRITEs to READs simultaneously", () => { + expect( + hasScopeExpansion( + [AccessScope.BOOKING_WRITE, AccessScope.SCHEDULE_WRITE], + [AccessScope.BOOKING_READ, AccessScope.SCHEDULE_READ] + ) + ).toBe(false); + }); + + it("returns true when demoting one WRITE to READ but upgrading another READ to WRITE", () => { + expect( + hasScopeExpansion( + [AccessScope.BOOKING_WRITE, AccessScope.SCHEDULE_READ], + [AccessScope.BOOKING_READ, AccessScope.SCHEDULE_WRITE] + ) + ).toBe(true); + }); + }); + + describe("legacy scopes", () => { + it("returns true when adding a legacy scope", () => { + expect( + hasScopeExpansion([AccessScope.BOOKING_READ], [AccessScope.BOOKING_READ, AccessScope.READ_PROFILE]) + ).toBe(true); + }); + + it("returns false when legacy scope already exists", () => { + expect( + hasScopeExpansion( + [AccessScope.READ_BOOKING, AccessScope.READ_PROFILE], + [AccessScope.READ_BOOKING, AccessScope.READ_PROFILE] + ) + ).toBe(false); + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/oAuth/hasScopeExpansion.ts b/packages/trpc/server/routers/viewer/oAuth/hasScopeExpansion.ts new file mode 100644 index 00000000000000..266c9da617ea85 --- /dev/null +++ b/packages/trpc/server/routers/viewer/oAuth/hasScopeExpansion.ts @@ -0,0 +1,20 @@ +import type { AccessScope } from "@calcom/prisma/enums"; + +export function hasScopeExpansion(currentScopes: AccessScope[], newScopes: AccessScope[]): boolean { + const clientScopes = new Set(currentScopes); + + for (const newScope of newScopes) { + if (clientScopes.has(newScope)) continue; + + if (newScope.endsWith("_READ")) { + const correspondingWriteScope = newScope.replace("_READ", "_WRITE"); + if (clientScopes.has(correspondingWriteScope)) { + continue; + } + } + + return true; + } + + return false; +} diff --git a/packages/trpc/server/routers/viewer/oAuth/submitClientForReview.handler.ts b/packages/trpc/server/routers/viewer/oAuth/submitClientForReview.handler.ts index f0c41d8657d8de..03f5cdb6c35f2d 100644 --- a/packages/trpc/server/routers/viewer/oAuth/submitClientForReview.handler.ts +++ b/packages/trpc/server/routers/viewer/oAuth/submitClientForReview.handler.ts @@ -1,9 +1,8 @@ import { sendAdminOAuthClientNotification } from "@calcom/emails/oauth-email-service"; -import { getTranslation } from "@calcom/i18n/server"; import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository"; import { generateSecret } from "@calcom/features/oauth/utils/generateSecret"; +import { getTranslation } from "@calcom/i18n/server"; import type { PrismaClient } from "@calcom/prisma"; - import type { TSubmitClientInputSchema } from "./submitClientForReview.schema"; type SubmitClientOptions = { @@ -19,7 +18,7 @@ type SubmitClientOptions = { }; export const submitClientForReviewHandler = async ({ ctx, input }: SubmitClientOptions) => { - const { name, purpose, redirectUri, logo, websiteUrl, enablePkce } = input; + const { name, purpose, redirectUri, logo, websiteUrl, enablePkce, scopes } = input; const userId = ctx.user.id; const oAuthClientRepository = new OAuthClientRepository(ctx.prisma); @@ -40,6 +39,7 @@ export const submitClientForReviewHandler = async ({ ctx, input }: SubmitClientO logo, websiteUrl, enablePkce, + scopes, userId, status: "PENDING", }); diff --git a/packages/trpc/server/routers/viewer/oAuth/submitClientForReview.schema.ts b/packages/trpc/server/routers/viewer/oAuth/submitClientForReview.schema.ts index 02a81129931229..89e38d3b4c835f 100644 --- a/packages/trpc/server/routers/viewer/oAuth/submitClientForReview.schema.ts +++ b/packages/trpc/server/routers/viewer/oAuth/submitClientForReview.schema.ts @@ -1,3 +1,4 @@ +import { AccessScopeSchema } from "@calcom/prisma/zod/inputTypeSchemas"; import { z } from "zod"; export const ZSubmitClientInputSchema = z.object({ @@ -13,6 +14,7 @@ export const ZSubmitClientInputSchema = z.object({ .optional() .transform((value) => (value === "" ? undefined : value)), enablePkce: z.boolean().optional().default(false), + scopes: z.array(AccessScopeSchema).min(1, "At least one scope is required"), }); export const ZSubmitClientOutputSchema = z.object({ diff --git a/packages/trpc/server/routers/viewer/oAuth/updateClient.handler.test.ts b/packages/trpc/server/routers/viewer/oAuth/updateClient.handler.test.ts index ec6281367d82d6..25ee0892d655a1 100644 --- a/packages/trpc/server/routers/viewer/oAuth/updateClient.handler.test.ts +++ b/packages/trpc/server/routers/viewer/oAuth/updateClient.handler.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { TFunction } from "i18next"; import type { PrismaClient } from "@calcom/prisma"; -import { OAuthClientStatus, UserPermissionRole } from "@calcom/prisma/enums"; +import { AccessScope, OAuthClientStatus, UserPermissionRole } from "@calcom/prisma/enums"; import { updateClientHandler } from "./updateClient.handler"; @@ -380,6 +380,7 @@ describe("updateClientHandler", () => { redirectUri: REDIRECT_URI, websiteUrl: null, logo: null, + scopes: [AccessScope.BOOKING_READ], status: "APPROVED", user: null, }); @@ -439,6 +440,7 @@ describe("updateClientHandler", () => { redirectUri: REDIRECT_URI, websiteUrl: null, logo: null, + scopes: [AccessScope.BOOKING_READ], status: "APPROVED", user: null, }); @@ -487,4 +489,450 @@ describe("updateClientHandler", () => { }) ); }); + + it("sets status to PENDING when owner adds a new scope to an approved client", async () => { + mocks.findByClientIdIncludeUser.mockResolvedValue({ + clientId: CLIENT_ID, + userId: OWNER_USER_ID, + name: CLIENT_NAME, + purpose: CLIENT_PURPOSE, + redirectUri: REDIRECT_URI, + websiteUrl: null, + logo: null, + scopes: [AccessScope.BOOKING_READ], + status: "APPROVED", + user: null, + }); + + const prismaUpdate = vi.fn().mockResolvedValue({ + clientId: CLIENT_ID, + name: CLIENT_NAME, + purpose: CLIENT_PURPOSE, + status: "PENDING", + redirectUri: REDIRECT_URI, + websiteUrl: null, + logo: null, + rejectionReason: null, + }); + + const ctx = { + user: { + id: OWNER_USER_ID, + role: UserPermissionRole.USER, + }, + prisma: { + oAuthClient: { + update: prismaUpdate, + }, + } as unknown as PrismaClient, + }; + + await updateClientHandler({ + ctx, + input: { + clientId: CLIENT_ID, + scopes: [AccessScope.BOOKING_READ, AccessScope.SCHEDULE_READ], + }, + }); + + expect(prismaUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { clientId: CLIENT_ID }, + data: { + scopes: [AccessScope.BOOKING_READ, AccessScope.SCHEDULE_READ], + status: "PENDING", + rejectionReason: null, + }, + }) + ); + }); + + it("does not change status when owner removes a scope from an approved client", async () => { + mocks.findByClientIdIncludeUser.mockResolvedValue({ + clientId: CLIENT_ID, + userId: OWNER_USER_ID, + name: CLIENT_NAME, + purpose: CLIENT_PURPOSE, + redirectUri: REDIRECT_URI, + websiteUrl: null, + logo: null, + scopes: [AccessScope.BOOKING_READ, AccessScope.SCHEDULE_READ], + status: "APPROVED", + user: null, + }); + + const prismaUpdate = vi.fn().mockResolvedValue({ + clientId: CLIENT_ID, + name: CLIENT_NAME, + purpose: CLIENT_PURPOSE, + status: "APPROVED", + redirectUri: REDIRECT_URI, + websiteUrl: null, + logo: null, + rejectionReason: null, + }); + + const ctx = { + user: { + id: OWNER_USER_ID, + role: UserPermissionRole.USER, + }, + prisma: { + oAuthClient: { + update: prismaUpdate, + }, + } as unknown as PrismaClient, + }; + + await updateClientHandler({ + ctx, + input: { + clientId: CLIENT_ID, + scopes: [AccessScope.BOOKING_READ], + }, + }); + + expect(prismaUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { clientId: CLIENT_ID }, + data: { + scopes: [AccessScope.BOOKING_READ], + }, + }) + ); + }); + + it("does not change status when owner adds READ alongside existing WRITE for the same resource", async () => { + mocks.findByClientIdIncludeUser.mockResolvedValue({ + clientId: CLIENT_ID, + userId: OWNER_USER_ID, + name: CLIENT_NAME, + purpose: CLIENT_PURPOSE, + redirectUri: REDIRECT_URI, + websiteUrl: null, + logo: null, + scopes: [AccessScope.BOOKING_WRITE], + status: "APPROVED", + user: null, + }); + + const prismaUpdate = vi.fn().mockResolvedValue({ + clientId: CLIENT_ID, + name: CLIENT_NAME, + purpose: CLIENT_PURPOSE, + status: "APPROVED", + redirectUri: REDIRECT_URI, + websiteUrl: null, + logo: null, + rejectionReason: null, + }); + + const ctx = { + user: { + id: OWNER_USER_ID, + role: UserPermissionRole.USER, + }, + prisma: { + oAuthClient: { + update: prismaUpdate, + }, + } as unknown as PrismaClient, + }; + + await updateClientHandler({ + ctx, + input: { + clientId: CLIENT_ID, + scopes: [AccessScope.BOOKING_WRITE, AccessScope.BOOKING_READ], + }, + }); + + expect(prismaUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { clientId: CLIENT_ID }, + data: { + scopes: [AccessScope.BOOKING_WRITE, AccessScope.BOOKING_READ], + }, + }) + ); + }); + + it("does not change status when owner demotes from WRITE to READ for the same resource", async () => { + mocks.findByClientIdIncludeUser.mockResolvedValue({ + clientId: CLIENT_ID, + userId: OWNER_USER_ID, + name: CLIENT_NAME, + purpose: CLIENT_PURPOSE, + redirectUri: REDIRECT_URI, + websiteUrl: null, + logo: null, + scopes: [AccessScope.BOOKING_WRITE], + status: "APPROVED", + user: null, + }); + + const prismaUpdate = vi.fn().mockResolvedValue({ + clientId: CLIENT_ID, + name: CLIENT_NAME, + purpose: CLIENT_PURPOSE, + status: "APPROVED", + redirectUri: REDIRECT_URI, + websiteUrl: null, + logo: null, + rejectionReason: null, + }); + + const ctx = { + user: { + id: OWNER_USER_ID, + role: UserPermissionRole.USER, + }, + prisma: { + oAuthClient: { + update: prismaUpdate, + }, + } as unknown as PrismaClient, + }; + + await updateClientHandler({ + ctx, + input: { + clientId: CLIENT_ID, + scopes: [AccessScope.BOOKING_READ], + }, + }); + + expect(prismaUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { clientId: CLIENT_ID }, + data: { + scopes: [AccessScope.BOOKING_READ], + }, + }) + ); + }); + + it("sets status to PENDING when owner upgrades from READ to WRITE for the same resource", async () => { + mocks.findByClientIdIncludeUser.mockResolvedValue({ + clientId: CLIENT_ID, + userId: OWNER_USER_ID, + name: CLIENT_NAME, + purpose: CLIENT_PURPOSE, + redirectUri: REDIRECT_URI, + websiteUrl: null, + logo: null, + scopes: [AccessScope.BOOKING_READ], + status: "APPROVED", + user: null, + }); + + const prismaUpdate = vi.fn().mockResolvedValue({ + clientId: CLIENT_ID, + name: CLIENT_NAME, + purpose: CLIENT_PURPOSE, + status: "PENDING", + redirectUri: REDIRECT_URI, + websiteUrl: null, + logo: null, + rejectionReason: null, + }); + + const ctx = { + user: { + id: OWNER_USER_ID, + role: UserPermissionRole.USER, + }, + prisma: { + oAuthClient: { + update: prismaUpdate, + }, + } as unknown as PrismaClient, + }; + + await updateClientHandler({ + ctx, + input: { + clientId: CLIENT_ID, + scopes: [AccessScope.BOOKING_WRITE], + }, + }); + + expect(prismaUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { clientId: CLIENT_ID }, + data: { + scopes: [AccessScope.BOOKING_WRITE], + status: "PENDING", + rejectionReason: null, + }, + }) + ); + }); + + it("does not change status when owner demotes WRITE to READ while also removing another scope", async () => { + mocks.findByClientIdIncludeUser.mockResolvedValue({ + clientId: CLIENT_ID, + userId: OWNER_USER_ID, + name: CLIENT_NAME, + purpose: CLIENT_PURPOSE, + redirectUri: REDIRECT_URI, + websiteUrl: null, + logo: null, + scopes: [AccessScope.BOOKING_WRITE, AccessScope.SCHEDULE_READ], + status: "APPROVED", + user: null, + }); + + const prismaUpdate = vi.fn().mockResolvedValue({ + clientId: CLIENT_ID, + name: CLIENT_NAME, + purpose: CLIENT_PURPOSE, + status: "APPROVED", + redirectUri: REDIRECT_URI, + websiteUrl: null, + logo: null, + rejectionReason: null, + }); + + const ctx = { + user: { + id: OWNER_USER_ID, + role: UserPermissionRole.USER, + }, + prisma: { + oAuthClient: { + update: prismaUpdate, + }, + } as unknown as PrismaClient, + }; + + await updateClientHandler({ + ctx, + input: { + clientId: CLIENT_ID, + scopes: [AccessScope.BOOKING_READ], + }, + }); + + expect(prismaUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { clientId: CLIENT_ID }, + data: { + scopes: [AccessScope.BOOKING_READ], + }, + }) + ); + }); + + it("sets status to PENDING when owner demotes one scope but adds an entirely new one", async () => { + mocks.findByClientIdIncludeUser.mockResolvedValue({ + clientId: CLIENT_ID, + userId: OWNER_USER_ID, + name: CLIENT_NAME, + purpose: CLIENT_PURPOSE, + redirectUri: REDIRECT_URI, + websiteUrl: null, + logo: null, + scopes: [AccessScope.BOOKING_WRITE], + status: "APPROVED", + user: null, + }); + + const prismaUpdate = vi.fn().mockResolvedValue({ + clientId: CLIENT_ID, + name: CLIENT_NAME, + purpose: CLIENT_PURPOSE, + status: "PENDING", + redirectUri: REDIRECT_URI, + websiteUrl: null, + logo: null, + rejectionReason: null, + }); + + const ctx = { + user: { + id: OWNER_USER_ID, + role: UserPermissionRole.USER, + }, + prisma: { + oAuthClient: { + update: prismaUpdate, + }, + } as unknown as PrismaClient, + }; + + await updateClientHandler({ + ctx, + input: { + clientId: CLIENT_ID, + scopes: [AccessScope.BOOKING_READ, AccessScope.SCHEDULE_READ], + }, + }); + + expect(prismaUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { clientId: CLIENT_ID }, + data: { + scopes: [AccessScope.BOOKING_READ, AccessScope.SCHEDULE_READ], + status: "PENDING", + rejectionReason: null, + }, + }) + ); + }); + + it("does not trigger reapproval when admin updates scopes", async () => { + mocks.findByClientIdIncludeUser.mockResolvedValue({ + clientId: CLIENT_ID, + userId: OWNER_USER_ID, + name: CLIENT_NAME, + purpose: CLIENT_PURPOSE, + redirectUri: REDIRECT_URI, + websiteUrl: null, + logo: null, + scopes: [AccessScope.BOOKING_READ], + status: "APPROVED", + user: null, + }); + + const prismaUpdate = vi.fn().mockResolvedValue({ + clientId: CLIENT_ID, + name: CLIENT_NAME, + purpose: CLIENT_PURPOSE, + status: "APPROVED", + redirectUri: REDIRECT_URI, + websiteUrl: null, + logo: null, + rejectionReason: null, + }); + + const ctx = { + user: { + id: 1, + role: UserPermissionRole.ADMIN, + }, + prisma: { + oAuthClient: { + update: prismaUpdate, + }, + } as unknown as PrismaClient, + }; + + await updateClientHandler({ + ctx, + input: { + clientId: CLIENT_ID, + scopes: [AccessScope.BOOKING_READ, AccessScope.SCHEDULE_WRITE], + }, + }); + + expect(prismaUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { clientId: CLIENT_ID }, + data: { + scopes: [AccessScope.BOOKING_READ, AccessScope.SCHEDULE_WRITE], + }, + }) + ); + }); }); diff --git a/packages/trpc/server/routers/viewer/oAuth/updateClient.handler.ts b/packages/trpc/server/routers/viewer/oAuth/updateClient.handler.ts index 72b7b2b7f404fc..bbd0216a3fe613 100644 --- a/packages/trpc/server/routers/viewer/oAuth/updateClient.handler.ts +++ b/packages/trpc/server/routers/viewer/oAuth/updateClient.handler.ts @@ -5,8 +5,10 @@ import { getTranslation } from "@calcom/i18n/server"; import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository"; import type { PrismaClient } from "@calcom/prisma"; import { UserPermissionRole } from "@calcom/prisma/enums"; -import type { OAuthClientStatus } from "@calcom/prisma/enums"; +import type { AccessScope, OAuthClientStatus } from "@calcom/prisma/enums"; +import { isLegacyClient } from "@calcom/features/oauth/constants"; +import { hasScopeExpansion } from "./hasScopeExpansion"; import type { TUpdateClientInputSchema } from "./updateClient.schema"; type UpdateClientOptions = { @@ -41,6 +43,7 @@ const updateClientHandler = async ({ ctx, input }: UpdateClientOptions): Promise redirectUri, websiteUrl, logo, + scopes, } = input; const oAuthClientRepository = new OAuthClientRepository(ctx.prisma); @@ -61,7 +64,7 @@ const updateClientHandler = async ({ ctx, input }: UpdateClientOptions): Promise throw new TRPCError({ code: "BAD_REQUEST", message: "Rejection reason is required" }); } - const isUpdatingFields = hasAnyFieldsChanged({ name, purpose, redirectUri, websiteUrl, logo }); + const isUpdatingFields = hasAnyFieldsChanged({ name, purpose, redirectUri, websiteUrl, logo, scopes }); const isUpdatingStatus = requestedStatus !== undefined; if (isUpdatingStatus && !isAdmin) { @@ -83,12 +86,14 @@ const updateClientHandler = async ({ ctx, input }: UpdateClientOptions): Promise logo: clientWithUser.logo, websiteUrl: clientWithUser.websiteUrl, redirectUri: clientWithUser.redirectUri, + scopes: clientWithUser.scopes, }, proposedUpdates: { name, logo, websiteUrl, redirectUri, + scopes, }, }); @@ -104,6 +109,7 @@ const updateClientHandler = async ({ ctx, input }: UpdateClientOptions): Promise redirectUri, logo, websiteUrl, + scopes, requestedStatus, rejectionReason, nextStatus, @@ -161,6 +167,7 @@ type ClientFieldsForReapprovalCheck = { logo: string | null; websiteUrl: string | null; redirectUri: string; + scopes: AccessScope[]; }; function triggersReapprovalForOwnerEdit(params: { @@ -172,6 +179,7 @@ function triggersReapprovalForOwnerEdit(params: { logo: string | null; websiteUrl: string | null; redirectUri: string; + scopes: AccessScope[]; }>; }) { const { isAdmin, isOwner, currentClient, proposedUpdates } = params; @@ -203,6 +211,14 @@ function triggersReapprovalForOwnerEdit(params: { return true; } + if ( + proposedUpdates.scopes !== undefined && + !isLegacyClient(currentClient.scopes) && + hasScopeExpansion(currentClient.scopes, proposedUpdates.scopes) + ) { + return true; + } + return false; } @@ -263,6 +279,7 @@ type UpdateOAuthClientData = { redirectUri?: string; logo?: string | null; websiteUrl?: string | null; + scopes?: AccessScope[]; status?: OAuthClientStatus; rejectionReason?: string | null; }; @@ -273,6 +290,7 @@ function buildUpdateClientUpdateData(params: { redirectUri: string | undefined; logo: string | null | undefined; websiteUrl: string | null | undefined; + scopes: AccessScope[] | undefined; requestedStatus: OAuthClientStatus | undefined; rejectionReason: string | undefined; nextStatus: OAuthClientStatus; @@ -284,6 +302,7 @@ function buildUpdateClientUpdateData(params: { redirectUri, logo, websiteUrl, + scopes, requestedStatus, rejectionReason, nextStatus, @@ -297,6 +316,7 @@ function buildUpdateClientUpdateData(params: { if (redirectUri !== undefined) updateData.redirectUri = redirectUri; if (logo !== undefined) updateData.logo = logo; if (websiteUrl !== undefined) updateData.websiteUrl = websiteUrl; + if (scopes !== undefined) updateData.scopes = scopes; if (nextStatus !== currentStatus) updateData.status = nextStatus; if (requestedStatus === "REJECTED") { diff --git a/packages/trpc/server/routers/viewer/oAuth/updateClient.schema.ts b/packages/trpc/server/routers/viewer/oAuth/updateClient.schema.ts index 9d595068935963..8feb95770210ab 100644 --- a/packages/trpc/server/routers/viewer/oAuth/updateClient.schema.ts +++ b/packages/trpc/server/routers/viewer/oAuth/updateClient.schema.ts @@ -1,32 +1,32 @@ import { z } from "zod"; +import { AccessScopeSchema } from "@calcom/prisma/zod/inputTypeSchemas"; import { OAuthClientStatus } from "@calcom/prisma/enums"; -export const ZUpdateClientInputSchema = z - .object({ - clientId: z.string(), - status: z.nativeEnum(OAuthClientStatus).optional(), - rejectionReason: z.string().min(1).optional(), - name: z.string().min(1).optional(), - purpose: z.string().min(1).optional(), - redirectUri: z.string().url().optional(), - logo: z.preprocess( - (value) => (typeof value === "string" && value.trim() === "" ? null : value), - z.string().nullable().optional() - ), - websiteUrl: z.preprocess( - (value) => (typeof value === "string" && value.trim() === "" ? null : value), - z.string().url().nullable().optional() - ), - }) - .superRefine((val, ctx) => { - if (val.status === OAuthClientStatus.REJECTED && !val.rejectionReason) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["rejectionReason"], - message: "Rejection reason is required", - }); - } - }); +export const ZUpdateClientInputSchema = z.object({ + clientId: z.string(), + status: z.nativeEnum(OAuthClientStatus).optional(), + rejectionReason: z.string().min(1).optional(), + name: z.string().min(1).optional(), + purpose: z.string().min(1).optional(), + redirectUri: z.string().url().optional(), + logo: z.preprocess( + (value) => (typeof value === "string" && value.trim() === "" ? null : value), + z.string().nullable().optional() + ), + websiteUrl: z.preprocess( + (value) => (typeof value === "string" && value.trim() === "" ? null : value), + z.string().url().nullable().optional() + ), + scopes: z.array(AccessScopeSchema).min(1, "At least one scope is required").optional(), +}).superRefine((val, ctx) => { + if (val.status === OAuthClientStatus.REJECTED && !val.rejectionReason) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["rejectionReason"], + message: "Rejection reason is required", + }); + } +}); export type TUpdateClientInputSchema = z.infer;