Merge pull request #3014 from mposolda/KEYCLOAK-3222
OIDC client auth fixes & tests
This commit is contained in:
commit
8bdfd57e9b
11 changed files with 271 additions and 7 deletions
|
@ -54,6 +54,10 @@ public class JWTClientCredentialsProvider implements ClientCredentialsProvider {
|
|||
this.tokenTimeout = tokenTimeout;
|
||||
}
|
||||
|
||||
protected int getTokenTimeout() {
|
||||
return tokenTimeout;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(KeycloakDeployment deployment, Object config) {
|
||||
if (config == null || !(config instanceof Map)) {
|
||||
|
|
|
@ -26,6 +26,8 @@ public interface OAuth2Constants {
|
|||
|
||||
String CLIENT_ID = "client_id";
|
||||
|
||||
String CLIENT_SECRET = "client_secret";
|
||||
|
||||
String ERROR = "error";
|
||||
|
||||
String ERROR_DESCRIPTION = "error_description";
|
||||
|
|
|
@ -86,7 +86,7 @@ public class ClientIdAndSecretAuthenticator extends AbstractClientAuthenticator
|
|||
|
||||
if (formData != null && client_id == null) {
|
||||
client_id = formData.getFirst(OAuth2Constants.CLIENT_ID);
|
||||
clientSecret = formData.getFirst("client_secret");
|
||||
clientSecret = formData.getFirst(OAuth2Constants.CLIENT_SECRET);
|
||||
}
|
||||
|
||||
if (client_id == null) {
|
||||
|
|
|
@ -32,6 +32,7 @@ import javax.ws.rs.core.Response;
|
|||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.authentication.AuthenticationFlowError;
|
||||
import org.keycloak.authentication.ClientAuthenticationFlowContext;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.jose.jws.JWSInput;
|
||||
import org.keycloak.jose.jws.crypto.RSAProvider;
|
||||
import org.keycloak.models.AuthenticationExecutionModel;
|
||||
|
@ -145,6 +146,11 @@ public class JWTClientAuthenticator extends AbstractClientAuthenticator {
|
|||
throw new RuntimeException("Token is not active");
|
||||
}
|
||||
|
||||
// KEYCLOAK-2986
|
||||
if (token.getExpiration() == 0 && token.getIssuedAt() + 10 < Time.currentTime()) {
|
||||
throw new RuntimeException("Token is not active");
|
||||
}
|
||||
|
||||
context.success();
|
||||
} catch (Exception e) {
|
||||
logger.errorValidatingAssertion(e);
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
package org.keycloak.protocol.oidc;
|
||||
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.jose.jws.Algorithm;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.protocol.oidc.endpoints.TokenEndpoint;
|
||||
|
@ -39,7 +40,7 @@ import java.util.List;
|
|||
*/
|
||||
public class OIDCWellKnownProvider implements WellKnownProvider {
|
||||
|
||||
public static final List<String> DEFAULT_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED = list("RS256");
|
||||
public static final List<String> DEFAULT_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED = list(Algorithm.RS256.toString());
|
||||
|
||||
public static final List<String> DEFAULT_GRANT_TYPES_SUPPORTED = list(OAuth2Constants.AUTHORIZATION_CODE, OAuth2Constants.IMPLICIT, OAuth2Constants.REFRESH_TOKEN, OAuth2Constants.PASSWORD, OAuth2Constants.CLIENT_CREDENTIALS);
|
||||
|
||||
|
@ -49,6 +50,11 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
|
|||
|
||||
public static final List<String> DEFAULT_RESPONSE_MODES_SUPPORTED = list("query", "fragment", "form_post");
|
||||
|
||||
// Should be rather retrieved dynamically based on available ClientAuthenticator providers?
|
||||
public static final List<String> DEFAULT_CLIENT_AUTH_METHODS_SUPPORTED = list("client_secret_basic", "client_secret_post", "private_key_jwt");
|
||||
|
||||
public static final List<String> DEFAULT_CLIENT_AUTH_SIGNING_ALG_VALUES_SUPPORTED = list(Algorithm.RS256.toString());
|
||||
|
||||
private KeycloakSession session;
|
||||
|
||||
public OIDCWellKnownProvider(KeycloakSession session) {
|
||||
|
@ -78,6 +84,9 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
|
|||
config.setResponseModesSupported(DEFAULT_RESPONSE_MODES_SUPPORTED);
|
||||
config.setGrantTypesSupported(DEFAULT_GRANT_TYPES_SUPPORTED);
|
||||
|
||||
config.setTokenEndpointAuthMethodsSupported(DEFAULT_CLIENT_AUTH_METHODS_SUPPORTED);
|
||||
config.setTokenEndpointAuthSigningAlgValuesSupported(DEFAULT_CLIENT_AUTH_SIGNING_ALG_VALUES_SUPPORTED);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
|
|
|
@ -28,6 +28,8 @@ import org.keycloak.wellknown.WellKnownProviderFactory;
|
|||
*/
|
||||
public class OIDCWellKnownProviderFactory implements WellKnownProviderFactory {
|
||||
|
||||
public static final String PROVIDER_ID = "openid-configuration";
|
||||
|
||||
@Override
|
||||
public WellKnownProvider create(KeycloakSession session) {
|
||||
return new OIDCWellKnownProvider(session);
|
||||
|
@ -47,7 +49,7 @@ public class OIDCWellKnownProviderFactory implements WellKnownProviderFactory {
|
|||
|
||||
@Override
|
||||
public String getId() {
|
||||
return "openid-configuration";
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -70,6 +70,12 @@ public class OIDCConfigurationRepresentation {
|
|||
@JsonProperty("registration_endpoint")
|
||||
private String registrationEndpoint;
|
||||
|
||||
@JsonProperty("token_endpoint_auth_methods_supported")
|
||||
private List<String> tokenEndpointAuthMethodsSupported;
|
||||
|
||||
@JsonProperty("token_endpoint_auth_signing_alg_values_supported")
|
||||
private List<String> tokenEndpointAuthSigningAlgValuesSupported;
|
||||
|
||||
protected Map<String, Object> otherClaims = new HashMap<String, Object>();
|
||||
|
||||
public String getIssuer() {
|
||||
|
@ -176,6 +182,22 @@ public class OIDCConfigurationRepresentation {
|
|||
this.registrationEndpoint = registrationEndpoint;
|
||||
}
|
||||
|
||||
public List<String> getTokenEndpointAuthMethodsSupported() {
|
||||
return tokenEndpointAuthMethodsSupported;
|
||||
}
|
||||
|
||||
public void setTokenEndpointAuthMethodsSupported(List<String> tokenEndpointAuthMethodsSupported) {
|
||||
this.tokenEndpointAuthMethodsSupported = tokenEndpointAuthMethodsSupported;
|
||||
}
|
||||
|
||||
public List<String> getTokenEndpointAuthSigningAlgValuesSupported() {
|
||||
return tokenEndpointAuthSigningAlgValuesSupported;
|
||||
}
|
||||
|
||||
public void setTokenEndpointAuthSigningAlgValuesSupported(List<String> tokenEndpointAuthSigningAlgValuesSupported) {
|
||||
this.tokenEndpointAuthSigningAlgValuesSupported = tokenEndpointAuthSigningAlgValuesSupported;
|
||||
}
|
||||
|
||||
@JsonAnyGetter
|
||||
public Map<String, Object> getOtherClaims() {
|
||||
return otherClaims;
|
||||
|
|
|
@ -99,6 +99,10 @@ public class RealmsResource {
|
|||
return uriInfo.getBaseUriBuilder().path(RealmsResource.class).path(RealmsResource.class, "getBrokerService");
|
||||
}
|
||||
|
||||
public static UriBuilder wellKnownProviderUrl(UriBuilder builder) {
|
||||
return builder.path(RealmsResource.class).path(RealmsResource.class, "getWellKnown");
|
||||
}
|
||||
|
||||
@Path("{realm}/protocol/{protocol}")
|
||||
public Object getProtocol(final @PathParam("realm") String name,
|
||||
final @PathParam("protocol") String protocol) {
|
||||
|
|
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
* Copyright 2016 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.oauth;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.http.NameValuePair;
|
||||
import org.apache.http.client.entity.UrlEncodedFormEntity;
|
||||
import org.apache.http.client.methods.HttpPost;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.apache.http.impl.client.DefaultHttpClient;
|
||||
import org.apache.http.message.BasicNameValuePair;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.representations.AccessToken;
|
||||
import org.keycloak.representations.idm.EventRepresentation;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.testsuite.AbstractKeycloakTest;
|
||||
import org.keycloak.testsuite.AssertEvents;
|
||||
import org.keycloak.testsuite.admin.AbstractAdminTest;
|
||||
import org.keycloak.testsuite.util.OAuthClient;
|
||||
|
||||
import static org.hamcrest.Matchers.allOf;
|
||||
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
|
||||
import static org.hamcrest.Matchers.lessThanOrEqualTo;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
/**
|
||||
* Test for "client_secret_post" client authentication (clientID + clientSecret sent in the POST body instead of in "Authorization: Basic" header)
|
||||
*
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
*/
|
||||
public class ClientAuthPostMethodTest extends AbstractKeycloakTest {
|
||||
|
||||
@Rule
|
||||
public AssertEvents events = new AssertEvents(this);
|
||||
|
||||
|
||||
@Override
|
||||
public void addTestRealms(List<RealmRepresentation> testRealms) {
|
||||
RealmRepresentation realm = AbstractAdminTest.loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class);
|
||||
testRealms.add(realm);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testPostAuthentication() {
|
||||
oauth.doLogin("test-user@localhost", "password");
|
||||
|
||||
EventRepresentation loginEvent = events.expectLogin().assertEvent();
|
||||
|
||||
String sessionId = loginEvent.getSessionId();
|
||||
String codeId = loginEvent.getDetails().get(Details.CODE_ID);
|
||||
|
||||
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
|
||||
OAuthClient.AccessTokenResponse response = doAccessTokenRequestPostAuth(code, "password");
|
||||
|
||||
assertEquals(200, response.getStatusCode());
|
||||
|
||||
Assert.assertThat(response.getExpiresIn(), allOf(greaterThanOrEqualTo(250), lessThanOrEqualTo(300)));
|
||||
Assert.assertThat(response.getRefreshExpiresIn(), allOf(greaterThanOrEqualTo(1750), lessThanOrEqualTo(1800)));
|
||||
|
||||
AccessToken token = oauth.verifyToken(response.getAccessToken());
|
||||
|
||||
EventRepresentation event = events.expectCodeToToken(codeId, sessionId).assertEvent();
|
||||
assertEquals(token.getId(), event.getDetails().get(Details.TOKEN_ID));
|
||||
assertEquals(oauth.verifyRefreshToken(response.getRefreshToken()).getId(), event.getDetails().get(Details.REFRESH_TOKEN_ID));
|
||||
assertEquals(sessionId, token.getSessionState());
|
||||
}
|
||||
|
||||
|
||||
private OAuthClient.AccessTokenResponse doAccessTokenRequestPostAuth(String code, String clientSecret) {
|
||||
CloseableHttpClient client = new DefaultHttpClient();
|
||||
try {
|
||||
HttpPost post = new HttpPost(oauth.getAccessTokenUrl());
|
||||
|
||||
List<NameValuePair> parameters = new LinkedList<NameValuePair>();
|
||||
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.AUTHORIZATION_CODE));
|
||||
parameters.add(new BasicNameValuePair(OAuth2Constants.CODE, code));
|
||||
parameters.add(new BasicNameValuePair(OAuth2Constants.REDIRECT_URI, oauth.getRedirectUri()));
|
||||
|
||||
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, oauth.getClientId()));
|
||||
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_SECRET, clientSecret));
|
||||
|
||||
|
||||
UrlEncodedFormEntity formEntity;
|
||||
try {
|
||||
formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
post.setEntity(formEntity);
|
||||
|
||||
try {
|
||||
return new OAuthClient.AccessTokenResponse(client.execute(post));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to retrieve access token", e);
|
||||
}
|
||||
} finally {
|
||||
oauth.closeClient(client);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
|
@ -613,7 +613,7 @@ public class ClientAuthSignedJWTTest extends AbstractKeycloakTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
@Ignore // Waiting for KEYCLOAK-2986 to be implemented
|
||||
// KEYCLOAK-2986
|
||||
public void testMissingExpirationClaim() throws Exception {
|
||||
// Missing only exp; the lifespan should be calculated from issuedAt
|
||||
OAuthClient.AccessTokenResponse response = testMissingClaim("expiration");
|
||||
|
@ -840,9 +840,7 @@ public class ClientAuthSignedJWTTest extends AbstractKeycloakTest {
|
|||
|
||||
int now = Time.currentTime();
|
||||
if (isClaimEnabled("issuedAt")) reqToken.issuedAt(now);
|
||||
// For the time being there's no getter for tokenTimeout in JWTClientCredentialsProvider
|
||||
// This is fine because KC doesn't care when exp claim is missing (see KEYCLOAK-2986)
|
||||
/*if (isClaimEnabled("expiration")) reqToken.expiration(now + getTokenTimeout());*/
|
||||
if (isClaimEnabled("expiration")) reqToken.expiration(now + getTokenTimeout());
|
||||
if (isClaimEnabled("notBefore")) reqToken.notBefore(now);
|
||||
|
||||
return reqToken;
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* Copyright 2016 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;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
|
||||
import javax.ws.rs.client.Client;
|
||||
import javax.ws.rs.client.ClientBuilder;
|
||||
import javax.ws.rs.client.WebTarget;
|
||||
import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.core.UriBuilder;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.jose.jws.Algorithm;
|
||||
import org.keycloak.protocol.oidc.OIDCWellKnownProviderFactory;
|
||||
import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentation;
|
||||
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.services.resources.RealmsResource;
|
||||
import org.keycloak.testsuite.AbstractKeycloakTest;
|
||||
import org.keycloak.testsuite.Assert;
|
||||
import org.keycloak.testsuite.admin.AbstractAdminTest;
|
||||
import org.keycloak.testsuite.util.OAuthClient;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
*/
|
||||
public class OIDCWellKnownProviderTest extends AbstractKeycloakTest {
|
||||
|
||||
@Override
|
||||
public void addTestRealms(List<RealmRepresentation> testRealms) {
|
||||
RealmRepresentation realm = AbstractAdminTest.loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class);
|
||||
testRealms.add(realm);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void testDiscovery() {
|
||||
Client client = ClientBuilder.newClient();
|
||||
try {
|
||||
OIDCConfigurationRepresentation oidcConfig = getOIDCDiscoveryConfiguration(client);
|
||||
|
||||
// Support standard + implicit + hybrid flow
|
||||
assertContains(oidcConfig.getResponseTypesSupported(), OAuth2Constants.CODE, OIDCResponseType.ID_TOKEN, "id_token token", "code id_token", "code token", "code id_token token");
|
||||
assertContains(oidcConfig.getGrantTypesSupported(), OAuth2Constants.AUTHORIZATION_CODE, OAuth2Constants.IMPLICIT);
|
||||
assertContains(oidcConfig.getResponseModesSupported(), "query", "fragment");
|
||||
|
||||
Assert.assertNames(oidcConfig.getSubjectTypesSupported(), "public");
|
||||
Assert.assertNames(oidcConfig.getIdTokenSigningAlgValuesSupported(), Algorithm.RS256.toString());
|
||||
|
||||
// Client authentication
|
||||
Assert.assertNames(oidcConfig.getTokenEndpointAuthMethodsSupported(), "client_secret_basic", "client_secret_post", "private_key_jwt");
|
||||
Assert.assertNames(oidcConfig.getTokenEndpointAuthSigningAlgValuesSupported(), Algorithm.RS256.toString());
|
||||
System.out.println("Fopo");
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
private OIDCConfigurationRepresentation getOIDCDiscoveryConfiguration(Client client) {
|
||||
UriBuilder builder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT);
|
||||
URI oidcDiscoveryUri = RealmsResource.wellKnownProviderUrl(builder).build("test", OIDCWellKnownProviderFactory.PROVIDER_ID);
|
||||
WebTarget oidcDiscoveryTarget = client.target(oidcDiscoveryUri);
|
||||
|
||||
Response response = oidcDiscoveryTarget.request().get();
|
||||
return response.readEntity(OIDCConfigurationRepresentation.class);
|
||||
}
|
||||
|
||||
private void assertContains(List<String> actual, String... expected) {
|
||||
for (String exp : expected) {
|
||||
Assert.assertTrue(actual.contains(exp));
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue