JWT VC Issuer Metadata /.well-known/jwt-vc-issuer to comply with SD-JWT VC Specification (#29635)

closes #29634 

Signed-off-by: Francis Pouatcha <francis.pouatcha@adorsys.com>


Co-authored-by: DYLANE BENGONO <85441363+bengo237@users.noreply.github.com>
This commit is contained in:
Francis Pouatcha 2024-05-28 11:51:56 +01:00 committed by GitHub
parent 68d9dcecb5
commit 4317a474d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 320 additions and 31 deletions

View file

@ -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
* <p/>
* {@see https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-03.html#name-jwt-vc-issuer-metadata}
*
* @author <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
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;
}
}

View file

@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
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;
}
}

View file

@ -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
* <p/>
* {@see https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-03.html#name-jwt-vc-issuer-metadata}
*
* @author <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/
@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;
}
}

View file

@ -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<X509Certificate> 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);

View file

@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/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<X509Certificate> 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;
}
}

View file

@ -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
org.keycloak.protocol.oid4vc.issuance.JWTVCIssuerWellKnownProviderFactory
org.keycloak.protocol.oauth2.OAuth2WellKnownProviderFactory

View file

@ -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 <a href="mailto:francis.pouatcha@adorsys.com">Francis Pouatcha</a>
*/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))
)));
}
}
}