1#![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
24pub 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 pub fn new(config: SDKConfig) -> Result<Self, LastIDError> {
64 Self::with_optional_keypair(config, None)
65 }
66
67 pub fn with_optional_keypair(
80 config: SDKConfig,
81 keypair: Option<DPoPKeyPair>,
82 ) -> Result<Self, LastIDError> {
83 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 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 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 #[must_use]
123 pub const fn builder() -> super::ClientBuilder<super::builder::NoConfig> {
124 super::ClientBuilder::new()
125 }
126
127 #[must_use]
129 pub const fn config(&self) -> &SDKConfig {
130 &self.config
131 }
132
133 #[must_use]
140 pub fn dpop_thumbprint(&self) -> &str {
141 use crate::auth::TokenManagerTrait;
142 self.token_manager.thumbprint()
143 }
144
145 #[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 let mut policy_request = policy.to_request()?;
169 policy_request.client_id.clone_from(&self.config.client_id);
170
171 #[cfg(feature = "tracing")]
173 debug!("Acquiring OAuth token for authenticated request");
174
175 let access_token = self.token_manager.get_token().await?;
176
177 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 let vp = VerifiablePresentation::parse(presentation)?;
415
416 let issuer_did = vp.issuer_did()?;
418
419 #[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 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 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 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 let now = chrono::Utc::now();
478 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 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 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 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 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 let config = test_config();
585 assert_eq!(config.clock_skew_seconds, 60);
587 }
588}