diff --git a/apps/web/app/api/auth/oauth/refreshToken/route.ts b/apps/web/app/api/auth/oauth/refreshToken/route.ts index ef0baeeee8..aafcedaa77 100644 --- a/apps/web/app/api/auth/oauth/refreshToken/route.ts +++ b/apps/web/app/api/auth/oauth/refreshToken/route.ts @@ -4,52 +4,71 @@ import jwt from "jsonwebtoken"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository"; +import { OAuthService } from "@calcom/features/oauth/services/OAuthService"; import prisma from "@calcom/prisma"; -import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler"; import type { OAuthTokenPayload } from "@calcom/types/oauth"; async function handler(req: NextRequest) { - const { client_id, client_secret, grant_type } = await parseUrlFormData(req); + const { client_id, client_secret, grant_type, refresh_token, code_verifier } = await parseUrlFormData(req); - if (!client_id || !client_secret) { - return NextResponse.json({ message: "Missing client id or secret" }, { status: 400 }); + if (!process.env.CALENDSO_ENCRYPTION_KEY) { + return NextResponse.json({ message: "CALENDSO_ENCRYPTION_KEY is not set" }, { status: 500 }); + } + + if (!client_id) { + return NextResponse.json({ error: "invalid_request" }, { status: 400 }); } if (grant_type !== "refresh_token") { - return NextResponse.json({ message: "grant type invalid" }, { status: 400 }); + return NextResponse.json({ error: "invalid_request" }, { status: 400 }); } - const [hashedSecret] = generateSecret(client_secret); + const oAuthClientRepository = new OAuthClientRepository(prisma); - const client = await prisma.oAuthClient.findFirst({ - where: { - clientId: client_id, - clientSecret: hashedSecret, - }, - select: { - redirectUri: true, - }, - }); + const client = await oAuthClientRepository.findByClientId(client_id); if (!client) { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + return NextResponse.json({ error: "invalid_client" }, { status: 401 }); } - const secretKey = process.env.CALENDSO_ENCRYPTION_KEY || ""; + const isValidClient = OAuthService.validateClient(client, client_secret); + + if (!isValidClient) { + return NextResponse.json({ error: "invalid_client" }, { status: 401 }); + } + + const secretKey = process.env.CALENDSO_ENCRYPTION_KEY; let decodedRefreshToken: OAuthTokenPayload; try { - const refreshToken = req.headers.get("authorization")?.split(" ")[1] || ""; - decodedRefreshToken = jwt.verify(refreshToken, secretKey) as OAuthTokenPayload; + const refreshTokenValue = refresh_token || req.headers.get("authorization")?.split(" ")[1] || ""; + + if (!refreshTokenValue) { + return NextResponse.json({ error: "invalid_request" }, { status: 400 }); + } + + decodedRefreshToken = jwt.verify(refreshTokenValue, secretKey) as OAuthTokenPayload; } catch { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + return NextResponse.json({ error: "invalid_grant" }, { status: 400 }); } if (!decodedRefreshToken || decodedRefreshToken.token_type !== "Refresh Token") { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + return NextResponse.json({ error: "invalid_grant" }, { status: 400 }); } + if (decodedRefreshToken.clientId !== client_id) { + return NextResponse.json({ error: "invalid_grant" }, { status: 400 }); + } + + const pkceError = OAuthService.verifyPKCE(client, decodedRefreshToken, code_verifier); + if (pkceError) { + return NextResponse.json({ error: pkceError.error }, { status: pkceError.status }); + } + + const accessTokenExpiresIn = 1800; // 30 minutes + const payloadAuthToken: OAuthTokenPayload = { userId: decodedRefreshToken.userId, teamId: decodedRefreshToken.teamId, @@ -64,17 +83,37 @@ async function handler(req: NextRequest) { scope: decodedRefreshToken.scope, token_type: "Refresh Token", clientId: client_id, + // Preserve PKCE information for any client that used PKCE originally + ...(decodedRefreshToken.codeChallenge && { + codeChallenge: decodedRefreshToken.codeChallenge, + codeChallengeMethod: decodedRefreshToken.codeChallengeMethod, + }), }; const access_token = jwt.sign(payloadAuthToken, secretKey, { - expiresIn: 1800, // 30 min + expiresIn: accessTokenExpiresIn, }); - const refresh_token = jwt.sign(payloadRefreshToken, secretKey, { + const refresh_token_new = jwt.sign(payloadRefreshToken, secretKey, { expiresIn: 30 * 24 * 60 * 60, // 30 days }); - return NextResponse.json({ access_token, refresh_token }, { status: 200 }); + return NextResponse.json( + { + access_token, + token_type: "bearer", + refresh_token: refresh_token_new, + expires_in: accessTokenExpiresIn, + }, + { + status: 200, + headers: { + "Content-Type": "application/json;charset=UTF-8", + "Cache-Control": "no-store", + Pragma: "no-cache", + }, + } + ); } export const POST = defaultResponderForAppDir(handler); diff --git a/apps/web/app/api/auth/oauth/token/__tests__/route.test.ts b/apps/web/app/api/auth/oauth/token/__tests__/route.test.ts new file mode 100644 index 0000000000..aa58439d63 --- /dev/null +++ b/apps/web/app/api/auth/oauth/token/__tests__/route.test.ts @@ -0,0 +1,540 @@ +import prismaMock from "../../../../../../../../tests/libs/__mocks__/prismaMock"; + +import jwt from "jsonwebtoken"; +// Import mocked dependencies after mocks are set up +import { NextRequest } from "next/server"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import { verifyCodeChallenge } from "@calcom/lib/pkce"; +import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler"; + +import { POST } from "../route"; + +// Create mock NextResponse.json function using hoisted +const mockNextResponseJson = vi.hoisted(() => { + return vi.fn((data: unknown, options?: { status?: number; headers?: Record }) => ({ + json: vi.fn().mockResolvedValue(data), + status: options?.status || 200, + headers: new Headers(options?.headers || {}), + })); +}); + +// Mock next/server before importing anything that uses it +vi.mock("next/server", () => ({ + NextRequest: class MockNextRequest { + url: string; + method: string; + nextUrl: { pathname: string; searchParams: URLSearchParams }; + private _body: string; + + constructor( + url: string, + options: { method?: string; body?: string; headers?: Record } = {} + ) { + this.url = url; + this.method = options.method || "POST"; + this._body = options.body || ""; + const urlObj = new URL(url); + this.nextUrl = { + pathname: urlObj.pathname, + searchParams: urlObj.searchParams, + }; + } + + async text(): Promise { + return this._body; + } + }, + NextResponse: { + json: mockNextResponseJson, + }, +})); + +// Mock dependencies +vi.mock("@calcom/lib/pkce", () => ({ + verifyCodeChallenge: vi.fn(), +})); + +vi.mock("@calcom/trpc/server/routers/viewer/oAuth/addClient.handler", () => ({ + generateSecret: vi.fn(), +})); + +vi.mock("jsonwebtoken", () => ({ + default: { + sign: vi.fn(() => "mock_jwt_token"), + }, +})); + +vi.mock("@calcom/prisma", () => ({ + default: prismaMock, +})); + +vi.mock("app/api/defaultResponderForAppDir", async () => { + const { NextResponse } = await import("next/server"); + return { + defaultResponderForAppDir: + ( + handler: (req: NextRequest, context: { params: Promise> }) => Promise + ) => + async (req: NextRequest, context?: { params: Promise> }) => { + try { + const result = await handler(req, context || { params: Promise.resolve({}) }); + if (result) { + return result; + } + return NextResponse.json({}); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "Internal server error"; + return NextResponse.json( + { + message: errorMessage, + }, + { status: 500 } + ); + } + }, + }; +}); + +vi.mock("app/api/parseRequestData", () => ({ + parseUrlFormData: async (req: NextRequest): Promise> => { + const text = await req.text(); + const params = new URLSearchParams(text); + return Object.fromEntries(params); + }, +})); + +const mockVerifyCodeChallenge = vi.mocked(verifyCodeChallenge); +const mockGenerateSecret = vi.mocked(generateSecret); +const mockJwt = vi.mocked(jwt); + +// Set up environment +vi.stubEnv("CALENDSO_ENCRYPTION_KEY", "test_encryption_key"); + +// Helper to create token exchange request +function createTokenRequest(data: Record) { + const formData = new URLSearchParams(data); + const request = new NextRequest("http://localhost:3000/api/auth/oauth/token", { + method: "POST", + body: formData.toString(), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + return request; +} + +describe("POST /api/auth/oauth/token", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockJwt.sign.mockReturnValue("mock_jwt_token" as never); + mockVerifyCodeChallenge.mockReturnValue(false); + mockGenerateSecret.mockReturnValue(["hashed_secret", "plain_secret"]); + // Reset the mock for NextResponse.json + mockNextResponseJson.mockImplementation( + (data: unknown, options?: { status?: number; headers?: Record }) => ({ + json: vi.fn().mockResolvedValue(data), + status: options?.status || 200, + headers: new Headers(options?.headers || {}), + }) + ); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("PUBLIC clients", () => { + const mockPublicClient = { + redirectUri: "https://app.example.com/callback", + clientSecret: null, + clientType: "PUBLIC" as const, + } as const; + + const mockAccessCode = { + userId: 1, + teamId: null, + scopes: [] as const, + codeChallenge: "test_challenge", + codeChallengeMethod: "S256", + } as const; + + it("should exchange authorization code for tokens with valid PKCE", async () => { + prismaMock.oAuthClient.findFirst.mockResolvedValue( + mockPublicClient as Awaited> + ); + prismaMock.accessCode.findFirst.mockResolvedValue( + mockAccessCode as unknown as Awaited> + ); + prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 }); + mockVerifyCodeChallenge.mockReturnValue(true); + + const tokenRequest = createTokenRequest({ + grant_type: "authorization_code", + code: "test_auth_code", + client_id: "public_client_123", + redirect_uri: "https://app.example.com/callback", + code_verifier: "test_verifier", + }); + + const response = await POST(tokenRequest, { params: Promise.resolve({}) }); + expect(response).toBeDefined(); + if (!response) throw new Error("Response is undefined"); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ + access_token: "mock_jwt_token", + token_type: "bearer", + refresh_token: "mock_jwt_token", + expires_in: 1800, + }); + + expect(mockVerifyCodeChallenge).toHaveBeenCalledWith("test_verifier", "test_challenge", "S256"); + expect(prismaMock.accessCode.deleteMany).toHaveBeenCalled(); + }); + + it("should reject PUBLIC client without code_verifier", async () => { + prismaMock.oAuthClient.findFirst.mockResolvedValue( + mockPublicClient as Awaited> + ); + prismaMock.accessCode.findFirst.mockResolvedValue( + mockAccessCode as unknown as Awaited> + ); + + const tokenRequest = createTokenRequest({ + grant_type: "authorization_code", + code: "test_auth_code", + client_id: "public_client_123", + redirect_uri: "https://app.example.com/callback", + }); + + const response = await POST(tokenRequest, { params: Promise.resolve({}) }); + expect(response).toBeDefined(); + if (!response) throw new Error("Response is undefined"); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("invalid_request"); + }); + + it("should reject PUBLIC client with missing code challenge in access code", async () => { + prismaMock.oAuthClient.findFirst.mockResolvedValue( + mockPublicClient as Awaited> + ); + prismaMock.accessCode.findFirst.mockResolvedValue({ + ...mockAccessCode, + codeChallenge: null, + } as unknown as Awaited>); + prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 }); + + const tokenRequest = createTokenRequest({ + grant_type: "authorization_code", + code: "test_auth_code", + client_id: "public_client_123", + redirect_uri: "https://app.example.com/callback", + code_verifier: "test_verifier", + }); + + const response = await POST(tokenRequest, { params: Promise.resolve({}) }); + expect(response).toBeDefined(); + if (!response) throw new Error("Response is undefined"); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("invalid_request"); + }); + + it("should reject PUBLIC client with invalid code_verifier", async () => { + prismaMock.oAuthClient.findFirst.mockResolvedValue( + mockPublicClient as Awaited> + ); + prismaMock.accessCode.findFirst.mockResolvedValue( + mockAccessCode as unknown as Awaited> + ); + prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 }); + mockVerifyCodeChallenge.mockReturnValue(false); + + const tokenRequest = createTokenRequest({ + grant_type: "authorization_code", + code: "test_auth_code", + client_id: "public_client_123", + redirect_uri: "https://app.example.com/callback", + code_verifier: "wrong_verifier", + }); + + const response = await POST(tokenRequest, { params: Promise.resolve({}) }); + expect(response).toBeDefined(); + if (!response) throw new Error("Response is undefined"); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("invalid_grant"); + expect(mockVerifyCodeChallenge).toHaveBeenCalledWith("wrong_verifier", "test_challenge", "S256"); + }); + }); + + describe("CONFIDENTIAL clients", () => { + const mockConfidentialClient = { + redirectUri: "https://app.example.com/callback", + clientSecret: "hashed_secret", + clientType: "CONFIDENTIAL" as const, + } as const; + + const mockAccessCode = { + userId: 1, + teamId: null, + scopes: [] as const, + codeChallenge: null, + codeChallengeMethod: null, + } as const; + + it("should exchange authorization code for tokens with valid client secret", async () => { + prismaMock.oAuthClient.findFirst.mockResolvedValue( + mockConfidentialClient as Awaited> + ); + prismaMock.accessCode.findFirst.mockResolvedValue( + mockAccessCode as unknown as Awaited> + ); + prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 }); + mockGenerateSecret.mockReturnValue(["hashed_secret", "plain_secret"]); + + const tokenRequest = createTokenRequest({ + grant_type: "authorization_code", + code: "test_auth_code", + client_id: "confidential_client_456", + client_secret: "plain_secret", + redirect_uri: "https://app.example.com/callback", + }); + + const response = await POST(tokenRequest, { params: Promise.resolve({}) }); + expect(response).toBeDefined(); + if (!response) throw new Error("Response is undefined"); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ + access_token: "mock_jwt_token", + token_type: "bearer", + refresh_token: "mock_jwt_token", + expires_in: 1800, + }); + + expect(mockGenerateSecret).toHaveBeenCalledWith("plain_secret"); + expect(prismaMock.accessCode.deleteMany).toHaveBeenCalled(); + }); + + it("should reject CONFIDENTIAL client without client_secret", async () => { + prismaMock.oAuthClient.findFirst.mockResolvedValue( + mockConfidentialClient as Awaited> + ); + + const tokenRequest = createTokenRequest({ + grant_type: "authorization_code", + code: "test_auth_code", + client_id: "confidential_client_456", + redirect_uri: "https://app.example.com/callback", + }); + + const response = await POST(tokenRequest, { params: Promise.resolve({}) }); + expect(response).toBeDefined(); + if (!response) throw new Error("Response is undefined"); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("invalid_client"); + }); + + it("should reject CONFIDENTIAL client with invalid client_secret", async () => { + prismaMock.oAuthClient.findFirst.mockResolvedValue( + mockConfidentialClient as Awaited> + ); + mockGenerateSecret.mockReturnValue(["different_hashed_secret", "wrong_secret"]); + + const tokenRequest = createTokenRequest({ + grant_type: "authorization_code", + code: "test_auth_code", + client_id: "confidential_client_456", + client_secret: "wrong_secret", + redirect_uri: "https://app.example.com/callback", + }); + + const response = await POST(tokenRequest, { params: Promise.resolve({}) }); + expect(response).toBeDefined(); + if (!response) throw new Error("Response is undefined"); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("invalid_client"); + }); + + it("should accept CONFIDENTIAL client with code_verifier for enhanced security", async () => { + const mockAccessCodeWithPKCE = { + userId: 1, + teamId: null, + scopes: [] as const, + codeChallenge: "test_challenge", + codeChallengeMethod: "S256", + } as const; + + prismaMock.oAuthClient.findFirst.mockResolvedValue( + mockConfidentialClient as Awaited> + ); + prismaMock.accessCode.findFirst.mockResolvedValue( + mockAccessCodeWithPKCE as unknown as Awaited> + ); + prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 }); + mockGenerateSecret.mockReturnValue(["hashed_secret", "plain_secret"]); + mockVerifyCodeChallenge.mockReturnValue(true); + + const tokenRequest = createTokenRequest({ + grant_type: "authorization_code", + code: "test_auth_code", + client_id: "confidential_client_456", + client_secret: "plain_secret", + redirect_uri: "https://app.example.com/callback", + code_verifier: "test_verifier", + }); + + const response = await POST(tokenRequest, { params: Promise.resolve({}) }); + expect(response).toBeDefined(); + if (!response) throw new Error("Response is undefined"); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toEqual({ + access_token: "mock_jwt_token", + token_type: "bearer", + refresh_token: "mock_jwt_token", + expires_in: 1800, + }); + + expect(mockGenerateSecret).toHaveBeenCalledWith("plain_secret"); + expect(mockVerifyCodeChallenge).toHaveBeenCalledWith("test_verifier", "test_challenge", "S256"); + }); + + it("should reject CONFIDENTIAL client with invalid code_verifier", async () => { + const mockAccessCodeWithPKCE = { + userId: 1, + teamId: null, + scopes: [] as const, + codeChallenge: "test_challenge", + codeChallengeMethod: "S256", + } as const; + + prismaMock.oAuthClient.findFirst.mockResolvedValue( + mockConfidentialClient as Awaited> + ); + prismaMock.accessCode.findFirst.mockResolvedValue( + mockAccessCodeWithPKCE as unknown as Awaited> + ); + prismaMock.accessCode.deleteMany.mockResolvedValue({ count: 1 }); + mockGenerateSecret.mockReturnValue(["hashed_secret", "plain_secret"]); + mockVerifyCodeChallenge.mockReturnValue(false); + + const tokenRequest = createTokenRequest({ + grant_type: "authorization_code", + code: "test_auth_code", + client_id: "confidential_client_456", + client_secret: "plain_secret", + redirect_uri: "https://app.example.com/callback", + code_verifier: "wrong_verifier", + }); + + const response = await POST(tokenRequest, { params: Promise.resolve({}) }); + expect(response).toBeDefined(); + if (!response) throw new Error("Response is undefined"); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("invalid_grant"); + expect(mockVerifyCodeChallenge).toHaveBeenCalledWith("wrong_verifier", "test_challenge", "S256"); + }); + }); + + describe("General validation", () => { + it("should reject invalid grant_type", async () => { + const tokenRequest = createTokenRequest({ + grant_type: "implicit", + code: "test_auth_code", + client_id: "test_client", + redirect_uri: "https://app.example.com/callback", + }); + + const response = await POST(tokenRequest, { params: Promise.resolve({}) }); + expect(response).toBeDefined(); + if (!response) throw new Error("Response is undefined"); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("invalid_request"); + }); + + it("should reject invalid client_id", async () => { + prismaMock.oAuthClient.findFirst.mockResolvedValue(null); + + const tokenRequest = createTokenRequest({ + grant_type: "authorization_code", + code: "test_auth_code", + client_id: "invalid_client", + redirect_uri: "https://app.example.com/callback", + }); + + const response = await POST(tokenRequest, { params: Promise.resolve({}) }); + expect(response).toBeDefined(); + if (!response) throw new Error("Response is undefined"); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("invalid_client"); + }); + + it("should reject mismatched redirect_uri", async () => { + prismaMock.oAuthClient.findFirst.mockResolvedValue({ + redirectUri: "https://app.example.com/callback", + clientSecret: null, + clientType: "PUBLIC" as const, + } as Awaited>); + + const tokenRequest = createTokenRequest({ + grant_type: "authorization_code", + code: "test_auth_code", + client_id: "test_client", + redirect_uri: "https://malicious.com/callback", + }); + + const response = await POST(tokenRequest, { params: Promise.resolve({}) }); + expect(response).toBeDefined(); + if (!response) throw new Error("Response is undefined"); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("invalid_grant"); + }); + + it("should reject expired authorization code", async () => { + prismaMock.oAuthClient.findFirst.mockResolvedValue({ + redirectUri: "https://app.example.com/callback", + clientSecret: null, + clientType: "PUBLIC" as const, + } as Awaited>); + prismaMock.accessCode.findFirst.mockResolvedValue(null); + + const tokenRequest = createTokenRequest({ + grant_type: "authorization_code", + code: "expired_auth_code", + client_id: "test_client", + redirect_uri: "https://app.example.com/callback", + code_verifier: "test_verifier", + }); + + const response = await POST(tokenRequest, { params: Promise.resolve({}) }); + expect(response).toBeDefined(); + if (!response) throw new Error("Response is undefined"); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.error).toBe("invalid_grant"); + }); + }); +}); diff --git a/apps/web/app/api/auth/oauth/token/route.ts b/apps/web/app/api/auth/oauth/token/route.ts index d094de02c5..6577ef18c9 100644 --- a/apps/web/app/api/auth/oauth/token/route.ts +++ b/apps/web/app/api/auth/oauth/token/route.ts @@ -4,64 +4,63 @@ import jwt from "jsonwebtoken"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { AccessCodeRepository } from "@calcom/features/oauth/repositories/AccessCodeRepository"; +import { OAuthClientRepository } from "@calcom/features/oauth/repositories/OAuthClientRepository"; +import { OAuthService } from "@calcom/features/oauth/services/OAuthService"; import prisma from "@calcom/prisma"; -import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler"; import type { OAuthTokenPayload } from "@calcom/types/oauth"; async function handler(req: NextRequest) { - const { code, client_id, client_secret, grant_type, redirect_uri } = await parseUrlFormData(req); + const { code, client_id, client_secret, grant_type, redirect_uri, code_verifier } = await parseUrlFormData( + req + ); + + if (!process.env.CALENDSO_ENCRYPTION_KEY) { + return NextResponse.json({ message: "CALENDSO_ENCRYPTION_KEY is not set" }, { status: 500 }); + } + + if (!client_id || !code || !redirect_uri) { + return NextResponse.json({ error: "invalid_request" }, { status: 400 }); + } + if (grant_type !== "authorization_code") { - return NextResponse.json({ message: "grant_type invalid" }, { status: 400 }); + return NextResponse.json({ error: "invalid_request" }, { status: 400 }); } - const [hashedSecret] = generateSecret(client_secret); + const oAuthClientRepository = new OAuthClientRepository(prisma); + const accessCodeRepository = new AccessCodeRepository(prisma); - const client = await prisma.oAuthClient.findFirst({ - where: { - clientId: client_id, - clientSecret: hashedSecret, - }, - select: { - redirectUri: true, - }, - }); + const client = await oAuthClientRepository.findByClientId(client_id); - if (!client || client.redirectUri !== redirect_uri) { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + if (!client) { + return NextResponse.json({ error: "invalid_client" }, { status: 401 }); } - const accessCode = await prisma.accessCode.findFirst({ - where: { - code: code, - clientId: client_id, - expiresAt: { - gt: new Date(), - }, - }, - }); + if (client.redirectUri !== redirect_uri) { + return NextResponse.json({ error: "invalid_grant" }, { status: 400 }); + } + + const isValidClient = OAuthService.validateClient(client, client_secret); + + if (!isValidClient) { + return NextResponse.json({ error: "invalid_client" }, { status: 401 }); + } + + const accessCode = await accessCodeRepository.findValidCode(code, client_id); // Delete all expired accessCodes + the one that is used here - await prisma.accessCode.deleteMany({ - where: { - OR: [ - { - expiresAt: { - lt: new Date(), - }, - }, - { - code: code, - clientId: client_id, - }, - ], - }, - }); + await accessCodeRepository.deleteExpiredAndUsedCodes(code, client_id); if (!accessCode) { - return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); + return NextResponse.json({ error: "invalid_grant" }, { status: 400 }); } - const secretKey = process.env.CALENDSO_ENCRYPTION_KEY || ""; + const pkceError = OAuthService.verifyPKCE(client, accessCode, code_verifier); + if (pkceError) { + return NextResponse.json({ error: pkceError.error }, { status: pkceError.status }); + } + + const secretKey = process.env.CALENDSO_ENCRYPTION_KEY; const payloadAuthToken: OAuthTokenPayload = { userId: accessCode.userId, @@ -77,6 +76,11 @@ async function handler(req: NextRequest) { scope: accessCode.scopes, token_type: "Refresh Token", clientId: client_id, + // Include PKCE information for clients that used PKCE (PUBLIC mandatory, CONFIDENTIAL optional) + ...(accessCode.codeChallenge && { + codeChallenge: accessCode.codeChallenge, + codeChallengeMethod: accessCode.codeChallengeMethod, + }), }; const accessTokenExpiresIn = 1800; // 30 minutes @@ -89,7 +93,6 @@ async function handler(req: NextRequest) { expiresIn: 30 * 24 * 60 * 60, // 30 days }); - // @see https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 return NextResponse.json( { access_token, token_type: "bearer", refresh_token, expires_in: accessTokenExpiresIn }, { diff --git a/apps/web/modules/auth/oauth2/authorize-view.tsx b/apps/web/modules/auth/oauth2/authorize-view.tsx index f528d2d2ae..1f9ba1505a 100644 --- a/apps/web/modules/auth/oauth2/authorize-view.tsx +++ b/apps/web/modules/auth/oauth2/authorize-view.tsx @@ -24,6 +24,8 @@ export default function Authorize() { const client_id = (searchParams?.get("client_id") as string) || ""; const state = searchParams?.get("state") as string; const scope = searchParams?.get("scope") as string; + const code_challenge = searchParams?.get("code_challenge") as string; + const code_challenge_method = searchParams?.get("code_challenge_method") as string; const queryString = searchParams?.toString(); @@ -46,6 +48,30 @@ export default function Authorize() { onSuccess: (data) => { window.location.href = `${client?.redirectUri}?code=${data.authorizationCode}&state=${state}`; }, + onError: (error) => { + let oauthError = "server_error"; + if (error.data?.code === "BAD_REQUEST") { + oauthError = "invalid_request"; + } else if (error.data?.code === "UNAUTHORIZED") { + oauthError = "unauthorized_client"; + } + + const errorParams = new URLSearchParams({ + error: oauthError, + }); + + if (error.message) { + errorParams.append("error_description", error.message); + } + + if (state) { + errorParams.append("state", state); + } + + if (client?.redirectUri) { + window.location.href = `${client.redirectUri}${client.redirectUri.includes("?") ? "&" : "?"}${errorParams.toString()}`; + } + }, }); const mappedProfiles = data @@ -144,7 +170,7 @@ export default function Authorize() {
-
+
{t("allow_client_to_do", { clientName: client.name })}
@@ -175,6 +201,8 @@ export default function Authorize() { teamSlug: selectedAccount?.value.startsWith("team/") ? selectedAccount?.value.substring(5) : undefined, // team account starts with /team/ + codeChallenge: code_challenge || undefined, + codeChallengeMethod: (code_challenge_method as "S256") || undefined, }); }} data-testid="allow-button"> diff --git a/apps/web/modules/settings/admin/oauth-view.tsx b/apps/web/modules/settings/admin/oauth-view.tsx index 5e0575936a..cbc69a7867 100644 --- a/apps/web/modules/settings/admin/oauth-view.tsx +++ b/apps/web/modules/settings/admin/oauth-view.tsx @@ -8,6 +8,8 @@ import { trpc } from "@calcom/trpc"; import { Avatar } from "@calcom/ui/components/avatar"; import { Button } from "@calcom/ui/components/button"; import { Form } from "@calcom/ui/components/form"; +import { Label } from "@calcom/ui/components/form"; +import { Switch } from "@calcom/ui/components/form"; import { TextField } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; import { ImageUploader } from "@calcom/ui/components/image-uploader"; @@ -18,10 +20,16 @@ type FormValues = { name: string; redirectUri: string; logo: string; + enablePkce: boolean; }; export default function OAuthView() { - const oAuthForm = useForm(); + const oAuthForm = useForm({ + defaultValues: { + logo: "", + enablePkce: false, + }, + }); const [clientSecret, setClientSecret] = useState(""); const [clientId, setClientId] = useState(""); const [logo, setLogo] = useState(""); @@ -29,12 +37,12 @@ export default function OAuthView() { const mutation = trpc.viewer.oAuth.addClient.useMutation({ onSuccess: async (data) => { - setClientSecret(data.clientSecret); + setClientSecret(data.clientSecret || ""); setClientId(data.clientId); showToast(`Successfully added ${data.name} as new client`, "success"); }, onError: (error) => { - showToast(`Adding clientfailed: ${error.message}`, "error"); + showToast(`Adding client failed: ${error.message}`, "error"); }, }); @@ -48,6 +56,7 @@ export default function OAuthView() { name: values.name, redirectUri: values.redirectUri, logo: values.logo, + enablePkce: values.enablePkce, }); }}>
@@ -68,6 +77,20 @@ export default function OAuthView() { placeholder="" required /> + +
+ +
+ oAuthForm.setValue("enablePkce", checked)} + /> + + Use PKCE (recommended for mobile/SPA applications) + +
+
+
{t("client_id")}
- {" "} {clientId} @@ -119,7 +141,6 @@ export default function OAuthView() {
{t("client_secret")}
- {" "} {clientSecret} @@ -139,7 +160,9 @@ export default function OAuthView() {
{t("copy_client_secret_info")}
) : ( - <> +
+ This client uses PKCE authentication (no client secret required). +
)}