baseid_oid4vci/
wallet.rs

1//! OID4VCI wallet/holder flow for the pre-authorized code grant.
2//!
3//! Orchestrates the end-to-end credential issuance flow:
4//! offer → metadata discovery → token exchange → credential request.
5
6use 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
17/// Wallet-side OID4VCI client for credential issuance.
18pub 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    /// Create a new wallet client.
26    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    /// Execute the pre-authorized code flow end-to-end.
35    ///
36    /// 1. Discovers issuer metadata
37    /// 2. Exchanges the pre-authorized code for an access token (with tx_code if required)
38    /// 3. Builds a proof-of-possession JWT (if nonce provided)
39    /// 4. Requests the credential from the issuer
40    ///
41    /// If the credential offer requires a transaction code (PIN), pass it via `tx_code`.
42    pub async fn accept_offer(
43        &self,
44        offer: &CredentialOffer,
45        tx_code: Option<&str>,
46    ) -> baseid_core::Result<CredentialResponse> {
47        // 1. Discover issuer metadata
48        let metadata = metadata::discover(self.client, &offer.credential_issuer).await?;
49
50        // 2. Extract pre-authorized code
51        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        // 3. Exchange code for token
58        let token_response = self
59            .exchange_token(&metadata, &pre_auth_grant.pre_authorized_code, tx_code)
60            .await?;
61
62        // 4. Obtain nonce: prefer token response c_nonce, fall back to nonce endpoint
63        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        // 5. Build proof-of-possession if nonce is present
72        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        // 6. Request credential using the first offered credential configuration
88        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    /// Fetch a challenge nonce from the nonce endpoint (OID4VCI 1.0).
99    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    /// Exchange a pre-authorized code for an access token.
114    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, &params)
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    /// Request a credential from the issuer's credential endpoint.
155    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        // Check for server error response
183        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    /// A mock HTTP client that returns pre-configured responses in sequence.
211    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    /// A mock HTTP client that captures POST form params.
415    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        // Token response without c_nonce, metadata has nonce_endpoint.
528        // Sequence: metadata → token (no nonce) → nonce endpoint → credential
529        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            // metadata includes nonce_endpoint
536            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        // Should succeed — nonce was fetched from dedicated endpoint
554        assert!(resp.first_credential().is_some());
555    }
556
557    #[tokio::test]
558    async fn nonce_from_token_response_preferred() {
559        // Token response WITH c_nonce — nonce endpoint should NOT be called.
560        // Sequence: metadata (has nonce_endpoint) → token (with c_nonce) → credential
561        // Only 3 responses queued — if nonce endpoint were called, it would exhaust the queue.
562        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(), // has c_nonce
572            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    /// A mock HTTP client that captures the URL of POST form requests.
599    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        // Priority 1: explicit token_endpoint
661        {
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        // Priority 2: authorization_server fallback
686        {
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        // Priority 3: credential_issuer fallback
711        {
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        // Token response without c_nonce, metadata without nonce_endpoint.
739        // Sequence: metadata → token (no nonce) → credential
740        // No nonce endpoint call, no proof generated.
741        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        // Should succeed without proof — only 3 responses queued (metadata, token, credential)
761        let resp = wallet.accept_offer(&offer, None).await.unwrap();
762        assert!(resp.first_credential().is_some());
763    }
764}