Skip to main content

affinidi_oid4vc_core/
lib.rs

1/*!
2 * Shared types for the OpenID for Verifiable Credentials (OID4VC) protocol family.
3 *
4 * This crate provides common types used across SIOPv2, OpenID4VP, and OpenID4VCI:
5 *
6 * - [`ResponseType`] — protocol response types (id_token, vp_token, code)
7 * - [`ResponseMode`] — delivery modes (fragment, direct_post)
8 * - [`SubjectSyntaxType`] — subject identifier types (JWK Thumbprint, DID)
9 * - [`ClientMetadata`] — Relying Party registration metadata
10 * - [`DisplayProperties`] — UI rendering properties
11 * - [`compute_jwk_thumbprint`] — RFC 7638 JWK Thumbprint computation
12 */
13
14pub mod jwt;
15
16/// In-memory nonce replay-prevention helper (single-process deployments).
17pub mod nonce;
18
19/// ES256 (ECDSA P-256) signer and verifier for production JWT operations.
20#[cfg(feature = "es256")]
21pub mod es256;
22
23/// EdDSA (Ed25519) signer and verifier for production JWT operations.
24#[cfg(feature = "eddsa")]
25pub mod eddsa;
26
27use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30use sha2::{Digest, Sha256};
31
32/// OAuth 2.0 response types used across OID4VC protocols.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub enum ResponseType {
35    /// Self-Issued ID Token (SIOPv2).
36    #[serde(rename = "id_token")]
37    IdToken,
38    /// Verifiable Presentation Token (OpenID4VP).
39    #[serde(rename = "vp_token")]
40    VpToken,
41    /// Combined ID Token + VP Token (SIOPv2 + OpenID4VP).
42    #[serde(rename = "vp_token id_token")]
43    VpTokenIdToken,
44    /// Authorization code (standard OAuth 2.0).
45    #[serde(rename = "code")]
46    Code,
47}
48
49impl std::fmt::Display for ResponseType {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            Self::IdToken => write!(f, "id_token"),
53            Self::VpToken => write!(f, "vp_token"),
54            Self::VpTokenIdToken => write!(f, "vp_token id_token"),
55            Self::Code => write!(f, "code"),
56        }
57    }
58}
59
60/// OAuth 2.0 response delivery modes.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub enum ResponseMode {
63    /// URL fragment (default for id_token response type, same-device).
64    #[serde(rename = "fragment")]
65    Fragment,
66    /// HTTPS POST to redirect_uri (cross-device).
67    #[serde(rename = "direct_post")]
68    DirectPost,
69    /// Encrypted HTTPS POST (HAIP profile).
70    #[serde(rename = "direct_post.jwt")]
71    DirectPostJwt,
72}
73
74impl std::fmt::Display for ResponseMode {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            Self::Fragment => write!(f, "fragment"),
78            Self::DirectPost => write!(f, "direct_post"),
79            Self::DirectPostJwt => write!(f, "direct_post.jwt"),
80        }
81    }
82}
83
84/// Subject syntax types for identifying credential holders.
85///
86/// Per SIOPv2 §8.1 and OpenID4VP client metadata.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(untagged)]
89pub enum SubjectSyntaxType {
90    /// JWK Thumbprint per RFC 7638.
91    /// URI: `urn:ietf:params:oauth:jwk-thumbprint`
92    JwkThumbprint,
93    /// A specific DID method (e.g., "did:key", "did:web", "did:ebsi").
94    Did(String),
95}
96
97impl SubjectSyntaxType {
98    /// The URN for JWK Thumbprint subject syntax.
99    pub const JWK_THUMBPRINT_URN: &'static str = "urn:ietf:params:oauth:jwk-thumbprint";
100
101    /// Parse from a string.
102    pub fn parse(s: &str) -> Self {
103        if s == Self::JWK_THUMBPRINT_URN {
104            Self::JwkThumbprint
105        } else {
106            Self::Did(s.to_string())
107        }
108    }
109
110    /// Convert to string representation.
111    pub fn as_str(&self) -> &str {
112        match self {
113            Self::JwkThumbprint => Self::JWK_THUMBPRINT_URN,
114            Self::Did(method) => method,
115        }
116    }
117
118    /// Whether this is a DID-based subject type.
119    pub fn is_did(&self) -> bool {
120        matches!(self, Self::Did(_))
121    }
122}
123
124impl std::fmt::Display for SubjectSyntaxType {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        write!(f, "{}", self.as_str())
127    }
128}
129
130/// Compute a JWK Thumbprint per RFC 7638.
131///
132/// The thumbprint is `base64url(SHA-256(canonical_jwk))` where canonical_jwk
133/// is a JSON object with only the REQUIRED members, sorted lexicographically.
134///
135/// # Supported Key Types
136///
137/// - **EC** (P-256, secp256k1): members `crv`, `kty`, `x`, `y`
138/// - **OKP** (Ed25519): members `crv`, `kty`, `x`
139/// - **RSA**: members `e`, `kty`, `n`
140pub fn compute_jwk_thumbprint(jwk: &Value) -> Option<String> {
141    let kty = jwk.get("kty")?.as_str()?;
142
143    let canonical = match kty {
144        "EC" => {
145            let crv = jwk.get("crv")?.as_str()?;
146            let x = jwk.get("x")?.as_str()?;
147            let y = jwk.get("y")?.as_str()?;
148            format!(r#"{{"crv":"{crv}","kty":"EC","x":"{x}","y":"{y}"}}"#)
149        }
150        "OKP" => {
151            let crv = jwk.get("crv")?.as_str()?;
152            let x = jwk.get("x")?.as_str()?;
153            format!(r#"{{"crv":"{crv}","kty":"OKP","x":"{x}"}}"#)
154        }
155        "RSA" => {
156            let e = jwk.get("e")?.as_str()?;
157            let n = jwk.get("n")?.as_str()?;
158            format!(r#"{{"e":"{e}","kty":"RSA","n":"{n}"}}"#)
159        }
160        _ => return None,
161    };
162
163    let hash = Sha256::digest(canonical.as_bytes());
164    Some(URL_SAFE_NO_PAD.encode(hash))
165}
166
167/// Display properties for UI rendering (shared across OID4VCI, OpenID4VP).
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct DisplayProperties {
170    /// Display name.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub name: Option<String>,
173    /// Locale (e.g., "en-US").
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub locale: Option<String>,
176    /// Logo information.
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub logo: Option<LogoProperties>,
179    /// Background color (CSS color string).
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub background_color: Option<String>,
182    /// Text color (CSS color string).
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub text_color: Option<String>,
185}
186
187/// Logo properties for display.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct LogoProperties {
190    /// URI of the logo image.
191    pub uri: String,
192    /// Alt text for accessibility.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub alt_text: Option<String>,
195}
196
197/// Client (RP) metadata fields shared across OID4VC protocols.
198#[derive(Debug, Clone, Default, Serialize, Deserialize)]
199pub struct ClientMetadata {
200    /// Supported subject syntax types.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub subject_syntax_types_supported: Option<Vec<String>>,
203    /// Preferred ID Token signing algorithm.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub id_token_signed_response_alg: Option<String>,
206    /// Valid redirect URIs.
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub redirect_uris: Option<Vec<String>>,
209    /// Privacy policy URI.
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub policy_uri: Option<String>,
212    /// Terms of service URI.
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub tos_uri: Option<String>,
215    /// Logo URI.
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub logo_uri: Option<String>,
218    /// Client name.
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub client_name: Option<String>,
221    /// Additional properties.
222    #[serde(flatten)]
223    pub additional: serde_json::Map<String, Value>,
224}
225
226/// Standard OAuth 2.0 error codes used across OID4VC.
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
228#[non_exhaustive]
229pub enum OAuthError {
230    #[error("invalid_request")]
231    #[serde(rename = "invalid_request")]
232    InvalidRequest,
233    #[error("unauthorized_client")]
234    #[serde(rename = "unauthorized_client")]
235    UnauthorizedClient,
236    #[error("access_denied")]
237    #[serde(rename = "access_denied")]
238    AccessDenied,
239    #[error("unsupported_response_type")]
240    #[serde(rename = "unsupported_response_type")]
241    UnsupportedResponseType,
242    #[error("invalid_scope")]
243    #[serde(rename = "invalid_scope")]
244    InvalidScope,
245    #[error("server_error")]
246    #[serde(rename = "server_error")]
247    ServerError,
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use serde_json::json;
254
255    #[test]
256    fn jwk_thumbprint_ec_p256() {
257        // RFC 7638 §3.1 example (modified for EC P-256)
258        let jwk = json!({
259            "kty": "EC",
260            "crv": "P-256",
261            "x": "TCAER19Zvu3OHF4j4W4vfSVoHIP1ILilDls7vCeGemc",
262            "y": "ZxjiWWbZMQGHVWKVQ4hbSIirsVfuecCE6t4jT9F2HZQ"
263        });
264
265        let thumbprint = compute_jwk_thumbprint(&jwk).unwrap();
266        assert!(!thumbprint.is_empty());
267        // Thumbprint is base64url-no-pad, 43 chars for SHA-256
268        assert_eq!(thumbprint.len(), 43);
269    }
270
271    #[test]
272    fn jwk_thumbprint_okp_ed25519() {
273        let jwk = json!({
274            "kty": "OKP",
275            "crv": "Ed25519",
276            "x": "Xx4_L89E6RsyvDTzN9wuN3cDwgifPkXMgFJv_HMIxdk"
277        });
278
279        let thumbprint = compute_jwk_thumbprint(&jwk).unwrap();
280        assert_eq!(thumbprint.len(), 43);
281    }
282
283    #[test]
284    fn jwk_thumbprint_deterministic() {
285        let jwk = json!({"kty": "EC", "crv": "P-256", "x": "abc", "y": "def"});
286        let t1 = compute_jwk_thumbprint(&jwk).unwrap();
287        let t2 = compute_jwk_thumbprint(&jwk).unwrap();
288        assert_eq!(t1, t2);
289    }
290
291    #[test]
292    fn jwk_thumbprint_different_keys() {
293        let jwk1 = json!({"kty": "EC", "crv": "P-256", "x": "a", "y": "b"});
294        let jwk2 = json!({"kty": "EC", "crv": "P-256", "x": "c", "y": "d"});
295        assert_ne!(compute_jwk_thumbprint(&jwk1), compute_jwk_thumbprint(&jwk2));
296    }
297
298    #[test]
299    fn jwk_thumbprint_ignores_extra_fields() {
300        let jwk1 = json!({"kty": "EC", "crv": "P-256", "x": "a", "y": "b"});
301        let jwk2 = json!({"kty": "EC", "crv": "P-256", "x": "a", "y": "b", "kid": "extra"});
302        assert_eq!(compute_jwk_thumbprint(&jwk1), compute_jwk_thumbprint(&jwk2));
303    }
304
305    #[test]
306    fn jwk_thumbprint_unsupported_kty() {
307        let jwk = json!({"kty": "oct", "k": "secret"});
308        assert!(compute_jwk_thumbprint(&jwk).is_none());
309    }
310
311    #[test]
312    fn response_type_display() {
313        assert_eq!(ResponseType::IdToken.to_string(), "id_token");
314        assert_eq!(ResponseType::VpToken.to_string(), "vp_token");
315        assert_eq!(
316            ResponseType::VpTokenIdToken.to_string(),
317            "vp_token id_token"
318        );
319    }
320
321    #[test]
322    fn response_mode_display() {
323        assert_eq!(ResponseMode::Fragment.to_string(), "fragment");
324        assert_eq!(ResponseMode::DirectPost.to_string(), "direct_post");
325        assert_eq!(ResponseMode::DirectPostJwt.to_string(), "direct_post.jwt");
326    }
327
328    #[test]
329    fn subject_syntax_type_parsing() {
330        let jwk = SubjectSyntaxType::parse("urn:ietf:params:oauth:jwk-thumbprint");
331        assert_eq!(jwk, SubjectSyntaxType::JwkThumbprint);
332        assert!(!jwk.is_did());
333
334        let did = SubjectSyntaxType::parse("did:key");
335        assert!(did.is_did());
336        assert_eq!(did.as_str(), "did:key");
337    }
338
339    #[test]
340    fn client_metadata_serialization() {
341        let meta = ClientMetadata {
342            client_name: Some("Test RP".into()),
343            subject_syntax_types_supported: Some(vec![
344                "urn:ietf:params:oauth:jwk-thumbprint".into(),
345                "did:key".into(),
346            ]),
347            ..Default::default()
348        };
349
350        let json = serde_json::to_string(&meta).unwrap();
351        assert!(json.contains("Test RP"));
352        assert!(json.contains("did:key"));
353
354        let parsed: ClientMetadata = serde_json::from_str(&json).unwrap();
355        assert_eq!(parsed.client_name.as_deref(), Some("Test RP"));
356    }
357}