Skip to content

Commit

Permalink
feature: add runtime support for private_key_jwt client authentication
Browse files Browse the repository at this point in the history
More details in #2449, in #2433 as this PR include #2433. -> because to have smaller review packages

Enable the validation of client_assertion as replacement for client_secret
Add private_key_jwt as client_auth_method into tokens.
  • Loading branch information
strehle committed Sep 27, 2023
1 parent 77d5ea5 commit eebcc48
Show file tree
Hide file tree
Showing 22 changed files with 559 additions and 42 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ static public List<String> getStringValues() {
public static final String GRANT_TYPE_IMPLICIT = "implicit";

public static final String CLIENT_AUTH_NONE = "none";
public static final String CLIENT_AUTH_PRIVATE_KEY_JWT = "private_key_jwt";

public static final String ID_TOKEN_HINT_PROMPT = "prompt";
public static final String ID_TOKEN_HINT_PROMPT_NONE = "none";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,4 +310,11 @@ public static String getCleanedUserControlString(String input) {
}
return CTRL_PATTERN.matcher(input).replaceAll("_");
}

public static String getSafeParameterValue(String[] value) {
if (null == value || value.length < 1) {
return EMPTY_STRING;
}
return StringUtils.hasText(value[0]) ? value[0] : EMPTY_STRING;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,14 @@ void getMapFromProperties() {
assertThat(objectMap, hasEntry("key", "value"));
}

@Test
void getSafeParameterValue() {
assertEquals("test", UaaStringUtils.getSafeParameterValue(new String[] {"test"}));
assertEquals("", UaaStringUtils.getSafeParameterValue(new String[] {" "}));
assertEquals("", UaaStringUtils.getSafeParameterValue(new String[] {}));
assertEquals("", UaaStringUtils.getSafeParameterValue(null));
}

private static void replaceZoneVariables(IdentityZone zone) {
String s = "https://{zone.subdomain}.domain.com/z/{zone.id}?id={zone.id}&domain={zone.subdomain}";
String expect = String.format("https://%s.domain.com/z/%s?id=%s&domain=%s", zone.getSubdomain(), zone.getId(), zone.getId(), zone.getSubdomain());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
*/
package org.cloudfoundry.identity.uaa.authentication;

import org.cloudfoundry.identity.uaa.oauth.jwt.JwtClientAuthentication;
import org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants;
import org.cloudfoundry.identity.uaa.util.UaaStringUtils;
import org.slf4j.Logger;
Expand Down Expand Up @@ -42,6 +43,7 @@
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

/**
* Filter which processes and authenticates a client based on
Expand All @@ -54,6 +56,7 @@ public abstract class AbstractClientParametersAuthenticationFilter implements Fi

public static final String CLIENT_ID = "client_id";
public static final String CLIENT_SECRET = "client_secret";
public static final String CLIENT_ASSERTION = "client_assertion";
protected final Logger logger = LoggerFactory.getLogger(getClass());

protected AuthenticationManager clientAuthenticationManager;
Expand Down Expand Up @@ -85,6 +88,9 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha
String clientId = loginInfo.get(CLIENT_ID);

try {
if (clientId == null) {
clientId = Optional.ofNullable(loginInfo.get(CLIENT_ASSERTION)).map(JwtClientAuthentication::getClientId).orElse(null);
}
wrapClientCredentialLogin(req, res, loginInfo, clientId);
} catch (AuthenticationException ex) {
logger.debug("Could not authenticate with client credentials.");
Expand Down Expand Up @@ -152,8 +158,12 @@ private Authentication performClientAuthentication(HttpServletRequest req, Map<S

private Map<String, String> getCredentials(HttpServletRequest request) {
Map<String, String> credentials = new HashMap<>();
credentials.put(CLIENT_ID, request.getParameter(CLIENT_ID));
String clientId = request.getParameter(CLIENT_ID);
credentials.put(CLIENT_ID, clientId);
credentials.put(CLIENT_SECRET, request.getParameter(CLIENT_SECRET));
if (clientId == null) {
credentials.put(CLIENT_ASSERTION, request.getParameter(CLIENT_ASSERTION));
}
return credentials;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@

package org.cloudfoundry.identity.uaa.authentication;

import org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants;
import org.cloudfoundry.identity.uaa.provider.oauth.ExternalOAuthAuthenticationManager;
import org.cloudfoundry.identity.uaa.util.SessionUtils;
import org.cloudfoundry.identity.uaa.util.UaaSecurityContextUtils;
import org.cloudfoundry.identity.uaa.util.UaaStringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -130,6 +132,10 @@ public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
if (clientAuth.isAuthenticated()) {
// Ensure the OAuth2Authentication is authenticated
authorizationRequest.setApproved(true);
String clientAuthentication = UaaSecurityContextUtils.getClientAuthenticationMethod(clientAuth);
if (clientAuthentication != null) {
authorizationRequest.getExtensions().put(ClaimConstants.CLIENT_AUTH_METHOD, clientAuthentication);
}
}

OAuth2Request storedOAuth2Request = oAuth2RequestFactory.createOAuth2Request(authorizationRequest);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@
package org.cloudfoundry.identity.uaa.authentication;

import org.cloudfoundry.identity.uaa.client.UaaClient;
import org.cloudfoundry.identity.uaa.oauth.jwt.JwtClientAuthentication;
import org.cloudfoundry.identity.uaa.oauth.pkce.PkceValidationService;
import org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants;
import org.cloudfoundry.identity.uaa.oauth.token.TokenConstants;
import org.cloudfoundry.identity.uaa.util.UaaStringUtils;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
Expand All @@ -28,16 +29,21 @@

import java.util.Collections;
import java.util.Map;
import java.util.Optional;

import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.CLIENT_AUTH_NONE;
import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.CLIENT_AUTH_PRIVATE_KEY_JWT;
import static org.cloudfoundry.identity.uaa.util.UaaStringUtils.getSafeParameterValue;

public class ClientDetailsAuthenticationProvider extends DaoAuthenticationProvider {

final JwtClientAuthentication jwtClientAuthentication;

public ClientDetailsAuthenticationProvider(UserDetailsService userDetailsService, PasswordEncoder encoder) {
public ClientDetailsAuthenticationProvider(UserDetailsService userDetailsService, PasswordEncoder encoder, JwtClientAuthentication jwtClientAuthentication) {
super();
setUserDetailsService(userDetailsService);
setPasswordEncoder(encoder);
this.jwtClientAuthentication = jwtClientAuthentication;
}

@Override
Expand All @@ -59,7 +65,13 @@ protected void additionalAuthenticationChecks(UserDetails userDetails, UsernameP
if (authentication.getCredentials() == null) {
if (isPublicGrantTypeUsageAllowed(authentication.getDetails()) && uaaClient.isAllowPublic()) {
// in case of grant_type=authorization_code and code_verifier passed (PKCE) we check if client has option allowpublic with true and continue even if no secret is in request
((UaaAuthenticationDetails) authentication.getDetails()).setAuthenticationMethod(CLIENT_AUTH_NONE);
setAuthenticationMethod(authentication, CLIENT_AUTH_NONE);
break;
} else if (isPrivateKeyJwt(authentication.getDetails())) {
if (!validatePrivateKeyJwt(authentication.getDetails(), uaaClient)) {
error = new BadCredentialsException("Bad client_assertion type");
}
setAuthenticationMethod(authentication, CLIENT_AUTH_PRIVATE_KEY_JWT);
break;
}
}
Expand All @@ -79,11 +91,15 @@ protected void additionalAuthenticationChecks(UserDetails userDetails, UsernameP
}
}

private static void setAuthenticationMethod(AbstractAuthenticationToken authentication, String method) {
if (authentication.getDetails() instanceof UaaAuthenticationDetails) {
((UaaAuthenticationDetails) authentication.getDetails()).setAuthenticationMethod(method);
}
}

private boolean isPublicGrantTypeUsageAllowed(Object uaaAuthenticationDetails) {
UaaAuthenticationDetails authenticationDetails = uaaAuthenticationDetails instanceof UaaAuthenticationDetails ?
(UaaAuthenticationDetails) uaaAuthenticationDetails : new UaaAuthenticationDetails();
Map<String, String[]> requestParameters = authenticationDetails.getParameterMap() != null ?
authenticationDetails.getParameterMap() : Collections.emptyMap();
UaaAuthenticationDetails authenticationDetails = getUaaAuthenticationDetails(uaaAuthenticationDetails);
Map<String, String[]> requestParameters = getRequestParameters(authenticationDetails);
return isPublicTokenRequest(authenticationDetails) && (isAuthorizationWithPkce(requestParameters) || isRefreshFlow(requestParameters));
}

Expand All @@ -105,10 +121,28 @@ private boolean isRefreshFlow(Map<String, String[]> requestParameters) {
&& TokenConstants.GRANT_TYPE_REFRESH_TOKEN.equals(getSafeParameterValue(requestParameters.get(ClaimConstants.GRANT_TYPE)));
}

private String getSafeParameterValue(String[] value) {
if (null == value || value.length < 1) {
return UaaStringUtils.EMPTY_STRING;
private static UaaAuthenticationDetails getUaaAuthenticationDetails(Object object) {
return object instanceof UaaAuthenticationDetails ? (UaaAuthenticationDetails) object : new UaaAuthenticationDetails();
}

private static Map<String, String[]> getRequestParameters(UaaAuthenticationDetails authenticationDetails) {
return Optional.ofNullable(authenticationDetails.getParameterMap()).orElse(Collections.emptyMap());
}

private boolean isPrivateKeyJwt(Object uaaAuthenticationDetails) {
UaaAuthenticationDetails authenticationDetails = getUaaAuthenticationDetails(uaaAuthenticationDetails);
Map<String, String[]> requestParameters = getRequestParameters(authenticationDetails);
if (isPublicTokenRequest(authenticationDetails) &&
!StringUtils.hasText(getSafeParameterValue(requestParameters.get("client_secret"))) &&
StringUtils.hasText(getSafeParameterValue(requestParameters.get("client_assertion_type"))) &&
StringUtils.hasText(getSafeParameterValue(requestParameters.get("client_assertion")))) {
return true;
}
return StringUtils.hasText(value[0]) ? value[0] : UaaStringUtils.EMPTY_STRING;
return false;
}

private boolean validatePrivateKeyJwt(Object uaaAuthenticationDetails, UaaClient uaaClient) {
return jwtClientAuthentication.validateClientJwt(getRequestParameters(getUaaAuthenticationDetails(uaaAuthenticationDetails)),
uaaClient.getClientJwtConfiguration(), uaaClient.getUsername());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,24 @@ public class UaaClient extends User {
private transient Map<String, Object> additionalInformation;

private final String secret;
private final String clientJwtConfig;

public UaaClient(String username, String password, Collection<? extends GrantedAuthority> authorities, Map<String, Object> additionalInformation) {
public UaaClient(String username, String password, Collection<? extends GrantedAuthority> authorities, Map<String, Object> additionalInformation,
String clientJwtConfig) {
super(username, password == null ? "" : password, authorities);
this.additionalInformation = additionalInformation;
this.secret = password;
this.clientJwtConfig = clientJwtConfig;
}

public UaaClient(UserDetails userDetails, String secret) {
super(userDetails.getUsername(), secret == null ? "" : secret, userDetails.isEnabled(), userDetails.isAccountNonExpired(),
userDetails.isCredentialsNonExpired(), userDetails.isAccountNonLocked(), userDetails.getAuthorities());
if (userDetails instanceof UaaClient) {
this.additionalInformation = ((UaaClient) userDetails).getAdditionalInformation();
this.clientJwtConfig = ((UaaClient) userDetails).clientJwtConfig;
} else {
this.clientJwtConfig = null;
}
this.secret = secret;
}
Expand All @@ -46,6 +52,12 @@ private Map<String, Object> getAdditionalInformation() {
return this.additionalInformation;
}

public ClientJwtConfiguration getClientJwtConfiguration() {
UaaClientDetails uaaClientDetails = new UaaClientDetails();
uaaClientDetails.setClientJwtConfig(clientJwtConfig);
return Optional.ofNullable(ClientJwtConfiguration.readValue(uaaClientDetails)).orElse(new ClientJwtConfiguration());
}

/**
* Allow to return a null password. Super class does not allow to omit a password, therefore use own method
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx
} catch (NoSuchClientException e) {
throw new UsernameNotFoundException(e.getMessage(), e);
}
return new UaaClient(username, clientDetails.getClientSecret(), clientDetails.getAuthorities(), clientDetails.getAdditionalInformation());
return new UaaClient(username, clientDetails.getClientSecret(), clientDetails.getAuthorities(), clientDetails.getAdditionalInformation(),
clientDetails.getClientJwtConfig());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@
package org.cloudfoundry.identity.uaa.oauth;

import org.cloudfoundry.identity.uaa.impl.config.LegacyTokenKey;
import org.cloudfoundry.identity.uaa.util.UaaTokenUtils;
import org.cloudfoundry.identity.uaa.zone.IdentityZoneConfiguration;
import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder;
import org.cloudfoundry.identity.uaa.zone.TokenPolicy;
import org.springframework.util.StringUtils;

import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;

Expand Down Expand Up @@ -89,4 +91,8 @@ private String getActiveKeyId() {

return activeKeyId;
}

public String getTokenEndpointUrl() throws URISyntaxException {
return UaaTokenUtils.constructTokenEndpointUrl(uaaBaseURL, IdentityZoneHolder.get());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@
package org.cloudfoundry.identity.uaa.oauth;

import org.cloudfoundry.identity.uaa.oauth.client.ClientConstants;
import org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants;
import org.cloudfoundry.identity.uaa.oauth.token.TokenConstants;
import org.cloudfoundry.identity.uaa.provider.IdentityProvider;
import org.cloudfoundry.identity.uaa.provider.IdentityProviderProvisioning;
import org.cloudfoundry.identity.uaa.provider.JdbcIdentityProviderProvisioning;
import org.cloudfoundry.identity.uaa.security.beans.SecurityContextAccessor;
import org.cloudfoundry.identity.uaa.user.UaaUser;
import org.cloudfoundry.identity.uaa.user.UaaUserDatabase;
import org.cloudfoundry.identity.uaa.util.UaaSecurityContextUtils;
import org.cloudfoundry.identity.uaa.util.UaaStringUtils;
import org.cloudfoundry.identity.uaa.util.UaaTokenUtils;
import org.cloudfoundry.identity.uaa.zone.MultitenantClientServices;
Expand Down Expand Up @@ -397,6 +398,10 @@ public UaaTokenRequest(Map<String, String> requestParameters, String clientId, C
@Override
public OAuth2Request createOAuth2Request(ClientDetails client) {
OAuth2Request request = super.createOAuth2Request(client);
String clientAuthentication = UaaSecurityContextUtils.getClientAuthenticationMethod();
if (clientAuthentication != null) {
request.getExtensions().put(ClaimConstants.CLIENT_AUTH_METHOD, clientAuthentication);
}
return new OAuth2Request(
request.getRequestParameters(),
client.getClientId(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,13 +319,14 @@ private static String getAuthenticationMethod(OAuth2Request oAuth2Request) {
}

private void addAuthenticationMethod(Claims claims, Map<String, Object> additionalRootClaims, UserAuthenticationData authenticationData) {
if (authenticationData.clientAuth != null && CLIENT_AUTH_NONE.equals(authenticationData.clientAuth)) {
if (authenticationData.clientAuth != null) {
// public refresh flow, allowed if access_token before was also without authentication (claim: client_auth_method=none) and refresh token is one time use (rotate it in refresh)
if (refreshTokenCreator.shouldRotateRefreshTokens() && CLIENT_AUTH_NONE.equals(claims.getClientAuth())) {
addRootClaimEntry(additionalRootClaims, CLIENT_AUTH_METHOD, authenticationData.clientAuth);
} else {
if (CLIENT_AUTH_NONE.equals(authenticationData.clientAuth) && // current authentication
(!CLIENT_AUTH_NONE.equals(claims.getClientAuth()) || // authentication before
!refreshTokenCreator.shouldRotateRefreshTokens())) {
throw new TokenRevokedException("Refresh without client authentication not allowed.");
}
addRootClaimEntry(additionalRootClaims, CLIENT_AUTH_METHOD, authenticationData.clientAuth);
}
}

Expand Down
Loading

0 comments on commit eebcc48

Please sign in to comment.