8000 [OID4VCI] align issuer metadata/encryption behavior and move encryption tests to base suite by forkimenjeckayang · Pull Request #48048 · 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 @@ -3660,8 +3660,10 @@ signedMetadataLifespan=Signed Metadata Lifespan
signedMetadataLifespanHelp=The lifetime of the signed metadata. After this time, the signed metadata will expire.
signedMetadataSigningAlgorithm=Signed Metadata Signing Algorithm
signedMetadataSigningAlgorithmHelp=The algorithm used to sign the issuer metadata. This ensures the integrity and authenticity of the metadata.
requireEncryption=Require Encryption
requireEncryptionHelp=If enabled, encryption is required for credential requests. Clients must encrypt their requests using the supported encryption algorithms.
requireRequestEncryption=Require Request Encryption
requireRequestEncryptionHelp=If enabled, encryption is required for credential requests on top of TLS. Wallets must encrypt requests using the supported request encryption metadata.
requireResponseEncryption=Require Response Encryption
requireResponseEncryptionHelp=If enabled, encryption is required for credential responses on top of TLS. Wallets must provide encryption keys in the credential request.
enableDeflateCompression=Enable DEF compression
enableDeflateCompressionHelp=If enabled, the DEF compression algorithm is supported for credential requests. This allows clients to compress their requests to reduce payload size.
batchIssuanceSize=Batch Issuance Size
Expand Down
27 changes: 19 additions & 8 deletions js/apps/admin-ui/src/realm-settings/TokensTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,12 @@ export const RealmSettingsTokensTab = ({
defaultValue: false,
});

const encryptionRequired = useWatch({
const requestEncryptionRequired = useWatch({
control,
name: convertAttributeNameToForm("attributes.oid4vci.encryption.required"),
defaultValue: realm.attributes?.["oid4vci.encryption.required"],
name: convertAttributeNameToForm(
"attributes.oid4vci.request.encryption.required",
),
defaultValue: realm.attributes?.["oid4vci.request.encryption.required"],
});

const strategy = useWatch({
Expand Down Expand Up @@ -726,14 +728,23 @@ export const RealmSettingsTokensTab = ({
/>
<DefaultSwitchControl
name={convertAttributeNameToForm(
"attributes.oid4vci.encryption.required",
"attributes.oid4vci.request.encryption.required",
)}
label={t("requireEncryption")}
labelIcon={t("requireEncryptionHelp")}
label={t("requireRequestEncryption")}
labelIcon={t("requireRequestEncryptionHelp")}
stringify
data-testid="require-encryption-switch"
data-testid="require-request-encryption-switch"
/>
{encryptionRequired === "true" && (
<DefaultSwitchControl
name={convertAttributeNameToForm(
"attributes.oid4vci.response.encryption.required",
)}
label={t("requireResponseEncryption")}
labelIcon={t("requireResponseEncryptionHelp")}
stringify
data-testid="require-response-encryption-switch"
/>
{requestEncryptionRequired === "true" && (
<DefaultSwitchControl
name={convertAttributeNameToForm(
"attributes.oid4vci.request.zip.algorithms",
Expand Down
24 changes: 17 additions & 7 deletions js/apps/admin-ui/test/realm-settings/oid4vci-attributes.spec.ts
10BC8
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,15 @@ test("should save signed metadata, encryption, and batch issuance settings", asy
await expect(signedMetadataAlgField).toBeVisible();
await selectItem(page, signedMetadataAlgField, "ES256");

const requireEncryptionSwitch = page.getByTestId(
"attributes.oid4vci.encryption.required",
const requireRequestEncryptionSwitch = page.getByTestId(
"attributes.oid4vci.request.encryption.required",
);
await requireEncryptionSwitch.click({ force: true });
await requireRequestEncryptionSwitch.click({ force: true });

const requireResponseEncryptionSwitch = page.getByTestId(
"attributes.oid4vci.response.encryption.required",
);
await requireResponseEncryptionSwitch.click({ force: true });

const batchIssuanceField = page.locator(
'[id="attributes.oid4vci🍺batch_credential_issuance🍺batch_size"]',
Expand All @@ -283,7 +288,12 @@ test("should save signed metadata, encryption, and batch issuance settings", asy
"7200",
);
expect(realmData?.attributes?.["oid4vci.signed_metadata.alg"]).toBe("ES256");
expect(realmData?.attributes?.["oid4vci.encryption.required"]).toBe("true");
expect(realmData?.attributes?.["oid4vci.request.encryption.required"]).toBe(
"true",
);
expect(realmData?.attributes?.["oid4vci.response.encryption.required"]).toBe(
"true",
);
expect(
realmData?.attributes?.["oid4vci.batch_credential_issuance.batch_size"],
).toBe("5");
Expand Down Expand Up @@ -351,10 +361,10 @@ test("should save Deflate Compression setting", async ({ page }) => {
const oid4vciJumpLink = page.getByTestId("jump-link-oid4vci-attributes");
await oid4vciJumpLink.click();

const encryptionSwitch = page.getByTestId(
"attributes.oid4vci.encryption.required",
const requestEncryptionSwitch = page.getByTestId(
"attributes.oid4vci.request.encryption.required",
);
await encryptionSwitch.click({ force: true });
await requestEncryptionSwitch.click({ force: true });

const deflateSwitch = page.getByTestId(
"attributes.oid4vci.request.zip.algorithms",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,18 @@ private CredentialRequest validateRequestEncryption(String requestPayload, Crede
contentType = contentType.split(";")[0].trim(); // Handle parameters like charset
}
boolean contentTypeIsJwt = MediaType.APPLICATION_JWT.equalsIgnoreCase(contentType);
boolean payloadLooksLikeJwe = isCompactJwePayload(requestPayload);

// OID4VCI Section 10: encrypted request payloads MUST use application/jwt media type.
if (payloadLooksLikeJwe && !contentTypeIsJwt) {
String errorMessage = "Encrypted Credential Request must use Content-Type application/jwt.";
LOGGER.debug(errorMessage);
if (eventBuilder != null) {
eventBuilder.detail(Details.REASON, errorMessage)
.error(ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue());
}
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}

if (isRequestEncryptionRequired || contentTypeIsJwt) {
if (requestEncryptionMetadata == null && contentTypeIsJwt) {
Expand Down Expand Up @@ -1024,6 +1036,17 @@ private CredentialRequest validateRequestEncryption(String requestPayload, Crede

try {
CredentialRequest credentialRequest = JsonSerialization.mapper.readValue(requestPayload, CredentialRequest.class);
// OID4VCI 1.0 Section 8.2:
// If credential_response_encryption is requested, Credential Request encryption MUST be used.
if (credentialRequest.getCredentialResponseEncryption() != null) {
String errorMessage = "credential_response_encryption requires encrypted Credential Request (JWE) on top of TLS.";
LOGGER.debug(errorMessage);
if (eventBuilder != null) {
eventBuilder.detail(Details.REASON, errorMessage)
.error(ErrorType.INVALID_ENCRYPTION_PARAMETERS.getValue());
}
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_ENCRYPTION_PARAMETERS, errorMessage));
}
normalizeProofFields(credentialRequest);
return credentialRequest;
} catch (JsonProcessingException e) {
Expand Down Expand Up @@ -1401,6 +1424,21 @@ private boolean isSupportedCompression(CredentialResponseEncryptionMetadata meta
metadata.getZipValuesSupported().contains(zip);
}

/**
* Detect whether payload is a compact JWE by delegating to Keycloak JOSE parser.
*/
private boolean isCompactJwePayload(String payload) {
if (payload == null || payload.trim().isEmpty()) {
return false;
}
try {
JWE jwe = new JWE(payload.trim());
return jwe.getHeader() != null;
} catch (Exception ignored) {
return false;
}
}

private AuthenticatedClientSessionModel getAuthenticatedClientSession() {
AuthenticationManager.AuthResult authResult = getAuthResult();
UserSessionModel userSessionModel = authResult.session();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import java.net.URI;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -87,7 +88,8 @@ public class OID4VCIssuerWellKnownProvider implements WellKnownProvider {
public static final String SIGNED_METADATA_ALG_ATTR = "oid4vci.signed_metadata.alg";

public static final String VC_KEY = "vc";
public static final String ATTR_ENCRYPTION_REQUIRED = "oid4vci.encryption.required";
public static final String ATTR_RESPONSE_ENCRYPTION_REQUIRED = "oid4vci.response.encryption.required";
public static final String ATTR_REQUEST_ENCRYPTION_REQUIRED = "oid4vci.request.encryption.required";

public static final String DEFLATE_COMPRESSION = "DEF";
public static final String ATTR_REQUEST_ZIP_ALGS = "oid4vci.request.zip.algorithms";
Expand Down Expand Up @@ -117,30 +119,15 @@ public Object getConfig() {
public CredentialIssuer getIssuerMetadata() {
KeycloakContext context = keycloakSession.getContext();

// Build encryption metadata first to enforce coupling rule from spec:
// If credential_response_encryption is included, credential_request_encryption MUST also be included.
// Build encryption metadata.
// Request and response encryption are independent per OID4VCI.
CredentialResponseEncryptionMetadata responseEnc = getCredentialResponseEncryption(keycloakSession);
CredentialRequestEncryptionMetadata requestEnc = getCredentialRequestEncryption(keycloakSession);

// Keep response encryption metadata even if request encryption metadata is missing
// Request and response encryption are independent.
if (responseEnc != null && requestEnc == null) {
LOGGER.warn("credential_response_encryption is advertised but credential_request_encryption metadata is not available. " +
"If response encryption is included, request encryption should also be included. " +
"keep response metadata and setting encryption_required=false.");
if (Boolean.TRUE.equals(responseEnc.getEncryptionRequired())) {
responseEnc.setEncryptionRequired(false);
}
}

// Consistency rule: if both are present and response encryption is required, mark request encryption as required too
if (responseEnc != null && requestEnc != null) {
boolean responseRequired = Boolean.TRUE.equals(responseEnc.getEncryptionRequired());
boolean requestRequired = Boolean.TRUE.equals(requestEnc.isEncryptionRequired());
if (responseRequired && !requestRequired) {
LOGGER.warn("credential_response_encryption.encryption_required=true while credential_request_encryption.encryption_required is false. " +
"Marking request encryption as required to maintain consistency.");
requestEnc.setEncryptionRequired(true);
}
LOGGER.debug("credential_response_encryption is advertised but credential_request_encryption metadata is not available. " +
"This is allowed because request and response encryption are independent.");
}

// Add deprecation headers/logs if the old realm-scoped route was used
Expand All @@ -158,19 +145,23 @@ public CredentialIssuer getIssuerMetadata() {
}

public Object getMetadataResponse(CredentialIssuer issuer, KeycloakSession session) {
RealmModel realm = session.getContext().getRealm();
String acceptHeader = session.getContext().getRequestHeaders().getHeaderString(HttpHeaders.ACCEPT);
boolean preferJwt = acceptHeader != null && acceptHeader.contains(MediaType.APPLICATION_JWT);
boolean preferJwt = acceptHeader != null
&& acceptHeader.toLowerCase(Locale.ROOT).contains(MediaType.APPLICATION_JWT);

if (preferJwt) {
Optional<String> signedJwt = generateSignedMetadata(issuer, session);
if (signedJwt.isPresent()) {
session.getContext().getHttpResponse().setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JWT);
return signedJwt.get();
} else {
RealmModel realm = session.getContext().getRealm();
LOGGER.debugf("Falling back to JSON response due to signed metadata failure for realm: %s", realm.getName());
}
}

// JSON metadata is mandatory and always supported.
session.getContext().getHttpResponse().setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
return issuer;
}

Expand Down Expand Up @@ -349,13 +340,17 @@ public static CredentialRequestEncryptionMetadata getCredentialRequestEncryption
RealmModel realm = session.getContext().getRealm();

// Build JWKS with public encryption keys
JSONWebKeySet jwks = buildJwks(session);
JSONWebKeySet jwks = buildJwksForEncryption(session);

// If encryption is required but no keys exist → reject unencrypted requests
boolean encryptionRequired = isEncryptionRequired(realm);
// Request encryption requirement is independent from response encryption requirement.
boolean requestEncryptionRequired = false;
String requestEncRequiredAttr = realm.getAttribute(ATTR_REQUEST_ENCRYPTION_REQUIRED);
if (requestEncRequiredAttr != null) {
requestEncryptionRequired = Boolean.parseBoolean(requestEncRequiredAttr);
}
if (jwks.getKeys() == null || jwks.getKeys().length == 0) {
if (encryptionRequired) {
LOGGER.error("Encryption is required but no valid encryption keys are available.");
if (requestEncryptionRequired) {
LOGGER.error("Request encryption is required but no valid encryption keys are available.");
throw new IllegalStateException("Missing encryption keys for required credential_request_encryption.");
} else {
LOGGER.warn("No valid encryption keys found; omitting credential_request_encryption metadata.");
Expand All @@ -368,7 +363,7 @@ public static CredentialRequestEncryptionMetadata getCredentialRequestEncryption
.setJwks(jwks)
.setEncValuesSupported(getSupportedEncryptionMethods())
.setZipValuesSupported(getSupportedZipAlgorithms(realm))
.setEncryptionRequired(encryptionRequired);
.setEncryptionRequired(requestEncryptionRequired);

return metadata;
}
Expand All @@ -391,7 +386,7 @@ public static List<String> getSupportedEncryptionAlgorithms(KeycloakSession sess
/**
* Builds JWKS from realm encryption keys with use=enc.
*/
private static JSONWebKeySet buildJwks(KeycloakSession session) {
private static JSONWebKeySet buildJwksForEncryption(KeycloakSession session) {
RealmModel realm = session.getContext().getRealm();
JSONWebKeySet jwks = JWKSServerUtils.getRealmJwks(session, realm);

Expand Down Expand Up @@ -432,7 +427,7 @@ private static List<String> getSupportedEncryptionMethods() {
* Returns whether encryption is required from realm attributes.
*/
private static boolean isEncryptionRequired(RealmModel realm) {
String required = realm.getAttribute(ATTR_ENCRYPTION_REQUIRED);
String required = realm.getAttribute(ATTR_RESPONSE_ENCRYPTION_REQUIRED);
return Boolean.parseBoolean(required);
}

Expand Down Expand Up @@ -465,8 +460,8 @@ public static Map<String, SupportedCredentialConfiguration> getSupportedCredenti


private static void applyFormatSpecificMetadata(KeycloakSession keycloakSession,
SupportedCredentialConfiguration config,
CredentialScopeModel credentialScope) {
SupportedCredentialConfiguration config,
8D85 CredentialScopeModel credentialScope) {
String format = config.getFormat();
if (format == null) {
return;
Expand Down Expand Up @@ -530,8 +525,8 @@ public static String getCredentialsEndpoint(KeycloakContext context) {
public static List<String> getAuthorizationServers(KeycloakSession session) {
return List.of(getIssuer(session.getContext()));
}
/**

/**
* Returns the supported asymmetric signature algorithms.
* Delegates to CryptoUtils for shared implementation with OIDCWellKnownProvider.
* This includes all asymmetric algorithms supported by Keycloak (RSA, EC, EdDSA).
Expand Down
Loading
Loading
0