Skip to main content

better_auth_core/session/
native.rs

1use super::SessionData;
2use crate::{
3    AuthError, AuthResult, FieldMap, FieldValue, StructuredCloneContext,
4    store::JoinValue,
5    user_fields::UserConfig,
6    wire::{SessionView, UserView},
7};
8
9/// Session data before the selected User value crosses a typed authentication boundary.
10#[derive(Debug, Clone)]
11pub struct NativeSessionData {
12    /// Session passed to the credential writer.
13    pub session: SessionView,
14    /// Preserve the selected User object or relationship array for trusted callbacks.
15    pub user: FieldValue,
16}
17
18impl NativeSessionData {
19    /// Read a User model field. Relationship arrays have no User model fields.
20    pub fn user_field(&self, name: &str) -> &FieldValue {
21        self.user
22            .as_object()
23            .and_then(|fields| fields.get(name))
24            .unwrap_or(&FieldValue::Undefined)
25    }
26
27    /// Clone enumerable User fields and remove fields with `returned: false`.
28    /// Arrays become numeric-key objects only at this output boundary.
29    pub fn public_user(&self, config: &UserConfig) -> AuthResult<FieldValue> {
30        let value = StructuredCloneContext::new().clone_value(&self.user);
31        let mut fields = match value {
32            FieldValue::Object(fields) => (*fields).clone(),
33            FieldValue::Array(values) => values
34                .iter()
35                .enumerate()
36                .map(|(index, value)| (index.to_string(), value.clone()))
37                .collect(),
38            _ => {
39                return Err(AuthError::internal(
40                    "Session User must be an object or an array",
41                ));
42            }
43        };
44        fields.retain(|name, _| {
45            config
46                .fields()
47                .get(name)
48                .is_none_or(|field| field.returned())
49        });
50        Ok(fields.into())
51    }
52}
53
54impl From<SessionData> for NativeSessionData {
55    fn from(data: SessionData) -> Self {
56        Self {
57            session: data.session,
58            user: FieldMap::from(data.user).into(),
59        }
60    }
61}
62
63impl From<SessionData> for SessionData<JoinValue<UserView>> {
64    fn from(data: SessionData) -> Self {
65        Self {
66            session: data.session,
67            user: JoinValue::One(Some(data.user)),
68        }
69    }
70}
71
72impl SessionData<JoinValue<UserView>> {
73    /// A typed authentication caller requires one User; a relationship page has no User identity.
74    pub fn into_typed(self) -> AuthResult<Option<SessionData>> {
75        match self.user {
76            JoinValue::One(user) => Ok(user.map(|user| SessionData {
77                session: self.session,
78                user,
79            })),
80            JoinValue::Many(_) => Err(super::relationship_array_error()),
81        }
82    }
83}
84
85impl From<SessionData<JoinValue<UserView>>> for NativeSessionData {
86    fn from(data: SessionData<JoinValue<UserView>>) -> Self {
87        Self {
88            session: data.session,
89            user: match data.user {
90                JoinValue::One(Some(user)) => FieldMap::from(user).into(),
91                JoinValue::One(None) => FieldValue::Null,
92                JoinValue::Many(users) => users
93                    .into_iter()
94                    .map(|user| FieldValue::from(FieldMap::from(user)))
95                    .collect::<Vec<_>>()
96                    .into(),
97            },
98        }
99    }
100}
101
102impl From<NativeSessionData> for FieldMap {
103    fn from(data: NativeSessionData) -> Self {
104        Self::from([
105            ("session".into(), Self::from(data.session).into()),
106            ("user".into(), data.user),
107        ])
108    }
109}
110
111impl serde::Serialize for NativeSessionData {
112    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
113        crate::field_value::serde::map::serialize(&FieldMap::from(self.clone()), serializer)
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::{
121        AuthConfig, AuthRequest, CreateSession, CreateUser, FieldDate, FromFieldMap, HttpMethod,
122        config::{CookieCacheConfig, CookieCacheVersion},
123        session::{SessionManager, SessionRead, cookie_cache},
124        store::{EphemeralStore, StatelessSchema},
125        user_fields::{UserFieldConfig, UserFieldReference},
126    };
127    use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
128    use std::sync::{
129        Arc,
130        atomic::{AtomicUsize, Ordering},
131    };
132
133    fn session() -> AuthResult<SessionView> {
134        let now = FieldDate::from_milliseconds(1_000.0);
135        SessionView::from_field_values(FieldMap::from([
136            ("id".into(), "session".into()),
137            ("token".into(), "session-token".into()),
138            ("expiresAt".into(), now.clone().into()),
139            ("createdAt".into(), now.clone().into()),
140            ("updatedAt".into(), now.into()),
141        ]))
142    }
143
144    #[tokio::test]
145    async fn typed_relationship_array_rejection_precedes_refresh_and_cookie_callbacks()
146    -> AuthResult<()> {
147        let calls = Arc::new(AtomicUsize::new(0));
148        let recorded = calls.clone();
149        let mut config =
150            AuthConfig::new("typed-session-relationship-secret-at-least-32-characters");
151        let _ = config.user.fields_mut().insert(
152            "image".into(),
153            UserFieldConfig {
154                references: Some(UserFieldReference {
155                    model: "session".into(),
156                    field: "id".into(),
157                }),
158                ..Default::default()
159            },
160        );
161        config.session.cookie_cache = Some(CookieCacheConfig {
162            enabled: Some(true),
163            version: CookieCacheVersion::dynamic(move |_| {
164                let recorded = recorded.clone();
165                async move {
166                    let _ = recorded.fetch_add(1, Ordering::SeqCst);
167                    Ok("selected-relation".into())
168                }
169            }),
170            ..Default::default()
171        });
172        let config = Arc::new(config);
173        let manager = SessionManager::<StatelessSchema>::new(
174            config.clone(),
175            Arc::new(EphemeralStore::new(config.clone())),
176        );
177        let session = manager
178            .database
179            .create_session(CreateSession {
180                user_id: "canonical-owner".into(),
181                expires_at: (chrono::Utc::now() + chrono::Duration::hours(1)).into(),
182                additional_fields: Default::default(),
183                ip_address: None,
184                user_agent: None,
185                impersonated_by: None,
186                active_organization_id: None,
187            })
188            .await?;
189        let mut user = CreateUser::new()
190            .with_email("selected-owner@example.test")
191            .with_name("Selected owner");
192        user.image = Some(session.id.typed()?.clone()).into();
193        let user = manager.database.create_user(user).await?;
194        assert!(manager.needs_refresh(&session));
195        let mut request = AuthRequest::new(HttpMethod::Get, "/get-session");
196        let _ = request.headers.insert(
197            "cookie".into(),
198            format!(
199                "{}={}",
200                config.auth_cookie("session_token", Default::default()).name,
201                crate::utils::cookie_utils::sign_cookie_value(
202                    &session.token,
203                    config.signing_secret()
204                ),
205            ),
206        );
207        assert!(matches!(
208            manager.resolve(&request, SessionRead::Authoritative).await,
209            Err(AuthError::Internal(message)) if message == "A User relationship array cannot authenticate a typed User"
210        ));
211        assert_eq!(calls.load(Ordering::SeqCst), 0);
212        assert_eq!(
213            request
214                .take_response_headers()?
215                .get_all("set-cookie")
216                .count(),
217            0
218        );
219        let stored = manager
220            .database
221            .get_session(&session.token)
222            .await?
223            .ok_or_else(|| AuthError::internal("Selected Session must remain stored"))?;
224        assert_eq!(stored.expires_at, session.expires_at);
225        assert!(request.new_session()?.is_none());
226
227        let mut native_request = AuthRequest::new(HttpMethod::Get, "/get-session");
228        native_request.headers = request.headers.clone();
229        native_request.query = Some(serde_json::json!({"disableRefresh": true}));
230        let native = manager
231            .resolve_native(&native_request, SessionRead::Authoritative)
232            .await?
233            .data
234            .ok_or_else(|| {
235                AuthError::internal("Native resolution must preserve the selected relationship")
236            })?;
237        let selected = native
238            .user
239            .as_array()
240            .ok_or_else(|| AuthError::internal("Native User relationship must remain an array"))?;
241        assert_eq!(selected.len(), 1);
242        assert_eq!(
243            selected
244                .first()
245                .and_then(FieldValue::as_object)
246                .and_then(|fields| fields.get("id")),
247            Some(&user.id.field_value())
248        );
249        assert_eq!(calls.load(Ordering::SeqCst), 0);
250        Ok(())
251    }
252
253    #[test]
254    fn native_user_output_keeps_array_members_and_clone_identity() -> AuthResult<()> {
255        let date = FieldDate::from_milliseconds(123.0);
256        let user: FieldValue = FieldMap::from([
257            ("id".into(), "user-b".into()),
258            ("secretNote".into(), "private".into()),
259            ("ownUndefined".into(), FieldValue::Undefined),
260            ("when".into(), date.clone().into()),
261            ("sameWhen".into(), date.clone().into()),
262        ])
263        .into();
264        let mut config = UserConfig::default();
265        let _ = config.fields_mut().insert(
266            "secretNote".into(),
267            UserFieldConfig {
268                returned: Some(false),
269                ..Default::default()
270            },
271        );
272        let mut data = NativeSessionData {
273            session: session()?,
274            user: user.clone(),
275        };
276        let public = data.public_user(&config)?;
277        let public = public
278            .as_object()
279            .ok_or_else(|| AuthError::internal("Expected User object"))?;
280        assert!(!public.contains_key("secretNote"));
281        assert_eq!(public.get("ownUndefined"), Some(&FieldValue::Undefined));
282        data.user = vec![user.clone()].into();
283        assert!(data.user_field("id").is_undefined());
284        let public = data.public_user(&config)?;
285        assert_eq!(
286            public,
287            FieldValue::from(FieldMap::from([("0".into(), user)]))
288        );
289        let child = public
290            .as_object()
291            .and_then(|fields| fields.get("0"))
292            .and_then(FieldValue::as_object)
293            .ok_or_else(|| AuthError::internal("Expected numeric-key User output"))?;
294        let date_field = |name: &str| {
295            child
296                .get(name)
297                .and_then(FieldValue::as_date)
298                .ok_or_else(|| AuthError::internal(format!("Expected Date field: {name}")))
299        };
300        assert!(date_field("when")?.same_object(date_field("sameWhen")?));
301        assert!(!date_field("when")?.same_object(&date));
302        assert!(matches!(data.user, FieldValue::Array(_)));
303        Ok(())
304    }
305
306    #[tokio::test]
307    async fn native_cookie_issuance_keeps_array_for_callbacks_and_snapshot() -> AuthResult<()> {
308        let user: FieldValue = vec![FieldValue::from(FieldMap::from([
309            ("id".into(), "user-b".into()),
310            ("secretNote".into(), "child-value".into()),
311        ]))]
312        .into();
313        let calls = Arc::new(AtomicUsize::new(0));
314        let recorded = calls.clone();
315        let expected = user.clone();
316        let mut config = AuthConfig::new("native-session-cookie-secret-at-least-32-characters");
317        config.session.cookie_cache = Some(CookieCacheConfig {
318            enabled: Some(true),
319            version: CookieCacheVersion::dynamic(move |data| {
320                let expected = expected.clone();
321                let recorded = recorded.clone();
322                async move {
323                    assert!(data.user.strict_equals(&expected));
324                    assert!(data.session.user_id.is_undefined());
325                    let _ = recorded.fetch_add(1, Ordering::SeqCst);
326                    Ok("native".into())
327                }
328            }),
329            ..Default::default()
330        });
331        let config = Arc::new(config);
332        let manager = SessionManager::<StatelessSchema>::new(
333            config.clone(),
334            Arc::new(EphemeralStore::new(config.clone())),
335        );
336        let session = manager
337            .create_session_for_id(Default::default(), None, None)
338            .await?;
339        assert!(session.user_id.is_undefined());
340        let token = session.token.clone();
341        let data = NativeSessionData {
342            session,
343            user: user.clone(),
344        };
345        let request = AuthRequest::new(HttpMethod::Post, "/sign-in/social");
346        manager
347            .set_native_session_cookie(&request, data.clone(), None)
348            .await?;
349        assert_eq!(calls.load(Ordering::SeqCst), 1);
350        let snapshot = request
351            .new_session()?
352            .ok_or_else(|| AuthError::internal("Missing issued snapshot"))?;
353        assert!(snapshot.user.strict_equals(&user));
354        assert!(snapshot.session.user_id.is_undefined());
355        let headers = request.take_response_headers()?;
356        let cookies = headers
357            .get_all("set-cookie")
358            .map(|value| {
359                cookie::Cookie::parse(value.as_str())
360                    .map_err(|error| AuthError::internal(format!("Parsing test cookie: {error}")))
361            })
362            .collect::<AuthResult<Vec<_>>>()?;
363        let mut browser = AuthRequest::new(HttpMethod::Get, "/get-session");
364        let _ = browser.headers.insert(
365            "cookie".into(),
366            cookies
367                .iter()
368                .map(|cookie| format!("{}={}", cookie.name(), cookie.value()))
369                .collect::<Vec<_>>()
370                .join("; "),
371        );
372        assert_eq!(manager.extract_session_token(&browser), Some(token));
373        let cache = config
374            .session
375            .cookie_cache
376            .as_ref()
377            .ok_or_else(|| AuthError::internal("Missing cache config"))?;
378        let encoded = cookie_cache::read(&browser, "better-auth.session_data")
379            .ok_or_else(|| AuthError::internal("Missing issued cache cookie"))?;
380        let bytes = URL_SAFE_NO_PAD
381            .decode(&encoded)
382            .map_err(|error| AuthError::internal(format!("Decoding test cache: {error}")))?;
383        let payload: serde_json::Value = serde_json::from_slice(&bytes)?;
384        assert_eq!(
385            payload
386                .get("session")
387                .and_then(|data| data.get("user"))
388                .cloned(),
389            data.public_user(&config.user)?.json()?
390        );
391        assert!(cookie_cache::decode(&encoded, &config, cache).is_none());
392        Ok(())
393    }
394}