Skip to main content

openagent_auth_protocol/
session.rs

1// Copyright (c) 2026 L1fe Labs, Inc. — Apache-2.0 OR MIT
2//! Session state management for authenticated Core Protocol connections.
3//!
4//! Server-side state for the identity flow: a session begins when a challenge
5//! is issued and becomes usable when the proof verifies (Core Protocol
6//! Specification, Section 12).
7
8use std::collections::HashMap;
9use std::sync::{Arc, RwLock};
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13
14use crate::error::AuthProtocolError;
15
16/// Opaque session identifier (BLAKE3 of random bytes, hex-encoded).
17pub type SessionId = String;
18
19/// States a session transitions through during the handshake.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub enum SessionState {
22    /// Challenge issued; waiting for PROVE.
23    AwaitingProof,
24    /// Handshake complete; session is usable.
25    Established,
26    /// Session revoked or expired.
27    Closed,
28}
29
30/// An authenticated session between two agents.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Session {
33    /// Unique session identifier.
34    pub id: SessionId,
35    /// Current handshake state.
36    pub state: SessionState,
37    /// DID of the initiator (client).
38    pub initiator_did: String,
39    /// DID of the responder (server).
40    pub responder_did: String,
41    /// Nonce issued in the CHALLENGE step.
42    pub nonce: String,
43    /// When the session was created.
44    pub created_at: DateTime<Utc>,
45    /// When the session expires.
46    pub expires_at: DateTime<Utc>,
47    /// Capabilities granted upon establishment.
48    #[serde(default)]
49    pub capabilities: Vec<String>,
50}
51
52impl Session {
53    /// Returns `true` if the session has expired.
54    pub fn is_expired(&self) -> bool {
55        Utc::now() >= self.expires_at
56    }
57
58    /// Returns `true` if the session is established and not expired.
59    pub fn is_valid(&self) -> bool {
60        self.state == SessionState::Established && !self.is_expired()
61    }
62}
63
64/// Trait for pluggable session storage backends.
65///
66/// Implementations must be `Send + Sync` for use with async runtimes.
67#[async_trait::async_trait]
68pub trait SessionStore: Send + Sync {
69    /// Persist a new or updated session.
70    async fn put(&self, session: Session) -> Result<(), AuthProtocolError>;
71    /// Retrieve a session by its ID. Returns `None` if not found.
72    async fn get(&self, id: &str) -> Result<Option<Session>, AuthProtocolError>;
73    /// Remove a session by its ID.
74    async fn remove(&self, id: &str) -> Result<(), AuthProtocolError>;
75}
76
77/// In-memory session store — suitable for tests and single-node deployments.
78#[derive(Debug, Clone, Default)]
79pub struct InMemorySessionStore {
80    sessions: Arc<RwLock<HashMap<SessionId, Session>>>,
81}
82
83impl InMemorySessionStore {
84    /// Creates a new empty session store.
85    pub fn new() -> Self {
86        Self::default()
87    }
88}
89
90#[async_trait::async_trait]
91impl SessionStore for InMemorySessionStore {
92    async fn put(&self, session: Session) -> Result<(), AuthProtocolError> {
93        let mut map = self
94            .sessions
95            .write()
96            .map_err(|e| AuthProtocolError::Internal(format!("session lock poisoned: {e}")))?;
97        map.insert(session.id.clone(), session);
98        Ok(())
99    }
100
101    async fn get(&self, id: &str) -> Result<Option<Session>, AuthProtocolError> {
102        let map = self
103            .sessions
104            .read()
105            .map_err(|e| AuthProtocolError::Internal(format!("session lock poisoned: {e}")))?;
106        Ok(map.get(id).cloned())
107    }
108
109    async fn remove(&self, id: &str) -> Result<(), AuthProtocolError> {
110        let mut map = self
111            .sessions
112            .write()
113            .map_err(|e| AuthProtocolError::Internal(format!("session lock poisoned: {e}")))?;
114        map.remove(id);
115        Ok(())
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[tokio::test]
124    async fn put_and_get_session() {
125        let store = InMemorySessionStore::new();
126        let session = Session {
127            id: "sess-001".into(),
128            state: SessionState::AwaitingProof,
129            initiator_did: "did:oas:test:agent:alice".into(),
130            responder_did: "did:oas:test:agent:bob".into(),
131            nonce: "test-nonce".into(),
132            created_at: Utc::now(),
133            expires_at: Utc::now() + chrono::Duration::hours(1),
134            capabilities: vec![],
135        };
136        store.put(session.clone()).await.unwrap();
137        let retrieved = store.get("sess-001").await.unwrap();
138        assert!(retrieved.is_some());
139        assert_eq!(retrieved.unwrap().initiator_did, "did:oas:test:agent:alice");
140    }
141
142    #[tokio::test]
143    async fn get_missing_session_returns_none() {
144        let store = InMemorySessionStore::new();
145        let result = store.get("nonexistent").await.unwrap();
146        assert!(result.is_none());
147    }
148
149    #[tokio::test]
150    async fn remove_session() {
151        let store = InMemorySessionStore::new();
152        let session = Session {
153            id: "sess-002".into(),
154            state: SessionState::Established,
155            initiator_did: "did:oas:test:agent:alice".into(),
156            responder_did: "did:oas:test:agent:bob".into(),
157            nonce: "nonce".into(),
158            created_at: Utc::now(),
159            expires_at: Utc::now() + chrono::Duration::hours(1),
160            capabilities: vec![],
161        };
162        store.put(session).await.unwrap();
163        store.remove("sess-002").await.unwrap();
164        assert!(store.get("sess-002").await.unwrap().is_none());
165    }
166
167    #[test]
168    fn expired_session_is_detected() {
169        let session = Session {
170            id: "sess-003".into(),
171            state: SessionState::Established,
172            initiator_did: "did:oas:test:agent:alice".into(),
173            responder_did: "did:oas:test:agent:bob".into(),
174            nonce: "nonce".into(),
175            created_at: Utc::now() - chrono::Duration::hours(2),
176            expires_at: Utc::now() - chrono::Duration::hours(1),
177            capabilities: vec![],
178        };
179        assert!(session.is_expired());
180        assert!(!session.is_valid());
181    }
182
183    #[test]
184    fn valid_session_passes_check() {
185        let session = Session {
186            id: "sess-004".into(),
187            state: SessionState::Established,
188            initiator_did: "did:oas:test:agent:alice".into(),
189            responder_did: "did:oas:test:agent:bob".into(),
190            nonce: "nonce".into(),
191            created_at: Utc::now(),
192            expires_at: Utc::now() + chrono::Duration::hours(1),
193            capabilities: vec![],
194        };
195        assert!(!session.is_expired());
196        assert!(session.is_valid());
197    }
198}