Skip to main content

parse_rust_auth/
sessions.rs

1//! `_Session` rows.
2//!
3//! Sessions are rows in a real Parse class, not entries in a process's memory. That is the whole
4//! point of this module: a token minted here survives a restart, is visible to every node, and is
5//! accepted by a parse-server pointed at the same database.
6//!
7//! **Everything here runs unauthenticated against storage, deliberately.** Upstream resolves a
8//! session token with `auth: master(config)` (`Auth.js:168`). It has to: until the lookup
9//! finishes there is no caller, so there is no ACL to evaluate and no CLP to consult. Creation is
10//! the same, `new RestWrite(config, Auth.master(config), '_Session', null, sessionData)`
11//! (`RestWrite.js:1133`). What keeps that narrow is that no query in this module comes from a
12//! client. Each one is built here from a session token, a user objectId or a session objectId.
13//!
14//! The read side of `_Session` that a *client* reaches, `GET /sessions`, is a different path and
15//! is not this module. Upstream narrows it to the caller's own user rather than master-gating it
16//! (`RestQuery.js:117-133`), and that belongs with the router.
17//!
18//! Out of scope, and absent rather than half-present:
19//!
20//! - **Session renewal.** `extendSessionOnUse` defaults to `false` upstream, so sessions expire
21//!   and are never extended. Matching the default configuration is the whole of it.
22//! - **The user cache.** `getAuthForSessionToken` consults a cache before the query
23//!   (`Auth.js:135-155`). Every resolution here is a query.
24//! - **Legacy non-`r:` tokens.** `middlewares.js` routes a token without the prefix to a separate
25//!   resolver that looks the user up by `_session_token` on `_User`. Not implemented.
26//! - **`POST /upgradeToRevocableSession`**, and client-driven `_Session` create and update.
27
28use indexmap::IndexMap;
29use rand::{CryptoRng, RngCore};
30
31use parse_rust_core::{new_object_id, ErrorCode, ParseDate, ParseError, ParseMap, ParseValue};
32use parse_rust_schema::default_schema;
33use parse_rust_storage::{
34    ClassSchema, Comparison, Constraint, Query, QueryOptions, Row, StorageAdapter,
35};
36
37/// Upstream's revocable-session prefix (`RestWrite.js:1111`).
38///
39/// Load-bearing rather than decorative: `middlewares.js` routes a token *without* it to the
40/// legacy resolver, which looks the token up on `_User` instead of `_Session`. A token minted
41/// without the prefix is therefore not a session token at all.
42pub const SESSION_TOKEN_PREFIX: &str = "r:";
43
44/// `randomHexString(32)` is 32 hex characters, which is 16 bytes (`cryptoUtils.js:6-14`, `:41`).
45const TOKEN_BYTES: usize = 16;
46
47/// The class sessions live in.
48const SESSION_CLASS: &str = "_Session";
49
50/// Fill from a cryptographically secure generator.
51///
52/// The `CryptoRng` bound is the point. A session token is a bearer credential, so a generator
53/// swapped for a faster non-cryptographic one has to fail to compile rather than pass the tests.
54/// Note that `random_string` in `parse-rust-core` is not usable here even though it draws from
55/// the same generator: its alphabet is the 62-character `objectId` set, and a session token's
56/// character set is observable to a client.
57fn fill_secure<R: RngCore + CryptoRng>(rng: &mut R, buf: &mut [u8]) {
58    rng.fill_bytes(buf);
59}
60
61/// A new session token: `r:` followed by 32 lowercase hex characters, 34 in total.
62///
63/// `'r:' + cryptoUtils.newToken()`, where `newToken` is `randomHexString(32)`
64/// (`RestWrite.js:1111`, `cryptoUtils.js:41`).
65pub fn new_session_token() -> String {
66    let mut bytes = [0u8; TOKEN_BYTES];
67    fill_secure(&mut rand::thread_rng(), &mut bytes);
68
69    let mut token = String::with_capacity(SESSION_TOKEN_PREFIX.len() + TOKEN_BYTES * 2);
70    token.push_str(SESSION_TOKEN_PREFIX);
71    for b in bytes {
72        // Lowercase, because that is what Node's `Buffer.toString('hex')` produces and a client
73        // comparing tokens case-sensitively would see the difference.
74        token.push(char::from(HEX[(b >> 4) as usize]));
75        token.push(char::from(HEX[(b & 0x0f) as usize]));
76    }
77    token
78}
79
80const HEX: &[u8; 16] = b"0123456789abcdef";
81
82/// What created a session.
83///
84/// Upstream stores this as a plain object with an `action` and, usually, an `authProvider`
85/// (`RestWrite.js:849`). Two of the four shapes **omit `authProvider` entirely**:
86/// `{action: 'upgrade'}` (`SessionsRouter.js:73`) and `{action: 'create'}`
87/// (`RestWrite.js:1274`). Modelling the provider as `Option<String>` rather than defaulting it to
88/// an empty string is what keeps those two writing the document parse-server writes: an empty
89/// string is a present key, and a present key is a difference a mixed fleet can read.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum SessionAction {
92    Signup,
93    Login,
94    /// `POST /upgradeToRevocableSession`. Out of scope for the route, modelled here so a row
95    /// written by parse-server round-trips rather than failing to parse.
96    Upgrade,
97    /// A client creating a `_Session` directly. Same note as `Upgrade`.
98    Create,
99}
100
101impl SessionAction {
102    pub fn as_str(&self) -> &'static str {
103        match self {
104            SessionAction::Signup => "signup",
105            SessionAction::Login => "login",
106            SessionAction::Upgrade => "upgrade",
107            SessionAction::Create => "create",
108        }
109    }
110}
111
112/// The `createdWith` column.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct CreatedWith {
115    pub action: SessionAction,
116    /// Absent for `upgrade` and `create`, which build the object literally rather than through
117    /// `buildCreatedWith`.
118    pub auth_provider: Option<String>,
119}
120
121impl CreatedWith {
122    /// `buildCreatedWith('signup', provider)`. A missing provider becomes `password`, which is
123    /// upstream's `authProvider || 'password'` (`RestWrite.js:849-851`).
124    pub fn signup(auth_provider: Option<&str>) -> Self {
125        Self {
126            action: SessionAction::Signup,
127            auth_provider: Some(auth_provider.unwrap_or("password").to_string()),
128        }
129    }
130
131    /// `buildCreatedWith('login', provider)`.
132    pub fn login(auth_provider: Option<&str>) -> Self {
133        Self {
134            action: SessionAction::Login,
135            auth_provider: Some(auth_provider.unwrap_or("password").to_string()),
136        }
137    }
138
139    /// `{action: 'upgrade'}`, with no `authProvider` key (`SessionsRouter.js:73`).
140    pub fn upgrade() -> Self {
141        Self {
142            action: SessionAction::Upgrade,
143            auth_provider: None,
144        }
145    }
146
147    /// `{action: 'create'}`, with no `authProvider` key (`RestWrite.js:1274`).
148    pub fn create() -> Self {
149        Self {
150            action: SessionAction::Create,
151            auth_provider: None,
152        }
153    }
154
155    fn to_value(&self) -> ParseValue {
156        let mut map = ParseMap::new();
157        map.insert(
158            "action".to_string(),
159            ParseValue::String(self.action.as_str().to_string()),
160        );
161        if let Some(provider) = &self.auth_provider {
162            map.insert(
163                "authProvider".to_string(),
164                ParseValue::String(provider.clone()),
165            );
166        }
167        ParseValue::Object(map)
168    }
169}
170
171/// The two options that decide a session's lifetime.
172///
173/// Taken as configuration rather than hardcoded, because both are server options and an operator
174/// can set either. The defaults are upstream's: `sessionLength` 31536000 seconds, one year
175/// (`Options/Definitions.js:629-634`), and `expireInactiveSessions` true
176/// (`Options/Definitions.js:269-274`).
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct SessionConfig {
179    pub session_length_secs: i64,
180    pub expire_inactive_sessions: bool,
181}
182
183impl Default for SessionConfig {
184    fn default() -> Self {
185        Self {
186            session_length_secs: 31_536_000,
187            expire_inactive_sessions: true,
188        }
189    }
190}
191
192impl SessionConfig {
193    /// `config.generateSessionExpiresAt()` (`Config.js:910-916`).
194    ///
195    /// `None` when `expireInactiveSessions` is false, which is upstream's `undefined` and which
196    /// produces a session that never expires. See [`resolve_session`].
197    pub fn generate_expires_at(&self, now: ParseDate) -> Option<ParseDate> {
198        if !self.expire_inactive_sessions {
199            return None;
200        }
201        let millis = now
202            .timestamp_millis()
203            .checked_add(self.session_length_secs.saturating_mul(1000))?;
204        chrono::DateTime::from_timestamp_millis(millis).map(ParseDate::from_datetime)
205    }
206}
207
208/// What a caller must supply to mint a session.
209#[derive(Debug, Clone)]
210pub struct NewSession<'a> {
211    pub user_object_id: &'a str,
212    /// `None` writes no `createdWith` column at all, which is what a password-change replacement
213    /// session gets. `setCreatedWith` computes an action of `login` only when an auth provider is
214    /// in storage and `signup` only on a create, and returns early with neither set otherwise
215    /// (`RestWrite.js:860-870`), so the column is simply absent. Modelled as an `Option` rather
216    /// than a third `SessionAction`, because the difference is whether the key exists.
217    pub created_with: Option<CreatedWith>,
218    /// `X-Parse-Installation-Id`, when the client sent one. Its presence is what enables
219    /// duplicate destruction; see [`create_session`].
220    pub installation_id: Option<&'a str>,
221}
222
223/// A freshly minted session.
224#[derive(Debug, Clone)]
225pub struct CreatedSession {
226    pub session_token: String,
227    pub object_id: String,
228    /// `None` when `expireInactiveSessions` is false.
229    pub expires_at: Option<ParseDate>,
230    pub created_at: ParseDate,
231}
232
233/// A session resolved from a token.
234#[derive(Debug, Clone)]
235pub struct ResolvedSession {
236    pub object_id: String,
237    pub user_object_id: String,
238    pub session_token: String,
239    pub installation_id: Option<String>,
240    /// `None` for a session that never expires.
241    pub expires_at: Option<ParseDate>,
242    /// The row as stored, in Parse form. `GET /sessions/me` returns it; nothing else should need
243    /// it, and it carries no secret beyond the token the caller already presented.
244    pub row: Row,
245}
246
247fn invalid_session_token() -> ParseError {
248    // No trailing period. Upstream's two session errors differ in punctuation and a client
249    // matching on the message would see it (`Auth.js:185` versus `:191`).
250    ParseError::new(ErrorCode::InvalidSessionToken, "Invalid session token")
251}
252
253fn session_expired() -> ParseError {
254    ParseError::new(ErrorCode::InvalidSessionToken, "Session token is expired.")
255}
256
257/// `Auth.js:195-197`. A user objectId beginning with `role:` would be granted that role by every
258/// ACL check, so upstream refuses to build an `Auth` from one.
259fn role_prefixed_object_id() -> ParseError {
260    ParseError::new(ErrorCode::InternalServerError, "Invalid object ID.")
261}
262
263/// The schema `_Session` rows are read and written against.
264///
265/// Built from the default columns rather than loaded, because every column this module touches is
266/// a default column of `_Session` (`SchemaController.js:70-77`) and a client cannot redefine one.
267/// The one thing the stored schema could add is an extra column from `additionalSessionData`,
268/// which is a route this milestone does not serve, and which raises correctly anyway because the
269/// stored form is self-describing.
270fn session_schema() -> ClassSchema {
271    default_schema(SESSION_CLASS)
272}
273
274/// Ensure `_SCHEMA` carries a `_Session` entry.
275///
276/// Worth stating why this exists at all. parse-server creates the `_SCHEMA` document for
277/// `_Session` on its first session write. If parse-rust writes rows into the `_Session`
278/// collection without ever writing that document, a parse-server pointed at the same database has
279/// no `_Session` class to query, and every token parse-rust issued is invisible to it. That is
280/// exactly the shared-state property this milestone claims, so it cannot be left to chance.
281///
282/// Reads first and writes only when the class is absent. The read is per session creation, not
283/// per request, and it is cheaper than an unconditional upsert on every login. When it does
284/// write, it is safe against a class parse-server already created: `upsert_schema` sets the field
285/// keys it is given and leaves `_metadata` alone, so an existing CLP block survives.
286pub async fn ensure_session_schema<S: StorageAdapter>(storage: &S) -> Result<(), ParseError> {
287    let existing = storage.all_schemas().await?;
288    if existing.iter().any(|s| s.class_name == SESSION_CLASS) {
289        return Ok(());
290    }
291    storage.upsert_schema(&session_schema()).await
292}
293
294/// Mint a session and write its row.
295///
296/// The row is upstream's, key for key and in upstream's order (`RestWrite.js:1107-1135`, then the
297/// default fields at `:423-431`): `sessionToken`, `user`, `createdWith`, `expiresAt`,
298/// `installationId` when present, then `updatedAt`, `createdAt`, `objectId`.
299///
300/// **No ACL.** `_Session` rows carry none. Upstream refuses a client-supplied one outright,
301/// `Cannot set ACL on a Session.` (`RestWrite.js:1231-1232`), and the two paths that add an ACL
302/// automatically do not apply: the CLP-derived default ACL needs a non-default
303/// `classLevelPermissions.ACL` (`RestWrite.js:378-395`), and the owner-private ACL is `_User`
304/// only (`RestWrite.js:1674-1686`). An absent ACL means the row is public to anything reading the
305/// collection directly, and what makes that safe is that the client-facing read of `_Session` is
306/// narrowed to the caller's own user before it reaches storage (`RestQuery.js:117-133`). Adding
307/// an ACL here would be inventing a column parse-server does not write.
308pub async fn create_session<S: StorageAdapter>(
309    storage: &S,
310    config: &SessionConfig,
311    new: NewSession<'_>,
312) -> Result<CreatedSession, ParseError> {
313    let schema = session_schema();
314    let now = ParseDate::now();
315    let token = new_session_token();
316    let expires_at = config.generate_expires_at(now);
317    let object_id = new_object_id();
318
319    let user = ParseValue::Pointer {
320        class_name: "_User".to_string(),
321        object_id: new.user_object_id.to_string(),
322    };
323
324    let mut row: Row = IndexMap::new();
325    row.insert(
326        "sessionToken".to_string(),
327        ParseValue::String(token.clone()),
328    );
329    row.insert("user".to_string(), user.clone());
330    if let Some(created_with) = &new.created_with {
331        row.insert("createdWith".to_string(), created_with.to_value());
332    }
333    // Deliberately omitted rather than written as null when there is no expiry. Upstream assigns
334    // `Parse._encode(undefined)`, and what the Node BSON serializer then stores for an undefined
335    // value is a driver-configuration question this has not been measured against a running
336    // server. Both forms read identically upstream, because `session.expiresAt ? ... : undefined`
337    // treats an absent key and a null one the same (`Auth.js:189`), so the choice is not
338    // observable through any Parse API. An absent key is the form that also means "no expiry"
339    // unambiguously in a SQL backend.
340    if let Some(expires_at) = expires_at {
341        row.insert("expiresAt".to_string(), ParseValue::Date(expires_at));
342    }
343    if let Some(installation_id) = new.installation_id {
344        row.insert(
345            "installationId".to_string(),
346            ParseValue::String(installation_id.to_string()),
347        );
348    }
349    row.insert("updatedAt".to_string(), ParseValue::Date(now));
350    row.insert("createdAt".to_string(), ParseValue::Date(now));
351    row.insert(
352        "objectId".to_string(),
353        ParseValue::String(object_id.clone()),
354    );
355
356    // Before the insert, not after. `destroyDuplicatedSessions` runs at `RestWrite.js:144`, which
357    // is ahead of `runDatabaseOperation` at `:147`, so the new row is not yet a candidate for its
358    // own dedup. The `sessionToken != token` guard upstream carries is kept anyway: it costs one
359    // clause and it is what makes the order not matter.
360    destroy_duplicated_sessions(storage, &schema, &user, new.installation_id, &token).await?;
361
362    ensure_session_schema(storage).await?;
363    storage.create(&schema, &row).await?;
364
365    Ok(CreatedSession {
366        session_token: token,
367        object_id,
368        expires_at,
369        created_at: now,
370    })
371}
372
373/// `destroyDuplicatedSessions` (`RestWrite.js:1153`).
374///
375/// **Note the conjunction.** The delete matches the same user *and* the same installationId, so
376/// two sessions from two devices coexist and two from one device do not. It is skipped entirely
377/// when there is no installationId, which is the common case for a REST client that sends no
378/// `X-Parse-Installation-Id`: without one, every login would otherwise revoke every other
379/// session the user has.
380///
381/// Upstream swallows `OBJECT_NOT_FOUND` from the destroy. There is nothing to swallow here,
382/// because the adapter reports a match count rather than raising on zero.
383async fn destroy_duplicated_sessions<S: StorageAdapter>(
384    storage: &S,
385    schema: &ClassSchema,
386    user: &ParseValue,
387    installation_id: Option<&str>,
388    session_token: &str,
389) -> Result<(), ParseError> {
390    let Some(installation_id) = installation_id else {
391        return Ok(());
392    };
393
394    let query = Query::from_constraints(vec![
395        Constraint::equal("user", user.clone()),
396        Constraint::equal(
397            "installationId",
398            ParseValue::String(installation_id.to_string()),
399        ),
400        Constraint {
401            field: "sessionToken".to_string(),
402            comparison: Comparison::NotEqual(ParseValue::String(session_token.to_string())),
403        },
404    ]);
405    storage.delete(schema, &query).await?;
406    Ok(())
407}
408
409/// Resolve a session token to its session.
410///
411/// `getAuthForSessionToken`'s miss path (`Auth.js:157-197`). The order of the three failures is
412/// upstream's and is observable, because the first one reached is the error the client sees:
413///
414/// 1. no row, or a row with no `user`: `INVALID_SESSION_TOKEN` (209) `Invalid session token`
415/// 2. `expiresAt` in the past: 209 `Session token is expired.`
416/// 3. the user objectId starts with `role:`: `INTERNAL_SERVER_ERROR` (1) `Invalid object ID.`
417///
418/// **UPSTREAM-QUIRK: a session with no `expiresAt` never expires.** Upstream computes
419/// `expiresAt = session.expiresAt ? new Date(session.expiresAt.iso) : undefined` and then tests
420/// `expiresAt < now` (`Auth.js:188-192`). In JavaScript `undefined < now` is false, so the check
421/// passes. That is intended, and it is what keeps a row written under
422/// `expireInactiveSessions: false`, or by an older server, working. Reproduced exactly. See
423/// `a_session_with_no_expiry_never_expires` for the test that makes "fixing" this fail loudly.
424///
425/// The `role:` guard is upstream's check on the *included* user object rather than on the
426/// pointer. The two carry the same objectId, including when the referenced `_User` row does not
427/// exist, in which case `include` leaves the pointer un-hydrated and upstream reads the objectId
428/// straight off it.
429pub async fn resolve_session<S: StorageAdapter>(
430    storage: &S,
431    session_token: &str,
432) -> Result<ResolvedSession, ParseError> {
433    let schema = session_schema();
434    let query = Query::from_constraints(vec![Constraint::equal(
435        "sessionToken",
436        ParseValue::String(session_token.to_string()),
437    )]);
438    let options = QueryOptions {
439        limit: Some(1),
440        skip: None,
441        order: Vec::new(),
442        keys: None,
443        case_insensitive: false,
444    };
445
446    let rows = storage.find(&schema, &query, &options).await?;
447    let Some(row) = rows.into_iter().next() else {
448        return Err(invalid_session_token());
449    };
450
451    // Upstream's condition is `results.length !== 1 || !results[0]['user']`. A row whose `user`
452    // is absent, null or not a pointer fails it.
453    let user_object_id = match row.get("user") {
454        Some(ParseValue::Pointer { object_id, .. }) => object_id.clone(),
455        _ => return Err(invalid_session_token()),
456    };
457
458    let expires_at = match row.get("expiresAt") {
459        Some(ParseValue::Date(d)) => Some(*d),
460        // A stored `expiresAt` that is not a Date is treated as absent, which is the never-expires
461        // path. That is upstream's behavior for a null, and for a string it is the behavior the
462        // Mongo transform's own note describes: a string `expiresAt` is compared against a Date
463        // and never satisfies the check.
464        _ => None,
465    };
466    if let Some(expires_at) = expires_at {
467        if expires_at.timestamp_millis() < ParseDate::now().timestamp_millis() {
468            return Err(session_expired());
469        }
470    }
471
472    if user_object_id.starts_with("role:") {
473        return Err(role_prefixed_object_id());
474    }
475
476    let object_id = match row.get("objectId") {
477        Some(ParseValue::String(id)) => id.clone(),
478        _ => return Err(invalid_session_token()),
479    };
480    let installation_id = match row.get("installationId") {
481        Some(ParseValue::String(id)) => Some(id.clone()),
482        _ => None,
483    };
484
485    Ok(ResolvedSession {
486        object_id,
487        user_object_id,
488        session_token: session_token.to_string(),
489        installation_id,
490        expires_at,
491        row,
492    })
493}
494
495/// Delete one session by its token. Returns whether a row was removed.
496pub async fn revoke<S: StorageAdapter>(
497    storage: &S,
498    session_token: &str,
499) -> Result<bool, ParseError> {
500    let query = Query::from_constraints(vec![Constraint::equal(
501        "sessionToken",
502        ParseValue::String(session_token.to_string()),
503    )]);
504    let deleted = storage.delete(&session_schema(), &query).await?;
505    Ok(deleted > 0)
506}
507
508/// Delete every session belonging to a user. Returns how many.
509///
510/// This is what a password change needs. `revokeSessionOnPasswordReset` defaults to true
511/// (`Options/Definitions.js:584-589`) and the destroy it performs is exactly this query, keyed on
512/// the user pointer with nothing else (`RestWrite.js:1192-1204`).
513///
514/// Implemented ahead of any route that reaches it, on purpose. The alternative is that
515/// `DELETE /sessions/:objectId` grows its own one-row delete and the password-change path grows a
516/// second, similar one later, and the two then diverge. One function, two callers.
517pub async fn revoke_all_for_user<S: StorageAdapter>(
518    storage: &S,
519    user_object_id: &str,
520) -> Result<u64, ParseError> {
521    let query = Query::from_constraints(vec![Constraint::equal(
522        "user",
523        ParseValue::Pointer {
524            class_name: "_User".to_string(),
525            object_id: user_object_id.to_string(),
526        },
527    )]);
528    storage.delete(&session_schema(), &query).await
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use crate::testing::FakeStorage;
535    use std::collections::HashSet;
536
537    fn cfg() -> SessionConfig {
538        SessionConfig::default()
539    }
540
541    #[test]
542    fn a_token_is_r_plus_thirty_two_lowercase_hex() {
543        let t = new_session_token();
544        assert_eq!(t.len(), 34, "r: plus 32 hex characters: {t}");
545        let hex = t.strip_prefix("r:").expect("the r: prefix is load-bearing");
546        assert_eq!(hex.len(), 32);
547        assert!(
548            hex.bytes()
549                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
550            "lowercase hex only, matching Buffer.toString('hex'): {hex}"
551        );
552    }
553
554    /// The failure this guards against is reusing the objectId alphabet, which is alphanumeric
555    /// over 62 characters. Any uppercase letter or any digit above `f` proves it happened.
556    #[test]
557    fn tokens_do_not_use_the_object_id_alphabet() {
558        let mut seen: HashSet<char> = HashSet::new();
559        for _ in 0..500 {
560            seen.extend(new_session_token()[2..].chars());
561        }
562        assert_eq!(seen.len(), 16, "a hex token uses exactly 16 characters");
563        assert!(seen
564            .iter()
565            .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()));
566    }
567
568    #[test]
569    fn tokens_do_not_repeat() {
570        let tokens: HashSet<String> = (0..1000).map(|_| new_session_token()).collect();
571        assert_eq!(tokens.len(), 1000);
572    }
573
574    #[tokio::test]
575    async fn the_row_is_upstreams_columns_in_upstreams_order() {
576        let s = FakeStorage::new();
577        let created = create_session(
578            &s,
579            &cfg(),
580            NewSession {
581                user_object_id: "user000001",
582                created_with: Some(CreatedWith::signup(None)),
583                installation_id: Some("install-1"),
584            },
585        )
586        .await
587        .expect("create");
588
589        let rows = s.rows("_Session");
590        assert_eq!(rows.len(), 1);
591        let row = &rows[0];
592        let keys: Vec<&str> = row.keys().map(String::as_str).collect();
593        assert_eq!(
594            keys,
595            vec![
596                "sessionToken",
597                "user",
598                "createdWith",
599                "expiresAt",
600                "installationId",
601                "updatedAt",
602                "createdAt",
603                "objectId",
604            ]
605        );
606
607        assert!(
608            matches!(row.get("user"), Some(ParseValue::Pointer { class_name, object_id })
609            if class_name == "_User" && object_id == "user000001")
610        );
611        assert!(
612            matches!(row.get("objectId"), Some(ParseValue::String(id)) if id == &created.object_id)
613        );
614        assert!(
615            matches!(row.get("sessionToken"), Some(ParseValue::String(t)) if t == &created.session_token)
616        );
617    }
618
619    #[tokio::test]
620    async fn created_with_carries_action_and_provider_for_signup_and_login() {
621        for (built, action, provider) in [
622            (CreatedWith::signup(None), "signup", Some("password")),
623            (CreatedWith::login(None), "login", Some("password")),
624            (
625                CreatedWith::login(Some("facebook")),
626                "login",
627                Some("facebook"),
628            ),
629        ] {
630            let ParseValue::Object(map) = built.to_value() else {
631                panic!("createdWith is an object");
632            };
633            let keys: Vec<&str> = map.keys().map(String::as_str).collect();
634            assert_eq!(keys, vec!["action", "authProvider"]);
635            assert!(matches!(map.get("action"), Some(ParseValue::String(a)) if a == action));
636            assert!(
637                matches!(map.get("authProvider"), Some(ParseValue::String(p)) if Some(p.as_str()) == provider)
638            );
639        }
640    }
641
642    /// The two shapes that omit the key. An empty-string default would be a present key, and a
643    /// present key is a difference a parse-server reading the same row can see.
644    #[tokio::test]
645    async fn upgrade_and_create_omit_the_auth_provider_key_entirely() {
646        for (built, action) in [
647            (CreatedWith::upgrade(), "upgrade"),
648            (CreatedWith::create(), "create"),
649        ] {
650            assert_eq!(built.auth_provider, None);
651            let ParseValue::Object(map) = built.to_value() else {
652                panic!("createdWith is an object");
653            };
654            let keys: Vec<&str> = map.keys().map(String::as_str).collect();
655            assert_eq!(
656                keys,
657                vec!["action"],
658                "authProvider must be absent, not empty"
659            );
660            assert!(matches!(map.get("action"), Some(ParseValue::String(a)) if a == action));
661        }
662    }
663
664    #[tokio::test]
665    async fn a_session_row_has_no_acl() {
666        let s = FakeStorage::new();
667        create_session(
668            &s,
669            &cfg(),
670            NewSession {
671                user_object_id: "u1",
672                created_with: Some(CreatedWith::login(None)),
673                installation_id: None,
674            },
675        )
676        .await
677        .expect("create");
678        let rows = s.rows("_Session");
679        assert!(
680            rows[0].get("ACL").is_none(),
681            "upstream refuses an ACL on _Session and adds none of its own"
682        );
683    }
684
685    #[tokio::test]
686    async fn creating_a_session_writes_the_session_schema() {
687        let s = FakeStorage::new();
688        create_session(
689            &s,
690            &cfg(),
691            NewSession {
692                user_object_id: "u1",
693                created_with: Some(CreatedWith::login(None)),
694                installation_id: None,
695            },
696        )
697        .await
698        .expect("create");
699        // Without this, a parse-server on the same database has no _Session class to query and
700        // every token parse-rust issued is invisible to it.
701        assert!(s.schema("_Session").is_some());
702    }
703
704    #[tokio::test]
705    async fn expiry_is_now_plus_session_length_and_is_absent_when_disabled() {
706        let s = FakeStorage::new();
707        let created = create_session(
708            &s,
709            &cfg(),
710            NewSession {
711                user_object_id: "u1",
712                created_with: Some(CreatedWith::login(None)),
713                installation_id: None,
714            },
715        )
716        .await
717        .expect("create");
718        let expires = created.expires_at.expect("default config expires sessions");
719        let delta = expires.timestamp_millis() - created.created_at.timestamp_millis();
720        assert_eq!(delta, 31_536_000 * 1000, "one year, in milliseconds");
721
722        let never = SessionConfig {
723            expire_inactive_sessions: false,
724            ..SessionConfig::default()
725        };
726        let created = create_session(
727            &s,
728            &never,
729            NewSession {
730                user_object_id: "u2",
731                created_with: Some(CreatedWith::login(None)),
732                installation_id: None,
733            },
734        )
735        .await
736        .expect("create");
737        assert_eq!(created.expires_at, None);
738        let row = s
739            .rows("_Session")
740            .into_iter()
741            .find(|r| matches!(r.get("user"), Some(ParseValue::Pointer { object_id, .. }) if object_id == "u2"))
742            .expect("row");
743        assert!(row.get("expiresAt").is_none());
744    }
745
746    #[tokio::test]
747    async fn a_minted_token_resolves_to_its_user() {
748        let s = FakeStorage::new();
749        let created = create_session(
750            &s,
751            &cfg(),
752            NewSession {
753                user_object_id: "user000001",
754                created_with: Some(CreatedWith::signup(None)),
755                installation_id: Some("install-1"),
756            },
757        )
758        .await
759        .expect("create");
760
761        let resolved = resolve_session(&s, &created.session_token)
762            .await
763            .expect("resolve");
764        assert_eq!(resolved.user_object_id, "user000001");
765        assert_eq!(resolved.object_id, created.object_id);
766        assert_eq!(resolved.installation_id.as_deref(), Some("install-1"));
767        assert_eq!(resolved.expires_at, created.expires_at);
768    }
769
770    #[tokio::test]
771    async fn an_unknown_token_is_invalid_session_token() {
772        let s = FakeStorage::new();
773        let e = resolve_session(&s, "r:nope").await.unwrap_err();
774        assert_eq!(e.code, ErrorCode::InvalidSessionToken);
775        assert_eq!(e.message, "Invalid session token");
776    }
777
778    #[tokio::test]
779    async fn a_row_with_no_user_is_invalid_session_token() {
780        let s = FakeStorage::new();
781        s.insert_row(
782            "_Session",
783            vec![
784                ("objectId", ParseValue::String("s1".into())),
785                ("sessionToken", ParseValue::String("r:orphan".into())),
786            ],
787        );
788        let e = resolve_session(&s, "r:orphan").await.unwrap_err();
789        assert_eq!(e.code, ErrorCode::InvalidSessionToken);
790        assert_eq!(e.message, "Invalid session token");
791    }
792
793    /// Expiry is checked after the user check and before the `role:` check. The order is
794    /// observable: a row that is both expired and role-prefixed reports expiry.
795    #[tokio::test]
796    async fn the_three_failures_are_checked_in_upstreams_order() {
797        let s = FakeStorage::new();
798        let past = ParseDate::parse_iso("2000-01-01T00:00:00.000Z").expect("date");
799
800        // Expired and role-prefixed at once: expiry wins.
801        s.insert_row(
802            "_Session",
803            vec![
804                ("objectId", ParseValue::String("s1".into())),
805                ("sessionToken", ParseValue::String("r:both".into())),
806                (
807                    "user",
808                    ParseValue::Pointer {
809                        class_name: "_User".into(),
810                        object_id: "role:Admins".into(),
811                    },
812                ),
813                ("expiresAt", ParseValue::Date(past)),
814            ],
815        );
816        let e = resolve_session(&s, "r:both").await.unwrap_err();
817        assert_eq!(e.code, ErrorCode::InvalidSessionToken);
818        assert_eq!(e.message, "Session token is expired.");
819
820        // Role-prefixed but not expired: the internal error.
821        s.insert_row(
822            "_Session",
823            vec![
824                ("objectId", ParseValue::String("s2".into())),
825                ("sessionToken", ParseValue::String("r:role".into())),
826                (
827                    "user",
828                    ParseValue::Pointer {
829                        class_name: "_User".into(),
830                        object_id: "role:Admins".into(),
831                    },
832                ),
833            ],
834        );
835        let e = resolve_session(&s, "r:role").await.unwrap_err();
836        assert_eq!(e.code, ErrorCode::InternalServerError);
837        assert_eq!(e.message, "Invalid object ID.");
838
839        // No user at all, and expired: the user check wins.
840        s.insert_row(
841            "_Session",
842            vec![
843                ("objectId", ParseValue::String("s3".into())),
844                ("sessionToken", ParseValue::String("r:nouser".into())),
845                ("expiresAt", ParseValue::Date(past)),
846            ],
847        );
848        let e = resolve_session(&s, "r:nouser").await.unwrap_err();
849        assert_eq!(e.message, "Invalid session token");
850    }
851
852    /// UPSTREAM-QUIRK, and the test exists so that "fixing" it fails loudly.
853    ///
854    /// `undefined < now` is false in JavaScript (`Auth.js:188-192`), so a `_Session` row with no
855    /// `expiresAt` authenticates forever. Legacy rows and rows written under
856    /// `expireInactiveSessions: false` depend on it. If this test ever fails because someone made
857    /// a missing expiry mean "expired", they have logged out every such session on the database.
858    #[tokio::test]
859    async fn upstream_quirk_a_session_with_no_expiry_never_expires() {
860        let s = FakeStorage::new();
861        s.insert_row(
862            "_Session",
863            vec![
864                ("objectId", ParseValue::String("s1".into())),
865                ("sessionToken", ParseValue::String("r:legacy".into())),
866                (
867                    "user",
868                    ParseValue::Pointer {
869                        class_name: "_User".into(),
870                        object_id: "u1".into(),
871                    },
872                ),
873            ],
874        );
875        let resolved = resolve_session(&s, "r:legacy").await.expect("resolve");
876        assert_eq!(resolved.user_object_id, "u1");
877        assert_eq!(resolved.expires_at, None);
878    }
879
880    #[tokio::test]
881    async fn duplicate_destruction_is_per_user_and_per_installation() {
882        let s = FakeStorage::new();
883        let mk = |user: &'static str, install: Option<&'static str>| NewSession {
884            user_object_id: user,
885            created_with: Some(CreatedWith::login(None)),
886            installation_id: install,
887        };
888
889        let phone_a = create_session(&s, &cfg(), mk("alice", Some("phone")))
890            .await
891            .expect("create");
892        let tablet_a = create_session(&s, &cfg(), mk("alice", Some("tablet")))
893            .await
894            .expect("create");
895        let phone_b = create_session(&s, &cfg(), mk("bob", Some("phone")))
896            .await
897            .expect("create");
898
899        // A second login from alice's phone destroys only alice's phone session.
900        let phone_a2 = create_session(&s, &cfg(), mk("alice", Some("phone")))
901            .await
902            .expect("create");
903
904        assert!(resolve_session(&s, &phone_a.session_token).await.is_err());
905        assert!(resolve_session(&s, &phone_a2.session_token).await.is_ok());
906        assert!(
907            resolve_session(&s, &tablet_a.session_token).await.is_ok(),
908            "two devices, two sessions: the match is on user AND installationId"
909        );
910        assert!(
911            resolve_session(&s, &phone_b.session_token).await.is_ok(),
912            "another user's session on the same installationId must survive"
913        );
914    }
915
916    #[tokio::test]
917    async fn without_an_installation_id_nothing_is_destroyed() {
918        let s = FakeStorage::new();
919        let mk = || NewSession {
920            user_object_id: "alice",
921            created_with: Some(CreatedWith::login(None)),
922            installation_id: None,
923        };
924        let first = create_session(&s, &cfg(), mk()).await.expect("create");
925        let second = create_session(&s, &cfg(), mk()).await.expect("create");
926        // Upstream skips the dedup when either half of the pair is missing. Doing otherwise would
927        // make every REST login revoke every other session the user has.
928        assert!(resolve_session(&s, &first.session_token).await.is_ok());
929        assert!(resolve_session(&s, &second.session_token).await.is_ok());
930    }
931
932    #[tokio::test]
933    async fn revoke_removes_one_session_and_revoke_all_removes_the_users() {
934        let s = FakeStorage::new();
935        let mk = |user: &'static str| NewSession {
936            user_object_id: user,
937            created_with: Some(CreatedWith::login(None)),
938            installation_id: None,
939        };
940        let a1 = create_session(&s, &cfg(), mk("alice")).await.expect("c");
941        let a2 = create_session(&s, &cfg(), mk("alice")).await.expect("c");
942        let b1 = create_session(&s, &cfg(), mk("bob")).await.expect("c");
943
944        assert!(revoke(&s, &a1.session_token).await.expect("revoke"));
945        assert!(
946            !revoke(&s, &a1.session_token).await.expect("revoke"),
947            "revoking twice reports the second as a miss"
948        );
949        assert!(resolve_session(&s, &a2.session_token).await.is_ok());
950
951        assert_eq!(revoke_all_for_user(&s, "alice").await.expect("revoke"), 1);
952        assert!(resolve_session(&s, &a2.session_token).await.is_err());
953        assert!(
954            resolve_session(&s, &b1.session_token).await.is_ok(),
955            "another user's sessions must survive"
956        );
957    }
958
959    #[tokio::test]
960    async fn resolution_reads_at_most_one_row() {
961        let s = FakeStorage::new();
962        let created = create_session(
963            &s,
964            &cfg(),
965            NewSession {
966                user_object_id: "u1",
967                created_with: Some(CreatedWith::login(None)),
968                installation_id: None,
969            },
970        )
971        .await
972        .expect("create");
973        s.reset_find_count();
974        resolve_session(&s, &created.session_token)
975            .await
976            .expect("resolve");
977        assert_eq!(
978            s.find_count(),
979            1,
980            "session resolution is on every authenticated request; it stays one query"
981        );
982        assert_eq!(s.last_find_limit(), Some(Some(1)));
983    }
984}