From 4f432775edc9fd0a022f9c28c83172e291eb78fd Mon Sep 17 00:00:00 2001 From: pedroigor Date: Fri, 16 Jan 2015 15:45:27 -0200 Subject: [PATCH] [KEYCLOAK-884] - OpenID Connect UserInfo Endpoint. --- .../org/keycloak/representations/IDToken.java | 299 +--------------- .../representations/JsonWebToken.java | 4 + .../representations/UserClaimSet.java | 331 ++++++++++++++++++ .../keycloak/representations/UserInfo.java | 25 ++ .../org/keycloak/SkeletonKeyTokenTest.java | 7 +- .../java/org/keycloak/events/EventType.java | 4 +- .../src/main/webapp/customers/view.jsp | 12 +- .../third-party/src/main/webapp/pull_data.jsp | 14 +- .../boundary/ProtectedServlet.java | 9 +- .../org/keycloak/adapters/AdapterUtils.java | 21 +- integration/js/src/main/resources/keycloak.js | 25 ++ .../proxy/ConstraintAuthorizationHandler.java | 18 +- .../protocol/oidc/OpenIDConnectService.java | 17 + .../keycloak/protocol/oidc/TokenManager.java | 71 ++-- .../protocol/oidc/UserInfoService.java | 150 ++++++++ .../testsuite/adapter/MultiTenantServlet.java | 9 +- .../keycloak/testsuite/oidc/UserInfoTest.java | 129 +++++++ 17 files changed, 787 insertions(+), 358 deletions(-) create mode 100644 core/src/main/java/org/keycloak/representations/UserClaimSet.java create mode 100644 core/src/main/java/org/keycloak/representations/UserInfo.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/UserInfoService.java create mode 100755 testsuite/integration/src/test/java/org/keycloak/testsuite/oidc/UserInfoTest.java diff --git a/core/src/main/java/org/keycloak/representations/IDToken.java b/core/src/main/java/org/keycloak/representations/IDToken.java index 975da619f0..784e409edc 100755 --- a/core/src/main/java/org/keycloak/representations/IDToken.java +++ b/core/src/main/java/org/keycloak/representations/IDToken.java @@ -1,96 +1,23 @@ package org.keycloak.representations; import org.codehaus.jackson.annotate.JsonProperty; +import org.codehaus.jackson.annotate.JsonUnwrapped; /** * @author Bill Burke * @version $Revision: 1 $ */ public class IDToken extends JsonWebToken { + @JsonProperty("nonce") protected String nonce; - @JsonProperty("name") - protected String name; - - @JsonProperty("given_name") - protected String givenName; - - @JsonProperty("family_name") - protected String familyName; - - @JsonProperty("middle_name") - protected String middleName; - - @JsonProperty("nickname") - protected String nickName; - - @JsonProperty("preferred_username") - protected String preferredUsername; - - @JsonProperty("profile") - protected String profile; - - @JsonProperty("picture") - protected String picture; - - @JsonProperty("website") - protected String website; - - @JsonProperty("email") - protected String email; - - @JsonProperty("email_verified") - protected Boolean emailVerified; - - @JsonProperty("gender") - protected String gender; - - @JsonProperty("birthdate") - protected String birthdate; - - @JsonProperty("zoneinfo") - protected String zoneinfo; - - @JsonProperty("locale") - protected String locale; - - @JsonProperty("phone_number") - protected String phoneNumber; - - @JsonProperty("phone_number_verified") - protected Boolean phoneNumberVerified; - - @JsonProperty("address") - protected String address; - - @JsonProperty("updated_at") - protected Long updatedAt; - - @JsonProperty("formatted") - protected String formattedAddress; - - @JsonProperty("street_address") - protected String streetAddress; - - @JsonProperty("locality") - protected String locality; - - @JsonProperty("region") - protected String region; - - @JsonProperty("postal_code") - protected String postalCode; - - @JsonProperty("country") - protected String country; - - @JsonProperty("claims_locales") - protected String claimsLocales; - @JsonProperty("session_state") protected String sessionState; + @JsonUnwrapped + protected UserClaimSet userClaimSet = new UserClaimSet(); + public String getNonce() { return nonce; } @@ -99,214 +26,6 @@ public class IDToken extends JsonWebToken { this.nonce = nonce; } - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getGivenName() { - return givenName; - } - - public void setGivenName(String givenName) { - this.givenName = givenName; - } - - public String getFamilyName() { - return familyName; - } - - public void setFamilyName(String familyName) { - this.familyName = familyName; - } - - public String getMiddleName() { - return middleName; - } - - public void setMiddleName(String middleName) { - this.middleName = middleName; - } - - public String getNickName() { - return nickName; - } - - public void setNickName(String nickName) { - this.nickName = nickName; - } - - public String getPreferredUsername() { - return preferredUsername; - } - - public void setPreferredUsername(String preferredUsername) { - this.preferredUsername = preferredUsername; - } - - public String getProfile() { - return profile; - } - - public void setProfile(String profile) { - this.profile = profile; - } - - public String getPicture() { - return picture; - } - - public void setPicture(String picture) { - this.picture = picture; - } - - public String getWebsite() { - return website; - } - - public void setWebsite(String website) { - this.website = website; - } - - public String getEmail() { - return email; - } - - public void setEmail(String email) { - this.email = email; - } - - public Boolean getEmailVerified() { - return emailVerified; - } - - public void setEmailVerified(Boolean emailVerified) { - this.emailVerified = emailVerified; - } - - public String getGender() { - return gender; - } - - public void setGender(String gender) { - this.gender = gender; - } - - public String getBirthdate() { - return birthdate; - } - - public void setBirthdate(String birthdate) { - this.birthdate = birthdate; - } - - public String getZoneinfo() { - return zoneinfo; - } - - public void setZoneinfo(String zoneinfo) { - this.zoneinfo = zoneinfo; - } - - public String getLocale() { - return locale; - } - - public void setLocale(String locale) { - this.locale = locale; - } - - public String getPhoneNumber() { - return phoneNumber; - } - - public void setPhoneNumber(String phoneNumber) { - this.phoneNumber = phoneNumber; - } - - public Boolean getPhoneNumberVerified() { - return phoneNumberVerified; - } - - public void setPhoneNumberVerified(Boolean phoneNumberVerified) { - this.phoneNumberVerified = phoneNumberVerified; - } - - public String getAddress() { - return address; - } - - public void setAddress(String address) { - this.address = address; - } - - public Long getUpdatedAt() { - return updatedAt; - } - - public void setUpdatedAt(Long updatedAt) { - this.updatedAt = updatedAt; - } - - public String getFormattedAddress() { - return formattedAddress; - } - - public void setFormattedAddress(String formattedAddress) { - this.formattedAddress = formattedAddress; - } - - public String getStreetAddress() { - return streetAddress; - } - - public void setStreetAddress(String streetAddress) { - this.streetAddress = streetAddress; - } - - public String getLocality() { - return locality; - } - - public void setLocality(String locality) { - this.locality = locality; - } - - public String getRegion() { - return region; - } - - public void setRegion(String region) { - this.region = region; - } - - public String getPostalCode() { - return postalCode; - } - - public void setPostalCode(String postalCode) { - this.postalCode = postalCode; - } - - public String getCountry() { - return country; - } - - public void setCountry(String country) { - this.country = country; - } - - public String getClaimsLocales() { - return claimsLocales; - } - - public void setClaimsLocales(String claimsLocales) { - this.claimsLocales = claimsLocales; - } - public String getSessionState() { return sessionState; } @@ -314,4 +33,12 @@ public class IDToken extends JsonWebToken { public void setSessionState(String sessionState) { this.sessionState = sessionState; } + + public UserClaimSet getUserClaimSet() { + return this.userClaimSet; + } + + public void setUserClaimSet(UserClaimSet userClaimSet) { + this.userClaimSet = userClaimSet; + } } diff --git a/core/src/main/java/org/keycloak/representations/JsonWebToken.java b/core/src/main/java/org/keycloak/representations/JsonWebToken.java index 42e9328110..d0a4e9d91e 100755 --- a/core/src/main/java/org/keycloak/representations/JsonWebToken.java +++ b/core/src/main/java/org/keycloak/representations/JsonWebToken.java @@ -127,6 +127,10 @@ public class JsonWebToken implements Serializable { return this; } + public void setSubject(String subject) { + this.subject = subject; + } + public String getType() { return type; } diff --git a/core/src/main/java/org/keycloak/representations/UserClaimSet.java b/core/src/main/java/org/keycloak/representations/UserClaimSet.java new file mode 100644 index 0000000000..c6472691a1 --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/UserClaimSet.java @@ -0,0 +1,331 @@ +/* + * JBoss, Home of Professional Open Source + * + * Copyright 2013 Red Hat, Inc. and/or its affiliates. + * + * 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.representations; + +import org.codehaus.jackson.annotate.JsonProperty; + +/** + * @author pedroigor + */ +public class UserClaimSet { + + @JsonProperty("sub") + protected String sub; + + @JsonProperty("name") + protected String name; + + @JsonProperty("given_name") + protected String givenName; + + @JsonProperty("family_name") + protected String familyName; + + @JsonProperty("middle_name") + protected String middleName; + + @JsonProperty("nickname") + protected String nickName; + + @JsonProperty("preferred_username") + protected String preferredUsername; + + @JsonProperty("profile") + protected String profile; + + @JsonProperty("picture") + protected String picture; + + @JsonProperty("website") + protected String website; + + @JsonProperty("email") + protected String email; + + @JsonProperty("email_verified") + protected Boolean emailVerified; + + @JsonProperty("gender") + protected String gender; + + @JsonProperty("birthdate") + protected String birthdate; + + @JsonProperty("zoneinfo") + protected String zoneinfo; + + @JsonProperty("locale") + protected String locale; + + @JsonProperty("phone_number") + protected String phoneNumber; + + @JsonProperty("phone_number_verified") + protected Boolean phoneNumberVerified; + + @JsonProperty("address") + protected String address; + + @JsonProperty("updated_at") + protected Long updatedAt; + + @JsonProperty("formatted") + protected String formattedAddress; + + @JsonProperty("street_address") + protected String streetAddress; + + @JsonProperty("locality") + protected String locality; + + @JsonProperty("region") + protected String region; + + @JsonProperty("postal_code") + protected String postalCode; + + @JsonProperty("country") + protected String country; + + @JsonProperty("claims_locales") + protected String claimsLocales; + + public String getSubject() { + return this.sub; + } + + public void setSubject(String subject) { + this.sub = subject; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public String getGivenName() { + return this.givenName; + } + + public void setGivenName(String givenName) { + this.givenName = givenName; + } + + public String getFamilyName() { + return this.familyName; + } + + public void setFamilyName(String familyName) { + this.familyName = familyName; + } + + public String getMiddleName() { + return this.middleName; + } + + public void setMiddleName(String middleName) { + this.middleName = middleName; + } + + public String getNickName() { + return this.nickName; + } + + public void setNickName(String nickName) { + this.nickName = nickName; + } + + public String getPreferredUsername() { + return this.preferredUsername; + } + + public void setPreferredUsername(String preferredUsername) { + this.preferredUsername = preferredUsername; + } + + public String getProfile() { + return this.profile; + } + + public void setProfile(String profile) { + this.profile = profile; + } + + public String getPicture() { + return this.picture; + } + + public void setPicture(String picture) { + this.picture = picture; + } + + public String getWebsite() { + return this.website; + } + + public void setWebsite(String website) { + this.website = website; + } + + public String getEmail() { + return this.email; + } + + public void setEmail(String email) { + this.email = email; + } + + public Boolean getEmailVerified() { + return this.emailVerified; + } + + public void setEmailVerified(Boolean emailVerified) { + this.emailVerified = emailVerified; + } + + public String getGender() { + return this.gender; + } + + public void setGender(String gender) { + this.gender = gender; + } + + public String getBirthdate() { + return this.birthdate; + } + + public void setBirthdate(String birthdate) { + this.birthdate = birthdate; + } + + public String getZoneinfo() { + return this.zoneinfo; + } + + public void setZoneinfo(String zoneinfo) { + this.zoneinfo = zoneinfo; + } + + public String getLocale() { + return this.locale; + } + + public void setLocale(String locale) { + this.locale = locale; + } + + public String getPhoneNumber() { + return this.phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public Boolean getPhoneNumberVerified() { + return this.phoneNumberVerified; + } + + public void setPhoneNumberVerified(Boolean phoneNumberVerified) { + this.phoneNumberVerified = phoneNumberVerified; + } + + public String getAddress() { + return this.address; + } + + public void setAddress(String address) { + this.address = address; + } + + public Long getUpdatedAt() { + return this.updatedAt; + } + + public void setUpdatedAt(Long updatedAt) { + this.updatedAt = updatedAt; + } + + public String getSub() { + return this.sub; + } + + public void setSub(String sub) { + this.sub = sub; + } + + public String getFormattedAddress() { + return this.formattedAddress; + } + + public void setFormattedAddress(String formattedAddress) { + this.formattedAddress = formattedAddress; + } + + public String getStreetAddress() { + return this.streetAddress; + } + + public void setStreetAddress(String streetAddress) { + this.streetAddress = streetAddress; + } + + public String getLocality() { + return this.locality; + } + + public void setLocality(String locality) { + this.locality = locality; + } + + public String getRegion() { + return this.region; + } + + public void setRegion(String region) { + this.region = region; + } + + public String getPostalCode() { + return this.postalCode; + } + + public void setPostalCode(String postalCode) { + this.postalCode = postalCode; + } + + public String getCountry() { + return this.country; + } + + public void setCountry(String country) { + this.country = country; + } + + public String getClaimsLocales() { + return this.claimsLocales; + } + + public void setClaimsLocales(String claimsLocales) { + this.claimsLocales = claimsLocales; + } +} diff --git a/core/src/main/java/org/keycloak/representations/UserInfo.java b/core/src/main/java/org/keycloak/representations/UserInfo.java new file mode 100644 index 0000000000..311298115f --- /dev/null +++ b/core/src/main/java/org/keycloak/representations/UserInfo.java @@ -0,0 +1,25 @@ +/* + * JBoss, Home of Professional Open Source + * + * Copyright 2013 Red Hat, Inc. and/or its affiliates. + * + * 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.representations; + +/** + * @author pedroigor + */ +public class UserInfo extends UserClaimSet { + +} diff --git a/core/src/test/java/org/keycloak/SkeletonKeyTokenTest.java b/core/src/test/java/org/keycloak/SkeletonKeyTokenTest.java index f2ec50221d..e5a332d2e3 100755 --- a/core/src/test/java/org/keycloak/SkeletonKeyTokenTest.java +++ b/core/src/test/java/org/keycloak/SkeletonKeyTokenTest.java @@ -6,6 +6,7 @@ import org.keycloak.jose.jws.JWSBuilder; import org.keycloak.jose.jws.JWSInput; import org.keycloak.jose.jws.crypto.RSAProvider; import org.keycloak.representations.AccessToken; +import org.keycloak.representations.UserClaimSet; import org.keycloak.representations.IDToken; import org.keycloak.util.JsonSerialization; @@ -58,7 +59,9 @@ public class SkeletonKeyTokenTest { public void testSerialization() throws Exception { AccessToken token = createSimpleToken(); IDToken idToken = new IDToken(); - idToken.setEmail("joe@email.cz"); + UserClaimSet claimSet = idToken.getUserClaimSet(); + + claimSet.setEmail("joe@email.cz"); KeyPair keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); @@ -95,7 +98,7 @@ public class SkeletonKeyTokenTest { Assert.assertEquals("111", token.getId()); Assert.assertTrue(token.getResourceAccess("foo").isUserInRole("admin")); Assert.assertTrue(token.getResourceAccess("bar").isUserInRole("user")); - Assert.assertEquals("joe@email.cz", idToken.getEmail()); + Assert.assertEquals("joe@email.cz", claimSet.getEmail()); Assert.assertEquals("acme", ctx.getRealm()); ois.close(); } diff --git a/events/api/src/main/java/org/keycloak/events/EventType.java b/events/api/src/main/java/org/keycloak/events/EventType.java index 681c87d4f9..d292c4c9af 100755 --- a/events/api/src/main/java/org/keycloak/events/EventType.java +++ b/events/api/src/main/java/org/keycloak/events/EventType.java @@ -45,6 +45,8 @@ public enum EventType { INVALID_SIGNATURE_ERROR, REGISTER_NODE, - UNREGISTER_NODE + UNREGISTER_NODE, + USER_INFO_REQUEST, + USER_INFO_REQUEST_ERROR } diff --git a/examples/demo-template/customer-app/src/main/webapp/customers/view.jsp b/examples/demo-template/customer-app/src/main/webapp/customers/view.jsp index 04a54bb1e6..2eb5e6605a 100755 --- a/examples/demo-template/customer-app/src/main/webapp/customers/view.jsp +++ b/examples/demo-template/customer-app/src/main/webapp/customers/view.jsp @@ -3,6 +3,7 @@ <%@ page import="org.keycloak.constants.ServiceUrlConstants" %> <%@ page import="org.keycloak.example.CustomerDatabaseClient" %> <%@ page import="org.keycloak.representations.IDToken" %> +<%@ page import="org.keycloak.representations.UserClaimSet" %> <%@ page import="org.keycloak.util.KeycloakUriBuilder" %> <%@ page session="false" %> @@ -16,17 +17,18 @@ String acctUri = KeycloakUriBuilder.fromUri("/auth").path(ServiceUrlConstants.ACCOUNT_SERVICE_PATH) .queryParam("referrer", "customer-portal").build("demo").toString(); IDToken idToken = CustomerDatabaseClient.getIDToken(request); + UserClaimSet claims = idToken.getUserClaimSet(); %>

Goto: products | logout | manage acct

Servlet User Principal <%=request.getUserPrincipal().getName()%> made this request.

Caller IDToken values (You can specify what is returned in IDToken in the customer-portal claims page in the admin console:

-

Username: <%=idToken.getPreferredUsername()%>

-

Email: <%=idToken.getEmail()%>

-

Full Name: <%=idToken.getName()%>

-

First: <%=idToken.getGivenName()%>

-

Last: <%=idToken.getFamilyName()%>

+

Username: <%=claims.getPreferredUsername()%>

+

Email: <%=claims.getEmail()%>

+

Full Name: <%=claims.getName()%>

+

First: <%=claims.getGivenName()%>

+

Last: <%=claims.getFamilyName()%>

Customer Listing

<% java.util.List list = null; diff --git a/examples/demo-template/third-party/src/main/webapp/pull_data.jsp b/examples/demo-template/third-party/src/main/webapp/pull_data.jsp index 9f102b48c4..6ed0478331 100755 --- a/examples/demo-template/third-party/src/main/webapp/pull_data.jsp +++ b/examples/demo-template/third-party/src/main/webapp/pull_data.jsp @@ -2,6 +2,7 @@ <%@ page import="org.keycloak.representations.AccessTokenResponse" %> <%@ page import="org.keycloak.representations.IDToken" %> <%@ page import="org.keycloak.servlet.ServletOAuthClient" %> +<%@ page import="org.keycloak.representations.UserClaimSet" %> <%@ page language="java" contentType="text/html; charset=ISO-8859-1" pageEncoding="ISO-8859-1"%> <%@ page session="false" %> @@ -16,15 +17,16 @@ AccessTokenResponse tokenResponse = ProductDatabaseClient.getTokenResponse(request); if (tokenResponse.getIdToken() != null) { IDToken idToken = ServletOAuthClient.extractIdToken(tokenResponse.getIdToken()); + UserClaimSet claimSet = idToken.getUserClaimSet(); out.println("

Change client claims in admin console to view personal info of user

"); - if (idToken.getPreferredUsername() != null) { - out.println("

Username: " + idToken.getPreferredUsername() + "

"); + if (claimSet.getPreferredUsername() != null) { + out.println("

Username: " + claimSet.getPreferredUsername() + "

"); } - if (idToken.getName() != null) { - out.println("

Full Name: " + idToken.getName() + "

"); + if (claimSet.getName() != null) { + out.println("

Full Name: " + claimSet.getName() + "

"); } - if (idToken.getEmail() != null) { - out.println("

Email: " + idToken.getEmail() + "

"); + if (claimSet.getEmail() != null) { + out.println("

Email: " + claimSet.getEmail() + "

"); } } list = ProductDatabaseClient.getProducts(request, tokenResponse.getToken()); diff --git a/examples/multi-tenant/src/main/java/org/keycloak/example/multitenant/boundary/ProtectedServlet.java b/examples/multi-tenant/src/main/java/org/keycloak/example/multitenant/boundary/ProtectedServlet.java index 991169dc29..ad41327cea 100644 --- a/examples/multi-tenant/src/main/java/org/keycloak/example/multitenant/boundary/ProtectedServlet.java +++ b/examples/multi-tenant/src/main/java/org/keycloak/example/multitenant/boundary/ProtectedServlet.java @@ -16,14 +16,15 @@ */ package org.keycloak.example.multitenant.boundary; -import java.io.IOException; -import java.io.PrintWriter; +import org.keycloak.KeycloakPrincipal; + import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import org.keycloak.KeycloakPrincipal; +import java.io.IOException; +import java.io.PrintWriter; /** * @@ -54,7 +55,7 @@ public class ProtectedServlet extends HttpServlet { writer.write(principal.getKeycloakSecurityContext().getIdToken().getIssuer()); writer.write("
User: "); - writer.write(principal.getKeycloakSecurityContext().getIdToken().getPreferredUsername()); + writer.write(principal.getKeycloakSecurityContext().getIdToken().getUserClaimSet().getPreferredUsername()); writer.write(String.format("
Logout", realm)); } diff --git a/integration/adapter-core/src/main/java/org/keycloak/adapters/AdapterUtils.java b/integration/adapter-core/src/main/java/org/keycloak/adapters/AdapterUtils.java index e0551fcfe9..a3cfd1d0e5 100755 --- a/integration/adapter-core/src/main/java/org/keycloak/adapters/AdapterUtils.java +++ b/integration/adapter-core/src/main/java/org/keycloak/adapters/AdapterUtils.java @@ -1,14 +1,15 @@ package org.keycloak.adapters; -import java.util.Collections; -import java.util.Set; - import org.jboss.logging.Logger; import org.keycloak.KeycloakPrincipal; import org.keycloak.KeycloakSecurityContext; import org.keycloak.representations.AccessToken; +import org.keycloak.representations.UserClaimSet; import org.keycloak.util.UriUtils; +import java.util.Collections; +import java.util.Set; + /** * @author Marek Posolda */ @@ -65,20 +66,22 @@ public class AdapterUtils { String attr = "sub"; if (deployment.getPrincipalAttribute() != null) attr = deployment.getPrincipalAttribute(); String name = null; + UserClaimSet claimSet = token.getUserClaimSet(); + if ("sub".equals(attr)) { name = token.getSubject(); } else if ("email".equals(attr)) { - name = token.getEmail(); + name = claimSet.getEmail(); } else if ("preferred_username".equals(attr)) { - name = token.getPreferredUsername(); + name = claimSet.getPreferredUsername(); } else if ("name".equals(attr)) { - name = token.getName(); + name = claimSet.getName(); } else if ("given_name".equals(attr)) { - name = token.getGivenName(); + name = claimSet.getGivenName(); } else if ("family_name".equals(attr)) { - name = token.getFamilyName(); + name = claimSet.getFamilyName(); } else if ("nickname".equals(attr)) { - name = token.getNickName(); + name = claimSet.getNickName(); } if (name == null) name = token.getSubject(); return name; diff --git a/integration/js/src/main/resources/keycloak.js b/integration/js/src/main/resources/keycloak.js index 697f118ea5..5161cb1c6a 100755 --- a/integration/js/src/main/resources/keycloak.js +++ b/integration/js/src/main/resources/keycloak.js @@ -204,6 +204,31 @@ return promise.promise; } + kc.loadUserInfo = function() { + var url = getRealmUrl() + '/protocol/openid-connect/userinfo'; + var req = new XMLHttpRequest(); + req.open('GET', url, true); + req.setRequestHeader('Accept', 'application/json'); + req.setRequestHeader('Authorization', 'bearer ' + kc.token); + + var promise = createPromise(); + + req.onreadystatechange = function () { + if (req.readyState == 4) { + if (req.status == 200) { + kc.userInfo = JSON.parse(req.responseText); + promise.setSuccess(kc.userInfo); + } else { + promise.setError(); + } + } + } + + req.send(); + + return promise.promise; + } + kc.isTokenExpired = function(minValidity) { if (!kc.tokenParsed || !kc.refreshToken) { throw 'Not authenticated'; diff --git a/proxy/proxy-server/src/main/java/org/keycloak/proxy/ConstraintAuthorizationHandler.java b/proxy/proxy-server/src/main/java/org/keycloak/proxy/ConstraintAuthorizationHandler.java index 9889d51e4d..c497565bf7 100755 --- a/proxy/proxy-server/src/main/java/org/keycloak/proxy/ConstraintAuthorizationHandler.java +++ b/proxy/proxy-server/src/main/java/org/keycloak/proxy/ConstraintAuthorizationHandler.java @@ -4,10 +4,9 @@ import io.undertow.server.HttpHandler; import io.undertow.server.HttpServerExchange; import io.undertow.util.HttpString; import org.keycloak.adapters.undertow.KeycloakUndertowAccount; +import org.keycloak.representations.UserClaimSet; import org.keycloak.representations.IDToken; -import java.util.Collection; - /** * @author Bill Burke * @version $Revision: 1 $ @@ -65,14 +64,17 @@ public class ConstraintAuthorizationHandler implements HttpHandler { if (idToken.getSubject() != null) { exchange.getRequestHeaders().put(KEYCLOAK_SUBJECT, idToken.getSubject()); } - if (idToken.getPreferredUsername() != null) { - exchange.getRequestHeaders().put(KEYCLOAK_USERNAME, idToken.getPreferredUsername()); + + UserClaimSet claimSet = idToken.getUserClaimSet(); + + if (claimSet.getPreferredUsername() != null) { + exchange.getRequestHeaders().put(KEYCLOAK_USERNAME, claimSet.getPreferredUsername()); } - if (idToken.getEmail() != null) { - exchange.getRequestHeaders().put(KEYCLOAK_EMAIL, idToken.getEmail()); + if (claimSet.getEmail() != null) { + exchange.getRequestHeaders().put(KEYCLOAK_EMAIL, claimSet.getEmail()); } - if (idToken.getName() != null) { - exchange.getRequestHeaders().put(KEYCLOAK_NAME, idToken.getName()); + if (claimSet.getName() != null) { + exchange.getRequestHeaders().put(KEYCLOAK_NAME, claimSet.getName()); } if (sendAccessToken) { exchange.getRequestHeaders().put(KEYCLOAK_ACCESS_TOKEN, account.getKeycloakSecurityContext().getTokenString()); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OpenIDConnectService.java b/services/src/main/java/org/keycloak/protocol/oidc/OpenIDConnectService.java index c33ef91cab..d8033abc53 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/OpenIDConnectService.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OpenIDConnectService.java @@ -8,6 +8,7 @@ import org.jboss.resteasy.spi.HttpRequest; import org.jboss.resteasy.spi.HttpResponse; import org.jboss.resteasy.spi.NotAcceptableException; import org.jboss.resteasy.spi.NotFoundException; +import org.jboss.resteasy.spi.ResteasyProviderFactory; import org.jboss.resteasy.spi.UnauthorizedException; import org.keycloak.ClientConnection; import org.keycloak.Config; @@ -683,6 +684,15 @@ public class OpenIDConnectService { return Cors.add(request, Response.ok(res)).auth().allowedOrigins(client).allowedMethods("POST").exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS).build(); } + @Path("userinfo") + public Object issueUserInfo() { + UserInfoService userInfoEndpoint = new UserInfoService(this); + + ResteasyProviderFactory.getInstance().injectProperties(userInfoEndpoint); + + return userInfoEndpoint; + } + protected ClientModel authorizeClient(String authorizationHeader, MultivaluedMap formData, EventBuilder event) { ClientModel client = authorizeClientBase(authorizationHeader, formData, event, realm); @@ -1148,4 +1158,11 @@ public class OpenIDConnectService { return Response.status(status).entity(e).type("application/json").build(); } + TokenManager getTokenManager() { + return this.tokenManager; + } + + RealmModel getRealm() { + return this.realm; + } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index 219f1a1281..6069db7a75 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -20,6 +20,7 @@ import org.keycloak.models.UserSessionModel; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.representations.AccessToken; import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.representations.UserClaimSet; import org.keycloak.representations.IDToken; import org.keycloak.representations.RefreshToken; import org.keycloak.services.managers.AuthenticationManager; @@ -204,21 +205,23 @@ public class TokenManager { } } - public void initClaims(IDToken token, ClientModel model, UserModel user) { + public void initClaims(UserClaimSet claimSet, ClientModel model, UserModel user) { + claimSet.setSubject(user.getId()); + if (ClaimMask.hasUsername(model.getAllowedClaimsMask())) { - token.setPreferredUsername(user.getUsername()); + claimSet.setPreferredUsername(user.getUsername()); } if (ClaimMask.hasEmail(model.getAllowedClaimsMask())) { - token.setEmail(user.getEmail()); - token.setEmailVerified(user.isEmailVerified()); + claimSet.setEmail(user.getEmail()); + claimSet.setEmailVerified(user.isEmailVerified()); } if (ClaimMask.hasName(model.getAllowedClaimsMask())) { - token.setFamilyName(user.getLastName()); - token.setGivenName(user.getFirstName()); + claimSet.setFamilyName(user.getLastName()); + claimSet.setGivenName(user.getFirstName()); StringBuilder fullName = new StringBuilder(); if (user.getFirstName() != null) fullName.append(user.getFirstName()).append(" "); if (user.getLastName() != null) fullName.append(user.getLastName()); - token.setName(fullName.toString()); + claimSet.setName(fullName.toString()); } } @@ -233,7 +236,8 @@ public class TokenManager { if (realm.getAccessTokenLifespan() > 0) { token.expiration(Time.currentTime() + realm.getAccessTokenLifespan()); } - initClaims(token, claimer, user); + UserClaimSet claimSet = token.getUserClaimSet(); + initClaims(claimSet, claimer, user); return token; } @@ -257,7 +261,8 @@ public class TokenManager { if (allowedOrigins != null) { token.setAllowedOrigins(allowedOrigins); } - initClaims(token, client, user); + UserClaimSet claimSet = token.getUserClaimSet(); + initClaims(claimSet, client, user); return token; } @@ -354,30 +359,30 @@ public class TokenManager { if (realm.getAccessTokenLifespan() > 0) { idToken.expiration(Time.currentTime() + realm.getAccessTokenLifespan()); } - idToken.setPreferredUsername(accessToken.getPreferredUsername()); - idToken.setGivenName(accessToken.getGivenName()); - idToken.setMiddleName(accessToken.getMiddleName()); - idToken.setFamilyName(accessToken.getFamilyName()); - idToken.setName(accessToken.getName()); - idToken.setNickName(accessToken.getNickName()); - idToken.setGender(accessToken.getGender()); - idToken.setPicture(accessToken.getPicture()); - idToken.setProfile(accessToken.getProfile()); - idToken.setWebsite(accessToken.getWebsite()); - idToken.setBirthdate(accessToken.getBirthdate()); - idToken.setEmail(accessToken.getEmail()); - idToken.setEmailVerified(accessToken.getEmailVerified()); - idToken.setLocale(accessToken.getLocale()); - idToken.setFormattedAddress(accessToken.getFormattedAddress()); - idToken.setAddress(accessToken.getAddress()); - idToken.setStreetAddress(accessToken.getStreetAddress()); - idToken.setLocality(accessToken.getLocality()); - idToken.setRegion(accessToken.getRegion()); - idToken.setPostalCode(accessToken.getPostalCode()); - idToken.setCountry(accessToken.getCountry()); - idToken.setPhoneNumber(accessToken.getPhoneNumber()); - idToken.setPhoneNumberVerified(accessToken.getPhoneNumberVerified()); - idToken.setZoneinfo(accessToken.getZoneinfo()); + idToken.getUserClaimSet().setPreferredUsername(accessToken.getUserClaimSet().getPreferredUsername()); + idToken.getUserClaimSet().setGivenName(accessToken.getUserClaimSet().getGivenName()); + idToken.getUserClaimSet().setMiddleName(accessToken.getUserClaimSet().getMiddleName()); + idToken.getUserClaimSet().setFamilyName(accessToken.getUserClaimSet().getFamilyName()); + idToken.getUserClaimSet().setName(accessToken.getUserClaimSet().getName()); + idToken.getUserClaimSet().setNickName(accessToken.getUserClaimSet().getNickName()); + idToken.getUserClaimSet().setGender(accessToken.getUserClaimSet().getGender()); + idToken.getUserClaimSet().setPicture(accessToken.getUserClaimSet().getPicture()); + idToken.getUserClaimSet().setProfile(accessToken.getUserClaimSet().getProfile()); + idToken.getUserClaimSet().setWebsite(accessToken.getUserClaimSet().getWebsite()); + idToken.getUserClaimSet().setBirthdate(accessToken.getUserClaimSet().getBirthdate()); + idToken.getUserClaimSet().setEmail(accessToken.getUserClaimSet().getEmail()); + idToken.getUserClaimSet().setEmailVerified(accessToken.getUserClaimSet().getEmailVerified()); + idToken.getUserClaimSet().setLocale(accessToken.getUserClaimSet().getLocale()); + idToken.getUserClaimSet().setFormattedAddress(accessToken.getUserClaimSet().getFormattedAddress()); + idToken.getUserClaimSet().setAddress(accessToken.getUserClaimSet().getAddress()); + idToken.getUserClaimSet().setStreetAddress(accessToken.getUserClaimSet().getStreetAddress()); + idToken.getUserClaimSet().setLocality(accessToken.getUserClaimSet().getLocality()); + idToken.getUserClaimSet().setRegion(accessToken.getUserClaimSet().getRegion()); + idToken.getUserClaimSet().setPostalCode(accessToken.getUserClaimSet().getPostalCode()); + idToken.getUserClaimSet().setCountry(accessToken.getUserClaimSet().getCountry()); + idToken.getUserClaimSet().setPhoneNumber(accessToken.getUserClaimSet().getPhoneNumber()); + idToken.getUserClaimSet().setPhoneNumberVerified(accessToken.getUserClaimSet().getPhoneNumberVerified()); + idToken.getUserClaimSet().setZoneinfo(accessToken.getUserClaimSet().getZoneinfo()); return this; } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/UserInfoService.java b/services/src/main/java/org/keycloak/protocol/oidc/UserInfoService.java new file mode 100644 index 0000000000..5e69d05b54 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/UserInfoService.java @@ -0,0 +1,150 @@ +/* + * JBoss, Home of Professional Open Source + * + * Copyright 2013 Red Hat, Inc. and/or its affiliates. + * + * 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 org.jboss.resteasy.annotations.cache.NoCache; +import org.jboss.resteasy.spi.HttpRequest; +import org.jboss.resteasy.spi.HttpResponse; +import org.jboss.resteasy.spi.UnauthorizedException; +import org.keycloak.ClientConnection; +import org.keycloak.events.Details; +import org.keycloak.events.EventBuilder; +import org.keycloak.events.EventType; +import org.keycloak.models.ClientModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserSessionModel; +import org.keycloak.representations.AccessToken; +import org.keycloak.representations.UserClaimSet; +import org.keycloak.services.managers.AppAuthManager; +import org.keycloak.services.managers.EventsManager; +import org.keycloak.services.resources.Cors; + +import javax.ws.rs.Consumes; +import javax.ws.rs.FormParam; +import javax.ws.rs.GET; +import javax.ws.rs.OPTIONS; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; + +/** + * @author pedroigor + */ +public class UserInfoService { + + @Context + private HttpRequest request; + + @Context + private HttpResponse response; + + @Context + private KeycloakSession session; + + @Context + private ClientConnection clientConnection; + + private final TokenManager tokenManager; + private final AppAuthManager appAuthManager; + private final OpenIDConnectService openIdConnectService; + private final RealmModel realmModel; + + public UserInfoService(OpenIDConnectService openIDConnectService) { + this.realmModel = openIDConnectService.getRealm(); + + if (this.realmModel == null) { + throw new RuntimeException("Null realm."); + } + + this.tokenManager = openIDConnectService.getTokenManager(); + + if (this.tokenManager == null) { + throw new RuntimeException("Null token manager."); + } + + this.openIdConnectService = openIDConnectService; + this.appAuthManager = new AppAuthManager(); + } + + @Path("/") + @OPTIONS + @Produces(MediaType.APPLICATION_JSON) + public Response issueUserInfoPreflight() { + return Cors.add(this.request, Response.ok()).auth().preflight().build(); + } + + @Path("/") + @GET + @NoCache + @Produces(MediaType.APPLICATION_JSON) + public Response issueUserInfoGet(@Context final HttpHeaders headers) { + String accessToken = this.appAuthManager.extractAuthorizationHeaderToken(headers); + return issueUserInfo(accessToken); + } + + @Path("/") + @POST + @NoCache + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + public Response issueUserInfoPost(@FormParam("access_token") String accessToken) { + return issueUserInfo(accessToken); + } + + private Response issueUserInfo(String token) { + try { + EventBuilder event = new EventsManager(this.realmModel, this.session, this.clientConnection).createEventBuilder() + .event(EventType.USER_INFO_REQUEST) + .detail(Details.AUTH_METHOD, Details.VALIDATE_ACCESS_TOKEN); + + Response validationResponse = this.openIdConnectService.validateAccessToken(token); + + if (!AccessToken.class.isInstance(validationResponse.getEntity())) { + event.error(EventType.USER_INFO_REQUEST.name()); + return Response.fromResponse(validationResponse).status(Status.FORBIDDEN).build(); + } + + AccessToken accessToken = (AccessToken) validationResponse.getEntity(); + UserSessionModel userSession = session.sessions().getUserSession(realmModel, accessToken.getSessionState()); + ClientModel clientModel = realmModel.findClient(accessToken.getIssuedFor()); + UserModel userModel = userSession.getUser(); + UserClaimSet userInfo = new UserClaimSet(); + + this.tokenManager.initClaims(userInfo, clientModel, userModel); + + event + .detail(Details.USERNAME, userModel.getUsername()) + .client(clientModel) + .session(userSession) + .user(userModel) + .success(); + + return Cors.add(request, Response.ok(userInfo)).auth().allowedOrigins(accessToken).build(); + } catch (Exception e) { + throw new UnauthorizedException("Could not retrieve user info.", e); + } + } + +} diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/adapter/MultiTenantServlet.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/adapter/MultiTenantServlet.java index 5e04cf29e3..1501f048e6 100644 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/adapter/MultiTenantServlet.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/adapter/MultiTenantServlet.java @@ -16,13 +16,14 @@ */ package org.keycloak.testsuite.adapter; -import java.io.IOException; -import java.io.PrintWriter; +import org.keycloak.KeycloakSecurityContext; + import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import org.keycloak.KeycloakSecurityContext; +import java.io.IOException; +import java.io.PrintWriter; /** * @@ -37,7 +38,7 @@ public class MultiTenantServlet extends HttpServlet { KeycloakSecurityContext context = (KeycloakSecurityContext)req.getAttribute(KeycloakSecurityContext.class.getName()); pw.print("Username: "); - pw.println(context.getIdToken().getPreferredUsername()); + pw.println(context.getIdToken().getUserClaimSet().getPreferredUsername()); pw.print("
Realm: "); pw.println(context.getRealm()); diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/oidc/UserInfoTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/oidc/UserInfoTest.java new file mode 100755 index 0000000000..2828f8de5d --- /dev/null +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/oidc/UserInfoTest.java @@ -0,0 +1,129 @@ +/* + * JBoss, Home of Professional Open Source. + * Copyright 2012, Red Hat, Inc., and individual contributors + * as indicated by the @author tags. See the copyright.txt file in the + * distribution for a full listing of individual contributors. + * + * This is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation; either version 2.1 of + * the License, or (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this software; if not, write to the Free + * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA + * 02110-1301 USA, or see the FSF site: http://www.fsf.org. + */ +package org.keycloak.testsuite.oidc; + +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.keycloak.models.RealmModel; +import org.keycloak.protocol.oidc.OpenIDConnectService; +import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.representations.UserInfo; +import org.keycloak.testsuite.rule.KeycloakRule; +import org.keycloak.testsuite.rule.WebResource; +import org.keycloak.testsuite.rule.WebRule; +import org.keycloak.util.BasicAuthHelper; +import org.openqa.selenium.WebDriver; + +import javax.ws.rs.client.Client; +import javax.ws.rs.client.ClientBuilder; +import javax.ws.rs.client.Entity; +import javax.ws.rs.client.WebTarget; +import javax.ws.rs.core.Form; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; +import javax.ws.rs.core.UriBuilder; +import java.net.URI; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * @author pedroigor + */ +public class UserInfoTest { + + private static RealmModel realm; + + @ClassRule + public static KeycloakRule keycloakRule = new KeycloakRule(); + + @Rule + public WebRule webRule = new WebRule(this); + + @WebResource + protected WebDriver driver; + + @Test + public void testSuccessfulUserInfoRequest() throws Exception { + Client client = ClientBuilder.newClient(); + UriBuilder builder = UriBuilder.fromUri(org.keycloak.testsuite.Constants.AUTH_SERVER_ROOT); + URI grantUri = OpenIDConnectService.grantAccessTokenUrl(builder).build("test"); + WebTarget grantTarget = client.target(grantUri); + AccessTokenResponse accessTokenResponse = executeGrantAccessTokenRequest(grantTarget); + Response response = executeUserInfoRequest(accessTokenResponse.getToken()); + + assertEquals(Status.OK.getStatusCode(), response.getStatus()); + + UserInfo userInfo = response.readEntity(UserInfo.class); + + response.close(); + + assertNotNull(userInfo); + assertNotNull(userInfo.getSubject()); + assertEquals("test-user@localhost", userInfo.getEmail()); + assertEquals("test-user@localhost", userInfo.getPreferredUsername()); + + client.close(); + } + + @Test + public void testUnsuccessfulUserInfoRequest() throws Exception { + Response response = executeUserInfoRequest("bad"); + + response.close(); + + assertEquals(Status.FORBIDDEN.getStatusCode(), response.getStatus()); + } + + private AccessTokenResponse executeGrantAccessTokenRequest(WebTarget grantTarget) { + String header = BasicAuthHelper.createHeader("test-app", "password"); + Form form = new Form(); + form.param("username", "test-user@localhost") + .param("password", "password"); + + Response response = grantTarget.request() + .header(HttpHeaders.AUTHORIZATION, header) + .post(Entity.form(form)); + + assertEquals(200, response.getStatus()); + + AccessTokenResponse accessTokenResponse = response.readEntity(AccessTokenResponse.class); + + response.close(); + + return accessTokenResponse; + } + + private Response executeUserInfoRequest(String accessToken) { + UriBuilder builder = UriBuilder.fromUri(org.keycloak.testsuite.Constants.AUTH_SERVER_ROOT); + UriBuilder uriBuilder = OpenIDConnectService.tokenServiceBaseUrl(builder); + URI userInfoUri = uriBuilder.path(OpenIDConnectService.class, "issueUserInfo").build("test"); + Client client = ClientBuilder.newClient(); + WebTarget userInfoTarget = client.target(userInfoUri); + + return userInfoTarget.request() + .header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken) + .get(); + } +}