🗝
summary refs log tree commit diff
path: root/src/server
diff options
context:
space:
mode:
authormia <mia@mia.jetzt>2024-04-16 19:05:41 -0700
committermia <mia@mia.jetzt>2024-04-16 19:05:41 -0700
commit796b2cafc798a7faa80a007002831a4c40635fe8 (patch)
treed8e68590524f4adab7ff8ff6e2cb3dfbb0c64b37 /src/server
downloaddissociate-0.1.0.tar.gz
dissociate-0.1.0.zip
initial commit v0.1.0
Diffstat (limited to 'src/server')
-rw-r--r--src/server/admin.rs93
-rw-r--r--src/server/config.rs29
-rw-r--r--src/server/login.rs208
-rw-r--r--src/server/mod.rs160
-rw-r--r--src/server/nginx_check.rs41
-rw-r--r--src/server/panel.rs30
-rw-r--r--src/server/store.rs330
7 files changed, 891 insertions, 0 deletions
diff --git a/src/server/admin.rs b/src/server/admin.rs
new file mode 100644
index 0000000..54bdf12
--- /dev/null
+++ b/src/server/admin.rs
@@ -0,0 +1,93 @@
+use std::{future::Future, path::Path};
+
+use tap::Pipe;
+use tokio::{
+    io::{AsyncReadExt, AsyncWriteExt},
+    net::{UnixListener, UnixStream},
+};
+
+use crate::ipc::*;
+
+use super::store::Store;
+
+pub fn serve(
+    bind: &Path,
+    web_base: String,
+    store: Store,
+) -> eyre::Result<impl Future<Output = eyre::Result<()>>> {
+    if bind.exists() {
+        std::fs::remove_file(bind)?;
+    }
+    let listener = UnixListener::bind(bind)?;
+
+    Ok(async move {
+        loop {
+            match listener.accept().await {
+                Ok((stream, _)) => tokio::spawn({
+                    let store = store.clone();
+                    let web_base = web_base.clone();
+                    async move {
+                        if let Err(err) = handle(stream, store, &web_base).await {
+                            eprintln!("error handling admin connection: {err:?}");
+                        }
+                    }
+                }),
+                Err(err) => {
+                    eprintln!("error accepting admin connection: {err:?}");
+                    continue;
+                }
+            };
+        }
+    })
+}
+
+async fn handle(mut stream: UnixStream, store: Store, web_base: &str) -> eyre::Result<()> {
+    while let Ok(request_length) = stream.read_u16().await {
+        let mut request_buffer = vec![0u8; request_length as usize];
+        stream.read_exact(&mut request_buffer).await?;
+        let (request, _): (IPCRequest, _) =
+            bincode::decode_from_slice(&request_buffer, bincode::config::standard())?;
+        let response = match request {
+            IPCRequest::ListAccounts(_) => ListAccountsResponse {
+                names: store.list_accounts().await,
+            }
+            .into_response(),
+            IPCRequest::GetAccount(GetAccountRequest { name }) => {
+                IPCResponse::GetAccount(match store.get_account(&name).await {
+                    Some(account) => Ok(GetAccountResponse {
+                        scopes: account.scopes.clone(),
+                    }),
+                    None => Err(GetAccountError::NotFound),
+                })
+            }
+            IPCRequest::DeleteAccount(DeleteAccountRequest { name }) => {
+                store.delete_account(&name).await?;
+                DeleteAccountResponse {}.into_response()
+            }
+            IPCRequest::CreateInvite(_) => store
+                .create_invite()
+                .await?
+                .pipe(|invite| CreateInviteResponse {
+                    link: format!("{web_base}/invite/{invite}"),
+                })
+                .into_response(),
+            IPCRequest::UpdateScopes(UpdateScopesRequest { account, scopes }) => {
+                IPCResponse::UpdateScopes(
+                    match store
+                        .update_account(&account, |account| {
+                            account.scopes = scopes;
+                        })
+                        .await?
+                    {
+                        true => Ok(UpdateScopesResponse {}),
+                        false => Err(UpdateScopesError::NotFound),
+                    },
+                )
+            }
+        };
+        let response_buffer = bincode::encode_to_vec(response, bincode::config::standard())?;
+        stream.write_u16(response_buffer.len() as u16).await?;
+        stream.write_all(&response_buffer).await?;
+    }
+    Ok(())
+}
diff --git a/src/server/config.rs b/src/server/config.rs
new file mode 100644
index 0000000..4563f34
--- /dev/null
+++ b/src/server/config.rs
@@ -0,0 +1,29 @@
+use std::net::SocketAddr;
+use std::path::{Path, PathBuf};
+
+use eyre::Context;
+use tap::Pipe;
+
+#[derive(serde::Deserialize)]
+pub struct Config {
+    pub web_socket: SocketAddr,
+    pub web_base: String,
+    pub cookie_domain: Option<String>,
+    pub admin_socket: PathBuf,
+    pub data: PathBuf,
+}
+
+impl Config {
+    pub fn load(path: &Path) -> eyre::Result<Self> {
+        let mut config: Config = path
+            .pipe(std::fs::read_to_string)
+            .wrap_err("reading config file")?
+            .as_str()
+            .pipe(toml::from_str)
+            .wrap_err("parsing config file")?;
+        if config.web_base.ends_with('/') {
+            config.web_base = config.web_base.trim_end_matches('/').to_string();
+        }
+        Ok(config)
+    }
+}
diff --git a/src/server/login.rs b/src/server/login.rs
new file mode 100644
index 0000000..f9cee70
--- /dev/null
+++ b/src/server/login.rs
@@ -0,0 +1,208 @@
+use std::time::Duration;
+
+use argon2::{Argon2, PasswordVerifier};
+use axum::{
+    extract::{Path, State},
+    http::StatusCode,
+    response::{IntoResponse, Redirect, Response},
+    routing::get,
+    Form, Router,
+};
+use axum_extra::extract::{
+    cookie::{Cookie, SameSite},
+    CookieJar,
+};
+use maud::{html, PreEscaped};
+use tap::Tap;
+
+use crate::server::store::Store;
+
+use super::{render_html, ApiState, CookieDomain, MakeError, MakeErrorMessage};
+
+pub fn bind(app: Router<ApiState>) -> Router<ApiState> {
+    app.route("/login", get(handle_login_get).post(handle_login_post))
+        .route("/logout", get(logout))
+        .route(
+            "/invite/:invite",
+            get(handle_invite_get).post(handle_invite_post),
+        )
+}
+
+fn make_form(status: StatusCode, error: Option<&str>, submit: &str) -> Response {
+    render_html(
+        html!(title {"login"}),
+        html! {
+            @if let Some(error) = error {
+                p { "error: " (PreEscaped(error)) }
+            }
+            form action="" method="post" {
+                label for="name" { "name:" }
+                input type="text" id="name" name="name" {}
+                label for="password" { "password:" }
+                input type="password" id="password" name="password" {}
+                input type="submit" value=(submit) {}
+            }
+        },
+    )
+    .tap_mut(|response| *response.status_mut() = status)
+}
+
+#[axum::debug_handler]
+async fn handle_login_get() -> Response {
+    make_form(StatusCode::OK, None, "login")
+}
+
+#[derive(serde::Deserialize)]
+struct FormData {
+    name: String,
+    password: String,
+}
+
+#[axum::debug_handler(state = ApiState)]
+async fn handle_login_post(
+    jar: CookieJar,
+    State(store): State<Store>,
+    State(CookieDomain(cookie_domain)): State<CookieDomain>,
+    Form(form): Form<FormData>,
+) -> Result<Response, Response> {
+    // read lock scope
+    {
+        let account = store.get_account(&form.name).await.ok_or_else(|| {
+            make_form(StatusCode::FORBIDDEN, Some("invalid credentials"), "login")
+        })?;
+        Argon2::default()
+            .verify_password(form.password.as_bytes(), &account.password.parsed())
+            .ok()
+            .ok_or_else(|| {
+                make_form(StatusCode::FORBIDDEN, Some("invalid credentials"), "login")
+            })?;
+    }
+    let token = store
+        .create_token(&form.name)
+        .await
+        .make_error()?
+        .error_message(
+            StatusCode::INTERNAL_SERVER_ERROR,
+            "account disappeared while generating token",
+        )?;
+
+    let mut cookie = Cookie::new("dissociate-token", token);
+    cookie.set_http_only(true);
+    cookie.set_max_age(Duration::from_secs(60 * 60 * 24 * 29).try_into().ok());
+    cookie.set_path("/");
+    cookie.set_same_site(SameSite::Strict);
+    cookie.set_secure(true);
+    if let Some(cookie_domain) = cookie_domain {
+        cookie.set_domain(cookie_domain);
+    }
+
+    Ok((jar.add(cookie), Redirect::to("/")).into_response())
+}
+
+#[axum::debug_handler]
+async fn logout(jar: CookieJar) -> Response {
+    (
+        jar.remove(Cookie::from("dissociate-token")),
+        Redirect::to("/login"),
+    )
+        .into_response()
+}
+
+fn invite_error() -> Response {
+    render_html(
+        html!(),
+        html! {
+            h1 { "unknown invite" }
+            a href="/" { "return home" }
+        },
+    )
+    .tap_mut(|response| *response.status_mut() = StatusCode::NOT_FOUND)
+}
+
+#[axum::debug_handler]
+async fn handle_invite_get(Path(invite): Path<String>, State(store): State<Store>) -> Response {
+    if !store.check_invite(&invite).await {
+        return invite_error();
+    }
+    make_form(StatusCode::OK, None, "create account")
+}
+
+const NAME_ERROR: &str = "invalid name
+requirements:
+must not be empty
+must not exceed 32 characters
+must not contain control characters";
+#[axum::debug_handler(state = ApiState)]
+async fn handle_invite_post(
+    jar: CookieJar,
+    Path(invite): Path<String>,
+    State(store): State<Store>,
+    State(CookieDomain(cookie_domain)): State<CookieDomain>,
+    Form(form): Form<FormData>,
+) -> Result<Response, Response> {
+    if form.name.to_ascii_lowercase() == "empty" || form.password.to_ascii_lowercase() == "empty" {
+        return Err(make_form(
+            StatusCode::BAD_REQUEST,
+            Some("are you jokester?"),
+            "create account",
+        ));
+    }
+    if form.name.is_empty() || form.name.len() > 32 || form.name.chars().any(|ch| ch.is_control()) {
+        return Err(make_form(
+            StatusCode::BAD_REQUEST,
+            Some(&NAME_ERROR.replace('\n', "<br>")),
+            "create account",
+        ));
+    }
+    if form.password.is_empty() {
+        return Err(make_form(
+            StatusCode::BAD_REQUEST,
+            Some("password cannot be empty"),
+            "create account",
+        ));
+    }
+
+    if store.get_account(&form.name).await.is_some() {
+        return Err(make_form(
+            StatusCode::CONFLICT,
+            Some("name taken"),
+            "create account",
+        ));
+    }
+
+    if !store.use_invite(&invite).await.make_error()? {
+        return Err(invite_error());
+    }
+
+    if !store
+        .create_account(&form.name, &form.password)
+        .await
+        .make_error()?
+    {
+        return Err(make_form(
+            StatusCode::CONFLICT,
+            Some("name taken"),
+            "create account",
+        ));
+    }
+    let token = store
+        .create_token(&form.name)
+        .await
+        .make_error()?
+        .error_message(
+            StatusCode::INTERNAL_SERVER_ERROR,
+            "account disappeared while generating token",
+        )?;
+
+    let mut cookie = Cookie::new("dissociate-token", token);
+    cookie.set_http_only(true);
+    cookie.set_max_age(Duration::from_secs(60 * 60 * 24 * 29).try_into().ok());
+    cookie.set_path("/");
+    cookie.set_same_site(SameSite::Strict);
+    cookie.set_secure(true);
+    if let Some(cookie_domain) = cookie_domain {
+        cookie.set_domain(cookie_domain);
+    }
+
+    Ok((jar.add(cookie), Redirect::to("/")).into_response())
+}
diff --git a/src/server/mod.rs b/src/server/mod.rs
new file mode 100644
index 0000000..d9b3beb
--- /dev/null
+++ b/src/server/mod.rs
@@ -0,0 +1,160 @@
+mod admin;
+mod config;
+mod login;
+mod nginx_check;
+mod panel;
+mod store;
+
+use std::{future::IntoFuture, path::PathBuf};
+
+use axum::{
+    body::Body,
+    extract::FromRef,
+    http::{header::CONTENT_TYPE, StatusCode},
+    response::{IntoResponse, Redirect, Response},
+    routing::get,
+    Router,
+};
+use axum_extra::extract::CookieJar;
+use eyre::Context;
+use maud::{html, PreEscaped};
+use tap::Pipe;
+use tokio::{net::TcpListener, select};
+
+use crate::server::store::Store;
+
+use self::config::Config;
+
+pub async fn serve() -> eyre::Result<()> {
+    let config_path = std::env::args()
+        .skip(2)
+        .next()
+        .map(PathBuf::from)
+        .unwrap_or_else(|| std::env::current_dir().unwrap().join("dissociate.toml"));
+    let config = Config::load(&config_path)?;
+
+    let store = Store::load(config.data).await?;
+    println!("admin listening on {:?}", config.admin_socket);
+    let admin_serve = admin::serve(&config.admin_socket, config.web_base.clone(), store.clone())?;
+
+    let listener = TcpListener::bind(config.web_socket).await?;
+
+    let app = Router::new()
+        .pipe(login::bind)
+        .pipe(nginx_check::bind)
+        .pipe(panel::bind)
+        .with_state(ApiState {
+            store,
+            cookie_domain: CookieDomain(config.cookie_domain),
+            web_base: WebBase(config.web_base),
+        })
+        .fallback(get(|| async {
+            render_html(
+                html!(title { "not found" }),
+                html! {
+                    h1 { "404 not found" }
+                    p {"sowwy :("}
+                },
+            )
+        }));
+
+    println!("web listening on {:?}", config.web_socket);
+    let web_serve = axum::serve(listener, app).into_future();
+
+    select! {
+        res = admin_serve => res.wrap_err("in admin"),
+        res = web_serve => res.wrap_err("in web"),
+    }
+}
+
+#[derive(Clone, FromRef)]
+struct ApiState {
+    pub store: Store,
+    pub cookie_domain: CookieDomain,
+    pub web_base: WebBase,
+}
+
+#[derive(Clone)]
+struct CookieDomain(Option<String>);
+
+#[derive(Clone)]
+struct WebBase(String);
+
+fn render_html(head: PreEscaped<impl AsRef<str>>, body: PreEscaped<impl AsRef<str>>) -> Response {
+    let html = html! {
+        (PreEscaped("<!doctype html>"))
+        html {
+            head {(head)}
+            body {(body)}
+        }
+    }
+    .into_string();
+    Response::builder()
+        .header(CONTENT_TYPE, "text/html; charset=utf-8")
+        .body(Body::new(html))
+        .unwrap()
+}
+
+trait MakeErrorMessage<T> {
+    fn error_message(self, status: StatusCode, message: impl ToString) -> Result<T, Response>;
+}
+
+impl<T, E: ToString> MakeErrorMessage<T> for Result<T, E> {
+    fn error_message(self, status: StatusCode, message: impl ToString) -> Result<T, Response> {
+        self.map_err(|err| {
+            render_html(
+                html!(title { "error" }),
+                html! {
+                    h1 { (status.canonical_reason().unwrap_or_else(|| status.as_str())) }
+                    pre { (err.to_string()) ": " (message.to_string()) }
+                },
+            )
+        })
+    }
+}
+
+impl<T> MakeErrorMessage<T> for Option<T> {
+    fn error_message(self, status: StatusCode, message: impl ToString) -> Result<T, Response> {
+        self.ok_or_else(|| {
+            render_html(
+                html!(title { "error" }),
+                html! {
+                    h1 { (status.canonical_reason().unwrap_or_else(|| status.as_str())) }
+                    pre { (message.to_string()) }
+                },
+            )
+        })
+    }
+}
+
+trait MakeError<T> {
+    fn make_error(self) -> Result<T, Response>;
+}
+
+impl<T> MakeError<T> for std::io::Result<T> {
+    fn make_error(self) -> Result<T, Response> {
+        self.error_message(StatusCode::INTERNAL_SERVER_ERROR, "internal io error")
+    }
+}
+
+trait Nevermind<T> {
+    fn prompt_login(self) -> Result<T, Response>;
+    fn prompt_logout(self) -> Result<T, Response>;
+}
+
+impl<T> Nevermind<T> for Option<T> {
+    fn prompt_login(self) -> Result<T, Response> {
+        self.ok_or_else(|| Redirect::to("/login").into_response())
+    }
+
+    fn prompt_logout(self) -> Result<T, Response> {
+        self.ok_or_else(|| Redirect::to("/login").into_response())
+    }
+}
+
+async fn account_auth(jar: &CookieJar, store: &Store) -> Option<String> {
+    let cookie = jar.get("dissociate-token")?;
+    let token = cookie.value();
+    let name = store.check_token(token).await?;
+    Some(name)
+}
diff --git a/src/server/nginx_check.rs b/src/server/nginx_check.rs
new file mode 100644
index 0000000..7b67f26
--- /dev/null
+++ b/src/server/nginx_check.rs
@@ -0,0 +1,41 @@
+// for ngx_http_auth_request_module authentication
+// make sure you have cookie_domain set properly
+// depends on https://git.mia.jetzt/sysconf/tree/patches/nginx_auth_redirect.patch
+
+use axum::{
+    extract::{Path, State},
+    http::StatusCode,
+    response::{IntoResponse, Redirect, Response},
+    routing::get,
+    Router,
+};
+use axum_extra::extract::CookieJar;
+
+use crate::server::{account_auth, store::Store};
+
+use super::{ApiState, WebBase};
+
+pub fn bind(app: Router<ApiState>) -> Router<ApiState> {
+    app.route("/nginx_check/:scope", get(nginx_check))
+}
+
+#[axum::debug_handler(state = ApiState)]
+async fn nginx_check(
+    jar: CookieJar,
+    Path(scope): Path<String>,
+    State(store): State<Store>,
+    State(WebBase(web_base)): State<WebBase>,
+) -> Response {
+    let nevermind = || Redirect::to(&format!("{web_base}/logout")).into_response();
+    let Some(name) = account_auth(&jar, &store).await else {
+        return nevermind();
+    };
+    let Some(account) = store.get_account(&name).await else {
+        return nevermind();
+    };
+    if account.scopes.contains(&scope) {
+        StatusCode::OK.into_response()
+    } else {
+        StatusCode::FORBIDDEN.into_response()
+    }
+}
diff --git a/src/server/panel.rs b/src/server/panel.rs
new file mode 100644
index 0000000..addb0d8
--- /dev/null
+++ b/src/server/panel.rs
@@ -0,0 +1,30 @@
+use axum::{extract::State, response::Response, routing::get, Router};
+use axum_extra::extract::CookieJar;
+use maud::html;
+use tap::Pipe;
+
+use crate::server::{store::Store, Nevermind};
+
+use super::{account_auth, render_html, ApiState};
+
+pub fn bind(app: Router<ApiState>) -> Router<ApiState> {
+    app.route("/", get(get_panel))
+}
+
+#[axum::debug_handler(state = ApiState)]
+async fn get_panel(jar: CookieJar, State(store): State<Store>) -> Result<Response, Response> {
+    Ok(account_auth(&jar, &store)
+        .await
+        .prompt_login()?
+        .pipe(render_normal_panel))
+}
+
+fn render_normal_panel(name: String) -> Response {
+    render_html(
+        html!(title { "dissociate" }),
+        html! {
+            p { "currently logged in as " (name) }
+            a href="/logout" { button { "log out" } }
+        },
+    )
+}
diff --git a/src/server/store.rs b/src/server/store.rs
new file mode 100644
index 0000000..98c1bcc
--- /dev/null
+++ b/src/server/store.rs
@@ -0,0 +1,330 @@
+use std::{
+    collections::HashMap,
+    path::PathBuf,
+    sync::Arc,
+    time::{Duration, SystemTime, UNIX_EPOCH},
+};
+
+use argon2::{
+    password_hash::{rand_core::OsRng, Encoding, PasswordHashString, SaltString},
+    Argon2, PasswordHash, PasswordHasher,
+};
+use nanoid::nanoid;
+use serde::{de::Unexpected, Deserialize, Serialize};
+use serde_json::{Map, Value};
+use tap::{Pipe, Tap};
+use tokio::sync::{RwLock, RwLockReadGuard};
+
+#[derive(Clone)]
+pub struct Store(Arc<RwLock<StoreInner>>);
+
+impl Store {
+    pub async fn load(path: PathBuf) -> eyre::Result<Self> {
+        let mut inner = StoreInner::new(path.clone());
+        if path.try_exists()? {
+            let mut map: Map<String, Value> =
+                { std::fs::File::open(&path)?.pipe(serde_json::from_reader)? };
+            let accounts: Vec<Account> =
+                serde_json::from_value(map.remove("accounts").unwrap_or_default())?;
+            let apps: Vec<App> = serde_json::from_value(map.remove("apps").unwrap_or_default())?;
+            for mut account in accounts {
+                account.tokens = account
+                    .tokens
+                    .into_iter()
+                    .filter(|token| token.expires > SystemTime::now())
+                    .collect();
+                for token in &account.tokens {
+                    inner.token_map.insert(
+                        token.value.clone(),
+                        (account.name.clone(), token.expires.clone()),
+                    );
+                }
+                inner.accounts.insert(account.name.clone(), account);
+            }
+            for app in apps {
+                inner.apps.insert(app.name.clone(), app);
+            }
+            inner.invites = serde_json::from_value(map.remove("invites").unwrap_or_default())?;
+            inner.invites = inner
+                .invites
+                .into_iter()
+                .filter(|invite| invite.expires > SystemTime::now())
+                .collect();
+        } else {
+            Self::save(&mut inner).await?;
+        }
+        Ok(Store(Arc::new(RwLock::new(inner))))
+    }
+
+    async fn save(inner: &mut StoreInner) -> std::io::Result<()> {
+        let mut map: Map<String, Value> = Map::new();
+        map.insert(
+            "accounts".into(),
+            serde_json::to_value(inner.accounts.values().collect::<Vec<_>>()).unwrap(),
+        );
+        map.insert(
+            "apps".into(),
+            serde_json::to_value(inner.apps.values().collect::<Vec<_>>()).unwrap(),
+        );
+        map.insert(
+            "invites".into(),
+            serde_json::to_value(&inner.invites).unwrap(),
+        );
+        let data = serde_json::to_vec_pretty(&map).unwrap();
+        let mut temp = inner.path.clone();
+        temp.set_file_name(
+            temp.file_name()
+                .unwrap_or_default()
+                .to_os_string()
+                .tap_mut(|name| name.push(".tmp")),
+        );
+        tokio::fs::write(&temp, data).await?;
+        tokio::fs::rename(temp, &inner.path).await?;
+        Ok(())
+    }
+
+    pub async fn get_account(&self, name: &str) -> Option<RwLockReadGuard<'_, Account>> {
+        let guard = self.0.read().await;
+        RwLockReadGuard::try_map(guard, |guard| guard.accounts.get(name)).ok()
+    }
+
+    pub async fn create_account(&self, name: &str, password: &str) -> std::io::Result<bool> {
+        let hash = Argon2::default()
+            .hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
+            .unwrap()
+            .pipe(|hash| hash.serialize())
+            .pipe(OwnedPasswordHash::from);
+        let mut guard = self.0.write().await;
+        if guard.accounts.get(name).is_some() {
+            return Ok(false);
+        }
+        guard.accounts.insert(
+            name.to_string(),
+            Account {
+                name: name.to_string(),
+                password: hash,
+                tokens: Default::default(),
+                scopes: Default::default(),
+            },
+        );
+        Self::save(&mut guard).await?;
+        Ok(true)
+    }
+
+    pub async fn delete_account(&self, name: &str) -> std::io::Result<()> {
+        let mut guard = self.0.write().await;
+        if let Some(account) = guard.accounts.remove(name) {
+            for token in account.tokens {
+                guard.token_map.remove(&token.value);
+            }
+            Self::save(&mut guard).await
+        } else {
+            Ok(())
+        }
+    }
+
+    pub async fn update_account(
+        &self,
+        name: &str,
+        with: impl FnOnce(&mut Account),
+    ) -> std::io::Result<bool> {
+        let mut guard = self.0.write().await;
+        if let Some(account) = guard.accounts.get_mut(name) {
+            with(account);
+            Self::save(&mut guard).await.map(|_| true)
+        } else {
+            Ok(false)
+        }
+    }
+
+    pub async fn list_accounts(&self) -> Vec<String> {
+        self.0
+            .read()
+            .await
+            .accounts
+            .values()
+            .map(|account| account.name.clone())
+            .collect()
+    }
+
+    pub async fn create_token(&self, name: &str) -> std::io::Result<Option<String>> {
+        let mut guard = self.0.write().await;
+        let token = nanoid!(32);
+        if let Some(account) = guard.accounts.get_mut(name) {
+            let expires = SystemTime::now() + Duration::from_secs(60 * 60 * 24 * 30);
+            account.tokens.push(ExpiringValue {
+                value: token.clone(),
+                expires: expires.clone(),
+            });
+            guard
+                .token_map
+                .insert(token.clone(), (name.to_string(), expires));
+            Self::save(&mut guard).await?;
+            Ok(Some(token))
+        } else {
+            Ok(None)
+        }
+    }
+
+    pub async fn check_token(&self, token: &str) -> Option<String> {
+        let guard = self.0.read().await;
+        let Some((name, expires)) = guard.token_map.get(token) else {
+            return None;
+        };
+        if *expires < SystemTime::now() {
+            return None;
+        }
+        Some(name.clone())
+    }
+
+    pub async fn create_invite(&self) -> std::io::Result<String> {
+        let mut guard = self.0.write().await;
+        let invite = nanoid!(32);
+        let expires = SystemTime::now() + Duration::from_secs(60 * 60 * 24 * 14);
+        guard.invites.push(ExpiringValue {
+            value: invite.clone(),
+            expires,
+        });
+        Self::save(&mut guard).await?;
+        Ok(invite)
+    }
+
+    pub async fn check_invite(&self, invite: &str) -> bool {
+        let guard = self.0.read().await;
+        let now = SystemTime::now();
+        guard
+            .invites
+            .iter()
+            .any(|check| check.expires > now && check.value == invite)
+    }
+
+    pub async fn use_invite(&self, invite: &str) -> std::io::Result<bool> {
+        let mut guard = self.0.write().await;
+        let now = SystemTime::now();
+        if let Some((index, _)) = guard
+            .invites
+            .iter()
+            .enumerate()
+            .find(|(_, check)| check.expires > now && check.value == invite)
+        {
+            guard.invites.swap_remove(index);
+            Self::save(&mut guard).await?;
+            Ok(true)
+        } else {
+            Ok(false)
+        }
+    }
+}
+
+pub struct StoreInner {
+    path: PathBuf,
+    accounts: HashMap<String, Account>,
+    apps: HashMap<String, App>,
+    invites: Vec<ExpiringValue>,
+    token_map: HashMap<String, (String, SystemTime)>,
+}
+
+impl StoreInner {
+    fn new(at: PathBuf) -> Self {
+        Self {
+            path: at,
+            accounts: Default::default(),
+            apps: Default::default(),
+            invites: Default::default(),
+            token_map: Default::default(),
+        }
+    }
+}
+
+#[derive(serde::Serialize, serde::Deserialize)]
+pub struct Account {
+    pub name: String,
+    pub password: OwnedPasswordHash,
+    pub tokens: Vec<ExpiringValue>,
+    pub scopes: Vec<String>,
+}
+
+#[ouroboros::self_referencing]
+pub struct OwnedPasswordHash {
+    owned: PasswordHashString,
+    #[borrows(owned)]
+    #[not_covariant]
+    parsed: PasswordHash<'this>,
+}
+
+impl OwnedPasswordHash {
+    pub fn from(inner: PasswordHashString) -> Self {
+        OwnedPasswordHash::new(inner, |inner| inner.password_hash())
+    }
+
+    pub fn parsed(&self) -> PasswordHash {
+        self.with_parsed(|x| x.clone())
+    }
+}
+
+impl Serialize for OwnedPasswordHash {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        self.with_owned(|owned| owned.as_str().serialize(serializer))
+    }
+}
+
+impl<'de> Deserialize<'de> for OwnedPasswordHash {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        Ok(String::deserialize(deserializer)?
+            .pipe(|hash| {
+                PasswordHashString::parse(&hash, Encoding::B64).map_err(|_| {
+                    <D::Error as serde::de::Error>::invalid_value(
+                        Unexpected::Str(&hash),
+                        &"valid password hash",
+                    )
+                })
+            })?
+            .pipe(OwnedPasswordHash::from))
+    }
+}
+
+pub struct ExpiringValue {
+    pub value: String,
+    pub expires: SystemTime,
+}
+
+impl Serialize for ExpiringValue {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        <(&str, u64)>::serialize(
+            &(
+                &self.value,
+                (self.expires.duration_since(SystemTime::UNIX_EPOCH))
+                    .unwrap()
+                    .as_secs(),
+            ),
+            serializer,
+        )
+    }
+}
+
+impl<'de> Deserialize<'de> for ExpiringValue {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        <(String, u64)>::deserialize(deserializer).map(|(token, unix)| ExpiringValue {
+            value: token,
+            expires: UNIX_EPOCH + Duration::from_secs(unix),
+        })
+    }
+}
+
+#[derive(serde::Serialize, serde::Deserialize)]
+pub struct App {
+    name: String,
+    secret: String,
+}