use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChallengeRequest {
#[serde(rename = "subject", alias = "did")]
pub did: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RevokeSessionRequest {
pub session_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct RevokeSessionResponse {}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChallengeResponse {
pub challenge: String,
pub session_id: String,
pub expires_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tee_attestation: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
pub id: String,
pub subject: String,
pub issued_at: String,
pub expires_at: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub amr: Vec<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub acr: String,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TokenBundle {
pub access_token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
pub token_type: String,
pub expires_in: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_expires_in: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scope: Vec<String>,
}
impl std::fmt::Debug for TokenBundle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenBundle")
.field("access_token", &"<redacted>")
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "<redacted>"),
)
.field("token_type", &self.token_type)
.field("expires_in", &self.expires_in)
.field("refresh_expires_in", &self.refresh_expires_in)
.field("scope", &self.scope)
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthenticateResponse {
pub session: Session,
pub tokens: TokenBundle,
}
impl AuthenticateResponse {
pub fn access_expires_at_epoch(&self) -> Option<u64> {
let issued = chrono::DateTime::parse_from_rfc3339(&self.session.issued_at).ok()?;
let issued_epoch = u64::try_from(issued.timestamp()).ok()?;
Some(issued_epoch.saturating_add(self.tokens.expires_in))
}
pub fn refresh_expires_at_epoch(&self) -> Option<u64> {
let refresh_secs = self.tokens.refresh_expires_in?;
let issued = chrono::DateTime::parse_from_rfc3339(&self.session.issued_at).ok()?;
let issued_epoch = u64::try_from(issued.timestamp()).ok()?;
Some(issued_epoch.saturating_add(refresh_secs))
}
}
pub fn epoch_to_rfc3339(epoch_secs: u64) -> String {
let secs = i64::try_from(epoch_secs).unwrap_or(0);
chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0)
.map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
.unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn revoke_session_request_round_trips() {
let req = RevokeSessionRequest {
session_id: "sess-abc-123".to_string(),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"sessionId\":\"sess-abc-123\""), "{json}");
let parsed: RevokeSessionRequest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.session_id, "sess-abc-123");
}
#[test]
fn revoke_session_response_is_empty_object() {
let resp = RevokeSessionResponse::default();
let json = serde_json::to_string(&resp).unwrap();
assert_eq!(json, "{}", "empty success body");
}
#[test]
fn challenge_response_canonical_shape() {
let json = r#"{
"challenge": "nonce-bytes-base64url",
"sessionId": "sess-abc",
"expiresAt": "2026-05-23T10:02:00Z"
}"#;
let resp: ChallengeResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.challenge, "nonce-bytes-base64url");
assert_eq!(resp.session_id, "sess-abc");
assert_eq!(resp.expires_at, "2026-05-23T10:02:00Z");
assert!(resp.tee_attestation.is_none());
}
#[test]
fn challenge_response_with_tee_attestation_serialises_camel_case() {
let resp = ChallengeResponse {
challenge: "n".into(),
session_id: "s".into(),
expires_at: "2026-05-23T10:02:00Z".into(),
tee_attestation: Some(serde_json::json!({ "kind": "nitro" })),
};
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["teeAttestation"]["kind"], "nitro");
assert_eq!(json["sessionId"], "s");
assert_eq!(json["expiresAt"], "2026-05-23T10:02:00Z");
}
#[test]
fn authenticate_response_canonical_shape() {
let json = r#"{
"session": {
"id": "sess-abc",
"subject": "did:web:alice.example",
"issuedAt": "2026-05-23T10:00:31Z",
"expiresAt": "2026-05-23T10:15:31Z",
"amr": ["did"],
"acr": "aal1"
},
"tokens": {
"accessToken": "eyJhbGc",
"refreshToken": "rt_abc",
"tokenType": "Bearer",
"expiresIn": 900,
"refreshExpiresIn": 86400
}
}"#;
let resp: AuthenticateResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.session.id, "sess-abc");
assert_eq!(resp.session.subject, "did:web:alice.example");
assert_eq!(resp.session.amr, vec!["did".to_string()]);
assert_eq!(resp.session.acr, "aal1");
assert_eq!(resp.tokens.access_token, "eyJhbGc");
assert_eq!(resp.tokens.expires_in, 900);
assert_eq!(resp.tokens.token_type, "Bearer");
let issued = chrono::DateTime::parse_from_rfc3339("2026-05-23T10:00:31Z").unwrap();
let issued_epoch = issued.timestamp() as u64;
assert_eq!(resp.access_expires_at_epoch(), Some(issued_epoch + 900));
assert_eq!(resp.refresh_expires_at_epoch(), Some(issued_epoch + 86400));
}
#[test]
fn epoch_to_rfc3339_round_trip() {
let epoch = 1779184831u64;
let s = epoch_to_rfc3339(epoch);
let back = chrono::DateTime::parse_from_rfc3339(&s).unwrap();
assert_eq!(back.timestamp() as u64, epoch);
}
#[test]
fn test_challenge_request_serialize() {
let req = ChallengeRequest {
did: "did:key:z6Mk123".to_string(),
};
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["subject"], "did:key:z6Mk123");
assert!(json.get("did").is_none());
let legacy: ChallengeRequest = serde_json::from_str(r#"{"did":"did:key:legacy"}"#).unwrap();
assert_eq!(legacy.did, "did:key:legacy");
}
}