closes #25941 Signed-off-by: Stefan Wiedemann <wistefan@googlemail.com>
This commit is contained in:
parent
1213556eff
commit
fa948f37e0
10 changed files with 597 additions and 6 deletions
|
@ -17,7 +17,7 @@
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||||
<parent>
|
<parent>
|
||||||
<artifactId>keycloak-parent</artifactId>
|
<artifactId>keycloak-parent</artifactId>
|
||||||
<groupId>org.keycloak</groupId>
|
<groupId>org.keycloak</groupId>
|
||||||
|
@ -28,7 +28,7 @@
|
||||||
|
|
||||||
<artifactId>keycloak-services</artifactId>
|
<artifactId>keycloak-services</artifactId>
|
||||||
<name>Keycloak REST Services</name>
|
<name>Keycloak REST Services</name>
|
||||||
<description />
|
<description/>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<version.swagger.doclet>1.1.2</version.swagger.doclet>
|
<version.swagger.doclet>1.1.2</version.swagger.doclet>
|
||||||
|
@ -222,6 +222,7 @@
|
||||||
<groupId>org.keycloak</groupId>
|
<groupId>org.keycloak</groupId>
|
||||||
<artifactId>keycloak-model-storage-private</artifactId>
|
<artifactId>keycloak-model-storage-private</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
/*
|
||||||
|
* 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.common.util.Time;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of the {@link TimeProvider} that delegates calls to the common {@link Time} class.
|
||||||
|
*/
|
||||||
|
public class OffsetTimeProvider implements TimeProvider {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int currentTimeSeconds() {
|
||||||
|
return Time.currentTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long currentTimeMillis() {
|
||||||
|
return Time.currentTimeMillis();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
/*
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface to provide the current time
|
||||||
|
*/
|
||||||
|
public interface TimeProvider {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns current time in seconds
|
||||||
|
*
|
||||||
|
* @return see description
|
||||||
|
*/
|
||||||
|
int currentTimeSeconds();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns current time in milliseconds
|
||||||
|
*
|
||||||
|
* @return see description
|
||||||
|
*/
|
||||||
|
long currentTimeMillis();
|
||||||
|
|
||||||
|
}
|
|
@ -17,8 +17,19 @@
|
||||||
|
|
||||||
package org.keycloak.protocol.oid4vc.issuance.signing;
|
package org.keycloak.protocol.oid4vc.issuance.signing;
|
||||||
|
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
import org.keycloak.crypto.KeyWrapper;
|
||||||
|
import org.keycloak.crypto.SignatureProvider;
|
||||||
|
import org.keycloak.crypto.SignatureSignerContext;
|
||||||
|
import org.keycloak.jose.jws.JWSBuilder;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
|
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
|
||||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||||
|
import org.keycloak.representations.JsonWebToken;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link VerifiableCredentialsSigningService} implementing the JWT_VC format. It returns a string, containing the
|
* {@link VerifiableCredentialsSigningService} implementing the JWT_VC format. It returns a string, containing the
|
||||||
|
@ -29,14 +40,75 @@ import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||||
*/
|
*/
|
||||||
public class JwtSigningService extends SigningService<String> {
|
public class JwtSigningService extends SigningService<String> {
|
||||||
|
|
||||||
public JwtSigningService(KeycloakSession keycloakSession, String keyId, String algorithmType) {
|
private static final Logger LOGGER = Logger.getLogger(JwtSigningService.class);
|
||||||
|
|
||||||
|
private static final String ID_TEMPLATE = "urn:uuid:%s";
|
||||||
|
private static final String TOKEN_TYPE = "JWT";
|
||||||
|
private static final String VC_CLAIM_KEY = "vc";
|
||||||
|
private static final String ID_CLAIM_KEY = "id";
|
||||||
|
|
||||||
|
|
||||||
|
private final SignatureSignerContext signatureSignerContext;
|
||||||
|
private final TimeProvider timeProvider;
|
||||||
|
private final String tokenType;
|
||||||
|
protected final String issuerDid;
|
||||||
|
|
||||||
|
public JwtSigningService(KeycloakSession keycloakSession, String keyId, String algorithmType, String tokenType, String issuerDid, TimeProvider timeProvider) {
|
||||||
super(keycloakSession, keyId, algorithmType);
|
super(keycloakSession, keyId, algorithmType);
|
||||||
|
this.issuerDid = issuerDid;
|
||||||
|
this.timeProvider = timeProvider;
|
||||||
|
this.tokenType = tokenType;
|
||||||
|
KeyWrapper signingKey = getKey(keyId, algorithmType);
|
||||||
|
if (signingKey == null) {
|
||||||
|
throw new SigningServiceException(String.format("No key for id %s and algorithm %s available.", keyId, algorithmType));
|
||||||
|
}
|
||||||
|
SignatureProvider signatureProvider = keycloakSession.getProvider(SignatureProvider.class, algorithmType);
|
||||||
|
signatureSignerContext = signatureProvider.signer(signingKey);
|
||||||
|
|
||||||
|
LOGGER.debugf("Successfully initiated the JWT Signing Service with algorithm %s.", algorithmType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String signCredential(VerifiableCredential verifiableCredential) {
|
public String signCredential(VerifiableCredential verifiableCredential) {
|
||||||
|
LOGGER.debugf("Sign credentials to jwt-vc format.");
|
||||||
|
|
||||||
throw new UnsupportedOperationException("JWT-VC Credentials Signing is not yet supported.");
|
// Get the issuance date from the credential. Since nbf is mandatory, we set it to the current time if not
|
||||||
|
// provided
|
||||||
|
long iat = Optional.ofNullable(verifiableCredential.getIssuanceDate())
|
||||||
|
.map(issuanceDate -> issuanceDate.toInstant().getEpochSecond())
|
||||||
|
.orElse((long) timeProvider.currentTimeSeconds());
|
||||||
|
|
||||||
|
// set mandatory fields
|
||||||
|
JsonWebToken jsonWebToken = new JsonWebToken()
|
||||||
|
.issuer(verifiableCredential.getIssuer().toString())
|
||||||
|
.nbf(iat)
|
||||||
|
.id(createCredentialId(verifiableCredential));
|
||||||
|
jsonWebToken.setOtherClaims(VC_CLAIM_KEY, verifiableCredential);
|
||||||
|
|
||||||
|
// expiry is optional
|
||||||
|
Optional.ofNullable(verifiableCredential.getExpirationDate())
|
||||||
|
.ifPresent(d -> jsonWebToken.exp(d.toInstant().getEpochSecond()));
|
||||||
|
|
||||||
|
// subject id should only be set if the credential subject has an id.
|
||||||
|
Optional.ofNullable(
|
||||||
|
verifiableCredential
|
||||||
|
.getCredentialSubject()
|
||||||
|
.getClaims()
|
||||||
|
.get(ID_CLAIM_KEY))
|
||||||
|
.map(Object::toString)
|
||||||
|
.ifPresent(jsonWebToken::subject);
|
||||||
|
|
||||||
|
return new JWSBuilder()
|
||||||
|
.type(tokenType)
|
||||||
|
.jsonContent(jsonWebToken)
|
||||||
|
.sign(signatureSignerContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// retrieve the credential id from the given VC or generate one.
|
||||||
|
private String createCredentialId(VerifiableCredential verifiableCredential) {
|
||||||
|
return Optional.ofNullable(
|
||||||
|
verifiableCredential.getId())
|
||||||
|
.orElse(URI.create(String.format(ID_TEMPLATE, UUID.randomUUID())))
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -21,14 +21,19 @@ import org.keycloak.component.ComponentModel;
|
||||||
import org.keycloak.component.ComponentValidationException;
|
import org.keycloak.component.ComponentValidationException;
|
||||||
import org.keycloak.models.KeycloakSession;
|
import org.keycloak.models.KeycloakSession;
|
||||||
import org.keycloak.models.RealmModel;
|
import org.keycloak.models.RealmModel;
|
||||||
|
import org.keycloak.protocol.oid4vc.issuance.OffsetTimeProvider;
|
||||||
|
import org.keycloak.protocol.oid4vc.issuance.VCIssuerException;
|
||||||
import org.keycloak.protocol.oid4vc.model.Format;
|
import org.keycloak.protocol.oid4vc.model.Format;
|
||||||
import org.keycloak.provider.ConfigurationValidationHelper;
|
import org.keycloak.provider.ConfigurationValidationHelper;
|
||||||
import org.keycloak.provider.ProviderConfigProperty;
|
import org.keycloak.provider.ProviderConfigProperty;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provider Factory to create {@link JwtSigningService}s
|
* Provider Factory to create {@link JwtSigningService}s
|
||||||
|
*
|
||||||
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
|
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
|
||||||
*/
|
*/
|
||||||
public class JwtSigningServiceProviderFactory implements VCSigningServiceProviderFactory {
|
public class JwtSigningServiceProviderFactory implements VCSigningServiceProviderFactory {
|
||||||
|
@ -38,10 +43,18 @@ public class JwtSigningServiceProviderFactory implements VCSigningServiceProvide
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public VerifiableCredentialsSigningService create(KeycloakSession session, ComponentModel model) {
|
public VerifiableCredentialsSigningService create(KeycloakSession session, ComponentModel model) {
|
||||||
|
|
||||||
String keyId = model.get(SigningProperties.KEY_ID.getKey());
|
String keyId = model.get(SigningProperties.KEY_ID.getKey());
|
||||||
String algorithmType = model.get(SigningProperties.ALGORITHM_TYPE.getKey());
|
String algorithmType = model.get(SigningProperties.ALGORITHM_TYPE.getKey());
|
||||||
|
String tokenType = model.get(SigningProperties.TOKEN_TYPE.getKey());
|
||||||
|
String issuerDid = Optional.ofNullable(
|
||||||
|
session
|
||||||
|
.getContext()
|
||||||
|
.getRealm()
|
||||||
|
.getAttribute(ISSUER_DID_REALM_ATTRIBUTE_KEY))
|
||||||
|
.orElseThrow(() -> new VCIssuerException("No issuerDid configured."));
|
||||||
|
|
||||||
return new JwtSigningService(session, keyId, algorithmType);
|
return new JwtSigningService(session, keyId, algorithmType, tokenType, issuerDid, new OffsetTimeProvider());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -53,6 +66,7 @@ public class JwtSigningServiceProviderFactory implements VCSigningServiceProvide
|
||||||
public List<ProviderConfigProperty> getConfigProperties() {
|
public List<ProviderConfigProperty> getConfigProperties() {
|
||||||
return VCSigningServiceProviderFactory.configurationBuilder()
|
return VCSigningServiceProviderFactory.configurationBuilder()
|
||||||
.property(SigningProperties.ALGORITHM_TYPE.asConfigProperty())
|
.property(SigningProperties.ALGORITHM_TYPE.asConfigProperty())
|
||||||
|
.property(SigningProperties.TOKEN_TYPE.asConfigProperty())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,6 +78,7 @@ public class JwtSigningServiceProviderFactory implements VCSigningServiceProvide
|
||||||
@Override
|
@Override
|
||||||
public void validateSpecificConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) throws ComponentValidationException {
|
public void validateSpecificConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) throws ComponentValidationException {
|
||||||
ConfigurationValidationHelper.check(model)
|
ConfigurationValidationHelper.check(model)
|
||||||
|
.checkRequired(SigningProperties.TOKEN_TYPE.asConfigProperty())
|
||||||
.checkRequired(SigningProperties.ALGORITHM_TYPE.asConfigProperty());
|
.checkRequired(SigningProperties.ALGORITHM_TYPE.asConfigProperty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -31,6 +31,8 @@ public enum SigningProperties {
|
||||||
KEY_ID("keyId", "Id of the signing key.", "The id of the key to be used for signing credentials. The key needs to be provided as a realm key.", ProviderConfigProperty.STRING_TYPE, null),
|
KEY_ID("keyId", "Id of the signing key.", "The id of the key to be used for signing credentials. The key needs to be provided as a realm key.", ProviderConfigProperty.STRING_TYPE, null),
|
||||||
PROOF_TYPE("proofType", "Type of the LD-Proof.", "The type of LD-Proofs to be created. Needs to fit the provided signing key.", ProviderConfigProperty.STRING_TYPE, null),
|
PROOF_TYPE("proofType", "Type of the LD-Proof.", "The type of LD-Proofs to be created. Needs to fit the provided signing key.", ProviderConfigProperty.STRING_TYPE, null),
|
||||||
ALGORITHM_TYPE("algorithmType", "Type of the signing algorithm.", "The type of the algorithm to be used for signing. Needs to fit the provided signing key.", ProviderConfigProperty.STRING_TYPE, Algorithm.RS256),
|
ALGORITHM_TYPE("algorithmType", "Type of the signing algorithm.", "The type of the algorithm to be used for signing. Needs to fit the provided signing key.", ProviderConfigProperty.STRING_TYPE, Algorithm.RS256),
|
||||||
|
|
||||||
|
TOKEN_TYPE("tokenType", "Type of the token.", "The type of the token to be created. Will be used as `typ` claim in the JWT-Header.", ProviderConfigProperty.STRING_TYPE, "JWT"),
|
||||||
DECOYS("decoys", "Number of decoys to be added.", "The number of decoys to be added to the SD-JWT.", ProviderConfigProperty.STRING_TYPE, 0);
|
DECOYS("decoys", "Number of decoys to be added.", "The number of decoys to be added to the SD-JWT.", ProviderConfigProperty.STRING_TYPE, 0);
|
||||||
|
|
||||||
private final String key;
|
private final String key;
|
||||||
|
|
|
@ -38,6 +38,12 @@ import java.time.Clock;
|
||||||
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
|
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
|
||||||
*/
|
*/
|
||||||
public interface VCSigningServiceProviderFactory extends ComponentFactory<VerifiableCredentialsSigningService, VerifiableCredentialsSigningService>, EnvironmentDependentProviderFactory {
|
public interface VCSigningServiceProviderFactory extends ComponentFactory<VerifiableCredentialsSigningService, VerifiableCredentialsSigningService>, EnvironmentDependentProviderFactory {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key for the realm attribute providing the issuerDidy.
|
||||||
|
*/
|
||||||
|
String ISSUER_DID_REALM_ATTRIBUTE_KEY = "issuerDid";
|
||||||
|
|
||||||
public static ProviderConfigurationBuilder configurationBuilder() {
|
public static ProviderConfigurationBuilder configurationBuilder() {
|
||||||
return ProviderConfigurationBuilder.create()
|
return ProviderConfigurationBuilder.create()
|
||||||
.property(SigningProperties.KEY_ID.asConfigProperty());
|
.property(SigningProperties.KEY_ID.asConfigProperty());
|
||||||
|
|
|
@ -0,0 +1,237 @@
|
||||||
|
/*
|
||||||
|
* 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 com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.keycloak.TokenVerifier;
|
||||||
|
import org.keycloak.common.VerificationException;
|
||||||
|
import org.keycloak.common.crypto.CryptoIntegration;
|
||||||
|
import org.keycloak.common.util.KeyUtils;
|
||||||
|
import org.keycloak.common.util.MultivaluedHashMap;
|
||||||
|
import org.keycloak.crypto.Algorithm;
|
||||||
|
import org.keycloak.crypto.AsymmetricSignatureVerifierContext;
|
||||||
|
import org.keycloak.crypto.KeyWrapper;
|
||||||
|
import org.keycloak.crypto.ServerECDSASignatureVerifierContext;
|
||||||
|
import org.keycloak.crypto.SignatureVerifierContext;
|
||||||
|
import org.keycloak.models.KeycloakSession;
|
||||||
|
import org.keycloak.protocol.oid4vc.issuance.signing.JwtSigningService;
|
||||||
|
import org.keycloak.protocol.oid4vc.issuance.signing.SigningServiceException;
|
||||||
|
import org.keycloak.protocol.oid4vc.model.CredentialSubject;
|
||||||
|
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||||
|
import org.keycloak.representations.JsonWebToken;
|
||||||
|
import org.keycloak.representations.idm.RealmRepresentation;
|
||||||
|
import org.keycloak.testsuite.runonserver.RunOnServerException;
|
||||||
|
|
||||||
|
import java.security.PublicKey;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertNotNull;
|
||||||
|
import static org.junit.Assert.fail;
|
||||||
|
|
||||||
|
|
||||||
|
public class JwtSigningServiceTest extends SigningServiceTest {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(JwtSigningServiceTest.class);
|
||||||
|
|
||||||
|
private static KeyWrapper rsaKey = getRsaKey();
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void setup() {
|
||||||
|
CryptoIntegration.init(this.getClass().getClassLoader());
|
||||||
|
}
|
||||||
|
|
||||||
|
// If an unsupported algorithm is provided, the JWT Sigining Service should not be instantiated.
|
||||||
|
@Test(expected = SigningServiceException.class)
|
||||||
|
public void testUnsupportedAlgorithm() throws Throwable {
|
||||||
|
try {
|
||||||
|
getTestingClient()
|
||||||
|
.server(TEST_REALM_NAME)
|
||||||
|
.run(session ->
|
||||||
|
new JwtSigningService(
|
||||||
|
session,
|
||||||
|
getKeyFromSession(session).getKid(),
|
||||||
|
"did:web:test.org",
|
||||||
|
"JWT",
|
||||||
|
"unsupported-algorithm",
|
||||||
|
new StaticTimeProvider(1000)));
|
||||||
|
} catch (RunOnServerException ros) {
|
||||||
|
throw ros.getCause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no key is provided, the JWT Sigining Service should not be instantiated.
|
||||||
|
@Test(expected = SigningServiceException.class)
|
||||||
|
public void testFailIfNoKey() throws Throwable {
|
||||||
|
try {
|
||||||
|
getTestingClient()
|
||||||
|
.server(TEST_REALM_NAME)
|
||||||
|
.run(session ->
|
||||||
|
new JwtSigningService(
|
||||||
|
session,
|
||||||
|
"no-such-key",
|
||||||
|
Algorithm.RS256,
|
||||||
|
"JWT",
|
||||||
|
"did:web:test.org",
|
||||||
|
new StaticTimeProvider(1000)));
|
||||||
|
} catch (RunOnServerException ros) {
|
||||||
|
throw ros.getCause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The provided credentials should be successfully signed as a JWT-VC.
|
||||||
|
@Test
|
||||||
|
public void testRsaSignedCredentialWithOutIssuanceDate() {
|
||||||
|
getTestingClient()
|
||||||
|
.server(TEST_REALM_NAME)
|
||||||
|
.run(session ->
|
||||||
|
testSignJwtCredential(
|
||||||
|
session,
|
||||||
|
Algorithm.RS256,
|
||||||
|
Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()),
|
||||||
|
"test", "test",
|
||||||
|
"arrayClaim", List.of("a", "b", "c"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testRsaSignedCredentialWithIssuanceDate() {
|
||||||
|
getTestingClient()
|
||||||
|
.server(TEST_REALM_NAME)
|
||||||
|
.run(session ->
|
||||||
|
testSignJwtCredential(
|
||||||
|
session,
|
||||||
|
Algorithm.RS256,
|
||||||
|
Map.of("id", String.format("uri:uuid:%s", UUID.randomUUID()),
|
||||||
|
"test", "test",
|
||||||
|
"arrayClaim", List.of("a", "b", "c"),
|
||||||
|
"issuanceDate", Date.from(Instant.ofEpochSecond(10)))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testRsaSignedCredentialWithoutAdditionalClaims() {
|
||||||
|
getTestingClient()
|
||||||
|
.server(TEST_REALM_NAME)
|
||||||
|
.run(session ->
|
||||||
|
testSignJwtCredential(
|
||||||
|
session,
|
||||||
|
Algorithm.RS256,
|
||||||
|
Map.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static void testSignJwtCredential(KeycloakSession session, String algorithm, Map<String, Object> claims) {
|
||||||
|
KeyWrapper keyWrapper = getKeyFromSession(session);
|
||||||
|
|
||||||
|
JwtSigningService jwtSigningService = new JwtSigningService(
|
||||||
|
session,
|
||||||
|
keyWrapper.getKid(),
|
||||||
|
algorithm,
|
||||||
|
"JWT",
|
||||||
|
"did:web:test.org",
|
||||||
|
new StaticTimeProvider(1000));
|
||||||
|
|
||||||
|
VerifiableCredential testCredential = getTestCredential(claims);
|
||||||
|
|
||||||
|
String jwtCredential = jwtSigningService.signCredential(testCredential);
|
||||||
|
|
||||||
|
SignatureVerifierContext verifierContext = null;
|
||||||
|
switch (algorithm) {
|
||||||
|
case Algorithm.ES256: {
|
||||||
|
verifierContext = new ServerECDSASignatureVerifierContext(keyWrapper);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Algorithm.RS256: {
|
||||||
|
verifierContext = new AsymmetricSignatureVerifierContext(keyWrapper);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
fail("Algorithm not supported.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TokenVerifier<JsonWebToken> verifier = TokenVerifier
|
||||||
|
.create(jwtCredential, JsonWebToken.class)
|
||||||
|
.verifierContext(verifierContext);
|
||||||
|
verifier.publicKey((PublicKey) keyWrapper.getPublicKey());
|
||||||
|
try {
|
||||||
|
verifier.verify();
|
||||||
|
} catch (VerificationException e) {
|
||||||
|
fail("The credential should successfully be verified.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
JsonWebToken theToken = verifier.getToken();
|
||||||
|
|
||||||
|
assertEquals("JWT claim in JWT encoded VC or VP MUST be used to set the value of the “expirationDate” of the VC", TEST_EXPIRATION_DATE.toInstant().getEpochSecond(), theToken.getExp().longValue());
|
||||||
|
if (claims.containsKey("issuanceDate")) {
|
||||||
|
assertEquals("VC Data Model v1.1 specifies that “issuanceDate” property MUST be represented as an nbf JWT claim, and not iat JWT claim.", ((Date) claims.get("issuanceDate")).toInstant().getEpochSecond(), theToken.getNbf().longValue());
|
||||||
|
} else {
|
||||||
|
// if not specific date is set, check against "currentTime"
|
||||||
|
assertEquals("VC Data Model v1.1 specifies that “issuanceDate” property MUST be represented as an nbf JWT claim, and not iat JWT claim.", TEST_ISSUANCE_DATE.toInstant().getEpochSecond(), theToken.getNbf().longValue());
|
||||||
|
}
|
||||||
|
assertEquals("The issuer should be set in the token.", TEST_DID.toString(), theToken.getIssuer());
|
||||||
|
assertEquals("The credential ID should be set as the token ID.", testCredential.getId().toString(), theToken.getId());
|
||||||
|
Optional.ofNullable(testCredential.getCredentialSubject().getClaims().get("id")).ifPresent(id -> assertEquals("If the credentials subject id is set, it should be set as the token subject.", id.toString(), theToken.getSubject()));
|
||||||
|
|
||||||
|
assertNotNull("The credentials should be included at the vc-claim.", theToken.getOtherClaims().get("vc"));
|
||||||
|
VerifiableCredential credential = new ObjectMapper().convertValue(theToken.getOtherClaims().get("vc"), VerifiableCredential.class);
|
||||||
|
assertEquals("The types should be included", TEST_TYPES, credential.getType());
|
||||||
|
assertEquals("The issuer should be included", TEST_DID, credential.getIssuer());
|
||||||
|
assertEquals("The expiration date should be included", TEST_EXPIRATION_DATE, credential.getExpirationDate());
|
||||||
|
if (claims.containsKey("issuanceDate")) {
|
||||||
|
assertEquals("The issuance date should be included", claims.get("issuanceDate"), credential.getIssuanceDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
CredentialSubject subject = credential.getCredentialSubject();
|
||||||
|
claims.entrySet().stream()
|
||||||
|
.filter(e -> !e.getKey().equals("issuanceDate"))
|
||||||
|
.forEach(e -> assertEquals(String.format("All additional claims should be set - %s is incorrect", e.getKey()), e.getValue(), subject.getClaims().get(e.getKey())));
|
||||||
|
} catch (VerificationException e) {
|
||||||
|
fail("Was not able to get the token from the verifier.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyWrapper getKeyFromSession(KeycloakSession keycloakSession) {
|
||||||
|
// we only set one key to the realm, thus can just take the first one
|
||||||
|
// if run inside the testsuite, configure is called seperated from the test itself, thus we cannot just take
|
||||||
|
// the key from the `configureTestRealm` method.
|
||||||
|
return keycloakSession
|
||||||
|
.keys()
|
||||||
|
.getKeysStream(keycloakSession.getContext().getRealm())
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new RuntimeException("No key was configured"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configureTestRealm(RealmRepresentation testRealm) {
|
||||||
|
if (testRealm.getComponents() != null) {
|
||||||
|
testRealm.getComponents().add("org.keycloak.keys.KeyProvider", getRsaKeyProvider(rsaKey));
|
||||||
|
} else {
|
||||||
|
testRealm.setComponents(new MultivaluedHashMap<>(
|
||||||
|
Map.of("org.keycloak.keys.KeyProvider", List.of(getRsaKeyProvider(rsaKey)))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,182 @@
|
||||||
|
/*
|
||||||
|
* 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 org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
import org.keycloak.common.util.CertificateUtils;
|
||||||
|
import org.keycloak.common.util.KeyUtils;
|
||||||
|
import org.keycloak.common.util.MultivaluedHashMap;
|
||||||
|
import org.keycloak.common.util.PemUtils;
|
||||||
|
import org.keycloak.crypto.KeyUse;
|
||||||
|
import org.keycloak.crypto.KeyWrapper;
|
||||||
|
import org.keycloak.protocol.oid4vc.issuance.TimeProvider;
|
||||||
|
import org.keycloak.protocol.oid4vc.model.CredentialSubject;
|
||||||
|
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||||
|
import org.keycloak.representations.idm.ComponentExportRepresentation;
|
||||||
|
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.NoSuchProviderException;
|
||||||
|
import java.security.PrivateKey;
|
||||||
|
import java.security.PublicKey;
|
||||||
|
import java.security.Security;
|
||||||
|
import java.security.cert.Certificate;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Super class for all signing service tests. Provides convenience methods to ease the testing.
|
||||||
|
*/
|
||||||
|
public abstract class SigningServiceTest extends AbstractTestRealmKeycloakTest {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = Logger.getLogger(SigningServiceTest.class);
|
||||||
|
protected static final String CONTEXT_URL = "https://www.w3.org/2018/credentials/v1";
|
||||||
|
protected static final URI TEST_DID = URI.create("did:web:test.org");
|
||||||
|
protected static final List<String> TEST_TYPES = List.of("VerifiableCredential");
|
||||||
|
protected static final Date TEST_EXPIRATION_DATE = Date.from(Instant.ofEpochSecond(2000));
|
||||||
|
protected static final Date TEST_ISSUANCE_DATE = Date.from(Instant.ofEpochSecond(1000));
|
||||||
|
|
||||||
|
protected static CredentialSubject getCredentialSubject(Map<String, Object> claims) {
|
||||||
|
CredentialSubject credentialSubject = new CredentialSubject();
|
||||||
|
claims.forEach(credentialSubject::setClaims);
|
||||||
|
return credentialSubject;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static VerifiableCredential getTestCredential(Map<String, Object> claims) {
|
||||||
|
|
||||||
|
VerifiableCredential testCredential = new VerifiableCredential();
|
||||||
|
testCredential.setId(URI.create(String.format("uri:uuid:%s", UUID.randomUUID())));
|
||||||
|
testCredential.setContext(List.of(CONTEXT_URL));
|
||||||
|
testCredential.setType(TEST_TYPES);
|
||||||
|
testCredential.setIssuer(TEST_DID);
|
||||||
|
testCredential.setExpirationDate(TEST_EXPIRATION_DATE);
|
||||||
|
if (claims.containsKey("issuanceDate")) {
|
||||||
|
testCredential.setIssuanceDate((Date) claims.get("issuanceDate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
testCredential.setCredentialSubject(getCredentialSubject(claims));
|
||||||
|
return testCredential;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static KeyWrapper getECKey(String keyId) {
|
||||||
|
try {
|
||||||
|
Security.addProvider(new BouncyCastleProvider());
|
||||||
|
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC", "BC");
|
||||||
|
kpg.initialize(256);
|
||||||
|
var keyPair = kpg.generateKeyPair();
|
||||||
|
KeyWrapper kw = new KeyWrapper();
|
||||||
|
kw.setPrivateKey(keyPair.getPrivate());
|
||||||
|
kw.setPublicKey(keyPair.getPublic());
|
||||||
|
kw.setUse(KeyUse.SIG);
|
||||||
|
kw.setKid(keyId);
|
||||||
|
kw.setType("EC");
|
||||||
|
kw.setAlgorithm("ES256");
|
||||||
|
return kw;
|
||||||
|
|
||||||
|
} catch (NoSuchAlgorithmException | NoSuchProviderException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static KeyWrapper getEd25519Key(String keyId) {
|
||||||
|
try {
|
||||||
|
Security.addProvider(new BouncyCastleProvider());
|
||||||
|
KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519", "BC");
|
||||||
|
var keyPair = kpg.generateKeyPair();
|
||||||
|
KeyWrapper kw = new KeyWrapper();
|
||||||
|
kw.setPrivateKey(keyPair.getPrivate());
|
||||||
|
kw.setPublicKey(keyPair.getPublic());
|
||||||
|
kw.setUse(KeyUse.SIG);
|
||||||
|
kw.setKid(keyId);
|
||||||
|
kw.setType("Ed25519");
|
||||||
|
return kw;
|
||||||
|
|
||||||
|
} catch (NoSuchAlgorithmException | NoSuchProviderException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static KeyWrapper getRsaKey() {
|
||||||
|
try {
|
||||||
|
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
|
||||||
|
kpg.initialize(2048);
|
||||||
|
var keyPair = kpg.generateKeyPair();
|
||||||
|
KeyWrapper kw = new KeyWrapper();
|
||||||
|
kw.setPrivateKey(keyPair.getPrivate());
|
||||||
|
kw.setPublicKey(keyPair.getPublic());
|
||||||
|
kw.setUse(KeyUse.SIG);
|
||||||
|
kw.setKid(KeyUtils.createKeyId(keyPair.getPublic()));
|
||||||
|
kw.setType("RSA");
|
||||||
|
kw.setAlgorithm("RS256");
|
||||||
|
return kw;
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected ComponentExportRepresentation getRsaKeyProvider(KeyWrapper keyWrapper) {
|
||||||
|
ComponentExportRepresentation componentExportRepresentation = new ComponentExportRepresentation();
|
||||||
|
componentExportRepresentation.setName("rsa-key-provider");
|
||||||
|
componentExportRepresentation.setId(UUID.randomUUID().toString());
|
||||||
|
componentExportRepresentation.setProviderId("rsa");
|
||||||
|
|
||||||
|
|
||||||
|
Certificate certificate = CertificateUtils.generateV1SelfSignedCertificate(
|
||||||
|
new KeyPair((PublicKey) keyWrapper.getPublicKey(), (PrivateKey) keyWrapper.getPrivateKey()), "TestKey");
|
||||||
|
|
||||||
|
componentExportRepresentation.setConfig(new MultivaluedHashMap<>(
|
||||||
|
Map.of(
|
||||||
|
"privateKey", List.of(PemUtils.encodeKey(keyWrapper.getPrivateKey())),
|
||||||
|
"certificate", List.of(PemUtils.encodeCertificate(certificate)),
|
||||||
|
"active", List.of("true"),
|
||||||
|
"priority", List.of("0"),
|
||||||
|
"enabled", List.of("true"),
|
||||||
|
"algorithm", List.of("RS256")
|
||||||
|
)
|
||||||
|
));
|
||||||
|
return componentExportRepresentation;
|
||||||
|
}
|
||||||
|
|
||||||
|
static class StaticTimeProvider implements TimeProvider {
|
||||||
|
private final int currentTimeInS;
|
||||||
|
|
||||||
|
StaticTimeProvider(int currentTimeInS) {
|
||||||
|
this.currentTimeInS = currentTimeInS;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int currentTimeSeconds() {
|
||||||
|
return currentTimeInS;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long currentTimeMillis() {
|
||||||
|
return currentTimeInS * 1000L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -27,6 +27,7 @@ metrics,4
|
||||||
migration,4
|
migration,4
|
||||||
model,6
|
model,6
|
||||||
oauth,6
|
oauth,6
|
||||||
|
oid4vc,6
|
||||||
oidc,6
|
oidc,6
|
||||||
policy,6
|
policy,6
|
||||||
providers,4
|
providers,4
|
||||||
|
|
Loading…
Reference in a new issue