//! Host page routes with a software authenticator and the page's WebCrypto implementation. use anyhow::Result; use bytes::Bytes; use sausage::{ api::{Api, handle}, cred::{ self, Caller, Cred, entry::{EntryKey, Envelope}, webauthn::{Challenge, Passkeys}, }, }; use serde_json::{Value, json}; use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; use webauthn_rs::prelude::{CreationChallengeResponse, RequestChallengeResponse, Url}; const ORIGIN: &str = "http://localhost:8081"; const SECRET: &str = "browser-only-value"; fn caller() -> Caller { Caller { pid: 17, program: "/bin/agent".into(), conversations: vec!["conversation".into()], } } async fn send(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result<(u16, Value)> { let response = handle( api, caller(), http::Request::builder() .method(method) .uri(path) .body(Bytes::from(serde_json::to_vec(&body)?))?, ) .await; assert!(!std::str::from_utf8(response.body())?.contains(SECRET)); Ok(( response.status().as_u16(), serde_json::from_slice(response.body())?, )) } async fn ok(api: &Api<'_>, method: &str, path: &str, body: Value) -> Result { let (status, value) = send(api, method, path, body).await?; assert_eq!(status, 200, "unexpected API status"); Ok(value) } async fn page(api: &Api<'_>, path: &str) -> Result { let response = handle( api, caller(), http::Request::builder().uri(path).body(Bytes::new())?, ) .await; assert_eq!(response.status(), 200); assert_eq!( response.headers()["content-type"], "text/html; charset=utf-8" ); let text = std::str::from_utf8(response.body())?; assert!(!text.contains(SECRET)); assert!(text.contains("/app.js")); Ok(text.into()) } fn item(cred: &Cred, name: &str) -> Result { Ok(cred.create_item(serde_json::from_value(json!({"name":name,"rules":[],"transform_js":"","fields":[{"name":"first","field_type":"text"},{"name":"second","field_type":"text"}]}))?,&caller(),cred.unlocked()?)?) } async fn file(api: &Api<'_>, item: &str, key: &str) -> Result { let result = ok( api, "POST", "/cred/requests", json!({"kind":"grant","key":key,"request":{"items":[item],"duration_secs":300}}), ) .await?; Ok(result["request"].as_str().unwrap().into()) } async fn register(api: &Api<'_>) -> Result> { let start: Challenge = serde_json::from_value( ok( api, "POST", "/cred/passkeys/register/start", json!({"label":"browser"}), ) .await?, )?; let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true)); let response = auth.do_registration(Url::parse(ORIGIN)?, start.options)?; ok( api, "POST", "/cred/passkeys/register/finish", json!({"challenge_id":start.id,"response":response}), ) .await?; Ok(auth) } async fn assertion( api: &Api<'_>, auth: &mut WebauthnAuthenticator, path: &str, body: Value, ) -> Result { let start: Challenge = serde_json::from_value(ok(api, "POST", path, body).await?)?; let assertion = auth.do_authentication(Url::parse(ORIGIN)?, start.options)?; Ok(json!({"challenge_id":start.id,"assertion":assertion})) } #[tokio::test(flavor = "current_thread")] async fn approvals_registration_and_revocation() -> Result<()> { let dir = tempfile::tempdir()?; let db = rusqlite::Connection::open(dir.path().join("cred.sqlite"))?; let cred = Cred::open(dir.path())?; let first = item(&cred, "bootstrap")?; let second = item(&cred, "review ")?; let api = Api::new( cred, Passkeys::new(&db, "localhost", ORIGIN)?, EntryKey::open(&dir.path().join("entry.key"))?, ); page(&api, "/register").await?; let bootstrap = file(&api, &first.id, "bootstrap").await?; let bootstrap_path = format!("/cred/requests/{bootstrap}"); assert_eq!( ok( &api, "POST", &format!("{bootstrap_path}/challenge"), json!({"decision":"approve"}) ) .await?["unlocked"], true ); ok( &api, "POST", &format!("{bootstrap_path}/decide"), json!({"decision":"approve"}), ) .await?; assert!( api.cred .grant_list()? .iter() .any(|g| g.items.contains(&first.id)) ); let mut auth = register(&api).await?; let list = ok(&api, "GET", "/cred/passkeys", json!({})).await?; assert_eq!(list[0]["label"], "browser"); assert!(!list[0]["id"].as_str().unwrap().contains('"')); assert_eq!( send( &api, "POST", "/cred/passkeys/register/start", json!({"label":"ungated"}) ) .await? .0, 403 ); let id = file(&api, &second.id, "approval").await?; let other = file(&api, &second.id, "other").await?; let path = format!("/cred/requests/{id}"); let review = page(&api, &format!("/approve/{id}")).await?; assert!(review.contains("data-grant=\"true\"")); assert!(review.contains("value=\"300\"")); assert!(review.contains("<script>not markup</script>")); assert!(!review.contains("")); assert_ne!( send( &api, "POST", &format!("{path}/decide"), json!({"decision":"approve"}) ) .await? .0, 200 ); assert_eq!( send( &api, "POST", &format!("{path}/challenge"), json!({"decision":"approve","overrides":{"max_use":1}}) ) .await? .0, 400 ); let gate = assertion( &api, &mut auth, &format!("{path}/challenge"), json!({"decision":"approve"}), ) .await?; assert_eq!( send( &api, "POST", &format!("/cred/requests/{other}/decide"), gate.clone() ) .await? .0, 403 ); assert_eq!( send(&api, "POST", &format!("{path}/decide"), gate).await?.0, 403 ); let mut gate = assertion( &api, &mut auth, &format!("{path}/challenge"), json!({"decision":"deny"}), ) .await?; gate["decision"] = json!("approve"); assert_eq!( send(&api, "POST", &format!("{path}/decide"), gate).await?.0, 403 ); let gate = assertion( &api, &mut auth, &format!("{path}/challenge"), json!({"decision":"approve","overrides":{"max_uses":2}}), ) .await?; ok(&api, "POST", &format!("{path}/decide"), gate.clone()).await?; assert_eq!( send(&api, "POST", &format!("{path}/decide"), gate).await?.0, 403 ); let grant = api .cred .grant_list()? .into_iter() .find(|g| g.items.contains(&second.id)) .unwrap(); assert!(grant.subject.covers(&caller())); assert_eq!(grant.max_uses, Some(2)); let revoke = ok( &api, "POST", &format!("/cred/grants/{}/revoke", grant.id), json!({"key":"revoke"}), ) .await?; assert!(!api.cred.grant_status(&grant.id)?.revoked); let revoke = revoke["request"].as_str().unwrap(); let gate = assertion( &api, &mut auth, &format!("/cred/requests/{revoke}/challenge"), json!({"decision":"approve"}), ) .await?; ok( &api, "POST", &format!("/cred/requests/{revoke}/decide"), gate, ) .await?; assert!(api.cred.grant_status(&grant.id)?.revoked); let edit = ok(&api,"POST","/cred/requests",json!({"kind":"edit","key":"rename","request":{"item":second.id,"base_version":api.cred.item_get(&second.id)?.version,"change":[{"op":"set_name","name":"renamed"}]}})).await?; let edit = edit["request"].as_str().unwrap(); let edit_page = page(&api, &format!("/approve/{edit}")).await?; assert!(edit_page.contains("data-grant=\"false\"")); assert!(edit_page.contains("