Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions e2e/common/oauth2-fixtures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
}

167 changes: 167 additions & 0 deletions e2e/oauth2/device-pkce-test.spec.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading