Skip to content
Merged
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
4 changes: 3 additions & 1 deletion common/src/main/java/org/keycloak/common/Profile.java
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,9 @@ public enum Feature {
@Deprecated
INSTAGRAM_BROKER("Instagram Identity Broker", Type.DEPRECATED, 1),

SCIM_API("Exposes a SCIM API for managing realm resources on a per-realm basis", Type.EXPERIMENTAL);
SCIM_API("Exposes a SCIM API for managing realm resources on a per-realm basis", Type.EXPERIMENTAL),

RESOURCE_INDICATORS("Resource Indicators for OAuth 2.0", Type.EXPERIMENTAL);

private final Type type;
private final String label;
Expand Down
3 changes: 3 additions & 0 deletions core/src/main/java/org/keycloak/OAuthErrorException.java
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ public class OAuthErrorException extends Exception {
// DPoP
public static final String INVALID_DPOP_PROOF = "invalid_dpop_proof";

// Resource Indicators
public static final String INVALID_TARGET = "invalid_target";

// Others
public static final String INVALID_CLIENT = "invalid_client";
public static final String INVALID_GRANT = "invalid_grant";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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
Expand Up @@ -82,6 +82,7 @@ public class OIDCLoginProtocol implements LoginProtocol {
public static final String LOGIN_PROTOCOL = Constants.OIDC_PROTOCOL;
public static final String STATE_PARAM = "state";
public static final String SCOPE_PARAM = "scope";
public static final String RESOURCE_PARAM = OAuth2Constants.RESOURCE;
public static final String CODE_PARAM = "code";
public static final String RESPONSE_TYPE_PARAM = "response_type";
public static final String GRANT_TYPE_PARAM = "grant_type";
Expand Down Expand Up @@ -262,6 +263,7 @@ public Response authenticated(AuthenticationSessionModel authSession, UserSessio
Time.currentTime() + userSession.getRealm().getAccessCodeLifespan(),
nonce,
authSession.getClientNote(OAuth2Constants.SCOPE),
authSession.getClientNote(OAuth2Constants.RESOURCE),
authSession.getClientNote(OIDCLoginProtocol.REDIRECT_URI_PARAM),
authSession.getClientNote(OIDCLoginProtocol.CODE_CHALLENGE_PARAM),
authSession.getClientNote(OIDCLoginProtocol.CODE_CHALLENGE_METHOD_PARAM),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@
import org.keycloak.protocol.oidc.mappers.OIDCIDTokenMapper;
import org.keycloak.protocol.oidc.mappers.TokenIntrospectionTokenMapper;
import org.keycloak.protocol.oidc.mappers.UserInfoTokenMapper;
import org.keycloak.protocol.oidc.token.TokenPostProcessor;
import org.keycloak.protocol.oidc.token.TokenPostProcessorContext;
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
import org.keycloak.rar.AuthorizationDetails;
import org.keycloak.rar.AuthorizationRequestContext;
Expand Down Expand Up @@ -304,7 +306,7 @@ public static UserModel lookupUserFromStatelessToken(KeycloakSession session, Re


public AccessTokenResponseBuilder refreshAccessToken(KeycloakSession session, UriInfo uriInfo, ClientConnection connection, RealmModel realm, ClientModel authorizedClient,
String encodedRefreshToken, EventBuilder event, HttpHeaders headers, HttpRequest request, String scopeParameter) throws OAuthErrorException {
String encodedRefreshToken, EventBuilder event, HttpHeaders headers, HttpRequest request, String scopeParameter, String resourceParameter) throws OAuthErrorException {
RefreshToken refreshToken = verifyRefreshToken(session, realm, authorizedClient, request, encodedRefreshToken, true);

if (realm.isRevokeRefreshToken()) {
Expand Down Expand Up @@ -361,6 +363,8 @@ public AccessTokenResponseBuilder refreshAccessToken(KeycloakSession session, Ur
.toArray(ClientModel[]::new));
}

validation.clientSessionCtx.setAttribute(OAuth2Constants.RESOURCE, resourceParameter);

AccessTokenResponseBuilder responseBuilder = responseBuilder(realm, authorizedClient, event, session,
validation.userSession, validation.clientSessionCtx).offlineToken( TokenUtil.TOKEN_TYPE_OFFLINE.equals(refreshToken.getType())).accessToken(validation.newToken);

Expand Down Expand Up @@ -1370,7 +1374,14 @@ public boolean isOfflineToken() {
return offlineToken;
}

private void invokeTokenPostProcessors() {
TokenPostProcessorContext context = new TokenPostProcessorContext(refreshToken, accessToken, clientSessionCtx);
session.getAllProviders(TokenPostProcessor.class).forEach(processor -> processor.process(context));
}

public AccessTokenResponse build() {
invokeTokenPostProcessors();

if (response != null) return response;

if (accessToken != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ public static void performActionOnParameters(AuthorizationEndpointRequest reques
paramAction.accept(OIDCLoginProtocol.PROMPT_PARAM, request.getPrompt());
paramAction.accept(OIDCLoginProtocol.RESPONSE_MODE_PARAM, request.getResponseMode());
paramAction.accept(OIDCLoginProtocol.SCOPE_PARAM, request.getScope());
paramAction.accept(OIDCLoginProtocol.RESOURCE_PARAM, request.getResource());
paramAction.accept(OIDCLoginProtocol.STATE_PARAM, request.getState());
paramAction.accept(OIDCLoginProtocol.DPOP_JKT, request.getDpopJkt());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.grants.OAuth2GrantType;
import org.keycloak.protocol.oidc.token.TokenInterceptorException;
import org.keycloak.protocol.oidc.utils.AuthorizeClientUtil;
import org.keycloak.protocol.saml.JaxrsSAML2BindingBuilder;
import org.keycloak.protocol.saml.SamlClient;
Expand Down Expand Up @@ -148,7 +149,12 @@ public Response processGrantRequest() {

OAuth2GrantType.Context context = new OAuth2GrantType.Context(session, clientConfig, clientAuthAttributes,
formParams, event, cors, tokenManager);
return grant.process(context);

try {
return grant.process(context);
} catch (TokenInterceptorException e) {
throw new CorsErrorResponseException(cors, e.getError(), e.getDescription(), Response.Status.BAD_REQUEST);
}
}

@Path("introspect")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public class AuthorizationEndpointRequest {
String responseMode;
String state;
String scope;
String resource;
String loginHint;
String display;
String prompt;
Expand Down Expand Up @@ -93,6 +94,10 @@ public String getScope() {
return scope;
}

public String getResource() {
return resource;
}

public String getLoginHint() {
return loginHint;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ public abstract class AuthzEndpointRequestParser {
KNOWN_REQ_PARAMS.add(OIDCLoginProtocol.REDIRECT_URI_PARAM);
KNOWN_REQ_PARAMS.add(OIDCLoginProtocol.STATE_PARAM);
KNOWN_REQ_PARAMS.add(OIDCLoginProtocol.SCOPE_PARAM);
KNOWN_REQ_PARAMS.add(OIDCLoginProtocol.RESOURCE_PARAM);
KNOWN_REQ_PARAMS.add(OIDCLoginProtocol.LOGIN_HINT_PARAM);
KNOWN_REQ_PARAMS.add(OIDCLoginProtocol.PROMPT_PARAM);
KNOWN_REQ_PARAMS.add(AdapterConstants.KC_IDP_HINT);
Expand Down Expand Up @@ -132,6 +133,7 @@ public void parseRequest(AuthorizationEndpointRequest request) {
request.redirectUriParam = replaceIfNotNull(request.redirectUriParam, getAndValidateParameter(OIDCLoginProtocol.REDIRECT_URI_PARAM));
request.state = replaceIfNotNull(request.state, getAndValidateParameter(OIDCLoginProtocol.STATE_PARAM));
request.scope = replaceIfNotNull(request.scope, getAndValidateParameter(OIDCLoginProtocol.SCOPE_PARAM));
request.resource = replaceIfNotNull(request.resource, getAndValidateParameter(OIDCLoginProtocol.RESOURCE_PARAM));
request.loginHint = replaceIfNotNull(request.loginHint, getAndValidateParameter(OIDCLoginProtocol.LOGIN_HINT_PARAM));
request.prompt = replaceIfNotNull(request.prompt, getAndValidateParameter(OIDCLoginProtocol.PROMPT_PARAM));
request.idpHint = replaceIfNotNull(request.idpHint, getAndValidateParameter(AdapterConstants.KC_IDP_HINT));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ protected void setContext(Context context) {

protected TokenManager.AccessTokenResponseBuilder createTokenResponseBuilder(UserModel user, UserSessionModel userSession, ClientSessionContext clientSessionCtx, String scopeParam, Function<TokenManager.AccessTokenResponseBuilder, ClientPolicyContext> clientPolicyContextGenerator) {
clientSessionCtx.setAttribute(Constants.GRANT_TYPE, context.getGrantType());
clientSessionCtx.setAttribute(OAuth2Constants.RESOURCE, formParams.getFirst(OAuth2Constants.RESOURCE));
Copy link
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
Contributor Author

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

AccessToken token = tokenManager.createClientAccessToken(session, realm, client, user, userSession, clientSessionCtx);

TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public Response process(Context context) {
}

String scopeParameter = getRequestedScopes();
String resourceParameter = formParams.getFirst(OAuth2Constants.RESOURCE);

try {
session.clientPolicy().triggerOnEvent(new TokenRefreshContext(formParams, client));
Expand All @@ -72,7 +73,7 @@ public Response process(Context context) {
AccessTokenResponse res;
try {
// KEYCLOAK-6771 Certificate Bound Token
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.refreshAccessToken(session, session.getContext().getUri(), clientConnection, realm, client, refreshToken, event, headers, request, scopeParameter);
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.refreshAccessToken(session, session.getContext().getUri(), clientConnection, realm, client, refreshToken, event, headers, request, scopeParameter, resourceParameter);

checkAndBindMtlsHoKToken(responseBuilder, clientConfig.isUseRefreshToken());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ public Response process(Context context) {

ClientSessionContext clientSessionCtx = processor.attachSession();
clientSessionCtx.setAttribute(Constants.GRANT_TYPE, context.getGrantType());
clientSessionCtx.setAttribute(OAuth2Constants.RESOURCE, formParams.getFirst(OAuth2Constants.RESOURCE));
UserSessionModel userSession = processor.getUserSession();
updateUserSessionFromClientAuth(userSession);

Expand Down
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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As long as the token has some audience and at least one of the audience has a resource url set, that would be the default.

Added a follow-up to revisit this more properly in #47126, but since I'm not 100% sure how that should look like I'd rather leave it as is for now, and revisit in the #47126

return;
}

String grantType = context.clientSessionCtx().getAttribute(Constants.GRANT_TYPE, String.class);

if (grantType.equals(OAuth2Constants.REFRESH_TOKEN)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding the refreshToken request, there is the possible issue that originalAudience from refresh token is not propagated back to the newly issued access-token in this PR.

As for example when accessToken will have multiple audiences after processing of protocol mappers (EG. theservice and theservice2), then the refreshed access token will still contain both of these audiences instead of the "original audience" from refresh-token. This is not the desired behaviour IMO?

Just a note that this issue is not visible in your automated tests as they use single audience theservice to be in the access-token after the protocol mappers processing, so it seems it is not tested that some undesired audiences should be filtered from the access token.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stianst This means when resource urn:client:xxxx the audience is just set to xxxx, right? yes, I see the same in the tests. For me it's OK although it surprised me initially.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, so the thinking was that urn:client was a special case to support filtering audiences as is. We pretty much know that no clients will be expecting the special urn:client: in the audience, but at the same time it needs to be a URI in the resource parameter. Will make sure to document this as I agree it may be a bit unexpected. Added a note to #47127 to make sure it's covered in the docs.

}

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;
}
}
Loading
Loading