Skip to content

Commit

Permalink
Expose Role/Group Memberships (From SAML) in OpenID Connect JWT token
Browse files Browse the repository at this point in the history
[#99444178] https://www.pivotaltracker.com/story/show/99444178

Signed-off-by: Madhura Bhave <mbhave@pivotal.io>
  • Loading branch information
jlo authored and Chris Maher and David Dening committed Oct 7, 2015
1 parent e69036e commit 7ba367e
Show file tree
Hide file tree
Showing 9 changed files with 166 additions and 43 deletions.
Expand Up @@ -21,6 +21,7 @@
import java.io.Serializable;
import java.util.Collection;
import java.util.List;
import java.util.Set;

/**
* Authentication token which represents a user.
Expand All @@ -33,6 +34,7 @@ public class UaaAuthentication implements Authentication, Serializable {
private boolean authenticated;
private long authenticatedTime = -1l;
private long expiresAt = -1l;
private Set<String> externalGroups;

/**
* Creates a token with the supplied array of authorities.
Expand Down Expand Up @@ -75,6 +77,18 @@ public UaaAuthentication(@JsonProperty("principal") UaaPrincipal principal,
this.expiresAt = expiresAt <= 0 ? -1 : expiresAt;
}

public UaaAuthentication(UaaPrincipal uaaPrincipal,
Object credentials,
List<? extends GrantedAuthority> uaaAuthorityList,
Set<String> externalGroups,
UaaAuthenticationDetails details,
boolean authenticated,
long authenticatedTime,
long expiresAt) {
this(uaaPrincipal, credentials, uaaAuthorityList, details, authenticated, authenticatedTime, expiresAt);
this.externalGroups = externalGroups;
}

public long getAuthenticatedTime() {
return authenticatedTime;
}
Expand Down Expand Up @@ -148,4 +162,12 @@ public int hashCode() {
result = 31 * result + principal.hashCode();
return result;
}

public Set<String> getExternalGroups() {
return externalGroups;
}

public void setExternalGroups(Set<String> externalGroups) {
this.externalGroups = externalGroups;
}
}
Expand Up @@ -17,7 +17,9 @@
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.providers.ExpiringUsernameAuthenticationToken;

import java.util.Collection;
import java.util.List;
import java.util.Set;


public class LoginSamlAuthenticationToken extends ExpiringUsernameAuthenticationToken {
Expand All @@ -34,7 +36,7 @@ public UaaPrincipal getUaaPrincipal() {
return uaaPrincipal;
}

public UaaAuthentication getUaaAuthentication(List<? extends GrantedAuthority> uaaAuthorityList) {
return new UaaAuthentication(getUaaPrincipal(), getCredentials(), uaaAuthorityList, null, isAuthenticated(), System.currentTimeMillis(), getTokenExpiration()==null ? -1l : getTokenExpiration().getTime());
public UaaAuthentication getUaaAuthentication(List<? extends GrantedAuthority> uaaAuthorityList, Set<String> externalGroups) {
return new UaaAuthentication(getUaaPrincipal(), getCredentials(), uaaAuthorityList, externalGroups, null, isAuthenticated(), System.currentTimeMillis(), getTokenExpiration()==null ? -1l : getTokenExpiration().getTime());
}
}
Expand Up @@ -47,4 +47,5 @@ public class Claims {
public static final String REVOCATION_SIGNATURE = "rev_sig";
public static final String NONCE = "nonce";
public static final String ORIGIN = "origin";
public static final String ROLES = "roles";
}
Expand Up @@ -98,12 +98,15 @@
import static org.cloudfoundry.identity.uaa.oauth.Claims.JTI;
import static org.cloudfoundry.identity.uaa.oauth.Claims.NONCE;
import static org.cloudfoundry.identity.uaa.oauth.Claims.ORIGIN;
import static org.cloudfoundry.identity.uaa.oauth.Claims.REVOCATION_SIGNATURE;
import static org.cloudfoundry.identity.uaa.oauth.Claims.ROLES;
import static org.cloudfoundry.identity.uaa.oauth.Claims.SCOPE;
import static org.cloudfoundry.identity.uaa.oauth.Claims.SUB;
import static org.cloudfoundry.identity.uaa.oauth.Claims.USER_ID;
import static org.cloudfoundry.identity.uaa.oauth.Claims.USER_NAME;
import static org.cloudfoundry.identity.uaa.oauth.Claims.ZONE_ID;


/**
* This class provides token services for the UAA. It handles the production and
* consumption of UAA tokens.
Expand Down Expand Up @@ -227,7 +230,7 @@ public OAuth2AccessToken refreshAccessToken(String refreshTokenValue, TokenReque
@SuppressWarnings("unchecked")
Map<String, String> additionalAuthorizationInfo = (Map<String, String>) claims.get(ADDITIONAL_AZ_ATTR);

String revocableHashSignature = (String)claims.get(Claims.REVOCATION_SIGNATURE);
String revocableHashSignature = (String)claims.get(REVOCATION_SIGNATURE);
if (StringUtils.hasText(revocableHashSignature)) {
String newRevocableHashSignature = getRevocableTokenSignature(client, user);
if (!revocableHashSignature.equals(newRevocableHashSignature)) {
Expand Down Expand Up @@ -255,7 +258,8 @@ public OAuth2AccessToken refreshAccessToken(String refreshTokenValue, TokenReque
additionalAuthorizationInfo,
new HashSet<>(),
revocableHashSignature,
false); //TODO populate response types
false,
null); //TODO populate response types

return accessToken;
}
Expand Down Expand Up @@ -317,7 +321,8 @@ private OAuth2AccessToken createAccessToken(String userId,
Map<String, String> additionalAuthorizationAttributes,
Set<String> responseTypes,
String revocableHashSignature,
boolean forceIdTokenCreation) throws AuthenticationException {
boolean forceIdTokenCreation,
Set<String> externalGroupsForIdToken) throws AuthenticationException {
String tokenId = UUID.randomUUID().toString();
OpenIdToken accessToken = new OpenIdToken(tokenId);
if (validitySeconds > 0) {
Expand Down Expand Up @@ -366,7 +371,7 @@ private OAuth2AccessToken createAccessToken(String userId,
String token = JwtHelper.encode(content, signerProvider.getSigner()).getEncoded();
// This setter copies the value and returns. Don't change.
accessToken.setValue(token);
populateIdToken(accessToken, jwtAccessToken, requestedScopes, responseTypes, clientId, forceIdTokenCreation);
populateIdToken(accessToken, jwtAccessToken, requestedScopes, responseTypes, clientId, forceIdTokenCreation, externalGroupsForIdToken);
publish(new TokenIssuedEvent(accessToken, SecurityContextHolder.getContext().getAuthentication()));

return accessToken;
Expand All @@ -377,7 +382,8 @@ private void populateIdToken(OpenIdToken token,
Set<String> scopes,
Set<String> responseTypes,
String aud,
boolean forceIdTokenCreation) {
boolean forceIdTokenCreation,
Set<String> externalGroupsForIdToken) {
if (forceIdTokenCreation || (scopes.contains("openid") && responseTypes.contains(OpenIdToken.ID_TOKEN))) {
try {
Map<String, Object> clone = new HashMap<>(accessTokenValues);
Expand All @@ -390,6 +396,11 @@ private void populateIdToken(OpenIdToken token,
}
clone.put(SCOPE, idTokenScopes);
clone.put(AUD, new HashSet(Arrays.asList(aud)));

if (scopes.contains(ROLES) && !externalGroupsForIdToken.isEmpty()) {
clone.put(ROLES, externalGroupsForIdToken);
}

String content = JsonUtils.writeValueAsString(clone);
String encoded = JwtHelper.encode(content, signerProvider.getSigner()).getEncoded();
token.setIdTokenValue(encoded);
Expand Down Expand Up @@ -439,12 +450,12 @@ private void populateIdToken(OpenIdToken token,
response.put(EMAIL, userEmail);
}
if (userAuthenticationTime!=null) {
response.put(Claims.AUTH_TIME, userAuthenticationTime.getTime() / 1000);
response.put(AUTH_TIME, userAuthenticationTime.getTime() / 1000);
}
}

if (StringUtils.hasText(revocableHashSignature)) {
response.put(Claims.REVOCATION_SIGNATURE, revocableHashSignature);
response.put(REVOCATION_SIGNATURE, revocableHashSignature);
}

response.put(IAT, System.currentTimeMillis() / 1000);
Expand Down Expand Up @@ -511,7 +522,12 @@ public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication)
modifiableUserScopes.addAll(OAuth2Utils.parseParameterList(externalScopes));
}

String nonce = authentication.getOAuth2Request().getRequestParameters().get(Claims.NONCE);
Set<String> externalGroupsForIdToken = new HashSet<>();
if (authentication.getUserAuthentication() instanceof UaaAuthentication) {
externalGroupsForIdToken = ((UaaAuthentication)authentication.getUserAuthentication()).getExternalGroups();
}

String nonce = authentication.getOAuth2Request().getRequestParameters().get(NONCE);

Map<String, String> additionalAuthorizationAttributes =
getAdditionalAuthorizationAttributes(
Expand Down Expand Up @@ -545,7 +561,8 @@ public OAuth2AccessToken createAccessToken(OAuth2Authentication authentication)
additionalAuthorizationAttributes,
responseTypes,
revocableHashSignature,
wasIdTokenRequestedThroughAuthCodeScopeParameter);
wasIdTokenRequestedThroughAuthCodeScopeParameter,
externalGroupsForIdToken);

return accessToken;
}
Expand Down Expand Up @@ -700,7 +717,7 @@ protected String getUserId(OAuth2Authentication authentication) {
}

if (StringUtils.hasText(revocableSignature)) {
response.put(Claims.REVOCATION_SIGNATURE, revocableSignature);
response.put(REVOCATION_SIGNATURE, revocableSignature);
}

response.put(AUD, resourceIds);
Expand Down Expand Up @@ -922,10 +939,10 @@ private Map<String, Object> getClaimsForToken(String token) {
throw new InvalidTokenException("Invalid issuer for token:"+claims.get(ISS));
}

String signature = (String)claims.get(Claims.REVOCATION_SIGNATURE);
String signature = (String)claims.get(REVOCATION_SIGNATURE);
if (signature!=null) { //this ensures backwards compatibility during upgrade
String clientId = (String) claims.get(Claims.CID);
String userId = (String) claims.get(Claims.USER_ID);
String clientId = (String) claims.get(CID);
String userId = (String) claims.get(USER_ID);
UaaUser user = null;
ClientDetails client = clientDetailsService.loadClientByClientId(clientId);
try {
Expand Down
Expand Up @@ -16,10 +16,12 @@
package org.cloudfoundry.identity.uaa.authentication;

import org.cloudfoundry.identity.uaa.util.JsonUtils;
import org.hamcrest.Matchers;
import org.junit.Test;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;

public class UaaAuthenticationSerializationTests {
Expand Down Expand Up @@ -51,4 +53,13 @@ public void testDeserializationWithoutAuthenticatedTime() throws Exception {
assertEquals(inTheFuture, authentication4.getExpiresAt());
assertTrue(authentication4.isAuthenticated());
}
}

@Test
public void deserialization_with_external_groups() throws Exception {
String dataWithExternalGroups ="{\"principal\":{\"id\":\"user-id\",\"name\":\"username\",\"email\":\"email\",\"origin\":\"uaa\",\"externalId\":null,\"zoneId\":\"uaa\"},\"credentials\":null,\"authorities\":[],\"externalGroups\":[\"something\",\"or\",\"other\",\"something\"],\"details\":null,\"authenticated\":true,\"authenticatedTime\":null,\"name\":\"username\"}";
UaaAuthentication authentication = JsonUtils.readValue(dataWithExternalGroups, UaaAuthentication.class);
assertEquals(3, authentication.getExternalGroups().size());
assertThat(authentication.getExternalGroups(), Matchers.containsInAnyOrder("something", "or", "other"));
assertTrue(authentication.isAuthenticated());
}
}
Expand Up @@ -13,12 +13,16 @@
package org.cloudfoundry.identity.uaa.oauth.token;

import com.fasterxml.jackson.core.type.TypeReference;
import org.cloudfoundry.identity.uaa.UaaConfiguration;
import org.cloudfoundry.identity.uaa.audit.AuditEvent;
import org.cloudfoundry.identity.uaa.audit.AuditEventType;
import org.cloudfoundry.identity.uaa.audit.event.TokenIssuedEvent;
import org.cloudfoundry.identity.uaa.authentication.Origin;
import org.cloudfoundry.identity.uaa.authentication.UaaAuthentication;
import org.cloudfoundry.identity.uaa.authentication.UaaAuthenticationDetails;
import org.cloudfoundry.identity.uaa.authentication.UaaPrincipal;
import org.cloudfoundry.identity.uaa.client.ClientConstants;
import org.cloudfoundry.identity.uaa.login.saml.SamlIdentityProviderConfigurator;
import org.cloudfoundry.identity.uaa.oauth.Claims;
import org.cloudfoundry.identity.uaa.oauth.approval.Approval;
import org.cloudfoundry.identity.uaa.oauth.approval.Approval.ApprovalStatus;
Expand Down Expand Up @@ -47,13 +51,16 @@
import org.springframework.security.oauth2.common.exceptions.InvalidGrantException;
import org.springframework.security.oauth2.common.exceptions.InvalidScopeException;
import org.springframework.security.oauth2.common.exceptions.InvalidTokenException;
import org.springframework.security.oauth2.common.util.OAuth2Utils;
import org.springframework.security.oauth2.provider.AuthorizationRequest;
import org.springframework.security.oauth2.provider.OAuth2RequestFactory;
import org.springframework.security.oauth2.provider.client.BaseClientDetails;
import org.springframework.security.oauth2.provider.client.InMemoryClientDetailsService;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.request.DefaultOAuth2RequestFactory;

import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
Expand All @@ -63,9 +70,11 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import static org.cloudfoundry.identity.uaa.user.UaaAuthority.USER_AUTHORITIES;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
Expand Down Expand Up @@ -95,7 +104,8 @@ public class UaaTokenServicesTests {
public static final String ALL_GRANTS_CSV = "authorization_code,password,implicit,client_credentials";
public static final String CLIENTS = "clients";
public static final String SCIM = "scim";

public static final String OPENID = "openid";
public static final String ROLES = "roles";

private TestApplicationEventPublisher<TokenIssuedEvent> publisher;
private UaaTokenServices tokenServices = new UaaTokenServices();
Expand Down Expand Up @@ -657,6 +667,36 @@ public void testCreateAccessTokenImplicitGrant() {
testCreateAccessTokenForAUser(authentication, true);
}

@Test
public void create_id_token_with_roles_scope() {
Jwt idTokenJwt = getIdToken(Arrays.asList(OPENID, ROLES));
assertTrue(idTokenJwt.getClaims().contains("\"roles\":[\"group2\",\"group1\"]"));
}

@Test
public void create_id_token_without_roles_scope() {
Jwt idTokenJwt = getIdToken(Arrays.asList(OPENID));
assertFalse(idTokenJwt.getClaims().contains("\"roles\""));
}

private Jwt getIdToken(List<String> scopes) {
AuthorizationRequest authorizationRequest = new AuthorizationRequest(CLIENT_ID, scopes);

authorizationRequest.setResponseTypes(new HashSet<>(Arrays.asList(OpenIdToken.ID_TOKEN)));

UaaPrincipal uaaPrincipal = new UaaPrincipal(defaultUser.getId(), defaultUser.getUsername(), defaultUser.getEmail(), defaultUser.getOrigin(), defaultUser.getExternalId(), defaultUser.getZoneId());
UaaAuthentication userAuthentication = new UaaAuthentication(uaaPrincipal, null, defaultUserAuthorities, new HashSet<>(Arrays.asList("group1", "group2")), null, true, System.currentTimeMillis(), System.currentTimeMillis() + 1000l * 60l);

OAuth2Authentication authentication = new OAuth2Authentication(authorizationRequest.createOAuth2Request(), userAuthentication);

OAuth2AccessToken accessToken = tokenServices.createAccessToken(authentication);

Jwt tokenJwt = JwtHelper.decodeAndVerify(accessToken.getValue(), signerProvider.getVerifier());
assertNotNull(tokenJwt);

return JwtHelper.decodeAndVerify(((OpenIdToken) accessToken).getIdTokenValue(), signerProvider.getVerifier());
}

@Test
public void testCreateAccessWithNonExistingScopes() {
List<String> scopesThatDontExist = Arrays.asList("scope1","scope2");
Expand Down
4 changes: 2 additions & 2 deletions docs/UAA-APIs.rst
Expand Up @@ -1087,7 +1087,7 @@ Fields *Available Fields* ::
last_modified epoch timestamp Auto UAA sets the modification date

UAA Provider Configuration (provided in JSON format as part of the ``config`` field on the Identity Provider - See class org.cloudfoundry.identity.uaa.zone.UaaIdentityProviderDefinition
====================== =============== ======== =================================================================================================================================================================================================
============================= =============== ======== =================================================================================================================================================================================================
minLength int Required Minimum number of characters for a user provided password, 0+
maxLength int Required Maximum number of characters for a user provided password, 1+
requireUpperCaseCharacter int Required Minimum number of upper case characters for a user provided password, 0+
Expand Down Expand Up @@ -1115,7 +1115,7 @@ Fields *Available Fields* ::
emailDomain List<String> Optional List of email domains associated with the SAML provider for the purpose of associating users to the correct origin upon invitation. If null or empty list, no invitations are accepted. Wildcards supported.

LDAP Provider Configuration (provided in JSON format as part of the ``config`` field on the Identity Provider - See class org.cloudfoundry.identity.uaa.ldap.LdapIdentityProviderDefinition
====================== =============== ======== =================================================================================================================================================================================================
====================== =============== ======== =================================================================================================================================================================================================
ldapProfileFile String Required Value must be "ldap/ldap-search-and-bind.xml" (until other configuration options are supported)
ldapGroupFile String Required Value must be "ldap/ldap-groups-map-to-scopes.xml" (until other configuration options are supported)
baseUrl String Required URL to LDAP server, starts with ldap:// or ldaps://
Expand Down

0 comments on commit 7ba367e

Please sign in to comment.