Skip to main content

alibi_api/plugins/helpers/
sessions.rs

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