use std::io::Write; use std::path::PathBuf; use clap::{Parser, Subcommand}; use jiff::Timestamp; use crate::clients::{ Admission, AdmissionEntry, ClientId, ClientPolicy, ClientStore, ClientStoreError, EmailPolicy, NewClient, RedirectUri, }; use crate::config::{ConfigError, ServerConfig}; use crate::database::{self, Database, DatabaseError}; use crate::flows::FlowStore; use crate::identity::{Did, IdentityError}; use crate::keys::{KeyError, SigningAlgorithm}; use crate::server::{self, ServeError}; use crate::state::{self, StateError}; #[derive(Parser, Debug, Clone, Eq, PartialEq)] #[command( name = "ratlogin", version, about = "AT Protocol backed OpenID Connect provider" )] pub struct Cli { #[command(subcommand)] pub command: Command, } #[derive(Subcommand, Debug, Clone, Eq, PartialEq)] pub enum Command { #[command(about = "Create the signing keys and database in an empty state directory")] Init { #[arg(long, env = "RATLOGIN_STATE_DIR", default_value = "/var/lib/ratlogin")] state_dir: PathBuf, }, #[command(about = "Run the provider using the supplied state directory")] Serve { #[arg( long, env = "RATLOGIN_CONFIG", default_value = "/etc/ratlogin/config.toml" )] config: PathBuf, #[arg(long, env = "RATLOGIN_STATE_DIR", default_value = "/var/lib/ratlogin")] state_dir: PathBuf, }, #[command( about = "Generate new signing keys, keeping the current ones published for one cycle" )] RotateKey { #[arg(long, env = "RATLOGIN_STATE_DIR", default_value = "/var/lib/ratlogin")] state_dir: PathBuf, }, #[command(about = "Register a client and print its secret once")] AddClient { #[arg(long, env = "RATLOGIN_STATE_DIR", default_value = "/var/lib/ratlogin")] state_dir: PathBuf, #[arg(long)] id: String, #[arg(long = "redirect-uri", required = true, value_parser = parse_redirect_uri)] redirect_uris: Vec, #[arg(long, default_value = "none")] email: EmailPolicy, #[arg(long = "signing-algorithm", default_value = "ES256")] signing: SigningAlgorithm, #[arg(long = "allow", value_parser = parse_admission_entry)] allow: Vec, #[arg(long = "deny", value_parser = parse_admission_entry)] deny: Vec, #[arg(long = "allow-insecure-redirects")] insecure_redirects: bool, }, #[command(about = "Replace the admission lists on a stored client")] SetClientAccess { #[arg(long, env = "RATLOGIN_STATE_DIR", default_value = "/var/lib/ratlogin")] state_dir: PathBuf, #[arg(long)] id: String, #[arg(long = "allow", value_parser = parse_admission_entry)] allow: Vec, #[arg(long = "deny", value_parser = parse_admission_entry)] deny: Vec, #[arg(long, conflicts_with = "allow")] clear_allow: bool, #[arg(long, conflicts_with = "deny")] clear_deny: bool, }, #[command(about = "Replace a client's secret and print the new one once")] RotateClientSecret { #[arg(long, env = "RATLOGIN_STATE_DIR", default_value = "/var/lib/ratlogin")] state_dir: PathBuf, #[arg(long)] id: String, }, #[command(about = "Delete a client so it can no longer authenticate")] RevokeClient { #[arg(long, env = "RATLOGIN_STATE_DIR", default_value = "/var/lib/ratlogin")] state_dir: PathBuf, #[arg(long)] id: String, }, #[command(about = "List stored clients as a table")] ListClients { #[arg(long, env = "RATLOGIN_STATE_DIR", default_value = "/var/lib/ratlogin")] state_dir: PathBuf, }, #[command(about = "Revoke every remembered sign-in for a DID")] RevokeSessions { #[arg(long, env = "RATLOGIN_STATE_DIR", default_value = "/var/lib/ratlogin")] state_dir: PathBuf, #[arg(long)] did: String, }, } #[derive(Debug, thiserror::Error)] pub enum CliError { #[error(transparent)] Config(#[from] ConfigError), #[error(transparent)] Key(#[from] KeyError), #[error(transparent)] Database(#[from] DatabaseError), #[error(transparent)] Serve(#[from] ServeError), #[error(transparent)] State(#[from] StateError), #[error(transparent)] Clients(#[from] ClientStoreError), #[error("invalid DID: {0}")] Did(#[from] IdentityError), #[error("cannot write output: {0}")] Output(#[from] std::io::Error), } fn parse_admission_entry(value: &str) -> Result { AdmissionEntry::parse(value).map_err(|error| error.to_string()) } fn parse_redirect_uri(value: &str) -> Result { RedirectUri::parse(value).map_err(|error| error.to_string()) } fn admission_column(admission: &Admission) -> String { if admission.is_open() { return "open".to_owned(); } let list = |entries: &[AdmissionEntry]| { if entries.is_empty() { return "-".to_owned(); } entries .iter() .map(AdmissionEntry::to_string) .collect::>() .join(",") }; format!( "allow={} deny={}", admission.allow().map_or_else(|| "*".to_owned(), list), list(admission.deny()) ) } fn client_store(state_dir: &std::path::Path) -> Result { Ok(ClientStore::new(Database::open( &state_dir.join(database::FILE_NAME), )?)) } fn flow_store(state_dir: &std::path::Path) -> Result { Ok(FlowStore::new(Database::open( &state_dir.join(database::FILE_NAME), )?)?) } pub async fn run(cli: Cli, out: &mut impl Write) -> Result<(), CliError> { match cli.command { Command::Init { state_dir } => { server::init_tracing(); let keys = state::initialize(&state_dir)?; tracing::info!( rs256 = keys.signing.current(SigningAlgorithm::Rs256).kid(), es256 = keys.signing.current(SigningAlgorithm::Es256).kid(), client = keys.client.kid(), state_dir = %state_dir.display(), "state initialized" ); Ok(()) } Command::Serve { config, state_dir } => { server::init_tracing(); let config = ServerConfig::load(&config, state_dir)?; let keys = state::open(&config.state_dir)?; server::serve(config, keys).await?; Ok(()) } Command::RotateKey { state_dir } => { server::init_tracing(); let keys = state::rotate(&state_dir)?; tracing::info!( rs256 = keys.signing.current(SigningAlgorithm::Rs256).kid(), es256 = keys.signing.current(SigningAlgorithm::Es256).kid(), client = keys.client.kid(), "keys rotated" ); Ok(()) } Command::AddClient { state_dir, id, redirect_uris, email, signing, allow, deny, insecure_redirects, } => { let secret = client_store(&state_dir)?.create( NewClient { id: ClientId::new(id), redirect_uris, policy: ClientPolicy { email, signing, admission: Admission::new((!allow.is_empty()).then_some(allow), deny), insecure_redirects, }, }, Timestamp::now(), )?; writeln!(out, "{secret}")?; Ok(()) } Command::SetClientAccess { state_dir, id, allow, deny, clear_allow, clear_deny, } => { let store = client_store(&state_dir)?; let id = ClientId::new(id); let current = store .get(&id)? .ok_or_else(|| ClientStoreError::Unknown(id.clone()))?; let allow = match (clear_allow, allow.is_empty()) { (true, _) => None, (false, true) => current.admission().allow().map(<[AdmissionEntry]>::to_vec), (false, false) => Some(allow), }; let deny = match (clear_deny, deny.is_empty()) { (true, _) => Vec::new(), (false, true) => current.admission().deny().to_vec(), (false, false) => deny, }; store.set_access(&id, &Admission::new(allow, deny))?; Ok(()) } Command::RotateClientSecret { state_dir, id } => { let secret = client_store(&state_dir)?.rotate_secret(&ClientId::new(id))?; writeln!(out, "{secret}")?; Ok(()) } Command::RevokeClient { state_dir, id } => { client_store(&state_dir)?.revoke(&ClientId::new(id), Timestamp::now())?; Ok(()) } Command::RevokeSessions { state_dir, did } => { let did = Did::parse(&did)?; let revoked = flow_store(&state_dir)?.revoke_sessions_for(&did, Timestamp::now())?; writeln!(out, "{revoked}")?; Ok(()) } Command::ListClients { state_dir } => { for stored in client_store(&state_dir)?.list()? { let client = &stored.client; let redirects: Vec<&str> = client .redirect_uris() .iter() .map(RedirectUri::as_str) .collect(); let status = if stored.revoked_at.is_some() { "revoked" } else { "active" }; writeln!( out, "{}\t{}\t{}\t{}\t{}\t{}", client.id(), status, redirects.join(","), client.email_policy().as_str(), client.signing_algorithm().as_str(), admission_column(client.admission()) )?; } Ok(()) } } } #[cfg(test)] mod tests { use super::*; async fn ratlogin(state_dir: &std::path::Path, args: &[&str]) -> Result { let mut full = vec!["ratlogin", args[0], "--state-dir"]; full.push(state_dir.to_str().expect("utf8 path")); full.extend(&args[1..]); let mut out = Vec::new(); run(Cli::try_parse_from(full).expect("valid"), &mut out).await?; Ok(String::from_utf8(out).expect("utf8")) } #[test] fn the_command_line_has_defaults_and_required_arguments() { assert_eq!( Cli::try_parse_from(["ratlogin", "serve"]) .expect("defaults") .command, Command::Serve { config: PathBuf::from("/etc/ratlogin/config.toml"), state_dir: PathBuf::from("/var/lib/ratlogin"), } ); assert_eq!( Cli::try_parse_from(["ratlogin", "rotate-key", "--state-dir", "/var/lib/x"]) .expect("valid rotate-key") .command, Command::RotateKey { state_dir: PathBuf::from("/var/lib/x") } ); let add = ["ratlogin", "add-client", "--id", "y"]; let redirect = ["--redirect-uri", "https://app.example/cb"]; for (name, extra) in [ ("no redirect URI", vec![]), ( "a malformed admission entry", [&redirect[..], &["--allow", "*bad"]].concat(), ), ( "an unknown email policy", [&redirect[..], &["--email", "sometimes"]].concat(), ), ( "an unknown algorithm", [&redirect[..], &["--signing-algorithm", "HS256"]].concat(), ), ] { assert!( Cli::try_parse_from([&add[..], &extra].concat()).is_err(), "{name}" ); } assert!(Cli::try_parse_from([&add[..], &redirect].concat()).is_ok()); } #[tokio::test] async fn revokes_remembered_sessions_for_a_did() { let dir = tempfile::tempdir().expect("tempdir"); let store = flow_store(dir.path()).expect("store"); let now = Timestamp::now(); let alice = Did::parse("did:plc:aaaaaaaaaaaaaaaaaaaaaaaa").expect("did"); let value = store .create_session(crate::flows::BrowserSession { did: alice.clone(), handle: crate::identity::Handle::parse("alice.example.com").expect("handle"), auth_time: now, expires_at: now + jiff::SignedDuration::from_hours(1), email: None, }) .expect("session"); let out = ratlogin(dir.path(), &["revoke-sessions", "--did", alice.as_str()]) .await .expect("revoke"); assert_eq!(out, "1\n"); assert!(store.session(&value, now).expect("lookup").is_none()); } #[tokio::test] async fn stored_clients_are_managed_from_the_command_line() -> Result<(), CliError> { let dir = tempfile::tempdir().expect("tempdir"); let dir = dir.path(); assert_eq!(ratlogin(dir, &["init"]).await?, ""); assert_eq!(state::inspect(dir)?, state::Contents::Complete); assert_eq!(ratlogin(dir, &["rotate-key"]).await?, ""); assert!(dir.join(crate::keys::PREVIOUS_FILE_NAME).exists()); let native = [ "--id", "immich", "--redirect-uri", "app.immich:///oauth-callback", ]; let error = ratlogin(dir, &[&["add-client"], &native[..]].concat()) .await .expect_err("custom schemes need the flag"); assert!( matches!( error, CliError::Clients(ClientStoreError::Client( crate::clients::ClientError::InvalidRedirect { .. } )) ), "{error:?}" ); let flagged = [ &["add-client"], &native[..], &["--allow-insecure-redirects", "--email", "required"], &["--allow", "*.example.com", "--deny", "bob.example.com"], &["--allow", "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"], ] .concat(); let secret = ratlogin(dir, &flagged).await?; assert_eq!(secret.trim().len(), database::random_token().len()); let client = client_store(dir)? .get(&ClientId::new("immich"))? .ok_or_else(|| ClientStoreError::Unknown(ClientId::new("immich")))?; assert!(client.authenticate(secret.trim().as_bytes())); assert!(client.insecure_redirects()); assert_eq!( ratlogin(dir, &["list-clients"]).await?, "immich\tactive\tapp.immich:///oauth-callback\trequired\tES256\t\ allow=*.example.com,did:plc:aaaaaaaaaaaaaaaaaaaaaaaa deny=bob.example.com\n" ); let access = ["set-client-access", "--id", "immich"]; ratlogin( dir, &[&access[..], &["--allow", "alice.example.com"]].concat(), ) .await?; assert!( ratlogin(dir, &["list-clients"]) .await? .contains("allow=alice.example.com deny=bob.example.com"), "an unmentioned list is kept" ); ratlogin( dir, &[&access[..], &["--deny", "carol.example.com"]].concat(), ) .await?; assert!( ratlogin(dir, &["list-clients"]) .await? .contains("allow=alice.example.com deny=carol.example.com") ); ratlogin(dir, &[&access[..], &["--clear-deny"]].concat()).await?; assert!( ratlogin(dir, &["list-clients"]) .await? .contains("allow=alice.example.com deny=-") ); ratlogin(dir, &[&access[..], &["--clear-allow"]].concat()).await?; assert!(ratlogin(dir, &["list-clients"]).await?.contains("\topen")); let rotated = ratlogin(dir, &["rotate-client-secret", "--id", "immich"]).await?; assert_ne!(rotated, secret); ratlogin(dir, &["revoke-client", "--id", "immich"]).await?; assert!( ratlogin(dir, &["list-clients"]) .await? .contains("\trevoked\t") ); for command in ["rotate-client-secret", "set-client-access"] { let error = ratlogin(dir, &[command, "--id", "immich"]) .await .expect_err("revoked clients cannot be changed"); assert!( matches!(error, CliError::Clients(ClientStoreError::Unknown(_))), "{command}: {error:?}" ); } let error = ratlogin(dir, &["serve", "--config", "/nonexistent/config.toml"]) .await .expect_err("no configuration"); assert!( matches!(error, CliError::Config(ConfigError::Read { .. })), "{error:?}" ); struct Closed; impl Write for Closed { fn write(&mut self, _: &[u8]) -> std::io::Result { Err(std::io::ErrorKind::BrokenPipe.into()) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } let list = ["ratlogin", "list-clients", "--state-dir"]; let list = Cli::try_parse_from([&list[..], &[dir.to_str().expect("utf8 path")]].concat()) .expect("valid"); assert!( matches!(run(list, &mut Closed).await, Err(CliError::Output(_))), "a closed pipe is an error, not a panic" ); let absent = dir.join("absent"); for command in [ &["list-clients"][..], &["revoke-sessions", "--did", "did:web:a.example"], ] { let error = ratlogin(&absent, command) .await .expect_err("no state directory"); assert!(matches!(error, CliError::Database(_)), "{error:?}"); } Ok(()) } }