rocket_flex_session 0.2.0

Flexible sessions for Rocket applications
Documentation
#![deny(unsafe_code)]
#![cfg_attr(docsrs, feature(doc_auto_cfg))]

/*!
# Overview
Simple, extensible session library for Rocket applications.

- Session cookies are securely stored and encrypted using Rocket's
  built-in [private cookies](https://rocket.rs/guide/v0.5/requests/#private-cookies)
- Session guard can be used multiple times during a request, enabling various layers
  of authentication and authorization through Rocket's [request guard](https://rocket.rs/guide/v0.5/requests/#custom-guards)
  system.
- Makes use of Rocket's request-local cache to ensure that only one backend
  call will be made to get the session data, and if the session is updated multiple times
  during the request, only one call will be made at the end of the request to save the session.
- Multiple storage providers available, or you can
  use your own session storage by implementing the [`SessionStorage`](crate::storage::SessionStorage) trait.
- Optional session indexing support for advanced features like multi-device login tracking,
  bulk session invalidation, and security auditing.

# Usage
While technically not needed for development, it is highly recommended to
[set the secret key](https://rocket.rs/guide/v0.5/requests/#secret-key) in Rocket. That way the sessions
will stay valid after reloading your code if you're using a persistent storage provider. The secret key is
_required_ for release mode.

## Basic setup

```rust
use rocket::routes;
use rocket_flex_session::{Session, RocketFlexSession};

// Create a session data type (this type must be thread-safe and Clone)
#[derive(Clone)]
struct MySession {
    user_id: String,
    // ..other session fields
}

#[rocket::launch]
fn rocket() -> _ {
    rocket::build()
        // attach the `RocketFlexSession` fairing, passing in your session data type
        .attach(RocketFlexSession::<MySession>::default())
        .mount("/", routes![login])
}

// use the `Session` request guard in a route handler
#[rocket::post("/login")]
fn login(mut session: Session<MySession>) {
    session.set(MySession { user_id: "123".to_owned() });
}

```

## Request guard auth

If a valid session isn't found, the [Session] request guard will still succeed, but calling [Session.get()](Session#method.get)
or [Session.tap()](Session#method.tap) will yield `None` - indicating an empty/uninitialized session.
This primitive is designed for you to be able to add your authentication and authorization layer on
top of it using Rocket's flexible request guard system.

For example, we can write a request guard for our `MySession` type, that will attempt to retrieve the
session data and verify whether there is an active session:
```
use rocket::{
    http::Status,
    request::{FromRequest, Outcome},
    Request,
};
use rocket_flex_session::Session;

#[derive(Clone)]
struct MySession {
    user_id: String,
    // ..other session fields
}

#[rocket::async_trait]
impl<'r> FromRequest<'r> for MySession {
   type Error = &'r str; // or your custom error type

   async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
       // Run the Session request guard (this guard should always succeed)
       let session = req.guard::<Session<MySession>>().await.expect("should not fail");

       // Get the `MySession` session data, or if it's `None`, send an Unauthorized error
       match session.get() {
           Some(my_session) => Outcome::Success(my_session),
           None => Outcome::Error((Status::Unauthorized, "Not logged in")),
       }
    }
 }

 // Use our new `MySession` request guard in a route handler
 #[rocket::get("/user")]
 fn get_user(session: MySession) -> String {
    return format!("Logged in as user {}!", session.user_id);
 }
```

For more info and examples of this powerful pattern, please see Rocket's documentation on
[request guards](https://api.rocket.rs/v0.5/rocket/request/trait.FromRequest).

## HashMap session data

If your session data has a hashmap data structure, you can implement [`SessionHashMap`] which will
add [additional helper methods](Session::get_key) to Session to read and set keys. This is particularly useful if you expect your
session data structure to be inconsistent and/or change frequently.

```
use rocket_flex_session::{Session, SessionHashMap};
use std::collections::HashMap;

#[derive(Clone, Default)]
struct MySession(HashMap<String, String>);

impl SessionHashMap for MySession {
    type Value = String;

    fn get(&self, key: &str) -> Option<&Self::Value> {
        self.0.get(key)
    }
    fn insert(&mut self, key: String, value: Self::Value) {
        self.0.insert(key, value);
    }
    fn remove(&mut self, key: &str) {
        self.0.remove(key);
    }
}

#[rocket::post("/login")]
fn login(mut session: Session<MySession>) {
    let user_id: Option<String> = session.get_key("user_id");
    session.set_key("name".to_owned(), "Bob".to_owned());
    session.remove_key("foobar");
}
```

## Session Indexing

For use cases like multi-device login tracking or other security features, you can use a storage
provider that supports indexing, and then group sessions by an identifier (such as a user ID)
using the [`SessionIdentifier`] trait:

```rust
use rocket::routes;
use rocket_flex_session::{Session, SessionIdentifier, RocketFlexSession};
use rocket_flex_session::storage::memory::MemoryStorageIndexed;

#[derive(Clone)]
struct UserSession {
    user_id: String,
    device_name: String,
}

impl SessionIdentifier for UserSession {
    type Id = String;

    fn identifier(&self) -> Option<Self::Id> {
        Some(self.user_id.clone()) // Group sessions by user_id
    }
}

#[rocket::get("/user/sessions")]
async fn get_all_user_sessions(session: Session<'_, UserSession>) -> String {
    match session.get_all_sessions().await {
        Ok(Some(sessions)) => format!("Found {} active sessions", sessions.len()),
        Ok(None) => "No active session".to_string(),
        Err(e) => format!("Error: {}", e),
    }
}

#[rocket::get("/user/logout-everywhere")]
async fn logout_everywhere(session: Session<'_, UserSession>) -> String {
    match session.invalidate_all_sessions(false).await {
        Ok(Some(n)) => format!("Logged out from {n} sessions"),
        Ok(None) => "No active session".to_string(),
        Err(e) => format!("Error: {}", e),
    }
}

#[rocket::launch]
fn rocket() -> _ {
    rocket::build()
        .attach(
            RocketFlexSession::<UserSession>::builder()
                .storage(MemoryStorageIndexed::default())
                .build()
        )
        .mount("/", routes![get_all_user_sessions, logout_everywhere])
}
```

# Storage Providers

This crate supports multiple storage backends with different capabilities:

## Available Storage Providers

| Storage | Feature Flag | Indexing support | Use Cases |
|---------|-------------|------------------|------------|
| [`storage::memory::MemoryStorage`] | Built-in | ❌ | Development, testing |
| [`storage::memory::MemoryStorageIndexed`] | Built-in | ✅ | Development with indexing features |
| [`storage::cookie::CookieStorage`] | `cookie` | ❌ | Client-side storage, stateless servers |
| [`storage::redis::RedisFredStorage`] | `redis_fred` | ✅ | Production, distributed systems |
| [`storage::sqlx::SqlxPostgresStorage`] | `sqlx_postgres` | ✅ | Production, existing database |
| [`storage::sqlx::SqlxSqliteStorage`] | `sqlx_sqlite` | ✅ | Development and small-scale deployments |

## Custom Storage

To implement a custom storage provider, implement the [`SessionStorage`](crate::storage::SessionStorage) trait:

```rust
use rocket_flex_session::{error::SessionResult, storage::SessionStorage};
use rocket::{async_trait, http::CookieJar};

pub struct MyCustomStorage {}

#[async_trait]
impl<T> SessionStorage<T> for MyCustomStorage
where
    T: Send + Sync + Clone + 'static,
{
    async fn load(&self, id: &str, ttl: Option<u32>, cookie_jar: &CookieJar) -> SessionResult<(T, u32)> {
        // Load session from your storage
        todo!()
    }

    async fn save(&self, id: &str, data: T, ttl: u32) -> SessionResult<()> {
        // Save session to your storage
        todo!()
    }

    async fn delete(&self, id: &str, data: T) -> SessionResult<()> {
        // Delete session from your storage
        todo!()
    }
}
```

### Adding Indexing Support

To support session indexing, also implement [`SessionStorageIndexed`](crate::storage::SessionStorageIndexed),
and adjust the [`SessionStorage`](crate::storage::SessionStorage) implementation as follows:


```rust,ignore
use rocket_flex_session::{error::SessionResult, storage::{SessionStorage, SessionStorageIndexed, SessionIdentifier}};

struct MyCustomStorage;

#[async_trait]
impl<T> SessionStorageIndexed<T> for MyCustomStorage
where
    T: SessionIdentifier + Send + Sync + Clone + 'static,
{
    async fn get_sessions_by_identifier(&self, id: &T::Id) -> SessionResult<Vec<(String, T, u32)>> {
        // Return all sessions (session_id, session_data, session_ttl) for the given identifier
        todo!()
    }
    // etc...
}

// Make sure to also add the following to the `SessionStorage` trait:
#[async_trait]
impl<T> SessionStorage<T> for MyCustomStorage
where
    T: SessionIdentifier + Send + Sync + Clone + 'static, // add the SessionIdentifier trait bound
{
    // ... In the load() and delete() functions, you can access the identifier using data.identifier() ...

    // Add this function (used internally to access the indexing functions)
    fn as_indexed_storage(&self) -> Option<&dyn SessionStorageIndexed<T>> {
        Some(self)
    }
}
```

### Implementation Tips

1. **Trait bounds**: Add additional trait bounds to the session data type `<T>` as needed
2. **Error Handling**: Use [`error::SessionError::Backend`] for custom errors
3. **TTL Handling**: Respect the TTL parameters in `load` and `save` for session expiration
4. **Indexing Consistency**: Keep identifier indexes in sync with session data
5. **Cleanup**: Implement proper cleanup in `shutdown()` if needed

# Feature flags

These features can be enabled as shown
[in Cargo's documentation](https://doc.rust-lang.org/cargo/reference/features.html).

| Name    | Description    |
|---------|----------------|
| `cookie` | A cookie-based session store. Data is serialized using serde_json and then encrypted into the value of a cookie. |
| `redis_fred`  | A session store for Redis (and Redis-compatible databases), using the [fred.rs](https://docs.rs/crate/fred) crate. |
| `sqlx_postgres`  | A session store using PostgreSQL via the [sqlx](https://docs.rs/crate/sqlx) crate. |
| `sqlx_sqlite`  | A session store using SQLite via the [sqlx](https://docs.rs/crate/sqlx) crate. |
| `rocket_okapi`  | Enables support for the [rocket_okapi](https://docs.rs/crate/rocket_okapi) crate if needed. |
*/

mod fairing;
mod guard;
mod options;
mod session;
mod session_hash;
mod session_index;
mod session_inner;

pub mod error;
pub mod storage;
pub use fairing::RocketFlexSession;
pub use options::RocketFlexSessionOptions;
pub use session::Session;
pub use session_hash::SessionHashMap;
pub use session_index::SessionIdentifier;