SPIFFE should support OIDC JWK endpoint (#43651) (#43656)

Closes #43650


(cherry picked from commit f6ac64907d)

Signed-off-by: stianst <stianst@gmail.com>
This commit is contained in:
Stian Thorgersen
2025-10-23 08:08:31 +02:00
committed by GitHub
parent 4ad4ce5d58
commit 84fd00c9f7
11 changed files with 265 additions and 103 deletions

View File

@@ -28,5 +28,5 @@ image:images/spiffe-add-identity-provider.png[Add SPIFFE Provider]
|The SPIFFE Trust domain (for example `spiffe://my-trust-domain`)
|SPIFFE Bundle Endpoint
|`https` URL for the SPIFFE Bundle Endpoint where the SPIFFE servers public keys are exposed
|`https` URL for the SPIFFE Bundle Endpoint or the OpenID Connect JWKS endpoint where the SPIFFE servers public keys are exposed
|===

View File

@@ -940,7 +940,7 @@ addSamlProvider=Add SAML provider
addSpiffeProvider=Add SPIFFE provider
addKubernetesProvider=Add Kubernetes provider
spiffeTrustDomain=SPIFFE Trust Domain
spiffeBundleEndpoint=SPIFFE Bundle Endpoint
spiffeBundleEndpoint=SPIFFE Bundle or OIDC JWKs endpoint
kubernetesJWKSURL=Kubernetes JWKS URL
kubernetesJWKSURLHelp=Use Kubernetes JWKS URL when accessing an external Kubernetes cluster. The JWKS endpoint must not require authentication
permission=Permission

View File

@@ -21,7 +21,11 @@ public class SpiffeBundleEndpointLoader implements PublicKeyLoader {
@Override
public PublicKeysWrapper loadKeys() throws Exception {
JSONWebKeySet jwks = JWKSHttpUtils.sendJwksRequest(session, bundleEndpoint);
return JWKSUtils.getKeyWrappersForUse(jwks, JWK.Use.JWT_SVID);
PublicKeysWrapper keysWrapper = JWKSUtils.getKeyWrappersForUse(jwks, JWK.Use.JWT_SVID, true);
if (keysWrapper.getKeys().isEmpty()) {
keysWrapper = JWKSUtils.getKeyWrappersForUse(jwks, JWK.Use.SIG, true);
}
return keysWrapper;
}
}

View File

@@ -91,7 +91,11 @@ public class OAuthIdentityProvider {
if (!config.spiffe()) {
jwk.setAlgorithm("ES256");
}
jwk.setPublicKeyUse(keyUse.getSpecName());
if (config.jwkUse()) {
jwk.setPublicKeyUse(keyUse.getSpecName());
} else {
jwk.setPublicKeyUse(null);
}
Map<String, Object> jwks = new HashMap<>();
jwks.put("keys", new JWK[] { jwk });

View File

@@ -3,17 +3,23 @@ package org.keycloak.testframework.oauth;
public class OAuthIdentityProviderConfigBuilder {
private boolean spiffe;
private boolean jwkUse = true;
public OAuthIdentityProviderConfigBuilder spiffe() {
spiffe = true;
return this;
}
public OAuthIdentityProviderConfiguration build() {
return new OAuthIdentityProviderConfiguration(spiffe);
public OAuthIdentityProviderConfigBuilder jwkUse(boolean jwkUse) {
this.jwkUse = jwkUse;
return this;
}
public record OAuthIdentityProviderConfiguration(boolean spiffe) {
public OAuthIdentityProviderConfiguration build() {
return new OAuthIdentityProviderConfiguration(spiffe, jwkUse);
}
public record OAuthIdentityProviderConfiguration(boolean spiffe, boolean jwkUse) {
}
}

View File

@@ -0,0 +1,97 @@
package org.keycloak.tests.client.authentication.external;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.keycloak.common.util.Time;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.testframework.oauth.OAuthIdentityProvider;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
public abstract class AbstractBaseClientAuthTest extends AbstractClientAuthTest {
public AbstractBaseClientAuthTest(String expectedTokenIssuer, String internalClientId, String externalClientId) {
super(expectedTokenIssuer, internalClientId, externalClientId);
}
@Test
public void testValidToken() {
JsonWebToken token = createDefaultToken();
assertSuccess(internalClientId, doClientGrant(token));
assertSuccess(internalClientId, token.getId(), expectedTokenIssuer, externalClientId, events.poll());
}
@Test
public void testInvalidSignature() {
OAuthIdentityProvider.OAuthIdentityProviderKeys keys = getIdentityProvider().createKeys();
JsonWebToken jwt = createDefaultToken();
String jws = getIdentityProvider().encodeToken(jwt, keys);
assertFailure("Invalid client or Invalid client credentials", doClientGrant(jws));
assertFailure(internalClientId, expectedTokenIssuer, externalClientId, jwt.getId(), events.poll());
}
@Test
public void testInvalidSub() {
JsonWebToken jwt = createDefaultToken();
jwt.subject("invalid");
Assertions.assertFalse(doClientGrant(jwt).isSuccess());
assertFailure(null, expectedTokenIssuer, "invalid", jwt.getId(), "client_not_found", events.poll());
}
@Test
public void testExpired() {
JsonWebToken jwt = createDefaultToken();
jwt.exp((long) (Time.currentTime() - 30));
assertFailure("Token is not active", doClientGrant(jwt));
assertFailure(internalClientId, expectedTokenIssuer, externalClientId, jwt.getId(), events.poll());
}
@Test
public void testMissingExp() {
JsonWebToken jwt = createDefaultToken();
jwt.exp(null);
assertFailure("Token exp claim is required", doClientGrant(jwt));
assertFailure(internalClientId, expectedTokenIssuer, externalClientId, jwt.getId(), events.poll());
}
@Test
public void testInvalidNbf() {
JsonWebToken jwt = createDefaultToken();
jwt.nbf((long) (Time.currentTime() + 60));
assertFailure("Token is not active", doClientGrant(jwt));
assertFailure(internalClientId, expectedTokenIssuer, externalClientId, jwt.getId(), events.poll());
}
@Test
public void testInvalidAud() {
JsonWebToken jwt = createDefaultToken();
jwt.audience("invalid");
assertFailure("Invalid token audience", doClientGrant(jwt));
assertFailure(internalClientId, expectedTokenIssuer, externalClientId, jwt.getId(), events.poll());
}
@Test
public void testMissingAud() {
JsonWebToken jwt = createDefaultToken();
jwt.audience((String) null);
assertFailure("Invalid token audience", doClientGrant(jwt));
assertFailure(internalClientId, expectedTokenIssuer, externalClientId, jwt.getId(), events.poll());
}
@Test
public void testMultipleAud() {
JsonWebToken jwt = createDefaultToken();
jwt.audience(jwt.getAudience()[0], "invalid");
assertFailure("Multiple audiences not allowed", doClientGrant(jwt));
assertFailure(internalClientId, expectedTokenIssuer, externalClientId, jwt.getId(), events.poll());
}
@Test
public void testValidInvalidAssertionType() {
JsonWebToken jwt = createDefaultToken();
String jws = getIdentityProvider().encodeToken(jwt);
AccessTokenResponse response = oAuthClient.clientCredentialsGrantRequest().clientJwt(jws, "urn:ietf:params:oauth:client-assertion-type:invalid").send();
assertFailure(response);
assertFailure(null, expectedTokenIssuer, externalClientId, jwt.getId(), "client_not_found", events.poll());
}
}

View File

@@ -1,9 +1,7 @@
package org.keycloak.tests.client.authentication.external;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.common.util.Time;
import org.keycloak.events.EventType;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.JsonWebToken;
@@ -16,11 +14,11 @@ import org.keycloak.testframework.oauth.OAuthIdentityProvider;
import org.keycloak.testframework.oauth.annotations.InjectOAuthClient;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
public abstract class AbstractFederatedClientAuthTest {
public abstract class AbstractClientAuthTest {
private final String expectedTokenIssuer;
private final String internalClientId;
private final String externalClientId;
final String expectedTokenIssuer;
final String internalClientId;
final String externalClientId;
@InjectOAuthClient
OAuthClient oAuthClient;
@@ -28,93 +26,12 @@ public abstract class AbstractFederatedClientAuthTest {
@InjectEvents
Events events;
public AbstractFederatedClientAuthTest(String expectedTokenIssuer, String internalClientId, String externalClientId) {
public AbstractClientAuthTest(String expectedTokenIssuer, String internalClientId, String externalClientId) {
this.expectedTokenIssuer = expectedTokenIssuer;
this.internalClientId = internalClientId;
this.externalClientId = externalClientId;
}
@Test
public void testValidToken() {
JsonWebToken token = createDefaultToken();
assertSuccess(internalClientId, doClientGrant(token));
assertSuccess(internalClientId, token.getId(), expectedTokenIssuer, externalClientId, events.poll());
}
@Test
public void testInvalidSignature() {
OAuthIdentityProvider.OAuthIdentityProviderKeys keys = getIdentityProvider().createKeys();
JsonWebToken jwt = createDefaultToken();
String jws = getIdentityProvider().encodeToken(jwt, keys);
assertFailure("Invalid client or Invalid client credentials", doClientGrant(jws));
assertFailure(internalClientId, expectedTokenIssuer, externalClientId, jwt.getId(), events.poll());
}
@Test
public void testInvalidSub() {
JsonWebToken jwt = createDefaultToken();
jwt.subject("invalid");
Assertions.assertFalse(doClientGrant(jwt).isSuccess());
assertFailure(null, expectedTokenIssuer, "invalid", jwt.getId(), "client_not_found", events.poll());
}
@Test
public void testExpired() {
JsonWebToken jwt = createDefaultToken();
jwt.exp((long) (Time.currentTime() - 30));
assertFailure("Token is not active", doClientGrant(jwt));
assertFailure(internalClientId, expectedTokenIssuer, externalClientId, jwt.getId(), events.poll());
}
@Test
public void testMissingExp() {
JsonWebToken jwt = createDefaultToken();
jwt.exp(null);
assertFailure("Token exp claim is required", doClientGrant(jwt));
assertFailure(internalClientId, expectedTokenIssuer, externalClientId, jwt.getId(), events.poll());
}
@Test
public void testInvalidNbf() {
JsonWebToken jwt = createDefaultToken();
jwt.nbf((long) (Time.currentTime() + 60));
assertFailure("Token is not active", doClientGrant(jwt));
assertFailure(internalClientId, expectedTokenIssuer, externalClientId, jwt.getId(), events.poll());
}
@Test
public void testInvalidAud() {
JsonWebToken jwt = createDefaultToken();
jwt.audience("invalid");
assertFailure("Invalid token audience", doClientGrant(jwt));
assertFailure(internalClientId, expectedTokenIssuer, externalClientId, jwt.getId(), events.poll());
}
@Test
public void testMissingAud() {
JsonWebToken jwt = createDefaultToken();
jwt.audience((String) null);
assertFailure("Invalid token audience", doClientGrant(jwt));
assertFailure(internalClientId, expectedTokenIssuer, externalClientId, jwt.getId(), events.poll());
}
@Test
public void testMultipleAud() {
JsonWebToken jwt = createDefaultToken();
jwt.audience(jwt.getAudience()[0], "invalid");
assertFailure("Multiple audiences not allowed", doClientGrant(jwt));
assertFailure(internalClientId, expectedTokenIssuer, externalClientId, jwt.getId(), events.poll());
}
@Test
public void testValidInvalidAssertionType() {
JsonWebToken jwt = createDefaultToken();
String jws = getIdentityProvider().encodeToken(jwt);
AccessTokenResponse response = oAuthClient.clientCredentialsGrantRequest().clientJwt(jws, "urn:ietf:params:oauth:client-assertion-type:invalid").send();
assertFailure(response);
assertFailure(null, expectedTokenIssuer, externalClientId, jwt.getId(), "client_not_found", events.poll());
}
protected abstract OAuthIdentityProvider getIdentityProvider();
protected abstract JsonWebToken createDefaultToken();

View File

@@ -19,7 +19,7 @@ import org.keycloak.testsuite.util.IdentityProviderBuilder;
import java.util.UUID;
@KeycloakIntegrationTest(config = ClientAuthIdpServerConfig.class)
public class FederatedClientAuthTest extends AbstractFederatedClientAuthTest {
public class BaseClientAuthTest extends AbstractBaseClientAuthTest {
private static final String IDP_ALIAS = "external-idp";
@@ -33,7 +33,7 @@ public class FederatedClientAuthTest extends AbstractFederatedClientAuthTest {
@InjectOAuthIdentityProvider
OAuthIdentityProvider identityProvider;
public FederatedClientAuthTest() {
public BaseClientAuthTest() {
super(TOKEN_ISSUER, INTERNAL_CLIENT_ID, EXTERNAL_CLIENT_ID);
}

View File

@@ -25,13 +25,13 @@ import org.keycloak.testsuite.util.IdentityProviderBuilder;
@KeycloakIntegrationTest(config = SpiffeClientAuthTest.SpiffeServerConfig.class)
@TestMethodOrder(MethodOrderer.MethodName.class)
public class SpiffeClientAuthTest extends AbstractFederatedClientAuthTest {
public class SpiffeClientAuthTest extends AbstractBaseClientAuthTest {
private static final String INTERNAL_CLIENT_ID = "myclient";
private static final String EXTERNAL_CLIENT_ID = "spiffe://mytrust-domain/myclient";
private static final String IDP_ALIAS = "spiffe-idp";
private static final String TRUST_DOMAIN = "spiffe://mytrust-domain";
private static final String BUNDLE_ENDPOINT = "http://127.0.0.1:8500/idp/jwks";
static final String INTERNAL_CLIENT_ID = "myclient";
static final String EXTERNAL_CLIENT_ID = "spiffe://mytrust-domain/myclient";
static final String IDP_ALIAS = "spiffe-idp";
static final String TRUST_DOMAIN = "spiffe://mytrust-domain";
static final String BUNDLE_ENDPOINT = "http://127.0.0.1:8500/idp/jwks";
@InjectRealm(config = ExernalClientAuthRealmConfig.class)
protected ManagedRealm realm;

View File

@@ -0,0 +1,67 @@
package org.keycloak.tests.client.authentication.external;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.keycloak.broker.spiffe.SpiffeConstants;
import org.keycloak.common.util.Time;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.oauth.OAuthIdentityProvider;
import org.keycloak.testframework.oauth.OAuthIdentityProviderConfig;
import org.keycloak.testframework.oauth.OAuthIdentityProviderConfigBuilder;
import org.keycloak.testframework.oauth.annotations.InjectOAuthIdentityProvider;
import org.keycloak.testframework.realm.ManagedRealm;
@KeycloakIntegrationTest(config = SpiffeClientAuthTest.SpiffeServerConfig.class)
@TestMethodOrder(MethodOrderer.MethodName.class)
public class SpiffeClientAuthWithJwkUseSigTest extends AbstractClientAuthTest {
@InjectRealm(config = SpiffeClientAuthTest.ExernalClientAuthRealmConfig.class)
protected ManagedRealm realm;
@InjectOAuthIdentityProvider(config = SpiffeWithOidcIdpConfig.class)
OAuthIdentityProvider identityProvider;
public SpiffeClientAuthWithJwkUseSigTest() {
super(null, SpiffeClientAuthTest.INTERNAL_CLIENT_ID, SpiffeClientAuthTest.EXTERNAL_CLIENT_ID);
}
@Test
public void testWithIssClaimAndSigUseOnJwk() {
JsonWebToken jwt = createDefaultToken();
assertSuccess(SpiffeClientAuthTest.INTERNAL_CLIENT_ID, doClientGrant(createDefaultToken()));
assertSuccess(SpiffeClientAuthTest.INTERNAL_CLIENT_ID, jwt.getId(), "https://myissuer", SpiffeClientAuthTest.EXTERNAL_CLIENT_ID, events.poll());
}
@Override
protected String getClientAssertionType() {
return SpiffeConstants.CLIENT_ASSERTION_TYPE;
}
@Override
protected OAuthIdentityProvider getIdentityProvider() {
return identityProvider;
}
@Override
protected JsonWebToken createDefaultToken() {
JsonWebToken token = new JsonWebToken();
token.id(null);
token.issuer("https://myissuer");
token.audience(oAuthClient.getEndpoints().getIssuer());
token.exp((long) (Time.currentTime() + 300));
token.subject(SpiffeClientAuthTest.EXTERNAL_CLIENT_ID);
return token;
}
public static class SpiffeWithOidcIdpConfig implements OAuthIdentityProviderConfig {
@Override
public OAuthIdentityProviderConfigBuilder configure(OAuthIdentityProviderConfigBuilder config) {
return config.jwkUse(true);
}
}
}

View File

@@ -0,0 +1,67 @@
package org.keycloak.tests.client.authentication.external;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.keycloak.broker.spiffe.SpiffeConstants;
import org.keycloak.common.util.Time;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.testframework.annotations.InjectRealm;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testframework.oauth.OAuthIdentityProvider;
import org.keycloak.testframework.oauth.OAuthIdentityProviderConfig;
import org.keycloak.testframework.oauth.OAuthIdentityProviderConfigBuilder;
import org.keycloak.testframework.oauth.annotations.InjectOAuthIdentityProvider;
import org.keycloak.testframework.realm.ManagedRealm;
@KeycloakIntegrationTest(config = SpiffeClientAuthTest.SpiffeServerConfig.class)
@TestMethodOrder(MethodOrderer.MethodName.class)
public class SpiffeClientAuthWithOIDCPluginTest extends AbstractClientAuthTest {
@InjectRealm(config = SpiffeClientAuthTest.ExernalClientAuthRealmConfig.class)
protected ManagedRealm realm;
@InjectOAuthIdentityProvider(config = SpiffeWithOidcIdpConfig.class)
OAuthIdentityProvider identityProvider;
public SpiffeClientAuthWithOIDCPluginTest() {
super(null, SpiffeClientAuthTest.INTERNAL_CLIENT_ID, SpiffeClientAuthTest.EXTERNAL_CLIENT_ID);
}
@Test
public void testWithIssClaimAndNoUseOnJwk() {
JsonWebToken jwt = createDefaultToken();
assertSuccess(SpiffeClientAuthTest.INTERNAL_CLIENT_ID, doClientGrant(createDefaultToken()));
assertSuccess(SpiffeClientAuthTest.INTERNAL_CLIENT_ID, jwt.getId(), "https://myissuer", SpiffeClientAuthTest.EXTERNAL_CLIENT_ID, events.poll());
}
@Override
protected String getClientAssertionType() {
return SpiffeConstants.CLIENT_ASSERTION_TYPE;
}
@Override
protected OAuthIdentityProvider getIdentityProvider() {
return identityProvider;
}
@Override
protected JsonWebToken createDefaultToken() {
JsonWebToken token = new JsonWebToken();
token.id(null);
token.issuer("https://myissuer");
token.audience(oAuthClient.getEndpoints().getIssuer());
token.exp((long) (Time.currentTime() + 300));
token.subject(SpiffeClientAuthTest.EXTERNAL_CLIENT_ID);
return token;
}
public static class SpiffeWithOidcIdpConfig implements OAuthIdentityProviderConfig {
@Override
public OAuthIdentityProviderConfigBuilder configure(OAuthIdentityProviderConfigBuilder config) {
return config.jwkUse(false);
}
}
}