Skip to main content

alibi_plugins/helpers/
sessions.rs

1use super::{
2    CompletedSession, IssuedSession, IssuedSessionRecord, SessionIssueError, SessionOverrides,
3};
4use alibi_core::entity::AuthUser;
5use alibi_core::{AuthContext, AuthError, AuthResponse, AuthResult, UpdateUser};
6use chrono::Utc;
7
8/// Source ordinary middleware admits the authenticated cache snapshot.
9/// Only the nested read is caught; subsequent storage and callback errors keep
10/// their own endpoint contract.
11pub(crate) async fn ordinary_session<S: alibi_core::AuthSchema>(
12    request: &impl alibi_core::session::SessionRequest,
13    ctx: &AuthContext<S>,
14) -> AuthResult<(alibi_core::AuthenticatedUser<S>, alibi_core::SessionView)> {
15    ctx.require_cached_session(request)
16        .await
17        .map_err(super::unauthorized_if_unauthenticated)
18}
19
20/// Convert an `expiresIn` value (**seconds** from now) into an RFC 3339
21/// `expires_at` timestamp string.
22///
23/// Returns `None` when `expires_in_secs` is `None`.
24///
25/// # Errors
26///
27/// Returns an error if the duration or resulting timestamp is outside the supported range.
28pub fn expires_in_to_at(expires_in_secs: Option<i64>) -> AuthResult<Option<String>> {
29    expires_in_secs
30        .map(|secs| {
31            chrono::Duration::try_seconds(secs)
32                .and_then(|duration| Utc::now().checked_add_signed(duration))
33                .map(|expires_at| expires_at.to_rfc3339())
34                .ok_or_else(|| AuthError::bad_request("expiresIn is out of range"))
35        })
36        .transpose()
37}
38
39/// Resolve the session selected by a completed response's signed session cookie.
40///
41/// Cookie clearing is not a new session. The record is read from storage so
42/// hooks never trust user or session fields from a response body.
43///
44/// # Errors
45///
46/// Returns an error if the session cannot be serialized.
47pub async fn response_session<S: alibi_core::AuthSchema>(
48    ctx: &AuthContext<S>,
49    response: &AuthResponse,
50) -> AuthResult<Option<IssuedSession<S>>> {
51    let token = response
52        .headers
53        .get_all("set-cookie")
54        .filter_map(|header| {
55            let cookie = cookie::Cookie::parse(header.clone()).ok()?;
56            if cookie.name() != ctx.config.session.cookie_name || cookie.value().is_empty() {
57                return None;
58            }
59            alibi_core::utils::cookie_utils::verify_cookie_value(
60                cookie.value(),
61                ctx.config.current_secret(),
62            )
63        })
64        .last();
65    let Some(token) = token else {
66        return Ok(None);
67    };
68    let Some(session) = ctx.database.get_session(&token).await? else {
69        return Ok(None);
70    };
71    let Some(user) = ctx.session_user(&session).await? else {
72        return Ok(None);
73    };
74    Ok(Some(IssuedSession { user, session }))
75}
76
77fn completion_extensions() -> impl Iterator<Item = alibi_core::types::RequestExtensions> {
78    alibi_core::hooks::current_request_hook_context()
79        .map(|request| request.extensions)
80        .into_iter()
81        .chain(
82            alibi_core::endpoint::current_endpoint_call_context()
83                .map(|call| call.extensions().clone()),
84        )
85}
86
87pub(crate) fn record_completed_session<S: alibi_core::AuthSchema>(
88    user: &S::User,
89    session: &S::Session,
90) {
91    for extensions in completion_extensions() {
92        extensions.insert(CompletedSession::<S> {
93            user: user.clone(),
94            session: session.clone(),
95            user_view: None,
96            user_record: None,
97            session_record: None,
98        });
99    }
100}
101
102pub(crate) fn record_completed_session_record<S: alibi_core::AuthSchema>(
103    user: &alibi_core::AdapterRecord<S::User>,
104    session: &alibi_core::AdapterRecord<S::Session>,
105) {
106    for extensions in completion_extensions() {
107        extensions.insert(CompletedSession::<S> {
108            user: user.stored().clone(),
109            session: session.stored().clone(),
110            user_view: None,
111            user_record: Some(user.clone()),
112            session_record: Some(session.clone()),
113        });
114    }
115}
116
117/// Retain a Source-defined callback projection after genuine session issuance.
118/// This cannot create a completion or replace its raw models/owner/token.
119pub(crate) fn record_completed_session_user_view<S: alibi_core::AuthSchema>(
120    original_user: &impl AuthUser,
121    session: &impl alibi_core::AuthSession,
122    view: &alibi_core::wire::UserView,
123) {
124    use alibi_core::AuthSession;
125    for extensions in completion_extensions() {
126        if let Some(completed) = extensions.get::<CompletedSession<S>>()
127            && completed.user.id() == original_user.id()
128            && view.id == original_user.id().as_ref()
129            && completed.session.token() == session.token()
130        {
131            extensions.insert(CompletedSession::<S> {
132                user: completed.user.clone(),
133                session: completed.session.clone(),
134                user_view: Some(view.clone()),
135                user_record: completed.user_record.clone(),
136                session_record: completed.session_record.clone(),
137            });
138        }
139    }
140}
141
142pub(crate) fn response_has_session_cookie<S: alibi_core::AuthSchema>(
143    ctx: &AuthContext<S>,
144    response: &AuthResponse,
145) -> bool {
146    response.headers.get_all("set-cookie").any(|header| {
147        cookie::Cookie::parse(header.clone()).is_ok_and(|cookie| {
148            cookie.name() == ctx.config.session.cookie_name
149                && cookie
150                    .value()
151                    .split('.')
152                    .next()
153                    .is_some_and(|value| !value.is_empty())
154        })
155    })
156}
157
158pub(crate) fn completed_response_session<S: alibi_core::AuthSchema>(
159    req: &impl alibi_core::session::SessionRequest,
160    ctx: &AuthContext<S>,
161    response: &AuthResponse,
162) -> Option<std::sync::Arc<CompletedSession<S>>> {
163    // A clearing cookie is not a completed login. The snapshot comes from the
164    // trusted issuer, never the response body or a freshly mutated database row.
165    if response_has_session_cookie(ctx, response) {
166        req.extensions().get::<CompletedSession<S>>()
167    } else {
168        None
169    }
170}
171
172/// Whether the admin plugin is active for this auth instance.
173#[must_use]
174pub fn admin_plugin_enabled(ctx: &AuthContext<impl alibi_core::AuthSchema>) -> bool {
175    ctx.get_metadata("admin.enabled")
176        .and_then(serde_json::Value::as_bool)
177        .unwrap_or(false)
178}
179
180/// Resolve the configured message shown when a banned user attempts to create
181/// a session.
182pub fn admin_banned_user_message(ctx: &AuthContext<impl alibi_core::AuthSchema>) -> Option<String> {
183    ctx.get_metadata("admin.banned_user_message")
184        .and_then(|value| value.as_str())
185        .map(ToOwned::to_owned)
186}
187
188/// Resolve an awaited application message from the actual stored user entity.
189pub(crate) async fn resolve_admin_banned_user_message<S: alibi_core::AuthSchema>(
190    ctx: &AuthContext<S>,
191    user: &impl AuthUser,
192) -> AuthResult<String> {
193    if let Some(policy) = ctx
194        .extensions
195        .get::<crate::admin::BannedUserMessagePolicy>()
196    {
197        return policy.message(user).await;
198    }
199    Ok(admin_banned_user_message(ctx).unwrap_or_else(|| {
200        "You have been banned from this application. Please contact support if you believe this is an error.".to_owned()
201    }))
202}
203
204/// Issue a session for the given user, applying admin-plugin ban semantics
205/// when the admin plugin is enabled.
206///
207/// # Errors
208///
209/// Returns an error if session hooks reject issuance, the user is banned, or storage fails.
210pub async fn issue_user_session<S: alibi_core::AuthSchema>(
211    ctx: &AuthContext<S>,
212    user_id: &str,
213    ip_address: Option<String>,
214    user_agent: Option<String>,
215) -> Result<IssuedSession<S>, SessionIssueError> {
216    issue_user_session_record(ctx, user_id, ip_address, user_agent)
217        .await
218        .map(IssuedSessionRecord::into_stored)
219}
220
221/// Issue and publish genuine retained adapter results.
222///
223/// # Errors
224/// Propagates validation, storage and configured callback errors.
225pub async fn issue_user_session_record<S: alibi_core::AuthSchema>(
226    ctx: &AuthContext<S>,
227    user_id: &str,
228    ip_address: Option<String>,
229    user_agent: Option<String>,
230) -> Result<IssuedSessionRecord<S>, SessionIssueError> {
231    issue_user_session_inner(ctx, user_id, ip_address, user_agent, None, true, None).await
232}
233
234/// Issue a session with trusted fields applied during its initial insert.
235///
236/// Configured field policies and creation hooks receive the supplied fields;
237/// issuance publishes the resulting row through the normal cache and completion
238/// path. No follow-up session update is performed. Admin ban policy still applies.
239///
240/// # Errors
241/// Returns an error if hooks reject issuance, the user is banned, or storage fails.
242pub async fn issue_user_session_with_fields<S: alibi_core::AuthSchema>(
243    ctx: &AuthContext<S>,
244    user_id: &str,
245    ip_address: Option<String>,
246    user_agent: Option<String>,
247    fields: SessionOverrides,
248) -> Result<IssuedSession<S>, SessionIssueError> {
249    issue_user_session_with_fields_record(ctx, user_id, ip_address, user_agent, fields)
250        .await
251        .map(IssuedSessionRecord::into_stored)
252}
253
254/// Issue a session with trusted initial fields and retain its adapter output.
255///
256/// This is the retained-record counterpart to [`issue_user_session_with_fields`].
257///
258/// # Errors
259/// Propagates validation, storage, ban policy, and configured callback errors.
260pub async fn issue_user_session_with_fields_record<S: alibi_core::AuthSchema>(
261    ctx: &AuthContext<S>,
262    user_id: &str,
263    ip_address: Option<String>,
264    user_agent: Option<String>,
265    fields: SessionOverrides,
266) -> Result<IssuedSessionRecord<S>, SessionIssueError> {
267    // Additional fields use schema bindings, so reject names that could replace
268    // issuance-owned columns rather than trusting the caller's map shape.
269    if let Some(name) = fields.additional_fields.keys().find(|name| {
270        matches!(
271            name.as_str(),
272            "id" | "token"
273                | "userId"
274                | "user_id"
275                | "expiresAt"
276                | "expires_at"
277                | "createdAt"
278                | "created_at"
279                | "updatedAt"
280                | "updated_at"
281        )
282    }) {
283        return Err(
284            AuthError::bad_request(format!("Session issuance owns the {name} field")).into(),
285        );
286    }
287    issue_user_session_inner(
288        ctx,
289        user_id,
290        ip_address,
291        user_agent,
292        Some(fields),
293        true,
294        None,
295    )
296    .await
297}
298
299/// Create a genuine session for an endpoint that publishes it after later
300/// persistence and application callbacks have completed.
301pub(crate) async fn create_user_session_record<S: alibi_core::AuthSchema>(
302    ctx: &AuthContext<S>,
303    user_id: &str,
304    ip_address: Option<String>,
305    user_agent: Option<String>,
306) -> Result<IssuedSessionRecord<S>, SessionIssueError> {
307    issue_user_session_inner(ctx, user_id, ip_address, user_agent, None, false, None).await
308}
309
310/// Issue a replacement session while preserving trusted session extension fields.
311///
312/// # Errors
313///
314/// Returns an error if session hooks reject issuance, the user is banned, or storage fails.
315pub async fn issue_user_session_with_overrides<S: alibi_core::AuthSchema>(
316    ctx: &AuthContext<S>,
317    user_id: &str,
318    ip_address: Option<String>,
319    user_agent: Option<String>,
320    current_session: &impl alibi_core::AuthSession,
321) -> Result<IssuedSession<S>, SessionIssueError> {
322    issue_user_session_with_overrides_record(ctx, user_id, ip_address, user_agent, current_session)
323        .await
324        .map(IssuedSessionRecord::into_stored)
325}
326
327/// Issue replacement records with actual retained output.
328///
329/// # Errors
330/// Propagates persistence or configured callback errors.
331pub async fn issue_user_session_with_overrides_record<S: alibi_core::AuthSchema>(
332    ctx: &AuthContext<S>,
333    user_id: &str,
334    ip_address: Option<String>,
335    user_agent: Option<String>,
336    current_session: &impl alibi_core::AuthSession,
337) -> Result<IssuedSessionRecord<S>, SessionIssueError> {
338    let overrides = SessionOverrides {
339        additional_fields: current_session
340            .additional_fields()
341            .into_iter()
342            .map(|(name, value)| (name, alibi_core::utils::json::JsValue::from(value)))
343            .collect(),
344        impersonated_by: current_session.impersonated_by().map(str::to_owned),
345        active_organization_id: current_session.active_organization_id().map(str::to_owned),
346        active_team_id: current_session.active_team_id().map(str::to_owned),
347    };
348    issue_user_session_inner(
349        ctx,
350        user_id,
351        ip_address,
352        user_agent,
353        Some(overrides),
354        true,
355        None,
356    )
357    .await
358}
359
360/// Issue from the actual lookup already used to authenticate this user. This
361/// retains its callback snapshot without repeating adapter output callbacks.
362pub(crate) async fn issue_selected_user_session_record<S: alibi_core::AuthSchema>(
363    ctx: &AuthContext<S>,
364    user: alibi_core::AdapterRecord<S::User>,
365    ip_address: Option<String>,
366    user_agent: Option<String>,
367) -> Result<IssuedSessionRecord<S>, SessionIssueError> {
368    let id = user.id().into_owned();
369    issue_user_session_inner(ctx, &id, ip_address, user_agent, None, true, Some(user)).await
370}
371
372pub(crate) async fn issue_selected_user_session_in_transaction<S: alibi_core::AuthSchema>(
373    ctx: &AuthContext<S>,
374    database: &dyn alibi_core::AuthTransaction<S>,
375    mut user: alibi_core::AdapterRecord<S::User>,
376    ip_address: Option<String>,
377    user_agent: Option<String>,
378) -> Result<IssuedSessionRecord<S>, SessionIssueError> {
379    if admin_plugin_enabled(ctx) && user.banned() {
380        if user.ban_expires().is_some_and(|expiry| expiry < Utc::now()) {
381            user = database
382                .update_user_record(
383                    &user.id(),
384                    UpdateUser {
385                        banned: Some(false),
386                        ban_reason: None,
387                        ban_expires: None,
388                        ..Default::default()
389                    },
390                )
391                .await?;
392        } else {
393            return Err(SessionIssueError::Banned {
394                message: resolve_admin_banned_user_message(ctx, &user).await?,
395            });
396        }
397    }
398    let session = ctx
399        .session_manager()
400        .create_session_record_in_transaction(&user, ip_address, user_agent, database)
401        .await?;
402    Ok(IssuedSessionRecord { user, session })
403}
404pub(crate) async fn publish_committed_session<S: alibi_core::AuthSchema>(
405    ctx: &AuthContext<S>,
406    issued: &IssuedSessionRecord<S>,
407) -> AuthResult<()> {
408    alibi_core::session::cookie_cache::runtime::emit_issuance(ctx, &issued.user, &issued.session)
409        .await?;
410    record_completed_session_record::<S>(&issued.user, &issued.session);
411    Ok(())
412}
413
414pub(in crate::helpers) async fn issue_user_session_inner<S: alibi_core::AuthSchema>(
415    ctx: &AuthContext<S>,
416    user_id: &str,
417    ip_address: Option<String>,
418    user_agent: Option<String>,
419    overrides: Option<SessionOverrides>,
420    publish: bool,
421    selected: Option<alibi_core::AdapterRecord<S::User>>,
422) -> Result<IssuedSessionRecord<S>, SessionIssueError> {
423    let user = match selected {
424        Some(user) => user,
425        None => ctx
426            .database
427            .get_user_by_id_record(user_id)
428            .await?
429            .ok_or(AuthError::UserNotFound)?,
430    };
431
432    if admin_plugin_enabled(ctx) && user.banned() {
433        if user
434            .ban_expires()
435            .is_some_and(|expires| expires < Utc::now())
436        {
437            _ = ctx
438                .database
439                .update_user_record(
440                    user_id,
441                    UpdateUser {
442                        banned: Some(false),
443                        ban_reason: None,
444                        ban_expires: None,
445                        ..Default::default()
446                    },
447                )
448                .await?;
449        } else {
450            return Err(SessionIssueError::Banned {
451                message: resolve_admin_banned_user_message(ctx, &user).await?,
452            });
453        }
454    }
455
456    let session = match overrides {
457        None => {
458            ctx.session_manager()
459                .create_session_record(&user, ip_address, user_agent)
460                .await?
461        }
462        Some(overrides) => {
463            ctx.database
464                .create_session_record(alibi_core::CreateSession {
465                    additional_fields: overrides.additional_fields,
466                    token: None,
467                    user_id: user.id().to_string(),
468                    expires_at: Utc::now() + ctx.config.session.expires_in,
469                    ip_address,
470                    user_agent,
471                    impersonated_by: overrides.impersonated_by,
472                    active_organization_id: overrides.active_organization_id,
473                    active_team_id: overrides.active_team_id,
474                })
475                .await?
476        }
477    };
478
479    if publish {
480        alibi_core::session::cookie_cache::runtime::emit_issuance(ctx, &user, &session).await?;
481        record_completed_session_record::<S>(&user, &session);
482    }
483    Ok(IssuedSessionRecord { user, session })
484}
485
486/// Parse a cookie value from the request's `Cookie` header.
487#[must_use]
488pub fn get_cookie(req: &impl alibi_core::session::SessionRequest, name: &str) -> Option<String> {
489    let header = req.session_headers().get("cookie")?;
490    header.split(';').find_map(|cookie| {
491        let trimmed = cookie.trim();
492        let (cookie_name, cookie_value) = trimmed.split_once('=')?;
493        (cookie_name == name).then_some(cookie_value.to_owned())
494    })
495}
496
497/// TS-style cookie clearing used by `deleteSessionCookie`.
498pub fn delete_session_cookie_headers(config: &alibi_core::AuthConfig) -> AuthResult<Vec<String>> {
499    alibi_core::utils::cookie_utils::delete_session_cookie_headers(config)
500}