Merge pull request #3174 from mposolda/master

KEYCLOAK-3416 Add support for signed Userinfo requests
This commit is contained in:
Marek Posolda 2016-08-30 22:18:09 +02:00 committed by GitHub
commit e8efe10fc8
15 changed files with 367 additions and 8 deletions

View file

@ -16,12 +16,31 @@
*/ */
package org.keycloak.representations; package org.keycloak.representations;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import org.keycloak.json.StringOrArrayDeserializer;
import org.keycloak.json.StringOrArraySerializer;
/** /**
* @author pedroigor * @author pedroigor
*/ */
public class UserInfo { public class UserInfo {
// Should be in signed UserInfo response
@JsonProperty("iss")
protected String issuer;
@JsonProperty("aud")
@JsonSerialize(using = StringOrArraySerializer.class)
@JsonDeserialize(using = StringOrArrayDeserializer.class)
protected String[] audience;
@JsonProperty("sub") @JsonProperty("sub")
protected String sub; protected String sub;
@ -85,6 +104,34 @@ public class UserInfo {
@JsonProperty("claims_locales") @JsonProperty("claims_locales")
protected String claimsLocales; protected String claimsLocales;
protected Map<String, Object> otherClaims = new HashMap<>();
public String getIssuer() {
return issuer;
}
public void setIssuer(String issuer) {
this.issuer = issuer;
}
@JsonIgnore
public String[] getAudience() {
return audience;
}
public boolean hasAudience(String audience) {
for (String a : this.audience) {
if (a.equals(audience)) {
return true;
}
}
return false;
}
public void setAudience(String... audience) {
this.audience = audience;
}
public String getSubject() { public String getSubject() {
return this.sub; return this.sub;
} }
@ -260,4 +307,19 @@ public class UserInfo {
public void setClaimsLocales(String claimsLocales) { public void setClaimsLocales(String claimsLocales) {
this.claimsLocales = claimsLocales; this.claimsLocales = claimsLocales;
} }
/**
* This is a map of any other claims and data that might be in the UserInfo. Could be custom claims set up by the auth server
*
* @return
*/
@JsonAnyGetter
public Map<String, Object> getOtherClaims() {
return otherClaims;
}
@JsonAnySetter
public void setOtherClaims(String name, Object value) {
otherClaims.put(name, value);
}
} }

View file

@ -58,4 +58,7 @@ public interface Details {
String CLIENT_AUTH_METHOD = "client_auth_method"; String CLIENT_AUTH_METHOD = "client_auth_method";
String SIGNATURE_REQUIRED = "signature_required";
String SIGNATURE_ALGORITHM = "signature_algorithm";
} }

View file

@ -0,0 +1,94 @@
/*
* 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.protocol.oidc;
import java.util.HashMap;
import org.keycloak.jose.jws.Algorithm;
import org.keycloak.models.ClientModel;
import org.keycloak.representations.idm.ClientRepresentation;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class OIDCAdvancedConfigWrapper {
private static final String USER_INFO_RESPONSE_SIGNATURE_ALG = "user.info.response.signature.alg";
private final ClientModel clientModel;
private final ClientRepresentation clientRep;
private OIDCAdvancedConfigWrapper(ClientModel client, ClientRepresentation clientRep) {
this.clientModel = client;
this.clientRep = clientRep;
}
public static OIDCAdvancedConfigWrapper fromClientModel(ClientModel client) {
return new OIDCAdvancedConfigWrapper(client, null);
}
public static OIDCAdvancedConfigWrapper fromClientRepresentation(ClientRepresentation clientRep) {
return new OIDCAdvancedConfigWrapper(null, clientRep);
}
public Algorithm getUserInfoSignedResponseAlg() {
String alg = getAttribute(USER_INFO_RESPONSE_SIGNATURE_ALG);
return alg==null ? null : Enum.valueOf(Algorithm.class, alg);
}
public void setUserInfoSignedResponseAlg(Algorithm alg) {
String algStr = alg==null ? null : alg.toString();
setAttribute(USER_INFO_RESPONSE_SIGNATURE_ALG, algStr);
}
public boolean isUserInfoSignatureRequired() {
return getUserInfoSignedResponseAlg() != null;
}
private String getAttribute(String attrKey) {
if (clientModel != null) {
return clientModel.getAttribute(attrKey);
} else {
return clientRep.getAttributes()==null ? null : clientRep.getAttributes().get(attrKey);
}
}
private void setAttribute(String attrKey, String attrValue) {
if (clientModel != null) {
if (attrValue != null) {
clientModel.setAttribute(attrKey, attrValue);
} else {
clientModel.removeAttribute(attrKey);
}
} else {
if (attrValue != null) {
if (clientRep.getAttributes() == null) {
clientRep.setAttributes(new HashMap<>());
}
clientRep.getAttributes().put(attrKey, attrValue);
} else {
if (clientRep.getAttributes() != null) {
clientRep.getAttributes().put(attrKey, null);
}
}
}
}
}

View file

@ -48,6 +48,8 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
public static final List<String> DEFAULT_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED = list(Algorithm.RS256.toString()); public static final List<String> DEFAULT_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED = list(Algorithm.RS256.toString());
public static final List<String> DEFAULT_USER_INFO_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); public static final List<String> DEFAULT_GRANT_TYPES_SUPPORTED = list(OAuth2Constants.AUTHORIZATION_CODE, OAuth2Constants.IMPLICIT, OAuth2Constants.REFRESH_TOKEN, OAuth2Constants.PASSWORD, OAuth2Constants.CLIENT_CREDENTIALS);
public static final List<String> DEFAULT_RESPONSE_TYPES_SUPPORTED = list(OAuth2Constants.CODE, OIDCResponseType.NONE, OIDCResponseType.ID_TOKEN, OIDCResponseType.TOKEN, "id_token token", "code id_token", "code token", "code id_token token"); public static final List<String> DEFAULT_RESPONSE_TYPES_SUPPORTED = list(OAuth2Constants.CODE, OIDCResponseType.NONE, OIDCResponseType.ID_TOKEN, OIDCResponseType.TOKEN, "id_token token", "code id_token", "code token", "code id_token token");
@ -90,6 +92,7 @@ public class OIDCWellKnownProvider implements WellKnownProvider {
config.setRegistrationEndpoint(RealmsResource.clientRegistrationUrl(uriInfo).path(ClientRegistrationService.class, "provider").build(realm.getName(), OIDCClientRegistrationProviderFactory.ID).toString()); config.setRegistrationEndpoint(RealmsResource.clientRegistrationUrl(uriInfo).path(ClientRegistrationService.class, "provider").build(realm.getName(), OIDCClientRegistrationProviderFactory.ID).toString());
config.setIdTokenSigningAlgValuesSupported(DEFAULT_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED); config.setIdTokenSigningAlgValuesSupported(DEFAULT_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED);
config.setUserInfoSigningAlgValuesSupported(DEFAULT_USER_INFO_SIGNING_ALG_VALUES_SUPPORTED);
config.setResponseTypesSupported(DEFAULT_RESPONSE_TYPES_SUPPORTED); config.setResponseTypesSupported(DEFAULT_RESPONSE_TYPES_SUPPORTED);
config.setSubjectTypesSupported(DEFAULT_SUBJECT_TYPES_SUPPORTED); config.setSubjectTypesSupported(DEFAULT_SUBJECT_TYPES_SUPPORTED);
config.setResponseModesSupported(DEFAULT_RESPONSE_MODES_SUPPORTED); config.setResponseModesSupported(DEFAULT_RESPONSE_MODES_SUPPORTED);

View file

@ -19,6 +19,7 @@ package org.keycloak.protocol.oidc.endpoints;
import org.jboss.resteasy.annotations.cache.NoCache; import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.spi.HttpRequest; import org.jboss.resteasy.spi.HttpRequest;
import org.jboss.resteasy.spi.HttpResponse; import org.jboss.resteasy.spi.HttpResponse;
import org.keycloak.OAuth2Constants;
import org.keycloak.common.ClientConnection; import org.keycloak.common.ClientConnection;
import org.keycloak.OAuthErrorException; import org.keycloak.OAuthErrorException;
import org.keycloak.RSATokenVerifier; import org.keycloak.RSATokenVerifier;
@ -27,12 +28,15 @@ import org.keycloak.events.Details;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder; import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.jose.jws.Algorithm;
import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionModel; import org.keycloak.models.ClientSessionModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel; import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.TokenManager; import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessToken;
import org.keycloak.services.ErrorResponseException; import org.keycloak.services.ErrorResponseException;
@ -40,17 +44,18 @@ import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager; import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.resources.Cors; import org.keycloak.services.resources.Cors;
import org.keycloak.services.Urls; import org.keycloak.services.Urls;
import org.keycloak.utils.MediaType;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.OPTIONS; import javax.ws.rs.OPTIONS;
import javax.ws.rs.POST; import javax.ws.rs.POST;
import javax.ws.rs.Path; import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context; import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo; import javax.ws.rs.core.UriInfo;
import java.security.PrivateKey;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@ -86,7 +91,6 @@ public class UserInfoEndpoint {
@Path("/") @Path("/")
@OPTIONS @OPTIONS
@Produces(MediaType.APPLICATION_JSON)
public Response issueUserInfoPreflight() { public Response issueUserInfoPreflight() {
return Cors.add(this.request, Response.ok()).auth().preflight().build(); return Cors.add(this.request, Response.ok()).auth().preflight().build();
} }
@ -94,7 +98,6 @@ public class UserInfoEndpoint {
@Path("/") @Path("/")
@GET @GET
@NoCache @NoCache
@Produces(MediaType.APPLICATION_JSON)
public Response issueUserInfoGet(@Context final HttpHeaders headers) { public Response issueUserInfoGet(@Context final HttpHeaders headers) {
String accessToken = this.appAuthManager.extractAuthorizationHeaderToken(headers); String accessToken = this.appAuthManager.extractAuthorizationHeaderToken(headers);
return issueUserInfo(accessToken); return issueUserInfo(accessToken);
@ -103,7 +106,6 @@ public class UserInfoEndpoint {
@Path("/") @Path("/")
@POST @POST
@NoCache @NoCache
@Produces(MediaType.APPLICATION_JSON)
public Response issueUserInfoPost() { public Response issueUserInfoPost() {
// Try header first // Try header first
HttpHeaders headers = request.getHttpHeaders(); HttpHeaders headers = request.getHttpHeaders();
@ -176,12 +178,39 @@ public class UserInfoEndpoint {
AccessToken userInfo = new AccessToken(); AccessToken userInfo = new AccessToken();
tokenManager.transformUserInfoAccessToken(session, userInfo, realm, clientModel, userModel, userSession, clientSession); tokenManager.transformUserInfoAccessToken(session, userInfo, realm, clientModel, userModel, userSession, clientSession);
event.success();
Map<String, Object> claims = new HashMap<String, Object>(); Map<String, Object> claims = new HashMap<String, Object>();
claims.putAll(userInfo.getOtherClaims()); claims.putAll(userInfo.getOtherClaims());
claims.put("sub", userModel.getId()); claims.put("sub", userModel.getId());
return Cors.add(request, Response.ok(claims)).auth().allowedOrigins(token).build();
Response.ResponseBuilder responseBuilder;
OIDCAdvancedConfigWrapper cfg = OIDCAdvancedConfigWrapper.fromClientModel(clientModel);
if (cfg.isUserInfoSignatureRequired()) {
String issuerUrl = Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName());
String audience = clientModel.getClientId();
claims.put("iss", issuerUrl);
claims.put("aud", audience);
Algorithm signatureAlg = cfg.getUserInfoSignedResponseAlg();
PrivateKey privateKey = realm.getPrivateKey();
String signedUserInfo = new JWSBuilder()
.jsonContent(claims)
.sign(signatureAlg, privateKey);
responseBuilder = Response.ok(signedUserInfo).header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JWT);
event.detail(Details.SIGNATURE_REQUIRED, "true");
event.detail(Details.SIGNATURE_ALGORITHM, cfg.getUserInfoSignedResponseAlg().toString());
} else {
responseBuilder = Response.ok(claims).header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
event.detail(Details.SIGNATURE_REQUIRED, "false");
}
event.success();
return Cors.add(request, responseBuilder).auth().allowedOrigins(token).build();
} }
} }

View file

@ -64,6 +64,9 @@ public class OIDCConfigurationRepresentation {
@JsonProperty("id_token_signing_alg_values_supported") @JsonProperty("id_token_signing_alg_values_supported")
private List<String> idTokenSigningAlgValuesSupported; private List<String> idTokenSigningAlgValuesSupported;
@JsonProperty("userinfo_signing_alg_values_supported")
private List<String> userInfoSigningAlgValuesSupported;
@JsonProperty("response_modes_supported") @JsonProperty("response_modes_supported")
private List<String> responseModesSupported; private List<String> responseModesSupported;
@ -184,6 +187,14 @@ public class OIDCConfigurationRepresentation {
this.idTokenSigningAlgValuesSupported = idTokenSigningAlgValuesSupported; this.idTokenSigningAlgValuesSupported = idTokenSigningAlgValuesSupported;
} }
public List<String> getUserInfoSigningAlgValuesSupported() {
return userInfoSigningAlgValuesSupported;
}
public void setUserInfoSigningAlgValuesSupported(List<String> userInfoSigningAlgValuesSupported) {
this.userInfoSigningAlgValuesSupported = userInfoSigningAlgValuesSupported;
}
public List<String> getResponseModesSupported() { public List<String> getResponseModesSupported() {
return responseModesSupported; return responseModesSupported;
} }

View file

@ -24,8 +24,10 @@ import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthen
import org.keycloak.authentication.authenticators.client.JWTClientAuthenticator; import org.keycloak.authentication.authenticators.client.JWTClientAuthenticator;
import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jws.Algorithm;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil; import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
import org.keycloak.protocol.oidc.utils.JWKSUtils; import org.keycloak.protocol.oidc.utils.JWKSUtils;
@ -102,6 +104,13 @@ public class DescriptionConverter {
CertificateInfoHelper.updateClientRepresentationCertificateInfo(client, rep, JWTClientAuthenticator.ATTR_PREFIX); CertificateInfoHelper.updateClientRepresentationCertificateInfo(client, rep, JWTClientAuthenticator.ATTR_PREFIX);
} }
if (clientOIDC.getUserinfoSignedResponseAlg() != null) {
String userInfoSignedResponseAlg = clientOIDC.getUserinfoSignedResponseAlg();
Algorithm algorithm = Enum.valueOf(Algorithm.class, userInfoSignedResponseAlg);
OIDCAdvancedConfigWrapper.fromClientRepresentation(client).setUserInfoSignedResponseAlg(algorithm);
}
return client; return client;
} }
@ -152,6 +161,12 @@ public class DescriptionConverter {
response.setRegistrationClientUri(uri.toString()); response.setRegistrationClientUri(uri.toString());
response.setResponseTypes(getOIDCResponseTypes(client)); response.setResponseTypes(getOIDCResponseTypes(client));
response.setGrantTypes(getOIDCGrantTypes(client)); response.setGrantTypes(getOIDCGrantTypes(client));
OIDCAdvancedConfigWrapper config = OIDCAdvancedConfigWrapper.fromClientRepresentation(client);
if (config.isUserInfoSignatureRequired()) {
response.setUserinfoSignedResponseAlg(config.getUserInfoSignedResponseAlg().toString());
}
return response; return response;
} }

View file

@ -31,4 +31,7 @@ public class MediaType {
public static final String APPLICATION_FORM_URLENCODED = javax.ws.rs.core.MediaType.APPLICATION_FORM_URLENCODED; public static final String APPLICATION_FORM_URLENCODED = javax.ws.rs.core.MediaType.APPLICATION_FORM_URLENCODED;
public static final javax.ws.rs.core.MediaType APPLICATION_FORM_URLENCODED_TYPE = javax.ws.rs.core.MediaType.APPLICATION_FORM_URLENCODED_TYPE; public static final javax.ws.rs.core.MediaType APPLICATION_FORM_URLENCODED_TYPE = javax.ws.rs.core.MediaType.APPLICATION_FORM_URLENCODED_TYPE;
public static final String APPLICATION_JWT = "application/jwt";
public static final javax.ws.rs.core.MediaType APPLICATION_JWT_TYPE = new javax.ws.rs.core.MediaType("application", "jwt");
} }

View file

@ -28,6 +28,7 @@ import javax.ws.rs.core.UriBuilder;
import org.junit.Assert; import org.junit.Assert;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService; import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.representations.UserInfo; import org.keycloak.representations.UserInfo;
import org.keycloak.utils.MediaType;
/** /**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a> * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
@ -51,6 +52,7 @@ public class UserInfoClientUtil {
public static void testSuccessfulUserInfoResponse(Response response, String expectedUsername, String expectedEmail) { public static void testSuccessfulUserInfoResponse(Response response, String expectedUsername, String expectedEmail) {
Assert.assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); Assert.assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
Assert.assertEquals(response.getHeaderString(HttpHeaders.CONTENT_TYPE), MediaType.APPLICATION_JSON);
UserInfo userInfo = response.readEntity(UserInfo.class); UserInfo userInfo = response.readEntity(UserInfo.class);

View file

@ -35,7 +35,9 @@ import org.keycloak.common.util.CollectionUtil;
import org.keycloak.common.util.KeycloakUriBuilder; import org.keycloak.common.util.KeycloakUriBuilder;
import org.keycloak.constants.ServiceUrlConstants; import org.keycloak.constants.ServiceUrlConstants;
import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.jose.jws.Algorithm;
import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService; import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.protocol.oidc.utils.OIDCResponseType; import org.keycloak.protocol.oidc.utils.OIDCResponseType;
@ -44,6 +46,7 @@ import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.ClientInitialAccessCreatePresentation; import org.keycloak.representations.idm.ClientInitialAccessCreatePresentation;
import org.keycloak.representations.idm.ClientInitialAccessPresentation; import org.keycloak.representations.idm.ClientInitialAccessPresentation;
import org.keycloak.representations.idm.ClientRegistrationTrustedHostRepresentation; import org.keycloak.representations.idm.ClientRegistrationTrustedHostRepresentation;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.oidc.OIDCClientRepresentation; import org.keycloak.representations.oidc.OIDCClientRepresentation;
import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.Assert;
@ -155,6 +158,7 @@ public class OIDCClientRegistrationTest extends AbstractClientRegistrationTest {
assertEquals(Arrays.asList("code", "none"), response.getResponseTypes()); assertEquals(Arrays.asList("code", "none"), response.getResponseTypes());
assertEquals(Arrays.asList(OAuth2Constants.AUTHORIZATION_CODE, OAuth2Constants.REFRESH_TOKEN), response.getGrantTypes()); assertEquals(Arrays.asList(OAuth2Constants.AUTHORIZATION_CODE, OAuth2Constants.REFRESH_TOKEN), response.getGrantTypes());
assertEquals(OIDCLoginProtocol.CLIENT_SECRET_BASIC, response.getTokenEndpointAuthMethod()); assertEquals(OIDCLoginProtocol.CLIENT_SECRET_BASIC, response.getTokenEndpointAuthMethod());
Assert.assertNull(response.getUserinfoSignedResponseAlg());
} }
@Test @Test
@ -255,6 +259,21 @@ public class OIDCClientRegistrationTest extends AbstractClientRegistrationTest {
Assert.assertEquals(response.getClientId(), accessToken.getAudience()[0]); Assert.assertEquals(response.getClientId(), accessToken.getAudience()[0]);
} }
@Test
public void testSignaturesRequired() throws Exception {
OIDCClientRepresentation clientRep = createRep();
clientRep.setUserinfoSignedResponseAlg(Algorithm.RS256.toString());
OIDCClientRepresentation response = reg.oidc().create(clientRep);
Assert.assertEquals(Algorithm.RS256.toString(), response.getUserinfoSignedResponseAlg());
Assert.assertNotNull(response.getClientSecret());
// Test Keycloak representation
ClientRepresentation kcClient = getClient(response.getClientId());
OIDCAdvancedConfigWrapper config = OIDCAdvancedConfigWrapper.fromClientRepresentation(kcClient);
Assert.assertEquals(config.getUserInfoSignedResponseAlg(), Algorithm.RS256);
}
// Client auth with signedJWT - helper methods // Client auth with signedJWT - helper methods

View file

@ -86,6 +86,7 @@ public class OIDCWellKnownProviderTest extends AbstractKeycloakTest {
Assert.assertNames(oidcConfig.getSubjectTypesSupported(), "public"); Assert.assertNames(oidcConfig.getSubjectTypesSupported(), "public");
Assert.assertNames(oidcConfig.getIdTokenSigningAlgValuesSupported(), Algorithm.RS256.toString()); Assert.assertNames(oidcConfig.getIdTokenSigningAlgValuesSupported(), Algorithm.RS256.toString());
Assert.assertNames(oidcConfig.getUserInfoSigningAlgValuesSupported(), Algorithm.RS256.toString());
// Client authentication // Client authentication
Assert.assertNames(oidcConfig.getTokenEndpointAuthMethodsSupported(), "client_secret_basic", "client_secret_post", "private_key_jwt"); Assert.assertNames(oidcConfig.getTokenEndpointAuthMethodsSupported(), "client_secret_basic", "client_secret_post", "private_key_jwt");

View file

@ -21,19 +21,31 @@ import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.events.Details; import org.keycloak.events.Details;
import org.keycloak.events.Errors; import org.keycloak.events.Errors;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.jose.jws.Algorithm;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.crypto.RSAProvider;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService; import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.representations.AccessTokenResponse; import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.UserInfo; import org.keycloak.representations.UserInfo;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.services.Urls;
import org.keycloak.testsuite.AbstractKeycloakTest; import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.Assert;
import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.util.ClientManager; import org.keycloak.testsuite.util.ClientManager;
import org.keycloak.testsuite.util.RealmBuilder; import org.keycloak.testsuite.util.RealmBuilder;
import org.keycloak.testsuite.util.UserInfoClientUtil; import org.keycloak.testsuite.util.UserInfoClientUtil;
import org.keycloak.util.BasicAuthHelper; import org.keycloak.util.BasicAuthHelper;
import org.keycloak.util.JsonSerialization;
import org.keycloak.utils.MediaType;
import javax.ws.rs.client.Client; import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder; import javax.ws.rs.client.ClientBuilder;
@ -45,6 +57,7 @@ import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status; import javax.ws.rs.core.Response.Status;
import javax.ws.rs.core.UriBuilder; import javax.ws.rs.core.UriBuilder;
import java.net.URI; import java.net.URI;
import java.security.PublicKey;
import java.util.List; import java.util.List;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@ -152,6 +165,62 @@ public class UserInfoTest extends AbstractKeycloakTest {
} }
} }
@Test
public void testSuccessSignedResponse() throws Exception {
// Require signed userInfo request
ClientResource clientResource = ApiUtil.findClientByClientId(adminClient.realm("test"), "test-app");
ClientRepresentation clientRep = clientResource.toRepresentation();
OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setUserInfoSignedResponseAlg(Algorithm.RS256);
clientResource.update(clientRep);
// test signed response
Client client = ClientBuilder.newClient();
try {
AccessTokenResponse accessTokenResponse = executeGrantAccessTokenRequest(client);
Response response = UserInfoClientUtil.executeUserInfoRequest_getMethod(client, accessTokenResponse.getToken());
events.expect(EventType.USER_INFO_REQUEST)
.session(Matchers.notNullValue(String.class))
.detail(Details.AUTH_METHOD, Details.VALIDATE_ACCESS_TOKEN)
.detail(Details.USERNAME, "test-user@localhost")
.detail(Details.SIGNATURE_REQUIRED, "true")
.detail(Details.SIGNATURE_ALGORITHM, Algorithm.RS256.toString())
.assertEvent();
// Check signature and content
RealmRepresentation realmRep = adminClient.realm("test").toRepresentation();
PublicKey publicKey = KeycloakModelUtils.getPublicKey(realmRep.getPublicKey());
Assert.assertEquals(200, response.getStatus());
Assert.assertEquals(response.getHeaderString(HttpHeaders.CONTENT_TYPE), MediaType.APPLICATION_JWT);
String signedResponse = response.readEntity(String.class);
response.close();
JWSInput jwsInput = new JWSInput(signedResponse);
Assert.assertTrue(RSAProvider.verify(jwsInput, publicKey));
UserInfo userInfo = JsonSerialization.readValue(jwsInput.getContent(), UserInfo.class);
Assert.assertNotNull(userInfo);
Assert.assertNotNull(userInfo.getSubject());
Assert.assertEquals("test-user@localhost", userInfo.getEmail());
Assert.assertEquals("test-user@localhost", userInfo.getPreferredUsername());
Assert.assertTrue(userInfo.hasAudience("test-app"));
String expectedIssuer = Urls.realmIssuer(new URI(AUTH_SERVER_ROOT), "test");
Assert.assertEquals(expectedIssuer, userInfo.getIssuer());
} finally {
client.close();
}
// Revert signed userInfo request
OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep).setUserInfoSignedResponseAlg(null);
clientResource.update(clientRep);
}
@Test @Test
public void testSessionExpired() throws Exception { public void testSessionExpired() throws Exception {
Client client = ClientBuilder.newClient(); Client client = ClientBuilder.newClient();
@ -235,6 +304,7 @@ public class UserInfoTest extends AbstractKeycloakTest {
.session(Matchers.notNullValue(String.class)) .session(Matchers.notNullValue(String.class))
.detail(Details.AUTH_METHOD, Details.VALIDATE_ACCESS_TOKEN) .detail(Details.AUTH_METHOD, Details.VALIDATE_ACCESS_TOKEN)
.detail(Details.USERNAME, "test-user@localhost") .detail(Details.USERNAME, "test-user@localhost")
.detail(Details.SIGNATURE_REQUIRED, "false")
.assertEvent(); .assertEvent();
UserInfoClientUtil.testSuccessfulUserInfoResponse(response, "test-user@localhost", "test-user@localhost"); UserInfoClientUtil.testSuccessfulUserInfoResponse(response, "test-user@localhost", "test-user@localhost");
} }

View file

@ -236,6 +236,10 @@ idp-sso-relay-state=IDP Initiated SSO Relay State
idp-sso-relay-state.tooltip=Relay state you want to send with SAML request when you want to do IDP Initiated SSO. idp-sso-relay-state.tooltip=Relay state you want to send with SAML request when you want to do IDP Initiated SSO.
web-origins=Web Origins web-origins=Web Origins
web-origins.tooltip=Allowed CORS origins. To permit all origins of Valid Redirect URIs add '+'. To permit all origins add '*'. web-origins.tooltip=Allowed CORS origins. To permit all origins of Valid Redirect URIs add '+'. To permit all origins add '*'.
fine-oidc-endpoint-conf=Fine Grain OpenID Connect Configuration
fine-oidc-endpoint-conf.tooltip=Expand this section to configure advanced settings of this client related to OpenID Connect protocol
user-info-signed-response-alg=User Info Signed Response Algorithm
user-info-signed-response-alg.tooltip=JWA algorithm used for signed User Info Endpoint response. If set to 'unsigned', then User Info Response won't be signed and will be returned in application/json format.
fine-saml-endpoint-conf=Fine Grain SAML Endpoint Configuration fine-saml-endpoint-conf=Fine Grain SAML Endpoint Configuration
fine-saml-endpoint-conf.tooltip=Expand this section to configure exact URLs for Assertion Consumer and Single Logout Service. fine-saml-endpoint-conf.tooltip=Expand this section to configure exact URLs for Assertion Consumer and Single Logout Service.
assertion-consumer-post-binding-url=Assertion Consumer Service POST Binding URL assertion-consumer-post-binding-url=Assertion Consumer Service POST Binding URL

View file

@ -792,6 +792,11 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, templates,
{name: "INCLUSIVE_WITH_COMMENTS", value: "http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments"} {name: "INCLUSIVE_WITH_COMMENTS", value: "http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments"}
]; ];
$scope.oidcSignatureAlgorithms = [
"unsigned",
"RS256"
];
$scope.realm = realm; $scope.realm = realm;
$scope.samlAuthnStatement = false; $scope.samlAuthnStatement = false;
$scope.samlMultiValuedRoles = false; $scope.samlMultiValuedRoles = false;
@ -892,6 +897,8 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, templates,
$scope.samlForcePostBinding = false; $scope.samlForcePostBinding = false;
} }
} }
$scope.userInfoSignedResponseAlg = getSignatureAlgorithm('user.info.response');
} }
if (!$scope.create) { if (!$scope.create) {
@ -956,6 +963,25 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, templates,
$scope.client.attributes['saml_name_id_format'] = $scope.nameIdFormat; $scope.client.attributes['saml_name_id_format'] = $scope.nameIdFormat;
}; };
$scope.changeUserInfoSignedResponseAlg = function() {
changeSignatureAlgorithm('user.info.response', $scope.userInfoSignedResponseAlg);
};
function changeSignatureAlgorithm(attrPrefix, attrValue) {
var attrName = attrPrefix + '.signature.alg';
if (attrValue === 'unsigned') {
$scope.client.attributes[attrName] = null;
} else {
$scope.client.attributes[attrName] = attrValue;
}
}
function getSignatureAlgorithm(attrPrefix) {
var attrName = attrPrefix + '.signature.alg';
var attrVal = $scope.client.attributes[attrName];
return attrVal==null ? 'unsigned' : attrVal;
}
$scope.$watch(function() { $scope.$watch(function() {
return $location.path(); return $location.path();
}, function() { }, function() {

View file

@ -333,6 +333,23 @@
</div> </div>
</fieldset> </fieldset>
<fieldset data-ng-show="protocol == 'openid-connect'">
<legend collapsed><span class="text">{{:: 'fine-oidc-endpoint-conf' | translate}}</span> <kc-tooltip>{{:: 'fine-oidc-endpoint-conf.tooltip' | translate}}</kc-tooltip></legend>
<div class="form-group clearfix block" data-ng-show="protocol == 'openid-connect'">
<label class="col-md-2 control-label" for="userInfoSignedResponseAlg">{{:: 'user-info-signed-response-alg' | translate}}</label>
<div class="col-sm-6">
<div>
<select class="form-control" id="userInfoSignedResponseAlg"
ng-change="changeUserInfoSignedResponseAlg()"
ng-model="userInfoSignedResponseAlg"
ng-options="sig for sig in oidcSignatureAlgorithms">
</select>
</div>
</div>
<kc-tooltip>{{:: 'user-info-signed-response-alg.tooltip' | translate}}</kc-tooltip>
</div>
</fieldset>
<div class="form-group"> <div class="form-group">
<div class="col-md-10 col-md-offset-2" data-ng-show="access.manageClients"> <div class="col-md-10 col-md-offset-2" data-ng-show="access.manageClients">
<button kc-save data-ng-disabled="!changed">{{:: 'save' | translate}}</button> <button kc-save data-ng-disabled="!changed">{{:: 'save' | translate}}</button>