Skip to main content

lastid_sdk/http/
idp_client.rs

1//! IDP HTTP client trait and implementation.
2
3// WASM is single-threaded, so futures don't need to be Send
4#![cfg_attr(target_arch = "wasm32", allow(clippy::future_not_send))]
5
6use async_trait::async_trait;
7use uuid::Uuid;
8
9use crate::config::{NetworkConfig, RetryPolicy};
10use crate::constants::DEFAULT_RETRY_AFTER_SECONDS;
11use crate::error::HttpError;
12use crate::http::{HttpClient, execute_with_retry};
13use crate::types::{CredentialPolicyRequest, CredentialRequestResponse, RequestId, RequestStatus};
14use crate::verification::VerifiablePresentation;
15
16/// HTTP client interface for IDP operations.
17///
18/// This trait abstracts HTTP communication with the `LastID` IDP, enabling:
19/// 1. Testing with mock implementations
20/// 2. Platform-specific HTTP clients (native reqwest vs wasm fetch)
21/// 3. Retry and error handling strategies
22// Native: requires Send + Sync for thread safety
23#[cfg(not(target_arch = "wasm32"))]
24#[async_trait]
25pub trait IdpClient: Send + Sync {
26    /// Request a credential presentation from the IDP.
27    ///
28    /// Returns the full response including the `openid4vp://` request URI
29    /// needed for QR code display.
30    ///
31    /// # Arguments
32    ///
33    /// * `policy` - Credential policy request
34    /// * `bearer_token` - OAuth DPoP-bound access token
35    /// * `dpop_proof` - `DPoP` proof JWT with ath claim for token binding
36    async fn request_credential(
37        &self,
38        policy: CredentialPolicyRequest,
39        bearer_token: &str,
40        dpop_proof: &str,
41    ) -> Result<CredentialRequestResponse, HttpError>;
42
43    /// Poll for credential request status.
44    ///
45    /// # Arguments
46    ///
47    /// * `request_id` - Request identifier from `request_credential`
48    /// * `client_id` - OAuth client ID for CORS preflight validation
49    async fn poll_status(
50        &self,
51        request_id: &RequestId,
52        client_id: &str,
53    ) -> Result<RequestStatus, HttpError>;
54
55    /// Retrieve the presentation for a fulfilled request.
56    ///
57    /// # Arguments
58    ///
59    /// * `request_id` - Request identifier
60    /// * `client_id` - OAuth client ID for CORS preflight validation
61    async fn get_presentation(
62        &self,
63        request_id: &RequestId,
64        client_id: &str,
65    ) -> Result<VerifiablePresentation, HttpError>;
66}
67
68// WASM: single-threaded, no Send + Sync needed
69#[cfg(target_arch = "wasm32")]
70#[async_trait(?Send)]
71pub trait IdpClient {
72    /// Request a credential presentation from the IDP.
73    ///
74    /// Returns the full response including the `openid4vp://` request URI
75    /// needed for QR code display.
76    ///
77    /// # Arguments
78    ///
79    /// * `policy` - Credential policy request
80    /// * `bearer_token` - OAuth DPoP-bound access token
81    /// * `dpop_proof` - `DPoP` proof JWT with ath claim for token binding
82    ///
83    /// # Errors
84    ///
85    /// * `HttpError::Network` - Network failure (retryable)
86    /// * `HttpError::Status { 400 }` - Invalid policy (non-retryable)
87    /// * `HttpError::Status { 401 }` - Invalid token (non-retryable)
88    /// * `HttpError::RateLimited` - Rate limit exceeded (retryable with
89    ///   backoff)
90    async fn request_credential(
91        &self,
92        policy: CredentialPolicyRequest,
93        bearer_token: &str,
94        dpop_proof: &str,
95    ) -> Result<CredentialRequestResponse, HttpError>;
96
97    /// Poll for credential request status.
98    ///
99    /// # Arguments
100    ///
101    /// * `request_id` - Request identifier from `request_credential`
102    /// * `client_id` - OAuth client ID for CORS preflight validation
103    ///
104    /// # Errors
105    ///
106    /// * `HttpError::Status { 404 }` - Request ID not found
107    /// * `HttpError::Network` - Network failure (retryable)
108    async fn poll_status(
109        &self,
110        request_id: &RequestId,
111        client_id: &str,
112    ) -> Result<RequestStatus, HttpError>;
113
114    /// Retrieve the presentation for a fulfilled request.
115    ///
116    /// # Arguments
117    ///
118    /// * `request_id` - Request identifier
119    /// * `client_id` - OAuth client ID for CORS preflight validation
120    ///
121    /// # Errors
122    ///
123    /// * `HttpError::Status { 404 }` - Request not fulfilled or not found
124    /// * `HttpError::Status { 410 }` - Presentation already consumed
125    async fn get_presentation(
126        &self,
127        request_id: &RequestId,
128        client_id: &str,
129    ) -> Result<VerifiablePresentation, HttpError>;
130}
131
132/// Correlation ID configuration for request tracing.
133#[derive(Debug, Clone)]
134pub struct CorrelationIdConfig {
135    /// Whether correlation IDs are enabled.
136    pub enabled: bool,
137    /// The header name to use for correlation IDs.
138    pub header_name: String,
139}
140
141impl Default for CorrelationIdConfig {
142    fn default() -> Self {
143        Self {
144            enabled: true,
145            header_name: crate::constants::DEFAULT_CORRELATION_ID_HEADER.to_string(),
146        }
147    }
148}
149
150/// Production HTTP client for IDP operations.
151pub struct HttpIdpClient {
152    http_client: HttpClient,
153    base_url: String,
154    retry_policy: RetryPolicy,
155    correlation_config: CorrelationIdConfig,
156}
157
158impl HttpIdpClient {
159    /// Create a new IDP client with a simple timeout.
160    ///
161    /// This is a convenience constructor that creates a client with default
162    /// network configuration except for the request timeout. For full control
163    /// over proxy and timeout settings, use
164    /// [`HttpIdpClient::with_network_config`].
165    ///
166    /// # Arguments
167    ///
168    /// * `base_url` - IDP base URL (e.g., `https://human.lastid.co`)
169    /// * `timeout_seconds` - HTTP request timeout
170    /// * `retry_policy` - Retry configuration
171    ///
172    /// # Errors
173    ///
174    /// Returns `HttpError::Network` if HTTP client creation fails.
175    pub fn new(
176        base_url: String,
177        timeout_seconds: u64,
178        retry_policy: RetryPolicy,
179    ) -> Result<Self, HttpError> {
180        let network_config = NetworkConfig {
181            request_timeout_seconds: timeout_seconds,
182            ..Default::default()
183        };
184        Self::with_network_config(base_url, &network_config, retry_policy)
185    }
186
187    /// Create a new IDP client with full network configuration.
188    ///
189    /// This constructor provides full control over HTTP client settings
190    /// including:
191    /// - Proxy configuration (HTTP/HTTPS proxies, `no_proxy` exclusions)
192    /// - Granular timeout settings (connect, read, request)
193    /// - Connection pool tuning
194    /// - Correlation ID settings
195    ///
196    /// # Arguments
197    ///
198    /// * `base_url` - IDP base URL (e.g., `https://human.lastid.co`)
199    /// * `network_config` - Full network configuration
200    /// * `retry_policy` - Retry configuration
201    ///
202    /// # Errors
203    ///
204    /// Returns `HttpError::Network` if HTTP client creation fails (e.g.,
205    /// invalid proxy URL).
206    ///
207    /// # Example
208    ///
209    /// ```rust,no_run
210    /// use lastid_sdk::{HttpIdpClient, NetworkConfig, RetryPolicy};
211    ///
212    /// let network_config = NetworkConfig {
213    ///     proxy_url: Some("http://proxy.corp.example.com:8080".to_string()),
214    ///     connect_timeout_seconds: 5,
215    ///     read_timeout_seconds: 30,
216    ///     request_timeout_seconds: 60,
217    ///     ..Default::default()
218    /// };
219    ///
220    /// let client = HttpIdpClient::with_network_config(
221    ///     "https://human.lastid.co".to_string(),
222    ///     &network_config,
223    ///     RetryPolicy::default(),
224    /// )?;
225    /// # Ok::<(), lastid_sdk::HttpError>(())
226    /// ```
227    pub fn with_network_config(
228        base_url: String,
229        network_config: &NetworkConfig,
230        retry_policy: RetryPolicy,
231    ) -> Result<Self, HttpError> {
232        let correlation_config = CorrelationIdConfig {
233            enabled: network_config.enable_correlation_ids,
234            header_name: network_config.correlation_id_header.clone(),
235        };
236
237        let http_client = HttpClient::new(network_config)?;
238
239        Ok(Self {
240            http_client,
241            base_url,
242            retry_policy,
243            correlation_config,
244        })
245    }
246
247    /// Generate a new correlation ID for request tracing.
248    ///
249    /// Returns `None` if correlation IDs are disabled.
250    fn generate_correlation_id(&self) -> Option<String> {
251        if self.correlation_config.enabled {
252            Some(Uuid::new_v4().to_string())
253        } else {
254            None
255        }
256    }
257
258    /// Parse HTTP response into an error if not successful.
259    async fn response_to_error(response: reqwest::Response) -> HttpError {
260        let status = response.status().as_u16();
261
262        // Check for rate limiting
263        if status == 429 {
264            let retry_after = response
265                .headers()
266                .get("Retry-After")
267                .and_then(|v| v.to_str().ok())
268                .and_then(|s| s.parse().ok())
269                .unwrap_or(DEFAULT_RETRY_AFTER_SECONDS);
270
271            return HttpError::rate_limited(retry_after);
272        }
273
274        let message = response.text().await.unwrap_or_default();
275        HttpError::status(status, message)
276    }
277}
278
279/// Check if a JWT part is an issuer-signed SD-JWT (typ: vc+sd-jwt).
280///
281/// Returns true if the JWT header contains `"typ": "vc+sd-jwt"`, which indicates
282/// it's an issuer-signed credential (starts a new SD-JWT).
283/// Returns false for KB-JWTs (`kb+jwt`) or non-JWTs.
284fn is_issuer_jwt(part: &str) -> bool {
285    use base64::Engine;
286    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
287
288    // Must look like a JWT (has dots)
289    if !part.contains('.') {
290        return false;
291    }
292
293    // Extract and decode the header (first part before .)
294    let header_b64 = match part.split('.').next() {
295        Some(h) if h.starts_with("eyJ") => h,
296        _ => return false,
297    };
298
299    // Decode header and check typ
300    if let Ok(header_bytes) = URL_SAFE_NO_PAD.decode(header_b64)
301        && let Ok(header) = serde_json::from_slice::<serde_json::Value>(&header_bytes)
302    {
303        return header.get("typ").and_then(|v| v.as_str()) == Some("vc+sd-jwt");
304    }
305
306    false
307}
308
309/// Split VP tokens that are joined by `~`.
310///
311/// VP tokens from IDP are joined with `~`, but `~` is also used within SD-JWTs
312/// as the disclosure separator. This function correctly splits them by detecting
313/// issuer-signed JWTs (typ: vc+sd-jwt) which start new credentials.
314///
315/// # SD-JWT Structure
316///
317/// Each SD-JWT has the format: `<issuer-jwt>~<disclosure1>~...~<kb-jwt>`
318/// - Issuer JWT has `typ: "vc+sd-jwt"` in header
319/// - KB-JWT has `typ: "kb+jwt"` in header
320/// - Disclosures are base64url encoded arrays (not JWTs)
321fn split_vp_tokens(presentation: &str) -> Vec<String> {
322    if presentation.is_empty() {
323        return Vec::new();
324    }
325
326    let parts: Vec<&str> = presentation.split('~').collect();
327    let mut tokens: Vec<String> = Vec::new();
328    let mut current_token = String::new();
329
330    for part in parts {
331        // Check if this part is an issuer-signed JWT (starts a new credential)
332        if is_issuer_jwt(part) && !current_token.is_empty() {
333            // This is a new credential's issuer JWT, save current and start fresh
334            tokens.push(current_token);
335            current_token = part.to_string();
336        } else {
337            // Part of current credential (disclosure, KB-JWT, or first issuer JWT)
338            if !current_token.is_empty() {
339                current_token.push('~');
340            }
341            current_token.push_str(part);
342        }
343    }
344
345    if !current_token.is_empty() {
346        tokens.push(current_token);
347    }
348
349    tokens
350}
351
352/// Build IDP verification request from SDK policy.
353///
354/// Transforms the SDK's `CredentialPolicyRequest` into the IDP's expected
355/// `VerificationRequestInput` format following `OpenID4VP` specification.
356///
357/// # Presentation Definition Structure
358///
359/// Creates a SINGLE `input_descriptor` with the credential chain in an `enum`
360/// filter. This matches the human-idp-client reference implementation and allows
361/// the wallet to present any credential from the chain.
362///
363/// # Field Handling
364///
365/// - **Required fields** (`required_fields`): Each field becomes a path entry
366///   WITHOUT `optional: true` - the field MUST be disclosed.
367/// - **Optional fields** (`optional_fields`): Each field becomes a path entry
368///   WITH `optional: true` - user can choose whether to disclose.
369///
370/// # Example
371///
372/// For `Persona` with required `display_name` and optional `email`:
373/// ```json
374/// {
375///   "input_descriptors": [{
376///     "id": "credential",
377///     "constraints": {
378///       "fields": [
379///         { "path": ["$.vct"], "filter": { "type": "string", "enum": [...] } },
380///         { "path": ["$.display_name"] },
381///         { "path": ["$.email"], "optional": true }
382///       ]
383///     }
384///   }]
385/// }
386/// ```
387fn build_verification_request(policy: &CredentialPolicyRequest) -> serde_json::Value {
388    use serde_json::json;
389
390    // Use credential_types (the chain) for the vct filter
391    let credential_types = &policy.credential_types;
392
393    // Build vct filter - use const when single type, enum when multiple types
394    // This matches the human-idp-client reference implementation
395    let vct_filter = if credential_types.len() == 1 {
396        json!({
397            "type": "string",
398            "const": credential_types[0]
399        })
400    } else {
401        json!({
402            "type": "string",
403            "enum": credential_types
404        })
405    };
406
407    // Build fields array starting with vct constraint
408    let mut fields = vec![json!({
409        "path": ["$.vct"],
410        "filter": vct_filter
411    })];
412
413    // Add required fields (no optional flag = must be present)
414    if let Some(required) = &policy.required_fields {
415        for field in required {
416            fields.push(json!({
417                "path": [format!("$.{}", field)]
418            }));
419        }
420    }
421
422    // Add optional fields (user can choose to disclose)
423    if let Some(optional) = &policy.optional_fields {
424        for field in optional {
425            fields.push(json!({
426                "path": [format!("$.{}", field)],
427                "optional": true
428            }));
429        }
430    }
431
432    // Add other constraint fields (domain, issuer, verification level, etc.)
433    if let Some(constraints) = &policy.constraints {
434        for (key, value) in constraints {
435            // Skip deprecated requestedFields/optionalFields keys
436            if key == "requestedFields" || key == "optionalFields" {
437                continue;
438            }
439            fields.push(json!({
440                "path": [format!("$.{}", key)],
441                "filter": value
442            }));
443        }
444    }
445
446    let mut request = json!({
447        "client_id": &policy.client_id,
448        "response_type": "vp_token",
449        "response_mode": "direct_post",
450        "presentation_definition": {
451            "id": "sdk-request",
452            "input_descriptors": [{
453                "id": "credential",
454                "constraints": {
455                    "fields": fields
456                }
457            }]
458        }
459    });
460
461    // Add OpenID4VP top-level fields (NOT credential constraints)
462    // These are standard OAuth/OpenID4VP request parameters
463
464    // redirect_uri: where to redirect after completion
465    if let Some(callback) = &policy.callback_url {
466        request["redirect_uri"] = json!(callback);
467    }
468
469    // state: CSRF protection token, returned unchanged in response
470    if let Some(state) = &policy.state {
471        request["state"] = json!(state);
472    }
473
474    // nonce: cryptographic binding value for key binding proofs
475    if let Some(nonce) = &policy.nonce {
476        request["nonce"] = json!(nonce);
477    }
478
479    request
480}
481
482#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
483#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
484impl IdpClient for HttpIdpClient {
485    async fn request_credential(
486        &self,
487        policy: CredentialPolicyRequest,
488        bearer_token: &str,
489        dpop_proof: &str,
490    ) -> Result<CredentialRequestResponse, HttpError> {
491        // OpenID4VP verification request endpoint
492        // Include client_id in query string for CORS preflight validation.
493        // Browser preflight (OPTIONS) requests have no body, so the IDP needs
494        // client_id in the URL to validate the origin against allowedOrigins.
495        let url = format!(
496            "{}/v1/verify/request?client_id={}",
497            self.base_url,
498            urlencoding::encode(&policy.client_id)
499        );
500        let token = bearer_token.to_string();
501        let proof = dpop_proof.to_string();
502        let policy_clone = policy.clone();
503
504        // Generate correlation ID once per request (reused across retry attempts)
505        let correlation_id = self.generate_correlation_id();
506        let correlation_header = self.correlation_config.header_name.clone();
507
508        #[cfg(feature = "tracing")]
509        if let Some(ref cid) = correlation_id {
510            tracing::debug!(correlation_id = %cid, "Generated correlation ID for credential request");
511        }
512
513        execute_with_retry(&self.retry_policy, || {
514            let url = url.clone();
515            let token = token.clone();
516            let proof = proof.clone();
517            let policy = policy_clone.clone();
518            let client = &self.http_client;
519            let correlation_id = correlation_id.clone();
520            let correlation_header = correlation_header.clone();
521
522            async move {
523                // Transform SDK policy to IDP's VerificationRequestInput format
524                let idp_request = build_verification_request(&policy);
525
526                // DPoP-bound tokens use DPoP scheme, not Bearer (RFC 9449)
527                let mut request_builder = client
528                    .post(&url)
529                    .header("Authorization", format!("DPoP {token}"))
530                    .header("DPoP", &proof);
531
532                // Add correlation ID header if enabled
533                if let Some(ref cid) = correlation_id {
534                    request_builder = request_builder.header(&correlation_header, cid);
535                }
536
537                let response = request_builder
538                    .json(&idp_request)
539                    .send()
540                    .await
541                    .map_err(|e| {
542                        if e.is_timeout() {
543                            HttpError::Timeout
544                        } else {
545                            HttpError::network(e.to_string())
546                        }
547                    })?;
548
549                if response.status().is_success() {
550                    // IDP returns: { request_id, request_uri, expires_in, nonce }
551                    #[derive(serde::Deserialize)]
552                    struct IdpResponse {
553                        request_id: String,
554                        request_uri: String,
555                        expires_in: u64,
556                        nonce: String,
557                    }
558
559                    let body: IdpResponse = response
560                        .json()
561                        .await
562                        .map_err(|e| HttpError::network(e.to_string()))?;
563
564                    // Validate OpenID4VP URI scheme
565                    if !body.request_uri.starts_with("openid4vp://")
566                        && !body.request_uri.starts_with("openid://")
567                    {
568                        return Err(HttpError::network(format!(
569                            "Invalid request_uri scheme: expected openid4vp:// or openid://, got {}",
570                            body.request_uri.split("://").next().unwrap_or("unknown")
571                        )));
572                    }
573
574                    Ok(CredentialRequestResponse::new(
575                        RequestId::new(body.request_id),
576                        body.request_uri,
577                        body.expires_in,
578                        body.nonce,
579                    ))
580                } else {
581                    Err(Self::response_to_error(response).await)
582                }
583            }
584        })
585        .await
586    }
587
588    async fn poll_status(
589        &self,
590        request_id: &RequestId,
591        client_id: &str,
592    ) -> Result<RequestStatus, HttpError> {
593        // OpenID4VP status endpoint
594        // Include client_id in query string for CORS preflight validation.
595        // Browser preflight (OPTIONS) requests have no body, so the IDP needs
596        // client_id in the URL to validate the origin against allowedOrigins.
597        let url = format!(
598            "{}/v1/verify/status/{}?client_id={}",
599            self.base_url,
600            request_id.as_str(),
601            urlencoding::encode(client_id)
602        );
603        let rid = request_id.clone();
604
605        // Generate correlation ID once per request (reused across retry attempts)
606        let correlation_id = self.generate_correlation_id();
607        let correlation_header = self.correlation_config.header_name.clone();
608
609        #[cfg(feature = "tracing")]
610        if let Some(ref cid) = correlation_id {
611            tracing::debug!(correlation_id = %cid, request_id = %request_id.as_str(), "Generated correlation ID for status poll");
612        }
613
614        execute_with_retry(&self.retry_policy, || {
615            let url = url.clone();
616            let client = &self.http_client;
617            let request_id = rid.clone();
618            let correlation_id = correlation_id.clone();
619            let correlation_header = correlation_header.clone();
620
621            async move {
622                let mut request_builder = client.get(&url);
623
624                // Add correlation ID header if enabled
625                if let Some(ref cid) = correlation_id {
626                    request_builder = request_builder.header(&correlation_header, cid);
627                }
628
629                let response = request_builder.send().await.map_err(|e| {
630                    if e.is_timeout() {
631                        HttpError::Timeout
632                    } else {
633                        HttpError::network(e.to_string())
634                    }
635                })?;
636
637                if response.status().is_success() {
638                    // IDP returns: { request_id, state, result?, expires_at, vp_token? }
639                    // vp_token is an array of SD-JWT strings (one per credential)
640                    #[derive(serde::Deserialize)]
641                    #[allow(dead_code)]
642                    struct IdpStatus {
643                        request_id: String,
644                        state: String,
645                        /// VP tokens - array of SD-JWT strings per `OpenID4VP`
646                        /// spec
647                        #[serde(default)]
648                        vp_token: Option<Vec<String>>,
649                        expires_at: Option<String>,
650                    }
651
652                    let body: IdpStatus = response
653                        .json()
654                        .await
655                        .map_err(|e| HttpError::network(e.to_string()))?;
656
657                    // Transform IDP state to SDK RequestStatus
658                    let status = match body.state.as_str() {
659                        "verified" => {
660                            // Join multiple VP tokens with ~ separator (matches WebSocket)
661                            // Each token is a complete SD-JWT that can be parsed independently
662                            let presentation = body
663                                .vp_token
664                                .map(|tokens| tokens.join("~"))
665                                .unwrap_or_default();
666                            RequestStatus::Fulfilled {
667                                request_id,
668                                presentation,
669                                fulfilled_at: body.expires_at.unwrap_or_default(),
670                            }
671                        }
672                        "denied" | "failed" => RequestStatus::Denied {
673                            request_id,
674                            reason: "User declined or verification failed".to_string(),
675                            denied_at: body.expires_at.unwrap_or_default(),
676                        },
677                        "expired" => RequestStatus::Expired {
678                            request_id,
679                            expired_at: body.expires_at.unwrap_or_default(),
680                        },
681                        // Default to pending for "pending" and unknown states
682                        _ => RequestStatus::Pending {
683                            request_id,
684                            created_at: body.expires_at.unwrap_or_default(),
685                        },
686                    };
687                    Ok(status)
688                } else if response.status().as_u16() == 410 {
689                    // 410 Gone = expired
690                    Ok(RequestStatus::Expired {
691                        request_id,
692                        expired_at: String::new(),
693                    })
694                } else if response.status().as_u16() == 404 {
695                    // 404 Not Found = request doesn't exist (terminal state, stop polling)
696                    let message = response.text().await.unwrap_or_default();
697                    Ok(RequestStatus::NotFound {
698                        request_id,
699                        message,
700                    })
701                } else {
702                    Err(Self::response_to_error(response).await)
703                }
704            }
705        })
706        .await
707    }
708
709    async fn get_presentation(
710        &self,
711        request_id: &RequestId,
712        client_id: &str,
713    ) -> Result<VerifiablePresentation, HttpError> {
714        // Get presentation from status endpoint (vp_token field)
715        let status = self.poll_status(request_id, client_id).await?;
716
717        match status {
718            RequestStatus::Fulfilled { presentation, .. } => {
719                // Presentation may contain multiple SD-JWTs joined by ~.
720                // Parse the first one. For multiple credentials, callers should
721                // access RequestStatus::Fulfilled.presentation directly and use
722                // split_vp_tokens() to separate them.
723                let first_token = split_vp_tokens(&presentation)
724                    .into_iter()
725                    .next()
726                    .unwrap_or(presentation);
727                VerifiablePresentation::parse(&first_token)
728                    .map_err(|e| HttpError::network(format!("Failed to parse presentation: {e}")))
729            }
730            RequestStatus::Pending { .. } => {
731                Err(HttpError::status(404, "Request not yet fulfilled"))
732            }
733            RequestStatus::Denied { reason, .. } => Err(HttpError::status(403, reason)),
734            RequestStatus::Expired { .. } => Err(HttpError::status(410, "Request expired")),
735            RequestStatus::NotFound { message, .. } => Err(HttpError::status(404, message)),
736            RequestStatus::Timeout { .. } => Err(HttpError::status(408, "Request timed out")),
737        }
738    }
739}
740
741impl Clone for HttpIdpClient {
742    fn clone(&self) -> Self {
743        Self {
744            http_client: self.http_client.clone(),
745            base_url: self.base_url.clone(),
746            retry_policy: self.retry_policy.clone(),
747            correlation_config: self.correlation_config.clone(),
748        }
749    }
750}
751
752#[cfg(any(test, feature = "test-utils"))]
753pub mod mock {
754    //! Mock IDP client for testing.
755
756    use std::collections::VecDeque;
757    use std::sync::Mutex;
758
759    use super::{
760        CredentialPolicyRequest, CredentialRequestResponse, HttpError, IdpClient, RequestId,
761        RequestStatus, VerifiablePresentation, async_trait,
762    };
763
764    /// Mock IDP client for testing.
765    pub struct MockIdpClient {
766        requests: Mutex<VecDeque<Result<CredentialRequestResponse, HttpError>>>,
767        polls: Mutex<VecDeque<Result<RequestStatus, HttpError>>>,
768        presentations: Mutex<VecDeque<Result<VerifiablePresentation, HttpError>>>,
769    }
770
771    impl MockIdpClient {
772        /// Create a new mock IDP client.
773        #[must_use]
774        #[allow(clippy::missing_const_for_fn)] // Mutex::new is not const in stable Rust
775        pub fn new() -> Self {
776            Self {
777                requests: Mutex::new(VecDeque::new()),
778                polls: Mutex::new(VecDeque::new()),
779                presentations: Mutex::new(VecDeque::new()),
780            }
781        }
782
783        /// Add an expected response for `request_credential`.
784        pub fn expect_request(&self, response: Result<CredentialRequestResponse, HttpError>) {
785            self.requests.lock().unwrap().push_back(response);
786        }
787
788        /// Add an expected response for `poll_status`.
789        pub fn expect_poll(&self, response: Result<RequestStatus, HttpError>) {
790            self.polls.lock().unwrap().push_back(response);
791        }
792
793        /// Add an expected response for `get_presentation`.
794        pub fn expect_presentation(&self, response: Result<VerifiablePresentation, HttpError>) {
795            self.presentations.lock().unwrap().push_back(response);
796        }
797    }
798
799    impl Default for MockIdpClient {
800        fn default() -> Self {
801            Self::new()
802        }
803    }
804
805    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
806    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
807    impl IdpClient for MockIdpClient {
808        async fn request_credential(
809            &self,
810            _policy: CredentialPolicyRequest,
811            _bearer_token: &str,
812            _dpop_proof: &str,
813        ) -> Result<CredentialRequestResponse, HttpError> {
814            self.requests
815                .lock()
816                .unwrap()
817                .pop_front()
818                .unwrap_or_else(|| Err(HttpError::network("Mock not configured")))
819        }
820
821        async fn poll_status(
822            &self,
823            _request_id: &RequestId,
824            _client_id: &str,
825        ) -> Result<RequestStatus, HttpError> {
826            self.polls
827                .lock()
828                .unwrap()
829                .pop_front()
830                .unwrap_or_else(|| Err(HttpError::network("Mock not configured")))
831        }
832
833        async fn get_presentation(
834            &self,
835            _request_id: &RequestId,
836            _client_id: &str,
837        ) -> Result<VerifiablePresentation, HttpError> {
838            self.presentations
839                .lock()
840                .unwrap()
841                .pop_front()
842                .unwrap_or_else(|| Err(HttpError::network("Mock not configured")))
843        }
844    }
845}
846
847#[cfg(test)]
848mod tests {
849    use super::*;
850    use crate::types::credential_chain::types;
851
852    #[test]
853    fn test_build_verification_request_base_uses_const() {
854        // Base has single-element chain, so should use const (not enum)
855        let mut policy = CredentialPolicyRequest::with_chain(types::BASE);
856        policy.client_id = "test-client".to_string();
857
858        let request = build_verification_request(&policy);
859
860        // Should have response_type = "vp_token"
861        assert_eq!(request["response_type"], "vp_token");
862        assert_eq!(request["response_mode"], "direct_post");
863        assert_eq!(request["client_id"], "test-client");
864
865        // Should have SINGLE input_descriptor
866        let descriptors = &request["presentation_definition"]["input_descriptors"];
867        assert!(descriptors.is_array());
868        assert_eq!(descriptors.as_array().unwrap().len(), 1);
869        assert_eq!(descriptors[0]["id"], "credential");
870
871        // Single type should use const, not enum
872        let filter = &descriptors[0]["constraints"]["fields"][0]["filter"];
873        assert_eq!(filter["type"], "string");
874        assert_eq!(filter["const"], types::BASE);
875        assert!(
876            filter["enum"].is_null(),
877            "Single type should use const, not enum"
878        );
879    }
880
881    #[test]
882    fn test_build_verification_request_verified_email_uses_enum() {
883        // VerifiedEmail chain: [Base, VerifiedEmail]
884        let mut policy = CredentialPolicyRequest::with_chain(types::VERIFIED_EMAIL);
885        policy.client_id = "test-client".to_string();
886
887        let request = build_verification_request(&policy);
888
889        // Should have SINGLE input_descriptor (not multiple)
890        let descriptors = &request["presentation_definition"]["input_descriptors"];
891        let descriptors_arr = descriptors.as_array().unwrap();
892        assert_eq!(descriptors_arr.len(), 1);
893        assert_eq!(descriptors_arr[0]["id"], "credential");
894
895        // Should have enum filter with the full chain
896        let filter = &descriptors_arr[0]["constraints"]["fields"][0]["filter"];
897        assert_eq!(filter["type"], "string");
898        let enum_values = filter["enum"].as_array().expect("Should have enum filter");
899        assert_eq!(enum_values.len(), 2);
900        assert_eq!(enum_values[0], types::BASE);
901        assert_eq!(enum_values[1], types::VERIFIED_EMAIL);
902    }
903
904    #[test]
905    fn test_build_verification_request_employment_uses_enum() {
906        // Employment chain: [Base, Persona, Employment]
907        let mut policy = CredentialPolicyRequest::with_chain(types::EMPLOYMENT);
908        policy.client_id = "test-client".to_string();
909
910        let request = build_verification_request(&policy);
911
912        // Should have SINGLE input_descriptor
913        let descriptors = &request["presentation_definition"]["input_descriptors"];
914        let descriptors_arr = descriptors.as_array().unwrap();
915        assert_eq!(descriptors_arr.len(), 1);
916
917        // Should have enum filter with the full chain
918        let filter = &descriptors_arr[0]["constraints"]["fields"][0]["filter"];
919        let enum_values = filter["enum"].as_array().expect("Should have enum filter");
920        assert_eq!(enum_values.len(), 3);
921        assert_eq!(enum_values[0], types::BASE);
922        assert_eq!(enum_values[1], types::PERSONA);
923        assert_eq!(enum_values[2], types::EMPLOYMENT);
924    }
925
926    #[test]
927    fn test_build_verification_request_with_constraints() {
928        // VerifiedEmail chain with constraints
929        let mut policy = CredentialPolicyRequest::with_chain(types::VERIFIED_EMAIL);
930        policy.client_id = "test-client".to_string();
931        policy.add_constraint("domain", serde_json::json!({"pattern": ".*@example.com"}));
932
933        let request = build_verification_request(&policy);
934
935        // Single descriptor with vct + domain constraint
936        let descriptors = &request["presentation_definition"]["input_descriptors"];
937        let fields = descriptors[0]["constraints"]["fields"].as_array().unwrap();
938        assert_eq!(fields.len(), 2);
939
940        // First field: vct with enum
941        assert_eq!(fields[0]["path"][0], "$.vct");
942        assert!(fields[0]["filter"]["enum"].is_array());
943
944        // Second field: domain constraint
945        assert_eq!(fields[1]["path"][0], "$.domain");
946        assert_eq!(fields[1]["filter"]["pattern"], ".*@example.com");
947    }
948
949    #[test]
950    fn test_build_verification_request_no_extra_fields() {
951        let mut policy = CredentialPolicyRequest::with_chain(types::BASE);
952        policy.client_id = "test-client".to_string();
953
954        let request = build_verification_request(&policy);
955
956        let descriptor = &request["presentation_definition"]["input_descriptors"][0];
957
958        // Should NOT have format, name, or purpose fields
959        assert!(
960            descriptor["format"].is_null(),
961            "Should not have 'format' field"
962        );
963        assert!(descriptor["name"].is_null(), "Should not have 'name' field");
964        assert!(
965            descriptor["purpose"].is_null(),
966            "Should not have 'purpose' field"
967        );
968
969        // Should have id and constraints
970        assert_eq!(descriptor["id"], "credential");
971        assert!(!descriptor["constraints"].is_null());
972    }
973
974    #[test]
975    fn test_build_verification_request_with_callback() {
976        let mut policy = CredentialPolicyRequest::with_chain(types::BASE);
977        policy.client_id = "test-client".to_string();
978        policy.callback_url = Some("https://example.com/callback".to_string());
979
980        let request = build_verification_request(&policy);
981
982        assert_eq!(request["redirect_uri"], "https://example.com/callback");
983    }
984
985    #[test]
986    fn test_build_verification_request_static_id() {
987        let mut policy = CredentialPolicyRequest::with_chain(types::BASE);
988        policy.client_id = "test-client".to_string();
989
990        let request = build_verification_request(&policy);
991
992        // Should use static "sdk-request" ID (not UUID)
993        assert_eq!(request["presentation_definition"]["id"], "sdk-request");
994    }
995
996    #[test]
997    fn test_build_verification_request_with_state() {
998        let policy =
999            CredentialPolicyRequest::with_chain(types::BASE).with_state("csrf-token-12345");
1000
1001        let request = build_verification_request(&policy);
1002
1003        // State should be a top-level field (NOT in presentation_definition)
1004        assert_eq!(request["state"], "csrf-token-12345");
1005
1006        // Verify state is NOT in the field constraints
1007        let fields =
1008            &request["presentation_definition"]["input_descriptors"][0]["constraints"]["fields"];
1009        for field in fields.as_array().unwrap() {
1010            let path = field["path"][0].as_str().unwrap();
1011            assert_ne!(path, "$.state", "state should NOT be a field constraint");
1012        }
1013    }
1014
1015    #[test]
1016    fn test_build_verification_request_with_nonce() {
1017        let policy = CredentialPolicyRequest::with_chain(types::BASE).with_nonce("nonce-67890");
1018
1019        let request = build_verification_request(&policy);
1020
1021        // Nonce should be a top-level field (NOT in presentation_definition)
1022        assert_eq!(request["nonce"], "nonce-67890");
1023
1024        // Verify nonce is NOT in the field constraints
1025        let fields =
1026            &request["presentation_definition"]["input_descriptors"][0]["constraints"]["fields"];
1027        for field in fields.as_array().unwrap() {
1028            let path = field["path"][0].as_str().unwrap();
1029            assert_ne!(path, "$.nonce", "nonce should NOT be a field constraint");
1030        }
1031    }
1032
1033    #[test]
1034    fn test_build_verification_request_with_all_openid4vp_fields() {
1035        let policy = CredentialPolicyRequest::with_chain(types::BASE)
1036            .with_callback("https://example.com/callback")
1037            .with_state("csrf-token")
1038            .with_nonce("binding-nonce");
1039
1040        let request = build_verification_request(&policy);
1041
1042        // All OpenID4VP fields should be at top level
1043        assert_eq!(request["redirect_uri"], "https://example.com/callback");
1044        assert_eq!(request["state"], "csrf-token");
1045        assert_eq!(request["nonce"], "binding-nonce");
1046        assert_eq!(request["response_type"], "vp_token");
1047        assert_eq!(request["response_mode"], "direct_post");
1048    }
1049
1050    #[test]
1051    fn test_build_verification_request_without_optional_fields() {
1052        let mut policy = CredentialPolicyRequest::with_chain(types::BASE);
1053        policy.client_id = "test-client".to_string();
1054        // Don't set callback_url, state, or nonce
1055
1056        let request = build_verification_request(&policy);
1057
1058        // Optional fields should not be present
1059        assert!(request.get("redirect_uri").is_none() || request["redirect_uri"].is_null());
1060        assert!(request.get("state").is_none() || request["state"].is_null());
1061        assert!(request.get("nonce").is_none() || request["nonce"].is_null());
1062
1063        // Required fields should still be present
1064        assert_eq!(request["client_id"], "test-client");
1065        assert_eq!(request["response_type"], "vp_token");
1066    }
1067
1068    #[test]
1069    fn test_build_verification_request_with_required_and_optional_fields() {
1070        let mut policy = CredentialPolicyRequest::with_chain(types::PERSONA);
1071        policy.client_id = "test-client".to_string();
1072        policy.required_fields = Some(vec!["display_name".to_string()]);
1073        policy.optional_fields = Some(vec!["email".to_string(), "phone".to_string()]);
1074
1075        let request = build_verification_request(&policy);
1076
1077        let fields =
1078            &request["presentation_definition"]["input_descriptors"][0]["constraints"]["fields"]
1079                .as_array()
1080                .unwrap();
1081
1082        // Should have vct + 1 required + 2 optional = 4 fields
1083        assert_eq!(fields.len(), 4);
1084
1085        // First field: vct filter
1086        assert_eq!(fields[0]["path"][0], "$.vct");
1087        assert!(fields[0]["filter"]["enum"].is_array());
1088
1089        // Second field: required display_name (no optional flag)
1090        assert_eq!(fields[1]["path"][0], "$.display_name");
1091        assert!(
1092            fields[1].get("optional").is_none() || fields[1]["optional"].is_null(),
1093            "Required field should not have optional flag"
1094        );
1095
1096        // Third field: optional email
1097        assert_eq!(fields[2]["path"][0], "$.email");
1098        assert_eq!(fields[2]["optional"], true);
1099
1100        // Fourth field: optional phone
1101        assert_eq!(fields[3]["path"][0], "$.phone");
1102        assert_eq!(fields[3]["optional"], true);
1103    }
1104
1105    #[test]
1106    fn test_build_verification_request_skips_deprecated_requested_fields() {
1107        let mut policy = CredentialPolicyRequest::with_chain(types::PERSONA);
1108        policy.client_id = "test-client".to_string();
1109        // Add deprecated requestedFields in constraints (should be skipped)
1110        policy.add_constraint(
1111            "requestedFields",
1112            serde_json::json!(["old_field1", "old_field2"]),
1113        );
1114        // Add new required_fields
1115        policy.required_fields = Some(vec!["display_name".to_string()]);
1116
1117        let request = build_verification_request(&policy);
1118
1119        let fields =
1120            &request["presentation_definition"]["input_descriptors"][0]["constraints"]["fields"]
1121                .as_array()
1122                .unwrap();
1123
1124        // Should have vct + display_name only (requestedFields in constraints is skipped)
1125        assert_eq!(fields.len(), 2);
1126        assert_eq!(fields[0]["path"][0], "$.vct");
1127        assert_eq!(fields[1]["path"][0], "$.display_name");
1128
1129        // Verify no requestedFields filter was added
1130        for field in fields.iter() {
1131            let path = field["path"][0].as_str().unwrap();
1132            assert_ne!(
1133                path, "$.requestedFields",
1134                "requestedFields should not appear as a constraint field"
1135            );
1136        }
1137    }
1138}