From f6198ae2fb999f9df1f74da2d977b96ce073e528 Mon Sep 17 00:00:00 2001 From: Dimitris Zervas Date: Tue, 26 Aug 2025 23:30:11 +0100 Subject: [PATCH 1/3] feat(api): manage api keys via secrets --- config.sample.yaml | 3 + hurl/api_key.hurl | 83 +++++++++++++ src/auth_url/handle_status.rs | 45 +++++-- src/config.rs | 83 +++++++------ src/database.rs | 112 ++++++++--------- src/handle_api_key.rs | 102 +++++++++++++++ src/handle_index.rs | 33 +++-- src/lib.rs | 7 +- src/main.rs | 36 +++--- src/secret/api_key.rs | 199 ++++++++++++++++++++++++++++++ src/secret/mod.rs | 20 +-- static/templates/api_key.html.hbs | 9 ++ static/templates/index.html.hbs | 40 ++++-- 13 files changed, 622 insertions(+), 150 deletions(-) create mode 100644 hurl/api_key.hurl create mode 100644 src/handle_api_key.rs create mode 100644 src/secret/api_key.rs create mode 100644 static/templates/api_key.html.hbs diff --git a/config.sample.yaml b/config.sample.yaml index c0d807c4..88acc4a1 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -17,6 +17,9 @@ link_duration: 1h # How long will a global session be valid for session_duration: 1mon +# Maximum allowed API key duration (0 disables expiration limit) +api_key_max_expiration: 365d + # How often to run expired secrets cleanup secrets_cleanup_interval: 24h diff --git a/hurl/api_key.hurl b/hurl/api_key.hurl new file mode 100644 index 00000000..aaadeaad --- /dev/null +++ b/hurl/api_key.hurl @@ -0,0 +1,83 @@ +# Login and create an API key +GET http://localhost:8080/login +HTTP 200 +[Asserts] +xpath "//form[@method='post']" count == 1 + +POST http://localhost:8080/login +[Form] +email: valid@example.com +HTTP 200 + +GET http://localhost:8081/.link.txt +HTTP 200 +[Captures] +link: body + +GET {{link}} +HTTP 302 +Location: / +[Captures] +session: cookie "session_id" + +POST http://localhost:8080/api_key +[Cookies] +session_id: {{session}} +[Form] +service: example +expiration: 60 +HTTP 200 +[Captures] +created_key: xpath "//code[@id='api-key']/text()" + +# Use created key +GET http://localhost:8080/auth-url/status +[Headers] +X-Original-URL: http://localhost:8081/ +X-Api-Key: {{created_key}} +HTTP 200 + +# Rotate key +POST http://localhost:8080/api_key/{{created_key}}/rotate +[Cookies] +session_id: {{session}} +[Form] +service: example +HTTP 200 +[Captures] +rotated_key: xpath "//code[@id='api-key']/text()" + +# Old key should fail +GET http://localhost:8080/auth-url/status +[Headers] +X-Original-URL: http://localhost:8081/ +X-Api-Key: {{created_key}} +HTTP 401 + +# New key should succeed +GET http://localhost:8080/auth-url/status +[Headers] +X-Original-URL: http://localhost:8081/ +X-Api-Key: {{rotated_key}} +HTTP 200 + +# Delete new key +POST http://localhost:8080/api_key/{{rotated_key}}/delete +[Cookies] +session_id: {{session}} +HTTP 302 +Location: / + +# Deleted key should fail +GET http://localhost:8080/auth-url/status +[Headers] +X-Original-URL: http://localhost:8081/ +X-Api-Key: {{rotated_key}} +HTTP 401 + +GET http://localhost:8080/ +[Cookies] +session_id: {{session}} +HTTP 200 +[Asserts] +body contains "API Keys" diff --git a/src/auth_url/handle_status.rs b/src/auth_url/handle_status.rs index 0fb63f1e..db9e2571 100644 --- a/src/auth_url/handle_status.rs +++ b/src/auth_url/handle_status.rs @@ -3,8 +3,7 @@ use actix_web::{get, web, HttpResponse}; use log::info; use crate::error::Response; -use crate::secret::ProxySessionSecret; -use crate::secret::ProxyCodeSecret; +use crate::secret::{ApiKeySecret, ProxyCodeSecret, ProxySessionSecret}; use crate::{CONFIG, PROXY_SESSION_COOKIE}; /// This endpoint is used to check weather a user is logged in from a proxy @@ -23,23 +22,47 @@ async fn status( db: web::Data, proxy_code_opt: Option, proxy_session_opt: Option, + api_key_opt: Option, ) -> Response { let mut response_builder = HttpResponse::Ok(); let mut response = response_builder.content_type("text/plain"); + if let Some(api_key) = api_key_opt { + let config = CONFIG.read().await; + return Ok(response + .insert_header(( + config.auth_url_email_header.as_str(), + api_key.user().email.clone(), + )) + .insert_header(( + config.auth_url_user_header.as_str(), + api_key.user().username.clone(), + )) + .insert_header(( + config.auth_url_name_header.as_str(), + api_key.user().name.clone(), + )) + .insert_header(( + config.auth_url_realms_header.as_str(), + api_key.user().realms.join(","), + )) + .finish()); + } + let proxy_session = if let Some(proxy_session) = proxy_session_opt { proxy_session } else if let Some(proxy_code) = proxy_code_opt { info!("Proxied login for {}", &proxy_code.user().email); - let proxy_session = proxy_code - .exchange_sibling(&db) - .await?; + let proxy_session = proxy_code.exchange_sibling(&db).await?; response = response.cookie( - Cookie::build(PROXY_SESSION_COOKIE, proxy_session.code().to_str_that_i_wont_print()) - .path("/") - .http_only(true) - .finish(), + Cookie::build( + PROXY_SESSION_COOKIE, + proxy_session.code().to_str_that_i_wont_print(), + ) + .path("/") + .http_only(true) + .finish(), ); proxy_session @@ -47,9 +70,7 @@ async fn status( let mut remove_cookie = Cookie::new(PROXY_SESSION_COOKIE, ""); remove_cookie.make_removal(); - return Ok(HttpResponse::Unauthorized() - .cookie(remove_cookie) - .finish()); + return Ok(HttpResponse::Unauthorized().cookie(remove_cookie).finish()); }; let config = CONFIG.read().await; diff --git a/src/config.rs b/src/config.rs index 6bc229e9..10feb1f8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -35,12 +35,16 @@ pub struct ConfigFile { #[serde(deserialize_with = "duration_str::deserialize_duration_chrono")] pub link_duration: Duration, - #[serde(deserialize_with = "duration_str::deserialize_duration_chrono")] - pub session_duration: Duration, + #[serde(deserialize_with = "duration_str::deserialize_duration_chrono")] + pub session_duration: Duration, - /// Interval for periodic cleanup of expired secrets - #[serde(deserialize_with = "duration_str::deserialize_duration_chrono")] - pub secrets_cleanup_interval: Duration, + /// Maximum allowed API key duration. 0 means no limit + #[serde(deserialize_with = "duration_str::deserialize_duration_chrono")] + pub api_key_max_expiration: Duration, + + /// Interval for periodic cleanup of expired secrets + #[serde(deserialize_with = "duration_str::deserialize_duration_chrono")] + pub secrets_cleanup_interval: Duration, pub title: String, pub static_path: String, @@ -79,8 +83,8 @@ pub struct ConfigFile { } impl Default for ConfigFile { - fn default() -> Self { - Self { + fn default() -> Self { + Self { database_url: std::env::var("DATABASE_URL").unwrap_or("database.db".to_string()), listen_host : std::env::var("LISTEN_HOST").unwrap_or("127.0.0.1".to_string()), @@ -91,6 +95,8 @@ impl Default for ConfigFile { link_duration : Duration::try_hours(12).unwrap(), session_duration: Duration::try_days(30).unwrap(), + api_key_max_expiration: Duration::try_days(365).unwrap(), + secrets_cleanup_interval: Duration::try_hours(24).unwrap(), title: "MagicEntry".to_string(), @@ -128,7 +134,7 @@ impl Default for ConfigFile { services: Services(vec![]), } - } + } } impl ConfigFile { @@ -163,16 +169,13 @@ impl ConfigFile { let mut config = CONFIG.write().await; log::info!("Reloading config from {}", CONFIG_FILE.as_str()); - let mut new_config = serde_yaml::from_str::( - &std::fs::read_to_string(CONFIG_FILE.as_str())? - )?; + let mut new_config = + serde_yaml::from_str::(&std::fs::read_to_string(CONFIG_FILE.as_str())?)?; if let Some(users_file) = &new_config.users_file { - new_config.users.extend( - serde_yaml::from_str::>( - &std::fs::read_to_string(users_file)? - )? - ); + new_config.users.extend(serde_yaml::from_str::>( + &std::fs::read_to_string(users_file)?, + )?); } if new_config.users_file != config.users_file { @@ -191,25 +194,27 @@ impl ConfigFile { .with_poll_interval(std::time::Duration::from_secs(2)) .with_follow_symlinks(true); - let mut watcher = notify::PollWatcher::new(move |_| { - log::info!("Config file changed, reloading"); - futures::executor::block_on(async { - if let Err(e) = ConfigFile::reload().await { - log::error!("Failed to reload config file: {}", e); - } - }) - }, watcher_config) - .expect("Failed to create watcher for the config file"); + let mut watcher = notify::PollWatcher::new( + move |_| { + log::info!("Config file changed, reloading"); + futures::executor::block_on(async { + if let Err(e) = ConfigFile::reload().await { + log::error!("Failed to reload config file: {}", e); + } + }) + }, + watcher_config, + ) + .expect("Failed to create watcher for the config file"); watcher - .watch(Path::new(CONFIG_FILE.as_str()), notify::RecursiveMode::NonRecursive) + .watch( + Path::new(CONFIG_FILE.as_str()), + notify::RecursiveMode::NonRecursive, + ) .expect("Failed to watch config file for changes"); - if let Some(users_file) = CONFIG - .try_read() - .ok() - .and_then(|c| c.users_file.clone()) - { + if let Some(users_file) = CONFIG.try_read().ok().and_then(|c| c.users_file.clone()) { watcher .watch(Path::new(&users_file), notify::RecursiveMode::NonRecursive) .expect("Failed to watch users file for changes"); @@ -235,9 +240,7 @@ impl ConfigFile { let data = std::fs::read_to_string(&self.saml_key_pem_path)?; Ok(data .lines() - .filter(|line| { - !line.contains("BEGIN PRIVATE KEY") && !line.contains("END PRIVATE KEY") - }) + .filter(|line| !line.contains("BEGIN PRIVATE KEY") && !line.contains("END PRIVATE KEY")) .collect::() .replace("\n", "")) } @@ -256,19 +259,23 @@ pub struct ConfigKV { impl ConfigKV { /// Set the provided key to the provided value - overwrites any previous values - pub async fn set(key: ConfigKeys, value: Option, db: &Database) -> crate::error::Result<()> { + pub async fn set( + key: ConfigKeys, + value: Option, + db: &Database, + ) -> crate::error::Result<()> { let key_str = serde_json::to_string(&key)?; let value_str = value.unwrap_or_default(); - + let row = ConfigKVRow { key: key_str, value: value_str, updated_at: None, }; - + row.save(db).await } - + /// Get a config value by key pub async fn get(key: &ConfigKeys, db: &Database) -> crate::error::Result> { let key_str = serde_json::to_string(key)?; diff --git a/src/database.rs b/src/database.rs index a1e9b04e..6dfcc7e6 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,6 +1,6 @@ use chrono::NaiveDateTime; use serde::{Deserialize, Serialize}; -use sqlx::{SqlitePool, sqlite::SqliteConnectOptions, FromRow}; +use sqlx::{sqlite::SqliteConnectOptions, FromRow, SqlitePool}; use std::str::FromStr; use crate::error::Result; @@ -15,12 +15,12 @@ pub async fn init_database(database_url: &str) -> Result { .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) .shared_cache(true) .create_if_missing(true); - + let pool = SqlitePool::connect_with(options).await?; - + // Run migrations sqlx::migrate!("./migrations").run(&pool).await?; - + Ok(pool) } @@ -48,10 +48,10 @@ impl UserSecretRow { .bind(&self.metadata) .execute(db) .await?; - + Ok(()) } - + /// Get a user secret by ID pub async fn get(id: &str, db: &Database) -> Result> { let row = sqlx::query_as::<_, UserSecretRow>( @@ -60,34 +60,29 @@ impl UserSecretRow { .bind(id) .fetch_optional(db) .await?; - + Ok(row) } - + /// Check if a user secret exists pub async fn exists(id: &str, db: &Database) -> Result { - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM user_secrets WHERE id = ?" - ) - .bind(id) - .fetch_one(db) - .await?; - + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_secrets WHERE id = ?") + .bind(id) + .fetch_one(db) + .await?; + Ok(count > 0) } - + /// Remove a user secret by ID pub async fn remove(id: &str, db: &Database) -> Result<()> { - sqlx::query( - "DELETE FROM user_secrets WHERE id = ?" - ) - .bind(id) - .execute(db) - .await?; - + sqlx::query("DELETE FROM user_secrets WHERE id = ?") + .bind(id) + .execute(db) + .await?; + Ok(()) } - } /// Represents a passkey stored in the database @@ -102,29 +97,27 @@ pub struct PasskeyRow { impl PasskeyRow { /// Save a passkey to the database pub async fn save(&mut self, db: &Database) -> Result<()> { - let result = sqlx::query( - "INSERT INTO passkeys (user_data, passkey_data) VALUES (?, ?)" - ) - .bind(&self.user_data) - .bind(&self.passkey_data) - .execute(db) - .await?; - + let result = sqlx::query("INSERT INTO passkeys (user_data, passkey_data) VALUES (?, ?)") + .bind(&self.user_data) + .bind(&self.passkey_data) + .execute(db) + .await?; + self.id = Some(result.last_insert_rowid()); Ok(()) } - + /// Get all passkeys for a user pub async fn get_by_user(user: &User, db: &Database) -> Result> { let user_str = serde_json::to_string(user)?; - + let rows = sqlx::query_as::<_, PasskeyRow>( - "SELECT id, user_data, passkey_data, created_at FROM passkeys WHERE user_data = ?" + "SELECT id, user_data, passkey_data, created_at FROM passkeys WHERE user_data = ?", ) .bind(user_str) .fetch_all(db) .await?; - + Ok(rows) } } @@ -148,31 +141,27 @@ impl ConfigKVRow { .bind(&self.value) .execute(db) .await?; - + Ok(()) } - + /// Get a config value by key pub async fn get(key: &str, db: &Database) -> Result> { - let row: Option<(String,)> = sqlx::query_as( - "SELECT value FROM config_kv WHERE key = ?" - ) - .bind(key) - .fetch_optional(db) - .await?; - + let row: Option<(String,)> = sqlx::query_as("SELECT value FROM config_kv WHERE key = ?") + .bind(key) + .fetch_optional(db) + .await?; + Ok(row.map(|(value,)| value)) } - + /// Remove a config KV pair by key pub async fn remove(key: &str, db: &Database) -> Result<()> { - sqlx::query( - "DELETE FROM config_kv WHERE key = ?" - ) - .bind(key) - .execute(db) - .await?; - + sqlx::query("DELETE FROM config_kv WHERE key = ?") + .bind(key) + .execute(db) + .await?; + Ok(()) } } @@ -189,7 +178,7 @@ mod tests { #[tokio::test] async fn test_user_secret_crud() { let db = setup_test_db().await.unwrap(); - + let secret = UserSecretRow { id: "test_secret_123".to_string(), secret_type: "login_link".to_string(), @@ -203,7 +192,10 @@ mod tests { secret.save(&db).await.unwrap(); // Test get - let retrieved = UserSecretRow::get("test_secret_123", &db).await.unwrap().unwrap(); + let retrieved = UserSecretRow::get("test_secret_123", &db) + .await + .unwrap() + .unwrap(); assert_eq!(retrieved.id, secret.id); assert_eq!(retrieved.secret_type, secret.secret_type); assert_eq!(retrieved.user_data, secret.user_data); @@ -220,7 +212,7 @@ mod tests { #[tokio::test] async fn test_config_kv_crud() { let db = setup_test_db().await.unwrap(); - + let config = ConfigKVRow { key: "jwt_keypair".to_string(), value: "test_keypair_value".to_string(), @@ -241,13 +233,15 @@ mod tests { updated_at: None, }; updated_config.save(&db).await.unwrap(); - + let updated_value = ConfigKVRow::get("jwt_keypair", &db).await.unwrap().unwrap(); assert_eq!(updated_value, "updated_keypair_value"); // Test remove ConfigKVRow::remove("jwt_keypair", &db).await.unwrap(); - assert!(ConfigKVRow::get("jwt_keypair", &db).await.unwrap().is_none()); + assert!(ConfigKVRow::get("jwt_keypair", &db) + .await + .unwrap() + .is_none()); } - } diff --git a/src/handle_api_key.rs b/src/handle_api_key.rs new file mode 100644 index 00000000..61f00721 --- /dev/null +++ b/src/handle_api_key.rs @@ -0,0 +1,102 @@ +use std::collections::BTreeMap; + +use actix_web::http::header::ContentType; +use actix_web::{post, web, HttpResponse}; +use chrono::{Duration, Utc}; +use serde::Deserialize; + +use crate::database::Database; +use crate::error::{AppErrorKind, Response}; +use crate::secret::{ApiKeySecret, BrowserSessionSecret}; +use crate::utils::get_partial; + +#[derive(Deserialize)] +struct CreateForm { + service: String, + /// expiration in seconds + expiration: Option, +} + +#[post("/api_key")] +async fn create( + browser_session: BrowserSessionSecret, + form: web::Form, + db: web::Data, +) -> Response { + let duration = form + .expiration + .and_then(Duration::try_seconds) + .unwrap_or_else(|| chrono::Duration::seconds(0)); + let key = ApiKeySecret::new_with_expiration( + browser_session.user().clone(), + form.service.clone(), + duration, + db.get_ref(), + ) + .await?; + let mut data = BTreeMap::new(); + data.insert("key", key.code().to_str_that_i_wont_print().to_string()); + data.insert("id", key.code().to_str_that_i_wont_print().to_string()); + let page = get_partial::<()>("api_key", data, None)?; + Ok(HttpResponse::Ok() + .content_type(ContentType::html()) + .body(page)) +} + +#[derive(Deserialize)] +struct IdPath { + id: String, +} + +#[post("/api_key/{id}/delete")] +async fn delete( + browser_session: BrowserSessionSecret, + path: web::Path, + db: web::Data, +) -> Response { + let key = ApiKeySecret::try_from_string(path.id.clone(), db.get_ref()).await?; + if key.user() != browser_session.user() { + return Err(AppErrorKind::Unauthorized.into()); + } + key.delete(db.get_ref()).await?; + Ok(HttpResponse::Found() + .append_header(("Location", "/")) + .finish()) +} + +#[post("/api_key/{id}/rotate")] +async fn rotate( + browser_session: BrowserSessionSecret, + path: web::Path, + form: web::Form, + db: web::Data, +) -> Response { + let old = ApiKeySecret::try_from_string(path.id.clone(), db.get_ref()).await?; + if old.user() != browser_session.user() { + return Err(AppErrorKind::Unauthorized.into()); + } + let remaining = if old.expires_at() == chrono::NaiveDateTime::MAX { + chrono::Duration::seconds(0) + } else { + old.expires_at() - Utc::now().naive_utc() + }; + let new_key = ApiKeySecret::new_with_expiration( + browser_session.user().clone(), + form.service.clone(), + remaining, + db.get_ref(), + ) + .await?; + old.delete(db.get_ref()).await?; + let mut data = BTreeMap::new(); + data.insert("key", new_key.code().to_str_that_i_wont_print().to_string()); + data.insert("id", new_key.code().to_str_that_i_wont_print().to_string()); + let page = get_partial::<()>("api_key", data, None)?; + Ok(HttpResponse::Ok() + .content_type(ContentType::html()) + .body(page)) +} + +pub fn init(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(create).service(delete).service(rotate); +} diff --git a/src/handle_index.rs b/src/handle_index.rs index 49e78342..957223d4 100644 --- a/src/handle_index.rs +++ b/src/handle_index.rs @@ -5,23 +5,42 @@ use std::collections::BTreeMap; use actix_web::http::header::ContentType; -use actix_web::{get, HttpResponse}; +use actix_web::{get, web, HttpResponse}; +use serde::Serialize; +use crate::database::Database; use crate::error::Response; -use crate::secret::BrowserSessionSecret; +use crate::secret::{ApiKeyInfo, ApiKeySecret, BrowserSessionSecret}; +use crate::service::Service; use crate::utils::get_partial; use crate::CONFIG; +#[derive(Serialize)] +struct ServiceWithKeys { + #[serde(flatten)] + service: Service, + api_keys: Vec, +} + #[get("/")] -async fn index( - browser_session: BrowserSessionSecret, -) -> Response { +async fn index(browser_session: BrowserSessionSecret, db: web::Data) -> Response { // Render the index page let config = CONFIG.read().await; let mut index_data = BTreeMap::new(); index_data.insert("email", browser_session.user().email.clone()); let realmed_services = config.services.from_user(&browser_session.user()); - let index_page = get_partial("index", index_data, Some(realmed_services))?; + drop(config); + + let mut services_with_keys = Vec::new(); + for svc in realmed_services.0 { + let keys = ApiKeySecret::list(browser_session.user(), &svc.name, db.get_ref()).await?; + services_with_keys.push(ServiceWithKeys { + service: svc, + api_keys: keys, + }); + } + + let index_page = get_partial("index", index_data, Some(services_with_keys))?; // Respond with the index page and set the X-Remote headers as configured Ok(HttpResponse::Ok() @@ -69,7 +88,7 @@ mod tests { App::new() .app_data(web::Data::new(db.clone())) .service(index) - .service(handle_magic_link::magic_link) + .service(handle_magic_link::magic_link), ) .await; diff --git a/src/lib.rs b/src/lib.rs index 1173b07d..35df72a7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -70,17 +70,18 @@ pub mod database; pub mod error; pub mod oidc; pub mod saml; +pub mod secret; pub mod service; pub mod user; -pub mod secret; pub mod utils; pub mod webauthn; +pub mod handle_api_key; pub mod handle_index; -pub mod handle_login_post; -pub mod handle_magic_link; pub mod handle_login; +pub mod handle_login_post; pub mod handle_logout; +pub mod handle_magic_link; pub mod handle_static; #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index 847daf41..a87c8033 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,9 +15,10 @@ use tokio::select; pub async fn main() -> std::io::Result<()> { env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); - #[cfg(feature = "e2e-test")] - log::warn!("Running in E2E Tests mode, all magic links will written to disk in the `.link.txt` file."); + log::warn!( + "Running in E2E Tests mode, all magic links will written to disk in the `.link.txt` file." + ); #[cfg(debug_assertions)] log::warn!("Running in debug mode, all magic links will be printed to the console."); @@ -69,9 +70,7 @@ pub async fn main() -> std::io::Result<()> { .app_data(web::Data::new(mailer.clone())) .app_data(web::Data::new(http_client.clone())) .app_data(basic::Config::default().realm("MagicEntry")) - .default_service(web::route().to(error::not_found)) - // Auth routes .service(handle_index::index) .service(handle_login::login) @@ -80,14 +79,12 @@ pub async fn main() -> std::io::Result<()> { .service(handle_logout::logout) .service(handle_static::static_files) .service(handle_static::favicon) - + .configure(handle_api_key::init) // Auth URL routes .service(auth_url::handle_status::status) - // SAML routes .service(saml::handle_metadata::metadata) .service(saml::handle_sso::sso) - // OIDC routes .app_data(web::Data::new(oidc_key.clone())) .service(oidc::handle_discover::discover) @@ -99,8 +96,13 @@ pub async fn main() -> std::io::Result<()> { .service(oidc::handle_jwks::jwks) .service(oidc::handle_userinfo::userinfo) // Handle oauth discovery too - .service(web::redirect("/.well-known/oauth-authorization-server", "/.well-known/openid-configuration").permanent()) - + .service( + web::redirect( + "/.well-known/oauth-authorization-server", + "/.well-known/openid-configuration", + ) + .permanent(), + ) // Middleware .wrap(Logger::default()); @@ -118,13 +120,15 @@ pub async fn main() -> std::io::Result<()> { app }) - .workers(if cfg!(debug_assertions) || cfg!(test) || cfg!(feature = "e2e-test") { - 1 - } else { - std::thread::available_parallelism() - .map(|n| n.get()) - .unwrap_or(2) - }) + .workers( + if cfg!(debug_assertions) || cfg!(test) || cfg!(feature = "e2e-test") { + 1 + } else { + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(2) + }, + ) .bind(format!("{}:{}", listen_host, listen_port)) .unwrap() .run(); diff --git a/src/secret/api_key.rs b/src/secret/api_key.rs new file mode 100644 index 00000000..b4312245 --- /dev/null +++ b/src/secret/api_key.rs @@ -0,0 +1,199 @@ +use chrono::{Duration, NaiveDateTime, Utc}; +use futures::future::BoxFuture; +use serde::{Deserialize, Serialize}; + +use crate::error::{AppErrorKind, Result}; +use crate::user::User; +use crate::{CONFIG, PROXY_ORIGIN_HEADER}; + +use super::primitive::{InternalUserSecret, UserSecret, UserSecretKind}; +use super::{MetadataKind, SecretString}; +use crate::database::{Database, UserSecretRow}; + +/// Metadata attached to an API key, storing the service name it grants access to. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +pub struct ApiKeyMetadata { + pub service: String, +} + +impl MetadataKind for ApiKeyMetadata { + async fn validate(&self, _: &Database) -> Result<()> { + let config = CONFIG.read().await; + if config.services.get(&self.service).is_none() { + return Err(AppErrorKind::NotFound.into()); + } + Ok(()) + } +} + +/// Secret kind used for API keys. +#[derive(PartialEq, Serialize, Deserialize)] +pub struct ApiKeySecretKind; + +impl UserSecretKind for ApiKeySecretKind { + const PREFIX: &'static str = "api"; + type Metadata = ApiKeyMetadata; + + async fn duration() -> chrono::Duration { + CONFIG.read().await.api_key_max_expiration + } +} + +/// API key secret type. +pub type ApiKeySecret = UserSecret; + +impl ApiKeySecret { + /// Create a new API key with a custom expiration. + pub async fn new_with_expiration( + user: User, + service: String, + duration: Duration, + db: &Database, + ) -> Result { + let max = ApiKeySecretKind::duration().await; + let final_duration = if max.num_seconds() == 0 || duration <= max { + duration + } else { + max + }; + let expires_at = if final_duration.num_seconds() == 0 { + NaiveDateTime::MAX + } else { + Utc::now() + .naive_utc() + .checked_add_signed(final_duration) + .ok_or(AppErrorKind::InvalidDuration)? + }; + let internal = InternalUserSecret { + code: SecretString::new(ApiKeySecretKind::PREFIX), + user, + expires_at, + metadata: ApiKeyMetadata { service }, + }; + internal.save(db).await?; + Ok(Self(internal)) + } + + /// List API keys for a user and service. + pub async fn list(user: &User, service: &str, db: &Database) -> Result> { + let user_str = serde_json::to_string(user)?; + let rows = sqlx::query_as::<_, UserSecretRow>( +"SELECT id, secret_type, user_data, expires_at, metadata, created_at FROM user_secrets WHERE secret_type = ? AND user_data = ?", +) +.bind(ApiKeySecretKind::PREFIX) +.bind(user_str) +.fetch_all(db) +.await?; + + let mut keys = Vec::new(); + for row in rows { + let meta: ApiKeyMetadata = serde_json::from_str(&row.metadata)?; + if meta.service != service { + continue; + } + let display = format!("{}…", &row.id[..row.id.len().min(8)]); + let expires_at = if row.expires_at == NaiveDateTime::MAX { + None + } else { + Some(row.expires_at) + }; + keys.push(ApiKeyInfo { + id: row.id, + display, + expires_at, + }); + } + Ok(keys) + } + + /// Get the associated service name. + pub fn service(&self) -> &str { + &self.0.metadata.service + } +} + +/// Public representation of an API key for listing purposes. +#[derive(Debug, Clone, Serialize)] +pub struct ApiKeyInfo { + pub id: String, + pub display: String, + pub expires_at: Option, +} + +impl actix_web::FromRequest for ApiKeySecret { + type Error = crate::error::Error; + type Future = BoxFuture<'static, Result>; + + fn from_request(req: &actix_web::HttpRequest, _: &mut actix_web::dev::Payload) -> Self::Future { + let Some(key_header) = req.headers().get("X-Api-Key").cloned() else { + return Box::pin(async { Err(AppErrorKind::NotLoggedIn.into()) }); + }; + let Some(origin_header) = req.headers().get(PROXY_ORIGIN_HEADER).cloned() else { + return Box::pin(async { Err(AppErrorKind::MissingOriginHeader.into()) }); + }; + let Some(db) = req.app_data::>().cloned() else { + return Box::pin(async { Err(AppErrorKind::DatabaseInstanceError.into()) }); + }; + Box::pin(async move { + let key = key_header.to_str()?.to_string(); + let origin_url = url::Url::parse(origin_header.to_str()?)?; + let config = CONFIG.read().await; + let service = config + .services + .from_auth_url_origin(&origin_url.origin()) + .ok_or(AppErrorKind::InvalidOriginHeader)?; + let secret = ApiKeySecret::try_from_string(key, db.get_ref()).await?; + if secret.service() != service.name { + return Err(AppErrorKind::Unauthorized.into()); + } + if !service.is_user_allowed(secret.user()) { + return Err(AppErrorKind::Unauthorized.into()); + } + Ok(secret) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::tests::*; + + #[tokio::test] + async fn test_api_key_crud() { + let db = db_connect().await; + let user = get_valid_user().await; + let key = ApiKeySecret::new_with_expiration( + user.clone(), + "example".to_string(), + Duration::try_seconds(60).unwrap(), + &db, + ) + .await + .unwrap(); + let id = key.code().to_str_that_i_wont_print().to_string(); + let list = ApiKeySecret::list(&user, "example", &db).await.unwrap(); + assert_eq!(list.len(), 1); + let fetched = ApiKeySecret::try_from_string(id.clone(), &db) + .await + .unwrap(); + let new_key = ApiKeySecret::new_with_expiration( + user.clone(), + "example".to_string(), + Duration::try_seconds(60).unwrap(), + &db, + ) + .await + .unwrap(); + fetched.delete(&db).await.unwrap(); + let new_id = new_key.code().to_str_that_i_wont_print().to_string(); + let list = ApiKeySecret::list(&user, "example", &db).await.unwrap(); + assert_eq!(list.len(), 1); + let fetched_new = ApiKeySecret::try_from_string(new_id.clone(), &db) + .await + .unwrap(); + fetched_new.delete(&db).await.unwrap(); + let list = ApiKeySecret::list(&user, "example", &db).await.unwrap(); + assert!(list.is_empty()); + } +} diff --git a/src/secret/mod.rs b/src/secret/mod.rs index a8d60ba1..f252c3c6 100644 --- a/src/secret/mod.rs +++ b/src/secret/mod.rs @@ -1,28 +1,30 @@ #![allow(async_fn_in_trait)] -pub mod primitive; +pub mod cleanup; pub mod ephemeral_primitive; pub mod metadata; -pub mod cleanup; +pub mod primitive; // Secret types +pub mod api_key; pub mod browser_session; pub mod login_link; +pub mod oidc_authcode; +pub mod oidc_token; pub mod proxy_code; pub mod proxy_session; -pub mod oidc_token; -pub mod oidc_authcode; pub mod webauthn_auth; pub mod webauthn_reg; +pub use api_key::{ApiKeyInfo, ApiKeySecret}; pub use browser_session::BrowserSessionSecret; pub use login_link::LoginLinkSecret; -pub use proxy_code::ProxyCodeSecret; -pub use proxy_session::ProxySessionSecret; +pub use metadata::{ChildSecretMetadata, EmptyMetadata, MetadataKind}; pub use oidc_authcode::OIDCAuthCodeSecret; pub use oidc_token::OIDCTokenSecret; +pub use proxy_code::ProxyCodeSecret; +pub use proxy_session::ProxySessionSecret; pub use webauthn_auth::WebAuthnAuthSecret; pub use webauthn_reg::WebAuthnRegSecret; -pub use metadata::{MetadataKind, ChildSecretMetadata, EmptyMetadata}; use serde::{Deserialize, Serialize}; @@ -36,7 +38,9 @@ pub fn get_prefix(prefix: &str) -> String { pub struct SecretString(String); impl SecretString { - pub fn to_str_that_i_wont_print(&self) -> &str { &self.0 } + pub fn to_str_that_i_wont_print(&self) -> &str { + &self.0 + } } impl SecretString { diff --git a/static/templates/api_key.html.hbs b/static/templates/api_key.html.hbs new file mode 100644 index 00000000..e47c31de --- /dev/null +++ b/static/templates/api_key.html.hbs @@ -0,0 +1,9 @@ +{{> header }} +
+
+

Store this API key now. You won't be able to see it again.

+ {{data.key}} + Back +
+
+{{> footer }} diff --git a/static/templates/index.html.hbs b/static/templates/index.html.hbs index 70309ba5..4052fb56 100644 --- a/static/templates/index.html.hbs +++ b/static/templates/index.html.hbs @@ -39,13 +39,39 @@
realm
- -
- - - {{/each}} - - + +
+ + + + +
+ API Keys +
    +{{#each api_keys}} +
  • +{{this.display}} - {{#if this.expires_at}}{{this.expires_at}}{{else}}never{{/if}} +
    + + +
    +
    + +
    +
  • +{{/each}} +
+
+ + + +
+
+ + + {{/each}} + + {{> script name="webauthn" }} From d8c147a8e374f4b9dff54a31e25b429293012f36 Mon Sep 17 00:00:00 2001 From: Dimitris Zervas Date: Wed, 27 Aug 2025 11:47:41 +0100 Subject: [PATCH 2/3] feat(api): manage keys without ids --- hurl/api_key.hurl | 8 ++-- src/handle_api_key.rs | 45 +++++-------------- src/handle_index.rs | 12 +++-- src/secret/api_key.rs | 78 +++++++++++++++++++++++---------- static/templates/index.html.hbs | 7 +-- 5 files changed, 83 insertions(+), 67 deletions(-) diff --git a/hurl/api_key.hurl b/hurl/api_key.hurl index aaadeaad..be6d2505 100644 --- a/hurl/api_key.hurl +++ b/hurl/api_key.hurl @@ -38,11 +38,11 @@ X-Api-Key: {{created_key}} HTTP 200 # Rotate key -POST http://localhost:8080/api_key/{{created_key}}/rotate +POST http://localhost:8080/api_key/rotate [Cookies] session_id: {{session}} [Form] -service: example +key: {{created_key}} HTTP 200 [Captures] rotated_key: xpath "//code[@id='api-key']/text()" @@ -62,9 +62,11 @@ X-Api-Key: {{rotated_key}} HTTP 200 # Delete new key -POST http://localhost:8080/api_key/{{rotated_key}}/delete +POST http://localhost:8080/api_key/delete [Cookies] session_id: {{session}} +[Form] +key: {{rotated_key}} HTTP 302 Location: / diff --git a/src/handle_api_key.rs b/src/handle_api_key.rs index 61f00721..d806c5ca 100644 --- a/src/handle_api_key.rs +++ b/src/handle_api_key.rs @@ -2,11 +2,11 @@ use std::collections::BTreeMap; use actix_web::http::header::ContentType; use actix_web::{post, web, HttpResponse}; -use chrono::{Duration, Utc}; +use chrono::Duration; use serde::Deserialize; use crate::database::Database; -use crate::error::{AppErrorKind, Response}; +use crate::error::Response; use crate::secret::{ApiKeySecret, BrowserSessionSecret}; use crate::utils::get_partial; @@ -36,7 +36,6 @@ async fn create( .await?; let mut data = BTreeMap::new(); data.insert("key", key.code().to_str_that_i_wont_print().to_string()); - data.insert("id", key.code().to_str_that_i_wont_print().to_string()); let page = get_partial::<()>("api_key", data, None)?; Ok(HttpResponse::Ok() .content_type(ContentType::html()) @@ -44,53 +43,33 @@ async fn create( } #[derive(Deserialize)] -struct IdPath { - id: String, +struct KeyForm { + key: String, } -#[post("/api_key/{id}/delete")] +#[post("/api_key/delete")] async fn delete( browser_session: BrowserSessionSecret, - path: web::Path, + form: web::Form, db: web::Data, ) -> Response { - let key = ApiKeySecret::try_from_string(path.id.clone(), db.get_ref()).await?; - if key.user() != browser_session.user() { - return Err(AppErrorKind::Unauthorized.into()); - } - key.delete(db.get_ref()).await?; + ApiKeySecret::delete_with_user(form.key.clone(), browser_session.user(), db.get_ref()).await?; Ok(HttpResponse::Found() .append_header(("Location", "/")) .finish()) } -#[post("/api_key/{id}/rotate")] +#[post("/api_key/rotate")] async fn rotate( browser_session: BrowserSessionSecret, - path: web::Path, - form: web::Form, + form: web::Form, db: web::Data, ) -> Response { - let old = ApiKeySecret::try_from_string(path.id.clone(), db.get_ref()).await?; - if old.user() != browser_session.user() { - return Err(AppErrorKind::Unauthorized.into()); - } - let remaining = if old.expires_at() == chrono::NaiveDateTime::MAX { - chrono::Duration::seconds(0) - } else { - old.expires_at() - Utc::now().naive_utc() - }; - let new_key = ApiKeySecret::new_with_expiration( - browser_session.user().clone(), - form.service.clone(), - remaining, - db.get_ref(), - ) - .await?; - old.delete(db.get_ref()).await?; + let new_key = + ApiKeySecret::rotate_with_user(form.key.clone(), browser_session.user(), db.get_ref()) + .await?; let mut data = BTreeMap::new(); data.insert("key", new_key.code().to_str_that_i_wont_print().to_string()); - data.insert("id", new_key.code().to_str_that_i_wont_print().to_string()); let page = get_partial::<()>("api_key", data, None)?; Ok(HttpResponse::Ok() .content_type(ContentType::html()) diff --git a/src/handle_index.rs b/src/handle_index.rs index 957223d4..bb60a703 100644 --- a/src/handle_index.rs +++ b/src/handle_index.rs @@ -29,6 +29,10 @@ async fn index(browser_session: BrowserSessionSecret, db: web::Data) - let mut index_data = BTreeMap::new(); index_data.insert("email", browser_session.user().email.clone()); let realmed_services = config.services.from_user(&browser_session.user()); + let auth_email_header = config.auth_url_email_header.clone(); + let auth_user_header = config.auth_url_user_header.clone(); + let auth_name_header = config.auth_url_name_header.clone(); + let auth_realms_header = config.auth_url_realms_header.clone(); drop(config); let mut services_with_keys = Vec::new(); @@ -45,19 +49,19 @@ async fn index(browser_session: BrowserSessionSecret, db: web::Data) - // Respond with the index page and set the X-Remote headers as configured Ok(HttpResponse::Ok() .append_header(( - config.auth_url_email_header.as_str(), + auth_email_header.as_str(), browser_session.user().email.clone(), )) .append_header(( - config.auth_url_user_header.as_str(), + auth_user_header.as_str(), browser_session.user().username.clone(), )) .append_header(( - config.auth_url_name_header.as_str(), + auth_name_header.as_str(), browser_session.user().name.clone(), )) .append_header(( - config.auth_url_realms_header.as_str(), + auth_realms_header.as_str(), browser_session.user().realms.join(","), )) .content_type(ContentType::html()) diff --git a/src/secret/api_key.rs b/src/secret/api_key.rs index b4312245..4f0a85e3 100644 --- a/src/secret/api_key.rs +++ b/src/secret/api_key.rs @@ -6,7 +6,7 @@ use crate::error::{AppErrorKind, Result}; use crate::user::User; use crate::{CONFIG, PROXY_ORIGIN_HEADER}; -use super::primitive::{InternalUserSecret, UserSecret, UserSecretKind}; +use super::primitive::{UserSecret, UserSecretKind}; use super::{MetadataKind, SecretString}; use crate::database::{Database, UserSecretRow}; @@ -64,14 +64,20 @@ impl ApiKeySecret { .checked_add_signed(final_duration) .ok_or(AppErrorKind::InvalidDuration)? }; - let internal = InternalUserSecret { - code: SecretString::new(ApiKeySecretKind::PREFIX), - user, + let code = SecretString::new(ApiKeySecretKind::PREFIX); + let metadata = ApiKeyMetadata { service }; + let user_str = serde_json::to_string(&user)?; + let metadata_str = serde_json::to_string(&metadata)?; + let row = UserSecretRow { + id: code.to_str_that_i_wont_print().to_string(), + secret_type: ApiKeySecretKind::PREFIX.to_string(), + user_data: user_str, expires_at, - metadata: ApiKeyMetadata { service }, + metadata: metadata_str, + created_at: None, }; - internal.save(db).await?; - Ok(Self(internal)) + row.save(db).await?; + ApiKeySecret::try_from_string(row.id, db).await } /// List API keys for a user and service. @@ -91,14 +97,20 @@ impl ApiKeySecret { if meta.service != service { continue; } - let display = format!("{}…", &row.id[..row.id.len().min(8)]); + let prefix = crate::secret::get_prefix(ApiKeySecretKind::PREFIX); + let bare = row.id.strip_prefix(&prefix).unwrap_or(&row.id); + let display = if bare.len() <= 8 { + bare.to_string() + } else { + format!("{}…{}", &bare[..4], &bare[bare.len().saturating_sub(4)..],) + }; let expires_at = if row.expires_at == NaiveDateTime::MAX { None } else { Some(row.expires_at) }; keys.push(ApiKeyInfo { - id: row.id, + key: row.id, display, expires_at, }); @@ -108,14 +120,41 @@ impl ApiKeySecret { /// Get the associated service name. pub fn service(&self) -> &str { - &self.0.metadata.service + &self.metadata().service + } + + /// Rotate an API key if it belongs to `user`. + pub async fn rotate_with_user(code: String, user: &User, db: &Database) -> Result { + let old = Self::try_from_string(code, db).await?; + if old.user() != user { + return Err(AppErrorKind::Unauthorized.into()); + } + let remaining = if old.expires_at() == NaiveDateTime::MAX { + Duration::seconds(0) + } else { + old.expires_at() - Utc::now().naive_utc() + }; + let new_key = + Self::new_with_expiration(user.clone(), old.service().to_string(), remaining, db) + .await?; + old.delete(db).await?; + Ok(new_key) + } + + /// Delete an API key if it belongs to `user`. + pub async fn delete_with_user(code: String, user: &User, db: &Database) -> Result<()> { + let key = Self::try_from_string(code, db).await?; + if key.user() != user { + return Err(AppErrorKind::Unauthorized.into()); + } + key.delete(db).await } } /// Public representation of an API key for listing purposes. #[derive(Debug, Clone, Serialize)] pub struct ApiKeyInfo { - pub id: String, + pub key: String, pub display: String, pub expires_at: Option, } @@ -174,25 +213,16 @@ mod tests { let id = key.code().to_str_that_i_wont_print().to_string(); let list = ApiKeySecret::list(&user, "example", &db).await.unwrap(); assert_eq!(list.len(), 1); - let fetched = ApiKeySecret::try_from_string(id.clone(), &db) + let rotated = ApiKeySecret::rotate_with_user(id.clone(), &user, &db) .await .unwrap(); - let new_key = ApiKeySecret::new_with_expiration( - user.clone(), - "example".to_string(), - Duration::try_seconds(60).unwrap(), + ApiKeySecret::delete_with_user( + rotated.code().to_str_that_i_wont_print().to_string(), + &user, &db, ) .await .unwrap(); - fetched.delete(&db).await.unwrap(); - let new_id = new_key.code().to_str_that_i_wont_print().to_string(); - let list = ApiKeySecret::list(&user, "example", &db).await.unwrap(); - assert_eq!(list.len(), 1); - let fetched_new = ApiKeySecret::try_from_string(new_id.clone(), &db) - .await - .unwrap(); - fetched_new.delete(&db).await.unwrap(); let list = ApiKeySecret::list(&user, "example", &db).await.unwrap(); assert!(list.is_empty()); } diff --git a/static/templates/index.html.hbs b/static/templates/index.html.hbs index 4052fb56..2a9def2e 100644 --- a/static/templates/index.html.hbs +++ b/static/templates/index.html.hbs @@ -51,11 +51,12 @@ {{#each api_keys}}
  • {{this.display}} - {{#if this.expires_at}}{{this.expires_at}}{{else}}never{{/if}} -
    - + +
    -
    + +
  • From 40d880f2221312428f18640c9a58468d8d53bc89 Mon Sep 17 00:00:00 2001 From: Dimitris Zervas Date: Wed, 27 Aug 2025 14:36:22 +0300 Subject: [PATCH 3/3] Fix e2e tests for api key --- hurl/api_key.hurl | 8 ++------ static/templates/api_key.html.hbs | 10 +++++----- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/hurl/api_key.hurl b/hurl/api_key.hurl index be6d2505..5fbf7aac 100644 --- a/hurl/api_key.hurl +++ b/hurl/api_key.hurl @@ -28,11 +28,10 @@ service: example expiration: 60 HTTP 200 [Captures] -created_key: xpath "//code[@id='api-key']/text()" +created_key: xpath "string(//code[@id='api-key'])" # Use created key GET http://localhost:8080/auth-url/status -[Headers] X-Original-URL: http://localhost:8081/ X-Api-Key: {{created_key}} HTTP 200 @@ -45,18 +44,16 @@ session_id: {{session}} key: {{created_key}} HTTP 200 [Captures] -rotated_key: xpath "//code[@id='api-key']/text()" +rotated_key: xpath "string(//code[@id='api-key'])" # Old key should fail GET http://localhost:8080/auth-url/status -[Headers] X-Original-URL: http://localhost:8081/ X-Api-Key: {{created_key}} HTTP 401 # New key should succeed GET http://localhost:8080/auth-url/status -[Headers] X-Original-URL: http://localhost:8081/ X-Api-Key: {{rotated_key}} HTTP 200 @@ -72,7 +69,6 @@ Location: / # Deleted key should fail GET http://localhost:8080/auth-url/status -[Headers] X-Original-URL: http://localhost:8081/ X-Api-Key: {{rotated_key}} HTTP 401 diff --git a/static/templates/api_key.html.hbs b/static/templates/api_key.html.hbs index e47c31de..3a20f2db 100644 --- a/static/templates/api_key.html.hbs +++ b/static/templates/api_key.html.hbs @@ -1,9 +1,9 @@ {{> header }}
    -
    -

    Store this API key now. You won't be able to see it again.

    - {{data.key}} - Back -
    +
    +

    Store this API key now. You won't be able to see it again.

    + {{data.key}} + Back +
    {{> footer }}