forked from
smokesignal.events/smokesignal
The smokesignal.events web application
1//! Cookie utilities for session management.
2//!
3//! This module provides the `SessionCookie` struct and utilities for encoding/decoding
4//! session data directly in browser cookies rather than storing tokens in the database.
5
6use anyhow::Result;
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use crate::http::errors::WebSessionError;
11
12/// Session data stored directly in the browser cookie.
13///
14/// This struct contains all the data needed to authenticate requests without
15/// requiring a database lookup. The cookie is encrypted using the HTTP_COOKIE_KEY.
16#[derive(Clone, PartialEq, Serialize, Deserialize)]
17pub(crate) struct SessionCookie {
18 /// The user's DID (Decentralized Identifier)
19 pub did: String,
20 /// The OAuth access token for AT Protocol requests
21 pub access_token: String,
22 /// The OAuth refresh token for obtaining new access tokens
23 pub refresh_token: Option<String>,
24 /// When the access token expires
25 pub expires_at: DateTime<Utc>,
26 /// The DPoP private key in did:key format for proof-of-possession
27 pub dpop_private_key: String,
28 /// The issuer (authorization server) URL
29 pub issuer: String,
30}
31
32impl SessionCookie {
33 /// Check if the access token has expired
34 pub fn is_expired(&self) -> bool {
35 Utc::now() >= self.expires_at
36 }
37
38 /// Check if the access token will expire within the given duration
39 pub fn expires_within(&self, duration: chrono::Duration) -> bool {
40 Utc::now() + duration >= self.expires_at
41 }
42}
43
44impl TryFrom<String> for SessionCookie {
45 type Error = anyhow::Error;
46
47 fn try_from(value: String) -> Result<Self, Self::Error> {
48 serde_json::from_str(&value)
49 .map_err(WebSessionError::DeserializeFailed)
50 .map_err(Into::into)
51 }
52}
53
54impl TryInto<String> for SessionCookie {
55 type Error = anyhow::Error;
56
57 fn try_into(self) -> Result<String, Self::Error> {
58 serde_json::to_string(&self)
59 .map_err(WebSessionError::SerializeFailed)
60 .map_err(Into::into)
61 }
62}
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67 use chrono::Duration;
68
69 #[test]
70 fn test_session_cookie_serialization() {
71 let session = SessionCookie {
72 did: "did:plc:test123".to_string(),
73 access_token: "test_access_token".to_string(),
74 refresh_token: Some("test_refresh_token".to_string()),
75 expires_at: Utc::now() + Duration::hours(1),
76 dpop_private_key: "did:key:test".to_string(),
77 issuer: "https://bsky.social".to_string(),
78 };
79
80 let serialized: String = session.clone().try_into().unwrap();
81 let deserialized: SessionCookie = serialized.try_into().unwrap();
82
83 assert_eq!(session.did, deserialized.did);
84 assert_eq!(session.access_token, deserialized.access_token);
85 assert_eq!(session.refresh_token, deserialized.refresh_token);
86 assert_eq!(session.dpop_private_key, deserialized.dpop_private_key);
87 assert_eq!(session.issuer, deserialized.issuer);
88 }
89
90 #[test]
91 fn test_is_expired() {
92 let expired_session = SessionCookie {
93 did: "did:plc:test123".to_string(),
94 access_token: "test".to_string(),
95 refresh_token: None,
96 expires_at: Utc::now() - Duration::hours(1),
97 dpop_private_key: "did:key:test".to_string(),
98 issuer: "https://bsky.social".to_string(),
99 };
100
101 let valid_session = SessionCookie {
102 did: "did:plc:test123".to_string(),
103 access_token: "test".to_string(),
104 refresh_token: None,
105 expires_at: Utc::now() + Duration::hours(1),
106 dpop_private_key: "did:key:test".to_string(),
107 issuer: "https://bsky.social".to_string(),
108 };
109
110 assert!(expired_session.is_expired());
111 assert!(!valid_session.is_expired());
112 }
113}