Skip to content

[OID4VCI] keep credential-offer state reusable and align offer-flow tests - #48586

Closed
forkimenjeckayang wants to merge 2 commits into
keycloak:mainfrom
adorsys:oid4vci-conformace-4
Closed

[OID4VCI] keep credential-offer state reusable and align offer-flow tests#48586
forkimenjeckayang wants to merge 2 commits into
keycloak:mainfrom
adorsys:oid4vci-conformace-4

Conversation

@forkimenjeckayang

Copy link
Copy Markdown
Contributor

Summary

  • Fixes OID4VCI conformance failures caused by credential-offer state being removed too early during issuance.
  • Keeps credential-offer URIs reusable (no one-time nonce consumption behavior), aligned with the intended runtime contract.
  • Updates offer-flow tests to match reusable offer behavior across auth-code and pre-authorized flows.

Problem

Conformance flow oid4vci-1_0-issuer-happy-flow (pre-authorized code variant) failed with:

  • No credential offer state for: <credentials_offer_id>

This happened because offer state could be unavailable when follow-up accesses occurred.

Why

OID4VCI flows may require follow-up accesses after initial interactions. Persisting offer state for normal lifetime (TTL) and keeping offer fetch reusable prevents false invalid_credential_request failures and aligns implementation + tests with expected behavior.

image

see: adorsys#245 (comment)
Depends on : #48528

@VinodAnandan

Copy link
Copy Markdown
Contributor

@forkimenjeckayang thank you for your PR. Unfortunately, there are some new conflicts that need to be resolved. Could you please take a look when you get a chance?

@VinodAnandan

Copy link
Copy Markdown
Contributor

@mposolda, @graziang, @pskopek, @jimmychakkalakal: Could you please help review this community contribution PR when you have a chance?

@tdiesler

tdiesler commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

AFAIK, the VCI tests don't deal with offers at all because there is no standard way to make the Issuer create an offer.
Instead, the PAR request contains a scope property like this

client_id=oid4vci-client
&redirect_uri=https://localhost.emobix.co.uk:8443/test/a/keycloak/callback
&scope=oid4vc_natural_person_sd
&state=lIpOyhpIZN
&response_type=code
&code_challenge=IkwUJq4V3ILmA1PKkH-Rj9WLTQQ7hPO5RNNEm0_QVZc
&code_challenge_method=S256&OSmPRkYF9tPSKe5y=m2TG0Xi06L4qKqZT

and the Issuer returns authorization_details fromt the token endpoint like this

{
  "access_token": "eyJhbGciOiJSUzI1NiI...c3ZC4vR2AQ",
  "expires_in": 300,
  "refresh_expires_in": 1800,
  "refresh_token": "eyJhbGciOiJIUzUxMi...2l4M4BiZg",
  "token_type": "DPoP",
  "not-before-policy": 1781157283,
  "session_state": "9yhzYKSyFy3y5NWDKtP7y9ci",
  "scope": "profile oid4vc_natural_person_sd",
  "authorization_details": [
    {
      "type": "openid_credential",
      "credential_configuration_id": "oid4vc_natural_person_sd",
      "credential_identifiers": [
        "oid4vc_natural_person_sd_0000"
      ]
    }
  ]
}

The credential request then uses the credential_identifiers from the the token response.

The oid4vci-1_0-issuer-happy-flow does not request a credential by offer.

Perhaps there is another use case that requires the offer state to survive the credential request, could you describe that and explain why the pre-authorized_code should not be single use?

Signed-off-by: forkimenjeckayang <forkimenjeckayang@gmail.com>
@forkimenjeckayang

Copy link
Copy Markdown
Contributor Author

AFAIK, the VCI tests don't deal with offers at all because there is no standard way to make the Issuer create an offer. Instead, the PAR request contains a scope property like this

client_id=oid4vci-client
&redirect_uri=https://localhost.emobix.co.uk:8443/test/a/keycloak/callback
&scope=oid4vc_natural_person_sd
&state=lIpOyhpIZN
&response_type=code
&code_challenge=IkwUJq4V3ILmA1PKkH-Rj9WLTQQ7hPO5RNNEm0_QVZc
&code_challenge_method=S256&OSmPRkYF9tPSKe5y=m2TG0Xi06L4qKqZT

and the Issuer returns authorization_details fromt the token endpoint like this

{
  "access_token": "eyJhbGciOiJSUzI1NiI...c3ZC4vR2AQ",
  "expires_in": 300,
  "refresh_expires_in": 1800,
  "refresh_token": "eyJhbGciOiJIUzUxMi...2l4M4BiZg",
  "token_type": "DPoP",
  "not-before-policy": 1781157283,
  "session_state": "9yhzYKSyFy3y5NWDKtP7y9ci",
  "scope": "profile oid4vc_natural_person_sd",
  "authorization_details": [
    {
      "type": "openid_credential",
      "credential_configuration_id": "oid4vc_natural_person_sd",
      "credential_identifiers": [
        "oid4vc_natural_person_sd_0000"
      ]
    }
  ]
}

The credential request then uses the credential_identifiers from the the token response.

The oid4vci-1_0-issuer-happy-flow does not request a credential by offer.

Perhaps there is another use case that requires the offer state to survive the credential request, could you describe that and explain why the pre-authorized_code should not be single use?

Thanks for the review and the feedback! I understand the concern, but there's an important distinction here based on the specific test variant being executed.

1. The Conformance Suite DOES use Offers for Pre-Authorized Code
While it's true that in Wallet-Initiated Authorization Code flows the tests rely on PAR requests and authorization_details (as in your example), the failing test variant here is specifically Issuer-Initiated with Pre-Authorized Code:

  • vci_authorization_code_flow_variant=issuer_initiated
  • vci_grant_type=pre_authorization_code

For this specific grant type, the OID4VCI spec (Section 4.1.1) mandates that the pre-authorized_code is delivered via the grants object in a Credential Offer. The conformance suite does process offers in this mode (e.g., using its internal VCIExtractPreAuthorizedCodeAndTxCodeFromCredentialOffer class) to extract the code and make the Token Request.

2. Clarification: The pre-authorized_code is STILL single-use
To clarify your second point: this PR does not make the pre-authorized_code multi-use.

  • Replay protection for the pre-authorized_code remains intact at the token exchange level (Token Endpoint). Once it is exchanged for an Access Token, it is consumed and cannot be used again.
  • What this PR changes is the Offer State tracked at the Credential Endpoint.

3. Why the Offer State must survive the Credential Request
OID4VCI Section 14.3 ("Multiple Accesses to the Credential Endpoint") explicitly states:

"The Credential Endpoint can be accessed multiple times by a Wallet using the same Access Token, even for the same Credential."

Before this PR, Keycloak was aggressively deleting the offer state as soon as the first credential was issued (offerStorage.removeOfferState(offerState)). Because the state was wiped out, any valid follow-up requests (such as multi-credential fetches) to the Credential Endpoint using the same valid Access Token were failing with invalid_credential_request (No credential offer state for: <credentials_offer_id>).

By allowing the offer state to persist for its normal TTL rather than eagerly deleting it, we strictly comply with Section 14.3. This allows the Wallet to make multiple accesses to the Credential Endpoint without the server falsely failing the request due to premature state cleanup.

@tdiesler

tdiesler commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

This is interesting - thanks. How does the conformance suite tell the Issuer to create a credential offer (i.e. how does issuer_initiated work). This could perhaps give valuable insight on how the "create credential offer" process should work.

This spec requirement ...

The Credential Endpoint can be accessed multiple times by a Wallet using the same Access Token

makes me wonder whether we should remove the offer state not later but earlier ;-)

If the client has an access token derived from a pre-authorized_code grant, it could be interpreted as sufficient proof that the client received a pre-authorized offer. In which case the credential endpoint would not need to check the offer state (again). If true, we could perhaps remove the offer state at the end of the token request already. wdyt?

@forkimenjeckayang

Copy link
Copy Markdown
Contributor Author

This is interesting - thanks. How does the conformance suite tell the Issuer to create a credential offer (i.e. how does issuer_initiated work). This could perhaps give valuable insight on how the "create credential offer" process should work.

Great question about how issuer_initiated works, and your thought about deleting the state earlier is spot on architecturally!

1. How the Conformance Suite receives the offer
You're exactly right that the conformance suite doesn't tell the issuer to create an offer. Instead, we use an external orchestration script (simulating an out-of-band delivery like a QR code). The process looks like this:

  1. Our script calls Keycloak's REST API (/protocol/oid4vc/create-credential-offer?pre_authorized=true) to generate a fresh Credential Offer URI.
  2. The script fetches the full JSON offer from Keycloak.
  3. The script then POSTs this JSON offer directly to the Conformance Suite's /credential_offer webhook endpoint.
  4. The Conformance Suite parses the injected offer, extracts the pre-authorized_code, and hits Keycloak's Token Endpoint.

This spec requirement ...

The Credential Endpoint can be accessed multiple times by a Wallet using the same Access Token

makes me wonder whether we should remove the offer state not later but earlier ;-)

If the client has an access token derived from a pre-authorized_code grant, it could be interpreted as sufficient proof that the client received a pre-authorized offer. In which case the credential endpoint would not need to check the offer state (again). If true, we could perhaps remove the offer state at the end of the token request already. wdyt?

2. Regarding removing the offer state at the Token Request
Your idea to remove the offer state at the end of the token request is a very valid long-term architectural optimization. While Keycloak does successfully write the credential_configuration_id into the Access Token's authorization_details today, the offerState still serves as the central repository for the full issuance context (such as the specific target_user, target_client, and the original offer nonce).

In Keycloak's current implementation for the pre-authorized code flow, the Token Endpoint injects a custom credentials_offer_id into the Access Token's authorization_details. When the Credential Endpoint is called, Keycloak extracts this ID and maps the token back to the full offerState to validate these constraints.

If we deleted the offer state at the Token Endpoint today, the Credential Endpoint would immediately fail with a No credential offer state for... error because it expects to find this context to authorize the issuance.

By simply keeping the offerState alive for its natural TTL (the approach in this PR), we achieve full compliance with Section 14.3 (allowing multiple Credential Endpoint accesses) using the existing state-mapping architecture. The state organically expires and cleans itself up when the TTL is reached, preventing memory leaks without requiring a larger refactor of how authorization context is handed off between endpoints.

Does this pragmatic approach sound reasonable for the current implementation?

@tdiesler

Copy link
Copy Markdown
Contributor

Well, I'd say the lifecycle of a pre-auth offer state should end when it is exchanged for a auth token. It is actually the credential_identifier not the credential_configuration_id that matters for the credential request. Let me ponder about / tinker with this a little longer - it might be tru for the the authorization_code grant as well.

As far as this PR is concerned, it fine with me that it gets merged as is. In a follow up, I could try to get of references to the offer state from the credential endpoint if all the information ever needed is in the access/refresh token already. Anyway, this "optimization" does not strictly need to be part of this PR

@forkimenjeckayang

Copy link
Copy Markdown
Contributor Author

Well, I'd say the lifecycle of a pre-auth offer state should end when it is exchanged for a auth token. It is actually the credential_identifier not the credential_configuration_id that matters for the credential request. Let me ponder about / tinker with this a little longer - it might be tru for the the authorization_code grant as well.

As far as this PR is concerned, it fine with me that it gets merged as is. In a follow up, I could try to get of references to the offer state from the credential endpoint if all the information ever needed is in the access/refresh token already. Anyway, this "optimization" does not strictly need to be part of this PR

Perfect, thank you for the thorough review. Insightful observation.

@tdiesler

Copy link
Copy Markdown
Contributor

LGTM

@VinodAnandan VinodAnandan 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.

LGTM

@mposolda mposolda self-assigned this Jun 30, 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.

@forkimenjeckayang It seems that your PR has lots of irrelevant changes and looks that it was somehow messed with your other PR for credential signing #48528 . If I understand correctly, only relevant change is in the class OID4VCIssuerEndpoint (besides changes in the testsuite). Am I correct?

Moreover, I think that not removing credential offer does not solve the issue entirely. Maybe it works for the scenario from the conformance testsuite, but may fail for some other cases like:

  • Credential offer timeout is shorter than access token timeout. For example when credential offer timeout is 1 minute, access token timeout is 5 minutes and credential-request would be sent with the access token after 2 minutes from authentication (when credential offer is expired), request would fail
  • Request would also fail for the cases of refresh tokens used (see specs 14.5) . As the refresh-token (and subsequent credential-request) can happen very long time after initial authentication (possibly even weeks/months).

Right now, it looks to me that to support refresh tokens together with credential offers, we may rather refactor credential offers to not be needed at credential-request at all, but being verified (and removed) in the token-endpoint after initial issuance . The related GH issue is #50340 . This would apply for authorization_code as well as pre-authorized code and hence should solve this issue as well. FYI. The issue is planned for our current sprint of core team and hence we can hopefully address this relatively soon.

@forkimenjeckayang

forkimenjeckayang commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

@forkimenjeckayang It seems that your PR has lots of irrelevant changes and looks that it was somehow messed with your other PR for credential signing #48528 . If I understand correctly, only relevant change is in the class OID4VCIssuerEndpoint (besides changes in the testsuite). Am I correct?

Moreover, I think that not removing credential offer does not solve the issue entirely. Maybe it works for the scenario from the conformance testsuite, but may fail for some other cases like:

  • Credential offer timeout is shorter than access token timeout. For example when credential offer timeout is 1 minute, access token timeout is 5 minutes and credential-request would be sent with the access token after 2 minutes from authentication (when credential offer is expired), request would fail
  • Request would also fail for the cases of refresh tokens used (see specs 14.5) . As the refresh-token (and subsequent credential-request) can happen very long time after initial authentication (possibly even weeks/months).

Right now, it looks to me that to support refresh tokens together with credential offers, we may rather refactor credential offers to not be needed at credential-request at all, but being verified (and removed) in the token-endpoint after initial issuance . The related GH issue is #50340 . This would apply for authorization_code as well as pre-authorized code and hence should solve this issue as well. FYI. The issue is planned for our current sprint of core team and hence we can hopefully address this relatively soon.

@mposolda Yes, the PR is currently polluted by changes from #48528 because this branch was based on oid4vci-conformace-3. I’ll clean that up so this PR only contains the credential-offer change and the related offer-flow tests, or otherwise wait until #48528 is merged/rebase cleanly.

On the actual credential-offer behavior, I agree with your point. Not removing the offer state after credential issuance only fixes the immediate repeated credential-request case while the offer state is still present. It does not fully solve cases where the offer expires before the access token, or refresh-token flows where the credential request happens much later.

Your suggested direction sounds better: validate/consume the credential offer during the token endpoint flow, for both authorization_code and pre-authorized_code, and make the credential endpoint rely on the authorization details already carried by the access token rather than requiring the original credential offer state to still exist.

Given that #50340 is planned for the core team, I’m fine with not pushing this partial workaround further. I can either close this PR or reduce it to a clean, minimal test/behavior discussion, depending on what you prefer.

@mposolda

Copy link
Copy Markdown
Contributor

@forkimenjeckayang FYI. The issue #50340 is resolved now.

There is still follow-up issue #51050 to possibly not check credential offers in credential-endpoint at all, but instead check it properly during token-endpoint. However it seems to me that your reported issue should be fine now with latest main (even without #51050 being addressed) and the mentioned conformance test might be already working with the changes from #50340 ? If it is the case, feel free to either close this PR or update it for minimal test/behaviour as you suggested (possibly behavior which mimics the conformance test).

@forkimenjeckayang

Copy link
Copy Markdown
Contributor Author

@forkimenjeckayang FYI. The issue #50340 is resolved now.

There is still follow-up issue #51050 to possibly not check credential offers in credential-endpoint at all, but instead check it properly during token-endpoint. However it seems to me that your reported issue should be fine now with latest main (even without #51050 being addressed) and the mentioned conformance test might be already working with the changes from #50340 ? If it is the case, feel free to either close this PR or update it for minimal test/behaviour as you suggested (possibly behavior which mimics the conformance test).

I can confirm this error on the conformance test doesn't still occur @mposolda.
So I will just go ahead to close this

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.

5 participants