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
#2507)

* feature: add runtime support for private_key_jwt client authentication

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.

* review

* fix smells from sonar

https://sonarcloud.io/summary/new_code?id=cloudfoundry-identity-parent&pullRequest=2507
  • Loading branch information
strehle committed Oct 12, 2023
1 parent 319cc75 commit 2658f90
Show file tree
Hide file tree
Showing 23 changed files with 575 additions and 53 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 {

private 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,36 +91,55 @@ protected void additionalAuthenticationChecks(UserDetails userDetails, UsernameP
}
}

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();
private static void setAuthenticationMethod(AbstractAuthenticationToken authentication, String method) {
if (authentication.getDetails() instanceof UaaAuthenticationDetails) {
((UaaAuthenticationDetails) authentication.getDetails()).setAuthenticationMethod(method);
}
}

private static boolean isPublicGrantTypeUsageAllowed(Object uaaAuthenticationDetails) {
UaaAuthenticationDetails authenticationDetails = getUaaAuthenticationDetails(uaaAuthenticationDetails);
Map<String, String[]> requestParameters = getRequestParameters(authenticationDetails);
return isPublicTokenRequest(authenticationDetails) && (isAuthorizationWithPkce(requestParameters) || isRefreshFlow(requestParameters));
}

private static boolean isPublicTokenRequest(UaaAuthenticationDetails authenticationDetails) {
return !authenticationDetails.isAuthorizationSet() && "/oauth/token".equals(authenticationDetails.getRequestPath());
}

private boolean isAuthorizationWithPkce(Map<String, String[]> requestParameters) {
private static boolean isAuthorizationWithPkce(Map<String, String[]> requestParameters) {
return PkceValidationService.isCodeVerifierParameterValid(getSafeParameterValue(requestParameters.get("code_verifier"))) &&
StringUtils.hasText(getSafeParameterValue(requestParameters.get("client_id"))) &&
StringUtils.hasText(getSafeParameterValue(requestParameters.get("code"))) &&
StringUtils.hasText(getSafeParameterValue(requestParameters.get("redirect_uri"))) &&
TokenConstants.GRANT_TYPE_AUTHORIZATION_CODE.equals(getSafeParameterValue(requestParameters.get(ClaimConstants.GRANT_TYPE)));
}

private boolean isRefreshFlow(Map<String, String[]> requestParameters) {
private static boolean isRefreshFlow(Map<String, String[]> requestParameters) {
return StringUtils.hasText(getSafeParameterValue(requestParameters.get("client_id")))
&& StringUtils.hasText(getSafeParameterValue(requestParameters.get("refresh_token")))
&& 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;
}
return StringUtils.hasText(value[0]) ? value[0] : 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 static boolean isPrivateKeyJwt(Object uaaAuthenticationDetails) {
UaaAuthenticationDetails authenticationDetails = getUaaAuthenticationDetails(uaaAuthenticationDetails);
Map<String, String[]> requestParameters = getRequestParameters(authenticationDetails);
return (isPublicTokenRequest(authenticationDetails) &&
!StringUtils.hasText(getSafeParameterValue(requestParameters.get("client_secret"))) &&
StringUtils.hasText(getSafeParameterValue(requestParameters.get("client_assertion_type"))) &&
StringUtils.hasText(getSafeParameterValue(requestParameters.get("client_assertion"))));
}

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 @@ -211,7 +211,18 @@ public static ClientJwtConfiguration readValue(UaaClientDetails clientDetails) {
!(clientDetails.getClientJwtConfig() instanceof String)) {
return null;
}
return JsonUtils.readValue(clientDetails.getClientJwtConfig(), ClientJwtConfiguration.class);
return readValue(clientDetails.getClientJwtConfig());
}

/**
* Creator from searialized ClientJwtConfiguration.
*
* @param clientJwtConfig
* @return
*/
@JsonIgnore
public static ClientJwtConfiguration readValue(String clientJwtConfig) {
return JsonUtils.readValue(clientJwtConfig, ClientJwtConfiguration.class);
}

/**
Expand Down
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,10 @@ private Map<String, Object> getAdditionalInformation() {
return this.additionalInformation;
}

public ClientJwtConfiguration getClientJwtConfiguration() {
return Optional.ofNullable(clientJwtConfig).map(ClientJwtConfiguration::readValue).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 2658f90

Please sign in to comment.