-
Notifications
You must be signed in to change notification settings - Fork 8.3k
Initial experimental support for Resource Indicators #46763
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,15 +18,28 @@ | |
| package org.keycloak.representations; | ||
|
|
||
| import org.keycloak.TokenCategory; | ||
| import org.keycloak.json.StringOrArrayDeserializer; | ||
| import org.keycloak.json.StringOrArraySerializer; | ||
| import org.keycloak.util.TokenUtil; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonProperty; | ||
| import com.fasterxml.jackson.databind.annotation.JsonDeserialize; | ||
| import com.fasterxml.jackson.databind.annotation.JsonSerialize; | ||
|
|
||
|
|
||
| /** | ||
| * @author <a href="mailto:[email protected]">Bill Burke</a> | ||
| * @version $Revision: 1 $ | ||
| */ | ||
| public class RefreshToken extends AccessToken { | ||
|
|
||
| public static final String ORIGINAL_AUD = "aud_x"; | ||
|
|
||
| @JsonProperty(ORIGINAL_AUD) | ||
| @JsonSerialize(using = StringOrArraySerializer.class) | ||
| @JsonDeserialize(using = StringOrArrayDeserializer.class) | ||
| protected String[] originalAudience; | ||
|
|
||
| private RefreshToken() { | ||
| type(TokenUtil.TOKEN_TYPE_REFRESH); | ||
| } | ||
|
|
@@ -43,6 +56,7 @@ public RefreshToken(AccessToken token) { | |
| this.sessionId = token.sessionId; | ||
| this.nonce = token.nonce; | ||
| this.audience = new String[] { token.issuer }; | ||
| this.originalAudience = token.audience; | ||
| this.scope = token.scope; | ||
| this.authorizationDetails = token.authorizationDetails; | ||
| } | ||
|
|
@@ -70,4 +84,8 @@ public String getSessionId() { | |
| // Fallback as offline tokens created in Keycloak 14 or earlier have only the "session_state" claim, but not "sid" | ||
| return sessionId != null ? sessionId : (String) getOtherClaims().get(IDToken.SESSION_STATE); | ||
| } | ||
|
|
||
| public String[] getOriginalAudience() { | ||
| return originalAudience; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| package org.keycloak.protocol.oidc.token; | ||
|
|
||
| import org.keycloak.OAuth2Constants; | ||
| import org.keycloak.OAuthErrorException; | ||
| import org.keycloak.models.ClientModel; | ||
| import org.keycloak.models.Constants; | ||
| import org.keycloak.models.KeycloakSession; | ||
|
|
||
| public class ResourceIndicatorsPostProcessor implements TokenPostProcessor { | ||
|
|
||
| public static final String ERROR_NOT_MATCHING = "The requested resource is not matching the original request."; | ||
| public static final String ERROR_INVALID_RESOURCE = "The requested resource is invalid, missing, unknown, or malformed."; | ||
| public static final String URN_CLIENT_PREFIX = "urn:client:"; | ||
| public static final String CLIENT_RESOURCE_URL_ATTRIBUTE = "resource_url"; | ||
|
|
||
| private final KeycloakSession session; | ||
|
|
||
| public ResourceIndicatorsPostProcessor(KeycloakSession session) { | ||
| this.session = session; | ||
| } | ||
|
|
||
| @Override | ||
| public void process(TokenPostProcessorContext context) { | ||
| String requestedResource = context.clientSessionCtx().getAttribute(OAuth2Constants.RESOURCE, String.class); | ||
| if (requestedResource == null) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Probably the config option to enable the resource at client level should be checked here in the first PR. Note that if enabled, all URLs should be returned for the client if the resource passed is null, or some default URLs at least, not null.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| return; | ||
| } | ||
|
|
||
| String grantType = context.clientSessionCtx().getAttribute(Constants.GRANT_TYPE, String.class); | ||
|
|
||
| if (grantType.equals(OAuth2Constants.REFRESH_TOKEN)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Regarding the refreshToken request, there is the possible issue that As for example when accessToken will have multiple audiences after processing of protocol mappers (EG. Just a note that this issue is not visible in your automated tests as they use single audience
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wasn't initially planning on including refresh token request in this PR at all, but ended up adding it, but it's not completed. I forgot to add a follow-up task around this and added #47180 now to capture this case. Would it be okay to handle it as a follow-up?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, follow-up is ok |
||
| if (isClientUrn(requestedResource)) { | ||
| if (findAudienceByClientUrn(requestedResource, context.refreshToken().getOriginalAudience()) == null) { | ||
| throw new TokenInterceptorException(OAuthErrorException.INVALID_TARGET, ERROR_NOT_MATCHING); | ||
| } | ||
| } else { | ||
| if (find(requestedResource, context.refreshToken().getOriginalAudience()) == null) { | ||
| throw new TokenInterceptorException(OAuthErrorException.INVALID_TARGET, ERROR_NOT_MATCHING); | ||
| } | ||
| } | ||
| } else { | ||
| String audienceToSet; | ||
|
|
||
| String originalResourceParam = context.clientSessionCtx().getClientSession().getNote(OAuth2Constants.RESOURCE); | ||
| if (originalResourceParam != null && !originalResourceParam.equals(requestedResource)) { | ||
| throw new TokenInterceptorException(OAuthErrorException.INVALID_TARGET, ERROR_NOT_MATCHING); | ||
| } | ||
|
|
||
| if (isClientUrn(requestedResource)) { | ||
| audienceToSet = findAudienceByClientUrn(requestedResource, context.accessToken().getAudience()); | ||
| } else { | ||
| audienceToSet = findAudienceByClientAttribute(requestedResource, context.accessToken().getAudience()); | ||
| } | ||
|
|
||
| if (audienceToSet == null) { | ||
| throw new TokenInterceptorException(OAuthErrorException.INVALID_TARGET, ERROR_INVALID_RESOURCE); | ||
| } | ||
|
|
||
| context.accessToken().audience(audienceToSet); | ||
| } | ||
| } | ||
|
|
||
| private boolean isClientUrn(String resource) { | ||
| return resource.startsWith(URN_CLIENT_PREFIX); | ||
| } | ||
|
|
||
| private String findAudienceByClientUrn(String resource, String[] audience) { | ||
| String requestedClientId = resource.substring(URN_CLIENT_PREFIX.length()); | ||
| return find(requestedClientId, audience); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @stianst This means when resource
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, so the thinking was that |
||
| } | ||
|
|
||
| private String findAudienceByClientAttribute(String resource, String[] audience) { | ||
| for (String a : audience) { | ||
| ClientModel client = session.clients().getClientByClientId(session.getContext().getRealm(), a); | ||
| if (client != null) { | ||
| String clientResourceUrl = client.getAttribute(CLIENT_RESOURCE_URL_ATTRIBUTE); | ||
| if (clientResourceUrl.equals(resource)) { | ||
| return resource; | ||
| } | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| private String find(String search, String[] array) { | ||
| for (String a : array) { | ||
| if (a.equals(search)) { | ||
| return a; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| package org.keycloak.protocol.oidc.token; | ||
|
|
||
| import org.keycloak.Config; | ||
| import org.keycloak.common.Profile; | ||
| import org.keycloak.models.KeycloakSession; | ||
| import org.keycloak.provider.EnvironmentDependentProviderFactory; | ||
|
|
||
| public class ResourceIndicatorsPostProcessorFactory implements TokenPostProcessorFactory, EnvironmentDependentProviderFactory { | ||
|
|
||
| public static final String ID = "resource-indicators"; | ||
|
|
||
| @Override | ||
| public TokenPostProcessor create(KeycloakSession session) { | ||
| return new ResourceIndicatorsPostProcessor(session); | ||
| } | ||
|
|
||
| @Override | ||
| public String getId() { | ||
| return ID; | ||
| } | ||
|
|
||
| @Override | ||
| public boolean isSupported(Config.Scope config) { | ||
| return Profile.isFeatureEnabled(Profile.Feature.RESOURCE_INDICATORS); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package org.keycloak.protocol.oidc.token; | ||
|
|
||
| public class TokenInterceptorException extends RuntimeException { | ||
|
|
||
| private String error; | ||
| private String description; | ||
|
|
||
| public TokenInterceptorException(String error, String description) { | ||
| this.error = error; | ||
| this.description = description; | ||
| } | ||
|
|
||
| public String getError() { | ||
| return error; | ||
| } | ||
|
|
||
| public String getDescription() { | ||
| return description; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package org.keycloak.protocol.oidc.token; | ||
|
|
||
| import org.keycloak.provider.Provider; | ||
| import org.keycloak.provider.ProviderFactory; | ||
| import org.keycloak.provider.Spi; | ||
|
|
||
| public class TokenInterceptorSpi implements Spi { | ||
|
|
||
| @Override | ||
| public boolean isInternal() { | ||
| return true; | ||
| } | ||
|
|
||
| @Override | ||
| public String getName() { | ||
| return "token-interceptor"; | ||
| } | ||
|
|
||
| @Override | ||
| public Class<? extends Provider> getProviderClass() { | ||
| return TokenPostProcessor.class; | ||
| } | ||
|
|
||
| @Override | ||
| public Class<? extends ProviderFactory> getProviderFactoryClass() { | ||
| return TokenPostProcessorFactory.class; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here we need to check the resource parameter following the spec.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Need to find a way to verify the resource parameter properly, but it's not straightforward as there are multiple places that will set the resource parameter as well as I'd like to extract as much as possible into a separate provider. I'd like to handle this as a follow-up in #47116