eu-digital-identity-wallet / eudi-lib-jvm-openid4vci-kt

Implementation of OpenID for Verifiable Credential Issuance protocol (wallet's role) in Kotlin
Apache License 2.0
18 stars 9 forks source link
kotlin kotlin-library openid4vci rfc-7636 rfc-9126 rfc-9449

EUDI OpenId4VCI library

:heavy_exclamation_mark: Important! Before you proceed, please read the EUDI Wallet Reference Implementation project description

License

Table of contents

Overview

This is a Kotlin library, targeting JVM, that supports the OpenId4VCI (draft 14) protocol.

In particular, the library focuses on the wallet's role in and provides the following features:

Feature Coverage
Wallet-initiated issuance
Resolve a credential offer ✅ Unsigned metadata ❌ accept-languagesigned metadata
Authorization code flow
Pre-authorized code flow
mso_mdoc format
SD-JWT-VC format
W3C VC DM VC Signed as a JWT, Not Using JSON-LD
Place credential request ✅ Including automatic handling of invalid_proof & multiple proofs
Query for deferred credentials ✅ Including automatic refresh of access_token
Query for deferred credentials at a later time ✅ Including automatic refresh of access_token
Notify credential issuer
Proof ✅ JWT
Credential response encryption
Pushed authorization requests ✅ Used by default, if supported by issuer
Demonstrating Proof of Possession (DPoP)
PKCE
Wallet authentication ✅ public client,
Attestation-Based Client Authentication

Disclaimer

The released software is an initial development release version:

Use cases supported

Wallet-initiated issuance

As a wallet/caller having an out-of-band knowledge of a credential issuer, use the library to initiate issuance:

This is equivalent to resolving a credential offer, having authorization code grant, without issuer state.

Wallet-initiated issuance preconditions

Wallet-initiated issuance successful outcome

Same as outcome, as if a equivalent offer was resolved.

Wallet-initiated issuance steps

  1. Wallet/caller using the library to instantiate an Issuer
  2. If checks pass, an Issuer will be returned to the caller.

Wallet-initiated issuance execution

import eu.europa.ec.eudi.openid4vci.*

val openId4VCIConfig = ...
val credentialIssuerId: CredentialIssuerId = //known 
val credentialConfigurationIds: List<CredentialConfigurationIdentifier> = // known

val issuer = 
    Issuer.makeWalletInitiated(
        config,
        credentialIssuerId,
        credentialConfigurationIds
    ).getOrThrow()

Wallet-initiated issuance next steps

Resolve a credential offer

As a wallet/caller use the library to process a URI that represents a credential offer

Resolve a credential offer preconditions

This resolution includes the following

Resolve a credential offer successful outcome:

An instance of the Issuer interface (the main entry point to the library) will have been initiated. This instance includes the resolved CredentialOffer and the necessary methods to proceed. The resolved offer contains the mandatory elements required for issuance.

These elements can be used to populate a wallet view that asks user's consensus to proceed with the issuance. This includes the authorization flow to be used (either authorization code flow, or pre-authorized code)

This concern, though, is out of the scope of the library

Resolve a credential offer execution

To resolve a credential offer, wallet/caller must provide configuration options

import eu.europa.ec.eudi.openid4vci.*

val openId4VCIConfig = ...
val credentialOfferUri: String = "..."
val issuer = Issuer.make(openId4VCIConfig, credentialOfferUri).getOrThrow()

Resolve a credential offer next steps

Authorize wallet for issuance

As a wallet/caller use the library to obtain an access_token, to be able to access the credential issuer's protected endpoints

Authorize wallet for issuance preconditions

There are three distinct cases, depending on the content of the credential offer

Authorize wallet for issuance successful outcome:

At the end of the use case, wallet will have an AuthorizedRequest instance.

Depending on the capabilities of the token endpoint, this AuthorizedRequest will be either

AuthorizedRequest will contain the access_token (bearer or DPoP), and, if provided, the refresh_token and c_nonce

Authorize wallet for issuance next steps

Authorization code flow

---
title: Authorization Code Flow state transision diagram
---
stateDiagram-v2
    state c_nonce_returned <<choice>>
    [*] --> AuthorizationRequestPrepared: prepareAuthorizationRequest
    AuthorizationRequestPrepared --> c_nonce_exists: authorizeWithAuthorizationCode
    c_nonce_exists --> c_nonce_returned
    c_nonce_returned --> AuthorizedRequest.ProofRequired : yes
c_nonce_returned --> AuthorizedRequest.NoProofRequired : no

Authorization code flow preconditions

In addition to the common authorization preconditions

Authorization code flow steps

  1. Wallet/caller using asks the Issuer instance to prepare a URL where the mobile device browser needs to be pointed to. Library prepares this URL as follows
    • If PAR endpoint is advertised it will place a PAR request and assemble the URL for the authorization endpoint
    • If PAR endpoint is not supported (or disabled), it will assemble the URL, as normally, for the authorization endpoint
    • In both case PKCE will be used
  2. Wallet/Caller opens the mobile's browser to the URL calculated in the previous step
  3. User interacts with the authorization server via mobile device agent, typically providing his authorization
  4. On success, authorization redirects to a wallet provided redirect_uri, providing the code and a state parameters
  5. Using the Issuer instance exchange the authorization code for an access_token.

In the scope of the library are steps 1 and 5.

Authorization code flow execution

import eu.europa.ec.eudi.openid4vci.*

// Step 1
val preparedAuthorizationRequest = 
    with(issuer) {
        prepareAuthorizationRequest().getOrThrow()
    }
// Step 2
// Wallet opens mobile's browser and points it to 

// Step 4
// Wallet has extracted from authorization redirect_uri
// the code and state parameters
val (authorizationCode, state) = ... // using url preparedAuthorizationRequest.authorizationCodeURL authenticate via front-channel on authorization server and retrieve authorization code 

// Step 5
val authorizedRequest =
     with(issuer) {
         with(preparedAuthorizationRequest) {
             authorizeWithAuthorizationCode(AuthorizationCode(authorizationCode),state).getOrThrow()
         }
     }

[!TIP] If credential issuer supports authorization_details, caller can reduce the scope of the access_token by passing authorization_details also to the token endpoint. Function authorizeWithAuthorizationCode supports this via parameter authDetailsOption: AccessTokenOption

Pre-authorized code flow

---
title: PreAuthorization Code Flow state transision diagram
---
stateDiagram-v2
    state c_nonce_returned <<choice>>
    [*] --> c_nonce_exists: authorizeWithPreAuthorizationCode
    c_nonce_exists --> c_nonce_returned
    c_nonce_returned --> AuthorizedRequest.ProofRequired : yes
c_nonce_returned --> AuthorizedRequest.NoProofRequired : no

Pre-authorized code flow preconditions

In addition to the common authorization preconditions

Steps:

  1. Using the Issuer instance exchange the pre-authorized code & optionally the tx_code with an access_token
  2. Library will place an adequate request the token endpoint of the credential issuer
  3. Library will receive token endpoint response and map it to a AuthorizedRequest

Pre-authorized code flow execution

import eu.europa.ec.eudi.openid4vci.*

val txCode : Sting? = ... // Pin retrieved from another channel, if needed

val authorizedRequest =  
    with(issuer) {
         authorizeWithPreAuthorizationCode(txCode).getOrThrow()
    }

[!TIP] If credential issuer supports authorization_details, caller can reduce the scope of the access_token by passing authorization_details to the token endpoint. Function authorizeWithPreAuthorizationCode supports this via parameter authDetailsOption: AccessTokenOption

Place credential request

Wallet/caller wants to place a request against the credential issuer, for one of the credential configurations that were present in the offer, or alternatively for a specific credential identifier in case token endpoint provided an authorization_details.

Place credential request preconditions

Place credential request steps

  1. Wallet/caller using the library assemble the request providing a credential_configuration_id and optionally a credential_identifier
  2. Wallet/caller using the Issuer and AuthorizedRequest place the request
  3. Library places the appropriate request against the Credential Endpoint of the Credential Issuer
  4. Library receives the Credential Issuer response and maps it to a SubmissionOutcome
  5. Wallet/caller gets back the SubmissionOutcome for further processing
  6. Wallet/caller may have to introspect the outcome to assemble a fresh AuthorizedRequest carrying possibly a fresh c_nonce

Place credential request outcome

The result of placing a request is represented by a SubmissionOutcome as follows:

In case of an unexpected error, a runtime exception will be raised.

Place credential request execution

import eu.europa.ec.eudi.openid4vci.*

val popSigner: PopSigner? = // optional JWT or CWT signer. Required only if proof are required by issuer
val claimSetToRequest : ClaimSet? = null // null indicates that all claims will be requested    

// Step 1
// Assemble the request    
val request = 
    IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId, claimSetToRequest)

// Place the request
val (updatedAuthorizedRequest, outcome) =
    with(issuer) {
        with(authorizedRequest) {
            request(request, listOf(popSigner))
        }
    }

[!TIP] If more than one popSigner are passed to function request multiple instances of the credential will be issued, provided that credential issuer supports batch issuance.

[!NOTE]

The ability of the token endpoint to provide a c_nonce is an optional feature specified in the OpenId4VCI specification.

According to the specification, the wallet must be able to receive a c_nonce primarily via the credential issuance response, which is represented by SubmissionOutcome in the library.

For this reason, it is not uncommon that the first request to the credential issuance endpoint will have as an outcome Failed with an error InvalidProof. That's typical if credential issuer's token endpoint doesn't provide a c_nonce and proof is required for the requested credential.

The library will automatically try to handle the invalid proof response and place a second request which includes proofs. This can be done only if caller has provided a popSigner while invoking request(). In case, that this second request fails with invalid_proof library will report as IrrecoverableInvalidProof.

Place credential request next steps

Query for deferred credentials

Wallet/caller wants to query credential issuer for credentials, while still holding an AuthorizedRequest and an Issuer instance.

Query for deferred credentials preconditions

Query for deferred credentials steps

  1. Wallet/caller issuing the Issuer instance places the query providing AuthorizedRequest and transaction_id
  2. Library checks if access_token in AuthorizedRequest is expired
  3. If access_token is expired it will automatically be refreshed, if credential issuer has given a refresh_token
  4. Library places the query against the Deferred Endpoint of the credential issuer
  5. Library gets credential issuer response and maps it into DeferredCredentialQueryOutcome
  6. Caller gets back an AuthorizedRequest and DeferredCredentialQueryOutcome

Query for deferred credentials outcome

The outcome of placing this query is a pair comprised of

Query for deferred credentials execution

val authorizedRequest = // has been retrieved in a previous step
val deferredCredential = // has been retrieved in a previous step. Holds the transaction_id

val (updatedAuthorizedRequest, outcome) =  
    with(issuer) {
        with(authorizedRequest) {
            queryForDeferredCredential(deferredCredential).getOrThrow()
        }
    }    

Query for deferred credentials next steps

Query for deferred credentials at later time

Wallet/caller wants to suspend an issuance process, store its context and query issuer at a later time. There are limitations for this use case

This means that wallet/caller can query for deferred credentials as long as it has a non-expired access_token or refresh_token.

Query for deferred credentials at later time preconditions

As per query for deferred credentials

Query for deferred credentials at a later time steps

  1. Wallet/caller using the Issuer instance obtains a DeferredIssuanceContext. That's a minimum set of data (configuration options and state) that are needed to query again the credential issuer
  2. Wallet/caller stores the DeferredIssuanceContext. How this is done is outside the scope of the library
  3. Wallet/caller loads the DeferredIssuanceContext. That's also outside the scope of the library
  4. Wallet/caller queries the credential issuer issuing DeferredIssuer
  5. Library performs all steps defined in Query for deferred credentials
  6. Library returns to the caller the DeferredIssuanceContxt? and the DeferredCredentialQueryOutcome
  7. Depending on the outcome, wallet/caller may choose to store the new DeferredIssuanceContxt to query again, later on

Query for deferred credentials at later time outcome

The outcome of placing this query is a pair comprised of

Query for deferred credentials at later time execution


val authorizedRequest = // has been retrieved in a previous step
val deferredCredential = // has been retrieved in a previous step. Holds the transaction_id

// Step 1
val deferredCtx = 
    with(issuer) {
        with(authorizedRequest) {
            deferredContext(deferredCredential).getOrThrough()    
        }
    }

// Store context

// Load context

// Step 4
val (updatedDeferredCtx, outcome) = 
    DeferredIssuer.queryForDeferredCredential(deferredCtx).getOrThrough()

Serializing DeferredIssuanceContext

How waller/caller stores and loads the DeferredIssuanceContext is out of scope of the library.

There is though an indicative implementation that serializes the context as a JSON object.

Notify Credential Issuer

Wallet/caller wants to notify the credential issuer about the overall outcome of the issuance, using one of the defined notifications:

Notify Credential Issuer preconditions

Notify Credential Issuer outcome

The use case always succeeds, even in the case of an unexpected error.

Notify Credential Issuer steps

  1. Wallet/caller using the library creates a notification event
  2. Wallet/caller using the Issuer instance places the notification to the credential issuer

Notify Credential Issuer execution

val authorizedRequest = // has been retrieved in a previous step
val notificationId = // has been provided by the issuer

// Step 1
// Other events are Deleted and Failed    
val event = 
    CredentialIssuanceEvent.Accepted(notificationId, "Got it!")

// Step 2
with(issuer){
    with(authorizedRequest){
        notify(event).getOrNull()
    }
}

Configuration Options

The options available for the Issuer are represented by OpenId4VCIConfig

data class OpenId4VCIConfig(
    val client: Client,
    val authFlowRedirectionURI: URI,
    val keyGenerationConfig: KeyGenerationConfig,
    val credentialResponseEncryptionPolicy: CredentialResponseEncryptionPolicy,
    val authorizeIssuanceConfig: AuthorizeIssuanceConfig = AuthorizeIssuanceConfig.FAVOR_SCOPES,
    val dPoPSigner: PopSigner.Jwt? = null,
    val parUsage: ParUsage = ParUsage.IfSupported,
    val clock: Clock = Clock.systemDefaultZone(),
)

Options available:

import eu.europa.ec.eudi.openid4vci.*

val openId4VCIConfig = OpenId4VCIConfig(
    client = Client.Public("wallet-dev"), // the client id of wallet (acting as an OAUTH2 client)
    authFlowRedirectionURI = URI.create("eudi-wallet//auth"), // where the Credential Issuer should redirect after Authorization code flow succeeds
    keyGenerationConfig = KeyGenerationConfig.ecOnly(Curve.P_256), // what kind of ephemeral keys could be generated to encrypt credential issuance response
    credentialResponseEncryptionPolicy = CredentialResponseEncryptionPolicy.SUPPORTED, // policy concerning the wallet's requirements for encryption of credential responses
)
val credentialOfferUri: String = "..." 
val issuer = Issuer.make(openId4VCIConfig, credentialOfferUri).getOrThrow()

Other features

Pushed authorization requests

Library supports RFC 9126 OAuth 2.0 Pushed Authorization Requests To use the PAR endpoint

Library will automatically use the PAR endpoint during the authorization code flow, otherwise it will fall back to a regular authorization request.

Proof Types Supported

The current version of the library supports JWT proofs

Demonstrating Proof of Possession (DPoP)

Library supports RFC9449. In addition to bearer authentication scheme, library can be configured to use DPoP authentication provided that the authorization server, that protects the credential issuer, supports this feature as well.

If wallet configuration provides a DPoP Signer and if the credential issuer advertises DPoP with algorithms supported by wallet's DPoP Signer, then library will transparently request for a DPoP access_token instead of the default Bearer token.

Furthermore, all further interactions will use the correct token type (Bearer or DPoP)

Library supports both Authorization Server-Provided Nonce and Resource Server-Provided Nonce. It features automatic recovery/retry support, and is able to recover from use_dpop_nonce errors given that a new DPoP Nonce is provided in the DPoP-Nonce header. Finally, library refreshes DPoP Nonce whenever a new value is provided, either by the Authorization Server or the Credential Issuer, using the DPoP-Nonce header, and all subsequent interactions use the newly provided DPoP Nonce value.

Proof Key for Code Exchange by OAuth Public Clients (PKCE)

Library supports RFC7636 by default while performing Authorization code flow. This feature cannot be disabled.

OAUTH2 Attestation-Based Client Authentication

Library supports OAUTH2 Attestation-Based Client Authentication - Draft 03

To enable this, caller must have obtained a Client/Wallet Attestation JWT How this is done, it is outside the scope of the library.

Furthermore, caller must provide a specification on how to produce the Client Attestation PoP JWT


val clientAttestationJWT: ClientAttestationJWT("...")
val popJwtSpec: ClientAttestationPoPJWTSpec = ClientAttestationPoPJWTSpec(
    signingAlgorithm = JWSAlgorithm.ES256, // Algorithm to sign the PoP JWT
    duration = 1.minutes, // Duration of PoP JWT. Used for `exp` claim 
    typ = null, // Optional, `typ` claim in the JWS header
    jwsSigner = signer, // Nimbus signer
)  
val wallet = Client.Attested(clientAttestationJWT, popJwtSpec)
val openId4VCIConfig = OpenId4VCIConfig(
    client = wallet
    authFlowRedirectionURI = URI.create("eudi-wallet//auth"), // where the Credential Issuer should redirect after Authorization code flow succeeds
    keyGenerationConfig = KeyGenerationConfig.ecOnly(Curve.P_256), // what kind of ephemeral keys could be generated to encrypt credential issuance response
    credentialResponseEncryptionPolicy = CredentialResponseEncryptionPolicy.SUPPORTED, // policy concerning the wallet's requirements for encryption of credential responses
)

With this configuration library is able to

Library will check that the authorization server of the issuer, includes method attest_jwt_client_auth in claim token_endpoint_auth_methods_supported of its metadata.

Features not supported

Issuer metadata accept-language

Specification recommends the use of header Accept-Language to indicate the language(s) preferred for display. Current version of the library does not support this.

Issuer signed metadata

Specification details the metadata an issuer advertises through its metadata endpoint. Current version of the library supports all metadata specified there except signed_metadata attribute.

Authorization

Specification defines that a credential's issuance can be requested using authorization_details or scope parameter when using authorization code flow. The current version of the library supports usage of both parameters. Though for authorization_details we don't support the format attribute and its specializations per format. Only credential_configuration_id attribute is supported.

How to contribute

We welcome contributions to this project. To ensure that the process is smooth for everyone involved, follow the guidelines found in CONTRIBUTING.md.

License

Third-party component licenses

License details

Copyright (c) 2023 European Commission

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.