Implement DPoP for all grantTypes (#29967)
fixes #30179 fixes #30181 Signed-off-by: Pascal Knüppel <captain.p.goldfish@gmx.de> Signed-off-by: Pascal Knüppel <pascal.knueppel@governikus.de>
This commit is contained in:
parent
17c01c9380
commit
94784182df
16 changed files with 405 additions and 171 deletions
|
@ -152,6 +152,8 @@ public interface OAuth2Constants {
|
|||
String ISSUER = "iss";
|
||||
|
||||
String AUTHENTICATOR_METHOD_REFERENCE = "amr";
|
||||
|
||||
String CNF = "cnf";
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -36,7 +36,6 @@ public class RefreshToken extends AccessToken {
|
|||
/**
|
||||
* Deep copies issuer, subject, issuedFor, sessionState from AccessToken.
|
||||
*
|
||||
* @param token
|
||||
*/
|
||||
public RefreshToken(AccessToken token) {
|
||||
this();
|
||||
|
@ -49,6 +48,25 @@ public class RefreshToken extends AccessToken {
|
|||
this.scope = token.scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep copies issuer, subject, issuedFor, sessionState from AccessToken.
|
||||
*
|
||||
* @param token
|
||||
* @param confirmation optional confirmation parameter that might be processed during authentication but should not
|
||||
* always be included in the response
|
||||
*/
|
||||
public RefreshToken(AccessToken token, Confirmation confirmation) {
|
||||
this();
|
||||
this.issuer = token.issuer;
|
||||
this.subject = token.subject;
|
||||
this.issuedFor = token.issuedFor;
|
||||
this.sessionId = token.sessionId;
|
||||
this.nonce = token.nonce;
|
||||
this.audience = new String[] { token.issuer };
|
||||
this.scope = token.scope;
|
||||
this.confirmation = confirmation;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TokenCategory getCategory() {
|
||||
return TokenCategory.INTERNAL;
|
||||
|
|
|
@ -33,7 +33,6 @@ import org.keycloak.models.ClientModel;
|
|||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.provider.Provider;
|
||||
import org.keycloak.representations.dpop.DPoP;
|
||||
import org.keycloak.services.cors.Cors;
|
||||
|
||||
/**
|
||||
|
@ -72,10 +71,9 @@ public interface OAuth2GrantType extends Provider {
|
|||
protected EventBuilder event;
|
||||
protected Cors cors;
|
||||
protected Object tokenManager;
|
||||
protected DPoP dPoP;
|
||||
|
||||
public Context(KeycloakSession session, Object clientConfig, Map<String, String> clientAuthAttributes,
|
||||
MultivaluedMap<String, String> formParams, EventBuilder event, Cors cors, Object tokenManager, DPoP dPoP) {
|
||||
MultivaluedMap<String, String> formParams, EventBuilder event, Cors cors, Object tokenManager) {
|
||||
this.session = session;
|
||||
this.realm = session.getContext().getRealm();
|
||||
this.client = session.getContext().getClient();
|
||||
|
@ -89,7 +87,6 @@ public interface OAuth2GrantType extends Provider {
|
|||
this.event = event;
|
||||
this.cors = cors;
|
||||
this.tokenManager = tokenManager;
|
||||
this.dPoP = dPoP;
|
||||
}
|
||||
|
||||
public Context(Context context) {
|
||||
|
@ -106,7 +103,6 @@ public interface OAuth2GrantType extends Provider {
|
|||
this.event = context.event;
|
||||
this.cors = context.cors;
|
||||
this.tokenManager = context.tokenManager;
|
||||
this.dPoP = context.dPoP;
|
||||
}
|
||||
|
||||
public void setFormParams(MultivaluedHashMap<String, String> formParams) {
|
||||
|
|
|
@ -24,6 +24,7 @@ import org.keycloak.models.ProtocolMapperModel;
|
|||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory;
|
||||
import org.keycloak.services.util.DPoPUtil;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.AbstractMap;
|
||||
|
@ -130,7 +131,9 @@ public class ProtocolMapperUtils {
|
|||
|
||||
public static Stream<Entry<ProtocolMapperModel, ProtocolMapper>> getSortedProtocolMappers(KeycloakSession session, ClientSessionContext ctx, Predicate<Entry<ProtocolMapperModel, ProtocolMapper>> filter) {
|
||||
KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory();
|
||||
return ctx.getProtocolMappersStream()
|
||||
|
||||
Stream<Entry<ProtocolMapperModel, ProtocolMapper>> protocolMapperStream = //
|
||||
ctx.getProtocolMappersStream()
|
||||
.<Entry<ProtocolMapperModel, ProtocolMapper>>map(mapperModel -> {
|
||||
ProtocolMapper mapper = (ProtocolMapper) sessionFactory.getProviderFactory(ProtocolMapper.class, mapperModel.getProtocolMapper());
|
||||
if (mapper == null) {
|
||||
|
@ -139,7 +142,9 @@ public class ProtocolMapperUtils {
|
|||
return new AbstractMap.SimpleEntry<>(mapperModel, mapper);
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.filter(filter)
|
||||
.filter(filter);
|
||||
|
||||
return Stream.concat(protocolMapperStream, DPoPUtil.getTransientProtocolMapper())
|
||||
.sorted(Comparator.comparing(ProtocolMapperUtils::compare));
|
||||
}
|
||||
|
||||
|
|
|
@ -113,6 +113,7 @@ public class DockerAuthV2Protocol implements LoginProtocol {
|
|||
AtomicReference<DockerResponseToken> finalResponseToken = new AtomicReference<>(responseToken);
|
||||
ProtocolMapperUtils.getSortedProtocolMappers(session, clientSessionCtx, mapper ->
|
||||
mapper.getValue() instanceof DockerAuthV2AttributeMapper && ((DockerAuthV2AttributeMapper) mapper.getValue()).appliesTo(finalResponseToken.get()))
|
||||
.filter(mapper -> mapper instanceof DockerAuthV2AttributeMapper)
|
||||
.forEach(mapper -> finalResponseToken.set(((DockerAuthV2AttributeMapper) mapper.getValue())
|
||||
.transformDockerResponseToken(finalResponseToken.get(), mapper.getKey(), session, userSession, clientSession)));
|
||||
responseToken = finalResponseToken.get();
|
||||
|
|
|
@ -46,7 +46,6 @@ import org.keycloak.models.ClientModel;
|
|||
import org.keycloak.models.ClientScopeModel;
|
||||
import org.keycloak.models.ClientSessionContext;
|
||||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.models.ImpersonationSessionNote;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.ProtocolMapperModel;
|
||||
import org.keycloak.models.RealmModel;
|
||||
|
@ -959,7 +958,7 @@ public class TokenManager {
|
|||
ClientSessionContext clientSessionCtx, UriInfo uriInfo) {
|
||||
AccessToken token = new AccessToken();
|
||||
token.id(KeycloakModelUtils.generateId());
|
||||
token.type(TokenUtil.TOKEN_TYPE_BEARER);
|
||||
token.type(formatTokenType(client, token));
|
||||
if (UserSessionModel.SessionPersistenceState.TRANSIENT.equals(session.getPersistenceState())) {
|
||||
token.subject(user.getId());
|
||||
}
|
||||
|
@ -1061,7 +1060,7 @@ public class TokenManager {
|
|||
this.session = session;
|
||||
this.userSession = userSession;
|
||||
this.clientSessionCtx = clientSessionCtx;
|
||||
this.responseTokenType = formatTokenType(client);
|
||||
this.responseTokenType = formatTokenType(client, null);
|
||||
}
|
||||
|
||||
public AccessToken getAccessToken() {
|
||||
|
@ -1078,6 +1077,7 @@ public class TokenManager {
|
|||
|
||||
public AccessTokenResponseBuilder accessToken(AccessToken accessToken) {
|
||||
this.accessToken = accessToken;
|
||||
this.responseTokenType = formatTokenType(client, accessToken);
|
||||
return this;
|
||||
}
|
||||
public AccessTokenResponseBuilder refreshToken(RefreshToken refreshToken) {
|
||||
|
@ -1098,6 +1098,7 @@ public class TokenManager {
|
|||
public AccessTokenResponseBuilder generateAccessToken() {
|
||||
UserModel user = userSession.getUser();
|
||||
accessToken = createClientAccessToken(session, realm, client, user, userSession, clientSessionCtx);
|
||||
responseTokenType = formatTokenType(client, accessToken);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
@ -1136,10 +1137,11 @@ public class TokenManager {
|
|||
}
|
||||
|
||||
private void generateRefreshToken(boolean offlineTokenRequested) {
|
||||
refreshToken = new RefreshToken(accessToken);
|
||||
AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession();
|
||||
final AccessToken.Confirmation confirmation = getConfirmation(clientSession, accessToken);
|
||||
refreshToken = new RefreshToken(accessToken, confirmation);
|
||||
refreshToken.id(KeycloakModelUtils.generateId());
|
||||
refreshToken.issuedNow();
|
||||
AuthenticatedClientSessionModel clientSession = clientSessionCtx.getClientSession();
|
||||
clientSession.setTimestamp(refreshToken.getIat().intValue());
|
||||
UserSessionModel userSession = clientSession.getUserSession();
|
||||
userSession.setLastSessionRefresh(refreshToken.getIat().intValue());
|
||||
|
@ -1160,6 +1162,19 @@ public class TokenManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC9449 chapter 5<br/>
|
||||
* Refresh tokens issued to confidential clients are not bound to the DPoP proof public key because
|
||||
* they are already sender-constrained with a different existing mechanism.<br/>
|
||||
* <br/>
|
||||
* Based on the definition above the confirmation is only returned for public-clients.
|
||||
*/
|
||||
private AccessToken.Confirmation getConfirmation(AuthenticatedClientSessionModel clientSession,
|
||||
AccessToken accessToken) {
|
||||
final boolean isPublicClient = clientSession.getClient().isPublicClient();
|
||||
return isPublicClient ? accessToken.getConfirmation() : null;
|
||||
}
|
||||
|
||||
private Long getExpiration(boolean offline) {
|
||||
long expiration = SessionExpirationUtils.calculateClientSessionIdleTimestamp(
|
||||
offline, userSession.isRememberMe(),
|
||||
|
@ -1314,11 +1329,13 @@ public class TokenManager {
|
|||
|
||||
}
|
||||
|
||||
private String formatTokenType(ClientModel client) {
|
||||
private String formatTokenType(ClientModel client, AccessToken accessToken) {
|
||||
final String tokenType = Optional.ofNullable(accessToken).map(AccessToken::getType)
|
||||
.orElse(TokenUtil.TOKEN_TYPE_BEARER);
|
||||
if (OIDCAdvancedConfigWrapper.fromClientModel(client).isUseLowerCaseInTokenResponse()) {
|
||||
return TokenUtil.TOKEN_TYPE_BEARER.toLowerCase();
|
||||
return tokenType.toLowerCase();
|
||||
}
|
||||
return TokenUtil.TOKEN_TYPE_BEARER;
|
||||
return tokenType;
|
||||
}
|
||||
|
||||
public static class NotBeforeCheck implements TokenVerifier.Predicate<JsonWebToken> {
|
||||
|
|
|
@ -48,7 +48,6 @@ import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
|
|||
import org.keycloak.protocol.saml.JaxrsSAML2BindingBuilder;
|
||||
import org.keycloak.protocol.saml.SamlClient;
|
||||
import org.keycloak.protocol.saml.SamlProtocol;
|
||||
import org.keycloak.representations.dpop.DPoP;
|
||||
import org.keycloak.saml.common.constants.JBossSAMLConstants;
|
||||
import org.keycloak.saml.common.constants.JBossSAMLURIConstants;
|
||||
import org.keycloak.saml.common.exceptions.ConfigurationException;
|
||||
|
@ -56,6 +55,7 @@ import org.keycloak.saml.common.exceptions.ProcessingException;
|
|||
import org.keycloak.saml.common.util.DocumentUtil;
|
||||
import org.keycloak.services.CorsErrorResponseException;
|
||||
import org.keycloak.services.cors.Cors;
|
||||
import org.keycloak.services.util.DPoPUtil;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
|
||||
|
@ -73,7 +73,6 @@ public class TokenEndpoint {
|
|||
private ClientModel client;
|
||||
private Map<String, String> clientAuthAttributes;
|
||||
private OIDCAdvancedConfigWrapper clientConfig;
|
||||
private DPoP dPoP;
|
||||
|
||||
private final KeycloakSession session;
|
||||
|
||||
|
@ -136,7 +135,19 @@ public class TokenEndpoint {
|
|||
checkParameterDuplicated();
|
||||
}
|
||||
|
||||
OAuth2GrantType.Context context = new OAuth2GrantType.Context(session, clientConfig, clientAuthAttributes, formParams, event, cors, tokenManager, dPoP);
|
||||
/*
|
||||
* To request an access token that is bound to a public key using DPoP, the client MUST provide a valid DPoP
|
||||
* proof JWT in a DPoP header when making an access token request to the authorization server's token endpoint.
|
||||
* This is applicable for all access token requests regardless of grant type (e.g., the common
|
||||
* authorization_code and refresh_token grant types and extension grants such as the JWT
|
||||
* authorization grant [RFC7523])
|
||||
*/
|
||||
DPoPUtil.retrieveDPoPHeaderIfPresent(session, clientConfig, event, cors).ifPresent(dPoP -> {
|
||||
session.setAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE, dPoP);
|
||||
});
|
||||
|
||||
OAuth2GrantType.Context context = new OAuth2GrantType.Context(session, clientConfig, clientAuthAttributes,
|
||||
formParams, event, cors, tokenManager);
|
||||
return grant.process(context);
|
||||
}
|
||||
|
||||
|
|
|
@ -26,7 +26,6 @@ import org.jboss.logging.Logger;
|
|||
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.OAuthErrorException;
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.common.util.KeycloakUriBuilder;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.events.Errors;
|
||||
|
@ -34,7 +33,6 @@ import org.keycloak.events.EventType;
|
|||
import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||
import org.keycloak.models.ClientScopeModel;
|
||||
import org.keycloak.models.ClientSessionContext;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
|
@ -63,8 +61,6 @@ public class AuthorizationCodeGrantType extends OAuth2GrantTypeBase {
|
|||
public Response process(Context context) {
|
||||
setContext(context);
|
||||
|
||||
checkAndRetrieveDPoPProof(Profile.isFeatureEnabled(Profile.Feature.DPOP));
|
||||
|
||||
String code = formParams.getFirst(OAuth2Constants.CODE);
|
||||
if (code == null) {
|
||||
String errorMessage = "Missing parameter: " + OAuth2Constants.CODE;
|
||||
|
|
|
@ -24,12 +24,7 @@ import jakarta.ws.rs.core.Response;
|
|||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collector;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
|
@ -37,7 +32,6 @@ import org.keycloak.OAuth2Constants;
|
|||
import org.keycloak.OAuthErrorException;
|
||||
import org.keycloak.common.ClientConnection;
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.constants.AdapterConstants;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.events.Errors;
|
||||
|
@ -46,7 +40,6 @@ import org.keycloak.http.HttpRequest;
|
|||
import org.keycloak.http.HttpResponse;
|
||||
import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.ClientScopeModel;
|
||||
import org.keycloak.models.ClientSessionContext;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
|
@ -58,14 +51,12 @@ import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
|
|||
import org.keycloak.rar.AuthorizationRequestContext;
|
||||
import org.keycloak.representations.AccessToken;
|
||||
import org.keycloak.representations.AccessTokenResponse;
|
||||
import org.keycloak.representations.dpop.DPoP;
|
||||
import org.keycloak.services.CorsErrorResponseException;
|
||||
import org.keycloak.services.ServicesLogger;
|
||||
import org.keycloak.services.clientpolicy.ClientPolicyContext;
|
||||
import org.keycloak.services.clientpolicy.ClientPolicyException;
|
||||
import org.keycloak.services.cors.Cors;
|
||||
import org.keycloak.services.util.AuthorizationContextUtil;
|
||||
import org.keycloak.services.util.DPoPUtil;
|
||||
import org.keycloak.services.util.MtlsHoKTokenUtil;
|
||||
import org.keycloak.util.TokenUtil;
|
||||
|
||||
|
@ -90,7 +81,6 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
|
|||
protected EventBuilder event;
|
||||
protected Cors cors;
|
||||
protected TokenManager tokenManager;
|
||||
protected DPoP dPoP;
|
||||
protected HttpRequest request;
|
||||
protected HttpResponse response;
|
||||
protected HttpHeaders headers;
|
||||
|
@ -110,7 +100,6 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
|
|||
this.event = context.event;
|
||||
this.cors = context.cors;
|
||||
this.tokenManager = (TokenManager) context.tokenManager;
|
||||
this.dPoP = context.dPoP;
|
||||
}
|
||||
|
||||
protected Response createTokenResponse(UserModel user, UserSessionModel userSession, ClientSessionContext clientSessionCtx,
|
||||
|
@ -125,7 +114,6 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
|
|||
}
|
||||
|
||||
checkAndBindMtlsHoKToken(responseBuilder, useRefreshToken);
|
||||
checkAndBindDPoPToken(responseBuilder, useRefreshToken && client.isPublicClient(), Profile.isFeatureEnabled(Profile.Feature.DPOP));
|
||||
|
||||
if (TokenUtil.isOIDCRequest(scopeParam)) {
|
||||
responseBuilder.generateIDToken().generateAccessTokenHash();
|
||||
|
@ -182,21 +170,6 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
|
|||
}
|
||||
}
|
||||
|
||||
protected void checkAndBindDPoPToken(TokenManager.AccessTokenResponseBuilder responseBuilder, boolean useRefreshToken, boolean isDPoPSupported) {
|
||||
if (!isDPoPSupported) return;
|
||||
|
||||
if (clientConfig.isUseDPoP() || dPoP != null) {
|
||||
DPoPUtil.bindToken(responseBuilder.getAccessToken(), dPoP);
|
||||
responseBuilder.getAccessToken().type(DPoPUtil.DPOP_TOKEN_TYPE);
|
||||
responseBuilder.responseTokenType(DPoPUtil.DPOP_TOKEN_TYPE);
|
||||
|
||||
// Bind refresh tokens for public clients, See "Section 5. DPoP Access Token Request" from DPoP specification
|
||||
if (useRefreshToken) {
|
||||
DPoPUtil.bindToken(responseBuilder.getRefreshToken(), dPoP);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void updateClientSession(AuthenticatedClientSessionModel clientSession) {
|
||||
|
||||
if(clientSession == null) {
|
||||
|
@ -227,21 +200,6 @@ public abstract class OAuth2GrantTypeBase implements OAuth2GrantType {
|
|||
}
|
||||
}
|
||||
|
||||
protected void checkAndRetrieveDPoPProof(boolean isDPoPSupported) {
|
||||
if (!isDPoPSupported) return;
|
||||
|
||||
if (clientConfig.isUseDPoP() || request.getHttpHeaders().getHeaderString(DPoPUtil.DPOP_HTTP_HEADER) != null) {
|
||||
try {
|
||||
dPoP = new DPoPUtil.Validator(session).request(request).uriInfo(session.getContext().getUri()).validate();
|
||||
session.setAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE, dPoP);
|
||||
} catch (VerificationException ex) {
|
||||
event.detail(Details.REASON, ex.getMessage());
|
||||
event.error(Errors.INVALID_DPOP_PROOF);
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_DPOP_PROOF, ex.getMessage(), Response.Status.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected String getRequestedScopes() {
|
||||
String scope = formParams.getFirst(OAuth2Constants.SCOPE);
|
||||
|
||||
|
|
|
@ -52,8 +52,6 @@ public class RefreshTokenGrantType extends OAuth2GrantTypeBase {
|
|||
public Response process(Context context) {
|
||||
setContext(context);
|
||||
|
||||
checkAndRetrieveDPoPProof(Profile.isFeatureEnabled(Profile.Feature.DPOP));
|
||||
|
||||
String refreshToken = formParams.getFirst(OAuth2Constants.REFRESH_TOKEN);
|
||||
if (refreshToken == null) {
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "No refresh token", Response.Status.BAD_REQUEST);
|
||||
|
@ -76,7 +74,6 @@ public class RefreshTokenGrantType extends OAuth2GrantTypeBase {
|
|||
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.refreshAccessToken(session, session.getContext().getUri(), clientConnection, realm, client, refreshToken, event, headers, request, scopeParameter);
|
||||
|
||||
checkAndBindMtlsHoKToken(responseBuilder, clientConfig.isUseRefreshToken());
|
||||
checkAndBindDPoPToken(responseBuilder, clientConfig.isUseRefreshToken() && (client.isPublicClient() || client.isBearerOnly()), Profile.isFeatureEnabled(Profile.Feature.DPOP));
|
||||
|
||||
session.clientPolicy().triggerOnEvent(new TokenRefreshResponseContext(formParams, responseBuilder));
|
||||
|
||||
|
|
|
@ -26,6 +26,8 @@ import org.keycloak.models.RealmModel;
|
|||
|
||||
import jakarta.ws.rs.core.HttpHeaders;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import org.keycloak.util.TokenUtil;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
|
@ -65,7 +67,7 @@ public class AppAuthManager extends AuthenticationManager {
|
|||
}
|
||||
|
||||
String bearerPart = split[0];
|
||||
if (!bearerPart.equalsIgnoreCase(BEARER)){
|
||||
if (!bearerPart.equalsIgnoreCase(BEARER) && !bearerPart.equalsIgnoreCase(TokenUtil.TOKEN_TYPE_DPOP)){
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
@ -19,33 +19,61 @@ package org.keycloak.services.util;
|
|||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.UriBuilder;
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
|
||||
import org.apache.commons.codec.binary.Hex;
|
||||
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.OAuthErrorException;
|
||||
import org.keycloak.TokenVerifier;
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.common.VerificationException;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.crypto.Algorithm;
|
||||
import org.keycloak.crypto.KeyWrapper;
|
||||
import org.keycloak.crypto.SignatureProvider;
|
||||
import org.keycloak.crypto.SignatureVerifierContext;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.events.Errors;
|
||||
import org.keycloak.events.EventBuilder;
|
||||
import org.keycloak.exceptions.TokenVerificationException;
|
||||
import org.keycloak.http.HttpRequest;
|
||||
import org.keycloak.jose.jwk.JWK;
|
||||
import org.keycloak.jose.jws.JWSHeader;
|
||||
import org.keycloak.jose.jws.crypto.HashUtils;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.ClientSessionContext;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.ProtocolMapperModel;
|
||||
import org.keycloak.models.SingleUseObjectProvider;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.protocol.ProtocolMapper;
|
||||
import org.keycloak.protocol.ProtocolMapperUtils;
|
||||
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
|
||||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.protocol.oidc.mappers.AbstractOIDCProtocolMapper;
|
||||
import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper;
|
||||
import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenResponseMapper;
|
||||
import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
|
||||
import org.keycloak.protocol.oidc.mappers.OIDCIDTokenMapper;
|
||||
import org.keycloak.protocol.oidc.mappers.TokenIntrospectionTokenMapper;
|
||||
import org.keycloak.protocol.oidc.mappers.UserInfoTokenMapper;
|
||||
import org.keycloak.provider.ProviderConfigProperty;
|
||||
import org.keycloak.representations.AccessToken;
|
||||
import org.keycloak.representations.dpop.DPoP;
|
||||
import org.keycloak.services.CorsErrorResponseException;
|
||||
import org.keycloak.services.cors.Cors;
|
||||
import org.keycloak.util.JWKSUtils;
|
||||
|
||||
import static org.keycloak.utils.StringUtil.isNotBlank;
|
||||
|
@ -60,8 +88,6 @@ public class DPoPUtil {
|
|||
public static final String DPOP_TOKEN_TYPE = "DPoP";
|
||||
public static final String DPOP_SCHEME = "DPoP";
|
||||
public final static String DPOP_SESSION_ATTRIBUTE = "dpop";
|
||||
public final static String DPOP_PARAM = "dpop";
|
||||
public final static String DPOP_THUMBPRINT_NOTE = "dpop.thumbprint";
|
||||
|
||||
public static enum Mode {
|
||||
ENABLED,
|
||||
|
@ -89,6 +115,62 @@ public class DPoPUtil {
|
|||
return UriBuilder.fromUri(uri).replaceQuery("").build();
|
||||
}
|
||||
|
||||
/**
|
||||
* creates a protocol mapper that cannot be modified by administration users and that is used to bind AccessTokens
|
||||
* to specific DPoP keys. <br />
|
||||
* <br />
|
||||
* NOTE: The binding was solved with a protocol mapper to have generic solution for DPoP on all implemented
|
||||
* grantTypes, even custom-implemented grantTypes.
|
||||
*/
|
||||
public static Stream<Map.Entry<ProtocolMapperModel, ProtocolMapper>> getTransientProtocolMapper() {
|
||||
final String PROVIDER_ID = DPOP_SCHEME.toLowerCase(Locale.ROOT) + "-protocol-mapper";
|
||||
|
||||
ProtocolMapperModel protocolMapperModel = new ProtocolMapperModel();
|
||||
protocolMapperModel.setId(DPOP_SCHEME);
|
||||
protocolMapperModel.setName(DPOP_SCHEME);
|
||||
protocolMapperModel.setProtocolMapper(PROVIDER_ID);
|
||||
protocolMapperModel.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
|
||||
Map<String, String> config = new HashMap<>();
|
||||
config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ACCESS_TOKEN, "true");
|
||||
config.put(OIDCAttributeMapperHelper.INCLUDE_IN_ID_TOKEN, "false");
|
||||
config.put(OIDCAttributeMapperHelper.INCLUDE_IN_USERINFO, "false");
|
||||
config.put(OIDCAttributeMapperHelper.INCLUDE_IN_INTROSPECTION, "false");
|
||||
protocolMapperModel.setConfig(config);
|
||||
|
||||
ProtocolMapper dpopProtocolMapper = new DpopProtocolMapper(PROVIDER_ID);
|
||||
return Stream.of(Map.entry(protocolMapperModel, dpopProtocolMapper));
|
||||
}
|
||||
|
||||
/**
|
||||
* checks the current request if a DPoP HTTP Header is present and returns it if it is present.
|
||||
*/
|
||||
public static Optional<DPoP> retrieveDPoPHeaderIfPresent(KeycloakSession keycloakSession,
|
||||
OIDCAdvancedConfigWrapper clientConfig,
|
||||
EventBuilder event,
|
||||
Cors cors) {
|
||||
boolean isDPoPSupported = Profile.isFeatureEnabled(Profile.Feature.DPOP);
|
||||
if (!isDPoPSupported) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
HttpRequest request = keycloakSession.getContext().getHttpRequest();
|
||||
final boolean isClientRequiresDpop = clientConfig.isUseDPoP();
|
||||
final boolean isDpopHeaderPresent = request.getHttpHeaders().getHeaderString(DPoPUtil.DPOP_HTTP_HEADER) != null;
|
||||
if (!isClientRequiresDpop && !isDpopHeaderPresent) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
try {
|
||||
DPoP dPoP = new DPoPUtil.Validator(keycloakSession).request(request).uriInfo(keycloakSession.getContext().getUri()).validate();
|
||||
keycloakSession.setAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE, dPoP);
|
||||
return Optional.of(dPoP);
|
||||
} catch (VerificationException ex) {
|
||||
event.detail(Details.REASON, ex.getMessage());
|
||||
event.error(Errors.INVALID_DPOP_PROOF);
|
||||
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_DPOP_PROOF, ex.getMessage(), Response.Status.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
private static DPoP validateDPoP(KeycloakSession session, URI uri, String method, String token, String accessToken, int lifetime, int clockSkew) throws VerificationException {
|
||||
|
||||
if (token == null || token.trim().equals("")) {
|
||||
|
@ -150,7 +232,7 @@ public class DPoPUtil {
|
|||
} catch (DPoPVerificationException ex) {
|
||||
throw ex;
|
||||
} catch (VerificationException ex) {
|
||||
throw new VerificationException("DPoP verification failure", ex);
|
||||
throw new VerificationException("DPoP verification failure: " + ex.getMessage(), ex);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -387,4 +469,67 @@ public class DPoPUtil {
|
|||
|
||||
}
|
||||
|
||||
/**
|
||||
* a custom protocol mapper that is not meant for configuration in the Admin-UI. This mapper is created on the
|
||||
* fly for TokenRequests to bind the created generated AccessTokens to the key of the DPoP HTTP Header.
|
||||
*/
|
||||
private static final class DpopProtocolMapper extends AbstractOIDCProtocolMapper
|
||||
implements OIDCAccessTokenMapper, OIDCIDTokenMapper, UserInfoTokenMapper, TokenIntrospectionTokenMapper,
|
||||
OIDCAccessTokenResponseMapper {
|
||||
|
||||
private final String providerId;
|
||||
|
||||
public DpopProtocolMapper(String providerId) {
|
||||
this.providerId = providerId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayCategory() {
|
||||
return TOKEN_MAPPER_CATEGORY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayType() {
|
||||
return DPOP_SCHEME;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getHelpText() {
|
||||
return "not needed";
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProviderConfigProperty> getConfigProperties() {
|
||||
return List.of(new ProviderConfigProperty("multivalued", ProtocolMapperUtils.MULTIVALUED, "",
|
||||
ProviderConfigProperty.BOOLEAN_TYPE, false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return providerId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccessToken transformAccessToken(AccessToken token, ProtocolMapperModel mappingModel, KeycloakSession session, UserSessionModel userSession, ClientSessionContext clientSessionCtx) {
|
||||
boolean isDPoPSupported = Profile.isFeatureEnabled(Profile.Feature.DPOP);
|
||||
if (!isDPoPSupported) {
|
||||
return super.transformAccessToken(token, mappingModel, session, userSession, clientSessionCtx);
|
||||
}
|
||||
|
||||
DPoP dPoP = session.getAttribute(DPOP_SESSION_ATTRIBUTE, DPoP.class);
|
||||
if (dPoP == null) {
|
||||
return super.transformAccessToken(token, mappingModel, session, userSession, clientSessionCtx);
|
||||
}
|
||||
AccessToken.Confirmation confirmation = (AccessToken.Confirmation) token.getOtherClaims()
|
||||
.get(OAuth2Constants.CNF);
|
||||
if (confirmation == null) {
|
||||
confirmation = new AccessToken.Confirmation();
|
||||
token.setConfirmation(confirmation);
|
||||
}
|
||||
confirmation.setKeyThumbprint(dPoP.getThumbprint());
|
||||
// make sure that the token-type is set to DPoP. This will be resolved if the AccessTokenResponse is built.
|
||||
token.type(DPOP_TOKEN_TYPE);
|
||||
return super.transformAccessToken(token, mappingModel, session, userSession, clientSessionCtx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -83,6 +83,8 @@ import org.keycloak.utils.MediaType;
|
|||
import org.openqa.selenium.By;
|
||||
import org.openqa.selenium.WebDriver;
|
||||
import org.openqa.selenium.WebElement;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
|
@ -116,6 +118,9 @@ import static org.keycloak.testsuite.util.WaitUtils.waitUntilElement;
|
|||
* @author Stan Silvert ssilvert@redhat.com (C) 2016 Red Hat Inc.
|
||||
*/
|
||||
public class OAuthClient {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(OAuthClient.class);
|
||||
|
||||
public static String SERVER_ROOT;
|
||||
public static String AUTH_SERVER_ROOT;
|
||||
public static String APP_ROOT;
|
||||
|
@ -384,7 +389,7 @@ public class OAuthClient {
|
|||
}
|
||||
driver.findElement(By.name("login")).click();
|
||||
} catch (Throwable t) {
|
||||
System.err.println(src);
|
||||
logger.error("Unexpected page was loaded\n{}", src);
|
||||
throw t;
|
||||
}
|
||||
}
|
||||
|
@ -499,7 +504,7 @@ public class OAuthClient {
|
|||
}
|
||||
|
||||
if (dpopProof != null) {
|
||||
post.addHeader("DPoP", dpopProof);
|
||||
post.addHeader(TokenUtil.TOKEN_TYPE_DPOP, dpopProof);
|
||||
}
|
||||
|
||||
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters, StandardCharsets.UTF_8);
|
||||
|
@ -592,6 +597,9 @@ public class OAuthClient {
|
|||
post.addHeader(header.getKey(), header.getValue());
|
||||
}
|
||||
}
|
||||
if (dpopProof != null) {
|
||||
post.addHeader(TokenUtil.TOKEN_TYPE_DPOP, dpopProof);
|
||||
}
|
||||
|
||||
List<NameValuePair> parameters = new LinkedList<>();
|
||||
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.PASSWORD));
|
||||
|
@ -748,6 +756,9 @@ public class OAuthClient {
|
|||
String authorization = BasicAuthHelper.RFC6749.createHeader(clientId, clientSecret);
|
||||
post.setHeader("Authorization", authorization);
|
||||
}
|
||||
if (dpopProof != null) {
|
||||
post.addHeader(TokenUtil.TOKEN_TYPE_DPOP, dpopProof);
|
||||
}
|
||||
|
||||
List<NameValuePair> parameters = new LinkedList<>();
|
||||
parameters.add(new BasicNameValuePair(OAuth2Constants.GRANT_TYPE, OAuth2Constants.CLIENT_CREDENTIALS));
|
||||
|
@ -979,7 +990,7 @@ public class OAuthClient {
|
|||
}
|
||||
|
||||
if (dpopProof != null) {
|
||||
post.addHeader("DPoP", dpopProof);
|
||||
post.addHeader(TokenUtil.TOKEN_TYPE_DPOP, dpopProof);
|
||||
}
|
||||
|
||||
UrlEncodedFormEntity formEntity;
|
||||
|
@ -1033,7 +1044,7 @@ public class OAuthClient {
|
|||
}
|
||||
|
||||
if (dpopProof != null) {
|
||||
post.addHeader("DPoP", dpopProof);
|
||||
post.addHeader(TokenUtil.TOKEN_TYPE_DPOP, dpopProof);
|
||||
}
|
||||
|
||||
UrlEncodedFormEntity formEntity;
|
||||
|
@ -1154,12 +1165,12 @@ public class OAuthClient {
|
|||
}
|
||||
}
|
||||
|
||||
public UserInfoResponse doUserInfoRequestByGet(String accessToken) throws Exception {
|
||||
public UserInfoResponse doUserInfoRequestByGet(OAuthClient.AccessTokenResponse accessTokenResponse) throws Exception {
|
||||
try (CloseableHttpClient client = HttpClientBuilder.create().build()) {
|
||||
HttpGet get = new HttpGet(getUserInfoUrl());
|
||||
get.setHeader("Authorization", "Bearer " + accessToken);
|
||||
get.setHeader("Authorization", accessTokenResponse.getTokenType() + " " + accessTokenResponse.getAccessToken());
|
||||
if (dpopProof != null) {
|
||||
get.addHeader("DPoP", dpopProof);
|
||||
get.addHeader(TokenUtil.TOKEN_TYPE_DPOP, dpopProof);
|
||||
}
|
||||
return new UserInfoResponse(client.execute(get));
|
||||
} catch (IOException ex) {
|
||||
|
|
|
@ -551,7 +551,7 @@ public final class KcOidcBrokerTransientSessionsTest extends AbstractAdvancedBro
|
|||
OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, CONSUMER_BROKER_APP_SECRET);
|
||||
|
||||
// Check that userInfo can be invoked
|
||||
var userInfoResponse = oauth.doUserInfoRequestByGet(tokenResponse.getAccessToken());
|
||||
var userInfoResponse = oauth.doUserInfoRequestByGet(tokenResponse);
|
||||
assertThat(userInfoResponse.getUserInfo().getSub(), is(lwUserId));
|
||||
assertThat(userInfoResponse.getUserInfo().getPreferredUsername(), is(bc.getUserLogin()));
|
||||
assertThat(userInfoResponse.getUserInfo().getEmail(), is(bc.getUserEmail()));
|
||||
|
|
|
@ -26,7 +26,6 @@ import org.junit.Before;
|
|||
import org.junit.Test;
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.OAuthErrorException;
|
||||
import org.keycloak.authentication.authenticators.client.X509ClientAuthenticator;
|
||||
import org.keycloak.client.registration.ClientRegistrationException;
|
||||
import org.keycloak.common.Profile;
|
||||
import org.keycloak.common.util.SecretGenerator;
|
||||
|
@ -41,13 +40,12 @@ import org.keycloak.representations.idm.ClientRepresentation;
|
|||
import org.keycloak.representations.oidc.OIDCClientRepresentation;
|
||||
import org.keycloak.representations.oidc.TokenMetadataRepresentation;
|
||||
import org.keycloak.services.clientpolicy.ClientPolicyException;
|
||||
import org.keycloak.testsuite.AssertEvents;
|
||||
import org.keycloak.services.clientpolicy.condition.AnyClientConditionFactory;
|
||||
import org.keycloak.testsuite.AssertEvents;
|
||||
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
||||
import org.keycloak.testsuite.client.resources.TestApplicationResourceUrls;
|
||||
import org.keycloak.testsuite.util.ClientPoliciesUtil;
|
||||
import org.keycloak.testsuite.util.Matchers;
|
||||
import org.keycloak.testsuite.util.MutualTLSUtils;
|
||||
import org.keycloak.testsuite.util.OAuthClient;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
|
@ -76,7 +74,7 @@ public class OAuth2_1PublicClientTest extends AbstractFAPITest {
|
|||
|
||||
private JWK jwkEc;
|
||||
|
||||
private String validRedirectUri;;
|
||||
private String validRedirectUri;
|
||||
|
||||
@Before
|
||||
public void setupValidateRedirectUri() {
|
||||
|
@ -243,7 +241,7 @@ public class OAuth2_1PublicClientTest extends AbstractFAPITest {
|
|||
// userinfo request with DPoP Proof - success
|
||||
dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET, oauth.getUserInfoUrl(), (long) Time.currentTime(), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate());
|
||||
oauth.dpopProof(dpopProofEcEncoded);
|
||||
OAuthClient.UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
|
||||
OAuthClient.UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response);
|
||||
assertEquals(TEST_USER_NAME, userInfoResponse.getUserInfo().getPreferredUsername());
|
||||
|
||||
oauth.idTokenHint(response.getIdToken()).openLogout();
|
||||
|
@ -271,7 +269,7 @@ public class OAuth2_1PublicClientTest extends AbstractFAPITest {
|
|||
updatePolicies(json);
|
||||
}
|
||||
|
||||
private void setupValidClientExceptForRedirectUri(ClientRepresentation clientRep, OIDCAdvancedConfigWrapper clientConfig ) {
|
||||
private void setupValidClientExceptForRedirectUri(ClientRepresentation clientRep, OIDCAdvancedConfigWrapper clientConfig) {
|
||||
clientRep.setPublicClient(Boolean.TRUE);
|
||||
clientRep.setRedirectUris(Collections.singletonList(validRedirectUri));
|
||||
clientRep.setImplicitFlowEnabled(false);
|
||||
|
@ -279,7 +277,7 @@ public class OAuth2_1PublicClientTest extends AbstractFAPITest {
|
|||
clientConfig.setRequestUris(Collections.singletonList(TestApplicationResourceUrls.clientRequestUri()));
|
||||
clientConfig.setPkceCodeChallengeMethod(OAuth2Constants.PKCE_METHOD_S256);
|
||||
clientConfig.setUseDPoP(true);
|
||||
};
|
||||
}
|
||||
|
||||
private void testProhibitedImplicitOrHybridFlow(boolean isOpenid, String responseType, String nonce) {
|
||||
oauth.openid(isOpenid);
|
||||
|
|
|
@ -17,32 +17,10 @@
|
|||
|
||||
package org.keycloak.testsuite.oauth;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.emptyOrNullString;
|
||||
import static org.hamcrest.Matchers.is;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createClientAccessTypeConditionConfig;
|
||||
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createDPoPBindEnforcerExecutorConfig;
|
||||
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createEcJwk;
|
||||
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createRsaJwk;
|
||||
import static org.keycloak.testsuite.util.ClientPoliciesUtil.generateEcdsaKey;
|
||||
import static org.keycloak.testsuite.util.ClientPoliciesUtil.generateSignedDPoPProof;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.KeyPair;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import jakarta.ws.rs.BadRequestException;
|
||||
import jakarta.ws.rs.HttpMethod;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.Response.Status;
|
||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
|
@ -83,22 +61,44 @@ import org.keycloak.testsuite.Assert;
|
|||
import org.keycloak.testsuite.AssertEvents;
|
||||
import org.keycloak.testsuite.admin.ApiUtil;
|
||||
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
||||
import org.keycloak.testsuite.util.Matchers;
|
||||
import org.keycloak.testsuite.util.OAuthClient;
|
||||
import org.keycloak.testsuite.util.OAuthClient.UserInfoResponse;
|
||||
import org.keycloak.testsuite.util.ServerURLs;
|
||||
import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientPoliciesBuilder;
|
||||
import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientPolicyBuilder;
|
||||
import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientProfileBuilder;
|
||||
import org.keycloak.testsuite.util.ClientPoliciesUtil.ClientProfilesBuilder;
|
||||
import org.keycloak.testsuite.util.Matchers;
|
||||
import org.keycloak.testsuite.util.OAuthClient;
|
||||
import org.keycloak.testsuite.util.OAuthClient.UserInfoResponse;
|
||||
import org.keycloak.testsuite.util.ServerURLs;
|
||||
import org.keycloak.util.JWKSUtils;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
import org.keycloak.util.TokenUtil;
|
||||
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.core.Response.Status;
|
||||
import jakarta.ws.rs.BadRequestException;
|
||||
import jakarta.ws.rs.HttpMethod;
|
||||
import java.io.IOException;
|
||||
import java.security.KeyPair;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
import static org.hamcrest.Matchers.emptyOrNullString;
|
||||
import static org.hamcrest.Matchers.is;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createClientAccessTypeConditionConfig;
|
||||
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createDPoPBindEnforcerExecutorConfig;
|
||||
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createEcJwk;
|
||||
import static org.keycloak.testsuite.util.ClientPoliciesUtil.createRsaJwk;
|
||||
import static org.keycloak.testsuite.util.ClientPoliciesUtil.generateEcdsaKey;
|
||||
import static org.keycloak.testsuite.util.ClientPoliciesUtil.generateSignedDPoPProof;
|
||||
|
||||
@EnableFeature(value = Profile.Feature.DPOP, skipRestart = true)
|
||||
public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
||||
|
@ -110,19 +110,16 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
private static final String TEST_USER_NAME = "test-user@localhost";
|
||||
private static final String TEST_USER_PASSWORD = "password";
|
||||
private static final String DPOP_JWT_HEADER_TYPE = "dpop+jwt";
|
||||
|
||||
@Rule
|
||||
public AssertEvents events = new AssertEvents(this);
|
||||
private KeyPair ecKeyPair;
|
||||
private KeyPair rsaKeyPair;
|
||||
private JWK jwkRsa;
|
||||
private JWK jwkEc;
|
||||
private JWSHeader jwsRsaHeader;
|
||||
private JWSHeader jwsEcHeader;
|
||||
|
||||
private ClientRegistration reg;
|
||||
|
||||
@Rule
|
||||
public AssertEvents events = new AssertEvents(this);
|
||||
|
||||
@Before
|
||||
public void beforeDPoPTest() throws Exception {
|
||||
ecKeyPair = generateEcdsaKey("secp256r1");
|
||||
|
@ -155,12 +152,13 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
|
||||
oauth.dpopProof(dpopProofEcEncoded);
|
||||
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, null);
|
||||
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
|
||||
|
||||
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
|
||||
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
|
||||
jwkEc.getOtherClaims().put(ECPublicJWK.CRV, ((ECPublicJWK)jwkEc).getCrv());
|
||||
jwkEc.getOtherClaims().put(ECPublicJWK.X, ((ECPublicJWK)jwkEc).getX());
|
||||
jwkEc.getOtherClaims().put(ECPublicJWK.Y, ((ECPublicJWK)jwkEc).getY());
|
||||
jwkEc.getOtherClaims().put(ECPublicJWK.CRV, ((ECPublicJWK) jwkEc).getCrv());
|
||||
jwkEc.getOtherClaims().put(ECPublicJWK.X, ((ECPublicJWK) jwkEc).getX());
|
||||
jwkEc.getOtherClaims().put(ECPublicJWK.Y, ((ECPublicJWK) jwkEc).getY());
|
||||
String jkt = JWKSUtils.computeThumbprint(jwkEc);
|
||||
assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint());
|
||||
RefreshToken refreshToken = oauth.parseRefreshToken(response.getRefreshToken());
|
||||
|
@ -171,6 +169,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
|
||||
oauth.dpopProof(dpopProofEcEncoded);
|
||||
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), null);
|
||||
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
|
||||
|
||||
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
|
||||
accessToken = oauth.verifyToken(response.getAccessToken());
|
||||
|
@ -181,9 +180,8 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
// userinfo access
|
||||
dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET.toString(), oauth.getUserInfoUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate());
|
||||
oauth.dpopProof(dpopProofEcEncoded);
|
||||
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
|
||||
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response);
|
||||
assertEquals(TEST_USER_NAME, userInfoResponse.getUserInfo().getPreferredUsername());
|
||||
|
||||
oauth.idTokenHint(response.getIdToken()).openLogout();
|
||||
}
|
||||
|
||||
|
@ -202,11 +200,12 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
|
||||
oauth.dpopProof(dpopProofRsaEncoded);
|
||||
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, TEST_CONFIDENTIAL_CLIENT_SECRET);
|
||||
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
|
||||
|
||||
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
|
||||
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
|
||||
jwkRsa.getOtherClaims().put(RSAPublicJWK.MODULUS, ((RSAPublicJWK)jwkRsa).getModulus());
|
||||
jwkRsa.getOtherClaims().put(RSAPublicJWK.PUBLIC_EXPONENT, ((RSAPublicJWK)jwkRsa).getPublicExponent());
|
||||
jwkRsa.getOtherClaims().put(RSAPublicJWK.MODULUS, ((RSAPublicJWK) jwkRsa).getModulus());
|
||||
jwkRsa.getOtherClaims().put(RSAPublicJWK.PUBLIC_EXPONENT, ((RSAPublicJWK) jwkRsa).getPublicExponent());
|
||||
String jkt = JWKSUtils.computeThumbprint(jwkRsa);
|
||||
assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint());
|
||||
RefreshToken refreshToken = oauth.parseRefreshToken(response.getRefreshToken());
|
||||
|
@ -235,10 +234,11 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
|
||||
oauth.dpopProof(dpopProofRsaEncoded);
|
||||
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
|
||||
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
|
||||
|
||||
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
|
||||
jwkRsa.getOtherClaims().put(RSAPublicJWK.MODULUS, ((RSAPublicJWK)jwkRsa).getModulus());
|
||||
jwkRsa.getOtherClaims().put(RSAPublicJWK.PUBLIC_EXPONENT, ((RSAPublicJWK)jwkRsa).getPublicExponent());
|
||||
jwkRsa.getOtherClaims().put(RSAPublicJWK.MODULUS, ((RSAPublicJWK) jwkRsa).getModulus());
|
||||
jwkRsa.getOtherClaims().put(RSAPublicJWK.PUBLIC_EXPONENT, ((RSAPublicJWK) jwkRsa).getPublicExponent());
|
||||
jkt = JWKSUtils.computeThumbprint(jwkRsa);
|
||||
accessToken = oauth.verifyToken(response.getAccessToken());
|
||||
assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint());
|
||||
|
@ -263,6 +263,8 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
|
||||
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
|
||||
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, null);
|
||||
// token-type must be "Bearer" because no DPoP is present within the token-request
|
||||
assertEquals(TokenUtil.TOKEN_TYPE_BEARER, response.getTokenType());
|
||||
|
||||
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
|
||||
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
|
||||
|
@ -272,6 +274,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
|
||||
// token refresh
|
||||
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), null);
|
||||
assertEquals(TokenUtil.TOKEN_TYPE_BEARER, response.getTokenType());
|
||||
|
||||
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
|
||||
accessToken = oauth.verifyToken(response.getAccessToken());
|
||||
|
@ -296,9 +299,11 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
|
||||
oauth.dpopProof(dpopProofEcEncoded);
|
||||
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, null);
|
||||
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
|
||||
|
||||
// token refresh
|
||||
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), null);
|
||||
assertNull(response.getTokenType());
|
||||
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
|
||||
assertEquals(OAuthErrorException.INVALID_DPOP_PROOF, response.getError());
|
||||
assertEquals("DPoP proof has already been used", response.getErrorDescription());
|
||||
|
@ -317,10 +322,12 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
|
||||
oauth.dpopProof(dpopProofRsaEncoded);
|
||||
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, TEST_USER_PASSWORD);
|
||||
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
|
||||
|
||||
// token refresh
|
||||
oauth.dpopProof(null);
|
||||
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
|
||||
assertNull(response.getTokenType());
|
||||
assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
|
||||
assertEquals(OAuthErrorException.INVALID_DPOP_PROOF, response.getError());
|
||||
assertEquals("DPoP proof is missing", response.getErrorDescription());
|
||||
|
@ -340,7 +347,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
|
||||
@Test
|
||||
public void testDPoPProofWithoutJwk() throws Exception {
|
||||
JWSHeader jwsHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.ES256, DPOP_JWT_HEADER_TYPE, jwkEc.getKeyId(), (JWK)null);
|
||||
JWSHeader jwsHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.ES256, DPOP_JWT_HEADER_TYPE, jwkEc.getKeyId(), (JWK) null);
|
||||
testDPoPProofFailure(generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsHeader, ecKeyPair.getPrivate()), "No JWK in DPoP header");
|
||||
}
|
||||
|
||||
|
@ -358,7 +365,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
|
||||
@Test
|
||||
public void testDPoPProofInvalidSignature() throws Exception {
|
||||
testDPoPProofFailure(generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsEcHeader, rsaKeyPair.getPrivate()), "DPoP verification failure");
|
||||
testDPoPProofFailure(generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsEcHeader, rsaKeyPair.getPrivate()), "DPoP verification failure: org.keycloak.exceptions.TokenSignatureInvalidException: Invalid token signature");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -376,6 +383,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
|
||||
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
|
||||
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, TEST_USER_PASSWORD);
|
||||
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
|
||||
oauth.doLogout(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
|
||||
|
||||
testDPoPProofFailure(dpopProofEcEncoded, "DPoP proof has already been used");
|
||||
|
@ -417,7 +425,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
public void testDPoPProofOnUserInfoByConfidentialClient() throws Exception {
|
||||
KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
|
||||
OAuthClient.AccessTokenResponse response = getDPoPBindAccessToken(rsaKeyPair);
|
||||
doSuccessfulUserInfoGet(response.getAccessToken(), rsaKeyPair);
|
||||
doSuccessfulUserInfoGet(response, rsaKeyPair);
|
||||
|
||||
oauth.doLogout(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
|
||||
}
|
||||
|
@ -429,11 +437,11 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
try {
|
||||
KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
|
||||
OAuthClient.AccessTokenResponse response = getDPoPBindAccessToken(rsaKeyPair);
|
||||
doSuccessfulUserInfoGet(response.getAccessToken(), rsaKeyPair);
|
||||
doSuccessfulUserInfoGet(response, rsaKeyPair);
|
||||
|
||||
// delete DPoP proof
|
||||
oauth.dpopProof(null);
|
||||
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
|
||||
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response);
|
||||
assertEquals(401, userInfoResponse.getStatusCode());
|
||||
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"DPoP proof and token binding verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
|
||||
|
||||
|
@ -449,7 +457,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
OAuthClient.AccessTokenResponse response = getDPoPBindAccessToken(rsaKeyPair);
|
||||
|
||||
oauth.dpopProof(null);
|
||||
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
|
||||
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response);
|
||||
assertEquals(401, userInfoResponse.getStatusCode());
|
||||
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"DPoP proof and token binding verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
|
||||
|
||||
|
@ -466,7 +474,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
// invalid "htu" claim
|
||||
String dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate());
|
||||
oauth.dpopProof(dpopProofRsaEncoded);
|
||||
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
|
||||
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response);
|
||||
assertEquals(401, userInfoResponse.getStatusCode());
|
||||
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"DPoP proof and token binding verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
|
||||
|
||||
|
@ -477,10 +485,10 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
public void testMultipleUseDPoPProofOnUserInfo() throws Exception {
|
||||
KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
|
||||
OAuthClient.AccessTokenResponse response = getDPoPBindAccessToken(rsaKeyPair);
|
||||
doSuccessfulUserInfoGet(response.getAccessToken(), rsaKeyPair);
|
||||
doSuccessfulUserInfoGet(response, rsaKeyPair);
|
||||
|
||||
// use the same DPoP proof
|
||||
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
|
||||
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response);
|
||||
assertEquals(401, userInfoResponse.getStatusCode());
|
||||
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"DPoP proof and token binding verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
|
||||
|
||||
|
@ -498,7 +506,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
JWSHeader jwsRsaHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.PS256, DPOP_JWT_HEADER_TYPE, jwkRsa.getKeyId(), jwkRsa);
|
||||
String dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET.toString(), oauth.getUserInfoUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate());
|
||||
oauth.dpopProof(dpopProofRsaEncoded);
|
||||
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response.getAccessToken());
|
||||
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response);
|
||||
assertEquals(401, userInfoResponse.getStatusCode());
|
||||
assertEquals("Bearer realm=\"test\", error=\"invalid_token\", error_description=\"DPoP proof and token binding verification failed\"", userInfoResponse.getHeaders().get("WWW-Authenticate"));
|
||||
|
||||
|
@ -603,9 +611,9 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
String encodedRefreshToken = response.getRefreshToken();
|
||||
String encodedIdToken = response.getIdToken();
|
||||
AccessToken accessToken = oauth.verifyToken(encodedAccessToken);
|
||||
jwkEc.getOtherClaims().put(ECPublicJWK.CRV, ((ECPublicJWK)jwkEc).getCrv());
|
||||
jwkEc.getOtherClaims().put(ECPublicJWK.X, ((ECPublicJWK)jwkEc).getX());
|
||||
jwkEc.getOtherClaims().put(ECPublicJWK.Y, ((ECPublicJWK)jwkEc).getY());
|
||||
jwkEc.getOtherClaims().put(ECPublicJWK.CRV, ((ECPublicJWK) jwkEc).getCrv());
|
||||
jwkEc.getOtherClaims().put(ECPublicJWK.X, ((ECPublicJWK) jwkEc).getX());
|
||||
jwkEc.getOtherClaims().put(ECPublicJWK.Y, ((ECPublicJWK) jwkEc).getY());
|
||||
String jkt = JWKSUtils.computeThumbprint(jwkEc);
|
||||
assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint());
|
||||
RefreshToken refreshToken = oauth.parseRefreshToken(encodedRefreshToken);
|
||||
|
@ -613,14 +621,14 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
|
||||
// userinfo request without a DPoP proof - fail
|
||||
oauth.dpopProof(null);
|
||||
OAuthClient.UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(encodedAccessToken);
|
||||
OAuthClient.UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(response);
|
||||
assertEquals(401, userInfoResponse.getStatusCode());
|
||||
|
||||
// userinfo request with a valid DPoP proof - success
|
||||
jwsEcHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.ES256, DPOP_JWT_HEADER_TYPE, jwkEc.getKeyId(), jwkEc);
|
||||
dpopProofEcEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET.toString(), oauth.getUserInfoUrl(), Long.valueOf(Time.currentTime()), Algorithm.ES256, jwsEcHeader, ecKeyPair.getPrivate());
|
||||
oauth.dpopProof(dpopProofEcEncoded);
|
||||
userInfoResponse = oauth.doUserInfoRequestByGet(encodedAccessToken);
|
||||
userInfoResponse = oauth.doUserInfoRequestByGet(response);
|
||||
assertEquals(200, userInfoResponse.getStatusCode());
|
||||
assertEquals(TEST_USER_NAME, userInfoResponse.getUserInfo().getPreferredUsername());
|
||||
|
||||
|
@ -639,9 +647,9 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
encodedAccessToken = response.getAccessToken();
|
||||
encodedRefreshToken = response.getRefreshToken();
|
||||
accessToken = oauth.verifyToken(encodedAccessToken);
|
||||
jwkEc.getOtherClaims().put(ECPublicJWK.CRV, ((ECPublicJWK)jwkEc).getCrv());
|
||||
jwkEc.getOtherClaims().put(ECPublicJWK.X, ((ECPublicJWK)jwkEc).getX());
|
||||
jwkEc.getOtherClaims().put(ECPublicJWK.Y, ((ECPublicJWK)jwkEc).getY());
|
||||
jwkEc.getOtherClaims().put(ECPublicJWK.CRV, ((ECPublicJWK) jwkEc).getCrv());
|
||||
jwkEc.getOtherClaims().put(ECPublicJWK.X, ((ECPublicJWK) jwkEc).getX());
|
||||
jwkEc.getOtherClaims().put(ECPublicJWK.Y, ((ECPublicJWK) jwkEc).getY());
|
||||
jkt = JWKSUtils.computeThumbprint(jwkEc);
|
||||
assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint());
|
||||
refreshToken = oauth.parseRefreshToken(encodedRefreshToken);
|
||||
|
@ -669,6 +677,66 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
oauth.idTokenHint(encodedIdToken).openLogout();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDPoPProofWithClientCredentialsGrant() throws Exception {
|
||||
modifyClient(TEST_CONFIDENTIAL_CLIENT_ID, (clientRepresentation, configWrapper) -> {
|
||||
clientRepresentation.setServiceAccountsEnabled(true);
|
||||
configWrapper.setUseDPoP(true);
|
||||
});
|
||||
oauth.clientId(TEST_CONFIDENTIAL_CLIENT_ID);
|
||||
oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
|
||||
|
||||
KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
|
||||
|
||||
JWK jwkRsa = createRsaJwk(rsaKeyPair.getPublic());
|
||||
JWSHeader jwsRsaHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.PS256, DPOP_JWT_HEADER_TYPE, jwkRsa.getKeyId(), jwkRsa);
|
||||
String dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate());
|
||||
|
||||
oauth.dpopProof(dpopProofRsaEncoded);
|
||||
OAuthClient.AccessTokenResponse response = oauth.doClientCredentialsGrantAccessTokenRequest(TEST_CONFIDENTIAL_CLIENT_SECRET);
|
||||
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
|
||||
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
|
||||
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
|
||||
|
||||
jwkRsa.getOtherClaims().put(RSAPublicJWK.MODULUS, ((RSAPublicJWK) jwkRsa).getModulus());
|
||||
jwkRsa.getOtherClaims().put(RSAPublicJWK.PUBLIC_EXPONENT, ((RSAPublicJWK) jwkRsa).getPublicExponent());
|
||||
String jkt = JWKSUtils.computeThumbprint(jwkRsa);
|
||||
assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint());
|
||||
|
||||
oauth.doLogout(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDPoPProofWithResourceOwnerPasswordCredentialsGrant() throws Exception {
|
||||
modifyClient(TEST_CONFIDENTIAL_CLIENT_ID, (clientRepresentation, configWrapper) -> {
|
||||
clientRepresentation.setDirectAccessGrantsEnabled(true);
|
||||
configWrapper.setUseDPoP(true);
|
||||
});
|
||||
oauth.clientId(TEST_CONFIDENTIAL_CLIENT_ID);
|
||||
oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
|
||||
|
||||
KeyPair rsaKeyPair = KeyUtils.generateRsaKeyPair(2048);
|
||||
|
||||
JWK jwkRsa = createRsaJwk(rsaKeyPair.getPublic());
|
||||
JWSHeader jwsRsaHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.PS256, DPOP_JWT_HEADER_TYPE, jwkRsa.getKeyId(), jwkRsa);
|
||||
String dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.POST.toString(), oauth.getAccessTokenUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate());
|
||||
|
||||
oauth.dpopProof(dpopProofRsaEncoded);
|
||||
OAuthClient.AccessTokenResponse response = oauth.doGrantAccessTokenRequest(TEST_CONFIDENTIAL_CLIENT_SECRET,
|
||||
TEST_USER_NAME,
|
||||
TEST_USER_PASSWORD);
|
||||
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
|
||||
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
|
||||
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
|
||||
|
||||
jwkRsa.getOtherClaims().put(RSAPublicJWK.MODULUS, ((RSAPublicJWK) jwkRsa).getModulus());
|
||||
jwkRsa.getOtherClaims().put(RSAPublicJWK.PUBLIC_EXPONENT, ((RSAPublicJWK) jwkRsa).getPublicExponent());
|
||||
String jkt = JWKSUtils.computeThumbprint(jwkRsa);
|
||||
assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint());
|
||||
|
||||
oauth.doLogout(response.getRefreshToken(), TEST_CONFIDENTIAL_CLIENT_SECRET);
|
||||
}
|
||||
|
||||
private OAuthClient.AccessTokenResponse getDPoPBindAccessToken(KeyPair rsaKeyPair) throws Exception {
|
||||
oauth.clientId(TEST_CONFIDENTIAL_CLIENT_ID);
|
||||
oauth.doLogin(TEST_USER_NAME, TEST_USER_PASSWORD);
|
||||
|
@ -680,23 +748,24 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
|
||||
oauth.dpopProof(dpopProofRsaEncoded);
|
||||
OAuthClient.AccessTokenResponse response = oauth.doAccessTokenRequest(code, TEST_CONFIDENTIAL_CLIENT_SECRET);
|
||||
assertEquals(TokenUtil.TOKEN_TYPE_DPOP, response.getTokenType());
|
||||
|
||||
assertEquals(Status.OK.getStatusCode(), response.getStatusCode());
|
||||
AccessToken accessToken = oauth.verifyToken(response.getAccessToken());
|
||||
jwkRsa.getOtherClaims().put(RSAPublicJWK.MODULUS, ((RSAPublicJWK)jwkRsa).getModulus());
|
||||
jwkRsa.getOtherClaims().put(RSAPublicJWK.PUBLIC_EXPONENT, ((RSAPublicJWK)jwkRsa).getPublicExponent());
|
||||
jwkRsa.getOtherClaims().put(RSAPublicJWK.MODULUS, ((RSAPublicJWK) jwkRsa).getModulus());
|
||||
jwkRsa.getOtherClaims().put(RSAPublicJWK.PUBLIC_EXPONENT, ((RSAPublicJWK) jwkRsa).getPublicExponent());
|
||||
String jkt = JWKSUtils.computeThumbprint(jwkRsa);
|
||||
assertEquals(jkt, accessToken.getConfirmation().getKeyThumbprint());
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private void doSuccessfulUserInfoGet(String accessToken, KeyPair rsaKeyPair) throws Exception {
|
||||
private void doSuccessfulUserInfoGet(OAuthClient.AccessTokenResponse accessTokenResponse, KeyPair rsaKeyPair) throws Exception {
|
||||
JWK jwkRsa = createRsaJwk(rsaKeyPair.getPublic());
|
||||
JWSHeader jwsRsaHeader = new JWSHeader(org.keycloak.jose.jws.Algorithm.PS256, DPOP_JWT_HEADER_TYPE, jwkRsa.getKeyId(), jwkRsa);
|
||||
String dpopProofRsaEncoded = generateSignedDPoPProof(UUID.randomUUID().toString(), HttpMethod.GET.toString(), oauth.getUserInfoUrl(), Long.valueOf(Time.currentTime()), Algorithm.PS256, jwsRsaHeader, rsaKeyPair.getPrivate());
|
||||
oauth.dpopProof(dpopProofRsaEncoded);
|
||||
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(accessToken);
|
||||
UserInfoResponse userInfoResponse = oauth.doUserInfoRequestByGet(accessTokenResponse);
|
||||
assertEquals(TEST_USER_NAME, userInfoResponse.getUserInfo().getPreferredUsername());
|
||||
}
|
||||
|
||||
|
@ -720,6 +789,14 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
clientResource.update(clientRep);
|
||||
}
|
||||
|
||||
private void modifyClient(String clientId, BiConsumer<ClientRepresentation, OIDCAdvancedConfigWrapper> modify) {
|
||||
ClientResource clientResource = ApiUtil.findClientByClientId(adminClient.realm(REALM_NAME), clientId);
|
||||
ClientRepresentation clientRep = clientResource.toRepresentation();
|
||||
OIDCAdvancedConfigWrapper configWrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(clientRep);
|
||||
modify.accept(clientRep, configWrapper);
|
||||
clientResource.update(clientRep);
|
||||
}
|
||||
|
||||
private String createClientByAdmin(String clientName, Consumer<ClientRepresentation> op) throws ClientPolicyException {
|
||||
ClientRepresentation clientRep = new ClientRepresentation();
|
||||
clientRep.setClientId(clientName);
|
||||
|
@ -763,7 +840,7 @@ public class DPoPTest extends AbstractTestRealmKeycloakTest {
|
|||
|
||||
private void updatePolicies(String json) throws ClientPolicyException {
|
||||
try {
|
||||
ClientPoliciesRepresentation clientPolicies = json==null ? null : JsonSerialization.readValue(json, ClientPoliciesRepresentation.class);
|
||||
ClientPoliciesRepresentation clientPolicies = json == null ? null : JsonSerialization.readValue(json, ClientPoliciesRepresentation.class);
|
||||
adminClient.realm(REALM_NAME).clientPoliciesPoliciesResource().updatePolicies(clientPolicies);
|
||||
} catch (BadRequestException e) {
|
||||
throw new ClientPolicyException("update policies failed", e.getResponse().getStatusInfo().toString());
|
||||
|
|
Loading…
Reference in a new issue