baseid_oid4vci/
metadata.rs

1//! OID4VCI metadata discovery.
2//!
3//! Fetches issuer metadata from the `.well-known/openid-credential-issuer` endpoint.
4
5use crate::client::HttpClient;
6use crate::error::Oid4vciError;
7use crate::IssuerMetadata;
8
9/// Discover issuer metadata by fetching the well-known endpoint.
10///
11/// Requests `{issuer_url}/.well-known/openid-credential-issuer` and deserializes
12/// the response into `IssuerMetadata`.
13pub async fn discover<C: HttpClient>(
14    client: &C,
15    issuer_url: &str,
16) -> baseid_core::Result<IssuerMetadata> {
17    let url = format!(
18        "{}/.well-known/openid-credential-issuer",
19        issuer_url.trim_end_matches('/')
20    );
21
22    let json = client.get_json(&url).await.map_err(|e| {
23        let err: baseid_core::Error =
24            Oid4vciError::MetadataDiscovery(format!("failed to fetch metadata from {url}: {e}"))
25                .into();
26        err
27    })?;
28
29    serde_json::from_value(json).map_err(|e| {
30        let err: baseid_core::Error =
31            Oid4vciError::MetadataDiscovery(format!("invalid metadata response: {e}")).into();
32        err
33    })
34}
35
36#[cfg(test)]
37mod tests {
38    use super::*;
39    use crate::client::HttpClient;
40    use std::collections::BTreeMap;
41
42    struct MockClient {
43        response: serde_json::Value,
44    }
45
46    impl HttpClient for MockClient {
47        async fn get_json(&self, _url: &str) -> baseid_core::Result<serde_json::Value> {
48            Ok(self.response.clone())
49        }
50        async fn post_form(
51            &self,
52            _url: &str,
53            _params: &[(&str, &str)],
54        ) -> baseid_core::Result<serde_json::Value> {
55            unimplemented!()
56        }
57        async fn post_json_bearer(
58            &self,
59            _url: &str,
60            _body: &serde_json::Value,
61            _token: &str,
62        ) -> baseid_core::Result<serde_json::Value> {
63            unimplemented!()
64        }
65        async fn post_json(
66            &self,
67            _url: &str,
68            _body: &serde_json::Value,
69        ) -> baseid_core::Result<serde_json::Value> {
70            unimplemented!()
71        }
72        async fn get(
73            &self,
74            _url: &str,
75        ) -> baseid_core::Result<baseid_transport::http::HttpResponse> {
76            unimplemented!()
77        }
78        async fn post_raw(
79            &self,
80            _url: &str,
81            _body: &[u8],
82            _content_type: &str,
83        ) -> baseid_core::Result<baseid_transport::http::HttpResponse> {
84            unimplemented!()
85        }
86    }
87
88    #[tokio::test]
89    async fn metadata_discovery() {
90        let client = MockClient {
91            response: serde_json::json!({
92                "credential_issuer": "https://issuer.example.com",
93                "credential_endpoint": "https://issuer.example.com/credential",
94                "credential_configurations_supported": {
95                    "UniversityDegree": {
96                        "format": "jwt_vc_json",
97                        "credential_definition": {
98                            "type": ["VerifiableCredential", "UniversityDegreeCredential"]
99                        }
100                    }
101                }
102            }),
103        };
104
105        let metadata = discover(&client, "https://issuer.example.com")
106            .await
107            .unwrap();
108        assert_eq!(metadata.credential_issuer, "https://issuer.example.com");
109        assert_eq!(
110            metadata.credential_endpoint,
111            "https://issuer.example.com/credential"
112        );
113        assert!(metadata
114            .credential_configurations_supported
115            .contains_key("UniversityDegree"));
116    }
117
118    #[tokio::test]
119    async fn metadata_discovery_with_token_endpoint() {
120        let mut configs = BTreeMap::new();
121        configs.insert("Degree".to_string(), serde_json::json!({}));
122        let client = MockClient {
123            response: serde_json::json!({
124                "credential_issuer": "https://issuer.example.com",
125                "authorization_server": "https://auth.example.com",
126                "credential_endpoint": "https://issuer.example.com/credential",
127                "credential_configurations_supported": configs,
128            }),
129        };
130
131        let metadata = discover(&client, "https://issuer.example.com/")
132            .await
133            .unwrap();
134        assert_eq!(
135            metadata.authorization_server.as_deref(),
136            Some("https://auth.example.com")
137        );
138    }
139
140    struct FailingClient;
141
142    impl HttpClient for FailingClient {
143        async fn get_json(&self, _url: &str) -> baseid_core::Result<serde_json::Value> {
144            Err(baseid_core::error::ProtocolError::Transport.into())
145        }
146        async fn post_form(
147            &self,
148            _url: &str,
149            _params: &[(&str, &str)],
150        ) -> baseid_core::Result<serde_json::Value> {
151            unimplemented!()
152        }
153        async fn post_json_bearer(
154            &self,
155            _url: &str,
156            _body: &serde_json::Value,
157            _token: &str,
158        ) -> baseid_core::Result<serde_json::Value> {
159            unimplemented!()
160        }
161        async fn post_json(
162            &self,
163            _url: &str,
164            _body: &serde_json::Value,
165        ) -> baseid_core::Result<serde_json::Value> {
166            unimplemented!()
167        }
168        async fn get(
169            &self,
170            _url: &str,
171        ) -> baseid_core::Result<baseid_transport::http::HttpResponse> {
172            unimplemented!()
173        }
174        async fn post_raw(
175            &self,
176            _url: &str,
177            _body: &[u8],
178            _content_type: &str,
179        ) -> baseid_core::Result<baseid_transport::http::HttpResponse> {
180            unimplemented!()
181        }
182    }
183
184    #[tokio::test]
185    async fn metadata_discovery_failure() {
186        let result = discover(&FailingClient, "https://issuer.example.com").await;
187        assert!(result.is_err());
188    }
189
190    // --- Spec vector tests ---
191
192    #[test]
193    fn spec_vector_metadata_jwt_vc_json() {
194        let meta: IssuerMetadata =
195            serde_json::from_str(baseid_test_vectors::oid4vci::METADATA_JWT_VC_JSON).unwrap();
196        assert_eq!(
197            meta.credential_issuer,
198            "https://credential-issuer.example.com"
199        );
200        let config = &meta.credential_configurations_supported["UniversityDegreeCredential"];
201        assert_eq!(config["format"], "jwt_vc_json");
202        assert_eq!(
203            config["credential_signing_alg_values_supported"][0],
204            "ES256"
205        );
206    }
207
208    #[test]
209    fn spec_vector_metadata_sd_jwt_vc() {
210        let meta: IssuerMetadata =
211            serde_json::from_str(baseid_test_vectors::oid4vci::METADATA_SD_JWT_VC).unwrap();
212        let config = &meta.credential_configurations_supported["SD_JWT_VC_example_in_OpenID4VCI"];
213        assert_eq!(config["format"], "dc+sd-jwt");
214        assert_eq!(config["vct"], "SD_JWT_VC_example_in_OpenID4VCI");
215    }
216
217    #[test]
218    fn spec_vector_metadata_mso_mdoc() {
219        let meta: IssuerMetadata =
220            serde_json::from_str(baseid_test_vectors::oid4vci::METADATA_MSO_MDOC).unwrap();
221        let config = &meta.credential_configurations_supported["org.iso.18013.5.1.mDL"];
222        assert_eq!(config["format"], "mso_mdoc");
223        assert_eq!(config["doctype"], "org.iso.18013.5.1.mDL");
224    }
225
226    #[test]
227    fn spec_vector_metadata_full_endpoints() {
228        let meta: IssuerMetadata =
229            serde_json::from_str(baseid_test_vectors::oid4vci::METADATA_FULL).unwrap();
230        assert_eq!(
231            meta.nonce_endpoint.as_deref(),
232            Some("https://credential-issuer.example.com/nonce")
233        );
234        assert_eq!(
235            meta.deferred_credential_endpoint.as_deref(),
236            Some("https://credential-issuer.example.com/deferred_credential")
237        );
238        assert_eq!(
239            meta.notification_endpoint.as_deref(),
240            Some("https://credential-issuer.example.com/notification")
241        );
242    }
243
244    #[test]
245    fn spec_vector_token_response() {
246        use crate::token::TokenResponse;
247        let resp: TokenResponse =
248            serde_json::from_str(baseid_test_vectors::oid4vci::TOKEN_RESPONSE).unwrap();
249        assert_eq!(resp.token_type, "Bearer");
250        assert_eq!(resp.expires_in, Some(86400));
251    }
252
253    // --- Phase C: Cross-library interop roundtrip tests ---
254
255    #[test]
256    fn spec_interop_metadata_jwt_vc_roundtrip() {
257        let meta: IssuerMetadata =
258            serde_json::from_str(baseid_test_vectors::oid4vci::METADATA_JWT_VC_JSON).unwrap();
259        let json = serde_json::to_string(&meta).unwrap();
260        let reparsed: IssuerMetadata = serde_json::from_str(&json).unwrap();
261        assert_eq!(reparsed.credential_issuer, meta.credential_issuer);
262        assert_eq!(
263            reparsed.credential_configurations_supported.len(),
264            meta.credential_configurations_supported.len()
265        );
266        // Verify the credential config content survived roundtrip
267        let config = &reparsed.credential_configurations_supported["UniversityDegreeCredential"];
268        assert_eq!(config["format"], "jwt_vc_json");
269        assert_eq!(
270            config["credential_signing_alg_values_supported"][0],
271            "ES256"
272        );
273    }
274
275    #[test]
276    fn spec_interop_metadata_sd_jwt_vc_roundtrip() {
277        let meta: IssuerMetadata =
278            serde_json::from_str(baseid_test_vectors::oid4vci::METADATA_SD_JWT_VC).unwrap();
279        let json = serde_json::to_string(&meta).unwrap();
280        let reparsed: IssuerMetadata = serde_json::from_str(&json).unwrap();
281        assert_eq!(reparsed.credential_issuer, meta.credential_issuer);
282        assert_eq!(
283            reparsed.credential_configurations_supported.len(),
284            meta.credential_configurations_supported.len()
285        );
286        let config =
287            &reparsed.credential_configurations_supported["SD_JWT_VC_example_in_OpenID4VCI"];
288        assert_eq!(config["format"], "dc+sd-jwt");
289        assert_eq!(config["vct"], "SD_JWT_VC_example_in_OpenID4VCI");
290    }
291
292    #[test]
293    fn spec_interop_metadata_mso_mdoc_roundtrip() {
294        let meta: IssuerMetadata =
295            serde_json::from_str(baseid_test_vectors::oid4vci::METADATA_MSO_MDOC).unwrap();
296        let json = serde_json::to_string(&meta).unwrap();
297        let reparsed: IssuerMetadata = serde_json::from_str(&json).unwrap();
298        assert_eq!(reparsed.credential_issuer, meta.credential_issuer);
299        assert_eq!(
300            reparsed.credential_configurations_supported.len(),
301            meta.credential_configurations_supported.len()
302        );
303        let config = &reparsed.credential_configurations_supported["org.iso.18013.5.1.mDL"];
304        assert_eq!(config["format"], "mso_mdoc");
305        assert_eq!(config["doctype"], "org.iso.18013.5.1.mDL");
306    }
307
308    #[test]
309    fn spec_interop_metadata_full_roundtrip() {
310        let meta: IssuerMetadata =
311            serde_json::from_str(baseid_test_vectors::oid4vci::METADATA_FULL).unwrap();
312        let json = serde_json::to_string(&meta).unwrap();
313        let reparsed: IssuerMetadata = serde_json::from_str(&json).unwrap();
314        assert_eq!(reparsed.credential_issuer, meta.credential_issuer);
315        assert_eq!(reparsed.credential_endpoint, meta.credential_endpoint);
316        assert_eq!(reparsed.nonce_endpoint, meta.nonce_endpoint);
317        assert_eq!(
318            reparsed.deferred_credential_endpoint,
319            meta.deferred_credential_endpoint
320        );
321        assert_eq!(reparsed.notification_endpoint, meta.notification_endpoint);
322        assert_eq!(
323            reparsed.credential_configurations_supported.len(),
324            meta.credential_configurations_supported.len()
325        );
326    }
327}