Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 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
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
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,
BOOKING_READ,
BOOKING_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 +80,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 +93,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 +149,60 @@ describe("PermissionsGuard", () => {
);
});

it("should return true for 3rd party access token", async () => {
it("should return true for 3rd party access token with legacy/unknown scopes (backward compat)", 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);
});

it("should return true for 3rd party access token with empty scopes (backward compat)", 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 with matching new scopes", async () => {
const mockContext = createMockExecutionContext({ Authorization: "Bearer token" });
jest.spyOn(reflector, "get").mockReturnValue([BOOKING_READ]);
jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue({
scope: ["scope"],
scope: ["BOOKING_READ", "BOOKING_WRITE"],
token_type: "Bearer",
});

await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});

it("should throw for 3rd party access token with insufficient new scopes", async () => {
const mockContext = createMockExecutionContext({ Authorization: "Bearer token" });
jest.spyOn(reflector, "get").mockReturnValue([BOOKING_WRITE]);
jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue({
scope: ["BOOKING_READ"],
token_type: "Bearer",
});

await expect(guard.canActivate(mockContext)).rejects.toThrow("insufficient_scope");
});

it("should throw for 3rd party token when endpoint requires permission without scope mapping", async () => {
const mockContext = createMockExecutionContext({ Authorization: "Bearer token" });
jest.spyOn(reflector, "get").mockReturnValue([APPS_WRITE]);
jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue({
scope: ["BOOKING_READ"],
token_type: "Bearer",
});

await expect(guard.canActivate(mockContext)).rejects.toThrow("insufficient_scope");
});
});

describe("when OAuth id is provided", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
import {
BOOKING_READ,
BOOKING_WRITE,
EVENT_TYPE_READ,
EVENT_TYPE_WRITE,
PROFILE_READ,
SCHEDULE_READ,
SCHEDULE_WRITE,
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";
const SCOPE_TO_PERMISSION: Record<string, number> = {
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,
PROFILE_READ: PROFILE_READ,
};

@Injectable()
export class PermissionsGuard implements CanActivate {
Expand All @@ -37,13 +55,17 @@ 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) {
// NextAuth sessions and API keys have full access
if (nextAuthToken || apiKey) {
return true;
}

if (decodedThirdPartyToken) {
return this.checkThirdPartyTokenPermissions(decodedThirdPartyToken, requiredPermissions);
}

if (!bearerToken && !oAuthClientId) {
throw new ForbiddenException(
"PermissionsGuard - no authentication provided. Provide either authorization bearer token containing managed user access token or oAuth client id in 'x-cal-client-id' header."
Expand Down Expand Up @@ -91,6 +113,48 @@ export class PermissionsGuard implements CanActivate {
return oAuthClient;
}

checkThirdPartyTokenPermissions(
decodedToken: { scope?: string[] },
requiredPermissions: number[]
): boolean {
const tokenScopes: string[] = decodedToken.scope ?? [];

if (tokenScopes.length === 0) {
return true;
}

const tokenPermissions = this.resolveTokenPermissions(tokenScopes);

// note(Lauris): legacy access tokens either did not have scopes defined or had legacy scopes defined,
// if so give full access just like we have been doing up until now.
if (tokenPermissions.size === 0) {
return true;
}

const missing = requiredPermissions.filter((permission) => !tokenPermissions.has(permission));
if (missing.length > 0) {
const missingNames = missing
.map((permission) => Object.entries(SCOPE_TO_PERMISSION).find(([, value]) => value === permission)?.[0] ?? `UNKNOWN(${permission})`)
.filter(Boolean);
throw new ForbiddenException(
`insufficient_scope: token does not have the required scopes. Required: ${missingNames.join(", ")}. Token has: ${tokenScopes.join(", ")}`
);
}

return true;
}

private resolveTokenPermissions(scopes: string[]): Set<number> {
const permissions = new Set<number>();
for (const scope of scopes) {
const permission = SCOPE_TO_PERMISSION[scope];
if (permission !== undefined) {
permissions.add(permission);
}
}
return permissions;
}

getDecodedThirdPartyAccessToken(bearerToken: string) {
return this.tokensService.getDecodedThirdPartyAccessToken(bearerToken);
}
Expand Down
52 changes: 0 additions & 52 deletions apps/api/v2/src/modules/auth/oauth2/inputs/authorize.input.ts

This file was deleted.

11 changes: 6 additions & 5 deletions apps/web/app/api/auth/oauth/refreshToken/route.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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,
Expand Down
16 changes: 8 additions & 8 deletions apps/web/app/api/auth/oauth/token/route.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
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 } = await parseUrlFormData(
req
);
const { code, client_id, client_secret, grant_type, redirect_uri, code_verifier } =
await parseUrlFormData(req);

if (!process.env.CALENDSO_ENCRYPTION_KEY) {
return NextResponse.json({ message: OAUTH_ERROR_REASONS["encryption_key_missing"] }, { status: 500 });
Expand Down Expand Up @@ -41,6 +40,7 @@ async function handler(req: NextRequest) {
token_type: "bearer",
refresh_token: tokens.refreshToken,
expires_in: tokens.expiresIn,
scope: tokens.scope,
},
{
status: 200,
Expand Down
Loading
Loading