Enable use of kc_idp_hint in Pushed Authorization Requests.

The client can select which Identity Provider to use for user authentication by including an Identity Provider alias in a "kc_idp_hint" parameter in a Pushed Authorization Request.

Closes #47229

Signed-off-by: Laurids Møller Jepsen <laurids.jepsen@cryptomathic.com>
This commit is contained in:
Laurids Møller Jepsen
2026-03-17 15:39:52 +01:00
committed by Marek Posolda
parent d15b258880
commit 3e3191d60c
4 changed files with 104 additions and 5 deletions
@@ -47,6 +47,6 @@ public interface AdapterConstants {
// Cookie used on adapter side to store token info. Used only when tokenStore is 'COOKIE'
public static final String KEYCLOAK_ADAPTER_STATE_COOKIE = "KEYCLOAK_ADAPTER_STATE";
// Request parameter used to specify the identifier of the identity provider that should be used to authenticate an user
// Request parameter used to specify the identifier of the identity provider that should be used to authenticate a user
String KC_IDP_HINT = "kc_idp_hint";
}
@@ -46,10 +46,13 @@ public class IdentityProviderAuthenticator implements Authenticator {
@Override
public void authenticate(AuthenticationFlowContext context) {
if (context.getUriInfo().getQueryParameters().containsKey(AdapterConstants.KC_IDP_HINT)) {
String providerId = context.getUriInfo().getQueryParameters().getFirst(AdapterConstants.KC_IDP_HINT);
if (providerId == null || providerId.equals("")) {
LOG.tracef("Skipping: kc_idp_hint query parameter is empty");
String providerId = context.getUriInfo().getQueryParameters().containsKey(AdapterConstants.KC_IDP_HINT)
? context.getUriInfo().getQueryParameters().getFirst(AdapterConstants.KC_IDP_HINT)
: context.getAuthenticationSession().getClientNote(AdapterConstants.KC_IDP_HINT);
if (providerId != null) {
if (providerId.equals("")) {
LOG.tracef("Skipping: %s parameter is empty", AdapterConstants.KC_IDP_HINT);
context.attempted();
} else {
LOG.tracef("Redirecting: %s set to %s", AdapterConstants.KC_IDP_HINT, providerId);
@@ -4,6 +4,7 @@ import java.io.IOException;
import java.util.List;
import org.keycloak.OAuth2Constants;
import org.keycloak.constants.AdapterConstants;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.util.JsonSerialization;
import org.keycloak.util.TokenUtil;
@@ -83,6 +84,11 @@ public class ParRequest extends AbstractHttpPostRequest<ParRequest, ParResponse>
return this;
}
public ParRequest idpHint(String idpHint) {
parameter(AdapterConstants.KC_IDP_HINT, idpHint);
return this;
}
@Override
protected void initRequest() {
parameter(OAuth2Constants.RESPONSE_TYPE, client.config().getResponseType());
@@ -59,11 +59,13 @@ 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.IdentityProviderBuilder;
import org.keycloak.testsuite.util.RoleBuilder;
import org.keycloak.testsuite.util.oauth.AbstractHttpResponse;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
import org.keycloak.testsuite.util.oauth.OAuthClient;
import org.keycloak.testsuite.util.oauth.ParRequest;
import org.keycloak.testsuite.util.oauth.ParResponse;
import org.keycloak.util.JsonSerialization;
@@ -99,6 +101,8 @@ public class ParTest extends AbstractClientPoliciesTest {
private static final String VALID_CORS_URL = "http://localtest.me:8180";
private static final String INVALID_CORS_URL = "http://invalid.localtest.me:8180";
private static final String IDENTITY_PROVIDER_ALIAS = "test-idp";
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
RealmRepresentation realm = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class);
@@ -133,6 +137,8 @@ public class ParTest extends AbstractClientPoliciesTest {
realm.getClients().add(ClientBuilder.create().redirectUris(VALID_CORS_URL + "/realms/master/app")
.addWebOrigin(VALID_CORS_URL).clientId("test-app2").publicClient().directAccessGrants().build());
realm.addIdentityProvider(IdentityProviderBuilder.create().alias(IDENTITY_PROVIDER_ALIAS).providerId("oidc").build());
testRealms.add(realm);
}
@@ -1225,6 +1231,90 @@ public class ParTest extends AbstractClientPoliciesTest {
assertEquals("Exception thrown intentionally", response.getErrorDescription());
}
// Successful redirection to identity provider when including kc_idp_hint in the PAR request.
@Test
public void testSuccessfulIdpRedirectUsingIdpHintInParRequest() throws Exception {
try {
// setup PAR realm settings
int requestUriLifespan = 45;
setParRealmSettings(requestUriLifespan);
// create client dynamically
String clientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> {
clientRep.setRequirePushedAuthorizationRequests(Boolean.TRUE);
clientRep.setRedirectUris(new ArrayList<String>(Arrays.asList(CLIENT_REDIRECT_URI)));
});
OIDCClientRepresentation oidcCRep = getClientDynamically(clientId);
String clientSecret = oidcCRep.getClientSecret();
assertEquals(Boolean.TRUE, oidcCRep.getRequirePushedAuthorizationRequests());
assertTrue(oidcCRep.getRedirectUris().contains(CLIENT_REDIRECT_URI));
assertEquals(OIDCLoginProtocol.CLIENT_SECRET_BASIC, oidcCRep.getTokenEndpointAuthMethod());
// Pushed Authorization Request
oauth.client(clientId, clientSecret);
oauth.redirectUri(CLIENT_REDIRECT_URI);
ParRequest parRequest = oauth.pushedAuthorizationRequest()
.idpHint(IDENTITY_PROVIDER_ALIAS);
ParResponse pResp = parRequest.send();
assertEquals(201, pResp.getStatusCode());
String requestUri = pResp.getRequestUri();
assertEquals(requestUriLifespan, pResp.getExpiresIn());
// Authorization Request with request_uri of PAR
// remove parameters as query strings of uri
oauth.redirectUri(null);
oauth.scope(null);
oauth.responseType(null);
String state = "testSuccessfulIdpRedirectUsingIdpHintInParRequest";
oauth.loginForm().requestUri(requestUri).state(state).open();
assertThat(driver.getCurrentUrl(), startsWith(OAuthClient.AUTH_SERVER_ROOT + "/realms/" + oauth.getRealm() + "/broker/%s/login".formatted(IDENTITY_PROVIDER_ALIAS)));
} finally {
restoreParRealmSettings();
}
}
// No identity provider redirection when including an invalid alias as kc_idp_hint in the PAR request.
@Test
public void testNoIdpRedirectUsingInvalidIdpHintInParRequest() throws Exception {
try {
// setup PAR realm settings
int requestUriLifespan = 45;
setParRealmSettings(requestUriLifespan);
// create client dynamically
String clientId = createClientDynamically(generateSuffixedName(CLIENT_NAME), (OIDCClientRepresentation clientRep) -> {
clientRep.setRequirePushedAuthorizationRequests(Boolean.TRUE);
clientRep.setRedirectUris(new ArrayList<String>(Arrays.asList(CLIENT_REDIRECT_URI)));
});
OIDCClientRepresentation oidcCRep = getClientDynamically(clientId);
String clientSecret = oidcCRep.getClientSecret();
assertEquals(Boolean.TRUE, oidcCRep.getRequirePushedAuthorizationRequests());
assertTrue(oidcCRep.getRedirectUris().contains(CLIENT_REDIRECT_URI));
assertEquals(OIDCLoginProtocol.CLIENT_SECRET_BASIC, oidcCRep.getTokenEndpointAuthMethod());
// Pushed Authorization Request
oauth.client(clientId, clientSecret);
oauth.redirectUri(CLIENT_REDIRECT_URI);
ParRequest parRequest = oauth.pushedAuthorizationRequest()
.idpHint("invalid-idp-alias");
ParResponse pResp = parRequest.send();
assertEquals(201, pResp.getStatusCode());
String requestUri = pResp.getRequestUri();
assertEquals(requestUriLifespan, pResp.getExpiresIn());
// Authorization Request with request_uri of PAR
// remove parameters as query strings of uri
oauth.redirectUri(null);
oauth.scope(null);
oauth.responseType(null);
String state = "testNoIdpRedirectUsingInvalidIdpHintInParRequest";
oauth.loginForm().requestUri(requestUri).state(state).open();
assertThat(driver.getCurrentUrl(), startsWith(OAuthClient.AUTH_SERVER_ROOT + "/realms/" + oauth.getRealm() + "/login-actions/authenticate"));
} finally {
restoreParRealmSettings();
}
}
private void doNormalAuthzProcess(String requestUri, String redirectUrl, String clientId, String clientSecret) {
// Authorization Request with request_uri of PAR
// remove parameters as query strings of uri