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 <158243242+devin-ai-integration[bot]@users.noreply.github.com>
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);