From 63305191c2641ee04594e6583405f59fae8936e8 Mon Sep 17 00:00:00 2001 From: forkimenjeckayang Date: Mon, 6 Apr 2026 15:02:14 +0000 Subject: [PATCH 1/4] Fix OID4VCI token typ defaults by credential format closes #45420 Signed-off-by: forkimenjeckayang --- .../oid4vci/vc-issuer-configuration.adoc | 6 +- .../admin/messages/messages_en.properties | 3 +- .../src/client-scopes/details/ScopeForm.tsx | 42 ++++++- .../client-scope/oid4vci-client-scope.spec.ts | 57 +++++++++ .../models/oid4vci/CredentialScopeModel.java | 17 ++- .../oid4vc/OID4VCLoginProtocolFactory.java | 4 +- .../model/CredentialScopeRepresentation.java | 6 +- .../admin/client/ClientScopeTestOid4Vci.java | 2 +- .../oid4vc/OID4VCJWTIssuerEndpointTest.java | 118 ++++++++++++++++++ 9 files changed, 241 insertions(+), 14 deletions(-) diff --git a/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc b/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc index 61da9f54344f..c3b156409919 100644 --- a/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc +++ b/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc @@ -243,7 +243,7 @@ Create a JSON file (e.g., `client-scopes.json`) with the following content: "vc.issuer_did": "did:web:vc.example.com", "vc.credential_configuration_id": "my-credential-configuration-id", "vc.credential_identifier": "my-credential-identifier", - "vc.format": "jwt_vc", + "vc.format": "jwt_vc_json", "vc.expiry_in_seconds": 31536000, "vc.verifiable_credential_type": "my-vct", "vc.supported_credential_types": "credential-type-1,credential-type-2", @@ -255,7 +255,7 @@ Create a JSON file (e.g., `client-scopes.json`) with the following content: "vc.sd_jwt.number_of_decoys": "2", "vc.credential_build_config.sd_jwt.visible_claims": "iat,jti,nbf,exp,given_name", "vc.credential_build_config.hash_algorithm": "SHA-256", - "vc.credential_build_config.token_jws_type": "JWS", + "vc.credential_build_config.token_jws_type": "vc+jwt", "vc.include_in_metadata": "true" }, "protocolMappers": [ @@ -396,7 +396,7 @@ _Default_: `SHA-256` | `vc.credential_build_config.token_jws_type` | optional | JWT type written into the `typ` header of the token. + -_Default_: `JWS` +_Default_: format-specific (`dc+sd-jwt` for `dc+sd-jwt`, `vc+jwt` for `jwt_vc_json`) | `vc.expiry_in_s` | optional diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 2f8f792f4f3b..51695f5ccc38 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -3712,7 +3712,8 @@ supportedCredentialTypesHelp=Comma-separated list of credential types (e.g., "Ve verifiableCredentialType=Verifiable Credential Type (VCT) verifiableCredentialTypeHelp=The credential type identifier for SD-JWT format credentials. This value is used in the vct claim of the issued credential. Required for SD-JWT format. tokenJwsType=Token JWS Type -tokenJwsTypeHelp=The type value written into the typ header of the JWT. Defaults to "JWS". Can be set to custom values like "dc+sd-jwt" if required by the wallet or system. +tokenJwsTypeHelp=The type value written into the typ header of the JWT. If empty, a format-specific default is used ("dc+sd-jwt" for SD-JWT VC and "vc+jwt" for JWT VC). Can be set to custom values if required by the wallet or system. +tokenJwsTypeFormatWarning=The configured Token JWS Type does not match the recommended value for the selected credential format. Recommended value: {{recommended}}. visibleClaims=Visible Claims visibleClaimsHelp=Comma-separated list of claims that are always disclosed in the SD-JWT body (e.g., "id,iat,nbf,exp,jti,given_name"). Defaults to "id,iat,nbf,exp,jti". Only applicable for SD-JWT format. signingKeyId=Signing Key ID diff --git a/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx b/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx index a6a6f91edbc9..c18b400ccfd6 100644 --- a/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx +++ b/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx @@ -1,6 +1,12 @@ import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation"; import type { KeyMetadataRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/keyMetadataRepresentation"; -import { ActionGroup, Button } from "@patternfly/react-core"; +import { + ActionGroup, + Button, + FormHelperText, + HelperText, + HelperTextItem, +} from "@patternfly/react-core"; import { useEffect, useMemo, useState } from "react"; import { FormProvider, useForm, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; @@ -34,6 +40,8 @@ import { removeEmptyOid4vcAttributes } from "./oid4vciAttributes"; const OID4VC_PROTOCOL = "oid4vc"; const VC_FORMAT_JWT_VC = "jwt_vc_json"; const VC_FORMAT_SD_JWT = "dc+sd-jwt"; +const VC_FORMAT_JWT_VC_TYP = "vc+jwt"; +const VC_FORMAT_SD_JWT_TYP = "dc+sd-jwt"; // Allowed values for OID4VCI cryptographic binding methods and proof types. // Keep these in sync with server-side support in CredentialScopeModel / ProofType. @@ -157,12 +165,31 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => { ), defaultValue: clientScope?.attributes?.["vc.format"] ?? VC_FORMAT_SD_JWT, }); + const selectedTokenJwsType = useWatch({ + control, + name: convertAttributeNameToForm( + "attributes.vc.credential_build_config.token_jws_type", + ), + defaultValue: + clientScope?.attributes?.["vc.credential_build_config.token_jws_type"] ?? + "", + }); const isOid4vcProtocol = selectedProtocol === OID4VC_PROTOCOL; const isOid4vcEnabled = isFeatureEnabled(Feature.OpenId4VCI) && realmRepresentation?.verifiableCredentialsEnabled; const isNotSaml = selectedProtocol != "saml"; + const recommendedTokenJwsType = + selectedFormat === VC_FORMAT_SD_JWT + ? VC_FORMAT_SD_JWT_TYP + : selectedFormat === VC_FORMAT_JWT_VC + ? VC_FORMAT_JWT_VC_TYP + : undefined; + const showTokenJwsTypeWarning = + Boolean(selectedTokenJwsType?.trim()) && + Boolean(recommendedTokenJwsType) && + selectedTokenJwsType.trim() !== recommendedTokenJwsType; const bindingRequired = useWatch({ control, @@ -415,9 +442,20 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => { defaultValue={ clientScope?.attributes?.[ "vc.credential_build_config.token_jws_type" - ] ?? "JWS" + ] ?? "" } /> + {showTokenJwsTypeWarning && ( + + + + {t("tokenJwsTypeFormatWarning", { + recommended: recommendedTokenJwsType, + })} + + + + )} {realmKeys && realmKeys.length > 0 && ( { test("should display OID4VCI fields when protocol is selected", async ({ @@ -549,6 +551,61 @@ test.describe("OID4VCI Client Scope Functionality", () => { await expect(page.getByTestId(OID4VCI_FIELDS.TOKEN_JWS_TYPE)).toBeVisible(); }); + test("should show warning for mismatched token_jws_type based on selected format", async ({ + page, + }) => { + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); + await createClientScopeAndSelectProtocolAndFormat( + page, + testBed, + "JWT VC (jwt_vc_json)", + ); + + const tokenJwsType = page.getByTestId(OID4VCI_FIELDS.TOKEN_JWS_TYPE); + await tokenJwsType.fill("dc+sd-jwt"); + + await expect(page.getByText(TOKEN_JWS_TYPE_WARNING_PREFIX)).toBeVisible(); + await expect(page.getByText("Recommended value: vc+jwt.")).toBeVisible(); + + await tokenJwsType.fill("vc+jwt"); + await expect(page.getByText(TOKEN_JWS_TYPE_WARNING_PREFIX)).toHaveCount(0); + + await selectItem(page, "#kc-vc-format", "SD-JWT VC (dc+sd-jwt)"); + await page.waitForLoadState("domcontentloaded"); + + await tokenJwsType.fill("vc+jwt"); + await expect(page.getByText(TOKEN_JWS_TYPE_WARNING_PREFIX)).toBeVisible(); + await expect(page.getByText("Recommended value: dc+sd-jwt.")).toBeVisible(); + + await tokenJwsType.fill("dc+sd-jwt"); + await expect(page.getByText(TOKEN_JWS_TYPE_WARNING_PREFIX)).toHaveCount(0); + }); + + test("should not show token_jws_type warning when the value is empty", async ({ + page, + }) => { + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); + await createClientScopeAndSelectProtocolAndFormat( + page, + testBed, + "SD-JWT VC (dc+sd-jwt)", + ); + + const tokenJwsType = page.getByTestId(OID4VCI_FIELDS.TOKEN_JWS_TYPE); + await tokenJwsType.fill(""); + await expect(page.getByText(TOKEN_JWS_TYPE_WARNING_PREFIX)).toHaveCount(0); + + await selectItem(page, "#kc-vc-format", "JWT VC (jwt_vc_json)"); + await page.waitForLoadState("domcontentloaded"); + + await tokenJwsType.fill(""); + await expect(page.getByText(TOKEN_JWS_TYPE_WARNING_PREFIX)).toHaveCount(0); + }); + test("should display signing algorithm dropdown with available algorithms", async ({ page, }) => { diff --git a/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java b/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java index a85e0c122d8d..f932830857e2 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java +++ b/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java @@ -30,6 +30,7 @@ import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.RealmModel; import org.keycloak.models.RoleModel; +import org.keycloak.utils.StringUtil; import static org.keycloak.constants.OID4VCIConstants.OID4VC_PROTOCOL; @@ -45,7 +46,8 @@ public class CredentialScopeModel implements ClientScopeModel { public static final String VC_BUILD_CONFIG_HASH_ALGORITHM_DEFAULT = "SHA-256"; public static final String VC_BUILD_CONFIG_SD_JWT_VISIBLE_CLAIMS_DEFAULT = "id,iat,nbf,exp,jti"; - public static final String VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT = "JWS"; + public static final String VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT_SD_JWT_VC = "dc+sd-jwt"; + public static final String VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT_JWT_VC = "vc+jwt"; public static final Integer VC_EXPIRY_IN_SECONDS_DEFAULT = 31536000; // 1 year public static final String VC_FORMAT_DEFAULT = VCFormat.SD_JWT_VC; public static final Integer VC_SD_JWT_NUMBER_OF_DECOYS_DEFAULT = 10; @@ -232,13 +234,24 @@ public void setVct(String vct) { public String getBuildConfigTokenJwsType() { return Optional.ofNullable(clientScope.getAttribute(VC_BUILD_CONFIG_TOKEN_JWS_TYPE)) - .orElse(VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT); + .filter(StringUtil::isNotBlank) + .orElse(getDefaultTokenJwsTypeForFormat(getFormat())); } public void setBuildConfigTokenJwsType(String tokenJwsType) { clientScope.setAttribute(VC_BUILD_CONFIG_TOKEN_JWS_TYPE, tokenJwsType); } + public static String getDefaultTokenJwsTypeForFormat(String format) { + if (VCFormat.SD_JWT_VC.equals(format)) { + return VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT_SD_JWT_VC; + } + if (VCFormat.JWT_VC.equals(format)) { + return VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT_JWT_VC; + } + return null; + } + public String getSigningKeyId() { return clientScope.getAttribute(VC_SIGNING_KEY_ID); } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java index 07c51826e7a2..a718c3ea385e 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java @@ -60,7 +60,6 @@ import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BUILD_CONFIG_SD_JWT_VISIBLE_CLAIMS; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BUILD_CONFIG_SD_JWT_VISIBLE_CLAIMS_DEFAULT; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE; -import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CONFIGURATION_ID; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CONTEXTS; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CRYPTOGRAPHIC_BINDING_METHODS; @@ -71,6 +70,7 @@ import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_SD_JWT_NUMBER_OF_DECOYS; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_SD_JWT_NUMBER_OF_DECOYS_DEFAULT; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_SUPPORTED_TYPES; +import static org.keycloak.models.oid4vci.CredentialScopeModel.getDefaultTokenJwsTypeForFormat; /** * Factory for creating all OID4VC related endpoints and the default mappers. @@ -174,7 +174,7 @@ public void addClientScopeDefaults(ClientScopeRepresentation clientScope) { clientScope.getAttributes().putIfAbsent(VCT, credentialType); clientScope.getAttributes().putIfAbsent(VC_CRYPTOGRAPHIC_BINDING_METHODS, CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT); clientScope.getAttributes().putIfAbsent(VC_BUILD_CONFIG_HASH_ALGORITHM, VC_BUILD_CONFIG_HASH_ALGORITHM_DEFAULT); - clientScope.getAttributes().putIfAbsent(VC_BUILD_CONFIG_TOKEN_JWS_TYPE, VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT); + clientScope.getAttributes().putIfAbsent(VC_BUILD_CONFIG_TOKEN_JWS_TYPE, getDefaultTokenJwsTypeForFormat(format)); clientScope.getAttributes().putIfAbsent(VC_EXPIRY_IN_SECONDS, String.valueOf(VC_EXPIRY_IN_SECONDS_DEFAULT)); if (SD_JWT_VC.equals(format)) { diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialScopeRepresentation.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialScopeRepresentation.java index 826955761f07..50eefea1b7a6 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialScopeRepresentation.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialScopeRepresentation.java @@ -18,7 +18,6 @@ import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BUILD_CONFIG_SD_JWT_VISIBLE_CLAIMS; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BUILD_CONFIG_SD_JWT_VISIBLE_CLAIMS_DEFAULT; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE; -import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CONFIGURATION_ID; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CONTEXTS; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CRYPTOGRAPHIC_BINDING_METHODS; @@ -37,6 +36,7 @@ import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_SIGNING_ALG; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_SIGNING_KEY_ID; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_SUPPORTED_TYPES; +import static org.keycloak.models.oid4vci.CredentialScopeModel.getDefaultTokenJwsTypeForFormat; /** * An extension of {@link ClientScopeRepresentation} which and adds additional functionality for OID4VCI @@ -48,12 +48,12 @@ public class CredentialScopeRepresentation extends ClientScopeRepresentation { public CredentialScopeRepresentation(String name) { this.name = name; this.protocol = OID4VCIConstants.OID4VC_PROTOCOL; + setFormat(VC_FORMAT_DEFAULT); setBuildConfigHashAlgorithm(VC_BUILD_CONFIG_HASH_ALGORITHM_DEFAULT); setBuildConfigSdJwtVisibleClaims(VC_BUILD_CONFIG_SD_JWT_VISIBLE_CLAIMS_DEFAULT); - setBuildConfigTokenJwsType(VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT); + setBuildConfigTokenJwsType(getDefaultTokenJwsTypeForFormat(VC_FORMAT_DEFAULT)); setSdJwtNumberOfDecoys(VC_SD_JWT_NUMBER_OF_DECOYS_DEFAULT); setExpiryInSeconds(VC_EXPIRY_IN_SECONDS_DEFAULT); - setFormat(VC_FORMAT_DEFAULT); } public CredentialScopeRepresentation(ClientScopeModel clientScope) { diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTestOid4Vci.java b/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTestOid4Vci.java index 7b723f2d23b3..8c0027bf34dd 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTestOid4Vci.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTestOid4Vci.java @@ -77,7 +77,7 @@ public void testDefaultOid4VciClientScopeAttributes() { createdClientScope.getAttributes().get(CredentialScopeModel.VC_FORMAT)); Assertions.assertEquals(CredentialScopeModel.VC_BUILD_CONFIG_HASH_ALGORITHM_DEFAULT, createdClientScope.getAttributes().get(CredentialScopeModel.VC_BUILD_CONFIG_HASH_ALGORITHM)); - Assertions.assertEquals(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT, + Assertions.assertEquals(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT_SD_JWT_VC, createdClientScope.getAttributes().get(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE)); Assertions.assertEquals(String.valueOf(CredentialScopeModel.VC_EXPIRY_IN_SECONDS_DEFAULT), createdClientScope.getAttributes().get(CredentialScopeModel.VC_EXPIRY_IN_SECONDS)); diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java index 5fc989664796..5ae056cefc91 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java @@ -21,6 +21,7 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; @@ -91,6 +92,7 @@ import static org.keycloak.OID4VCConstants.CREDENTIAL_SUBJECT; import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL; +import static org.keycloak.OID4VCConstants.SDJWT_DELIMITER; import static org.keycloak.tests.oid4vc.OID4VCProofTestUtils.generateJwtProof; import static org.keycloak.tests.oid4vc.OID4VCProofTestUtils.jwtProofs; @@ -1363,6 +1365,42 @@ public void testCredentialIssuerSigningSpecificKey() { testCredentialIssuanceSigningConfiguration(keyMetadata.getActive().get(Constants.DEFAULT_SIGNATURE_ALGORITHM), Constants.DEFAULT_SIGNATURE_ALGORITHM); } + @Test + public void testCredentialIssuerUsesDefaultTokenJwsTypeForJwtFormatWhenUnset() { + assertDefaultTokenJwsTypeWhenUnset( + jwtTypeCredentialScope, + VCFormat.JWT_VC, + CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT_JWT_VC + ); + } + + @Test + public void testCredentialIssuerUsesDefaultTokenJwsTypeForSdJwtFormatWhenUnset() { + assertDefaultTokenJwsTypeWhenUnset( + sdJwtTypeCredentialScope, + VCFormat.SD_JWT_VC, + CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT_SD_JWT_VC + ); + } + + @Test + public void testCredentialIssuerUsesDefaultTokenJwsTypeForJwtFormatWhenBlank() { + assertDefaultTokenJwsTypeWhenBlank( + jwtTypeCredentialScope, + VCFormat.JWT_VC, + CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT_JWT_VC + ); + } + + @Test + public void testCredentialIssuerUsesDefaultTokenJwsTypeForSdJwtFormatWhenBlank() { + assertDefaultTokenJwsTypeWhenBlank( + sdJwtTypeCredentialScope, + VCFormat.SD_JWT_VC, + CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT_SD_JWT_VC + ); + } + public void testCredentialIssuanceSigningConfiguration(String kid, String alg) { String scopeName = jwtTypeCredentialScope.getName(); String credConfigId = jwtTypeCredentialScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID); @@ -1406,6 +1444,86 @@ public void testCredentialIssuanceSigningConfiguration(String kid, String alg) { }); } + private void assertDefaultTokenJwsTypeWhenUnset( + ClientScopeRepresentation clientScope, + String expectedFormat, + String expectedTyp + ) { + String originalTyp = clientScope.getAttributes().remove(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE); + testRealm.admin().clientScopes().get(clientScope.getId()).update(clientScope); + + try { + String actualTyp = issueCredentialAndGetTyp(clientScope, expectedFormat); + assertEquals(expectedTyp, actualTyp, "Expected typ default for format " + expectedFormat); + } finally { + if (originalTyp == null) { + clientScope.getAttributes().remove(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE); + } else { + clientScope.getAttributes().put(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE, originalTyp); + } + testRealm.admin().clientScopes().get(clientScope.getId()).update(clientScope); + } + } + + private void assertDefaultTokenJwsTypeWhenBlank( + ClientScopeRepresentation clientScope, + String expectedFormat, + String expectedTyp + ) { + String originalTyp = clientScope.getAttributes().get(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE); + clientScope.getAttributes().put(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE, " "); + testRealm.admin().clientScopes().get(clientScope.getId()).update(clientScope); + + try { + String actualTyp = issueCredentialAndGetTyp(clientScope, expectedFormat); + assertEquals(expectedTyp, actualTyp, "Expected typ default for format " + expectedFormat); + } finally { + if (originalTyp == null) { + clientScope.getAttributes().remove(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE); + } else { + clientScope.getAttributes().put(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE, originalTyp); + } + testRealm.admin().clientScopes().get(clientScope.getId()).update(clientScope); + } + } + + private String issueCredentialAndGetTyp(ClientScopeRepresentation clientScope, String expectedFormat) { + AtomicReference typRef = new AtomicReference<>(); + + testCredentialIssuanceWithAuthZCodeFlow( + clientScope, + (testScope) -> getBearerToken(oauth, client, testScope), + m -> { + String accessToken = (String) m.get("accessToken"); + WebTarget credentialTarget = (WebTarget) m.get("credentialTarget"); + CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest"); + String issuer = getRealmPath(testRealm.getName()); + String cNonce = getCNonce(); + credentialRequest.setProofs(jwtProofs(issuer, cNonce)); + + try (Response response = credentialTarget.request() + .header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken) + .post(Entity.json(credentialRequest))) { + assertEquals(200, response.getStatus(), "Credential request should succeed"); + CredentialResponse credentialResponse = JsonSerialization.readValue( + response.readEntity(String.class), + CredentialResponse.class + ); + String credentialValue = (String) credentialResponse.getCredentials().get(0).getCredential(); + if (VCFormat.SD_JWT_VC.equals(expectedFormat)) { + credentialValue = credentialValue.split(SDJWT_DELIMITER)[0]; + } + JWSHeader header = TokenVerifier.create(credentialValue, JsonWebToken.class).getHeader(); + typRef.set(header.getType()); + } catch (VerificationException | IOException e) { + throw new RuntimeException(e); + } + } + ); + + return typRef.get(); + } + private String getCNonce() { UriBuilder builder = UriBuilder.fromUri(keycloakUrls.getBase()); URI oid4vcUri = RealmsResource.protocolUrl(builder) From ad16a5c4039a2b4c0b9bb9fb052e071daedc1615 Mon Sep 17 00:00:00 2001 From: forkimenjeckayang Date: Mon, 6 Apr 2026 16:03:47 +0000 Subject: [PATCH 2/4] try-fix CI doc failure Signed-off-by: forkimenjeckayang --- docs/documentation/upgrading/topics/changes/changes-26_0_0.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/documentation/upgrading/topics/changes/changes-26_0_0.adoc b/docs/documentation/upgrading/topics/changes/changes-26_0_0.adoc index 3a2ae5c06a1a..76322a6b1ea3 100644 --- a/docs/documentation/upgrading/topics/changes/changes-26_0_0.adoc +++ b/docs/documentation/upgrading/topics/changes/changes-26_0_0.adoc @@ -336,7 +336,7 @@ The Keycloak JS library is no longer served statically from the Keycloak server. Additionally, the `keycloakJsUrl` property that linked to the library on these URLs has been removed from the admin console theme. If your custom theme was using this property to include the library, you should update your theme to include the library using a different method. -If you are not already you should include the library in your project using a package manager like link:https://nodejs.org/en/learn/getting-started/an-introduction-to-the-npm-package-manager[NPM]. The library is available on the NPM registry as link:https://www.npmjs.com/package/keycloak-js[`keycloak-js`]. You can install it using the following command: +If you are not already you should include the library in your project using a package manager like link:https://nodejs.org/learn/getting-started/an-introduction-to-the-npm-package-manager[NPM]. The library is available on the NPM registry as link:https://www.npmjs.com/package/keycloak-js[`keycloak-js`]. You can install it using the following command: [source,bash] ---- From 8d85e325e0129ee1669f3418f88a51106aebab5b Mon Sep 17 00:00:00 2001 From: forkimenjeckayang Date: Mon, 6 Apr 2026 15:02:14 +0000 Subject: [PATCH 3/4] Fix OID4VCI token typ defaults by credential format closes #45420 Signed-off-by: forkimenjeckayang --- .../oid4vci/vc-issuer-configuration.adoc | 6 +- .../admin/messages/messages_en.properties | 3 +- .../src/client-scopes/details/ScopeForm.tsx | 42 ++++++- .../client-scope/oid4vci-client-scope.spec.ts | 57 +++++++++ .../models/oid4vci/CredentialScopeModel.java | 17 ++- .../oid4vc/OID4VCLoginProtocolFactory.java | 4 +- .../model/CredentialScopeRepresentation.java | 6 +- .../admin/client/ClientScopeTestOid4Vci.java | 2 +- .../oid4vc/OID4VCJWTIssuerEndpointTest.java | 118 ++++++++++++++++++ 9 files changed, 241 insertions(+), 14 deletions(-) diff --git a/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc b/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc index 61da9f54344f..c3b156409919 100644 --- a/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc +++ b/docs/documentation/server_admin/topics/oid4vci/vc-issuer-configuration.adoc @@ -243,7 +243,7 @@ Create a JSON file (e.g., `client-scopes.json`) with the following content: "vc.issuer_did": "did:web:vc.example.com", "vc.credential_configuration_id": "my-credential-configuration-id", "vc.credential_identifier": "my-credential-identifier", - "vc.format": "jwt_vc", + "vc.format": "jwt_vc_json", "vc.expiry_in_seconds": 31536000, "vc.verifiable_credential_type": "my-vct", "vc.supported_credential_types": "credential-type-1,credential-type-2", @@ -255,7 +255,7 @@ Create a JSON file (e.g., `client-scopes.json`) with the following content: "vc.sd_jwt.number_of_decoys": "2", "vc.credential_build_config.sd_jwt.visible_claims": "iat,jti,nbf,exp,given_name", "vc.credential_build_config.hash_algorithm": "SHA-256", - "vc.credential_build_config.token_jws_type": "JWS", + "vc.credential_build_config.token_jws_type": "vc+jwt", "vc.include_in_metadata": "true" }, "protocolMappers": [ @@ -396,7 +396,7 @@ _Default_: `SHA-256` | `vc.credential_build_config.token_jws_type` | optional | JWT type written into the `typ` header of the token. + -_Default_: `JWS` +_Default_: format-specific (`dc+sd-jwt` for `dc+sd-jwt`, `vc+jwt` for `jwt_vc_json`) | `vc.expiry_in_s` | optional diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 2f8f792f4f3b..51695f5ccc38 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -3712,7 +3712,8 @@ supportedCredentialTypesHelp=Comma-separated list of credential types (e.g., "Ve verifiableCredentialType=Verifiable Credential Type (VCT) verifiableCredentialTypeHelp=The credential type identifier for SD-JWT format credentials. This value is used in the vct claim of the issued credential. Required for SD-JWT format. tokenJwsType=Token JWS Type -tokenJwsTypeHelp=The type value written into the typ header of the JWT. Defaults to "JWS". Can be set to custom values like "dc+sd-jwt" if required by the wallet or system. +tokenJwsTypeHelp=The type value written into the typ header of the JWT. If empty, a format-specific default is used ("dc+sd-jwt" for SD-JWT VC and "vc+jwt" for JWT VC). Can be set to custom values if required by the wallet or system. +tokenJwsTypeFormatWarning=The configured Token JWS Type does not match the recommended value for the selected credential format. Recommended value: {{recommended}}. visibleClaims=Visible Claims visibleClaimsHelp=Comma-separated list of claims that are always disclosed in the SD-JWT body (e.g., "id,iat,nbf,exp,jti,given_name"). Defaults to "id,iat,nbf,exp,jti". Only applicable for SD-JWT format. signingKeyId=Signing Key ID diff --git a/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx b/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx index a6a6f91edbc9..c18b400ccfd6 100644 --- a/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx +++ b/js/apps/admin-ui/src/client-scopes/details/ScopeForm.tsx @@ -1,6 +1,12 @@ import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation"; import type { KeyMetadataRepresentation } from "@keycloak/keycloak-admin-client/lib/defs/keyMetadataRepresentation"; -import { ActionGroup, Button } from "@patternfly/react-core"; +import { + ActionGroup, + Button, + FormHelperText, + HelperText, + HelperTextItem, +} from "@patternfly/react-core"; import { useEffect, useMemo, useState } from "react"; import { FormProvider, useForm, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; @@ -34,6 +40,8 @@ import { removeEmptyOid4vcAttributes } from "./oid4vciAttributes"; const OID4VC_PROTOCOL = "oid4vc"; const VC_FORMAT_JWT_VC = "jwt_vc_json"; const VC_FORMAT_SD_JWT = "dc+sd-jwt"; +const VC_FORMAT_JWT_VC_TYP = "vc+jwt"; +const VC_FORMAT_SD_JWT_TYP = "dc+sd-jwt"; // Allowed values for OID4VCI cryptographic binding methods and proof types. // Keep these in sync with server-side support in CredentialScopeModel / ProofType. @@ -157,12 +165,31 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => { ), defaultValue: clientScope?.attributes?.["vc.format"] ?? VC_FORMAT_SD_JWT, }); + const selectedTokenJwsType = useWatch({ + control, + name: convertAttributeNameToForm( + "attributes.vc.credential_build_config.token_jws_type", + ), + defaultValue: + clientScope?.attributes?.["vc.credential_build_config.token_jws_type"] ?? + "", + }); const isOid4vcProtocol = selectedProtocol === OID4VC_PROTOCOL; const isOid4vcEnabled = isFeatureEnabled(Feature.OpenId4VCI) && realmRepresentation?.verifiableCredentialsEnabled; const isNotSaml = selectedProtocol != "saml"; + const recommendedTokenJwsType = + selectedFormat === VC_FORMAT_SD_JWT + ? VC_FORMAT_SD_JWT_TYP + : selectedFormat === VC_FORMAT_JWT_VC + ? VC_FORMAT_JWT_VC_TYP + : undefined; + const showTokenJwsTypeWarning = + Boolean(selectedTokenJwsType?.trim()) && + Boolean(recommendedTokenJwsType) && + selectedTokenJwsType.trim() !== recommendedTokenJwsType; const bindingRequired = useWatch({ control, @@ -415,9 +442,20 @@ export const ScopeForm = ({ clientScope, save }: ScopeFormProps) => { defaultValue={ clientScope?.attributes?.[ "vc.credential_build_config.token_jws_type" - ] ?? "JWS" + ] ?? "" } /> + {showTokenJwsTypeWarning && ( + + + + {t("tokenJwsTypeFormatWarning", { + recommended: recommendedTokenJwsType, + })} + + + + )} {realmKeys && realmKeys.length > 0 && ( { test("should display OID4VCI fields when protocol is selected", async ({ @@ -549,6 +551,61 @@ test.describe("OID4VCI Client Scope Functionality", () => { await expect(page.getByTestId(OID4VCI_FIELDS.TOKEN_JWS_TYPE)).toBeVisible(); }); + test("should show warning for mismatched token_jws_type based on selected format", async ({ + page, + }) => { + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); + await createClientScopeAndSelectProtocolAndFormat( + page, + testBed, + "JWT VC (jwt_vc_json)", + ); + + const tokenJwsType = page.getByTestId(OID4VCI_FIELDS.TOKEN_JWS_TYPE); + await tokenJwsType.fill("dc+sd-jwt"); + + await expect(page.getByText(TOKEN_JWS_TYPE_WARNING_PREFIX)).toBeVisible(); + await expect(page.getByText("Recommended value: vc+jwt.")).toBeVisible(); + + await tokenJwsType.fill("vc+jwt"); + await expect(page.getByText(TOKEN_JWS_TYPE_WARNING_PREFIX)).toHaveCount(0); + + await selectItem(page, "#kc-vc-format", "SD-JWT VC (dc+sd-jwt)"); + await page.waitForLoadState("domcontentloaded"); + + await tokenJwsType.fill("vc+jwt"); + await expect(page.getByText(TOKEN_JWS_TYPE_WARNING_PREFIX)).toBeVisible(); + await expect(page.getByText("Recommended value: dc+sd-jwt.")).toBeVisible(); + + await tokenJwsType.fill("dc+sd-jwt"); + await expect(page.getByText(TOKEN_JWS_TYPE_WARNING_PREFIX)).toHaveCount(0); + }); + + test("should not show token_jws_type warning when the value is empty", async ({ + page, + }) => { + await using testBed = await createTestBed({ + verifiableCredentialsEnabled: true, + }); + await createClientScopeAndSelectProtocolAndFormat( + page, + testBed, + "SD-JWT VC (dc+sd-jwt)", + ); + + const tokenJwsType = page.getByTestId(OID4VCI_FIELDS.TOKEN_JWS_TYPE); + await tokenJwsType.fill(""); + await expect(page.getByText(TOKEN_JWS_TYPE_WARNING_PREFIX)).toHaveCount(0); + + await selectItem(page, "#kc-vc-format", "JWT VC (jwt_vc_json)"); + await page.waitForLoadState("domcontentloaded"); + + await tokenJwsType.fill(""); + await expect(page.getByText(TOKEN_JWS_TYPE_WARNING_PREFIX)).toHaveCount(0); + }); + test("should display signing algorithm dropdown with available algorithms", async ({ page, }) => { diff --git a/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java b/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java index a85e0c122d8d..f932830857e2 100644 --- a/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java +++ b/server-spi-private/src/main/java/org/keycloak/models/oid4vci/CredentialScopeModel.java @@ -30,6 +30,7 @@ import org.keycloak.models.ProtocolMapperModel; import org.keycloak.models.RealmModel; import org.keycloak.models.RoleModel; +import org.keycloak.utils.StringUtil; import static org.keycloak.constants.OID4VCIConstants.OID4VC_PROTOCOL; @@ -45,7 +46,8 @@ public class CredentialScopeModel implements ClientScopeModel { public static final String VC_BUILD_CONFIG_HASH_ALGORITHM_DEFAULT = "SHA-256"; public static final String VC_BUILD_CONFIG_SD_JWT_VISIBLE_CLAIMS_DEFAULT = "id,iat,nbf,exp,jti"; - public static final String VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT = "JWS"; + public static final String VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT_SD_JWT_VC = "dc+sd-jwt"; + public static final String VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT_JWT_VC = "vc+jwt"; public static final Integer VC_EXPIRY_IN_SECONDS_DEFAULT = 31536000; // 1 year public static final String VC_FORMAT_DEFAULT = VCFormat.SD_JWT_VC; public static final Integer VC_SD_JWT_NUMBER_OF_DECOYS_DEFAULT = 10; @@ -232,13 +234,24 @@ public void setVct(String vct) { public String getBuildConfigTokenJwsType() { return Optional.ofNullable(clientScope.getAttribute(VC_BUILD_CONFIG_TOKEN_JWS_TYPE)) - .orElse(VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT); + .filter(StringUtil::isNotBlank) + .orElse(getDefaultTokenJwsTypeForFormat(getFormat())); } public void setBuildConfigTokenJwsType(String tokenJwsType) { clientScope.setAttribute(VC_BUILD_CONFIG_TOKEN_JWS_TYPE, tokenJwsType); } + public static String getDefaultTokenJwsTypeForFormat(String format) { + if (VCFormat.SD_JWT_VC.equals(format)) { + return VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT_SD_JWT_VC; + } + if (VCFormat.JWT_VC.equals(format)) { + return VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT_JWT_VC; + } + return null; + } + public String getSigningKeyId() { return clientScope.getAttribute(VC_SIGNING_KEY_ID); } diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java index 07c51826e7a2..a718c3ea385e 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/OID4VCLoginProtocolFactory.java @@ -60,7 +60,6 @@ import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BUILD_CONFIG_SD_JWT_VISIBLE_CLAIMS; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BUILD_CONFIG_SD_JWT_VISIBLE_CLAIMS_DEFAULT; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE; -import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CONFIGURATION_ID; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CONTEXTS; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CRYPTOGRAPHIC_BINDING_METHODS; @@ -71,6 +70,7 @@ import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_SD_JWT_NUMBER_OF_DECOYS; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_SD_JWT_NUMBER_OF_DECOYS_DEFAULT; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_SUPPORTED_TYPES; +import static org.keycloak.models.oid4vci.CredentialScopeModel.getDefaultTokenJwsTypeForFormat; /** * Factory for creating all OID4VC related endpoints and the default mappers. @@ -174,7 +174,7 @@ public void addClientScopeDefaults(ClientScopeRepresentation clientScope) { clientScope.getAttributes().putIfAbsent(VCT, credentialType); clientScope.getAttributes().putIfAbsent(VC_CRYPTOGRAPHIC_BINDING_METHODS, CRYPTOGRAPHIC_BINDING_METHODS_DEFAULT); clientScope.getAttributes().putIfAbsent(VC_BUILD_CONFIG_HASH_ALGORITHM, VC_BUILD_CONFIG_HASH_ALGORITHM_DEFAULT); - clientScope.getAttributes().putIfAbsent(VC_BUILD_CONFIG_TOKEN_JWS_TYPE, VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT); + clientScope.getAttributes().putIfAbsent(VC_BUILD_CONFIG_TOKEN_JWS_TYPE, getDefaultTokenJwsTypeForFormat(format)); clientScope.getAttributes().putIfAbsent(VC_EXPIRY_IN_SECONDS, String.valueOf(VC_EXPIRY_IN_SECONDS_DEFAULT)); if (SD_JWT_VC.equals(format)) { diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialScopeRepresentation.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialScopeRepresentation.java index 826955761f07..50eefea1b7a6 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialScopeRepresentation.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/CredentialScopeRepresentation.java @@ -18,7 +18,6 @@ import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BUILD_CONFIG_SD_JWT_VISIBLE_CLAIMS; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BUILD_CONFIG_SD_JWT_VISIBLE_CLAIMS_DEFAULT; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE; -import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CONFIGURATION_ID; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CONTEXTS; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_CRYPTOGRAPHIC_BINDING_METHODS; @@ -37,6 +36,7 @@ import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_SIGNING_ALG; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_SIGNING_KEY_ID; import static org.keycloak.models.oid4vci.CredentialScopeModel.VC_SUPPORTED_TYPES; +import static org.keycloak.models.oid4vci.CredentialScopeModel.getDefaultTokenJwsTypeForFormat; /** * An extension of {@link ClientScopeRepresentation} which and adds additional functionality for OID4VCI @@ -48,12 +48,12 @@ public class CredentialScopeRepresentation extends ClientScopeRepresentation { public CredentialScopeRepresentation(String name) { this.name = name; this.protocol = OID4VCIConstants.OID4VC_PROTOCOL; + setFormat(VC_FORMAT_DEFAULT); setBuildConfigHashAlgorithm(VC_BUILD_CONFIG_HASH_ALGORITHM_DEFAULT); setBuildConfigSdJwtVisibleClaims(VC_BUILD_CONFIG_SD_JWT_VISIBLE_CLAIMS_DEFAULT); - setBuildConfigTokenJwsType(VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT); + setBuildConfigTokenJwsType(getDefaultTokenJwsTypeForFormat(VC_FORMAT_DEFAULT)); setSdJwtNumberOfDecoys(VC_SD_JWT_NUMBER_OF_DECOYS_DEFAULT); setExpiryInSeconds(VC_EXPIRY_IN_SECONDS_DEFAULT); - setFormat(VC_FORMAT_DEFAULT); } public CredentialScopeRepresentation(ClientScopeModel clientScope) { diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTestOid4Vci.java b/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTestOid4Vci.java index 7b723f2d23b3..8c0027bf34dd 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTestOid4Vci.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/client/ClientScopeTestOid4Vci.java @@ -77,7 +77,7 @@ public void testDefaultOid4VciClientScopeAttributes() { createdClientScope.getAttributes().get(CredentialScopeModel.VC_FORMAT)); Assertions.assertEquals(CredentialScopeModel.VC_BUILD_CONFIG_HASH_ALGORITHM_DEFAULT, createdClientScope.getAttributes().get(CredentialScopeModel.VC_BUILD_CONFIG_HASH_ALGORITHM)); - Assertions.assertEquals(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT, + Assertions.assertEquals(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT_SD_JWT_VC, createdClientScope.getAttributes().get(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE)); Assertions.assertEquals(String.valueOf(CredentialScopeModel.VC_EXPIRY_IN_SECONDS_DEFAULT), createdClientScope.getAttributes().get(CredentialScopeModel.VC_EXPIRY_IN_SECONDS)); diff --git a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java index 5fc989664796..5ae056cefc91 100644 --- a/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCJWTIssuerEndpointTest.java @@ -21,6 +21,7 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; @@ -91,6 +92,7 @@ import static org.keycloak.OID4VCConstants.CREDENTIAL_SUBJECT; import static org.keycloak.OID4VCConstants.OPENID_CREDENTIAL; +import static org.keycloak.OID4VCConstants.SDJWT_DELIMITER; import static org.keycloak.tests.oid4vc.OID4VCProofTestUtils.generateJwtProof; import static org.keycloak.tests.oid4vc.OID4VCProofTestUtils.jwtProofs; @@ -1363,6 +1365,42 @@ public void testCredentialIssuerSigningSpecificKey() { testCredentialIssuanceSigningConfiguration(keyMetadata.getActive().get(Constants.DEFAULT_SIGNATURE_ALGORITHM), Constants.DEFAULT_SIGNATURE_ALGORITHM); } + @Test + public void testCredentialIssuerUsesDefaultTokenJwsTypeForJwtFormatWhenUnset() { + assertDefaultTokenJwsTypeWhenUnset( + jwtTypeCredentialScope, + VCFormat.JWT_VC, + CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT_JWT_VC + ); + } + + @Test + public void testCredentialIssuerUsesDefaultTokenJwsTypeForSdJwtFormatWhenUnset() { + assertDefaultTokenJwsTypeWhenUnset( + sdJwtTypeCredentialScope, + VCFormat.SD_JWT_VC, + CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT_SD_JWT_VC + ); + } + + @Test + public void testCredentialIssuerUsesDefaultTokenJwsTypeForJwtFormatWhenBlank() { + assertDefaultTokenJwsTypeWhenBlank( + jwtTypeCredentialScope, + VCFormat.JWT_VC, + CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT_JWT_VC + ); + } + + @Test + public void testCredentialIssuerUsesDefaultTokenJwsTypeForSdJwtFormatWhenBlank() { + assertDefaultTokenJwsTypeWhenBlank( + sdJwtTypeCredentialScope, + VCFormat.SD_JWT_VC, + CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE_DEFAULT_SD_JWT_VC + ); + } + public void testCredentialIssuanceSigningConfiguration(String kid, String alg) { String scopeName = jwtTypeCredentialScope.getName(); String credConfigId = jwtTypeCredentialScope.getAttributes().get(CredentialScopeModel.VC_CONFIGURATION_ID); @@ -1406,6 +1444,86 @@ public void testCredentialIssuanceSigningConfiguration(String kid, String alg) { }); } + private void assertDefaultTokenJwsTypeWhenUnset( + ClientScopeRepresentation clientScope, + String expectedFormat, + String expectedTyp + ) { + String originalTyp = clientScope.getAttributes().remove(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE); + testRealm.admin().clientScopes().get(clientScope.getId()).update(clientScope); + + try { + String actualTyp = issueCredentialAndGetTyp(clientScope, expectedFormat); + assertEquals(expectedTyp, actualTyp, "Expected typ default for format " + expectedFormat); + } finally { + if (originalTyp == null) { + clientScope.getAttributes().remove(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE); + } else { + clientScope.getAttributes().put(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE, originalTyp); + } + testRealm.admin().clientScopes().get(clientScope.getId()).update(clientScope); + } + } + + private void assertDefaultTokenJwsTypeWhenBlank( + ClientScopeRepresentation clientScope, + String expectedFormat, + String expectedTyp + ) { + String originalTyp = clientScope.getAttributes().get(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE); + clientScope.getAttributes().put(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE, " "); + testRealm.admin().clientScopes().get(clientScope.getId()).update(clientScope); + + try { + String actualTyp = issueCredentialAndGetTyp(clientScope, expectedFormat); + assertEquals(expectedTyp, actualTyp, "Expected typ default for format " + expectedFormat); + } finally { + if (originalTyp == null) { + clientScope.getAttributes().remove(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE); + } else { + clientScope.getAttributes().put(CredentialScopeModel.VC_BUILD_CONFIG_TOKEN_JWS_TYPE, originalTyp); + } + testRealm.admin().clientScopes().get(clientScope.getId()).update(clientScope); + } + } + + private String issueCredentialAndGetTyp(ClientScopeRepresentation clientScope, String expectedFormat) { + AtomicReference typRef = new AtomicReference<>(); + + testCredentialIssuanceWithAuthZCodeFlow( + clientScope, + (testScope) -> getBearerToken(oauth, client, testScope), + m -> { + String accessToken = (String) m.get("accessToken"); + WebTarget credentialTarget = (WebTarget) m.get("credentialTarget"); + CredentialRequest credentialRequest = (CredentialRequest) m.get("credentialRequest"); + String issuer = getRealmPath(testRealm.getName()); + String cNonce = getCNonce(); + credentialRequest.setProofs(jwtProofs(issuer, cNonce)); + + try (Response response = credentialTarget.request() + .header(HttpHeaders.AUTHORIZATION, "bearer " + accessToken) + .post(Entity.json(credentialRequest))) { + assertEquals(200, response.getStatus(), "Credential request should succeed"); + CredentialResponse credentialResponse = JsonSerialization.readValue( + response.readEntity(String.class), + CredentialResponse.class + ); + String credentialValue = (String) credentialResponse.getCredentials().get(0).getCredential(); + if (VCFormat.SD_JWT_VC.equals(expectedFormat)) { + credentialValue = credentialValue.split(SDJWT_DELIMITER)[0]; + } + JWSHeader header = TokenVerifier.create(credentialValue, JsonWebToken.class).getHeader(); + typRef.set(header.getType()); + } catch (VerificationException | IOException e) { + throw new RuntimeException(e); + } + } + ); + + return typRef.get(); + } + private String getCNonce() { UriBuilder builder = UriBuilder.fromUri(keycloakUrls.getBase()); URI oid4vcUri = RealmsResource.protocolUrl(builder) From 62c9bc283657a77c68fa61d152384f74c3ee488c Mon Sep 17 00:00:00 2001 From: forkimenjeckayang Date: Wed, 8 Apr 2026 11:57:18 +0000 Subject: [PATCH 4/4] fix broken link Signed-off-by: forkimenjeckayang --- .../upgrading/topics/changes/changes-20_0_0.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/documentation/upgrading/topics/changes/changes-20_0_0.adoc b/docs/documentation/upgrading/topics/changes/changes-20_0_0.adoc index 4f6663b933e4..09676ecfad1d 100644 --- a/docs/documentation/upgrading/topics/changes/changes-20_0_0.adoc +++ b/docs/documentation/upgrading/topics/changes/changes-20_0_0.adoc @@ -21,13 +21,13 @@ If an H2 JDBC URL is provided on the command line or in a configuration file, an If the connection factory for the H2 database is initialized outside Keycloak, that initialization needs to take care of adding the `NON_KEYWORDS` attribute. -See the H2 documentation http://www.h2database.com/html/commands.html#set_non_keywords[on the `NON_KEYWORDS` attribute] for details. +See the H2 documentation https://www.h2database.com/html/commands.html#set_non_keywords[on the `NON_KEYWORDS` attribute] for details. == Upgrade of H2 database files H2 database base files created with H2 version 1.x should not be used with version 2.x. -Purge existing H2 database files to start with an empty database, export and import the realms using Keycloak's export and import functionality, or refer to the http://www.h2database.com/html/migration-to-v2.html[migration notes on the H2 database project's website] for details on how to migrate H2 database contents. +Purge existing H2 database files to start with an empty database, export and import the realms using Keycloak's export and import functionality, or refer to the https://www.h2database.com/html/migration-to-v2.html[migration notes on the H2 database project's website] for details on how to migrate H2 database contents. = Breaking changes in the new version of Keycloak Operator