From ee6358b5d5131fb714f7fe85121322eb0190191b Mon Sep 17 00:00:00 2001 From: Nander Stabel Date: Thu, 7 May 2026 18:24:15 +0200 Subject: [PATCH 01/12] feat: implement interactive authorization flow --- .../authorization_server_metadata.rs | 4 + oid4vci/src/errors.rs | 41 ++++ .../src/interactive_authorization_request.rs | 119 ++++++++++++ .../src/interactive_authorization_response.rs | 183 ++++++++++++++++++ oid4vci/src/lib.rs | 8 + oid4vci/src/wallet/mod.rs | 77 ++++++++ 6 files changed, 432 insertions(+) create mode 100644 oid4vci/src/interactive_authorization_request.rs create mode 100644 oid4vci/src/interactive_authorization_response.rs diff --git a/oid4vci/src/credential_issuer/authorization_server_metadata.rs b/oid4vci/src/credential_issuer/authorization_server_metadata.rs index b67e9fe5..e9ebe769 100644 --- a/oid4vci/src/credential_issuer/authorization_server_metadata.rs +++ b/oid4vci/src/credential_issuer/authorization_server_metadata.rs @@ -38,5 +38,9 @@ pub struct AuthorizationServerMetadata { pub pushed_authorization_request_endpoint: Option, #[serde(default)] pub require_pushed_authorization_requests: Option, + // Interactive Authorization Endpoint (Section 6, OID4VCI 1.1) + pub interactive_authorization_endpoint: Option, + #[serde(default)] + pub require_interactive_authorization_request: Option, // Additional authorization server metadata parameters MAY also be used. } diff --git a/oid4vci/src/errors.rs b/oid4vci/src/errors.rs index bc09917b..3778f7c2 100644 --- a/oid4vci/src/errors.rs +++ b/oid4vci/src/errors.rs @@ -232,6 +232,47 @@ impl ErrorStatusCode for NotificationErrorResponse { } } +/// Interactive Authorization Error Response as defined in OID4VCI 1.1, Section 6.2.3. +/// +/// In addition to standard PAR error processing rules (RFC 9126, Section 2.3), this adds +/// the `missing_interaction_type` error code. +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InteractiveAuthorizationErrorCode { + /// The `interaction_types_supported` parameter is missing a required interaction type. + MissingInteractionType, + /// Standard OAuth error codes may also appear. + InvalidRequest, + InvalidClient, + UnauthorizedClient, + AccessDenied, +} + +impl ErrorStatusCode for InteractiveAuthorizationErrorCode { + fn status_code(&self) -> StatusCode { + match self { + Self::MissingInteractionType => StatusCode::BAD_REQUEST, + Self::InvalidRequest => StatusCode::BAD_REQUEST, + Self::InvalidClient => StatusCode::UNAUTHORIZED, + Self::UnauthorizedClient => StatusCode::UNAUTHORIZED, + Self::AccessDenied => StatusCode::FORBIDDEN, + } + } +} + +impl std::error::Error for InteractiveAuthorizationErrorCode {} +impl Display for InteractiveAuthorizationErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingInteractionType => write!(f, "Missing Interaction Type"), + Self::InvalidRequest => write!(f, "Invalid Request"), + Self::InvalidClient => write!(f, "Invalid Client"), + Self::UnauthorizedClient => write!(f, "Unauthorized Client"), + Self::AccessDenied => write!(f, "Access Denied"), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/oid4vci/src/interactive_authorization_request.rs b/oid4vci/src/interactive_authorization_request.rs new file mode 100644 index 00000000..e44c1350 --- /dev/null +++ b/oid4vci/src/interactive_authorization_request.rs @@ -0,0 +1,119 @@ +use crate::authorization_details::AuthorizationDetailsObject; +use crate::authorization_request::CodeChallengeMethod; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use url::Url; + +/// Interaction types supported by the Wallet as defined in Section 6.1.1. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum InteractionType { + /// The Wallet supports an OpenID4VP Presentation interaction. + #[serde(rename = "urn:openid:dcp:iae:openid4vp_presentation")] + OpenId4VpPresentation, + /// The Wallet supports a redirect to a web-based interaction. + #[serde(rename = "urn:openid:dcp:iae:redirect_to_web")] + RedirectToWeb, + /// Custom interaction type defined by an extension. + #[serde(untagged)] + Custom(String), +} + +/// The initial request to the Interactive Authorization Endpoint, as defined in Section 6.1.1. +/// +/// Formed and sent in the same way as a PAR request (RFC 9126 Section 2.1), with the addition +/// of the `interaction_types_supported` parameter. +#[skip_serializing_none] +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct InteractiveAuthorizationRequest { + pub response_type: String, + pub client_id: String, + pub redirect_uri: Option, + pub scope: Option, + pub state: Option, + pub authorization_details: Vec, + pub issuer_state: Option, + // PKCE parameters + pub code_challenge: Option, + pub code_challenge_method: Option, + /// Comma-separated list of interaction types the Wallet supports. + pub interaction_types_supported: String, +} + +impl InteractiveAuthorizationRequest { + /// Build the `interaction_types_supported` parameter value from a list of interaction types. + pub fn interaction_types_to_string(types: &[InteractionType]) -> String { + types + .iter() + .map(|t| match t { + InteractionType::OpenId4VpPresentation => "urn:openid:dcp:iae:openid4vp_presentation".to_string(), + InteractionType::RedirectToWeb => "urn:openid:dcp:iae:redirect_to_web".to_string(), + InteractionType::Custom(s) => s.clone(), + }) + .collect::>() + .join(",") + } +} + +/// A follow-up request to the Interactive Authorization Endpoint, as defined in Section 6.1.2. +/// +/// Follow-up requests include the `auth_session` value received most recently from the +/// Authorization Server. Additional parameters depend on the interaction type. +#[skip_serializing_none] +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct InteractiveAuthorizationFollowUpRequest { + /// The auth_session value from the most recent IAE response. + pub auth_session: String, + /// The OpenID4VP Authorization Response (JSON-encoded), present when responding + /// to a `urn:openid:dcp:iae:openid4vp_presentation` interaction. + pub openid4vp_response: Option, + /// The PKCE code verifier, required after a `urn:openid:dcp:iae:redirect_to_web` + /// interaction if PKCE was used in the initial request. + pub code_verifier: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_interaction_types_to_string() { + let types = vec![InteractionType::OpenId4VpPresentation, InteractionType::RedirectToWeb]; + let result = InteractiveAuthorizationRequest::interaction_types_to_string(&types); + assert_eq!( + result, + "urn:openid:dcp:iae:openid4vp_presentation,urn:openid:dcp:iae:redirect_to_web" + ); + } + + #[test] + fn test_interaction_type_serde() { + let vp = InteractionType::OpenId4VpPresentation; + let serialized = serde_json::to_string(&vp).unwrap(); + assert_eq!(serialized, "\"urn:openid:dcp:iae:openid4vp_presentation\""); + + let deserialized: InteractionType = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized, vp); + + let web = InteractionType::RedirectToWeb; + let serialized = serde_json::to_string(&web).unwrap(); + assert_eq!(serialized, "\"urn:openid:dcp:iae:redirect_to_web\""); + + let deserialized: InteractionType = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized, web); + } + + #[test] + fn test_follow_up_request_serde() { + let request = InteractiveAuthorizationFollowUpRequest { + auth_session: "wxroVrBY2MCq4dDNGXACS".to_string(), + openid4vp_response: Some(serde_json::json!({ + "vp_token": "eyJ..." + })), + code_verifier: None, + }; + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["auth_session"], "wxroVrBY2MCq4dDNGXACS"); + assert!(json["openid4vp_response"].is_object()); + assert!(json.get("code_verifier").is_none()); + } +} diff --git a/oid4vci/src/interactive_authorization_response.rs b/oid4vci/src/interactive_authorization_response.rs new file mode 100644 index 00000000..c7ab372d --- /dev/null +++ b/oid4vci/src/interactive_authorization_response.rs @@ -0,0 +1,183 @@ +use crate::interactive_authorization_request::InteractionType; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +/// The response status from the Interactive Authorization Endpoint. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InteractiveAuthorizationStatus { + /// The authorization process requires further user interaction. + RequireInteraction, + /// The authorization process completed successfully. + Ok, +} + +/// Response from the Interactive Authorization Endpoint, as defined in Section 6.2. +/// +/// The response indicates either that user interaction is required, that the authorization +/// was completed successfully (with an authorization code), or an error. +#[skip_serializing_none] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InteractiveAuthorizationResponse { + /// Whether an additional interaction is required or the authorization has been completed. + pub status: InteractiveAuthorizationStatus, + + // --- Fields for `status: "ok"` (Authorization Code Response, Section 6.2.2) --- + /// The authorization code, present when `status` is `ok`. + pub code: Option, + + // --- Fields for `status: "require_interaction"` (Section 6.2.1) --- + /// The interaction type required by the Authorization Server. + #[serde(rename = "type")] + pub interaction_type: Option, + + /// A value that allows the Authorization Server to associate subsequent requests + /// with the ongoing authorization request sequence. Must be included in follow-up requests. + pub auth_session: Option, + + // --- Fields specific to `urn:openid:dcp:iae:openid4vp_presentation` (Section 6.2.1.1) --- + /// An OpenID4VP Authorization Request for the Wallet to process. + /// May contain either a plain request object or a signed request (`{"request": "eyJ..."}`). + pub openid4vp_request: Option, + + // --- Fields specific to `urn:openid:dcp:iae:redirect_to_web` (Section 6.2.1.2) --- + /// A request_uri for building an Authorization Request via browser redirect. + pub request_uri: Option, + + /// The lifetime of the `request_uri` in seconds. + pub expires_in: Option, +} + +impl InteractiveAuthorizationResponse { + /// Returns true if the response indicates that the authorization is complete. + pub fn is_complete(&self) -> bool { + self.status == InteractiveAuthorizationStatus::Ok + } + + /// Returns the authorization code if the response is complete. + pub fn authorization_code(&self) -> Option<&str> { + if self.is_complete() { + self.code.as_deref() + } else { + None + } + } +} + +/// Error response from the Interactive Authorization Endpoint, as defined in Section 6.2.3. +/// +/// In addition to standard PAR error processing rules (RFC 9126, Section 2.3), this +/// specification adds the `missing_interaction_type` error code. +#[skip_serializing_none] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InteractiveAuthorizationErrorResponse { + pub error: String, + pub error_description: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_require_presentation_response() { + // Non-normative example from Section 6.2.1.1 + let json = serde_json::json!({ + "status": "require_interaction", + "type": "urn:openid:dcp:iae:openid4vp_presentation", + "auth_session": "wxroVrBY2MCq4dDNGXACS", + "openid4vp_request": { + "response_type": "vp_token", + "response_mode": "iae_post", + "dcql_query": { + "credentials": [{ + "id": "some_identity_credential", + "format": "dc+sd-jwt", + "meta": { + "vct_values": ["https://credentials.example.com/identity_credential"] + }, + "claims": [ + {"path": ["last_name"]}, + {"path": ["first_name"]} + ] + }] + }, + "nonce": "n-0S6_WzA2Mj" + } + }); + + let response: InteractiveAuthorizationResponse = serde_json::from_value(json).unwrap(); + assert_eq!(response.status, InteractiveAuthorizationStatus::RequireInteraction); + assert_eq!(response.interaction_type, Some(InteractionType::OpenId4VpPresentation)); + assert_eq!(response.auth_session.as_deref(), Some("wxroVrBY2MCq4dDNGXACS")); + assert!(response.openid4vp_request.is_some()); + assert!(!response.is_complete()); + } + + #[test] + fn test_deserialize_redirect_to_web_response() { + // Non-normative example from Section 6.2.1.2 + let json = serde_json::json!({ + "status": "require_interaction", + "type": "urn:openid:dcp:iae:redirect_to_web", + "request_uri": "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c", + "expires_in": 60 + }); + + let response: InteractiveAuthorizationResponse = serde_json::from_value(json).unwrap(); + assert_eq!(response.status, InteractiveAuthorizationStatus::RequireInteraction); + assert_eq!(response.interaction_type, Some(InteractionType::RedirectToWeb)); + assert_eq!( + response.request_uri.as_deref(), + Some("urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c") + ); + assert_eq!(response.expires_in, Some(60)); + assert!(!response.is_complete()); + } + + #[test] + fn test_deserialize_authorization_code_response() { + // Non-normative example from Section 6.2.2 + let json = serde_json::json!({ + "code": "uY29tL2F1dGhlbnRpY", + "status": "ok" + }); + + let response: InteractiveAuthorizationResponse = serde_json::from_value(json).unwrap(); + assert_eq!(response.status, InteractiveAuthorizationStatus::Ok); + assert_eq!(response.authorization_code(), Some("uY29tL2F1dGhlbnRpY")); + assert!(response.is_complete()); + } + + #[test] + fn test_deserialize_error_response() { + let json = serde_json::json!({ + "error": "missing_interaction_type", + "error_description": "interaction_types_supported in the request is missing the required interaction type 'urn:openid:dcp:iae:openid4vp_presentation'" + }); + + let response: InteractiveAuthorizationErrorResponse = serde_json::from_value(json).unwrap(); + assert_eq!(response.error, "missing_interaction_type"); + assert!(response.error_description.is_some()); + } + + #[test] + fn test_serialize_authorization_code_response() { + let response = InteractiveAuthorizationResponse { + status: InteractiveAuthorizationStatus::Ok, + code: Some("uY29tL2F1dGhlbnRpY".to_string()), + interaction_type: None, + auth_session: None, + openid4vp_request: None, + request_uri: None, + expires_in: None, + }; + + let json = serde_json::to_value(&response).unwrap(); + assert_eq!(json["status"], "ok"); + assert_eq!(json["code"], "uY29tL2F1dGhlbnRpY"); + // None fields should be absent due to skip_serializing_none + assert!(json.get("type").is_none()); + assert!(json.get("auth_session").is_none()); + } +} diff --git a/oid4vci/src/lib.rs b/oid4vci/src/lib.rs index 6f4e31d3..e20c71f9 100644 --- a/oid4vci/src/lib.rs +++ b/oid4vci/src/lib.rs @@ -8,6 +8,8 @@ pub mod credential_offer; pub mod credential_request; pub mod credential_response; pub mod errors; +pub mod interactive_authorization_request; +pub mod interactive_authorization_response; pub mod nonce_response; pub mod notification_request; pub mod proof; @@ -17,6 +19,12 @@ pub mod token_response; pub mod wallet; pub use credential::{VerifiableCredentialJwt, VerifiableCredentialJwtBuilder}; +pub use interactive_authorization_request::{ + InteractionType, InteractiveAuthorizationFollowUpRequest, InteractiveAuthorizationRequest, +}; +pub use interactive_authorization_response::{ + InteractiveAuthorizationErrorResponse, InteractiveAuthorizationResponse, InteractiveAuthorizationStatus, +}; pub use pkce; pub use proof::Proof; pub use wallet::Wallet; diff --git a/oid4vci/src/wallet/mod.rs b/oid4vci/src/wallet/mod.rs index 35bd56b8..64f3b893 100644 --- a/oid4vci/src/wallet/mod.rs +++ b/oid4vci/src/wallet/mod.rs @@ -9,6 +9,10 @@ use crate::credential_issuer::{ }; use crate::credential_offer::CredentialOfferParameters; use crate::credential_request::{CredentialIdentifierOrCredentialConfigurationId, CredentialRequest}; +use crate::interactive_authorization_request::{ + InteractionType, InteractiveAuthorizationFollowUpRequest, InteractiveAuthorizationRequest, +}; +use crate::interactive_authorization_response::InteractiveAuthorizationResponse; use crate::nonce_response::NonceResponse; use crate::notification_request::{NotificationEvent, NotificationRequest}; use crate::proof::ProofType; @@ -394,6 +398,79 @@ impl Wallet { .map_err(|e| e.into()) } + /// Send an initial Interactive Authorization Request to the IAE endpoint (Section 6.1.1). + /// + /// This is similar to a Pushed Authorization Request but adds `interaction_types_supported` + /// and returns an `InteractiveAuthorizationResponse` indicating the next step. + #[allow(clippy::too_many_arguments)] + pub async fn send_interactive_authorization_request( + &self, + interactive_authorization_endpoint: Url, + client_id: &str, + redirect_uri: Option, + state: Option, + authorization_details: Vec, + issuer_state: Option, + interaction_types_supported: Vec, + code_challenge: Option, + code_challenge_method: Option, + ) -> Result { + let request = InteractiveAuthorizationRequest { + response_type: "code".to_string(), + client_id: client_id.to_string(), + redirect_uri, + scope: None, + state, + authorization_details, + issuer_state, + code_challenge, + code_challenge_method, + interaction_types_supported: InteractiveAuthorizationRequest::interaction_types_to_string( + &interaction_types_supported, + ), + }; + + let url_encoded = to_form_urlencoded_string(&request)?; + + self.client + .post(interactive_authorization_endpoint) + .header( + CONTENT_TYPE, + HeaderValue::from_static("application/x-www-form-urlencoded"), + ) + .body(url_encoded) + .send() + .await? + .json::() + .await + .map_err(|err| anyhow::anyhow!("Failed to send interactive authorization request: {err}")) + } + + /// Send a follow-up Interactive Authorization Request (Section 6.1.2). + /// + /// This is used after receiving a `require_interaction` response to submit the + /// result of the interaction (e.g., an OpenID4VP presentation response). + pub async fn send_interactive_authorization_follow_up( + &self, + interactive_authorization_endpoint: Url, + follow_up: InteractiveAuthorizationFollowUpRequest, + ) -> Result { + let url_encoded = to_form_urlencoded_string(&follow_up)?; + + self.client + .post(interactive_authorization_endpoint) + .header( + CONTENT_TYPE, + HeaderValue::from_static("application/x-www-form-urlencoded"), + ) + .body(url_encoded) + .send() + .await? + .json::() + .await + .map_err(|err| anyhow::anyhow!("Failed to send interactive authorization follow-up: {err}")) + } + pub async fn send_notification_request( &self, notification_endpoint: Url, From b8656248f78f020b80e738bef09315a3239e1e22 Mon Sep 17 00:00:00 2001 From: Nander Stabel Date: Mon, 11 May 2026 17:26:09 +0200 Subject: [PATCH 02/12] refactor: `InteractiveAuthorizationRequest` to use `AuthorizationRequest` struct --- .../src/interactive_authorization_request.rs | 17 +++------------- oid4vci/src/wallet/mod.rs | 20 ++++++++++--------- 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/oid4vci/src/interactive_authorization_request.rs b/oid4vci/src/interactive_authorization_request.rs index e44c1350..7540538a 100644 --- a/oid4vci/src/interactive_authorization_request.rs +++ b/oid4vci/src/interactive_authorization_request.rs @@ -1,8 +1,6 @@ -use crate::authorization_details::AuthorizationDetailsObject; -use crate::authorization_request::CodeChallengeMethod; +use crate::authorization_request::AuthorizationRequest; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; -use url::Url; /// Interaction types supported by the Wallet as defined in Section 6.1.1. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -22,19 +20,10 @@ pub enum InteractionType { /// /// Formed and sent in the same way as a PAR request (RFC 9126 Section 2.1), with the addition /// of the `interaction_types_supported` parameter. -#[skip_serializing_none] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct InteractiveAuthorizationRequest { - pub response_type: String, - pub client_id: String, - pub redirect_uri: Option, - pub scope: Option, - pub state: Option, - pub authorization_details: Vec, - pub issuer_state: Option, - // PKCE parameters - pub code_challenge: Option, - pub code_challenge_method: Option, + #[serde(flatten)] + pub authorization_request: AuthorizationRequest, /// Comma-separated list of interaction types the Wallet supports. pub interaction_types_supported: String, } diff --git a/oid4vci/src/wallet/mod.rs b/oid4vci/src/wallet/mod.rs index 64f3b893..3ecfe7f5 100644 --- a/oid4vci/src/wallet/mod.rs +++ b/oid4vci/src/wallet/mod.rs @@ -416,15 +416,17 @@ impl Wallet { code_challenge_method: Option, ) -> Result { let request = InteractiveAuthorizationRequest { - response_type: "code".to_string(), - client_id: client_id.to_string(), - redirect_uri, - scope: None, - state, - authorization_details, - issuer_state, - code_challenge, - code_challenge_method, + authorization_request: AuthorizationRequest { + response_type: "code".to_string(), + client_id: client_id.to_string(), + redirect_uri, + scope: None, + state, + authorization_details, + issuer_state, + code_challenge, + code_challenge_method, + }, interaction_types_supported: InteractiveAuthorizationRequest::interaction_types_to_string( &interaction_types_supported, ), From 029703f31236905b640247bf881f6a7dd0fcb54a Mon Sep 17 00:00:00 2001 From: Nander Stabel Date: Wed, 20 May 2026 09:48:41 +0200 Subject: [PATCH 03/12] feat: add utility functions for JWT claims extraction and key ID resolution --- oid4vc-core/Cargo.toml | 3 +- oid4vc-core/src/jwt.rs | 5 +-- oid4vc-core/src/utils/did.rs | 44 ++++++++++++++++++++++++++ oid4vc-core/src/utils/mod.rs | 1 + oid4vp/src/token/vp_token_validator.rs | 23 ++++++-------- siopv2/Cargo.toml | 1 - 6 files changed, 59 insertions(+), 18 deletions(-) create mode 100644 oid4vc-core/src/utils/did.rs diff --git a/oid4vc-core/Cargo.toml b/oid4vc-core/Cargo.toml index 5b99d487..b5ffd490 100644 --- a/oid4vc-core/Cargo.toml +++ b/oid4vc-core/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true [dependencies] anyhow = "1.0.70" async-trait = "0.1.68" -base64-url = "2.0.0" +base64 = "0.22" derivative = "2.2.0" derive_more = "0.99.16" did-key.workspace = true @@ -37,4 +37,3 @@ tokio.workspace = true [features] test-utils = ["dep:mockall"] - diff --git a/oid4vc-core/src/jwt.rs b/oid4vc-core/src/jwt.rs index 5bf311e2..369838bf 100644 --- a/oid4vc-core/src/jwt.rs +++ b/oid4vc-core/src/jwt.rs @@ -1,5 +1,6 @@ use crate::Sign; use anyhow::{anyhow, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use getset::Getters; use jsonwebtoken::{Algorithm, DecodingKey, Header, Validation}; use serde::de::DeserializeOwned; @@ -72,7 +73,7 @@ where let message = [base64_url_encode(&jwt.header)?, base64_url_encode(&jwt.payload)?].join("."); let proof_value = signer.sign(&message, subject_syntax_type, algorithm).await?; - let signature = base64_url::encode(proof_value.as_slice()); + let signature = URL_SAFE_NO_PAD.encode(proof_value.as_slice()); let message = [message, signature].join("."); Ok(message) } @@ -81,7 +82,7 @@ pub fn base64_url_encode(value: &T) -> Result where T: ?Sized + Serialize, { - Ok(base64_url::encode(serde_json::to_vec(value)?.as_slice())) + Ok(URL_SAFE_NO_PAD.encode(serde_json::to_vec(value)?.as_slice())) } #[cfg(feature = "test-utils")] diff --git a/oid4vc-core/src/utils/did.rs b/oid4vc-core/src/utils/did.rs new file mode 100644 index 00000000..1ff4be87 --- /dev/null +++ b/oid4vc-core/src/utils/did.rs @@ -0,0 +1,44 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use jsonwebtoken::decode_header; + +// TODO: actually validate the JWT! +/// Get the claims from a JWT without performing validation. +pub fn get_unverified_jwt_claims(jwt: &serde_json::Value) -> Result { + jwt.as_str() + .and_then(|string| string.splitn(3, '.').collect::>().get(1).cloned()) + .and_then(|payload| { + URL_SAFE_NO_PAD + .decode(payload) + .ok() + .and_then(|payload_bytes| serde_json::from_slice::(&payload_bytes).ok()) + }) + .ok_or_else(|| anyhow::anyhow!("Failed to decode JWT claims")) +} + +fn sd_jwt_to_jwt(sd_jwt: &str) -> &str { + sd_jwt.split_once('~').map(|(jwt, _)| jwt).unwrap_or(sd_jwt) +} + +/// This function resolves the key ID from the JWT header, and makes it absolute if it's a relative reference (starts with '#') by prepending the 'iss' claim from the JWT payload. +pub fn resolve_key_id(jwt: &str) -> Result { + let jwt = sd_jwt_to_jwt(jwt); + + let jwt_header = decode_header(jwt).unwrap(); + // let jwt_header = decode_header(jwt).map_err(|_| anyhow::anyhow!("Failed to decode JWT header"))?; + let mut key_id = jwt_header + .kid + .ok_or_else(|| anyhow::anyhow!("Missing 'kid' in JWT header"))?; + + if key_id.starts_with('#') { + let claims = get_unverified_jwt_claims(&serde_json::json!(jwt))?; + let iss = claims + .get("iss") + .ok_or_else(|| anyhow::anyhow!("Missing 'iss' claim"))? + .as_str() + .ok_or_else(|| anyhow::anyhow!("'iss' claim is not a string"))?; + + key_id = format!("{iss}{key_id}"); + } + + Ok(key_id) +} diff --git a/oid4vc-core/src/utils/mod.rs b/oid4vc-core/src/utils/mod.rs index ec2accc2..7e18d0c4 100644 --- a/oid4vc-core/src/utils/mod.rs +++ b/oid4vc-core/src/utils/mod.rs @@ -1,2 +1,3 @@ +pub mod did; pub mod form_urlencoded; pub mod predicates; diff --git a/oid4vp/src/token/vp_token_validator.rs b/oid4vp/src/token/vp_token_validator.rs index 02268f11..08bf9e76 100644 --- a/oid4vp/src/token/vp_token_validator.rs +++ b/oid4vp/src/token/vp_token_validator.rs @@ -17,7 +17,10 @@ use identity_credential::{ use identity_did::DIDUrl; use identity_verification::jws::{Decoder, JwsVerifier}; use nutype::nutype; -use oid4vc_core::{credential_status_verifier::CredentialStatusVerifier, utils::predicates::not_empty}; +use oid4vc_core::{ + credential_status_verifier::CredentialStatusVerifier, + utils::{did::resolve_key_id, predicates::not_empty}, +}; use oid4vc_core::{ types::string_or_object::StringOrObject, verification_material_resolver::VerificationMaterialResolver, JsonObject, }; @@ -324,6 +327,7 @@ impl<'a, SV: JwsVerifier + Clone, VMR: VerificationMaterialResolver, CSV: Creden .decode_compact_serialization(presentation_jwt.as_str().as_bytes(), None) .map_err(VpTokenValidationError::JwsDecodingError)?; + // TODO: check whether the KID is a relative reference (starts with '#') and resolve it against the 'iss' claim in the payload if so (see `fn resolve_key_id`) let kid_str = validation_item.kid().ok_or(VpTokenValidationError::MissingKid)?; let kid: DIDUrl = kid_str .parse() @@ -382,6 +386,7 @@ impl<'a, SV: JwsVerifier + Clone, VMR: VerificationMaterialResolver, CSV: Creden .decode_compact_serialization(credential_jwt.as_str().as_bytes(), None) .map_err(VpTokenValidationError::JwsDecodingError)?; + // TODO: check whether the KID is a relative reference (starts with '#') and resolve it against the 'iss' claim in the payload if so (see `fn resolve_key_id`) let kid_str = validation_item.kid().ok_or(VpTokenValidationError::MissingKid)?; let kid: DIDUrl = kid_str .parse() @@ -422,12 +427,8 @@ impl<'a, SV: JwsVerifier + Clone, VMR: VerificationMaterialResolver, CSV: Creden nonce: Option<&str>, require_holder_binding: bool, ) -> Result { - let kid_str = sd_jwt_vc - .headers() - .get("kid") - .ok_or(VpTokenValidationError::MissingKid)? - .as_str() - .ok_or_else(|| VpTokenValidationError::InvalidKid("kid header is not a string".to_string()))?; + let kid_str = + resolve_key_id(&sd_jwt_vc.to_string()).map_err(|e| VpTokenValidationError::InvalidKid(e.to_string()))?; let kid: DIDUrl = kid_str .parse() @@ -491,12 +492,8 @@ impl<'a, SV: JwsVerifier + Clone, VMR: VerificationMaterialResolver, CSV: Creden /// Internal helper to validate VCDM 2.0 SD-JWT. async fn validate_vcdm2_sd_jwt(&self, vcdm2_sd_jwt: &SdJwt) -> Result { - let kid_str = vcdm2_sd_jwt - .headers() - .get("kid") - .ok_or(VpTokenValidationError::MissingKid)? - .as_str() - .ok_or_else(|| VpTokenValidationError::InvalidKid("kid header is not a string".to_string()))?; + let kid_str = + resolve_key_id(&vcdm2_sd_jwt.to_string()).map_err(|e| VpTokenValidationError::InvalidKid(e.to_string()))?; let kid: DIDUrl = kid_str .parse() diff --git a/siopv2/Cargo.toml b/siopv2/Cargo.toml index 815565a0..e910d127 100644 --- a/siopv2/Cargo.toml +++ b/siopv2/Cargo.toml @@ -13,7 +13,6 @@ oid4vc-core = { path = "../oid4vc-core" } anyhow = "1.0.70" async-trait = "0.1.68" -base64-url = "2.0.0" chrono.workspace = true derive_more = "0.99.16" did_url = "0.1.0" From d1ffe01f8b62f933fc03c43fc50ff9b1a78bffc8 Mon Sep 17 00:00:00 2001 From: Nander Stabel Date: Thu, 28 May 2026 09:15:42 +0200 Subject: [PATCH 04/12] refactor: improve JWT handling and add comprehensive tests for key ID resolution --- oid4vc-core/src/utils/did.rs | 91 ++++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/oid4vc-core/src/utils/did.rs b/oid4vc-core/src/utils/did.rs index 1ff4be87..5c616de9 100644 --- a/oid4vc-core/src/utils/did.rs +++ b/oid4vc-core/src/utils/did.rs @@ -1,7 +1,6 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use jsonwebtoken::decode_header; -// TODO: actually validate the JWT! /// Get the claims from a JWT without performing validation. pub fn get_unverified_jwt_claims(jwt: &serde_json::Value) -> Result { jwt.as_str() @@ -15,16 +14,17 @@ pub fn get_unverified_jwt_claims(jwt: &serde_json::Value) -> Result &str { sd_jwt.split_once('~').map(|(jwt, _)| jwt).unwrap_or(sd_jwt) } -/// This function resolves the key ID from the JWT header, and makes it absolute if it's a relative reference (starts with '#') by prepending the 'iss' claim from the JWT payload. +/// This function resolves the key ID from the JWT header, and makes it absolute if it's a relative reference (starts +/// with '#') by prepending the 'iss' claim from the JWT payload. pub fn resolve_key_id(jwt: &str) -> Result { let jwt = sd_jwt_to_jwt(jwt); - let jwt_header = decode_header(jwt).unwrap(); - // let jwt_header = decode_header(jwt).map_err(|_| anyhow::anyhow!("Failed to decode JWT header"))?; + let jwt_header = decode_header(jwt).map_err(|e| anyhow::anyhow!("Failed to decode JWT header: {e}"))?; let mut key_id = jwt_header .kid .ok_or_else(|| anyhow::anyhow!("Missing 'kid' in JWT header"))?; @@ -42,3 +42,86 @@ pub fn resolve_key_id(jwt: &str) -> Result { Ok(key_id) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn get_unverified_jwt_claims_successfully_gets_claims() { + let jwt = json!("eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2toUDQzTENTWGFqM1NRQm92eTF1RTJuWHZTQm5SUFdaMndoUExxblo4UGdEI3o2TWtraFA0M0xDU1hhajNTUUJvdnkxdUUyblh2U0JuUlBXWjJ3aFBMcW5aOFBnRCJ9.eyJpc3MiOiJodHRwOi8vMTkyLjE2OC4xLjEyNzo5MDkwLyIsInN1YiI6ImRpZDprZXk6ejZNa2cxWFhHVXFma2hBS1Uxa1ZkMVBtdzZVRWoxdnhpTGoxeGM5MU1CejVvd05ZIiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjAsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvZXhhbXBsZXMvdjEiXSwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsIlBlcnNvbmFsSW5mb3JtYXRpb24iXSwiaXNzdWFuY2VEYXRlIjoiMjAyMi0wMS0wMVQwMDowMDowMFoiLCJpc3N1ZXIiOiJodHRwOi8vMTkyLjE2OC4xLjEyNzo5MDkwLyIsImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rZzFYWEdVcWZraEFLVTFrVmQxUG13NlVFajF2eGlMajF4YzkxTUJ6NW93TlkiLCJnaXZlbk5hbWUiOiJGZXJyaXMiLCJmYW1pbHlOYW1lIjoiQ3JhYm1hbiIsImVtYWlsIjoiZmVycmlzLmNyYWJtYW5AY3JhYm1haWwuY29tIiwiYmlydGhkYXRlIjoiMTk4NS0wNS0yMSJ9fX0.Yl841U5BwWgctX5vF5Zi8SYCEQpxFqEs8_J8KrX9D_mOwL-IRmP64BeQZvnKeAdcOoYGn6CyciV51_amdPNQBw"); + + assert_eq!( + get_unverified_jwt_claims(&jwt).unwrap(), + json!({ + "iss": "http://192.168.1.127:9090/", + "sub": "did:key:z6Mkg1XXGUqfkhAKU1kVd1Pmw6UEj1vxiLj1xc91MBz5owNY", + "exp": 9999999999i64, + "iat": 0, + "vc": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "type": [ + "VerifiableCredential", + "PersonalInformation" + ], + "issuanceDate": "2022-01-01T00:00:00Z", + "issuer": "http://192.168.1.127:9090/", + "credentialSubject": { + "id": "did:key:z6Mkg1XXGUqfkhAKU1kVd1Pmw6UEj1vxiLj1xc91MBz5owNY", + "givenName": "Ferris", + "familyName": "Crabman", + "email": "ferris.crabman@crabmail.com", + "birthdate": "1985-05-21" + } + } + }) + ); + } + + #[test] + fn resolve_key_id_with_relative_reference() { + // JWT with relative key_id (starts with '#') + let jwt = + "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6IiNteWtleSJ9.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3N1ZXIifQ.signature"; + let result = resolve_key_id(jwt); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "did:example:issuer#mykey"); + } + + #[test] + fn resolve_key_id_with_absolute_reference() { + // JWT with absolute key_id (doesn't start with '#') + let jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDpleGFtcGxlOmlzc3VlciNteWtleSJ9.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3N1ZXIifQ.signature"; + let result = resolve_key_id(jwt); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "did:example:issuer#mykey"); + } + + #[test] + fn resolve_key_id_missing_kid() { + // JWT without kid in header + let jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3N1ZXIifQ.signature"; + let result = resolve_key_id(jwt); + assert!(result.is_err()); + } + + #[test] + fn resolve_key_id_missing_iss_claim() { + // JWT with relative key_id but missing 'iss' claim + let jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6IiNteWtleSJ9.e30.signature"; + let result = resolve_key_id(jwt); + assert!(result.is_err()); + } + + #[test] + fn resolve_key_id_iss_not_string() { + // JWT with relative key_id but 'iss' claim is not a string + let jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6IiNteWtleSJ9.eyJpc3MiOjEyMzQ1fQ.signature"; + let result = resolve_key_id(jwt); + assert!(result.is_err()); + } +} From b1af00c132029d5b4ce5f0ed06451829c23e1077 Mon Sep 17 00:00:00 2001 From: Nander Stabel Date: Thu, 28 May 2026 09:24:51 +0200 Subject: [PATCH 05/12] fix: make `authorization_details` optional in Wallet implementation --- oid4vci/src/wallet/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oid4vci/src/wallet/mod.rs b/oid4vci/src/wallet/mod.rs index 1d49d90a..c1929b10 100644 --- a/oid4vci/src/wallet/mod.rs +++ b/oid4vci/src/wallet/mod.rs @@ -409,7 +409,7 @@ impl Wallet { client_id: &str, redirect_uri: Option, state: Option, - authorization_details: Vec, + authorization_details: Option>, issuer_state: Option, interaction_types_supported: Vec, code_challenge: Option, From cf88c2067f79bedc0578b6e5f938002b4e3d744f Mon Sep 17 00:00:00 2001 From: Nander Stabel Date: Tue, 2 Jun 2026 10:41:46 +0200 Subject: [PATCH 06/12] refactor: rename `resolve_key_id` to `extract_normalized_did_kid_from_jwt` --- oid4vc-core/src/utils/did.rs | 22 ++++++++++++++-------- oid4vp/src/token/vp_token_validator.rs | 10 +++++----- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/oid4vc-core/src/utils/did.rs b/oid4vc-core/src/utils/did.rs index 5c616de9..2ca344de 100644 --- a/oid4vc-core/src/utils/did.rs +++ b/oid4vc-core/src/utils/did.rs @@ -19,9 +19,15 @@ fn sd_jwt_to_jwt(sd_jwt: &str) -> &str { sd_jwt.split_once('~').map(|(jwt, _)| jwt).unwrap_or(sd_jwt) } -/// This function resolves the key ID from the JWT header, and makes it absolute if it's a relative reference (starts -/// with '#') by prepending the 'iss' claim from the JWT payload. -pub fn resolve_key_id(jwt: &str) -> Result { +/// Extract the `kid` from a JWT header as a DID URL. +/// +/// If the `kid` is a relative DID fragment such as `#key-1`, this function +/// prefixes it with the unverified `iss` claim from the JWT payload to produce +/// an absolute DID URL. +/// +/// Returns an error if the JWT header cannot be decoded, if `kid` is missing, +/// or if a relative `kid` cannot be expanded because `iss` is missing or not a string. +pub fn extract_normalized_did_kid_from_jwt(jwt: &str) -> Result { let jwt = sd_jwt_to_jwt(jwt); let jwt_header = decode_header(jwt).map_err(|e| anyhow::anyhow!("Failed to decode JWT header: {e}"))?; @@ -87,7 +93,7 @@ mod tests { // JWT with relative key_id (starts with '#') let jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6IiNteWtleSJ9.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3N1ZXIifQ.signature"; - let result = resolve_key_id(jwt); + let result = extract_normalized_did_kid_from_jwt(jwt); assert!(result.is_ok()); assert_eq!(result.unwrap(), "did:example:issuer#mykey"); } @@ -96,7 +102,7 @@ mod tests { fn resolve_key_id_with_absolute_reference() { // JWT with absolute key_id (doesn't start with '#') let jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDpleGFtcGxlOmlzc3VlciNteWtleSJ9.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3N1ZXIifQ.signature"; - let result = resolve_key_id(jwt); + let result = extract_normalized_did_kid_from_jwt(jwt); assert!(result.is_ok()); assert_eq!(result.unwrap(), "did:example:issuer#mykey"); } @@ -105,7 +111,7 @@ mod tests { fn resolve_key_id_missing_kid() { // JWT without kid in header let jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJpc3MiOiJkaWQ6ZXhhbXBsZTppc3N1ZXIifQ.signature"; - let result = resolve_key_id(jwt); + let result = extract_normalized_did_kid_from_jwt(jwt); assert!(result.is_err()); } @@ -113,7 +119,7 @@ mod tests { fn resolve_key_id_missing_iss_claim() { // JWT with relative key_id but missing 'iss' claim let jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6IiNteWtleSJ9.e30.signature"; - let result = resolve_key_id(jwt); + let result = extract_normalized_did_kid_from_jwt(jwt); assert!(result.is_err()); } @@ -121,7 +127,7 @@ mod tests { fn resolve_key_id_iss_not_string() { // JWT with relative key_id but 'iss' claim is not a string let jwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6IiNteWtleSJ9.eyJpc3MiOjEyMzQ1fQ.signature"; - let result = resolve_key_id(jwt); + let result = extract_normalized_did_kid_from_jwt(jwt); assert!(result.is_err()); } } diff --git a/oid4vp/src/token/vp_token_validator.rs b/oid4vp/src/token/vp_token_validator.rs index 08bf9e76..bcfa2a35 100644 --- a/oid4vp/src/token/vp_token_validator.rs +++ b/oid4vp/src/token/vp_token_validator.rs @@ -19,7 +19,7 @@ use identity_verification::jws::{Decoder, JwsVerifier}; use nutype::nutype; use oid4vc_core::{ credential_status_verifier::CredentialStatusVerifier, - utils::{did::resolve_key_id, predicates::not_empty}, + utils::{did::extract_normalized_did_kid_from_jwt, predicates::not_empty}, }; use oid4vc_core::{ types::string_or_object::StringOrObject, verification_material_resolver::VerificationMaterialResolver, JsonObject, @@ -427,8 +427,8 @@ impl<'a, SV: JwsVerifier + Clone, VMR: VerificationMaterialResolver, CSV: Creden nonce: Option<&str>, require_holder_binding: bool, ) -> Result { - let kid_str = - resolve_key_id(&sd_jwt_vc.to_string()).map_err(|e| VpTokenValidationError::InvalidKid(e.to_string()))?; + let kid_str = extract_normalized_did_kid_from_jwt(&sd_jwt_vc.to_string()) + .map_err(|e| VpTokenValidationError::InvalidKid(e.to_string()))?; let kid: DIDUrl = kid_str .parse() @@ -492,8 +492,8 @@ impl<'a, SV: JwsVerifier + Clone, VMR: VerificationMaterialResolver, CSV: Creden /// Internal helper to validate VCDM 2.0 SD-JWT. async fn validate_vcdm2_sd_jwt(&self, vcdm2_sd_jwt: &SdJwt) -> Result { - let kid_str = - resolve_key_id(&vcdm2_sd_jwt.to_string()).map_err(|e| VpTokenValidationError::InvalidKid(e.to_string()))?; + let kid_str = extract_normalized_did_kid_from_jwt(&vcdm2_sd_jwt.to_string()) + .map_err(|e| VpTokenValidationError::InvalidKid(e.to_string()))?; let kid: DIDUrl = kid_str .parse() From 0ebdc7518ee5eef57c0150b319f627c71f103856 Mon Sep 17 00:00:00 2001 From: Nander Stabel Date: Thu, 4 Jun 2026 16:06:31 +0200 Subject: [PATCH 07/12] refactor: move `get_unverified_jwt_claims` and `sd_jwt_to_jwt` to dedicated file --- oid4vc-core/src/utils/did.rs | 55 +-------------------------------- oid4vc-core/src/utils/jwt.rs | 59 ++++++++++++++++++++++++++++++++++++ oid4vc-core/src/utils/mod.rs | 1 + 3 files changed, 61 insertions(+), 54 deletions(-) create mode 100644 oid4vc-core/src/utils/jwt.rs diff --git a/oid4vc-core/src/utils/did.rs b/oid4vc-core/src/utils/did.rs index 2ca344de..7d59b628 100644 --- a/oid4vc-core/src/utils/did.rs +++ b/oid4vc-core/src/utils/did.rs @@ -1,24 +1,6 @@ -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use crate::utils::jwt::{get_unverified_jwt_claims, sd_jwt_to_jwt}; use jsonwebtoken::decode_header; -/// Get the claims from a JWT without performing validation. -pub fn get_unverified_jwt_claims(jwt: &serde_json::Value) -> Result { - jwt.as_str() - .and_then(|string| string.splitn(3, '.').collect::>().get(1).cloned()) - .and_then(|payload| { - URL_SAFE_NO_PAD - .decode(payload) - .ok() - .and_then(|payload_bytes| serde_json::from_slice::(&payload_bytes).ok()) - }) - .ok_or_else(|| anyhow::anyhow!("Failed to decode JWT claims")) -} - -/// If the input is an SD-JWT, extract the JWT part. Otherwise, return the input as is. -fn sd_jwt_to_jwt(sd_jwt: &str) -> &str { - sd_jwt.split_once('~').map(|(jwt, _)| jwt).unwrap_or(sd_jwt) -} - /// Extract the `kid` from a JWT header as a DID URL. /// /// If the `kid` is a relative DID fragment such as `#key-1`, this function @@ -52,41 +34,6 @@ pub fn extract_normalized_did_kid_from_jwt(jwt: &str) -> Result Result { + jwt.as_str() + .and_then(|string| string.splitn(3, '.').collect::>().get(1).cloned()) + .and_then(|payload| { + URL_SAFE_NO_PAD + .decode(payload) + .ok() + .and_then(|payload_bytes| serde_json::from_slice::(&payload_bytes).ok()) + }) + .ok_or_else(|| anyhow::anyhow!("Failed to decode JWT claims")) +} + +/// If the input is an SD-JWT, extract the JWT part. Otherwise, return the input as is. +pub fn sd_jwt_to_jwt(sd_jwt: &str) -> &str { + sd_jwt.split_once('~').map(|(jwt, _)| jwt).unwrap_or(sd_jwt) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn get_unverified_jwt_claims_successfully_gets_claims() { + let jwt = json!("eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2toUDQzTENTWGFqM1NRQm92eTF1RTJuWHZTQm5SUFdaMndoUExxblo4UGdEI3o2TWtraFA0M0xDU1hhajNTUUJvdnkxdUUyblh2U0JuUlBXWjJ3aFBMcW5aOFBnRCJ9.eyJpc3MiOiJodHRwOi8vMTkyLjE2OC4xLjEyNzo5MDkwLyIsInN1YiI6ImRpZDprZXk6ejZNa2cxWFhHVXFma2hBS1Uxa1ZkMVBtdzZVRWoxdnhpTGoxeGM5MU1CejVvd05ZIiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjAsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvZXhhbXBsZXMvdjEiXSwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsIlBlcnNvbmFsSW5mb3JtYXRpb24iXSwiaXNzdWFuY2VEYXRlIjoiMjAyMi0wMS0wMVQwMDowMDowMFoiLCJpc3N1ZXIiOiJodHRwOi8vMTkyLjE2OC4xLjEyNzo5MDkwLyIsImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rZzFYWEdVcWZraEFLVTFrVmQxUG13NlVFajF2eGlMajF4YzkxTUJ6NW93TlkiLCJnaXZlbk5hbWUiOiJGZXJyaXMiLCJmYW1pbHlOYW1lIjoiQ3JhYm1hbiIsImVtYWlsIjoiZmVycmlzLmNyYWJtYW5AY3JhYm1haWwuY29tIiwiYmlydGhkYXRlIjoiMTk4NS0wNS0yMSJ9fX0.Yl841U5BwWgctX5vF5Zi8SYCEQpxFqEs8_J8KrX9D_mOwL-IRmP64BeQZvnKeAdcOoYGn6CyciV51_amdPNQBw"); + + assert_eq!( + get_unverified_jwt_claims(&jwt).unwrap(), + json!({ + "iss": "http://192.168.1.127:9090/", + "sub": "did:key:z6Mkg1XXGUqfkhAKU1kVd1Pmw6UEj1vxiLj1xc91MBz5owNY", + "exp": 9999999999i64, + "iat": 0, + "vc": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "type": [ + "VerifiableCredential", + "PersonalInformation" + ], + "issuanceDate": "2022-01-01T00:00:00Z", + "issuer": "http://192.168.1.127:9090/", + "credentialSubject": { + "id": "did:key:z6Mkg1XXGUqfkhAKU1kVd1Pmw6UEj1vxiLj1xc91MBz5owNY", + "givenName": "Ferris", + "familyName": "Crabman", + "email": "ferris.crabman@crabmail.com", + "birthdate": "1985-05-21" + } + } + }) + ); + } +} diff --git a/oid4vc-core/src/utils/mod.rs b/oid4vc-core/src/utils/mod.rs index 7e18d0c4..2526250c 100644 --- a/oid4vc-core/src/utils/mod.rs +++ b/oid4vc-core/src/utils/mod.rs @@ -1,3 +1,4 @@ pub mod did; pub mod form_urlencoded; +pub mod jwt; pub mod predicates; From be6a94919f3b69993513b30f808454e0bb8b7665 Mon Sep 17 00:00:00 2001 From: Nander Stabel Date: Thu, 4 Jun 2026 17:22:47 +0200 Subject: [PATCH 08/12] feat: add reference to scope support in authorization request comments --- oid4vci/src/wallet/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/oid4vci/src/wallet/mod.rs b/oid4vci/src/wallet/mod.rs index c1929b10..09089d95 100644 --- a/oid4vci/src/wallet/mod.rs +++ b/oid4vci/src/wallet/mod.rs @@ -172,7 +172,7 @@ impl Wallet { response_type: "code".to_string(), client_id: client_id.to_string(), redirect_uri: Some(redirect_uri), - // TODO: add support for `scope` + // TODO: add support for `scope`, see: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#section-3.3.4 scope: None, state: Some(state), authorization_details, @@ -420,6 +420,7 @@ impl Wallet { response_type: "code".to_string(), client_id: client_id.to_string(), redirect_uri, + // TODO: add support for `scope`, see: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#section-3.3.4 scope: None, state, authorization_details, From e24cf68555ca0709ca4ae27b175bf530b878c6cc Mon Sep 17 00:00:00 2001 From: Nander Stabel Date: Thu, 4 Jun 2026 17:27:35 +0200 Subject: [PATCH 09/12] refactor: rename InteractiveAuthorizationErrorCode to InteractiveAuthorizationErrorResponse --- oid4vci/src/errors.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/oid4vci/src/errors.rs b/oid4vci/src/errors.rs index 3778f7c2..5b5fd6a5 100644 --- a/oid4vci/src/errors.rs +++ b/oid4vci/src/errors.rs @@ -238,7 +238,7 @@ impl ErrorStatusCode for NotificationErrorResponse { /// the `missing_interaction_type` error code. #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum InteractiveAuthorizationErrorCode { +pub enum InteractiveAuthorizationErrorResponse { /// The `interaction_types_supported` parameter is missing a required interaction type. MissingInteractionType, /// Standard OAuth error codes may also appear. @@ -248,7 +248,7 @@ pub enum InteractiveAuthorizationErrorCode { AccessDenied, } -impl ErrorStatusCode for InteractiveAuthorizationErrorCode { +impl ErrorStatusCode for InteractiveAuthorizationErrorResponse { fn status_code(&self) -> StatusCode { match self { Self::MissingInteractionType => StatusCode::BAD_REQUEST, @@ -260,8 +260,8 @@ impl ErrorStatusCode for InteractiveAuthorizationErrorCode { } } -impl std::error::Error for InteractiveAuthorizationErrorCode {} -impl Display for InteractiveAuthorizationErrorCode { +impl std::error::Error for InteractiveAuthorizationErrorResponse {} +impl Display for InteractiveAuthorizationErrorResponse { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::MissingInteractionType => write!(f, "Missing Interaction Type"), From 09560d12bf2475384fa7dbe8a1accb57ceaf7041 Mon Sep 17 00:00:00 2001 From: Nander Stabel Date: Thu, 4 Jun 2026 17:40:05 +0200 Subject: [PATCH 10/12] refactor: use `thiserror::Error` --- oid4vci/src/errors.rs | 117 +++++++++++++++--------------------------- 1 file changed, 41 insertions(+), 76 deletions(-) diff --git a/oid4vci/src/errors.rs b/oid4vci/src/errors.rs index 5b5fd6a5..da29c317 100644 --- a/oid4vci/src/errors.rs +++ b/oid4vci/src/errors.rs @@ -51,15 +51,22 @@ where } /// Authorization Error Response as described here: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-authorization-error-respons -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Error)] #[serde(rename_all = "snake_case")] pub enum AuthorizationErrorResponse { + #[error("Access Denied")] AccessDenied, + #[error("Invalid Request")] InvalidRequest, + #[error("Unauthorized Client")] UnauthorizedClient, + #[error("Unsupported Response Type")] UnsupportedResponseType, + #[error("Invalid Scope")] InvalidScope, + #[error("Server Error")] ServerError, + #[error("Temporarily Unavailable")] TemporarilyUnavailable, } @@ -77,29 +84,21 @@ impl ErrorStatusCode for AuthorizationErrorResponse { } } -impl std::error::Error for AuthorizationErrorResponse {} -impl Display for AuthorizationErrorResponse { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::AccessDenied => write!(f, "Access Denied"), - Self::InvalidRequest => write!(f, "Invalid Request"), - Self::UnauthorizedClient => write!(f, "Unauthorized Client"), - Self::UnsupportedResponseType => write!(f, "Unsupported Response Type"), - Self::InvalidScope => write!(f, "Invalid Scope"), - Self::ServerError => write!(f, "Server Error"), - Self::TemporarilyUnavailable => write!(f, "Temporarily Unavailable"), - } - } -} /// Token Error Response as described here: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-token-error-response -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Error)] #[serde(rename_all = "snake_case")] pub enum TokenErrorResponse { + #[error("Invalid Request")] InvalidRequest, + #[error("Invalid Client")] InvalidClient, + #[error("Invalid Grant")] InvalidGrant, + #[error("Unauthorized Client")] UnauthorizedClient, + #[error("Unsupported Grant Type")] UnsupportedGrantType, + #[error("Invalid Scope")] InvalidScope, } @@ -115,30 +114,24 @@ impl ErrorStatusCode for TokenErrorResponse { } } } -impl std::error::Error for TokenErrorResponse {} -impl Display for TokenErrorResponse { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::InvalidRequest => write!(f, "Invalid Request"), - Self::InvalidClient => write!(f, "Invalid Client"), - Self::InvalidGrant => write!(f, "Invalid Grant"), - Self::UnauthorizedClient => write!(f, "Unauthorized Client"), - Self::UnsupportedGrantType => write!(f, "Unsupported Grant Type"), - Self::InvalidScope => write!(f, "Invalid Scope"), - } - } -} /// Credential Error Response as defined in OpenID4VCI 1.0 - https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request-errors -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Error)] #[serde(rename_all = "snake_case")] pub enum CredentialErrorResponse { + #[error("Invalid Credential Request")] InvalidCredentialRequest, + #[error("Unknown Credential Configuration")] UnknownCredentialConfiguration, + #[error("Unknown Credential Identifier")] UnknownCredentialIdentifier, + #[error("Invalid Proof")] InvalidProof, + #[error("Invalid Nonce")] InvalidNonce, + #[error("Invalid Encryption Parameters")] InvalidEncryptionParameters, + #[error("Credential Request Denied")] CredentialRequestDenied, } @@ -156,32 +149,25 @@ impl ErrorStatusCode for CredentialErrorResponse { } } -impl std::error::Error for CredentialErrorResponse {} -impl Display for CredentialErrorResponse { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::InvalidCredentialRequest => write!(f, "Invalid Credential Request"), - Self::UnknownCredentialConfiguration => write!(f, "Unknown Credential Configuration"), - Self::UnknownCredentialIdentifier => write!(f, "Unknown Credential Identifier"), - Self::InvalidProof => write!(f, "Invalid Proof"), - Self::InvalidNonce => write!(f, "Invalid Nonce"), - Self::InvalidEncryptionParameters => write!(f, "Invalid Encryption Parameters"), - Self::CredentialRequestDenied => write!(f, "Credential Request Denied"), - } - } -} - /// Deferred Credential Error Response as described here: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-deferred-credential-error-r -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Error)] #[serde(rename_all = "snake_case")] pub enum DeferredCredentialErrorResponse { + #[error("Invalid Credential Request")] InvalidCredentialRequest, + #[error("Unknown Credential Configuration")] UnknownCredentialConfiguration, + #[error("Unknown Credential Identifier")] UnknownCredentialIdentifier, + #[error("Invalid Proof")] InvalidProof, + #[error("Invalid Nonce")] InvalidNonce, + #[error("Invalid Encryption Parameters")] InvalidEncryptionParameters, + #[error("Credential Request Denied")] CredentialRequestDenied, + #[error("Invalid Transaction ID")] InvalidTransactionId, } @@ -200,26 +186,13 @@ impl ErrorStatusCode for DeferredCredentialErrorResponse { } } -impl std::error::Error for DeferredCredentialErrorResponse {} -impl Display for DeferredCredentialErrorResponse { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::InvalidCredentialRequest => write!(f, "Invalid Credential Request"), - Self::UnknownCredentialConfiguration => write!(f, "Unknown Credential Configuration"), - Self::UnknownCredentialIdentifier => write!(f, "Unknown Credential Identifier"), - Self::InvalidProof => write!(f, "Invalid Proof"), - Self::InvalidNonce => write!(f, "Invalid Nonce"), - Self::InvalidEncryptionParameters => write!(f, "Invalid Encryption Parameters"), - Self::CredentialRequestDenied => write!(f, "Credential Request Denied"), - Self::InvalidTransactionId => write!(f, "Invalid Transaction ID"), - } - } -} /// Notification Error Response as defined in OpenID4VCI 1.0: https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-notification-error-response -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Error)] #[serde(rename_all = "snake_case")] pub enum NotificationErrorResponse { + #[error("Invalid Notification Request")] InvalidNotificationRequest, + #[error("Invalid Notification ID")] InvalidNotificationId, } @@ -236,15 +209,20 @@ impl ErrorStatusCode for NotificationErrorResponse { /// /// In addition to standard PAR error processing rules (RFC 9126, Section 2.3), this adds /// the `missing_interaction_type` error code. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Error)] #[serde(rename_all = "snake_case")] pub enum InteractiveAuthorizationErrorResponse { /// The `interaction_types_supported` parameter is missing a required interaction type. + #[error("Missing Interaction Type")] MissingInteractionType, /// Standard OAuth error codes may also appear. + #[error("Invalid Request")] InvalidRequest, + #[error("Invalid Client")] InvalidClient, + #[error("Unauthorized Client")] UnauthorizedClient, + #[error("Access Denied")] AccessDenied, } @@ -260,19 +238,6 @@ impl ErrorStatusCode for InteractiveAuthorizationErrorResponse { } } -impl std::error::Error for InteractiveAuthorizationErrorResponse {} -impl Display for InteractiveAuthorizationErrorResponse { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::MissingInteractionType => write!(f, "Missing Interaction Type"), - Self::InvalidRequest => write!(f, "Invalid Request"), - Self::InvalidClient => write!(f, "Invalid Client"), - Self::UnauthorizedClient => write!(f, "Unauthorized Client"), - Self::AccessDenied => write!(f, "Access Denied"), - } - } -} - #[cfg(test)] mod tests { use super::*; From b3bd5f24d5109b9adb11f21e35ea9b2e36d47fb5 Mon Sep 17 00:00:00 2001 From: Nander Stabel Date: Mon, 8 Jun 2026 14:19:41 +0200 Subject: [PATCH 11/12] feat: implement JWT utility functions for claims extraction and SD-JWT handling --- oid4vc-core/Cargo.toml | 3 +- oid4vc-core/src/utils/jwt.rs | 59 ++++++++++++++++++++++++++++++++++++ oid4vc-core/src/utils/mod.rs | 1 + 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 oid4vc-core/src/utils/jwt.rs diff --git a/oid4vc-core/Cargo.toml b/oid4vc-core/Cargo.toml index 5b99d487..e5e6610b 100644 --- a/oid4vc-core/Cargo.toml +++ b/oid4vc-core/Cargo.toml @@ -7,6 +7,8 @@ license.workspace = true [dependencies] anyhow = "1.0.70" async-trait = "0.1.68" +base64 = "0.22" +# TODO: remove this dependency and use base64 instead, see: https://github.com/impierce/openid4vc/pull/127 base64-url = "2.0.0" derivative = "2.2.0" derive_more = "0.99.16" @@ -37,4 +39,3 @@ tokio.workspace = true [features] test-utils = ["dep:mockall"] - diff --git a/oid4vc-core/src/utils/jwt.rs b/oid4vc-core/src/utils/jwt.rs new file mode 100644 index 00000000..2f96ecdb --- /dev/null +++ b/oid4vc-core/src/utils/jwt.rs @@ -0,0 +1,59 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + +/// Get the claims from a JWT without performing validation. +pub fn get_unverified_jwt_claims(jwt: &serde_json::Value) -> Result { + jwt.as_str() + .and_then(|string| string.splitn(3, '.').collect::>().get(1).cloned()) + .and_then(|payload| { + URL_SAFE_NO_PAD + .decode(payload) + .ok() + .and_then(|payload_bytes| serde_json::from_slice::(&payload_bytes).ok()) + }) + .ok_or_else(|| anyhow::anyhow!("Failed to decode JWT claims")) +} + +/// If the input is an SD-JWT, extract the JWT part. Otherwise, return the input as is. +pub fn sd_jwt_to_jwt(sd_jwt: &str) -> &str { + sd_jwt.split_once('~').map(|(jwt, _)| jwt).unwrap_or(sd_jwt) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn get_unverified_jwt_claims_successfully_gets_claims() { + let jwt = json!("eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2toUDQzTENTWGFqM1NRQm92eTF1RTJuWHZTQm5SUFdaMndoUExxblo4UGdEI3o2TWtraFA0M0xDU1hhajNTUUJvdnkxdUUyblh2U0JuUlBXWjJ3aFBMcW5aOFBnRCJ9.eyJpc3MiOiJodHRwOi8vMTkyLjE2OC4xLjEyNzo5MDkwLyIsInN1YiI6ImRpZDprZXk6ejZNa2cxWFhHVXFma2hBS1Uxa1ZkMVBtdzZVRWoxdnhpTGoxeGM5MU1CejVvd05ZIiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjAsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvZXhhbXBsZXMvdjEiXSwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsIlBlcnNvbmFsSW5mb3JtYXRpb24iXSwiaXNzdWFuY2VEYXRlIjoiMjAyMi0wMS0wMVQwMDowMDowMFoiLCJpc3N1ZXIiOiJodHRwOi8vMTkyLjE2OC4xLjEyNzo5MDkwLyIsImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6Nk1rZzFYWEdVcWZraEFLVTFrVmQxUG13NlVFajF2eGlMajF4YzkxTUJ6NW93TlkiLCJnaXZlbk5hbWUiOiJGZXJyaXMiLCJmYW1pbHlOYW1lIjoiQ3JhYm1hbiIsImVtYWlsIjoiZmVycmlzLmNyYWJtYW5AY3JhYm1haWwuY29tIiwiYmlydGhkYXRlIjoiMTk4NS0wNS0yMSJ9fX0.Yl841U5BwWgctX5vF5Zi8SYCEQpxFqEs8_J8KrX9D_mOwL-IRmP64BeQZvnKeAdcOoYGn6CyciV51_amdPNQBw"); + + assert_eq!( + get_unverified_jwt_claims(&jwt).unwrap(), + json!({ + "iss": "http://192.168.1.127:9090/", + "sub": "did:key:z6Mkg1XXGUqfkhAKU1kVd1Pmw6UEj1vxiLj1xc91MBz5owNY", + "exp": 9999999999i64, + "iat": 0, + "vc": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "type": [ + "VerifiableCredential", + "PersonalInformation" + ], + "issuanceDate": "2022-01-01T00:00:00Z", + "issuer": "http://192.168.1.127:9090/", + "credentialSubject": { + "id": "did:key:z6Mkg1XXGUqfkhAKU1kVd1Pmw6UEj1vxiLj1xc91MBz5owNY", + "givenName": "Ferris", + "familyName": "Crabman", + "email": "ferris.crabman@crabmail.com", + "birthdate": "1985-05-21" + } + } + }) + ); + } +} diff --git a/oid4vc-core/src/utils/mod.rs b/oid4vc-core/src/utils/mod.rs index ec2accc2..82c6cccb 100644 --- a/oid4vc-core/src/utils/mod.rs +++ b/oid4vc-core/src/utils/mod.rs @@ -1,2 +1,3 @@ pub mod form_urlencoded; +pub mod jwt; pub mod predicates; From 95069d8d63c2a1fec8ce92d05433340e0d86de40 Mon Sep 17 00:00:00 2001 From: Nander Stabel Date: Wed, 10 Jun 2026 13:43:19 +0200 Subject: [PATCH 12/12] refactor: add TODO comments --- oid4vci/src/errors.rs | 1 + oid4vp/src/token/vp_token_validator.rs | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/oid4vci/src/errors.rs b/oid4vci/src/errors.rs index da29c317..4eb64341 100644 --- a/oid4vci/src/errors.rs +++ b/oid4vci/src/errors.rs @@ -205,6 +205,7 @@ impl ErrorStatusCode for NotificationErrorResponse { } } +// TODO: Validate error response serialization against the OID4VCI spec. /// Interactive Authorization Error Response as defined in OID4VCI 1.1, Section 6.2.3. /// /// In addition to standard PAR error processing rules (RFC 9126, Section 2.3), this adds diff --git a/oid4vp/src/token/vp_token_validator.rs b/oid4vp/src/token/vp_token_validator.rs index bcfa2a35..e4fbb9bb 100644 --- a/oid4vp/src/token/vp_token_validator.rs +++ b/oid4vp/src/token/vp_token_validator.rs @@ -327,7 +327,8 @@ impl<'a, SV: JwsVerifier + Clone, VMR: VerificationMaterialResolver, CSV: Creden .decode_compact_serialization(presentation_jwt.as_str().as_bytes(), None) .map_err(VpTokenValidationError::JwsDecodingError)?; - // TODO: check whether the KID is a relative reference (starts with '#') and resolve it against the 'iss' claim in the payload if so (see `fn resolve_key_id`) + // TODO: check whether the KID is a relative reference (starts with '#') and resolve it against the 'iss' claim + // in the payload if so (see `fn extract_normalized_did_kid_from_jwt`) let kid_str = validation_item.kid().ok_or(VpTokenValidationError::MissingKid)?; let kid: DIDUrl = kid_str .parse() @@ -386,7 +387,8 @@ impl<'a, SV: JwsVerifier + Clone, VMR: VerificationMaterialResolver, CSV: Creden .decode_compact_serialization(credential_jwt.as_str().as_bytes(), None) .map_err(VpTokenValidationError::JwsDecodingError)?; - // TODO: check whether the KID is a relative reference (starts with '#') and resolve it against the 'iss' claim in the payload if so (see `fn resolve_key_id`) + // TODO: check whether the KID is a relative reference (starts with '#') and resolve it against the 'iss' claim + //in the payload if so (see `fn extract_normalized_did_kid_from_jwt`) let kid_str = validation_item.kid().ok_or(VpTokenValidationError::MissingKid)?; let kid: DIDUrl = kid_str .parse()