Skip to main content

lastid_sdk/client/
lastid_client.rs

1//! Main `LastID` client 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
6#[cfg(not(target_arch = "wasm32"))]
7use std::time::Duration;
8
9use chrono::{TimeZone, Utc};
10#[cfg(feature = "tracing")]
11use tracing::{debug, info, instrument, warn};
12
13use crate::auth::{TokenManager, TokenManagerConfig, TokenManagerTrait};
14use crate::config::SDKConfig;
15use crate::crypto::DPoPKeyPair;
16use crate::error::LastIDError;
17use crate::http::{HttpIdpClient, IdpClient};
18use crate::policies::PolicyBuilder;
19use crate::shared::SharedPtr;
20use crate::trust_registry::{IssuerInfo, TrustRegistry, TrustRegistryClient};
21use crate::types::{CredentialRequestResponse, RequestId, RequestStatus, VerifiedCredential};
22use crate::verification::VerifiablePresentation;
23
24/// Main SDK client for `LastID` IDP integration.
25///
26/// Handles credential requests, polling, verification, and trust registry
27/// validation. Requires a caller-provided tokio runtime (native) or runs on
28/// wasm-bindgen-futures (wasm).
29///
30/// # Example
31///
32/// ```rust,no_run
33/// use lastid_sdk::policies::BaseCredentialPolicy;
34/// use lastid_sdk::ClientBuilder;
35///
36/// # async fn example() -> Result<(), lastid_sdk::LastIDError> {
37/// let client = ClientBuilder::new().with_auto_config()?.build()?;
38///
39/// let policy = BaseCredentialPolicy::new()
40///     .with_callback("https://example.com/callback");
41///
42/// let response = client.request_credential(policy).await?;
43/// println!("Request ID: {}", response.request_id);
44/// # Ok(())
45/// # }
46/// ```
47pub struct LastIDClient {
48    config: SDKConfig,
49    idp_client: SharedPtr<dyn IdpClient>,
50    trust_registry: SharedPtr<dyn TrustRegistry>,
51    token_manager: SharedPtr<TokenManager>,
52}
53
54impl LastIDClient {
55    /// Create a new client from configuration.
56    ///
57    /// # Errors
58    ///
59    /// Returns `LastIDError` if:
60    /// - HTTP client creation fails
61    /// - Trust registry client creation fails
62    /// - Token manager creation fails
63    pub fn new(config: SDKConfig) -> Result<Self, LastIDError> {
64        Self::with_optional_keypair(config, None)
65    }
66
67    /// Create a new client with an optional pre-generated `DPoP` keypair.
68    ///
69    /// This is useful for serverless deployments where the keypair needs to
70    /// persist across invocations. If `keypair` is `None`, a new keypair is
71    /// generated.
72    ///
73    /// # Errors
74    ///
75    /// Returns `LastIDError` if:
76    /// - HTTP client creation fails
77    /// - Trust registry client creation fails
78    /// - Token manager creation fails
79    pub fn with_optional_keypair(
80        config: SDKConfig,
81        keypair: Option<DPoPKeyPair>,
82    ) -> Result<Self, LastIDError> {
83        // Create HTTP client for IDP with full network configuration
84        // This includes proxy settings, timeouts, connection pooling, and correlation
85        // IDs
86        let idp_client = HttpIdpClient::with_network_config(
87            config.idp_endpoint.clone(),
88            &config.network,
89            config.retry.clone(),
90        )
91        .map_err(|e| LastIDError::config(format!("Failed to create IDP client: {e}")))?;
92
93        // Create trust registry client
94        let trust_registry_endpoint = config.effective_trust_registry_endpoint();
95        let trust_registry =
96            TrustRegistryClient::new(trust_registry_endpoint, &config.cache, config.retry.clone())
97                .map_err(|e| {
98                    LastIDError::config(format!("Failed to create trust registry client: {e}"))
99                })?;
100
101        // Create token manager for OAuth authentication
102        let token_manager_config = TokenManagerConfig {
103            client_id: config.client_id.clone(),
104            refresh_buffer_seconds: 30,
105        };
106        let token_manager = TokenManager::with_optional_keypair(
107            &config.idp_endpoint,
108            token_manager_config,
109            keypair,
110        )
111        .map_err(|e| LastIDError::config(format!("Failed to create token manager: {e}")))?;
112
113        Ok(Self {
114            config,
115            idp_client: SharedPtr::new(idp_client),
116            trust_registry: SharedPtr::new(trust_registry),
117            token_manager: SharedPtr::new(token_manager),
118        })
119    }
120
121    /// Create a builder for configuring the client.
122    #[must_use]
123    pub const fn builder() -> super::ClientBuilder<super::builder::NoConfig> {
124        super::ClientBuilder::new()
125    }
126
127    /// Get the SDK configuration.
128    #[must_use]
129    pub const fn config(&self) -> &SDKConfig {
130        &self.config
131    }
132
133    /// Get the `DPoP` key thumbprint for this client.
134    ///
135    /// The thumbprint is a base64url-encoded SHA-256 hash of the public key,
136    /// used to bind access tokens to this client's `DPoP` keypair.
137    /// This is useful for debugging and verifying key persistence in serverless
138    /// deployments.
139    #[must_use]
140    pub fn dpop_thumbprint(&self) -> &str {
141        use crate::auth::TokenManagerTrait;
142        self.token_manager.thumbprint()
143    }
144
145    /// Request a credential presentation from the IDP.
146    /// Returns `CredentialRequestResponse` with `request_uri` for QR code
147    /// display.
148    #[cfg_attr(
149        feature = "tracing",
150        instrument(skip(self, policy), fields(credential_type))
151    )]
152    pub async fn request_credential<P>(
153        &self,
154        policy: P,
155    ) -> Result<CredentialRequestResponse, LastIDError>
156    where
157        P: PolicyBuilder,
158    {
159        #[cfg(feature = "tracing")]
160        {
161            tracing::Span::current().record("credential_type", policy.credential_type());
162            info!(
163                credential_type = policy.credential_type(),
164                "Starting credential request"
165            );
166        }
167        // Build policy request and inject client_id from SDK config
168        let mut policy_request = policy.to_request()?;
169        policy_request.client_id.clone_from(&self.config.client_id);
170
171        // Get DPoP-bound access token from token manager
172        #[cfg(feature = "tracing")]
173        debug!("Acquiring OAuth token for authenticated request");
174
175        let access_token = self.token_manager.get_token().await?;
176
177        // Generate DPoP proof for the protected request with ath (access token hash)
178        // claim
179        let request_url = format!("{}/v1/verify/request", self.config.idp_endpoint);
180        let dpop_proof =
181            self.token_manager
182                .create_dpop_proof("POST", &request_url, Some(&access_token))?;
183
184        // Send request with DPoP-bound token and proof
185        #[cfg(feature = "tracing")]
186        debug!("Sending credential request to IDP with DPoP proof");
187
188        let response = self
189            .idp_client
190            .request_credential(policy_request, &access_token, &dpop_proof)
191            .await?;
192
193        #[cfg(feature = "tracing")]
194        info!(
195            request_id = %response.request_id,
196            request_uri = %response.request_uri,
197            expires_in = response.expires_in,
198            "Credential request created successfully"
199        );
200
201        Ok(response)
202    }
203
204    /// Poll for credential request status once. Cancellation-safe.
205    #[cfg_attr(feature = "tracing", instrument(skip(self), fields(request_id = %request_id)))]
206    pub async fn poll_request(&self, request_id: &RequestId) -> Result<RequestStatus, LastIDError> {
207        #[cfg(feature = "tracing")]
208        debug!("Polling request status");
209
210        let status = self
211            .idp_client
212            .poll_status(request_id, &self.config.client_id)
213            .await?;
214
215        #[cfg(feature = "tracing")]
216        debug!(status = ?status, "Received status");
217
218        Ok(status)
219    }
220
221    /// Poll for credential completion with timeout and exponential backoff.
222    /// Cancellation-safe: dropping the future stops polling cleanly.
223    #[cfg(not(target_arch = "wasm32"))]
224    #[cfg_attr(feature = "tracing", instrument(skip(self), fields(request_id = %request_id)))]
225    #[allow(clippy::cast_possible_truncation)]
226    pub async fn poll_for_completion(
227        &self,
228        request_id: &RequestId,
229    ) -> Result<RequestStatus, LastIDError> {
230        let start = std::time::Instant::now();
231        let timeout = Duration::from_secs(self.config.polling.max_duration_seconds);
232        let mut interval = Duration::from_millis(self.config.polling.initial_interval_ms);
233
234        #[cfg(feature = "tracing")]
235        info!(
236            timeout_seconds = self.config.polling.max_duration_seconds,
237            "Starting poll"
238        );
239
240        loop {
241            if start.elapsed() >= timeout {
242                #[cfg(feature = "tracing")]
243                warn!(elapsed = ?start.elapsed(), "Polling timeout");
244                return Err(LastIDError::Timeout(start.elapsed().as_secs()));
245            }
246
247            let status = self.poll_request(request_id).await?;
248            if status.is_terminal() {
249                #[cfg(feature = "tracing")]
250                info!(status = ?status, "Terminal state reached");
251                return Ok(status);
252            }
253
254            #[cfg(feature = "tracing")]
255            debug!(interval_ms = interval.as_millis() as u64, "Waiting");
256
257            tokio::time::sleep(interval).await;
258            interval = Duration::from_millis(
259                self.config
260                    .polling
261                    .next_interval(interval.as_millis() as u64),
262            );
263        }
264    }
265
266    /// Poll for credential completion with timeout (WASM version).
267    #[cfg(target_arch = "wasm32")]
268    #[cfg_attr(feature = "tracing", instrument(skip(self), fields(request_id = %request_id)))]
269    #[allow(
270        clippy::cast_precision_loss,
271        clippy::cast_possible_truncation,
272        clippy::cast_sign_loss
273    )]
274    pub async fn poll_for_completion(
275        &self,
276        request_id: &RequestId,
277    ) -> Result<RequestStatus, LastIDError> {
278        let start_ms = js_sys::Date::now();
279        let timeout_ms = self.config.polling.max_duration_seconds as f64 * 1000.0;
280        let mut interval_ms = self.config.polling.initial_interval_ms;
281
282        loop {
283            let elapsed_ms = js_sys::Date::now() - start_ms;
284            if elapsed_ms >= timeout_ms {
285                return Err(LastIDError::Timeout((elapsed_ms / 1000.0) as u64));
286            }
287
288            let status = self.poll_request(request_id).await?;
289            if status.is_terminal() {
290                return Ok(status);
291            }
292
293            wasm_bindgen_futures::JsFuture::from(js_sys::Promise::new(&mut |resolve, _| {
294                web_sys::window()
295                    .expect("no window")
296                    .set_timeout_with_callback_and_timeout_and_arguments_0(
297                        &resolve,
298                        interval_ms as i32,
299                    )
300                    .expect("setTimeout failed");
301            }))
302            .await
303            .expect("setTimeout promise failed");
304
305            interval_ms = self.config.polling.next_interval(interval_ms);
306        }
307    }
308
309    /// Subscribe via WebSocket for instant status updates.
310    /// Falls back to HTTP polling if WebSocket fails.
311    #[cfg(all(feature = "websocket", not(target_arch = "wasm32")))]
312    #[cfg_attr(feature = "tracing", instrument(skip(self), fields(request_id = %request_id)))]
313    pub async fn subscribe_for_completion(
314        &self,
315        request_id: &RequestId,
316    ) -> Result<RequestStatus, LastIDError> {
317        use crate::http::websocket::{StatusSubscription, create_transport};
318
319        if !self.config.websocket.enabled {
320            return self.poll_for_completion(request_id).await;
321        }
322
323        let Ok(transport) = create_transport(
324            &self.config.idp_endpoint,
325            request_id,
326            &self.config.websocket,
327        ) else {
328            #[cfg(feature = "tracing")]
329            warn!(request_id = %request_id, "WebSocket transport creation failed, falling back to HTTP polling");
330            return self.poll_for_completion(request_id).await;
331        };
332
333        let mut sub =
334            StatusSubscription::new(transport, self.config.websocket.clone(), request_id.clone());
335        if sub.connect().await.is_err() {
336            #[cfg(feature = "tracing")]
337            warn!(request_id = %request_id, "WebSocket connection failed, falling back to HTTP polling");
338            return self.poll_for_completion(request_id).await;
339        }
340
341        match sub.wait_for_completion().await {
342            Ok(status) => {
343                let _ = sub.disconnect().await;
344                Ok(status)
345            }
346            Err(LastIDError::WebSocket(crate::error::WebSocketError::ReconnectionExhausted {
347                ..
348            })) => {
349                #[cfg(feature = "tracing")]
350                warn!(request_id = %request_id, "WebSocket reconnection exhausted, falling back to HTTP polling");
351                self.poll_for_completion(request_id).await
352            }
353            Err(e) => Err(e),
354        }
355    }
356
357    /// Subscribe via WebSocket (WASM version).
358    #[cfg(all(feature = "websocket", target_arch = "wasm32"))]
359    #[cfg_attr(feature = "tracing", instrument(skip(self), fields(request_id = %request_id)))]
360    pub async fn subscribe_for_completion(
361        &self,
362        request_id: &RequestId,
363    ) -> Result<RequestStatus, LastIDError> {
364        use crate::http::websocket::{StatusSubscription, create_transport};
365
366        if !self.config.websocket.enabled {
367            return self.poll_for_completion(request_id).await;
368        }
369
370        let Ok(transport) = create_transport(
371            &self.config.idp_endpoint,
372            request_id,
373            &self.config.websocket,
374        ) else {
375            #[cfg(feature = "tracing")]
376            warn!(request_id = %request_id, "WebSocket transport creation failed, falling back to HTTP polling");
377            return self.poll_for_completion(request_id).await;
378        };
379
380        let mut sub =
381            StatusSubscription::new(transport, self.config.websocket.clone(), request_id.clone());
382        if sub.connect().await.is_err() {
383            #[cfg(feature = "tracing")]
384            warn!(request_id = %request_id, "WebSocket connection failed, falling back to HTTP polling");
385            return self.poll_for_completion(request_id).await;
386        }
387
388        match sub.wait_for_completion().await {
389            Ok(status) => {
390                let _ = sub.disconnect().await;
391                Ok(status)
392            }
393            Err(LastIDError::WebSocket(crate::error::WebSocketError::ReconnectionExhausted {
394                ..
395            })) => {
396                #[cfg(feature = "tracing")]
397                warn!(request_id = %request_id, "WebSocket reconnection exhausted, falling back to HTTP polling");
398                self.poll_for_completion(request_id).await
399            }
400            Err(e) => Err(e),
401        }
402    }
403
404    /// Verify a credential presentation (SD-JWT).
405    /// Validates issuer against trust registry and extracts claims.
406    #[cfg_attr(feature = "tracing", instrument(skip(self, presentation)))]
407    pub async fn verify_presentation(
408        &self,
409        presentation: &str,
410    ) -> Result<VerifiedCredential, LastIDError> {
411        #[cfg(feature = "tracing")]
412        info!("Verifying presentation");
413        // Parse SD-JWT
414        let vp = VerifiablePresentation::parse(presentation)?;
415
416        // Extract issuer DID
417        let issuer_did = vp.issuer_did()?;
418
419        // Validate issuer via trust registry
420        #[cfg(feature = "tracing")]
421        debug!(
422            issuer_did = issuer_did,
423            "Validating issuer against trust registry"
424        );
425
426        let issuer_info = self.trust_registry.validate_issuer(issuer_did).await?;
427
428        #[cfg(feature = "tracing")]
429        debug!(
430            issuer_status = ?issuer_info.status,
431            organization = %issuer_info.organization_name,
432            "Issuer validated"
433        );
434
435        // Validate credential type against issuer's permitted types
436        let credential_type = vp.credential_type()?;
437        if !issuer_info.can_issue(credential_type) {
438            #[cfg(feature = "tracing")]
439            warn!(
440                credential_type = credential_type,
441                permitted_types = ?issuer_info.permitted_types,
442                "Issuer is not authorized to issue this credential type"
443            );
444            return Err(LastIDError::invalid_credential(format!(
445                "Issuer '{}' is not authorized to issue '{}' credentials. Permitted types: {:?}",
446                issuer_did, credential_type, issuer_info.permitted_types
447            )));
448        }
449
450        #[cfg(feature = "tracing")]
451        debug!(
452            credential_type = credential_type,
453            "Credential type is permitted for this issuer"
454        );
455
456        // Verify JWT signature with issuer's public key
457        let public_key_jwk = serde_json::to_value(&issuer_info.public_key).map_err(|e| {
458            LastIDError::invalid_credential(format!("Invalid public key format: {e}"))
459        })?;
460
461        vp.verify_signature(&public_key_jwk)?;
462
463        #[cfg(feature = "tracing")]
464        info!("JWT signature verified successfully with issuer's public key");
465
466        // Extract timestamps
467        let issued_at = Utc
468            .timestamp_opt(vp.issued_at()?, 0)
469            .single()
470            .ok_or_else(|| LastIDError::invalid_credential("Invalid issued_at timestamp"))?;
471        let expires_at = Utc
472            .timestamp_opt(vp.expires_at()?, 0)
473            .single()
474            .ok_or_else(|| LastIDError::invalid_credential("Invalid expires_at timestamp"))?;
475
476        // SEC-005: Check expiration with clock skew tolerance
477        let now = chrono::Utc::now();
478        // Clock skew is typically small (30-300s), safe to convert
479        let clock_skew_secs = i64::try_from(self.config.clock_skew_seconds).unwrap_or(i64::MAX);
480        let clock_skew = chrono::TimeDelta::seconds(clock_skew_secs);
481
482        // nbf (not-before) validation with clock skew
483        if now + clock_skew < issued_at {
484            #[cfg(feature = "tracing")]
485            warn!(
486                issued_at = %issued_at,
487                now = %now,
488                clock_skew_seconds = self.config.clock_skew_seconds,
489                "Credential not yet valid (issued_at in the future)"
490            );
491            return Err(LastIDError::clock_skew_exceeded(
492                self.config.clock_skew_seconds,
493            ));
494        }
495
496        // exp (expiration) validation with clock skew
497        if now - clock_skew >= expires_at {
498            #[cfg(feature = "tracing")]
499            warn!(
500                expires_at = %expires_at,
501                now = %now,
502                clock_skew_seconds = self.config.clock_skew_seconds,
503                "Credential has expired"
504            );
505            return Err(LastIDError::invalid_credential("Credential has expired"));
506        }
507
508        // Reconstruct claims
509        let claims = vp.reconstruct_claims();
510
511        #[cfg(feature = "tracing")]
512        info!(
513            subject_did = vp.subject_did().unwrap_or("unknown"),
514            credential_type = vp.credential_type().unwrap_or("unknown"),
515            "Presentation verified successfully"
516        );
517
518        Ok(VerifiedCredential {
519            subject_did: vp.subject_did()?.to_string(),
520            issuer_did: issuer_did.to_string(),
521            credential_type: credential_type.to_string(),
522            claims,
523            issued_at,
524            expires_at,
525            issuer_status: issuer_info.status,
526            signature_verified: true,
527        })
528    }
529
530    /// Validate an issuer against the trust registry.
531    ///
532    /// # Errors
533    ///
534    /// * `LastIDError::TrustRegistry` - Issuer not found or invalid status
535    pub async fn validate_issuer(&self, issuer_did: &str) -> Result<IssuerInfo, LastIDError> {
536        let info = self.trust_registry.validate_issuer(issuer_did).await?;
537        Ok(info)
538    }
539}
540
541impl Clone for LastIDClient {
542    fn clone(&self) -> Self {
543        Self {
544            config: self.config.clone(),
545            idp_client: SharedPtr::clone(&self.idp_client),
546            trust_registry: SharedPtr::clone(&self.trust_registry),
547            token_manager: SharedPtr::clone(&self.token_manager),
548        }
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555
556    fn test_config() -> SDKConfig {
557        SDKConfig::builder()
558            .idp_endpoint("https://test.lastid.co")
559            .client_id("test-client")
560            .build()
561            .unwrap()
562    }
563
564    #[test]
565    fn test_client_creation() {
566        let config = test_config();
567        let client = LastIDClient::new(config);
568        assert!(client.is_ok());
569    }
570
571    #[test]
572    fn test_client_clone() {
573        let config = test_config();
574        let client = LastIDClient::new(config).unwrap();
575        let cloned = client.clone();
576
577        assert_eq!(client.config().idp_endpoint, cloned.config().idp_endpoint);
578        assert_eq!(client.config().client_id, cloned.config().client_id);
579    }
580
581    #[test]
582    fn test_clock_skew_config() {
583        // Test that clock_skew_seconds is accessible in config
584        let config = test_config();
585        // Default is 60 seconds
586        assert_eq!(config.clock_skew_seconds, 60);
587    }
588}