Skip to main content

session_guard/guard/
outcome.rs

1//! What guard operations return.
2
3use std::fmt;
4
5use chrono::{DateTime, Utc};
6use uuid::Uuid;
7
8use secrecy::{ExposeSecret, SecretString};
9use zeroize::Zeroizing;
10
11use crate::{event::SessionOrigin, risk::Assessment, store::SessionRecord, subject::Subject};
12
13/// An authenticated session, as handed to the app.
14#[derive(Clone, Debug, PartialEq, Eq)]
15#[non_exhaustive]
16pub struct AuthSession<S> {
17    /// Session id. Safe to log; key per-session app data by it.
18    pub session_id: Uuid,
19    /// Who the session belongs to.
20    pub subject: S,
21    /// When the session was created.
22    pub created_at: DateTime<Utc>,
23    /// When the subject last proved who they are (login or completed step-up).
24    pub authenticated_at: DateTime<Utc>,
25    /// Absolute expiry.
26    pub expires_at: DateTime<Utc>,
27}
28
29impl<S> AuthSession<S> {
30    pub(crate) fn new(session: &SessionRecord, subject: S) -> Self {
31        Self {
32            session_id: session.id,
33            subject,
34            created_at: session.created_at,
35            authenticated_at: session.authenticated_at,
36            expires_at: session.expires_at,
37        }
38    }
39}
40
41/// A freshly issued token and the `Set-Cookie` value that delivers it.
42///
43/// `Debug` hides the token.
44#[derive(Clone)]
45pub struct IssuedToken {
46    /// The cookie value: the configured prefix, if any, then the token.
47    pub(crate) value: SecretString,
48    pub(crate) expires_at: DateTime<Utc>,
49    /// Holds the token too, so it is wiped on drop like `value`.
50    pub(crate) set_cookie: Zeroizing<String>,
51}
52
53impl IssuedToken {
54    /// The cookie value: the configured token prefix, if any, followed by 43 base64url
55    /// characters. Send it only in the cookie.
56    pub fn expose(&self) -> &str {
57        self.value.expose_secret()
58    }
59
60    /// When the session, and so the cookie, expires.
61    pub fn expires_at(&self) -> DateTime<Utc> {
62        self.expires_at
63    }
64
65    /// The `Set-Cookie` header value. `Max-Age` counts from the guard's clock at issue time.
66    pub fn set_cookie(&self) -> &str {
67        &self.set_cookie
68    }
69}
70
71impl fmt::Debug for IssuedToken {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        f.debug_struct("IssuedToken")
74            .field("token", &"<redacted>")
75            .field("expires_at", &self.expires_at)
76            .finish_non_exhaustive()
77    }
78}
79
80/// Result of [`Guard::login`](crate::Guard::login).
81#[derive(Clone, Debug)]
82#[non_exhaustive]
83pub struct NewSession {
84    /// The new session's id.
85    pub session_id: Uuid,
86    /// Its first token. Send [`IssuedToken::set_cookie`] with the response.
87    pub token: IssuedToken,
88}
89
90/// Result of [`Guard::authenticate`](crate::Guard::authenticate).
91#[derive(Clone, Debug)]
92pub enum Outcome<S> {
93    /// The request may proceed.
94    Authenticated(Authenticated<S>),
95    /// The request must be answered with 401.
96    Rejected(Rejection),
97}
98
99impl<S> Outcome<S> {
100    /// The session, if authenticated.
101    pub fn session(&self) -> Option<&AuthSession<S>> {
102        match self {
103            Outcome::Authenticated(a) => Some(&a.session),
104            Outcome::Rejected(_) => None,
105        }
106    }
107
108    /// The rejection, if rejected.
109    pub fn rejection(&self) -> Option<Rejection> {
110        match self {
111            Outcome::Authenticated(_) => None,
112            Outcome::Rejected(r) => Some(*r),
113        }
114    }
115}
116
117/// An authenticated request.
118#[derive(Clone, Debug)]
119#[non_exhaustive]
120pub struct Authenticated<S> {
121    /// The session.
122    pub session: AuthSession<S>,
123    /// A replacement token to send with the response, when the token was rotated.
124    pub rotated: Option<IssuedToken>,
125    /// The risk assessment. Non-empty signals on an authenticated request mean the decision was
126    /// recorded but not enforced (shadow mode or [`StepUpPolicy::Allow`](crate::StepUpPolicy)).
127    pub assessment: Assessment,
128}
129
130/// Why a request was rejected. Every variant maps to 401.
131#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
132#[non_exhaustive]
133pub enum Rejection {
134    /// No token, a malformed one, or one the store does not know.
135    NotAuthenticated,
136    /// The session passed its absolute or idle expiry and was deleted.
137    Expired,
138    /// The session was revoked for token reuse or risk, and deleted.
139    Revoked,
140    /// The session is waiting for step-up. Keep the cookie and call
141    /// [`Guard::complete_step_up`](crate::Guard::complete_step_up) after re-verifying the
142    /// subject.
143    StepUpRequired,
144}
145
146impl Rejection {
147    /// The `error` value of the 401 JSON body.
148    pub const fn as_str(self) -> &'static str {
149        match self {
150            Rejection::NotAuthenticated => "not_authenticated",
151            Rejection::Expired => "session_expired",
152            Rejection::Revoked => "session_revoked",
153            Rejection::StepUpRequired => "step_up_required",
154        }
155    }
156
157    /// Whether the response should delete the cookie. Everything but step-up does.
158    pub const fn clears_cookie(self) -> bool {
159        !matches!(self, Rejection::StepUpRequired)
160    }
161
162    /// Whether the response should also send `Clear-Site-Data: "cookies"`.
163    pub const fn clears_site_data(self) -> bool {
164        matches!(self, Rejection::Revoked)
165    }
166}
167
168impl fmt::Display for Rejection {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        f.write_str(self.as_str())
171    }
172}
173
174/// Result of [`Guard::complete_step_up`](crate::Guard::complete_step_up).
175#[derive(Clone, Debug)]
176pub enum StepUpOutcome {
177    /// The session is trusted again from the current client. Send the new token.
178    Completed(IssuedToken),
179    /// The verified subject is not the session's subject. Nothing changed.
180    SubjectMismatch,
181    /// The token does not lead to a live session.
182    Rejected(Rejection),
183}
184
185/// A session as listed by [`Guard::list_sessions`](crate::Guard::list_sessions) or returned by
186/// [`Guard::peek`](crate::Guard::peek), for "your devices" pages.
187#[derive(Clone, Debug, PartialEq, Eq)]
188#[non_exhaustive]
189pub struct SessionInfo<S> {
190    /// Session id.
191    pub session_id: Uuid,
192    /// Who the session belongs to.
193    pub subject: S,
194    /// When it was created.
195    pub created_at: DateTime<Utc>,
196    /// When the subject last proved who they are.
197    pub authenticated_at: DateTime<Utc>,
198    /// Last request (written at most once per write interval).
199    pub last_seen_at: DateTime<Utc>,
200    /// Absolute expiry.
201    pub expires_at: DateTime<Utc>,
202    /// Whether the session is waiting for step-up.
203    pub step_up_required: bool,
204    /// Browser family of the session's client.
205    pub browser: Option<String>,
206    /// Operating system family of the session's client.
207    pub os: Option<String>,
208    /// Country of the session's client, if an IP intelligence source is configured.
209    pub country: Option<String>,
210    /// Where the session started. `None` for sessions created before 0.2.
211    pub origin: Option<SessionOrigin>,
212}
213
214impl<S: Subject> SessionInfo<S> {
215    pub(crate) fn from_record(record: &SessionRecord) -> Option<Self> {
216        let fp = &record.baseline.fingerprint;
217        Some(Self {
218            session_id: record.id,
219            subject: crate::subject::decode(&record.subject)?,
220            created_at: record.created_at,
221            authenticated_at: record.authenticated_at,
222            last_seen_at: record.last_seen_at,
223            expires_at: record.expires_at,
224            step_up_required: record.step_up_required,
225            browser: fp.browser.clone(),
226            os: fp.os.clone(),
227            country: fp.country.clone(),
228            origin: record.origin.clone(),
229        })
230    }
231}
232
233/// Result of [`Guard::purge_expired`](crate::Guard::purge_expired).
234#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
235#[non_exhaustive]
236pub struct PurgeReport {
237    /// Expired sessions deleted.
238    pub sessions: u64,
239    /// Events older than the retention period deleted.
240    pub events: u64,
241}