feat: OAuth PKCE (#25313)

* add public client

* implement PKCE

* pass codeChallenge and codeChallengeMethod to handler

* fixes for secure oauth flow

* fix type error

* clean up refresh token endpoint

* only support S256

* fix type error

* remove comment

* add tests

* fix type errors in route.test.ts

* add missing support for refresh token

* add e2e test for public client refresh tokens

* allow pkce for confidential clients

* fix type error

* fix e2e

* fix option pkce for confidential clients

* e2e test improvements

* fix test

* remove only

* add delay

* fix e2e tests

* remove only

* don't skip pkce if codeChallenge is set

* add service functions for token endpoint

* use service function in refreshToken endpoint

* use repository

* remove return types

* e2e test fixes

* fix e2e test

* remove .only in e2e test

* remove pause

* fix error responses in token endpoints

* adjust tests to new error responses

* fix error responses

* e2e improvements

* redirect on error

* adjust tests

* Update apps/web/modules/auth/oauth2/authorize-view.tsx

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Carina Wollendorfer
2025-11-26 17:02:42 +01:00
committed by GitHub
co-authored by cubic-dev-ai[bot] CarinaWolli
parent c95b0833c1
commit 4e0798577a
19 changed files with 2034 additions and 110 deletions
@@ -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);
@@ -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<string, string> }) => ({
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<string, string> } = {}
) {
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<string> {
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<Record<string, string>> }) => Promise<Response>
) =>
async (req: NextRequest, context?: { params: Promise<Record<string, string>> }) => {
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<Record<string, string>> => {
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<string, string>) {
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<string, string> }) => ({
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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue(
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
);
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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue(
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
);
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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue({
...mockAccessCode,
codeChallenge: null,
} as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>);
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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue(
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
);
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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue(
mockAccessCode as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
);
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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue(
mockAccessCodeWithPKCE as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
);
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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>
);
prismaMock.accessCode.findFirst.mockResolvedValue(
mockAccessCodeWithPKCE as unknown as Awaited<ReturnType<typeof prismaMock.accessCode.findFirst>>
);
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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>);
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<ReturnType<typeof prismaMock.oAuthClient.findFirst>>);
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");
});
});
});
+45 -42
View File
@@ -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 },
{
@@ -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() {
<div>
<Icon name="info" className="mr-1 mt-0.5 h-4 w-4" />
</div>
<div className="ml-1 ">
<div className="ml-1">
<div className="mb-1 text-sm font-medium">
{t("allow_client_to_do", { clientName: client.name })}
</div>
@@ -175,6 +201,8 @@ export default function Authorize() {
teamSlug: selectedAccount?.value.startsWith("team/")
? selectedAccount?.value.substring(5)
: undefined, // team account starts with /team/<slug>
codeChallenge: code_challenge || undefined,
codeChallengeMethod: (code_challenge_method as "S256") || undefined,
});
}}
data-testid="allow-button">
+29 -6
View File
@@ -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<FormValues>();
const oAuthForm = useForm<FormValues>({
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,
});
}}>
<div className="">
@@ -68,6 +77,20 @@ export default function OAuthView() {
placeholder=""
required
/>
<div className="mb-5 mt-5">
<Label className="text-emphasis mb-2 block text-sm font-medium">Authentication Mode</Label>
<div className="flex items-center space-x-3">
<Switch
checked={oAuthForm.watch("enablePkce")}
onCheckedChange={(checked) => oAuthForm.setValue("enablePkce", checked)}
/>
<span className="text-default text-sm">
Use PKCE (recommended for mobile/SPA applications)
</span>
</div>
</div>
<div className="mb-5 mt-5 flex items-center">
<Avatar
alt=""
@@ -98,7 +121,6 @@ export default function OAuthView() {
<div className="mb-2 font-medium">{t("client_id")}</div>
<div className="flex">
<code className="bg-subtle text-default w-full truncate rounded-md rounded-r-none py-[6px] pl-2 pr-2 align-middle font-mono">
{" "}
{clientId}
</code>
<Tooltip side="top" content="Copy to Clipboard">
@@ -119,7 +141,6 @@ export default function OAuthView() {
<div className="mb-2 mt-4 font-medium">{t("client_secret")}</div>
<div className="flex">
<code className="bg-subtle text-default w-full truncate rounded-md rounded-r-none py-[6px] pl-2 pr-2 align-middle font-mono">
{" "}
{clientSecret}
</code>
<Tooltip side="top" content="Copy to Clipboard">
@@ -139,7 +160,9 @@ export default function OAuthView() {
<div className="text-subtle text-sm">{t("copy_client_secret_info")}</div>
</>
) : (
<></>
<div className="text-subtle mt-4 text-sm">
This client uses PKCE authentication (no client secret required).
</div>
)}
<Button
onClick={() => {
+691 -8
View File
@@ -1,5 +1,5 @@
import { expect } from "@playwright/test";
import { randomBytes } from "crypto";
import { createHash, randomBytes } from "crypto";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
@@ -16,10 +16,29 @@ let client: {
redirectUri: string;
orginalSecret: string;
name: string;
clientSecret: string;
clientSecret: string | null;
logo: string | null;
};
let publicClient: {
clientId: string;
redirectUri: string;
name: string;
clientSecret: string | null;
logo: string | null;
};
// Helper function to generate PKCE values
function generatePKCE() {
const codeVerifier = randomBytes(32).toString("base64url");
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
return {
codeVerifier,
codeChallenge,
codeChallengeMethod: "S256" as const,
};
}
test.describe("OAuth Provider", () => {
test.beforeAll(async () => {
client = await createTestCLient();
@@ -89,7 +108,7 @@ test.describe("OAuth Provider", () => {
const refreshTokenData = await refreshTokenResponse.json();
expect(refreshTokenData.access_token).not.toBe(tokenData.access_token);
expect(refreshTokenData.access_token).toBeDefined();
const validTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/me`, {
method: "POST",
@@ -99,7 +118,8 @@ test.describe("OAuth Provider", () => {
},
});
expect(meData.username.startsWith("test user")).toBe(true);
const validTokenData = await validTokenResponse.json();
expect(validTokenData.username.startsWith("test user")).toBe(true);
});
test("should create valid access token & refresh token for team", async ({ page, users }) => {
@@ -111,8 +131,12 @@ test.describe("OAuth Provider", () => {
);
await page.locator("#account-select").click();
await page.locator("#react-select-2-option-1").click();
const teamOption = page
.locator('[id*="react-select-"][id*="-option-"]')
.filter({ hasText: /Team/i })
.first();
await teamOption.waitFor({ state: "visible" });
await teamOption.click();
await page.getByTestId("allow-button").click();
@@ -172,7 +196,7 @@ test.describe("OAuth Provider", () => {
const refreshTokenData = await refreshTokenResponse.json();
expect(refreshTokenData.access_token).not.toBe(tokenData.access_token);
expect(refreshTokenData.access_token).toBeDefined();
const validTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/me`, {
method: "POST",
@@ -182,7 +206,8 @@ test.describe("OAuth Provider", () => {
},
});
expect(meData.username).toEqual(`user-id-${user.id}'s Team`);
const validTokenData = await validTokenResponse.json();
expect(validTokenData.username).toEqual(`user-id-${user.id}'s Team`);
});
test("redirect not logged-in users to login page and after forward to authorization page", async ({
@@ -207,6 +232,647 @@ test.describe("OAuth Provider", () => {
});
});
test.describe("OAuth Provider - PKCE (Public Clients)", () => {
test.beforeAll(async () => {
publicClient = await createTestPublicClient();
});
test("should create valid access token for PUBLIC client with valid PKCE", async ({ page, users }) => {
const user = await users.create({ username: "test user pkce", name: "test user pkce" });
await user.apiLogin();
// Generate PKCE values
const pkce = generatePKCE();
// Authorization request with PKCE challenge
await page.goto(
`auth/oauth2/authorize?client_id=${publicClient.clientId}&redirect_uri=${publicClient.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234&code_challenge=${pkce.codeChallenge}&code_challenge_method=${pkce.codeChallengeMethod}`
);
await page.getByTestId("allow-button").click();
await page.waitForFunction(() => {
return window.location.href.startsWith("https://example.com");
});
// Assert URL to catch unexpected redirects
await expect(page).toHaveURL(/^https:\/\/example\.com/);
expect(page.url()).toContain("code=");
expect(page.url()).toContain("state=1234");
const url = new URL(page.url());
const code = url.searchParams.get("code");
// Token exchange with PKCE verifier
const tokenForm = new URLSearchParams();
tokenForm.append("code", code ?? "");
tokenForm.append("client_id", publicClient.clientId);
tokenForm.append("grant_type", "authorization_code");
tokenForm.append("redirect_uri", publicClient.redirectUri);
tokenForm.append("code_verifier", pkce.codeVerifier);
const tokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/token`, {
body: tokenForm.toString(),
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const tokenData = await tokenResponse.json();
expect(tokenResponse.status).toBe(200);
expect(tokenData.access_token).toBeDefined();
expect(tokenData.token_type).toBe("bearer");
expect(tokenData.refresh_token).toBeDefined();
expect(tokenData.expires_in).toBe(1800);
// Verify token is valid
const meResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/me`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokenData.access_token}`,
},
});
const meData = await meResponse.json();
expect(meData.username.startsWith("test user pkce")).toBe(true);
});
test("should reject PUBLIC client with invalid PKCE verifier", async ({ page, users }) => {
const user = await users.create({ username: "test user pkce invalid", name: "test user pkce invalid" });
await user.apiLogin();
// Generate PKCE values
const pkce = generatePKCE();
const wrongVerifier = randomBytes(32).toString("base64url");
// Authorization request with PKCE challenge
await page.goto(
`auth/oauth2/authorize?client_id=${publicClient.clientId}&redirect_uri=${publicClient.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234&code_challenge=${pkce.codeChallenge}&code_challenge_method=${pkce.codeChallengeMethod}`
);
await page.getByTestId("allow-button").click();
await page.waitForFunction(() => {
return window.location.href.startsWith("https://example.com");
});
// Assert URL to catch unexpected redirects
await expect(page).toHaveURL(/^https:\/\/example\.com/);
expect(page.url()).toContain("code=");
expect(page.url()).toContain("state=1234");
const url = new URL(page.url());
const code = url.searchParams.get("code");
// Token exchange with WRONG PKCE verifier
const tokenForm = new URLSearchParams();
tokenForm.append("code", code ?? "");
tokenForm.append("client_id", publicClient.clientId);
tokenForm.append("grant_type", "authorization_code");
tokenForm.append("redirect_uri", publicClient.redirectUri);
tokenForm.append("code_verifier", wrongVerifier); // Wrong verifier!
const tokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/token`, {
body: tokenForm.toString(),
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const tokenData = await tokenResponse.json();
expect(tokenResponse.status).toBe(400);
expect(tokenData.error).toBe("invalid_grant");
});
test("should reject PUBLIC client without code_verifier", async ({ page, users }) => {
const user = await users.create({ username: "test user pkce missing", name: "test user pkce missing" });
await user.apiLogin();
// Generate PKCE values
const pkce = generatePKCE();
// Authorization request with PKCE challenge
await page.goto(
`auth/oauth2/authorize?client_id=${publicClient.clientId}&redirect_uri=${publicClient.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234&code_challenge=${pkce.codeChallenge}&code_challenge_method=${pkce.codeChallengeMethod}`
);
await page.getByTestId("allow-button").click();
await page.waitForFunction(() => {
return window.location.href.startsWith("https://example.com");
});
// Assert URL to catch unexpected redirects
await expect(page).toHaveURL(/^https:\/\/example\.com/);
expect(page.url()).toContain("code=");
expect(page.url()).toContain("state=1234");
const url = new URL(page.url());
const code = url.searchParams.get("code");
// Token exchange WITHOUT code_verifier (should fail)
const tokenForm = new URLSearchParams();
tokenForm.append("code", code ?? "");
tokenForm.append("client_id", publicClient.clientId);
tokenForm.append("grant_type", "authorization_code");
tokenForm.append("redirect_uri", publicClient.redirectUri);
// Missing code_verifier!
const tokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/token`, {
body: tokenForm.toString(),
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const tokenData = await tokenResponse.json();
expect(tokenResponse.status).toBe(400);
expect(tokenData.error).toBe("invalid_request");
});
test("should reject PUBLIC client authorization without code_challenge", async ({ page, users }) => {
const user = await users.create({ username: "test user no challenge", name: "test user no challenge" });
await user.apiLogin();
// Try to authorize without PKCE challenge (should fail at authorization step)
await page.goto(
`auth/oauth2/authorize?client_id=${publicClient.clientId}&redirect_uri=${publicClient.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234`
);
await page.waitForSelector('[data-testid="allow-button"]');
// Clicking allow should fail because PUBLIC clients require PKCE
await page.getByTestId("allow-button").click();
// Wait for redirect to callback URL with error parameters
await page.waitForFunction(() => {
return window.location.href.startsWith("https://example.com");
});
// Should redirect to callback URL with error parameters (OAuth2 error redirect)
await expect(page).toHaveURL(/^https:\/\/example\.com/);
const url = new URL(page.url());
expect(url.searchParams.get("error")).toBe("invalid_request");
expect(url.searchParams.get("error_description")).toBe("code_challenge required for public clients");
expect(url.searchParams.get("state")).toBe("1234");
// Should not contain authorization code
expect(url.searchParams.get("code")).toBeNull();
});
test("should refresh tokens for PUBLIC client with valid PKCE", async ({ page, users }) => {
const user = await users.create({ username: "test user refresh", name: "test user refresh" });
await user.apiLogin();
// Generate PKCE values
const pkce = generatePKCE();
// Authorization request with PKCE challenge
await page.goto(
`auth/oauth2/authorize?client_id=${publicClient.clientId}&redirect_uri=${publicClient.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234&code_challenge=${pkce.codeChallenge}&code_challenge_method=${pkce.codeChallengeMethod}`
);
await page.getByTestId("allow-button").click();
await page.waitForFunction(() => {
return window.location.href.startsWith("https://example.com");
});
// Assert URL to catch unexpected redirects
await expect(page).toHaveURL(/^https:\/\/example\.com/);
expect(page.url()).toContain("code=");
expect(page.url()).toContain("state=1234");
const url = new URL(page.url());
const code = url.searchParams.get("code");
// Token exchange with PKCE verifier
const tokenForm = new URLSearchParams();
tokenForm.append("code", code ?? "");
tokenForm.append("client_id", publicClient.clientId);
tokenForm.append("grant_type", "authorization_code");
tokenForm.append("redirect_uri", publicClient.redirectUri);
tokenForm.append("code_verifier", pkce.codeVerifier);
const tokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/token`, {
body: tokenForm.toString(),
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const tokenData = await tokenResponse.json();
expect(tokenResponse.status).toBe(200);
expect(tokenData.access_token).toBeDefined();
expect(tokenData.refresh_token).toBeDefined();
// Now test refresh token with PKCE
const refreshTokenForm = new URLSearchParams();
refreshTokenForm.append("refresh_token", tokenData.refresh_token);
refreshTokenForm.append("client_id", publicClient.clientId);
refreshTokenForm.append("grant_type", "refresh_token");
refreshTokenForm.append("code_verifier", pkce.codeVerifier); // PUBLIC clients need code_verifier
const refreshTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/refreshToken`, {
body: refreshTokenForm.toString(),
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const refreshTokenData = await refreshTokenResponse.json();
expect(refreshTokenResponse.status).toBe(200);
expect(refreshTokenData.access_token).toBeDefined();
expect(refreshTokenData.token_type).toBe("bearer");
expect(refreshTokenData.refresh_token).toBeDefined();
expect(refreshTokenData.expires_in).toBe(1800);
// Verify new access token works
const meResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/me`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${refreshTokenData.access_token}`,
},
});
const meData = await meResponse.json();
expect(meData.username.startsWith("test user refresh")).toBe(true);
});
test("should reject PUBLIC client refresh token without code_verifier", async ({ page, users }) => {
const user = await users.create({
username: "test user refresh no pkce",
name: "test user refresh no pkce",
});
await user.apiLogin();
// Generate PKCE values
const pkce = generatePKCE();
// Get tokens first
await page.goto(
`auth/oauth2/authorize?client_id=${publicClient.clientId}&redirect_uri=${publicClient.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234&code_challenge=${pkce.codeChallenge}&code_challenge_method=${pkce.codeChallengeMethod}`
);
await page.getByTestId("allow-button").click();
await page.waitForFunction(() => {
return window.location.href.startsWith("https://example.com");
});
// Assert URL to catch unexpected redirects
await expect(page).toHaveURL(/^https:\/\/example\.com/);
expect(page.url()).toContain("code=");
expect(page.url()).toContain("state=1234");
const url = new URL(page.url());
const code = url.searchParams.get("code");
const tokenForm = new URLSearchParams();
tokenForm.append("code", code ?? "");
tokenForm.append("client_id", publicClient.clientId);
tokenForm.append("grant_type", "authorization_code");
tokenForm.append("redirect_uri", publicClient.redirectUri);
tokenForm.append("code_verifier", pkce.codeVerifier);
const tokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/token`, {
body: tokenForm.toString(),
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const tokenData = await tokenResponse.json();
expect(tokenResponse.status).toBe(200);
// Now try refresh WITHOUT code_verifier (should fail)
const refreshTokenForm = new URLSearchParams();
refreshTokenForm.append("refresh_token", tokenData.refresh_token);
refreshTokenForm.append("client_id", publicClient.clientId);
refreshTokenForm.append("grant_type", "refresh_token");
// Missing code_verifier!
const refreshTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/refreshToken`, {
body: refreshTokenForm.toString(),
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const refreshTokenData = await refreshTokenResponse.json();
expect(refreshTokenResponse.status).toBe(400);
expect(refreshTokenData.error).toBe("invalid_request");
});
test("should reject PUBLIC client refresh token with invalid code_verifier", async ({ page, users }) => {
const user = await users.create({
username: "test user refresh wrong pkce",
name: "test user refresh wrong pkce",
});
await user.apiLogin();
// Generate PKCE values
const pkce = generatePKCE();
const wrongVerifier = randomBytes(32).toString("base64url");
// Get tokens first
await page.goto(
`auth/oauth2/authorize?client_id=${publicClient.clientId}&redirect_uri=${publicClient.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234&code_challenge=${pkce.codeChallenge}&code_challenge_method=${pkce.codeChallengeMethod}`
);
await page.getByTestId("allow-button").click();
await page.waitForFunction(() => {
return window.location.href.startsWith("https://example.com");
});
// Assert URL to catch unexpected redirects
await expect(page).toHaveURL(/^https:\/\/example\.com/);
expect(page.url()).toContain("code=");
expect(page.url()).toContain("state=1234");
const url = new URL(page.url());
const code = url.searchParams.get("code");
const tokenForm = new URLSearchParams();
tokenForm.append("code", code ?? "");
tokenForm.append("client_id", publicClient.clientId);
tokenForm.append("grant_type", "authorization_code");
tokenForm.append("redirect_uri", publicClient.redirectUri);
tokenForm.append("code_verifier", pkce.codeVerifier);
const tokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/token`, {
body: tokenForm.toString(),
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const tokenData = await tokenResponse.json();
expect(tokenResponse.status).toBe(200);
// Now try refresh with WRONG code_verifier (should fail)
const refreshTokenForm = new URLSearchParams();
refreshTokenForm.append("refresh_token", tokenData.refresh_token);
refreshTokenForm.append("client_id", publicClient.clientId);
refreshTokenForm.append("grant_type", "refresh_token");
refreshTokenForm.append("code_verifier", wrongVerifier); // Wrong verifier!
const refreshTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/refreshToken`, {
body: refreshTokenForm.toString(),
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const refreshTokenData = await refreshTokenResponse.json();
expect(refreshTokenResponse.status).toBe(400);
expect(refreshTokenData.error).toBe("invalid_grant");
});
});
test.describe("OAuth Provider - PKCE with CONFIDENTIAL Clients (Enhanced Security)", () => {
test.beforeAll(async () => {
// Ensure we have a confidential client for these tests
client = await createTestCLient();
});
test("should accept CONFIDENTIAL client with PKCE for defense in depth", async ({ page, users }) => {
const user = await users.create({ username: "test user conf pkce", name: "test user conf pkce" });
await user.apiLogin();
// Generate PKCE values
const pkce = generatePKCE();
// Authorization request with PKCE challenge (even for CONFIDENTIAL client)
await page.goto(
`auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234&code_challenge=${pkce.codeChallenge}&code_challenge_method=${pkce.codeChallengeMethod}`
);
await page.getByTestId("allow-button").click();
await page.waitForFunction(() => {
return window.location.href.startsWith("https://example.com");
});
// Assert URL to catch unexpected redirects
await expect(page).toHaveURL(/^https:\/\/example\.com/);
expect(page.url()).toContain("code=");
expect(page.url()).toContain("state=1234");
const url = new URL(page.url());
const code = url.searchParams.get("code");
// Token exchange with both client_secret AND code_verifier (dual authentication)
const tokenForm = new URLSearchParams();
tokenForm.append("code", code ?? "");
tokenForm.append("client_id", client.clientId);
tokenForm.append("client_secret", client.orginalSecret);
tokenForm.append("grant_type", "authorization_code");
tokenForm.append("redirect_uri", client.redirectUri);
tokenForm.append("code_verifier", pkce.codeVerifier);
const tokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/token`, {
body: tokenForm.toString(),
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const tokenData = await tokenResponse.json();
expect(tokenResponse.status).toBe(200);
expect(tokenData.access_token).toBeDefined();
expect(tokenData.token_type).toBe("bearer");
expect(tokenData.refresh_token).toBeDefined();
expect(tokenData.expires_in).toBe(1800);
// Verify token works
const meResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/me`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokenData.access_token}`,
},
});
const meData = await meResponse.json();
expect(meData.username.startsWith("test user conf pkce")).toBe(true);
// Test refresh with both client_secret and code_verifier (enhanced security)
const refreshTokenForm = new URLSearchParams();
refreshTokenForm.append("refresh_token", tokenData.refresh_token);
refreshTokenForm.append("client_id", client.clientId);
refreshTokenForm.append("client_secret", client.orginalSecret);
refreshTokenForm.append("grant_type", "refresh_token");
refreshTokenForm.append("code_verifier", pkce.codeVerifier);
const refreshTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/refreshToken`, {
body: refreshTokenForm.toString(),
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const refreshTokenData = await refreshTokenResponse.json();
expect(refreshTokenResponse.status).toBe(200);
expect(refreshTokenData.access_token).toBeDefined();
expect(refreshTokenData.refresh_token).toBeDefined();
});
test("should reject CONFIDENTIAL client refresh without code_verifier when PKCE was used", async ({
page,
users,
}) => {
const user = await users.create({
username: "test user conf no verifier",
name: "test user conf no verifier",
});
await user.apiLogin();
// Generate PKCE values for initial authorization
const pkce = generatePKCE();
// Authorization with PKCE
await page.goto(
`auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234&code_challenge=${pkce.codeChallenge}&code_challenge_method=${pkce.codeChallengeMethod}`
);
await page.getByTestId("allow-button").click();
await page.waitForFunction(() => {
return window.location.href.startsWith("https://example.com");
});
// Assert URL to catch unexpected redirects
await expect(page).toHaveURL(/^https:\/\/example\.com/);
expect(page.url()).toContain("code=");
expect(page.url()).toContain("state=1234");
const url = new URL(page.url());
const code = url.searchParams.get("code");
// Token exchange with both credentials
const tokenForm = new URLSearchParams();
tokenForm.append("code", code ?? "");
tokenForm.append("client_id", client.clientId);
tokenForm.append("client_secret", client.orginalSecret);
tokenForm.append("grant_type", "authorization_code");
tokenForm.append("redirect_uri", client.redirectUri);
tokenForm.append("code_verifier", pkce.codeVerifier);
const tokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/token`, {
body: tokenForm.toString(),
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const tokenData = await tokenResponse.json();
expect(tokenResponse.status).toBe(200);
// Test refresh with ONLY client_secret (no code_verifier) - should FAIL since PKCE was used originally
const refreshTokenForm = new URLSearchParams();
refreshTokenForm.append("refresh_token", tokenData.refresh_token);
refreshTokenForm.append("client_id", client.clientId);
refreshTokenForm.append("client_secret", client.orginalSecret);
refreshTokenForm.append("grant_type", "refresh_token");
// Intentionally not providing code_verifier
const refreshTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/refreshToken`, {
body: refreshTokenForm.toString(),
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const refreshTokenData = await refreshTokenResponse.json();
expect(refreshTokenResponse.status).toBe(400);
expect(refreshTokenData.error).toBe("invalid_request");
});
test("should allow CONFIDENTIAL client refresh with only client_secret when PKCE was NOT used", async ({
page,
users,
}) => {
const user = await users.create({ username: "test user conf no pkce", name: "test user conf no pkce" });
await user.apiLogin();
// Authorization WITHOUT PKCE challenge (traditional CONFIDENTIAL client flow)
await page.goto(
`auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234`
);
await page.getByTestId("allow-button").click();
await page.waitForFunction(() => {
return window.location.href.startsWith("https://example.com");
});
// Assert URL to catch unexpected redirects
await expect(page).toHaveURL(/^https:\/\/example\.com/);
expect(page.url()).toContain("code=");
expect(page.url()).toContain("state=1234");
const url = new URL(page.url());
const code = url.searchParams.get("code");
// Token exchange with ONLY client_secret (no PKCE)
const tokenForm = new URLSearchParams();
tokenForm.append("code", code ?? "");
tokenForm.append("client_id", client.clientId);
tokenForm.append("client_secret", client.orginalSecret);
tokenForm.append("grant_type", "authorization_code");
tokenForm.append("redirect_uri", client.redirectUri);
const tokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/token`, {
body: tokenForm.toString(),
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const tokenData = await tokenResponse.json();
expect(tokenResponse.status).toBe(200);
// Refresh with ONLY client_secret - should work since PKCE was never used
const refreshTokenForm = new URLSearchParams();
refreshTokenForm.append("refresh_token", tokenData.refresh_token);
refreshTokenForm.append("client_id", client.clientId);
refreshTokenForm.append("client_secret", client.orginalSecret);
refreshTokenForm.append("grant_type", "refresh_token");
const refreshTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/refreshToken`, {
body: refreshTokenForm.toString(),
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
const refreshTokenData = await refreshTokenResponse.json();
expect(refreshTokenResponse.status).toBe(200);
expect(refreshTokenData.access_token).toBeDefined();
expect(refreshTokenData.token_type).toBe("bearer");
});
});
const createTestCLient = async () => {
const [hashedSecret, secret] = generateSecret();
const clientId = randomBytes(32).toString("hex");
@@ -217,8 +883,25 @@ const createTestCLient = async () => {
clientId,
clientSecret: hashedSecret,
redirectUri: "https://example.com",
clientType: "CONFIDENTIAL",
},
});
return { ...client, orginalSecret: secret };
};
const createTestPublicClient = async () => {
const clientId = randomBytes(32).toString("hex");
const client = await prisma.oAuthClient.create({
data: {
name: "Test Public Client",
clientId,
clientSecret: null,
redirectUri: "https://example.com",
clientType: "PUBLIC",
},
});
return client;
};
@@ -0,0 +1,42 @@
import type { PrismaClient } from "@calcom/prisma";
export class AccessCodeRepository {
constructor(private readonly prisma: PrismaClient) {}
async findValidCode(code: string, clientId: string) {
return await this.prisma.accessCode.findFirst({
where: {
code: code,
clientId: clientId,
expiresAt: {
gt: new Date(),
},
},
select: {
userId: true,
teamId: true,
scopes: true,
codeChallenge: true,
codeChallengeMethod: true,
},
});
}
async deleteExpiredAndUsedCodes(code: string, clientId: string) {
await this.prisma.accessCode.deleteMany({
where: {
OR: [
{
expiresAt: {
lt: new Date(),
},
},
{
code: code,
clientId: clientId,
},
],
},
});
}
}
@@ -0,0 +1,18 @@
import type { PrismaClient } from "@calcom/prisma";
export class OAuthClientRepository {
constructor(private readonly prisma: PrismaClient) {}
async findByClientId(clientId: string) {
return await this.prisma.oAuthClient.findFirst({
where: {
clientId: clientId,
},
select: {
redirectUri: true,
clientSecret: true,
clientType: true,
},
});
}
}
@@ -0,0 +1,61 @@
import { verifyCodeChallenge } from "@calcom/lib/pkce";
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
interface OAuthClient {
clientType: "CONFIDENTIAL" | "PUBLIC";
clientSecret?: string | null;
}
type OAuthErrorCode = "invalid_request" | "invalid_grant";
interface PKCESource {
codeChallenge?: string | null;
codeChallengeMethod?: string | null;
}
interface OAuthErrorResult {
error: OAuthErrorCode;
status: number;
}
export class OAuthService {
static validateClient(client: OAuthClient, client_secret?: string): boolean {
if (client.clientType === "CONFIDENTIAL") {
if (!client_secret) return false;
const [hashedSecret] = generateSecret(client_secret);
if (client.clientSecret !== hashedSecret) return false;
}
return true; // PUBLIC has no client_secret
}
/**
* PKCE validator for BOTH:
* - exchanging authorization code
* - exchanging refresh token
*/
static verifyPKCE(
client: OAuthClient,
source: PKCESource,
code_verifier?: string
): OAuthErrorResult | null {
// Determine if PKCE should be enforced
const shouldEnforcePKCE =
client.clientType === "PUBLIC" || (client.clientType === "CONFIDENTIAL" && source.codeChallenge);
if (!shouldEnforcePKCE) return null;
const method = source.codeChallengeMethod || "S256";
if (!source.codeChallenge || !code_verifier || method !== "S256") {
return { error: "invalid_request", status: 400 };
}
if (!verifyCodeChallenge(code_verifier, source.codeChallenge, method)) {
return { error: "invalid_grant", status: 400 };
}
return null;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { isValidCodeChallengeMethod, verifyCodeChallenge } from "./pkce";
describe("PKCE Functions", () => {
describe("verifyCodeChallenge", () => {
it("should verify S256 code challenge correctly", () => {
const codeVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const codeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
const result = verifyCodeChallenge(codeVerifier, codeChallenge, "S256");
expect(result).toBe(true);
});
it("should fail verification with wrong S256 code challenge", () => {
const codeVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const wrongCodeChallenge = "wrong_challenge_value";
const result = verifyCodeChallenge(codeVerifier, wrongCodeChallenge, "S256");
expect(result).toBe(false);
});
it("should default to S256 method when none specified", () => {
const codeVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const codeChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
const result = verifyCodeChallenge(codeVerifier, codeChallenge);
expect(result).toBe(true);
});
it("should return false for unsupported method", () => {
const codeVerifier = "test_verifier";
const codeChallenge = "test_challenge";
const result = verifyCodeChallenge(codeVerifier, codeChallenge, "MD5");
expect(result).toBe(false);
});
});
describe("isValidCodeChallengeMethod", () => {
it("should accept S256 method", () => {
expect(isValidCodeChallengeMethod("S256")).toBe(true);
});
it("should reject unsupported methods", () => {
expect(isValidCodeChallengeMethod("plain")).toBe(false);
expect(isValidCodeChallengeMethod("MD5")).toBe(false);
expect(isValidCodeChallengeMethod("SHA1")).toBe(false);
expect(isValidCodeChallengeMethod("invalid")).toBe(false);
expect(isValidCodeChallengeMethod("")).toBe(false);
});
});
});
+17
View File
@@ -0,0 +1,17 @@
import { createHash } from "crypto";
export function verifyCodeChallenge(
codeVerifier: string,
codeChallenge: string,
method: string = "S256"
): boolean {
if (method === "S256") {
const expectedChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
return expectedChallenge === codeChallenge;
}
return false;
}
export function isValidCodeChallengeMethod(method: string): boolean {
return method === "S256";
}
@@ -0,0 +1,10 @@
-- CreateEnum
CREATE TYPE "public"."OAuthClientType" AS ENUM ('confidential', 'public');
-- AlterTable
ALTER TABLE "public"."AccessCode" ADD COLUMN "codeChallenge" TEXT,
ADD COLUMN "codeChallengeMethod" TEXT DEFAULT 'S256';
-- AlterTable
ALTER TABLE "public"."OAuthClient" ADD COLUMN "clientType" "public"."OAuthClientType" NOT NULL DEFAULT 'confidential',
ALTER COLUMN "clientSecret" DROP NOT NULL;
+20 -12
View File
@@ -1737,26 +1737,34 @@ model SelectedSlots {
@@unique(fields: [userId, slotUtcStartDate, slotUtcEndDate, uid], name: "selectedSlotUnique")
}
enum OAuthClientType {
CONFIDENTIAL @map("confidential")
PUBLIC @map("public")
}
model OAuthClient {
clientId String @id @unique
clientId String @id @unique
redirectUri String
clientSecret String
clientSecret String?
clientType OAuthClientType @default(CONFIDENTIAL)
name String
logo String?
accessCodes AccessCode[]
}
model AccessCode {
id Int @id @default(autoincrement())
code String
clientId String?
client OAuthClient? @relation(fields: [clientId], references: [clientId], onDelete: Cascade)
expiresAt DateTime
scopes AccessScope[]
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
teamId Int?
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
id Int @id @default(autoincrement())
code String
clientId String?
client OAuthClient? @relation(fields: [clientId], references: [clientId], onDelete: Cascade)
expiresAt DateTime
scopes AccessScope[]
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
teamId Int?
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
codeChallenge String?
codeChallengeMethod String? @default("S256")
}
enum AccessScope {
@@ -1,6 +1,7 @@
import { randomBytes, createHash } from "crypto";
import { prisma } from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
import type { TAddClientInputSchema } from "./addClient.schema";
@@ -9,27 +10,39 @@ type AddClientOptions = {
};
export const addClientHandler = async ({ input }: AddClientOptions) => {
const { name, redirectUri, logo } = input;
const { name, redirectUri, logo, enablePkce } = input;
const [hashedSecret, secret] = generateSecret();
const clientId = randomBytes(32).toString("hex");
const clientType = enablePkce ? "PUBLIC" : "CONFIDENTIAL";
const client = await prisma.oAuthClient.create({
data: {
name,
redirectUri,
clientId,
clientSecret: hashedSecret,
logo,
},
});
const clientWithSecret = {
...client,
clientSecret: secret,
// Only generate client secret for confidential clients
const clientData: Prisma.OAuthClientCreateInput = {
name,
redirectUri,
clientId,
clientType,
logo,
};
return clientWithSecret;
let secret: string | undefined;
if (!enablePkce) {
const [hashedSecret, plainSecret] = generateSecret();
clientData.clientSecret = hashedSecret;
secret = plainSecret;
}
const client = await prisma.oAuthClient.create({
data: clientData,
});
return {
clientId: client.clientId,
name: client.name,
redirectUri: client.redirectUri,
logo: client.logo,
clientType: client.clientType,
clientSecret: secret, // Only return plain secret for confidential clients
isPkceEnabled: enablePkce,
};
};
const hashSecretKey = (apiKey: string): string => createHash("sha256").update(apiKey).digest("hex");
@@ -4,6 +4,7 @@ export const ZAddClientInputSchema = z.object({
name: z.string(),
redirectUri: z.string(),
logo: z.string(),
enablePkce: z.boolean().optional().default(false),
});
export type TAddClientInputSchema = z.infer<typeof ZAddClientInputSchema>;
@@ -0,0 +1,354 @@
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { TRPCError } from "@trpc/server";
import { generateAuthCodeHandler } from "./generateAuthCode.handler";
const mockUser = {
id: 1,
email: "test@example.com",
name: "Test User",
};
const mockCtx = {
user: mockUser,
};
describe("generateAuthCodeHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("PUBLIC clients", () => {
const mockPublicClient = {
clientId: "public_client_123",
redirectUri: "https://app.example.com/callback",
name: "Test Public Client",
clientType: "PUBLIC" as const,
};
it("should generate authorization code for PUBLIC client with valid PKCE", async () => {
prismaMock.oAuthClient.findUnique.mockResolvedValue(mockPublicClient);
prismaMock.accessCode.create.mockResolvedValue({
id: 1,
code: "test_auth_code",
clientId: "public_client_123",
userId: 1,
teamId: null,
codeChallenge: "test_challenge",
codeChallengeMethod: "S256",
expiresAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
});
const input = {
clientId: "public_client_123",
teamSlug: undefined,
codeChallenge: "test_challenge",
codeChallengeMethod: "S256" as const,
scopes: [],
};
const result = await generateAuthCodeHandler({ ctx: mockCtx, input });
expect(result.client).toEqual(mockPublicClient);
expect(result.authorizationCode).toBeDefined();
expect(prismaMock.accessCode.create).toHaveBeenCalledWith({
data: expect.objectContaining({
clientId: "public_client_123",
userId: 1,
teamId: undefined,
scopes: [],
codeChallenge: "test_challenge",
codeChallengeMethod: "S256",
}),
});
});
it("should reject PUBLIC client without code_challenge", async () => {
prismaMock.oAuthClient.findUnique.mockResolvedValue(mockPublicClient);
const input = {
clientId: "public_client_123",
scopes: [],
teamSlug: undefined,
codeChallenge: undefined,
codeChallengeMethod: "S256" as const,
};
await expect(generateAuthCodeHandler({ ctx: mockCtx, input })).rejects.toThrow(
new TRPCError({
code: "BAD_REQUEST",
message: "code_challenge required for public clients",
})
);
expect(prismaMock.accessCode.create).not.toHaveBeenCalled();
});
it("should reject PUBLIC client without code_challenge_method", async () => {
prismaMock.oAuthClient.findUnique.mockResolvedValue(mockPublicClient);
const input = {
clientId: "public_client_123",
scopes: [],
teamSlug: undefined,
codeChallenge: "test_challenge",
codeChallengeMethod: undefined,
};
await expect(generateAuthCodeHandler({ ctx: mockCtx, input })).rejects.toThrow(
new TRPCError({
code: "BAD_REQUEST",
message: "code_challenge_method must be S256 for public clients",
})
);
expect(prismaMock.accessCode.create).not.toHaveBeenCalled();
});
it("should reject PUBLIC client with invalid code_challenge_method", async () => {
prismaMock.oAuthClient.findUnique.mockResolvedValue(mockPublicClient);
// Test with MD5 (invalid method)
const inputMD5 = {
clientId: "public_client_123",
scopes: [],
teamSlug: undefined,
codeChallenge: "test_challenge",
codeChallengeMethod: "MD5",
};
await expect(generateAuthCodeHandler({ ctx: mockCtx, input: inputMD5 })).rejects.toThrow(
new TRPCError({
code: "BAD_REQUEST",
message: "code_challenge_method must be S256 for public clients",
})
);
// Test with plain (should be rejected)
const inputPlain = {
clientId: "public_client_123",
scopes: [],
teamSlug: undefined,
codeChallenge: "test_challenge",
codeChallengeMethod: "plain",
};
await expect(generateAuthCodeHandler({ ctx: mockCtx, input: inputPlain })).rejects.toThrow(
new TRPCError({
code: "BAD_REQUEST",
message: "code_challenge_method must be S256 for public clients",
})
);
expect(prismaMock.accessCode.create).not.toHaveBeenCalled();
});
});
describe("CONFIDENTIAL clients", () => {
const mockConfidentialClient = {
clientId: "confidential_client_456",
redirectUri: "https://app.example.com/callback",
name: "Test Confidential Client",
clientType: "CONFIDENTIAL" as const,
};
it("should generate authorization code for CONFIDENTIAL client without PKCE", async () => {
prismaMock.oAuthClient.findUnique.mockResolvedValue(mockConfidentialClient);
prismaMock.accessCode.create.mockResolvedValue({
id: 1,
code: "test_auth_code",
clientId: "confidential_client_456",
userId: 1,
teamId: null,
scopes: [],
codeChallenge: null,
codeChallengeMethod: null,
expiresAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
});
const input = {
clientId: "confidential_client_456",
scopes: [],
teamSlug: undefined,
codeChallenge: undefined,
codeChallengeMethod: undefined,
};
const result = await generateAuthCodeHandler({ ctx: mockCtx, input });
expect(result.client).toEqual(mockConfidentialClient);
expect(result.authorizationCode).toBeDefined();
expect(prismaMock.accessCode.create).toHaveBeenCalledWith({
data: expect.objectContaining({
clientId: "confidential_client_456",
userId: 1,
teamId: undefined,
scopes: [],
codeChallenge: undefined,
codeChallengeMethod: undefined,
}),
});
});
it("should accept CONFIDENTIAL client with valid PKCE for enhanced security", async () => {
prismaMock.oAuthClient.findUnique.mockResolvedValue(mockConfidentialClient);
prismaMock.accessCode.create.mockResolvedValue({
id: 1,
code: "test_auth_code",
clientId: "confidential_client_456",
userId: 1,
teamId: null,
scopes: [],
expiresAt: new Date(),
codeChallenge: "test_challenge",
codeChallengeMethod: "S256",
});
const input = {
clientId: "confidential_client_456",
scopes: [],
teamSlug: undefined,
codeChallenge: "test_challenge",
codeChallengeMethod: "S256" as const,
};
const result = await generateAuthCodeHandler({ ctx: mockCtx, input });
expect(result.authorizationCode).toBeDefined();
expect(prismaMock.accessCode.create).toHaveBeenCalledWith({
data: {
code: expect.any(String),
clientId: "confidential_client_456",
userId: 1,
teamId: undefined,
scopes: [],
expiresAt: expect.any(Date),
codeChallenge: "test_challenge",
codeChallengeMethod: "S256",
},
});
});
it("should reject CONFIDENTIAL client with code_challenge but missing method", async () => {
prismaMock.oAuthClient.findUnique.mockResolvedValue(mockConfidentialClient);
const input = {
clientId: "confidential_client_456",
scopes: [],
teamSlug: undefined,
codeChallenge: "test_challenge",
codeChallengeMethod: undefined,
};
await expect(generateAuthCodeHandler({ ctx: mockCtx, input })).rejects.toThrow(
new TRPCError({
code: "BAD_REQUEST",
message: "code_challenge_method must be S256 when PKCE is used",
})
);
expect(prismaMock.accessCode.create).not.toHaveBeenCalled();
});
});
describe("Client validation", () => {
it("should reject invalid client ID", async () => {
prismaMock.oAuthClient.findUnique.mockResolvedValue(null);
const input = {
clientId: "invalid_client",
scopes: [],
teamSlug: undefined,
codeChallenge: undefined,
codeChallengeMethod: undefined,
};
await expect(generateAuthCodeHandler({ ctx: mockCtx, input })).rejects.toThrow(
new TRPCError({
code: "UNAUTHORIZED",
message: "Client ID not valid",
})
);
expect(prismaMock.accessCode.create).not.toHaveBeenCalled();
});
it("should accept only supported PKCE methods", async () => {
const mockPublicClient = {
clientId: "public_client_123",
redirectUri: "https://app.example.com/callback",
name: "Test Public Client",
clientType: "PUBLIC" as const,
};
prismaMock.oAuthClient.findUnique.mockResolvedValue(mockPublicClient);
prismaMock.accessCode.create.mockResolvedValue({
id: 1,
code: "test_auth_code",
clientId: "public_client_123",
userId: 1,
teamId: null,
scopes: [],
codeChallenge: "test_challenge",
codeChallengeMethod: "S256",
expiresAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
});
// Test S256
const inputS256 = {
clientId: "public_client_123",
scopes: [],
teamSlug: undefined,
codeChallenge: "test_challenge",
codeChallengeMethod: "S256" as const,
};
await expect(generateAuthCodeHandler({ ctx: mockCtx, input: inputS256 })).resolves.toBeDefined();
// Reset mocks for next test
vi.clearAllMocks();
prismaMock.oAuthClient.findUnique.mockResolvedValue(mockPublicClient);
// Test invalid method
const inputInvalid = {
clientId: "public_client_123",
scopes: [],
teamSlug: undefined,
codeChallenge: "test_challenge",
codeChallengeMethod: "SHA1",
};
await expect(generateAuthCodeHandler({ ctx: mockCtx, input: inputInvalid })).rejects.toThrow(
new TRPCError({
code: "BAD_REQUEST",
message: "code_challenge_method must be S256 for public clients",
})
);
// Test plain method (should be rejected)
const inputPlain = {
clientId: "public_client_123",
scopes: [],
teamSlug: undefined,
codeChallenge: "test_challenge",
codeChallengeMethod: "plain",
};
await expect(generateAuthCodeHandler({ ctx: mockCtx, input: inputPlain })).rejects.toThrow(
new TRPCError({
code: "BAD_REQUEST",
message: "code_challenge_method must be S256 for public clients",
})
);
});
});
});
@@ -17,7 +17,7 @@ type AddClientOptions = {
};
export const generateAuthCodeHandler = async ({ ctx, input }: AddClientOptions) => {
const { clientId, scopes, teamSlug } = input;
const { clientId, scopes, teamSlug, codeChallenge, codeChallengeMethod } = input;
const client = await prisma.oAuthClient.findUnique({
where: {
clientId,
@@ -26,12 +26,37 @@ export const generateAuthCodeHandler = async ({ ctx, input }: AddClientOptions)
clientId: true,
redirectUri: true,
name: true,
clientType: true,
},
});
if (!client) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Client ID not valid" });
}
if (client.clientType === "PUBLIC") {
if (!codeChallenge) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "code_challenge required for public clients",
});
}
if (!codeChallengeMethod || codeChallengeMethod !== "S256") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "code_challenge_method must be S256 for public clients",
});
}
} else if (client.clientType === "CONFIDENTIAL") {
// Optional PKCE validation for CONFIDENTIAL clients
if (codeChallenge && (!codeChallengeMethod || codeChallengeMethod !== "S256")) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "code_challenge_method must be S256 when PKCE is used",
});
}
}
const authorizationCode = generateAuthorizationCode();
const team = teamSlug
@@ -62,6 +87,8 @@ export const generateAuthCodeHandler = async ({ ctx, input }: AddClientOptions)
teamId: team ? team.id : undefined,
expiresAt: dayjs().add(10, "minutes").toDate(),
scopes: scopes as [AccessScope],
codeChallenge,
codeChallengeMethod,
},
});
return { client, authorizationCode };
@@ -4,6 +4,8 @@ export const ZGenerateAuthCodeInputSchema = z.object({
clientId: z.string(),
scopes: z.array(z.string()),
teamSlug: z.string().optional(),
codeChallenge: z.string().optional(),
codeChallengeMethod: z.enum(["S256"]).optional(),
});
export type TGenerateAuthCodeInputSchema = z.infer<typeof ZGenerateAuthCodeInputSchema>;
+2
View File
@@ -4,4 +4,6 @@ export type OAuthTokenPayload = {
token_type: string;
scope: string[];
clientId: string;
codeChallenge?: string | null;
codeChallengeMethod?: string | null;
};