Skip to main content

alibi_core/session/cookie_cache/runtime/
read.rs

1use super::*;
2/// Try the authenticated compact cache before any physical session lookup.
3/// Missing or invalid cache data can only produce a storage fallback.
4///
5/// # Errors
6/// Returns an error when validation, storage, or an application callback fails.
7pub async fn read<S: AuthSchema>(
8    ctx: &AuthContext<S>,
9    request: &impl SessionRequest,
10) -> AuthResult<Option<super::super::CompactCache>> {
11    read_cache(ctx, request, CacheDecoding::Http).await
12}
13
14/// How a compact envelope that is not valid base64 is treated.
15#[derive(Clone, Copy, PartialEq, Eq)]
16pub(crate) enum CacheDecoding {
17    /// Raise the decoding error, which get-session answers with its 500.
18    Http,
19    /// Treat it as a cache miss; storage remains the authority.
20    MalformedIsMiss,
21}
22
23async fn read_cache<S: AuthSchema>(
24    ctx: &AuthContext<S>,
25    request: &impl SessionRequest,
26    decoding: CacheDecoding,
27) -> AuthResult<Option<super::super::CompactCache>> {
28    let manager = ctx.session_manager();
29    let enabled = ctx
30        .config
31        .session
32        .cookie_cache
33        .as_ref()
34        .filter(|config| config.enabled);
35    let token = manager.extract_session_token(request);
36    if token.is_none() && enabled.is_some() {
37        return Ok(None);
38    }
39    let name = related_cookie_name(&ctx.config, "session_data");
40    let values = cookies(request.session_headers());
41    let chunks = cookie_values(request.session_headers(), true);
42    if enabled.is_none() {
43        for old in existing_names(&chunks, &name) {
44            request.queue_response_header(
45                "Set-Cookie",
46                super::super::cookie_header(&old, "", Some(0.0), &ctx.config)?,
47            );
48        }
49        return Ok(None);
50    }
51    if request.session_query_truthy("disableCookieCache") {
52        return Ok(None);
53    }
54    let Some(token) = token else {
55        return Ok(None);
56    };
57    let Some(value) = cache_value(&values, &chunks, &name) else {
58        return Ok(None);
59    };
60    let config =
61        enabled.ok_or_else(|| AuthError::internal("Missing enabled cache configuration"))?;
62    let decoded = match config.strategy {
63        crate::CookieCacheStrategy::Compact => match decoding {
64            CacheDecoding::Http => {
65                super::super::decode_compact_http(&value, ctx.config.current_secret())?
66            }
67            CacheDecoding::MalformedIsMiss => {
68                super::super::decode_compact(&value, ctx.config.current_secret())
69            }
70        },
71        crate::CookieCacheStrategy::Jwt => {
72            if let Some(signer) = ctx
73                .extensions
74                .get::<super::super::jwt::CookieCacheSignerHandle<S>>()
75            {
76                signer
77                    .0
78                    .verify(&value, ctx)
79                    .await?
80                    .and_then(|claims| super::super::jwt::decode_payload(&claims.into(), 15.0))
81            } else {
82                super::super::jwt::decode(&value, ctx.config.current_secret())
83            }
84        }
85        crate::CookieCacheStrategy::Jwe => ctx
86            .config
87            .verification_secrets()
88            .find_map(|secret| {
89                crate::utils::jwe::decode(secret, "better-auth-session", &value).ok()
90            })
91            .and_then(|claims| super::super::jwt::decode_payload(&claims.into(), 15.0)),
92    };
93    if let Some(cache) = decoded
94        && let CacheValidation::Hit(cache) = super::super::validate_compact(
95            cache,
96            &token,
97            config.version.as_ref(),
98            chrono::Utc::now().timestamp_millis(),
99        )
100        .await?
101    {
102        return Ok(Some(*cache));
103    }
104    request.queue_response_header(
105        "Set-Cookie",
106        super::super::cookie_header(&name, "", Some(0.0), &ctx.config)?,
107    );
108    Ok(None)
109}
110
111/// Renew an authenticated envelope without changing embedded session state.
112pub(crate) async fn renew_cache<S: AuthSchema>(
113    ctx: &AuthContext<S>,
114    request: &impl SessionRequest,
115    cache: &super::super::CompactCache,
116) -> AuthResult<()> {
117    if ctx.config.session.stateless
118        && request
119            .extensions()
120            .get::<crate::session::SessionRefreshSuppressed>()
121            .is_none()
122    {
123        let config = ctx
124            .config
125            .session
126            .cookie_cache
127            .as_ref()
128            .ok_or_else(|| AuthError::internal("Missing stateless cache configuration"))?;
129        let update_age = match ctx.config.session.cookie_refresh_cache {
130            crate::CookieRefreshCache::Disabled => None,
131            crate::CookieRefreshCache::Automatic => {
132                Some((super::super::effective_max_age(config.max_age) * 0.2).floor())
133            }
134            crate::CookieRefreshCache::UpdateAge(age) => Some(age),
135        };
136        // Source's cache-hit branch precedes disableRefresh, dontRemember,
137        // disableSessionRefresh, and deferSessionRefresh. None of these
138        // suppress envelope renewal or extend the embedded session expiry.
139        if update_age.is_some_and(|age| {
140            cache.expires_at - (chrono::Utc::now().timestamp_millis() as f64) < age * 1000.0
141        }) {
142            let context = CacheVersionContext::cached(cache.user.clone(), cache.session.clone());
143            for header in
144                build_headers(ctx, context, request.session_headers(), false, None).await?
145            {
146                request.queue_response_header("Set-Cookie", header);
147            }
148            let remember = ctx.session_manager().has_dont_remember_cookie(request);
149            request.queue_response_header(
150                "Set-Cookie",
151                super::super::cookie_header(
152                    &ctx.config.session.cookie_name,
153                    &percent_encoding::percent_decode_str(&sign_cookie_value(
154                        &cache.session.token,
155                        ctx.config.current_secret(),
156                    ))
157                    .decode_utf8_lossy(),
158                    (!remember).then_some(ctx.config.session.expires_in.num_seconds() as f64),
159                    &ctx.config,
160                )?,
161            );
162        }
163    }
164    Ok(())
165}
166
167/// Shared direct/nested get-session lifecycle for the explicitly migrated
168/// guards. Nested reads behave as GET even when their parent endpoint is POST.
169///
170/// # Errors
171/// Returns an error when validation, storage, or an application callback fails.
172pub async fn authenticated<S: AuthSchema>(
173    ctx: &AuthContext<S>,
174    request: &impl SessionRequest,
175    direct: bool,
176) -> AuthResult<Option<AuthenticatedRead<S>>> {
177    authenticated_with(ctx, request, direct, CacheDecoding::Http).await
178}
179
180pub(crate) async fn authenticated_with<S: AuthSchema>(
181    ctx: &AuthContext<S>,
182    request: &impl SessionRequest,
183    direct: bool,
184    decoding: CacheDecoding,
185) -> AuthResult<Option<AuthenticatedRead<S>>> {
186    if !direct && let Some(call) = request.endpoint_call() {
187        let nested = call.session_read_context();
188        crate::endpoint::with_endpoint_call_context(
189            nested.clone(),
190            Box::pin(authenticated_inner(ctx, &nested, false, decoding)),
191        )
192        .await
193    } else {
194        Box::pin(authenticated_inner(ctx, request, direct, decoding)).await
195    }
196}
197
198#[expect(
199    clippy::as_conversions,
200    clippy::cast_precision_loss,
201    reason = "Preserve JavaScript Number rounding at the compatibility boundary"
202)]
203pub(in crate::session::cookie_cache::runtime) async fn authenticated_inner<S: AuthSchema>(
204    ctx: &AuthContext<S>,
205    request: &impl SessionRequest,
206    direct: bool,
207    decoding: CacheDecoding,
208) -> AuthResult<Option<AuthenticatedRead<S>>> {
209    if let Some(established) = request.extensions().get::<EstablishedSession<S>>()
210        && let Some(established) = &established.0
211        && Arc::ptr_eq(&established.config, &ctx.config)
212        && Arc::ptr_eq(&established.database, &ctx.database)
213        && &established.headers == request.session_headers()
214        && established.virtual_session == request.virtual_session(ctx)
215    {
216        let read = &established.read;
217        return Ok(Some(AuthenticatedRead {
218            user: read.user.clone(),
219            session: read.session.clone(),
220            needs_refresh: read.needs_refresh,
221        }));
222    }
223    request.extensions().insert(SessionHookCache(None));
224    if let Some(session) = request.virtual_session(ctx) {
225        let user = if let Some(user) = request.authenticated_user::<S>(ctx) {
226            ctx.user_adapter_record(user).await?
227        } else {
228            let Some(user) = ctx.database.get_user_by_id_record(&session.user_id).await? else {
229                return Ok(None);
230            };
231            user
232        };
233        request.set_session_hook_snapshot(ctx.user_view(&user), session.clone());
234        return Ok(Some(establish(
235            ctx,
236            request,
237            AuthenticatedRead {
238                user: crate::AuthenticatedUser::Stored(user),
239                session,
240                needs_refresh: None,
241            },
242        )));
243    }
244    if let Some(cache) = read_cache(ctx, request, decoding).await? {
245        renew_cache(ctx, request, &cache).await?;
246        request
247            .extensions()
248            .insert(SessionHookCache(Some(SessionHookCacheMetadata {
249                updated_at: cache.updated_at,
250                version: cache.version.clone(),
251            })));
252        request.set_session_hook_snapshot(cache.user.clone(), cache.session.clone());
253        return Ok(Some(establish(
254            ctx,
255            request,
256            AuthenticatedRead {
257                user: crate::AuthenticatedUser::Cached(Box::new(cache.user)),
258                session: cache.session,
259                needs_refresh: None,
260            },
261        )));
262    }
263    let manager = ctx.session_manager();
264    let Some(token) = manager.extract_session_token(request) else {
265        return Ok(None);
266    };
267    let Some(original) = ctx.database.get_session_record(&token).await? else {
268        cleanup(ctx, request)?;
269        return Ok(None);
270    };
271    let user = match ctx.database.get_session_user_record(&token).await? {
272        Some(user) => Some(user),
273        None if ctx.config.session.secondary_storage.is_some()
274            && (!ctx.config.session.store_in_database
275                || ctx.config.session.preserve_in_database) =>
276        {
277            None
278        }
279        None => {
280            ctx.database
281                .get_user_by_id_record(original.user_id().as_ref())
282                .await?
283        }
284    };
285    let Some(user) = user else {
286        cleanup(ctx, request)?;
287        return Ok(None);
288    };
289    request.set_session_hook_snapshot(ctx.user_view(&user), ctx.session_view(&original));
290    let suppressed = manager.request_disables_refresh(request);
291    let skip = request
292        .extensions()
293        .get::<crate::session::SessionRefreshSuppressed>()
294        .is_some();
295    let deferred = ctx.config.session.defer_session_refresh
296        && !(direct && request.session_method() == &crate::HttpMethod::Post);
297    let read = manager
298        .read_loaded_session_record(
299            original,
300            crate::session::SessionReadOptions {
301                allow_refresh: !suppressed && !deferred && !skip,
302                cleanup_expired: !deferred,
303            },
304        )
305        .await?;
306    let Some(session) = read.session else {
307        cleanup(ctx, request)?;
308        if read.needs_refresh {
309            return Err(AuthError::Upstream {
310                status: 401,
311                code: "FAILED_TO_GET_SESSION",
312                message: "Failed to get session",
313            });
314        }
315        return Ok(None);
316    };
317    if read.refreshed {
318        request.queue_response_header(
319            "Set-Cookie",
320            super::super::cookie_header(
321                &ctx.config.session.cookie_name,
322                &percent_encoding::percent_decode_str(&sign_cookie_value(
323                    session.token(),
324                    ctx.config.current_secret(),
325                ))
326                .decode_utf8_lossy(),
327                Some(ctx.config.session.expires_in.num_seconds() as f64),
328                &ctx.config,
329            )?,
330        );
331    }
332    if read.refreshed {
333        let user = ctx.filter_user_record(user.clone());
334        emit_issuance(ctx, &user, &session).await?;
335    } else if !suppressed {
336        for header in stored_read_headers(ctx, &user, &session, request.session_headers()).await? {
337            request.queue_response_header("Set-Cookie", header);
338        }
339    }
340    Ok(Some(establish(
341        ctx,
342        request,
343        AuthenticatedRead {
344            user: crate::AuthenticatedUser::Stored(user),
345            session: ctx.session_view(&session),
346            needs_refresh: (deferred && !suppressed).then_some(read.needs_refresh && !skip),
347        },
348    )))
349}
350
351pub(in crate::session::cookie_cache::runtime) fn cleanup<S: AuthSchema>(
352    ctx: &AuthContext<S>,
353    request: &impl SessionRequest,
354) -> AuthResult<()> {
355    let cache_name = related_cookie_name(&ctx.config, "session_data");
356    let mut names = vec![ctx.config.session.cookie_name.clone(), cache_name.clone()];
357    if ctx.config.account.store_account_cookie {
358        names.push(related_cookie_name(&ctx.config, "account_data"));
359    }
360    if matches!(
361        ctx.config.account.store_state_strategy,
362        crate::config::OAuthStateStrategy::Cookie
363    ) {
364        names.push(related_cookie_name(&ctx.config, "oauth_state"));
365    }
366    // Source expiration replaces earlier response entries for each cleared
367    // cookie. The subsequent chunk-store cleanup retains incoming cache names.
368    let prior = request.take_response_headers();
369    let remember_name = related_cookie_name(&ctx.config, "dont_remember");
370    for (name, value) in prior {
371        let replaced = name.eq_ignore_ascii_case("set-cookie")
372            && names
373                .iter()
374                .chain(std::iter::once(&remember_name))
375                .any(|cookie_name| {
376                    value.starts_with(&format!("{cookie_name}="))
377                        || value.starts_with(&format!("{cookie_name}."))
378                });
379        if !replaced {
380            request.queue_response_header(name, value);
381        }
382    }
383    for header in session_cleanup_headers(&ctx.config, request.session_headers(), false)? {
384        request.queue_response_header("Set-Cookie", header);
385    }
386    Ok(())
387}