From c8288a44d402d39aa9139e2c2a238ae9dd50900e Mon Sep 17 00:00:00 2001 From: Thomas Diesler Date: Thu, 21 May 2026 20:40:30 +0200 Subject: [PATCH] [OID4VCI-HAIP] Pass oid4vci-1_0-issuer-happy-flow closes #47316 -- add x5c certificates to header except for trust anchor -- add DPoP to token and credential request -- add DPoP support to basic wallet -- explicit usage of nonce in credential proof Signed-off-by: Thomas Diesler --- .../signing/SdJwtCredentialSigner.java | 19 +- .../tests/oid4vc/OID4VCIssuerTestBase.java | 32 +++- ...estationBasedClientAuthenticationTest.java | 30 --- .../haip/HAIPIssuerConformanceTest.java | 180 ++++++++++++++++++ 4 files changed, 226 insertions(+), 35 deletions(-) create mode 100644 tests/base/src/test/java/org/keycloak/tests/oid4vc/haip/HAIPIssuerConformanceTest.java diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtCredentialSigner.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtCredentialSigner.java index fc820700f0ce..14bb307a3615 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtCredentialSigner.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/signing/SdJwtCredentialSigner.java @@ -22,7 +22,7 @@ import java.util.Base64; import java.util.List; import java.util.Objects; -import java.util.stream.Collectors; +import javax.security.auth.x500.X500Principal; import org.keycloak.crypto.SignatureSignerContext; import org.keycloak.models.KeycloakSession; @@ -80,10 +80,10 @@ public String signCredential(CredentialBody credentialBody, CredentialBuildConfi */ private void addX5cHeader(SdJwtCredentialBody sdJwtCredentialBody, SignatureSignerContext signer) { List certificateChain = signer.getCertificateChain(); - if (certificateChain != null && !certificateChain.isEmpty()) { List x5cList = certificateChain.stream() .filter(Objects::nonNull) + .filter(cert -> !isTrustAnchor(cert)) .map(cert -> { try { return Base64.getEncoder().encodeToString(cert.getEncoded()); @@ -91,7 +91,7 @@ private void addX5cHeader(SdJwtCredentialBody sdJwtCredentialBody, SignatureSign throw new RuntimeException(e); } }) - .collect(Collectors.toList()); + .toList(); if (!x5cList.isEmpty()) { sdJwtCredentialBody.getIssuerSignedJWT().getJwsHeader().setX5c(x5cList); @@ -102,4 +102,17 @@ private void addX5cHeader(SdJwtCredentialBody sdJwtCredentialBody, SignatureSign LOGGER.debugf("No certificate or certificate chain available for x5c header in SD-JWT credential."); } } + + private boolean isTrustAnchor(X509Certificate cert) { + boolean isTrustAnchor = false; + try { + int basicConstraints = cert.getBasicConstraints(); + X500Principal issuerPrincipal = cert.getIssuerX500Principal(); + X500Principal subjectPrincipal = cert.getSubjectX500Principal(); + isTrustAnchor = subjectPrincipal.equals(issuerPrincipal) && basicConstraints >= 0; + } catch (Exception e) { + // ignore + } + return isTrustAnchor; + } } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java index 87b41fba60c5..b09f3da6eeed 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerTestBase.java @@ -43,8 +43,10 @@ import org.keycloak.crypto.KeyWrapper; import org.keycloak.events.EventType; import org.keycloak.keys.KeyProvider; +import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.KeyManager; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.protocol.oid4vc.clientpolicy.CredentialClientPolicyExecutorFactory; @@ -122,6 +124,7 @@ import static org.keycloak.OID4VCConstants.CLAIM_NAME_SUBJECT_ID; import static org.keycloak.OID4VCConstants.OID4VCI_ENABLED_ATTRIBUTE_KEY; import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL; +import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS; import static org.keycloak.constants.OID4VCIConstants.CREDENTIAL_OFFER_CREATE; import static org.keycloak.models.Constants.CREATE_DEFAULT_CLIENT_SCOPES; import static org.keycloak.models.oid4vci.CredentialScopeModel.CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT; @@ -266,7 +269,32 @@ void afterEachBase() { driver.open("about:blank"); } - public static KeyWrapper getKeyFromSession(KeycloakSession keycloakSession) { + protected static void configureTrustIdentityProvider(RealmModel realm, String alias, String providerId, Map config) { + IdentityProviderModel trustIdp = realm.getIdentityProviderByAlias(alias); + if (trustIdp == null) { + trustIdp = new IdentityProviderModel(); + trustIdp.setAlias(alias); + trustIdp.setProviderId(providerId); + trustIdp.setEnabled(true); + trustIdp.setConfig(config); + realm.addIdentityProvider(trustIdp); + } else { + trustIdp.setProviderId(providerId); + trustIdp.setEnabled(true); + trustIdp.setConfig(config); + realm.updateIdentityProvider(trustIdp); + } + } + + protected void setClientTrustSource(String alias) { + Map attributes = new HashMap<>(Optional.ofNullable(abcaClient.getAttributes()).orElse(Map.of())); + attributes.put(OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS, alias); + abcaClient.setAttributes(attributes); + testRealm.admin().clients().get(abcaClient.getId()).update(abcaClient); + abcaClient = testRealm.admin().clients().get(abcaClient.getId()).toRepresentation(); + } + + protected static KeyWrapper getKeyFromSession(KeycloakSession keycloakSession) { String realmName = keycloakSession.getContext().getRealm().getName(); Logger logger = Logger.getLogger(OID4VCIssuerTestBase.class); KeyManager keyManager = keycloakSession.keys(); @@ -280,7 +308,7 @@ public static KeyWrapper getKeyFromSession(KeycloakSession keycloakSession) { return kw; } - public static String getKeyIdFromSession(KeycloakSession keycloakSession) { + protected static String getKeyIdFromSession(KeycloakSession keycloakSession) { return getKeyFromSession(keycloakSession).getKid(); } diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAttestationBasedClientAuthenticationTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAttestationBasedClientAuthenticationTest.java index 6684386c1273..26ac9e9bd29d 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAttestationBasedClientAuthenticationTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/abca/OIDCAttestationBasedClientAuthenticationTest.java @@ -17,10 +17,8 @@ package org.keycloak.tests.oid4vc.abca; import java.security.PublicKey; -import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; import org.keycloak.TokenVerifier; import org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.ClientAttestationJwt; @@ -32,7 +30,6 @@ import org.keycloak.jose.jwk.JSONWebKeySet; import org.keycloak.jose.jwk.JWK; import org.keycloak.jose.jwk.JWKBuilder; -import org.keycloak.models.IdentityProviderModel; import org.keycloak.models.RealmModel; import org.keycloak.protocol.oid4vc.model.CredentialResponse; import org.keycloak.protocol.oid4vc.model.Proofs; @@ -50,7 +47,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS; import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_HEADER; import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_POP_HEADER; import static org.keycloak.protocol.oidc.OIDCLoginProtocol.ATTEST_JWT_CLIENT_AUTH; @@ -89,7 +85,6 @@ void beforeEach() { String jwks = attesterJwks; runOnServer.run(session -> { RealmModel realm = session.getContext().getRealm(); - configureTrustIdentityProvider(realm, ATTESTER_DEFAULT_TRUST_IDP_ALIAS, DefaultTrustIdentityProviderFactory.PROVIDER_ID, Map.of(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS, jwks)); @@ -98,31 +93,6 @@ void beforeEach() { oauth.client(abcaClient.getClientId(), null); } - private static void configureTrustIdentityProvider(RealmModel realm, String alias, String providerId, Map config) { - IdentityProviderModel trustIdp = realm.getIdentityProviderByAlias(alias); - if (trustIdp == null) { - trustIdp = new IdentityProviderModel(); - trustIdp.setAlias(alias); - trustIdp.setProviderId(providerId); - trustIdp.setEnabled(true); - trustIdp.setConfig(config); - realm.addIdentityProvider(trustIdp); - } else { - trustIdp.setProviderId(providerId); - trustIdp.setEnabled(true); - trustIdp.setConfig(config); - realm.updateIdentityProvider(trustIdp); - } - } - - private void setClientTrustSource(String alias) { - Map attributes = new HashMap<>(Optional.ofNullable(abcaClient.getAttributes()).orElse(Map.of())); - attributes.put(OAUTH_CLIENT_ATTESTATION_CONFIG_TRUST_IDPS, alias); - abcaClient.setAttributes(attributes); - testRealm.admin().clients().get(abcaClient.getId()).update(abcaClient); - abcaClient = testRealm.admin().clients().get(abcaClient.getId()).toRepresentation(); - } - @Test public void testTokenEndpointAuthMethods() { OIDCConfigurationRepresentation oidcConfiguration = oauth.doWellKnownRequest(); diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/haip/HAIPIssuerConformanceTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/haip/HAIPIssuerConformanceTest.java new file mode 100644 index 000000000000..61735e87e98e --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/haip/HAIPIssuerConformanceTest.java @@ -0,0 +1,180 @@ +package org.keycloak.tests.oid4vc.haip; + + +import java.util.Map; +import java.util.stream.Collectors; + +import org.keycloak.TokenVerifier; +import org.keycloak.broker.trust.DefaultTrustIdentityProviderConfig; +import org.keycloak.broker.trust.DefaultTrustIdentityProviderFactory; +import org.keycloak.crypto.KeyWrapper; +import org.keycloak.jose.jwk.JSONWebKeySet; +import org.keycloak.jose.jwk.JWK; +import org.keycloak.jose.jwk.JWKBuilder; +import org.keycloak.models.RealmModel; +import org.keycloak.protocol.oid4vc.model.CredentialResponse; +import org.keycloak.protocol.oid4vc.model.CredentialScopeRepresentation; +import org.keycloak.protocol.oid4vc.model.Proofs; +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.testframework.annotations.TestSetup; +import org.keycloak.tests.oid4vc.OID4VCIssuerTestBase; +import org.keycloak.tests.oid4vc.OID4VCTestContext; +import org.keycloak.tests.oid4vc.abca.OIDCClientAttester; +import org.keycloak.tests.oid4vc.abca.OIDCMockClientAttester; +import org.keycloak.testsuite.util.oauth.AccessTokenResponse; +import org.keycloak.testsuite.util.oauth.PkceGenerator; +import org.keycloak.util.JsonSerialization; +import org.keycloak.util.TokenUtil; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.keycloak.OID4VCConstants.CLAIM_NAME_VCT; +import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_HEADER; +import static org.keycloak.authentication.authenticators.client.AttestationBasedClientAuthenticator.OAUTH_CLIENT_ATTESTATION_POP_HEADER; +import static org.keycloak.tests.oid4vc.OID4VCProofTestUtils.createRsaKeyPair; +import static org.keycloak.tests.oid4vc.OID4VCTestContext.CLIENT_ATTESTER_ATTACHMENT_KEY; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Mirrors oid4vci-1_0-issuer-haip-test-plan:oid4vci-1_0-issuer-happy-flow + */ +@KeycloakIntegrationTest(config = OID4VCIssuerTestBase.VCTestServerWithABCAEnabled.class) +public class HAIPIssuerConformanceTest extends OID4VCIssuerTestBase { + + private static final String ATTESTER_DEFAULT_TRUST_IDP_ALIAS = "abca-attester-default-trust"; + + private static OIDCClientAttester attester; + private static String attesterJwks; + + @TestSetup + public void configure() throws Exception { + var kw = createRsaKeyPair("openid-abca-attester-key"); + JWK jwk = JWKBuilder.create() + .kid(kw.getKid()) + .algorithm(kw.getAlgorithm()) + .rsa(kw.getPublicKey(), kw.getCertificate()); + JSONWebKeySet jwks = new JSONWebKeySet(); + jwks.setKeys(new JWK[] { jwk }); + attesterJwks = JsonSerialization.writeValueAsString(jwks); + attester = new OIDCMockClientAttester(kw); + } + + @BeforeEach + void beforeEach() { + String jwks = attesterJwks; + runOnServer.run(session -> { + RealmModel realm = session.getContext().getRealm(); + + configureTrustIdentityProvider(realm, ATTESTER_DEFAULT_TRUST_IDP_ALIAS, + DefaultTrustIdentityProviderFactory.PROVIDER_ID, + Map.of(DefaultTrustIdentityProviderConfig.TRUSTED_JWKS, jwks)); + }); + setClientTrustSource(ATTESTER_DEFAULT_TRUST_IDP_ALIAS); + setClientPolicyEnabled(VCI_CLIENT_POLICY_HAIP, true); + oauth.client(abcaClient.getClientId(), null); + } + + @Test + public void testIssuerHappyFlow() throws Exception { + + var ctx = new OID4VCTestContext(abcaClient, sdJwtTypeCredentialScope); + ctx.putAttachment(CLIENT_ATTESTER_ATTACHMENT_KEY, attester); + + var pkce = PkceGenerator.s256(); + + // Generate ABCA Headers + // + KeyWrapper rsaKey = wallet.getRSAKeyPair(ctx); + String attestationJwt = wallet.buildClientAttestationJWT(ctx, rsaKey); + String attestationPoPJwt = wallet.buildClientAttestationPoPJWT(ctx, rsaKey); + + // Send PAR Request + // + String requestUri = oauth.pushedAuthorizationRequest() + .header(OAUTH_CLIENT_ATTESTATION_HEADER, attestationJwt) + .header(OAUTH_CLIENT_ATTESTATION_POP_HEADER, attestationPoPJwt) + .scopeParam(ctx.getScope()) + .codeChallenge(pkce) + .send().getRequestUri(); + assertNotNull(requestUri, "No requestUri"); + + // Send Authorization Request + // + String authCode = wallet.authorizationRequest() + .requestUri(requestUri) + .codeChallenge(pkce) + .send(ctx.getHolder(), TEST_PASSWORD) + .getCode(); + assertNotNull(authCode, "No auth code"); + + // Send Token Request + // + KeyWrapper ecKey = wallet.getECKeyPair(ctx); + String tokenEndpoint = oauth.getEndpoints().getToken(); + String dpopProof = wallet.generateSignedDPoPProof(tokenEndpoint, ecKey, null); + + AccessTokenResponse tokenResponse = wallet.accessTokenRequest(ctx, authCode) + .header(OAUTH_CLIENT_ATTESTATION_HEADER, attestationJwt) + .header(OAUTH_CLIENT_ATTESTATION_POP_HEADER, attestationPoPJwt) + .codeVerifier(pkce) + .dpopProof(dpopProof) + .send(); + String tokenType = tokenResponse.getTokenType(); + String accessToken = tokenResponse.getAccessToken(); + assertEquals(TokenUtil.TOKEN_TYPE_DPOP, tokenType); + assertNotNull(accessToken, "No access token"); + + String credentialIdentifier = ctx.getAuthorizedCredentialIdentifier(); + assertNotNull(credentialIdentifier, "No authorized credential identifier"); + + // Send Nonce Request + // + String nonce = wallet.nonceRequest().send().getNonce(); + Proofs jwtProof = wallet.generateJwtProof(ctx, ecKey, nonce); + + // Send Credential Request + // Note, we use the same EC key for DPoP and Holder identity + // + String credentialEndpoint = oauth.getEndpoints().getOid4vcCredential(); + dpopProof = wallet.generateSignedDPoPProof(credentialEndpoint, ecKey, accessToken); + + CredentialResponse credResponse = wallet.credentialRequest(ctx, tokenType, accessToken) + .credentialIdentifier(credentialIdentifier) + .dpopProof(dpopProof) + .proofs(jwtProof) + .send().getCredentialResponse(); + + // Verify Credential Response + // + verifyCredentialResponse(ctx, credResponse); + } + + private void verifyCredentialResponse(OID4VCTestContext ctx, CredentialResponse credResponse) throws Exception { + + CredentialScopeRepresentation credScope = ctx.getCredentialScope(); + String issuer = wallet.getIssuerMetadata(ctx).getCredentialIssuer(); + CredentialResponse.Credential credObj = credResponse.getCredentials().get(0); + assertNotNull(credObj, "The first credential in the array should not be null"); + + SdJwtVP sdJwtVP = SdJwtVP.of(credObj.getCredential().toString()); + IssuerSignedJWT issuerSignedJWT = sdJwtVP.getIssuerSignedJWT(); + JsonWebToken vcSdJwt = TokenVerifier.create(issuerSignedJWT.getJws(), JsonWebToken.class).getToken(); + Map otherClaims = vcSdJwt.getOtherClaims(); + assertEquals(issuer, vcSdJwt.getIssuer()); + assertEquals(credScope.getVct(), otherClaims.get(CLAIM_NAME_VCT)); + + Map claims = sdJwtVP.getClaims().values().stream().collect(Collectors.toMap( + arrayNode -> arrayNode.get(1).asText(), + arrayNode -> arrayNode.get(2).asText() + )); + assertEquals("Alice", claims.get("firstName")); + assertEquals("Wonderland", claims.get("lastName")); + assertEquals("alice@email.cz", claims.get("email")); + } +}