Skip to content
Closed
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
4c88fa0
chore: delete unused input file
supalarry Feb 5, 2026
b254490
feat: create OAuth client with scopes
supalarry Feb 6, 2026
c3109b7
feat: authorize page validate scope
supalarry Feb 6, 2026
2f5e771
chore: return scopes in refresh token next api
supalarry Feb 6, 2026
4d50b49
chore: add new scopes in database
supalarry Feb 6, 2026
5e87dc4
feat: permissions guard checks oauth token scopes
supalarry Feb 6, 2026
bdfd9ba
feedback
supalarry Feb 6, 2026
0833647
refactor: permissions guard
supalarry Feb 6, 2026
7e9e42f
fix: correct "compatability" typo to "compatibility" in test descript…
devin-ai-integration[bot] Feb 6, 2026
6a23408
refactor: oauth2 controller return scope
supalarry Feb 6, 2026
3b2d46f
fix: v2 oauth service dependencies
supalarry Feb 6, 2026
909b4c0
refactor: reuse error constant
supalarry Feb 6, 2026
241fc8b
refactor: OAuth client details dialog
supalarry Feb 6, 2026
96a6fa7
remove comments
supalarry Feb 6, 2026
6c01bad
remove comments
supalarry Feb 6, 2026
bf07a58
rename consts
supalarry Feb 6, 2026
3cbf0e0
refactor: centralize constants
supalarry Feb 6, 2026
ec534a6
refactor: reuse oauth permissions constant
supalarry Feb 6, 2026
e8b8bc2
refactor: centralize scope param parsing
supalarry Feb 6, 2026
20ed8a4
fix: limit endpoints OAuth access tokens can access
supalarry Feb 12, 2026
8b2b1eb
Merge branch 'lauris/cal-7035-fix-invalidate-old-refresh-tokens' into…
supalarry Feb 12, 2026
9dd3bca
Merge branch 'lauris/cal-7035-fix-invalidate-old-refresh-tokens' into…
supalarry Feb 13, 2026
da12867
third party scopes parity with permissions
supalarry Feb 13, 2026
70719a1
docs: scope
supalarry Feb 13, 2026
26b9038
refactor: adding new scope triggers re-approval
supalarry Feb 13, 2026
c158d6b
Merge branch 'lauris/cal-7035-fix-invalidate-old-refresh-tokens' into…
supalarry Feb 23, 2026
336e4f3
refactor: leave legacy oauth clients as they are
supalarry Feb 24, 2026
6f5b449
update docs
supalarry Feb 24, 2026
ffa8d04
Merge branch 'lauris/cal-7035-fix-invalidate-old-refresh-tokens' into…
supalarry Feb 24, 2026
0f424ed
test legacy oauth client
supalarry Feb 24, 2026
d75192b
fix: build issue
supalarry Feb 24, 2026
eb52b2f
refactor: dont display scopes for legacy clients
supalarry Feb 24, 2026
052da88
fix: allow updating legacy clients without scopes
supalarry Feb 24, 2026
f6a89f6
refactor: allow updating legacy oauth scopes
supalarry Feb 25, 2026
1fedcd0
refactor: allow updating legacy oauth scopes
supalarry Feb 25, 2026
751795e
fix: allow updating legacy clients without scopes
supalarry Feb 25, 2026
a6a61f2
refactor: validate state param in authorization page
supalarry Feb 25, 2026
d6b754a
refactor: when creating client dont check scopes by default
supalarry Feb 25, 2026
56cfa68
docs
supalarry Feb 25, 2026
1e32074
fix: unknown_scope -> invalid_scope
supalarry Feb 25, 2026
aaa4ac9
Merge branch 'lauris/cal-7035-fix-invalidate-old-refresh-tokens' into…
supalarry Feb 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/api/v2/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -59,6 +61,7 @@ import { VercelWebhookController } from "@/vercel-webhook.controller";
EndpointsModule,
AuthModule,
JwtModule,
TokensModule,
],
controllers: [AppController, VercelWebhookController],
providers: [
Expand All @@ -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 {
Expand Down
10 changes: 5 additions & 5 deletions apps/api/v2/src/lib/docs/headers.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"value must be `Bearer <token>` where `<token>` 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 <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"value must be `Bearer <token>` where `<token>` 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 <token>` where `<token>` is managed user access token",
description:
"value must be `Bearer <token>` where `<token>` is managed user access token or OAuth access token",
required: true,
};
9 changes: 8 additions & 1 deletion apps/api/v2/src/lib/modules/oauth.module.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { PrismaAccessCodeRepository } from "@/lib/repositories/prisma-access-code.repository";
import { PrismaOAuthClientRepository } from "@/lib/repositories/prisma-oauth-client.repository";
import { PrismaOAuthRefreshTokenRepository } from "@/lib/repositories/prisma-oauth-refresh-token.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { OAuthService } from "@/lib/services/oauth.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module } from "@nestjs/common";

@Module({
imports: [PrismaModule],
providers: [PrismaAccessCodeRepository, PrismaOAuthClientRepository, PrismaTeamRepository, OAuthService],
providers: [
PrismaAccessCodeRepository,
PrismaOAuthClientRepository,
PrismaOAuthRefreshTokenRepository,
PrismaTeamRepository,
OAuthService,
],
exports: [OAuthService],
})
export class oAuthServiceModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";

import { PrismaOAuthRefreshTokenRepository as PrismaOAuthRefreshTokenRepositoryLib } from "@calcom/platform-libraries/repositories";
import type { PrismaClient } from "@calcom/prisma";

@Injectable()
export class PrismaOAuthRefreshTokenRepository extends PrismaOAuthRefreshTokenRepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
9 changes: 6 additions & 3 deletions apps/api/v2/src/lib/services/oauth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { OAuthService as BaseOAuthService } from "@calcom/platform-libraries";
import { Injectable } from "@nestjs/common";
import { PrismaAccessCodeRepository } from "@/lib/repositories/prisma-access-code.repository";
import { PrismaOAuthClientRepository } from "@/lib/repositories/prisma-oauth-client.repository";
import { PrismaOAuthRefreshTokenRepository } from "@/lib/repositories/prisma-oauth-refresh-token.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import type { OAuth2ExchangeInput } from "@/modules/auth/oauth2/inputs/exchange.input";
import { OAuth2ExchangeConfidentialInput } from "@/modules/auth/oauth2/inputs/exchange.input";
Expand All @@ -15,12 +16,14 @@ export class OAuthService extends BaseOAuthService {
constructor(
accessCodeRepository: PrismaAccessCodeRepository,
oAuthClientRepository: PrismaOAuthClientRepository,
oAuthRefreshTokenRepository: PrismaOAuthRefreshTokenRepository,
teamsRepository: PrismaTeamRepository
) {
super({
accessCodeRepository: accessCodeRepository,
oAuthClientRepository: oAuthClientRepository,
teamsRepository: teamsRepository,
accessCodeRepository,
oAuthClientRepository,
oAuthRefreshTokenRepository,
teamsRepository,
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
}

Expand Down
Loading