rototo 0.1.0-alpha.6

Control plane for runtime configuration of your application.
Documentation
use std::time::Duration;

use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ring::rand::{SecureRandom, SystemRandom};
use rusqlite::{OptionalExtension, params};

use crate::console::identity::ActorIdentity;
use crate::console::time::{now_iso, now_iso_minus, now_iso_plus};
use crate::error::{Result, RototoError};

use super::Store;
use super::types::{NewSession, SessionUser};
use super::util::db_err;

const SESSION_TTL: Duration = Duration::from_secs(60 * 60 * 24 * 14);
const OAUTH_STATE_TTL: Duration = Duration::from_secs(60 * 10);
const SESSION_TOKEN_BYTES: usize = 32;

impl Store {
    pub async fn create_session(&self, input: NewSession) -> Result<String> {
        self.with_conn(move |conn, crypto| {
            let session_token = new_session_token()?;
            let now = now_iso();
            let expires_at = now_iso_plus(SESSION_TTL);
            let ActorIdentity::GitHub {
                id,
                login,
                name,
                avatar_url,
            } = input.identity
            else {
                return Err(RototoError::new(
                    "GitHub OAuth sessions require a GitHub identity",
                ));
            };
            let principal_id = format!("github:{id}");
            conn.execute(
                "INSERT INTO sessions (
               id, principal_id, github_login, github_name, github_avatar_url,
               github_token_ciphertext, created_at, expires_at
             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
                params![
                    session_token_hash(&session_token),
                    principal_id,
                    login,
                    name,
                    avatar_url,
                    crypto.encrypt(&input.github_token)?,
                    now,
                    expires_at,
                ],
            )
            .map_err(db_err)?;
            Ok(session_token)
        })
        .await
    }

    pub async fn get_session(&self, session_token: &str) -> Result<Option<SessionUser>> {
        let session_token = session_token.to_owned();
        self.with_conn(move |conn, crypto| {
            let hash = session_token_hash(&session_token);
            let row = conn
                .query_row(
                    "SELECT id, principal_id, github_login, github_name, github_avatar_url,
                        github_token_ciphertext, expires_at
                 FROM sessions WHERE id = ?1",
                    params![hash],
                    |row| {
                        Ok((
                            row.get::<_, String>(0)?,
                            row.get::<_, String>(1)?,
                            row.get::<_, String>(2)?,
                            row.get::<_, Option<String>>(3)?,
                            row.get::<_, Option<String>>(4)?,
                            row.get::<_, String>(5)?,
                            row.get::<_, String>(6)?,
                        ))
                    },
                )
                .optional()
                .map_err(db_err)?;
            let Some((session_id, principal_id, login, name, avatar, ciphertext, expires_at)) = row
            else {
                return Ok(None);
            };
            if expires_at.as_str() <= now_iso().as_str() {
                conn.execute("DELETE FROM sessions WHERE id = ?1", params![hash])
                    .map_err(db_err)?;
                return Ok(None);
            }
            let Ok(github_token) = crypto.decrypt(&ciphertext) else {
                return Ok(None);
            };
            let github_id = principal_id
                .strip_prefix("github:")
                .unwrap_or(principal_id.as_str())
                .to_owned();
            Ok(Some(SessionUser {
                session_hash: session_id,
                principal_id,
                identity: ActorIdentity::GitHub {
                    id: github_id,
                    login,
                    name,
                    avatar_url: avatar,
                },
                github_token: Some(github_token),
            }))
        })
        .await
    }

    pub async fn delete_session(&self, session_token: &str) -> Result<()> {
        let session_token = session_token.to_owned();
        self.with_conn(move |conn, _| {
            conn.execute(
                "DELETE FROM sessions WHERE id = ?1",
                params![session_token_hash(&session_token)],
            )
            .map_err(db_err)?;
            Ok(())
        })
        .await
    }

    pub async fn create_oauth_state(&self, state: &str) -> Result<()> {
        let state = state.to_owned();
        self.with_conn(move |conn, _| {
            conn.execute(
                "INSERT OR REPLACE INTO oauth_states (state, created_at) VALUES (?1, ?2)",
                params![state, now_iso()],
            )
            .map_err(db_err)?;
            Ok(())
        })
        .await
    }

    pub async fn consume_oauth_state(&self, state: &str) -> Result<bool> {
        let state = state.to_owned();
        self.with_conn(move |conn, _| {
            let created_at: Option<String> = conn
                .query_row(
                    "SELECT created_at FROM oauth_states WHERE state = ?1",
                    params![state],
                    |row| row.get(0),
                )
                .optional()
                .map_err(db_err)?;
            conn.execute("DELETE FROM oauth_states WHERE state = ?1", params![state])
                .map_err(db_err)?;
            let Some(created_at) = created_at else {
                return Ok(false);
            };
            Ok(created_at.as_str() > now_iso_minus_state_ttl().as_str())
        })
        .await
    }
}

fn now_iso_minus_state_ttl() -> String {
    now_iso_minus(OAUTH_STATE_TTL)
}

fn new_session_token() -> Result<String> {
    let mut bytes = [0u8; SESSION_TOKEN_BYTES];
    SystemRandom::new()
        .fill(&mut bytes)
        .map_err(|_| RototoError::new("failed to generate a session token"))?;
    Ok(URL_SAFE_NO_PAD.encode(bytes))
}

fn session_token_hash(session_token: &str) -> String {
    let digest = ring::digest::digest(&ring::digest::SHA256, session_token.as_bytes());
    digest
        .as_ref()
        .iter()
        .map(|byte| format!("{byte:02x}"))
        .collect()
}