From 898cdd5decadc013a3d56289c9fc644fa7eb0563 Mon Sep 17 00:00:00 2001 From: maximthomas Date: Mon, 27 Jul 2026 10:15:06 +0300 Subject: [PATCH] Issue #1080 OAuth2 consent: serve the consent details as JSON 16.1.1 (CVE-2026-53660) replaced the `csrf` consent parameter with a random per-request token that is only minted while rendering the HTML consent page, so a client that posts the consent decision directly always gets HTTP 400. There was no supported way for it to obtain a token. Add a JSON representation of the consent details to GET /oauth2/authorize, selected with `Accept: application/json`. It carries the token along with the requested scopes and claims, so a non-browser client can post the decision back exactly as the consent form does. The strict per-request token is kept as it is; the legacy `csrf=` form is not restored. - negotiate strictly: JSON has to be preferred over HTML, so `*/*` and the library defaults that weight the two equally keep getting the consent page - refuse the JSON representation to a foreign Origin, since the OAuth2 endpoints sit behind a CORS filter that can be configured to echo the caller's Origin with credentials - report errors as JSON to JSON clients, and turn the login redirect into 401 login_required, which a non-browser client can act on - e2e coverage of the two-step flow, and correct the dev guide and admin guide, which still described `csrf` as a copy of the iPlanetDirectoryPro cookie --- e2e/common/openam-commons.mjs | 40 +++ e2e/oauth2/oauth2-consent-json-test.spec.mjs | 249 +++++++++++++ e2e/oauth2/oauth2-test.spec.mjs | 52 +-- .../asciidoc/admin-guide/chap-oauth2.adoc | 3 + .../asciidoc/dev-guide/chap-client-dev.adoc | 70 +++- .../oauth2/restlet/AuthorizeResource.java | 137 ++++++- .../restlet/ConsentRequiredResource.java | 83 +++++ .../oauth2/restlet/AuthorizeResourceTest.java | 339 +++++++++++++++++- .../restlet/ConsentRequiredResourceTest.java | 154 ++++++++ 9 files changed, 1061 insertions(+), 66 deletions(-) create mode 100644 e2e/oauth2/oauth2-consent-json-test.spec.mjs create mode 100644 openam-oauth2/src/test/java/org/forgerock/oauth2/restlet/ConsentRequiredResourceTest.java diff --git a/e2e/common/openam-commons.mjs b/e2e/common/openam-commons.mjs index 6f84272277..4895184581 100644 --- a/e2e/common/openam-commons.mjs +++ b/e2e/common/openam-commons.mjs @@ -25,6 +25,46 @@ export async function getAdminToken(request) { return getAuthToken(request, ADMIN_USER, ADMIN_PASS) } +/** + * Ensures the OAuth2 provider service exists in the realm, creating it with the given scopes if not. + * Safe to call from several spec files at once: a create that loses the race counts as success. + */ +export async function ensureOAuth2ServiceExists(adminToken, request, realm, scopes) { + const url = `${OPENAM_BASE}/json/realms/${realm}/realm-config/services/oauth-oidc`; + const headers = { + "iPlanetDirectoryPro": adminToken, + "Accept-API-Version": "protocol=1.0,resource=1.0", + }; + + const response = await request.get(url, { headers }); + if (response.ok()) { + console.log("OAuth2 service already exists"); + return; + } + if (response.status() !== 404) { + throw new Error(`Failed to check OAuth2 service: ${response.statusText()}`); + } + + const createResponse = await request.post(`${url}?_action=create`, { + headers: { ...headers, "Content-Type": "application/json" }, + data: { + advancedOAuth2Config: { + clientsCanSkipConsent: true, + supportedScopes: scopes, + defaultScopes: scopes, + }, + }, + }); + + if (createResponse.ok()) { + console.log("OAuth2 service created successfully"); + return; + } + if (!(await request.get(url, { headers })).ok()) { + throw new Error(`Failed to create OAuth2 service: ${createResponse.statusText()}`); + } +} + export async function getAuthToken(request, username, password) { const resp = await request.post(`${OPENAM_BASE}/json/authenticate`, { headers: { diff --git a/e2e/oauth2/oauth2-consent-json-test.spec.mjs b/e2e/oauth2/oauth2-consent-json-test.spec.mjs new file mode 100644 index 0000000000..9fad37c1dc --- /dev/null +++ b/e2e/oauth2/oauth2-consent-json-test.spec.mjs @@ -0,0 +1,249 @@ +/* + * 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 3A Systems, LLC. + */ + +/** + * Covers the JSON consent representation, which lets a client that cannot render the consent page + * obtain the anti-CSRF token it must echo back with the consent decision. + * + * Consent is only requested when the client does not imply it, so these tests use their own client + * with isConsentImplied=false. The implied-consent client is here to prove the JSON branch cannot + * hijack the ordinary redirect flow. + */ + +import { test, expect } from "@playwright/test"; +import { OPENAM_BASE, ensureOAuth2ServiceExists, getAdminToken, getAuthToken, PASSWORD, USERNAME } + from "../common/openam-commons.mjs"; + +const REALM = "root"; +const CONSENT_CLIENT_ID = "test_consent_client"; +const IMPLIED_CLIENT_ID = "test_implied_consent_client"; +const SCOPE = "profile"; +const REDIRECT_URI = "http://app.invalid/cb"; + +async function ensureClientExists(adminToken, request, clientId, consentImplied) { + const response = await request.get( + `${OPENAM_BASE}/json/realms/${REALM}/realm-config/agents/OAuth2Client/${clientId}`, + { + headers: { + "iPlanetDirectoryPro": adminToken, + "Accept-API-Version": "protocol=2.0,resource=1.0", + }, + } + ); + + if (response.status() !== 404) { + if (!response.ok()) { + throw new Error(`Failed to check OAuth2 client "${clientId}": ${response.statusText()}`); + } + console.log(`OAuth2 client "${clientId}" already exists`); + return; + } + + const createResponse = await request.put( + `${OPENAM_BASE}/json/realms/${REALM}/realm-config/agents/OAuth2Client/${clientId}`, + { + headers: { + "iPlanetDirectoryPro": adminToken, + "Content-Type": "application/json", + "Accept-API-Version": "protocol=2.0,resource=1.0", + }, + data: { + "com.forgerock.openam.oauth2provider.clientType": "Public", + "com.forgerock.openam.oauth2provider.redirectionURIs": [`[0]=${REDIRECT_URI}`], + "com.forgerock.openam.oauth2provider.scopes": [`[0]=${SCOPE}`], + "com.forgerock.openam.oauth2provider.defaultScopes": [`[0]=${SCOPE}`], + "com.forgerock.openam.oauth2provider.grantTypes": ["[0]=authorization_code"], + "com.forgerock.openam.oauth2provider.responseTypes": ["[0]=code"], + "com.forgerock.openam.oauth2provider.tokenEndPointAuthMethod": "none", + "isConsentImplied": consentImplied, + "sunIdentityServerDeviceStatus": "Active", + }, + } + ); + + if (!createResponse.ok()) { + throw new Error(`Failed to create OAuth2 client "${clientId}": ${createResponse.statusText()}`); + } + console.log(`OAuth2 client "${clientId}" created successfully`); +} + +function generateVerifier(length = 64) { + const array = new Uint32Array(length); + crypto.getRandomValues(array); + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + return Array.from(array, (x) => chars[x % chars.length]).join(""); +} + +async function generateChallenge(verifier) { + const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); + return btoa(String.fromCharCode(...new Uint8Array(hash))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +/** The authorization request parameters, shared by every test below. */ +async function authorizeParams(clientId, state) { + const verifier = generateVerifier(); + return { + verifier, + params: { + response_type: "code", + client_id: clientId, + redirect_uri: REDIRECT_URI, + scope: SCOPE, + state, + code_challenge: await generateChallenge(verifier), + code_challenge_method: "S256", + }, + }; +} + +test.beforeAll(async ({ request }) => { + const adminToken = await getAdminToken(request); + if (!adminToken) { + test.skip("Skipping: could not obtain an admin token"); + } + // The implied-consent client only skips consent when the provider allows it, so the service has to be + // in place before either client is created - this file cannot rely on running after its sibling. + await ensureOAuth2ServiceExists(adminToken, request, REALM, [SCOPE]); + await ensureClientExists(adminToken, request, CONSENT_CLIENT_ID, false); + await ensureClientExists(adminToken, request, IMPLIED_CLIENT_ID, true); +}); + +test.describe("OAuth2 JSON consent representation", () => { + + test("Should return the consent model as JSON and accept the decision posted back", async ({ request }) => { + const demoToken = await getAuthToken(request, USERNAME, PASSWORD); + const { verifier, params } = await authorizeParams(CONSENT_CLIENT_ID, "json-consent-state"); + + // 1. Ask for the consent details as JSON instead of the HTML consent page. + const consentResponse = await request.get(`${OPENAM_BASE}/oauth2/authorize`, { + headers: { "iPlanetDirectoryPro": demoToken, "Accept": "application/json" }, + params, + maxRedirects: 0, + }); + + expect(consentResponse.status()).toBe(200); + expect(consentResponse.headers()["content-type"]).toContain("application/json"); + + const consent = await consentResponse.json(); + console.log("Consent model:", consent); + expect(typeof consent.csrf).toBe("string"); + expect(consent.csrf.length).toBeGreaterThan(0); + expect(consent.target).toContain("authorize"); + expect(consent.client_id).toBe(CONSENT_CLIENT_ID); + expect(Array.isArray(consent.scopes)).toBe(true); + + // 2. Post the decision back, exactly as the consent form does. The target already carries the + // authorization request parameters, so only the decision and the token are added. + const decisionResponse = await request.post(new URL(consent.target, OPENAM_BASE).toString(), { + headers: { "iPlanetDirectoryPro": demoToken }, + form: { decision: "allow", csrf: consent.csrf }, + maxRedirects: 0, + }); + + expect(decisionResponse.status()).toBe(302); + const code = new URL(decisionResponse.headers()["location"]).searchParams.get("code"); + expect(code).toBeTruthy(); + + // 3. The code must be redeemable, proving the whole flow completed. + const tokenResponse = await request.post(`${OPENAM_BASE}/oauth2/access_token`, { + headers: { "Accept": "application/json" }, + form: { + grant_type: "authorization_code", + client_id: CONSENT_CLIENT_ID, + code, + redirect_uri: REDIRECT_URI, + code_verifier: verifier, + }, + }); + + expect(tokenResponse.ok()).toBeTruthy(); + expect(await tokenResponse.json()).toHaveProperty("access_token"); + }); + + test("Should reject a consent decision posted without the token", async ({ request }) => { + const demoToken = await getAuthToken(request, USERNAME, PASSWORD); + const { params } = await authorizeParams(CONSENT_CLIENT_ID, "missing-csrf-state"); + + const consentResponse = await request.get(`${OPENAM_BASE}/oauth2/authorize`, { + headers: { "iPlanetDirectoryPro": demoToken, "Accept": "application/json" }, + params, + maxRedirects: 0, + }); + expect(consentResponse.status()).toBe(200); + const consent = await consentResponse.json(); + + const decisionResponse = await request.post(new URL(consent.target, OPENAM_BASE).toString(), { + headers: { "iPlanetDirectoryPro": demoToken }, + form: { decision: "allow" }, + maxRedirects: 0, + }); + + expect(decisionResponse.status()).toBe(400); + }); + + test("Should not hand the token to another origin", async ({ request }) => { + const demoToken = await getAuthToken(request, USERNAME, PASSWORD); + const { params } = await authorizeParams(CONSENT_CLIENT_ID, "foreign-origin-state"); + + // A page on another origin must not be able to read the token and forge a consent decision, + // even where the deployment allows credentialed CORS on /oauth2. + const response = await request.get(`${OPENAM_BASE}/oauth2/authorize`, { + headers: { + "iPlanetDirectoryPro": demoToken, + "Accept": "application/json", + "Origin": "http://evil.invalid", + }, + params, + maxRedirects: 0, + }); + + expect(response.status()).toBe(403); + expect(await response.text()).not.toContain("csrf"); + }); + + test("Should still serve the HTML consent page when JSON is not asked for", async ({ request }) => { + const demoToken = await getAuthToken(request, USERNAME, PASSWORD); + const { params } = await authorizeParams(CONSENT_CLIENT_ID, "html-consent-state"); + + // curl's default Accept is */*, which must not be treated as a request for JSON. + const response = await request.get(`${OPENAM_BASE}/oauth2/authorize`, { + headers: { "iPlanetDirectoryPro": demoToken, "Accept": "*/*" }, + params, + maxRedirects: 0, + }); + + expect(response.status()).toBe(200); + expect(response.headers()["content-type"]).toContain("text/html"); + }); + + test("Should not change the flow for clients that imply consent", async ({ request }) => { + const demoToken = await getAuthToken(request, USERNAME, PASSWORD); + const { params } = await authorizeParams(IMPLIED_CLIENT_ID, "implied-consent-state"); + + // Consent is never requested here, so asking for JSON must make no difference at all. + const response = await request.get(`${OPENAM_BASE}/oauth2/authorize`, { + headers: { "iPlanetDirectoryPro": demoToken, "Accept": "application/json" }, + params, + maxRedirects: 0, + }); + + expect(response.status()).toBe(302); + expect(new URL(response.headers()["location"]).searchParams.get("code")).toBeTruthy(); + }); +}); diff --git a/e2e/oauth2/oauth2-test.spec.mjs b/e2e/oauth2/oauth2-test.spec.mjs index deedec5861..27c33602cb 100644 --- a/e2e/oauth2/oauth2-test.spec.mjs +++ b/e2e/oauth2/oauth2-test.spec.mjs @@ -15,59 +15,13 @@ */ import { test, expect } from "@playwright/test"; -import { OPENAM_BASE, getAdminToken, getAuthToken, PASSWORD, USERNAME } from "../common/openam-commons.mjs"; +import { OPENAM_BASE, ensureOAuth2ServiceExists, getAdminToken, getAuthToken, PASSWORD, USERNAME } + from "../common/openam-commons.mjs"; const REALM = "root"; const CLIENT_ID = "test_client_app"; const SCOPE="profile" const REDIRECT_URI="http://app.invalid/cb" -/** - * Ensures the OAuth2 service exists in the OpenAM instance. - * Creates it with default configuration if it doesn't exist. - */ -async function ensureOAuth2ServiceExists(adminToken, request) { - const response = await request.get(`${OPENAM_BASE}/json/realms/${REALM}/realm-config/services/oauth-oidc`, - { - headers: { - "iPlanetDirectoryPro": adminToken, - "Accept-API-Version": "protocol=1.0,resource=1.0", - }, - } - ); - - if (response.status() === 404) { - // OAuth2 service doesn't exist, create it - const createResponse = await request.post(`${OPENAM_BASE}/json/realms/${REALM}/realm-config/services/oauth-oidc?_action=create`, - { - headers: { - "iPlanetDirectoryPro": adminToken, - "Content-Type": "application/json", - "Accept-API-Version": "protocol=1.0,resource=1.0", - }, - data: { - advancedOAuth2Config: { - clientsCanSkipConsent: true, - supportedScopes: [SCOPE], - defaultScopes: [SCOPE], - }, - }, - } - ); - - if (!createResponse.ok()) { - throw new Error( - `Failed to create OAuth2 service: ${createResponse.statusText()}` - ); - } - console.log("OAuth2 service created successfully"); - } else if (!response.ok()) { - throw new Error( - `Failed to check OAuth2 service: ${createResponse.statusText()}` - ); - } else { - console.log("OAuth2 service already exists"); - } -} /** * Ensures an OAuth2 client application exists in the OpenAM instance. @@ -132,7 +86,7 @@ test.beforeAll(async ({ request }) => { test.skip("Skipping: ADMIN_TOKEN not set"); } - await ensureOAuth2ServiceExists(adminToken, request); + await ensureOAuth2ServiceExists(adminToken, request, REALM, [SCOPE]); await ensureOAuth2ClientExists(adminToken, request); }); diff --git a/openam-documentation/openam-doc-source/src/main/asciidoc/admin-guide/chap-oauth2.adoc b/openam-documentation/openam-doc-source/src/main/asciidoc/admin-guide/chap-oauth2.adoc index 495b2b60f7..a376ea3972 100644 --- a/openam-documentation/openam-doc-source/src/main/asciidoc/admin-guide/chap-oauth2.adoc +++ b/openam-documentation/openam-doc-source/src/main/asciidoc/admin-guide/chap-oauth2.adoc @@ -236,6 +236,9 @@ A dedicated, random anti-CSRF token bound to the authorization request and gener + Because the token is independent of the SSO cookie, the SSO cookie can be marked `HttpOnly`. For stateful sessions the token is stored as a protected session property; for stateless sessions a double-submit cookie (`__Host-oauth2_csrf`, or `oauth2_csrf` on plain HTTP) is used. ++ +Clients that do not render the consent page obtain the token by sending `Accept: application/json` on the `GET /oauth2/authorize` request: when consent is required OpenAM returns the consent details, including `csrf`, as JSON instead of the consent page. A wildcard such as `*/*` is not treated as a request for JSON, so existing clients are unaffected. See xref:../dev-guide/chap-client-dev.adoc#rest-api-oauth2-client-endpoints["OAuth 2.0 Client and Resource Server Endpoints"] in the __Developer's Guide__. + ==== Example: diff --git a/openam-documentation/openam-doc-source/src/main/asciidoc/dev-guide/chap-client-dev.adoc b/openam-documentation/openam-doc-source/src/main/asciidoc/dev-guide/chap-client-dev.adoc index f32b150da9..ee746108af 100644 --- a/openam-documentation/openam-doc-source/src/main/asciidoc/dev-guide/chap-client-dev.adoc +++ b/openam-documentation/openam-doc-source/src/main/asciidoc/dev-guide/chap-client-dev.adoc @@ -5105,31 +5105,79 @@ You must provide the __Saved Consent Attribute Name__ property with a profile at For more information on setting this property in the OAuth2 Provider service, see xref:../reference/chap-config-ref.adoc#oauth2-provider-configuration["OAuth2 Provider"] in the __Reference__. `csrf`:: -Duplicates the contents of the `iPlanetDirectoryPro` cookie, which contains the SSO token of the resource owner giving consent. +A dedicated, random anti-CSRF token generated by OpenAM for this authorization request. You must send back the value OpenAM issued; it is not derived from the `iPlanetDirectoryPro` cookie. -Duplicating the cookie value helps prevent against Cross-Site Request Forgery (CSRF) attacks. ++ +Browser-based clients read it from the consent page, where OpenAM renders it as `pageData.oauth2Data.csrf`. Clients that do not render the consent page obtain it by requesting the consent details as JSON, as shown below. + ++ +IMPORTANT: Send `csrf` in the request body rather than the query string. Query parameters take priority over body parameters, and they are recorded in access logs and `Referer` headers. ==== Example: +Clients that cannot render the consent page request the consent details as JSON. Set the `Accept` header to `application/json`; OpenAM returns the consent details instead of the consent page whenever consent is required. JSON must be preferred over HTML, so a wildcard such as `*/*` returns the HTML consent page as before, and so does a header that weights the two equally, such as the `application/json, text/plain, */*` that some HTTP client libraries send by default. Send `Accept: application/json` on its own to be unambiguous. + +[source, console] +---- +$ curl \ + --cookie-jar jar --cookie jar \ + --header "Accept: application/json" \ + --header "Cookie: iPlanetDirectoryPro=AQIC5w...*" \ + "https://openam.example.com:8443/openam/oauth2/authorize?response_type=code&client_id=myClient"\ + "&realm=/&scope=profile&redirect_uri=http://www.example.net" +---- + +[source, json] +---- +{ + "csrf": "9Xy3...random-token...aQ", + "target": "/openam/oauth2/authorize?response_type=code&client_id=myClient&...", + "client_id": "myClient", + "client_name": "My Client", + "client_description": "An example client", + "user_name": "demo", + "user_code": null, + "save_consent_enabled": true, + "scopes": [ + { + "name": "profile", + "description": "Your personal information", + "claims": [ + { "name": "given_name", "description": "First name", "value": "Demo" } + ] + } + ], + "claims": [] +} +---- + +Post the decision back to `target`, which is relative to the deployment and already carries the authorization request parameters: + [source, console] ---- $ curl \ --request POST \ + --cookie-jar jar --cookie jar \ --header "Content-Type: application/x-www-form-urlencoded" \ - --Cookie "iPlanetDirectoryPro=AQIC5w...*" \ - --data "redirect_uri=http://www.example.net" \ - --data "scope=profile" \ - --data "response_type=code" \ - --data "client_id=myClient" \ - --data "csrf=AQIC5w...*" \ + --header "Cookie: iPlanetDirectoryPro=AQIC5w...*" \ + --data "csrf=9Xy3...random-token...aQ" \ --data "decision=allow" \ --data "save_consent=on" \ "https://openam.example.com:8443/openam/oauth2/authorize?response_type=code&client_id=myClient"\ "&realm=/&scope=profile&redirect_uri=http://www.example.net" ---- +[NOTE] +==== +On deployments that use client-side (stateless) sessions the token is carried in an `oauth2_csrf` cookie, so the client must keep a cookie jar, as `--cookie-jar` does above. On server-side (stateful) sessions the token value alone is sufficient. + +OpenAM refuses to return the consent details to a request that carries an `Origin` header for a different origin, so that another site cannot read the token and forge a consent decision. + +The JSON consent representation is an OpenAM extension intended for server-side automation, testing and trusted first-party clients. Native applications should use an external user agent, as required by link:https://www.rfc-editor.org/rfc/rfc8252[RFC 8252, window=\_blank]; for devices and backchannel flows prefer the device flow or CIBA. +==== + You must specify the realm if the OpenAM OAuth 2.0 provider is configured for a subrealm rather than the top-level realm. For example, if the OAuth 2.0 provider is configured for the `/customers` realm, then use `/oauth2/customers/authorize`. @@ -5473,9 +5521,9 @@ Must be `token`. To allow client access, specify `allow`. Any other value will deny consent. `csrf`:: -Duplicates the contents of the `iPlanetDirectoryPro` cookie, which contains the SSO token of the user granting access. +A dedicated, random anti-CSRF token generated by OpenAM for this authorization request. You must send back the value OpenAM issued; it is not derived from the `iPlanetDirectoryPro` cookie. + -Duplicating the cookie value helps prevent against Cross-Site Request Forgery (CSRF) attacks. +OpenAM renders the token into the user code confirmation page as `pageData.oauth2Data.csrf`. Unlike the `/oauth2/authorize` endpoint, this endpoint does not offer a JSON consent representation, so the token has to be read from that page. -- + @@ -5493,7 +5541,7 @@ $ curl \ --data response_type=token \ --data client_id=myDeviceAgentProfile \ --data decision=allow \ - --data csrf=AQIC5... \ + --data csrf=9Xy3...random-token...aQ \ http://openam.example.com:8080/openam/oauth2/device/user?user_code=VAL12e0v ---- + diff --git a/openam-oauth2/src/main/java/org/forgerock/oauth2/restlet/AuthorizeResource.java b/openam-oauth2/src/main/java/org/forgerock/oauth2/restlet/AuthorizeResource.java index cada037b40..6eed293047 100644 --- a/openam-oauth2/src/main/java/org/forgerock/oauth2/restlet/AuthorizeResource.java +++ b/openam-oauth2/src/main/java/org/forgerock/oauth2/restlet/AuthorizeResource.java @@ -19,6 +19,10 @@ import jakarta.inject.Inject; import jakarta.inject.Named; +import jakarta.servlet.http.HttpServletRequest; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; import java.util.Set; import org.forgerock.oauth2.core.AuthorizationService; @@ -36,8 +40,14 @@ import org.forgerock.oauth2.core.exceptions.RedirectUriMismatchException; import org.forgerock.oauth2.core.exceptions.ResourceOwnerAuthenticationRequired; import org.forgerock.oauth2.core.exceptions.ResourceOwnerConsentRequired; +import org.forgerock.openam.rest.jakarta.servlet.ServletUtils; +import org.forgerock.openam.rest.representations.JacksonRepresentationFactory; import org.forgerock.openam.services.baseurl.BaseURLProviderFactory; +import org.forgerock.openam.utils.StringUtils; import org.forgerock.openam.xui.XUIState; +import org.restlet.data.Dimension; +import org.restlet.data.MediaType; +import org.restlet.data.Preference; import org.restlet.representation.Representation; import org.restlet.resource.Get; import org.restlet.resource.Post; @@ -60,6 +70,7 @@ public class AuthorizeResource extends ConsentRequiredResource { private final OAuth2Representation representation; private final Set hooks; private final RedirectUriResolver redirectUriResolver; + private final JacksonRepresentationFactory jacksonRepresentationFactory; /** @@ -75,7 +86,7 @@ public AuthorizeResource(OAuth2RequestFactory requestFactory, AuthorizationServi ExceptionHandler exceptionHandler, OAuth2Representation representation, Set hooks, XUIState xuiState, @Named("OAuth2Router") Router router, BaseURLProviderFactory baseURLProviderFactory, RedirectUriResolver redirectUriResolver, ResourceOwnerSessionValidator resourceOwnerSessionValidator, - CsrfProtection csrfProtection) { + CsrfProtection csrfProtection, JacksonRepresentationFactory jacksonRepresentationFactory) { super(router, baseURLProviderFactory, xuiState, resourceOwnerSessionValidator, csrfProtection); this.requestFactory = requestFactory; this.authorizationService = authorizationService; @@ -83,6 +94,7 @@ public AuthorizeResource(OAuth2RequestFactory requestFactory, AuthorizationServi this.representation = representation; this.hooks = hooks; this.redirectUriResolver = redirectUriResolver; + this.jacksonRepresentationFactory = jacksonRepresentationFactory; } /** @@ -125,9 +137,17 @@ public Representation authorize() throws OAuth2RestletException { throw new OAuth2RestletException(400, "invalid_request", e.getMessage(), request.getParameter("redirect_uri"), request.getParameter("state")); } catch (ResourceOwnerAuthenticationRequired e) { + if (wantsJson()) { + // A redirect to the login page is not actionable by a non-browser client. + throw new OAuth2RestletException(401, "login_required", e.getMessage(), + request.getParameter("state")); + } throw new OAuth2RestletException(e.getStatusCode(), e.getError(), e.getMessage(), e.getRedirectUri().toString(), null); } catch (ResourceOwnerConsentRequired e) { + if (wantsJson()) { + return consentRepresentation(e, request); + } return representation.getRepresentation(getContext(), request, "authorize.ftl", getDataModel(e, request)); } catch (InvalidClientException e) { @@ -206,13 +226,128 @@ public Representation authorize(Representation entity) throws OAuth2RestletExcep } } + /** + * Whether this request asked for the JSON consent representation rather than the HTML consent page. + * + * @return {@code true} if JSON was explicitly preferred. + */ + private boolean wantsJson() { + return prefersJson(getRequest().getClientInfo().getAcceptedMediaTypes()); + } + + /** + * Builds the JSON consent representation, refusing to hand the CSRF token to another origin. + * + *

The OAuth2 endpoints are covered by the CORS filter, which echoes the caller's {@code Origin} with + * {@code Access-Control-Allow-Credentials} when so configured. Without this check a deployment that + * allows credentialed CORS would let any page read the token and then forge a consent decision.

+ * + * @param consentRequired The details for requesting consent. + * @param request The OAuth2 request. + * @return The JSON consent representation. + * @throws OAuth2RestletException If the request came from a foreign origin. + */ + private Representation consentRepresentation(ResourceOwnerConsentRequired consentRequired, OAuth2Request request) + throws OAuth2RestletException { + if (isForeignOrigin(request)) { + logger.debug("Refusing to serve the JSON consent representation to a foreign origin"); + throw new OAuth2RestletException(403, "access_denied", "Cross-origin consent requests are not allowed", + request.getParameter("state")); + } + // The representation now depends on the Accept header; Restlet renders this as "Vary: Accept". + getResponse().getDimensions().add(Dimension.MEDIA_TYPE); + return jacksonRepresentationFactory.create(getConsentModel(consentRequired, request).asMap()); + } + + /** + * Whether the request carries an {@code Origin} that is not this deployment's own. Requests without the + * header - non-browser clients, and top-level navigations - are not cross-origin. + * + *

Extracted as a seam so the branch can be unit-tested without static mocking.

+ * + * @param request The OAuth2 request. + * @return {@code true} if the request originates from another origin. + */ + protected boolean isForeignOrigin(OAuth2Request request) { + final HttpServletRequest servletRequest = ServletUtils.getRequest(getRequest()); + final String origin = servletRequest == null ? null : servletRequest.getHeader("Origin"); + if (StringUtils.isBlank(origin)) { + return false; + } + return !sameOrigin(origin, baseURLProviderFactory.get(request.getParameter("realm")) + .getRootURL(servletRequest)); + } + + /** + * Compares an {@code Origin} header against a deployment URL on scheme, host and port. + * + * @param origin The value of the {@code Origin} header. + * @param baseUrl The deployment's root URL. + * @return {@code true} if both denote the same origin. + */ + static boolean sameOrigin(String origin, String baseUrl) { + try { + final URI actual = new URI(origin); + final URI expected = new URI(baseUrl); + return actual.getScheme() != null && actual.getScheme().equalsIgnoreCase(expected.getScheme()) + && actual.getHost() != null && actual.getHost().equalsIgnoreCase(expected.getHost()) + && effectivePort(actual) == effectivePort(expected); + } catch (URISyntaxException e) { + return false; + } + } + + private static int effectivePort(URI uri) { + if (uri.getPort() != -1) { + return uri.getPort(); + } + return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80; + } + + /** + * Decides whether JSON was explicitly preferred over HTML. Only an exact {@code application/json} entry + * counts, and it has to outrank everything that can carry HTML. A client that expresses no preference + * between the two - a bare {@code */*} (curl's default), or a library default such as + * {@code application/json, text/plain, */*} - keeps getting the consent page, so existing clients + * that scrape it are unaffected. + * + * @param accepted The accepted media types, in any order. + * @return {@code true} if JSON was named explicitly and is preferred over HTML. + */ + static boolean prefersJson(List> accepted) { + float json = 0f; + float html = 0f; + for (Preference preference : accepted) { + final MediaType mediaType = preference.getMetadata(); + if (MediaType.APPLICATION_JSON.equals(mediaType, true)) { + json = Math.max(json, preference.getQuality()); + } else if (mediaType.includes(MediaType.TEXT_HTML, true)) { + html = Math.max(html, preference.getQuality()); + } + } + return json > 0f && json > html; + } + /** * Handles any exception that is thrown when processing a OAuth2 authorization request. * + *

Errors that carry a redirect uri keep being reported by redirecting to the client, as RFC 6749 + * 4.1.2.1 requires. The rest are rendered as the error page, or as JSON if that is what was asked for.

+ * * @param throwable The throwable. */ @Override protected void doCatch(Throwable throwable) { + if (wantsJson() && !hasRedirectUri(throwable)) { + exceptionHandler.handle(throwable, getResponse()); + return; + } exceptionHandler.handle(throwable, getContext(), getRequest(), getResponse()); } + + private static boolean hasRedirectUri(Throwable throwable) { + final Throwable cause = throwable instanceof OAuth2RestletException ? throwable : throwable.getCause(); + return cause instanceof OAuth2RestletException + && StringUtils.isNotBlank(((OAuth2RestletException) cause).getRedirectUri()); + } } diff --git a/openam-oauth2/src/main/java/org/forgerock/oauth2/restlet/ConsentRequiredResource.java b/openam-oauth2/src/main/java/org/forgerock/oauth2/restlet/ConsentRequiredResource.java index 31dbc838d7..8535af65b4 100644 --- a/openam-oauth2/src/main/java/org/forgerock/oauth2/restlet/ConsentRequiredResource.java +++ b/openam-oauth2/src/main/java/org/forgerock/oauth2/restlet/ConsentRequiredResource.java @@ -107,6 +107,89 @@ protected Map getDataModel(ResourceOwnerConsentRequired consentR return data; } + /** + * Gets the consent details as JSON, for clients that drive the consent flow programmatically instead of + * rendering the consent page. Carries the same information as {@link #getDataModel}, but unencoded and + * without the presentation-specific fields. + * + *

Mints the CSRF token, so it must be called at most once per request - exactly like + * {@link #getDataModel}, and on a mutually exclusive branch from it.

+ * + * @param consentRequired The details for requesting consent. + * @param request The OAuth2 request. + * @return The consent model. + */ + protected JsonValue getConsentModel(ResourceOwnerConsentRequired consentRequired, OAuth2Request request) { + final Reference resRef = getRequest().getResourceRef(); + final String query = resRef.getQuery(); + // Relative, exactly like the consent form's action: the caller resolves it against the URL it just + // requested, which is what works behind a reverse proxy. + final String target = StringUtils.isBlank(query) ? resRef.getPath() : resRef.getPath() + "?" + query; + + final JsonValue model = json(object( + field("csrf", csrfProtection.createCsrfToken(request)), + field("target", target), + field("client_id", request.getParameter(OAuth2Constants.Params.CLIENT_ID)), + field("client_name", consentRequired.getClientName()), + field("client_description", consentRequired.getClientDescription()), + field("user_name", consentRequired.getUserDisplayName()), + field("user_code", request.getParameter(OAuth2Constants.DeviceCode.USER_CODE)), + field("save_consent_enabled", consentRequired.isSaveConsentEnabled()))); + model.asMap().putAll(rawScopesAndClaims(consentRequired).asMap()); + return model; + } + + /** + * Builds the requested scopes and claims for the JSON consent representation. Unlike + * {@link #addDisplayScopesAndClaims} the values are left raw: HTML encoding is a concern of the consent + * page, and would corrupt the values for an API client. + * + *

Claims that belong to a requested scope are nested under it; the remainder are listed separately. + * Values are rendered as text, matching what the consent page displays.

+ * + * @param consentRequired The details for requesting consent. + * @return An object holding a {@code scopes} and a {@code claims} array. + */ + static JsonValue rawScopesAndClaims(ResourceOwnerConsentRequired consentRequired) { + final JsonValue scopes = json(array()); + final Set consumedClaims = new HashSet<>(); + final Map> compositeScopes = consentRequired.getClaims().getCompositeScopes(); + final Map claimDescriptions = consentRequired.getClaimDescriptions(); + final Map claimValues = new LinkedHashMap<>(consentRequired.getClaims().getValues()); + + for (Map.Entry scope : consentRequired.getScopeDescriptions().entrySet()) { + final JsonValue claims = json(array()); + final List scopeClaims = compositeScopes.get(scope.getKey()); + if (scopeClaims != null) { + for (String claim : scopeClaims) { + if (claimValues.get(claim) != null) { + claims.add(rawClaim(claim, claimDescriptions, claimValues).getObject()); + consumedClaims.add(claim); + } + } + } + scopes.add(object( + field("name", scope.getKey()), + field("description", scope.getValue()), + field("claims", claims.getObject()))); + } + + claimValues.keySet().removeAll(consumedClaims); + final JsonValue remainingClaims = json(array()); + for (String claim : claimValues.keySet()) { + remainingClaims.add(rawClaim(claim, claimDescriptions, claimValues).getObject()); + } + + return json(object(field("scopes", scopes.getObject()), field("claims", remainingClaims.getObject()))); + } + + private static JsonValue rawClaim(String claim, Map descriptions, Map values) { + return json(object( + field("name", claim), + field("description", descriptions.get(claim)), + field("value", values.get(claim).toString()))); + } + private void addDisplayScopesAndClaims(ResourceOwnerConsentRequired consentRequired, Map data) { JsonValue scopes = json(array()); List scopeNames = new ArrayList<>(); diff --git a/openam-oauth2/src/test/java/org/forgerock/oauth2/restlet/AuthorizeResourceTest.java b/openam-oauth2/src/test/java/org/forgerock/oauth2/restlet/AuthorizeResourceTest.java index 1105e870a8..92c115f59f 100644 --- a/openam-oauth2/src/test/java/org/forgerock/oauth2/restlet/AuthorizeResourceTest.java +++ b/openam-oauth2/src/test/java/org/forgerock/oauth2/restlet/AuthorizeResourceTest.java @@ -17,9 +17,16 @@ package org.forgerock.oauth2.restlet; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.failBecauseExceptionWasNotThrown; +import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.Mockito.*; +import java.net.URI; +import java.util.Arrays; import java.util.Collections; +import java.util.List; +import java.util.Map; import org.forgerock.oauth2.core.AuthorizationService; import org.forgerock.oauth2.core.AuthorizationToken; @@ -28,13 +35,23 @@ import org.forgerock.oauth2.core.OAuth2RequestFactory; import org.forgerock.oauth2.core.RedirectUriResolver; import org.forgerock.oauth2.core.ResourceOwnerSessionValidator; +import org.forgerock.oauth2.core.UserInfoClaims; +import org.forgerock.oauth2.core.exceptions.ResourceOwnerAuthenticationRequired; +import org.forgerock.oauth2.core.exceptions.ResourceOwnerConsentRequired; +import org.forgerock.openam.rest.representations.JacksonRepresentationFactory; import org.forgerock.openam.utils.CollectionUtils; import org.forgerock.openam.xui.XUIState; +import org.mockito.ArgumentCaptor; import org.restlet.Request; import org.restlet.Response; +import org.restlet.data.ClientInfo; +import org.restlet.data.MediaType; +import org.restlet.data.Preference; +import org.restlet.data.Reference; import org.restlet.representation.EmptyRepresentation; import org.restlet.routing.Router; import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; import org.testng.annotations.Test; public class AuthorizeResourceTest { @@ -48,10 +65,15 @@ public class AuthorizeResourceTest { private AuthorizationToken authToken = new AuthorizationToken(Collections.singletonMap("fred", "fred"), false); private XUIState xuiState; private RedirectUriResolver redirectUriResolver; + private OAuth2Representation representation; + private CsrfProtection csrfProtection; + private ExceptionHandler exceptionHandler; + private JacksonRepresentationFactory jacksonRepresentationFactory; @BeforeMethod public void setup() throws Exception { - OAuth2Representation representation = mock(OAuth2Representation.class); + representation = mock(OAuth2Representation.class); + jacksonRepresentationFactory = mock(JacksonRepresentationFactory.class); OAuth2RequestFactory oauth2RequestFactory = mock(OAuth2RequestFactory.class); o2request = mock(OAuth2Request.class); request = mock(Request.class); @@ -61,13 +83,15 @@ public void setup() throws Exception { xuiState = mock(XUIState.class); redirectUriResolver = mock(RedirectUriResolver.class); ResourceOwnerSessionValidator resourceOwnerSessionValidator = mock(ResourceOwnerSessionValidator.class); - CsrfProtection csrfProtection = mock(CsrfProtection.class); + csrfProtection = mock(CsrfProtection.class); when(oauth2RequestFactory.create(request)).thenReturn(o2request); - resource = new AuthorizeResource(oauth2RequestFactory, service, null, representation, - CollectionUtils.asSet(hook), xuiState, mock(Router.class), null, redirectUriResolver, - resourceOwnerSessionValidator, csrfProtection); + exceptionHandler = mock(ExceptionHandler.class); + + resource = new AuthorizeResource(oauth2RequestFactory, service, exceptionHandler, representation, + CollectionUtils.asSet(hook), xuiState, mock(Router.class), null, redirectUriResolver, + resourceOwnerSessionValidator, csrfProtection, jacksonRepresentationFactory); resource = spy(resource); doReturn(request).when(resource).getRequest(); doReturn(response).when(resource).getResponse(); @@ -99,4 +123,309 @@ public void shouldCallHooksInPost() throws Exception { verify(hook).afterAuthorizeSuccess(o2request, request, response); } + private static Preference pref(MediaType type, float quality) { + return new Preference<>(type, quality); + } + + @SafeVarargs + private static List> accepts(Preference... preferences) { + return Arrays.asList(preferences); + } + + @DataProvider(name = "acceptedMediaTypes") + public Object[][] acceptedMediaTypes() { + return new Object[][]{ + // A bare */* is curl's default. Treating it as a JSON request would silently switch the + // response format for every existing script that scrapes the consent page. + {"*/*", accepts(pref(MediaType.ALL, 1f)), false}, + {"no Accept header", accepts(), false}, + // application/* includes JSON without naming it - same trap as */*. + {"application/*", accepts(pref(MediaType.valueOf("application/*"), 1f)), false}, + {"text/html", accepts(pref(MediaType.TEXT_HTML, 1f)), false}, + {"text/*", accepts(pref(MediaType.valueOf("text/*"), 1f)), false}, + {"browser default", accepts(pref(MediaType.TEXT_HTML, 1f), pref(MediaType.ALL, 0.8f)), false}, + {"application/json", accepts(pref(MediaType.APPLICATION_JSON, 1f)), true}, + {"json wins on q", accepts(pref(MediaType.APPLICATION_JSON, 0.9f), + pref(MediaType.TEXT_HTML, 0.8f)), true}, + {"html wins on q", accepts(pref(MediaType.TEXT_HTML, 0.9f), + pref(MediaType.APPLICATION_JSON, 0.8f)), false}, + // A tie expresses no preference, so the existing behaviour wins. + {"explicit json ties with */*", accepts(pref(MediaType.APPLICATION_JSON, 1f), + pref(MediaType.ALL, 1f)), false}, + // The stock Accept of axios and similar libraries: JSON is named, but only alongside an + // equally weighted wildcard. Scripts that scrape the consent page must keep getting it. + {"axios default", accepts(pref(MediaType.APPLICATION_JSON, 1f), + pref(MediaType.TEXT_PLAIN, 1f), pref(MediaType.ALL, 1f)), false}, + // jQuery's $.getJSON deprioritises the wildcard, which is a real preference for JSON. + {"jQuery getJSON", accepts(pref(MediaType.APPLICATION_JSON, 1f), + pref(MediaType.valueOf("text/javascript"), 1f), pref(MediaType.ALL, 0.01f)), true}, + // Spring's RestTemplate names no HTML-capable type at all. + {"RestTemplate default", accepts(pref(MediaType.APPLICATION_JSON, 1f), + pref(MediaType.valueOf("application/*+json"), 1f)), true}, + {"json explicitly unacceptable", accepts(pref(MediaType.APPLICATION_JSON, 0f)), false}, + {"json with charset parameter", accepts(pref( + MediaType.valueOf("application/json;charset=UTF-8"), 1f)), true}, + }; + } + + @Test(dataProvider = "acceptedMediaTypes") + public void shouldServeJsonOnlyWhenExplicitlyPreferred(String label, List> accepted, + boolean expected) { + assertThat(AuthorizeResource.prefersJson(accepted)).as(label).isEqualTo(expected); + } + + @DataProvider(name = "origins") + public Object[][] origins() { + return new Object[][]{ + {"identical", "https://openam.example.com:8443", "https://openam.example.com:8443/openam", true}, + {"implied https port", "https://openam.example.com", "https://openam.example.com:443/openam", true}, + {"implied http port", "http://openam.example.com:80", "http://openam.example.com/openam", true}, + {"host case differs", "https://OpenAM.Example.COM", "https://openam.example.com/openam", true}, + {"different host", "https://evil.invalid", "https://openam.example.com/openam", false}, + {"different scheme", "http://openam.example.com", "https://openam.example.com/openam", false}, + {"different port", "https://openam.example.com:9443", "https://openam.example.com:8443/openam", + false}, + // Sandboxed iframes and some redirects send the literal string "null". + {"opaque origin", "null", "https://openam.example.com/openam", false}, + {"unparseable", "not a url", "https://openam.example.com/openam", false}, + {"sibling subdomain", "https://evil.example.com", "https://openam.example.com/openam", false}, + }; + } + + @Test(dataProvider = "origins") + public void shouldRecogniseOnlyItsOwnOrigin(String label, String origin, String rootUrl, boolean expected) { + assertThat(AuthorizeResource.sameOrigin(origin, rootUrl)).as(label).isEqualTo(expected); + } + + private void givenConsentIsRequired() throws Exception { + when(service.authorize(o2request)).thenThrow(new ResourceOwnerConsentRequired("client", "description", + Collections.emptyMap(), Collections.emptyMap(), + new UserInfoClaims(Collections.emptyMap(), + Collections.>emptyMap()), "Demo User", true)); + when(request.getResourceRef()) + .thenReturn(new Reference("https://openam.example.com/openam/oauth2/authorize?client_id=x")); + } + + private void givenClientAccepts(MediaType mediaType) { + ClientInfo clientInfo = new ClientInfo(); + clientInfo.getAcceptedMediaTypes().add(new Preference<>(mediaType, 1f)); + when(request.getClientInfo()).thenReturn(clientInfo); + } + + @Test + public void shouldServeJsonConsentModelWhenRequested() throws Exception { + //given + givenConsentIsRequired(); + givenClientAccepts(MediaType.APPLICATION_JSON); + doReturn(false).when(resource).isForeignOrigin(o2request); + when(csrfProtection.createCsrfToken(o2request)).thenReturn("the-token"); + + //when + resource.authorize(); + + //then + Map model = captureConsentModel(); + assertThat(model).containsEntry("csrf", "the-token") + .containsEntry("target", "/openam/oauth2/authorize?client_id=x") + .containsEntry("client_name", "client") + .containsEntry("save_consent_enabled", true); + verify(representation, never()).getRepresentation(any(), any(OAuth2Request.class), anyString(), anyMap()); + } + + @SuppressWarnings("unchecked") + private Map captureConsentModel() { + ArgumentCaptor model = ArgumentCaptor.forClass(Map.class); + verify(jacksonRepresentationFactory).create(model.capture()); + return model.getValue(); + } + + @Test + public void shouldSerialiseNestedScopesAndClaims() throws Exception { + //given a scope carrying a claim, and a claim belonging to no scope + when(service.authorize(o2request)).thenThrow(new ResourceOwnerConsentRequired("client", "description", + Collections.singletonMap("profile", "Your personal information"), + Collections.singletonMap("given_name", "First name"), + new UserInfoClaims(Collections.singletonMap("given_name", "Demo"), + Collections.singletonMap("profile", Collections.singletonList("given_name"))), + "Demo User", true)); + when(request.getResourceRef()).thenReturn(new Reference("https://openam.example.com/openam/oauth2/authorize")); + givenClientAccepts(MediaType.APPLICATION_JSON); + doReturn(false).when(resource).isForeignOrigin(o2request); + + //when + resource.authorize(); + + //then the model handed to Jackson nests three levels deep + Map model = captureConsentModel(); + List> scopes = (List>) model.get("scopes"); + assertThat(scopes).hasSize(1); + assertThat(scopes.get(0)).containsEntry("name", "profile") + .containsEntry("description", "Your personal information"); + List> nested = (List>) scopes.get(0).get("claims"); + assertThat(nested).hasSize(1); + assertThat(nested.get(0)).containsEntry("name", "given_name").containsEntry("value", "Demo"); + assertThat((List) model.get("claims")).isEmpty(); + } + + @Test + public void shouldCarryAbsentFieldsAsNull() throws Exception { + //given no device flow, so there is no user code + givenConsentIsRequired(); + givenClientAccepts(MediaType.APPLICATION_JSON); + doReturn(false).when(resource).isForeignOrigin(o2request); + + //when + resource.authorize(); + + //then the key is present with a null value rather than dropped + Map model = captureConsentModel(); + assertThat(model).containsKey("user_code"); + assertThat(model.get("user_code")).isNull(); + } + + @Test + public void shouldRefuseJsonConsentModelToForeignOriginWithoutMintingAToken() throws Exception { + //given + givenConsentIsRequired(); + givenClientAccepts(MediaType.APPLICATION_JSON); + doReturn(true).when(resource).isForeignOrigin(o2request); + + //when + try { + resource.authorize(); + failBecauseExceptionWasNotThrown(OAuth2RestletException.class); + } catch (OAuth2RestletException e) { + //then + assertThat(e.getStatus().getCode()).isEqualTo(403); + } + // The guard must run before the token is minted, otherwise it would clobber a legitimate one. + verify(csrfProtection, never()).createCsrfToken(any(OAuth2Request.class)); + verify(jacksonRepresentationFactory, never()).create(any()); + } + + @Test + public void shouldReportAuthenticationRequiredAsLoginRequiredForJsonClients() throws Exception { + //given + givenClientAccepts(MediaType.APPLICATION_JSON); + when(service.authorize(o2request)).thenThrow(new ResourceOwnerAuthenticationRequired( + new URI("https://openam.example.com/openam/XUI/#login"))); + + //when + try { + resource.authorize(); + failBecauseExceptionWasNotThrown(OAuth2RestletException.class); + } catch (OAuth2RestletException e) { + //then a redirect to the login page is useless to a non-browser client + assertThat(e.getStatus().getCode()).isEqualTo(401); + assertThat(e.getError()).isEqualTo("login_required"); + assertThat(e.getRedirectUri()).isNull(); + } + } + + @Test + public void shouldStillRedirectToLoginForBrowsers() throws Exception { + //given + givenClientAccepts(MediaType.TEXT_HTML); + when(service.authorize(o2request)).thenThrow(new ResourceOwnerAuthenticationRequired( + new URI("https://openam.example.com/openam/XUI/#login"))); + + //when + try { + resource.authorize(); + failBecauseExceptionWasNotThrown(OAuth2RestletException.class); + } catch (OAuth2RestletException e) { + //then + assertThat(e.getRedirectUri()).isEqualTo("https://openam.example.com/openam/XUI/#login"); + } + } + + @Test + public void shouldRenderErrorsAsJsonForJsonClients() { + //given + givenClientAccepts(MediaType.APPLICATION_JSON); + Throwable failure = new OAuth2RestletException(400, "invalid_scope", "Unknown scope", null); + + //when + resource.doCatch(failure); + + //then + verify(exceptionHandler).handle(failure, response); + verify(exceptionHandler, never()).handle(any(Throwable.class), any(), any(Request.class), + any(Response.class)); + } + + @Test + public void shouldRenderErrorsAsHtmlForBrowsers() { + //given + givenClientAccepts(MediaType.TEXT_HTML); + Throwable failure = new OAuth2RestletException(400, "invalid_scope", "Unknown scope", null); + + //when + resource.doCatch(failure); + + //then + verify(exceptionHandler).handle(eq(failure), any(), eq(request), eq(response)); + verify(exceptionHandler, never()).handle(any(Throwable.class), any(Response.class)); + } + + @Test + public void shouldKeepRedirectingErrorsThatCarryARedirectUri() { + //given RFC 6749 4.1.2.1: errors with a usable redirect_uri are reported by redirecting + givenClientAccepts(MediaType.APPLICATION_JSON); + Throwable failure = new OAuth2RestletException(400, "invalid_scope", "Unknown scope", + "https://client.example.com/cb", null); + + //when + resource.doCatch(failure); + + //then + verify(exceptionHandler).handle(eq(failure), any(), eq(request), eq(response)); + verify(exceptionHandler, never()).handle(any(Throwable.class), any(Response.class)); + } + + @Test + public void shouldLookThroughRestletWrappingWhenErrorCarriesARedirectUri() { + //given Restlet hands doCatch the exception wrapped, which is why ExceptionHandler inspects getCause() + givenClientAccepts(MediaType.APPLICATION_JSON); + Throwable wrapped = new RuntimeException(new OAuth2RestletException(400, "invalid_scope", "Unknown scope", + "https://client.example.com/cb", null)); + + //when + resource.doCatch(wrapped); + + //then + verify(exceptionHandler).handle(eq(wrapped), any(), eq(request), eq(response)); + } + + @Test + public void shouldLookThroughRestletWrappingWhenErrorHasNoRedirectUri() { + //given + givenClientAccepts(MediaType.APPLICATION_JSON); + Throwable wrapped = new RuntimeException( + new OAuth2RestletException(400, "invalid_scope", "Unknown scope", null)); + + //when + resource.doCatch(wrapped); + + //then + verify(exceptionHandler).handle(wrapped, response); + } + + @Test + public void shouldStillRenderConsentPageWhenJsonNotRequested() throws Exception { + //given + givenConsentIsRequired(); + givenClientAccepts(MediaType.ALL); + doReturn(Collections.emptyMap()).when(resource) + .getDataModel(any(ResourceOwnerConsentRequired.class), any(OAuth2Request.class)); + + //when + resource.authorize(); + + //then + verify(representation).getRepresentation(any(), eq(o2request), eq("authorize.ftl"), anyMap()); + verify(jacksonRepresentationFactory, never()).create(any()); + verify(resource, never()).isForeignOrigin(any(OAuth2Request.class)); + } + } \ No newline at end of file diff --git a/openam-oauth2/src/test/java/org/forgerock/oauth2/restlet/ConsentRequiredResourceTest.java b/openam-oauth2/src/test/java/org/forgerock/oauth2/restlet/ConsentRequiredResourceTest.java new file mode 100644 index 0000000000..285a706c18 --- /dev/null +++ b/openam-oauth2/src/test/java/org/forgerock/oauth2/restlet/ConsentRequiredResourceTest.java @@ -0,0 +1,154 @@ +/* + * 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 3A Systems LLC. + */ + +package org.forgerock.oauth2.restlet; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.forgerock.json.JsonValue; +import org.forgerock.oauth2.core.UserInfoClaims; +import org.forgerock.oauth2.core.exceptions.ResourceOwnerConsentRequired; +import org.testng.annotations.Test; + +/** + * Tests the raw consent model served to API clients. The distinguishing property against + * {@code getDataModel()} is that nothing here is HTML-encoded. + */ +public class ConsentRequiredResourceTest { + + private static ResourceOwnerConsentRequired consentRequired(Map scopeDescriptions, + Map claimDescriptions, Map claimValues, + Map> compositeScopes) { + return new ResourceOwnerConsentRequired("client", "description", scopeDescriptions, claimDescriptions, + new UserInfoClaims(claimValues, compositeScopes), "Demo User", true); + } + + private static Map values(String key, Object value) { + Map map = new LinkedHashMap<>(); + map.put(key, value); + return map; + } + + @Test + public void shouldExposeScopeKeyAndDescription() { + ResourceOwnerConsentRequired consent = consentRequired( + Collections.singletonMap("profile", "Your personal information"), + Collections.emptyMap(), Collections.emptyMap(), + Collections.>emptyMap()); + + JsonValue scopes = ConsentRequiredResource.rawScopesAndClaims(consent).get("scopes"); + + assertThat(scopes.size()).isEqualTo(1); + assertThat(scopes.get(0).get("name").asString()).isEqualTo("profile"); + assertThat(scopes.get(0).get("description").asString()).isEqualTo("Your personal information"); + } + + @Test + public void shouldNestClaimsUnderTheirScope() { + ResourceOwnerConsentRequired consent = consentRequired( + Collections.singletonMap("profile", "Your personal information"), + Collections.singletonMap("given_name", "First name"), + values("given_name", "Demo"), + Collections.singletonMap("profile", Collections.singletonList("given_name"))); + + JsonValue model = ConsentRequiredResource.rawScopesAndClaims(consent); + JsonValue claims = model.get("scopes").get(0).get("claims"); + + assertThat(claims.size()).isEqualTo(1); + assertThat(claims.get(0).get("name").asString()).isEqualTo("given_name"); + assertThat(claims.get(0).get("description").asString()).isEqualTo("First name"); + assertThat(claims.get(0).get("value").asString()).isEqualTo("Demo"); + // Consumed by the scope, so it must not also appear at the top level. + assertThat(model.get("claims").size()).isEqualTo(0); + } + + @Test + public void shouldNotHtmlEncodeValues() { + ResourceOwnerConsentRequired consent = consentRequired( + Collections.singletonMap("profile", "Fish & Chips "), + Collections.singletonMap("given_name", "First & last"), + values("given_name", "O'Brien & Sons"), + Collections.singletonMap("profile", Collections.singletonList("given_name"))); + + JsonValue scope = ConsentRequiredResource.rawScopesAndClaims(consent).get("scopes").get(0); + + assertThat(scope.get("description").asString()).isEqualTo("Fish & Chips "); + assertThat(scope.get("claims").get(0).get("description").asString()).isEqualTo("First & last"); + assertThat(scope.get("claims").get(0).get("value").asString()).isEqualTo("O'Brien & Sons"); + } + + @Test + public void shouldSkipScopeClaimsWithoutAValue() { + ResourceOwnerConsentRequired consent = consentRequired( + Collections.singletonMap("profile", "Your personal information"), + Collections.singletonMap("given_name", "First name"), + Collections.emptyMap(), + Collections.singletonMap("profile", Collections.singletonList("given_name"))); + + JsonValue scope = ConsentRequiredResource.rawScopesAndClaims(consent).get("scopes").get(0); + + assertThat(scope.get("claims").size()).isEqualTo(0); + } + + @Test + public void shouldUseNullDescriptionWhenClaimHasNone() { + ResourceOwnerConsentRequired consent = consentRequired( + Collections.singletonMap("profile", "Your personal information"), + Collections.emptyMap(), + values("given_name", "Demo"), + Collections.singletonMap("profile", Collections.singletonList("given_name"))); + + JsonValue claim = ConsentRequiredResource.rawScopesAndClaims(consent).get("scopes").get(0) + .get("claims").get(0); + + assertThat(claim.get("name").asString()).isEqualTo("given_name"); + assertThat(claim.get("description").getObject()).isNull(); + } + + @Test + public void shouldListClaimsNotCoveredByAnyScope() { + ResourceOwnerConsentRequired consent = consentRequired( + Collections.singletonMap("profile", "Your personal information"), + Collections.singletonMap("email", "Email address"), + values("email", "demo@example.com"), + Collections.>emptyMap()); + + JsonValue model = ConsentRequiredResource.rawScopesAndClaims(consent); + + assertThat(model.get("scopes").get(0).get("claims").size()).isEqualTo(0); + assertThat(model.get("claims").size()).isEqualTo(1); + assertThat(model.get("claims").get(0).get("name").asString()).isEqualTo("email"); + assertThat(model.get("claims").get(0).get("description").asString()).isEqualTo("Email address"); + assertThat(model.get("claims").get(0).get("value").asString()).isEqualTo("demo@example.com"); + } + + @Test + public void shouldRenderNonStringClaimValuesAsText() { + ResourceOwnerConsentRequired consent = consentRequired( + Collections.emptyMap(), Collections.emptyMap(), + values("email_verified", Boolean.TRUE), + Collections.>emptyMap()); + + JsonValue model = ConsentRequiredResource.rawScopesAndClaims(consent); + + assertThat(model.get("claims").get(0).get("value").asString()).isEqualTo("true"); + } +}