Skip to main content

x402_extensions/
sign_in_with_x.rs

1//! The `sign-in-with-x` extension for authenticated access.
2//!
3//! The `sign-in-with-x` extension enables resource servers to require
4//! authenticated sign-in alongside payment. The server provides sign-in
5//! parameters and supported chains, and the client echoes back the extension
6//! with a signature.
7//!
8//! # Example
9//!
10//! ```
11//! use x402_extensions::sign_in_with_x::*;
12//! use x402_core::types::Extension;
13//! use serde_json::json;
14//!
15//! let info = SignInWithXInfo::builder()
16//!     .domain("api.example.com")
17//!     .uri("https://api.example.com/premium-data")
18//!     .version("1")
19//!     .nonce("a1b2c3d4e5f67890a1b2c3d4e5f67890")
20//!     .issued_at("2024-01-15T10:30:00.000Z")
21//!     .statement("Sign in to access premium data")
22//!     .build();
23//!
24//! let ext = Extension::typed(info)
25//!     .with_extra("supportedChains", json!([
26//!         {"chainId": "eip155:8453", "type": "eip191"}
27//!     ]));
28//!
29//! let (key, transport) = ext.into_pair();
30//! assert_eq!(key, "sign-in-with-x");
31//!
32//! // Extra fields are flattened in serialization
33//! let json = serde_json::to_value(&transport).unwrap();
34//! assert!(json.get("supportedChains").is_some());
35//! ```
36
37use bon::Builder;
38use schemars::JsonSchema;
39use serde::{Deserialize, Serialize};
40use x402_core::types::{AnyJson, ExtensionInfo};
41
42/// Sign-in info for the `sign-in-with-x` extension.
43///
44/// Contains parameters for authenticated sign-in that the server provides.
45/// Clients echo this back with additional fields (e.g., `address`, `signature`).
46#[derive(Builder, Debug, Clone, Serialize, Deserialize, JsonSchema)]
47#[serde(rename_all = "camelCase")]
48pub struct SignInWithXInfo {
49    /// The domain requesting the sign-in.
50    #[builder(into)]
51    pub domain: String,
52
53    /// The URI of the resource being accessed.
54    #[builder(into)]
55    pub uri: String,
56
57    /// The sign-in message version.
58    #[builder(into)]
59    pub version: String,
60
61    /// A unique nonce to prevent replay attacks.
62    #[builder(into)]
63    pub nonce: String,
64
65    /// The timestamp when this sign-in request was issued (ISO 8601).
66    #[builder(into)]
67    pub issued_at: String,
68
69    /// When the sign-in request expires (ISO 8601).
70    #[serde(skip_serializing_if = "Option::is_none")]
71    #[builder(into)]
72    pub expiration_time: Option<String>,
73
74    /// Human-readable statement for the sign-in.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    #[builder(into)]
77    pub statement: Option<String>,
78
79    /// Resources associated with this sign-in.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub resources: Option<Vec<String>>,
82}
83
84impl ExtensionInfo for SignInWithXInfo {
85    const ID: &'static str = "sign-in-with-x";
86
87    fn schema() -> AnyJson {
88        let schema = schemars::schema_for!(SignInWithXInfo);
89        serde_json::to_value(&schema).expect("SignInWithXInfo schema generation should not fail")
90    }
91}
92
93/// A supported chain entry for the `sign-in-with-x` extension.
94///
95/// Used in the `supportedChains` extra field.
96#[derive(Builder, Debug, Clone, Serialize, Deserialize, JsonSchema)]
97#[serde(rename_all = "camelCase")]
98pub struct SupportedChain {
99    /// The chain identifier in CAIP-2 format (e.g., `"eip155:8453"`).
100    #[builder(into)]
101    pub chain_id: String,
102
103    /// The signature type (e.g., `"eip191"`).
104    #[serde(rename = "type")]
105    #[builder(into)]
106    pub chain_type: String,
107}
108
109#[cfg(test)]
110mod tests {
111    use serde_json::json;
112    use x402_core::types::{Extension, ExtensionMapInsert, Record};
113
114    use super::*;
115
116    #[test]
117    fn sign_in_with_x_basic() {
118        let info = SignInWithXInfo::builder()
119            .domain("api.example.com")
120            .uri("https://api.example.com/premium-data")
121            .version("1")
122            .nonce("a1b2c3d4e5f67890a1b2c3d4e5f67890")
123            .issued_at("2024-01-15T10:30:00.000Z")
124            .build();
125
126        let ext = Extension::typed(info);
127        let (key, transport_ext) = ext.into_pair();
128
129        assert_eq!(key, "sign-in-with-x");
130        assert_eq!(transport_ext.info["domain"], "api.example.com");
131        assert_eq!(transport_ext.info["version"], "1");
132    }
133
134    #[test]
135    fn sign_in_with_x_with_optional_fields() {
136        let info = SignInWithXInfo::builder()
137            .domain("api.example.com")
138            .uri("https://api.example.com/premium-data")
139            .version("1")
140            .nonce("a1b2c3d4e5f67890a1b2c3d4e5f67890")
141            .issued_at("2024-01-15T10:30:00.000Z")
142            .expiration_time("2024-01-15T10:35:00.000Z")
143            .statement("Sign in to access premium data")
144            .resources(vec!["https://api.example.com/premium-data".to_string()])
145            .build();
146
147        let (_, ext) = Extension::typed(info).into_pair();
148
149        assert_eq!(ext.info["statement"], "Sign in to access premium data");
150        assert_eq!(ext.info["expirationTime"], "2024-01-15T10:35:00.000Z");
151        assert!(ext.info["resources"].is_array());
152    }
153
154    #[test]
155    fn sign_in_with_x_with_supported_chains() {
156        let info = SignInWithXInfo::builder()
157            .domain("api.example.com")
158            .uri("https://api.example.com/premium-data")
159            .version("1")
160            .nonce("a1b2c3d4e5f67890")
161            .issued_at("2024-01-15T10:30:00.000Z")
162            .build();
163
164        let chains = vec![
165            SupportedChain::builder()
166                .chain_id("eip155:8453")
167                .chain_type("eip191")
168                .build(),
169        ];
170
171        let ext = Extension::typed(info)
172            .with_extra("supportedChains", serde_json::to_value(&chains).unwrap());
173
174        let (key, transport_ext) = ext.into_pair();
175        assert_eq!(key, "sign-in-with-x");
176
177        // Serialize and check extra fields are flattened
178        let json = serde_json::to_value(&transport_ext).unwrap();
179        assert!(json.get("supportedChains").is_some());
180        assert_eq!(json["supportedChains"][0]["chainId"], "eip155:8453");
181        assert_eq!(json["supportedChains"][0]["type"], "eip191");
182    }
183
184    #[test]
185    fn sign_in_with_x_schema_is_generated() {
186        let schema = <SignInWithXInfo as ExtensionInfo>::schema();
187        assert!(schema.is_object());
188    }
189
190    #[test]
191    fn sign_in_with_x_insert_into_map() {
192        let mut extensions: Record<Extension> = Record::new();
193
194        let info = SignInWithXInfo::builder()
195            .domain("example.com")
196            .uri("https://example.com")
197            .version("1")
198            .nonce("test_nonce")
199            .issued_at("2024-01-01T00:00:00.000Z")
200            .build();
201
202        extensions.insert_typed(Extension::typed(info));
203
204        assert!(extensions.contains_key("sign-in-with-x"));
205        assert_eq!(extensions["sign-in-with-x"].info["domain"], "example.com");
206    }
207
208    #[test]
209    fn sign_in_with_x_roundtrip() {
210        let info = SignInWithXInfo::builder()
211            .domain("api.example.com")
212            .uri("https://api.example.com/resource")
213            .version("1")
214            .nonce("nonce123")
215            .issued_at("2024-01-15T10:30:00.000Z")
216            .expiration_time("2024-01-15T10:35:00.000Z")
217            .statement("Test statement")
218            .build();
219
220        let json = serde_json::to_value(&info).unwrap();
221        let deserialized: SignInWithXInfo = serde_json::from_value(json.clone()).unwrap();
222        let re_serialized = serde_json::to_value(&deserialized).unwrap();
223
224        assert_eq!(json, re_serialized);
225    }
226
227    #[test]
228    fn sign_in_with_x_transport_roundtrip_with_extra() {
229        let info = SignInWithXInfo::builder()
230            .domain("api.example.com")
231            .uri("https://api.example.com/resource")
232            .version("1")
233            .nonce("nonce123")
234            .issued_at("2024-01-15T10:30:00.000Z")
235            .build();
236
237        let ext = Extension::typed(info).with_extra(
238            "supportedChains",
239            json!([{"chainId": "eip155:8453", "type": "eip191"}]),
240        );
241
242        let (_, transport_ext) = ext.into_pair();
243
244        // Serialize
245        let json = serde_json::to_value(&transport_ext).unwrap();
246
247        // Deserialize back
248        let deserialized: Extension = serde_json::from_value(json.clone()).unwrap();
249
250        // Verify roundtrip
251        assert_eq!(transport_ext.info, deserialized.info);
252        assert_eq!(transport_ext.schema, deserialized.schema);
253        assert_eq!(
254            deserialized.extra.get("supportedChains").unwrap(),
255            &json!([{"chainId": "eip155:8453", "type": "eip191"}])
256        );
257    }
258
259    #[test]
260    fn sign_in_with_x_full_spec_example() {
261        // Test against the full example from the x402 spec
262        let json = json!({
263            "info": {
264                "domain": "api.example.com",
265                "uri": "https://api.example.com/premium-data",
266                "version": "1",
267                "nonce": "a1b2c3d4e5f67890a1b2c3d4e5f67890",
268                "issuedAt": "2024-01-15T10:30:00.000Z",
269                "expirationTime": "2024-01-15T10:35:00.000Z",
270                "statement": "Sign in to access premium data",
271                "resources": ["https://api.example.com/premium-data"]
272            },
273            "supportedChains": [
274                {
275                    "chainId": "eip155:8453",
276                    "type": "eip191"
277                }
278            ],
279            "schema": {}
280        });
281
282        let ext: Extension = serde_json::from_value(json).unwrap();
283        assert_eq!(ext.info["domain"], "api.example.com");
284        assert!(ext.extra.contains_key("supportedChains"));
285
286        // Convert to typed
287        let typed_ext: Extension<SignInWithXInfo> = ext.into_typed().unwrap();
288        assert_eq!(typed_ext.info.domain, "api.example.com");
289        assert_eq!(typed_ext.info.version, "1");
290        assert_eq!(
291            typed_ext.info.statement.as_deref(),
292            Some("Sign in to access premium data")
293        );
294    }
295}