topcoat_session/session.rs
1use std::time::SystemTime;
2
3use topcoat_core::{context::Cx, error::Result};
4
5use crate::{TokenHash, config, state, token::Token, token_store};
6
7/// A session as the application should record it, returned by [`start`],
8/// [`refresh`], and [`rotate`].
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Session {
11 /// The hash identifying the session. Persist it next to the user it
12 /// authenticates; the raw token never needs to be stored server-side.
13 pub token_hash: TokenHash,
14 /// When the session expires. Persist it with the hash and reject the
15 /// session once the moment has passed.
16 pub expires_at: SystemTime,
17}
18
19/// The outcome of [`rotate`]: the replacement session and the hash it
20/// replaces.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Rotation {
23 /// The hash of the replaced token. Delete its record, or re-key the
24 /// record under the new session's hash.
25 pub revoked: TokenHash,
26 /// The replacement session to record.
27 pub session: Session,
28}
29
30/// Starts a new session, issuing a fresh token to the client.
31///
32/// Always generates a new token (never reuses one the request presented), so
33/// calling this on login also protects against session fixation. Record the
34/// returned [`Session`] in the application's session storage.
35///
36/// # Errors
37///
38/// Returns an error when the token store fails to issue the token.
39pub async fn start(cx: &Cx) -> Result<Session> {
40 let token = Token::random();
41 let session = Session {
42 token_hash: token.hash(),
43 expires_at: expires_at(cx),
44 };
45 token_store(cx)
46 .write(cx, token.clone(), config(cx).lifetime)
47 .await?;
48 state(cx).set(Some(token)).await;
49 Ok(session)
50}
51
52/// Stops the current session, instructing the client to discard its token.
53///
54/// Returns the hash of the stopped session so the application can delete its
55/// record, or `None` when the request carried no session.
56///
57/// # Errors
58///
59/// Returns an error when the token store fails to read or discard the token.
60pub async fn stop(cx: &Cx) -> Result<Option<TokenHash>> {
61 let hash = state(cx).token(cx).await?.map(|token| token.hash());
62 token_store(cx).delete(cx).await?;
63 state(cx).set(None).await;
64 Ok(hash)
65}
66
67/// Extends the current session's lifetime without changing its token.
68///
69/// Re-issues the presented token with a full [`lifetime`](crate::ConfigBuilder::lifetime)
70/// ahead of it, implementing sliding expiration. Returns the session with its
71/// new expiry so the application can update its record, or `None` when the
72/// request carried no session.
73///
74/// # Errors
75///
76/// Returns an error when the token store fails to read or re-issue the token.
77pub async fn refresh(cx: &Cx) -> Result<Option<Session>> {
78 let Some(token) = state(cx).token(cx).await? else {
79 return Ok(None);
80 };
81 let session = Session {
82 token_hash: token.hash(),
83 expires_at: expires_at(cx),
84 };
85 token_store(cx)
86 .write(cx, token, config(cx).lifetime)
87 .await?;
88 Ok(Some(session))
89}
90
91/// Replaces the current session's token with a fresh one.
92///
93/// Rotate after a privilege change (or periodically) so a previously leaked
94/// token stops working. Returns the [`Rotation`] describing the record to
95/// revoke and the session to record in its place, or `None` when the request
96/// carried no session.
97///
98/// # Errors
99///
100/// Returns an error when the token store fails to read or issue a token.
101pub async fn rotate(cx: &Cx) -> Result<Option<Rotation>> {
102 let Some(old) = state(cx).token(cx).await? else {
103 return Ok(None);
104 };
105 let token = Token::random();
106 let rotation = Rotation {
107 revoked: old.hash(),
108 session: Session {
109 token_hash: token.hash(),
110 expires_at: expires_at(cx),
111 },
112 };
113 token_store(cx)
114 .write(cx, token.clone(), config(cx).lifetime)
115 .await?;
116 state(cx).set(Some(token)).await;
117 Ok(Some(rotation))
118}
119
120/// Returns the hash identifying the current request's session, or `None`
121/// when the request carries no (valid) token.
122///
123/// Look the hash up in the application's session storage to resolve the
124/// session; a hash the storage does not contain (or whose record has
125/// expired) is not an authenticated session.
126///
127/// # Errors
128///
129/// Returns an error when the token store fails to read the token.
130pub async fn token_hash(cx: &Cx) -> Result<Option<TokenHash>> {
131 Ok(state(cx).token(cx).await?.map(|token| token.hash()))
132}
133
134fn expires_at(cx: &Cx) -> SystemTime {
135 SystemTime::now() + config(cx).lifetime
136}