Something went wrong. Try again.
An OIDC provider for AT Protocol OAuth. Rust port of apenwarr/atlogin. forked from quilling.dev/ratlogin
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517use 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<RedirectUri>, #[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<AdmissionEntry>, #[arg(long = "deny", value_parser = parse_admission_entry)] deny: Vec<AdmissionEntry>, #[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<AdmissionEntry>, #[arg(long = "deny", value_parser = parse_admission_entry)] deny: Vec<AdmissionEntry>, #[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, String> { AdmissionEntry::parse(value).map_err(|error| error.to_string())}
fn parse_redirect_uri(value: &str) -> Result<RedirectUri, String> { 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::<Vec<String>>() .join(",") }; format!( "allow={} deny={}", admission.allow().map_or_else(|| "*".to_owned(), list), list(admission.deny()) )}
fn client_store(state_dir: &std::path::Path) -> Result<ClientStore, CliError> { Ok(ClientStore::new(Database::open( &state_dir.join(database::FILE_NAME), )?))}
fn flow_store(state_dir: &std::path::Path) -> Result<FlowStore, CliError> { 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<String, CliError> { 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<usize> { 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(()) }}