From 2115a045ec5f0b35a9d7c3b877ba76a800d5b6a8 Mon Sep 17 00:00:00 2001 From: Lauris Skraucis Date: Fri, 18 Jul 2025 15:53:51 +0200 Subject: [PATCH] fix: v2 ignore permissions check for 3rd party oauth (#22588) --- .../permissions/permissions.guard.spec.ts | 14 +++++++ .../guards/permissions/permissions.guard.ts | 11 +++++- .../api-auth/api-auth.strategy.e2e-spec.ts | 4 +- .../strategies/api-auth/api-auth.strategy.ts | 33 +++------------- .../cal-unified-calendars.module.ts | 5 +-- .../v2/src/modules/tokens/tokens.module.ts | 8 ++-- .../v2/src/modules/tokens/tokens.service.ts | 38 +++++++++++++++++++ 7 files changed, 75 insertions(+), 38 deletions(-) create mode 100644 apps/api/v2/src/modules/tokens/tokens.service.ts 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 3f1f6f0bcd..e2baca3845 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,6 +1,7 @@ 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 { createMock } from "@golevelup/ts-jest"; import { ExecutionContext } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; @@ -19,6 +20,7 @@ describe("PermissionsGuard", () => { guard = new PermissionsGuard( reflector, createMock(), + createMock(), createMock({ get: jest.fn().mockImplementation((key: string) => { switch (key) { @@ -118,6 +120,7 @@ describe("PermissionsGuard", () => { jest .spyOn(guard, "getOAuthClientByAccessToken") .mockResolvedValue(getMockOAuthClient(oAuthClientPermissions)); + jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue(null); await expect(guard.canActivate(mockContext)).rejects.toThrow( "PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE" @@ -133,11 +136,22 @@ describe("PermissionsGuard", () => { jest .spyOn(guard, "getOAuthClientByAccessToken") .mockResolvedValue(getMockOAuthClient(oAuthClientPermissions)); + jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue(null); await expect(guard.canActivate(mockContext)).rejects.toThrow( "PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE, SCHEDULE_READ" ); }); + + 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", + }); + + 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 28c6710909..9de4e86f10 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 @@ -3,6 +3,7 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d 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"; @@ -17,6 +18,7 @@ export class PermissionsGuard implements CanActivate { constructor( private reflector: Reflector, private tokensRepository: TokensRepository, + private tokensService: TokensService, private readonly config: ConfigService, private readonly oAuthClientRepository: OAuthClientRepository, private readonly oAuthClientsOutputService: OAuthClientsOutputService @@ -35,9 +37,10 @@ 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); - // only check permissions for accessTokens attached to an oAuth Client or oAuth credentials, not for next token or api key - if (nextAuthToken || apiKey) { + // 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) { return true; } @@ -87,4 +90,8 @@ export class PermissionsGuard implements CanActivate { } return oAuthClient; } + + getDecodedThirdPartyAccessToken(bearerToken: string) { + return this.tokensService.getDecodedThirdPartyAccessToken(bearerToken); + } } diff --git a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.e2e-spec.ts b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.e2e-spec.ts index 3f2cc3fee4..11717d3bbc 100644 --- a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.e2e-spec.ts +++ b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.e2e-spec.ts @@ -9,7 +9,7 @@ import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.se import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { ProfilesModule } from "@/modules/profiles/profiles.module"; -import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { TokensModule } from "@/modules/tokens/tokens.module"; import { UsersService } from "@/modules/users/services/users.service"; import { UsersRepository } from "@/modules/users/users.repository"; import { ExecutionContext, HttpException } from "@nestjs/common"; @@ -64,6 +64,7 @@ describe("ApiAuthStrategy", () => { load: [appConfig], }), ProfilesModule, + TokensModule, ], providers: [ MockedRedisService, @@ -77,7 +78,6 @@ describe("ApiAuthStrategy", () => { OAuthClientRepository, PrismaReadService, PrismaWriteService, - TokensRepository, JwtService, DeploymentsRepository, NestJwtService, diff --git a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts index 4afaf48af8..c04c5d8238 100644 --- a/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts +++ b/apps/api/v2/src/modules/auth/strategies/api-auth/api-auth.strategy.ts @@ -8,32 +8,20 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; import { ProfilesRepository } from "@/modules/profiles/profiles.repository"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { TokensService } from "@/modules/tokens/tokens.service"; import { UsersService } from "@/modules/users/services/users.service"; import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository"; -import { - HttpException, - Injectable, - InternalServerErrorException, - UnauthorizedException, -} from "@nestjs/common"; +import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common"; import { Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { PassportStrategy } from "@nestjs/passport"; import type { Request } from "express"; -import * as jwt from "jsonwebtoken"; import { getToken } from "next-auth/jwt"; import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants"; import type { AllowedAuthMethod } from "../../decorators/api-auth-guard-only-allow.decorator"; -interface OAuthTokenPayload { - userId?: number; - teamId?: number; - scope: string[]; - token_type: string; -} - export type ApiAuthGuardUser = UserWithProfile & { isSystemAdmin: boolean }; export type ApiAuthGuardRequest = Request & { authMethod: AuthMethods; @@ -53,6 +41,7 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") private readonly config: ConfigService, private readonly oauthFlowService: OAuthFlowService, private readonly tokensRepository: TokensRepository, + private readonly tokensService: TokensService, private readonly userRepository: UsersRepository, private readonly apiKeyRepository: ApiKeysRepository, private readonly oauthRepository: OAuthClientRepository, @@ -323,20 +312,8 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") token: string, request: ApiAuthGuardRequest ): Promise<{ success: true; data: UserWithProfile } | { success: false }> { - // Removed requiredScopes parameter - const encryptionKey = this.config.get("CALENDSO_ENCRYPTION_KEY"); - if (!encryptionKey) { - throw new InternalServerErrorException("CALENDSO_ENCRYPTION_KEY environment variable is not set."); - } - - let decodedToken: OAuthTokenPayload; - try { - decodedToken = jwt.verify(token, encryptionKey) as OAuthTokenPayload; - } catch (e) { - return { success: false }; - } - - if (!decodedToken || decodedToken.token_type !== "Access Token") { + const decodedToken = this.tokensService.getDecodedThirdPartyAccessToken(token); + if (!decodedToken) { return { success: false }; } diff --git a/apps/api/v2/src/modules/cal-unified-calendars/cal-unified-calendars.module.ts b/apps/api/v2/src/modules/cal-unified-calendars/cal-unified-calendars.module.ts index 75ff206b9f..0a5c4bfc3c 100644 --- a/apps/api/v2/src/modules/cal-unified-calendars/cal-unified-calendars.module.ts +++ b/apps/api/v2/src/modules/cal-unified-calendars/cal-unified-calendars.module.ts @@ -9,12 +9,12 @@ import { CredentialsRepository } from "@/modules/credentials/credentials.reposit import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; -import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { TokensModule } from "@/modules/tokens/tokens.module"; import { UsersRepository } from "@/modules/users/users.repository"; import { Module } from "@nestjs/common"; @Module({ - imports: [], + imports: [TokensModule], providers: [ GCalService, GoogleCalendarService, @@ -22,7 +22,6 @@ import { Module } from "@nestjs/common"; BookingReferencesRepository_2024_08_13, CredentialsRepository, CalendarsService, - TokensRepository, SelectedCalendarsRepository, PrismaReadService, PrismaWriteService, diff --git a/apps/api/v2/src/modules/tokens/tokens.module.ts b/apps/api/v2/src/modules/tokens/tokens.module.ts index d700cc7754..3b43614cd3 100644 --- a/apps/api/v2/src/modules/tokens/tokens.module.ts +++ b/apps/api/v2/src/modules/tokens/tokens.module.ts @@ -1,10 +1,12 @@ +import { JwtModule } from "@/modules/jwt/jwt.module"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { TokensService } from "@/modules/tokens/tokens.service"; import { Module } from "@nestjs/common"; @Module({ - imports: [PrismaModule], - providers: [TokensRepository], - exports: [TokensRepository], + imports: [PrismaModule, JwtModule], + providers: [TokensRepository, TokensService], + exports: [TokensRepository, TokensService], }) export class TokensModule {} diff --git a/apps/api/v2/src/modules/tokens/tokens.service.ts b/apps/api/v2/src/modules/tokens/tokens.service.ts new file mode 100644 index 0000000000..8091d80875 --- /dev/null +++ b/apps/api/v2/src/modules/tokens/tokens.service.ts @@ -0,0 +1,38 @@ +import { Injectable, InternalServerErrorException } from "@nestjs/common"; +import { Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import * as jwt from "jsonwebtoken"; + +type OAuthTokenPayload = { + userId?: number; + teamId?: number; + scope: string[]; + token_type: string; +}; + +@Injectable() +export class TokensService { + private readonly logger = new Logger("TokensService"); + + constructor(private readonly config: ConfigService) {} + + getDecodedThirdPartyAccessToken(token: string): OAuthTokenPayload | null { + const encryptionKey = this.config.get("CALENDSO_ENCRYPTION_KEY"); + if (!encryptionKey) { + throw new InternalServerErrorException("CALENDSO_ENCRYPTION_KEY environment variable is not set."); + } + + let decodedToken: OAuthTokenPayload; + try { + decodedToken = jwt.verify(token, encryptionKey) as OAuthTokenPayload; + } catch (e) { + return null; + } + + if (!decodedToken || decodedToken.token_type !== "Access Token") { + return null; + } + + return decodedToken; + } +}