Files
calendar/apps/web/playwright/oauth-provider.e2e.ts
T
Carina WollendorferGitHubCarinaWollicubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
4e0798577a 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>
2025-11-26 17:02:42 +01:00

908 lines
33 KiB
TypeScript

import { expect } from "@playwright/test";
import { createHash, randomBytes } from "crypto";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
import { test } from "./lib/fixtures";
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
let client: {
clientId: string;
redirectUri: string;
orginalSecret: string;
name: 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();
});
test("should create valid access token & refresh token for user", async ({ page, users }) => {
const user = await users.create({ username: "test user", name: "test user" });
await user.apiLogin();
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");
});
const url = new URL(page.url());
// authorization code that is returned to client with redirect uri
const code = url.searchParams.get("code");
// request token with authorization code
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();
// test if 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();
// check if user access token is valid
expect(meData.username.startsWith("test user")).toBe(true);
// request new token with refresh token
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(refreshTokenData.access_token).toBeDefined();
const validTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/me`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokenData.access_token}`,
},
});
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 }) => {
const user = await users.create({ username: "test user", name: "test user" }, { hasTeam: true });
await user.apiLogin();
await page.goto(
`auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234`
);
await page.locator("#account-select").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();
await page.waitForFunction(() => {
return window.location.href.startsWith("https://example.com");
});
const url = new URL(page.url());
// authorization code that is returned to client with redirect uri
const code = url.searchParams.get("code");
// request token with authorization code
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();
// test if 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();
// Check if team access token is valid
expect(meData.username).toEqual(`user-id-${user.id}'s Team`);
// request new token with refresh token
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(refreshTokenData.access_token).toBeDefined();
const validTokenResponse = await fetch(`${WEBAPP_URL}/api/auth/oauth/me`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokenData.access_token}`,
},
});
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 ({
page,
users,
}) => {
const user = await users.create({ username: "test-user", name: "test user" });
await page.goto(
`auth/oauth2/authorize?client_id=${client.clientId}&redirect_uri=${client.redirectUri}&response_type=code&scope=READ_PROFILE&state=1234`
);
// check if user is redirected to login page
await expect(page.getByRole("heading", { name: "Welcome back" })).toBeVisible();
await page.locator("#email").fill(user.email);
await page.locator("#password").fill(user.username || "");
await page.locator('[type="submit"]').click();
await page.waitForSelector("#account-select");
await expect(page.getByText("test user")).toBeVisible();
});
});
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");
const client = await prisma.oAuthClient.create({
data: {
name: "Test Client",
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;
};