Skip to content
Open
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
152 changes: 127 additions & 25 deletions e2e/oauth2/oauth2-test.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import { OPENAM_BASE, getAdminToken, getAuthToken, PASSWORD, USERNAME } from "..

const REALM = "root";
const CLIENT_ID = "test_client_app";
// A client that does not imply consent, so the resource owner's decision must be posted explicitly. This is
// the situation issue #1080 reports: a non-browser client cannot obtain a token to post with the decision.
const CONSENT_CLIENT_ID = "test_consent_app";
const CONSENT_STATE = "consent-state";
const SCOPE="profile"
const REDIRECT_URI="http://app.invalid/cb"
/**
Expand Down Expand Up @@ -74,9 +78,9 @@ async function ensureOAuth2ServiceExists(adminToken, request) {
* Creates it with default configuration if it doesn't exist.
*/

async function ensureOAuth2ClientExists(adminToken, request) {
async function ensureOAuth2ClientExists(adminToken, request, clientId = CLIENT_ID, isConsentImplied = true) {
const response = await request.get(
`${OPENAM_BASE}/json/realms/${REALM}/realm-config/agents/OAuth2Client/${CLIENT_ID}`,
`${OPENAM_BASE}/json/realms/${REALM}/realm-config/agents/OAuth2Client/${clientId}`,
{
method: "GET",
headers: {
Expand All @@ -89,7 +93,7 @@ async function ensureOAuth2ClientExists(adminToken, request) {
if (response.status() === 404) {
// Client doesn't exist, create it
const createResponse = await request.put(
`${OPENAM_BASE}/json/realms/${REALM}/realm-config/agents/OAuth2Client/${CLIENT_ID}`,
`${OPENAM_BASE}/json/realms/${REALM}/realm-config/agents/OAuth2Client/${clientId}`,
{
headers: {
"iPlanetDirectoryPro": adminToken,
Expand All @@ -104,7 +108,7 @@ async function ensureOAuth2ClientExists(adminToken, request) {
"com.forgerock.openam.oauth2provider.grantTypes": ["[0]=authorization_code"],
"com.forgerock.openam.oauth2provider.responseTypes": ["[0]=code"],
"com.forgerock.openam.oauth2provider.tokenEndPointAuthMethod": "none",
"isConsentImplied": true,
"isConsentImplied": isConsentImplied,
"sunIdentityServerDeviceStatus": "Active"
},
}
Expand All @@ -115,13 +119,13 @@ async function ensureOAuth2ClientExists(adminToken, request) {
`Failed to create OAuth2 client: ${createResponse.statusText}`
);
}
console.log(`OAuth2 client "${CLIENT_ID}" created successfully`);
console.log(`OAuth2 client "${clientId}" created successfully`);
} else if (!response.ok()) {
throw new Error(
`Failed to check OAuth2 client: ${response.statusText}`
);
} else {
console.log(`OAuth2 client "${CLIENT_ID}" already exists`);
console.log(`OAuth2 client "${clientId}" already exists`);
}
}

Expand All @@ -134,30 +138,33 @@ test.beforeAll(async ({ request }) => {
}
await ensureOAuth2ServiceExists(adminToken, request);
await ensureOAuth2ClientExists(adminToken, request);
await ensureOAuth2ClientExists(adminToken, request, CONSENT_CLIENT_ID, false);
});

let accessToken;

test.describe("OAuth Service test", () => {
test("Should receive an auth code and exchange it to access token", async ({ request }) => {
// PKCE is mandatory for these clients: they are public and authenticate with "none", so OpenAM rejects an
// authorization request without code_challenge before it reaches consent handling.
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('');
}

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 encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);

async function generateChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);

return btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
return btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}

test.describe("OAuth Service test", () => {
test("Should receive an auth code and exchange it to access token", async ({ request }) => {

const demoToken = await getAuthToken(request, USERNAME, PASSWORD);

Expand Down Expand Up @@ -238,7 +245,102 @@ test.describe("OAuth Service test", () => {
const userInfo = await response.json();
expect(userInfo.sub).toBe('demo');
console.log('User Info Claims:', userInfo);


});

});

/**
* A non-browser client posts its consent decision directly, without ever rendering the consent page, and
* submits its own session id as the csrf value. This is the flow documented for headless clients.
*/
test.describe("OAuth2 consent posted directly by a non-browser client", () => {

/**
* The authorization request parameters, shared by the GET that renders the consent page and the POST that
* carries the decision. Both must describe the same request, including the PKCE challenge: the request
* validators run before the csrf check, so an incomplete POST fails with a redirect long before the csrf
* value is looked at.
*/
function authorizeRequest(challenge, extra) {
return {
response_type: "code",
client_id: CONSENT_CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: SCOPE,
state: CONSENT_STATE,
code_challenge: challenge,
code_challenge_method: "S256",
...extra,
};
}

/**
* Authenticates and confirms consent really is required for this client: the GET returns the consent page
* instead of redirecting with a code. That precondition also proves the client's redirect URI, scope,
* response type and PKCE requirements are satisfied, so the rejection tests below cannot go green on a 400
* that has nothing to do with the csrf value.
*/
async function startConsentFlow(request) {
const demoToken = await getAuthToken(request, USERNAME, PASSWORD);
const challenge = await generateChallenge(generateVerifier());

const consentPage = await request.get(`${OPENAM_BASE}/oauth2/realms/${REALM}/authorize`, {
headers: {
"iPlanetDirectoryPro": demoToken,
},
params: authorizeRequest(challenge),
maxRedirects: 0,
});

expect(consentPage.status()).toBe(200);
return { demoToken, challenge };
}

test("Should accept the session id as the csrf value", async ({ request }) => {
const { demoToken, challenge } = await startConsentFlow(request);

const response = await request.post(`${OPENAM_BASE}/oauth2/realms/${REALM}/authorize`, {
headers: {
"iPlanetDirectoryPro": demoToken,
},
form: authorizeRequest(challenge, { decision: "allow", csrf: demoToken }),
maxRedirects: 0,
});

expect(response.status()).toBe(302);

const location = new URL(response.headers()['location']);
expect(location.searchParams.get("code")).toBeTruthy();
expect(location.searchParams.get("state")).toBe(CONSENT_STATE);
});

test("Should reject the consent decision when csrf is missing", async ({ request }) => {
const { demoToken, challenge } = await startConsentFlow(request);

const response = await request.post(`${OPENAM_BASE}/oauth2/realms/${REALM}/authorize`, {
headers: {
"iPlanetDirectoryPro": demoToken,
},
form: authorizeRequest(challenge, { decision: "allow" }),
maxRedirects: 0,
});

expect(response.status()).toBe(400);
});

test("Should reject a csrf value that is not the caller's session", async ({ request }) => {
const { demoToken, challenge } = await startConsentFlow(request);

const response = await request.post(`${OPENAM_BASE}/oauth2/realms/${REALM}/authorize`, {
headers: {
"iPlanetDirectoryPro": demoToken,
},
form: authorizeRequest(challenge, { decision: "allow", csrf: "not-the-session-id" }),
maxRedirects: 0,
});

expect(response.status()).toBe(400);
});

});
Original file line number Diff line number Diff line change
Expand Up @@ -231,14 +231,17 @@ 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`::
A dedicated, random anti-CSRF token bound to the authorization request and generated by the server. It is no longer derived from the `iPlanetDirectoryPro` SSO cookie value. OpenAM renders the token into the consent page (as `pageData.oauth2Data.csrf`); you must post the same value back with the consent decision.
An anti-CSRF value proving that the consent decision came from the resource owner rather than from a third-party site. OpenAM accepts either of two values here.

+
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.
For browser-based consent, OpenAM generates a dedicated, random token bound to the authorization request and renders it into the consent page (as `pageData.oauth2Data.csrf`); post that value back with the decision. This token is __not__ derived from the `iPlanetDirectoryPro` SSO cookie value, which is what allows the SSO cookie to 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.

+
OpenAM also accepts the resource owner's own SSO token — the `tokenId` returned by `/json/authenticate` and carried in the `iPlanetDirectoryPro` cookie. This is the value to use in a client that posts the decision directly and never renders the consent page, since it has no page to read a generated token from. Note that OpenAM cannot tell which kind of client is calling, so this value is accepted on any consent decision. It does not weaken the protection a browser gets: another site can read neither your cookies nor the response to a cross-origin request, so it cannot discover the session id, and anything that already knows the session id can act as you outright without forging a consent decision.

====

Example:
Example, posting the token that OpenAM rendered into the consent page:

[source, console]
----
Expand All @@ -257,6 +260,42 @@ $ curl \
"&realm=/&scope=profile&redirect_uri=http://www.example.net"
----

A non-browser client can skip the consent page entirely and send its own session id instead. Authenticate first, then post the decision:

[source, console]
----
$ curl \
--request POST \
--header "Content-Type: application/json" \
--header "X-OpenAM-Username: demo" \
--header "X-OpenAM-Password: Ch4ng31t" \
--header "Accept-API-Version: resource=2.0, protocol=1.0" \
"https://openam.example.com:8443/openam/json/realms/root/authenticate"
{"tokenId":"AQIC5w...*","successUrl":"/openam/console","realm":"/"}

$ curl \
--request POST \
--header "Content-Type: application/x-www-form-urlencoded" \
--header "Cookie: iPlanetDirectoryPro=AQIC5w...*" \
--data "redirect_uri=http://www.example.net" \
--data "scope=profile" \
--data "response_type=code" \
--data "client_id=myClient" \
--data "csrf=AQIC5w...*" \
--data "decision=allow" \
"https://openam.example.com:8443/openam/oauth2/realms/root/authorize"
----

[NOTE]
====
Send `csrf` in the request body, as shown. Query string parameters take priority over body parameters, and they are also recorded in access logs and `Referer` headers, so a session id placed in the query string is easily leaked.
====

[NOTE]
====
If the deployment sets `com.sun.identity.cookie.httponly` to `true`, `/json/authenticate` does not echo `tokenId` in the response body; the session is delivered only through the `Set-Cookie` header. Because `csrf` is a form field, sending the cookie back is not sufficient on such a deployment: the client has to extract the session id from the `Set-Cookie` header (or from a cookie jar written with `curl --cookie-jar`) and post that string as `csrf`. Setting `org.openidentityplatform.openam.httponly.allowTokenInBody` to `true` restores `tokenId` in the response body and avoids the parsing step. See xref:../reference/chap-config-ref.adoc#chap-config-ref["Configuration Reference"] in the __Reference__.
====

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`.


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5105,10 +5105,12 @@ 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.
Duplicates the contents of the `iPlanetDirectoryPro` cookie, which contains the SSO token of the resource owner giving consent. Send it in the request body rather than the query string, so that the session id is not recorded in access logs or `Referer` headers.

Duplicating the cookie value helps prevent against Cross-Site Request Forgery (CSRF) attacks.

If you render the OpenAM consent page rather than posting the decision directly, use the dedicated token OpenAM generated for the request instead, available as `pageData.oauth2Data.csrf`. For details of both forms, see xref:../admin-guide/chap-oauth2.adoc#oauth2-endpoints["OpenAM OAuth 2.0 Endpoints"] in the __Administration Guide__.

====

Example:
Expand Down Expand Up @@ -5473,9 +5475,11 @@ 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.
Duplicates the contents of the `iPlanetDirectoryPro` cookie, which contains the SSO token of the user granting access. Send it in the request body rather than the query string, so that the session id is not recorded in access logs or `Referer` headers.
+
Duplicating the cookie value helps prevent against Cross-Site Request Forgery (CSRF) attacks.
+
If you render the OpenAM consent page rather than posting the decision directly, use the dedicated token OpenAM generated for the request instead, available as `pageData.oauth2Data.csrf`.

--
+
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,8 @@ public AuthorizationToken authorize(OAuth2Request request, boolean consentGiven,
}

if (csrfProtection.isCsrfAttack(request)) {
logger.debug("Session id from consent request does not match users session");
logger.debug("Consent request rejected: csrf parameter matched neither the consent token nor "
+ "the resource owner's session id");
throw new CsrfException();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

import com.iplanet.sso.SSOException;
import com.iplanet.sso.SSOToken;
import com.iplanet.sso.SSOTokenID;
import com.sun.identity.shared.Constants;
import com.sun.identity.shared.debug.Debug;
import com.sun.identity.shared.encode.CookieUtils;
Expand All @@ -44,6 +45,10 @@
* request. It is no longer derived from the SSO cookie, so the SSO cookie no longer needs to be script-readable
* and can be shipped as {@code HttpOnly}. For stateful sessions the token is stored as a protected session
* property; for stateless sessions a double-submit cookie is used as a fallback.</p>
*
* <p>Validation additionally accepts the resource owner's own session id, which is what a non-browser client
* that posts its consent decision directly submits. That client has no consent page to read a minted token
* from, and it already holds the session id from its authentication response.</p>
*/
public class CsrfProtection {

Expand Down Expand Up @@ -97,7 +102,8 @@ public String createCsrfToken(OAuth2Request request) {

/**
* Checks if the request contains the required "csrf" parameter and that it matches the token bound to the
* resource owner's authorization request (either the protected session property or the double-submit cookie).
* resource owner's authorization request (either the protected session property or the double-submit cookie),
* or failing that the resource owner's session id.
*
* @param request The request.
* @return {@code true} if the request is a CSRF attack, {@code false} if not.
Expand Down Expand Up @@ -127,6 +133,21 @@ public boolean isCsrfAttack(OAuth2Request request) {
return false;
}

// The resource owner's own session id is also accepted. This is the contract documented for direct
// POSTs and the only value available to a client that never renders the consent page. Nothing in the
// request reliably identifies such a client, so the value is accepted from any caller, browsers
// included. That does not weaken the protection a browser gets: a foreign origin can read neither the
// victim's cookies nor a cross-origin response, so an attacking page still cannot discover the value
// it would have to submit, and anyone who does know the session id can already act as the user
// outright without forging anything. This holds whether or not the SSO cookie is HttpOnly, which
// ships disabled by default.
if (ssoToken != null) {
SSOTokenID ssoTokenId = ssoToken.getTokenID();
if (ssoTokenId != null && constantTimeEquals(ssoTokenId.toString(), csrfValue)) {
return false;
}
}

return true;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,8 @@ public Representation verify(Representation body) throws ServerException, NotFou
if (StringUtils.isNotEmpty(decision)) {

if (csrfProtection.isCsrfAttack(request)) {
logger.debug("Session id from consent request does not match users session");
logger.debug("Consent request rejected: csrf parameter matched neither the consent token "
+ "nor the resource owner's session id");
throw new OAuth2RestletException(400, "bad_request", null, request.<String>getParameter("state"));
}

Expand Down
Loading
Loading