Skip to main content

vta_sdk/protocols/
auth.rs

1//! VTA auth wire types.
2//!
3//! Conforms to the cross-cutting `spec/auth/*/0.1` canonical
4//! Trust-Task specs in the trusttasks-tf registry. Field names mirror
5//! OIDC Core §2 / RFC 8176 / RFC 6749 §5.1 so off-the-shelf identity
6//! libraries can deserialise the wire payloads into their native
7//! types unchanged.
8//!
9//! - [`ChallengeResponse`] mirrors `spec/auth/challenge/0.1#response`.
10//! - [`AuthenticateResponse`] mirrors
11//!   `spec/auth/authenticate/0.1#response`; carries the canonical
12//!   [`Session`] + [`TokenBundle`] structures from
13//!   `auth/_shared/0.1/`.
14//!
15//! VTA-specific extensions: [`ChallengeResponse::tee_attestation`]
16//! surfaces Nitro-Enclave attestation evidence top-level for
17//! ergonomic access; documented as a VTA extension in
18//! `docs/02-vta/tee-architecture.md`.
19
20use serde::{Deserialize, Serialize};
21
22/// Client sends to `POST /auth/challenge`.
23///
24/// Wire shape conforms to `spec/auth/challenge/0.1`: the `did` field
25/// serialises as `subject` per the canonical payload schema. The Rust
26/// identifier stays `did` for consistency with `AuthClaims.did` and
27/// the rest of the codebase. `alias = "did"` keeps clients that still
28/// send the legacy name working through one upgrade cycle.
29#[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/// Trust-task payload for `spec/auth/revoke-session/0.1` (request)
37/// — revoke a single session by id.
38///
39/// Authorisation: the caller (via `AuthClaims`) must own the session
40/// OR have `Role::Admin`. Enforced in the dispatcher handler, not the
41/// schema.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
45pub struct RevokeSessionRequest {
46    /// Identifier of the session to revoke.
47    pub session_id: String,
48}
49
50/// Trust-task payload for `spec/auth/revoke-session/0.1#response`
51/// — empty success body modelled as a struct so future fields
52/// (e.g. `revokedAt`) can be added without a wire-format version bump.
53#[derive(Debug, Clone, Serialize, Deserialize, Default)]
54#[serde(rename_all = "camelCase")]
55#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
56pub struct RevokeSessionResponse {}
57
58/// Server responds from `POST /auth/challenge`.
59///
60/// Canonical shape: `{ challenge, sessionId, expiresAt }`.
61/// `teeAttestation` is a VTA-specific top-level field documented as
62/// a vendor extension — Nitro-Enclave deployments populate it; non-
63/// TEE deployments omit it.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase")]
66#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
67pub struct ChallengeResponse {
68    /// base64url-encoded one-time nonce.
69    pub challenge: String,
70    /// Opaque session identifier the producer echoes into the matching
71    /// `authenticate` document.
72    pub session_id: String,
73    /// ISO-8601 timestamp after which the challenge MUST NOT be honored.
74    pub expires_at: String,
75    /// VTA-specific (optional): TEE attestation evidence bound to the
76    /// challenge nonce. Present when the VTA is running inside a Nitro
77    /// Enclave; proves the challenge was generated within the trusted
78    /// boundary. Absent for non-TEE deployments.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub tee_attestation: Option<serde_json::Value>,
81}
82
83/// Canonical `Session` from `spec/auth/_shared/0.1/session.schema.json`.
84///
85/// Aligns with OIDC Core §2 / RFC 8176:
86/// - `amr`: authentication method references. VTI vocabulary uses
87///   `"did"` (challenge-response), `"passkey"` (WebAuthn assertion),
88///   `"vta"` (verifiable-trust-agent approval).
89/// - `acr`: authentication context class reference. Typical values
90///   `"aal1"` (single-factor DID), `"aal2"` (second possession/
91///   biometric factor), `"aal3"` (hardware-bound second factor).
92#[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    /// ISO-8601 timestamp the session was created.
99    pub issued_at: String,
100    /// ISO-8601 timestamp the session ceases to be valid.
101    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/// Canonical `TokenBundle` from `spec/auth/_shared/0.1/tokens.schema.json`.
109///
110/// OAuth 2.0 (RFC 6749 §5.1)-shaped: `expiresIn` is seconds from
111/// issuance, not an absolute timestamp. Clients compute the absolute
112/// expiry as `now() + expires_in` immediately after issuance, or
113/// store the issuance moment alongside the bundle.
114#[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
129// Manual Debug — `access_token` and `refresh_token` are bearer
130// credentials. Any tracing or panic that captures a `TokenBundle`
131// via `{:?}` would otherwise leak them straight into logs. Serialize
132// is unchanged so the wire format / on-disk session cache still
133// round-trips.
134impl 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/// Server responds from `POST /auth/`. Conforms to
151/// `spec/auth/authenticate/0.1#response`: `{ session, tokens }`.
152#[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    /// Absolute Unix-second expiry of the access token, computed from
162    /// `session.issued_at + tokens.expires_in`. Convenience for
163    /// callers that need an epoch (e.g. JWT exp comparison, audit
164    /// logs). Returns `None` if `session.issued_at` fails to parse as
165    /// RFC 3339.
166    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    /// Absolute Unix-second expiry of the refresh token, when one was
173    /// issued. Returns `None` if no refresh token or if the issued-at
174    /// timestamp fails to parse.
175    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
183/// Convert a Unix-epoch second timestamp to the RFC 3339 / ISO-8601
184/// string the canonical wire format uses. Hot-path helper for
185/// handlers that have epoch values internally and need to emit
186/// canonical strings.
187pub 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        // Convenience helpers compute absolute epoch expiries from
273        // session.issuedAt + tokens.expiresIn. Computed-vs-asserted so
274        // the test is robust to chrono encoding choices.
275        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        // Round-trip the helper through chrono's RFC3339 parser. The
284        // exact string is `chrono`-encoding-dependent (e.g. `Z` vs
285        // `+00:00` suffix), so assert on the round-trip, not the
286        // string form.
287        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}