Skip to main content

alibi_core/session/cookie_cache/runtime/
read.rs

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