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:
co-authored by
unknown
Devin AI
parent
bcbb714ea9
commit
bd25cba89c
@@ -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)
|
||||
|
||||
@@ -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
@@ -9,7 +9,7 @@ As an example, you can view our OAuth flow in action on Zapier. Try to connect y
|
||||

|
||||
</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>
|
||||
|
||||
+470
-208
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user