storebound/apps/desktop/src-tauri/src/accounts.rs

137 lines
5.5 KiB
Rust

//! Store accounts. The secret part (Play service account JSON, App Store Connect .p8 key with its key id and issuer)
//! goes into the OS credential store under the service `dev.storebound.desktop`; only a label, the kind and the
//! creation time are kept in the app data folder. Secrets are never sent to the webview: they are read from the
//! keychain right before a sidecar call and handed to it in that call's params (memory only), which passes them to
//! the storebound command as environment variables.
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
pub const KEYRING_SERVICE: &str = "dev.storebound.desktop";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AccountKind {
Play,
Apple,
}
/// What the app keeps on disk and shows: no secret, no key id, no email.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Account {
pub id: String,
pub label: String,
pub kind: AccountKind,
pub created: String,
/// The studio this account belongs to (workspace::Studio id), if any.
#[serde(default)]
pub studio: Option<String>,
}
/// The keychain entry of one account (JSON).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum Secret {
Play { service_account: String },
Apple { key_id: String, issuer_id: String, p8: String },
}
impl Secret {
pub fn kind(&self) -> AccountKind {
match self {
Secret::Play { .. } => AccountKind::Play,
Secret::Apple { .. } => AccountKind::Apple,
}
}
/// The env names storebound reads (cli/lib/credentials.ts), as a sidecar `credentials` object.
pub fn to_credentials(&self, into: &mut Map<String, Value>) {
match self {
Secret::Play { service_account } => {
into.insert("PLAY_SERVICE_ACCOUNT".into(), Value::String(service_account.clone()));
}
Secret::Apple { key_id, issuer_id, p8 } => {
into.insert("ASC_KEY_ID".into(), Value::String(key_id.clone()));
into.insert("ASC_ISSUER_ID".into(), Value::String(issuer_id.clone()));
into.insert("ASC_P8".into(), Value::String(p8.clone()));
}
}
}
}
/// A Google service account JSON key: client_email and a PEM private key.
pub fn check_service_account(text: &str) -> Result<String, String> {
let v: Value = serde_json::from_str(text).map_err(|_| "not a JSON file".to_string())?;
let email = v.get("client_email").and_then(Value::as_str).unwrap_or_default();
let key = v.get("private_key").and_then(Value::as_str).unwrap_or_default();
if email.is_empty() || !key.contains("PRIVATE KEY") {
return Err("not a Google service account key (client_email / private_key missing)".into());
}
// Store the compact form: no file path, nothing else from the disk.
serde_json::to_string(&v).map_err(|e| e.to_string())
}
/// App Store Connect key id: 10 upper-case letters or digits.
pub fn check_key_id(s: &str) -> Result<String, String> {
let s = s.trim();
if s.len() == 10 && s.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) {
Ok(s.to_string())
} else {
Err("key id: 10 letters or digits, as shown in App Store Connect > Users and Access > Integrations".into())
}
}
/// App Store Connect issuer id: a UUID.
pub fn check_issuer_id(s: &str) -> Result<String, String> {
let s = s.trim().to_ascii_lowercase();
let parts: Vec<&str> = s.split('-').collect();
let lens = [8, 4, 4, 4, 12];
let ok = parts.len() == 5
&& parts.iter().zip(lens).all(|(p, n)| p.len() == n && p.chars().all(|c| c.is_ascii_hexdigit()));
if ok {
Ok(s)
} else {
Err("issuer id: the UUID shown above the keys in App Store Connect".into())
}
}
pub fn check_p8(text: &str) -> Result<String, String> {
if text.contains("-----BEGIN PRIVATE KEY-----") && text.contains("-----END PRIVATE KEY-----") {
Ok(text.trim().to_string() + "\n")
} else {
Err("not an App Store Connect .p8 key (BEGIN PRIVATE KEY missing)".into())
}
}
pub fn check_label(s: &str) -> Result<String, String> {
let s = s.trim();
if s.is_empty() || s.chars().count() > 60 || s.chars().any(char::is_control) {
return Err("name: 1 to 60 characters".into());
}
Ok(s.to_string())
}
/// Where secrets live. The OS keychain in the app; a map in tests.
pub trait SecretStore: Send + Sync {
fn set(&self, id: &str, secret: &Secret) -> Result<(), String>;
fn get(&self, id: &str) -> Result<Secret, String>;
fn delete(&self, id: &str) -> Result<(), String>;
}
pub struct Keychain;
impl SecretStore for Keychain {
fn set(&self, id: &str, secret: &Secret) -> Result<(), String> {
let json = serde_json::to_string(secret).map_err(|e| e.to_string())?;
keyring::Entry::new(KEYRING_SERVICE, id).and_then(|e| e.set_password(&json)).map_err(|e| format!("keychain: {e}"))
}
fn get(&self, id: &str) -> Result<Secret, String> {
let json = keyring::Entry::new(KEYRING_SERVICE, id).and_then(|e| e.get_password()).map_err(|e| format!("keychain: {e}"))?;
serde_json::from_str(&json).map_err(|_| "keychain: unreadable entry".to_string())
}
fn delete(&self, id: &str) -> Result<(), String> {
match keyring::Entry::new(KEYRING_SERVICE, id).and_then(|e| e.delete_credential()) {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(format!("keychain: {e}")),
}
}
}