Add switch to enable token-exchange to requester clients

Closes #37110

Signed-off-by: Douglas Palmer <dpalmer@redhat.com>
This commit is contained in:
Douglas Palmer 2025-02-18 10:42:40 -08:00 committed by Marek Posolda
parent fe090c1635
commit 3687aae436
8 changed files with 122 additions and 0 deletions

View File

@ -448,6 +448,7 @@ nameIdFormatHelp=The name ID format to use for the subject.
detailsHelp=This is information about the details.
adminEvents=Admin events
serviceAccountHelp=Allows you to authenticate this client to Keycloak and retrieve access token dedicated to this client. In terms of OAuth2 specification, this enables support of 'Client Credentials Grant' for this client.
standardTokenExchangeEnabledHelp=Enable Standard Token Exchange V2 for this client.
urisHelp=Set of URIs which are protected by resource.
eventTypes.IDENTITY_PROVIDER_RESPONSE.name=Identity provider response
confirmClientSecretTitle=Regenerate secret for this client?
@ -1112,6 +1113,7 @@ client-updater-source-groups.tooltip=Name of groups to check. The condition eval
webAuthnPolicyRpId=Relying party ID
ldapRolesDnHelp=LDAP DN where roles of this tree are saved. For example, 'ou\=finance,dc\=example,dc\=org'.
serviceAccount=Service accounts roles
standardTokenExchangeEnabled=Standard Token Exchange
providerUpdatedSuccess=Client policy updated successfully
assertionConsumerServiceRedirectBindingURL=Assertion Consumer Service Redirect Binding URL
createClientScopeError=Could not create client scope\: '{{error}}'

View File

@ -76,6 +76,12 @@ export const CapabilityConfig = ({
),
false,
);
setValue(
convertAttributeNameToForm<FormFields>(
"attributes.standard.token.exchange.enabled",
),
false,
);
}
}}
aria-label={t("clientAuthentication")}
@ -234,6 +240,41 @@ export const CapabilityConfig = ({
)}
/>
</GridItem>
{isFeatureEnabled(Feature.StandardTokenExchangeV2) && (
<GridItem lg={8} sm={6}>
<Controller
name={convertAttributeNameToForm<
Required<ClientRepresentation["attributes"]>
>("attributes.standard.token.exchange.enabled")}
defaultValue={false}
control={control}
render={({ field }) => (
<InputGroup>
<InputGroupItem>
<Checkbox
data-testid="standard-token-exchange-enabled"
label={t("standardTokenExchangeEnabled")}
id="kc-standard-token-exchange-enabled"
name="standard-token-exchange-enabled"
isChecked={
field.value.toString() === "true" &&
!clientAuthentication
}
onChange={field.onChange}
isDisabled={clientAuthentication}
/>
</InputGroupItem>
<InputGroupItem>
<HelpItem
helpText={t("standardTokenExchangeEnabledHelp")}
fieldLabelId="standardTokenExchangeEnabled"
/>
</InputGroupItem>
</InputGroup>
)}
/>
</GridItem>
)}
{isFeatureEnabled(Feature.DeviceFlow) && (
<GridItem lg={8} sm={6}>
<Controller

View File

@ -14,6 +14,7 @@ export enum Feature {
Organizations = "ORGANIZATION",
OpenId4VCI = "OID4VC_VCI",
QuickTheme = "QUICK_THEME",
StandardTokenExchangeV2 = "TOKEN_EXCHANGE_STANDARD_V2",
}
export default function useIsFeatureEnabled() {

View File

@ -86,6 +86,8 @@ public final class OIDCConfigAttributes {
public static final String FRONT_CHANNEL_LOGOUT_SESSION_REQUIRED = "frontchannel.logout.session.required";
public static final String POST_LOGOUT_REDIRECT_URIS = "post.logout.redirect.uris";
public static final String STANDARD_TOKEN_EXCHANGE_ENABLED = "standard.token.exchange.enabled";
private OIDCConfigAttributes() {
}

View File

@ -230,6 +230,16 @@ public class OIDCAdvancedConfigWrapper extends AbstractClientConfigWrapper {
setAttribute(OIDCConfigAttributes.USE_REFRESH_TOKEN_FOR_CLIENT_CREDENTIALS_GRANT, val);
}
public boolean isStandardTokenExchangeEnabled() {
String val = getAttribute(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_ENABLED, "false");
return Boolean.parseBoolean(val);
}
public void setStandardTokenExchangeEnabled(boolean enable) {
String val = String.valueOf(enable);
setAttribute(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_ENABLED, val);
}
public String getTlsClientAuthSubjectDn() {
return getAttribute(X509ClientAuthenticator.ATTR_SUBJECT_DN);
}

View File

@ -79,6 +79,12 @@ public class StandardTokenExchangeProvider extends AbstractTokenExchangeProvider
Cors cors = context.getCors();
EventBuilder event = context.getEvent();
if(!OIDCAdvancedConfigWrapper.fromClientModel(context.getClient()).isStandardTokenExchangeEnabled()) {
event.detail(Details.REASON, "Standard token exchange is not enabled for the requested client");
event.error(Errors.INVALID_REQUEST);
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Standard token exchange is not enabled for the requested client", Response.Status.BAD_REQUEST);
}
String subjectToken = context.getParams().getSubjectToken();
if (subjectToken == null) {
event.detail(Details.REASON, "subject_token parameter not provided");

View File

@ -454,6 +454,19 @@ public class StandardTokenExchangeV2Test extends AbstractKeycloakTest {
testExchange();
testingClient.disableFeature(Profile.Feature.DYNAMIC_SCOPES);
}
@Test
@UncaughtServerErrorExpected
public void testExchangeDisabledOnClient() throws Exception {
oauth.realm(TEST);
String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret");
{
AccessTokenResponse response = tokenExchange(accessToken, "disabled-requester-client", "secret", null, null);
org.junit.Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
org.junit.Assert.assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
org.junit.Assert.assertEquals("Standard token exchange is not enabled for the requested client", response.getErrorDescription());
}
}
@Test
public void testConsents() throws Exception {

View File

@ -707,6 +707,7 @@
"oidc.ciba.grant.enabled" : "false",
"client.secret.creation.time" : "1739807936",
"backchannel.logout.session.required" : "true",
"standard.token.exchange.enabled":"true",
"frontchannel.logout.session.required" : "true",
"post.logout.redirect.uris" : "+",
"oauth2.device.authorization.grant.enabled" : "false",
@ -718,6 +719,49 @@
"nodeReRegistrationTimeout" : -1,
"defaultClientScopes" : [ "service_account", "acr", "roles", "basic" ],
"optionalClientScopes" : [ ]
}, {
"id" : "54b8e1b4-e912-4821-9335-3f0c0d2f4a2d",
"clientId" : "disabled-requester-client",
"name" : "",
"description" : "",
"rootUrl" : "",
"adminUrl" : "",
"baseUrl" : "",
"surrogateAuthRequired" : false,
"enabled" : true,
"alwaysDisplayInConsole" : false,
"clientAuthenticatorType" : "client-secret",
"secret" : "secret",
"redirectUris" : [ "/*" ],
"webOrigins" : [ "/*" ],
"notBefore" : 0,
"bearerOnly" : false,
"consentRequired" : false,
"standardFlowEnabled" : true,
"implicitFlowEnabled" : false,
"directAccessGrantsEnabled" : true,
"serviceAccountsEnabled" : true,
"publicClient" : false,
"frontchannelLogout" : true,
"protocol" : "openid-connect",
"attributes" : {
"realm_client" : "false",
"oidc.ciba.grant.enabled" : "false",
"client.secret.creation.time" : "1732884723",
"backchannel.logout.session.required" : "true",
"standard.token.exchange.enabled":"false",
"post.logout.redirect.uris" : "+",
"frontchannel.logout.session.required" : "true",
"oauth2.device.authorization.grant.enabled" : "false",
"display.on.consent.screen" : "false",
"use.jwks.url" : "false",
"backchannel.logout.revoke.offline.tokens" : "false"
},
"authenticationFlowBindingOverrides" : { },
"fullScopeAllowed" : false,
"nodeReRegistrationTimeout" : -1,
"defaultClientScopes" : [ "service_account", "acr", "default-scope1", "roles", "basic" ],
"optionalClientScopes" : [ "optional-scope2" ]
}, {
"id" : "a11ebbaf-c0fd-46df-9fe7-64e94ac8d945",
"clientId" : "realm-management",
@ -780,6 +824,7 @@
"oidc.ciba.grant.enabled" : "false",
"client.secret.creation.time" : "1732884723",
"backchannel.logout.session.required" : "true",
"standard.token.exchange.enabled":"true",
"post.logout.redirect.uris" : "+",
"frontchannel.logout.session.required" : "true",
"oauth2.device.authorization.grant.enabled" : "false",
@ -820,6 +865,7 @@
"realm_client" : "false",
"oidc.ciba.grant.enabled" : "false",
"backchannel.logout.session.required" : "true",
"standard.token.exchange.enabled":"true",
"frontchannel.logout.session.required" : "true",
"oauth2.device.authorization.grant.enabled" : "false",
"display.on.consent.screen" : "false",
@ -909,6 +955,7 @@
"oidc.ciba.grant.enabled" : "false",
"client.secret.creation.time" : "1739806499",
"backchannel.logout.session.required" : "true",
"standard.token.exchange.enabled":"true",
"post.logout.redirect.uris" : "+",
"frontchannel.logout.session.required" : "true",
"oauth2.device.authorization.grant.enabled" : "false",