feat: owner can test non accepted OAuth client (#27525)

* refactor: combine exchange and refresh into token endpoint

* refactor: controller error handling

* refactor: use snake_case

* refactor: use snake_case

* refactor: use snake case

* refactor: token endpoint accepts application/x-www-form-urlencoded

* refactor: token endpoint accepts application/x-www-form-urlencoded

* refactor: flat token response data

* refactor: error structure

* refactor: client_id in the body

* fix: address Cubic AI review feedback on OAuth2 endpoints

- Fix getClient endpoint to use proper REST API error format instead of OAuth token error format (confidence 9/10)
- Add missing space after comma in error format string in token.input.pipe.ts (confidence 9/10)
- Support both camelCase and snake_case inputs in authorize endpoint for backward compatibility (confidence 10/10)
- Restore legacy /exchange and /refresh endpoints alongside new /token endpoint for backward compatibility (confidence 10/10)
- Add OAuth2TokensResponseDto for legacy endpoint wrapped responses
- Add OAuth2LegacyExchangeInput and OAuth2LegacyRefreshInput for legacy endpoints

Co-Authored-By: unknown <>

* fix: address additional Cubic AI feedback on OAuth2 endpoints

- Log errors when status code >= 500 in handleClientError (confidence 9/10)
- Add Cache-Control: no-store and Pragma: no-cache headers to legacy /exchange and /refresh endpoints (confidence 9/10)

Co-Authored-By: unknown <>

* docs

* Revert "fix: address additional Cubic AI feedback on OAuth2 endpoints"

This reverts commit 39cc4aa3ebb9e59a171541d7010398425995ed89.

* Revert "fix: address Cubic AI review feedback on OAuth2 endpoints"

This reverts commit 97bf593186db04c0859f9ca30950c9e3e524019d.

* docs

* fix: address Cubic AI review feedback on OAuth2 endpoints

- Fix getClient to use handleClientError instead of handleTokenError (confidence 10)
- Restore legacy /exchange and /refresh endpoints for backward compatibility (confidence 9)
- Fix RFC 6749 error format: use human-readable messages in error_description (confidence 9)
- Fix errorDescription in OAuthService to use OAUTH_ERROR_REASONS mapping (confidence 9)

Co-Authored-By: unknown <>

* fix: address additional Cubic AI feedback on OAuth2 endpoints

- Fix security issue: Replace 'CALENDSO_ENCRYPTION_KEY is not set' with generic 'Internal server configuration error' message (confidence 10/10)
- Fix backward compatibility: Create OAuth2LegacyTokensDto with camelCase properties for legacy /exchange and /refresh endpoints (confidence 9/10)
- Skipped: RFC 6749 error field issue (confidence 8/10, below threshold)

Co-Authored-By: unknown <>

* e2e

* Revert "fix: address additional Cubic AI feedback on OAuth2 endpoints"

This reverts commit a080e93f07aaf5a7dcf81fe605012cb7ebcdc192.

* Revert "fix: address Cubic AI review feedback on OAuth2 endpoints"

This reverts commit 04986a16c981521ca97069152457bf521a9ee45f.

* fix: re-apply Cubic AI review feedback on OAuth2 endpoints

- Restore OAuth2LegacyExchangeInput and OAuth2LegacyRefreshInput classes
- Restore legacy /exchange and /refresh endpoints in OAuth2Controller
- Restore OAuth2LegacyTokensDto and OAuth2TokensResponseDto classes
- Restore OAUTH_ERROR_DESCRIPTIONS mapping in oauth2-error.service.ts
- Restore OAUTH_ERROR_REASONS lookup in OAuthService.ts mapErrorToOAuthError
- Fix encryption_key_missing error to not expose internal env var name

Addresses Cubic AI feedback with confidence >= 9/10:
- Comment 32 (9/10): Legacy endpoints and input classes
- Comment 34 (9/10): Error description mapping in OAuthService
- Comment 35 (10/10): OAUTH_ERROR_DESCRIPTIONS in error service

Skipped (confidence < 9/10):
- Comment 33 (8/10): getClient handleTokenError vs handleClientError

Co-Authored-By: unknown <>

* Revert "fix: re-apply Cubic AI review feedback on OAuth2 endpoints"

This reverts commit 416bef9c931d9a7ed78c65a70a3425550d61b151.

* delete unused file

* fix: e2e tests

* address cubic review

* fix: address Cubic AI review feedback on OAuth2 exception filter

- Fix header case sensitivity: use lowercase 'x-request-id' instead of 'X-Request-Id' since Express lowercases all request headers
- Redact request body in error logs to prevent exposing sensitive OAuth2 credentials like client_secret, password, and refresh_token

Co-Authored-By: unknown <>

* docs: api v2 oauth controller docs

* chore: remove authorize endpoint

* feat: owner can test non accepted OAuth client

* fix: remove sensitive data from OAuth2 exception logs

Remove Authorization header and userEmail from error logs in
OAuth2HttpExceptionFilter to avoid logging sensitive information.

Addresses Cubic AI review feedback (confidence 9/10).

Co-Authored-By: unknown <>

* fix: e2e

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Lauris Skraucis
2026-02-19 08:11:46 -03:00
committed by GitHub
co-authored by unknown Devin AI
parent 0eb2c15b39
commit c321a6c07a
12 changed files with 532 additions and 67 deletions
@@ -1,11 +1,12 @@
import { generateSecret } from "@calcom/platform-libraries";
import type { Membership, Team, User } from "@calcom/prisma/client";
import { AccessScope, OAuthClientType } from "@calcom/prisma/enums";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test, TestingModule } from "@nestjs/testing";
import { getToken } from "next-auth/jwt";
import { AccessScope, OAuthClientStatus, OAuthClientType } from "@calcom/prisma/enums";
import type { INestApplication } from "@nestjs/common";
import type { NestExpressApplication } from "@nestjs/platform-express";
import type { TestingModule } from "@nestjs/testing";
import { Test } from "@nestjs/testing";
import request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuth2ClientRepositoryFixture } from "test/fixtures/repository/oauth2-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
@@ -21,20 +22,16 @@ import { AuthModule } from "@/modules/auth/auth.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
// Mock next-auth/jwt getToken for ApiAuthStrategy NEXT_AUTH authentication
// Mock next-auth/jwt getToken so the NextAuth fallback in ApiAuthStrategy doesn't crash in tests
jest.mock("next-auth/jwt", () => ({
getToken: jest.fn(),
getToken: jest.fn().mockResolvedValue(null),
}));
const mockGetToken = getToken as jest.MockedFunction<typeof getToken>;
describe("OAuth2 Controller Endpoints", () => {
describe("User Not Authenticated", () => {
let appWithoutAuth: INestApplication;
beforeAll(async () => {
// Mock getToken to return null for unauthenticated tests
mockGetToken.mockResolvedValue(null);
const moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
imports: [AppModule, UsersModule, AuthModule, PrismaModule],
@@ -69,20 +66,23 @@ describe("OAuth2 Controller Endpoints", () => {
});
});
describe("User Authenticated", () => {
describe("User Authenticated (non-owner)", () => {
let app: INestApplication;
let moduleRef: TestingModule;
let userRepositoryFixture: UserRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let oAuthClientFixture: OAuth2ClientRepositoryFixture;
let oAuthService: OAuthService;
let user: User;
let clientOwner: User;
let authenticatedUser: User;
let team: Team;
let membership: Membership;
let oAuthClient: { clientId: string };
let apiKeyString: string;
let refreshToken: string;
const testClientId = `test-oauth-client-${randomString()}`;
@@ -96,7 +96,7 @@ describe("OAuth2 Controller Endpoints", () => {
): Promise<string> {
const result = await oAuthService.generateAuthorizationCode(
testClientId,
user.id,
authenticatedUser.id,
testRedirectUri,
scopes,
undefined,
@@ -107,12 +107,6 @@ describe("OAuth2 Controller Endpoints", () => {
}
beforeAll(async () => {
const userEmail = `oauth2-e2e-user-${randomString()}@api.com`;
// Mock getToken to return the user email for authenticated tests
// This is needed because ApiAuthGuard uses ApiAuthStrategy which calls getToken from next-auth/jwt
mockGetToken.mockResolvedValue({ email: userEmail });
moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter, ZodExceptionFilter],
imports: [AppModule, UsersModule, AuthModule, PrismaModule],
@@ -125,20 +119,28 @@ describe("OAuth2 Controller Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
oAuthClientFixture = new OAuth2ClientRepositoryFixture(moduleRef);
oAuthService = moduleRef.get(OAuthService);
user = await userRepositoryFixture.create({
email: userEmail,
clientOwner = await userRepositoryFixture.create({
email: `oauth2-e2e-owner-${randomString()}@api.com`,
});
authenticatedUser = await userRepositoryFixture.create({
email: `oauth2-e2e-user-${randomString()}@api.com`,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(authenticatedUser.id, null);
apiKeyString = keyString;
team = await teamRepositoryFixture.create({
name: `oauth2-e2e-team-${randomString()}`,
slug: `oauth2-e2e-team-${randomString()}`,
});
membership = await membershipRepositoryFixture.create({
user: { connect: { id: user.id } },
user: { connect: { id: authenticatedUser.id } },
team: { connect: { id: team.id } },
role: "OWNER",
accepted: true,
@@ -151,6 +153,7 @@ describe("OAuth2 Controller Endpoints", () => {
redirectUri: testRedirectUri,
clientSecret: hashedSecret,
clientType: OAuthClientType.CONFIDENTIAL,
userId: clientOwner.id,
});
});
@@ -159,6 +162,7 @@ describe("OAuth2 Controller Endpoints", () => {
it("should return OAuth client info for valid client ID", async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/auth/oauth2/clients/${testClientId}`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200);
expect(response.body.status).toBe("success");
@@ -174,6 +178,7 @@ describe("OAuth2 Controller Endpoints", () => {
it("should return 404 for non-existent client ID", async () => {
await request(app.getHttpServer())
.get("/api/v2/auth/oauth2/clients/non-existent-client-id")
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(404);
});
});
@@ -434,7 +439,238 @@ describe("OAuth2 Controller Endpoints", () => {
await oAuthClientFixture.delete(oAuthClient.clientId);
await membershipRepositoryFixture.delete(membership.id);
await teamRepositoryFixture.delete(team.id);
await userRepositoryFixture.delete(user.id);
await userRepositoryFixture.delete(authenticatedUser.id);
await userRepositoryFixture.delete(clientOwner.id);
await app.close();
});
});
describe("Owner can use non-approved OAuth client", () => {
let app: INestApplication;
let moduleRef: TestingModule;
let userRepositoryFixture: UserRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let oAuthClientFixture: OAuth2ClientRepositoryFixture;
let oAuthService: OAuthService;
let owner: User;
let team: Team;
let membership: Membership;
let pendingClient: { clientId: string };
let rejectedClient: { clientId: string };
let apiKeyString: string;
const pendingClientId = `test-pending-client-${randomString()}`;
const rejectedClientId = `test-rejected-client-${randomString()}`;
const testClientSecret = "test-secret-456";
const testRedirectUri = "https://example.com/callback";
/** Generate a user-scoped authorization code (no teamSlug) so the owner's userId is in the token. */
async function generateAuthCodeForClient(
clientId: string,
scopes: AccessScope[] = [AccessScope.READ_BOOKING]
): Promise<string> {
const result = await oAuthService.generateAuthorizationCode(
clientId,
owner.id,
testRedirectUri,
scopes
);
const redirectUrl = new URL(result.redirectUrl);
return redirectUrl.searchParams.get("code") as string;
}
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);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
oAuthClientFixture = new OAuth2ClientRepositoryFixture(moduleRef);
oAuthService = moduleRef.get(OAuthService);
owner = await userRepositoryFixture.create({
email: `oauth2-owner-e2e-${randomString()}@api.com`,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(owner.id, null);
apiKeyString = keyString;
team = await teamRepositoryFixture.create({
name: `oauth2-owner-team-${randomString()}`,
slug: `oauth2-owner-team-${randomString()}`,
});
membership = await membershipRepositoryFixture.create({
user: { connect: { id: owner.id } },
team: { connect: { id: team.id } },
role: "OWNER",
accepted: true,
});
const [hashedSecret] = generateSecret(testClientSecret);
pendingClient = await oAuthClientFixture.create({
clientId: pendingClientId,
name: "Pending OAuth Client",
redirectUri: testRedirectUri,
clientSecret: hashedSecret,
clientType: OAuthClientType.CONFIDENTIAL,
status: OAuthClientStatus.PENDING,
userId: owner.id,
});
rejectedClient = await oAuthClientFixture.create({
clientId: rejectedClientId,
name: "Rejected OAuth Client",
redirectUri: testRedirectUri,
clientSecret: hashedSecret,
clientType: OAuthClientType.CONFIDENTIAL,
status: OAuthClientStatus.REJECTED,
userId: owner.id,
});
});
it("should return PENDING client info when authenticated as owner", async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/auth/oauth2/clients/${pendingClientId}`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200);
expect(response.body.status).toBe("success");
expect(response.body.data.client_id).toBe(pendingClientId);
expect(response.body.data.name).toBe("Pending OAuth Client");
});
it("should exchange authorization code for tokens with PENDING client owned by user", async () => {
const code = await generateAuthCodeForClient(pendingClientId);
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: pendingClientId,
grant_type: "authorization_code",
code,
client_secret: testClientSecret,
redirect_uri: testRedirectUri,
})
.expect(200);
expect(response.body.access_token).toBeDefined();
expect(response.body.refresh_token).toBeDefined();
expect(response.body.token_type).toBe("bearer");
});
it("should refresh tokens with PENDING client owned by user", async () => {
const code = await generateAuthCodeForClient(pendingClientId);
const tokenResponse = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: pendingClientId,
grant_type: "authorization_code",
code,
client_secret: testClientSecret,
redirect_uri: testRedirectUri,
})
.expect(200);
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: pendingClientId,
grant_type: "refresh_token",
refresh_token: tokenResponse.body.refresh_token,
client_secret: testClientSecret,
})
.expect(200);
expect(response.body.access_token).toBeDefined();
expect(response.body.refresh_token).toBeDefined();
expect(response.body.token_type).toBe("bearer");
});
it("should reject authorization code generation for REJECTED client even as owner", async () => {
await expect(generateAuthCodeForClient(rejectedClientId)).rejects.toThrow();
});
it("should reject token exchange when client becomes rejected", async () => {
const code = await generateAuthCodeForClient(pendingClientId);
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.REJECTED);
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: pendingClientId,
grant_type: "authorization_code",
code,
client_secret: testClientSecret,
redirect_uri: testRedirectUri,
})
.expect(401);
expect(response.body.error).toBe("unauthorized_client");
expect(response.body.error_description).toBe("client_rejected");
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.PENDING);
});
it("should reject token refresh when client becomes rejected", async () => {
const code = await generateAuthCodeForClient(pendingClientId);
const tokenResponse = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: pendingClientId,
grant_type: "authorization_code",
code,
client_secret: testClientSecret,
redirect_uri: testRedirectUri,
})
.expect(200);
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.REJECTED);
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: pendingClientId,
grant_type: "refresh_token",
refresh_token: tokenResponse.body.refresh_token,
client_secret: testClientSecret,
})
.expect(401);
expect(response.body.error).toBe("unauthorized_client");
expect(response.body.error_description).toBe("client_rejected");
await oAuthClientFixture.updateStatus(pendingClientId, OAuthClientStatus.PENDING);
});
afterAll(async () => {
await oAuthClientFixture.delete(pendingClient.clientId);
await oAuthClientFixture.delete(rejectedClient.clientId);
await membershipRepositoryFixture.delete(membership.id);
await teamRepositoryFixture.delete(team.id);
await userRepositoryFixture.delete(owner.id);
await app.close();
});
});
@@ -16,15 +16,16 @@ export class OAuth2HttpExceptionFilter implements ExceptionFilter<OAuth2HttpExce
const requestId = request.headers["x-request-id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const userContext = extractUserContext(request);
const { userEmail: _userEmail, ...safeUserContext } = extractUserContext(request);
const { Authorization: _auth, ...safeHeaders } = filterReqHeaders(request.headers);
this.logger.error(`OAuth2 Http Exception: ${exception.oAuthErrorData.error}`, {
exception,
body: "[REDACTED]",
headers: filterReqHeaders(request.headers),
headers: safeHeaders,
url: request.url,
method: request.method,
requestId,
...userContext,
...safeUserContext,
});
response.status(exception.getStatus()).json(exception.oAuthErrorData);
@@ -21,6 +21,7 @@ export class OAuth2ClientRepositoryFixture {
status?: OAuthClientStatus;
logo?: string;
isTrusted?: boolean;
userId?: number;
}) {
return this.prismaWriteClient.oAuthClient.create({
data: {
@@ -32,10 +33,18 @@ export class OAuth2ClientRepositoryFixture {
status: data.status || OAuthClientStatus.APPROVED,
logo: data.logo,
isTrusted: data.isTrusted || false,
...(data.userId && { user: { connect: { id: data.userId } } }),
},
});
}
async updateStatus(clientId: string, status: OAuthClientStatus) {
return this.prismaWriteClient.oAuthClient.update({
where: { clientId },
data: { status },
});
}
async delete(clientId: string) {
return this.prismaWriteClient.oAuthClient.delete({
where: { clientId },
@@ -238,12 +238,16 @@ const OAuthClientDetailsDialog = ({
) : null}
{status === "REJECTED" && client.rejectionReason ? (
<div
className="text-sm text-subtle"
data-testid="oauth-client-details-rejection-reason-display">
<span className="font-medium">{t("oauth_client_rejection_reason")}:</span>{" "}
{client.rejectionReason}
</div>
<Alert
severity="error"
title={t("oauth_client_rejection_reason")}
message={
<div data-testid="oauth-client-details-rejection-reason-display">
<p className="mb-2">"{client.rejectionReason}".</p>
<p>{t("oauth_client_rejected_resubmit_info")}</p>
</div>
}
/>
) : null}
<div>
@@ -1,9 +1,7 @@
import { expect } from "@playwright/test";
import { randomBytes } from "node:crypto";
import { OAUTH_ERROR_REASONS } from "@calcom/features/oauth/services/OAuthService";
import type { PrismaClient } from "@calcom/prisma";
import { expect } from "@playwright/test";
import { test } from "../lib/fixtures";
test.describe("OAuth authorize - client approval status", () => {
@@ -25,10 +23,12 @@ test.describe("OAuth authorize - client approval status", () => {
prisma,
name,
status,
userId,
}: {
prisma: PrismaClient;
name: string;
status: "PENDING" | "APPROVED" | "REJECTED";
userId?: number;
}) {
const clientId = randomBytes(32).toString("hex");
@@ -40,6 +40,7 @@ test.describe("OAuth authorize - client approval status", () => {
clientSecret: null,
clientType: "CONFIDENTIAL",
status,
...(userId && { user: { connect: { id: userId } } }),
},
});
@@ -65,10 +66,8 @@ test.describe("OAuth authorize - client approval status", () => {
`auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&state=1234`
);
// Should NOT redirect, should show error on page
await expect(page).not.toHaveURL(/^https:\/\/example\.com/);
// Check error message is displayed
await expect(page.getByText(OAUTH_ERROR_REASONS["client_not_approved"])).toBeVisible();
});
@@ -91,11 +90,9 @@ test.describe("OAuth authorize - client approval status", () => {
`auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&state=1234`
);
// Should NOT redirect, should show error on page
await expect(page).not.toHaveURL(/^https:\/\/example\.com/);
// Check error message is displayed
await expect(page.getByText(OAUTH_ERROR_REASONS["client_not_approved"])).toBeVisible();
await expect(page.getByText(OAUTH_ERROR_REASONS["client_rejected"])).toBeVisible();
});
test("APPROVED client redirects to redirectUri with code", async ({ page, users, prisma }, testInfo) => {
@@ -171,4 +168,64 @@ test.describe("OAuth authorize - client approval status", () => {
await expect(page).toHaveURL(/\/auth\/oauth2\/authorize/);
await expect(page.getByText(OAUTH_ERROR_REASONS["client_not_found"])).toBeVisible();
});
test("PENDING client owned by logged-in user allows authorization", async ({
page,
users,
prisma,
}, testInfo) => {
const user = await users.create({ username: "oauth-authorize-pending-owner" });
await user.apiLogin();
const testPrefix = `e2e-oauth-authorize-status-${testInfo.testId}-`;
const client = await createOAuthClient({
prisma,
name: `${testPrefix}pending-owner-${Date.now()}`,
status: "PENDING",
userId: user.id,
});
await page.goto(
`auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&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("REJECTED client owned by logged-in user blocks authorization", async ({
page,
users,
prisma,
}, testInfo) => {
const user = await users.create({ username: "oauth-authorize-rejected-owner" });
await user.apiLogin();
const testPrefix = `e2e-oauth-authorize-status-${testInfo.testId}-`;
const client = await createOAuthClient({
prisma,
name: `${testPrefix}rejected-owner-${Date.now()}`,
status: "REJECTED",
userId: user.id,
});
await page.goto(
`auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&state=1234`
);
await expect(page).not.toHaveURL(/^https:\/\/example\.com/);
await expect(page.getByText(OAUTH_ERROR_REASONS["client_rejected"])).toBeVisible();
});
});
@@ -1,5 +1,6 @@
import { expect } from "@playwright/test";
import { randomBytes } from "node:crypto";
import jwt from "jsonwebtoken";
import { test } from "../lib/fixtures";
import type { PrismaClient } from "@calcom/prisma";
@@ -46,6 +47,25 @@ test.describe("OAuth - refresh tokens", () => {
return client;
}
function signRefreshToken(clientId: string, userId: number): string {
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY;
if (!secretKey) {
throw new Error("CALENDSO_ENCRYPTION_KEY is not set");
}
return jwt.sign(
{
userId,
teamId: null,
scope: [],
token_type: "Refresh Token",
clientId,
},
secretKey,
{ expiresIn: 30 * 24 * 60 * 60 }
);
}
test("token refresh fails if client is not approved", async ({ page, users, prisma }, testInfo) => {
const user = await users.create({ username: "oauth-refresh-status-check" });
await user.apiLogin();
@@ -58,11 +78,13 @@ test.describe("OAuth - refresh tokens", () => {
clientType: "PUBLIC",
});
const refreshToken = signRefreshToken(client.clientId, user.id);
const refreshResponse = await page.request.post("/api/auth/oauth/refreshToken", {
form: {
grant_type: "refresh_token",
client_id: client.clientId,
refresh_token: "fake-refresh-token",
refresh_token: refreshToken,
},
});
@@ -1150,9 +1150,10 @@
"oauth_client_approved_email_footer": "You can now use your client ID and secret to integrate with Cal.com.",
"oauth_client_secret_one_time_warning": "Important: This client secret is shown only once. Store it securely now - you will not be able to view it again.",
"reason_for_rejection": "Reason for rejection",
"oauth_client_rejection_reason": "Rejection reason",
"oauth_client_rejection_reason": "Your OAuth client was rejected. Rejection reason:",
"oauth_client_rejected_resubmit_info": "Please address this issue and submit for re-approval. While in rejected status, this client cannot be used by anyone, including the client owner.",
"oauth_client_approved_reapproval_info": "Changing the client's name, logo, website URL, or redirect URIs will trigger a re-review by our admins, and the client will be unusable until approved again.",
"oauth_client_pending_info_description": "This OAuth client is pending approval and cannot be used yet.",
"oauth_client_pending_info_description": "This OAuth client is pending approval and can only be used by the client owner for testing. It cannot be used by other users until approved.",
"reject_oauth_client": "Reject OAuth client",
"oauth_client_rejected_email_subject": "Your OAuth Client {{clientName}} Has Been Rejected",
"oauth_client_rejected_email_title": "OAuth Client Rejected: {{clientName}}",
@@ -37,6 +37,7 @@ export class OAuthClientRepository {
clientSecret: true,
clientType: true,
status: true,
userId: true,
},
});
}
@@ -1,15 +1,15 @@
import { randomBytes } from "node:crypto";
import jwt from "jsonwebtoken";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { AccessCodeRepository } from "@calcom/features/oauth/repositories/AccessCodeRepository";
import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository";
import process from "node:process";
import type { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import type { AccessCodeRepository } from "@calcom/features/oauth/repositories/AccessCodeRepository";
import type { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository";
import { generateSecret } from "@calcom/features/oauth/utils/generateSecret";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { ErrorWithCode } from "@calcom/lib/errors";
import { verifyCodeChallenge } from "@calcom/lib/pkce";
import { OAuthClientStatus } from "@calcom/prisma/enums";
import type { AccessScope, OAuthClientType } from "@calcom/prisma/enums";
import { OAuthClientStatus } from "@calcom/prisma/enums";
import jwt from "jsonwebtoken";
export interface OAuth2Client {
clientId: string;
@@ -82,7 +82,11 @@ export class OAuthService {
};
}
async getClientForAuthorization(clientId: string, redirectUri: string): Promise<OAuth2Client> {
async getClientForAuthorization(
clientId: string,
redirectUri: string,
loggedInUserId?: number
): Promise<OAuth2Client> {
const client = await this.oAuthClientRepository.findByClientId(clientId);
if (!client) {
@@ -91,7 +95,7 @@ export class OAuthService {
this.validateRedirectUri(client.redirectUri, redirectUri);
this.ensureClientIsApproved(client);
this.ensureClientAccessAllowed(client, loggedInUserId);
return {
clientId: client.clientId,
@@ -105,7 +109,7 @@ export class OAuthService {
async generateAuthorizationCode(
clientId: string,
userId: number,
loggedInUserId: number,
redirectUri: string,
scopes: AccessScope[],
state?: string,
@@ -119,7 +123,7 @@ export class OAuthService {
throw new ErrorWithCode(ErrorCode.Unauthorized, "unauthorized_client", { reason: "client_not_found" });
}
this.ensureClientIsApproved(client);
this.ensureClientAccessAllowed(client, loggedInUserId);
// RFC 6749 4.1.2.1: Redirect URI mismatch on Auth step is 'invalid_request'
this.validateRedirectUri(client.redirectUri, redirectUri);
@@ -143,7 +147,7 @@ export class OAuthService {
let teamId: number | undefined;
if (teamSlug) {
const team = await this.teamsRepository.findTeamBySlugWithAdminRole(teamSlug, userId);
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", {
@@ -158,7 +162,7 @@ export class OAuthService {
await this.accessCodeRepository.create({
code: authorizationCode,
clientId,
userId: teamSlug ? undefined : userId,
userId: teamSlug ? undefined : loggedInUserId,
teamId,
scopes,
codeChallenge,
@@ -173,6 +177,22 @@ export class OAuthService {
return { redirectUrl, authorizationCode, client };
}
private ensureClientAccessAllowed(
client: { status: OAuthClientStatus; userId: number | null },
loggedInUserId?: number | null
): void {
if (client.status === OAuthClientStatus.REJECTED) {
throw new ErrorWithCode(ErrorCode.Unauthorized, "unauthorized_client", {
reason: "client_rejected",
});
}
const isOwner = loggedInUserId != null && loggedInUserId === client.userId;
if (!isOwner) {
this.ensureClientIsApproved(client);
}
}
private ensureClientIsApproved(client: { status: OAuthClientStatus }): void {
if (client.status !== OAuthClientStatus.APPROVED) {
throw new ErrorWithCode(ErrorCode.Unauthorized, "unauthorized_client", {
@@ -283,6 +303,8 @@ export class OAuthService {
throw new ErrorWithCode(ErrorCode.BadRequest, "invalid_grant", { reason: "code_invalid_or_expired" });
}
this.ensureClientAccessAllowed(client, accessCode.userId);
const pkceError = this.verifyPKCE(client, accessCode, codeVerifier);
if (pkceError) {
// RFC 7636 4.4.1: If verification fails, return 'invalid_grant'
@@ -318,8 +340,6 @@ export class OAuthService {
});
}
this.ensureClientIsApproved(client);
const decodedToken = this.verifyRefreshToken(refreshToken);
if (!decodedToken || decodedToken.token_type !== "Refresh Token") {
@@ -330,6 +350,8 @@ export class OAuthService {
throw new ErrorWithCode(ErrorCode.BadRequest, "invalid_grant", { reason: "client_id_mismatch" });
}
this.ensureClientAccessAllowed(client, decodedToken.userId);
const tokens = this.createTokens({
clientId,
userId: decodedToken.userId,
@@ -459,6 +481,7 @@ export class OAuthService {
export type OAuthErrorReason =
| "client_not_found"
| "client_not_approved"
| "client_rejected"
| "redirect_uri_mismatch"
| "pkce_required"
| "invalid_code_challenge_method"
@@ -476,6 +499,7 @@ export type OAuthErrorReason =
export const OAUTH_ERROR_REASONS: Record<OAuthErrorReason, string> = {
client_not_found: "OAuth client with ID not found",
client_not_approved: "OAuth client is not approved",
client_rejected: "OAuth client has been rejected",
redirect_uri_mismatch: "redirect_uri does not match OAuth client's redirect URI",
pkce_required: "code_challenge required for public clients",
invalid_code_challenge_method: "code_challenge_method must be S256",
@@ -1,21 +1,21 @@
import authedProcedure, { authedAdminProcedure } from "@calcom/trpc/server/procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZCreateClientInputSchema } from "./createClient.schema";
import { ZDeleteClientInputSchema } from "./deleteClient.schema";
import { ZGenerateAuthCodeInputSchema } from "./generateAuthCode.schema";
import { ZGetClientForAuthorizationInputSchema } from "./getClientForAuthorization.schema";
import { ZListClientsInputSchema } from "./listClients.schema";
import { ZSubmitClientInputSchema, ZSubmitClientOutputSchema } from "./submitClientForReview.schema";
import { ZUpdateClientInputSchema } from "./updateClient.schema";
import { ZDeleteClientInputSchema } from "./deleteClient.schema";
export const oAuthRouter = router({
getClientForAuthorization: authedProcedure
.input(ZGetClientForAuthorizationInputSchema)
.query(async ({ input }) => {
.query(async ({ ctx, input }) => {
const { getClientForAuthorizationHandler } = await import("./getClientForAuthorization.handler");
return getClientForAuthorizationHandler({
ctx,
input,
});
}),
@@ -425,11 +425,118 @@ describe("generateAuthCodeHandler", () => {
await expect(generateAuthCodeHandler({ ctx: mockCtx, input })).rejects.toThrow(
new TRPCError({
code: "UNAUTHORIZED",
message: OAUTH_ERROR_REASONS.client_not_approved,
message: OAUTH_ERROR_REASONS.client_rejected,
})
);
expect(prismaMock.accessCode.create).not.toHaveBeenCalled();
});
it("should reject REJECTED client even when user is the owner", async () => {
const mockRejectedClientOwnedByUser = {
clientId: "rejected_client_owned",
redirectUri: "https://app.example.com/callback",
name: "Test Rejected Client Owned",
clientType: "CONFIDENTIAL" as const,
status: "REJECTED" as const,
userId: 1,
};
prismaMock.oAuthClient.findFirst.mockResolvedValue(mockRejectedClientOwnedByUser);
const input = {
clientId: "rejected_client_owned",
scopes: [],
teamSlug: undefined,
codeChallenge: undefined,
codeChallengeMethod: undefined,
redirectUri: "https://app.example.com/callback",
};
await expect(generateAuthCodeHandler({ ctx: mockCtx, input })).rejects.toThrow(
new TRPCError({
code: "UNAUTHORIZED",
message: OAUTH_ERROR_REASONS.client_rejected,
})
);
expect(prismaMock.accessCode.create).not.toHaveBeenCalled();
});
it("should allow PENDING client when user is the owner", async () => {
const mockPendingClientOwnedByUser = {
clientId: "pending_client_owned",
redirectUri: "https://app.example.com/callback",
name: "Test Pending Client Owned",
clientType: "CONFIDENTIAL" as const,
status: "PENDING" as const,
userId: 1,
};
prismaMock.oAuthClient.findFirst.mockResolvedValue(mockPendingClientOwnedByUser);
prismaMock.accessCode.create.mockResolvedValue({
id: 1,
code: "test_auth_code",
clientId: "pending_client_owned",
userId: 1,
teamId: null,
scopes: [],
codeChallenge: null,
codeChallengeMethod: null,
expiresAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
});
const input = {
clientId: "pending_client_owned",
scopes: [],
teamSlug: undefined,
codeChallenge: undefined,
codeChallengeMethod: undefined,
redirectUri: "https://app.example.com/callback",
};
const result = await generateAuthCodeHandler({ ctx: mockCtx, input });
expect(result.authorizationCode).toBeDefined();
});
it("should allow APPROVED client when user is the owner", async () => {
const mockApprovedClientOwnedByUser = {
clientId: "approved_client_owned",
redirectUri: "https://app.example.com/callback",
name: "Test Approved Client Owned",
clientType: "CONFIDENTIAL" as const,
status: "APPROVED" as const,
userId: 1,
};
prismaMock.oAuthClient.findFirst.mockResolvedValue(mockApprovedClientOwnedByUser);
prismaMock.accessCode.create.mockResolvedValue({
id: 1,
code: "test_auth_code",
clientId: "approved_client_owned",
userId: 1,
teamId: null,
scopes: [],
codeChallenge: null,
codeChallengeMethod: null,
expiresAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
});
const input = {
clientId: "approved_client_owned",
scopes: [],
teamSlug: undefined,
codeChallenge: undefined,
codeChallengeMethod: undefined,
redirectUri: "https://app.example.com/callback",
};
const result = await generateAuthCodeHandler({ ctx: mockCtx, input });
expect(result.authorizationCode).toBeDefined();
});
});
});
@@ -1,24 +1,27 @@
import { getOAuthService } from "@calcom/features/oauth/di/OAuthService.container";
import { OAUTH_ERROR_REASONS, OAuthErrorReason } from "@calcom/features/oauth/services/OAuthService";
import type { OAuthErrorReason } from "@calcom/features/oauth/services/OAuthService";
import { OAUTH_ERROR_REASONS } from "@calcom/features/oauth/services/OAuthService";
import { ErrorWithCode } from "@calcom/lib/errors";
import { getHttpStatusCode } from "@calcom/lib/server/getServerErrorFromUnknown";
import { httpStatusToTrpcCode } from "@calcom/trpc/server/lib/toTRPCError";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { TGetClientForAuthorizationInputSchema } from "./getClientForAuthorization.schema";
type GetClientForAuthorizationOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TGetClientForAuthorizationInputSchema;
};
export const getClientForAuthorizationHandler = async ({ input }: GetClientForAuthorizationOptions) => {
export const getClientForAuthorizationHandler = async ({ ctx, input }: GetClientForAuthorizationOptions) => {
try {
const { clientId, redirectUri } = input;
const oAuthService = getOAuthService();
const oAuthClient = await oAuthService.getClientForAuthorization(clientId, redirectUri);
const oAuthClient = await oAuthService.getClientForAuthorization(clientId, redirectUri, ctx.user.id);
return {
clientId: oAuthClient.clientId,