use indexmap::IndexMap;
use rand::{CryptoRng, RngCore};
use parse_rust_core::{new_object_id, ErrorCode, ParseDate, ParseError, ParseMap, ParseValue};
use parse_rust_schema::default_schema;
use parse_rust_storage::{
ClassSchema, Comparison, Constraint, Query, QueryOptions, Row, StorageAdapter,
};
pub const SESSION_TOKEN_PREFIX: &str = "r:";
const TOKEN_BYTES: usize = 16;
const SESSION_CLASS: &str = "_Session";
fn fill_secure<R: RngCore + CryptoRng>(rng: &mut R, buf: &mut [u8]) {
rng.fill_bytes(buf);
}
pub fn new_session_token() -> String {
let mut bytes = [0u8; TOKEN_BYTES];
fill_secure(&mut rand::thread_rng(), &mut bytes);
let mut token = String::with_capacity(SESSION_TOKEN_PREFIX.len() + TOKEN_BYTES * 2);
token.push_str(SESSION_TOKEN_PREFIX);
for b in bytes {
token.push(char::from(HEX[(b >> 4) as usize]));
token.push(char::from(HEX[(b & 0x0f) as usize]));
}
token
}
const HEX: &[u8; 16] = b"0123456789abcdef";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionAction {
Signup,
Login,
Upgrade,
Create,
}
impl SessionAction {
pub fn as_str(&self) -> &'static str {
match self {
SessionAction::Signup => "signup",
SessionAction::Login => "login",
SessionAction::Upgrade => "upgrade",
SessionAction::Create => "create",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreatedWith {
pub action: SessionAction,
pub auth_provider: Option<String>,
}
impl CreatedWith {
pub fn signup(auth_provider: Option<&str>) -> Self {
Self {
action: SessionAction::Signup,
auth_provider: Some(auth_provider.unwrap_or("password").to_string()),
}
}
pub fn login(auth_provider: Option<&str>) -> Self {
Self {
action: SessionAction::Login,
auth_provider: Some(auth_provider.unwrap_or("password").to_string()),
}
}
pub fn upgrade() -> Self {
Self {
action: SessionAction::Upgrade,
auth_provider: None,
}
}
pub fn create() -> Self {
Self {
action: SessionAction::Create,
auth_provider: None,
}
}
fn to_value(&self) -> ParseValue {
let mut map = ParseMap::new();
map.insert(
"action".to_string(),
ParseValue::String(self.action.as_str().to_string()),
);
if let Some(provider) = &self.auth_provider {
map.insert(
"authProvider".to_string(),
ParseValue::String(provider.clone()),
);
}
ParseValue::Object(map)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionConfig {
pub session_length_secs: i64,
pub expire_inactive_sessions: bool,
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
session_length_secs: 31_536_000,
expire_inactive_sessions: true,
}
}
}
impl SessionConfig {
pub fn generate_expires_at(&self, now: ParseDate) -> Option<ParseDate> {
if !self.expire_inactive_sessions {
return None;
}
let millis = now
.timestamp_millis()
.checked_add(self.session_length_secs.saturating_mul(1000))?;
chrono::DateTime::from_timestamp_millis(millis).map(ParseDate::from_datetime)
}
}
#[derive(Debug, Clone)]
pub struct NewSession<'a> {
pub user_object_id: &'a str,
pub created_with: Option<CreatedWith>,
pub installation_id: Option<&'a str>,
}
#[derive(Debug, Clone)]
pub struct CreatedSession {
pub session_token: String,
pub object_id: String,
pub expires_at: Option<ParseDate>,
pub created_at: ParseDate,
}
#[derive(Debug, Clone)]
pub struct ResolvedSession {
pub object_id: String,
pub user_object_id: String,
pub session_token: String,
pub installation_id: Option<String>,
pub expires_at: Option<ParseDate>,
pub row: Row,
}
fn invalid_session_token() -> ParseError {
ParseError::new(ErrorCode::InvalidSessionToken, "Invalid session token")
}
fn session_expired() -> ParseError {
ParseError::new(ErrorCode::InvalidSessionToken, "Session token is expired.")
}
fn role_prefixed_object_id() -> ParseError {
ParseError::new(ErrorCode::InternalServerError, "Invalid object ID.")
}
fn session_schema() -> ClassSchema {
default_schema(SESSION_CLASS)
}
pub async fn ensure_session_schema<S: StorageAdapter>(storage: &S) -> Result<(), ParseError> {
let existing = storage.all_schemas().await?;
if existing.iter().any(|s| s.class_name == SESSION_CLASS) {
return Ok(());
}
storage.upsert_schema(&session_schema()).await
}
pub async fn create_session<S: StorageAdapter>(
storage: &S,
config: &SessionConfig,
new: NewSession<'_>,
) -> Result<CreatedSession, ParseError> {
let schema = session_schema();
let now = ParseDate::now();
let token = new_session_token();
let expires_at = config.generate_expires_at(now);
let object_id = new_object_id();
let user = ParseValue::Pointer {
class_name: "_User".to_string(),
object_id: new.user_object_id.to_string(),
};
let mut row: Row = IndexMap::new();
row.insert(
"sessionToken".to_string(),
ParseValue::String(token.clone()),
);
row.insert("user".to_string(), user.clone());
if let Some(created_with) = &new.created_with {
row.insert("createdWith".to_string(), created_with.to_value());
}
if let Some(expires_at) = expires_at {
row.insert("expiresAt".to_string(), ParseValue::Date(expires_at));
}
if let Some(installation_id) = new.installation_id {
row.insert(
"installationId".to_string(),
ParseValue::String(installation_id.to_string()),
);
}
row.insert("updatedAt".to_string(), ParseValue::Date(now));
row.insert("createdAt".to_string(), ParseValue::Date(now));
row.insert(
"objectId".to_string(),
ParseValue::String(object_id.clone()),
);
destroy_duplicated_sessions(storage, &schema, &user, new.installation_id, &token).await?;
ensure_session_schema(storage).await?;
storage.create(&schema, &row).await?;
Ok(CreatedSession {
session_token: token,
object_id,
expires_at,
created_at: now,
})
}
async fn destroy_duplicated_sessions<S: StorageAdapter>(
storage: &S,
schema: &ClassSchema,
user: &ParseValue,
installation_id: Option<&str>,
session_token: &str,
) -> Result<(), ParseError> {
let Some(installation_id) = installation_id else {
return Ok(());
};
let query = Query::from_constraints(vec![
Constraint::equal("user", user.clone()),
Constraint::equal(
"installationId",
ParseValue::String(installation_id.to_string()),
),
Constraint {
field: "sessionToken".to_string(),
comparison: Comparison::NotEqual(ParseValue::String(session_token.to_string())),
},
]);
storage.delete(schema, &query).await?;
Ok(())
}
pub async fn resolve_session<S: StorageAdapter>(
storage: &S,
session_token: &str,
) -> Result<ResolvedSession, ParseError> {
let schema = session_schema();
let query = Query::from_constraints(vec![Constraint::equal(
"sessionToken",
ParseValue::String(session_token.to_string()),
)]);
let options = QueryOptions {
limit: Some(1),
skip: None,
order: Vec::new(),
keys: None,
case_insensitive: false,
};
let rows = storage.find(&schema, &query, &options).await?;
let Some(row) = rows.into_iter().next() else {
return Err(invalid_session_token());
};
let user_object_id = match row.get("user") {
Some(ParseValue::Pointer { object_id, .. }) => object_id.clone(),
_ => return Err(invalid_session_token()),
};
let expires_at = match row.get("expiresAt") {
Some(ParseValue::Date(d)) => Some(*d),
_ => None,
};
if let Some(expires_at) = expires_at {
if expires_at.timestamp_millis() < ParseDate::now().timestamp_millis() {
return Err(session_expired());
}
}
if user_object_id.starts_with("role:") {
return Err(role_prefixed_object_id());
}
let object_id = match row.get("objectId") {
Some(ParseValue::String(id)) => id.clone(),
_ => return Err(invalid_session_token()),
};
let installation_id = match row.get("installationId") {
Some(ParseValue::String(id)) => Some(id.clone()),
_ => None,
};
Ok(ResolvedSession {
object_id,
user_object_id,
session_token: session_token.to_string(),
installation_id,
expires_at,
row,
})
}
pub async fn revoke<S: StorageAdapter>(
storage: &S,
session_token: &str,
) -> Result<bool, ParseError> {
let query = Query::from_constraints(vec![Constraint::equal(
"sessionToken",
ParseValue::String(session_token.to_string()),
)]);
let deleted = storage.delete(&session_schema(), &query).await?;
Ok(deleted > 0)
}
pub async fn revoke_all_for_user<S: StorageAdapter>(
storage: &S,
user_object_id: &str,
) -> Result<u64, ParseError> {
let query = Query::from_constraints(vec![Constraint::equal(
"user",
ParseValue::Pointer {
class_name: "_User".to_string(),
object_id: user_object_id.to_string(),
},
)]);
storage.delete(&session_schema(), &query).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::FakeStorage;
use std::collections::HashSet;
fn cfg() -> SessionConfig {
SessionConfig::default()
}
#[test]
fn a_token_is_r_plus_thirty_two_lowercase_hex() {
let t = new_session_token();
assert_eq!(t.len(), 34, "r: plus 32 hex characters: {t}");
let hex = t.strip_prefix("r:").expect("the r: prefix is load-bearing");
assert_eq!(hex.len(), 32);
assert!(
hex.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
"lowercase hex only, matching Buffer.toString('hex'): {hex}"
);
}
#[test]
fn tokens_do_not_use_the_object_id_alphabet() {
let mut seen: HashSet<char> = HashSet::new();
for _ in 0..500 {
seen.extend(new_session_token()[2..].chars());
}
assert_eq!(seen.len(), 16, "a hex token uses exactly 16 characters");
assert!(seen
.iter()
.all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()));
}
#[test]
fn tokens_do_not_repeat() {
let tokens: HashSet<String> = (0..1000).map(|_| new_session_token()).collect();
assert_eq!(tokens.len(), 1000);
}
#[tokio::test]
async fn the_row_is_upstreams_columns_in_upstreams_order() {
let s = FakeStorage::new();
let created = create_session(
&s,
&cfg(),
NewSession {
user_object_id: "user000001",
created_with: Some(CreatedWith::signup(None)),
installation_id: Some("install-1"),
},
)
.await
.expect("create");
let rows = s.rows("_Session");
assert_eq!(rows.len(), 1);
let row = &rows[0];
let keys: Vec<&str> = row.keys().map(String::as_str).collect();
assert_eq!(
keys,
vec![
"sessionToken",
"user",
"createdWith",
"expiresAt",
"installationId",
"updatedAt",
"createdAt",
"objectId",
]
);
assert!(
matches!(row.get("user"), Some(ParseValue::Pointer { class_name, object_id })
if class_name == "_User" && object_id == "user000001")
);
assert!(
matches!(row.get("objectId"), Some(ParseValue::String(id)) if id == &created.object_id)
);
assert!(
matches!(row.get("sessionToken"), Some(ParseValue::String(t)) if t == &created.session_token)
);
}
#[tokio::test]
async fn created_with_carries_action_and_provider_for_signup_and_login() {
for (built, action, provider) in [
(CreatedWith::signup(None), "signup", Some("password")),
(CreatedWith::login(None), "login", Some("password")),
(
CreatedWith::login(Some("facebook")),
"login",
Some("facebook"),
),
] {
let ParseValue::Object(map) = built.to_value() else {
panic!("createdWith is an object");
};
let keys: Vec<&str> = map.keys().map(String::as_str).collect();
assert_eq!(keys, vec!["action", "authProvider"]);
assert!(matches!(map.get("action"), Some(ParseValue::String(a)) if a == action));
assert!(
matches!(map.get("authProvider"), Some(ParseValue::String(p)) if Some(p.as_str()) == provider)
);
}
}
#[tokio::test]
async fn upgrade_and_create_omit_the_auth_provider_key_entirely() {
for (built, action) in [
(CreatedWith::upgrade(), "upgrade"),
(CreatedWith::create(), "create"),
] {
assert_eq!(built.auth_provider, None);
let ParseValue::Object(map) = built.to_value() else {
panic!("createdWith is an object");
};
let keys: Vec<&str> = map.keys().map(String::as_str).collect();
assert_eq!(
keys,
vec!["action"],
"authProvider must be absent, not empty"
);
assert!(matches!(map.get("action"), Some(ParseValue::String(a)) if a == action));
}
}
#[tokio::test]
async fn a_session_row_has_no_acl() {
let s = FakeStorage::new();
create_session(
&s,
&cfg(),
NewSession {
user_object_id: "u1",
created_with: Some(CreatedWith::login(None)),
installation_id: None,
},
)
.await
.expect("create");
let rows = s.rows("_Session");
assert!(
rows[0].get("ACL").is_none(),
"upstream refuses an ACL on _Session and adds none of its own"
);
}
#[tokio::test]
async fn creating_a_session_writes_the_session_schema() {
let s = FakeStorage::new();
create_session(
&s,
&cfg(),
NewSession {
user_object_id: "u1",
created_with: Some(CreatedWith::login(None)),
installation_id: None,
},
)
.await
.expect("create");
assert!(s.schema("_Session").is_some());
}
#[tokio::test]
async fn expiry_is_now_plus_session_length_and_is_absent_when_disabled() {
let s = FakeStorage::new();
let created = create_session(
&s,
&cfg(),
NewSession {
user_object_id: "u1",
created_with: Some(CreatedWith::login(None)),
installation_id: None,
},
)
.await
.expect("create");
let expires = created.expires_at.expect("default config expires sessions");
let delta = expires.timestamp_millis() - created.created_at.timestamp_millis();
assert_eq!(delta, 31_536_000 * 1000, "one year, in milliseconds");
let never = SessionConfig {
expire_inactive_sessions: false,
..SessionConfig::default()
};
let created = create_session(
&s,
&never,
NewSession {
user_object_id: "u2",
created_with: Some(CreatedWith::login(None)),
installation_id: None,
},
)
.await
.expect("create");
assert_eq!(created.expires_at, None);
let row = s
.rows("_Session")
.into_iter()
.find(|r| matches!(r.get("user"), Some(ParseValue::Pointer { object_id, .. }) if object_id == "u2"))
.expect("row");
assert!(row.get("expiresAt").is_none());
}
#[tokio::test]
async fn a_minted_token_resolves_to_its_user() {
let s = FakeStorage::new();
let created = create_session(
&s,
&cfg(),
NewSession {
user_object_id: "user000001",
created_with: Some(CreatedWith::signup(None)),
installation_id: Some("install-1"),
},
)
.await
.expect("create");
let resolved = resolve_session(&s, &created.session_token)
.await
.expect("resolve");
assert_eq!(resolved.user_object_id, "user000001");
assert_eq!(resolved.object_id, created.object_id);
assert_eq!(resolved.installation_id.as_deref(), Some("install-1"));
assert_eq!(resolved.expires_at, created.expires_at);
}
#[tokio::test]
async fn an_unknown_token_is_invalid_session_token() {
let s = FakeStorage::new();
let e = resolve_session(&s, "r:nope").await.unwrap_err();
assert_eq!(e.code, ErrorCode::InvalidSessionToken);
assert_eq!(e.message, "Invalid session token");
}
#[tokio::test]
async fn a_row_with_no_user_is_invalid_session_token() {
let s = FakeStorage::new();
s.insert_row(
"_Session",
vec![
("objectId", ParseValue::String("s1".into())),
("sessionToken", ParseValue::String("r:orphan".into())),
],
);
let e = resolve_session(&s, "r:orphan").await.unwrap_err();
assert_eq!(e.code, ErrorCode::InvalidSessionToken);
assert_eq!(e.message, "Invalid session token");
}
#[tokio::test]
async fn the_three_failures_are_checked_in_upstreams_order() {
let s = FakeStorage::new();
let past = ParseDate::parse_iso("2000-01-01T00:00:00.000Z").expect("date");
s.insert_row(
"_Session",
vec![
("objectId", ParseValue::String("s1".into())),
("sessionToken", ParseValue::String("r:both".into())),
(
"user",
ParseValue::Pointer {
class_name: "_User".into(),
object_id: "role:Admins".into(),
},
),
("expiresAt", ParseValue::Date(past)),
],
);
let e = resolve_session(&s, "r:both").await.unwrap_err();
assert_eq!(e.code, ErrorCode::InvalidSessionToken);
assert_eq!(e.message, "Session token is expired.");
s.insert_row(
"_Session",
vec![
("objectId", ParseValue::String("s2".into())),
("sessionToken", ParseValue::String("r:role".into())),
(
"user",
ParseValue::Pointer {
class_name: "_User".into(),
object_id: "role:Admins".into(),
},
),
],
);
let e = resolve_session(&s, "r:role").await.unwrap_err();
assert_eq!(e.code, ErrorCode::InternalServerError);
assert_eq!(e.message, "Invalid object ID.");
s.insert_row(
"_Session",
vec![
("objectId", ParseValue::String("s3".into())),
("sessionToken", ParseValue::String("r:nouser".into())),
("expiresAt", ParseValue::Date(past)),
],
);
let e = resolve_session(&s, "r:nouser").await.unwrap_err();
assert_eq!(e.message, "Invalid session token");
}
#[tokio::test]
async fn upstream_quirk_a_session_with_no_expiry_never_expires() {
let s = FakeStorage::new();
s.insert_row(
"_Session",
vec![
("objectId", ParseValue::String("s1".into())),
("sessionToken", ParseValue::String("r:legacy".into())),
(
"user",
ParseValue::Pointer {
class_name: "_User".into(),
object_id: "u1".into(),
},
),
],
);
let resolved = resolve_session(&s, "r:legacy").await.expect("resolve");
assert_eq!(resolved.user_object_id, "u1");
assert_eq!(resolved.expires_at, None);
}
#[tokio::test]
async fn duplicate_destruction_is_per_user_and_per_installation() {
let s = FakeStorage::new();
let mk = |user: &'static str, install: Option<&'static str>| NewSession {
user_object_id: user,
created_with: Some(CreatedWith::login(None)),
installation_id: install,
};
let phone_a = create_session(&s, &cfg(), mk("alice", Some("phone")))
.await
.expect("create");
let tablet_a = create_session(&s, &cfg(), mk("alice", Some("tablet")))
.await
.expect("create");
let phone_b = create_session(&s, &cfg(), mk("bob", Some("phone")))
.await
.expect("create");
let phone_a2 = create_session(&s, &cfg(), mk("alice", Some("phone")))
.await
.expect("create");
assert!(resolve_session(&s, &phone_a.session_token).await.is_err());
assert!(resolve_session(&s, &phone_a2.session_token).await.is_ok());
assert!(
resolve_session(&s, &tablet_a.session_token).await.is_ok(),
"two devices, two sessions: the match is on user AND installationId"
);
assert!(
resolve_session(&s, &phone_b.session_token).await.is_ok(),
"another user's session on the same installationId must survive"
);
}
#[tokio::test]
async fn without_an_installation_id_nothing_is_destroyed() {
let s = FakeStorage::new();
let mk = || NewSession {
user_object_id: "alice",
created_with: Some(CreatedWith::login(None)),
installation_id: None,
};
let first = create_session(&s, &cfg(), mk()).await.expect("create");
let second = create_session(&s, &cfg(), mk()).await.expect("create");
assert!(resolve_session(&s, &first.session_token).await.is_ok());
assert!(resolve_session(&s, &second.session_token).await.is_ok());
}
#[tokio::test]
async fn revoke_removes_one_session_and_revoke_all_removes_the_users() {
let s = FakeStorage::new();
let mk = |user: &'static str| NewSession {
user_object_id: user,
created_with: Some(CreatedWith::login(None)),
installation_id: None,
};
let a1 = create_session(&s, &cfg(), mk("alice")).await.expect("c");
let a2 = create_session(&s, &cfg(), mk("alice")).await.expect("c");
let b1 = create_session(&s, &cfg(), mk("bob")).await.expect("c");
assert!(revoke(&s, &a1.session_token).await.expect("revoke"));
assert!(
!revoke(&s, &a1.session_token).await.expect("revoke"),
"revoking twice reports the second as a miss"
);
assert!(resolve_session(&s, &a2.session_token).await.is_ok());
assert_eq!(revoke_all_for_user(&s, "alice").await.expect("revoke"), 1);
assert!(resolve_session(&s, &a2.session_token).await.is_err());
assert!(
resolve_session(&s, &b1.session_token).await.is_ok(),
"another user's sessions must survive"
);
}
#[tokio::test]
async fn resolution_reads_at_most_one_row() {
let s = FakeStorage::new();
let created = create_session(
&s,
&cfg(),
NewSession {
user_object_id: "u1",
created_with: Some(CreatedWith::login(None)),
installation_id: None,
},
)
.await
.expect("create");
s.reset_find_count();
resolve_session(&s, &created.session_token)
.await
.expect("resolve");
assert_eq!(
s.find_count(),
1,
"session resolution is on every authenticated request; it stays one query"
);
assert_eq!(s.last_find_limit(), Some(Some(1)));
}
}