Files
papercrate/backend/tests/auth_flow.rs
T
2025-10-29 18:26:59 +01:00

599 lines
18 KiB
Rust

mod common;
use anyhow::{anyhow, Context, Result};
use axum::body::Body;
use axum::http::{header::SET_COOKIE, StatusCode};
use backend::auth::passkeys::{
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
RegistrationChallengeResponse,
};
use backend::jobs::JOB_PROVISION_TENANT;
use backend::models::{Job, NewUserMembership, TenantStatus, UserPasskey};
use backend::openapi::schemas::PasskeySummary;
use backend::schema::{jobs, tenants, user_memberships, users};
use common::{acquire_db_lock, body_to_vec, TestApp};
use diesel::prelude::*;
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
use webauthn_rs_core::proto::{
AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential,
RegisterPublicKeyCredential,
};
#[derive(Deserialize)]
struct AuthenticatedUser {
username: String,
}
#[derive(Deserialize)]
struct ErrorResponse {
error: String,
}
#[derive(Deserialize)]
struct LoginTenant {
id: Uuid,
name: String,
}
#[derive(Deserialize)]
struct LoginResponse {
access_token: String,
tenant: LoginTenant,
}
#[derive(Deserialize)]
struct TenantSelectionResponse {
access_token: String,
tenants: Vec<TenantSummary>,
}
#[derive(Deserialize)]
struct TenantSummary {
id: Uuid,
name: String,
}
#[tokio::test]
async fn login_and_me_roundtrip() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "s3cret";
app.insert_user("alice", password, "admin").await?;
let (login, _) = login_with_session(&app, "alice", password).await?;
let response = app.get("/api/auth/me", Some(&login.access_token)).await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let user: AuthenticatedUser = serde_json::from_slice(&body)?;
assert_eq!(user.username, "alice");
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn login_rejects_unknown_user() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let payload = json!({ "username": "ghost", "password": "nope" });
let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let body = body_to_vec(response.into_body()).await?;
let err: ErrorResponse = serde_json::from_slice(&body)?;
assert_eq!(err.error, "unauthorized");
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn signup_creates_user_tenant_and_membership() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let payload = json!({
"username": "signup-user",
"password": "super-secret",
});
let response = app.post_json("/api/auth/signup", &payload, None).await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let login: LoginResponse = serde_json::from_slice(&body)?;
// session works immediately
let me = app.get("/api/auth/me", Some(&login.access_token)).await?;
assert_eq!(me.status(), StatusCode::OK);
app.with_conn(move |conn| {
let user: backend::models::User = users::table
.filter(users::username.eq("signup-user"))
.first(conn)?;
let tenant: backend::models::Tenant = tenants::table
.filter(tenants::name.eq("signup-user"))
.first(conn)?;
assert_eq!(tenant.status, TenantStatus::Creating);
assert_eq!(tenant.created_by, Some(user.id));
let membership_exists: bool = diesel::select(diesel::dsl::exists(
user_memberships::table
.filter(user_memberships::user_id.eq(user.id))
.filter(user_memberships::tenant_id.eq(tenant.id)),
))
.get_result(conn)?;
assert!(
!membership_exists,
"membership should be enqueued, not created synchronously"
);
let job: Job = jobs::table
.filter(jobs::tenant_id.eq(tenant.id))
.filter(jobs::job_type.eq(JOB_PROVISION_TENANT))
.first(conn)
.context("provision job missing")?;
let members = job
.payload
.get("members")
.and_then(|value| value.as_array())
.cloned()
.unwrap_or_default();
assert!(members.iter().any(|value| {
value
.as_str()
.and_then(|id| Uuid::parse_str(id).ok())
.map(|parsed| parsed == user.id)
.unwrap_or(false)
}));
Ok(())
})
.await?;
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn passkey_register_start_creates_challenge() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
app.insert_user("passkey-user", password, "admin").await?;
let (login, _) = login_with_session(&app, "passkey-user", password).await?;
let response = app
.post_json(
"/api/auth/passkeys/register/start",
&json!({}),
Some(&login.access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let challenge: RegistrationChallengeResponse = serde_json::from_slice(&body)?;
assert_ne!(challenge.challenge_id, Uuid::nil());
let challenge_id = challenge.challenge_id;
app.with_conn(move |conn| {
use backend::schema::webauthn_challenges::dsl;
use diesel::dsl::{exists, select};
let exists: bool = select(exists(
dsl::webauthn_challenges.filter(dsl::id.eq(challenge_id)),
))
.get_result(conn)?;
assert!(exists, "challenge not persisted");
Ok(())
})
.await?;
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn passkey_register_finish_rejects_unknown_challenge() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
app.insert_user("passkey-register", password, "admin")
.await?;
let (login, _) = login_with_session(&app, "passkey-register", password).await?;
let payload = PasskeyRegistrationFinishPayload {
challenge_id: Uuid::new_v4(),
credential: fake_register_credential(),
nickname: None,
};
let response = app
.post_json(
"/api/auth/passkeys/register/finish",
&payload,
Some(&login.access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn passkey_login_start_requires_passkey() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
app.insert_user("passkey-login", password, "admin").await?;
let payload = PasskeyLoginStartPayload {
username: "passkey-login".to_string(),
};
let response = app
.post_json("/api/auth/passkeys/login/start", &payload, None)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn passkey_login_start_unknown_user_returns_not_found() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let payload = PasskeyLoginStartPayload {
username: "nobody".to_string(),
};
let response = app
.post_json("/api/auth/passkeys/login/start", &payload, None)
.await?;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn passkey_login_finish_rejects_invalid_challenge() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let payload = PasskeyLoginFinishPayload {
challenge_id: Uuid::new_v4(),
credential: fake_authentication_credential(),
};
let response = app
.post_json("/api/auth/passkeys/login/finish", &payload, None)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn list_passkeys_returns_entries() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
let user_id = app.insert_user("passkey-owner", password, "admin").await?;
app.insert_passkey(user_id, Some("Laptop")).await?;
let (session, _) = login_with_session(&app, "passkey-owner", password).await?;
let response = app
.get("/api/profile/passkeys", Some(&session.access_token))
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let summaries: Vec<PasskeySummary> = serde_json::from_slice(&body)?;
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].nickname.as_deref(), Some("Laptop"));
assert!(summaries[0].revoked_at.is_none());
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn delete_passkey_soft_revokes() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
let user_id = app.insert_user("passkey-delete", password, "admin").await?;
let passkey_id = app.insert_passkey(user_id, Some("Phone")).await?;
let (session, _) = login_with_session(&app, "passkey-delete", password).await?;
let response = app
.delete(
&format!("/api/profile/passkeys/{}?reason=lost", passkey_id),
Some(&session.access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
app.with_conn(move |conn| {
use backend::schema::user_passkeys::dsl as passkey_dsl;
let record = passkey_dsl::user_passkeys
.find(passkey_id)
.first::<UserPasskey>(conn)?;
assert!(record.revoked_at.is_some());
assert_eq!(record.revoked_reason.as_deref(), Some("lost"));
Ok(())
})
.await?;
app.cleanup().await?;
Ok(())
}
fn fake_register_credential() -> RegisterPublicKeyCredential {
RegisterPublicKeyCredential {
id: "fake-passkey".to_string(),
raw_id: vec![1, 2, 3, 4].into(),
response: AuthenticatorAttestationResponseRaw {
attestation_object: vec![5, 6, 7, 8].into(),
client_data_json: vec![9, 10, 11, 12].into(),
transports: None,
},
type_: "public-key".to_string(),
extensions: Default::default(),
}
}
fn fake_authentication_credential() -> PublicKeyCredential {
PublicKeyCredential {
id: "fake-auth".to_string(),
raw_id: vec![1, 2, 3].into(),
response: AuthenticatorAssertionResponseRaw {
authenticator_data: vec![4, 5, 6].into(),
client_data_json: vec![7, 8, 9].into(),
signature: vec![10, 11, 12].into(),
user_handle: None,
},
extensions: Default::default(),
type_: "public-key".to_string(),
}
}
#[tokio::test]
async fn login_rejects_invalid_password() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "valid";
app.insert_user("robin", password, "admin").await?;
let payload = json!({ "username": "robin", "password": "wrong" });
let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let body = body_to_vec(response.into_body()).await?;
let err: ErrorResponse = serde_json::from_slice(&body)?;
assert_eq!(err.error, "unauthorized");
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn refresh_rotates_refresh_token() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "rotate";
app.insert_user("rita", password, "admin").await?;
let (login, refresh_cookie) = login_with_session(&app, "rita", password).await?;
let response = app
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
.await?;
assert_eq!(response.status(), StatusCode::OK);
let new_cookie = extract_refresh_cookie(response.headers())?;
let body = body_to_vec(response.into_body()).await?;
let refreshed: LoginResponse = serde_json::from_slice(&body)?;
assert_eq!(refreshed.tenant.name, login.tenant.name);
let me_response = app
.get("/api/auth/me", Some(&refreshed.access_token))
.await?;
assert_eq!(me_response.status(), StatusCode::OK);
let retry = app
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
.await?;
assert_eq!(retry.status(), StatusCode::UNAUTHORIZED);
// new cookie should differ from old to avoid reuse
assert_ne!(new_cookie, refresh_cookie);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn logout_revokes_refresh_token() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "logout";
app.insert_user("logan", password, "admin").await?;
let (login, refresh_cookie) = login_with_session(&app, "logan", password).await?;
let response = app
.post_json_with_cookie(
"/api/auth/logout",
&json!({}),
Some(&login.access_token),
Some(&refresh_cookie),
)
.await?;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let cleared_cookie = extract_refresh_cookie(response.headers())?;
assert!(cleared_cookie.ends_with("="));
let after_logout = app
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
.await?;
assert_eq!(after_logout.status(), StatusCode::UNAUTHORIZED);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn me_requires_authentication() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let unauthenticated = app.get("/api/auth/me", None).await?;
assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED);
let invalid = app.get("/api/auth/me", Some("invalid")).await?;
assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "multipass";
let user_id = app.insert_user("multipass", password, "admin").await?;
let secondary_name = "secondary".to_string();
let name_for_insert = secondary_name.clone();
let secondary_id = Uuid::new_v4();
app.with_conn(move |conn| {
diesel::insert_into(tenants::table)
.values((
tenants::id.eq(secondary_id),
tenants::name.eq(&name_for_insert),
tenants::status.eq(TenantStatus::Active),
))
.execute(conn)?;
let membership = NewUserMembership {
id: Uuid::new_v4(),
user_id,
tenant_id: secondary_id,
};
diesel::insert_into(user_memberships::table)
.values(&membership)
.execute(conn)?;
Ok(())
})
.await?;
let payload = json!({ "username": "multipass", "password": password });
let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let selection: TenantSelectionResponse = serde_json::from_slice(&body)?;
assert!(selection.tenants.len() >= 2);
let secondary = selection
.tenants
.iter()
.find(|tenant| tenant.name == secondary_name)
.map(|t| t.id)
.context("secondary tenant missing from selection")?;
let select_response = app
.post_json(
"/api/auth/select-tenant",
&json!({ "tenant_id": secondary }),
Some(&selection.access_token),
)
.await?;
assert_eq!(select_response.status(), StatusCode::OK);
let session_cookie = extract_refresh_cookie(select_response.headers())?;
let select_body = body_to_vec(select_response.into_body()).await?;
let login: LoginResponse = serde_json::from_slice(&select_body)?;
assert_eq!(login.tenant.name, secondary_name);
let me_response = app.get("/api/auth/me", Some(&login.access_token)).await?;
assert_eq!(me_response.status(), StatusCode::OK);
let refresh_response = app
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&session_cookie))
.await?;
assert_eq!(refresh_response.status(), StatusCode::OK);
app.cleanup().await?;
Ok(())
}
async fn login_with_session(
app: &TestApp,
username: &str,
password: &str,
) -> Result<(LoginResponse, String)> {
let payload = json!({ "username": username, "password": password });
let response = app.post_json("/api/auth/login", &payload, None).await?;
ensure_status(&response, StatusCode::OK)?;
let refresh_cookie = extract_refresh_cookie(response.headers())?;
let body = body_to_vec(response.into_body()).await?;
let login: LoginResponse = serde_json::from_slice(&body)
.map_err(|_| anyhow!("expected login response with session"))?;
Ok((login, refresh_cookie))
}
fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
let header_value = headers
.get(SET_COOKIE)
.context("missing set-cookie header")?
.to_str()
.context("invalid set-cookie header")?;
let cookie = header_value
.split(';')
.next()
.context("set-cookie missing cookie value")?
.to_string();
Ok(cookie)
}
fn ensure_status(response: &hyper::Response<Body>, expected: StatusCode) -> Result<()> {
if response.status() == expected {
Ok(())
} else {
Err(anyhow!(
"unexpected status: got {}, expected {}",
response.status(),
expected
))
}
}