Experimental federated client authentication

Closes #42228

Signed-off-by: stianst <stianst@gmail.com>
This commit is contained in:
stianst 2025-08-29 12:25:35 +02:00 committed by Pedro Igor
parent 624d236ced
commit 57242d2497
22 changed files with 872 additions and 17 deletions

View File

@ -91,6 +91,8 @@ public class Profile {
STEP_UP_AUTHENTICATION("Step-up Authentication", Type.DEFAULT),
CLIENT_AUTH_FEDERATED("Authenticates client based on assertions issued by identity provider", Type.EXPERIMENTAL),
// Check if kerberos is available in underlying JVM and auto-detect if feature should be enabled or disabled by default based on that
KERBEROS("Kerberos", Type.DEFAULT, 1, () -> KerberosJdkProvider.getProvider().isKerberosAvailable()),

View File

@ -43,6 +43,10 @@ public class CryptoIntegration {
}
}
public static boolean isInitialised() {
return cryptoProvider != null;
}
public static CryptoProvider getProvider() {
if (cryptoProvider == null) {
throw new IllegalStateException("Illegal state. Please init first before obtaining provider");

View File

@ -3230,6 +3230,8 @@ organizationDetails=Organization details
organizationsList=Organizations
caseSensitiveOriginalUsername=Case-sensitive username
caseSensitiveOriginalUsernameHelp=If enabled, the original username from the identity provider is kept as is when federating users. Otherwise, the username from the identity provider is lower-cased and might not match the original value if it is case-sensitive. This setting only affects the username associated with the federated identity as usernames in the server are always in lower-case.
supportsClientAssertions=Supports client assertions
supportsClientAssertionReuse=Allows client assertions to be re-used
organizationsExplain=Manage your organizations and members.
emptyOrganizations=No organizations
emptyOrganizationsInstructions=There is no organization yet. Please create an organization and manage it.

View File

@ -122,6 +122,9 @@ export const AdvancedSettings = ({
const claimFilterRequired = filteredByClaim === "true";
const isFeatureEnabled = useIsFeatureEnabled();
const isTransientUsersEnabled = isFeatureEnabled(Feature.TransientUsers);
const isClientAuthFederatedEnabled = isFeatureEnabled(
Feature.ClientAuthFederated,
);
const transientUsers = useWatch({
control,
name: "config.doNotStoreUsers",
@ -311,6 +314,18 @@ export const AdvancedSettings = ({
field="config.caseSensitiveOriginalUsername"
label="caseSensitiveOriginalUsername"
/>
{isClientAuthFederatedEnabled && isOIDC && (
<>
<SwitchField
field="config.supportsClientAssertions"
label="supportsClientAssertions"
/>
<SwitchField
field="config.supportsClientAssertionReuse"
label="supportsClientAssertionReuse"
/>
</>
)}
</>
);
};

View File

@ -17,6 +17,7 @@ export enum Feature {
QuickTheme = "QUICK_THEME",
StandardTokenExchangeV2 = "TOKEN_EXCHANGE_STANDARD_V2",
Passkeys = "PASSKEYS",
ClientAuthFederated = "CLIENT_AUTH_FEDERATED",
}
export default function useIsFeatureEnabled() {

View File

@ -0,0 +1,18 @@
package org.keycloak.broker.provider;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.ClientModel;
import org.keycloak.representations.JsonWebToken;
public interface ClientAssertionContext {
String getAssertionType();
JWSInput getJwsInput();
JsonWebToken getToken();
ClientModel getClient();
boolean isFailure();
String getError();
boolean failure(String error);
}

View File

@ -0,0 +1,7 @@
package org.keycloak.broker.provider;
public interface ClientAssertionIdentityProvider {
boolean verifyClientAssertion(ClientAssertionContext context);
}

View File

@ -506,6 +506,15 @@ public class DefaultAuthenticationFlows {
execution.setAuthenticatorFlow(false);
realm.addAuthenticatorExecution(execution);
if (Profile.isFeatureEnabled(Feature.CLIENT_AUTH_FEDERATED)) {
execution = new AuthenticationExecutionModel();
execution.setParentFlow(clients.getId());
execution.setRequirement(AuthenticationExecutionModel.Requirement.ALTERNATIVE);
execution.setAuthenticator("federated-jwt");
execution.setPriority(50);
execution.setAuthenticatorFlow(false);
realm.addAuthenticatorExecution(execution);
}
}
public static void firstBrokerLoginFlow(RealmModel realm, boolean migrate) {

View File

@ -0,0 +1,209 @@
package org.keycloak.authentication.authenticators.client;
import jakarta.ws.rs.core.MultivaluedMap;
import org.jboss.logging.Logger;
import org.keycloak.Config;
import org.keycloak.OAuth2Constants;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.ClientAuthenticationFlowContext;
import org.keycloak.authentication.ConfigurableAuthenticatorFactory;
import org.keycloak.broker.provider.ClientAssertionContext;
import org.keycloak.broker.provider.ClientAssertionIdentityProvider;
import org.keycloak.broker.provider.IdentityProvider;
import org.keycloak.common.Profile;
import org.keycloak.events.Details;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.resources.IdentityBrokerService;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class FederatedJWTClientAuthenticator extends AbstractClientAuthenticator implements EnvironmentDependentProviderFactory {
private static final Logger LOGGER = Logger.getLogger(FederatedJWTClientAuthenticator.class);
public static final String PROVIDER_ID = "federated-jwt";
public static final String JWT_CREDENTIAL_ISSUER_KEY = "jwt.credential.issuer";
private static final List<ProviderConfigProperty> CONFIG = List.of(
new ProviderConfigProperty(JWT_CREDENTIAL_ISSUER_KEY, "Identity provider", "Issuer of the client assertion", ProviderConfigProperty.STRING_TYPE, null)
);
private static final Set<String> SUPPORTED_ASSERTION_TYPES = Set.of(OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT);
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
// TODO Should share code with JWTClientAuthenticator/JWTClientValidator rather than duplicating, but that requires quite a bit of refactoring
public void authenticateClient(ClientAuthenticationFlowContext context) {
try {
MultivaluedMap<String, String> params = context.getHttpRequest().getDecodedFormParameters();
String clientAssertionType = params.getFirst(OAuth2Constants.CLIENT_ASSERTION_TYPE);
if (!SUPPORTED_ASSERTION_TYPES.contains(clientAssertionType)) {
return;
}
String clientAssertion = params.getFirst(OAuth2Constants.CLIENT_ASSERTION);
if (clientAssertion == null) {
return;
}
JWSInput jws = new JWSInput(clientAssertion);
JsonWebToken token = jws.readJsonContent(JsonWebToken.class);
context.getEvent().detail(Details.CLIENT_ASSERTION_ID, token.getId());
context.getEvent().detail(Details.CLIENT_ASSERTION_ISSUER, token.getIssuer());
ClientModel client = lookupClient(context, token.getSubject());
if (client == null) return;
ClientAssertionIdentityProvider identityProvider = lookupIdentityProvider(context, client);
ClientAssertionContext clientAssertionContext = new DefaultClientAssertionContext(client, clientAssertionType, jws, token);
if (identityProvider.verifyClientAssertion(clientAssertionContext)) {
context.success();
} else {
LOGGER.warnv("Failed to authenticate client: {0}", clientAssertionContext.getError());
context.failure(AuthenticationFlowError.INVALID_CLIENT_CREDENTIALS);
}
} catch (Exception e) {
LOGGER.warn("Authentication failed", e);
context.failure(AuthenticationFlowError.INVALID_CLIENT_CREDENTIALS);
}
}
private ClientModel lookupClient(ClientAuthenticationFlowContext context, String subject) {
ClientModel client = context.getRealm().getClientByClientId(subject);
if (client == null) {
context.failure(AuthenticationFlowError.CLIENT_NOT_FOUND);
return null;
}
if (!client.isEnabled()) {
context.failure(AuthenticationFlowError.CLIENT_DISABLED);
return null;
}
context.setClient(client);
context.getEvent().client(client);
context.getEvent().detail(Details.CLIENT_ASSERTION_SUB, subject);
return client;
}
private ClientAssertionIdentityProvider lookupIdentityProvider(ClientAuthenticationFlowContext context, ClientModel client) {
String idpAlias = client.getAttribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY);
IdentityProvider<?> identityProvider = IdentityBrokerService.getIdentityProvider(context.getSession(), idpAlias);
if (identityProvider instanceof ClientAssertionIdentityProvider clientAssertionProvider) {
return clientAssertionProvider;
} else {
throw new RuntimeException("Provider does not support client assertions");
}
}
@Override
public String getDisplayType() {
return "Signed JWT - Federated";
}
@Override
public String getHelpText() {
return "Validates client based on signed JWT issued and signed by an external identity provider";
}
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return ConfigurableAuthenticatorFactory.REQUIREMENT_CHOICES;
}
@Override
public boolean isConfigurable() {
return false;
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return Collections.emptyList();
}
@Override
public List<ProviderConfigProperty> getConfigPropertiesPerClient() {
return CONFIG;
}
@Override
public Map<String, Object> getAdapterConfiguration(ClientModel client) {
return Collections.emptyMap();
}
@Override
public Set<String> getProtocolAuthenticatorMethods(String loginProtocol) {
return Collections.emptySet();
}
@Override
public boolean isSupported(Config.Scope config) {
return Profile.isFeatureEnabled(Profile.Feature.CLIENT_AUTH_FEDERATED);
}
private static class DefaultClientAssertionContext implements ClientAssertionContext {
private final ClientModel client;
private final String assertionType;
private final JWSInput jwsInput;
private final JsonWebToken token;
private String error;
public DefaultClientAssertionContext(ClientModel client, String assertionType, JWSInput jwsInput, JsonWebToken token) {
this.client = client;
this.assertionType = assertionType;
this.jwsInput = jwsInput;
this.token = token;
}
@Override
public String getAssertionType() {
return assertionType;
}
@Override
public JWSInput getJwsInput() {
return jwsInput;
}
@Override
public JsonWebToken getToken() {
return token;
}
@Override
public ClientModel getClient() {
return client;
}
@Override
public boolean isFailure() {
return error != null;
}
@Override
public String getError() {
return error;
}
@Override
public boolean failure(String error) {
this.error = error;
return false;
}
}
}

View File

@ -133,7 +133,8 @@ public class JWTClientValidator {
}
if (!clientId.equals(token.getIssuer())) {
throw new RuntimeException("Issuer mismatch. The issuer should match the subject");
logger.debug("Token not issued by client, ignoring.");
return false;
}
String clientIdParam = params.getFirst(OAuth2Constants.CLIENT_ID);

View File

@ -17,16 +17,27 @@
package org.keycloak.broker.oidc;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo;
import org.jboss.logging.Logger;
import org.keycloak.OAuth2Constants;
import org.keycloak.OAuthErrorException;
import org.keycloak.broker.oidc.mappers.AbstractJsonUserAttributeMapper;
import org.keycloak.broker.provider.AuthenticationRequest;
import org.keycloak.broker.provider.BrokeredIdentityContext;
import org.keycloak.broker.provider.ClientAssertionContext;
import org.keycloak.broker.provider.ClientAssertionIdentityProvider;
import org.keycloak.broker.provider.ExchangeExternalToken;
import org.keycloak.broker.provider.IdentityBrokerException;
import org.keycloak.broker.provider.util.SimpleHttp;
import org.keycloak.common.Profile;
import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.common.util.Time;
@ -50,6 +61,7 @@ import org.keycloak.models.ClientModel;
import org.keycloak.models.FederatedIdentityModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.SingleUseObjectProvider;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
@ -59,6 +71,7 @@ import org.keycloak.representations.IDToken;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.services.ErrorPage;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.Urls;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.IdentityBrokerService;
@ -68,16 +81,6 @@ import org.keycloak.util.JsonSerialization;
import org.keycloak.util.TokenUtil;
import org.keycloak.vault.VaultStringSecret;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
@ -90,7 +93,7 @@ import java.util.Optional;
/**
* @author Pedro Igor
*/
public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIdentityProviderConfig> implements ExchangeExternalToken {
public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIdentityProviderConfig> implements ExchangeExternalToken, ClientAssertionIdentityProvider {
protected static final Logger logger = Logger.getLogger(OIDCIdentityProvider.class);
public static final String SCOPE_OPENID = "openid";
@ -1039,4 +1042,74 @@ public class OIDCIdentityProvider extends AbstractOAuth2IdentityProvider<OIDCIde
return Boolean.valueOf(emailVerified.toString());
}
@Override
public boolean verifyClientAssertion(ClientAssertionContext context) {
if (!Profile.isFeatureEnabled(Profile.Feature.CLIENT_AUTH_FEDERATED)) {
return false;
}
if (!getConfig().isSupportsClientAssertions()) {
return context.failure("Issuer does not support client assertions");
}
if (!getConfig().isValidateSignature()) {
return context.failure("Signature validation not enabled for issuer");
}
if (!context.getAssertionType().equals(OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT)) {
return false;
}
JWSInput jws = context.getJwsInput();
JsonWebToken token = context.getToken();
ClientModel client = context.getClient();
if (!verify(jws)) {
return context.failure("Invalid signature");
}
if (token.getIssuer() == null) {
return context.failure("Token issuer required");
}
if (!token.getIssuer().equals(getConfig().getIssuer())) {
return context.failure("Invalid token issuer");
}
String expectedAudience = Urls.realmIssuer(session.getContext().getUri().getBaseUri(), session.getContext().getRealm().getName());
int allowedClockSkew = getConfig().getAllowedClockSkew();
if (token.getExp() == null || token.getExp() <= 0) {
return context.failure("Token does not contain an expiration");
}
if (!(token.getAudience().length == 1 && token.getAudience()[0].equals(expectedAudience))) {
return context.failure("Invalid audience");
}
if (!token.isActive(allowedClockSkew)) {
return context.failure("Token not active");
}
if (token.getIat() != null && token.getIat() > 0 && token.getIat() - allowedClockSkew > Time.currentTime()) {
return context.failure("Token was issued in the future");
}
if (!(getConfig().isSupportsClientAssertionReuse())) {
if (token.getId() == null) {
return context.failure("Token id required");
}
SingleUseObjectProvider singeUseCache = session.singleUseObjects();
long lifespanInSecs = token.getExp() + allowedClockSkew - Time.currentTime();
if (singeUseCache.putIfAbsent(token.getId(), lifespanInSecs)) {
logger.tracef("Added token '%s' to single-use cache. Lifespan: %d seconds, client: %s", token.getId(), lifespanInSecs, client.getClientId());
} else {
logger.warnf("Token '%s' already used when authenticating client '%s'.", token.getId(), client.getClientId());
return context.failure("Token already used");
}
}
return true;
}
}

View File

@ -33,6 +33,8 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig {
public static final String VALIDATE_SIGNATURE = "validateSignature";
public static final String IS_ACCESS_TOKEN_JWT = "isAccessTokenJWT";
public static final String ISSUER = "issuer";
public static final String SUPPORTS_CLIENT_ASSERTIONS = "supportsClientAssertions";
public static final String SUPPORTS_CLIENT_ASSERTION_REUSE = "supportsClientAssertionReuse";
public OIDCIdentityProviderConfig(IdentityProviderModel identityProviderModel) {
super(identityProviderModel);
@ -180,6 +182,14 @@ public class OIDCIdentityProviderConfig extends OAuth2IdentityProviderConfig {
}
}
public boolean isSupportsClientAssertions() {
return Boolean.parseBoolean(getConfig().get(SUPPORTS_CLIENT_ASSERTIONS));
}
public boolean isSupportsClientAssertionReuse() {
return Boolean.parseBoolean(getConfig().get(SUPPORTS_CLIENT_ASSERTION_REUSE));
}
@Override
public void validate(RealmModel realm) {
super.validate(realm);

View File

@ -18,4 +18,5 @@
org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator
org.keycloak.authentication.authenticators.client.JWTClientAuthenticator
org.keycloak.authentication.authenticators.client.JWTClientSecretAuthenticator
org.keycloak.authentication.authenticators.client.X509ClientAuthenticator
org.keycloak.authentication.authenticators.client.X509ClientAuthenticator
org.keycloak.authentication.authenticators.client.FederatedJWTClientAuthenticator

View File

@ -0,0 +1,113 @@
package org.keycloak.testframework.oauth;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import org.keycloak.common.crypto.CryptoIntegration;
import org.keycloak.crypto.Algorithm;
import org.keycloak.crypto.ECDSASignatureSignerContext;
import org.keycloak.crypto.KeyUse;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.crypto.def.DefaultCryptoProvider;
import org.keycloak.jose.jwk.JSONWebKeySet;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKBuilder;
import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.util.JsonSerialization;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.spec.ECGenParameterSpec;
public class OAuthIdentityProvider {
private final HttpServer httpServer;
private final OAuthIdentityProviderKeys keys;
public OAuthIdentityProvider(HttpServer httpServer) {
if (!CryptoIntegration.isInitialised()) {
CryptoIntegration.setProvider(new DefaultCryptoProvider());
}
this.httpServer = httpServer;
httpServer.createContext("/idp/jwks", new JwksHttpHandler());
keys = new OAuthIdentityProviderKeys();
}
public String encodeToken(JsonWebToken token) {
return encodeToken(token, keys);
}
public String encodeToken(JsonWebToken token, OAuthIdentityProviderKeys keys) {
return new JWSBuilder().type("JWT").jsonContent(token).sign(new ECDSASignatureSignerContext(keys.getKeyWrapper()));
}
public OAuthIdentityProviderKeys createKeys() {
return new OAuthIdentityProviderKeys();
}
public void close() {
httpServer.removeContext("/idp/jwks");
}
public class JwksHttpHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, keys.getJwksString().length());
OutputStream outputStream = exchange.getResponseBody();
outputStream.write(keys.getJwksString().getBytes(StandardCharsets.UTF_8));
outputStream.close();
}
}
public static class OAuthIdentityProviderKeys {
private final KeyWrapper keyWrapper;
private final String jwksString;
public OAuthIdentityProviderKeys() {
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC");
ECGenParameterSpec ecSpec = new ECGenParameterSpec("secp256r1");
keyPairGenerator.initialize(ecSpec);
KeyPair keyPair = keyPairGenerator.generateKeyPair();
JWK jwk = JWKBuilder.create().ec(keyPair.getPublic());
jwk.setAlgorithm("ES256");
jwk.setPublicKeyUse(KeyUse.SIG.getSpecName());
JSONWebKeySet jwks = new JSONWebKeySet();
jwks.setKeys(new JWK[] { jwk });
jwksString = JsonSerialization.writeValueAsString(jwks);
keyWrapper = new KeyWrapper();
keyWrapper.setKid(jwk.getKeyId());
keyWrapper.setPublicKey(keyPair.getPublic());
keyWrapper.setPrivateKey(keyPair.getPrivate());
keyWrapper.setUse(KeyUse.SIG);
keyWrapper.setAlgorithm(Algorithm.ES256);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public KeyWrapper getKeyWrapper() {
return keyWrapper;
}
public String getJwksString() {
return jwksString;
}
}
}

View File

@ -0,0 +1,22 @@
package org.keycloak.testframework.oauth;
import com.sun.net.httpserver.HttpServer;
import org.keycloak.testframework.injection.InstanceContext;
import org.keycloak.testframework.injection.RequestedInstance;
import org.keycloak.testframework.injection.Supplier;
import org.keycloak.testframework.oauth.annotations.InjectOAuthIdentityProvider;
public class OAuthIdentityProviderSupplier implements Supplier<OAuthIdentityProvider, InjectOAuthIdentityProvider> {
@Override
public OAuthIdentityProvider getValue(InstanceContext<OAuthIdentityProvider, InjectOAuthIdentityProvider> instanceContext) {
HttpServer httpServer = instanceContext.getDependency(HttpServer.class);
return new OAuthIdentityProvider(httpServer);
}
@Override
public boolean compatible(InstanceContext<OAuthIdentityProvider, InjectOAuthIdentityProvider> a, RequestedInstance<OAuthIdentityProvider, InjectOAuthIdentityProvider> b) {
return true;
}
}

View File

@ -9,7 +9,7 @@ public class OAuthTestFrameworkExtension implements TestFrameworkExtension {
@Override
public List<Supplier<?, ?>> suppliers() {
return List.of(new OAuthClientSupplier(), new TestAppSupplier());
return List.of(new OAuthClientSupplier(), new TestAppSupplier(), new OAuthIdentityProviderSupplier());
}
}

View File

@ -0,0 +1,16 @@
package org.keycloak.testframework.oauth.annotations;
import org.keycloak.testframework.injection.LifeCycle;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface InjectOAuthIdentityProvider {
LifeCycle lifecycle() default LifeCycle.GLOBAL;
}

View File

@ -0,0 +1,14 @@
package org.keycloak.tests.client.authentication.external;
import org.keycloak.common.Profile;
import org.keycloak.testframework.server.KeycloakServerConfig;
import org.keycloak.testframework.server.KeycloakServerConfigBuilder;
public class ClientAuthIdpServerConfig implements KeycloakServerConfig {
@Override
public KeycloakServerConfigBuilder configure(KeycloakServerConfigBuilder config) {
return config.features(Profile.Feature.CLIENT_AUTH_FEDERATED);
}
}

View File

@ -0,0 +1,101 @@
package org.keycloak.tests.client.authentication.external;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.keycloak.authentication.authenticators.client.FederatedJWTClientAuthenticator;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.broker.oidc.OIDCIdentityProviderFactory;
import org.keycloak.representations.idm.ProtocolMapperRepresentation;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.oauth.OAuthClient;
import org.keycloak.testframework.oauth.annotations.InjectOAuthClient;
import org.keycloak.testframework.realm.ClientConfig;
import org.keycloak.testframework.realm.ClientConfigBuilder;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.RealmConfig;
import org.keycloak.testframework.realm.RealmConfigBuilder;
import org.keycloak.testsuite.util.IdentityProviderBuilder;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import java.util.List;
@KeycloakIntegrationTest(config = ClientAuthIdpServerConfig.class)
public class FederatedClientAuthFromKeycloakTest {
@InjectRealm(config = InternalRealmConfig.class)
ManagedRealm internalRealm;
@InjectRealm(ref = "external")
ManagedRealm externalRealm;
@InjectOAuthClient(config = InternalClientConfig.class)
OAuthClient internalOAuthClient;
@InjectOAuthClient(ref = "external", realmRef = "external", config = ExternalClientConfig.class)
OAuthClient externalOAuthClient;
@Test
public void testValidToken() {
String externalClientAssertion = externalOAuthClient.doClientCredentialsGrantAccessTokenRequest().getAccessToken();
AccessTokenResponse send = internalOAuthClient.clientCredentialsGrantRequest().clientJwt(externalClientAssertion).send();
Assertions.assertTrue(send.isSuccess());
}
public static class InternalRealmConfig implements RealmConfig {
@Override
public RealmConfigBuilder configure(RealmConfigBuilder realm) {
return realm.identityProvider(
IdentityProviderBuilder.create()
.providerId(OIDCIdentityProviderFactory.PROVIDER_ID)
.alias("external")
.setAttribute("issuer", "http://localhost:8080/realms/external")
.setAttribute(OIDCIdentityProviderConfig.SUPPORTS_CLIENT_ASSERTIONS, "true")
.setAttribute(OIDCIdentityProviderConfig.USE_JWKS_URL, "true")
.setAttribute(OIDCIdentityProviderConfig.JWKS_URL, "http://localhost:8080/realms/external/protocol/openid-connect/certs")
.setAttribute(OIDCIdentityProviderConfig.VALIDATE_SIGNATURE, "true")
.build());
}
}
public static class InternalClientConfig implements ClientConfig {
@Override
public ClientConfigBuilder configure(ClientConfigBuilder client) {
return client.clientId("myclient")
.serviceAccountsEnabled(true)
.authenticatorType(FederatedJWTClientAuthenticator.PROVIDER_ID)
.attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY, "external");
}
}
public static class ExternalClientConfig implements ClientConfig {
@Override
public ClientConfigBuilder configure(ClientConfigBuilder client) {
ProtocolMapperRepresentation subMapper = new ProtocolMapperRepresentation();
subMapper.setName("fixed-sub");
subMapper.setProtocol("openid-connect");
subMapper.setProtocolMapper("oidc-hardcoded-claim-mapper");
subMapper.getConfig().put("claim.name", "sub");
subMapper.getConfig().put("claim.value", "myclient");
subMapper.getConfig().put("access.token.claim", "true");
ProtocolMapperRepresentation audMapper = new ProtocolMapperRepresentation();
audMapper.setName("fixed-audience");
audMapper.setProtocol("openid-connect");
audMapper.setProtocolMapper("oidc-audience-mapper");
audMapper.getConfig().put("included.custom.audience", "http://localhost:8080/realms/default");
audMapper.getConfig().put("access.token.claim", "true");
return client.clientId("myclient")
.defaultClientScopes()
.serviceAccountsEnabled(true)
.secret("mysecret")
.protocolMappers(List.of(subMapper, audMapper));
}
}
}

View File

@ -0,0 +1,214 @@
package org.keycloak.tests.client.authentication.external;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.keycloak.admin.client.resource.IdentityProviderResource;
import org.keycloak.authentication.authenticators.client.FederatedJWTClientAuthenticator;
import org.keycloak.broker.oidc.OIDCIdentityProviderConfig;
import org.keycloak.broker.oidc.OIDCIdentityProviderFactory;
import org.keycloak.common.util.Time;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.representations.idm.IdentityProviderRepresentation;
import org.keycloak.testframework.annotations.InjectClient;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.oauth.OAuthIdentityProvider;
import org.keycloak.testframework.oauth.OAuthClient;
import org.keycloak.testframework.oauth.annotations.InjectOAuthIdentityProvider;
import org.keycloak.testframework.oauth.annotations.InjectOAuthClient;
import org.keycloak.testframework.realm.ClientConfig;
import org.keycloak.testframework.realm.ClientConfigBuilder;
import org.keycloak.testframework.realm.ManagedClient;
import org.keycloak.testframework.realm.ManagedRealm;
import org.keycloak.testframework.realm.RealmConfig;
import org.keycloak.testframework.realm.RealmConfigBuilder;
import org.keycloak.testsuite.util.IdentityProviderBuilder;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import java.util.UUID;
@KeycloakIntegrationTest(config = ClientAuthIdpServerConfig.class)
public class FederatedClientAuthTest {
private static final String IDP_ALIAS = "myidp";
private static final String CLIENT_ID = "myclient";
@InjectRealm(config = ExernalClientAuthRealmConfig.class)
protected ManagedRealm realm;
@InjectClient(config = ExernalClientAuthClientConfig.class)
protected ManagedClient client;
@InjectOAuthClient
OAuthClient oAuthClient;
@InjectOAuthIdentityProvider
OAuthIdentityProvider identityProvider;
@Test
public void testInvalidSignature() {
OAuthIdentityProvider.OAuthIdentityProviderKeys keys = identityProvider.createKeys();
String jws = identityProvider.encodeToken(createDefaultToken(), keys);
Assertions.assertFalse(doClientGrant(jws));
}
@Test
public void testInvalidAssertionType() {
String jws = identityProvider.encodeToken(createDefaultToken());
AccessTokenResponse response = oAuthClient.clientCredentialsGrantRequest().clientJwt(jws, "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe").send();
Assertions.assertFalse(response.isSuccess());
}
@Test
public void testValidToken() {
Assertions.assertTrue(doClientGrant(createDefaultToken()));
}
@Test
public void testInvalidIssuer() {
JsonWebToken token = createDefaultToken();
token.issuer("http://invalid");
Assertions.assertFalse(doClientGrant(token));
}
@Test
public void testMissingIssuer() {
JsonWebToken token = createDefaultToken();
token.issuer(null);
Assertions.assertFalse(doClientGrant(token));
}
@Test
public void testInvalidSub() {
JsonWebToken token = createDefaultToken();
token.subject("invalid");
Assertions.assertFalse(doClientGrant(token));
}
@Test
public void testInvalidAud() {
JsonWebToken token = createDefaultToken();
token.audience("invalid");
Assertions.assertFalse(doClientGrant(token));
}
@Test
public void testMultipleAud() {
JsonWebToken token = createDefaultToken();
token.audience(token.getAudience()[0], "invalid");
Assertions.assertFalse(doClientGrant(token));
}
@Test
public void testInvalidNbf() {
JsonWebToken token = createDefaultToken();
token.nbf((long) (Time.currentTime() + 30));
Assertions.assertFalse(doClientGrant(token));
}
@Test
public void testMissingJti() {
JsonWebToken token = createDefaultToken();
token.id(null);
Assertions.assertFalse(doClientGrant(token));
}
@Test
public void testExpired() {
JsonWebToken token = createDefaultToken();
token.exp((long) (Time.currentTime() - 30));
Assertions.assertFalse(doClientGrant(token));
}
@Test
public void testMissingExp() {
JsonWebToken token = createDefaultToken();
token.exp(null);
Assertions.assertFalse(doClientGrant(token));
}
@Test
public void testReuseNotPermitted() {
JsonWebToken token = createDefaultToken();
Assertions.assertTrue(doClientGrant(token));
Assertions.assertFalse(doClientGrant(token));
}
@Test
public void testReusePermitted() {
IdentityProviderResource idp = realm.admin().identityProviders().get(IDP_ALIAS);
IdentityProviderRepresentation rep = idp.toRepresentation();
rep.getConfig().put(OIDCIdentityProviderConfig.SUPPORTS_CLIENT_ASSERTION_REUSE, "true");
idp.update(rep);
JsonWebToken token = createDefaultToken();
Assertions.assertTrue(doClientGrant(token));
Assertions.assertTrue(doClientGrant(token));
rep.getConfig().remove(OIDCIdentityProviderConfig.SUPPORTS_CLIENT_ASSERTION_REUSE);
idp.update(rep);
}
@Test
public void testClientAssertionsNotSupported() {
IdentityProviderResource idp = realm.admin().identityProviders().get(IDP_ALIAS);
IdentityProviderRepresentation rep = idp.toRepresentation();
rep.getConfig().remove(OIDCIdentityProviderConfig.SUPPORTS_CLIENT_ASSERTIONS);
idp.update(rep);
Assertions.assertFalse(doClientGrant(createDefaultToken()));
rep.getConfig().put(OIDCIdentityProviderConfig.SUPPORTS_CLIENT_ASSERTIONS, "true");
idp.update(rep);
}
private boolean doClientGrant(JsonWebToken token) {
String jws = identityProvider.encodeToken(token);
return doClientGrant(jws);
}
private boolean doClientGrant(String jws) {
AccessTokenResponse response = oAuthClient.clientCredentialsGrantRequest().clientJwt(jws).send();
return response.isSuccess();
}
private JsonWebToken createDefaultToken() {
JsonWebToken token = new JsonWebToken();
token.id(UUID.randomUUID().toString());
token.issuer("http://127.0.0.1:8500");
token.audience(oAuthClient.getEndpoints().getIssuer());
token.exp((long) (Time.currentTime() + 300));
token.subject(CLIENT_ID);
return token;
}
public static class ExernalClientAuthRealmConfig implements RealmConfig {
@Override
public RealmConfigBuilder configure(RealmConfigBuilder realm) {
return realm.identityProvider(
IdentityProviderBuilder.create()
.providerId(OIDCIdentityProviderFactory.PROVIDER_ID)
.alias(IDP_ALIAS)
.setAttribute("issuer", "http://127.0.0.1:8500")
.setAttribute(OIDCIdentityProviderConfig.USE_JWKS_URL, "true")
.setAttribute(OIDCIdentityProviderConfig.JWKS_URL, "http://127.0.0.1:8500/idp/jwks")
.setAttribute(OIDCIdentityProviderConfig.VALIDATE_SIGNATURE, "true")
.setAttribute(OIDCIdentityProviderConfig.SUPPORTS_CLIENT_ASSERTIONS, "true")
.build());
}
}
public static class ExernalClientAuthClientConfig implements ClientConfig {
@Override
public ClientConfigBuilder configure(ClientConfigBuilder client) {
return client.clientId(CLIENT_ID)
.serviceAccountsEnabled(true)
.authenticatorType(FederatedJWTClientAuthenticator.PROVIDER_ID)
.attribute(FederatedJWTClientAuthenticator.JWT_CREDENTIAL_ISSUER_KEY, IDP_ALIAS);
}
}
}

View File

@ -24,6 +24,10 @@ public abstract class AbstractHttpPostRequest<T, R> {
protected String clientSecret;
protected String clientAssertion;
protected String clientAssertionType;
protected HttpPost post;
protected Map<String, String> headers = new HashMap<>();
@ -70,6 +74,18 @@ public abstract class AbstractHttpPostRequest<T, R> {
return request();
}
public T clientJwt(String clientAssertion) {
this.clientAssertion = clientAssertion;
this.clientAssertionType = OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT;
return request();
}
public T clientJwt(String clientAssertion, String clientAssertionType) {
this.clientAssertion = clientAssertion;
this.clientAssertionType = clientAssertionType;
return request();
}
protected void header(String name, String value) {
if (value != null) {
headers.put(name, value);
@ -86,10 +102,13 @@ public abstract class AbstractHttpPostRequest<T, R> {
String clientId = this.clientId != null ? this.clientId : client.config().getClientId();
String clientSecret = this.clientId != null ? this.clientSecret : client.config().getClientSecret();
if (clientSecret != null) {
if (clientAssertion != null && clientAssertionType != null) {
parameter("client_assertion_type", clientAssertionType);
parameter("client_assertion", clientAssertion);
} else if (clientSecret != null) {
String authorization = BasicAuthHelper.RFC6749.createHeader(clientId, clientSecret);
header("Authorization", authorization);
} else {
} else if (clientId != null) {
parameter("client_id", clientId);
}
}

View File

@ -21,6 +21,10 @@ public class Endpoints {
return asString(getBase().path(RealmsResource.class).path("{realm}/.well-known/openid-configuration"));
}
public String getIssuer() {
return asString(getBase().path(RealmsResource.class).path("{realm}"));
}
public String getAuthorization() {
return asString(OIDCLoginProtocolService.authUrl(getBase()));
}