From 00f5d3adeae18d95d269014e7e5ed3314e7c18e5 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sat, 1 Nov 2025 21:42:52 +0100 Subject: [PATCH] api-tokens --- .../202511010000_api_tokens/down.sql | 31 ++ .../migrations/202511010000_api_tokens/up.sql | 28 ++ backend/src/auth/api_tokens.rs | 339 ++++++++++++++++++ backend/src/auth/mod.rs | 2 +- backend/src/auth/webdav_tokens.rs | 240 ------------- backend/src/models.rs | 77 +++- backend/src/openapi.rs | 11 +- backend/src/routes/auth.rs | 75 +++- backend/src/routes/mod.rs | 14 +- backend/src/routes/profile.rs | 160 ++++++--- backend/src/routes/webdav/mod.rs | 48 +-- backend/src/schema.rs | 16 +- backend/src/tenants.rs | 10 +- backend/tests/api_tokens_flow.rs | 283 +++++++++++++++ backend/tests/common/mod.rs | 2 +- backend/tests/webdav_flow.rs | 219 ----------- 16 files changed, 999 insertions(+), 556 deletions(-) create mode 100644 backend/migrations/202511010000_api_tokens/down.sql create mode 100644 backend/migrations/202511010000_api_tokens/up.sql create mode 100644 backend/src/auth/api_tokens.rs delete mode 100644 backend/src/auth/webdav_tokens.rs create mode 100644 backend/tests/api_tokens_flow.rs delete mode 100644 backend/tests/webdav_flow.rs diff --git a/backend/migrations/202511010000_api_tokens/down.sql b/backend/migrations/202511010000_api_tokens/down.sql new file mode 100644 index 0000000..04636f2 --- /dev/null +++ b/backend/migrations/202511010000_api_tokens/down.sql @@ -0,0 +1,31 @@ +DROP POLICY IF EXISTS tenant_api_token_policy ON tenant.api_tokens; +DROP FUNCTION IF EXISTS shared.current_api_token_prefix(); + +ALTER TABLE tenant.api_tokens DISABLE ROW LEVEL SECURITY; +ALTER TABLE tenant.api_tokens NO FORCE ROW LEVEL SECURITY; + +ALTER TABLE tenant.api_tokens + DROP COLUMN IF EXISTS capabilities; + +DROP TYPE IF EXISTS shared.api_token_capability; + +ALTER TABLE tenant.api_tokens RENAME TO webdav_tokens; +ALTER INDEX tenant.api_tokens_token_prefix_key RENAME TO webdav_tokens_token_prefix_key; +ALTER INDEX tenant.api_tokens_user_tenant_idx RENAME TO webdav_tokens_user_tenant_idx; + +CREATE OR REPLACE FUNCTION shared.current_webdav_token_prefix() RETURNS text AS $$ + SELECT NULLIF(current_setting('papercrate.webdav_token_prefix', true), '') +$$ LANGUAGE SQL STABLE; + +ALTER TABLE tenant.webdav_tokens ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant.webdav_tokens FORCE ROW LEVEL SECURITY; + +CREATE POLICY tenant_webdav_token_policy ON tenant.webdav_tokens + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_webdav_token_prefix() IS NOT NULL + AND token_prefix = shared.current_webdav_token_prefix() + ) + ) + WITH CHECK (tenant_id = shared.current_tenant_id()); diff --git a/backend/migrations/202511010000_api_tokens/up.sql b/backend/migrations/202511010000_api_tokens/up.sql new file mode 100644 index 0000000..66d09fb --- /dev/null +++ b/backend/migrations/202511010000_api_tokens/up.sql @@ -0,0 +1,28 @@ +ALTER TABLE tenant.webdav_tokens RENAME TO api_tokens; +ALTER INDEX tenant.webdav_tokens_token_prefix_key RENAME TO api_tokens_token_prefix_key; +ALTER INDEX tenant.webdav_tokens_user_tenant_idx RENAME TO api_tokens_user_tenant_idx; + +DROP POLICY IF EXISTS tenant_webdav_token_policy ON tenant.api_tokens; +DROP FUNCTION IF EXISTS shared.current_webdav_token_prefix(); + +CREATE TYPE shared.api_token_capability AS ENUM ('api', 'webdav'); + +ALTER TABLE tenant.api_tokens + ADD COLUMN capabilities shared.api_token_capability[] NOT NULL DEFAULT ARRAY['webdav']::shared.api_token_capability[]; + +ALTER TABLE tenant.api_tokens ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant.api_tokens FORCE ROW LEVEL SECURITY; + +CREATE OR REPLACE FUNCTION shared.current_api_token_prefix() RETURNS text AS $$ + SELECT NULLIF(current_setting('papercrate.api_token_prefix', true), '') +$$ LANGUAGE SQL STABLE; + +CREATE POLICY tenant_api_token_policy ON tenant.api_tokens + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_api_token_prefix() IS NOT NULL + AND token_prefix = shared.current_api_token_prefix() + ) + ) + WITH CHECK (tenant_id = shared.current_tenant_id()); diff --git a/backend/src/auth/api_tokens.rs b/backend/src/auth/api_tokens.rs new file mode 100644 index 0000000..4de54dc --- /dev/null +++ b/backend/src/auth/api_tokens.rs @@ -0,0 +1,339 @@ +use argon2::{ + password_hash::{PasswordHasher, SaltString}, + Argon2, +}; +use chrono::{NaiveDateTime, Utc}; +use diesel::prelude::*; +use rand::rngs::OsRng; +use rand::RngCore; +use uuid::Uuid; + +use crate::{ + error::AppError, + models::{ApiToken, ApiTokenCapability, NewApiToken}, + schema::api_tokens, + state::PgPooledConnection, + tenants::{apply_api_token_prefix, clear_api_token_prefix}, +}; + +use crate::schema::api_tokens::dsl as api_tokens_dsl; + +const TOKEN_PREFIX_LENGTH: usize = 12; +const TOKEN_SECRET_LENGTH: usize = 32; + +/// Represents a newly issued API token and the raw secret that was generated for it. +pub struct IssuedApiToken { + pub token: String, + pub record: ApiToken, +} + +/// Creates a new API token for the supplied user/tenant combination. +pub fn create_api_token( + conn: &mut PgPooledConnection, + user_id: Uuid, + tenant_id: Uuid, + label: Option, + expires_at: Option, + capabilities: Vec, +) -> Result { + let capabilities = normalize_capabilities(capabilities)?; + + let raw_secret = generate_secret()?; + let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string(); + let token_hash = hash_secret(&raw_secret)?; + let new_token = NewApiToken { + id: Uuid::new_v4(), + user_id, + tenant_id, + token_prefix, + token_hash, + label, + expires_at, + capabilities, + }; + + let record = diesel::insert_into(api_tokens::table) + .values(&new_token) + .get_result::(conn)?; + + Ok(IssuedApiToken { + token: raw_secret, + record, + }) +} + +/// Lists API tokens belonging to a user within an optional tenant scope. +pub fn list_api_tokens( + conn: &mut PgPooledConnection, + user_id: Uuid, + tenant_id: Option, +) -> Result, AppError> { + let mut query = api_tokens::table + .filter(api_tokens::user_id.eq(user_id)) + .into_boxed(); + + if let Some(tenant_id) = tenant_id { + query = query.filter(api_tokens::tenant_id.eq(tenant_id)); + } + + let tokens = query + .order(api_tokens::created_at.asc()) + .load::(conn)?; + + Ok(tokens) +} + +/// Regenerates the secret value for an API token. +pub fn regenerate_api_token( + conn: &mut PgPooledConnection, + token_id: Uuid, + user_id: Uuid, + tenant_id: Option, +) -> Result { + let record = find_user_token(conn, token_id, user_id, tenant_id)?; + + if record.revoked_at.is_some() { + return Err(AppError::bad_request( + "cannot regenerate a revoked API token", + )); + } + + let raw_secret = generate_secret()?; + let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string(); + let token_hash = hash_secret(&raw_secret)?; + + let updated = diesel::update(api_tokens::table.find(record.id)) + .set(( + api_tokens::token_prefix.eq(&token_prefix), + api_tokens::token_hash.eq(&token_hash), + api_tokens::last_used_at.eq::>(None), + )) + .get_result::(conn)?; + + Ok(IssuedApiToken { + token: raw_secret, + record: updated, + }) +} + +/// Updates the set of capabilities associated with an API token. +pub fn update_api_token_capabilities( + conn: &mut PgPooledConnection, + token_id: Uuid, + user_id: Uuid, + tenant_id: Option, + capabilities: Vec, +) -> Result { + let capabilities = normalize_capabilities(capabilities)?; + + let token = find_user_token(conn, token_id, user_id, tenant_id)?; + + if token.revoked_at.is_some() { + return Err(AppError::bad_request( + "cannot modify capabilities of a revoked API token", + )); + } + + let updated = diesel::update(api_tokens::table.find(token.id)) + .set(api_tokens::capabilities.eq(capabilities)) + .get_result::(conn)?; + + Ok(updated) +} + +/// Attempts to resolve an API token by its secret value while ensuring it provides the +/// requested capability. +pub fn find_active_token_by_secret( + conn: &mut PgPooledConnection, + tenant_id: Option, + secret: &str, + required_capability: ApiTokenCapability, +) -> Result, AppError> { + if secret.len() < TOKEN_PREFIX_LENGTH { + return Ok(None); + } + + let prefix = &secret[..TOKEN_PREFIX_LENGTH]; + let candidates = with_api_token_prefix(conn, prefix, |conn| { + let mut query = api_tokens::table + .filter(api_tokens::token_prefix.eq(prefix)) + .filter(api_tokens::revoked_at.is_null()) + .into_boxed(); + + let now = Utc::now().naive_utc(); + query = query.filter( + api_tokens::expires_at + .is_null() + .or(api_tokens::expires_at.gt(now)), + ); + + if let Some(tenant_id) = tenant_id { + query = query.filter(api_tokens::tenant_id.eq(tenant_id)); + } + + query.load::(conn).map_err(AppError::from) + })?; + + for token in candidates { + if !token.capabilities.contains(&required_capability) { + continue; + } + + if verify_token_secret(secret, &token.token_hash)? { + return Ok(Some(token)); + } + } + + Ok(None) +} + +/// Revokes an API token belonging to the specified user. +pub fn revoke_api_token( + conn: &mut PgPooledConnection, + token_id: Uuid, + user_id: Uuid, +) -> Result<(), AppError> { + let token = find_user_token(conn, token_id, user_id, None)?; + + diesel::update(api_tokens::table.find(token.id)) + .set(api_tokens::revoked_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + + Ok(()) +} + +/// Updates the last-used timestamp for a token. +pub fn touch_api_token(conn: &mut PgPooledConnection, token_id: Uuid) -> Result<(), AppError> { + diesel::update(api_tokens::table.filter(api_tokens::id.eq(token_id))) + .set(api_tokens::last_used_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + Ok(()) +} + +/// Verifies a secret against its stored hash representation. +pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result { + crate::auth::password::verify_password(secret, token_hash).map_err(|err| { + tracing::error!(error = ?err, "failed to verify token"); + AppError::internal("failed to verify token") + }) +} + +fn normalize_capabilities( + capabilities: Vec, +) -> Result, AppError> { + if capabilities.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } + + let mut unique = Vec::new(); + for capability in capabilities { + if !unique.contains(&capability) { + unique.push(capability); + } + } + + Ok(unique) +} + +fn find_user_token( + conn: &mut PgPooledConnection, + token_id: Uuid, + user_id: Uuid, + tenant_id: Option, +) -> Result { + let mut query = api_tokens_dsl::api_tokens + .filter(api_tokens_dsl::id.eq(token_id)) + .filter(api_tokens_dsl::user_id.eq(user_id)) + .into_boxed(); + + if let Some(tid) = tenant_id { + query = query.filter(api_tokens_dsl::tenant_id.eq(tid)); + } + + query + .first::(conn) + .optional() + .map_err(AppError::from)? + .ok_or_else(AppError::not_found) +} + +fn with_api_token_prefix( + conn: &mut PgPooledConnection, + prefix: &str, + operation: F, +) -> Result +where + F: FnOnce(&mut PgPooledConnection) -> Result, +{ + apply_api_token_prefix(conn, prefix)?; + let operation_result = operation(conn); + let clear_result = clear_api_token_prefix(conn); + + if let Err(err) = clear_result { + return Err(err); + } + + operation_result +} + +fn generate_secret() -> Result { + let mut buffer = [0u8; TOKEN_SECRET_LENGTH]; + OsRng.try_fill_bytes(&mut buffer).map_err(|err| { + tracing::error!(error = ?err, "failed to generate token"); + AppError::internal("failed to generate token") + })?; + Ok(hex::encode(buffer)) +} + +fn hash_secret(secret: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let hash = Argon2::default() + .hash_password(secret.as_bytes(), &salt) + .map_err(|err| { + tracing::error!(error = ?err, "failed to hash token"); + AppError::internal("failed to hash token") + })?; + Ok(hash.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_secret_has_expected_length() { + let secret = generate_secret().unwrap(); + assert_eq!(secret.len(), TOKEN_SECRET_LENGTH * 2); + } + + #[test] + fn hash_and_verify_secret_round_trip() { + let secret = generate_secret().unwrap(); + let hash = hash_secret(&secret).unwrap(); + assert!(verify_token_secret(&secret, &hash).unwrap()); + assert!(!verify_token_secret("wrong", &hash).unwrap()); + } + + #[test] + fn normalize_capabilities_deduplicates() { + let caps = normalize_capabilities(vec![ + ApiTokenCapability::Api, + ApiTokenCapability::Webdav, + ApiTokenCapability::Api, + ]) + .unwrap(); + assert_eq!(caps.len(), 2); + assert!(caps.contains(&ApiTokenCapability::Api)); + assert!(caps.contains(&ApiTokenCapability::Webdav)); + } + + #[test] + fn normalize_capabilities_rejects_empty() { + assert!(normalize_capabilities(Vec::new()).is_err()); + } + + #[test] + fn prefix_length_is_less_than_secret_length() { + assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2); + } +} diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs index c57dec8..15c2080 100644 --- a/backend/src/auth/mod.rs +++ b/backend/src/auth/mod.rs @@ -1,7 +1,7 @@ +pub mod api_tokens; pub mod jwt; pub mod passkeys; pub mod password; -pub mod webdav_tokens; use axum::{async_trait, extract::FromRequestParts, http::request::Parts}; use axum_extra::headers::{authorization::Bearer, Authorization}; diff --git a/backend/src/auth/webdav_tokens.rs b/backend/src/auth/webdav_tokens.rs deleted file mode 100644 index a72e58c..0000000 --- a/backend/src/auth/webdav_tokens.rs +++ /dev/null @@ -1,240 +0,0 @@ -use argon2::{ - password_hash::{PasswordHasher, SaltString}, - Argon2, -}; -use chrono::{NaiveDateTime, Utc}; -use diesel::prelude::*; -use rand::rngs::OsRng; -use rand::RngCore; -use uuid::Uuid; - -use crate::{ - error::AppError, - models::{NewWebdavToken, WebdavToken}, - schema::webdav_tokens, - state::PgPooledConnection, - tenants::{apply_webdav_token_prefix, clear_webdav_token_prefix}, -}; - -const TOKEN_PREFIX_LENGTH: usize = 12; -const TOKEN_SECRET_LENGTH: usize = 32; - -pub struct IssuedWebdavToken { - pub token: String, - pub record: WebdavToken, -} - -pub fn create_webdav_token( - conn: &mut PgPooledConnection, - user_id: Uuid, - tenant_id: Uuid, - label: Option, - expires_at: Option, -) -> Result { - let raw_secret = generate_secret()?; - let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string(); - let token_hash = hash_secret(&raw_secret)?; - let new_token = NewWebdavToken { - id: Uuid::new_v4(), - user_id, - tenant_id, - token_prefix, - token_hash, - label, - expires_at, - }; - - let record = diesel::insert_into(webdav_tokens::table) - .values(&new_token) - .get_result::(conn)?; - - Ok(IssuedWebdavToken { - token: raw_secret, - record, - }) -} - -pub fn list_webdav_tokens( - conn: &mut PgPooledConnection, - user_id: Uuid, - tenant_id: Option, -) -> Result, AppError> { - let mut query = webdav_tokens::table - .filter(webdav_tokens::user_id.eq(user_id)) - .into_boxed(); - - if let Some(tenant_id) = tenant_id { - query = query.filter(webdav_tokens::tenant_id.eq(tenant_id)); - } - - let tokens = query - .order(webdav_tokens::created_at.asc()) - .load::(conn)?; - - Ok(tokens) -} - -pub fn regenerate_webdav_token( - conn: &mut PgPooledConnection, - token_id: Uuid, - user_id: Uuid, - tenant_id: Option, -) -> Result { - let mut query = webdav_tokens::table - .filter(webdav_tokens::id.eq(token_id)) - .filter(webdav_tokens::user_id.eq(user_id)) - .into_boxed(); - - if let Some(tenant) = tenant_id { - query = query.filter(webdav_tokens::tenant_id.eq(tenant)); - } - - let record = query - .first::(conn) - .optional() - .map_err(AppError::from)? - .ok_or_else(AppError::not_found)?; - - if record.revoked_at.is_some() { - return Err(AppError::bad_request( - "cannot regenerate a revoked WebDAV token", - )); - } - - let raw_secret = generate_secret()?; - let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string(); - let token_hash = hash_secret(&raw_secret)?; - - let updated = diesel::update(webdav_tokens::table.find(record.id)) - .set(( - webdav_tokens::token_prefix.eq(&token_prefix), - webdav_tokens::token_hash.eq(&token_hash), - webdav_tokens::last_used_at.eq::>(None), - )) - .get_result::(conn)?; - - Ok(IssuedWebdavToken { - token: raw_secret, - record: updated, - }) -} - -pub fn find_active_token_by_secret( - conn: &mut PgPooledConnection, - user_id: Uuid, - tenant_id: Option, - secret: &str, -) -> Result, AppError> { - if secret.len() < TOKEN_PREFIX_LENGTH { - return Ok(None); - } - - let prefix = &secret[..TOKEN_PREFIX_LENGTH]; - apply_webdav_token_prefix(conn, prefix)?; - let mut query = webdav_tokens::table - .filter(webdav_tokens::user_id.eq(user_id)) - .filter(webdav_tokens::token_prefix.eq(prefix)) - .filter(webdav_tokens::revoked_at.is_null()) - .into_boxed(); - - let now = Utc::now().naive_utc(); - query = query.filter( - webdav_tokens::expires_at - .is_null() - .or(webdav_tokens::expires_at.gt(now)), - ); - - if let Some(tenant_id) = tenant_id { - query = query.filter(webdav_tokens::tenant_id.eq(tenant_id)); - } - - let load_result = query.load::(conn); - let clear_result = clear_webdav_token_prefix(conn); - clear_result?; - let candidates = load_result?; - - for token in candidates { - if verify_token_secret(secret, &token.token_hash)? { - return Ok(Some(token)); - } - } - - Ok(None) -} - -pub fn revoke_webdav_token( - conn: &mut PgPooledConnection, - token_id: Uuid, - user_id: Uuid, -) -> Result<(), AppError> { - let affected = diesel::update( - webdav_tokens::table - .filter(webdav_tokens::id.eq(token_id)) - .filter(webdav_tokens::user_id.eq(user_id)), - ) - .set(webdav_tokens::revoked_at.eq(Utc::now().naive_utc())) - .execute(conn)?; - - if affected == 0 { - return Err(AppError::not_found()); - } - - Ok(()) -} - -pub fn touch_webdav_token(conn: &mut PgPooledConnection, token_id: Uuid) -> Result<(), AppError> { - diesel::update(webdav_tokens::table.filter(webdav_tokens::id.eq(token_id))) - .set(webdav_tokens::last_used_at.eq(Utc::now().naive_utc())) - .execute(conn)?; - Ok(()) -} - -pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result { - crate::auth::password::verify_password(secret, token_hash).map_err(|err| { - tracing::error!(error = ?err, "failed to verify token"); - AppError::internal("failed to verify token") - }) -} - -fn generate_secret() -> Result { - let mut buffer = [0u8; TOKEN_SECRET_LENGTH]; - OsRng.try_fill_bytes(&mut buffer).map_err(|err| { - tracing::error!(error = ?err, "failed to generate token"); - AppError::internal("failed to generate token") - })?; - Ok(hex::encode(buffer)) -} - -fn hash_secret(secret: &str) -> Result { - let salt = SaltString::generate(&mut OsRng); - let hash = Argon2::default() - .hash_password(secret.as_bytes(), &salt) - .map_err(|err| { - tracing::error!(error = ?err, "failed to hash token"); - AppError::internal("failed to hash token") - })?; - Ok(hash.to_string()) -} - -fn _ensure_constants() { - assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn generated_secret_has_expected_length() { - let secret = generate_secret().unwrap(); - assert_eq!(secret.len(), TOKEN_SECRET_LENGTH * 2); - } - - #[test] - fn hash_and_verify_secret_round_trip() { - let secret = generate_secret().unwrap(); - let hash = hash_secret(&secret).unwrap(); - assert!(verify_token_secret(&secret, &hash).unwrap()); - assert!(!verify_token_secret("wrong", &hash).unwrap()); - } -} diff --git a/backend/src/models.rs b/backend/src/models.rs index b0fb9c4..3512e8c 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -4,14 +4,18 @@ use diesel::pg::{Pg, PgValue}; use diesel::prelude::*; use diesel::serialize::{IsNull, Output, ToSql}; use diesel::{deserialize, serialize, AsExpression, FromSqlRow}; +use serde::{Deserialize, Serialize}; use serde_json::Value; use std::fmt; use std::io::Write; use std::str; use uuid::Uuid; +use utoipa::ToSchema; + use crate::schema::sql_types::{ - MagicTokenKind as MagicTokenKindSql, TenantStatus as TenantStatusSql, + ApiTokenCapability as ApiTokenCapabilitySql, MagicTokenKind as MagicTokenKindSql, + TenantStatus as TenantStatusSql, }; use crate::schema::*; @@ -52,6 +56,16 @@ pub enum MagicTokenKind { DemoLogin, } +#[derive( + Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow, Serialize, Deserialize, ToSchema, +)] +#[diesel(sql_type = ApiTokenCapabilitySql)] +#[serde(rename_all = "snake_case")] +pub enum ApiTokenCapability { + Api, + Webdav, +} + impl MagicTokenKind { pub fn as_str(&self) -> &'static str { match self { @@ -65,12 +79,31 @@ impl MagicTokenKind { } } +impl ApiTokenCapability { + pub fn as_str(&self) -> &'static str { + match self { + ApiTokenCapability::Api => "api", + ApiTokenCapability::Webdav => "webdav", + } + } + + pub fn variants() -> &'static [&'static str] { + &["api", "webdav"] + } +} + impl fmt::Display for MagicTokenKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.as_str()) } } +impl fmt::Display for ApiTokenCapability { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + impl ToSql for MagicTokenKind { fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { out.write_all(self.as_str().as_bytes())?; @@ -78,6 +111,13 @@ impl ToSql for MagicTokenKind { } } +impl ToSql for ApiTokenCapability { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + out.write_all(self.as_str().as_bytes())?; + Ok(IsNull::No) + } +} + impl FromSql for MagicTokenKind { fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { match std::str::from_utf8(bytes.as_bytes())? { @@ -91,6 +131,19 @@ impl FromSql for MagicTokenKind { } } +impl FromSql for ApiTokenCapability { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + match std::str::from_utf8(bytes.as_bytes())? { + "api" => Ok(ApiTokenCapability::Api), + "webdav" => Ok(ApiTokenCapability::Webdav), + other => Err(Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("invalid api_token_capability '{other}'"), + ))), + } + } +} + impl str::FromStr for MagicTokenKind { type Err = &'static str; @@ -103,6 +156,18 @@ impl str::FromStr for MagicTokenKind { } } +impl str::FromStr for ApiTokenCapability { + type Err = &'static str; + + fn from_str(value: &str) -> Result { + match value { + "api" => Ok(ApiTokenCapability::Api), + "webdav" => Ok(ApiTokenCapability::Webdav), + _ => Err("unsupported api token capability"), + } + } +} + impl TenantStatus { pub fn as_str(&self) -> &'static str { match self { @@ -243,10 +308,10 @@ pub struct NewWebauthnChallenge { } #[derive(Debug, Clone, Queryable, Identifiable, Associations)] -#[diesel(table_name = webdav_tokens)] +#[diesel(table_name = api_tokens)] #[diesel(belongs_to(User))] #[diesel(belongs_to(Tenant))] -pub struct WebdavToken { +pub struct ApiToken { pub id: Uuid, pub user_id: Uuid, pub tenant_id: Uuid, @@ -257,11 +322,12 @@ pub struct WebdavToken { pub last_used_at: Option, pub expires_at: Option, pub revoked_at: Option, + pub capabilities: Vec, } #[derive(Debug, Insertable)] -#[diesel(table_name = webdav_tokens)] -pub struct NewWebdavToken { +#[diesel(table_name = api_tokens)] +pub struct NewApiToken { pub id: Uuid, pub user_id: Uuid, pub tenant_id: Uuid, @@ -269,6 +335,7 @@ pub struct NewWebdavToken { pub token_hash: String, pub label: Option, pub expires_at: Option, + pub capabilities: Vec, } #[derive(Debug, Clone, Queryable, Identifiable)] diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs index 24f3525..5ae4b49 100644 --- a/backend/src/openapi.rs +++ b/backend/src/openapi.rs @@ -68,10 +68,11 @@ pub mod schemas { DocumentVersionDetailResponse, DocumentVersionResponse, }; pub use crate::documents::correspondents::DocumentCorrespondentResponse; + pub use crate::models::ApiTokenCapability; pub use crate::routes::auth::{ - LoginRequest, LoginResponse, LoginResponseVariants, SignupFinishRequest, - SignupStartRequest, SignupStartResponse, TenantListResponse, TenantSelectionRequest, - TenantSelectionResponse, TenantSnippet, + ApiTokenExchangeRequest, LoginRequest, LoginResponse, LoginResponseVariants, + SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse, + TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, }; pub use crate::routes::correspondents::{ CorrespondentSummary, CorrespondentUsage, CreateCorrespondentRequest, @@ -91,8 +92,8 @@ pub mod schemas { FolderInfo, FolderResponse, UpdateFolderRequest, }; pub use crate::routes::profile::{ - CreateWebdavTokenRequest, RevokePasskeyQuery, WebdavTokenCreatedResponse, - WebdavTokenResponse, + ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, RevokePasskeyQuery, + UpdateApiTokenCapabilitiesRequest, }; pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest}; } diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 9eb34b4..29f6b7b 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -18,6 +18,7 @@ use uuid::Uuid; use crate::{ auth::{ + api_tokens::{find_active_token_by_secret, touch_api_token}, passkeys::{ AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse, @@ -26,7 +27,8 @@ use crate::{ }, error::{AppError, AppResult}, models::{ - MagicToken, MagicTokenKind, NewRefreshToken, NewUser, RefreshToken, TenantStatus, User, + ApiTokenCapability, MagicToken, MagicTokenKind, NewRefreshToken, NewUser, RefreshToken, + TenantStatus, User, }, schema::{ magic_tokens::dsl as magic_dsl, refresh_tokens, tenants::dsl as tenant_dsl, @@ -59,6 +61,11 @@ pub struct LoginRequest { pub preferred_tenant_id: Option, } +#[derive(Debug, Deserialize, ToSchema)] +pub struct ApiTokenExchangeRequest { + pub api_token: String, +} + #[derive(Deserialize, Serialize, ToSchema)] pub struct LoginResponse { pub access_token: String, @@ -120,6 +127,7 @@ pub enum LoginResponseVariants { #[openapi( paths( login, + api_token_exchange, signup_start, signup_finish, refresh, @@ -134,6 +142,7 @@ pub enum LoginResponseVariants { ), components(schemas( LoginRequest, + ApiTokenExchangeRequest, SignupStartRequest, SignupStartResponse, SignupFinishRequest, @@ -150,6 +159,7 @@ pub enum LoginResponseVariants { crate::auth::passkeys::PasskeyRegistrationFinishPayload, crate::auth::passkeys::PasskeyLoginStartPayload, crate::auth::passkeys::PasskeyLoginFinishPayload, + crate::models::ApiTokenCapability, )) )] pub struct AuthApiDoc; @@ -201,6 +211,69 @@ pub async fn login( ) } +#[utoipa::path( + post, + path = "/api/auth/exchange-api-token", + request_body = ApiTokenExchangeRequest, + responses((status = 200, description = "Access token issued", body = LoginResponse)), + tag = "Auth" +)] +pub async fn api_token_exchange( + State(state): State, + Json(payload): Json, +) -> AppResult> { + let secret = payload.api_token.trim(); + if secret.is_empty() { + return Err(AppError::bad_request("api_token must not be empty")); + } + + let mut conn = state.db_unscoped()?; + + let token = find_active_token_by_secret(&mut conn, None, secret, ApiTokenCapability::Api)? + .ok_or_else(AppError::unauthorized)?; + + let user: User = dsl::users.find(token.user_id).first(&mut conn)?; + + apply_user_guc(&mut conn, user.id)?; + let membership = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.id)) + .filter(memberships_dsl::tenant_id.eq(token.tenant_id)) + .select(memberships_dsl::tenant_id) + .first::(&mut conn) + .optional()?; + clear_user_guc(&mut conn)?; + + if membership.is_none() { + return Err(AppError::unauthorized()); + } + + apply_tenant_guc(&mut conn, token.tenant_id)?; + touch_api_token(&mut conn, token.id)?; + + let access_token = state + .jwt + .generate_token(user.id, token.tenant_id, &user.username) + .map_err(AppError::from)?; + + let tenant_name: String = tenant_dsl::tenants + .find(token.tenant_id) + .select(tenant_dsl::name) + .first(&mut conn) + .map_err(AppError::from)?; + + let response = LoginResponse { + access_token, + token_type: "Bearer".to_string(), + expires_in: state.config.jwt_expiry_minutes * 60, + tenant: TenantSnippet { + id: token.tenant_id, + name: tenant_name, + }, + }; + + Ok(Json(response)) +} + #[utoipa::path( post, path = "/api/auth/signup/start", diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 081540f..4841ec9 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -57,6 +57,7 @@ pub fn create_router(state: AppState) -> Router<()> { .route("/signup/start", post(auth::signup_start)) .route("/signup/finish", post(auth::signup_finish)) .route("/login", post(auth::login)) + .route("/exchange-api-token", post(auth::api_token_exchange)) .route("/refresh", post(auth::refresh)) .route("/logout", post(auth::logout)) .route("/select-tenant", post(auth::select_tenant)) @@ -145,14 +146,17 @@ pub fn create_router(state: AppState) -> Router<()> { let profile_routes = Router::new() .route( - "/webdav-tokens", - get(profile::list_webdav_tokens).post(profile::create_webdav_token), + "/api-tokens", + get(profile::list_api_tokens).post(profile::create_api_token), ) .route( - "/webdav-tokens/:id/regenerate", - post(profile::regenerate_webdav_token), + "/api-tokens/:id/regenerate", + post(profile::regenerate_api_token), + ) + .route( + "/api-tokens/:id", + patch(profile::update_api_token).delete(profile::delete_api_token), ) - .route("/webdav-tokens/:id", delete(profile::delete_webdav_token)) .route("/passkeys", get(profile::list_passkeys)) .route("/passkeys/:id", delete(profile::delete_passkey)); diff --git a/backend/src/routes/profile.rs b/backend/src/routes/profile.rs index 576e467..a99a2b7 100644 --- a/backend/src/routes/profile.rs +++ b/backend/src/routes/profile.rs @@ -9,24 +9,26 @@ use utoipa::ToSchema; use uuid::Uuid; use crate::auth::{ - passkeys::PasskeySummary, - webdav_tokens::{ - create_webdav_token as issue_token, list_webdav_tokens as load_tokens, - regenerate_webdav_token as rotate_token, revoke_webdav_token as revoke_token, + api_tokens::{ + create_api_token as issue_token, list_api_tokens as load_tokens, + regenerate_api_token as rotate_token, revoke_api_token as revoke_token, + update_api_token_capabilities as update_capabilities, }, + passkeys::PasskeySummary, TenantScopedConn, }; use crate::error::{AppError, AppResult}; -use crate::models::WebdavToken; +use crate::models::{ApiToken, ApiTokenCapability}; use crate::state::AppState; use crate::utils::{db::no_content, time::to_iso}; #[derive(Debug, Serialize, ToSchema)] -pub struct WebdavTokenResponse { +pub struct ApiTokenResponse { pub id: Uuid, pub tenant_id: Uuid, #[schema(nullable)] pub label: Option, + pub capabilities: Vec, pub created_at: String, #[schema(nullable)] pub last_used_at: Option, @@ -37,17 +39,25 @@ pub struct WebdavTokenResponse { } #[derive(Debug, Serialize, ToSchema)] -pub struct WebdavTokenCreatedResponse { +pub struct ApiTokenCreatedResponse { pub token: String, - pub token_info: WebdavTokenResponse, + pub token_info: ApiTokenResponse, } #[derive(Debug, Deserialize, ToSchema)] -pub struct CreateWebdavTokenRequest { +pub struct CreateApiTokenRequest { #[schema(nullable)] pub label: Option, #[schema(nullable)] pub expires_at: Option, + #[serde(default)] + #[schema(nullable)] + pub capabilities: Option>, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateApiTokenCapabilitiesRequest { + pub capabilities: Vec, } #[derive(Debug, Deserialize, ToSchema)] @@ -80,55 +90,60 @@ pub async fn list_passkeys( #[utoipa::path( get, - path = "/api/profile/webdav-tokens", - responses((status = 200, description = "List WebDAV tokens", body = [WebdavTokenResponse])), + path = "/api/profile/api-tokens", + responses((status = 200, description = "List API tokens", body = [ApiTokenResponse])), tag = "Profile" )] -pub async fn list_webdav_tokens( +pub async fn list_api_tokens( TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, -) -> AppResult>> { +) -> AppResult>> { let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?; - let responses = tokens.into_iter().map(webdav_token_to_response).collect(); + let responses = tokens.into_iter().map(api_token_to_response).collect(); Ok(Json(responses)) } #[utoipa::path( post, - path = "/api/profile/webdav-tokens", - request_body = CreateWebdavTokenRequest, - responses((status = 201, description = "WebDAV token created", body = WebdavTokenCreatedResponse)), + path = "/api/profile/api-tokens", + request_body = CreateApiTokenRequest, + responses((status = 201, description = "API token created", body = ApiTokenCreatedResponse)), tag = "Profile" )] -pub async fn create_webdav_token( +pub async fn create_api_token( TenantScopedConn { mut conn, tenant_id, user_id, .. }: TenantScopedConn, - Json(payload): Json, -) -> AppResult<(StatusCode, Json)> { + Json(payload): Json, +) -> AppResult<(StatusCode, Json)> { let expires_at = match payload.expires_at { Some(ref value) => Some(parse_timestamp(value)?), None => None, }; + let capabilities = payload + .capabilities + .unwrap_or_else(|| vec![ApiTokenCapability::Webdav]); + let issued = issue_token( &mut conn, user_id, tenant_id, payload.label.clone(), expires_at, + capabilities, )?; - let response = WebdavTokenCreatedResponse { + let response = ApiTokenCreatedResponse { token: issued.token, - token_info: webdav_token_to_response(issued.record), + token_info: api_token_to_response(issued.record), }; Ok((StatusCode::CREATED, Json(response))) @@ -136,12 +151,12 @@ pub async fn create_webdav_token( #[utoipa::path( post, - path = "/api/profile/webdav-tokens/{id}/regenerate", - params(("id" = Uuid, Path, description = "WebDAV token ID")), - responses((status = 200, description = "WebDAV token regenerated", body = WebdavTokenCreatedResponse)), + path = "/api/profile/api-tokens/{id}/regenerate", + params(("id" = Uuid, Path, description = "API token ID")), + responses((status = 200, description = "API token regenerated", body = ApiTokenCreatedResponse)), tag = "Profile" )] -pub async fn regenerate_webdav_token( +pub async fn regenerate_api_token( TenantScopedConn { mut conn, tenant_id, @@ -149,24 +164,53 @@ pub async fn regenerate_webdav_token( .. }: TenantScopedConn, Path(token_id): Path, -) -> AppResult> { +) -> AppResult> { let issued = rotate_token(&mut conn, token_id, user_id, Some(tenant_id))?; - let response = WebdavTokenCreatedResponse { + let response = ApiTokenCreatedResponse { token: issued.token, - token_info: webdav_token_to_response(issued.record), + token_info: api_token_to_response(issued.record), }; Ok(Json(response)) } #[utoipa::path( - delete, - path = "/api/profile/webdav-tokens/{id}", - params(("id" = Uuid, Path, description = "WebDAV token ID")), - responses((status = 204, description = "WebDAV token revoked")), + patch, + path = "/api/profile/api-tokens/{id}", + params(("id" = Uuid, Path, description = "API token ID")), + request_body = UpdateApiTokenCapabilitiesRequest, + responses((status = 200, description = "API token updated", body = ApiTokenResponse)), tag = "Profile" )] -pub async fn delete_webdav_token( +pub async fn update_api_token( + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, + Path(token_id): Path, + Json(payload): Json, +) -> AppResult> { + let updated = update_capabilities( + &mut conn, + token_id, + user_id, + Some(tenant_id), + payload.capabilities, + )?; + + Ok(Json(api_token_to_response(updated))) +} + +#[utoipa::path( + delete, + path = "/api/profile/api-tokens/{id}", + params(("id" = Uuid, Path, description = "API token ID")), + responses((status = 204, description = "API token revoked")), + tag = "Profile" +)] +pub async fn delete_api_token( TenantScopedConn { mut conn, user_id, .. }: TenantScopedConn, @@ -210,15 +254,28 @@ pub async fn delete_passkey( no_content() } -fn webdav_token_to_response(token: WebdavToken) -> WebdavTokenResponse { - WebdavTokenResponse { - id: token.id, - tenant_id: token.tenant_id, - label: token.label, - created_at: to_iso(token.created_at), - last_used_at: token.last_used_at.map(to_iso), - expires_at: token.expires_at.map(to_iso), - revoked_at: token.revoked_at.map(to_iso), +fn api_token_to_response(token: ApiToken) -> ApiTokenResponse { + let ApiToken { + id, + tenant_id, + label, + created_at, + last_used_at, + expires_at, + revoked_at, + capabilities, + .. + } = token; + + ApiTokenResponse { + id, + tenant_id, + label, + capabilities, + created_at: to_iso(created_at), + last_used_at: last_used_at.map(to_iso), + expires_at: expires_at.map(to_iso), + revoked_at: revoked_at.map(to_iso), } } @@ -231,17 +288,20 @@ fn parse_timestamp(value: &str) -> AppResult { #[derive(utoipa::OpenApi)] #[openapi( paths( - crate::routes::profile::list_webdav_tokens, - crate::routes::profile::create_webdav_token, - crate::routes::profile::regenerate_webdav_token, - crate::routes::profile::delete_webdav_token, + crate::routes::profile::list_api_tokens, + crate::routes::profile::create_api_token, + crate::routes::profile::regenerate_api_token, + crate::routes::profile::update_api_token, + crate::routes::profile::delete_api_token, crate::routes::profile::list_passkeys, crate::routes::profile::delete_passkey ), components(schemas( - crate::routes::profile::WebdavTokenResponse, - crate::routes::profile::WebdavTokenCreatedResponse, - crate::routes::profile::CreateWebdavTokenRequest, + crate::models::ApiTokenCapability, + crate::routes::profile::ApiTokenResponse, + crate::routes::profile::ApiTokenCreatedResponse, + crate::routes::profile::CreateApiTokenRequest, + crate::routes::profile::UpdateApiTokenCapabilitiesRequest, crate::routes::profile::RevokePasskeyQuery, crate::auth::passkeys::PasskeySummary )) diff --git a/backend/src/routes/webdav/mod.rs b/backend/src/routes/webdav/mod.rs index 5255a3e..8f8e56e 100644 --- a/backend/src/routes/webdav/mod.rs +++ b/backend/src/routes/webdav/mod.rs @@ -16,9 +16,9 @@ use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event}; use quick_xml::Writer; use uuid::Uuid; -use crate::auth::webdav_tokens::{find_active_token_by_secret, touch_webdav_token}; +use crate::auth::api_tokens::{find_active_token_by_secret, touch_api_token}; use crate::error::{AppError, AppResult}; -use crate::models::{Document, DocumentVersion, Folder, User}; +use crate::models::{ApiTokenCapability, Document, DocumentVersion, Folder, User}; use crate::schema::{ document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl, folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl, @@ -425,34 +425,40 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result (username, secret), _ => return Ok(None), }; - tracing::debug!(%username, "attempting webdav login"); + tracing::debug!(presented_username = %presented_username, "attempting webdav login"); let mut conn = state.db_unscoped()?; - let user: User = match users_dsl::users - .filter(users_dsl::username.eq(username)) - .first(&mut conn) - { + let token = match find_active_token_by_secret( + &mut conn, + None, + secret, + ApiTokenCapability::Webdav, + )? { + Some(token) => token, + None => { + tracing::warn!(presented_username = %presented_username, "webdav token invalid or expired"); + return Ok(None); + } + }; + + let user: User = match users_dsl::users.find(token.user_id).first(&mut conn) { Ok(user) => user, Err(diesel::result::Error::NotFound) => { - tracing::warn!(%username, "webdav user not found"); + tracing::warn!( + presented_username = %presented_username, + user_id = %token.user_id, + "webdav token user missing" + ); return Ok(None); } Err(err) => return Err(AppError::from(err)), }; - let token = match find_active_token_by_secret(&mut conn, user.id, None, secret)? { - Some(token) => token, - None => { - tracing::warn!(%username, "webdav token invalid or expired"); - return Ok(None); - } - }; - apply_user_guc(&mut conn, user.id)?; let membership_exists = memberships_dsl::user_memberships @@ -468,7 +474,8 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result id, None => { tracing::warn!( - %username, + presented_username = %presented_username, + username = %user.username, tenant_id = %token.tenant_id, "webdav token tenant membership missing" ); @@ -477,10 +484,11 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result Uuid, user_id -> Uuid, tenant_id -> Uuid, @@ -257,6 +264,7 @@ diesel::table! { last_used_at -> Nullable, expires_at -> Nullable, revoked_at -> Nullable, + capabilities -> Array, } } @@ -285,8 +293,8 @@ diesel::joinable!(user_memberships -> tenants (tenant_id)); diesel::joinable!(user_memberships -> users (user_id)); diesel::joinable!(user_passkeys -> users (user_id)); diesel::joinable!(webauthn_challenges -> users (user_id)); -diesel::joinable!(webdav_tokens -> tenants (tenant_id)); -diesel::joinable!(webdav_tokens -> users (user_id)); +diesel::joinable!(api_tokens -> tenants (tenant_id)); +diesel::joinable!(api_tokens -> users (user_id)); diesel::allow_tables_to_appear_in_same_query!( correspondents, @@ -306,5 +314,5 @@ diesel::allow_tables_to_appear_in_same_query!( user_passkeys, users, webauthn_challenges, - webdav_tokens, + api_tokens, ); diff --git a/backend/src/tenants.rs b/backend/src/tenants.rs index 97dccba..08e60a4 100644 --- a/backend/src/tenants.rs +++ b/backend/src/tenants.rs @@ -149,7 +149,7 @@ pub fn clear_tenant_context(conn: &mut PgConnection) -> AppResult<()> { set_config('papercrate.tenant_id', '', false), \ set_config('papercrate.user_id', '', false), \ set_config('papercrate.refresh_token_hash', '', false), \ - set_config('papercrate.webdav_token_prefix', '', false)", + set_config('papercrate.api_token_prefix', '', false)", ) .execute(conn) .map(|_| ()) @@ -178,16 +178,16 @@ pub fn clear_refresh_token_hash(conn: &mut PgConnection) -> AppResult<()> { .map_err(AppError::from) } -pub fn apply_webdav_token_prefix(conn: &mut PgConnection, prefix: &str) -> AppResult<()> { - diesel::sql_query("SELECT set_config('papercrate.webdav_token_prefix', $1, false)") +pub fn apply_api_token_prefix(conn: &mut PgConnection, prefix: &str) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.api_token_prefix', $1, false)") .bind::(prefix) .execute(conn) .map(|_| ()) .map_err(AppError::from) } -pub fn clear_webdav_token_prefix(conn: &mut PgConnection) -> AppResult<()> { - diesel::sql_query("SELECT set_config('papercrate.webdav_token_prefix', '', false)") +pub fn clear_api_token_prefix(conn: &mut PgConnection) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.api_token_prefix', '', false)") .execute(conn) .map(|_| ()) .map_err(AppError::from) diff --git a/backend/tests/api_tokens_flow.rs b/backend/tests/api_tokens_flow.rs new file mode 100644 index 0000000..58f9515 --- /dev/null +++ b/backend/tests/api_tokens_flow.rs @@ -0,0 +1,283 @@ +mod common; + +use anyhow::Result; +use axum::body::Body; +use axum::http::{header, Method, Request, StatusCode}; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use common::{acquire_db_lock, body_to_vec, TestApp}; +use diesel::prelude::*; +use papercrate::models::{ApiToken, ApiTokenCapability}; +use papercrate::routes::webdav; +use papercrate::schema::api_tokens; +use serde::Deserialize; +use serde_json::json; +use tower::ServiceExt; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +struct TokenInfo { + id: Uuid, + label: Option, + last_used_at: Option, + revoked_at: Option, + capabilities: Vec, +} + +#[derive(Debug, Deserialize)] +struct CreateTokenResponse { + token: String, + #[serde(rename = "token_info")] + info: TokenInfo, +} + +#[derive(Debug, Deserialize)] +struct LoginResponseView { + access_token: String, + token_type: String, + expires_in: i64, + tenant: TenantView, +} + +#[derive(Debug, Deserialize)] +struct TenantView { + id: Uuid, + name: String, +} + +#[tokio::test] +async fn api_token_crud_flow() -> Result<()> { + let _guard = acquire_db_lock().await; + let app = TestApp::new().await?; + + let username = "alice"; + let password = "correct horse battery"; + app.insert_user(username, password, "admin").await?; + let access_token = app.login_token(username, password).await?; + + let created = create_token(&app, &access_token, json!({ "label": "dav" })).await?; + let token_id = created.info.id; + assert_eq!(created.info.label.as_deref(), Some("dav")); + assert!(created.info.last_used_at.is_none()); + assert_eq!(created.info.capabilities, vec![ApiTokenCapability::Webdav]); + + let regenerated = regenerate_token(&app, &access_token, token_id).await?; + assert_eq!(regenerated.info.id, token_id); + assert_ne!(regenerated.token, created.token); + assert!(regenerated.info.last_used_at.is_none()); + + let updated = update_token_capabilities( + &app, + &access_token, + token_id, + json!({ "capabilities": ["webdav", "api"] }), + ) + .await?; + assert_eq!(updated.capabilities.len(), 2); + assert!(updated.capabilities.contains(&ApiTokenCapability::Webdav)); + assert!(updated.capabilities.contains(&ApiTokenCapability::Api)); + + let listed = list_tokens(&app, &access_token).await?; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, token_id); + + let tenant_id_for_token = app + .with_conn(move |conn| { + let tenant_id = api_tokens::table + .find(token_id) + .select(api_tokens::tenant_id) + .first::(conn)?; + Ok::<_, anyhow::Error>(tenant_id) + }) + .await?; + + let exchange = exchange_token(&app, ®enerated.token).await?; + assert_eq!(exchange.token_type, "Bearer"); + assert!(!exchange.access_token.is_empty()); + assert!(exchange.expires_in > 0); + assert_eq!(exchange.tenant.id, tenant_id_for_token); + assert!(!exchange.tenant.name.is_empty()); + + delete_token(&app, &access_token, token_id).await?; + + let listed_after = list_tokens(&app, &access_token).await?; + assert_eq!(listed_after.len(), 1); + assert_eq!(listed_after[0].id, token_id); + assert!(listed_after[0].revoked_at.is_some()); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn webdav_basic_auth_uses_api_tokens() -> Result<()> { + let _guard = acquire_db_lock().await; + let app = TestApp::new().await?; + + let username = "bruce"; + let password = "wayne"; + app.insert_user(username, password, "admin").await?; + let access_token = app.login_token(username, password).await?; + + let created = create_token(&app, &access_token, json!({ "label": "webdav" })).await?; + let token_id = created.info.id; + + let router = webdav::create_router().with_state(app.state.clone()); + let original_secret = created.token.clone(); + let auth_header = format!( + "Basic {}", + BASE64.encode(format!("{}:{}", username, original_secret)) + ); + + let propfind = Method::from_bytes(b"PROPFIND")?; + let success_request = Request::builder() + .method(propfind.clone()) + .uri("/") + .header(header::AUTHORIZATION, auth_header.clone()) + .header("depth", "0") + .body(Body::empty())?; + let response = router.clone().oneshot(success_request).await?; + assert_eq!(response.status(), StatusCode::MULTI_STATUS); + + let used = app + .with_conn(move |conn| { + let record = api_tokens::table.find(token_id).first::(conn)?; + Ok::<_, anyhow::Error>(record.last_used_at) + }) + .await?; + assert!(used.is_some()); + + let regenerated = regenerate_token(&app, &access_token, token_id).await?; + assert_ne!(regenerated.token, original_secret); + + let unused_after_regen = app + .with_conn(move |conn| { + let record = api_tokens::table.find(token_id).first::(conn)?; + Ok::<_, anyhow::Error>(record.last_used_at) + }) + .await?; + assert!(unused_after_regen.is_none()); + + let old_secret_request = Request::builder() + .method(propfind.clone()) + .uri("/") + .header( + header::AUTHORIZATION, + format!( + "Basic {}", + BASE64.encode(format!("{}:{}", username, original_secret)) + ), + ) + .header("depth", "0") + .body(Body::empty())?; + let old_secret_response = router.clone().oneshot(old_secret_request).await?; + assert_eq!(old_secret_response.status(), StatusCode::UNAUTHORIZED); + + let new_secret_header = format!( + "Basic {}", + BASE64.encode(format!("{}:{}", username, regenerated.token)) + ); + + let success_request = Request::builder() + .method(propfind.clone()) + .uri("/") + .header(header::AUTHORIZATION, new_secret_header.clone()) + .header("depth", "0") + .body(Body::empty())?; + let response = router.clone().oneshot(success_request).await?; + assert_eq!(response.status(), StatusCode::MULTI_STATUS); + + delete_token(&app, &access_token, token_id).await?; + + let failure_request = Request::builder() + .method(propfind) + .uri("/") + .header(header::AUTHORIZATION, new_secret_header) + .header("depth", "0") + .body(Body::empty())?; + let failure_response = router.oneshot(failure_request).await?; + assert_eq!(failure_response.status(), StatusCode::UNAUTHORIZED); + + app.cleanup().await?; + Ok(()) +} + +async fn create_token( + app: &TestApp, + access_token: &str, + payload: serde_json::Value, +) -> Result { + let response = app + .post_json("/api/profile/api-tokens", &payload, Some(access_token)) + .await?; + assert_eq!(response.status(), StatusCode::CREATED); + let body = body_to_vec(response.into_body()).await?; + Ok(serde_json::from_slice(&body)?) +} + +async fn regenerate_token( + app: &TestApp, + access_token: &str, + token_id: Uuid, +) -> Result { + let response = app + .post_json( + &format!("/api/profile/api-tokens/{token_id}/regenerate"), + &json!({}), + Some(access_token), + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + Ok(serde_json::from_slice(&body)?) +} + +async fn update_token_capabilities( + app: &TestApp, + access_token: &str, + token_id: Uuid, + payload: serde_json::Value, +) -> Result { + let response = app + .patch_json( + &format!("/api/profile/api-tokens/{token_id}"), + &payload, + Some(access_token), + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + Ok(serde_json::from_slice(&body)?) +} + +async fn list_tokens(app: &TestApp, access_token: &str) -> Result> { + let response = app + .get("/api/profile/api-tokens", Some(access_token)) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + Ok(serde_json::from_slice(&body)?) +} + +async fn delete_token(app: &TestApp, access_token: &str, token_id: Uuid) -> Result<()> { + let response = app + .delete( + &format!("/api/profile/api-tokens/{token_id}"), + Some(access_token), + ) + .await?; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + Ok(()) +} +async fn exchange_token(app: &TestApp, api_token: &str) -> Result { + let response = app + .post_json( + "/api/auth/exchange-api-token", + &json!({ "api_token": api_token }), + None, + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + Ok(serde_json::from_slice(&body)?) +} diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 9710042..7b0d67e 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -789,7 +789,7 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> { shared.jobs, \ tenant.refresh_tokens, \ tenant.tags, \ - tenant.webdav_tokens, \ + tenant.api_tokens, \ shared.webauthn_challenges, \ shared.user_passkeys, \ tenant.user_memberships, \ diff --git a/backend/tests/webdav_flow.rs b/backend/tests/webdav_flow.rs deleted file mode 100644 index 2fe47ee..0000000 --- a/backend/tests/webdav_flow.rs +++ /dev/null @@ -1,219 +0,0 @@ -mod common; - -use anyhow::Result; -use axum::body::Body; -use axum::http::{header, Method, Request, StatusCode}; -use base64::engine::general_purpose::STANDARD as BASE64; -use base64::Engine; -use common::{acquire_db_lock, body_to_vec, TestApp}; -use diesel::prelude::*; -use papercrate::models::WebdavToken; -use papercrate::routes::webdav; -use papercrate::schema::webdav_tokens; -use serde::Deserialize; -use serde_json::json; -use tower::ServiceExt; -use uuid::Uuid; - -#[derive(Deserialize)] -struct TokenInfo { - id: Uuid, - label: Option, - last_used_at: Option, - revoked_at: Option, -} - -#[derive(Deserialize)] -struct CreateTokenResponse { - token: String, - #[serde(rename = "token_info")] - info: TokenInfo, -} - -#[tokio::test] -async fn webdav_token_api_crud() -> Result<()> { - let _guard = acquire_db_lock().await; - let app = TestApp::new().await?; - - let username = "alice"; - let password = "correct horse battery"; - app.insert_user(username, password, "admin").await?; - let access_token = app.login_token(username, password).await?; - - let create_response = app - .post_json( - "/api/profile/webdav-tokens", - &json!({ "label": "dav" }), - Some(&access_token), - ) - .await?; - assert_eq!(create_response.status(), StatusCode::CREATED); - let create_body = body_to_vec(create_response.into_body()).await?; - let created: CreateTokenResponse = serde_json::from_slice(&create_body)?; - let token_id = created.info.id; - assert_eq!(created.info.label.as_deref(), Some("dav")); - assert!(created.info.last_used_at.is_none()); - - let regenerate_response = app - .post_json( - &format!("/api/profile/webdav-tokens/{token_id}/regenerate"), - &json!({}), - Some(&access_token), - ) - .await?; - assert_eq!(regenerate_response.status(), StatusCode::OK); - let regenerate_body = body_to_vec(regenerate_response.into_body()).await?; - let regenerated: CreateTokenResponse = serde_json::from_slice(®enerate_body)?; - assert_eq!(regenerated.info.id, token_id); - assert_ne!(regenerated.token, created.token); - assert!(regenerated.info.last_used_at.is_none()); - - let list_response = app - .get("/api/profile/webdav-tokens", Some(&access_token)) - .await?; - assert_eq!(list_response.status(), StatusCode::OK); - let list_body = body_to_vec(list_response.into_body()).await?; - let listed: Vec = serde_json::from_slice(&list_body)?; - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].id, token_id); - - let delete_response = app - .delete( - &format!("/api/profile/webdav-tokens/{token_id}"), - Some(&access_token), - ) - .await?; - assert_eq!(delete_response.status(), StatusCode::NO_CONTENT); - - let list_after = app - .get("/api/profile/webdav-tokens", Some(&access_token)) - .await?; - let list_after_body = body_to_vec(list_after.into_body()).await?; - let listed_after: Vec = serde_json::from_slice(&list_after_body)?; - assert_eq!(listed_after.len(), 1); - assert_eq!(listed_after[0].id, token_id); - assert!(listed_after[0].revoked_at.is_some()); - - app.cleanup().await?; - Ok(()) -} - -#[tokio::test] -async fn webdav_basic_auth_uses_tokens() -> Result<()> { - let _guard = acquire_db_lock().await; - let app = TestApp::new().await?; - - let username = "bruce"; - let password = "wayne"; - app.insert_user(username, password, "admin").await?; - let access_token = app.login_token(username, password).await?; - - let create_response = app - .post_json( - "/api/profile/webdav-tokens", - &json!({ "label": "webdav" }), - Some(&access_token), - ) - .await?; - let create_body = body_to_vec(create_response.into_body()).await?; - let created: CreateTokenResponse = serde_json::from_slice(&create_body)?; - let token_id = created.info.id; - - let router = webdav::create_router().with_state(app.state.clone()); - let original_secret = created.token.clone(); - let auth_header = format!( - "Basic {}", - BASE64.encode(format!("{}:{}", username, original_secret)) - ); - - let propfind = Method::from_bytes(b"PROPFIND")?; - let success_request = Request::builder() - .method(propfind.clone()) - .uri("/") - .header(header::AUTHORIZATION, auth_header.clone()) - .header("depth", "0") - .body(Body::empty())?; - let response = router.clone().oneshot(success_request).await?; - assert_eq!(response.status(), StatusCode::MULTI_STATUS); - - let used = app - .with_conn(move |conn| { - let record = webdav_tokens::table - .find(token_id) - .first::(conn)?; - Ok::<_, anyhow::Error>(record.last_used_at) - }) - .await?; - assert!(used.is_some()); - - let regenerate_response = app - .post_json( - &format!("/api/profile/webdav-tokens/{token_id}/regenerate"), - &json!({}), - Some(&access_token), - ) - .await?; - assert_eq!(regenerate_response.status(), StatusCode::OK); - let regenerate_body = body_to_vec(regenerate_response.into_body()).await?; - let regenerated: CreateTokenResponse = serde_json::from_slice(®enerate_body)?; - assert_ne!(regenerated.token, original_secret); - - let unused_after_regen = app - .with_conn(move |conn| { - let record = webdav_tokens::table - .find(token_id) - .first::(conn)?; - Ok::<_, anyhow::Error>(record.last_used_at) - }) - .await?; - assert!(unused_after_regen.is_none()); - - let old_secret_request = Request::builder() - .method(propfind.clone()) - .uri("/") - .header( - header::AUTHORIZATION, - format!( - "Basic {}", - BASE64.encode(format!("{}:{}", username, original_secret)) - ), - ) - .header("depth", "0") - .body(Body::empty())?; - let old_secret_response = router.clone().oneshot(old_secret_request).await?; - assert_eq!(old_secret_response.status(), StatusCode::UNAUTHORIZED); - - let new_secret_header = format!( - "Basic {}", - BASE64.encode(format!("{}:{}", username, regenerated.token)) - ); - - let success_request = Request::builder() - .method(propfind.clone()) - .uri("/") - .header(header::AUTHORIZATION, new_secret_header.clone()) - .header("depth", "0") - .body(Body::empty())?; - let response = router.clone().oneshot(success_request).await?; - assert_eq!(response.status(), StatusCode::MULTI_STATUS); - - let delete_response = app - .delete( - &format!("/api/profile/webdav-tokens/{token_id}"), - Some(&access_token), - ) - .await?; - assert_eq!(delete_response.status(), StatusCode::NO_CONTENT); - - let failure_request = Request::builder() - .method(propfind) - .uri("/") - .header(header::AUTHORIZATION, new_secret_header) - .header("depth", "0") - .body(Body::empty())?; - let response = router.oneshot(failure_request).await?; - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - - app.cleanup().await?; - Ok(()) -}