Supporting OAuth 2.1 for public clients

closes #25316

Co-authored-by: shigeyuki kabano <shigeyuki.kabano.sj@hitachi.com>
Signed-off-by: Takashi Norimatsu <takashi.norimatsu.ws@hitachi.com>
This commit is contained in:
Takashi Norimatsu 2023-12-19 04:47:06 +09:00 committed by Marek Posolda
parent 17a4902c4a
commit 1e12b15890
9 changed files with 334 additions and 14 deletions

View file

@ -12,6 +12,8 @@ side may need to be still done manually or through some other third-party soluti
==== OAuth 2.1 client profiles
To make sure that your clients are OAuth 2.1 compliant, you can configure Client Policies in your realm as described in the link:{adminguide_link}#_client_policies[{adminguide_name}]
and link them to the global client profiles for OAuth 2.1 support, which are automatically available in each realm. You can use `oauth-2-1-for-confidential-client` profile for confidential clients.
and link them to the global client profiles for OAuth 2.1 support, which are automatically available in each realm. You can use either `oauth-2-1-for-confidential-client` profile for confidential clients or `oauth-2-1-for-public-client` profile for public clients.
NOTE: OAuth 2.1 specification is still a draft and it may change in the future. Hence the {project_name} built-in OAuth 2.1 client profiles can change as well.
NOTE: When using OAuth 2.1 profile for public clients, it is recommended to use DPoP preview feature as described in the link:{adminguide_link}#_dpop-bound-tokens[{adminguide_name}] because DPoP binds an access token and a refresh token together with the public part of a client's key pair. This binding prevents an attacker from using stolen tokens.

View file

@ -6,7 +6,7 @@ To make it easy to secure client applications, it is beneficial to realize the f
* Setting policies on what configuration a client can have
* Validation of client configurations
* Conformance to a required security standards and profiles such as Financial-grade API (FAPI)and OAuth 2.1
* Conformance to a required security standards and profiles such as Financial-grade API (FAPI) and OAuth 2.1
To realize these points in a unified way, _Client Policies_ concept is introduced.

View file

@ -2,4 +2,4 @@
=== OAuth 2.1 compliance
To make sure that {project_name} server will validate your client to be more secure and OAuth 2.1 compliant, you can configure client policies
for the OAuth 2.1 support. Details are described in the OAuth 2.1 section of link:{adapterguide_link}#_oauth21-support[{adapterguide_name}].
for the OAuth 2.1 support. Details are described in the OAuth 2.1 section of link:{adapterguide_link}#_oauth21-support[{adapterguide_name}].

View file

@ -85,6 +85,12 @@ public class DPoPBindEnforcerExecutor implements ClientPolicyExecutorProvider<DP
@Override
public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException {
if (!Profile.isFeatureEnabled(Feature.DPOP)) {
logger.warnf("DPoP executor is used, but DPOP feature is disabled. So DPOP is not enforced for the clients. " +
"Please enable DPOP feature in order to be able to have DPOP checks applied.");
return;
}
HttpRequest request = session.getContext().getHttpRequest();
switch (context.getEvent()) {
case REGISTER:
@ -105,7 +111,6 @@ public class DPoPBindEnforcerExecutor implements ClientPolicyExecutorProvider<DP
break;
case TOKEN_REVOKE:
checkTokenRevoke((TokenRevokeContext) context, request);
break;
default:
return;
}

View file

@ -28,7 +28,7 @@ import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderConfigProperty;
public class DPoPBindEnforcerExecutorFactory implements ClientPolicyExecutorProviderFactory, EnvironmentDependentProviderFactory {
public class DPoPBindEnforcerExecutorFactory implements ClientPolicyExecutorProviderFactory {
public static final String PROVIDER_ID = "dpop-bind-enforcer";
@ -68,9 +68,4 @@ public class DPoPBindEnforcerExecutorFactory implements ClientPolicyExecutorPro
public List<ProviderConfigProperty> getConfigProperties() {
return Collections.singletonList(AUTO_CONFIGURE_PROPERTY);
}
@Override
public boolean isSupported() {
return Profile.isFeatureEnabled(Feature.DPOP);
}
}

View file

@ -300,7 +300,7 @@
"client-jwt",
"client-x509"
],
"default-client-authenticator": "client-jwt"
"default-client-authenticator": "client-jwt"
}
},
{
@ -336,6 +336,44 @@
}
}
]
},
{
"name": "oauth-2-1-for-public-client",
"description": "Client profile, which enforce public clients to conform 'OAuth 2.1' specification.",
"executors": [
{
"executor": "secure-redirect-uris-enforcer",
"configuration": {
"allow-ipv4-loopback-address": "true",
"allow-ipv6-loopback-address": "true",
"allow-private-use-uri-scheme": "true"
}
},
{
"executor": "pkce-enforcer",
"configuration": {
"auto-configure": "true"
}
},
{
"executor": "dpop-bind-enforcer",
"configuration": {
"auto-configure": "true"
}
},
{
"executor": "reject-implicit-grant",
"configuration": {
"auto-configure": "true"
}
},
{
"executor": "reject-ropc-grant",
"configuration": {
"auto-configure": "true"
}
}
]
}
]
}

View file

@ -0,0 +1,280 @@
/*
* Copyright 2024 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.client;
import jakarta.ws.rs.HttpMethod;
import jakarta.ws.rs.core.Response;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.client.registration.ClientRegistrationException;
import org.keycloak.common.Profile;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.common.util.Time;
import org.keycloak.crypto.Algorithm;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.protocol.oidc.utils.PkceUtils;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.oidc.OIDCClientRepresentation;
import org.keycloak.representations.oidc.TokenMetadataRepresentation;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.testsuite.AssertEvents;
import org.keycloak.services.clientpolicy.condition.AnyClientConditionFactory;
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
import org.keycloak.testsuite.util.ClientPoliciesUtil;
import org.keycloak.testsuite.util.Matchers;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.util.JsonSerialization;
import java.security.KeyPair;
import java.util.List;
import java.util.UUID;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createAnyClientConditionConfig;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createEcJwk;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.generateEcdsaKey;
import static org.keycloak.testsuite.util.ClientPoliciesUtil.generateSignedDPoPProof;
@EnableFeature(value = Profile.Feature.DPOP, skipRestart = true)
public class OAuth2_1PublicClientTest extends AbstractFAPITest {
private static final String OAUTH2_1_PUBLIC_CLIENT_PROFILE_NAME = "oauth-2-1-for-public-client";
private static final String DPOP_JWT_HEADER_TYPE = "dpop+jwt";
private KeyPair ecKeyPair;
private JWK jwkEc;
@Before
public void beforeDPoPTest() throws Exception {
ecKeyPair = generateEcdsaKey("secp256r1");
jwkEc = createEcJwk(ecKeyPair.getPublic());
}
@After
public void revertPolicies() throws ClientPolicyException {
oauth.openid(true);
oauth.responseType(OIDCResponseType.CODE);
oauth.nonce(null);
oauth.codeChallenge(null);
oauth.codeChallengeMethod(null);
oauth.dpopProof(null);
updatePolicies("{}");
}
@Test
public void testOAuth2_1NotAllowImplicitGrant() throws Exception {
String clientId = generateSuffixedName(CLIENT_NAME);
createClientByAdmin(clientId, (ClientRepresentation clientRep) -> {
clientRep.setStandardFlowEnabled(Boolean.TRUE);
clientRep.setImplicitFlowEnabled(Boolean.TRUE);
clientRep.setPublicClient(Boolean.TRUE);
});
// setup profiles and policies
setupPolicyOAuth2_1PublicClientForAllClient();
setValidPkce(clientId);
// implicit grant
testProhibitedImplicitOrHybridFlow(false, OIDCResponseType.TOKEN, generateNonce()
);
// hybrid grant
testProhibitedImplicitOrHybridFlow(true, OIDCResponseType.TOKEN + " " + OIDCResponseType.ID_TOKEN,
generateNonce());
// hybrid grant
testProhibitedImplicitOrHybridFlow(true, OIDCResponseType.TOKEN + " " + OIDCResponseType.CODE,
generateNonce());
// hybrid grant
testProhibitedImplicitOrHybridFlow(true, OIDCResponseType.TOKEN + " " + OIDCResponseType.CODE + " " + OIDCResponseType.ID_TOKEN,
generateNonce());
}
@Test
public void testOAuth2_1NotAllowResourceOwnerPasswordCredentialsGrant() throws Exception {
String clientId = generateSuffixedName(CLIENT_NAME);
createClientByAdmin(clientId, (ClientRepresentation clientRep) -> {
clientRep.setStandardFlowEnabled(Boolean.TRUE);
clientRep.setPublicClient(Boolean.TRUE);
});
// setup profiles and policies
setupPolicyOAuth2_1PublicClientForAllClient();
oauth.clientId(clientId);
OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest(null, TEST_USER_NAME, TEST_USER_PASSWORD, null);
assertEquals(400, response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_GRANT, response.getError());
assertEquals("resource owner password credentials grant is prohibited.", response.getErrorDescription());
}
@Test
public void testOAuth2_1ProofKeyForCodeExchange() throws Exception {
// setup profiles and policies
setupPolicyOAuth2_1PublicClientForAllClient();
String clientId = generateSuffixedName(CLIENT_NAME);
String cId = createClientByAdmin(clientId, (ClientRepresentation clientRep) -> {
clientRep.setPublicClient(Boolean.TRUE);
clientRep.setRedirectUris(List.of(AssertEvents.DEFAULT_REDIRECT_URI));
});
assertEquals(OAuth2Constants.PKCE_METHOD_S256, OIDCAdvancedConfigWrapper.fromClientRepresentation(getClientByAdmin(cId)).getPkceCodeChallengeMethod());
failLoginByNotFollowingPKCE(clientId);
}
@Test
public void testOAuth2_1RedirectUris() throws Exception {
// setup profiles and policies
setupPolicyOAuth2_1PublicClientForAllClient();
// registration with invalid redirect_uri - fail
try {
createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) ->
clientRep.setRedirectUris(List.of("http://example.com/app")));
} catch (ClientRegistrationException cre) {
assertEquals(ERR_MSG_CLIENT_REG_FAIL, cre.getMessage());
}
// registration with valid redirect_uri- success
String clientId = generateSuffixedName(CLIENT_NAME);
String cId = createClientByAdmin(clientId, (ClientRepresentation clientRep) -> {
clientRep.setPublicClient(Boolean.TRUE);
clientRep.setRedirectUris(List.of(AssertEvents.DEFAULT_REDIRECT_URI));
});
assertEquals(AssertEvents.DEFAULT_REDIRECT_URI, getClientByAdmin(cId).getRedirectUris().get(0));
// update with valid redirect_uri - fail
try {
createClientDynamically(clientId, (OIDCClientRepresentation clientRep) ->
clientRep.setRedirectUris(List.of("https://localhost/app")));
} catch (ClientRegistrationException cre) {
assertEquals(ERR_MSG_CLIENT_REG_FAIL, cre.getMessage());
}
// authorization with invalid redirect_uri request - fail
setValidPkce(clientId);
oauth.redirectUri("https://localhost/app");
oauth.openLoginForm();
assertTrue(errorPage.isCurrent());
}
@Test
public void testOAuth2_1DPoPSenderConstrainedToken() throws Exception {
// setup profiles and policies
setupPolicyOAuth2_1PublicClientForAllClient();
// registration (auto-config) - success
String clientId = generateSuffixedName(CLIENT_NAME);
String cId = createClientByAdmin(clientId, (ClientRepresentation clientRep) -> {
clientRep.setPublicClient(Boolean.TRUE);
clientRep.setRedirectUris(List.of(AssertEvents.DEFAULT_REDIRECT_URI));
});
assertTrue(OIDCAdvancedConfigWrapper.fromClientRepresentation(getClientByAdmin(cId)).isUseDPoP());
// authorization request - success
setValidPkce(clientId);
oauth.clientId(clientId);
oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
// token request with DPoP Proof - success
JWSHeader jwsEcHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.ES256, DPOP_JWT_HEADER_TYPE, jwkEc.getKeyId(), jwkEc);
String dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST, oauth.getAccessTokenUrl(), (long) Time.currentTime(), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate());
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
oauth.dpopProof(dpopProofEcEncoded);
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, null);
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
oauth.verifyToken(response.getAccessToken());
// token refresh request with DPoP Proof - success
dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST, oauth.getAccessTokenUrl(), (long) Time.currentTime(), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate());
oauth.dpopProof(dpopProofEcEncoded);
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), null);
assertEquals(Response.Status.OK.getStatusCode(), response.getStatusCode());
// userinfo request with DPoP Proof - success
dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET, oauth.getUserInfoUrl(), (long) Time.currentTime(), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate());
oauth.dpopProof(dpopProofEcEncoded);
OAuthClient.UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
assertEquals(TEST_USER_NAME, userInfoResponse.getUserInfo().getPreferredUsername());
oauth.idTokenHint(response.getIdToken()).openLogout();
// revoke token with a valid DPoP proof - success
dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST, oauth.getTokenRevocationUrl(), (long) Time.currentTime(), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate());
oauth.dpopProof(dpopProofEcEncoded);
CloseableHttpResponse closableHttpResponse = oauth.doTokenRevoke(response.getAccessToken(), "access_token", null);
assertThat(closableHttpResponse, Matchers.statusCodeIsHC(Response.Status.OK));
String introspectionResponse = oauth.introspectAccessTokenWithClientCredential(clientId, null, response.getAccessToken());
TokenMetadataRepresentation tokenMetadataRepresentation = JsonSerialization.readValue(introspectionResponse, TokenMetadataRepresentation.class);
assertFalse(tokenMetadataRepresentation.isActive());
oauth.idTokenHint(response.getIdToken()).openLogout();
}
private void setupPolicyOAuth2_1PublicClientForAllClient() throws Exception {
String json = (new ClientPoliciesUtil.ClientPoliciesBuilder()).addPolicy(
(new ClientPoliciesUtil.ClientPolicyBuilder()).createPolicy("MyPolicy", "Policy for enable OAuth 2.1 public client profile for all clients", Boolean.TRUE)
.addCondition(AnyClientConditionFactory.PROVIDER_ID,
createAnyClientConditionConfig())
.addProfile(OAUTH2_1_PUBLIC_CLIENT_PROFILE_NAME)
.toRepresentation()
).toString();
updatePolicies(json);
}
private void testProhibitedImplicitOrHybridFlow(boolean isOpenid, String responseType, String nonce) {
oauth.openid(isOpenid);
oauth.responseType(responseType);
oauth.nonce(nonce);
oauth.openLoginForm();
assertEquals(OAuthErrorException.INVALID_REQUEST, oauth.getCurrentFragment().get(OAuth2Constants.ERROR));
assertEquals("Implicit/Hybrid flow is prohibited.", oauth.getCurrentFragment().get(OAuth2Constants.ERROR_DESCRIPTION));
}
private void setValidPkce(String clientId) throws Exception {
oauth.clientId(clientId);
String codeVerifier = PkceUtils.generateCodeVerifier();
String codeChallenge = generateS256CodeChallenge(codeVerifier);
oauth.codeChallenge(codeChallenge);
oauth.codeChallengeMethod(OAuth2Constants.PKCE_METHOD_S256);
oauth.codeVerifier(codeVerifier);
}
private String generateNonce() {
return SecretGenerator.getInstance().randomString(16);
}
}

View file

@ -202,8 +202,8 @@ public abstract class AbstractClientPoliciesTest extends AbstractKeycloakTest {
protected static final String FAPI_CIBA_PROFILE_NAME = "fapi-ciba";
protected static final String FAPI2_SECURITY_PROFILE_NAME = "fapi-2-security-profile";
protected static final String FAPI2_MESSAGE_SIGNING_PROFILE_NAME = "fapi-2-message-signing";
protected static final String OAUTH2_1_CONFIDENTIAL_CLIENT_PROFILE_NAME = "oauth-2-1-for-confidential-client";
protected static final String OAUTH2_1_PUBLIC_CLIENT_PROFILE_NAME = "oauth-2-1-for-public-client";
protected static final String ERR_MSG_MISSING_NONCE = "Missing parameter: nonce";
protected static final String ERR_MSG_MISSING_STATE = "Missing parameter: state";
@ -338,7 +338,7 @@ public abstract class AbstractClientPoliciesTest extends AbstractKeycloakTest {
ClientProfilesRepresentation actualProfilesRep = getProfilesWithGlobals();
// same profiles
assertExpectedProfiles(actualProfilesRep, Arrays.asList(FAPI1_BASELINE_PROFILE_NAME, FAPI1_ADVANCED_PROFILE_NAME, FAPI_CIBA_PROFILE_NAME, FAPI2_SECURITY_PROFILE_NAME, FAPI2_MESSAGE_SIGNING_PROFILE_NAME, OAUTH2_1_CONFIDENTIAL_CLIENT_PROFILE_NAME), Arrays.asList("ordinal-test-profile", "lack-of-builtin-field-test-profile"));
assertExpectedProfiles(actualProfilesRep, Arrays.asList(FAPI1_BASELINE_PROFILE_NAME, FAPI1_ADVANCED_PROFILE_NAME, FAPI_CIBA_PROFILE_NAME, FAPI2_SECURITY_PROFILE_NAME, FAPI2_MESSAGE_SIGNING_PROFILE_NAME, OAUTH2_1_CONFIDENTIAL_CLIENT_PROFILE_NAME, OAUTH2_1_PUBLIC_CLIENT_PROFILE_NAME), Arrays.asList("ordinal-test-profile", "lack-of-builtin-field-test-profile"));
// each profile - fapi-1-baseline
ClientProfileRepresentation actualProfileRep = getProfileRepresentation(actualProfilesRep, FAPI1_BASELINE_PROFILE_NAME, true);

View file

@ -84,7 +84,7 @@ public class ClientPoliciesLoadUpdateTest extends AbstractClientPoliciesTest {
ClientProfilesRepresentation actualProfilesRep = getProfilesWithGlobals();
// same profiles
assertExpectedProfiles(actualProfilesRep, Arrays.asList(FAPI1_BASELINE_PROFILE_NAME, FAPI1_ADVANCED_PROFILE_NAME, FAPI_CIBA_PROFILE_NAME, FAPI2_SECURITY_PROFILE_NAME, FAPI2_MESSAGE_SIGNING_PROFILE_NAME, OAUTH2_1_CONFIDENTIAL_CLIENT_PROFILE_NAME), Collections.emptyList());
assertExpectedProfiles(actualProfilesRep, Arrays.asList(FAPI1_BASELINE_PROFILE_NAME, FAPI1_ADVANCED_PROFILE_NAME, FAPI_CIBA_PROFILE_NAME, FAPI2_SECURITY_PROFILE_NAME, FAPI2_MESSAGE_SIGNING_PROFILE_NAME, OAUTH2_1_CONFIDENTIAL_CLIENT_PROFILE_NAME, OAUTH2_1_PUBLIC_CLIENT_PROFILE_NAME), Collections.emptyList());
// each profile - fapi-1-baseline
ClientProfileRepresentation actualProfileRep = getProfileRepresentation(actualProfilesRep, FAPI1_BASELINE_PROFILE_NAME, true);