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///
52/// Canonical requires `revokedCount` — how many sessions the request ended.
53/// This VTA revokes exactly the one named session, so the count is 1 on
54/// success (#857: the previous empty `{}` body failed the published schema's
55/// required set).
56#[derive(Debug, Clone, Serialize, Deserialize, Default)]
57#[serde(rename_all = "camelCase")]
58#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
59pub struct RevokeSessionResponse {
60    /// Number of sessions revoked by this request.
61    pub revoked_count: u64,
62}
63
64/// Server responds from `POST /auth/challenge`.
65///
66/// Canonical shape: `{ challenge, sessionId, expiresAt }`.
67/// `teeAttestation` is a VTA-specific top-level field documented as
68/// a vendor extension — Nitro-Enclave deployments populate it; non-
69/// TEE deployments omit it.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase")]
72#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
73pub struct ChallengeResponse {
74    /// base64url-encoded one-time nonce.
75    pub challenge: String,
76    /// Opaque session identifier the producer echoes into the matching
77    /// `authenticate` document.
78    pub session_id: String,
79    /// ISO-8601 timestamp after which the challenge MUST NOT be honored.
80    pub expires_at: String,
81    /// VTA-specific (optional): TEE attestation evidence bound to the
82    /// challenge nonce. Present when the VTA is running inside a Nitro
83    /// Enclave; proves the challenge was generated within the trusted
84    /// boundary. Absent for non-TEE deployments.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub tee_attestation: Option<serde_json::Value>,
87}
88
89/// Canonical `Session` from `spec/auth/_shared/0.1/session.schema.json`.
90///
91/// Aligns with OIDC Core §2 / RFC 8176:
92/// - `amr`: authentication method references. VTI vocabulary uses
93///   `"did"` (challenge-response), `"passkey"` (WebAuthn assertion),
94///   `"vta"` (verifiable-trust-agent approval).
95/// - `acr`: authentication context class reference. Typical values
96///   `"aal1"` (single-factor DID), `"aal2"` (second possession/
97///   biometric factor), `"aal3"` (hardware-bound second factor).
98#[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    /// ISO-8601 timestamp the session was created.
105    pub issued_at: String,
106    /// ISO-8601 timestamp the session ceases to be valid.
107    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/// Canonical `TokenBundle` from `spec/auth/_shared/0.1/tokens.schema.json`.
115///
116/// OAuth 2.0 (RFC 6749 §5.1)-shaped: `expiresIn` is seconds from
117/// issuance, not an absolute timestamp. Clients compute the absolute
118/// expiry as `now() + expires_in` immediately after issuance, or
119/// store the issuance moment alongside the bundle.
120#[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
135// Manual Debug — `access_token` and `refresh_token` are bearer
136// credentials. Any tracing or panic that captures a `TokenBundle`
137// via `{:?}` would otherwise leak them straight into logs. Serialize
138// is unchanged so the wire format / on-disk session cache still
139// round-trips.
140impl 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/// Server responds from `POST /auth/`. Conforms to
157/// `spec/auth/authenticate/0.1#response`: `{ session, tokens }`.
158#[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    /// Absolute Unix-second expiry of the access token, computed from
168    /// `session.issued_at + tokens.expires_in`. Convenience for
169    /// callers that need an epoch (e.g. JWT exp comparison, audit
170    /// logs). Returns `None` if `session.issued_at` fails to parse as
171    /// RFC 3339.
172    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    /// Absolute Unix-second expiry of the refresh token, when one was
179    /// issued. Returns `None` if no refresh token or if the issued-at
180    /// timestamp fails to parse.
181    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
189/// Convert a Unix-epoch second timestamp to the RFC 3339 / ISO-8601
190/// string the canonical wire format uses. Hot-path helper for
191/// handlers that have epoch values internally and need to emit
192/// canonical strings.
193pub 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        // Convenience helpers compute absolute epoch expiries from
279        // session.issuedAt + tokens.expiresIn. Computed-vs-asserted so
280        // the test is robust to chrono encoding choices.
281        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        // Round-trip the helper through chrono's RFC3339 parser. The
290        // exact string is `chrono`-encoding-dependent (e.g. `Z` vs
291        // `+00:00` suffix), so assert on the round-trip, not the
292        // string form.
293        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}