refactor: OAuth 2.0 endpoints (#27442)

* 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

* refactor: remove scope from docs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Lauris Skraucis
2026-02-05 10:16:15 +02:00
committed by GitHub
co-authored by unknown Devin AI
parent bcbb714ea9
commit bd25cba89c
22 changed files with 1642 additions and 605 deletions
+12
View File
@@ -13,6 +13,7 @@ import { RedirectsMiddleware } from "@/middleware/app.redirects.middleware";
import { RewriterMiddleware } from "@/middleware/app.rewrites.middleware";
import { JsonBodyMiddleware } from "@/middleware/body/json.body.middleware";
import { RawBodyMiddleware } from "@/middleware/body/raw.body.middleware";
import { UrlencodedBodyMiddleware } from "@/middleware/body/urlencoded.body.middleware";
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";
@@ -100,6 +101,17 @@ export class AppModule implements NestModule {
method: RequestMethod.POST,
}
)
.apply(UrlencodedBodyMiddleware)
.forRoutes(
{
path: "/v2/auth/oauth2/token",
method: RequestMethod.POST,
},
{
path: "/api/v2/auth/oauth2/token",
method: RequestMethod.POST,
}
)
.apply(JsonBodyMiddleware)
.forRoutes("*")
.apply(RequestIdMiddleware)
+36 -3
View File
@@ -1,9 +1,14 @@
import type { OAuth2Tokens } from "@calcom/platform-libraries";
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 { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { Injectable } from "@nestjs/common";
import { OAuthService as BaseOAuthService } from "@calcom/platform-libraries";
import type { OAuth2ExchangeInput } from "@/modules/auth/oauth2/inputs/exchange.input";
import { OAuth2ExchangeConfidentialInput } from "@/modules/auth/oauth2/inputs/exchange.input";
import type { OAuth2RefreshInput } from "@/modules/auth/oauth2/inputs/refresh.input";
import { OAuth2RefreshConfidentialInput } from "@/modules/auth/oauth2/inputs/refresh.input";
import type { OAuth2TokenInput } from "@/modules/auth/oauth2/inputs/token.input.pipe";
@Injectable()
export class OAuthService extends BaseOAuthService {
@@ -18,4 +23,32 @@ export class OAuthService extends BaseOAuthService {
teamsRepository: teamsRepository,
});
}
async handleTokenRequest(clientId: string, body: OAuth2TokenInput): Promise<OAuth2Tokens> {
switch (body.grant_type) {
case "authorization_code":
return this.exchangeAuthorizationCode(clientId, body);
case "refresh_token":
return this.refreshToken(clientId, body);
}
}
private async exchangeAuthorizationCode(
clientId: string,
body: OAuth2ExchangeInput
): Promise<OAuth2Tokens> {
if (body instanceof OAuth2ExchangeConfidentialInput) {
return this.exchangeCodeForTokens(clientId, body.code, body.client_secret, body.redirect_uri);
}
return this.exchangeCodeForTokens(clientId, body.code, undefined, body.redirect_uri, body.code_verifier);
}
private async refreshToken(clientId: string, body: OAuth2RefreshInput): Promise<OAuth2Tokens> {
if (body instanceof OAuth2RefreshConfidentialInput) {
return this.refreshAccessToken(clientId, body.refresh_token, body.client_secret);
}
return this.refreshAccessToken(clientId, body.refresh_token);
}
}
@@ -0,0 +1,10 @@
import { Injectable, NestMiddleware } from "@nestjs/common";
import { urlencoded } from "body-parser";
import type { Request, Response } from "express";
@Injectable()
export class UrlencodedBodyMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: () => any): void {
urlencoded({ extended: true })(req, res, next);
}
}
@@ -16,6 +16,7 @@ import { bootstrap } from "@/bootstrap";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { ZodExceptionFilter } from "@/filters/zod-exception.filter";
import { OAuthService } from "@/lib/services/oauth.service";
import { AuthModule } from "@/modules/auth/auth.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -49,12 +50,16 @@ describe("OAuth2 Controller Endpoints", () => {
.expect(401);
});
it("POST /v2/auth/oauth2/clients/:clientId/authorize should return 401 without auth", () => {
it("POST /v2/auth/oauth2/token should return 401 without auth", () => {
return request(appWithoutAuth.getHttpServer())
.post("/api/v2/auth/oauth2/clients/test-client-id/authorize")
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
redirectUri: "https://example.com/callback",
scopes: ["READ_BOOKING"],
client_id: "test-client-id",
grant_type: "authorization_code",
code: "test-code",
client_secret: "test-secret",
redirect_uri: "https://example.com/callback",
})
.expect(401);
});
@@ -72,18 +77,35 @@ describe("OAuth2 Controller Endpoints", () => {
let teamRepositoryFixture: TeamRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let oAuthClientFixture: OAuth2ClientRepositoryFixture;
let oAuthService: OAuthService;
let user: User;
let team: Team;
let membership: Membership;
let oAuthClient: { clientId: string };
let authorizationCode: string;
let refreshToken: string;
const testClientId = `test-oauth-client-${randomString()}`;
const testClientSecret = "test-secret-123";
const testRedirectUri = "https://example.com/callback";
/** Generate an authorization code directly via the service (bypasses HTTP layer). */
async function generateAuthCode(
scopes: AccessScope[] = [AccessScope.READ_BOOKING],
teamSlug?: string
): Promise<string> {
const result = await oAuthService.generateAuthorizationCode(
testClientId,
user.id,
testRedirectUri,
scopes,
undefined,
teamSlug ?? team.slug
);
const redirectUrl = new URL(result.redirectUrl);
return redirectUrl.searchParams.get("code") as string;
}
beforeAll(async () => {
const userEmail = `oauth2-e2e-user-${randomString()}@api.com`;
@@ -104,6 +126,7 @@ describe("OAuth2 Controller Endpoints", () => {
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
oAuthClientFixture = new OAuth2ClientRepositoryFixture(moduleRef);
oAuthService = moduleRef.get(OAuthService);
user = await userRepositoryFixture.create({
email: userEmail,
@@ -132,176 +155,278 @@ describe("OAuth2 Controller Endpoints", () => {
});
describe("GET /api/v2/auth/oauth2/clients/:clientId", () => {
it("should return OAuth client info for valid client ID", async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/auth/oauth2/clients/${testClientId}`)
.expect(200);
describe("Positive tests", () => {
it("should return OAuth client info for valid client ID", async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/auth/oauth2/clients/${testClientId}`)
.expect(200);
expect(response.body.status).toBe("success");
expect(response.body.data.id).toBe(testClientId);
expect(response.body.data.name).toBe("Test OAuth Client");
expect(response.body.data.redirectUri).toBe(testRedirectUri);
expect(response.body.data.type).toBe(OAuthClientType.CONFIDENTIAL);
expect(response.body.data.clientSecret).toBeUndefined();
expect(response.body.status).toBe("success");
expect(response.body.data.client_id).toBe(testClientId);
expect(response.body.data.name).toBe("Test OAuth Client");
expect(response.body.data.redirect_uri).toBe(testRedirectUri);
expect(response.body.data.client_type).toBe(OAuthClientType.CONFIDENTIAL);
expect(response.body.data.client_secret).toBeUndefined();
});
});
it("should return 404 for non-existent client ID", async () => {
await request(app.getHttpServer())
.get("/api/v2/auth/oauth2/clients/non-existent-client-id")
.expect(404);
describe("Negative tests", () => {
it("should return 404 for non-existent client ID", async () => {
await request(app.getHttpServer())
.get("/api/v2/auth/oauth2/clients/non-existent-client-id")
.expect(404);
});
});
});
describe("POST /api/v2/auth/oauth2/clients/:clientId/authorize", () => {
it("should redirect with authorization code for valid request", async () => {
const response = await request(app.getHttpServer())
.post(`/api/v2/auth/oauth2/clients/${testClientId}/authorize`)
.send({
redirectUri: testRedirectUri,
scopes: [AccessScope.READ_BOOKING, AccessScope.READ_PROFILE],
teamSlug: team.slug,
state: "test-state-123",
})
.expect(303);
describe("POST /api/v2/auth/oauth2/token (authorization_code grant)", () => {
describe("Positive tests", () => {
it("should exchange authorization code for tokens", async () => {
const authorizationCode = await generateAuthCode([
AccessScope.READ_BOOKING,
AccessScope.READ_PROFILE,
]);
expect(response.headers.location).toBeDefined();
const redirectUrl = new URL(response.headers.location);
expect(redirectUrl.origin + redirectUrl.pathname).toBe(testRedirectUri);
expect(redirectUrl.searchParams.get("code")).toBeDefined();
expect(redirectUrl.searchParams.get("state")).toBe("test-state-123");
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: testClientId,
grant_type: "authorization_code",
code: authorizationCode,
client_secret: testClientSecret,
redirect_uri: testRedirectUri,
})
.expect(200);
authorizationCode = redirectUrl.searchParams.get("code") as string;
expect(response.body.access_token).toBeDefined();
expect(response.body.refresh_token).toBeDefined();
expect(response.body.token_type).toBe("bearer");
expect(response.body.expires_in).toBe(1800);
expect(response.headers["cache-control"]).toBe("no-store");
expect(response.headers["pragma"]).toBe("no-cache");
refreshToken = response.body.refresh_token;
});
it("should exchange authorization code for tokens with JSON body", async () => {
const code = await generateAuthCode();
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.send({
client_id: testClientId,
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 exchange authorization code for tokens with application/x-www-form-urlencoded body", async () => {
const code = await generateAuthCode();
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: testClientId,
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");
expect(response.body.expires_in).toBe(1800);
});
});
it("should redirect with error for invalid client ID", async () => {
await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/clients/invalid-client-id/authorize")
.send({
redirectUri: testRedirectUri,
scopes: [AccessScope.READ_BOOKING],
})
.expect(404);
});
describe("Negative tests", () => {
it("should return 400 with RFC 6749 error for invalid/used authorization code", async () => {
const code = await generateAuthCode();
it("should redirect with error for invalid team slug", async () => {
const response = await request(app.getHttpServer())
.post(`/api/v2/auth/oauth2/clients/${testClientId}/authorize`)
.send({
redirectUri: testRedirectUri,
scopes: [AccessScope.READ_BOOKING],
teamSlug: "non-existent-team-slug",
state: "test-state-456",
})
.expect(303);
// Use the code once
await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: testClientId,
grant_type: "authorization_code",
code,
client_secret: testClientSecret,
redirect_uri: testRedirectUri,
})
.expect(200);
expect(response.headers.location).toBeDefined();
const redirectUrl = new URL(response.headers.location);
expect(redirectUrl.searchParams.get("error")).toBe("access_denied");
expect(redirectUrl.searchParams.get("state")).toBe("test-state-456");
});
// Try to use the same code again
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: testClientId,
grant_type: "authorization_code",
code,
client_secret: testClientSecret,
redirect_uri: testRedirectUri,
})
.expect(400);
it("should throw error 400 for mismatched redirect URI", async () => {
await request(app.getHttpServer())
.post(`/api/v2/auth/oauth2/clients/${testClientId}/authorize`)
.send({
redirectUri: "https://wrong-domain.com/callback",
scopes: [AccessScope.READ_BOOKING],
state: "test-state-789",
})
.expect(400);
expect(response.body.error).toBe("invalid_grant");
expect(response.body.error_description).toBe("code_invalid_or_expired");
});
it("should return 401 for invalid client secret", async () => {
const code = await generateAuthCode();
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: testClientId,
grant_type: "authorization_code",
code,
client_secret: "wrong-secret",
redirect_uri: testRedirectUri,
})
.expect(401);
expect(response.body.error).toBe("invalid_client");
expect(response.body.error_description).toBe("invalid_client_credentials");
});
it("should return 400 for invalid grant type", async () => {
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: testClientId,
grant_type: "invalid_grant",
code: "some-code",
redirect_uri: testRedirectUri,
})
.expect(400);
expect(response.body.error).toBe("invalid_request");
expect(response.body.error_description).toBe(
"grant_type must be 'authorization_code' or 'refresh_token'"
);
});
it("should return 401 with RFC 6749 error for non-existent client_id", async () => {
const code = await generateAuthCode();
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: "non-existent-client-id",
grant_type: "authorization_code",
code,
client_secret: testClientSecret,
redirect_uri: testRedirectUri,
})
.expect(401);
expect(response.body.error).toBe("invalid_client");
expect(response.body.error_description).toBe("client_not_found");
});
it("should return 400 with RFC 6749 error when client_id is missing", async () => {
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
grant_type: "authorization_code",
code: "some-code",
client_secret: testClientSecret,
redirect_uri: testRedirectUri,
})
.expect(400);
expect(response.body.error).toBe("invalid_request");
expect(response.body.error_description).toBe("client_id is required");
});
});
});
describe("POST /api/v2/auth/oauth2/clients/:clientId/exchange", () => {
it("should exchange authorization code for tokens", async () => {
const response = await request(app.getHttpServer())
.post(`/api/v2/auth/oauth2/clients/${testClientId}/exchange`)
.send({
code: authorizationCode,
clientSecret: testClientSecret,
redirectUri: testRedirectUri,
})
.expect(200);
describe("POST /api/v2/auth/oauth2/token (refresh_token grant)", () => {
describe("Positive tests", () => {
it("should refresh access token with valid refresh token", async () => {
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: testClientId,
grant_type: "refresh_token",
refresh_token: refreshToken,
client_secret: testClientSecret,
})
.expect(200);
expect(response.body.status).toBe("success");
expect(response.body.data.access_token).toBeDefined();
expect(response.body.data.refresh_token).toBeDefined();
expect(response.body.data.token_type).toBe("bearer");
expect(response.body.data.expires_in).toBe(1800);
refreshToken = response.body.data.refresh_token;
expect(response.body.access_token).toBeDefined();
expect(response.body.refresh_token).toBeDefined();
expect(response.body.token_type).toBe("bearer");
expect(response.body.expires_in).toBe(1800);
});
});
it("should return 400 for invalid/used authorization code", async () => {
await request(app.getHttpServer())
.post(`/api/v2/auth/oauth2/clients/${testClientId}/exchange`)
.send({
code: authorizationCode,
clientSecret: testClientSecret,
redirectUri: testRedirectUri,
})
.expect(400);
});
describe("Negative tests", () => {
it("should return 400 with RFC 6749 error for invalid refresh token", async () => {
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: testClientId,
grant_type: "refresh_token",
refresh_token: "invalid-refresh-token",
client_secret: testClientSecret,
})
.expect(400);
it("should return 401 for invalid client secret", async () => {
const newAuthResponse = await request(app.getHttpServer())
.post(`/api/v2/auth/oauth2/clients/${testClientId}/authorize`)
.send({
redirectUri: testRedirectUri,
scopes: [AccessScope.READ_BOOKING],
teamSlug: team.slug,
})
.expect(303);
expect(response.body.error).toBe("invalid_grant");
expect(response.body.error_description).toBe("invalid_refresh_token");
});
const newRedirectUrl = new URL(newAuthResponse.headers.location);
const newAuthCode = newRedirectUrl.searchParams.get("code") as string;
it("should return 401 for invalid client secret", async () => {
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: testClientId,
grant_type: "refresh_token",
refresh_token: refreshToken,
client_secret: "wrong-secret",
})
.expect(401);
await request(app.getHttpServer())
.post(`/api/v2/auth/oauth2/clients/${testClientId}/exchange`)
.send({
code: newAuthCode,
clientSecret: "wrong-secret",
redirectUri: testRedirectUri,
})
.expect(401);
});
});
expect(response.body.error).toBe("invalid_client");
expect(response.body.error_description).toBe("invalid_client_credentials");
});
describe("POST /api/v2/auth/oauth2/clients/:clientId/refresh", () => {
it("should refresh access token with valid refresh token", async () => {
const response = await request(app.getHttpServer())
.post(`/api/v2/auth/oauth2/clients/${testClientId}/refresh`)
.send({
refreshToken: refreshToken,
clientSecret: testClientSecret,
})
.expect(200);
it("should return 401 with RFC 6749 error for non-existent client_id", async () => {
const response = await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/token")
.type("form")
.send({
client_id: "wrong-client-id",
grant_type: "refresh_token",
refresh_token: refreshToken,
client_secret: testClientSecret,
})
.expect(401);
expect(response.body.status).toBe("success");
expect(response.body.data.access_token).toBeDefined();
expect(response.body.data.refresh_token).toBeDefined();
expect(response.body.data.token_type).toBe("bearer");
expect(response.body.data.expires_in).toBe(1800);
});
it("should return 400 for invalid refresh token", async () => {
await request(app.getHttpServer())
.post(`/api/v2/auth/oauth2/clients/${testClientId}/refresh`)
.send({
refreshToken: "invalid-refresh-token",
clientSecret: testClientSecret,
})
.expect(400);
});
it("should return 401 for wrong client ID with valid refresh token", async () => {
await request(app.getHttpServer())
.post("/api/v2/auth/oauth2/clients/wrong-client-id/refresh")
.send({
refreshToken: refreshToken,
clientSecret: testClientSecret,
})
.expect(401);
expect(response.body.error).toBe("invalid_client");
expect(response.body.error_description).toBe("client_not_found");
});
});
});
@@ -1,46 +1,48 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { OAuthService } from "@/lib/services/oauth.service";
import { ApiAuthGuardOnlyAllow } from "@/modules/auth/decorators/api-auth-guard-only-allow.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { OAuth2AuthorizeInput } from "@/modules/auth/oauth2/inputs/authorize.input";
import { OAuth2ExchangeInput } from "@/modules/auth/oauth2/inputs/exchange.input";
import { OAuth2RefreshInput } from "@/modules/auth/oauth2/inputs/refresh.input";
import { OAuth2ClientDto, OAuth2ClientResponseDto } from "@/modules/auth/oauth2/outputs/oauth2-client.output";
import { OAuth2TokensDto, OAuth2TokensResponseDto } from "@/modules/auth/oauth2/outputs/oauth2-tokens.output";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
Body,
Controller,
Get,
Header,
HttpCode,
HttpException,
HttpStatus,
InternalServerErrorException,
Logger,
Param,
Post,
Res,
UseFilters,
UseGuards,
} from "@nestjs/common";
import { ApiExcludeController, ApiOperation, ApiTags } from "@nestjs/swagger";
import { ApiBody, ApiExtraModels, ApiOperation, ApiTags, getSchemaPath } from "@nestjs/swagger";
import { plainToInstance } from "class-transformer";
import type { Response } from "express";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ErrorWithCode, getHttpStatusCode } from "@calcom/platform-libraries/errors";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { OAuthService } from "@/lib/services/oauth.service";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { OAuth2HttpExceptionFilter } from "@/modules/auth/oauth2/filters/oauth2-http-exception.filter";
import {
OAuth2ExchangeConfidentialInput,
OAuth2ExchangePublicInput,
} from "@/modules/auth/oauth2/inputs/exchange.input";
import {
OAuth2RefreshConfidentialInput,
OAuth2RefreshPublicInput,
} from "@/modules/auth/oauth2/inputs/refresh.input";
import type { OAuth2TokenInput } from "@/modules/auth/oauth2/inputs/token.input.pipe";
import { OAuth2TokenInputPipe } from "@/modules/auth/oauth2/inputs/token.input.pipe";
import { OAuth2ClientDto, OAuth2ClientResponseDto } from "@/modules/auth/oauth2/outputs/oauth2-client.output";
import { OAuth2TokensDto } from "@/modules/auth/oauth2/outputs/oauth2-tokens.output";
import { OAuth2ErrorService } from "@/modules/auth/oauth2/services/oauth2-error.service";
@Controller({
path: "/v2/auth/oauth2/clients/:clientId",
path: "/v2/auth/oauth2",
version: API_VERSIONS_VALUES,
})
@ApiExcludeController(true)
@ApiTags("OAuth2")
export class OAuth2Controller {
private readonly logger = new Logger("OAuth2Controller");
constructor(
private readonly oAuthService: OAuthService,
private readonly errorHandler: OAuth2ErrorService
) {}
constructor(private readonly oAuthService: OAuthService) {}
@Get("/")
@Get("/clients/:clientId")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard)
@ApiOperation({
@@ -55,114 +57,50 @@ export class OAuth2Controller {
status: SUCCESS_STATUS,
data: plainToInstance(OAuth2ClientDto, client, { strategy: "excludeAll" }),
};
} catch (err: unknown) {
if (err instanceof ErrorWithCode) {
const statusCode = getHttpStatusCode(err);
throw new HttpException(err.message, statusCode);
}
this.logger.error(err);
throw new InternalServerErrorException("Could not get oAuthClient");
} catch (err) {
this.errorHandler.handleClientError(err, "Failed to retrieve OAuth client");
}
}
@Post("/authorize")
@UseGuards(ApiAuthGuard)
@ApiAuthGuardOnlyAllow(["NEXT_AUTH"])
@Post("/token")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Generate authorization code",
summary: "Exchange authorization code or refresh token for tokens",
description:
"Generates an authorization code for the OAuth2 flow and redirects to the redirect URI with the code. Requires user authentication.",
"RFC 6749-compliant token endpoint. Pass client_id in the request body (Section 2.3.1). " +
"Use grant_type 'authorization_code' to exchange an auth code for tokens, or 'refresh_token' to refresh an access token. " +
"Accepts both application/x-www-form-urlencoded (standard per RFC 6749 Section 4.1.3) and application/json content types.",
})
async authorize(
@Param("clientId") clientId: string,
@Body() body: OAuth2AuthorizeInput,
@GetUser("id") userId: number,
@Res() res: Response
): Promise<void> {
@ApiBody({
schema: {
oneOf: [
{ $ref: getSchemaPath(OAuth2ExchangeConfidentialInput) },
{ $ref: getSchemaPath(OAuth2ExchangePublicInput) },
{ $ref: getSchemaPath(OAuth2RefreshConfidentialInput) },
{ $ref: getSchemaPath(OAuth2RefreshPublicInput) },
],
},
description:
"Token request body. client_id is required. " +
"Accepts application/x-www-form-urlencoded (RFC 6749 standard) or application/json. " +
"Use grant_type 'authorization_code' with client_secret (confidential) or code_verifier (public/PKCE), or grant_type 'refresh_token' with client_secret (confidential) or just the refresh_token (public).",
})
@ApiExtraModels(
OAuth2ExchangeConfidentialInput,
OAuth2ExchangePublicInput,
OAuth2RefreshConfidentialInput,
OAuth2RefreshPublicInput
)
@UseFilters(OAuth2HttpExceptionFilter)
@Header("Cache-Control", "no-store")
@Header("Pragma", "no-cache")
async token(@Body(new OAuth2TokenInputPipe()) body: OAuth2TokenInput): Promise<OAuth2TokensDto> {
try {
const client = await this.oAuthService.getClient(clientId);
const result = await this.oAuthService.generateAuthorizationCode(
client.clientId,
userId,
body.redirectUri,
body.scopes,
body.state,
body.teamSlug,
body.codeChallenge,
body.codeChallengeMethod
);
return res.redirect(303, result.redirectUrl);
} catch (err: unknown) {
if (err instanceof ErrorWithCode) {
if (err.message === "unauthorized_client" || err?.data?.["reason"] === "redirect_uri_mismatch") {
const statusCode = getHttpStatusCode(err);
throw new HttpException(err.message, statusCode);
}
}
const errorRedirectUrl = this.oAuthService.buildErrorRedirectUrl(body.redirectUri, err, body.state);
return res.redirect(303, errorRedirectUrl);
}
}
const tokens = await this.oAuthService.handleTokenRequest(body.client_id, body);
@Post("/exchange")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Exchange authorization code for tokens",
description: "Exchanges an authorization code for access and refresh tokens",
})
async exchange(
@Param("clientId") clientId: string,
@Body() body: OAuth2ExchangeInput
): Promise<OAuth2TokensResponseDto> {
try {
const tokens = await this.oAuthService.exchangeCodeForTokens(
clientId,
body.code,
body.clientSecret,
body.redirectUri,
body.codeVerifier
);
return {
status: SUCCESS_STATUS,
data: plainToInstance(OAuth2TokensDto, tokens, { strategy: "excludeAll" }),
};
} catch (err: unknown) {
if (err instanceof ErrorWithCode) {
const statusCode = getHttpStatusCode(err);
throw new HttpException(err.message, statusCode);
}
this.logger.error(err);
throw new InternalServerErrorException("Could not exchange code for tokens");
}
}
@Post("/refresh")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Refresh access token",
description: "Refreshes an access token using a refresh token",
})
async refresh(
@Param("clientId") clientId: string,
@Body() body: OAuth2RefreshInput
): Promise<OAuth2TokensResponseDto> {
try {
const tokens = await this.oAuthService.refreshAccessToken(
clientId,
body.refreshToken,
body.clientSecret
);
return {
status: SUCCESS_STATUS,
data: plainToInstance(OAuth2TokensDto, tokens, { strategy: "excludeAll" }),
};
} catch (err: unknown) {
if (err instanceof ErrorWithCode) {
const statusCode = getHttpStatusCode(err);
throw new HttpException(err.message, statusCode);
}
this.logger.error(err);
throw new InternalServerErrorException("Could not refresh tokens");
return plainToInstance(OAuth2TokensDto, tokens, { strategy: "excludeAll" });
} catch (err) {
this.errorHandler.handleTokenError(err);
}
}
}
@@ -0,0 +1,32 @@
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Catch, Logger } from "@nestjs/common";
import type { Request, Response } from "express";
import { extractUserContext } from "@/lib/extract-user-context";
import { filterReqHeaders } from "@/lib/filterReqHeaders";
import { OAuth2HttpException } from "@/modules/auth/oauth2/filters/oauth2-http.exception";
@Catch(OAuth2HttpException)
export class OAuth2HttpExceptionFilter implements ExceptionFilter<OAuth2HttpException> {
private readonly logger = new Logger("OAuth2HttpExceptionFilter");
catch(exception: OAuth2HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const requestId = request.headers["x-request-id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const userContext = extractUserContext(request);
this.logger.error(`OAuth2 Http Exception: ${exception.oAuthErrorData.error}`, {
exception,
body: "[REDACTED]",
headers: filterReqHeaders(request.headers),
url: request.url,
method: request.method,
requestId,
...userContext,
});
response.status(exception.getStatus()).json(exception.oAuthErrorData);
}
}
@@ -0,0 +1,10 @@
import { HttpException } from "@nestjs/common";
export class OAuth2HttpException extends HttpException {
constructor(
public readonly oAuthErrorData: { error: string; error_description: string },
statusCode: number
) {
super(oAuthErrorData, statusCode);
}
}
@@ -0,0 +1,14 @@
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Catch } from "@nestjs/common";
import type { Response } from "express";
import { OAuth2RedirectException } from "@/modules/auth/oauth2/filters/oauth2-redirect.exception";
@Catch(OAuth2RedirectException)
export class OAuth2RedirectExceptionFilter implements ExceptionFilter<OAuth2RedirectException> {
catch(exception: OAuth2RedirectException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
response.redirect(303, exception.redirectUrl);
}
}
@@ -0,0 +1,10 @@
import { HttpException, HttpStatus } from "@nestjs/common";
export class OAuth2RedirectException extends HttpException {
readonly redirectUrl: string;
constructor(redirectUrl: string) {
super("OAuth2 redirect", HttpStatus.SEE_OTHER);
this.redirectUrl = redirectUrl;
}
}
@@ -1,8 +1,7 @@
import { AccessScope } from "@calcom/prisma/enums";
import { ApiHideProperty, ApiProperty } from "@nestjs/swagger";
import { Equals, IsArray, IsEnum, IsOptional, IsString } from "class-validator";
import { AccessScope } from "@calcom/prisma/enums";
export class OAuth2AuthorizeInput {
@ApiProperty({
description:
@@ -10,7 +9,7 @@ export class OAuth2AuthorizeInput {
example: "https://example.com/callback",
})
@IsString()
redirectUri!: string;
redirect_uri!: string;
@ApiProperty({
description: "OAuth state parameter for CSRF protection. Will be included in the redirect.",
@@ -36,7 +35,7 @@ export class OAuth2AuthorizeInput {
})
@IsOptional()
@IsString()
teamSlug?: string;
team_slug?: string;
@ApiProperty({
description: "PKCE code challenge (required for public clients)",
@@ -44,10 +43,10 @@ export class OAuth2AuthorizeInput {
})
@IsOptional()
@IsString()
codeChallenge?: string;
code_challenge?: string;
@ApiHideProperty()
@IsString()
@Equals("S256")
codeChallengeMethod: string = "S256";
code_challenge_method: string = "S256";
}
@@ -1,7 +1,23 @@
import { ApiHideProperty, ApiProperty } from "@nestjs/swagger";
import { Equals, IsOptional, IsString } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
import { Equals, IsString } from "class-validator";
export class OAuth2ExchangeConfidentialInput {
@ApiProperty({
description: "The client identifier",
example: "my-client-id",
})
@IsString()
client_id!: string;
@ApiProperty({
description: "The grant type — must be 'authorization_code'",
example: "authorization_code",
enum: ["authorization_code"],
})
@IsString()
@Equals("authorization_code")
grant_type!: "authorization_code";
export class OAuth2ExchangeInput {
@ApiProperty({
description: "The authorization code received from the authorize endpoint",
example: "abc123",
@@ -10,30 +26,55 @@ export class OAuth2ExchangeInput {
code!: string;
@ApiProperty({
description: "The client secret (required for confidential clients)",
required: false,
description: "The redirect URI used in the authorization request",
example: "https://example.com/callback",
})
@IsOptional()
@IsString()
clientSecret?: string;
redirect_uri!: string;
@ApiProperty({
description: "The client secret for confidential clients",
})
@IsString()
client_secret!: string;
}
export class OAuth2ExchangePublicInput {
@ApiProperty({
description: "The client identifier",
example: "my-client-id",
})
@IsString()
client_id!: string;
@ApiProperty({
description: "The grant type — must be 'authorization_code'",
example: "authorization_code",
enum: ["authorization_code"],
})
@IsString()
@Equals("authorization_code")
grant_type!: "authorization_code";
@ApiProperty({
description: "The authorization code received from the authorize endpoint",
example: "abc123",
})
@IsString()
code!: string;
@ApiProperty({
description: "The redirect URI used in the authorization request",
example: "https://example.com/callback",
})
@IsString()
redirectUri!: string;
@ApiHideProperty()
@IsString()
@Equals("authorization_code")
grantType: string = "authorization_code";
redirect_uri!: string;
@ApiProperty({
description: "PKCE code verifier (required if code_challenge was used)",
required: false,
description: "PKCE code verifier (required for public clients that used code_challenge)",
})
@IsOptional()
@IsString()
codeVerifier?: string;
code_verifier!: string;
}
export type OAuth2ExchangeInput = OAuth2ExchangeConfidentialInput | OAuth2ExchangePublicInput;
@@ -1,24 +1,60 @@
import { ApiHideProperty, ApiProperty } from "@nestjs/swagger";
import { Equals, IsOptional, IsString } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
import { Equals, IsString } from "class-validator";
export class OAuth2RefreshConfidentialInput {
@ApiProperty({
description: "The client identifier",
example: "my-client-id",
})
@IsString()
client_id!: string;
@ApiProperty({
description: "The grant type — must be 'refresh_token'",
example: "refresh_token",
enum: ["refresh_token"],
})
@IsString()
@Equals("refresh_token")
grant_type!: "refresh_token";
export class OAuth2RefreshInput {
@ApiProperty({
description: "The refresh token",
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
})
@IsString()
refreshToken!: string;
refresh_token!: string;
@ApiProperty({
description: "The client secret (required for confidential clients)",
required: false,
description: "The client secret for confidential clients",
})
@IsOptional()
@IsString()
clientSecret?: string;
client_secret!: string;
}
@ApiHideProperty()
export class OAuth2RefreshPublicInput {
@ApiProperty({
description: "The client identifier",
example: "my-client-id",
})
@IsString()
client_id!: string;
@ApiProperty({
description: "The grant type — must be 'refresh_token'",
example: "refresh_token",
enum: ["refresh_token"],
})
@IsString()
@Equals("refresh_token")
grantType: string = "refresh_token";
grant_type!: "refresh_token";
@ApiProperty({
description: "The refresh token",
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
})
@IsString()
refresh_token!: string;
}
export type OAuth2RefreshInput = OAuth2RefreshConfidentialInput | OAuth2RefreshPublicInput;
@@ -0,0 +1,107 @@
import type { PipeTransform } from "@nestjs/common";
import { HttpStatus, Injectable } from "@nestjs/common";
import { plainToClass } from "class-transformer";
import type { ValidationError } from "class-validator";
import { validateSync } from "class-validator";
import { OAuth2ExchangeConfidentialInput, OAuth2ExchangePublicInput } from "./exchange.input";
import { OAuth2RefreshConfidentialInput, OAuth2RefreshPublicInput } from "./refresh.input";
import { OAuth2HttpException } from "@/modules/auth/oauth2/filters/oauth2-http.exception";
export type OAuth2TokenInput =
| OAuth2ExchangeConfidentialInput
| OAuth2ExchangePublicInput
| OAuth2RefreshConfidentialInput
| OAuth2RefreshPublicInput;
@Injectable()
export class OAuth2TokenInputPipe implements PipeTransform {
// eslint-disable-next-line @typescript-eslint/no-empty-function
constructor() {}
transform(value: OAuth2TokenInput): OAuth2TokenInput {
if (!value) {
this.throwOAuthError("Body is required");
}
if (typeof value !== "object") {
this.throwOAuthError("Body should be an object");
}
const raw = value as unknown as Record<string, unknown>;
if (!raw.client_id || typeof raw.client_id !== "string") {
this.throwOAuthError("client_id is required");
}
const grantType = raw.grant_type;
if (grantType === "authorization_code") {
return this.validateExchange(value);
}
if (grantType === "refresh_token") {
return this.validateRefresh(value);
}
this.throwOAuthError("grant_type must be 'authorization_code' or 'refresh_token'");
}
private validateExchange(
value: OAuth2TokenInput
): OAuth2ExchangeConfidentialInput | OAuth2ExchangePublicInput {
if (this.isConfidentialExchange(value)) {
return this.validate(OAuth2ExchangeConfidentialInput, value);
}
return this.validate(OAuth2ExchangePublicInput, value);
}
private validateRefresh(
value: OAuth2TokenInput
): OAuth2RefreshConfidentialInput | OAuth2RefreshPublicInput {
if (this.isConfidentialRefresh(value)) {
return this.validate(OAuth2RefreshConfidentialInput, value);
}
return this.validate(OAuth2RefreshPublicInput, value);
}
private validate<T extends object>(cls: new () => T, value: OAuth2TokenInput): T {
const object = plainToClass(cls, value);
const errors = validateSync(object, {
whitelist: true,
forbidNonWhitelisted: true,
skipMissingProperties: false,
});
if (errors.length > 0) {
this.throwOAuthError(this.formatErrors(errors));
}
return object;
}
private formatErrors(errors: ValidationError[]): string {
return errors
.map((err) => {
const constraints = err.constraints ? Object.values(err.constraints).join(", ") : "";
const childrenErrors =
err.children && err.children.length > 0 ? `${this.formatErrors(err.children)}` : "";
return `${err.property} property is wrong,${constraints} ${childrenErrors}`;
})
.join(", ");
}
private isConfidentialExchange(value: OAuth2TokenInput): value is OAuth2ExchangeConfidentialInput {
return "client_secret" in value;
}
private isConfidentialRefresh(value: OAuth2TokenInput): value is OAuth2RefreshConfidentialInput {
return "client_secret" in value;
}
private throwOAuthError(description: string): never {
throw new OAuth2HttpException(
{ error: "invalid_request", error_description: description },
HttpStatus.BAD_REQUEST
);
}
}
@@ -1,10 +1,12 @@
import { Module } from "@nestjs/common";
import { oAuthServiceModule } from "@/lib/modules/oauth.module";
import { AtomsOAuth2Controller } from "@/modules/auth/oauth2/controllers/atoms-oauth2.controller";
import { OAuth2Controller } from "@/modules/auth/oauth2/controllers/oauth2.controller";
import { Module } from "@nestjs/common";
import { OAuth2ErrorService } from "@/modules/auth/oauth2/services/oauth2-error.service";
@Module({
imports: [oAuthServiceModule],
controllers: [OAuth2Controller, AtomsOAuth2Controller],
providers: [OAuth2ErrorService],
})
export class OAuth2Module {}
@@ -1,10 +1,9 @@
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { OAuthClientType } from "@calcom/prisma/enums";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsBoolean, IsEnum, IsNotEmptyObject, IsOptional, IsString, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { OAuthClientType } from "@calcom/prisma/enums";
export class OAuth2ClientDto {
@ApiProperty({
description: "The OAuth client ID",
@@ -12,15 +11,15 @@ export class OAuth2ClientDto {
})
@IsString()
@Expose({ name: "clientId" })
id!: string;
client_id!: string;
@ApiProperty({
description: "The redirect URI for the OAuth client",
example: "https://example.com/callback",
})
@IsString()
@Expose()
redirectUri!: string;
@Expose({ name: "redirectUri" })
redirect_uri!: string;
@ApiProperty({
description: "The name of the OAuth client",
@@ -44,8 +43,8 @@ export class OAuth2ClientDto {
example: false,
})
@IsBoolean()
@Expose()
isTrusted!: boolean;
@Expose({ name: "isTrusted" })
is_trusted!: boolean;
@ApiProperty({
description: "The type of OAuth client (CONFIDENTIAL or PUBLIC)",
@@ -54,7 +53,7 @@ export class OAuth2ClientDto {
})
@IsEnum(OAuthClientType)
@Expose({ name: "clientType" })
type!: OAuthClientType;
client_type!: OAuthClientType;
}
export class OAuth2ClientResponseDto {
@@ -1,8 +1,6 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, IsNumber, IsString, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { Expose } from "class-transformer";
import { IsNumber, IsString } from "class-validator";
export class OAuth2TokensDto {
@ApiProperty({
@@ -37,19 +35,3 @@ export class OAuth2TokensDto {
@Expose({ name: "expiresIn" })
expires_in!: number;
}
export class OAuth2TokensResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
@Expose()
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: OAuth2TokensDto,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => OAuth2TokensDto)
@Expose()
data!: OAuth2TokensDto;
}
@@ -0,0 +1,72 @@
import { ErrorWithCode, getHttpStatusCode } from "@calcom/platform-libraries/errors";
import { HttpException, Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
import { OAuthService } from "@/lib/services/oauth.service";
import { OAuth2HttpException } from "@/modules/auth/oauth2/filters/oauth2-http.exception";
import { OAuth2RedirectException } from "@/modules/auth/oauth2/filters/oauth2-redirect.exception";
const NON_REDIRECTABLE_REASONS = new Set([
"client_not_found",
"client_not_approved",
"redirect_uri_mismatch",
]);
@Injectable()
export class OAuth2ErrorService {
private readonly logger = new Logger("OAuth2ErrorService");
constructor(private readonly oAuthService: OAuthService) {}
handleAuthorizeError(err: unknown, redirectUri: string, state?: string): never {
if (err instanceof ErrorWithCode) {
const reason = err.data?.["reason"] as string | undefined;
if (reason && NON_REDIRECTABLE_REASONS.has(reason)) {
const statusCode = getHttpStatusCode(err);
throw new OAuth2HttpException(
{
error: err.message,
error_description: reason,
},
statusCode
);
}
}
const errorRedirectUrl = this.oAuthService.buildErrorRedirectUrl(redirectUri, err, state);
throw new OAuth2RedirectException(errorRedirectUrl);
}
handleTokenError(err: unknown): never {
if (err instanceof ErrorWithCode) {
const statusCode = getHttpStatusCode(err);
const reason = err.data?.["reason"] as string | undefined;
throw new OAuth2HttpException(
{
error: err.message,
error_description: reason ?? err.message,
},
statusCode
);
}
this.logger.error(err);
throw new OAuth2HttpException(
{
error: "server_error",
error_description: "An unexpected error occurred",
},
500
);
}
handleClientError(err: unknown, fallbackMessage: string): never {
if (err instanceof ErrorWithCode) {
const statusCode = getHttpStatusCode(err);
if (statusCode >= 500) {
this.logger.error(err);
}
throw new HttpException(err.message, statusCode);
}
this.logger.error(err);
throw new InternalServerErrorException(fallbackMessage);
}
}
@@ -1,8 +1,7 @@
import { OAuthClientStatus, OAuthClientType } from "@calcom/prisma/enums";
import { TestingModule } from "@nestjs/testing";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { OAuthClientType } from "@calcom/prisma/enums";
export class OAuth2ClientRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
@@ -19,6 +18,7 @@ export class OAuth2ClientRepositoryFixture {
redirectUri: string;
clientSecret?: string;
clientType?: OAuthClientType;
status?: OAuthClientStatus;
logo?: string;
isTrusted?: boolean;
}) {
@@ -29,6 +29,7 @@ export class OAuth2ClientRepositoryFixture {
redirectUri: data.redirectUri,
clientSecret: data.clientSecret,
clientType: data.clientType || OAuthClientType.CONFIDENTIAL,
status: data.status || OAuthClientStatus.APPROVED,
logo: data.logo,
isTrusted: data.isTrusted || false,
},
+397 -39
View File
@@ -9,7 +9,7 @@ As an example, you can view our OAuth flow in action on Zapier. Try to connect y
![](/images/oauth-zapier.png)
</Frame>
### Get your OAuth "Continue with [Cal.com](http://Cal.com)" Badge
#### Get your OAuth "Continue with [Cal.com](http://Cal.com)" Badge
- https://app.cal.com/continue-with-calcom-coss-ui.svg
- https://app.cal.com/continue-with-calcom-dark-rounded.svg
@@ -20,7 +20,7 @@ As an example, you can view our OAuth flow in action on Zapier. Try to connect y
- https://app.cal.com/continue-with-calcom-light-squared.svg
### OAuth Client Credentials
## 1. OAuth Client Credentials
You can create an OAuth client via the following page https://app.cal.com/settings/developer/oauth. The OAuth client will be in a "pending" state
and not yet ready to use.
@@ -28,66 +28,424 @@ and not yet ready to use.
An admin from Cal.com will then review your OAuth client and you will receive an email if it was accepted or rejected. If it was accepted then your OAuth client
is ready to be used.
### Authorization URL
## 2. Authorize
To initiate the OAuth flow, direct users to the following authorization URL:
- `https://app.cal.com/auth/oauth2/authorize`
- URL Parameters:
- _client_id_
- _state_: A securely generated random string to mitigate CSRF attacks
- _redirect_uri_: This is where users will be redirected after authorization
`https://app.cal.com/auth/oauth2/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=YOUR_REDIRECT_URI&state=YOUR_STATE`
After users click _Allow_, they will be redirected to the redirect_uri with the code (authorization code) and state as URL parameters.
**URL Parameters:**
### Access Token Request
| Parameter | Required | Description |
|-----------|----------|-------------|
| `client_id` | Yes | Your OAuth client ID |
| `redirect_uri` | Yes | Where users will be redirected after authorization. Must exactly match the registered redirect URI. |
| `state` | Recommended | A securely generated random string to mitigate CSRF attacks |
| `code_challenge` | For public clients | PKCE code challenge (S256 method) |
Endpoint: `POST https://app.cal.com/api/auth/oauth/token`
Request Body:
- _code_: The authorization code received in the redirect URI
- _client_id_
- _client_secret_
- _grant_type_: "authorization_code"
- _redirect_uri_
Response:
After users click **Allow**, they will be redirected to the `redirect_uri` with `code` (authorization code) and `state` as URL parameters:
```
https://your-app.com/callback?code=AUTHORIZATION_CODE&state=YOUR_STATE
```
#### Error Handling
Errors during the authorization step are displayed directly to the user on the Cal.com authorization page. Your application will not receive a JSON error response for these cases:
- **Client not found**: No OAuth client exists with the provided `client_id`.
- **Client not approved**: The OAuth client has not been approved by a Cal.com admin yet.
- **Mismatched redirect URI**: The `redirect_uri` does not match the one registered for the OAuth client.
If an error occurs after the client is validated (e.g., the user denies access or has insufficient permissions), the user is redirected to the `redirect_uri` with an error:
```
https://your-app.com/callback?error=access_denied&error_description=team_not_found_or_no_access&state=YOUR_STATE
```
## 3. Exchange Token
Exchange an authorization code for access and refresh tokens. The token endpoint also accepts `application/x-www-form-urlencoded` content type.
**Endpoint:** `POST https://api.cal.com/v2/auth/oauth2/token`
### 3.1 Confidential Clients
Confidential clients authenticate with a `client_secret`. All parameters are required:
| Parameter | Description |
|-----------|-------------|
| `client_id` | Your OAuth client ID |
| `client_secret` | Your OAuth client secret |
| `grant_type` | Must be `authorization_code` |
| `code` | The authorization code received in the redirect URI |
| `redirect_uri` | Must match the redirect URI used in the authorization request |
<Tabs>
<Tab title="cURL">
```bash
curl -X POST https://api.cal.com/v2/auth/oauth2/token \
-H "Content-Type: application/json" \
-d '{
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"grant_type": "authorization_code",
"code": "AUTHORIZATION_CODE",
"redirect_uri": "https://your-app.com/callback"
}'
```
</Tab>
<Tab title="fetch">
```typescript
const response = await fetch("https://api.cal.com/v2/auth/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: "YOUR_CLIENT_ID",
client_secret: "YOUR_CLIENT_SECRET",
grant_type: "authorization_code",
code: "AUTHORIZATION_CODE",
redirect_uri: "https://your-app.com/callback",
}),
});
const tokens = await response.json();
```
</Tab>
<Tab title="axios">
```typescript
import axios from "axios";
const { data } = await axios.post(
"https://api.cal.com/v2/auth/oauth2/token",
{
client_id: "YOUR_CLIENT_ID",
client_secret: "YOUR_CLIENT_SECRET",
grant_type: "authorization_code",
code: "AUTHORIZATION_CODE",
redirect_uri: "https://your-app.com/callback",
}
);
```
</Tab>
</Tabs>
### 3.2 Public Clients (PKCE)
Public clients (e.g. single-page apps, mobile apps) use PKCE instead of a `client_secret`. You must have sent a `code_challenge` during the authorization step. All parameters are required:
| Parameter | Description |
|-----------|-------------|
| `client_id` | Your OAuth client ID |
| `grant_type` | Must be `authorization_code` |
| `code` | The authorization code received in the redirect URI |
| `redirect_uri` | Must match the redirect URI used in the authorization request |
| `code_verifier` | The original PKCE code verifier used to generate the `code_challenge` |
<Tabs>
<Tab title="cURL">
```bash
curl -X POST https://api.cal.com/v2/auth/oauth2/token \
-H "Content-Type: application/json" \
-d '{
"client_id": "YOUR_CLIENT_ID",
"grant_type": "authorization_code",
"code": "AUTHORIZATION_CODE",
"redirect_uri": "https://your-app.com/callback",
"code_verifier": "YOUR_CODE_VERIFIER"
}'
```
</Tab>
<Tab title="fetch">
```typescript
const response = await fetch("https://api.cal.com/v2/auth/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: "YOUR_CLIENT_ID",
grant_type: "authorization_code",
code: "AUTHORIZATION_CODE",
redirect_uri: "https://your-app.com/callback",
code_verifier: "YOUR_CODE_VERIFIER",
}),
});
const tokens = await response.json();
```
</Tab>
<Tab title="axios">
```typescript
import axios from "axios";
const { data } = await axios.post(
"https://api.cal.com/v2/auth/oauth2/token",
{
client_id: "YOUR_CLIENT_ID",
grant_type: "authorization_code",
code: "AUTHORIZATION_CODE",
redirect_uri: "https://your-app.com/callback",
code_verifier: "YOUR_CODE_VERIFIER",
}
);
```
</Tab>
</Tabs>
#### Success Response (200)
```json
{
access_token: “exampleAccessToken”
refresh_token: “exampleRefreshToken”
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer",
"expires_in": 1800
}
```
### Refresh Token Request
<Note>
Access tokens expire after 30 minutes (`expires_in: 1800`). Use the refresh token to obtain a new access token.
</Note>
Endpoint: `POST https://app.cal.com/api/auth/oauth/refreshToken`
#### Error Responses
Headers:
Error responses include `error` and `error_description` fields.
- Authorization: Bearer _exampleRefreshToken_
<AccordionGroup>
<Accordion title="Invalid or expired authorization code (400)">
```json
{
"error": "invalid_grant",
"error_description": "code_invalid_or_expired"
}
```
The authorization code has already been used, has expired, or is invalid. Request a new authorization code.
</Accordion>
<Accordion title="Invalid client credentials (401)">
```json
{
"error": "invalid_client",
"error_description": "invalid_client_credentials"
}
```
The `client_secret` does not match the `client_id`. Verify your credentials.
</Accordion>
<Accordion title="Client not found (401)">
```json
{
"error": "invalid_client",
"error_description": "client_not_found"
}
```
No OAuth client exists with the provided `client_id`.
</Accordion>
<Accordion title="Missing client_id (400)">
```json
{
"error": "invalid_request",
"error_description": "client_id is required"
}
```
The `client_id` field is missing from the request body.
</Accordion>
<Accordion title="Invalid grant_type (400)">
```json
{
"error": "invalid_request",
"error_description": "grant_type must be 'authorization_code' or 'refresh_token'"
}
```
The `grant_type` field must be either `authorization_code` or `refresh_token`.
</Accordion>
</AccordionGroup>
Request Body:
## 4. Refresh Token
- _grant_type_: "refresh_token"
- _client_id_
- _client_secret_
Refresh an expired access token using a refresh token.
Response:
**Endpoint:** `POST https://api.cal.com/v2/auth/oauth2/token`
```
### 4.1 Confidential Clients
Confidential clients authenticate with a `client_secret`. All parameters are required:
| Parameter | Description |
|-----------|-------------|
| `client_id` | Your OAuth client ID |
| `client_secret` | Your OAuth client secret |
| `grant_type` | Must be `refresh_token` |
| `refresh_token` | The refresh token received from a previous token response |
<Tabs>
<Tab title="cURL">
```bash
curl -X POST https://api.cal.com/v2/auth/oauth2/token \
-H "Content-Type: application/json" \
-d '{
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"grant_type": "refresh_token",
"refresh_token": "YOUR_REFRESH_TOKEN"
}'
```
</Tab>
<Tab title="fetch">
```typescript
const response = await fetch("https://api.cal.com/v2/auth/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: "YOUR_CLIENT_ID",
client_secret: "YOUR_CLIENT_SECRET",
grant_type: "refresh_token",
refresh_token: "YOUR_REFRESH_TOKEN",
}),
});
const tokens = await response.json();
```
</Tab>
<Tab title="axios">
```typescript
import axios from "axios";
const { data } = await axios.post(
"https://api.cal.com/v2/auth/oauth2/token",
{
client_id: "YOUR_CLIENT_ID",
client_secret: "YOUR_CLIENT_SECRET",
grant_type: "refresh_token",
refresh_token: "YOUR_REFRESH_TOKEN",
}
);
```
</Tab>
</Tabs>
### 4.2 Public Clients
Public clients do not use a `client_secret`. All parameters are required:
| Parameter | Description |
|-----------|-------------|
| `client_id` | Your OAuth client ID |
| `grant_type` | Must be `refresh_token` |
| `refresh_token` | The refresh token received from a previous token response |
<Tabs>
<Tab title="cURL">
```bash
curl -X POST https://api.cal.com/v2/auth/oauth2/token \
-H "Content-Type: application/json" \
-d '{
"client_id": "YOUR_CLIENT_ID",
"grant_type": "refresh_token",
"refresh_token": "YOUR_REFRESH_TOKEN"
}'
```
</Tab>
<Tab title="fetch">
```typescript
const response = await fetch("https://api.cal.com/v2/auth/oauth2/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: "YOUR_CLIENT_ID",
grant_type: "refresh_token",
refresh_token: "YOUR_REFRESH_TOKEN",
}),
});
const tokens = await response.json();
```
</Tab>
<Tab title="axios">
```typescript
import axios from "axios";
const { data } = await axios.post(
"https://api.cal.com/v2/auth/oauth2/token",
{
client_id: "YOUR_CLIENT_ID",
grant_type: "refresh_token",
refresh_token: "YOUR_REFRESH_TOKEN",
}
);
```
</Tab>
</Tabs>
#### Success Response (200)
```json
{
access_token: “exampleAccessToken”,
refresh_token: "exampleRefreshToken"
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer",
"expires_in": 1800
}
```
### Testing OAuth Credentials
#### Error Responses
To verify the correct setup and functionality of OAuth credentials you can use the following endpoint: `GET https://api.cal.com/v2/me`
<AccordionGroup>
<Accordion title="Invalid refresh token (400)">
```json
{
"error": "invalid_grant",
"error_description": "invalid_refresh_token"
}
```
The refresh token is invalid, expired, or malformed. The user must re-authorize.
</Accordion>
<Accordion title="Invalid client credentials (401)">
```json
{
"error": "invalid_client",
"error_description": "invalid_client_credentials"
}
```
The `client_secret` does not match the `client_id`.
</Accordion>
<Accordion title="Client not found (401)">
```json
{
"error": "invalid_client",
"error_description": "client_not_found"
}
```
No OAuth client exists with the provided `client_id`.
</Accordion>
</AccordionGroup>
Headers:
## 5. Verify Access Token
- Authorization: Bearer _exampleAccessToken_
To verify the correct setup and functionality of OAuth credentials, use the following endpoint:
**Endpoint:** `GET https://api.cal.com/v2/me`
<Tabs>
<Tab title="cURL">
```bash
curl -X GET https://api.cal.com/v2/me \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```
</Tab>
<Tab title="fetch">
```typescript
const response = await fetch("https://api.cal.com/v2/me", {
headers: { Authorization: "Bearer YOUR_ACCESS_TOKEN" },
});
const user = await response.json();
```
</Tab>
<Tab title="axios">
```typescript
import axios from "axios";
const { data } = await axios.get("https://api.cal.com/v2/me", {
headers: { Authorization: "Bearer YOUR_ACCESS_TOKEN" },
});
```
</Tab>
</Tabs>
File diff suppressed because it is too large Load Diff
@@ -221,7 +221,7 @@ export class OAuthService {
if (validOAuthErrors.includes(error.message)) {
return {
error: error.message,
errorDescription: (error.data?.cause as string | undefined) ?? error.message,
errorDescription: (error.data?.reason as string | undefined) ?? error.message,
};
}
@@ -229,17 +229,17 @@ export class OAuthService {
case ErrorCode.BadRequest:
return {
error: "invalid_request",
errorDescription: (error.data?.cause as string | undefined) ?? error.message,
errorDescription: (error.data?.reason as string | undefined) ?? error.message,
};
case ErrorCode.Unauthorized:
return {
error: "unauthorized_client",
errorDescription: (error.data?.cause as string | undefined) ?? error.message,
errorDescription: (error.data?.reason as string | undefined) ?? error.message,
};
default:
return {
error: "server_error",
errorDescription: (error.data?.cause as string | undefined) ?? error.message,
errorDescription: (error.data?.reason as string | undefined) ?? error.message,
};
}
}
+3 -9
View File
@@ -49,16 +49,13 @@ export type {
BookingCreateBody,
BookingResponse,
} from "@calcom/features/bookings/types";
export { getBusyCalendarTimes } from "@calcom/features/calendars/lib/CalendarManager";
export type { ConnectedCalendar } from "@calcom/features/calendars/lib/CalendarManager";
export { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
export { getBusyCalendarTimes } from "@calcom/features/calendars/lib/CalendarManager";
export type { ConnectedDestinationCalendars } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
export { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
export type { CityTimezones } from "@calcom/features/cityTimezones/cityTimezonesHandler";
export { cityTimezonesHandler } from "@calcom/features/cityTimezones/cityTimezonesHandler";
export { ENABLE_ASYNC_TASKER, MINUTES_TO_BOOK } from "@calcom/lib/constants";
export { TRPCError } from "@trpc/server";
export { createNewUsersConnectToOrgIfExists };
@@ -67,9 +64,7 @@ export { getBookingInfo };
export { handleCancelBooking };
export { dynamicEvent } from "@calcom/features/eventtypes/lib/defaultEvents";
export { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
export { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
export { bookingMetadataSchema, teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
@@ -79,7 +74,6 @@ export { getTranslation };
export { roundRobinManualReassignment } from "@calcom/features/ee/round-robin/roundRobinManualReassignment";
export { roundRobinReassignment } from "@calcom/features/ee/round-robin/roundRobinReassignment";
export { validateCustomEventName } from "@calcom/features/eventtypes/lib/eventNaming";
export type TeamQuery = Prisma.TeamGetPayload<{
@@ -125,8 +119,8 @@ export { verifyCodeUnAuthenticated } from "@calcom/features/auth/lib/verifyCodeU
export { sendEmailVerificationByCode } from "@calcom/features/auth/lib/verifyEmail";
export { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService";
export { StripeBillingService } from "@calcom/features/ee/billing/service/billingProvider/StripeBillingService";
export { TeamService } from "@calcom/features/ee/teams/services/teamService";
export type { OAuth2Tokens } from "@calcom/features/oauth/services/OAuthService";
export { OAuthService } from "@calcom/features/oauth/services/OAuthService";
export { generateSecret } from "@calcom/features/oauth/utils/generateSecret";
export { ProfileRepository } from "@calcom/features/profile/repositories/ProfileRepository";