From 45968e61b331bc8b9dbc1035c9089b11f7e5575b Mon Sep 17 00:00:00 2001 From: ARAVIND YEDMALA Date: Wed, 11 Nov 2026 09:23:06 -0500 Subject: [PATCH 1/4] Add HTTP regression coverage for device-flow PKCE enforcement --- e2e/common/oauth2-fixtures.mjs | 56 ++++++++++ e2e/oauth2/device-pkce-test.spec.mjs | 149 +++++++++++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 e2e/oauth2/device-pkce-test.spec.mjs diff --git a/e2e/common/oauth2-fixtures.mjs b/e2e/common/oauth2-fixtures.mjs index 7faa38e9cf..58f9431d63 100644 --- a/e2e/common/oauth2-fixtures.mjs +++ b/e2e/common/oauth2-fixtures.mjs @@ -211,6 +211,61 @@ export async function ensureOidcClient(adminToken, request, clientId) { }); } +/** + * Only widen the shared provider, as device-flow-test does; restoring its old settings would race + * a sibling spec that is still using them. Retry when another spec updates the provider concurrently. + */ +export async function ensureDevicePkceProvider(adminToken, request) { + const url = `${OPENAM_BASE}/json/realms/${REALM}/realm-config/services/oauth-oidc`; + let current = await ensureOAuth2Provider(adminToken, request); + for (let attempt = 0; attempt < 3; attempt++) { + if (!current.ok()) { + throw new Error(`Device provider not readable: ${current.status()} ${await current.text()}`); + } + const config = await current.json(); + const advanced = config.advancedOAuth2Config ?? {}; + const supported = advanced.supportedScopes ?? []; + const defaults = advanced.defaultScopes ?? []; + if (advanced.clientsCanSkipConsent === true && supported.includes(SCOPE) && defaults.includes(SCOPE)) return; + const updated = await request.put(url, { + headers: adminHeaders(adminToken, CONFIG_VERSION), + data: { + ...config, + advancedOAuth2Config: { + ...advanced, + clientsCanSkipConsent: true, + supportedScopes: [...new Set([...supported, SCOPE])], + defaultScopes: [...new Set([...defaults, SCOPE])], + }, + }, + }); + current = await request.get(url, { headers: adminHeaders(adminToken, CONFIG_VERSION) }); + if (current.ok()) { + const reread = await current.json(); + const actual = reread.advancedOAuth2Config ?? {}; + if (actual.clientsCanSkipConsent === true && actual.supportedScopes?.includes(SCOPE) + && actual.defaultScopes?.includes(SCOPE)) return; + } + if (attempt === 2) { + throw new Error(`Could not configure device provider: write ${updated.status()}, read ${current.status()}`); + } + } +} + +/** A public device client owned by the PKCE spec, so provisioning cannot invalidate another spec's codes. */ +export async function ensureDevicePkceClient(adminToken, request, clientId) { + await ensureClient(adminToken, request, clientId, { + "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", + }); +} + export async function ensureResourceServerClient(adminToken, request, clientId) { await ensureClient(adminToken, request, clientId, { "userpassword": RS_CLIENT_SECRET, @@ -429,3 +484,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..8a4d71d4b9 --- /dev/null +++ b/e2e/oauth2/device-pkce-test.spec.mjs @@ -0,0 +1,149 @@ +/* + * 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 { test, expect } from "@playwright/test"; +import { OPENAM_BASE, getAdminToken, getAuthToken, USERNAME, PASSWORD } from "../common/openam-commons.mjs"; +import { REALM, SCOPE, ensureDevicePkceProvider, ensureDevicePkceClient } 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 DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; +const ENDPOINT = `${OPENAM_BASE}/oauth2/realms/${REALM}`; +// 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 }, + headers: { 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; + + test.beforeAll(async ({ request }) => { + const adminToken = await getAdminToken(request); + expect(adminToken, "administrator authentication is required; security coverage must not silently skip").toBeTruthy(); + await ensureDevicePkceProvider(adminToken, request); + await ensureDevicePkceClient(adminToken, request, CLIENT_ID); + sessionToken = await getAuthToken(request, USERNAME, PASSWORD); + expect(sessionToken, "resource-owner authentication is required").toBeTruthy(); + }); + + 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); + }); +}); From 276ac67e69412eb21b3d3879e427d1805df15028 Mon Sep 17 00:00:00 2001 From: ARAVIND YEDMALA Date: Wed, 11 Nov 2026 19:12:31 -0500 Subject: [PATCH 2/4] Fix Docker CI package downloads and fail fast on image build errors --- .github/workflows/build.yml | 1 - openam-distribution/openam-distribution-docker/Dockerfile | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a65a5c1b6f..f915f23d2d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -122,7 +122,6 @@ jobs: run: sed -i -E '/^#COPY openam-(server|distribution)\//s/^#//' ./openam-distribution/openam-distribution-docker/Dockerfile - name: Build image uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 - continue-on-error: true with: context: . file: ./openam-distribution/openam-distribution-docker/Dockerfile diff --git a/openam-distribution/openam-distribution-docker/Dockerfile b/openam-distribution/openam-distribution-docker/Dockerfile index 49b911a5b1..39609e7a83 100644 --- a/openam-distribution/openam-distribution-docker/Dockerfile +++ b/openam-distribution/openam-distribution-docker/Dockerfile @@ -35,9 +35,9 @@ RUN mkdir /usr/openam \ RUN if [ ! -f "$CATALINA_HOME/webapps/$OPENAM_PATH.war" ]; then echo file exists; fi -RUN apt-get update \ - && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \ - && apt-get install -y --no-install-recommends curl unzip \ +RUN apt-get -o Acquire::ForceIPv4=true -o Acquire::Retries=3 update \ + && DEBIAN_FRONTEND=noninteractive apt-get -o Acquire::ForceIPv4=true -o Acquire::Retries=3 upgrade -y \ + && apt-get -o Acquire::ForceIPv4=true -o Acquire::Retries=3 install -y --no-install-recommends curl unzip \ && if [ -z "$VERSION" ] ; then VERSION="$(curl -i -o - --silent https://api.github.com/repos/OpenIdentityPlatform/OpenAM/releases/latest | grep -m1 "\"name\"" | cut -d\" -f4)"; fi \ && if [ ! -f "$CATALINA_HOME/webapps/$OPENAM_PATH.war" ]; then echo file exists && curl -L https://github.com/OpenIdentityPlatform/OpenAM/releases/download/$VERSION/OpenAM-$VERSION.war --output $CATALINA_HOME/webapps/$OPENAM_PATH.war; fi \ && if [ ! -f "/usr/openam/ssoconfiguratortools.zip" ]; then curl -L https://github.com/OpenIdentityPlatform/OpenAM/releases/download/$VERSION/SSOConfiguratorTools-$VERSION.zip --output /usr/openam/ssoconfiguratortools.zip; fi \ @@ -51,7 +51,7 @@ RUN apt-get update \ && useradd -m -r -u 1001 -g root $OPENAM_USER \ && install -d -o $OPENAM_USER $OPENAM_DATA_DIR \ && chown -R $OPENAM_USER:root $CATALINA_HOME \ - && apt-get remove -y --purge unzip \ + && apt-get -o Acquire::ForceIPv4=true -o Acquire::Retries=3 remove -y --purge unzip \ && rm -rf /var/lib/apt/lists/* \ && sed -i '/<\/Host>/i \ \ \ \ ' $CATALINA_HOME/conf/server.xml From 316ae06977966d48fa52d01a7ba1b00325dea050 Mon Sep 17 00:00:00 2001 From: ARAVIND YEDMALA Date: Thu, 12 Nov 2026 08:16:15 -0500 Subject: [PATCH 3/4] Fix device PKCE session cookies and isolate provider configuration; revert Docker changes --- .github/workflows/build.yml | 1 + e2e/common/oauth2-fixtures.mjs | 91 +++++++++---------- e2e/oauth2/device-pkce-test.spec.mjs | 33 +++++-- .../openam-distribution-docker/Dockerfile | 8 +- 4 files changed, 74 insertions(+), 59 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f915f23d2d..a65a5c1b6f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -122,6 +122,7 @@ jobs: run: sed -i -E '/^#COPY openam-(server|distribution)\//s/^#//' ./openam-distribution/openam-distribution-docker/Dockerfile - name: Build image uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + continue-on-error: true with: context: . file: ./openam-distribution/openam-distribution-docker/Dockerfile diff --git a/e2e/common/oauth2-fixtures.mjs b/e2e/common/oauth2-fixtures.mjs index 58f9431d63..b7e3470a01 100644 --- a/e2e/common/oauth2-fixtures.mjs +++ b/e2e/common/oauth2-fixtures.mjs @@ -212,58 +212,55 @@ export async function ensureOidcClient(adminToken, request, clientId) { } /** - * Only widen the shared provider, as device-flow-test does; restoring its old settings would race - * a sibling spec that is still using them. Retry when another spec updates the provider concurrently. + * 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 ensureDevicePkceProvider(adminToken, request) { - const url = `${OPENAM_BASE}/json/realms/${REALM}/realm-config/services/oauth-oidc`; - let current = await ensureOAuth2Provider(adminToken, request); - for (let attempt = 0; attempt < 3; attempt++) { - if (!current.ok()) { - throw new Error(`Device provider not readable: ${current.status()} ${await current.text()}`); - } - const config = await current.json(); - const advanced = config.advancedOAuth2Config ?? {}; - const supported = advanced.supportedScopes ?? []; - const defaults = advanced.defaultScopes ?? []; - if (advanced.clientsCanSkipConsent === true && supported.includes(SCOPE) && defaults.includes(SCOPE)) return; - const updated = await request.put(url, { - headers: adminHeaders(adminToken, CONFIG_VERSION), - data: { - ...config, - advancedOAuth2Config: { - ...advanced, - clientsCanSkipConsent: true, - supportedScopes: [...new Set([...supported, SCOPE])], - defaultScopes: [...new Set([...defaults, SCOPE])], - }, - }, - }); - current = await request.get(url, { headers: adminHeaders(adminToken, CONFIG_VERSION) }); - if (current.ok()) { - const reread = await current.json(); - const actual = reread.advancedOAuth2Config ?? {}; - if (actual.clientsCanSkipConsent === true && actual.supportedScopes?.includes(SCOPE) - && actual.defaultScopes?.includes(SCOPE)) return; - } - if (attempt === 2) { - throw new Error(`Could not configure device provider: write ${updated.status()}, read ${current.status()}`); - } +export async function createDevicePkceRealm(adminToken, request, realm, clientId) { + 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"); + return { realmUrl, oauthUrl }; } -/** A public device client owned by the PKCE spec, so provisioning cannot invalidate another spec's codes. */ -export async function ensureDevicePkceClient(adminToken, request, clientId) { - await ensureClient(adminToken, request, clientId, { - "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", +export async function deleteDevicePkceRealm(adminToken, request, realm) { + 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) { diff --git a/e2e/oauth2/device-pkce-test.spec.mjs b/e2e/oauth2/device-pkce-test.spec.mjs index 8a4d71d4b9..3d7595058c 100644 --- a/e2e/oauth2/device-pkce-test.spec.mjs +++ b/e2e/oauth2/device-pkce-test.spec.mjs @@ -14,15 +14,16 @@ * Copyright 2026 Aravind Yedmala. */ +import { randomUUID } from "node:crypto"; import { test, expect } from "@playwright/test"; -import { OPENAM_BASE, getAdminToken, getAuthToken, USERNAME, PASSWORD } from "../common/openam-commons.mjs"; -import { REALM, SCOPE, ensureDevicePkceProvider, ensureDevicePkceClient } from "../common/oauth2-fixtures.mjs"; +import { getAdminToken, USERNAME, 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 DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; -const ENDPOINT = `${OPENAM_BASE}/oauth2/realms/${REALM}`; +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); @@ -54,7 +55,8 @@ 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 }, - headers: { iPlanetDirectoryPro: 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); @@ -90,16 +92,31 @@ async function expectTokensForUser(request, deviceCode, verifier) { test.describe("Device authorization PKCE over HTTP", () => { let sessionToken; + let adminToken; + let realm; test.beforeAll(async ({ request }) => { - const adminToken = await getAdminToken(request); + adminToken = await getAdminToken(request); expect(adminToken, "administrator authentication is required; security coverage must not silently skip").toBeTruthy(); - await ensureDevicePkceProvider(adminToken, request); - await ensureDevicePkceClient(adminToken, request, CLIENT_ID); - sessionToken = await getAuthToken(request, USERNAME, PASSWORD); + realm = `devicepkce${randomUUID().replaceAll("-", "")}`; + const fixture = await createDevicePkceRealm(adminToken, request, realm, CLIENT_ID); + 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); + }); + 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"); diff --git a/openam-distribution/openam-distribution-docker/Dockerfile b/openam-distribution/openam-distribution-docker/Dockerfile index 39609e7a83..49b911a5b1 100644 --- a/openam-distribution/openam-distribution-docker/Dockerfile +++ b/openam-distribution/openam-distribution-docker/Dockerfile @@ -35,9 +35,9 @@ RUN mkdir /usr/openam \ RUN if [ ! -f "$CATALINA_HOME/webapps/$OPENAM_PATH.war" ]; then echo file exists; fi -RUN apt-get -o Acquire::ForceIPv4=true -o Acquire::Retries=3 update \ - && DEBIAN_FRONTEND=noninteractive apt-get -o Acquire::ForceIPv4=true -o Acquire::Retries=3 upgrade -y \ - && apt-get -o Acquire::ForceIPv4=true -o Acquire::Retries=3 install -y --no-install-recommends curl unzip \ +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \ + && apt-get install -y --no-install-recommends curl unzip \ && if [ -z "$VERSION" ] ; then VERSION="$(curl -i -o - --silent https://api.github.com/repos/OpenIdentityPlatform/OpenAM/releases/latest | grep -m1 "\"name\"" | cut -d\" -f4)"; fi \ && if [ ! -f "$CATALINA_HOME/webapps/$OPENAM_PATH.war" ]; then echo file exists && curl -L https://github.com/OpenIdentityPlatform/OpenAM/releases/download/$VERSION/OpenAM-$VERSION.war --output $CATALINA_HOME/webapps/$OPENAM_PATH.war; fi \ && if [ ! -f "/usr/openam/ssoconfiguratortools.zip" ]; then curl -L https://github.com/OpenIdentityPlatform/OpenAM/releases/download/$VERSION/SSOConfiguratorTools-$VERSION.zip --output /usr/openam/ssoconfiguratortools.zip; fi \ @@ -51,7 +51,7 @@ RUN apt-get -o Acquire::ForceIPv4=true -o Acquire::Retries=3 update \ && useradd -m -r -u 1001 -g root $OPENAM_USER \ && install -d -o $OPENAM_USER $OPENAM_DATA_DIR \ && chown -R $OPENAM_USER:root $CATALINA_HOME \ - && apt-get -o Acquire::ForceIPv4=true -o Acquire::Retries=3 remove -y --purge unzip \ + && apt-get remove -y --purge unzip \ && rm -rf /var/lib/apt/lists/* \ && sed -i '/<\/Host>/i \ \ \ \ ' $CATALINA_HOME/conf/server.xml From 086a8737da27cc5d542d08874f046d91a5175bc8 Mon Sep 17 00:00:00 2001 From: yedmalaaravind Date: Thu, 12 Nov 2026 10:12:15 -0500 Subject: [PATCH 4/4] Fix device PKCE test session handling and isolate realm fixtures --- e2e/common/oauth2-fixtures.mjs | 21 +++++++++++++++++++-- e2e/oauth2/device-pkce-test.spec.mjs | 7 ++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/e2e/common/oauth2-fixtures.mjs b/e2e/common/oauth2-fixtures.mjs index b7e3470a01..c9eec6c100 100644 --- a/e2e/common/oauth2-fixtures.mjs +++ b/e2e/common/oauth2-fixtures.mjs @@ -215,7 +215,7 @@ 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) { +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); @@ -250,10 +250,27 @@ export async function createDevicePkceRealm(adminToken, request, realm, clientId 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) { +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), diff --git a/e2e/oauth2/device-pkce-test.spec.mjs b/e2e/oauth2/device-pkce-test.spec.mjs index 3d7595058c..413447c86d 100644 --- a/e2e/oauth2/device-pkce-test.spec.mjs +++ b/e2e/oauth2/device-pkce-test.spec.mjs @@ -16,12 +16,13 @@ import { randomUUID } from "node:crypto"; import { test, expect } from "@playwright/test"; -import { getAdminToken, USERNAME, PASSWORD } from "../common/openam-commons.mjs"; +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. @@ -99,7 +100,7 @@ test.describe("Device authorization PKCE over HTTP", () => { 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); + 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`, { @@ -114,7 +115,7 @@ test.describe("Device authorization PKCE over HTTP", () => { }); test.afterAll(async ({ request }) => { - if (adminToken && realm) await deleteDevicePkceRealm(adminToken, request, realm); + if (adminToken && realm) await deleteDevicePkceRealm(adminToken, request, realm, USERNAME); }); for (const method of ["bogus", "s256", "PLAIN", ""]) {