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)]
57#[serde(rename_all = "camelCase")]
58#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
59pub struct RevokeSessionResponse {
60 pub revoked_count: u64,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
73pub struct ChallengeResponse {
74 pub challenge: String,
76 pub session_id: String,
79 pub expires_at: String,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub tee_attestation: Option<serde_json::Value>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(rename_all = "camelCase")]
100#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
101pub struct Session {
102 pub id: String,
103 pub subject: String,
104 pub issued_at: String,
106 pub expires_at: String,
108 #[serde(default, skip_serializing_if = "Vec::is_empty")]
109 pub amr: Vec<String>,
110 #[serde(default, skip_serializing_if = "String::is_empty")]
111 pub acr: String,
112}
113
114#[derive(Clone, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase")]
122#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
123pub struct TokenBundle {
124 pub access_token: String,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub refresh_token: Option<String>,
127 pub token_type: String,
128 pub expires_in: u64,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub refresh_expires_in: Option<u64>,
131 #[serde(default, skip_serializing_if = "Vec::is_empty")]
132 pub scope: Vec<String>,
133}
134
135impl std::fmt::Debug for TokenBundle {
141 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142 f.debug_struct("TokenBundle")
143 .field("access_token", &"<redacted>")
144 .field(
145 "refresh_token",
146 &self.refresh_token.as_ref().map(|_| "<redacted>"),
147 )
148 .field("token_type", &self.token_type)
149 .field("expires_in", &self.expires_in)
150 .field("refresh_expires_in", &self.refresh_expires_in)
151 .field("scope", &self.scope)
152 .finish()
153 }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase")]
160#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
161pub struct AuthenticateResponse {
162 pub session: Session,
163 pub tokens: TokenBundle,
164}
165
166impl AuthenticateResponse {
167 pub fn access_expires_at_epoch(&self) -> Option<u64> {
173 let issued = chrono::DateTime::parse_from_rfc3339(&self.session.issued_at).ok()?;
174 let issued_epoch = u64::try_from(issued.timestamp()).ok()?;
175 Some(issued_epoch.saturating_add(self.tokens.expires_in))
176 }
177
178 pub fn refresh_expires_at_epoch(&self) -> Option<u64> {
182 let refresh_secs = self.tokens.refresh_expires_in?;
183 let issued = chrono::DateTime::parse_from_rfc3339(&self.session.issued_at).ok()?;
184 let issued_epoch = u64::try_from(issued.timestamp()).ok()?;
185 Some(issued_epoch.saturating_add(refresh_secs))
186 }
187}
188
189pub fn epoch_to_rfc3339(epoch_secs: u64) -> String {
194 let secs = i64::try_from(epoch_secs).unwrap_or(0);
195 chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0)
196 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
197 .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string())
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn revoke_session_request_round_trips() {
206 let req = RevokeSessionRequest {
207 session_id: "sess-abc-123".to_string(),
208 };
209 let json = serde_json::to_string(&req).unwrap();
210 assert!(json.contains("\"sessionId\":\"sess-abc-123\""), "{json}");
211 let parsed: RevokeSessionRequest = serde_json::from_str(&json).unwrap();
212 assert_eq!(parsed.session_id, "sess-abc-123");
213 }
214
215 #[test]
216 fn revoke_session_response_carries_the_canonical_count() {
217 let resp = RevokeSessionResponse { revoked_count: 1 };
218 let json = serde_json::to_string(&resp).unwrap();
219 assert_eq!(json, r#"{"revokedCount":1}"#, "canonical member name");
220 }
221
222 #[test]
223 fn challenge_response_canonical_shape() {
224 let json = r#"{
225 "challenge": "nonce-bytes-base64url",
226 "sessionId": "sess-abc",
227 "expiresAt": "2026-05-23T10:02:00Z"
228 }"#;
229 let resp: ChallengeResponse = serde_json::from_str(json).unwrap();
230 assert_eq!(resp.challenge, "nonce-bytes-base64url");
231 assert_eq!(resp.session_id, "sess-abc");
232 assert_eq!(resp.expires_at, "2026-05-23T10:02:00Z");
233 assert!(resp.tee_attestation.is_none());
234 }
235
236 #[test]
237 fn challenge_response_with_tee_attestation_serialises_camel_case() {
238 let resp = ChallengeResponse {
239 challenge: "n".into(),
240 session_id: "s".into(),
241 expires_at: "2026-05-23T10:02:00Z".into(),
242 tee_attestation: Some(serde_json::json!({ "kind": "nitro" })),
243 };
244 let json = serde_json::to_value(&resp).unwrap();
245 assert_eq!(json["teeAttestation"]["kind"], "nitro");
246 assert_eq!(json["sessionId"], "s");
247 assert_eq!(json["expiresAt"], "2026-05-23T10:02:00Z");
248 }
249
250 #[test]
251 fn authenticate_response_canonical_shape() {
252 let json = r#"{
253 "session": {
254 "id": "sess-abc",
255 "subject": "did:web:alice.example",
256 "issuedAt": "2026-05-23T10:00:31Z",
257 "expiresAt": "2026-05-23T10:15:31Z",
258 "amr": ["did"],
259 "acr": "aal1"
260 },
261 "tokens": {
262 "accessToken": "eyJhbGc",
263 "refreshToken": "rt_abc",
264 "tokenType": "Bearer",
265 "expiresIn": 900,
266 "refreshExpiresIn": 86400
267 }
268 }"#;
269 let resp: AuthenticateResponse = serde_json::from_str(json).unwrap();
270 assert_eq!(resp.session.id, "sess-abc");
271 assert_eq!(resp.session.subject, "did:web:alice.example");
272 assert_eq!(resp.session.amr, vec!["did".to_string()]);
273 assert_eq!(resp.session.acr, "aal1");
274 assert_eq!(resp.tokens.access_token, "eyJhbGc");
275 assert_eq!(resp.tokens.expires_in, 900);
276 assert_eq!(resp.tokens.token_type, "Bearer");
277
278 let issued = chrono::DateTime::parse_from_rfc3339("2026-05-23T10:00:31Z").unwrap();
282 let issued_epoch = issued.timestamp() as u64;
283 assert_eq!(resp.access_expires_at_epoch(), Some(issued_epoch + 900));
284 assert_eq!(resp.refresh_expires_at_epoch(), Some(issued_epoch + 86400));
285 }
286
287 #[test]
288 fn epoch_to_rfc3339_round_trip() {
289 let epoch = 1779184831u64;
294 let s = epoch_to_rfc3339(epoch);
295 let back = chrono::DateTime::parse_from_rfc3339(&s).unwrap();
296 assert_eq!(back.timestamp() as u64, epoch);
297 }
298
299 #[test]
300 fn test_challenge_request_serialize() {
301 let req = ChallengeRequest {
302 did: "did:key:z6Mk123".to_string(),
303 };
304 let json = serde_json::to_value(&req).unwrap();
305 assert_eq!(json["subject"], "did:key:z6Mk123");
306 assert!(json.get("did").is_none());
307
308 let legacy: ChallengeRequest = serde_json::from_str(r#"{"did":"did:key:legacy"}"#).unwrap();
309 assert_eq!(legacy.did, "did:key:legacy");
310 }
311}