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 ==== 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}] 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: 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

@ -85,6 +85,12 @@ public class DPoPBindEnforcerExecutor implements ClientPolicyExecutorProvider<DP
@Override @Override
public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException { 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(); HttpRequest request = session.getContext().getHttpRequest();
switch (context.getEvent()) { switch (context.getEvent()) {
case REGISTER: case REGISTER:
@ -105,7 +111,6 @@ public class DPoPBindEnforcerExecutor implements ClientPolicyExecutorProvider<DP
break; break;
case TOKEN_REVOKE: case TOKEN_REVOKE:
checkTokenRevoke((TokenRevokeContext) context, request); checkTokenRevoke((TokenRevokeContext) context, request);
break;
default: default:
return; return;
} }

View file

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

View file

@ -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 FAPI_CIBA_PROFILE_NAME = "fapi-ciba";
protected static final String FAPI2_SECURITY_PROFILE_NAME = "fapi-2-security-profile"; 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 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_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_NONCE = "Missing parameter: nonce";
protected static final String ERR_MSG_MISSING_STATE = "Missing parameter: state"; protected static final String ERR_MSG_MISSING_STATE = "Missing parameter: state";
@ -338,7 +338,7 @@ public abstract class AbstractClientPoliciesTest extends AbstractKeycloakTest {
ClientProfilesRepresentation actualProfilesRep = getProfilesWithGlobals(); ClientProfilesRepresentation actualProfilesRep = getProfilesWithGlobals();
// same profiles // 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 // each profile - fapi-1-baseline
ClientProfileRepresentation actualProfileRep = getProfileRepresentation(actualProfilesRep, FAPI1_BASELINE_PROFILE_NAME, true); ClientProfileRepresentation actualProfileRep = getProfileRepresentation(actualProfilesRep, FAPI1_BASELINE_PROFILE_NAME, true);

View file

@ -84,7 +84,7 @@ public class ClientPoliciesLoadUpdateTest extends AbstractClientPoliciesTest {
ClientProfilesRepresentation actualProfilesRep = getProfilesWithGlobals(); ClientProfilesRepresentation actualProfilesRep = getProfilesWithGlobals();
// same profiles // 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 // each profile - fapi-1-baseline
ClientProfileRepresentation actualProfileRep = getProfileRepresentation(actualProfilesRep, FAPI1_BASELINE_PROFILE_NAME, true); ClientProfileRepresentation actualProfileRep = getProfileRepresentation(actualProfilesRep, FAPI1_BASELINE_PROFILE_NAME, true);