From 4317a474d10c34b0691fef396989c08d4d4e7cd4 Mon Sep 17 00:00:00 2001 From: Francis Pouatcha Date: Tue, 28 May 2024 11:51:56 +0100 Subject: [PATCH] JWT VC Issuer Metadata /.well-known/jwt-vc-issuer to comply with SD-JWT VC Specification (#29635) closes #29634 Signed-off-by: Francis Pouatcha Co-authored-by: DYLANE BENGONO <85441363+bengo237@users.noreply.github.com> --- .../JWTVCIssuerWellKnownProvider.java | 61 ++++++++++++++ .../JWTVCIssuerWellKnownProviderFactory.java | 61 ++++++++++++++ .../oid4vc/model/JWTVCIssuerMetadata.java | 53 ++++++++++++ .../oidc/OIDCLoginProtocolService.java | 32 +------- .../protocol/oidc/utils/JWKSServerUtils.java | 61 ++++++++++++++ ...eycloak.wellknown.WellKnownProviderFactory | 3 +- .../JWTVCIssuerWellKnownProviderTest.java | 80 +++++++++++++++++++ 7 files changed, 320 insertions(+), 31 deletions(-) create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/issuance/JWTVCIssuerWellKnownProvider.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/issuance/JWTVCIssuerWellKnownProviderFactory.java create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/model/JWTVCIssuerMetadata.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc/utils/JWKSServerUtils.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JWTVCIssuerWellKnownProviderTest.java diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/JWTVCIssuerWellKnownProvider.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/JWTVCIssuerWellKnownProvider.java new file mode 100644 index 0000000000..d99589279b --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/JWTVCIssuerWellKnownProvider.java @@ -0,0 +1,61 @@ +/* + * Copyright 2024 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.protocol.oid4vc.issuance; + +import jakarta.ws.rs.core.UriInfo; +import org.keycloak.jose.jwk.JSONWebKeySet; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.protocol.oid4vc.model.JWTVCIssuerMetadata; +import org.keycloak.protocol.oidc.utils.JWKSServerUtils; +import org.keycloak.services.Urls; +import org.keycloak.urls.UrlType; +import org.keycloak.wellknown.WellKnownProvider; + +/** + * {@link WellKnownProvider} implementation for JWT VC Issuer metadata at endpoint /.well-known/jwt-vc-issuer + *

+ * {@see https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-03.html#name-jwt-vc-issuer-metadata} + * + * @author Francis Pouatcha + */ +public class JWTVCIssuerWellKnownProvider implements WellKnownProvider { + private final KeycloakSession session; + + public JWTVCIssuerWellKnownProvider(KeycloakSession session) { + this.session = session; + } + + @Override + public void close() { + // no-op + } + + @Override + public Object getConfig() { + UriInfo frontendUriInfo = session.getContext().getUri(UrlType.FRONTEND); + RealmModel realm = session.getContext().getRealm(); + + JWTVCIssuerMetadata config = new JWTVCIssuerMetadata(); + config.setIssuer(Urls.realmIssuer(frontendUriInfo.getBaseUri(), realm.getName())); + + JSONWebKeySet jwks = JWKSServerUtils.getRealmJwks(session, realm); + config.setJwks(jwks); + + return config; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/JWTVCIssuerWellKnownProviderFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/JWTVCIssuerWellKnownProviderFactory.java new file mode 100644 index 0000000000..5dd6b88c21 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/JWTVCIssuerWellKnownProviderFactory.java @@ -0,0 +1,61 @@ +/* + * Copyright 2024 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.protocol.oid4vc.issuance; + +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.protocol.oid4vc.OID4VCEnvironmentProviderFactory; +import org.keycloak.wellknown.WellKnownProvider; +import org.keycloak.wellknown.WellKnownProviderFactory; + +/** + * {@link WellKnownProviderFactory} implementation for JWT VC Issuer metadata at endpoint /.well-known/jwt-vc-issuer + * + * {@see https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-03.html#name-jwt-vc-issuer-metadata} + * + * @author Francis Pouatcha + */ +public class JWTVCIssuerWellKnownProviderFactory implements WellKnownProviderFactory, OID4VCEnvironmentProviderFactory { + + public static final String PROVIDER_ID = "jwt-vc-issuer"; + + @Override + public WellKnownProvider create(KeycloakSession session) { + return new JWTVCIssuerWellKnownProvider(session); + } + + @Override + public void init(Config.Scope config) { + // no-op + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + // no-op + } + + @Override + public void close() { + // no-op + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/JWTVCIssuerMetadata.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/JWTVCIssuerMetadata.java new file mode 100644 index 0000000000..8462dd8754 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/JWTVCIssuerMetadata.java @@ -0,0 +1,53 @@ +/* + * Copyright 2024 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.protocol.oid4vc.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.keycloak.jose.jwk.JSONWebKeySet; +import org.keycloak.jose.jwk.JWK; + +/** + * JWT VC Issuer metadata for endpoint /.well-known/jwt-vc-issuer + *

+ * {@see https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-03.html#name-jwt-vc-issuer-metadata} + * + * @author Francis Pouatcha + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class JWTVCIssuerMetadata { + @JsonProperty("issuer") + private String issuer; + @JsonProperty("jwks") + private JSONWebKeySet jwks; + + public String getIssuer() { + return issuer; + } + + public void setIssuer(String issuer) { + this.issuer = issuer; + } + + public JSONWebKeySet getJwks() { + return jwks; + } + + public void setJwks(JSONWebKeySet jwks) { + this.jwks = jwks; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java index 0d7eb735d4..a21a706085 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolService.java @@ -17,20 +17,13 @@ package org.keycloak.protocol.oidc; -import java.security.cert.X509Certificate; -import java.util.Collections; -import java.util.List; -import java.util.Optional; import org.jboss.resteasy.reactive.NoCache; import org.keycloak.http.HttpRequest; import org.keycloak.OAuthErrorException; import org.keycloak.common.ClientConnection; -import org.keycloak.crypto.KeyType; import org.keycloak.events.EventBuilder; import org.keycloak.forms.login.LoginFormsProvider; import org.keycloak.jose.jwk.JSONWebKeySet; -import org.keycloak.jose.jwk.JWK; -import org.keycloak.jose.jwk.JWKBuilder; import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; @@ -41,13 +34,12 @@ import org.keycloak.protocol.oidc.endpoints.TokenEndpoint; import org.keycloak.protocol.oidc.endpoints.TokenRevocationEndpoint; import org.keycloak.protocol.oidc.endpoints.UserInfoEndpoint; import org.keycloak.protocol.oidc.ext.OIDCExtProvider; +import org.keycloak.protocol.oidc.utils.JWKSServerUtils; import org.keycloak.services.CorsErrorResponseException; import org.keycloak.services.cors.Cors; import org.keycloak.services.resources.RealmsResource; import org.keycloak.services.util.CacheControlUtil; -import java.util.Objects; - import jakarta.ws.rs.GET; import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.OPTIONS; @@ -209,27 +201,7 @@ public class OIDCLoginProtocolService { public Response certs() { checkSsl(); - JWK[] jwks = session.keys().getKeysStream(realm) - .filter(k -> k.getStatus().isEnabled() && k.getPublicKey() != null) - .map(k -> { - JWKBuilder b = JWKBuilder.create().kid(k.getKid()).algorithm(k.getAlgorithmOrDefault()); - List certificates = Optional.ofNullable(k.getCertificateChain()) - .filter(certs -> !certs.isEmpty()) - .orElseGet(() -> Collections.singletonList(k.getCertificate())); - if (k.getType().equals(KeyType.RSA)) { - return b.rsa(k.getPublicKey(), certificates, k.getUse()); - } else if (k.getType().equals(KeyType.EC)) { - return b.ec(k.getPublicKey(), k.getUse()); - } else if (k.getType().equals(KeyType.OKP)) { - return b.okp(k.getPublicKey(), k.getUse()); - } - return null; - }) - .filter(Objects::nonNull) - .toArray(JWK[]::new); - - JSONWebKeySet keySet = new JSONWebKeySet(); - keySet.setKeys(jwks); + JSONWebKeySet keySet = JWKSServerUtils.getRealmJwks(session, realm); Response.ResponseBuilder responseBuilder = Response.ok(keySet).cacheControl(CacheControlUtil.getDefaultCacheControl()); return Cors.builder().allowedOrigins("*").auth().add(responseBuilder); diff --git a/services/src/main/java/org/keycloak/protocol/oidc/utils/JWKSServerUtils.java b/services/src/main/java/org/keycloak/protocol/oidc/utils/JWKSServerUtils.java new file mode 100644 index 0000000000..71fa0f830b --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc/utils/JWKSServerUtils.java @@ -0,0 +1,61 @@ +/* + * Copyright 2024 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.protocol.oidc.utils; + +import org.keycloak.crypto.KeyType; +import org.keycloak.jose.jwk.JSONWebKeySet; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jwk.JWKBuilder; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.wellknown.WellKnownProvider; + +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * + * @author Francis Pouatcha + */public class JWKSServerUtils { + public static JSONWebKeySet getRealmJwks(KeycloakSession session, RealmModel realm){ + JWK[] jwks = session.keys().getKeysStream(realm) + .filter(k -> k.getStatus().isEnabled() && k.getPublicKey() != null) + .map(k -> { + JWKBuilder b = JWKBuilder.create().kid(k.getKid()).algorithm(k.getAlgorithmOrDefault()); + List certificates = Optional.ofNullable(k.getCertificateChain()) + .filter(certs -> !certs.isEmpty()) + .orElseGet(() -> Collections.singletonList(k.getCertificate())); + if (k.getType().equals(KeyType.RSA)) { + return b.rsa(k.getPublicKey(), certificates, k.getUse()); + } else if (k.getType().equals(KeyType.EC)) { + return b.ec(k.getPublicKey(), k.getUse()); + } else if (k.getType().equals(KeyType.OKP)) { + return b.okp(k.getPublicKey(), k.getUse()); + } + return null; + }) + .filter(Objects::nonNull) + .toArray(JWK[]::new); + + JSONWebKeySet keySet = new JSONWebKeySet(); + keySet.setKeys(jwks); + return keySet; + } +} diff --git a/services/src/main/resources/META-INF/services/org.keycloak.wellknown.WellKnownProviderFactory b/services/src/main/resources/META-INF/services/org.keycloak.wellknown.WellKnownProviderFactory index 099fdf681c..3e9de33149 100644 --- a/services/src/main/resources/META-INF/services/org.keycloak.wellknown.WellKnownProviderFactory +++ b/services/src/main/resources/META-INF/services/org.keycloak.wellknown.WellKnownProviderFactory @@ -18,4 +18,5 @@ org.keycloak.protocol.oidc.OIDCWellKnownProviderFactory org.keycloak.authorization.config.UmaWellKnownProviderFactory org.keycloak.protocol.oid4vc.issuance.OID4VCIssuerWellKnownProviderFactory -org.keycloak.protocol.oauth2.OAuth2WellKnownProviderFactory \ No newline at end of file +org.keycloak.protocol.oid4vc.issuance.JWTVCIssuerWellKnownProviderFactory +org.keycloak.protocol.oauth2.OAuth2WellKnownProviderFactory diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JWTVCIssuerWellKnownProviderTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JWTVCIssuerWellKnownProviderTest.java new file mode 100644 index 0000000000..c6bdece96f --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/JWTVCIssuerWellKnownProviderTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2024 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.testsuite.oid4vc.issuance.signing; + +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; +import org.junit.Test; +import org.keycloak.common.util.MultivaluedHashMap; +import org.keycloak.jose.jwk.JSONWebKeySet; +import org.keycloak.protocol.oid4vc.issuance.JWTVCIssuerWellKnownProviderFactory; +import org.keycloak.protocol.oid4vc.model.JWTVCIssuerMetadata; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.services.resources.RealmsResource; +import org.keycloak.testsuite.util.AdminClientUtil; +import org.keycloak.testsuite.util.OAuthClient; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.net.URI; +import java.util.List; +import java.util.Map; + +import static junit.framework.Assert.assertTrue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + + +/** + * + * @author Francis Pouatcha + */public class JWTVCIssuerWellKnownProviderTest extends OID4VCTest { + + @Test + public void getConfig() throws IOException { + String expectedIssuer = suiteContext.getAuthServerInfo().getContextRoot().toString() + "/auth/realms/" + TEST_REALM_NAME; + + try (Client client = AdminClientUtil.createResteasyClient()) { + UriBuilder builder = UriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT); + URI jwtIssuerUri = RealmsResource.wellKnownProviderUrl(builder).build("test", JWTVCIssuerWellKnownProviderFactory.PROVIDER_ID); + WebTarget jwtIssuerTarget = client.target(jwtIssuerUri); + + try (Response jwtIssuerResponse = jwtIssuerTarget.request().get()) { + JWTVCIssuerMetadata jwtvcIssuerMetadata = JsonSerialization.readValue(jwtIssuerResponse.readEntity(String.class), JWTVCIssuerMetadata.class); + assertEquals("The correct issuer should be included.", expectedIssuer, jwtvcIssuerMetadata.getIssuer()); + JSONWebKeySet jwks = jwtvcIssuerMetadata.getJwks(); + assertNotNull("The key set shall not be null", jwks.getKeys()); + assertTrue("The key set shall not be empty", jwks.getKeys().length > 0); + } + } + } + + @Override + public void configureTestRealm(RealmRepresentation testRealm) { + if (testRealm.getComponents() != null) { + testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaKeyProvider(RSA_KEY)); + testRealm.getComponents().add("org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService", getJwtSigningProvider(RSA_KEY)); + } else { + testRealm.setComponents(new MultivaluedHashMap<>( + Map.of("org.keycloak.keys.KeyProvider", List.of(getRsaKeyProvider(RSA_KEY)), + "org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService", List.of(getJwtSigningProvider(RSA_KEY)) + ))); + } + } +}