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