FFFF Support refresh token as subject in internal-internal token exchange (#36683) by emouty · Pull Request #47528 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Open
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 8 additions & 2 deletions docs/guides/securing-apps/token-exchange.adoc
8000
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,9 @@ subject_token::
_REQUIRED._ A security token that represents the identity of the party on behalf of whom the request is being made.

subject_token_type::
_REQUIRED._ This parameter is the type of the token passed in the `subject_token` parameter. This must be `urn:ietf:params:oauth:token-type:access_token` when the standard token exchange is being used
because {project_name} does not support other types for the standard token exchange.
_REQUIRED._ This parameter is the type of the token passed in the `subject_token` parameter. This must be `urn:ietf:params:oauth:token-type:access_token` when the standard token exchange is being used.
The value could also be `urn:ietf:params:oauth:token-type:refresh_token` only if the `Allow refresh token as subject in Standard Token Exchange` client configuration option is enabled as specified in the <<_standard-token-exchange-details,standard token exchange>> section.
{project_name} does not support other types for the standard token exchange.

requested_token_type::
_OPTIONAL._ This parameter represents the type of token that the client wants to exchange for. In this version, only oauth and OpenID Connect token types are supported. The default value for this
Expand Down Expand Up @@ -282,6 +283,11 @@ link:{adminguide_link}#_offline-access[Offline session], the requesting refresh
.Enabling refresh token in Token Exchange
image::token-exchange-switch-refresh.png[Enabling refresh token in Token Exchange]

* Using a refresh token as subject token is allowed only if the client has the switch *Allow refresh token as subject in Standard Token Exchange* set to On.

.Enabling refresh token as subject in Token Exchange
image::token-exchange-switch-subject-refresh.png[Enabling refresh token as subject in Token Exchange]

* Token exchange never creates a new link:{adminguide_link}#managing-user-sessions[user session]. In case that `requested_token_type` is a refresh token, it may eventually create a new client session in the user session
for the requester client (if the client session was not yet created).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ jwtAuthorizationGrantIdpHelp=Select Allowed Identity Providers that can be used
jwtAuthorizationGrantAudience=Custom audience mapping
jwtAuthorizationGrantAudienceHelp=Custom audience mapping for the JWT Authorization Grant. The key is the Identity Provider alias and the value is the custom audience enforced for this provider.
enableRefreshRequestedTokenTypeHelp=Controls if the Standard Token Exchange V2 allows to request a refresh token (parameter "requested_token_type" set to value "urn:ietf:params:oauth:token-type:refresh_token"). If this option is "No" (default), the refresh token request type is never allowed and an error is returned. If this option is "Same session", the returned refresh token is enforced to use the same session as the subject token, returning an error if that session is not available (for example if the subject token is transient).
enableRefreshSubjectTokenTypeHelp=Controls if the Standard Token Exchange V2 allows to use a refresh token as subject (parameter "subject_token_type" set to value "urn:ietf:params:oauth:token-type:refresh_token"). If this option is "Off" (default), the refresh token subject type is never allowed and an error is returned.
sameSession=Same session
urisHelp=Set of URIs which are protected by resource.
eventTypes.IDENTITY_PROVIDER_RESPONSE.name=Identity provider response
Expand Down Expand Up @@ -1180,6 +1181,7 @@ externalTokenEnabledHelp=If enabled the client is allowed to retrieve external t
externalTokenIdp=Allowed Identity Providers for External Tokens
externalTokenIdpHelp=Select the Identity Providers from which external tokens can be retrieved.
enableRefreshRequestedTokenType=Allow refresh token in Standard Token Exchange
enableRefreshSubjectTokenType=Allow refresh token as subject in Standard Token Exchange
providerUpdatedSuccess=Client policy updated successfully
assertionConsumerServiceRedirectBindingURL=Assertion Consumer Service Redirect Binding URL
createClientScopeError=Could not create client scope\: '{{error}}'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,41 @@ export const OpenIdConnectCompatibilityModes = ({
]}
/>
)}
{isFeatureEnabled(Feature.StandardTokenExchangeV2) && (
<FormGroup
label={t("enableRefreshSubjectTokenType")}
fieldId="enableRefreshSubjectTokenType"
hasNoPaddingTop
labelIcon={
<HelpItem
helpText={t("enableRefreshSubjectTokenTypeHelp")}
fieldLabelId="enableRefreshSubjectTokenType"
/>
}
>
<Controller
name={convertAttributeNameToForm<FormFields>(
"attributes.standard.token.exchange.enableRefreshSubjectTokenType",
)}
defaultValue="false"
control={control}
render={({ field }) => (
<Switch
id="enableRefreshSubjectTokenType"
label={t("on")}
labelOff={t("off")}
isChecked={field.value === "true"}
value) => field.onChange(value.toString())}
aria-label={t("enableRefreshSubjectTokenType")}
isDisabled={
tokenExchangeEnabled?.toString() !== "true" ||
useRefreshTokens?.toString() !== "true"
}
/>
)}
/>
</FormGroup>
)}
{isFeatureEnabled(Feature.JWTAuthorizationGrant) &&
jwtAuthorizationGrantEnabled.toString() === "true" &&
jwtAuthorizationGrantIdP && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public interface Details {
String REQUESTED_ISSUER = "requested_issuer";
String REQUESTED_SUBJECT = "requested_subject";
String REQUESTED_TOKEN_TYPE = "requested_token_type";
String SUBJECT_TOKEN_TYPE = "subject_token_type";
String SUBJECT_TOKEN_CLIENT_ID = "subject_token_client_id";
String RESTART_AFTER_TIMEOUT = "restart_after_timeout";
String REDIRECTED_TO_CLIENT = "redirected_to_client";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ public final class OIDCConfigAttributes {

public static final String STANDARD_TOKEN_EXCHANGE_ENABLED = "standard.token.exchange.enabled";
public static final String STANDARD_TOKEN_EXCHANGE_REFRESH_ENABLED = "standard.token.exchange.enableRefreshRequestedTokenType";
public static final String STANDARD_TOKEN_EXCHANGE_REFRESH_AS_SUBJECT_ENABLED = "standard.token.exchange.enableRefreshSubjectTokenType";

public static final String JWT_AUTHORIZATION_GRANT_ENABLED = "oauth2.jwt.authorization.grant.enabled";
public static final String JWT_AUTHORIZATION_GRANT_IDP = "oauth2.jwt.authorization.grant.idp";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,16 @@ public void setStandardTokenExchangeRefreshEnabled(TokenExchangeRefreshTokenEnab
enable == null || enable == TokenExchangeRefreshTokenEnabled.NO? null : enable.name());
}

public boolean getStandardTokenExchangeRefreshTokenAsSubjectEnabled() {
String val = getAttribute(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_AS_SUBJECT_ENABLED, "false");
return Boolean.parseBoolean(val);
}

public void setStandardTokenExchangeRefreshTokenAsSubjectEnabled(boolean enable) {
String val = String.valueOf(enable);
setAttribute(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_AS_SUBJECT_ENABLED, val);
}

public boolean getJWTAuthorizationGrantEnabled() {
String val = getAttribute(OIDCConfigAttributes.JWT_AUTHORIZATION_GRANT_ENABLED, "false");
return Boolean.parseBoolean(val);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
import org.keycloak.rar.AuthorizationRequestContext;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.RefreshToken;
import org.keycloak.services.CorsErrorResponseException;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.AuthenticationSessionManager;
Expand All @@ -56,6 +57,7 @@
import org.keycloak.sessions.RootAuthenticationSessionModel;
import org.keycloak.util.TokenUtil;


/**
* Provider for internal-internal token exchange, which is compliant with the token exchange specification https://datatracker.ietf.org/doc/html/rfc8693
*
Expand Down Expand Up @@ -91,7 +93,8 @@ public boolean supports(TokenExchangeContext context) {
return false;
}

if(!OIDCAdvancedConfigWrapper.fromClientModel(context.getClient()).isStandardTokenExchangeEnabled()) {
OIDCAdvancedConfigWrapper oidcConfig = OIDCAdvancedConfigWrapper.fromClientModel(context.getClient());
if(!oidcConfig.isStandardTokenExchangeEnabled()) {
context.setUnsupportedReason("Standard token exchange is not enabled for the requested client");
return false;
}
Expand All @@ -108,8 +111,15 @@ public boolean supports(TokenExchangeContext context) {
return false;
}

if (!subjectTokenType.equals(OAuth2Constants.ACCESS_TOKEN_TYPE)) {
context.setUnsupportedReason("Parameter 'subject_token' supports access tokens only");
boolean refreshTokenAllowed = oidcConfig.getStandardTokenExchangeRefreshTokenAsSubjectEnabled();
boolean isValidTokenType = true;
if (!OAuth2Constants.ACCESS_TOKEN_TYPE.equals(subjectTokenType)) {< 10BC8 /span>
isValidTokenType = refreshTokenAllowed && OAuth2Constants.REFRESH_TOKEN_TYPE.equals(subjectTokenType);
}

if (!isValidTokenType) {
String supportedTypes = refreshTokenAllowed ? "access or refresh tokens" : "access tokens only";
context.setUnsupportedReason("Parameter 'subject_token' supports " + supportedTypes);
return false;
}

Expand All @@ -122,9 +132,10 @@ protected Response tokenExchange() {
String subjectToken = context.getParams().getSubjectToken();

event.detail(Details.REQUESTED_TOKEN_TYPE, context.getParams().getRequestedTokenType());
event.detail(Details.SUBJECT_TOKEN_TYPE, context.getParams().getSubjectTokenType());

AuthenticationManager.AuthResult authResult = AuthenticationManager.verifyIdentityToken(session, realm, session.getContext().getUri(), clientConnection, true, true, null,
false, subjectToken, context.getHeaders(), verifier -> {});
false, subjectToken, OAuth2Constants.REFRESH_TOKEN_TYPE.equals(context.getParams().getSubjectTokenType()), context.getHeaders(), verifier -> {});
if (authResult == null) {
event.detail(Details.REASON, "subject_token validation failure");
event.error(Errors.INVALID_TOKEN);
Expand All @@ -147,7 +158,6 @@ protected Response tokenExchange() {

@Override
protected void validateAudience(AccessToken token, boolean disallowOnHolderOfTokenMismatch, List<ClientModel> targetAudienceClients) {
ClientModel tokenHolder = token == null ? null : realm.getClientByClientId(token.getIssuedFor());

if (client.isPublicClient()) {
String errorMessage = "Public client is not allowed to exchange token";
Expand All @@ -164,10 +174,23 @@ protected void validateAudience(AccessToken token, boolean disallowOnHolderOfTok
throw new CorsErrorResponseException(cors, OAuthErrorException.INVALID_CLIENT, "Client disabled", Response.Status.BAD_REQUEST);
}
}
ClientModel tokenHolder = token == null ? null : realm.getClientByClientId(token.getIssuedFor());

//reject if the requester-client is not in the audience of the subject token
if (!client.equals(tokenHolder)) {
forbiddenIfClientIsNotWithinTokenAudience(token);
if (token instanceof RefreshToken refreshToken) {
if (Arrays.stream(refreshToken.getOriginalAudience())
.noneMatch(reqAudClient -> client.getClientId().equals(reqAudClient))) {
event.detail(Details.REASON, "client is not within the session audience");
event.error(Errors.NOT_ALLOWED);
throw new CorsErrorResponseException(cors, OAuthErrorException.ACCESS_DENIED,
"Client is not within the session audience", Response.Status.FORBIDDEN);
}

} else {

//reject if the requester-client is not in the audience of the subject token
if (!client.equals(tokenHolder)) {
forbiddenIfClientIsNotWithinTokenAudience(token);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
import org.keycloak.protocol.oidc.encode.TokenContextEncoderProvider;
import org.keycloak.rar.AuthorizationDetails;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.RefreshToken;
import org.keycloak.services.ErrorPage;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.ServicesLogger;
Expand Down Expand Up @@ -1529,13 +1530,21 @@ private static RequiredActionFactory toRequiredActionFactory(KeycloakSession ses

public static AuthResult verifyIdentityToken(KeycloakSession session, RealmModel realm, UriInfo uriInfo, ClientConnection connection, boolean checkActive, boolean checkTokenType,
String checkAudience, boolean isCookie, String tokenString, HttpHeaders headers, Consumer<TokenVerifier<AccessToken>> verifierConsumer) {
return verifyIdentityToken(session, realm, uriInfo, connection, checkActive, checkTokenType, checkAudience, isCookie, tokenString, false, headers, verifierConsumer);
}
public static AuthResult verifyIdentityToken(KeycloakSession session, RealmModel realm, UriInfo uriInfo, ClientConnection connection, boolean checkActive, boolean checkTokenType,
String checkAudience, boolean isCookie, String tokenString, boolean isRefreshToken, HttpHeaders headers, Consumer<TokenVerifier<AccessToken>> verifierConsumer) {
try {
TokenVerifier<AccessToken> verifier = TokenVerifier.create(tokenString, AccessToken.class)
@SuppressWarnings("unchecked")
TokenVerifier<AccessToken> verifier = (TokenVerifier<AccessToken>) (isRefreshToken ? TokenVerifier.create(tokenString, RefreshToken.class) : TokenVerifier.create(tokenString, AccessToken.class))
.withDefaultChecks()
.realmUrl(Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName()))
.checkActive(checkActive)
.checkTokenType(checkTokenType);

if(isRefreshToken){
verifier.tokenType(List.of(TokenUtil.TOKEN_TYPE_REFRESH));
}
verifierConsumer.accept(verifier);

if (checkAudience != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,56 @@ public void testScopeParamIncludedAudienceIncludedRefreshToken() throws Exceptio
}
}

@Test
public void testExchangeWithRefreshTokenAsSubject() throws Exception {
final UserRepresentation john = ApiUtil.findUserByUsername(adminClient.realm(TEST), "john");
oauth.realm(TEST);
final AccessTokenResponse loginResponse = resourceOwnerLogin("john", "password", "subject-client", "secret");
final String subjectRefreshToken = loginResponse.getRefreshToken();
assertNotNull(subjectRefreshToken);

AccessTokenResponse response = oauth.tokenExchangeRequest(subjectRefreshToken, OAuth2Constants.REFRESH_TOKEN_TYPE)
.client("requester-client", "secret")
.send();
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
assertEquals("Parameter 'subject_token' supports access tokens only", response.getErrorDescription());
events.clear();

try (ClientAttributeUpdater ignored = ClientAttributeUpdater.forClient(adminClient, TEST, "requester-client")
.setAttribute(OIDCConfigAttributes.STANDARD_TOKEN_EXCHANGE_REFRESH_AS_SUBJECT_ENABLED, Boolean.TRUE.toString())
.update()) {

response = oauth.tokenExchangeRequest(loginResponse.getAccessToken(), OAuth2Constants.REFRESH_TOKEN_TYPE)
.client("requester-client", "secret")
.send();
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
assertEquals("Invalid token", response.getErrorDescription());
events.clear();

response = oauth.tokenExchangeRequest(subjectRefreshToken, OAuth2Constants.REFRESH_TOKEN_TYPE)
.client("requester-client", "secret")
.audience(List.of("target-client3"))
.send();
assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatusCode());
assertEquals(OAuthErrorException.INVALID_REQUEST, response.getError());
assertEquals("Requested audience not available: target-client3", response.getErrorDescription());
events.clear();

response = oauth.tokenExchangeRequest(subjectRefreshToken, OAuth2Constants.REFRESH_TOKEN_TYPE)
.client("requester-client", "secret")
.send();
assertAudiencesAndScopes(response, john, List.of("target-client1"), List.of("default-scope1"));
assertEquals(OAuth2Constants.ACCESS_TOKEN_TYPE, response.getIssuedTokenType());
assertNotNull(response.getAccessToken());

final AccessToken exchangedToken = TokenVerifier.create(response.getAccessToken(), AccessToken.class).parse().getToken();
assertEquals(getSessionIdFromToken(loginResponse.getAccessToken()), exchangedToken.getSessionId());
assertEquals("requester-client", exchangedToken.getIssuedFor());
}
}

@Test
@UncaughtServerErrorExpected
public void testExchangeWithDynamicScopesEnabled() throws Exception {
Expand Down
0