1use serde::{Deserialize, Serialize};
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
30#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
31pub struct ChallengeRequest {
32 #[serde(rename = "subject", alias = "did")]
33 pub did: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
45pub struct RevokeSessionRequest {
46 pub session_id: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, Default)]
54#[serde(rename_all = "camelCase")]
55#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
56pub struct RevokeSessionResponse {}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
67pub struct ChallengeResponse {
68 pub challenge: String,
70 pub session_id: String,
73 pub expires_at: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub tee_attestation: Option<serde_json::Value>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase")]
94#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
95pub struct Session {
96 pub id: String,
97 pub subject: String,
98 pub issued_at: String,
100 pub expires_at: String,
102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub amr: Vec<String>,
104 #[serde(default, skip_serializing_if = "String::is_empty")]
105 pub acr: String,
106}
107
108#[derive(Clone, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
117pub struct TokenBundle {
118 pub access_token: String,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub refresh_token: Option<String>,
121 pub token_type: String,
122 pub expires_in: u64,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub refresh_expires_in: Option<u64>,
125 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 pub scope: Vec<String>,
127}
128
129impl std::fmt::Debug for TokenBundle {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 f.debug_struct("TokenBundle")
137 .field("access_token", &"<redacted>")
138 .field(
139 "refresh_token",
140 &self.refresh_token.as_ref().map(|_| "<redacted>"),
141 )
142 .field("token_type", &self.token_type)
143 .field("expires_in", &self.expires_in)
144 .field("refresh_expires_in", &self.refresh_expires_in)
145 .field("scope", &self.scope)
146 .finish()
147 }
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
155pub struct AuthenticateResponse {
156 pub session: Session,
157 pub tokens: TokenBundle,
158}
159
160impl AuthenticateResponse {
161 pub fn access_expires_at_epoch(&self) -> Option<u64> {
167 let issued = chrono::DateTime::parse_from_rfc3339(&self.session.issued_at).ok()?;
168 let issued_epoch = u64::try_from(issued.timestamp()).ok()?;
169 Some(issued_epoch.saturating_add(self.tokens.expires_in))
170 }
171
172 pub fn refresh_expires_at_epoch(&self) -> Option<u64> {
176 let refresh_secs = self.tokens.refresh_expires_in?;
177 let issued = chrono::DateTime::parse_from_rfc3339(&self.session.issued_at).ok()?;
178 let issued_epoch = u64::try_from(issued.timestamp()).ok()?;
179 Some(issued_epoch.saturating_add(refresh_secs))
180 }
181}
182
183pub fn epoch_to_rfc3339(epoch_secs: u64) -> String {
188 let secs = i64::try_from(epoch_secs).unwrap_or(0);
189 chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0)
190 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
191 .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string())
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn revoke_session_request_round_trips() {
200 let req = RevokeSessionRequest {
201 session_id: "sess-abc-123".to_string(),
202 };
203 let json = serde_json::to_string(&req).unwrap();
204 assert!(json.contains("\"sessionId\":\"sess-abc-123\""), "{json}");
205 let parsed: RevokeSessionRequest = serde_json::from_str(&json).unwrap();
206 assert_eq!(parsed.session_id, "sess-abc-123");
207 }
208
209 #[test]
210 fn revoke_session_response_is_empty_object() {
211 let resp = RevokeSessionResponse::default();
212 let json = serde_json::to_string(&resp).unwrap();
213 assert_eq!(json, "{}", "empty success body");
214 }
215
216 #[test]
217 fn challenge_response_canonical_shape() {
218 let json = r#"{
219 "challenge": "nonce-bytes-base64url",
220 "sessionId": "sess-abc",
221 "expiresAt": "2026-05-23T10:02:00Z"
222 }"#;
223 let resp: ChallengeResponse = serde_json::from_str(json).unwrap();
224 assert_eq!(resp.challenge, "nonce-bytes-base64url");
225 assert_eq!(resp.session_id, "sess-abc");
226 assert_eq!(resp.expires_at, "2026-05-23T10:02:00Z");
227 assert!(resp.tee_attestation.is_none());
228 }
229
230 #[test]
231 fn challenge_response_with_tee_attestation_serialises_camel_case() {
232 let resp = ChallengeResponse {
233 challenge: "n".into(),
234 session_id: "s".into(),
235 expires_at: "2026-05-23T10:02:00Z".into(),
236 tee_attestation: Some(serde_json::json!({ "kind": "nitro" })),
237 };
238 let json = serde_json::to_value(&resp).unwrap();
239 assert_eq!(json["teeAttestation"]["kind"], "nitro");
240 assert_eq!(json["sessionId"], "s");
241 assert_eq!(json["expiresAt"], "2026-05-23T10:02:00Z");
242 }
243
244 #[test]
245 fn authenticate_response_canonical_shape() {
246 let json = r#"{
247 "session": {
248 "id": "sess-abc",
249 "subject": "did:web:alice.example",
250 "issuedAt": "2026-05-23T10:00:31Z",
251 "expiresAt": "2026-05-23T10:15:31Z",
252 "amr": ["did"],
253 "acr": "aal1"
254 },
255 "tokens": {
256 "accessToken": "eyJhbGc",
257 "refreshToken": "rt_abc",
258 "tokenType": "Bearer",
259 "expiresIn": 900,
260 "refreshExpiresIn": 86400
261 }
262 }"#;
263 let resp: AuthenticateResponse = serde_json::from_str(json).unwrap();
264 assert_eq!(resp.session.id, "sess-abc");
265 assert_eq!(resp.session.subject, "did:web:alice.example");
266 assert_eq!(resp.session.amr, vec!["did".to_string()]);
267 assert_eq!(resp.session.acr, "aal1");
268 assert_eq!(resp.tokens.access_token, "eyJhbGc");
269 assert_eq!(resp.tokens.expires_in, 900);
270 assert_eq!(resp.tokens.token_type, "Bearer");
271
272 let issued = chrono::DateTime::parse_from_rfc3339("2026-05-23T10:00:31Z").unwrap();
276 let issued_epoch = issued.timestamp() as u64;
277 assert_eq!(resp.access_expires_at_epoch(), Some(issued_epoch + 900));
278 assert_eq!(resp.refresh_expires_at_epoch(), Some(issued_epoch + 86400));
279 }
280
281 #[test]
282 fn epoch_to_rfc3339_round_trip() {
283 let epoch = 1779184831u64;
288 let s = epoch_to_rfc3339(epoch);
289 let back = chrono::DateTime::parse_from_rfc3339(&s).unwrap();
290 assert_eq!(back.timestamp() as u64, epoch);
291 }
292
293 #[test]
294 fn test_challenge_request_serialize() {
295 let req = ChallengeRequest {
296 did: "did:key:z6Mk123".to_string(),
297 };
298 let json = serde_json::to_value(&req).unwrap();
299 assert_eq!(json["subject"], "did:key:z6Mk123");
300 assert!(json.get("did").is_none());
301
302 let legacy: ChallengeRequest = serde_json::from_str(r#"{"did":"did:key:legacy"}"#).unwrap();
303 assert_eq!(legacy.did, "did:key:legacy");
304 }
305}