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 #[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 #[cfg(feature = "session-list")]
72 fn session_topic(&self) -> Option<sofie_db::Topic> {
73 None
74 }
75}
76
77#[cfg(feature = "session-list")]
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub enum SessionRecord {
81 Unrecorded,
83 Live {
84 account: Uuid,
85 expires_at: Option<sofie_core::Instant>,
87 impersonator: Option<Uuid>,
89 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
107pub 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#[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 issued_at: Option<Timestamp>,
157 },
158 SignOut,
159}
160
161pub trait Admitter {}
165
166#[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 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 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 #[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 #[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 #[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 #[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
310pub 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 #[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
384fn 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 recorded: Option<Uuid>,
410 #[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 is_dead: bool,
421 changes: Arc<Mutex<Changes>>,
422 #[cfg(feature = "tokens")]
424 scopes: Option<Arc<[String]>>,
425 #[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}