zellij/zellij-utils/src/web_authentication_tokens.rs
Aram Drevekenin c5ac796880
Feature: web-client/server to share your sessions in the browser (#4242)
* work

* moar work

* notes

* work

* separate to terminal and control channels

* stdin working

* serve html web client initial

* serve static assets loaded with include_dir

* merge

* enable_web_server config parameter

* compile time flag to disable web server capability

* rustfmt

* add license to all xterm.js assets

* mouse working except copy/paste

* helpful comment

* web client improvements

- move script to js file
- add favicon
- add nerd font
- change title

TODO: investigate if font license embedded in otf is sufficient

* get mouse to work properly

* kitty keyboard support initial

* fix wrong type in preload link

* wip axum websocket handlers

- upgrade axum to v0.8.1, enable ws feature
- begin setup of websocket handlers
- tidy up imports

* replace control listener

* handle terminal websocket with axum

* cleanup Cargo.toml

* kitty fixes and bracketed paste

* fix(mouse): pane not found crash

* initial session switching infra

* add `web_client_font` option

* session switching, creation and resurrection working through the session manager

* move session module to zellij-utils and share logic with web-client

* some cleanups

* require restart for enable-web-server

* use session name from router

* write config to disk and watch for config changes

* rename session name to ipc path

* add basic panic handler, make render_to_client exit on channel close

* use while let instead of loop

* handle websocket close

* add mouse motions

* make clipboard work

* add weblink handling and webgl rendering

* add todo

* fix: use session name instead of patch on session switch

* use "default" layout for new sessions

* ui indication for session being shared

* share this session ui

* plugin assets

* Fix process crash on mac with notify watcher.

Use poll watcher instead of recommended as a workaround.

* make url session switching and creation work

* start welcome screen on root url

* scaffold control messages, set font from config

* set dimensions on session start

* bring back session name from url

* send bytes on terminal websocket instead of json

- create web client os input and id before websocket connection

* draft ui

* work

* refactor ui

* remove otf font, remove margins to avoid scrollbar

* version query endpoint for server status

* web session info query endpoint

* refactor: move stuff around

* add web client info to session metadata

* make tests pass

* populate real data in session list

* remove unnecessary endpoint

* add web_client node to config, add font option

* remove web_client_font

* allow disabling the web session through the config - WIP

* formalize sharing/not-sharing configuration

* fix tests

* allow shutting down web server

* display error when web clients are forbidden to attach

* only show sessions that allow web clients if this is a web client

* style(fmt): rustfmt

* fix: query web server from Zellij rather than from each plugin

* remove log spam

* handle some error paths better in the web client

* allow controlling the web server through the cli

* allow configuring the web server's ip/port

* fix tests and format code

* use direct WebServerStatus event instead of piggy-backing on SessionInfo

* plugin revamp initial

* make plugin responsive

* adjust plugin title

* refactor: share plugin

* refactor: share plugin

* add cors middleware

* some fixes for running without a compiled web server capability

* display error when starting the share plugin without web server support

* clarify config

* add pipelines to compile zellij without web support

* display error when unable to start web server

* only query web server when share plugin is running

* refactor(web-client): connection table

* give zellij_server_listener access to the control channel

* fixes and clarifications

* refactor: consolidate generate_unique_session_name

* give proper error when trying to attach to a forbidden session

* change browser URL when switching sessions

* add keyboard shortcut

* enforce https when bound to non-loopback ip

* initial authentication token implementation

* background color from theme

* initial web client theme config

* basic token generation ui

* refactor set config message creation

* also set body background

* allow editing scrollback for plugins too

* set scrollback to 0

* properly parse colors in config

* generate token from plugin

* nice login modals

* initial token management screen

* implement token authentication

* refactor(share): token management screen

* style(fmt): rustfmt

* fix(plugin): some minor bugs

* refactor(share): main screen

* refactor(share): token screen

* refactor(share): main

* refactor(share): ui components

* fix(responsiveness): properly send usage_width to the render function

* fix cli commands and add some verbosity

* add support for settings ansi and selection colors

* add cursor and cursor accent

* basic web client tests

* fix tests

* refactor: web client

* use session tokens for authentication

* improve modals

* move shutdown to ipc

* refactor: ipc logic

* serialize theme config for web client

* update tests

* refactor: move some stuff around to prepare for config hot reload

* config live reloading for the web clients

* change remember-me UI wording

* improve xterm.js link handling

* make sure terminal is focused on mousemove

* remove deprecated sharing indication from compact-bar

* gate deps and functionality behind the web_server_compatibility feature

* feat(build): add --no-web flag in all the places

* fix some other build flows

* add new assets

* update CI for no-web (untested)

* make more dependencies optional

* update axum-extra

* add web client configuration options

* gracefully close connections on server exit

* tests for graceful connection closing

* handle client-side reconnect when server is down

* fix: make sure ipc bus folder exists before starting

* add commands to manage login tokens from the cli

* style(fmt): rustfmt

* some cleanups

* fix(ux): allow alt-right-click on the web client without opening the context menu

* fix: prevent attaching to welcome screen

* fix: reload config issues

* fix long socket path on macos

* normalize config conversion and fix color gap in browser

* revoke session_token cookie if it is not valid

* fix: visual bug with multiple clients in extremely small screen sizes

* fix: only include rusqlite for the web server capability builds

* update e2e snapshots

* refactor(web): client side js

* some cleanups

* moar cleanups

* fix(tests): wait for server instead of using a fixed timeout

* debug CI

* fix(tests): use spawn_blocking for running the test web server

* fix(tests): wait for http rather than tcp port

* fix(tests): properly pass config path - hopefully this is the issue...

* success! bring back the rest of the tests

* attempt to fix the macos CI issue

* docs(changelog): add PR

---------

Co-authored-by: Thomas Linford <linford.t@gmail.com>
2025-06-23 19:19:37 +02:00

349 lines
10 KiB
Rust

// TODO: GATE THIS WHOLE FILE AND RELEVANT DEPS BEHIND web_server_capability
use crate::consts::ZELLIJ_PROJ_DIR;
use rusqlite::Connection;
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;
#[derive(Debug)]
pub struct TokenInfo {
pub name: String,
pub created_at: String,
}
#[derive(Debug)]
pub enum TokenError {
Database(rusqlite::Error),
Io(std::io::Error),
InvalidPath,
DuplicateName(String),
TokenNotFound(String),
InvalidToken,
}
impl std::fmt::Display for TokenError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TokenError::Database(e) => write!(f, "Database error: {}", e),
TokenError::Io(e) => write!(f, "IO error: {}", e),
TokenError::InvalidPath => write!(f, "Invalid path"),
TokenError::DuplicateName(name) => write!(f, "Token name '{}' already exists", name),
TokenError::TokenNotFound(name) => write!(f, "Token '{}' not found", name),
TokenError::InvalidToken => write!(f, "Invalid token"),
}
}
}
impl std::error::Error for TokenError {}
impl From<rusqlite::Error> for TokenError {
fn from(error: rusqlite::Error) -> Self {
match error {
rusqlite::Error::SqliteFailure(ffi_error, _)
if ffi_error.code == rusqlite::ErrorCode::ConstraintViolation =>
{
TokenError::DuplicateName("unknown".to_string())
},
_ => TokenError::Database(error),
}
}
}
impl From<std::io::Error> for TokenError {
fn from(error: std::io::Error) -> Self {
TokenError::Io(error)
}
}
type Result<T> = std::result::Result<T, TokenError>;
fn get_db_path() -> Result<PathBuf> {
if cfg!(debug_assertions) {
// tests db
let data_dir = ZELLIJ_PROJ_DIR.data_dir();
std::fs::create_dir_all(&data_dir)?;
Ok(data_dir.join("tokens_for_dev.db"))
} else {
// prod db
let data_dir = ZELLIJ_PROJ_DIR.data_dir();
std::fs::create_dir_all(data_dir)?;
Ok(data_dir.join("tokens.db"))
}
}
fn init_db(conn: &Connection) -> Result<()> {
conn.execute(
"CREATE TABLE IF NOT EXISTS tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT UNIQUE NOT NULL,
name TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)",
[],
)?;
conn.execute(
"CREATE TABLE IF NOT EXISTS session_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_token_hash TEXT UNIQUE NOT NULL,
auth_token_hash TEXT NOT NULL,
remember_me BOOLEAN NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
FOREIGN KEY (auth_token_hash) REFERENCES tokens(token_hash)
)",
[],
)?;
Ok(())
}
fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
format!("{:x}", hasher.finalize())
}
pub fn create_token(name: Option<String>) -> Result<(String, String)> {
let db_path = get_db_path()?;
let conn = Connection::open(db_path)?;
init_db(&conn)?;
let token = Uuid::new_v4().to_string();
let token_hash = hash_token(&token);
let token_name = if let Some(n) = name {
n.to_string()
} else {
let count: i64 = conn.query_row("SELECT COUNT(*) FROM tokens", [], |row| row.get(0))?;
format!("token_{}", count + 1)
};
match conn.execute(
"INSERT INTO tokens (token_hash, name) VALUES (?1, ?2)",
[&token_hash, &token_name],
) {
Err(rusqlite::Error::SqliteFailure(ffi_error, _))
if ffi_error.code == rusqlite::ErrorCode::ConstraintViolation =>
{
Err(TokenError::DuplicateName(token_name))
},
Err(e) => Err(TokenError::Database(e)),
Ok(_) => Ok((token, token_name)),
}
}
pub fn create_session_token(auth_token: &str, remember_me: bool) -> Result<String> {
let db_path = get_db_path()?;
let conn = Connection::open(db_path)?;
init_db(&conn)?;
cleanup_expired_sessions()?;
let auth_token_hash = hash_token(auth_token);
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM tokens WHERE token_hash = ?1",
[&auth_token_hash],
|row| row.get(0),
)?;
if count == 0 {
return Err(TokenError::InvalidToken);
}
let session_token = Uuid::new_v4().to_string();
let session_token_hash = hash_token(&session_token);
let expires_at = if remember_me {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let four_weeks = 4 * 7 * 24 * 60 * 60;
format!("datetime({}, 'unixepoch')", now + four_weeks)
} else {
// For session-only: very short expiration (e.g., 5 minutes)
// The browser will handle the session aspect via cookie expiration
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let short_duration = 5 * 60; // 5 minutes
format!("datetime({}, 'unixepoch')", now + short_duration)
};
conn.execute(
&format!("INSERT INTO session_tokens (session_token_hash, auth_token_hash, remember_me, expires_at) VALUES (?1, ?2, ?3, {})", expires_at),
[&session_token_hash, &auth_token_hash, &(remember_me as i64).to_string()],
)?;
Ok(session_token)
}
pub fn validate_session_token(session_token: &str) -> Result<bool> {
let db_path = get_db_path()?;
let conn = Connection::open(db_path)?;
init_db(&conn)?;
let session_token_hash = hash_token(session_token);
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM session_tokens WHERE session_token_hash = ?1 AND expires_at > datetime('now')",
[&session_token_hash],
|row| row.get(0),
)?;
Ok(count > 0)
}
pub fn cleanup_expired_sessions() -> Result<usize> {
let db_path = get_db_path()?;
let conn = Connection::open(db_path)?;
init_db(&conn)?;
let rows_affected = conn.execute(
"DELETE FROM session_tokens WHERE expires_at <= datetime('now')",
[],
)?;
Ok(rows_affected)
}
pub fn revoke_session_token(session_token: &str) -> Result<bool> {
let db_path = get_db_path()?;
let conn = Connection::open(db_path)?;
init_db(&conn)?;
let session_token_hash = hash_token(session_token);
let rows_affected = conn.execute(
"DELETE FROM session_tokens WHERE session_token_hash = ?1",
[&session_token_hash],
)?;
Ok(rows_affected > 0)
}
pub fn revoke_sessions_for_auth_token(auth_token: &str) -> Result<usize> {
let db_path = get_db_path()?;
let conn = Connection::open(db_path)?;
init_db(&conn)?;
let auth_token_hash = hash_token(auth_token);
let rows_affected = conn.execute(
"DELETE FROM session_tokens WHERE auth_token_hash = ?1",
[&auth_token_hash],
)?;
Ok(rows_affected)
}
pub fn revoke_token(name: &str) -> Result<bool> {
let db_path = get_db_path()?;
let conn = Connection::open(db_path)?;
init_db(&conn)?;
let token_hash = match conn.query_row(
"SELECT token_hash FROM tokens WHERE name = ?1",
[&name],
|row| row.get::<_, String>(0),
) {
Ok(hash) => Some(hash),
Err(rusqlite::Error::QueryReturnedNoRows) => None,
Err(e) => return Err(TokenError::Database(e)),
};
if let Some(token_hash) = token_hash {
conn.execute(
"DELETE FROM session_tokens WHERE auth_token_hash = ?1",
[&token_hash],
)?;
}
let rows_affected = conn.execute("DELETE FROM tokens WHERE name = ?1", [&name])?;
Ok(rows_affected > 0)
}
pub fn revoke_all_tokens() -> Result<usize> {
let db_path = get_db_path()?;
let conn = Connection::open(db_path)?;
init_db(&conn)?;
conn.execute("DELETE FROM session_tokens", [])?;
let rows_affected = conn.execute("DELETE FROM tokens", [])?;
Ok(rows_affected)
}
pub fn rename_token(old_name: &str, new_name: &str) -> Result<()> {
let db_path = get_db_path()?;
let conn = Connection::open(db_path)?;
init_db(&conn)?;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM tokens WHERE name = ?1",
[&old_name],
|row| row.get(0),
)?;
if count == 0 {
return Err(TokenError::TokenNotFound(old_name.to_string()));
}
match conn.execute(
"UPDATE tokens SET name = ?1 WHERE name = ?2",
[&new_name, &old_name],
) {
Err(rusqlite::Error::SqliteFailure(ffi_error, _))
if ffi_error.code == rusqlite::ErrorCode::ConstraintViolation =>
{
Err(TokenError::DuplicateName(new_name.to_string()))
},
Err(e) => Err(TokenError::Database(e)),
Ok(_) => Ok(()),
}
}
pub fn list_tokens() -> Result<Vec<TokenInfo>> {
let db_path = get_db_path()?;
let conn = Connection::open(db_path)?;
init_db(&conn)?;
let mut stmt = conn.prepare("SELECT name, created_at FROM tokens ORDER BY created_at")?;
let rows = stmt.query_map([], |row| {
Ok(TokenInfo {
name: row.get::<_, String>(0)?,
created_at: row.get::<_, String>(1)?,
})
})?;
let mut tokens = Vec::new();
for token in rows {
tokens.push(token?);
}
Ok(tokens)
}
pub fn delete_db() -> Result<()> {
let db_path = get_db_path()?;
if db_path.exists() {
std::fs::remove_file(db_path)?;
}
Ok(())
}
pub fn validate_token(token: &str) -> Result<bool> {
let db_path = get_db_path()?;
let conn = Connection::open(db_path)?;
init_db(&conn)?;
let token_hash = hash_token(token);
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM tokens WHERE token_hash = ?1",
[&token_hash],
|row| row.get(0),
)?;
Ok(count > 0)
}