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
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ public Response authenticated(AuthenticationSessionModel authSession, UserSessio
String code = null;
if (responseType.hasResponseType(OIDCResponseType.CODE)) {
OAuth2Code codeData = new OAuth2Code(SecretGenerator.getInstance().generateSecureID(),
authSession.getClient().getId(),
Time.currentTime() + userSession.getRealm().getAccessCodeLifespan(),
nonce,
authSession.getClientNote(OAuth2Constants.SCOPE),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public class OAuth2Code {

public static final String ID_NOTE = "id";
public static final String EXPIRATION_NOTE = "exp";
private static final String CLIENT_UUID_NOTE = "client_uuid";
private static final String NONCE_NOTE = "nonce";
private static final String SCOPE_NOTE = "scope";
private static final String RESOURCE_NOTE = "resource";
Expand All @@ -44,6 +45,8 @@ public class OAuth2Code {

private final String id;

private final String clientUUID;

private final int expiration;

private final String nonce;
Expand All @@ -60,23 +63,10 @@ public class OAuth2Code {

private final String userSessionId;


public OAuth2Code(String id, int expiration, String nonce, String scope, String userSessionId) {
this.id = id;
this.expiration = expiration;
this.nonce = nonce;
this.scope = scope;
this.resource = null;
this.redirectUriParam = null;
this.codeChallenge = null;
this.codeChallengeMethod = null;
this.dpopJkt = null;
this.userSessionId = userSessionId;
}

public OAuth2Code(String id, int expiration, String nonce, String scope, String resource, String redirectUriParam,
public OAuth2Code(String id, String clientUUID, int expiration, String nonce, String scope, String resource, String redirectUriParam,
String codeChallenge, String codeChallengeMethod, String dpopJkt, String userSessionId) {
this.id = id;
this.clientUUID = clientUUID;
this.expiration = expiration;
this.nonce = nonce;
this.scope = scope;
Expand All @@ -90,6 +80,7 @@ public OAuth2Code(String id, int expiration, String nonce, String scope, String

private OAuth2Code(Map<String, String> data) {
id = data.get(ID_NOTE);
clientUUID = data.get(CLIENT_UUID_NOTE);
expiration = Integer.parseInt(data.get(EXPIRATION_NOTE));
nonce = data.get(NONCE_NOTE);
scope = data.get(SCOPE_NOTE);
Expand All @@ -111,6 +102,7 @@ public Map<String, String> serializeCode() {
Map<String, String> result = new HashMap<>();

result.put(ID_NOTE, id);
result.put(CLIENT_UUID_NOTE, clientUUID);
result.put(EXPIRATION_NOTE, String.valueOf(expiration));
result.put(NONCE_NOTE, nonce);
result.put(SCOPE_NOTE, scope);
Expand All @@ -128,6 +120,10 @@ public String getId() {
return id;
}

public String getClientUUID() {
return clientUUID;
}

public int getExpiration() {
return expiration;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ public static ParseResult parseCode(KeycloakSession session, String code, RealmM
return result.illegalCode();
}

String persistedClientUUID = result.codeData.getClientUUID();
// We allow "persistedClientUUID" to be null just because zero-downtime upgrade between micro versions (as older KC versions might not have clientUUID stored).
// This might be removed in the future version
if (persistedClientUUID != null && !clientUUID.equals(persistedClientUUID)) {
Comment on lines +116 to +118
logger.warnf("Code is bound to a different client '%s'", clientUUID);
return result.illegalCode();
}

// Finally doublecheck if code is not expired
int currentTime = Time.currentTime();
if (currentTime > result.codeData.getExpiration()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import java.util.List;

import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;

import org.keycloak.OAuth2Constants;
Expand All @@ -45,6 +46,7 @@
import org.keycloak.testframework.injection.LifeCycle;
import org.keycloak.testframework.oauth.OAuthClient;
import org.keycloak.testframework.oauth.annotations.InjectOAuthClient;
import org.keycloak.testframework.realm.ClientBuilder;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.RealmBuilder;
import org.keycloak.testframework.realm.RealmConfig;
Expand All @@ -55,7 +57,9 @@
import org.keycloak.testframework.ui.page.ErrorPage;
import org.keycloak.testframework.ui.page.InstalledAppRedirectPage;
import org.keycloak.testframework.ui.webdriver.ManagedWebDriver;
import org.keycloak.testframework.util.ApiUtil;
import org.keycloak.tests.suites.DatabaseTest;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;

import org.apache.http.client.methods.CloseableHttpResponse;
Expand All @@ -66,6 +70,8 @@
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;

import static org.keycloak.OAuthErrorException.INVALID_GRANT;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo;
Expand Down Expand Up @@ -442,6 +448,57 @@ public void authorizationRequestClientParamsMoreThanOnce() {
EventAssertion.assertError(events.poll()).type(EventType.LOGIN_ERROR).error(Errors.INVALID_REQUEST).userId(null).sessionId(null).clientId(null);
}

// Issue 51003
@Test
public void authorizationCodeCanBeRedeemedAfterClientUuidRetargeting() {
String redirectUri = oauth.getRedirectUri();

ClientRepresentation clientRep = ClientBuilder.create("retarget-client")
.secret("secret")
.redirectUris(redirectUri)
.build();
try (Response resp = managedRealm.admin().clients().create(clientRep)) {
String retargetClientUuid = ApiUtil.getCreatedId(resp);
managedRealm.cleanup().add(r -> r.clients().delete(retargetClientUuid));

AuthorizationEndpointResponse retargetClientLogin = oauth
.client("retarget-client", "secret")
.redirectUri(redirectUri)
.loginForm()
.doLogin("test-user@localhost", "password");
Assertions.assertNotNull(retargetClientLogin.getCode());

AuthorizationEndpointResponse testAppLogin = oauth
.client("test-app", "test-secret")
.redirectUri(redirectUri)
.loginForm()
.doLoginWithCookie();
String testAppCode = testAppLogin.getCode();
Assertions.assertNotNull(testAppCode);

String testAppUuid = managedRealm.admin()
.clients()
.findByClientId("test-app")
.get(0)
.getId();

String retargetedCode = testAppCode.replace(testAppUuid, retargetClientUuid);

events.clear();

// Attempt to use "code" created for different client should fail
oauth.client("retarget-client", "secret").redirectUri(redirectUri);
AccessTokenResponse tokenResponse = oauth
.accessTokenRequest(retargetedCode)
.redirectUri(redirectUri)
.send();

Assertions.assertFalse(tokenResponse.isSuccess());
assertEquals(INVALID_GRANT, tokenResponse.getError());
EventAssertion.assertError(events.poll()).type(EventType.CODE_TO_TOKEN_ERROR).error(Errors.INVALID_CODE);
}
}

private static class AuthzCodeRealmConfig implements RealmConfig {

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,10 @@
import org.keycloak.admin.client.resource.ComponentsResource;
import org.keycloak.common.VerificationException;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.common.util.Time;
import org.keycloak.constants.OID4VCIConstants;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientScopeModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.oid4vci.CredentialScopeModel;
import org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint;
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
Expand All @@ -61,14 +57,12 @@
import org.keycloak.protocol.oid4vc.model.OID4VCAuthorizationDetail;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.protocol.oidc.utils.OAuth2Code;
import org.keycloak.protocol.oidc.utils.OAuth2CodeParser;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.ClientScopeRepresentation;
import org.keycloak.representations.idm.ComponentRepresentation;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.representations.userprofile.config.UPConfig;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.testframework.remote.providers.runonserver.RunOnServerException;
import org.keycloak.testframework.util.ApiUtil;
import org.keycloak.testsuite.util.oauth.oid4vc.CredentialIssuerMetadataResponse;
Expand All @@ -82,7 +76,6 @@
import static org.keycloak.OID4VCConstants.CLAIM_NAME_VC;
import static org.keycloak.jose.jwe.JWEConstants.RSA_OAEP_256;
import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CONFIGURATION_ID;
import static org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerEndpoint.CREDENTIAL_OFFER_URI_CODE_SCOPE;
import static org.keycloak.userprofile.DeclarativeUserProfileProvider.UP_COMPONENT_CONFIG_KEY;
import static org.keycloak.util.JsonSerialization.valueAsPrettyString;

Expand Down Expand Up @@ -453,28 +446,4 @@ protected static OID4VCIssuerEndpoint prepareIssuerEndpoint(
30
);
}

static OAuth2CodeEntry prepareSessionCode(
KeycloakSession session,
AppAuthManager.BearerTokenAuthenticator authenticator,
String note) {

AuthenticationManager.AuthResult authResult = authenticator.authenticate();
UserSessionModel userSessionModel = authResult.session();
AuthenticatedClientSessionModel authenticatedClientSessionModel =
userSessionModel.getAuthenticatedClientSessionByClient(authResult.client().getId());

OAuth2Code oauth2Code = new OAuth2Code(
SecretGenerator.getInstance().randomString(),
Time.currentTime() + 6000,
SecretGenerator.getInstance().randomString(),
CREDENTIAL_OFFER_URI_CODE_SCOPE,
authenticatedClientSessionModel.getUserSession().getId()
);

String nonce = OAuth2CodeParser.persistCode(session, authenticatedClientSessionModel, oauth2Code);
authenticatedClientSessionModel.setNote(nonce, note);

return new OID4VCIssuerEndpointTest.OAuth2CodeEntry(nonce, oauth2Code);
}
}
Loading