diff --git a/e2e/common/oauth2-fixtures.mjs b/e2e/common/oauth2-fixtures.mjs index 7faa38e9cf..c9eec6c100 100644 --- a/e2e/common/oauth2-fixtures.mjs +++ b/e2e/common/oauth2-fixtures.mjs @@ -211,6 +211,75 @@ export async function ensureOidcClient(adminToken, request, clientId) { }); } +/** + * Give each worker/run its own provider. No root-realm provider GET/PUT is performed, + * so this setup cannot overwrite settings being changed by the other OAuth/UMA specs. + */ +export async function createDevicePkceRealm(adminToken, request, realm, clientId, owner) { + const realmUrl = `${OPENAM_BASE}/json/realms/root/realms/${realm}`; + const oauthUrl = `${OPENAM_BASE}/oauth2/realms/root/realms/${realm}`; + const headers = adminHeaders(adminToken, CONFIG_VERSION); + async function requireSuccess(response, operation) { + if (!response.ok()) throw new Error(`${operation}: ${response.status()} ${await response.text()}`); + } + await requireSuccess(await request.post(`${OPENAM_BASE}/json/global-config/realms?_action=create`, { + headers, data: { name: realm, parentPath: "/", active: true, aliases: [] }, + }), "create PKCE realm"); + await requireSuccess(await request.post(`${realmUrl}/realm-config/services/oauth-oidc?_action=create`, { + headers, + data: { + advancedOAuth2Config: { clientsCanSkipConsent: true, supportedScopes: [SCOPE], defaultScopes: [SCOPE] }, + advancedOIDCConfig: { allowDynamicRegistration: true }, + }, + }), "create PKCE provider"); + // A new realm has no AgentService container. Dynamic registration creates it through + // the product's supported registration path before the admin API writes our client. + await requireSuccess(await request.post(`${oauthUrl}/connect/register`, { + data: { redirect_uris: [REDIRECT_URI], client_name: "PKCE realm bootstrap" }, + }), "bootstrap PKCE AgentService"); + await requireSuccess(await request.put(`${realmUrl}/realm-config/agents/OAuth2Client/${clientId}`, { + headers: adminHeaders(adminToken, AGENT_VERSION), + data: { + "com.forgerock.openam.oauth2provider.clientType": "Public", + "com.forgerock.openam.oauth2provider.scopes": [`[0]=${SCOPE}`], + "com.forgerock.openam.oauth2provider.defaultScopes": [`[0]=${SCOPE}`], + "com.forgerock.openam.oauth2provider.grantTypes": ["[0]=urn:ietf:params:oauth:grant-type:device_code"], + "com.forgerock.openam.oauth2provider.responseTypes": ["[0]=device_code"], + "com.forgerock.openam.oauth2provider.tokenEndPointAuthMethod": "none", + isConsentImplied: true, + sunIdentityServerDeviceStatus: "Active", + }, + }), "create PKCE client"); + // A fresh realm must provision its resource owner rather than rely on a root demo account. + await requireSuccess(await request.post(`${realmUrl}/users?_action=create`, { + headers: adminHeaders(adminToken, { "Accept-API-Version": "resource=3.0,protocol=2.1" }), + data: { + username: owner.username, userpassword: owner.password, + sn: "PKCE", cn: "PKCE Test User", givenName: "PKCE", + }, + }), "create PKCE resource owner"); + return { realmUrl, oauthUrl }; +} + +export async function deleteDevicePkceRealm(adminToken, request, realm, username) { + // Realm deletion does not remove entries from its shared LDAP identity store. + if (username) { + const user = await request.delete(`${OPENAM_BASE}/json/realms/root/realms/${realm}/users/${encodeURIComponent(username)}`, { + headers: adminHeaders(adminToken, { "Accept-API-Version": "resource=3.0,protocol=2.1" }), + }); + if (!user.ok() && user.status() !== 404) { + throw new Error(`delete PKCE resource owner: ${user.status()} ${await user.text()}`); + } + } + const id = Buffer.from(`/${realm}`).toString("base64url"); + const response = await request.delete(`${OPENAM_BASE}/json/global-config/realms/${id}`, { + headers: adminHeaders(adminToken, CONFIG_VERSION), + }); + if (!response.ok() && response.status() !== 404) { + throw new Error(`delete PKCE realm: ${response.status()} ${await response.text()}`); + } +} + export async function ensureResourceServerClient(adminToken, request, clientId) { await ensureClient(adminToken, request, clientId, { "userpassword": RS_CLIENT_SECRET, @@ -429,3 +498,4 @@ export async function protectionApiToken(request, clientId, username, password) export async function authorizationApiToken(request, clientId, username, password) { return resourceServerToken(request, clientId, username, password, "uma_authorization"); } + diff --git a/e2e/oauth2/device-pkce-test.spec.mjs b/e2e/oauth2/device-pkce-test.spec.mjs new file mode 100644 index 0000000000..413447c86d --- /dev/null +++ b/e2e/oauth2/device-pkce-test.spec.mjs @@ -0,0 +1,167 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 Aravind Yedmala. + */ + +import { randomUUID } from "node:crypto"; +import { test, expect } from "@playwright/test"; +import { getAdminToken, PASSWORD } from "../common/openam-commons.mjs"; +import { SCOPE, createDevicePkceRealm, deleteDevicePkceRealm } from "../common/oauth2-fixtures.mjs"; + +// HTTP coverage for GHSA-h93p-35jm-rj83, part of #1223. Unlike the Java resource tests, these +// assertions survive removal of Restlet and exercise challenge persistence through the token store. +const CLIENT_ID = "test_device_pkce_app"; +const USERNAME = `pkce${randomUUID().replaceAll("-", "")}`; +const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; +let ENDPOINT; +// RFC 7636 Appendix B's S256 test vector; plain uses the same valid 43-character verifier. +const VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; +const WRONG_VERIFIER = "a".repeat(43); +const METHODS = [ + { name: "S256", challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", method: "S256" }, + { name: "plain", challenge: VERIFIER, method: "plain" }, + { name: "omitted (defaults to plain)", challenge: VERIFIER }, +]; + +async function issueCode(request, { challenge, method } = {}) { + const form = { client_id: CLIENT_ID, response_type: "device_code", scope: SCOPE }; + if (challenge !== undefined) form.code_challenge = challenge; + if (method !== undefined) form.code_challenge_method = method; + return request.post(`${ENDPOINT}/device/code`, { form, headers: { Accept: "application/json" } }); +} + +async function newCode(request, parameters) { + const response = await issueCode(request, parameters); + expect(response.status(), `device code issuance: ${await response.text()}`).toBe(200); + const body = await response.json(); + expect(body.device_code).toEqual(expect.any(String)); + expect(body.device_code.length).toBeGreaterThan(0); + expect(body.user_code).toEqual(expect.any(String)); + expect(body.user_code.length).toBeGreaterThan(0); + return body; +} + +async function approveCode(request, userCode, sessionToken) { + // This is the documented non-browser submission, including the session-bound CSRF value. + const response = await request.post(`${ENDPOINT}/device/user`, { + form: { user_code: userCode, csrf: sessionToken }, + // Each test has a different request context: carry the owner session explicitly. + headers: { Cookie: `iPlanetDirectoryPro=${sessionToken}` }, + maxRedirects: 0, + }); + expect(response.status(), `device approval: ${await response.text()}`).toBe(200); +} + +function redeem(request, deviceCode, verifier) { + const form = { grant_type: DEVICE_GRANT, client_id: CLIENT_ID, device_code: deviceCode }; + if (verifier !== undefined) form.code_verifier = verifier; + return request.post(`${ENDPOINT}/access_token`, { form, headers: { Accept: "application/json" } }); +} + +async function expectOAuthError(response, error) { + expect(response.status(), `expected ${error}: ${await response.text()}`).toBe(400); + const body = await response.json(); + expect(body.error).toBe(error); + expect(body).not.toHaveProperty("access_token"); + expect(body).not.toHaveProperty("refresh_token"); + expect(body).not.toHaveProperty("id_token"); +} + +async function expectTokensForUser(request, deviceCode, verifier) { + const response = await redeem(request, deviceCode, verifier); + expect(response.status(), `token redemption: ${await response.text()}`).toBe(200); + const body = await response.json(); + expect(body.access_token).toEqual(expect.any(String)); + expect(body.access_token.length).toBeGreaterThan(0); + const userInfo = await request.get(`${ENDPOINT}/userinfo`, { + headers: { Authorization: `Bearer ${body.access_token}`, Accept: "application/json" }, + }); + expect(userInfo.status(), `userinfo: ${await userInfo.text()}`).toBe(200); + expect((await userInfo.json()).sub).toBe(USERNAME); +} + +test.describe("Device authorization PKCE over HTTP", () => { + let sessionToken; + let adminToken; + let realm; + + test.beforeAll(async ({ request }) => { + adminToken = await getAdminToken(request); + expect(adminToken, "administrator authentication is required; security coverage must not silently skip").toBeTruthy(); + realm = `devicepkce${randomUUID().replaceAll("-", "")}`; + const fixture = await createDevicePkceRealm(adminToken, request, realm, CLIENT_ID, { username: USERNAME, password: PASSWORD }); + ENDPOINT = fixture.oauthUrl; + // Authenticate in the isolated realm; a root-realm session is not sufficient here. + const authentication = await request.post(`${fixture.realmUrl}/authenticate`, { + headers: { + "X-OpenAM-Username": USERNAME, "X-OpenAM-Password": PASSWORD, + "Content-Type": "application/json", "Accept-API-Version": "resource=2.0,protocol=1.0", + }, + }); + expect(authentication.status(), "resource-owner authentication").toBe(200); + sessionToken = (await authentication.json()).tokenId; + expect(sessionToken, "resource-owner authentication is required").toBeTruthy(); + }); + + test.afterAll(async ({ request }) => { + if (adminToken && realm) await deleteDevicePkceRealm(adminToken, request, realm, USERNAME); + }); + + for (const method of ["bogus", "s256", "PLAIN", ""]) { + test(`issuance rejects challenge method ${JSON.stringify(method)}`, async ({ request }) => { + await expectOAuthError(await issueCode(request, { ...METHODS[0], method }), "invalid_request"); + }); + } + + for (const parameters of METHODS) { + test(`${parameters.name}: correct verifier completes the device flow`, async ({ request }) => { + const code = await newCode(request, parameters); + await approveCode(request, code.user_code, sessionToken); + await expectTokensForUser(request, code.device_code, VERIFIER); + }); + + for (const attempt of [ + { name: "missing", verifier: undefined, error: "invalid_request" }, + { name: "wrong", verifier: WRONG_VERIFIER, error: "invalid_grant" }, + ]) { + test(`${parameters.name}: ${attempt.name} verifier cannot redeem or consume an approved code`, + async ({ request }) => { + const code = await newCode(request, parameters); + await approveCode(request, code.user_code, sessionToken); + await expectOAuthError(await redeem(request, code.device_code, attempt.verifier), attempt.error); + // A rejection alone could pass if the flow were broken for everyone. Redeeming the SAME code + // with the right verifier proves both that approval worked and that the attack did not consume it. + await expectTokensForUser(request, code.device_code, VERIFIER); + }); + } + } + + for (const attempt of [ + { name: "missing", verifier: undefined, error: "invalid_request" }, + { name: "wrong", verifier: WRONG_VERIFIER, error: "invalid_grant" }, + ]) { + test(`S256: ${attempt.name} verifier is rejected before user approval`, async ({ request }) => { + const code = await newCode(request, METHODS[0]); + await expectOAuthError(await redeem(request, code.device_code, attempt.verifier), attempt.error); + await approveCode(request, code.user_code, sessionToken); + await expectTokensForUser(request, code.device_code, VERIFIER); + }); + } + + test("a device flow without a challenge still works without a verifier", async ({ request }) => { + const code = await newCode(request); + await approveCode(request, code.user_code, sessionToken); + await expectTokensForUser(request, code.device_code); + }); +});