Skip to content

[OID4VCI] Omit blank key attestation values from OID4VCI metadata - #51356

Merged
mposolda merged 1 commit into
keycloak:mainfrom
r0h1tb:fix/oid4vci-key-attestations-empty-arrays
Aug 13, 2026
Merged

[OID4VCI] Omit blank key attestation values from OID4VCI metadata#51356
mposolda merged 1 commit into
keycloak:mainfrom
r0h1tb:fix/oid4vci-key-attestations-empty-arrays

Conversation

@r0h1tb

@r0h1tb r0h1tb commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Closes #51347

Problem

With key attestation enabled, the credential issuer metadata contains empty-string entries:

"key_attestations_required": { "key_storage": [""], "user_authentication": [""] }

OID4VCI 12.2.4 requires key_storage and user_authentication to be non-empty arrays when present, and permits an empty key_attestations_required object when neither is constrained.

Root cause

There are two defects, and the first one is why the values are blank in the first place.

1. The setters discard their argument. All four key-attestation setters:

// CredentialScopeModel:418, :437 and CredentialScopeRepresentation:291, :306
Optional.ofNullable(keyStorage)
        .map(list -> String.join(","))      // `list` is never passed
        .orElse(null);

That is String.join(CharSequence, CharSequence...) with zero elements, so it returns "" and ignores list entirely. Configuring any resistance level writes an empty attribute.

This is the source of the reported metadata. The testsuite's key-attestation-credential scope is built with List.of(MODERATE)iso_18045_moderate was configured, silently dropped on write, and the getter then rendered the blank attribute as [""]. Introduced in #51261.

2. The getters turn a blank attribute into [""].

Optional.ofNullable(clientScope.getAttribute(VC_KEY_ATTESTATION_REQUIRED_KEY_STORAGE))
        .map(s -> Arrays.asList(s.split(",")))
        // it is important to return null here instead of an empty list ...
        .orElse(null);

The attribute is present and blank, not absent, so ofNullable sees "", "".split(",") yields a single empty element, and the orElse(null) fallback never runs. The existing comment already states that null is the intended result — the guard just does not cover the blank case.

Fix

  • SettersString.join(",", list), in both CredentialScopeModel and CredentialScopeRepresentation.
  • Getters → filter blank entries and collapse to null when nothing remains, in both classes so they agree on what a blank attribute means:
.map(s -> Arrays.stream(s.split(","))
                 .map(String::trim)
                 .filter(value -> !value.isEmpty())
                 .toList())
.filter(values -> !values.isEmpty())
.orElse(null);

KeyAttestationsRequired is already @JsonInclude(NON_NULL), so null members are omitted and the bare "key_attestations_required": {} falls out with no change there. Values are also trimmed, so " a , b " no longer yields entries with surrounding whitespace.

That covers the four cases enumerated in the issue:

key_storage user_authentication metadata
unset unset {}
set unset only key_storage
unset set only user_authentication
set set both

Tests

Per review, these live in the base testsuite rather than as unit tests. Two methods added to OID4VCIssuerWellKnownProviderTest, both driving the real metadata endpoint:

  • testKeyAttestationsRequiredAdvertisesConfiguredResistanceLevels — asserts the configured iso_18045_moderate actually reaches the metadata. This is the one that pins the setter bug.
  • testKeyAttestationsRequiredOmitsUnconfiguredResistanceLevels — walks the four cases above plus separator-only and padded input, restoring the scope in a finally.

Asserting literal expected values is deliberate. The existing coverage at OID4VCIssuerWellKnownProviderTest:714 derives its expectation from the same getter under test:

expectedKeyAttestationsRequired.setKeyStorage(credScope.getRequiredKeyAttestationKeyStorage());

so it holds whether that getter returns [""] or null — which is why this bug shipped with integration coverage already in place.

Verification

./mvnw -pl tests/base test -Dtest='OID4VCIssuerWellKnownProviderTest#testKeyAttestationsRequired*'

Tests run: 2, Failures: 0, Errors: 0

Keeping the tests and reverting only the production change:

[ERROR] testKeyAttestationsRequiredAdvertisesConfiguredResistanceLevels
Expected: iterable containing ["iso_18045_moderate"]
     but: was null
Tests run: 2, Failures: 1

That is 1, not 2, and the difference matters. Only the first test fails without the fix. The second passes on main too, so it is a spec-compliance guard rather than a regression test for the reported symptom.

The reason is worth flagging: on the reverted build the getter returned null, not [""] — writing "" through ClientScopeResource.update() comes back as an absent attribute, so I could not reproduce {"key_storage":[""]} through the admin REST path at all. The [""] in the report must come from a write path that does persist a blank value. If you know which one, I will point the second test at it so it pins the getter half too. Until then the getter hardening is defensive — it protects realms that already hold blank attributes, but no test here proves it is required.

Note that tests/base needs a prior ./mvnw install -DskipTests before the command above will resolve.

Scope

  • Getter hardening is kept alongside the setter fix on the grounds that blank attributes may already be persisted in existing realms. If you would rather the getters stayed strict and this were handled as a data migration, say so and I will drop that half.
  • No other String.join(",") call sites are affected — the rest of both classes already pass the list.

Copilot AI balanced review requested due to automatic review settings August 1, 2026 19:56
@r0h1tb
r0h1tb requested a review from a team as a code owner August 1, 2026 19:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes OID4VCI metadata generation by omitting blank key-attestation constraints.

Changes:

  • Trims and filters blank resistance-level values.
  • Returns null when no valid values remain.
  • Adds six focused regression tests.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
CredentialScopeModel.java Normalizes key-attestation attributes.
CredentialScopeModelKeyAttestationTest.java Tests absent, blank, mixed, and configured values.

@mposolda mposolda self-assigned this Aug 3, 2026

@mposolda mposolda left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR. Adding a review comment. Can you please doublecheck?

* previously yielded {@code [""]}, because {@code "".split(",")} returns a single empty
* element and so the null fallback never applied.
*/
public class CredentialScopeModelKeyAttestationTest {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it please possible to rather test with the integration test from the base testsuite? Maybe adding new test method to the OID4VCIssuerWellKnownProviderTest would work fine.

@mposolda mposolda changed the title Omit blank key attestation values from OID4VCI metadata [OID4VCI] Omit blank key attestation values from OID4VCI metadata Aug 3, 2026
Copilot AI review requested due to automatic review settings August 3, 2026 11:16
@r0h1tb
r0h1tb force-pushed the fix/oid4vci-key-attestations-empty-arrays branch from 2296702 to 8193dcb Compare August 3, 2026 11:16
@r0h1tb

r0h1tb commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

@mposolda thanks — moved to the base testsuite as you asked, and doing so surfaced a second bug that I think changes the shape of this fix. Flagging it up front because it widens the diff beyond what #51347 describes.

The integration test found a setter bug

All four key-attestation setters drop their argument:

// CredentialScopeModel:418, :437
// CredentialScopeRepresentation:291, :306
.map(list -> String.join(","))     // `list` is never passed

That is String.join(CharSequence, CharSequence...) with zero elements, so it returns "" and ignores list entirely. Configuring any resistance level writes an empty attribute.

This is where the reported [""] actually comes from. The testsuite's key-attestation-credential scope is set up with List.of(MODERATE) — so iso_18045_moderate was configured, silently discarded on write, and the getter then turned the blank attribute into [""]. Introduced in #51261.

So my original patch was treating the symptom. It made the metadata spec-compliant, but a user configuring iso_18045_high in the Admin Console would still have it silently thrown away — just invisibly rather than visibly. I'd rather not land that.

Why the unit test couldn't see it, and yours can

You were right to push this to the base testsuite, and for a stronger reason than the mocking shape. My unit test stubbed the attribute directly, so it exercised the getter in isolation and could never observe the setter.

The existing integration coverage couldn't catch it either, for a different reason — OID4VCIssuerWellKnownProviderTest:714 builds the expectation from the same getter under test:

expectedKeyAttestationsRequired.setKeyStorage(credScope.getRequiredKeyAttestationKeyStorage());

That assertion holds whether the getter returns [""] or null, so it passes before and after either fix. The new tests assert against literal expected values instead, which is what makes them able to fail.

What changed

  • Fixed all four setters to String.join(",", list).
  • Applied the blank-filtering getter fix to CredentialScopeRepresentation too — it had the same Arrays.asList(s.split(",")) shape as the model. Without this the two classes disagree on what a blank attribute means.
  • Deleted CredentialScopeModelKeyAttestationTest and the java.lang.reflect.Proxy stub with it.
  • Added two methods to OID4VCIssuerWellKnownProviderTest:
    • testKeyAttestationsRequiredAdvertisesConfiguredResistanceLevels — asserts the configured iso_18045_moderate actually reaches the metadata. This is the one that pins the setter bug.
    • testKeyAttestationsRequiredOmitsUnconfiguredResistanceLevels — walks the four cases from your issue plus separator-only and padded input, restoring the scope in a finally.

Verification

./mvnw -pl tests/base test -Dtest='OID4VCIssuerWellKnownProviderTest#testKeyAttestationsRequired*'
Tests run: 2, Failures: 0, Errors: 0

Keeping the tests and reverting only the production change:

[ERROR] testKeyAttestationsRequiredAdvertisesConfiguredResistanceLevels
Expected: iterable containing ["iso_18045_moderate"]
     but: was null
Tests run: 2, Failures: 1

I want to be precise about that "1", because it is not 2. Only the first test fails without the fix. The second passes on main too, so it is a spec-compliance guard rather than a regression test for the reported symptom — I would rather say that than imply more coverage than it has.

The reason is worth your attention. On the reverted build the getter returned null, not [""] — so writing "" through ClientScopeResource.update() does not leave a present-but-blank attribute; it comes back absent. I could not reproduce {"key_storage":[""]} through the admin REST path at all.

Which raises a question I could not answer from here: the [""] you saw must come from a write path that does persist a blank value where the admin API does not. If you know which one (Admin Console UI directly? realm import?), I will point the second test at it so it actually pins the getter half. Until then I have left the getter hardening in as defensive — it costs nothing and protects realms that already hold blank attributes — but I am not claiming a test proves it is needed.

DCO is signed off now as well.

One open question: if you would rather the getters stayed strict and blank data were handled as a migration instead, say so and I will drop that half and keep only the setter fix.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

Suppressed comments (1)

tests/base/src/test/java/org/keycloak/tests/oid4vc/OID4VCIssuerWellKnownProviderTest.java:606

  • The PR’s Tests/Verification section names CredentialScopeModelKeyAttestationTest and a server-spi-private command, but that class does not exist in this change and the added coverage is this tests/base integration test, so the documented command does not verify the submitted test. Please update the PR description with the actual test and command that were run (or add the described unit test).
    @Test
    public void testKeyAttestationsRequiredOmitsUnconfiguredResistanceLevels() throws IOException {

@r0h1tb

r0h1tb commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Good catch from the Copilot review — the description was stale. I rewrote the code and the tests but left the original Tests/Verification section in place, so it still named CredentialScopeModelKeyAttestationTest and a server-spi-private -am test command. That class is deleted in this PR, so the documented command verified nothing.

The description now describes what is actually here: the two OID4VCIssuerWellKnownProviderTest methods and

./mvnw -pl tests/base test -Dtest='OID4VCIssuerWellKnownProviderTest#testKeyAttestationsRequired*'

(with the note that tests/base needs a prior ./mvnw install -DskipTests to resolve). No code change — description only.

Closes keycloak#51347

Both key attestation setters silently discarded their argument:

    Optional.ofNullable(keyStorage)
            .map(list -> String.join(","))
            .orElse(null);

That is String.join(CharSequence, CharSequence...) with zero elements, so it
returns "" and ignores list entirely. Configuring any resistance level wrote an
empty attribute. This is the source of the reported metadata: the testsuite's
key-attestation-credential scope is built with List.of(MODERATE), the value was
dropped on write, and the getter then rendered the blank attribute as:

    "key_attestations_required": {"key_storage":[""],"user_authentication":[""]}

The same defect is present in CredentialScopeRepresentation, so both classes are
fixed to String.join(",", list).

OID4VCI 12.2.4 requires key_storage and user_authentication to be non-empty
arrays when present, and permits an empty key_attestations_required object when
neither is constrained. The getters now filter blank entries and collapse to
null when nothing remains, which is the contract their existing comment already
describes. CredentialScopeRepresentation gains the same treatment so the two
classes agree on what a blank attribute means. KeyAttestationsRequired is
annotated @JsonInclude(NON_NULL), so null members are omitted and the bare
"key_attestations_required": {} falls out without further change. Values are
also trimmed, so " a , b " no longer yields entries with surrounding whitespace.

Testing moves to the base testsuite per review. The unit test and its
java.lang.reflect.Proxy stub are removed in favour of two methods on
OID4VCIssuerWellKnownProviderTest, which exercise the real metadata endpoint:
one asserting a configured resistance level reaches the metadata, and one
walking the cases from the report. Asserting literal expected values matters
here -- the existing coverage derives its expectation from the same getter under
test, so it holds regardless of what that getter returns.

Signed-off-by: Rohit Behera <126186063+r0h1tb@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 12, 2026 04:42
@r0h1tb
r0h1tb force-pushed the fix/oid4vci-key-attestations-empty-arrays branch from 8193dcb to 91ce00d Compare August 12, 2026 04:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

@r0h1tb

r0h1tb commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (8193dcb91ce00de). The diff is unchanged — 3 files, +140/-8.

The four red checks were a stale base, not the change. The branch head was Aug 3 and main was Aug 11, so it was ~100 commits behind; the tell was Keycloak JavaScript CI failing on a Java-only OID4VCI change, with annotations that were bare Process completed with exit code 1 and no source-level error. CodeQL was green on main throughout.

Workflow runs are currently sitting at action_required and need an approval before CI will report.

@mposolda mposolda left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@r0h1tb Thanks!

@mposolda
mposolda merged commit f078da7 into keycloak:main Aug 13, 2026
94 of 100 checks passed
theohh0 pushed a commit to theohh0/keycloak that referenced this pull request Aug 13, 2026
Closes keycloak#51347

Signed-off-by: Rohit Behera <126186063+r0h1tb@users.noreply.github.com>
Signed-off-by: theohh0 <theo.hinton-hallows@ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[OID4VCI] Incorrect metadata for key_attestations_required

3 participants