Skip to content

Conversation

@nodh
Copy link
Contributor

@nodh nodh commented Dec 17, 2025

Handles the legitimate case of issuers advertising same scope values for different credential configurations (distinguished by their credential representation). Had to do some refactoring in our code to cleanly test this scenario.

@nodh nodh requested review from JesusMcCloud and tlenz December 17, 2025 12:06
@nodh nodh self-assigned this Dec 17, 2025
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 363 to 367
val issuerStateCorrect = issuerState?.let {
if (codeService.verifyAndRemove(it)) true
else if (issuerStateToCredentialOffer.remove(it) != null) true
else false
} ?: true

Choose a reason for hiding this comment

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

P1 Badge Remove issuer_state mapping after successful verify

The new issuer_state validation accepts a state if codeService.verifyAndRemove returns true or if issuerStateToCredentialOffer.remove returns a value, but the credential-offer mapping is only removed in the second branch. A first valid request therefore leaves the issuer_state entry in issuerStateToCredentialOffer, and a second request using the same issuer_state bypasses replay protection via the remove branch and is accepted. The previous code removed both entries on first use, so this change introduces a one-time replay window for issuer_state-protected authorization.

Useful? React with 👍 / 👎.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I am not sure whether this is actually an issue in this specific case, because issuerStateToCredentialOffer will only contain an entry if a state was explicitly set via credentialOfferWithAuthorizationCode(...), which maybe is not be done in any flow.

However, the complete set of state maps defined as

    private val issuerStateToCredentialOffer: MapStore<String, CredentialOffer> = DefaultMapStore(),
    /** Associates issued codes with the auth request from the client. */
    private val codeToClientAuthRequest: MapStore<String, ClientAuthRequest> = DefaultMapStore(),
    /** Associates issued refresh tokens with the auth request from the client. *Refresh tokens are usually long-lived!* */
    private val refreshTokenToAuthRequest: MapStore<String, ClientAuthRequest> = DefaultMapStore(),
    /** Associates the issued request_uri to the auth request from the client. */
    private val requestUriToPushedAuthorizationRequest: MapStore<String, AuthenticationRequestParameters> = DefaultMapStore(),

poses a general risk of unbounded growth. If processing breaks, a client never responds, or a flow is aborted midway, these entries may never be cleaned up.

Introducing a DefaultMapStore variant with automatic cleanup (e.g. time-based expiration or a fixed maximum size with eviction) could mitigate this risk and make the implementation more robust.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

See #466

@nodh nodh changed the title OpenID4VCI Same scope values OpenID4VCI: Same scope values Dec 17, 2025
Copy link
Collaborator

@tlenz tlenz left a comment

Choose a reason for hiding this comment

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

Contains a general remark, but that is not specific for this pull request

Comment on lines 363 to 367
val issuerStateCorrect = issuerState?.let {
if (codeService.verifyAndRemove(it)) true
else if (issuerStateToCredentialOffer.remove(it) != null) true
else false
} ?: true
Copy link
Collaborator

Choose a reason for hiding this comment

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

I am not sure whether this is actually an issue in this specific case, because issuerStateToCredentialOffer will only contain an entry if a state was explicitly set via credentialOfferWithAuthorizationCode(...), which maybe is not be done in any flow.

However, the complete set of state maps defined as

    private val issuerStateToCredentialOffer: MapStore<String, CredentialOffer> = DefaultMapStore(),
    /** Associates issued codes with the auth request from the client. */
    private val codeToClientAuthRequest: MapStore<String, ClientAuthRequest> = DefaultMapStore(),
    /** Associates issued refresh tokens with the auth request from the client. *Refresh tokens are usually long-lived!* */
    private val refreshTokenToAuthRequest: MapStore<String, ClientAuthRequest> = DefaultMapStore(),
    /** Associates the issued request_uri to the auth request from the client. */
    private val requestUriToPushedAuthorizationRequest: MapStore<String, AuthenticationRequestParameters> = DefaultMapStore(),

poses a general risk of unbounded growth. If processing breaks, a client never responds, or a flow is aborted midway, these entries may never be cleaned up.

Introducing a DefaultMapStore variant with automatic cleanup (e.g. time-based expiration or a fixed maximum size with eviction) could mitigate this risk and make the implementation more robust.

throw InvalidDpopProof("invalid type: ${dpopProof.header.type}")
}
if (dpopProof.payload.nonce == null || !dpopNonceService.verifyAndRemoveNonce(dpopProof.payload.nonce!!)) {
if (dpopProof.payload.nonce == null) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

cant this be pulled out as val nonce = dpopProof.payload.nonce?: throw throw UseDpopNonce(dpopNonceService.provideNonce(), "DPoP JWT nonce is null") so this check and the check in line 188 can be merged into this single line?

Copy link
Collaborator

Choose a reason for hiding this comment

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

same goes for the verifyAndRemoveNonce, (where you would not need a !! any more)

@nodh nodh force-pushed the feature/openid4vci-scopes branch from 646ad5b to e0c22ff Compare December 18, 2025 20:14
@nodh nodh requested review from JesusMcCloud and tlenz December 18, 2025 20:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants