Skip to content

Commit c15a24f

Browse files
rmartincmposolda
authored andcommitted
Update default requested token-type and add switch for refresh token
Closes #37115 Signed-off-by: rmartinc <[email protected]>
1 parent e2f586c commit c15a24f

File tree

8 files changed

+184
-63
lines changed

8 files changed

+184
-63
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
@@ -449,6 +449,7 @@ 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.
451451
standardTokenExchangeEnabledHelp=Enable Standard Token Exchange V2 for this client.
452+
enableRefreshRequestedTokenTypeHelp=If property is on, Standard Token Exchange V2 allows the urn:ietf:params:oauth:token-type:refresh_token value for the requested_token_type parameter and returns a refresh_token in the response. If it is off, an error is returned for that requested_token_type.
452453
urisHelp=Set of URIs which are protected by resource.
453454
eventTypes.IDENTITY_PROVIDER_RESPONSE.name=Identity provider response
454455
confirmClientSecretTitle=Regenerate secret for this client?
@@ -1114,6 +1115,7 @@ webAuthnPolicyRpId=Relying party ID
11141115
ldapRolesDnHelp=LDAP DN where roles of this tree are saved. For example, 'ou\=finance,dc\=example,dc\=org'.
11151116
serviceAccount=Service accounts roles
11161117
standardTokenExchangeEnabled=Standard Token Exchange
1118+
enableRefreshRequestedTokenType=Allow refresh token in Standard Token Exchange
11171119
providerUpdatedSuccess=Client policy updated successfully
11181120
assertionConsumerServiceRedirectBindingURL=Assertion Consumer Service Redirect Binding URL
11191121
createClientScopeError=Could not create client scope\: '{{error}}'

js/apps/admin-ui/src/clients/advanced/OpenIdConnectCompatibilityModes.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { FormAccess } from "../../components/form/FormAccess";
66
import { HelpItem } from "@keycloak/keycloak-ui-shared";
77
import { convertAttributeNameToForm } from "../../util";
88
import { FormFields } from "../ClientDetails";
9+
import useIsFeatureEnabled, { Feature } from "../../utils/useIsFeatureEnabled";
910

1011
type OpenIdConnectCompatibilityModesProps = {
1112
save: () => void;
@@ -19,7 +20,16 @@ export const OpenIdConnectCompatibilityModes = ({
1920
hasConfigureAccess,
2021
}: OpenIdConnectCompatibilityModesProps) => {
2122
const { t } = useTranslation();
22-
const { control } = useFormContext();
23+
const { control, watch } = useFormContext();
24+
const isFeatureEnabled = useIsFeatureEnabled();
25+
const tokenExchangeEnabled = watch(
26+
convertAttributeNameToForm<FormFields>(
27+
"attributes.standard.token.exchange.enabled",
28+
),
29+
);
30+
const useRefreshTokens = watch(
31+
convertAttributeNameToForm<FormFields>("attributes.use.refresh.tokens"),
32+
);
2333
return (
2434
<FormAccess
2535
role="manage-clients"
@@ -171,6 +181,46 @@ export const OpenIdConnectCompatibilityModes = ({
171181
)}
172182
/>
173183
</FormGroup>
184+
185+
{isFeatureEnabled(Feature.StandardTokenExchangeV2) && (
186+
<FormGroup
187+
label={t("enableRefreshRequestedTokenType")}
188+
fieldId="enableRefreshRequestedTokenType"
189+
hasNoPaddingTop
190+
labelIcon={
191+
<HelpItem
192+
helpText={t("enableRefreshRequestedTokenTypeHelp")}
193+
fieldLabelId="enableRefreshRequestedTokenType"
194+
/>
195+
}
196+
>
197+
<Controller
198+
name={convertAttributeNameToForm<FormFields>(
199+
"attributes.standard.token.exchange.enableRefreshRequestedTokenType",
200+
)}
201+
defaultValue="false"
202+
control={control}
203+
render={({ field }) => (
204+
<Switch
205+
id="enableRefreshRequestedTokenType"
206+
label={t("on")}
207+
labelOff={t("off")}
208+
isChecked={
209+
field.value === "true" &&
210+
tokenExchangeEnabled?.toString() === "true" &&
211+
useRefreshTokens?.toString() === "true"
212+
}
213+
onChange={(_event, value) => field.onChange(value.toString())}
214+
aria-label={t("enableRefreshRequestedTokenType")}
215+
isDisabled={
216+
tokenExchangeEnabled?.toString() !== "true" ||
217+
useRefreshTokens?.toString() !== "true"
218+
}
219+
/>
220+
)}
221+
/>
222+
</FormGroup>
223+
)}
174224
<ActionGroup>
175225
<Button
176226
variant="secondary"

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ public final class OIDCConfigAttributes {
8888
public static final String POST_LOGOUT_REDIRECT_URIS = "post.logout.redirect.uris";
8989

9090
public static final String STANDARD_TOKEN_EXCHANGE_ENABLED = "standard.token.exchange.enabled";
91+
public static final String STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED = "standard.token.exchange.enableRefreshRequestedTokenType";
9192

9293
private OIDCConfigAttributes() {
9394
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,14 @@ public void setStandardTokenExchangeEnabled(boolean enable) {
240240
setAttribute(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_ENABLED, val);
241241
}
242242

243+
public boolean isStandardTokenExchangeRefreshEnabled() {
244+
return Boolean.parseBoolean(getAttribute(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED));
245+
}
246+
247+
public void setStandardTokenExchangeRefreshEnabled(boolean enable) {
248+
setAttribute(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED, String.valueOf(enable));
249+
}
250+
243251
public String getTlsClientAuthSubjectDn() {
244252
return getAttribute(X509ClientAuthenticator.ATTR_SUBJECT_DN);
245253
}

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

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -210,14 +210,17 @@ protected String getRequestedTokenType() {
210210
String requestedTokenType = formParams.getFirst(OAuth2Constants.REQUESTED_TOKEN_TYPE);
211211
if (requestedTokenType == null) {
212212
requestedTokenType = OAuth2Constants.REFRESH_TOKEN_TYPE;
213-
} else if (!requestedTokenType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE) &&
214-
!requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE) &&
215-
!requestedTokenType.equals(OAuth2Constants.SAML2_TOKEN_TYPE)) {
216-
event.detail(Details.REASON, "requested_token_type unsupported");
217-
event.error(Errors.INVALID_REQUEST);
218-
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "requested_token_type unsupported", Response.Status.BAD_REQUEST);
219-
}
220-
return requestedTokenType;
213+
return requestedTokenType;
214+
}
215+
if (requestedTokenType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE)
216+
|| requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE)
217+
|| requestedTokenType.equals(OAuth2Constants.SAML2_TOKEN_TYPE)) {
218+
return requestedTokenType;
219+
}
220+
221+
event.detail(Details.REASON, "requested_token_type unsupported");
222+
event.error(Errors.INVALID_REQUEST);
223+
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "requested_token_type unsupported", Response.Status.BAD_REQUEST);
221224
}
222225

223226
protected List<ClientModel> getTargetAudienceClients() {

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

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import java.util.List;
2828
import java.util.Set;
2929
import java.util.StringJoiner;
30+
import java.util.stream.Collectors;
3031
import org.keycloak.OAuth2Constants;
3132
import org.keycloak.OAuthErrorException;
3233
import org.keycloak.common.ClientConnection;
@@ -205,15 +206,26 @@ protected Response exchangeClientToOIDCClient(UserModel targetUser, UserSessionM
205206
AuthenticationSessionModel authSession = createSessionModel(targetUserSession, rootAuthSession, targetUser, client, scope);
206207

207208
if (targetUserSession == null) {
208-
// if no session is associated with a subject_token, a transient session is created to only allow building a token to the audience
209+
// if no session is associated with a subject_token, a new session will be created, only persistent if refresh token type requested
209210
targetUserSession = new UserSessionManager(session).createUserSession(authSession.getParentSession().getId(), realm, targetUser, targetUser.getUsername(),
210-
clientConnection.getRemoteAddr(), ServiceAccountConstants.CLIENT_AUTH, false, null, null, UserSessionModel.SessionPersistenceState.TRANSIENT);
211+
clientConnection.getRemoteAddr(), ServiceAccountConstants.CLIENT_AUTH, false, null, null,
212+
requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE)
213+
? UserSessionModel.SessionPersistenceState.PERSISTENT
214+
: UserSessionModel.SessionPersistenceState.TRANSIENT);
211215
}
212216

213217
event.session(targetUserSession);
214218

215219
ClientSessionContext clientSessionCtx = TokenManager.attachAuthenticationSession(this.session, targetUserSession, authSession);
216220

221+
if (requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE)
222+
&& clientSessionCtx.getClientScopesStream().filter(s -> OAuth2Constants.OFFLINE_ACCESS.equals(s.getName())).findAny().isPresent()) {
223+
event.detail(Details.REASON, "Scope offline_access not allowed for token exchange");
224+
event.error(Errors.INVALID_REQUEST);
225+
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST,
226+
"Scope offline_access not allowed for token exchange", Response.Status.BAD_REQUEST);
227+
}
228+
217229
updateUserSessionFromClientAuth(targetUserSession);
218230

219231
if (params.getAudience() != null && !targetAudienceClients.isEmpty()) {
@@ -231,20 +243,12 @@ protected Response exchangeClientToOIDCClient(UserModel targetUser, UserSessionM
231243
responseBuilder.getAccessToken().setSessionId(null);
232244
}
233245

234-
String issuedTokenType;
235-
if (requestedTokenType.equals(OAuth2Constants.ID_TOKEN_TYPE)) {
236-
issuedTokenType = OAuth2Constants.ID_TOKEN_TYPE;
237-
} else if (requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE)
238-
&& OIDCAdvancedConfigWrapper.fromClientModel(client).isUseRefreshToken()
239-
&& targetUserSession.getPersistenceState() != UserSessionModel.SessionPersistenceState.TRANSIENT) {
246+
if (OAuth2Constants.REFRESH_TOKEN_TYPE.equals(requestedTokenType)) {
240247
responseBuilder.generateRefreshToken();
241-
issuedTokenType = OAuth2Constants.REFRESH_TOKEN_TYPE;
242-
} else {
243-
issuedTokenType = OAuth2Constants.ACCESS_TOKEN_TYPE;
244248
}
245249

246250
AccessTokenResponse res;
247-
if (OAuth2Constants.ID_TOKEN_TYPE.equals(issuedTokenType)) {
251+
if (OAuth2Constants.ID_TOKEN_TYPE.equals(requestedTokenType)) {
248252
// Using the id-token inside "access_token" parameter as per description of "access_token" parameter under https://datatracker.ietf.org/doc/html/rfc8693#name-successful-response
249253
res = responseBuilder.generateIDToken().build();
250254
res.setToken(res.getIdToken());
@@ -258,7 +262,7 @@ protected Response exchangeClientToOIDCClient(UserModel targetUser, UserSessionM
258262
res = responseBuilder.build();
259263
}
260264

261-
res.setOtherClaims(OAuth2Constants.ISSUED_TOKEN_TYPE, issuedTokenType);
265+
res.setOtherClaims(OAuth2Constants.ISSUED_TOKEN_TYPE, requestedTokenType);
262266

263267
if (responseBuilder.getAccessToken().getAudience() != null) {
264268
StringJoiner joiner = new StringJoiner(" ");
@@ -306,15 +310,22 @@ protected List<String> getSupportedOAuthResponseTokenTypes() {
306310
protected String getRequestedTokenType() {
307311
String requestedTokenType = params.getRequestedTokenType();
308312
if (requestedTokenType == null) {
309-
requestedTokenType = OAuth2Constants.REFRESH_TOKEN_TYPE; // TODO: Refresh token should not be the default one and should be supported just if enabled by the switch
310-
} else if (!requestedTokenType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE) &&
311-
!requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE) &&
312-
!requestedTokenType.equals(OAuth2Constants.ID_TOKEN_TYPE) &&
313-
!requestedTokenType.equals(OAuth2Constants.SAML2_TOKEN_TYPE)) {
314-
event.detail(Details.REASON, "requested_token_type unsupported");
315-
event.error(Errors.INVALID_REQUEST);
316-
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "requested_token_type unsupported", Response.Status.BAD_REQUEST);
313+
requestedTokenType = OAuth2Constants.ACCESS_TOKEN_TYPE;
314+
return requestedTokenType;
315+
}
316+
if (requestedTokenType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE)
317+
|| requestedTokenType.equals(OAuth2Constants.ID_TOKEN_TYPE)
318+
|| requestedTokenType.equals(OAuth2Constants.SAML2_TOKEN_TYPE)) {
319+
return requestedTokenType;
317320
}
318-
return requestedTokenType;
321+
OIDCAdvancedConfigWrapper oidcClient = OIDCAdvancedConfigWrapper.fromClientModel(client);
322+
if (requestedTokenType.equals(OAuth2Constants.REFRESH_TOKEN_TYPE)
323+
&& oidcClient.isUseRefreshToken() && oidcClient.isStandardTokenExchangeRefreshEnabled()) {
324+
return requestedTokenType;
325+
}
326+
327+
event.detail(Details.REASON, "requested_token_type unsupported");
328+
event.error(Errors.INVALID_REQUEST);
329+
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_REQUEST, "requested_token_type unsupported", Response.Status.BAD_REQUEST);
319330
}
320331
}

0 commit comments

Comments
 (0)