diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 1293bcd7..f7ea107c 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -267,6 +267,13 @@ Camera app offers to open them directly in Mold; cold-launch and already-open links share the same claim, instance-verification, and Keychain path as Mold's in-app scanner. +An authenticated claim receives a distinct `mold_pair_...` credential, not the +host's operator key. The host stores only its digest in `mold.db`; web and +desktop Settings list grants with `GET /api/pairing/clients` and revoke one +with `DELETE /api/pairing/clients/:id`. Paired credentials can use normal APIs +but cannot create or manage other grants. When host authentication is disabled, +pairing remains credential-free and there is no grant to revoke. + Authenticated gallery media uses `POST /api/gallery/media-token` to exchange the normal `X-Api-Key` request for a short-lived, read-only URL scoped to one `/api/gallery/image/:filename` path. This allows native video Range requests and diff --git a/bun.nix b/bun.nix index 820a709e..621b1c63 100644 --- a/bun.nix +++ b/bun.nix @@ -1377,4 +1377,4 @@ url = "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz"; hash = "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="; }; -} \ No newline at end of file +} diff --git a/crates/mold-db/src/lib.rs b/crates/mold-db/src/lib.rs index 7fa01ca3..60b3a9c5 100644 --- a/crates/mold-db/src/lib.rs +++ b/crates/mold-db/src/lib.rs @@ -14,6 +14,7 @@ mod device_preferences; pub mod metadata_io; pub mod migrations; mod model_prefs; +pub mod paired_clients; mod path; pub mod persist; mod prompt_history; diff --git a/crates/mold-db/src/migrations.rs b/crates/mold-db/src/migrations.rs index b34c6b61..4ab666f4 100644 --- a/crates/mold-db/src/migrations.rs +++ b/crates/mold-db/src/migrations.rs @@ -378,6 +378,25 @@ const V15_SCHEDULER_ESTIMATE_RUNTIME: &str = r#" ALTER TABLE scheduler_estimates ADD COLUMN ewma_runtime_ms REAL; "#; +/// v16 → individually revocable credentials created by mobile pairing. +/// +/// Only SHA-256 digests of the high-entropy bearer credentials are retained. +/// Revocation deletes the row, so a copied client credential stops working +/// immediately and stays revoked across server restarts. +const V16_PAIRED_CLIENTS: &str = r#" +CREATE TABLE paired_clients ( + id TEXT PRIMARY KEY, + server_instance_id TEXT NOT NULL, + name TEXT NOT NULL, + client_kind TEXT NOT NULL, + credential_hash BLOB NOT NULL UNIQUE CHECK (length(credential_hash) = 32), + created_at_ms INTEGER NOT NULL, + last_used_at_ms INTEGER +); +CREATE INDEX idx_paired_clients_instance_created_at +ON paired_clients(server_instance_id, created_at_ms DESC); +"#; + /// Ordered list of schema migrations. Version numbers must be strictly /// increasing — [`apply_pending`] validates this at startup. pub(crate) const MIGRATIONS: &[Migration] = &[ @@ -441,11 +460,15 @@ pub(crate) const MIGRATIONS: &[Migration] = &[ version: 15, kind: MigrationKind::Sql(V15_SCHEDULER_ESTIMATE_RUNTIME), }, + Migration { + version: 16, + kind: MigrationKind::Sql(V16_PAIRED_CLIENTS), + }, ]; /// The highest migration version this build ships. Exposed publicly so /// operators / tests can assert what schema level they're running against. -pub const SCHEMA_VERSION: i64 = 15; +pub const SCHEMA_VERSION: i64 = 16; /// v1 → v2: rewrite every `output_dir` value to its canonical form so /// rows written by the v0.8.x release (which keyed on raw paths) keep @@ -821,7 +844,7 @@ mod tests { SCHEMA_VERSION, "fresh DB must end at the latest SCHEMA_VERSION", ); - assert_eq!(SCHEMA_VERSION, 15); + assert_eq!(SCHEMA_VERSION, 16); assert!(table_exists(&conn, "device_preferences")); assert_eq!( column_names(&conn, "device_preferences"), @@ -1152,8 +1175,8 @@ mod v9_tests { use rusqlite::Connection; #[test] - fn schema_version_is_thirteen() { - assert_eq!(SCHEMA_VERSION, 15); + fn schema_version_is_current() { + assert_eq!(SCHEMA_VERSION, 16); } #[test] @@ -1234,7 +1257,7 @@ mod v15_tests { apply_pending(&mut conn).unwrap(); - assert_eq!(current_version(&conn).unwrap(), 15); + assert_eq!(current_version(&conn).unwrap(), SCHEMA_VERSION); let runtime: Option = conn .query_row( "SELECT ewma_runtime_ms FROM scheduler_estimates WHERE estimate_key = 'legacy'", diff --git a/crates/mold-db/src/paired_clients.rs b/crates/mold-db/src/paired_clients.rs new file mode 100644 index 00000000..53c71580 --- /dev/null +++ b/crates/mold-db/src/paired_clients.rs @@ -0,0 +1,132 @@ +//! Durable, individually revocable credentials issued by the pairing API. + +use anyhow::{Context, Result}; +use rusqlite::params; + +use crate::MetadataDb; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PairedClient { + pub id: String, + pub server_instance_id: String, + pub name: String, + pub client_kind: String, + pub credential_hash: [u8; 32], + pub created_at_ms: i64, + pub last_used_at_ms: Option, +} + +pub struct PairedClients<'a> { + db: &'a MetadataDb, +} + +impl<'a> PairedClients<'a> { + pub fn new(db: &'a MetadataDb) -> Self { + Self { db } + } + + pub fn list(&self, server_instance_id: &str) -> Result> { + self.db.with_conn(|conn| { + let mut statement = conn.prepare( + "SELECT id, server_instance_id, name, client_kind, credential_hash, created_at_ms, last_used_at_ms + FROM paired_clients + WHERE server_instance_id = ?1 + ORDER BY created_at_ms DESC, id ASC", + )?; + let rows = statement.query_map([server_instance_id], |row| { + let hash: Vec = row.get(4)?; + let credential_hash: [u8; 32] = hash.try_into().map_err(|value: Vec| { + rusqlite::Error::FromSqlConversionFailure( + value.len(), + rusqlite::types::Type::Blob, + "paired credential hash must be 32 bytes".into(), + ) + })?; + Ok(PairedClient { + id: row.get(0)?, + server_instance_id: row.get(1)?, + name: row.get(2)?, + client_kind: row.get(3)?, + credential_hash, + created_at_ms: row.get(5)?, + last_used_at_ms: row.get(6)?, + }) + })?; + rows.collect::>>() + .map_err(Into::into) + }) + } + + pub fn insert(&self, client: &PairedClient) -> Result<()> { + self.db.with_conn(|conn| { + conn.execute( + "INSERT INTO paired_clients ( + id, server_instance_id, name, client_kind, credential_hash, created_at_ms, last_used_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + client.id, + client.server_instance_id, + client.name, + client.client_kind, + client.credential_hash.as_slice(), + client.created_at_ms, + client.last_used_at_ms, + ], + ) + .context("inserting paired client")?; + Ok(()) + }) + } + + pub fn touch(&self, server_instance_id: &str, id: &str, last_used_at_ms: i64) -> Result<()> { + self.db.with_conn(|conn| { + conn.execute( + "UPDATE paired_clients SET last_used_at_ms = ?3 WHERE server_instance_id = ?1 AND id = ?2", + params![server_instance_id, id, last_used_at_ms], + )?; + Ok(()) + }) + } + + pub fn revoke(&self, server_instance_id: &str, id: &str) -> Result { + self.db.with_conn(|conn| { + Ok(conn.execute( + "DELETE FROM paired_clients WHERE server_instance_id = ?1 AND id = ?2", + params![server_instance_id, id], + )? > 0) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grants_round_trip_touch_and_revoke() { + let db = MetadataDb::open_in_memory().unwrap(); + let clients = PairedClients::new(&db); + let client = PairedClient { + id: "phone-1".into(), + server_instance_id: "server-a".into(), + name: "James's iPhone".into(), + client_kind: "mobile".into(), + credential_hash: [7; 32], + created_at_ms: 100, + last_used_at_ms: None, + }; + + clients.insert(&client).unwrap(); + assert_eq!(clients.list("server-a").unwrap(), vec![client.clone()]); + assert!(clients.list("server-b").unwrap().is_empty()); + clients.touch("server-a", &client.id, 250).unwrap(); + assert_eq!( + clients.list("server-a").unwrap()[0].last_used_at_ms, + Some(250) + ); + assert!(!clients.revoke("server-b", &client.id).unwrap()); + assert!(clients.revoke("server-a", &client.id).unwrap()); + assert!(!clients.revoke("server-a", &client.id).unwrap()); + assert!(clients.list("server-a").unwrap().is_empty()); + } +} diff --git a/crates/mold-server/src/auth.rs b/crates/mold-server/src/auth.rs index 6421f374..18fabb33 100644 --- a/crates/mold-server/src/auth.rs +++ b/crates/mold-server/src/auth.rs @@ -8,7 +8,7 @@ use axum::{ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use hmac::{Hmac, Mac}; use serde::Serialize; -use sha2::Sha256; +use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::sync::Mutex; @@ -30,28 +30,54 @@ pub type AuthState = Option>; /// Set of valid API keys loaded from `MOLD_API_KEY`. pub struct ApiKeySet { - keys: HashSet, + operator_keys: HashSet, gallery_signing_secret: [u8; GALLERY_SIGNING_SECRET_BYTES], pairing_sessions: Mutex>, + paired_clients: Mutex>, + metadata_db: Arc>, + server_instance_id: Arc, } struct PairingSession { - api_key: String, expires_at: u64, } +#[derive(Clone)] +struct PairedClientAccess { + id: String, + name: String, + client_kind: String, + created_at_ms: i64, + last_used_at_ms: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AuthenticationKind { + Operator, + PairedClient, +} + impl ApiKeySet { pub fn new(keys: HashSet) -> Self { - Self::try_new(keys).expect("OS randomness is required for gallery media authentication") + Self::try_new(keys, Arc::new(None), Arc::new(String::new())) + .expect("OS randomness is required for gallery media authentication") } - fn try_new(keys: HashSet) -> Result { + fn try_new( + keys: HashSet, + metadata_db: Arc>, + server_instance_id: Arc, + ) -> Result { let mut gallery_signing_secret = [0_u8; GALLERY_SIGNING_SECRET_BYTES]; getrandom::fill(&mut gallery_signing_secret)?; + let paired_clients = load_paired_clients(&metadata_db, &server_instance_id); Ok(Self { - keys, + operator_keys: keys, gallery_signing_secret, pairing_sessions: Mutex::new(HashMap::new()), + paired_clients: Mutex::new(paired_clients), + metadata_db, + server_instance_id, }) } @@ -61,21 +87,69 @@ impl ApiKeySet { gallery_signing_secret: [u8; GALLERY_SIGNING_SECRET_BYTES], ) -> Self { Self { - keys, + operator_keys: keys, gallery_signing_secret, pairing_sessions: Mutex::new(HashMap::new()), + paired_clients: Mutex::new(HashMap::new()), + metadata_db: Arc::new(None), + server_instance_id: Arc::new(String::new()), } } + #[cfg(test)] + pub(crate) fn new_with_metadata_db( + keys: HashSet, + metadata_db: Arc>, + server_instance_id: impl Into, + ) -> Self { + Self::try_new(keys, metadata_db, Arc::new(server_instance_id.into())).unwrap() + } + pub fn contains(&self, candidate: &str) -> bool { + self.authenticate(candidate).is_some() + } + + fn authenticate(&self, candidate: &str) -> Option { // Check ALL keys unconditionally to avoid leaking which key matched // via timing side-channel (`.any()` would short-circuit on first match). let candidate_bytes = candidate.as_bytes(); let mut found = subtle::Choice::from(0u8); - for k in &self.keys { + for k in &self.operator_keys { found |= k.as_bytes().ct_eq(candidate_bytes); } - found.into() + if bool::from(found) { + return Some(AuthenticationKind::Operator); + } + + let candidate_hash: [u8; 32] = Sha256::digest(candidate_bytes).into(); + let now_ms = unix_timestamp_ms(); + let mut matched = None; + let mut clients = self + .paired_clients + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + for (hash, client) in clients.iter_mut() { + if bool::from(hash.ct_eq(&candidate_hash)) { + matched = Some(client); + } + } + let client = matched?; + if client + .last_used_at_ms + .is_none_or(|last_used| now_ms.saturating_sub(last_used) >= 60_000) + { + client.last_used_at_ms = Some(now_ms); + if let Some(db) = self.metadata_db.as_ref() { + if let Err(error) = mold_db::paired_clients::PairedClients::new(db).touch( + &self.server_instance_id, + &client.id, + now_ms, + ) { + warn!(error = %format!("{error:#}"), client_id = %client.id, "failed to persist paired client activity"); + } + } + } + Some(AuthenticationKind::PairedClient) } fn audit_identity(&self, candidate: &str) -> String { @@ -122,13 +196,10 @@ impl ApiKeySet { ) } - /// Create a one-time, short-lived handoff for the API key that authorized - /// the Settings request. Only the HMAC of the random token is retained; - /// the bearer value exists solely in the no-store response and QR code. - pub(crate) fn issue_pairing_token( - &self, - api_key: String, - ) -> Result<(String, u64), getrandom::Error> { + /// Create a one-time, short-lived handoff authorizing a new paired grant. + /// Only the HMAC of the random token is retained; the bearer value exists + /// solely in the no-store response and QR code. + pub(crate) fn issue_pairing_token(&self) -> Result<(String, u64), getrandom::Error> { let mut token_bytes = [0_u8; PAIRING_TOKEN_BYTES]; getrandom::fill(&mut token_bytes)?; let token = URL_SAFE_NO_PAD.encode(token_bytes); @@ -149,19 +220,19 @@ impl ApiKeySet { sessions.remove(&oldest); } } - sessions.insert( - token_hash, - PairingSession { - api_key, - expires_at, - }, - ); + sessions.insert(token_hash, PairingSession { expires_at }); Ok((token, expires_at)) } - /// Consume a pairing token exactly once. Removal happens before the API - /// key is returned so concurrent redemption attempts cannot both win. - pub(crate) fn claim_pairing_token(&self, token: &str) -> Option { + /// Consume a pairing token exactly once and mint a distinct durable grant. + /// The operator credential that opened Settings is never copied to the + /// paired client, so this grant can be revoked independently. + pub(crate) fn claim_pairing_token( + &self, + token: &str, + client_name: &str, + client_kind: &str, + ) -> anyhow::Result> { let token_hash = self.pairing_token_hash(token); let now = unix_timestamp(); let mut sessions = self @@ -169,10 +240,84 @@ impl ApiKeySet { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); sessions.retain(|_, session| session.expires_at > now); - sessions + let session = sessions .remove(&token_hash) - .filter(|session| session.expires_at > now) - .map(|session| session.api_key) + .filter(|session| session.expires_at > now); + drop(sessions); + if session.is_none() { + return Ok(None); + } + let Some(db) = self.metadata_db.as_ref() else { + anyhow::bail!("paired access requires the Mold metadata database"); + }; + + let mut key_bytes = [0_u8; 32]; + getrandom::fill(&mut key_bytes)?; + let credential = format!("mold_pair_{}", URL_SAFE_NO_PAD.encode(key_bytes)); + let credential_hash: [u8; 32] = Sha256::digest(credential.as_bytes()).into(); + let id = format!("pair_{}", URL_SAFE_NO_PAD.encode(&credential_hash[..12])); + let created_at_ms = unix_timestamp_ms(); + let access = PairedClientAccess { + id: id.clone(), + name: normalized_client_name(client_name), + client_kind: normalized_client_kind(client_kind), + created_at_ms, + last_used_at_ms: None, + }; + mold_db::paired_clients::PairedClients::new(db).insert( + &mold_db::paired_clients::PairedClient { + id, + server_instance_id: self.server_instance_id.as_ref().clone(), + name: access.name.clone(), + client_kind: access.client_kind.clone(), + credential_hash, + created_at_ms, + last_used_at_ms: None, + }, + )?; + self.paired_clients + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(credential_hash, access); + Ok(Some(credential)) + } + + pub(crate) fn pairing_available(&self) -> bool { + self.metadata_db.is_some() + } + + pub(crate) fn paired_clients(&self) -> Vec { + let mut clients = self + .paired_clients + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .values() + .cloned() + .map(|client| PairedClientSummary { + id: client.id, + name: client.name, + client_kind: client.client_kind, + created_at_ms: client.created_at_ms, + last_used_at_ms: client.last_used_at_ms, + }) + .collect::>(); + clients.sort_by_key(|client| std::cmp::Reverse(client.created_at_ms)); + clients + } + + pub(crate) fn revoke_paired_client(&self, id: &str) -> anyhow::Result { + let Some(db) = self.metadata_db.as_ref() else { + anyhow::bail!("paired access requires the Mold metadata database"); + }; + let revoked = + mold_db::paired_clients::PairedClients::new(db).revoke(&self.server_instance_id, id)?; + if revoked { + self.paired_clients + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .retain(|_, client| client.id != id); + } + Ok(revoked) } fn pairing_token_hash(&self, token: &str) -> [u8; 32] { @@ -195,6 +340,63 @@ impl ApiKeySet { } } +#[derive(Debug, Clone, Serialize)] +pub(crate) struct PairedClientSummary { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) client_kind: String, + pub(crate) created_at_ms: i64, + pub(crate) last_used_at_ms: Option, +} + +fn load_paired_clients( + metadata_db: &Arc>, + server_instance_id: &str, +) -> HashMap<[u8; 32], PairedClientAccess> { + let Some(db) = metadata_db.as_ref() else { + return HashMap::new(); + }; + match mold_db::paired_clients::PairedClients::new(db).list(server_instance_id) { + Ok(clients) => clients + .into_iter() + .map(|client| { + ( + client.credential_hash, + PairedClientAccess { + id: client.id, + name: client.name, + client_kind: client.client_kind, + created_at_ms: client.created_at_ms, + last_used_at_ms: client.last_used_at_ms, + }, + ) + }) + .collect(), + Err(error) => { + warn!(error = %format!("{error:#}"), "failed to load paired clients"); + HashMap::new() + } + } +} + +fn normalized_client_name(value: &str) -> String { + let value = value.trim(); + if value.is_empty() { + "Mold mobile".to_string() + } else { + value.chars().take(80).collect() + } +} + +fn normalized_client_kind(value: &str) -> String { + match value.trim() { + "iphone" => "iphone", + "ipad" => "ipad", + _ => "mobile", + } + .to_string() +} + /// Marker proving normal API-key authentication succeeded for this request. /// The matched API key itself deliberately never leaves the middleware. #[derive(Clone)] @@ -205,13 +407,10 @@ pub(crate) struct ApiKeyAuthenticated { pub(crate) identity: String, } -/// Present only on an authenticated pairing-session creation request. It is -/// deliberately scoped to that exact route so ordinary handlers never gain -/// access to a caller's durable credential. +/// Present only when the request used an operator-configured credential. +/// Paired credentials deliberately cannot mint or manage other grants. #[derive(Clone)] -pub(crate) struct PairingAuthority { - pub(crate) api_key: String, -} +pub(crate) struct PairingAuthority; #[derive(Debug, Serialize)] struct AuthError { @@ -228,6 +427,13 @@ struct AuthError { /// /// Returns `None` when the variable is unset or empty (auth disabled). pub fn load_api_keys() -> anyhow::Result { + load_api_keys_with_db(Arc::new(None), Arc::new(String::new())) +} + +pub(crate) fn load_api_keys_with_db( + metadata_db: Arc>, + server_instance_id: Arc, +) -> anyhow::Result { let raw = match std::env::var("MOLD_API_KEY") { Ok(v) if !v.is_empty() => v, _ => return Ok(None), @@ -255,7 +461,7 @@ pub fn load_api_keys() -> anyhow::Result { } tracing::info!(num_keys = keys.len(), "API key authentication enabled"); - let key_set = ApiKeySet::try_new(keys) + let key_set = ApiKeySet::try_new(keys, metadata_db, server_instance_id) .map_err(|error| anyhow::anyhow!("failed to generate gallery signing secret: {error}"))?; Ok(Some(Arc::new(key_set))) } @@ -307,17 +513,13 @@ pub async fn require_api_key(request: Request, next: Next) -> Response { match request.headers().get("x-api-key") { Some(value) => { let candidate = value.to_str().unwrap_or("").to_string(); - if key_set.contains(&candidate) { + if let Some(kind) = key_set.authenticate(&candidate) { let identity = key_set.audit_identity(&candidate); request .extensions_mut() .insert(ApiKeyAuthenticated { identity }); - if request.method() == Method::POST - && request.uri().path() == "/api/pairing/sessions" - { - request - .extensions_mut() - .insert(PairingAuthority { api_key: candidate }); + if kind == AuthenticationKind::Operator { + request.extensions_mut().insert(PairingAuthority); } next.run(request).await } else { @@ -419,6 +621,15 @@ fn unix_timestamp() -> u64 { .as_secs() } +fn unix_timestamp_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(i64::MAX) +} + fn unauthorized(msg: &str) -> Response { let body = AuthError { error: msg.to_string(), @@ -625,29 +836,78 @@ mod tests { #[test] fn pairing_token_is_random_url_safe_and_single_use() { - let ks = ApiKeySet::new_with_gallery_signing_secret( + let ks = ApiKeySet::new_with_metadata_db( HashSet::from(["phone-key".to_string()]), - [0x42; GALLERY_SIGNING_SECRET_BYTES], + Arc::new(Some(mold_db::MetadataDb::open_in_memory().unwrap())), + "server-a", ); - let (token, expires_at) = ks.issue_pairing_token("phone-key".to_string()).unwrap(); + let (token, expires_at) = ks.issue_pairing_token().unwrap(); assert_eq!(token.len(), 43); assert!(token .bytes() .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')); assert!(expires_at > unix_timestamp()); - assert_eq!(ks.claim_pairing_token(&token).as_deref(), Some("phone-key")); - assert_eq!(ks.claim_pairing_token(&token), None); - assert_eq!(ks.claim_pairing_token("not-the-token"), None); + let paired = ks + .claim_pairing_token(&token, "James's iPhone", "iphone") + .unwrap() + .unwrap(); + assert!(paired.starts_with("mold_pair_")); + assert_ne!(paired, "phone-key"); + assert!(ks.contains(&paired)); + assert_eq!(ks.paired_clients()[0].name, "James's iPhone"); + assert_eq!( + ks.claim_pairing_token(&token, "Replay", "iphone").unwrap(), + None + ); + assert_eq!( + ks.claim_pairing_token("not-the-token", "Unknown", "mobile") + .unwrap(), + None + ); + let id = ks.paired_clients()[0].id.clone(); + assert!(ks.revoke_paired_client(&id).unwrap()); + assert!(!ks.contains(&paired)); + assert!(ks.contains("phone-key")); + } + + #[test] + fn paired_credentials_are_scoped_to_one_server_instance() { + let metadata_db = Arc::new(Some(mold_db::MetadataDb::open_in_memory().unwrap())); + let operators = HashSet::from(["operator-key".to_string()]); + let server_a = + ApiKeySet::new_with_metadata_db(operators.clone(), metadata_db.clone(), "server-a"); + let (token, _) = server_a.issue_pairing_token().unwrap(); + let paired = server_a + .claim_pairing_token(&token, "James's iPhone", "iphone") + .unwrap() + .unwrap(); + + let server_b = + ApiKeySet::new_with_metadata_db(operators.clone(), metadata_db.clone(), "server-b"); + let restarted_a = + ApiKeySet::new_with_metadata_db(operators, metadata_db.clone(), "server-a"); + assert!(!server_b.contains(&paired)); + assert!(restarted_a.contains(&paired)); + + let client_id = restarted_a.paired_clients()[0].id.clone(); + assert!(restarted_a.revoke_paired_client(&client_id).unwrap()); + let restarted_after_revoke = ApiKeySet::new_with_metadata_db( + HashSet::from(["operator-key".to_string()]), + metadata_db, + "server-a", + ); + assert!(!restarted_after_revoke.contains(&paired)); } #[test] fn expired_pairing_token_is_rejected_and_removed() { - let ks = ApiKeySet::new_with_gallery_signing_secret( + let ks = ApiKeySet::new_with_metadata_db( HashSet::from(["phone-key".to_string()]), - [0x42; GALLERY_SIGNING_SECRET_BYTES], + Arc::new(Some(mold_db::MetadataDb::open_in_memory().unwrap())), + "server-a", ); - let (token, _) = ks.issue_pairing_token("phone-key".to_string()).unwrap(); + let (token, _) = ks.issue_pairing_token().unwrap(); let token_hash = ks.pairing_token_hash(&token); ks.pairing_sessions .lock() @@ -656,7 +916,10 @@ mod tests { .unwrap() .expires_at = unix_timestamp(); - assert_eq!(ks.claim_pairing_token(&token), None); + assert_eq!( + ks.claim_pairing_token(&token, "Expired", "mobile").unwrap(), + None + ); assert!(!ks .pairing_sessions .lock() @@ -686,7 +949,7 @@ mod tests { if authority.is_some() { StatusCode::OK } else { - StatusCode::INTERNAL_SERVER_ERROR + StatusCode::FORBIDDEN } }, ), @@ -739,6 +1002,32 @@ mod tests { assert_eq!(claimed.status(), StatusCode::OK); } + #[tokio::test] + async fn paired_credentials_cannot_mint_more_credentials() { + let key_set = Arc::new(ApiKeySet::new_with_metadata_db( + HashSet::from(["operator-key".to_string()]), + Arc::new(Some(mold_db::MetadataDb::open_in_memory().unwrap())), + "server-a", + )); + let (token, _) = key_set.issue_pairing_token().unwrap(); + let paired = key_set + .claim_pairing_token(&token, "Mold on iPhone", "iphone") + .unwrap() + .unwrap(); + let auth = Some(key_set); + + let denied = pairing_test_app(auth) + .oneshot( + Request::post("/api/pairing/sessions") + .header("x-api-key", paired) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + } + #[tokio::test] async fn configured_key_is_required_for_device_patch_even_with_loopback_connect_info() { let auth = Some(Arc::new(ApiKeySet::new(HashSet::from([ diff --git a/crates/mold-server/src/lib.rs b/crates/mold-server/src/lib.rs index 02458898..a13154ad 100644 --- a/crates/mold-server/src/lib.rs +++ b/crates/mold-server/src/lib.rs @@ -883,7 +883,8 @@ pub async fn run_server( } // Load optional auth and rate-limit configuration from env vars. - let auth_state = auth::load_api_keys()?; + let auth_state = + auth::load_api_keys_with_db(state.metadata_db.clone(), state.instance_id.clone())?; // Capture whether auth is required before `auth_state` is moved into the // router below — surfaced in the mDNS TXT `auth` flag. #[cfg(feature = "mdns")] diff --git a/crates/mold-server/src/routes.rs b/crates/mold-server/src/routes.rs index 4871fbeb..75744b5a 100644 --- a/crates/mold-server/src/routes.rs +++ b/crates/mold-server/src/routes.rs @@ -204,6 +204,8 @@ use crate::queue::clean_error_message; create_gallery_media_token, create_pairing_session, claim_pairing_session, + list_paired_clients, + revoke_paired_client, import_gallery_file, server_status, list_devices, @@ -267,6 +269,8 @@ use crate::queue::clean_error_message; PairingSessionResponse, PairingClaimRequest, PairingClaimResponse, + PairedClientsResponse, + PairedClientResponse, mold_core::ActiveGenerationStatus, mold_core::GpuInfo, mold_core::DeviceState, @@ -427,6 +431,8 @@ pub fn create_router(state: AppState) -> Router { .route("/api/gallery/media-token", post(create_gallery_media_token)) .route("/api/pairing/sessions", post(create_pairing_session)) .route("/api/pairing/claim", post(claim_pairing_session)) + .route("/api/pairing/clients", get(list_paired_clients)) + .route("/api/pairing/clients/:id", delete(revoke_paired_client)) .route( "/api/gallery/import/:filename", put(import_gallery_file).layer(DefaultBodyLimit::max( @@ -4915,6 +4921,8 @@ pub(crate) struct PairingSessionResponse { #[derive(Debug, Deserialize, utoipa::ToSchema)] pub(crate) struct PairingClaimRequest { pub(crate) token: Option, + pub(crate) client_name: Option, + pub(crate) client_kind: Option, } #[derive(Debug, Serialize, utoipa::ToSchema)] @@ -4924,6 +4932,22 @@ pub(crate) struct PairingClaimResponse { pub(crate) hostname: Option, } +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub(crate) struct PairedClientResponse { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) client_kind: String, + pub(crate) created_at_ms: i64, + pub(crate) last_used_at_ms: Option, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub(crate) struct PairedClientsResponse { + pub(crate) auth_required: bool, + pub(crate) pairing_available: bool, + pub(crate) clients: Vec, +} + fn pairing_hostname() -> Option { hostname::get() .ok() @@ -4951,21 +4975,23 @@ async fn create_pairing_session( let key_set = auth_state.and_then(|Extension(state)| state); let (token, expires_at, auth_required) = match key_set { Some(key_set) => { - let Extension(authority) = authority.ok_or_else(|| { + let Extension(_) = authority.ok_or_else(|| { ApiError::with_code( - "API key authentication is required to start mobile pairing", - "UNAUTHORIZED", - StatusCode::UNAUTHORIZED, + "only an operator API key can start mobile pairing", + "PAIRING_OPERATOR_REQUIRED", + StatusCode::FORBIDDEN, ) })?; - let (token, expires_at) = - key_set - .issue_pairing_token(authority.api_key) - .map_err(|error| { - ApiError::internal(format!( - "failed to create a secure pairing token: {error}" - )) - })?; + if !key_set.pairing_available() { + return Err(ApiError::with_code( + "paired access is unavailable while the metadata database is disabled", + "PAIRING_UNAVAILABLE", + StatusCode::SERVICE_UNAVAILABLE, + )); + } + let (token, expires_at) = key_set.issue_pairing_token().map_err(|error| { + ApiError::internal(format!("failed to create a secure pairing token: {error}")) + })?; (Some(token), Some(expires_at), true) } None => (None, None, false), @@ -5016,13 +5042,24 @@ async fn claim_pairing_session( StatusCode::UNAUTHORIZED, ) })?; - Some(key_set.claim_pairing_token(token).ok_or_else(|| { - ApiError::with_code( - "pairing token is missing, expired, or already used", - "PAIRING_TOKEN_INVALID", - StatusCode::UNAUTHORIZED, - ) - })?) + Some( + key_set + .claim_pairing_token( + token, + request.client_name.as_deref().unwrap_or("Mold mobile"), + request.client_kind.as_deref().unwrap_or("mobile"), + ) + .map_err(|error| { + ApiError::internal(format!("failed to create paired access: {error:#}")) + })? + .ok_or_else(|| { + ApiError::with_code( + "pairing token is missing, expired, or already used", + "PAIRING_TOKEN_INVALID", + StatusCode::UNAUTHORIZED, + ) + })?, + ) } None => None, }; @@ -5038,6 +5075,97 @@ async fn claim_pairing_session( )) } +#[utoipa::path( + get, + path = "/api/pairing/clients", + tag = "server", + responses( + (status = 200, description = "Paired client access grants", body = PairedClientsResponse), + (status = 403, description = "Operator API key is required"), + ) +)] +async fn list_paired_clients( + auth_state: Option>, + authority: Option>, +) -> Result, ApiError> { + let key_set = auth_state.and_then(|Extension(state)| state); + let Some(key_set) = key_set else { + return Ok(Json(PairedClientsResponse { + auth_required: false, + pairing_available: true, + clients: Vec::new(), + })); + }; + if authority.is_none() { + return Err(ApiError::with_code( + "only an operator API key can manage paired access", + "PAIRING_OPERATOR_REQUIRED", + StatusCode::FORBIDDEN, + )); + } + Ok(Json(PairedClientsResponse { + auth_required: true, + pairing_available: key_set.pairing_available(), + clients: key_set + .paired_clients() + .into_iter() + .map(|client| PairedClientResponse { + id: client.id, + name: client.name, + client_kind: client.client_kind, + created_at_ms: client.created_at_ms, + last_used_at_ms: client.last_used_at_ms, + }) + .collect(), + })) +} + +#[utoipa::path( + delete, + path = "/api/pairing/clients/{id}", + tag = "server", + params(("id" = String, Path, description = "Paired client grant id")), + responses( + (status = 204, description = "Paired client access revoked"), + (status = 403, description = "Operator API key is required"), + (status = 404, description = "Paired client was not found"), + ) +)] +async fn revoke_paired_client( + Path(id): Path, + auth_state: Option>, + authority: Option>, +) -> Result { + let key_set = auth_state + .and_then(|Extension(state)| state) + .ok_or_else(|| { + ApiError::with_code( + "authentication is disabled; there is no paired access to revoke", + "PAIRING_NOT_REQUIRED", + StatusCode::NOT_FOUND, + ) + })?; + if authority.is_none() { + return Err(ApiError::with_code( + "only an operator API key can manage paired access", + "PAIRING_OPERATOR_REQUIRED", + StatusCode::FORBIDDEN, + )); + } + if key_set + .revoke_paired_client(&id) + .map_err(|error| ApiError::internal(format!("failed to revoke paired access: {error:#}")))? + { + Ok(StatusCode::NO_CONTENT) + } else { + Err(ApiError::with_code( + "paired client was not found", + "PAIRED_CLIENT_NOT_FOUND", + StatusCode::NOT_FOUND, + )) + } +} + /// Issue a short-lived credential for a browser media element. /// /// The endpoint itself always uses normal `X-Api-Key` authentication. The @@ -6216,17 +6344,19 @@ mod tests { #[tokio::test] async fn production_pairing_handlers_issue_claim_and_reject_replay() { - let state = AppState::for_tests(); - let key_set = Arc::new(crate::auth::ApiKeySet::new( + let mut state = AppState::for_tests(); + let metadata_db = Arc::new(Some(mold_db::MetadataDb::open_in_memory().unwrap())); + state.metadata_db = metadata_db.clone(); + let key_set = Arc::new(crate::auth::ApiKeySet::new_with_metadata_db( std::collections::HashSet::from(["phone-key".to_string()]), + metadata_db, + state.instance_id.as_ref().clone(), )); let auth_state = Some(key_set.clone()); let created = create_pairing_session( State(state.clone()), Some(Extension(auth_state.clone())), - Some(Extension(crate::auth::PairingAuthority { - api_key: "phone-key".to_string(), - })), + Some(Extension(crate::auth::PairingAuthority)), ) .await .unwrap() @@ -6246,6 +6376,8 @@ mod tests { Some(Extension(auth_state.clone())), Json(PairingClaimRequest { token: Some(token.clone()), + client_name: Some("Test iPhone".into()), + client_kind: Some("iphone".into()), }), ) .await @@ -6257,13 +6389,40 @@ mod tests { "no-store" ); let claimed = response_json(claimed).await; - assert_eq!(claimed["api_key"], "phone-key"); + let paired_key = claimed["api_key"].as_str().unwrap().to_string(); + assert!(paired_key.starts_with("mold_pair_")); + assert_ne!(claimed["api_key"], "phone-key"); assert_eq!(claimed["instance_id"], *state.instance_id); + assert!(key_set.contains(&paired_key)); + + let clients = list_paired_clients( + Some(Extension(auth_state.clone())), + Some(Extension(crate::auth::PairingAuthority)), + ) + .await + .unwrap() + .0; + assert_eq!(clients.clients.len(), 1); + assert_eq!(clients.clients[0].name, "Test iPhone"); + assert_eq!(clients.clients[0].client_kind, "iphone"); + revoke_paired_client( + Path(clients.clients[0].id.clone()), + Some(Extension(auth_state.clone())), + Some(Extension(crate::auth::PairingAuthority)), + ) + .await + .unwrap(); + assert!(!key_set.contains(&paired_key)); + assert!(key_set.contains("phone-key")); let replay = match claim_pairing_session( State(state), Some(Extension(auth_state)), - Json(PairingClaimRequest { token: Some(token) }), + Json(PairingClaimRequest { + token: Some(token), + client_name: None, + client_kind: None, + }), ) .await { @@ -6288,7 +6447,11 @@ mod tests { let claimed = claim_pairing_session( State(state), Some(Extension(None)), - Json(PairingClaimRequest { token: None }), + Json(PairingClaimRequest { + token: None, + client_name: None, + client_kind: None, + }), ) .await .unwrap() diff --git a/desktop/docs/server-api.md b/desktop/docs/server-api.md index 6c67518a..f4ebffae 100644 --- a/desktop/docs/server-api.md +++ b/desktop/docs/server-api.md @@ -105,6 +105,14 @@ The **non-streaming `/api/generate`** returns raw bytes with headers, not SSE. C `auth.rs`: `load_api_keys()` reads `MOLD_API_KEY` — single value, comma-separated list, or `@/path/to/file` (one key/line, `#` comments). Unset/empty ⇒ auth disabled. Enforced by `require_api_key` middleware checking the **`X-Api-Key`** header with constant-time compare (`subtle`). Exempt paths: `/health`, `/api/docs`, `/api/openapi.json` (and `/metrics` is mounted outside the auth layer entirely). When auth is **disabled**, `POST /api/shutdown` is restricted to loopback IPs. Rate limiting (`rate_limit.rs`) is opt-in via `MOLD_RATE_LIMIT=N/period` (sec|min|hour) + `MOLD_RATE_LIMIT_BURST`. +Pairing never copies an operator key. `POST /api/pairing/sessions` creates a +two-minute, one-use handoff; `POST /api/pairing/claim` exchanges it for a +random per-client credential whose SHA-256 digest is persisted in +`paired_clients`. Operator credentials list grants with +`GET /api/pairing/clients` and revoke one immediately with +`DELETE /api/pairing/clients/:id`. Paired credentials are accepted by normal +API routes but cannot mint or manage pairing grants. + Browser and native media elements cannot attach `X-Api-Key` to their own Range requests. An authenticated client can POST `{ "path": "/api/gallery/image/:filename" }` to `/api/gallery/media-token` and receive a diff --git a/desktop/src/mobile/MobileApp.test.ts b/desktop/src/mobile/MobileApp.test.ts index 601e5750..7acb4e81 100644 --- a/desktop/src/mobile/MobileApp.test.ts +++ b/desktop/src/mobile/MobileApp.test.ts @@ -4544,7 +4544,10 @@ describe("MobileApp host and catalog coordination", () => { await scanFromMachines(); - expect(claimPairingSession).toHaveBeenCalledWith("http://pair.local:7680", "one-time-token"); + expect(claimPairingSession).toHaveBeenCalledWith("http://pair.local:7680", "one-time-token", { + name: "Mold on iPhone", + kind: "iphone", + }); expect(wrapper?.get(".error-text").text()).toContain("different Mold host"); expect(invoke).not.toHaveBeenCalledWith("keychain_set_api_key", expect.anything()); }); @@ -4780,7 +4783,10 @@ describe("MobileApp host and catalog coordination", () => { ); expect(onOpenDeepLinks).toHaveBeenCalledOnce(); - expect(claimPairingSession).toHaveBeenCalledWith("http://pair.local:7680", "one-time-token"); + expect(claimPairingSession).toHaveBeenCalledWith("http://pair.local:7680", "one-time-token", { + name: "Mold on iPhone", + kind: "iphone", + }); }); it("stops listening for iOS pairing links when the mobile shell unmounts", async () => { diff --git a/desktop/src/mobile/MobileApp.vue b/desktop/src/mobile/MobileApp.vue index c99b53fe..e7b659d4 100644 --- a/desktop/src/mobile/MobileApp.vue +++ b/desktop/src/mobile/MobileApp.vue @@ -1185,7 +1185,13 @@ async function pairFromCode(code: () => Promise): Promise { throw new Error("That pairing code expired. Create a new one in the host's Settings."); } const baseUrl = normalizeRemoteAddress(payload.base_url); - const claim = await claimPairingSession(baseUrl, payload.token); + const iPad = + /iPad/i.test(navigator.userAgent) || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + const claim = await claimPairingSession(baseUrl, payload.token, { + name: iPad ? "Mold on iPad" : "Mold on iPhone", + kind: iPad ? "ipad" : "iphone", + }); if (claim.instance_id !== payload.instance_id) { throw new Error("The pairing code was redeemed by a different Mold host."); } diff --git a/desktop/src/views/SettingsView.test.ts b/desktop/src/views/SettingsView.test.ts index dfb72233..351e380a 100644 --- a/desktop/src/views/SettingsView.test.ts +++ b/desktop/src/views/SettingsView.test.ts @@ -20,6 +20,7 @@ vi.mock("../components/settings/ExpansionSection.vue", () => stub("stub-expansio vi.mock("../components/settings/AccountsSection.vue", () => stub("stub-accounts")); vi.mock("../components/settings/ProfilesSection.vue", () => stub("stub-profiles")); vi.mock("../components/settings/AdvancedSection.vue", () => stub("stub-advanced")); +vi.mock("@studio/components/PairingAccessPanel.vue", () => stub("stub-paired-access")); import SettingsView from "./SettingsView.vue"; diff --git a/desktop/src/views/SettingsView.vue b/desktop/src/views/SettingsView.vue index 70df7d14..680dea4a 100644 --- a/desktop/src/views/SettingsView.vue +++ b/desktop/src/views/SettingsView.vue @@ -2,7 +2,7 @@ import { computed, ref, watch } from "vue"; import AccordionSection from "@ui/components/AccordionSection.vue"; import CardSurface from "@ui/components/CardSurface.vue"; -import MobilePairingCard from "@studio/components/MobilePairingCard.vue"; +import PairingAccessPanel from "@studio/components/PairingAccessPanel.vue"; import AppearanceCard from "../components/settings/AppearanceCard.vue"; import UpdatesSection from "../components/settings/UpdatesSection.vue"; import AboutSection from "../components/settings/AboutSection.vue"; @@ -119,7 +119,7 @@ function toggle(id: SectionId): void {
Mobile pairing
- vi.unstubAllGlobals()); + describe("parseMobilePairingPayload", () => { it("accepts the versioned one-time pairing envelope", () => { expect(parseMobilePairingPayload(JSON.stringify(payload))).toMatchObject({ @@ -56,3 +62,33 @@ describe("parseMobilePairingPayload", () => { ).toThrow("not a supported Mold pairing code"); }); }); + +describe("claimPairingSession", () => { + it("identifies the client without putting its new credential in the request", async () => { + const fetch = vi.fn( + async () => + new Response( + JSON.stringify({ + api_key: "mold_pair_secret", + instance_id: "host-id", + hostname: "studio", + }), + { headers: { "content-type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetch); + + await claimPairingSession("http://studio:7680", "one-use", { + name: "Mold on iPhone", + kind: "iphone", + }); + + const [, init] = fetch.mock.calls[0] as unknown as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toEqual({ + token: "one-use", + client_name: "Mold on iPhone", + client_kind: "iphone", + }); + expect(String(init.body)).not.toContain("mold_pair_secret"); + }); +}); diff --git a/studio/api/pairing.ts b/studio/api/pairing.ts index a0ecd712..93df89ee 100644 --- a/studio/api/pairing.ts +++ b/studio/api/pairing.ts @@ -1,5 +1,5 @@ import type { ApiTarget } from "./client"; -import { apiJsonTo } from "./client"; +import { apiFetchTo, apiJsonTo } from "./client"; export interface PairingSession { token: string | null; @@ -15,6 +15,25 @@ export interface PairingClaim { hostname: string | null; } +export interface PairingClientIdentity { + name: string; + kind: "iphone" | "ipad" | "mobile"; +} + +export interface PairedClient { + id: string; + name: string; + client_kind: string; + created_at_ms: number; + last_used_at_ms: number | null; +} + +export interface PairedClientsResponse { + auth_required: boolean; + pairing_available: boolean; + clients: PairedClient[]; +} + export interface MobilePairingPayload { type: "mold.mobile-pairing"; version: 1; @@ -48,6 +67,7 @@ export function createPairingSession( export function claimPairingSession( baseUrl: string, token: string | null, + client: PairingClientIdentity = { name: "Mold mobile", kind: "mobile" }, ): Promise { return apiJsonTo( { baseUrl, apiKey: null }, @@ -55,11 +75,43 @@ export function claimPairingSession( { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ token }), + body: JSON.stringify({ + token, + client_name: client.name, + client_kind: client.kind, + }), }, ); } +export function listPairedClients( + target: ApiTarget, +): Promise { + return apiJsonTo(target, "/api/pairing/clients").then((value) => { + if ( + !value || + typeof value !== "object" || + typeof (value as PairedClientsResponse).auth_required !== "boolean" || + typeof (value as PairedClientsResponse).pairing_available !== "boolean" || + !Array.isArray((value as PairedClientsResponse).clients) + ) { + throw new Error( + "This Mold host does not support paired access management yet.", + ); + } + return value as PairedClientsResponse; + }); +} + +export async function revokePairedClient( + target: ApiTarget, + id: string, +): Promise { + await apiFetchTo(target, `/api/pairing/clients/${encodeURIComponent(id)}`, { + method: "DELETE", + }); +} + export function parseMobilePairingPayload(raw: string): MobilePairingPayload { let value: unknown; try { diff --git a/studio/components/MobilePairingCard.vue b/studio/components/MobilePairingCard.vue index c80d1da3..04168542 100644 --- a/studio/components/MobilePairingCard.vue +++ b/studio/components/MobilePairingCard.vue @@ -12,8 +12,9 @@ import { const props = defineProps<{ target: ApiTarget | null; suggestedBaseUrl: string; - hostLabel?: string; + hostLabel?: string | undefined; }>(); +const emit = defineEmits<{ sessionCreated: [] }>(); const address = ref(props.suggestedBaseUrl); const session = ref(null); @@ -100,6 +101,7 @@ async function startPairing(): Promise { color: { dark: "#111111", light: "#ffffff" }, }); session.value = next; + emit("sessionCreated"); now.value = Math.floor(Date.now() / 1000); if (timer) clearInterval(timer); timer = setInterval( diff --git a/studio/components/PairingAccessPanel.test.ts b/studio/components/PairingAccessPanel.test.ts new file mode 100644 index 00000000..ac208356 --- /dev/null +++ b/studio/components/PairingAccessPanel.test.ts @@ -0,0 +1,195 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { flushPromises, mount } from "@vue/test-utils"; +import PairingAccessPanel from "./PairingAccessPanel.vue"; + +vi.mock("qrcode", () => ({ + default: { toDataURL: vi.fn(async () => "data:image/png;base64,pairing") }, +})); + +afterEach(() => vi.unstubAllGlobals()); + +describe("PairingAccessPanel", () => { + it("lists grants and requires a second click before revoking one", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + auth_required: true, + pairing_available: true, + clients: [ + { + id: "pair_phone", + name: "Mold on iPhone", + client_kind: "iphone", + created_at_ms: 100, + last_used_at_ms: 200, + }, + ], + }), + { headers: { "content-type": "application/json" } }, + ), + ) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetch); + const wrapper = mount(PairingAccessPanel, { + props: { + target: { baseUrl: "http://studio:7680", apiKey: "operator-key" }, + suggestedBaseUrl: "http://studio:7680", + }, + }); + await flushPromises(); + + expect(wrapper.text()).toContain("Mold on iPhone"); + const revoke = wrapper.get("[data-test='revoke-paired-pair_phone']"); + await revoke.trigger("click"); + expect(fetch).toHaveBeenCalledTimes(1); + expect(revoke.text()).toContain("Revoke Mold on iPhone?"); + await revoke.trigger("click"); + await flushPromises(); + + expect(fetch).toHaveBeenLastCalledWith( + "http://studio:7680/api/pairing/clients/pair_phone", + expect.objectContaining({ method: "DELETE" }), + ); + expect(wrapper.text()).not.toContain("Mold on iPhone"); + }); + + it("explains when the host does not require access credentials", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + auth_required: false, + pairing_available: true, + clients: [], + }), + { headers: { "content-type": "application/json" } }, + ), + ), + ); + const wrapper = mount(PairingAccessPanel, { + props: { + target: { baseUrl: "http://studio:7680", apiKey: null }, + suggestedBaseUrl: "http://studio:7680", + }, + }); + await flushPromises(); + expect(wrapper.text()).toContain("Access control is off"); + }); + + it("discards a response from the previous target", async () => { + let resolveA!: (response: Response) => void; + let resolveB!: (response: Response) => void; + const fetch = vi.fn((url: string | URL | Request) => { + const pending = new Promise((resolve) => { + if (String(url).startsWith("http://server-a")) resolveA = resolve; + else resolveB = resolve; + }); + return pending; + }); + vi.stubGlobal("fetch", fetch); + const wrapper = mount(PairingAccessPanel, { + props: { + target: { baseUrl: "http://server-a", apiKey: "operator-key" }, + suggestedBaseUrl: "http://server-a", + }, + }); + await wrapper.setProps({ + target: { baseUrl: "http://server-b", apiKey: "operator-key" }, + suggestedBaseUrl: "http://server-b", + }); + resolveB( + Response.json({ + auth_required: true, + pairing_available: true, + clients: [ + { + id: "pair_b", + name: "Server B iPhone", + client_kind: "iphone", + created_at_ms: 100, + last_used_at_ms: null, + }, + ], + }), + ); + await flushPromises(); + resolveA( + Response.json({ + auth_required: true, + pairing_available: true, + clients: [ + { + id: "pair_a", + name: "Server A iPhone", + client_kind: "iphone", + created_at_ms: 100, + last_used_at_ms: null, + }, + ], + }), + ); + await flushPromises(); + + expect(wrapper.text()).toContain("Server B iPhone"); + expect(wrapper.text()).not.toContain("Server A iPhone"); + }); + + it("does not restore a revoked client from an older refresh", async () => { + const client = { + id: "pair_phone", + name: "Mold on iPhone", + client_kind: "iphone", + created_at_ms: 100, + last_used_at_ms: null, + }; + let resolveRefresh!: (response: Response) => void; + let getCount = 0; + vi.stubGlobal( + "fetch", + vi.fn((_: string | URL | Request, init?: RequestInit) => { + if (init?.method === "DELETE") { + return Promise.resolve(new Response(null, { status: 204 })); + } + getCount += 1; + if (getCount === 1) { + return Promise.resolve( + Response.json({ + auth_required: true, + pairing_available: true, + clients: [client], + }), + ); + } + return new Promise((resolve) => { + resolveRefresh = resolve; + }); + }), + ); + const wrapper = mount(PairingAccessPanel, { + props: { + target: { baseUrl: "http://studio:7680", apiKey: "operator-key" }, + suggestedBaseUrl: "http://studio:7680", + }, + }); + await flushPromises(); + await wrapper.get("[data-test='paired-access-refresh']").trigger("click"); + const revoke = wrapper.get("[data-test='revoke-paired-pair_phone']"); + await revoke.trigger("click"); + await revoke.trigger("click"); + await flushPromises(); + resolveRefresh( + Response.json({ + auth_required: true, + pairing_available: true, + clients: [client], + }), + ); + await flushPromises(); + + expect(wrapper.text()).not.toContain("Mold on iPhone"); + }); +}); diff --git a/studio/components/PairingAccessPanel.vue b/studio/components/PairingAccessPanel.vue new file mode 100644 index 00000000..6af2f257 --- /dev/null +++ b/studio/components/PairingAccessPanel.vue @@ -0,0 +1,325 @@ + + + + + diff --git a/web/src/pages/SettingsPage.test.ts b/web/src/pages/SettingsPage.test.ts index 9b8ee2a8..196eb921 100644 --- a/web/src/pages/SettingsPage.test.ts +++ b/web/src/pages/SettingsPage.test.ts @@ -91,6 +91,13 @@ describe("SettingsPage", () => { civitai: { configured: false, source: null, masked: null }, }; } + if (String(input).endsWith("/api/pairing/clients")) { + return { + auth_required: true, + pairing_available: true, + clients: [], + }; + } return { entries: [] }; }, }) as Response, diff --git a/web/src/pages/SettingsPage.vue b/web/src/pages/SettingsPage.vue index 22be24f4..90632a15 100644 --- a/web/src/pages/SettingsPage.vue +++ b/web/src/pages/SettingsPage.vue @@ -8,7 +8,7 @@ */ import { computed, onBeforeUnmount, onMounted, ref } from "vue"; import CardSurface from "@ui/components/CardSurface.vue"; -import MobilePairingCard from "@studio/components/MobilePairingCard.vue"; +import PairingAccessPanel from "@studio/components/PairingAccessPanel.vue"; import DevicePanel from "@studio/components/DevicePanel.vue"; import type { DeviceInfo } from "@studio/api/devices"; import { setQueueDevicePin, type QueuePlan } from "@studio/api/queuePlan"; @@ -264,7 +264,7 @@ onBeforeUnmount(() => {

Mobile pairing

-