8000 [OID4VCI] Add comprehensive testing for pre-installed vc configs by tdiesler · Pull Request #47947 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,6 @@ public class SupportedCredentialConfiguration {

private static final Logger LOGGER = Logger.getLogger(SupportedCredentialConfiguration.class);

public static final String DOT_SEPARATOR = ".";

@JsonIgnore
private static final String FORMAT_KEY = "format";
@JsonIgnore
Expand Down
10BC8
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@
import java.util.Set;
import java.util.function.Consumer;

import org.keycloak.OID4VCConstants;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.crypto.KeyWrapper;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKBuilder;
import org.keycloak.jose.jws.JWSHeader;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;
Expand Down Expand Up @@ -48,7 +51,6 @@
import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE;
import static org.keycloak.tests.oid4vc.OID4VCIssuerTestBase.TEST_PASSWORD;
import static org.keycloak.tests.oid4vc.OID4VCIssuerTestBase.VCTestRealmConfig.TEST_REALM_NAME;
import static org.keycloak.tests.oid4vc.OID4VCProofTestUtils.createEcKeyPair;
import static org.keycloak.tests.oid4vc.OID4VCTestContext.ACCESS_TOKEN_RESPONSE_ATTACHMENT_KEY;
import static org.keycloak.tests.oid4vc.OID4VCTestContext.AUTHORIZATION_SERVER_METADATA_ATTACHMENT_KEY;
import static org.keycloak.tests.oid4vc.OID4VCTestContext.AttachmentKey;
Expand Down Expand Up @@ -160,19 +162,41 @@ public OIDCConfigurationRepresentation getAuthorizationServerMetadata(OID4VCTest
return authServerMetadata;
}

public Proofs generateJwtProof(OID4VCTestContext ctx, String user) {
public Proofs generateAttestationProof(OID4VCTestContext ctx, Consumer<KeyWrapper> attestationKeyConsumer) {
KeyWrapper attestationKey = getECKeyPair(ctx, "attestationKey");
KeyWrapper proofKey = getECKeyPair(ctx, "proofKey");

JWK proofJwk = JWKBuilder.create().ec(proofKey.getPublicKey());
proofJwk.setKeyId(proofKey.getKid());
proofJwk.setAlgorithm(proofKey.getAlgorithm());

String nonce = oauth.oid4vc().doNonceRequest().getNonce();
Proofs proofs = Proofs.create(ProofType.ATTESTATION, OID4VCProofTestUtils.generateAttestationProof(
attestationKey,
nonce,
List.of(proofJwk),
List.of(OID4VCConstants.KeyAttestationResistanceLevels.HIGH),
List.of(OID4VCConstants.KeyAttestationResistanceLevels.HIGH),
null
));
attestationKeyConsumer.accept(attestationKey);
return proofs;
}

public Proofs generateJwtProof(OID4VCTestContext ctx) {
String aud = getIssuerMetadata(ctx).getCredentialIssuer();
String nonce = oauth.oid4vc().nonceRequest().send().getNonce();
KeyWrapper kw = getECKeyPair(ctx, user, null);
String nonce = oauth.oid4vc().doNonceRequest().getNonce();
KeyWrapper kw = getECKeyPair(ctx, null);
return Proofs.create(ProofType.JWT, OID4VCProofTestUtils.generateJwtProof(aud, kw, nonce));
}

public KeyWrapper getECKeyPair(OID4VCTestContext ctx, String user, String keyId) {
String cacheKey = user + (keyId != null ? "_" + keyId : "");
public KeyWrapper getECKeyPair(OID4VCTestContext ctx, String keyId) {
String cacheKey = keyId != null ? keyId : ctx.getHolder() + "_key";
AttachmentKey<KeyWrapper> attachmentKey = new AttachmentKey<>(cacheKey, KeyWrapper.class);
KeyWrapper kw = ctx.getAttachment(attachmentKey);
if (kw == null) {
kw = createEcKeyPair();
kw = OID4VCProofTestUtils.createEcKeyPair();
if (keyId != null) kw.setKid(keyId);
ctx.putAttachment(attachmentKey, kw);
}
return kw;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package org.keycloak.tests.oid4vc;

import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import org.keycloak.TokenVerifier;
import org.keycloak.jose.jwk.JWK;
import org.keycloak.jose.jwk.JWKBuilder;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation;
import org.keycloak.protocol.oid4vc.model.Proofs;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
import org.keycloak.representations.JsonWebToken;
import org.keycloak.sdjwt.IssuerSignedJWT;
import org.keycloak.sdjwt.vp.SdJwtVP;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import org.keycloak.testsuite.util.oauth.AccessTokenResponse;
import org.keycloak.testsuite.util.oauth.AuthorizationEndpointResponse;
import org.keycloak.testsuite.util.oauth.PkceGenerator;
import org.keycloak.util.JsonSerialization;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.keycloak.OID4VCConstants.CLAIM_NAME_VCT;
import static org.keycloak.VCFormat.JWT_VC;
import static org.keycloak.VCFormat.SD_JWT_VC;
import static org.keycloak.constants.OID4VCIConstants.TRUSTED_KEYS_REALM_ATTR;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerConfig.class)
public class OID4VCINaturalPersonTest extends OID4VCIssuerTestBase {

@BeforeEach
void beforeEach() {
oauth.client(pubClient.getClientId());
}

@Test
public void testNaturalPersonJwt_AttestationProof() throws Exception {

var ctx = new OID4VCTestContext(client, jwtNaturalPersonCredentialScope);

Proofs proofs = wallet.generateAttestationProof(ctx, ak -> {
JWK trustedKey = JWKBuilder.create().kid(ak.getKid()).ec(ak.getPublicKey());
String trustedKeyJson = JsonSerialization.valueAsString(List.of(trustedKey));
setRealmAttributes(Map.of(TRUSTED_KEYS_REALM_ATTR, trustedKeyJson));
});

String accessToken = getAccessToken(ctx);
CredentialResponse credResponse = getCredentialResponse(ctx, accessToken, proofs);
verifyCredentialResponse(ctx, ctx.getHolder(), credResponse);
}

@Test
public void testNaturalPersonJwt_JwtProof() throws Exception {

var ctx = new OID4VCTestContext(client, jwtNaturalPersonCredentialScope);

String accessToken = getAccessToken(ctx);
Proofs proofs = wallet.generateJwtProof(ctx);
CredentialResponse credResponse = getCredentialResponse(ctx, accessToken, proofs);
verifyCredentialResponse(ctx, ctx.getHolder(), credResponse);
}

@Test
public void testNaturalPersonSdJwt_AttestationProof() throws Exception {

var ctx = new OID4VCTestContext(client, sdJwtNaturalPersonCredentialScope);

Proofs proofs = wallet.generateAttestationProof(ctx, ak -> {
JWK trustedKey = JWKBuilder.create().kid(ak.getKid()).ec(ak.getPublicKey());
String trustedKeyJson = JsonSerialization.valueAsString(List.of(trustedKey));
setRealmAttributes(Map.of(TRUSTED_KEYS_REALM_ATTR, trustedKeyJson));
});

String accessToken = getAccessToken(ctx);
CredentialResponse credResponse = getCredentialResponse(ctx, accessToken, proofs);
verifyCredentialResponse(ctx, ctx.getHolder(), credResponse);
}


@Test
public void testNaturalPersonSdJwt_JwtProof() throws Exception {

var ctx = new OID4VCTestContext(client, sdJwtNaturalPersonCredentialScope);

String accessToken = getAccessToken(ctx);
Proofs proofs = wallet.generateJwtProof(ctx);
CredentialResponse credResponse = getCredentialResponse(ctx, accessToken, proofs);
verifyCredentialResponse(ctx, ctx.getHolder(), credResponse);
}

// Private ---------------------------------------------------------------------------------------------------------

private String getAccessToken(OID4VCTestContext ctx) {

PkceGenerator pkce = PkceGenerator.s256();
AuthorizationEndpointResponse authResponse = wallet
.authorizationRequest()
.scope(ctx.getScope())
.codeChallenge(pkce)
.send(ctx.getHolder(), TEST_PASSWORD);
String authCode = authResponse.getCode();
assertNotNull(authCode, "No authCode");

AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authCode)
.codeVerifier(pkce)
.send();
String accessToken = wallet.validateHolderAccessToken(ctx, tokenResponse);
assertNotNull(accessToken, "No accessToken");
return accessToken;
}

private CredentialResponse getCredentialResponse(OID4VCTestContext ctx, String accessToken, Proofs proofs) {

String authorizedIdentifier = ctx.getAuthorizedCredentialIdentifier();
assertNotNull(authorizedIdentifier, "No authorized credential identifier");

CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken)
.credentialIdentifier(authorizedIdentifier)
.proofs(proofs)
.send().getCredentialResponse();
return credResponse;
}

private void verifyCredentialResponse(OID4VCTestContext ctx, String expUser, CredentialResponse credResponse) throws Exception {

CredentialScopeRepresentation credScope = ctx.getCredentialScope();
String issuer = wallet.getIssuerMetadata(ctx).getCredentialIssuer();
CredentialResponse.Credential credentialObj = credResponse.getCredentials().get(0);
assertNotNull(credentialObj, "The first credential in the array should not be null");

switch (credScope.getFormat()) {
case JWT_VC -> {
JsonWebToken vcJwt = TokenVerifier.create((String) credentialObj.getCredential(), JsonWebToken.class).getToken();
assertEquals(issuer, vcJwt.getIssuer());
Object vc = vcJwt.getOtherClaims().get("vc");
VerifiableCredential credential = JsonSerialization.mapper.convertValue(vc, VerifiableCredential.class);
assertEquals(credScope.getSupportedCredentialTypes(), credential.getType());
assertEquals(URI.create(issuer), credential.getIssuer());
assertEquals(expUser + "@email.cz", credential.getCredentialSubject().getClaims().get("email"));
}
case SD_JWT_VC -> {
SdJwtVP sdJwtVP = SdJwtVP.of(credentialObj.getCredential().toString());
IssuerSignedJWT issuerSignedJWT = sdJwtVP.getIssuerSignedJWT();
JsonWebToken vcSdJwt = TokenVerifier.create(issuerSignedJWT.getJws(), JsonWebToken.class).getToken();
Map<String, Object> otherClaims = vcSdJwt.getOtherClaims();
assertEquals(issuer, vcSdJwt.getIssuer());
assertEquals(credScope.getVct(), otherClaims.get(CLAIM_NAME_VCT));

Map<String, String> claims = sdJwtVP.getClaims().values().stream().collect(Collectors.toMap(
arrayNode -> arrayNode.get(1).asText(),
arrayNode -> arrayNode.get(2).asText()
));
assertEquals(Map.of(
"firstName", "Alice",
"familyName", "Wonderland",
"email", "alice@email.cz",
"sub", "did:key:5678"
), claims);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
import org.keycloak.OID4VCConstants;
import org.keycloak.VCFormat;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.admin.client.resource.ClientScopeResource;
import org.keycloak.admin.client.resource.ClientScopesResource;
import org.keycloak.admin.client.resource.RealmResource;
Expand Down Expand Up @@ -113,6 +112,9 @@ public abstract class OID4VCIssuerTestBase {
public static final String TEST_USER = "john";
public static final String TEST_PASSWORD = "password";

public static final String jwtTypeNaturalPersonScopeName = "oid4vc_natural_person_jwt";
public static final String sdJwtTypeNaturalPersonScopeName = "oid4vc_natural_person_sd";

public static final String sdJwtTypeCredentialScopeName = "sd-jwt-credential";
public static final String sdJwtTypeCredentialConfigurationIdName = "sd-jwt-credential-config-id";
public static final String sdJwtTypeCredentialVct = "https://credentials.example.com/SD-JWT-Credential";
Expand All @@ -125,7 +127,7 @@ public abstract class OID4VCIssuerTestBase {
@InjectRealm(config = VCTestRealmConfig.class)
protected ManagedRealm testRealm;

@InjectClient(ref = "oid4vci-client", config = ConfidentialOID4VCIClient.class)
@InjectClient(ref = "oid4vci-client", config = PrivateOID4VCIClient.class)
protected ManagedClient managedClient;

@InjectClient(ref = OID4VCI_PUBLIC_CLIENT_ID, config = PublicOID4VCIClient.class)
Expand All @@ -149,9 +151,11 @@ public abstract class OID4VCIssuerTestBase {
@InjectKeycloakUrls
protected KeycloakUrls keycloakUrls;

protected CredentialScopeRepresentation minimalJwtTypeCredentialScope;
protected CredentialScopeRepresentation jwtTypeCredentialScope;
protected CredentialScopeRepresentation sdJwtTypeCredentialScope;
protected CredentialScopeRepresentation minimalJwtTypeCredentialScope;
protected CredentialScopeRepresentation jwtNaturalPersonCredentialScope;
protected CredentialScopeRepresentation sdJwtNaturalPersonCredentialScope;

protected ClientRepresentation client;
protected ClientRepresentation pubClient;
Expand All @@ -174,8 +178,10 @@ void beforeEachBase() {
pubClient = managedPublicClient.admin().toRepresentation();

jwtTypeCredentialScope = requireExistingCredentialScope(jwtTypeCredentialScopeName);
minimalJwtTypeCredentialScope = requireExistingCredentialScope(minimalJwtTypeCredentialScopeName);
sdJwtTypeCredentialScope = requireExistingCredentialScope(sdJwtTypeCredentialScopeName);
minimalJwtTypeCredentialScope = requireExistingCredentialScope(minimalJwtTypeCredentialScopeName);
jwtNaturalPersonCredentialScope = requireExistingCredentialScope(jwtTypeNaturalPersonScopeName);
sdJwtNaturalPersonCredentialScope = requireExistingCredentialScope(sdJwtTypeNaturalPersonScopeName);

oauth.client(client.getClientId(), client.getSecret());
enableVerifiableCredentialEvents(testRealm);
Expand Down Expand Up @@ -576,15 +582,23 @@ private List<ProtocolMapperRepresentation> resolveProtocolMappers(String protoco
}
}

public static class ConfidentialOID4VCIClient implements ClientConfig {
public static class PrivateOID4VCIClient implements ClientConfig {

@Override
public ClientConfigBuilder configure(ClientConfigBuilder client) {
String[] optionalClientScopes = {
jwtTypeCredentialScopeName,
sdJwtTypeCredentialScopeName,
minimalJwtTypeCredentialScopeName,
jwtTypeNaturalPersonScopeName,
sdJwtTypeNaturalPersonScopeName,
"email"
};
client.clientId(OID4VCI_CLIENT_ID)
.serviceAccountsEnabled(true)
.directAccessGrantsEnabled(true)
.defaultClientScopes("basic", "profile", "roles")
.optionalClientScopes(jwtTypeCredentialScopeName, minimalJwtTypeCredentialScopeName, sdJwtTypeCredentialScopeName, "email")
.optionalClientScopes(optionalClientScopes)
.attribute(OID4VCI_ENABLED_ATTRIBUTE_KEY, "true")
.redirectUris("*")
.secret("test-secret");
Expand All @@ -596,10 +610,18 @@ public static class PublicOID4VCIClient implements ClientConfig {

@Override
public ClientConfigBuilder configure(ClientConfigBuilder client) {
String[] optionalClientScopes = {
jwtTypeCredentialScopeName,
sdJwtTypeCredentialScopeName,
minimalJwtTypeCredentialScopeName,
jwtTypeNaturalPersonScopeName,
sdJwtTypeNaturalPersonScopeName,
"email"
};
client.clientId(OID4VCI_PUBLIC_CLIENT_ID)
.publicClient(true)
.defaultClientScopes("basic", "profile", "roles")
.optionalClientScopes(jwtTypeCredentialScopeName, minimalJwtTypeCredentialScopeName, sdJwtTypeCredentialScopeName, "email")
.optionalClientScopes(optionalClientScopes)
.redirectUris("http://127.0.0.1:8500/callback/oauth")
.attribute(OID4VCI_ENABLED_ATTRIBUTE_KEY, "true")
.attribute("pkce.code.challenge.method", "S256"); // require PKCE
Expand Down Expand Up @@ -731,12 +753,4 @@ public long currentTimeMillis() {
return currentTimeInS * 1000L;
}
}

protected void setOid4vciEnabled(ClientRepresentation testClient, boolean enabled) {
ClientResource clientResource = testRealm.admin().clients().get(testClient.getId());
Map<String, String> attributes = Optional.ofNullable(testClient.getAttributes()).orElse(new HashMap<>());
attributes.put(OID4VCI_ENABLED_ATTRIBUTE_KEY, String.valueOf(enabled));
testClient.setAttributes(attributes);
clientResource.update(testClient);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public static String generateAttestationProof(
String certification
) {
KeyAttestationJwtBody body = new KeyAttestationJwtBody();
body.setIat((long) (System.currentTimeMillis() / 1000));
body.setIat(System.currentTimeMillis() / 1000);
body.setNonce(nonce);
body.setAttestedKeys(attestedKeys);
body.setKeyStorage(keyStorage);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,7 @@ public class OID4VCPublicClientTest extends OID4VCIssuerTestBase {

@BeforeEach
void beforeEach() {
// Reconfigure OAuthClient
oauth.client(pubClient.getClientId(), null);
oauth.client(pubClient.getClientId());
}

@Test
Expand Down Expand Up @@ -99,7 +98,7 @@ public void testCredentialFromPublicClient() throws Exception {
//
CredentialResponse credResponse = wallet.credentialRequest(ctx, accessToken)
.credentialIdentifier(authorizedIdentifier)
.proofs(wallet.generateJwtProof(ctx, ctx.getHolder()))
.proofs(wallet.generateJwtProof(ctx))
.send().getCredentialResponse();

verifyCredentialResponse(ctx, ctx.getHolder(), credResponse);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ public <T> void putAttachment(AttachmentKey<T> key, T value) {
if (value != null) {
attachments.put(key, value);
} else {
attachments.remove(key, value);
attachments.remove(key);
}
}

Expand Down
Loading
Loading
0