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, AuthRequest, 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
77pub(crate) fn record_completed_session<S: alibi_core::AuthSchema>(
78    user: &S::User,
79    session: &S::Session,
80) {
81    if let Some(request) = alibi_core::hooks::current_request_hook_context() {
82        request.extensions.insert(CompletedSession::<S> {
83            user: user.clone(),
84            session: session.clone(),
85            user_view: None,
86            user_record: None,
87            session_record: None,
88        });
89    }
90}
91
92pub(crate) fn record_completed_session_record<S: alibi_core::AuthSchema>(
93    user: &alibi_core::AdapterRecord<S::User>,
94    session: &alibi_core::AdapterRecord<S::Session>,
95) {
96    if let Some(request) = alibi_core::hooks::current_request_hook_context() {
97        request.extensions.insert(CompletedSession::<S> {
98            user: user.stored().clone(),
99            session: session.stored().clone(),
100            user_view: None,
101            user_record: Some(user.clone()),
102            session_record: Some(session.clone()),
103        });
104    }
105}
106
107/// Retain a Source-defined callback projection after genuine session issuance.
108/// This cannot create a completion or replace its raw models/owner/token.
109pub(crate) fn record_completed_session_user_view<S: alibi_core::AuthSchema>(
110    original_user: &impl AuthUser,
111    session: &impl alibi_core::AuthSession,
112    view: alibi_core::wire::UserView,
113) {
114    use alibi_core::AuthSession;
115    if let Some(request) = alibi_core::hooks::current_request_hook_context()
116        && let Some(completed) = request.extensions.get::<CompletedSession<S>>()
117        && completed.user.id() == original_user.id()
118        && view.id == original_user.id().as_ref()
119        && completed.session.token() == session.token()
120    {
121        request.extensions.insert(CompletedSession::<S> {
122            user: completed.user.clone(),
123            session: completed.session.clone(),
124            user_view: Some(view),
125            user_record: completed.user_record.clone(),
126            session_record: completed.session_record.clone(),
127        });
128    }
129}
130
131pub(crate) fn response_has_session_cookie<S: alibi_core::AuthSchema>(
132    ctx: &AuthContext<S>,
133    response: &AuthResponse,
134) -> bool {
135    response.headers.get_all("set-cookie").any(|header| {
136        cookie::Cookie::parse(header.clone()).is_ok_and(|cookie| {
137            cookie.name() == ctx.config.session.cookie_name
138                && cookie
139                    .value()
140                    .split('.')
141                    .next()
142                    .is_some_and(|value| !value.is_empty())
143        })
144    })
145}
146
147pub(crate) fn completed_response_session<S: alibi_core::AuthSchema>(
148    req: &AuthRequest,
149    ctx: &AuthContext<S>,
150    response: &AuthResponse,
151) -> Option<std::sync::Arc<CompletedSession<S>>> {
152    // A clearing cookie is not a completed login. The snapshot comes from the
153    // trusted issuer, never the response body or a freshly mutated database row.
154    if response_has_session_cookie(ctx, response) {
155        req.extensions().get::<CompletedSession<S>>()
156    } else {
157        None
158    }
159}
160
161/// Whether the admin plugin is active for this auth instance.
162#[must_use]
163pub fn admin_plugin_enabled(ctx: &AuthContext<impl alibi_core::AuthSchema>) -> bool {
164    ctx.get_metadata("admin.enabled")
165        .and_then(serde_json::Value::as_bool)
166        .unwrap_or(false)
167}
168
169/// Resolve the configured message shown when a banned user attempts to create
170/// a session.
171pub fn admin_banned_user_message(ctx: &AuthContext<impl alibi_core::AuthSchema>) -> Option<String> {
172    ctx.get_metadata("admin.banned_user_message")
173        .and_then(|value| value.as_str())
174        .map(ToOwned::to_owned)
175}
176
177/// Resolve an awaited application message from the actual stored user entity.
178pub(crate) async fn resolve_admin_banned_user_message<S: alibi_core::AuthSchema>(
179    ctx: &AuthContext<S>,
180    user: &impl AuthUser,
181) -> AuthResult<String> {
182    if let Some(policy) = ctx
183        .extensions
184        .get::<crate::admin::BannedUserMessagePolicy>()
185    {
186        return policy.message(user).await;
187    }
188    Ok(admin_banned_user_message(ctx).unwrap_or_else(|| {
189        "You have been banned from this application. Please contact support if you believe this is an error.".to_owned()
190    }))
191}
192
193/// Issue a session for the given user, applying admin-plugin ban semantics
194/// when the admin plugin is enabled.
195///
196/// # Errors
197///
198/// Returns an error if session hooks reject issuance, the user is banned, or storage fails.
199pub async fn issue_user_session<S: alibi_core::AuthSchema>(
200    ctx: &AuthContext<S>,
201    user_id: &str,
202    ip_address: Option<String>,
203    user_agent: Option<String>,
204) -> Result<IssuedSession<S>, SessionIssueError> {
205    issue_user_session_record(ctx, user_id, ip_address, user_agent)
206        .await
207        .map(IssuedSessionRecord::into_stored)
208}
209
210/// Issue and publish genuine retained adapter results.
211///
212/// # Errors
213/// Propagates validation, storage and configured callback errors.
214pub async fn issue_user_session_record<S: alibi_core::AuthSchema>(
215    ctx: &AuthContext<S>,
216    user_id: &str,
217    ip_address: Option<String>,
218    user_agent: Option<String>,
219) -> Result<IssuedSessionRecord<S>, SessionIssueError> {
220    issue_user_session_inner(ctx, user_id, ip_address, user_agent, None, true, None).await
221}
222
223/// Issue a session with trusted fields applied during its initial insert.
224///
225/// Configured field policies and creation hooks receive the supplied fields;
226/// issuance publishes the resulting row through the normal cache and completion
227/// path. No follow-up session update is performed. Admin ban policy still applies.
228///
229/// # Errors
230/// Returns an error if hooks reject issuance, the user is banned, or storage fails.
231pub async fn issue_user_session_with_fields<S: alibi_core::AuthSchema>(
232    ctx: &AuthContext<S>,
233    user_id: &str,
234    ip_address: Option<String>,
235    user_agent: Option<String>,
236    fields: SessionOverrides,
237) -> Result<IssuedSession<S>, SessionIssueError> {
238    issue_user_session_with_fields_record(ctx, user_id, ip_address, user_agent, fields)
239        .await
240        .map(IssuedSessionRecord::into_stored)
241}
242
243/// Issue a session with trusted initial fields and retain its adapter output.
244///
245/// This is the retained-record counterpart to [`issue_user_session_with_fields`].
246///
247/// # Errors
248/// Propagates validation, storage, ban policy, and configured callback errors.
249pub async fn issue_user_session_with_fields_record<S: alibi_core::AuthSchema>(
250    ctx: &AuthContext<S>,
251    user_id: &str,
252    ip_address: Option<String>,
253    user_agent: Option<String>,
254    fields: SessionOverrides,
255) -> Result<IssuedSessionRecord<S>, SessionIssueError> {
256    // Additional fields use schema bindings, so reject names that could replace
257    // issuance-owned columns rather than trusting the caller's map shape.
258    if let Some(name) = fields.additional_fields.keys().find(|name| {
259        matches!(
260            name.as_str(),
261            "id" | "token"
262                | "userId"
263                | "user_id"
264                | "expiresAt"
265                | "expires_at"
266                | "createdAt"
267                | "created_at"
268                | "updatedAt"
269                | "updated_at"
270        )
271    }) {
272        return Err(
273            AuthError::bad_request(format!("Session issuance owns the {name} field")).into(),
274        );
275    }
276    issue_user_session_inner(
277        ctx,
278        user_id,
279        ip_address,
280        user_agent,
281        Some(fields),
282        true,
283        None,
284    )
285    .await
286}
287
288/// Create a genuine session for an endpoint that publishes it after later
289/// persistence and application callbacks have completed.
290pub(crate) async fn create_user_session_record<S: alibi_core::AuthSchema>(
291    ctx: &AuthContext<S>,
292    user_id: &str,
293    ip_address: Option<String>,
294    user_agent: Option<String>,
295) -> Result<IssuedSessionRecord<S>, SessionIssueError> {
296    issue_user_session_inner(ctx, user_id, ip_address, user_agent, None, false, None).await
297}
298
299/// Issue a replacement session while preserving trusted session extension fields.
300///
301/// # Errors
302///
303/// Returns an error if session hooks reject issuance, the user is banned, or storage fails.
304pub async fn issue_user_session_with_overrides<S: alibi_core::AuthSchema>(
305    ctx: &AuthContext<S>,
306    user_id: &str,
307    ip_address: Option<String>,
308    user_agent: Option<String>,
309    current_session: &impl alibi_core::AuthSession,
310) -> Result<IssuedSession<S>, SessionIssueError> {
311    issue_user_session_with_overrides_record(ctx, user_id, ip_address, user_agent, current_session)
312        .await
313        .map(IssuedSessionRecord::into_stored)
314}
315
316/// Issue replacement records with actual retained output.
317///
318/// # Errors
319/// Propagates persistence or configured callback errors.
320pub async fn issue_user_session_with_overrides_record<S: alibi_core::AuthSchema>(
321    ctx: &AuthContext<S>,
322    user_id: &str,
323    ip_address: Option<String>,
324    user_agent: Option<String>,
325    current_session: &impl alibi_core::AuthSession,
326) -> Result<IssuedSessionRecord<S>, SessionIssueError> {
327    let overrides = SessionOverrides {
328        additional_fields: current_session
329            .additional_fields()
330            .into_iter()
331            .map(|(name, value)| (name, alibi_core::utils::json::JsValue::from(value)))
332            .collect(),
333        impersonated_by: current_session.impersonated_by().map(str::to_owned),
334        active_organization_id: current_session.active_organization_id().map(str::to_owned),
335        active_team_id: current_session.active_team_id().map(str::to_owned),
336    };
337    issue_user_session_inner(
338        ctx,
339        user_id,
340        ip_address,
341        user_agent,
342        Some(overrides),
343        true,
344        None,
345    )
346    .await
347}
348
349/// Issue from the actual lookup already used to authenticate this user. This
350/// retains its callback snapshot without repeating adapter output callbacks.
351pub(crate) async fn issue_selected_user_session_record<S: alibi_core::AuthSchema>(
352    ctx: &AuthContext<S>,
353    user: alibi_core::AdapterRecord<S::User>,
354    ip_address: Option<String>,
355    user_agent: Option<String>,
356) -> Result<IssuedSessionRecord<S>, SessionIssueError> {
357    let id = user.id().into_owned();
358    issue_user_session_inner(ctx, &id, ip_address, user_agent, None, true, Some(user)).await
359}
360
361pub(in crate::helpers) async fn issue_user_session_inner<S: alibi_core::AuthSchema>(
362    ctx: &AuthContext<S>,
363    user_id: &str,
364    ip_address: Option<String>,
365    user_agent: Option<String>,
366    overrides: Option<SessionOverrides>,
367    publish: bool,
368    selected: Option<alibi_core::AdapterRecord<S::User>>,
369) -> Result<IssuedSessionRecord<S>, SessionIssueError> {
370    let user = match selected {
371        Some(user) => user,
372        None => ctx
373            .database
374            .get_user_by_id_record(user_id)
375            .await?
376            .ok_or(AuthError::UserNotFound)?,
377    };
378
379    if admin_plugin_enabled(ctx) && user.banned() {
380        if user
381            .ban_expires()
382            .is_some_and(|expires| expires < Utc::now())
383        {
384            _ = ctx
385                .database
386                .update_user_record(
387                    user_id,
388                    UpdateUser {
389                        banned: Some(false),
390                        ban_reason: None,
391                        ban_expires: None,
392                        ..Default::default()
393                    },
394                )
395                .await?;
396        } else {
397            return Err(SessionIssueError::Banned {
398                message: resolve_admin_banned_user_message(ctx, &user).await?,
399            });
400        }
401    }
402
403    let session = match overrides {
404        None => {
405            ctx.session_manager()
406                .create_session_record(&user, ip_address, user_agent)
407                .await?
408        }
409        Some(overrides) => {
410            ctx.database
411                .create_session_record(alibi_core::CreateSession {
412                    additional_fields: overrides.additional_fields,
413                    token: None,
414                    user_id: user.id().to_string(),
415                    expires_at: Utc::now() + ctx.config.session.expires_in,
416                    ip_address,
417                    user_agent,
418                    impersonated_by: overrides.impersonated_by,
419                    active_organization_id: overrides.active_organization_id,
420                    active_team_id: overrides.active_team_id,
421                })
422                .await?
423        }
424    };
425
426    if publish {
427        alibi_core::session::cookie_cache::runtime::emit_issuance(ctx, &user, &session).await?;
428        record_completed_session_record::<S>(&user, &session);
429    }
430    Ok(IssuedSessionRecord { user, session })
431}
432
433/// Parse a cookie value from the request's `Cookie` header.
434#[must_use]
435pub fn get_cookie(req: &impl alibi_core::session::SessionRequest, name: &str) -> Option<String> {
436    let header = req.session_headers().get("cookie")?;
437    header.split(';').find_map(|cookie| {
438        let trimmed = cookie.trim();
439        let (cookie_name, cookie_value) = trimmed.split_once('=')?;
440        (cookie_name == name).then_some(cookie_value.to_owned())
441    })
442}
443
444/// TS-style cookie clearing used by `deleteSessionCookie`.
445pub fn delete_session_cookie_headers(config: &alibi_core::AuthConfig) -> AuthResult<Vec<String>> {
446    alibi_core::utils::cookie_utils::delete_session_cookie_headers(config)
447}