diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java index f6eacf0849..3c525542f5 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java @@ -64,6 +64,8 @@ public final class OIDCConfigAttributes { public static final String USE_REFRESH_TOKEN = "use.refresh.tokens"; + public static final String ID_TOKEN_AS_DETACHED_SIGNATURE = "id.token.as.detached.signature"; + private OIDCConfigAttributes() { } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java index 8466bd67fa..e10c9770ba 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocol.java @@ -57,6 +57,7 @@ import org.keycloak.util.TokenUtil; import java.io.IOException; import java.net.URI; +import java.util.Optional; import java.util.UUID; import javax.ws.rs.core.HttpHeaders; @@ -243,7 +244,7 @@ public class OIDCLoginProtocol implements LoginProtocol { if (responseType.hasResponseType(OIDCResponseType.ID_TOKEN)) { - responseBuilder.generateIDToken(); + responseBuilder.generateIDToken(isIdTokenAsDetachedSignature(clientSession.getClient())); if (responseType.hasResponseType(OIDCResponseType.TOKEN)) { responseBuilder.generateAccessTokenHash(); @@ -275,6 +276,12 @@ public class OIDCLoginProtocol implements LoginProtocol { return redirectUri.build(); } + // For FAPI 1.0 Advanced + private boolean isIdTokenAsDetachedSignature(ClientModel client) { + if (client == null) return false; + return Boolean.valueOf(Optional.ofNullable(client.getAttribute(OIDCConfigAttributes.ID_TOKEN_AS_DETACHED_SIGNATURE)).orElse(Boolean.FALSE.toString())).booleanValue(); + } + @Override public Response sendError(AuthenticationSessionModel authSession, Error error) { if (isOAuth2DeviceVerificationFlow(authSession)) { diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index 34e20985a8..83713f2412 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -967,6 +967,10 @@ public class TokenManager { } public AccessTokenResponseBuilder generateIDToken() { + return generateIDToken(false); + } + + public AccessTokenResponseBuilder generateIDToken(boolean isIdTokenAsDetachedSignature) { if (accessToken == null) { throw new IllegalStateException("accessToken not set"); } @@ -983,7 +987,9 @@ public class TokenManager { idToken.setSessionState(accessToken.getSessionState()); idToken.expiration(accessToken.getExpiration()); idToken.setAcr(accessToken.getAcr()); - transformIDToken(session, idToken, userSession, clientSessionCtx); + if (isIdTokenAsDetachedSignature == false) { + transformIDToken(session, idToken, userSession, clientSessionCtx); + } return this; } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/flows/OIDCHybridResponseTypeCodeIDTokenAsDetachedSigTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/flows/OIDCHybridResponseTypeCodeIDTokenAsDetachedSigTest.java new file mode 100644 index 0000000000..8da72edeb2 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/flows/OIDCHybridResponseTypeCodeIDTokenAsDetachedSigTest.java @@ -0,0 +1,118 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.testsuite.oidc.flows; + +import org.junit.Before; +import org.junit.Test; +import org.keycloak.events.Details; +import org.keycloak.jose.jws.crypto.HashUtils; +import org.keycloak.protocol.oidc.OIDCConfigAttributes; +import org.keycloak.protocol.oidc.utils.OIDCResponseType; +import org.keycloak.representations.IDToken; +import org.keycloak.representations.idm.EventRepresentation; +import org.keycloak.testsuite.Assert; +import org.keycloak.testsuite.util.OAuthClient; + +import java.util.Arrays; +import java.util.List; + +/** + * Tests with response_type=code id_token as detached signature + * + * @author Takashi Norimatsu + */ +public class OIDCHybridResponseTypeCodeIDTokenAsDetachedSigTest extends AbstractOIDCResponseTypeTest { + + @Before + public void clientConfiguration() { + clientManagerBuilder().standardFlow(true).implicitFlow(true).updateAttribute(OIDCConfigAttributes.ID_TOKEN_AS_DETACHED_SIGNATURE, Boolean.TRUE.toString()); + + oauth.clientId("test-app"); + oauth.responseType(OIDCResponseType.CODE + " " + OIDCResponseType.ID_TOKEN); + } + + + @Override + protected boolean isFragment() { + return true; + } + + + protected List testAuthzResponseAndRetrieveIDTokens(OAuthClient.AuthorizationEndpointResponse authzResponse, EventRepresentation loginEvent) { + Assert.assertEquals(OIDCResponseType.CODE + " " + OIDCResponseType.ID_TOKEN, loginEvent.getDetails().get(Details.RESPONSE_TYPE)); + + // IDToken from the authorization response + Assert.assertNull(authzResponse.getAccessToken()); + String idTokenStr = authzResponse.getIdToken(); + IDToken idToken = oauth.verifyIDToken(idTokenStr); + // confirm ID token as detached signature does not include authenticated user's claims + Assert.assertNull(idToken.getEmailVerified()); + Assert.assertNull(idToken.getName()); + Assert.assertNull(idToken.getPreferredUsername()); + Assert.assertNull(idToken.getGivenName()); + Assert.assertNull(idToken.getFamilyName()); + Assert.assertNull(idToken.getEmail()); + + // Validate "at_hash" + Assert.assertNull(idToken.getAccessTokenHash()); + + // Validate "c_hash" + assertValidCodeHash(idToken.getCodeHash(), authzResponse.getCode()); + + // Financial API - Part 2: Read and Write API Security Profile + // http://openid.net/specs/openid-financial-api-part-2.html#authorization-server + // Validate "s_hash" + Assert.assertNotNull(idToken.getStateHash()); + + Assert.assertEquals(idToken.getStateHash(), HashUtils.oidcHash(getIdTokenSignatureAlgorithm(), authzResponse.getState())); + + // Validate if token_type is null + Assert.assertNull(authzResponse.getTokenType()); + + // Validate if expires_in is null + Assert.assertNull(authzResponse.getExpiresIn()); + + // IDToken exchanged for the code + IDToken idToken2 = sendTokenRequestAndGetIDToken(loginEvent); + // confirm ordinal ID token includes authenticated user's claims + Assert.assertNotNull(idToken2.getEmailVerified()); + Assert.assertNotNull(idToken2.getName()); + Assert.assertNotNull(idToken2.getPreferredUsername()); + Assert.assertNotNull(idToken2.getGivenName()); + Assert.assertNotNull(idToken2.getFamilyName()); + Assert.assertNotNull(idToken2.getEmail()); + + return Arrays.asList(idToken, idToken2); + } + + + @Test + public void nonceNotUsedErrorExpected() { + super.validateNonceNotUsedErrorExpected(); + } + + @Test + public void errorStandardFlowNotAllowed() throws Exception { + super.validateErrorStandardFlowNotAllowed(); + } + + @Test + public void errorImplicitFlowNotAllowed() throws Exception { + super.validateErrorImplicitFlowNotAllowed(); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/flows/OIDCHybridResponseTypeCodeIDTokenAsDetachedSigTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/flows/OIDCHybridResponseTypeCodeIDTokenAsDetachedSigTokenTest.java new file mode 100644 index 0000000000..63d1d616b1 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/flows/OIDCHybridResponseTypeCodeIDTokenAsDetachedSigTokenTest.java @@ -0,0 +1,117 @@ +/* + * Copyright 2021 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.testsuite.oidc.flows; + +import org.junit.Before; +import org.junit.Test; +import org.keycloak.events.Details; +import org.keycloak.jose.jws.crypto.HashUtils; +import org.keycloak.protocol.oidc.OIDCConfigAttributes; +import org.keycloak.protocol.oidc.utils.OIDCResponseType; +import org.keycloak.representations.IDToken; +import org.keycloak.representations.idm.EventRepresentation; +import org.keycloak.testsuite.Assert; +import org.keycloak.testsuite.util.OAuthClient; + +import java.util.Arrays; +import java.util.List; + +/** + * Tests with response_type=code id_token token as detached signature + * + * @author Takashi Norimatsu + */ +public class OIDCHybridResponseTypeCodeIDTokenAsDetachedSigTokenTest extends AbstractOIDCResponseTypeTest { + + @Before + public void clientConfiguration() { + clientManagerBuilder().standardFlow(true).implicitFlow(true).updateAttribute(OIDCConfigAttributes.ID_TOKEN_AS_DETACHED_SIGNATURE, Boolean.TRUE.toString()); + + oauth.clientId("test-app"); + oauth.responseType(OIDCResponseType.CODE + " " + OIDCResponseType.ID_TOKEN + " " + OIDCResponseType.TOKEN); + } + + + @Override + protected boolean isFragment() { + return true; + } + + + protected List testAuthzResponseAndRetrieveIDTokens(OAuthClient.AuthorizationEndpointResponse authzResponse, EventRepresentation loginEvent) { + Assert.assertEquals(OIDCResponseType.CODE + " " + OIDCResponseType.ID_TOKEN + " " + OIDCResponseType.TOKEN, loginEvent.getDetails().get(Details.RESPONSE_TYPE)); + + // IDToken from the authorization response + Assert.assertNotNull(authzResponse.getAccessToken()); + String idTokenStr = authzResponse.getIdToken(); + IDToken idToken = oauth.verifyIDToken(idTokenStr); + // confirm ID token as detached signature does not include authenticated user's claims + Assert.assertNull(idToken.getEmailVerified()); + Assert.assertNull(idToken.getName()); + Assert.assertNull(idToken.getPreferredUsername()); + Assert.assertNull(idToken.getGivenName()); + Assert.assertNull(idToken.getFamilyName()); + Assert.assertNull(idToken.getEmail()); + + // Validate "at_hash" + assertValidAccessTokenHash(idToken.getAccessTokenHash(), authzResponse.getAccessToken()); + + // Validate "c_hash" + assertValidCodeHash(idToken.getCodeHash(), authzResponse.getCode()); + + // Financial API - Part 2: Read and Write API Security Profile + // http://openid.net/specs/openid-financial-api-part-2.html#authorization-server + // Validate "s_hash" + Assert.assertNotNull(idToken.getStateHash()); + + Assert.assertEquals(idToken.getStateHash(), HashUtils.oidcHash(getIdTokenSignatureAlgorithm(), authzResponse.getState())); + + // Validate if token_type is present + Assert.assertNotNull(authzResponse.getTokenType()); + + // Validate if expires_in is present + Assert.assertNotNull(authzResponse.getExpiresIn()); + + // IDToken exchanged for the code + IDToken idToken2 = sendTokenRequestAndGetIDToken(loginEvent); + // confirm ordinal ID token includes authenticated user's claims + Assert.assertNotNull(idToken2.getEmailVerified()); + Assert.assertNotNull(idToken2.getName()); + Assert.assertNotNull(idToken2.getPreferredUsername()); + Assert.assertNotNull(idToken2.getGivenName()); + Assert.assertNotNull(idToken2.getFamilyName()); + Assert.assertNotNull(idToken2.getEmail()); + + return Arrays.asList(idToken, idToken2); + } + + @Test + public void nonceNotUsedErrorExpected() { + super.validateNonceNotUsedErrorExpected(); + } + + @Test + public void errorStandardFlowNotAllowed() throws Exception { + super.validateErrorStandardFlowNotAllowed(); + } + + @Test + public void errorImplicitFlowNotAllowed() throws Exception { + super.validateErrorImplicitFlowNotAllowed(); + } +} diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties index 41731bd1b2..7d3ee79914 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_en.properties @@ -1862,6 +1862,9 @@ subjectdn-tooltip=A regular expression for validating Subject DN in the Client C pkce-code-challenge-method=Proof Key for Code Exchange Code Challenge Method pkce-code-challenge-method.tooltip=Choose which code challenge method for PKCE is used. If not specified, keycloak does not applies PKCE to a client unless the client sends an authorization request with appropriate code challenge and code exchange method. +use-idtoken-as-detached-signature=Use ID Token as a Detached Signature +use-idtoken-as-detached-signature.tooltip=This makes ID token returned from Authorization Endpoint in OIDC Hybrid flow use as a detached signature defined in FAPI 1.0 Advanced Security Profile. Therefore, this ID token does not include an authenticated user's information. + key-not-allowed-here=Key '{{character}}' is not allowed here. # KEYCLOAK-10927 Implement LDAPv3 Password Modify Extended Operation diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js index b2259478c8..56839490db 100755 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/clients.js @@ -1115,6 +1115,7 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro // https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3 $scope.tlsClientCertificateBoundAccessTokens = false; $scope.useRefreshTokens = true; + $scope.useIdTokenAsDetachedSignature = false; $scope.accessTokenLifespan = TimeUnit2.asUnit(client.attributes['access.token.lifespan']); $scope.samlAssertionLifespan = TimeUnit2.asUnit(client.attributes['saml.assertion.lifespan']); @@ -1319,6 +1320,14 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro } } + if ($scope.client.attributes["id.token.as.detached.signature"]) { + if ($scope.client.attributes["id.token.as.detached.signature"] == "true") { + $scope.useIdTokenAsDetachedSignature = true; + } else { + $scope.useIdTokenAsDetachedSignature = false; + } + } + // KEYCLOAK-6771 Certificate Bound Token // https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3 if ($scope.client.attributes["tls.client.certificate.bound.access.tokens"]) { @@ -1748,6 +1757,12 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro $scope.clientEdit.attributes["use.refresh.tokens"] = "false"; } + if ($scope.useIdTokenAsDetachedSignature == true) { + $scope.clientEdit.attributes["id.token.as.detached.signature"] = "true"; + } else { + $scope.clientEdit.attributes["id.token.as.detached.signature"] = "false"; + } + // KEYCLOAK-6771 Certificate Bound Token // https://tools.ietf.org/html/draft-ietf-oauth-mtls-08#section-3 if ($scope.tlsClientCertificateBoundAccessTokens == true) { diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html b/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html index 073c170148..5245a0b25a 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/client-detail.html @@ -762,6 +762,14 @@ {{:: 'tls-client-certificate-bound-access-tokens.tooltip' | translate}} +
+ +
+ +
+ {{:: 'use-idtoken-as-detached-signature.tooltip' | translate}} +
+