Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 72 additions & 38 deletions services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -347,51 +347,85 @@ public RefreshToken verifyRefreshToken(KeycloakSession session, RealmModel realm
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token. Token client and authorized client don't match");
}

// KEYCLOAK-6771 Certificate Bound Token
if (OIDCAdvancedConfigWrapper.fromClientModel(client).isUseMtlsHokToken()) {
if (!MtlsHoKTokenUtil.verifyTokenBindingWithClientCertificate(refreshToken, request, session)) {
throw new OAuthErrorException(OAuthErrorException.UNAUTHORIZED_CLIENT, MtlsHoKTokenUtil.CERT_VERIFY_ERROR_DESC);
}
verifyConfirmationBinding(session, client, request, refreshToken);

return refreshToken;

} catch (JWSInputException e) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token", e);
}
}

private void verifyConfirmationBinding(KeycloakSession session, ClientModel client, HttpRequest request, RefreshToken refreshToken) throws OAuthErrorException {
AccessToken.Confirmation cnf = refreshToken.getConfirmation();
OIDCAdvancedConfigWrapper clientConfig = OIDCAdvancedConfigWrapper.fromClientModel(client);
ABCAResult abcaResult = session.getAttribute(ABCAResult.ABCA_RESULT, ABCAResult.class);

// MTLS required but token has no certificate thumbprint
if (clientConfig.isUseMtlsHokToken() && (cnf == null || cnf.getCertThumbprint() == null)) {
throw new OAuthErrorException(OAuthErrorException.UNAUTHORIZED_CLIENT, MtlsHoKTokenUtil.CERT_VERIFY_ERROR_DESC);
}

// DPoP required for public client but token has no DPoP binding (RFC 9449 §5)
if (clientConfig.isUseDPoP() && client.isPublicClient()) {
boolean dpopBound = cnf != null && cnf.getKeyThumbprint() != null && (cnf.getJktType() == null || DPOP_JKT_TYPE.equals(cnf.getJktType()));
if (!dpopBound) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "DPoP proof key binding is required");
Comment on lines +369 to +373

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DPoP and ABCA are alternatives to MTLS HoK, not complements.

}
}

// Verify RefreshToken Confirmation
//
AccessToken.Confirmation cnf = refreshToken.getConfirmation();
if (cnf != null && cnf.getKeyThumbprint() != null) {
String jktType = Optional.ofNullable(cnf.getJktType()).orElse(DPOP_JKT_TYPE);
switch (jktType) {
case ABCA_JKT_TYPE -> {
ABCAResult abcaResult = session.getAttribute(ABCAResult.ABCA_RESULT, ABCAResult.class);
if (abcaResult == null) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token. Client attestation must be used with this refresh token");
}
JWK jwk = abcaResult.getAttestationJwt().getConfirmation().getJwk();
String thumbprint = JWKSUtils.computeThumbprint(jwk);
if (!Objects.equals(thumbprint, cnf.getKeyThumbprint())) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Attestation-Based Key mismatch");
}
// ABCA attestation present but token has no attestation binding
if (abcaResult != null) {
boolean abcaBound = cnf != null && cnf.getKeyThumbprint() != null && ABCA_JKT_TYPE.equals(cnf.getJktType());
if (!abcaBound) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Attestation-based key binding is required for this refresh token");
Comment on lines +377 to +381

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DPoP and ABCA are alternatives to MTLS HoK, not complements.

Comment on lines +377 to +381

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DPoP and ABCA are alternatives to MTLS HoK, not complements.

Comment on lines +377 to +381
}
}

// No confirmation claim, nothing to verify
if (cnf == null) {
return;
}

// cnf present but no known binding type
if (cnf.getCertThumbprint() == null && cnf.getKeyThumbprint() == null) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token confirmation");
}

// Verify MTLS certificate binding (x5t#S256)
if (cnf.getCertThumbprint() != null) {
if (!MtlsHoKTokenUtil.verifyTokenBindingWithClientCertificate(refreshToken, request, session)) {
throw new OAuthErrorException(OAuthErrorException.UNAUTHORIZED_CLIENT, MtlsHoKTokenUtil.CERT_VERIFY_ERROR_DESC);
}
}

// Verify key binding (DPoP or ABCA)
if (cnf.getKeyThumbprint() != null) {
String jktType = Optional.ofNullable(cnf.getJktType()).orElse(DPOP_JKT_TYPE);
switch (jktType) {
case ABCA_JKT_TYPE -> {
if (abcaResult == null) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token. Client attestation must be used with this refresh token");
}
case DPOP_JKT_TYPE -> {
DPoP dPoP = session.getAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE, DPoP.class);
if (dPoP == null) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "DPoP proof is missing");
}
try {
DPoPUtil.validateBinding(refreshToken, dPoP);
} catch (VerificationException ex) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, ex.getMessage());
}
JWK jwk = abcaResult.getAttestationJwt().getConfirmation().getJwk();
String thumbprint = JWKSUtils.computeThumbprint(jwk);
if (!Objects.equals(thumbprint, cnf.getKeyThumbprint())) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Attestation-Based Key mismatch");
}
default -> {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Unknown jkt type: " + jktType);
}
case DPOP_JKT_TYPE -> {
DPoP dPoP = session.getAttribute(DPoPUtil.DPOP_SESSION_ATTRIBUTE, DPoP.class);
if (dPoP == null) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "DPoP proof is missing");
}
try {
DPoPUtil.validateBinding(refreshToken, dPoP);
} catch (VerificationException ex) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, ex.getMessage());
}
}
default -> throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Unknown jkt type: " + jktType);
}

return refreshToken;

} catch (JWSInputException e) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token", e);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@


import java.io.IOException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
Expand All @@ -26,7 +28,6 @@
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.RealmsResource;
import org.keycloak.common.enums.SslRequired;
Expand Down Expand Up @@ -82,11 +83,11 @@
import org.keycloak.testframework.util.ApiUtil;
import org.keycloak.tests.common.TestRealmUserConfig;
import org.keycloak.tests.utils.Assert;
import org.keycloak.tests.utils.admin.AdminApiUtil;
import org.keycloak.testsuite.util.AccountHelper;
import org.keycloak.testsuite.util.oauth.AbstractHttpPostRequest;
import org.keycloak.testsuite.util.oauth.AbstractOAuthClient;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.util.DPoPGenerator;

import org.apache.http.client.methods.CloseableHttpResponse;
import org.hamcrest.MatcherAssert;
Expand Down Expand Up @@ -151,8 +152,6 @@ public class RefreshTokenTest {
@InjectUser(config = TestRealmUserConfig.class)
protected ManagedUser user;

private ClientResource testAppClient;

public static class RefreshTokenTestRealmConfig implements RealmConfig {

@Override
Expand All @@ -168,7 +167,6 @@ public RealmBuilder configure(RealmBuilder realm) {

@BeforeEach
public void before() {
testAppClient = AdminApiUtil.findClientByClientId(realm.admin(), "test-app");
enableRefreshTokenEvents(realm);
AccountHelper.logout(realm.admin(), user.getUsername());
}
Expand Down Expand Up @@ -391,6 +389,102 @@ private String encodeRefreshToken(String encodedRefreshToken) {
});
}

@Test
public void refreshTokenWithUnsupportedConfirmationRejected() {
oauth.doLogin("test-user@localhost", "password");

String code = oauth.parseLoginResponse().getCode();

EventAssertion.assertSuccess(events.poll()).type(EventType.LOGIN);

AccessTokenResponse response = oauth.doAccessTokenRequest(code);
String refreshTokenString = response.getRefreshToken();

EventAssertion.assertSuccess(events.poll()).type(EventType.CODE_TO_TOKEN);

String tamperedRefreshToken = encodeRefreshTokenWithEmptyConfirmation(refreshTokenString);
response = oauth.doRefreshTokenRequest(tamperedRefreshToken);

Assert.assertEquals(400, response.getStatusCode());
Assert.assertEquals(OAuthErrorException.INVALID_GRANT, response.getError());
assertThat(response.getErrorDescription(), Matchers.startsWith("Invalid refresh token confirmation"));

EventAssertion.assertError(events.poll()).type(EventType.REFRESH_TOKEN_ERROR).error(Errors.INVALID_TOKEN);
}

private String encodeRefreshTokenWithEmptyConfirmation(String encodedRefreshToken) {
return runOnServer.fetchString(session -> {
try {
JWSInput input = new JWSInput(encodedRefreshToken);
RefreshToken refreshToken = input.readJsonContent(RefreshToken.class);

refreshToken.setConfirmation(new AccessToken.Confirmation());
return session.tokens().encode(refreshToken);
} catch (JWSInputException ioe) {
throw new RuntimeException("Failed to encode token: " + encodedRefreshToken);
}
});
}

@Test
public void refreshTokenRejectedAfterEnablingCNFCheck() throws Exception {
ClientRepresentation clientRep = oauth.clientResource().toRepresentation();
clientRep.setPublicClient(true);
oauth.clientResource().update(clientRep);
Comment on lines +431 to +433

oauth.doLogin("test-user@localhost", "password");

String code = oauth.parseLoginResponse().getCode();

EventAssertion.assertSuccess(events.poll()).type(EventType.LOGIN);

AccessTokenResponse response = oauth.doAccessTokenRequest(code);
String refreshTokenString = response.getRefreshToken();

EventAssertion.assertSuccess(events.poll()).type(EventType.CODE_TO_TOKEN);

// Refresh works before enabling DPoP
response = oauth.doRefreshTokenRequest(refreshTokenString);
assertEquals(200, response.getStatusCode());
refreshTokenString = response.getRefreshToken();

events.skip(1);

// Enable DPoP on the public client
clientRep.getAttributes().put(OIDCConfigAttributes.DPOP_BOUND_ACCESS_TOKENS, "true");
oauth.clientResource().update(clientRep);

// Generate a valid DPoP proof (handleDPoPHeader rejects requests without one when DPoP is required)
KeyPair rsaKeyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
String dpopProof = DPoPGenerator.generateRsaSignedDPoPProof(rsaKeyPair, "POST", oauth.getEndpoints().getToken(), null);

// Refresh with DPoP proof + old unbound token is rejected
response = oauth.refreshRequest(refreshTokenString).dpopProof(dpopProof).send();
assertEquals(400, response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_GRANT, response.getError());
assertThat(response.getErrorDescription(), Matchers.startsWith("DPoP proof key binding is required"));
EventAssertion.assertError(events.poll()).type(EventType.REFRESH_TOKEN_ERROR).error(Errors.INVALID_TOKEN);

//enable mtls
clientRep.getAttributes().put(OIDCConfigAttributes.DPOP_BOUND_ACCESS_TOKENS, "false");
clientRep.getAttributes().put(OIDCConfigAttributes.USE_MTLS_HOK_TOKEN, "true");
Comment thread
graziang marked this conversation as resolved.
clientRep.setPublicClient(false);
oauth.clientResource().update(clientRep);

// Refresh with old token is rejected - token has no x5t#S256 cnf
response = oauth.doRefreshTokenRequest(refreshTokenString);
assertEquals(401, response.getStatusCode());
assertEquals(OAuthErrorException.UNAUTHORIZED_CLIENT, response.getError());
EventAssertion.assertError(events.poll()).type(EventType.REFRESH_TOKEN_ERROR).error(Errors.NOT_ALLOWED);

clientRep.getAttributes().put(OIDCConfigAttributes.DPOP_BOUND_ACCESS_TOKENS, "false");
clientRep.getAttributes().put(OIDCConfigAttributes.USE_MTLS_HOK_TOKEN, "false");
oauth.clientResource().update(clientRep);

// Refresh works again
response = oauth.doRefreshTokenRequest(refreshTokenString);
assertEquals(200, response.getStatusCode());
}

@Test
public void refreshingTokenLoadsSessionIntoCache() {
Expand Down Expand Up @@ -621,8 +715,8 @@ public void refreshTokenReuseTokenWithoutRefreshTokensRevokedWithLessScopes() {
ClientScopeRepresentation phoneScope = findClientScopeByName("phone");
ClientScopeRepresentation addressScope = findClientScopeByName("address");

testAppClient.addOptionalClientScope(phoneScope.getId());
testAppClient.addOptionalClientScope(addressScope.getId());
oauth.clientResource().addOptionalClientScope(phoneScope.getId());
oauth.clientResource().addOptionalClientScope(addressScope.getId());

try {
oauth.doLogin("test-user@localhost", "password");
Expand All @@ -648,8 +742,8 @@ public void refreshTokenReuseTokenWithoutRefreshTokensRevokedWithLessScopes() {

} finally {
oauth.scope(null);
testAppClient.removeOptionalClientScope(phoneScope.getId());
testAppClient.removeOptionalClientScope(addressScope.getId());
oauth.clientResource().removeOptionalClientScope(phoneScope.getId());
oauth.clientResource().removeOptionalClientScope(addressScope.getId());
}
}

Expand Down Expand Up @@ -688,8 +782,8 @@ private ClientScopeRepresentation findClientScopeByName(String name) {
public void refreshWithOptionalClientScopeWithIncludeInTokenScopeDisabled() {
//set roles client scope as optional
ClientScopeRepresentation rolesScope = findClientScopeByName(OIDCLoginProtocolFactory.ROLES_SCOPE);
testAppClient.removeDefaultClientScope(rolesScope.getId());
testAppClient.addOptionalClientScope(rolesScope.getId());
oauth.clientResource().removeDefaultClientScope(rolesScope.getId());
oauth.clientResource().addOptionalClientScope(rolesScope.getId());

try {
oauth.scope("roles");
Expand Down Expand Up @@ -721,8 +815,8 @@ public void refreshWithOptionalClientScopeWithIncludeInTokenScopeDisabled() {
Assert.assertNotNull(accessToken.getResourceAccess());

} finally {
testAppClient.removeOptionalClientScope(rolesScope.getId());
testAppClient.addDefaultClientScope(rolesScope.getId());
oauth.clientResource().removeOptionalClientScope(rolesScope.getId());
oauth.clientResource().addDefaultClientScope(rolesScope.getId());
}
}

Expand All @@ -746,7 +840,7 @@ public void refreshTokenClientDisabled() {
String refreshTokenString = response.getRefreshToken();
events.clear();

realm.updateClientWithCleanup(testAppClient.toRepresentation().getClientId(), c -> c.enabled(false));
realm.updateClientWithCleanup(oauth.clientResource().toRepresentation().getClientId(), c -> c.enabled(false));

response = oauth.doRefreshTokenRequest(refreshTokenString);

Expand Down Expand Up @@ -979,7 +1073,7 @@ public void refreshTokenServiceAccount() {

@Test
public void refreshTokenRequestNoRefreshToken() {
ClientRepresentation client = testAppClient.toRepresentation();
ClientRepresentation client = oauth.clientResource().toRepresentation();
oauth.doLogin("test-user@localhost", "password");

String code = oauth.parseLoginResponse().getCode();
Expand All @@ -989,7 +1083,7 @@ public void refreshTokenRequestNoRefreshToken() {
String refreshTokenString = tokenResponse.getRefreshToken();

client.getAttributes().put(OIDCConfigAttributes.USE_REFRESH_TOKEN, "false");
testAppClient.update(client);
oauth.clientResource().update(client);

try {

Expand All @@ -999,7 +1093,7 @@ public void refreshTokenRequestNoRefreshToken() {
assertNull(response.getRefreshToken());
} finally {
client.getAttributes().put(OIDCConfigAttributes.USE_REFRESH_TOKEN, "true");
testAppClient.update(client);
oauth.clientResource().update(client);
}
}

Expand Down Expand Up @@ -1070,14 +1164,14 @@ public void refreshTokenWithRealmNotBeforeAndClientNotBefore() {

int currentTime = (int) (System.currentTimeMillis() / 1000);

ClientRepresentation clientRep = testAppClient.toRepresentation();
ClientRepresentation clientRep = oauth.clientResource().toRepresentation();
int originalClientNotBefore = clientRep.getNotBefore() != null ? clientRep.getNotBefore() : 0;

try {
//Test realm notBefore with client notBefore both set
// Set client notBefore to past
clientRep.setNotBefore(currentTime - 100);
testAppClient.update(clientRep);
oauth.clientResource().update(clientRep);

tokenResponse = oauth.doRefreshTokenRequest(refreshToken);
assertEquals(200, tokenResponse.getStatusCode());
Expand All @@ -1099,7 +1193,7 @@ public void refreshTokenWithRealmNotBeforeAndClientNotBefore() {

} finally {
clientRep.setNotBefore(originalClientNotBefore);
testAppClient.update(clientRep);
oauth.clientResource().update(clientRep);
}
}

Expand All @@ -1123,10 +1217,10 @@ private void changeRealmTokenSignatureProvider(String toSigAlgName) {
}

private void changeClientAccessTokenSignatureProvider(String toSigAlgName) {
ClientRepresentation clientRep = testAppClient.toRepresentation();
ClientRepresentation clientRep = oauth.clientResource().toRepresentation();
log.tracef("change client %s access token signature algorithm from %s to %s", clientRep.getClientId(), clientRep.getAttributes().get(OIDCConfigAttributes.ACCESS_TOKEN_SIGNED_RESPONSE_ALG), toSigAlgName);
clientRep.getAttributes().put(OIDCConfigAttributes.ACCESS_TOKEN_SIGNED_RESPONSE_ALG, toSigAlgName);
testAppClient.update(clientRep);
oauth.clientResource().update(clientRep);
}

private void refreshToken(String expectedRefreshAlg, String expectedAccessAlg, String expectedIdTokenAlg) throws Exception {
Expand Down
Loading