api-tokens
This commit is contained in:
@@ -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<String>,
|
||||
last_used_at: Option<String>,
|
||||
revoked_at: Option<String>,
|
||||
capabilities: Vec<ApiTokenCapability>,
|
||||
}
|
||||
|
||||
#[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::<Uuid>(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::<ApiToken>(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::<ApiToken>(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<CreateTokenResponse> {
|
||||
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<CreateTokenResponse> {
|
||||
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<TokenInfo> {
|
||||
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<Vec<TokenInfo>> {
|
||||
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<LoginResponseView> {
|
||||
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)?)
|
||||
}
|
||||
@@ -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, \
|
||||
|
||||
@@ -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<String>,
|
||||
last_used_at: Option<String>,
|
||||
revoked_at: Option<String>,
|
||||
}
|
||||
|
||||
#[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<TokenInfo> = 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<TokenInfo> = 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::<WebdavToken>(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::<WebdavToken>(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(())
|
||||
}
|
||||
Reference in New Issue
Block a user