+
Skip to content

FAPI 2.0 Final - only accept its issuer identifier value as a string in the aud claim received in client authentication assertions #41133

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

Merged
merged 1 commit into from
Jul 18, 2025
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.keycloak.services.clientpolicy.executor;

import jakarta.ws.rs.core.MultivaluedMap;
import org.jboss.logging.Logger;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.common.util.Base64Url;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.ClientPolicyExecutorConfigurationRepresentation;
import org.keycloak.services.Urls;
import org.keycloak.services.clientpolicy.ClientPolicyContext;
import org.keycloak.services.clientpolicy.ClientPolicyException;
import org.keycloak.util.JsonSerialization;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

/**
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public class SecureClientAuthenticationAssertionExecutor implements ClientPolicyExecutorProvider<ClientPolicyExecutorConfigurationRepresentation> {

private static final Logger logger = Logger.getLogger(SecureClientAuthenticationAssertionExecutor.class);

private final KeycloakSession session;

public SecureClientAuthenticationAssertionExecutor(KeycloakSession session) {
this.session = session;
}

@Override
public String getProviderId() {
return SecureClientAuthenticationAssertionExecutorFactory.PROVIDER_ID;
}

@Override
public void executeOnEvent(ClientPolicyContext context) throws ClientPolicyException {
switch (context.getEvent()) {
case PUSHED_AUTHORIZATION_REQUEST:
case TOKEN_REQUEST:
case TOKEN_REFRESH:
case TOKEN_INTROSPECT:
case TOKEN_REVOKE:
validateClientAssertion();
default:
}
}

private void validateClientAssertion() throws ClientPolicyException {
KeycloakContext context = session.getContext();
ClientModel client = context.getClient();

// a public client does not need to send its credential for client authentication
if (client.isPublicClient()) return;

MultivaluedMap<String, String> params = context.getHttpRequest().getDecodedFormParameters();
String clientAssertionType = params.getFirst(OAuth2Constants.CLIENT_ASSERTION_TYPE);
String clientAssertion = params.getFirst(OAuth2Constants.CLIENT_ASSERTION);

if (clientAssertionType == null || clientAssertion == null || !clientAssertionType.equals(OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT)) {
// if the client did not send client assertion, then skit the further validation.
return;
}

// Validate the client assertion format
String[] parts = clientAssertion.split("\\.");
if (parts.length < 2 || parts.length > 3) {
logger.warn("client assertion format error");
throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "invalid client assertion format");
}

// Decode the client assertion
String encodedContent = parts[1];
byte[] content = Base64Url.decode(encodedContent);
JsonWebToken token;
try {
token = JsonSerialization.readValue(content, JsonWebToken.class);
} catch (IOException e) {
logger.warnf("client assertion parse error: %s", e.getMessage());
throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "invalid client assertion");
}

// Validate the client assertion audience
String issuerUrl = Urls.realmIssuer(session.getContext().getUri().getBaseUri(), session.getContext().getRealm().getName());
List<String> expectedAudiences = new ArrayList<>(Collections.singletonList(issuerUrl));
if (token.getAudience() == null || token.getAudience().length != 1) {
logger.warnf("invalid audience in client assertion - no audience or multiple audiences found");
throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "invalid audience in client assertion");
}
if (!token.hasAnyAudience(expectedAudiences)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Does it makes sense to also verify here that issuerUrl is the only audience and there are not any other audiences included?

So maybe something like:

if (!token.hasAnyAudience(expectedAudiences) && token.getAudience().length == 1) {

In the past, there was security issue found in the private_key_jwt authentication that servers accepted the JWT with multiple audiences and same JWT token, which was used for client authentication, was also used for some other party. That is why the recent draft of OIDC core mentions explicitly single audience https://openid.net/specs/openid-connect-core-1_0-36.html#ClientAuthentication . The FAPI2 seems to not mention it explicitly for the server, however it indirectly mention this as well in the requirements for client, where it mentions - The issuer identifier value shall be sent as a string not as an item in an array. . .

I am not 100% sure if it is possible to reliably check that aud was sent as a string and not an array (also there is likely not any security benefit in that). However for better security, it makes sense to me to check that the issuer is single audience and not other audiences are included. WDYT?

Copy link
Contributor Author

@tnorimat tnorimat Jul 16, 2025

Choose a reason for hiding this comment

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

Thank you for your comments.

As you suggested, I will add the "aud" check.

I know the secuirty issue about specification and you have resolved that.
#38819
#38830

OIDF FAPI 2.0 Final conformance tests include the test for check whether "aud" claim value is single.
So, when running the conformance tests, I do the following settings.

spi-login-protocol-openid-connect-allow-multiple-audiences-for-jwt-client-authentication=true

When a keycloak user tries to apply FAPI 2.0 Final security profile, they need to do the setting, which checks the "aud" single value check, so I omitted the same check in the PR.

However, if the PR includes the "aud" check, then the user need not do the setting because the PR itself checks that. It might be helpful for the user.

In order to do that, what about the following?

        if (token.getAudience().length != 1) {
            logger.warnf("invalid audience in client assertion - no audience or multiple audiences found");
            throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "invalid audience in client assertion");
        }
        if (!token.hasAnyAudience(expectedAudiences)) {
            logger.warnf("invalid audience in client assertion - audience not issuer URL");
            throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "invalid audience in client assertion");
        }

Copy link
Contributor Author

@tnorimat tnorimat Jul 16, 2025

Choose a reason for hiding this comment

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

I modified the PR as just above and confirmed that Keycloak incoporating the PR can pass FAPI 2.0 Security Profile Final and FAPI 2.0 Message Singing Final conformance tests with both the following settings (current Keycloak's default setting):

spi-login-protocol-openid-connect-allow-multiple-audiences-for-jwt-client-authentication=true

and

#spi-login-protocol-openid-connect-allow-multiple-audiences-for-jwt-client-authentication=true

The PR was force-pushed.

Copy link
Contributor

@mposolda mposolda Jul 17, 2025

Choose a reason for hiding this comment

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

@tnorimat Probably a detail, but I afraid a bit of NPE when there are not any audience in the token as in this case token.getAudience().length might probably throw NPE? Is it ok to rather change the condition for length to if (token.getAudience() == null || token.getAudience().length != 1) ?

Another option is to make sure that length is checked after token.hasAudience call (as token.hasAudience would fail in case that token does not have any audience and hence no chance of NPE).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@mposolda Thank you. I will revise the PR as you suggested.

logger.warnf("invalid audience in client assertion - audience not issuer URL");
throw new ClientPolicyException(OAuthErrorException.INVALID_REQUEST, "invalid audience in client assertion");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright 2025 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.keycloak.services.clientpolicy.executor;

import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;

import java.util.Collections;
import java.util.List;

/**
* @author <a href="mailto:takashi.norimatsu.ws@hitachi.com">Takashi Norimatsu</a>
*/
public class SecureClientAuthenticationAssertionExecutorFactory implements ClientPolicyExecutorProviderFactory {
public static final String PROVIDER_ID = "secure-client-authentication-assertion";

@Override
public ClientPolicyExecutorProvider create(KeycloakSession session) {
return new SecureClientAuthenticationAssertionExecutor(session);
}

@Override
public void init(Config.Scope config) {
}

@Override
public void postInit(KeycloakSessionFactory factory) {
}

@Override
public void close() {
}

@Override
public String getId() {
return PROVIDER_ID;
}

@Override
public String getHelpText() {
return "This executor only accepts Keycloak's issuer URI as the aud claim value in a client authentication assertion ";
}

@Override
public List<ProviderConfigProperty> getConfigProperties() {
return Collections.emptyList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ org.keycloak.services.clientpolicy.executor.SamlSecureClientUrisExecutorFactory
org.keycloak.services.clientpolicy.executor.SamlAvoidRedirectBindingExecutorFactory
org.keycloak.services.clientpolicy.executor.SamlSignatureEnforcerExecutorFactory
org.keycloak.services.clientpolicy.executor.AuthenticationFlowSelectorExecutorFactory
org.keycloak.services.clientpolicy.executor.SecureClientAuthenticationAssertionExecutorFactory
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,10 @@
import org.keycloak.models.AdminRoles;
import org.keycloak.models.Constants;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.OIDCLoginProtocolFactory;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.RefreshToken;
Expand Down Expand Up @@ -483,6 +485,11 @@ private String getKeyAlgorithmFromJwaAlgorithm(String jwaAlgorithm) {

// Signed JWT for client authentication utility

protected void allowMultipleAudiencesForClientJWTOnServer(boolean allowMultipleAudiences) {
getTestingClient().testing().setSystemPropertyOnServer("oidc." + OIDCLoginProtocolFactory.CONFIG_OIDC_ALLOW_MULTIPLE_AUDIENCES_FOR_JWT_CLIENT_AUTHENTICATION, String.valueOf(allowMultipleAudiences));
getTestingClient().testing().reinitializeProviderFactoryWithSystemPropertiesScope(LoginProtocol.class.getName(), OIDCLoginProtocol.LOGIN_PROTOCOL, "oidc.");
}

protected String createSignedRequestToken(String clientId, PrivateKey privateKey, PublicKey publicKey, String algorithm) {
JsonWebToken jwt = createRequestToken(clientId, getRealmInfoUrl());
String kid = KeyUtils.createKeyId(publicKey);
Expand All @@ -495,12 +502,26 @@ private String getRealmInfoUrl() {
return KeycloakUriBuilder.fromUri(authServerBaseUrl).path(ServiceUrlConstants.REALM_INFO_PATH).build(REALM_NAME).toString();
}

private JsonWebToken createRequestToken(String clientId, String realmInfoUrl) {
protected JsonWebToken createRequestToken(String clientId, String realmInfoUrl) {
JsonWebToken reqToken = new JsonWebToken();
if (realmInfoUrl != null && !realmInfoUrl.isEmpty()) {
reqToken.audience(realmInfoUrl);
}
return createRequestToken(reqToken, clientId);
}

protected JsonWebToken createRequestToken(String clientId, String[] audienceUrls) {
JsonWebToken reqToken = new JsonWebToken();
if (audienceUrls != null && audienceUrls.length > 0) {
reqToken.audience(audienceUrls);
}
return createRequestToken(reqToken, clientId);
}

private JsonWebToken createRequestToken(JsonWebToken reqToken, String clientId) {
reqToken.id(KeycloakModelUtils.generateId());
reqToken.issuer(clientId);
reqToken.subject(clientId);
reqToken.audience(realmInfoUrl);

int now = Time.currentTime();
reqToken.iat((long) now);
Expand Down
Loading
Loading
点击 这是indexloc提供的php浏览器服务,不要输入任何密码和下载