Skip to main content

sofie_http/
session.rs

1use std::sync::{Arc, Mutex};
2
3use http::header::SET_COOKIE;
4use http::{HeaderMap, HeaderValue};
5use sofie_auth::{OpenError, SessionClaims, SigningKeys};
6use sofie_core::{Result, Timestamp, Uuid, unpoisoned};
7#[cfg(any(
8    feature = "permissions",
9    feature = "tokens",
10    feature = "shield",
11    feature = "session-list"
12))]
13use sofie_db::BoxFuture;
14use sofie_db::{Id, Resolve};
15
16use crate::answer::{self, Response};
17use crate::caller::Caller;
18use crate::cookie::CookieNames;
19use crate::{Area, PrincipalRecord, RequestContext};
20
21pub trait AreaMembers: Send + Sync + 'static {
22    #[cfg(feature = "permissions")]
23    fn grants<'a>(
24        &'a self,
25        read: &'a sofie_db::Read,
26        area: &'static str,
27        principal: Uuid,
28    ) -> BoxFuture<'a, Result<Vec<String>>> {
29        let _ = (read, area, principal);
30        Box::pin(std::future::ready(Ok(Vec::new())))
31    }
32
33    #[cfg(feature = "tokens")]
34    fn bearer<'a>(
35        &'a self,
36        db: &'a sofie_db::Db,
37        area: &'static str,
38        digest: &'a str,
39    ) -> BoxFuture<'a, Result<Option<crate::Bearer>>> {
40        let _ = (db, area, digest);
41        Box::pin(std::future::ready(Ok(None)))
42    }
43
44    #[cfg(feature = "shield")]
45    fn sign_in_locked<'a>(
46        &'a self,
47        db: &'a sofie_db::Db,
48        account: Uuid,
49        until: Timestamp,
50    ) -> BoxFuture<'a, Result<()>> {
51        let _ = (db, account, until);
52        Box::pin(std::future::ready(Ok(())))
53    }
54
55    /// Asked for a session a request's cookie names when the cache does not hold it, and
56    /// whenever the cookie is refreshed (`seen` set, at most every five minutes). A session no
57    /// longer live is signed out.
58    #[cfg(feature = "session-list")]
59    fn session_record<'a>(
60        &'a self,
61        db: &'a sofie_db::Db,
62        session: Uuid,
63        seen: Option<Timestamp>,
64    ) -> BoxFuture<'a, Result<SessionRecord>> {
65        let _ = (db, session, seen);
66        Box::pin(std::future::ready(Ok(SessionRecord::Unrecorded)))
67    }
68
69    /// The table of `session_record`'s records, written each time one ends, owned by its
70    /// account. With it, records are cached until such a write, here or on another instance.
71    #[cfg(feature = "session-list")]
72    fn session_topic(&self) -> Option<sofie_db::Topic> {
73        None
74    }
75}
76
77/// What a session store holds of a session.
78#[cfg(feature = "session-list")]
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub enum SessionRecord {
81    /// Signed in before the store recorded sessions: live until its cookie expires.
82    Unrecorded,
83    Live {
84        account: Uuid,
85        /// Ends by itself then, as an impersonation's does.
86        expires_at: Option<sofie_core::Instant>,
87        /// The account signed in as `account`; each request it makes is logged.
88        impersonator: Option<Uuid>,
89    },
90    Ended,
91}
92
93#[cfg(feature = "session-list")]
94impl SessionRecord {
95    #[must_use]
96    pub fn is_live(self, now: sofie_core::Instant) -> bool {
97        match self {
98            Self::Unrecorded => true,
99            Self::Live { expires_at, .. } => expires_at.is_none_or(|at| at > now),
100            Self::Ended => false,
101        }
102    }
103}
104
105/// Changes reach the cookie only once the handler's outcome has committed.
106pub struct Session {
107    changes: Arc<Mutex<Changes>>,
108    current: Option<Uuid>,
109    #[cfg(feature = "flows")]
110    flow: Option<String>,
111    #[cfg(feature = "client-headers")]
112    client: Client,
113}
114
115/// What a client says of itself: untrusted, read only by handlers that ask.
116#[cfg(feature = "client-headers")]
117#[derive(Clone, Default)]
118struct Client {
119    user_agent: Option<HeaderValue>,
120    origin: Option<HeaderValue>,
121}
122
123#[cfg(feature = "client-headers")]
124impl Client {
125    fn of(request: &http::request::Parts) -> Self {
126        let origin = request.headers.get(http::header::ORIGIN).filter(|origin| {
127            crate::csrf::host_of(request)
128                .is_some_and(|host| crate::csrf::is_origin_of(origin, host))
129        });
130        Self {
131            user_agent: request.headers.get(http::header::USER_AGENT).cloned(),
132            origin: origin.cloned(),
133        }
134    }
135}
136
137#[derive(Default)]
138struct Changes {
139    requested: Option<Change>,
140    #[cfg(feature = "flows")]
141    begun_flow: Option<String>,
142    committed: bool,
143}
144
145#[derive(Clone, Copy)]
146enum Change {
147    SignIn {
148        session: Uuid,
149        area: &'static str,
150        principal: Uuid,
151        /// A session signed back in keeps when it first signed in; a new one signs in now.
152        issued_at: Option<Timestamp>,
153    },
154    SignOut,
155}
156
157/// Implemented by a private type of each package whose job is signing principals in, such as
158/// sofie-identity's: it alone mints the `SignInRight` that `Session::sign_in` takes. Code that
159/// implements it for a type of its own can sign anyone in, so only such a package should.
160pub trait Admitter {}
161
162/// The right to sign a principal in, minted only for an `Admitter`; zero-sized, so carrying it
163/// costs nothing. It cannot be built any other way:
164///
165/// ```compile_fail,E0451
166/// let right = sofie_http::SignInRight { sealed: () };
167/// ```
168///
169/// ```compile_fail,E0277
170/// struct Visitor;
171/// let right = sofie_http::SignInRight::of::<Visitor>();
172/// ```
173#[derive(Clone, Copy)]
174pub struct SignInRight {
175    sealed: (),
176}
177
178const _: () = assert!(size_of::<SignInRight>() == 0);
179
180impl SignInRight {
181    #[must_use]
182    pub const fn of<K: Admitter>() -> Self {
183        Self { sealed: () }
184    }
185}
186
187impl Session {
188    /// Issues a fresh cookie, so no session value from before signing in stays valid; whether
189    /// the principal may enter the area is the caller's to check. An action without a
190    /// `SignInRight` cannot sign anyone in:
191    ///
192    /// ```compile_fail,E0061
193    /// # use sofie_http::{Area, PrincipalRecord, Session};
194    /// fn sign_in_anyone<A: Area>(session: &Session, anyone: sofie_db::Id<PrincipalRecord<A>>) {
195    ///     let _ = session.sign_in::<A>(anyone);
196    /// }
197    /// ```
198    pub fn sign_in<A: Area>(
199        &self,
200        right: SignInRight,
201        principal: Id<PrincipalRecord<A>>,
202    ) -> Result<Uuid> {
203        let SignInRight { sealed: () } = right;
204        let session = Uuid::new_v4()
205            .map_err(|error| sofie_core::Error::internal("choosing a session id", error))?;
206        #[cfg(feature = "product-events")]
207        sofie_telemetry::attach_principal(&tracing::Span::current(), principal.uuid());
208        self.request(Change::SignIn {
209            session,
210            area: A::NAME,
211            principal: principal.uuid(),
212            issued_at: None,
213        });
214        Ok(session)
215    }
216
217    /// Under the session's own id and first sign-in, so a recent sign-in check does not trust it anew.
218    pub fn resume<A: Area>(
219        &self,
220        right: SignInRight,
221        session: Uuid,
222        principal: Id<PrincipalRecord<A>>,
223        issued_at: Timestamp,
224    ) {
225        let SignInRight { sealed: () } = right;
226        #[cfg(feature = "product-events")]
227        sofie_telemetry::attach_principal(&tracing::Span::current(), principal.uuid());
228        self.request(Change::SignIn {
229            session,
230            area: A::NAME,
231            principal: principal.uuid(),
232            issued_at: Some(issued_at),
233        });
234    }
235
236    #[must_use]
237    pub fn current(&self) -> Option<Uuid> {
238        self.current
239    }
240
241    /// As the client sent it: untrusted, for showing a session's device to its owner.
242    #[cfg(feature = "client-headers")]
243    #[must_use]
244    pub fn user_agent(&self) -> Option<std::borrow::Cow<'_, str>> {
245        let value = self.client.user_agent.as_ref()?;
246        Some(String::from_utf8_lossy(value.as_bytes()))
247    }
248
249    /// The `Origin` the browser sent, when it names the host the request was sent to.
250    #[cfg(feature = "client-headers")]
251    #[must_use]
252    pub fn origin(&self) -> Option<&str> {
253        self.client.origin.as_ref()?.to_str().ok()
254    }
255
256    pub fn sign_out(&self) {
257        self.request(Change::SignOut);
258    }
259
260    /// Ties a flow through another site, such as an OAuth sign-in, to this browser: the
261    /// returned value goes into the flow's state, and `flow` reads it back on return.
262    #[cfg(feature = "flows")]
263    pub fn begin_flow(&self) -> Result<String> {
264        let value = Uuid::new_v4()
265            .map_err(|error| sofie_core::Error::internal("choosing a flow's value", error))?
266            .to_string();
267        unpoisoned::lock(&self.changes).begun_flow = Some(value.clone());
268        Ok(value)
269    }
270
271    #[cfg(feature = "flows")]
272    #[must_use]
273    pub fn flow(&self) -> Option<&str> {
274        self.flow.as_deref()
275    }
276
277    fn request(&self, change: Change) {
278        unpoisoned::lock(&self.changes).requested = Some(change);
279    }
280}
281
282impl<A: Area> Resolve<RequestContext<A>> for Session {
283    fn resolve(context: &mut RequestContext<A>) -> impl Future<Output = Result<Self>> + Send {
284        let session = context.session();
285        std::future::ready(Ok(Self {
286            changes: Arc::clone(&session.changes),
287            current: session.recorded,
288            #[cfg(feature = "flows")]
289            flow: session.flow.clone(),
290            #[cfg(feature = "client-headers")]
291            client: session.client.clone(),
292        }))
293    }
294}
295
296/// The session the request's cookie carries, read in a page's loader as in an action.
297pub struct CurrentSession {
298    id: Option<Uuid>,
299    account: Option<Uuid>,
300}
301
302impl CurrentSession {
303    #[must_use]
304    pub fn id(&self) -> Option<Uuid> {
305        self.id
306    }
307
308    /// The account its record signed in, as confirmed live for this request; `None` when no
309    /// record was confirmed.
310    #[must_use]
311    pub fn account(&self) -> Option<Uuid> {
312        self.account
313    }
314}
315
316impl<A: Area, M> Resolve<RequestContext<A, M>> for CurrentSession {
317    fn resolve(context: &mut RequestContext<A, M>) -> impl Future<Output = Result<Self>> + Send {
318        let session = context.session();
319        std::future::ready(Ok(Self {
320            id: session.recorded,
321            #[cfg(feature = "session-list")]
322            account: session.account,
323            #[cfg(not(feature = "session-list"))]
324            account: None,
325        }))
326    }
327}
328
329pub(crate) struct Sessions {
330    pub(crate) keys: Arc<SigningKeys>,
331    #[cfg(any(feature = "tokens", feature = "session-list"))]
332    pub(crate) members: Option<Arc<dyn AreaMembers>>,
333    pub(crate) cookies: CookieNames,
334    #[cfg(feature = "session-list")]
335    pub(crate) records: crate::session_cache::SessionCache,
336}
337
338impl Sessions {
339    pub(crate) fn open(&self, headers: &HeaderMap, now: Timestamp) -> RequestSession {
340        let sealed = self.cookies.session_value(headers);
341        let claims = sealed.and_then(|sealed| opened(&self.keys, sealed, now));
342        let is_dead = sealed.is_some() && claims.is_none();
343        let recorded = claims.as_ref().map(SessionClaims::session);
344        #[cfg(all(feature = "toolbar", debug_assertions))]
345        let toolbar = crate::toolbar::Overrides::of(headers, self.cookies.toolbar());
346        #[cfg(all(feature = "toolbar", debug_assertions))]
347        let (claims, recorded) = match toolbar.claims(now) {
348            Some(acting) => (Some(acting), None),
349            None => (claims, recorded),
350        };
351        RequestSession {
352            claims,
353            recorded,
354            #[cfg(feature = "flows")]
355            flow: self.cookies.flow_value(headers).map(str::to_owned),
356            is_dead,
357            #[cfg(all(feature = "toolbar", debug_assertions))]
358            toolbar,
359            ..RequestSession::default()
360        }
361    }
362}
363
364fn opened(keys: &SigningKeys, sealed: &str, now: Timestamp) -> Option<SessionClaims> {
365    SessionClaims::open(keys, sealed, now)
366        .map_err(log_unopened)
367        .ok()
368}
369
370/// A client can send any cookie, so only a corrupt one, which our own keys sealed, is an error.
371fn log_unopened(error: OpenError) {
372    let refused = match error {
373        OpenError::Malformed => "malformed",
374        OpenError::Unauthenticated => "unauthenticated",
375        OpenError::Expired => "expired",
376        OpenError::Corrupt => "corrupt",
377    };
378    match error {
379        OpenError::Malformed | OpenError::Expired => {
380            tracing::debug!(session.refused = refused, "the session cookie did not open");
381        }
382        OpenError::Unauthenticated => {
383            tracing::info!(session.refused = refused, "the session cookie did not open");
384        }
385        OpenError::Corrupt => {
386            tracing::error!(error.kind = "session", session.refused = refused, error = %error, "the session cookie did not open");
387        }
388    }
389}
390
391#[derive(Clone, Default)]
392pub(crate) struct RequestSession {
393    claims: Option<SessionClaims>,
394    /// The cookie's session, when the claims are the cookie's own.
395    recorded: Option<Uuid>,
396    /// The account the recorded session signed in, once its record is confirmed live.
397    #[cfg(feature = "session-list")]
398    account: Option<Uuid>,
399    #[cfg(feature = "flows")]
400    flow: Option<String>,
401    #[cfg(feature = "client-headers")]
402    client: Client,
403    /// Tampered, sealed by a retired key or expired; it is cleared.
404    is_dead: bool,
405    changes: Arc<Mutex<Changes>>,
406    /// A bearer token's; its request never writes a cookie.
407    #[cfg(feature = "tokens")]
408    scopes: Option<Arc<[String]>>,
409    /// Its principal, when acting as one, has replaced the signed-in one.
410    #[cfg(all(feature = "toolbar", debug_assertions))]
411    pub(crate) toolbar: crate::toolbar::Overrides,
412}
413
414impl RequestSession {
415    #[cfg(feature = "tokens")]
416    pub(crate) fn bearer(area: &str, bearer: crate::Bearer, now: Timestamp) -> Self {
417        let claims = SessionClaims::new(bearer.token, area, bearer.principal, now);
418        Self {
419            claims: Some(claims),
420            scopes: Some(bearer.scopes.into()),
421            ..Self::default()
422        }
423    }
424
425    #[cfg(feature = "engine")]
426    pub(crate) fn for_loader(&self) -> Self {
427        Self {
428            changes: Arc::default(),
429            ..self.clone()
430        }
431    }
432
433    #[cfg(feature = "client-headers")]
434    pub(crate) fn with_client(self, request: &http::request::Parts) -> Self {
435        Self {
436            client: Client::of(request),
437            ..self
438        }
439    }
440
441    #[cfg(feature = "session-list")]
442    pub(crate) async fn confirmed(
443        mut self,
444        sessions: &Sessions,
445        db: &sofie_db::Db,
446        now: Timestamp,
447    ) -> Result<Self> {
448        let (Some(session), Some(members)) = (self.recorded, sessions.members.as_deref()) else {
449            return Ok(self);
450        };
451        let seen = self
452            .claims
453            .as_ref()
454            .is_some_and(|claims| claims.needs_refresh(now))
455            .then_some(now);
456        match sessions.records.live(members, db, session, seen).await? {
457            Some(SessionRecord::Live {
458                account,
459                impersonator,
460                ..
461            }) => {
462                self.account = Some(account);
463                if let Some(impersonator) = impersonator {
464                    tracing::info!(
465                        impersonation.account = %account,
466                        impersonation.impersonator = %impersonator,
467                        "a request made while impersonating"
468                    );
469                }
470            }
471            Some(_) => {}
472            None => {
473                self.claims = None;
474                self.recorded = None;
475                self.is_dead = true;
476            }
477        }
478        Ok(self)
479    }
480
481    #[cfg(feature = "tokens")]
482    pub(crate) fn scopes(&self) -> Option<Arc<[String]>> {
483        self.scopes.clone()
484    }
485
486    #[cfg_attr(
487        not(feature = "tokens"),
488        expect(clippy::unused_self, reason = "only a token opens a bearer session")
489    )]
490    pub(crate) fn is_bearer(&self) -> bool {
491        #[cfg(feature = "tokens")]
492        return self.scopes.is_some();
493        #[cfg(not(feature = "tokens"))]
494        false
495    }
496
497    pub(crate) fn principal(&self) -> Option<Uuid> {
498        self.claims.as_ref().map(SessionClaims::principal)
499    }
500
501    #[cfg(feature = "remote-contracts")]
502    pub(crate) fn acting(&self) -> Option<sofie_auth::Acting> {
503        self.claims.as_ref().map(|claims| sofie_auth::Acting {
504            area: claims.area().to_owned(),
505            principal: claims.principal(),
506        })
507    }
508
509    pub(crate) fn principal_in(&self, area: &str) -> Option<Uuid> {
510        self.claims
511            .as_ref()
512            .filter(|claims| claims.area() == area)
513            .map(SessionClaims::principal)
514    }
515
516    pub(crate) fn committed(&self) {
517        unpoisoned::lock(&self.changes).committed = true;
518    }
519
520    pub(crate) fn signed_in_area(&self) -> Option<&'static str> {
521        match self.committed_change() {
522            Some(Change::SignIn { area, .. }) => Some(area),
523            _ => None,
524        }
525    }
526
527    #[cfg(feature = "flows")]
528    fn begun_flow(&self) -> Option<String> {
529        let changes = unpoisoned::lock(&self.changes);
530        changes.begun_flow.clone().filter(|_| changes.committed)
531    }
532
533    fn committed_change(&self) -> Option<Change> {
534        let changes = unpoisoned::lock(&self.changes);
535        changes.requested.filter(|_| changes.committed)
536    }
537
538    pub(crate) fn finish(
539        &self,
540        mut response: Response,
541        sessions: &Sessions,
542        now: Timestamp,
543        caller: Caller<'_>,
544    ) -> Response {
545        if self.is_bearer() {
546            return response;
547        }
548        #[cfg(feature = "flows")]
549        if let Some(flow) = self.begun_flow() {
550            match sessions.cookies.begun_flow(&flow) {
551                Ok(cookie) => response.headers_mut().append(SET_COOKIE, cookie),
552                Err(error) => return answer::failure(error, caller),
553            };
554        }
555        match self.cookie(sessions, now) {
556            Ok(Some(cookie)) => {
557                response.headers_mut().append(SET_COOKIE, cookie);
558                response
559            }
560            Ok(None) => response,
561            Err(error) => answer::failure(error, caller),
562        }
563    }
564
565    fn cookie(&self, sessions: &Sessions, now: Timestamp) -> Result<Option<HeaderValue>> {
566        let (keys, cookies) = (&sessions.keys, &sessions.cookies);
567        match self.committed_change() {
568            Some(Change::SignIn {
569                session,
570                area,
571                principal,
572                issued_at,
573            }) => {
574                let claims = SessionClaims::new(session, area, principal, now)
575                    .issued_at(issued_at.unwrap_or(now));
576                cookies.sealed_session(keys, &claims, now).map(Some)
577            }
578            Some(Change::SignOut) => cookies.session_removal().map(Some),
579            None => self.refreshed_cookie(sessions, now),
580        }
581    }
582
583    fn refreshed_cookie(&self, sessions: &Sessions, now: Timestamp) -> Result<Option<HeaderValue>> {
584        let cookies = &sessions.cookies;
585        match &self.claims {
586            Some(claims) if claims.needs_refresh(now) => cookies
587                .sealed_session(&sessions.keys, &claims.clone().seen_at(now), now)
588                .map(Some),
589            None if self.is_dead => cookies.session_removal().map(Some),
590            Some(_) | None => Ok(None),
591        }
592    }
593}