* feat(api-v2): add OAuth2 controller skeleton for auth module - Add new OAuth2 module under /auth with controller, service, repository, and DTOs - Implement skeleton endpoints: - GET /v2/auth/oauth2/clients/:clientId - Get OAuth client info - POST /v2/auth/oauth2/clients/:clientId/authorize - Generate authorization code - POST /v2/auth/oauth2/clients/:clientId/exchange - Exchange code for tokens - POST /v2/auth/oauth2/clients/:clientId/refresh - Refresh access token - Create input DTOs for authorize, exchange, and refresh operations - Create output DTOs for client info, authorization code, and tokens - Register OAuth2Module in endpoints.module.ts Co-Authored-By: morgan@cal.com <morgan@cal.com> * chore: add missing functions to platform libraries * feat(api-v2): integrate OAuthService from platform-libraries into OAuth2 controller - Add OAuthService export to platform-libraries - Refactor OAuth2Service to use OAuthService.validateClient() and OAuthService.verifyPKCE() - Remove duplicate local implementations of validateClient and verifyPKCE methods Co-Authored-By: morgan@cal.com <morgan@cal.com> * feat(api-v2): use local validateClient and verifyPKCE implementations - Remove OAuthService export from platform-libraries (will be deprecated) - Reimplement validateClient and verifyPKCE methods locally in OAuth2Service - Use verifyCodeChallenge and generateSecret from platform-libraries directly Co-Authored-By: morgan@cal.com <morgan@cal.com> * refactor(api-v2): separate repositories for OAuth2, access codes, and teams - Create AccessCodeRepository for access code Prisma operations - Add findTeamBySlugWithAdminRole method to TeamsRepository - Move generateAuthorizationCode, createTokens, verifyRefreshToken to OAuth2Service - Update OAuth2Repository to only contain OAuth client operations - Update OAuth2Module to import TeamsModule and provide AccessCodeRepository Addresses PR review comments to properly separate concerns Co-Authored-By: morgan@cal.com <morgan@cal.com> * feat(api-v2): implement JWT token creation and verification for OAuth2 - Implement createTokens method using jsonwebtoken to sign access and refresh tokens - Implement verifyRefreshToken method to verify JWT refresh tokens - Add ConfigService injection for CALENDSO_ENCRYPTION_KEY access - Import ConfigModule in OAuth2Module for dependency injection Based on logic from apps/web/app/api/auth/oauth/token/route.ts and apps/web/app/api/auth/oauth/refreshToken/route.ts Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: refactor and dayjs import fix * feat(api-v2): add ApiAuthGuard to getClient endpoint and e2e tests for OAuth2 - Add @UseGuards(ApiAuthGuard) to getClient endpoint for authentication - Add comprehensive e2e tests for all OAuth2 endpoints: - GET /v2/auth/oauth2/clients/:clientId - POST /v2/auth/oauth2/clients/:clientId/authorize - POST /v2/auth/oauth2/clients/:clientId/exchange - POST /v2/auth/oauth2/clients/:clientId/refresh - Tests cover happy paths and error cases (invalid client, invalid code, etc.) Co-Authored-By: morgan@cal.com <morgan@cal.com> * chore(api-v2): hide OAuth2 endpoints from Swagger documentation Co-Authored-By: morgan@cal.com <morgan@cal.com> * hide endpoints from doc * fix(api-v2): address PR feedback for OAuth2 controller - Fix P0 critical bug: token_type field name mismatch in DecodedRefreshToken interface - Add @Equals('authorization_code') validation to exchange.input.ts grantType - Add @Equals('refresh_token') validation to refresh.input.ts grantType - Add @IsNotEmpty() validation to get-client.input.ts clientId - Add @Expose() decorators to all output DTOs for proper serialization - Add security test assertion to verify clientSecret is not returned in response Co-Authored-By: morgan@cal.com <morgan@cal.com> * feat(api-v2): implement redirect behavior for OAuth2 authorize endpoint - Update authorize endpoint to return HTTP 303 redirect with authorization code - Add exact match validation for redirect URI (security requirement) - Implement error handling with redirect to redirect URI and error query params - Add state parameter support for CSRF protection - Update e2e tests to verify redirect behavior (303 status, Location header, error redirects) Co-Authored-By: morgan@cal.com <morgan@cal.com> * refactor(api-v2): address PR feedback for OAuth2 authorize endpoint - Make redirectUri a required input parameter (remove optional fallback) - Move buildRedirectUrl and mapErrorToOAuthError to oauth2Service - Add return statements to res.redirect() calls - Simplify controller by delegating redirect URL building to service Co-Authored-By: morgan@cal.com <morgan@cal.com> * wip move code outside of api v2 * feat(oauth): wire up dependency injection for OAuthService and repositories Co-Authored-By: morgan@cal.com <morgan@cal.com> * refactor: oAuthService used in routes * fix imports * fix(api-v2): return 404 for invalid client ID instead of redirect in authorize endpoint Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix(api-v2): fix E2E test URL paths and remove bootstrap() in authenticated section - Remove bootstrap() call in authenticated section to match working test pattern - Change URL paths from /api/v2/... to /v2/... in authenticated section - Keep bootstrap() and /api/v2/... paths in unauthenticated section Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix(api-v2): mock getToken from next-auth/jwt for E2E tests - Add jest.mock for next-auth/jwt getToken function - Mock returns null for unauthenticated tests - Mock returns { email: userEmail } for authenticated tests - Remove withNextAuth helper since ApiAuthGuard uses ApiAuthStrategy which calls getToken directly, not NextAuthStrategy Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix tests and code review * cleanup generate secrets * cleanup controller * chore: remove console.log from OAuthService.validateClient Co-Authored-By: morgan@cal.com <morgan@cal.com> * Update apps/web/app/api/auth/oauth/refreshToken/route.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * fix(api-v2): add bootstrap() to authenticated E2E tests and update URL paths to /api/v2/ Co-Authored-By: morgan@cal.com <morgan@cal.com> * Merge remote-tracking branch 'origin/devin/oauth2-controller-skeleton-1765988792' and remove console.log from token route Co-Authored-By: morgan@cal.com <morgan@cal.com> * chore: remove dead code * refactor * refactor: generateAuthCode trpc handler and getClient trpc handler * remove pkce check for refreshToken endpoint * Update packages/trpc/server/routers/viewer/oAuth/getClient.handler.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * refactor: error handling * refactor: error handling * refactor: error handling * remove console log * provide redirectUri in generateAuthCodeHandler * provide redirectUri in authorize view * refactor: replace HttpError with ErrorWithCode in OAuth files - Update OAuthService to use ErrorWithCode instead of HttpError - Update mapErrorToOAuthError to check ErrorCode instead of status codes - Update token/route.ts and refreshToken/route.ts to use ErrorWithCode - Add ErrorWithCode export to platform-libraries - Use getHttpStatusCode to map ErrorCode to HTTP status codes Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: update generateAuthCode handler to use ErrorWithCode instead of HttpError The handler was still checking for HttpError in its catch block, but OAuthService now throws ErrorWithCode. This caused the error messages to be lost and replaced with 'server_error' instead of the specific error messages. Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: update OAuth2 controller to use ErrorWithCode instead of HttpError The controller was still checking for HttpError in its catch blocks, but OAuthService now throws ErrorWithCode. This caused all errors to return 500 Internal Server Error instead of the correct HTTP status codes (400, 401, 404). Also added getHttpStatusCode export to platform-libraries. Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: add explicit unknown type annotation to catch blocks in OAuth2 controller Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: change NotFoundException to HttpException in authorize endpoint Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: handle err with ErrorWithCode in authorize endpoint catch block Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: move errorWithCode * fix: error code rfc * fix: no need to handle errors there is a middleware * refactor errors * refactor errors * refactor redirecturi and state from api * refactor: address PR comments for OAuth2 controller - Remove unused GetOAuth2ClientInput class - Change API tag from 'Auth / OAuth2' to 'OAuth2' - Move OAuthClientFixture to separate fixture file (oauth2-client.repository.fixture.ts) - Add 'type' property to OAuth2ClientDto for confidential/public client type - Rename 'clientId' to 'id' in OAuth2ClientDto (using @Expose mapping) - Clean up duplicate provider declarations in oauth2.module.ts - Update e2e tests to use new fixture and verify type property Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com> Co-authored-by: Volnei Munhoz <volnei@cal.com>
698 lines
25 KiB
TypeScript
698 lines
25 KiB
TypeScript
import { expect } from "@playwright/test";
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
|
|
import { OAUTH_ERROR_REASONS } from "@calcom/features/oauth/services/OAuthService";
|
|
import { generateSecret } from "@calcom/features/oauth/utils/generateSecret";
|
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
|
import { prisma } from "@calcom/prisma";
|
|
|
|
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(OAUTH_ERROR_REASONS["pkce_required"]);
|
|
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", 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();
|
|
|
|
// Refresh token - NO PKCE needed
|
|
const refreshTokenForm = new URLSearchParams();
|
|
refreshTokenForm.append("refresh_token", tokenData.refresh_token);
|
|
refreshTokenForm.append("client_id", publicClient.clientId);
|
|
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");
|
|
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.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);
|
|
|
|
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.refresh_token).toBeDefined();
|
|
});
|
|
|
|
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 client_secret
|
|
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;
|
|
};
|