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:
co-authored by
cubic-dev-ai[bot]
CarinaWolli
parent
c95b0833c1
commit
4e0798577a
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
|
||||
Vendored
+2
@@ -4,4 +4,6 @@ export type OAuthTokenPayload = {
|
||||
token_type: string;
|
||||
scope: string[];
|
||||
clientId: string;
|
||||
codeChallenge?: string | null;
|
||||
codeChallengeMethod?: string | null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user