From 52712d2c822471175a5c98b3084ad91ee0ffd473 Mon Sep 17 00:00:00 2001 From: mposolda Date: Wed, 9 Feb 2022 11:50:37 +0100 Subject: [PATCH] ACR support in the javascript adapter Closes #10154 --- adapters/oidc/js/dist/keycloak.d.ts | 18 +++++++ adapters/oidc/js/src/keycloak.js | 14 +++++ .../util/javascript/JSObjectBuilder.java | 25 +++++++-- .../javascript/JavascriptAdapterTest.java | 53 +++++++++++++++++++ 4 files changed, 105 insertions(+), 5 deletions(-) diff --git a/adapters/oidc/js/dist/keycloak.d.ts b/adapters/oidc/js/dist/keycloak.d.ts index 7ef865e677..fe408e6203 100644 --- a/adapters/oidc/js/dist/keycloak.d.ts +++ b/adapters/oidc/js/dist/keycloak.d.ts @@ -39,6 +39,19 @@ export interface KeycloakConfig { clientId: string; } +export interface Acr { + /** + * Array of values, which will be used inside ID Token `acr` claim sent inside the `claims` parameter to Keycloak server during login. + * Values should correspond to the ACR levels defined in the ACR to Loa mapping for realm or client or to the numbers (levels) inside defined + * Keycloak authentication flow. See section 5.5.1 of OIDC 1.0 specification for the details. + */ + values: string[]; + /** + * This parameter specifies if ACR claims is considered essential or not. + */ + essential: boolean; +} + export interface KeycloakInitOptions { /** * Adds a [cryptographic nonce](https://en.wikipedia.org/wiki/Cryptographic_nonce) @@ -217,6 +230,11 @@ export interface KeycloakLoginOptions { */ loginHint?: string; + /** + * Sets the `acr` claim of the ID token sent inside the `claims` parameter. See section 5.5.1 of the OIDC 1.0 specification. + */ + acr?: Acr; + /** * Used to tell Keycloak which IDP the user wants to authenticate with. */ diff --git a/adapters/oidc/js/src/keycloak.js b/adapters/oidc/js/src/keycloak.js index db95534bec..3db6b04158 100755 --- a/adapters/oidc/js/src/keycloak.js +++ b/adapters/oidc/js/src/keycloak.js @@ -378,6 +378,15 @@ function Keycloak (config) { } } + function buildClaimsParameter(requestedAcr){ + var claims = { + id_token: { + acr: requestedAcr + } + } + return JSON.stringify(claims); + } + kc.createLoginUrl = function(options) { var state = createUUID(); var nonce = createUUID(); @@ -445,6 +454,11 @@ function Keycloak (config) { url += '&ui_locales=' + encodeURIComponent(options.locale); } + if (options && options.acr) { + var claimsParameter = buildClaimsParameter(options.acr); + url += '&claims=' + encodeURIComponent(claimsParameter); + } + if (kc.pkceMethod) { var codeVerifier = generateCodeVerifier(96); callbackState.pkceCodeVerifier = codeVerifier; diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/javascript/JSObjectBuilder.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/javascript/JSObjectBuilder.java index 45877b3926..23d346975a 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/javascript/JSObjectBuilder.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/util/javascript/JSObjectBuilder.java @@ -1,8 +1,12 @@ package org.keycloak.testsuite.util.javascript; +import java.io.IOException; +import java.lang.reflect.Array; import java.util.HashMap; import java.util.Map; +import org.keycloak.util.JsonSerialization; + /** * @author mhajas */ @@ -100,7 +104,7 @@ public class JSObjectBuilder { } private boolean skipQuotes(Object o) { - return (o instanceof Integer || o instanceof Boolean); + return (o instanceof Integer || o instanceof Boolean || o instanceof JSObjectBuilder); } public String build() { @@ -111,11 +115,19 @@ public class JSObjectBuilder { .append(option.getKey()) .append(" : "); - if (!skipQuotes(option.getValue())) argument.append("\""); + if (option.getValue().getClass().isArray()) { + try { + argument.append(JsonSerialization.writeValueAsString(option.getValue())); + } catch (IOException ioe) { + throw new IllegalArgumentException("Not possible to serialize value of the option " + option.getKey(), ioe); + } + } else { + if (!skipQuotes(option.getValue())) argument.append("\""); - argument.append(option.getValue()); + argument.append(option.getValue()); - if (!skipQuotes(option.getValue())) argument.append("\""); + if (!skipQuotes(option.getValue())) argument.append("\""); + } comma = ","; } @@ -124,5 +136,8 @@ public class JSObjectBuilder { return argument.toString(); } - + @Override + public String toString() { + return build(); + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/javascript/JavascriptAdapterTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/javascript/JavascriptAdapterTest.java index a1669b9c19..2be9199be5 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/javascript/JavascriptAdapterTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/javascript/JavascriptAdapterTest.java @@ -9,8 +9,12 @@ import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.common.Profile; import org.keycloak.common.util.Retry; +import org.keycloak.common.util.UriUtils; import org.keycloak.events.Details; import org.keycloak.events.EventType; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.representations.ClaimsRepresentation; +import org.keycloak.representations.IDToken; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.RealmRepresentation; @@ -33,11 +37,14 @@ import org.keycloak.testsuite.util.javascript.JSObjectBuilder; import org.keycloak.testsuite.util.javascript.JavascriptStateValidator; import org.keycloak.testsuite.util.javascript.JavascriptTestExecutor; import org.keycloak.testsuite.util.javascript.XMLHttpRequest; +import org.keycloak.util.JsonSerialization; import org.openqa.selenium.TimeoutException; import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebDriverException; import org.openqa.selenium.WebElement; +import java.io.IOException; +import java.net.URL; import java.util.List; import java.util.Map; @@ -498,6 +505,52 @@ public class JavascriptAdapterTest extends AbstractJavascriptTest { }); } + /** + * Test for acr handling via {@code loginOptions}:
{@code
+     * Keycloak keycloak = new Keycloak(); keycloak.login({.... acr: { values: ["foo", "bar"], essential: false}})
+     * }
+ */ + @Test + public void testAcrInLoginOptionsShouldBeConsideredByLoginUrl() { + // Test when no "acr" option given. Claims parameter won't be passed to Keycloak server + testExecutor.configure().init(defaultArguments()); + JSObjectBuilder loginOptions = JSObjectBuilder.create(); + + testExecutor.login(loginOptions, (JavascriptStateValidator) (driver, output, events) -> { + try { + String queryString = new URL(driver.getCurrentUrl()).getQuery(); + String claimsParam = UriUtils.decodeQueryString(queryString).getFirst(OIDCLoginProtocol.CLAIMS_PARAM); + Assert.assertNull(claimsParam); + } catch (IOException ioe) { + throw new AssertionError(ioe); + } + }); + + // Test given "acr" option will be translated into the "claims" parameter passed to Keycloak server + jsDriver.navigate().to(testAppUrl); + testExecutor.configure().init(defaultArguments()); + + JSObjectBuilder acr1 = JSObjectBuilder.create() + .add("values", new String[] {"foo", "bar"}) + .add("essential", false); + loginOptions = JSObjectBuilder.create().add("acr", acr1); + + testExecutor.login(loginOptions, (JavascriptStateValidator) (driver, output, events) -> { + try { + String queryString = new URL(driver.getCurrentUrl()).getQuery(); + String claimsParam = UriUtils.decodeQueryString(queryString).getFirst(OIDCLoginProtocol.CLAIMS_PARAM); + Assert.assertNotNull(claimsParam); + + ClaimsRepresentation claimsRep = JsonSerialization.readValue(claimsParam, ClaimsRepresentation.class); + ClaimsRepresentation.ClaimValue claimValue = claimsRep.getClaimValue(IDToken.ACR, ClaimsRepresentation.ClaimContext.ID_TOKEN, String.class); + Assert.assertNames(claimValue.getValues(), "foo", "bar"); + Assert.assertThat(claimValue.isEssential(), is(false)); + } catch (IOException ioe) { + throw new AssertionError(ioe); + } + }); + } + @Test public void testUpdateToken() { XMLHttpRequest request = XMLHttpRequest.create()