Skip to content

Commit 3687aae

Browse files
douglaspalmermposolda
authored andcommitted
Add switch to enable token-exchange to requester clients
Closes #37110 Signed-off-by: Douglas Palmer <[email protected]>
1 parent fe090c1 commit 3687aae

File tree

8 files changed

+122
-0
lines changed

8 files changed

+122
-0
lines changed

js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,7 @@ nameIdFormatHelp=The name ID format to use for the subject.
448448
detailsHelp=This is information about the details.
449449
adminEvents=Admin events
450450
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.
451+
standardTokenExchangeEnabledHelp=Enable Standard Token Exchange V2 for this client.
451452
urisHelp=Set of URIs which are protected by resource.
452453
eventTypes.IDENTITY_PROVIDER_RESPONSE.name=Identity provider response
453454
confirmClientSecretTitle=Regenerate secret for this client?
@@ -1112,6 +1113,7 @@ client-updater-source-groups.tooltip=Name of groups to check. The condition eval
11121113
webAuthnPolicyRpId=Relying party ID
11131114
ldapRolesDnHelp=LDAP DN where roles of this tree are saved. For example, 'ou\=finance,dc\=example,dc\=org'.
11141115
serviceAccount=Service accounts roles
1116+
standardTokenExchangeEnabled=Standard Token Exchange
11151117
providerUpdatedSuccess=Client policy updated successfully
11161118
assertionConsumerServiceRedirectBindingURL=Assertion Consumer Service Redirect Binding URL
11171119
createClientScopeError=Could not create client scope\: '{{error}}'

js/apps/admin-ui/src/clients/add/CapabilityConfig.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ export const CapabilityConfig = ({
7676
),
7777
false,
7878
);
79+
setValue(
80+
convertAttributeNameToForm<FormFields>(
81+
"attributes.standard.token.exchange.enabled",
82+
),
83+
false,
84+
);
7985
}
8086
}}
8187
aria-label={t("clientAuthentication")}
@@ -234,6 +240,41 @@ export const CapabilityConfig = ({
234240
)}
235241
/>
236242
</GridItem>
243+
{isFeatureEnabled(Feature.StandardTokenExchangeV2) && (
244+
<GridItem lg={8} sm={6}>
245+
<Controller
246+
name={convertAttributeNameToForm<
247+
Required<ClientRepresentation["attributes"]>
248+
>("attributes.standard.token.exchange.enabled")}
249+
defaultValue={false}
250+
control={control}
251+
render={({ field }) => (
252+
<InputGroup>
253+
<InputGroupItem>
254+
<Checkbox
255+
data-testid="standard-token-exchange-enabled"
256+
label={t("standardTokenExchangeEnabled")}
257+
id="kc-standard-token-exchange-enabled"
258+
name="standard-token-exchange-enabled"
259+
isChecked={
260+
field.value.toString() === "true" &&
261+
!clientAuthentication
262+
}
263+
onChange={field.onChange}
264+
isDisabled={clientAuthentication}
265+
/>
266+
</InputGroupItem>
267+
<InputGroupItem>
268+
<HelpItem
269+
helpText={t("standardTokenExchangeEnabledHelp")}
270+
fieldLabelId="standardTokenExchangeEnabled"
271+
/>
272+
</InputGroupItem>
273+
</InputGroup>
274+
)}
275+
/>
276+
</GridItem>
277+
)}
237278
{isFeatureEnabled(Feature.DeviceFlow) && (
238279
<GridItem lg={8} sm={6}>
239280
<Controller

js/apps/admin-ui/src/utils/useIsFeatureEnabled.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export enum Feature {
1414
Organizations = "ORGANIZATION",
1515
OpenId4VCI = "OID4VC_VCI",
1616
QuickTheme = "QUICK_THEME",
17+
StandardTokenExchangeV2 = "TOKEN_EXCHANGE_STANDARD_V2",
1718
}
1819

1920
export default function useIsFeatureEnabled() {

server-spi-private/src/main/java/org/keycloak/protocol/oidc/OIDCConfigAttributes.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ public final class OIDCConfigAttributes {
8686
public static final String FRONT_CHANNEL_LOGOUT_SESSION_REQUIRED = "frontchannel.logout.session.required";
8787

8888
public static final String POST_LOGOUT_REDIRECT_URIS = "post.logout.redirect.uris";
89+
90+
public static final String STANDARD_TOKEN_EXCHANGE_ENABLED = "standard.token.exchange.enabled";
8991

9092
private OIDCConfigAttributes() {
9193
}

services/src/main/java/org/keycloak/protocol/oidc/OIDCAdvancedConfigWrapper.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,16 @@ public void setUseRefreshTokenForClientCredentialsGrant(boolean enable) {
230230
setAttribute(OIDCConfigAttributes.USE_REFRESH_TOKEN_FOR_CLIENT_CREDENTIALS_GRANT, val);
231231
}
232232

233+
public boolean isStandardTokenExchangeEnabled() {
234+
String val = getAttribute(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_ENABLED, "false");
235+
return Boolean.parseBoolean(val);
236+
}
237+
238+
public void setStandardTokenExchangeEnabled(boolean enable) {
239+
String val = String.valueOf(enable);
240+
setAttribute(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_ENABLED, val);
241+
}
242+
233243
public String getTlsClientAuthSubjectDn() {
234244
return getAttribute(X509ClientAuthenticator.ATTR_SUBJECT_DN);
235245
}

services/src/main/java/org/keycloak/protocol/oidc/tokenexchange/StandardTokenExchangeProvider.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ protected Response tokenExchange() {
7979
Cors cors = context.getCors();
8080
EventBuilder event = context.getEvent();
8181

82+
if(!OIDCAdvancedConfigWrapper.fromClientModel(context.getClient()).isStandardTokenExchangeEnabled()) {
83+
event.detail(Details.REASON, "Standard token exchange is not enabled for the requested client");
84+
event.error(Errors.INVALID_REQUEST);
85+
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "Standard token exchange is not enabled for the requested client", Response.Status.BAD_REQUEST);
86+
}
87+
8288
String subjectToken = context.getParams().getSubjectToken();
8389
if (subjectToken == null) {
8490
event.detail(Details.REASON, "subject_token parameter not provided");

testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/tokenexchange/StandardTokenExchangeV2Test.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,19 @@ public void testExchangeWithDynamicScopesEnabled() throws Exception {
454454
testExchange();
455455
testingClient.disableFeature(Profile.Feature.DYNAMIC_SCOPES);
456456
}
457+
458+
@Test
459+
@UncaughtServerErrorExpected
460+
public void testExchangeDisabledOnClient() throws Exception {
461+
oauth.realm(TEST);
462+
String accessToken = resourceOwnerLogin("john", "password", "subject-client", "secret");
463+
{
464+
AccessTokenResponse response = tokenExchange(accessToken, "disabled-requester-client", "secret", null, null);
465+
org.junit.Assert.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
466+
org.junit.Assert.assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
467+
org.junit.Assert.assertEquals("Standard token exchange is not enabled for the requested client", response.getErrorDescription());
468+
}
469+
}
457470

458471
@Test
459472
public void testConsents() throws Exception {

testsuite/integration-arquillian/tests/base/src/test/resources/token-exchange/testrealm-token-exchange-v2.json

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,7 @@
707707
"oidc.ciba.grant.enabled" : "false",
708708
"client.secret.creation.time" : "1739807936",
709709
"backchannel.logout.session.required" : "true",
710+
"standard.token.exchange.enabled":"true",
710711
"frontchannel.logout.session.required" : "true",
711712
"post.logout.redirect.uris" : "+",
712713
"oauth2.device.authorization.grant.enabled" : "false",
@@ -718,6 +719,49 @@
718719
"nodeReRegistrationTimeout" : -1,
719720
"defaultClientScopes" : [ "service_account", "acr", "roles", "basic" ],
720721
"optionalClientScopes" : [ ]
722+
}, {
723+
"id" : "54b8e1b4-e912-4821-9335-3f0c0d2f4a2d",
724+
"clientId" : "disabled-requester-client",
725+
"name" : "",
726+
"description" : "",
727+
"rootUrl" : "",
728+
"adminUrl" : "",
729+
"baseUrl" : "",
730+
"surrogateAuthRequired" : false,
731+
"enabled" : true,
732+
"alwaysDisplayInConsole" : false,
733+
"clientAuthenticatorType" : "client-secret",
734+
"secret" : "secret",
735+
"redirectUris" : [ "/*" ],
736+
"webOrigins" : [ "/*" ],
737+
"notBefore" : 0,
738+
"bearerOnly" : false,
739+
"consentRequired" : false,
740+
"standardFlowEnabled" : true,
741+
"implicitFlowEnabled" : false,
742+
"directAccessGrantsEnabled" : true,
743+
"serviceAccountsEnabled" : true,
744+
"publicClient" : false,
745+
"frontchannelLogout" : true,
746+
"protocol" : "openid-connect",
747+
"attributes" : {
748+
"realm_client" : "false",
749+
"oidc.ciba.grant.enabled" : "false",
750+
"client.secret.creation.time" : "1732884723",
751+
"backchannel.logout.session.required" : "true",
752+
"standard.token.exchange.enabled":"false",
753+
"post.logout.redirect.uris" : "+",
754+
"frontchannel.logout.session.required" : "true",
755+
"oauth2.device.authorization.grant.enabled" : "false",
756+
"display.on.consent.screen" : "false",
757+
"use.jwks.url" : "false",
758+
"backchannel.logout.revoke.offline.tokens" : "false"
759+
},
760+
"authenticationFlowBindingOverrides" : { },
761+
"fullScopeAllowed" : false,
762+
"nodeReRegistrationTimeout" : -1,
763+
"defaultClientScopes" : [ "service_account", "acr", "default-scope1", "roles", "basic" ],
764+
"optionalClientScopes" : [ "optional-scope2" ]
721765
}, {
722766
"id" : "a11ebbaf-c0fd-46df-9fe7-64e94ac8d945",
723767
"clientId" : "realm-management",
@@ -780,6 +824,7 @@
780824
"oidc.ciba.grant.enabled" : "false",
781825
"client.secret.creation.time" : "1732884723",
782826
"backchannel.logout.session.required" : "true",
827+
"standard.token.exchange.enabled":"true",
783828
"post.logout.redirect.uris" : "+",
784829
"frontchannel.logout.session.required" : "true",
785830
"oauth2.device.authorization.grant.enabled" : "false",
@@ -820,6 +865,7 @@
820865
"realm_client" : "false",
821866
"oidc.ciba.grant.enabled" : "false",
822867
"backchannel.logout.session.required" : "true",
868+
"standard.token.exchange.enabled":"true",
823869
"frontchannel.logout.session.required" : "true",
824870
"oauth2.device.authorization.grant.enabled" : "false",
825871
"display.on.consent.screen" : "false",
@@ -909,6 +955,7 @@
909955
"oidc.ciba.grant.enabled" : "false",
910956
"client.secret.creation.time" : "1739806499",
911957
"backchannel.logout.session.required" : "true",
958+
"standard.token.exchange.enabled":"true",
912959
"post.logout.redirect.uris" : "+",
913960
"frontchannel.logout.session.required" : "true",
914961
"oauth2.device.authorization.grant.enabled" : "false",

0 commit comments

Comments
 (0)