1use baseid_crypto::Signer;
7
8use crate::client::HttpClient;
9use crate::credential::{
10 CredentialRequest, CredentialResponse, ProofOfPossession, PRE_AUTHORIZED_CODE_GRANT_TYPE,
11};
12use crate::credential_offer::CredentialOffer;
13use crate::error::Oid4vciError;
14use crate::token::{NonceResponse, TokenResponse};
15use crate::{metadata, IssuerMetadata};
16
17pub struct Oid4vciWallet<'a, C: HttpClient> {
19 client: &'a C,
20 signer: &'a dyn Signer,
21 holder_did: String,
22}
23
24impl<'a, C: HttpClient> Oid4vciWallet<'a, C> {
25 pub fn new(client: &'a C, signer: &'a dyn Signer, holder_did: String) -> Self {
27 Self {
28 client,
29 signer,
30 holder_did,
31 }
32 }
33
34 pub async fn accept_offer(
43 &self,
44 offer: &CredentialOffer,
45 tx_code: Option<&str>,
46 ) -> baseid_core::Result<CredentialResponse> {
47 let metadata = metadata::discover(self.client, &offer.credential_issuer).await?;
49
50 let pre_auth_grant = offer
52 .grants
53 .as_ref()
54 .and_then(|g| g.pre_authorized_code.as_ref())
55 .ok_or(Oid4vciError::UnsupportedGrantType)?;
56
57 let token_response = self
59 .exchange_token(&metadata, &pre_auth_grant.pre_authorized_code, tx_code)
60 .await?;
61
62 let c_nonce = if let Some(nonce) = token_response.c_nonce.clone() {
64 Some(nonce)
65 } else if let Some(ref nonce_url) = metadata.nonce_endpoint {
66 self.fetch_nonce(nonce_url).await.ok().map(|r| r.c_nonce)
67 } else {
68 None
69 };
70
71 let proof = if let Some(ref nonce) = c_nonce {
73 let jwt = crate::proof::create_proof_jwt(
74 self.signer,
75 &offer.credential_issuer,
76 nonce,
77 &self.holder_did,
78 )?;
79 Some(ProofOfPossession {
80 proof_type: "jwt".to_string(),
81 jwt,
82 })
83 } else {
84 None
85 };
86
87 let config_id = offer
89 .credential_configuration_ids
90 .first()
91 .cloned()
92 .unwrap_or_else(|| "jwt_vc_json".to_string());
93
94 self.request_credential(&metadata, &token_response.access_token, &config_id, proof)
95 .await
96 }
97
98 async fn fetch_nonce(&self, nonce_url: &str) -> baseid_core::Result<NonceResponse> {
100 let json =
101 self.client
102 .post_form(nonce_url, &[])
103 .await
104 .map_err(|e| -> baseid_core::Error {
105 Oid4vciError::TokenRequestFailed(format!("nonce endpoint failed: {e}")).into()
106 })?;
107
108 serde_json::from_value(json).map_err(|e| -> baseid_core::Error {
109 Oid4vciError::TokenRequestFailed(format!("invalid nonce response: {e}")).into()
110 })
111 }
112
113 async fn exchange_token(
115 &self,
116 metadata: &IssuerMetadata,
117 pre_authorized_code: &str,
118 tx_code: Option<&str>,
119 ) -> baseid_core::Result<TokenResponse> {
120 let token_endpoint = metadata
121 .token_endpoint
122 .clone()
123 .or_else(|| {
124 metadata
125 .authorization_server
126 .as_deref()
127 .map(|s| format!("{}/token", s.trim_end_matches('/')))
128 })
129 .unwrap_or_else(|| {
130 format!("{}/token", metadata.credential_issuer.trim_end_matches('/'))
131 });
132
133 let mut params = vec![
134 ("grant_type", PRE_AUTHORIZED_CODE_GRANT_TYPE),
135 ("pre-authorized_code", pre_authorized_code),
136 ];
137 if let Some(code) = tx_code {
138 params.push(("tx_code", code));
139 }
140
141 let json = self
142 .client
143 .post_form(&token_endpoint, ¶ms)
144 .await
145 .map_err(|e| -> baseid_core::Error {
146 Oid4vciError::TokenRequestFailed(e.to_string()).into()
147 })?;
148
149 serde_json::from_value(json).map_err(|e| -> baseid_core::Error {
150 Oid4vciError::TokenRequestFailed(e.to_string()).into()
151 })
152 }
153
154 async fn request_credential(
156 &self,
157 metadata: &IssuerMetadata,
158 access_token: &str,
159 credential_configuration_id: &str,
160 proof: Option<ProofOfPossession>,
161 ) -> baseid_core::Result<CredentialResponse> {
162 let cred_request = CredentialRequest {
163 credential_configuration_id: Some(credential_configuration_id.to_string()),
164 credential_identifier: None,
165 format: None,
166 credential_definition: None,
167 proof,
168 };
169
170 let body = serde_json::to_value(&cred_request).map_err(|e| -> baseid_core::Error {
171 Oid4vciError::CredentialRequestFailed(e.to_string()).into()
172 })?;
173
174 let json = self
175 .client
176 .post_json_bearer(&metadata.credential_endpoint, &body, access_token)
177 .await
178 .map_err(|e| -> baseid_core::Error {
179 Oid4vciError::CredentialRequestFailed(e.to_string()).into()
180 })?;
181
182 if let Some(error) = json.get("error").and_then(|v| v.as_str()) {
184 let error_description = json
185 .get("error_description")
186 .and_then(|v| v.as_str())
187 .unwrap_or("no description");
188 return Err(Oid4vciError::ServerError {
189 error: error.to_string(),
190 error_description: error_description.to_string(),
191 }
192 .into());
193 }
194
195 serde_json::from_value(json).map_err(|e| -> baseid_core::Error {
196 Oid4vciError::CredentialRequestFailed(e.to_string()).into()
197 })
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use crate::client::HttpClient;
205 use crate::credential_offer::{Grants, PreAuthorizedCodeGrant};
206 use baseid_core::types::KeyType;
207 use baseid_crypto::KeyPair;
208 use std::sync::Mutex;
209
210 struct MockSequenceClient {
212 responses: Mutex<Vec<serde_json::Value>>,
213 }
214
215 impl MockSequenceClient {
216 fn new(responses: Vec<serde_json::Value>) -> Self {
217 let mut responses = responses;
218 responses.reverse();
219 Self {
220 responses: Mutex::new(responses),
221 }
222 }
223
224 fn next_response(&self) -> baseid_core::Result<serde_json::Value> {
225 self.responses
226 .lock()
227 .unwrap()
228 .pop()
229 .ok_or_else(|| baseid_core::error::ProtocolError::InvalidResponse.into())
230 }
231 }
232
233 impl HttpClient for MockSequenceClient {
234 async fn get_json(&self, _url: &str) -> baseid_core::Result<serde_json::Value> {
235 self.next_response()
236 }
237 async fn post_form(
238 &self,
239 _url: &str,
240 _params: &[(&str, &str)],
241 ) -> baseid_core::Result<serde_json::Value> {
242 self.next_response()
243 }
244 async fn post_json_bearer(
245 &self,
246 _url: &str,
247 _body: &serde_json::Value,
248 _token: &str,
249 ) -> baseid_core::Result<serde_json::Value> {
250 self.next_response()
251 }
252 async fn post_json(
253 &self,
254 _url: &str,
255 _body: &serde_json::Value,
256 ) -> baseid_core::Result<serde_json::Value> {
257 self.next_response()
258 }
259 async fn get(
260 &self,
261 _url: &str,
262 ) -> baseid_core::Result<baseid_transport::http::HttpResponse> {
263 unimplemented!()
264 }
265 async fn post_raw(
266 &self,
267 _url: &str,
268 _body: &[u8],
269 _content_type: &str,
270 ) -> baseid_core::Result<baseid_transport::http::HttpResponse> {
271 unimplemented!()
272 }
273 }
274
275 fn test_offer() -> CredentialOffer {
276 CredentialOffer {
277 credential_issuer: "https://issuer.example.com".to_string(),
278 credential_configuration_ids: vec!["UniversityDegree".to_string()],
279 grants: Some(Grants {
280 authorization_code: None,
281 pre_authorized_code: Some(PreAuthorizedCodeGrant {
282 pre_authorized_code: "pre-auth-code-123".to_string(),
283 tx_code: None,
284 }),
285 }),
286 }
287 }
288
289 fn metadata_json() -> serde_json::Value {
290 serde_json::json!({
291 "credential_issuer": "https://issuer.example.com",
292 "credential_endpoint": "https://issuer.example.com/credential",
293 "credential_configurations_supported": {
294 "UniversityDegree": {
295 "format": "jwt_vc_json"
296 }
297 }
298 })
299 }
300
301 fn token_json() -> serde_json::Value {
302 serde_json::json!({
303 "access_token": "access-token-xyz",
304 "token_type": "Bearer",
305 "c_nonce": "server-nonce-456",
306 "c_nonce_expires_in": 300
307 })
308 }
309
310 fn credential_json() -> serde_json::Value {
311 serde_json::json!({
312 "credentials": [
313 { "credential": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..." }
314 ]
315 })
316 }
317
318 #[tokio::test]
319 async fn token_exchange_pre_auth() {
320 let client = MockSequenceClient::new(vec![token_json()]);
321 let kp = KeyPair::generate(KeyType::P256).unwrap();
322 let wallet = Oid4vciWallet::new(&client, &kp, "did:key:z6Mk...".to_string());
323 let metadata = IssuerMetadata {
324 credential_issuer: "https://issuer.example.com".to_string(),
325 authorization_server: None,
326 credential_endpoint: "https://issuer.example.com/credential".to_string(),
327 token_endpoint: None,
328 nonce_endpoint: None,
329 deferred_credential_endpoint: None,
330 notification_endpoint: None,
331 credential_configurations_supported: Default::default(),
332 };
333 let token = wallet
334 .exchange_token(&metadata, "pre-auth-code-123", None)
335 .await
336 .unwrap();
337 assert_eq!(token.access_token, "access-token-xyz");
338 assert_eq!(token.c_nonce.as_deref(), Some("server-nonce-456"));
339 }
340
341 #[tokio::test]
342 async fn credential_request_with_proof() {
343 let client = MockSequenceClient::new(vec![credential_json()]);
344 let kp = KeyPair::generate(KeyType::P256).unwrap();
345 let wallet = Oid4vciWallet::new(&client, &kp, "did:key:z6Mk...".to_string());
346
347 let metadata = IssuerMetadata {
348 credential_issuer: "https://issuer.example.com".to_string(),
349 authorization_server: None,
350 credential_endpoint: "https://issuer.example.com/credential".to_string(),
351 token_endpoint: None,
352 nonce_endpoint: None,
353 deferred_credential_endpoint: None,
354 notification_endpoint: None,
355 credential_configurations_supported: Default::default(),
356 };
357
358 let proof_jwt = crate::proof::create_proof_jwt(
359 &kp,
360 "https://issuer.example.com",
361 "nonce-abc",
362 "did:key:z6Mk...",
363 )
364 .unwrap();
365
366 let proof = Some(ProofOfPossession {
367 proof_type: "jwt".to_string(),
368 jwt: proof_jwt,
369 });
370
371 let resp = wallet
372 .request_credential(&metadata, "access-token-xyz", "UniversityDegree", proof)
373 .await
374 .unwrap();
375
376 assert_eq!(
377 resp.first_credential().unwrap(),
378 &serde_json::json!("eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9...")
379 );
380 }
381
382 #[tokio::test]
383 async fn full_pre_auth_flow() {
384 let client =
385 MockSequenceClient::new(vec![metadata_json(), token_json(), credential_json()]);
386
387 let kp = KeyPair::generate(KeyType::P256).unwrap();
388 let wallet = Oid4vciWallet::new(&client, &kp, "did:key:z6Mk...".to_string());
389 let offer = test_offer();
390
391 let resp = wallet.accept_offer(&offer, None).await.unwrap();
392 assert_eq!(
393 resp.first_credential().unwrap(),
394 &serde_json::json!("eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9...")
395 );
396 }
397
398 #[tokio::test]
399 async fn server_error_propagation() {
400 let error_response = serde_json::json!({
401 "error": "invalid_grant",
402 "error_description": "The pre-authorized code has expired"
403 });
404 let client = MockSequenceClient::new(vec![metadata_json(), token_json(), error_response]);
405
406 let kp = KeyPair::generate(KeyType::P256).unwrap();
407 let wallet = Oid4vciWallet::new(&client, &kp, "did:key:z6Mk...".to_string());
408 let offer = test_offer();
409
410 let result = wallet.accept_offer(&offer, None).await;
411 assert!(result.is_err());
412 }
413
414 struct ParamCapturingClient {
416 response: serde_json::Value,
417 captured_params: Mutex<Vec<(String, String)>>,
418 }
419
420 impl ParamCapturingClient {
421 fn new(response: serde_json::Value) -> Self {
422 Self {
423 response,
424 captured_params: Mutex::new(Vec::new()),
425 }
426 }
427 }
428
429 impl HttpClient for ParamCapturingClient {
430 async fn get_json(&self, _url: &str) -> baseid_core::Result<serde_json::Value> {
431 unimplemented!()
432 }
433 async fn post_form(
434 &self,
435 _url: &str,
436 params: &[(&str, &str)],
437 ) -> baseid_core::Result<serde_json::Value> {
438 let mut captured = self.captured_params.lock().unwrap();
439 for (k, v) in params {
440 captured.push((k.to_string(), v.to_string()));
441 }
442 Ok(self.response.clone())
443 }
444 async fn post_json_bearer(
445 &self,
446 _url: &str,
447 _body: &serde_json::Value,
448 _token: &str,
449 ) -> baseid_core::Result<serde_json::Value> {
450 unimplemented!()
451 }
452 async fn post_json(
453 &self,
454 _url: &str,
455 _body: &serde_json::Value,
456 ) -> baseid_core::Result<serde_json::Value> {
457 unimplemented!()
458 }
459 async fn get(
460 &self,
461 _url: &str,
462 ) -> baseid_core::Result<baseid_transport::http::HttpResponse> {
463 unimplemented!()
464 }
465 async fn post_raw(
466 &self,
467 _url: &str,
468 _body: &[u8],
469 _content_type: &str,
470 ) -> baseid_core::Result<baseid_transport::http::HttpResponse> {
471 unimplemented!()
472 }
473 }
474
475 #[tokio::test]
476 async fn tx_code_forwarded_to_token_exchange() {
477 let client = ParamCapturingClient::new(token_json());
478 let kp = KeyPair::generate(KeyType::P256).unwrap();
479 let wallet = Oid4vciWallet::new(&client, &kp, "did:key:z6Mk...".to_string());
480 let metadata = IssuerMetadata {
481 credential_issuer: "https://issuer.example.com".to_string(),
482 authorization_server: None,
483 credential_endpoint: "https://issuer.example.com/credential".to_string(),
484 token_endpoint: None,
485 nonce_endpoint: None,
486 deferred_credential_endpoint: None,
487 notification_endpoint: None,
488 credential_configurations_supported: Default::default(),
489 };
490 wallet
491 .exchange_token(&metadata, "pre-auth-code-123", Some("493536"))
492 .await
493 .unwrap();
494
495 let captured = client.captured_params.lock().unwrap();
496 assert!(captured
497 .iter()
498 .any(|(k, v)| k == "tx_code" && v == "493536"));
499 }
500
501 #[tokio::test]
502 async fn tx_code_omitted_when_none() {
503 let client = ParamCapturingClient::new(token_json());
504 let kp = KeyPair::generate(KeyType::P256).unwrap();
505 let wallet = Oid4vciWallet::new(&client, &kp, "did:key:z6Mk...".to_string());
506 let metadata = IssuerMetadata {
507 credential_issuer: "https://issuer.example.com".to_string(),
508 authorization_server: None,
509 credential_endpoint: "https://issuer.example.com/credential".to_string(),
510 token_endpoint: None,
511 nonce_endpoint: None,
512 deferred_credential_endpoint: None,
513 notification_endpoint: None,
514 credential_configurations_supported: Default::default(),
515 };
516 wallet
517 .exchange_token(&metadata, "pre-auth-code-123", None)
518 .await
519 .unwrap();
520
521 let captured = client.captured_params.lock().unwrap();
522 assert!(!captured.iter().any(|(k, _)| k == "tx_code"));
523 }
524
525 #[tokio::test]
526 async fn nonce_from_dedicated_endpoint() {
527 let token_no_nonce = serde_json::json!({
530 "access_token": "access-token-xyz",
531 "token_type": "Bearer"
532 });
533 let nonce_resp = serde_json::json!({ "c_nonce": "endpoint-nonce-789" });
534 let client = MockSequenceClient::new(vec![
535 serde_json::json!({
537 "credential_issuer": "https://issuer.example.com",
538 "credential_endpoint": "https://issuer.example.com/credential",
539 "nonce_endpoint": "https://issuer.example.com/nonce",
540 "credential_configurations_supported": {
541 "UniversityDegree": { "format": "jwt_vc_json" }
542 }
543 }),
544 token_no_nonce,
545 nonce_resp,
546 credential_json(),
547 ]);
548 let kp = KeyPair::generate(KeyType::P256).unwrap();
549 let wallet = Oid4vciWallet::new(&client, &kp, "did:key:z6Mk...".to_string());
550 let offer = test_offer();
551
552 let resp = wallet.accept_offer(&offer, None).await.unwrap();
553 assert!(resp.first_credential().is_some());
555 }
556
557 #[tokio::test]
558 async fn nonce_from_token_response_preferred() {
559 let client = MockSequenceClient::new(vec![
563 serde_json::json!({
564 "credential_issuer": "https://issuer.example.com",
565 "credential_endpoint": "https://issuer.example.com/credential",
566 "nonce_endpoint": "https://issuer.example.com/nonce",
567 "credential_configurations_supported": {
568 "UniversityDegree": { "format": "jwt_vc_json" }
569 }
570 }),
571 token_json(), credential_json(),
573 ]);
574 let kp = KeyPair::generate(KeyType::P256).unwrap();
575 let wallet = Oid4vciWallet::new(&client, &kp, "did:key:z6Mk...".to_string());
576 let offer = test_offer();
577
578 let resp = wallet.accept_offer(&offer, None).await.unwrap();
579 assert!(resp.first_credential().is_some());
580 }
581
582 #[tokio::test]
583 async fn missing_pre_auth_grant_rejected() {
584 let client = MockSequenceClient::new(vec![metadata_json()]);
585 let kp = KeyPair::generate(KeyType::P256).unwrap();
586 let wallet = Oid4vciWallet::new(&client, &kp, "did:key:z6Mk...".to_string());
587
588 let offer = CredentialOffer {
589 credential_issuer: "https://issuer.example.com".to_string(),
590 credential_configuration_ids: vec!["Degree".to_string()],
591 grants: None,
592 };
593
594 let result = wallet.accept_offer(&offer, None).await;
595 assert!(result.is_err());
596 }
597
598 struct UrlCapturingClient {
600 response: serde_json::Value,
601 captured_urls: Mutex<Vec<String>>,
602 }
603
604 impl UrlCapturingClient {
605 fn new(response: serde_json::Value) -> Self {
606 Self {
607 response,
608 captured_urls: Mutex::new(Vec::new()),
609 }
610 }
611 }
612
613 impl HttpClient for UrlCapturingClient {
614 async fn get_json(&self, _url: &str) -> baseid_core::Result<serde_json::Value> {
615 unimplemented!()
616 }
617 async fn post_form(
618 &self,
619 url: &str,
620 _params: &[(&str, &str)],
621 ) -> baseid_core::Result<serde_json::Value> {
622 self.captured_urls.lock().unwrap().push(url.to_string());
623 Ok(self.response.clone())
624 }
625 async fn post_json_bearer(
626 &self,
627 _url: &str,
628 _body: &serde_json::Value,
629 _token: &str,
630 ) -> baseid_core::Result<serde_json::Value> {
631 unimplemented!()
632 }
633 async fn post_json(
634 &self,
635 _url: &str,
636 _body: &serde_json::Value,
637 ) -> baseid_core::Result<serde_json::Value> {
638 unimplemented!()
639 }
640 async fn get(
641 &self,
642 _url: &str,
643 ) -> baseid_core::Result<baseid_transport::http::HttpResponse> {
644 unimplemented!()
645 }
646 async fn post_raw(
647 &self,
648 _url: &str,
649 _body: &[u8],
650 _content_type: &str,
651 ) -> baseid_core::Result<baseid_transport::http::HttpResponse> {
652 unimplemented!()
653 }
654 }
655
656 #[tokio::test]
657 async fn token_endpoint_resolution_priority() {
658 let kp = KeyPair::generate(KeyType::P256).unwrap();
659
660 {
662 let client = UrlCapturingClient::new(token_json());
663 let wallet = Oid4vciWallet::new(&client, &kp, "did:key:z6Mk...".to_string());
664 let metadata = IssuerMetadata {
665 credential_issuer: "https://issuer.example.com".to_string(),
666 authorization_server: Some("https://auth.example.com".to_string()),
667 credential_endpoint: "https://issuer.example.com/credential".to_string(),
668 token_endpoint: Some("https://issuer.example.com/explicit-token".to_string()),
669 nonce_endpoint: None,
670 deferred_credential_endpoint: None,
671 notification_endpoint: None,
672 credential_configurations_supported: Default::default(),
673 };
674 wallet
675 .exchange_token(&metadata, "code", None)
676 .await
677 .unwrap();
678 let urls = client.captured_urls.lock().unwrap();
679 assert_eq!(
680 urls[0], "https://issuer.example.com/explicit-token",
681 "token_endpoint should be used when set"
682 );
683 }
684
685 {
687 let client = UrlCapturingClient::new(token_json());
688 let wallet = Oid4vciWallet::new(&client, &kp, "did:key:z6Mk...".to_string());
689 let metadata = IssuerMetadata {
690 credential_issuer: "https://issuer.example.com".to_string(),
691 authorization_server: Some("https://auth.example.com".to_string()),
692 credential_endpoint: "https://issuer.example.com/credential".to_string(),
693 token_endpoint: None,
694 nonce_endpoint: None,
695 deferred_credential_endpoint: None,
696 notification_endpoint: None,
697 credential_configurations_supported: Default::default(),
698 };
699 wallet
700 .exchange_token(&metadata, "code", None)
701 .await
702 .unwrap();
703 let urls = client.captured_urls.lock().unwrap();
704 assert_eq!(
705 urls[0], "https://auth.example.com/token",
706 "authorization_server + /token should be used when token_endpoint is None"
707 );
708 }
709
710 {
712 let client = UrlCapturingClient::new(token_json());
713 let wallet = Oid4vciWallet::new(&client, &kp, "did:key:z6Mk...".to_string());
714 let metadata = IssuerMetadata {
715 credential_issuer: "https://issuer.example.com".to_string(),
716 authorization_server: None,
717 credential_endpoint: "https://issuer.example.com/credential".to_string(),
718 token_endpoint: None,
719 nonce_endpoint: None,
720 deferred_credential_endpoint: None,
721 notification_endpoint: None,
722 credential_configurations_supported: Default::default(),
723 };
724 wallet
725 .exchange_token(&metadata, "code", None)
726 .await
727 .unwrap();
728 let urls = client.captured_urls.lock().unwrap();
729 assert_eq!(
730 urls[0], "https://issuer.example.com/token",
731 "credential_issuer + /token should be used as last resort"
732 );
733 }
734 }
735
736 #[tokio::test]
737 async fn credential_request_no_nonce_no_proof() {
738 let token_no_nonce = serde_json::json!({
742 "access_token": "access-token-xyz",
743 "token_type": "Bearer"
744 });
745 let client = MockSequenceClient::new(vec![
746 serde_json::json!({
747 "credential_issuer": "https://issuer.example.com",
748 "credential_endpoint": "https://issuer.example.com/credential",
749 "credential_configurations_supported": {
750 "UniversityDegree": { "format": "jwt_vc_json" }
751 }
752 }),
753 token_no_nonce,
754 credential_json(),
755 ]);
756 let kp = KeyPair::generate(KeyType::P256).unwrap();
757 let wallet = Oid4vciWallet::new(&client, &kp, "did:key:z6Mk...".to_string());
758 let offer = test_offer();
759
760 let resp = wallet.accept_offer(&offer, None).await.unwrap();
762 assert!(resp.first_credential().is_some());
763 }
764}