Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100//! The `atgc auth` verbs: log in, log out, and say who atgc acts as.//!//! The OAuth client under all of this is//! [`crate::clients::atproto::oauth`] — the jacquard wiring, the loopback//! callback server, the session store. What is here is the command surface//! over it: which account a verb acts on, what it writes to the account//! registry, and what it prints.//!//! That division is also where the layering rule falls. A client is handed//! the DID it is to act for; choosing that DID out of//! `crate::config::account` is a command's job, which is why [`login`] and//! [`agent_for_did`] are on this side of the line and the `start_auth`,//! `callback` and `restore` calls they make are on the other.//!//! The pages a browser is served when the callback fires are//! [`crate::html::pages`], putting one on the socket is//! [`crate::clients::atproto::oauth::respond_html`], and reading a DID//! document is [`crate::clients::atproto::did`].
use crate::clients::atproto::did::{Identity, handle_from_did_doc, pds_from_did_doc};use crate::clients::atproto::oauth::Session;use crate::clients::atproto::oauth::client::{ client_metadata, derived_client_id, login_metadata, oauth_client, scopes,};use crate::clients::atproto::oauth::login::{ LOGIN_TIMEOUT_SECS, PanicCapture, auth_state_keys, discard_auth_state, join_failure, state_param, state_written_by, wait_for_callback,};use crate::clients::atproto::oauth::respond_html;use crate::clients::atproto::oauth::sessions::{SessionState, StoredSession, prune_stale_sessions};use crate::clients::tangled::scope::{SCOPES, missing_scopes, scope_gap, writer_of};use crate::html::pages::{failure_page, success_page};
use anyhow::{Context, Result, anyhow, bail};use jacquard::client::Agent;use jacquard::common::DefaultStr;use jacquard::oauth::scopes::Scopes;use jacquard::oauth::types::AuthorizeOptions;use jacquard::types::string::Did;use std::io::IsTerminal;use tokio::net::TcpListener;
/// Tangled's website, where every `view:` link and `url` field points. The/// address and its override are [`crate::clients::endpoints`]'s; this is the/// short spelling the URL builders below use.fn appview() -> String { crate::clients::endpoints::appview()}
/// Resume a specific account's session, refreshing the token if needed./// Never opens a browser.////// Always by explicit DID, and there is deliberately no "just give me an/// agent" entry point any more: the old code resumed with an "any" hint,/// which picks an arbitrary stored session — harmless with one account, a/// coin flip with two. Callers select an account first (and announce it, if/// they are about to write something), then resume that exact account./// The session this process may use for `did`, or a refusal saying why not.////// Three cases, and the third is the one this function exists for.////// The registry names which session is this plane's, so the ordinary answer/// is a lookup. A grant recorded before that id was kept has none, and only/// the human plane can be in that state — for it, the old scan over the store/// is exactly what it always used, so nothing regresses for an account that/// has not logged in since.////// And an account with a grant on the *other* plane only gets a refusal. An/// agent is not handed the person's credentials, even though they would work:/// both grants act as the same DID, so the fallback would publish under/// somebody's name with nothing anywhere recording that it was not them. That/// is the state planes exist to end, and a fallback would preserve it under a/// new name. One login is the cost of not doing that.fn plane_session(did: &str, plane: crate::config::account::Plane) -> Result<StoredSession> { use crate::config::account::Plane;
if let Some(found) = crate::config::account::session_for(did, plane) { return Ok(found); }
let handle = crate::config::account::cached_handle(did); let who = crate::config::account::display_account(did, handle.as_deref()); let named = handle.as_deref().unwrap_or(did); let other = match plane { Plane::Agent => Plane::Human, Plane::Human => Plane::Agent, }; if crate::config::account::has_grant(did, other) { crate::logging::oauth::emit(crate::logging::oauth::Event::PlaneRefused { did: did.to_string(), wanted: plane.as_str().to_string(), held: Some(other.as_str().to_string()), }); return Err(crate::exit::fail( crate::exit::Exit::NoSession, format!( "{who} has {} here, but no {}\n\ both would act as the same account, so atgc will not borrow the other \ one's credentials: work published that way is indistinguishable from \ theirs afterwards\n\ log in again from here with `atgc auth login {named}` to make {}", other.login(), plane.bare_login(), plane.login(), ), )); } Err(anyhow!( "no OAuth session for {who}\n\ either nothing has ever logged in as this account, or a refresh was refused \ and the session was deleted\n\ re-authorize with `atgc auth login {named}`; the account itself stays known" ))}
pub(in crate::cmd) async fn agent_for_did(did: &str) -> Result<Agent<Session>> { resume(did).await.map(|(agent, _)| agent)}
/// [`agent_for_did`], handing back the session it resumed as well.////// For the two callers that do not only *use* the session but reach into the/// store for it — `auth token` prints its access token, `atgc api` signs with/// its key. Both used to resolve it a second time on their own, and two/// lookups that must agree and are written down separately are one edit away/// from disagreeing: sign with a session other than the one just refreshed and/// the token is stale, or worse, belongs to the other plane.////// So the resumed session is returned rather than looked up again. A caller/// cannot act on a different one than was refreshed, because there is only one/// to have.pub(in crate::cmd) async fn resume(did: &str) -> Result<(Agent<Session>, StoredSession)> { let plane = crate::config::account::Plane::current(); let stored = plane_session(did, plane)?; let stored = &stored; // The client_id the grant was issued to, recorded at login. Presenting // any other one gets the refresh refused and the session deleted; see // `client_metadata`. let granted = crate::config::account::client_id(did, plane); let metadata = client_metadata(granted.as_deref())?; crate::logging::oauth::emit(crate::logging::oauth::Event::Restore { did: did.to_string(), session_id: stored.session_id.clone(), client_id: derived_client_id(&metadata), client_id_source: match granted { Some(_) => crate::logging::oauth::ClientIdSource::Granted, None => crate::logging::oauth::ClientIdSource::Unrecorded, }, plane: plane.as_str().to_string(), }); let oauth = oauth_client(metadata)?; let did_key = Did::<DefaultStr>::new_owned(did).map_err(|e| anyhow!("{did} is not a valid DID: {e}"))?; // restore() refreshes an expired access token in place, so an expired // token is not an error path — only a refresh that fails is. // Bounded, because `restore` holds the config-directory lock across the // refresh (see `crate::config::lock`): an authorization server that stops // answering must not make every other atgc process on this machine fail // while waiting on this one. let session = crate::config::lock::under_hold_budget( "refresh this account's token", oauth.restore(&did_key, &stored.session_id), ) .await? .map_err(|e| { crate::logging::debug::dump_err("session restore error", &e); crate::logging::oauth::emit(crate::logging::oauth::Event::RestoreFailed { did: did.to_string(), session_id: stored.session_id.clone(), error: crate::logging::oauth::Scrubbed::of(&e), }); // A refused refresh has already cost the session by the time this // runs — jacquard deletes it when the error classifies as // permanent — so this is the last chance to say whose session it // was and why it is gone. Naming the account matters because the // next command will report it as simply absent. let who = crate::config::account::display_account( did, crate::config::account::cached_handle(did).as_deref(), ); let unrecorded = granted.is_none(); // `Scrubbed::shown`, not `{e}`. This is `session::Error`, whose // `ServerAgent` variant is `#[error(transparent)]` over an error // that formats the token endpoint's response body into its own // `Display` — so a server that echoed the request parameters back // would print them on an ordinary terminal, with no `--debug` // anywhere in it. anyhow!( "could not refresh the session for {who}: {}\n\ {}\n\ log in again with `atgc auth login <handle|did>`; the account stays known", crate::logging::oauth::Scrubbed::shown(&e), if unrecorded { "this grant predates atgc recording its client_id, so none could be presented" } else { "it may have run out: localhost OAuth grants are capped at two weeks" }, ) })?; Ok((Agent::from(session), stored.clone()))}
/// Milliseconds since `started`, saturated into the `u64` the log carries.////// `as_millis` is a `u128` and every OAuth log field is a `u64`; the cast is/// lossless for any elapsed time short of half a billion years, but writing/// the saturation down is cheaper than arguing about it.fn elapsed_ms(started: std::time::Instant) -> u64 { started.elapsed().as_millis().try_into().unwrap_or(u64::MAX)}
/// The host a URL points at, for naming the site the browser should be/// showing. `None` for anything that is not an absolute http(s) URL, which/// no authorization URL is — the fallback exists so a malformed one costs a/// vague word rather than a panic.fn host_of(url: &str) -> Option<&str> { let rest = url .strip_prefix("https://") .or_else(|| url.strip_prefix("http://"))?; let host = rest .find(['/', '?', '#']) .map_or(rest, |end| &rest[..end]) // Userinfo is legal in a URL and belongs to nobody's mental model // of "which site is this"; the host is what comes after it. .rsplit('@') .next()?; (!host.is_empty()).then_some(host)}
/// What `auth login` says once the authorization URL exists.////// An OAuth authorization URL is a few hundred characters of PAR request/// and it used to be printed in full, every time, wrapped across a/// terminal's width — the one line of a successful login nobody could read/// past. What a person actually needs is the name of the site their/// browser should be on, and a way in if it is not: in a terminal that/// renders OSC 8 that way in is a link, and the URL never appears. Where/// it cannot be a link — a pipe, a redirect, a terminal without the escape/// — the URL is printed exactly as before, because there it is the only/// way through.////// `opened` only decides the wording, never whether a way in is offered:/// [`BrowserOpen::Opened`](crate::term::noinput::BrowserOpen::Opened) means a/// launcher exited zero, which is a weaker claim than "the user is looking/// at a consent page".fn login_prompt( auth_url: &str, opened: crate::term::noinput::BrowserOpen, clickable: bool,) -> String { use crate::term::noinput::BrowserOpen;
let host = host_of(auth_url).unwrap_or("your PDS"); let link = |text: &str| crate::term::hyperlink::wrap_when(clickable, auth_url, text); match opened { BrowserOpen::Opened if clickable => format!( "opened {host} in your browser to authenticate\ndidn't open? {}", link("click here instead") ), BrowserOpen::Opened => { format!( "opened {host} in your browser to authenticate\ndidn't open? visit:\n{auth_url}" ) } // `Skipped` is unreachable from here — a non-interactive login is // refused above — but the wording is kept honest anyway, since // "could not" and "did not try" are different reports. _ => { let head = if matches!(opened, BrowserOpen::Failed) { "could not open a browser" } else { "no browser was opened" }; if clickable { format!( "{head}: {}", link(&format!("click here to authenticate with {host}")) ) } else { format!("{head}: to authenticate with {host}, visit:\n{auth_url}") } } }}
/// The `atgc auth` verbs.#[derive(clap::Subcommand, Debug)]pub(crate) enum Command { /// Log in with an ATProto account via OAuth (additive: other accounts stay) /// /// Opens a browser at the consent page and waits up to five minutes for /// the redirect. A session with nobody at it (piped stdin, --no-input) /// opens no browser, so whatever is driving it can open the page itself. /// A CI job is refused instead: it has nothing that could open the page, /// so the wait could only end in the timeout. /// /// Off a terminal, stdout is the authorization URL and nothing else: /// read one line, open it, and this command goes on waiting for the /// callback. There is no --json — the URL is printed long before there is /// a result to report — so the result is the exit status, and `atgc auth /// status --json` is what says who was logged in. /// /// Logging in does not enable `git push`: a knot authorizes pushes by SSH /// key, which `atgc key add` registers per account. Login { /// Handle or DID to log in as, e.g. alice.example.com /// /// Either names the account. A handle is a name pointing at a DID /// and the DID is the account itself, so a DID keeps working through /// a rename and when a handle has stopped resolving. /// /// Named `owner`, not `account`, for the reason `auth default` /// gives: the global `--account` selector owns that argument id. #[arg(value_name = "HANDLE|DID")] owner: String, /// Record this login as an agent's, not a person's /// /// One account can be logged in twice — once for the person who owns /// it and once for an agent acting on their behalf — so that either /// can be replaced or revoked without disturbing the other. atgc /// works this out on its own when an agent logs itself in; this is /// for a person setting an agent's grant up from their own terminal, /// where it would otherwise be recorded as theirs. #[arg(long)] agent: bool, }, /// List the logged-in accounts and the acting one's session details /// /// `--json` prints one object: an `accounts` array of DID, handle, /// whether it is the one resolved, its session state as one word, and the /// scopes this build asks for that the login does not carry, and a `resolved` /// object for the account commands act as, `null` when none could be /// selected. Why none could be is a note on stderr. /// /// Handles come from the cache unless it has gone stale, and always do /// for an agent's login; `--refresh` re-reads them all. How many were /// cached is a note on stderr. Status { /// List the agent logins instead of the people's /// /// One account can be logged in twice, once for the person who owns /// it and once for an agent acting on their behalf. A machine running /// a fleet has an entry per agent, and burying the two or three that /// belong to people among them is how the listing stops being worth /// reading. So the agents are not shown unless asked for. #[arg(long, conflicts_with = "all")] agent: bool, /// List every login, on both planes #[arg(long)] all: bool, /// Re-read every handle from its DID document, however many there are /// /// Ordinarily this command reads a handle back only when the cached /// one has gone stale, and never for an agent's login: an agent is /// named by its DID and renaming one is not a thing that happens, so /// a fleet's worth of round trips buys a column nobody is reading. /// This spends them anyway, which is what to reach for when a handle /// has actually moved. #[arg(long)] refresh: bool, /// Print one JSON object instead of the listing (see --help) #[arg(long)] json: bool, }, /// Set the account atgc falls back to when nothing else says /// /// The last rank of five, and the weakest: `--account`, `ATGC_ACCOUNT` /// and the checkout's own identity all outrank it, and a linked worktree /// does not consult it at all. It was `auth default` and it named itself /// badly — "switching" reads as changing what the tool *is*, when this /// only decides what happens in a checkout that says nothing. /// /// `--json` prints the account it now names: `did`, `handle`. Whatever /// still outranks it is a note on stderr, since it is about the machine /// rather than about this account. Default { /// Handle or DID of an account you are logged in to /// /// Named `owner`, not `account`, because clap derives an argument's /// id from the field name and the global `--account` selector owns /// that id: sharing it made `--account` silently land in this /// positional instead of choosing who to act as. #[arg(value_name = "HANDLE|DID")] owner: String, /// Print one JSON object instead of the sentence (see --help) #[arg(long)] json: bool, }, /// Refresh the acting account's access token /// /// `--json` prints `did`, `handle`, `session` (`live`, `expired` or /// `missing`) and `expires_at`, the same three words and stamp /// `auth status --json` uses for a row. Refresh { /// Print one JSON object instead of the sentence (see --help) #[arg(long)] json: bool, }, /// Print the acting account's access token (DPoP-bound; see `--help`) /// /// `--json` prints `did`, `handle` and `access_token`. The bare form /// stays exactly the token and a newline, so `atgc auth token | …` /// keeps working. Token { /// Print one JSON object instead of the bare token (see --help) #[arg(long)] json: bool, }, /// Log out of one account, or with --all, every account /// /// `--json` prints `logged_out`, an array of the accounts forgotten by /// this run, and `remaining`, the accounts still logged in. Both are /// arrays of `did`/`handle` objects, and `logged_out` is `[]` rather /// than absent when there was nothing to forget. Logout { /// Handle or DID (defaults to the acting account) /// /// Named `owner`, not `account`, for the reason `repo list` gives: /// the global `--account` selector owns that argument id. #[arg(value_name = "HANDLE|DID")] owner: Option<String>, /// Log out of every account #[arg(long)] all: bool, /// Print one JSON object instead of the lines (see --help) #[arg(long)] json: bool, },}
/// Run whichever `auth` verb was parsed.pub(crate) async fn run(command: Command) -> Result<()> { match command { Command::Login { owner, agent } => login(&owner, agent).await, Command::Status { agent, all, refresh, json, } => status(agent, all, refresh, json).await, Command::Default { owner, json } => set_default(&owner, json).await, Command::Refresh { json } => refresh(json).await, Command::Token { json } => token(json).await, Command::Logout { owner, all, json } => logout(owner, all, json).await, }}
/// Hand stdout to the authorization URL: exactly the URL and a newline.////// The whole contract off a terminal. A caller reads this line *while the/// command keeps running* — the callback it is about to wait for is the/// caller's job to deliver — so the newline has to arrive now rather than/// whenever the process exits. `Stdout` is a `LineWriter` and flushes on it/// already; the flush is explicit because a caller blocked on a line it will/// never get, while atgc blocks on the callback that line leads to, is a/// deadlock with no message in it, and that promise should be visible at the/// one place that makes it.////// The bytes themselves are [`write_auth_url`], which is where the "and/// nothing else" half is checkable.fn print_auth_url(auth_url: &str) -> Result<()> { use std::io::Write; let mut stdout = std::io::stdout().lock(); write_auth_url(&mut stdout, auth_url).context("failed to print the authorization URL")?; stdout .flush() .context("failed to flush the authorization URL")}
/// The URL and a newline, and nothing else, to `out`.////// Split from [`print_auth_url`] so the contract is a test rather than a/// promise: no prose, no leading label, and — unlike every other place this/// URL is printed — no OSC 8 escape around it. `login_prompt` wraps it for a/// person, who reads a site name and clicks; a caller here reads the line and/// opens it, and each of those decorations is something it would have to/// undo. The one occurrence `login_prompt`'s own tests count is the reason to/// state this separately instead of reusing it.fn write_auth_url(out: &mut impl std::io::Write, auth_url: &str) -> std::io::Result<()> { writeln!(out, "{auth_url}")}
/// A line of the login's own result, on whichever stream is free to take it.////// A terminal gets stdout, exactly as before. A pipe does not: stdout has/// been handed to the authorization URL and nothing else may land there, so/// the same words go to stderr as steps — which is where [`crate::term::say`]/// puts everything a command says beside its payload, and is already the/// stream a caller reading one line off stdout is not parsing.fn said(piped: bool, line: &str) { match piped { true => crate::term::say::step!(Auth, "{line}"), false => println!("{line}"), }}
pub(crate) async fn login(input: &str, agent: bool) -> Result<()> { // Checked before a listener is bound or a browser is opened. `atgc auth // login me@gmail.com` is an easy thing to type — it is what "log in" // means on every other service — and it used to get as far as the PDS // discovery request before failing as an OAuth error. let identifier = crate::lexicon::identity::classify(input)?; let input = identifier.as_str();
// Which plane this login is *for*, decided here and written down with the // grant. This is the one moment the environment is consulted: afterwards // every command reads the recorded plane instead, so an agent that forgot // to declare itself is caught while it is authorizing rather than a week // later. `--agent` is for a person provisioning an agent's grant from // their own terminal, where the environment would say the wrong thing. let plane = match agent { true => crate::config::account::Plane::Agent, false => crate::config::account::Plane::current(), }; crate::term::say::step!(Auth, "logging in as {}", plane.actor());
// A CI job is refused, and it is the only thing that is. It has no // desktop, no browser and nobody to consent, so an authorization URL // printed into one leads nowhere and the only ending available is the // five-minute timeout — five minutes of a job burning to reach a failure // that was certain at the start. Said now, cheaply, before a listener is // bound or a single request goes out. // // The escape hatch is named because this is an inference and inferences // are wrong sometimes: a self-hosted runner really might have something // that opens the page. if crate::term::noinput::in_ci() { crate::logging::oauth::emit(crate::logging::oauth::Event::AuthorizeFailed { handle: crate::logging::oauth::clip(input, 256), error: crate::logging::oauth::Scrubbed::text("refused: CI is set"), elapsed_ms: 0, }); // `Usage` and not `NoSession`: `3` means "run `atgc auth login`", and // this *is* `atgc auth login` refusing. What has to change is where // it was run, which is the command line's context rather than the // session's state. return Err(crate::exit::fail( crate::exit::Exit::Usage, "cannot log in from a CI job: CI is set, and consent needs a browser\n\ log in on a machine that has one; every later invocation shares the \ session it stores\n\ if something here really will open the page, unset CI" .to_string(), )); }
// Nothing else is refused. Logging in used to turn away every // non-interactive session, on the grounds that a wait nobody can end can // only time out. That was over-broad and is gone. // // It read one signal — is anybody at the keyboard — for a question that // is not about a keyboard at all: whether the URL can reach something // able to open it. A pipe answers yes surprisingly often. `atgc auth // login … | tee` has a person watching, and stdin is not a terminal; an // agent has no person and opens the page anyway. Both were refused, and // the second is the one this tool now expects. What the refusal actually // protected against was a five-minute silence, and printing the URL and // saying what is being waited for protects against that better, because // it also *works*. // // So there is no gate here now. `--no-input` keeps its meaning for this // command through [`crate::term::noinput::open_in_browser`], which does // not open a browser at somebody who said they are not there.
// Our own loopback server, so the browser lands on a real page instead // of a connection error after jacquard's listener goes away. let listener = TcpListener::bind(("127.0.0.1", 0)) .await .context("failed to bind a loopback port")?; let port = listener.local_addr()?.port(); let redirect = format!("http://127.0.0.1:{port}/oauth/callback");
let metadata = login_metadata(&redirect)?; // Captured before the metadata is consumed, because this exact string is // what the grant will be bound to and what every later refresh has to // present. It is written to the registry once the exchange succeeds. let client_id = metadata.client_id.as_str().to_string(); crate::logging::oauth::emit(crate::logging::oauth::Event::AuthorizeStart { handle: crate::logging::oauth::clip(input, 256), redirect_uri: redirect.clone(), port, client_id: derived_client_id(&metadata), scopes: crate::logging::oauth::clip(SCOPES, 512), }); let oauth = oauth_client(metadata)?;
// Everything `start_auth` does is network — resolve the handle to a DID, // the DID to a PDS, then fetch that PDS's protected-resource metadata and // its authorization server's — and every hop is a host atgc has not // contacted before. It used to run with no output at all, so a slow or // unreachable one was several seconds of a dead terminal that reads as a // hang. Say what is being waited on before waiting on it. crate::term::say::step!(Auth, "resolving {input}..."); crate::logging::debug::log(format!("start_auth {input}")); let started = std::time::Instant::now(); // Somebody else's abandoned states, cleared on the way past: this is the // one command that writes to that directory, and every listing reads it. // An hour is far past the five minutes a login can be waiting. crate::clients::atproto::oauth::sessions::sweep_stale_states(std::time::Duration::from_secs( 60 * 60, ));
// Read before the call, so the key `start_auth` is about to file can be // told from the ones already there. See `state_written_by`. let states_before = auth_state_keys();
let auth_url = oauth .start_auth( input, AuthorizeOptions { scopes: scopes()?, ..Default::default() }, ) .await .map_err(|e| { crate::logging::oauth::emit(crate::logging::oauth::Event::AuthorizeFailed { handle: crate::logging::oauth::clip(input, 256), error: crate::logging::oauth::Scrubbed::of(&e), elapsed_ms: elapsed_ms(started), }); // The whole chain, for anybody who asked for it. `Display` on a // jacquard error is the outermost link only: a resolution failure // says "error resolving identity" and nothing about the host it // could not reach, which is how a malformed PLC base -- every // login on the machine failing at the first step -- presented as // a sentence with no lead in it. The sentence stays the sentence; // `--debug` gets the cause. crate::logging::debug::log(format!( "start_auth failed: {}", crate::logging::oauth::Scrubbed::of(&e) )); // `start_auth`'s failure is a PAR refusal, so this is the same // `Display` hole as the refresh path above. The PAR body holds no // long-lived credential — a public client sends `client_id`, the // PKCE challenge and the scopes — but the shape that carries it // here is identical, and the one line that used to differ from // its siblings is the one that turned out to be wrong. anyhow!( "could not start login for {input}: {}", crate::logging::oauth::Scrubbed::shown(&e) ) })?;
crate::logging::debug::log(format!("start_auth returned in {}ms", elapsed_ms(started))); crate::logging::oauth::emit(crate::logging::oauth::Event::AuthorizeReady { handle: crate::logging::oauth::clip(input, 256), elapsed_ms: elapsed_ms(started), });
// The browser first, then the line about it: what to say depends on // whether anything opened, and the URL is long enough that printing it // unconditionally buries every other line of the login. // // There is no `--no-browser` beside this. It was written and taken back // out: within this command it was `--no-input` spelled a second time. // `open_in_browser` already opens nothing when anybody has said they are // not there — the flag, `ATGC_NO_INPUT`, `CI`, or a stdin that is not a // terminal — and `auth login` starts no git subprocess and reads no // stdin, so those were the *whole* of what `--no-input` meant here. Two // spellings of one behaviour is a question a reader has to answer twice. let opened = crate::term::noinput::open_in_browser(&auth_url); crate::logging::oauth::emit(crate::logging::oauth::Event::BrowserOpen { outcome: opened.label().to_string(), });
// Who is reading stdout decides the shape, and nothing else does. A // person at a terminal gets the site's name and a link, // because the URL is hundreds of characters and printing it at them was // the defect `login_prompt` exists to fix. A pipe or a file gets the URL // alone, because whatever is on the far end is going to open it, and // every word of prose beside it is a word to strip first. // // The same tty question `crate::term::hyperlink` asks, without the parts // that are about colour: `NO_COLOR` in a real terminal is a person who // wants no escapes, not a caller that wants a bare URL. let piped = !std::io::stdout().is_terminal(); if piped { print_auth_url(&auth_url)?; } else { println!( "{}", login_prompt(&auth_url, opened, crate::term::hyperlink::supported()) ); }
// Said to both, because both are about to wait the same five minutes for // the same thing. The terminal path used to say nothing here: a person // whose browser had opened watched a still cursor with no way of knowing // whether atgc was waiting on them, on the network, or on nothing at all, // and the only way to find out was to give up. That the caller reading // this is often a program does not change what the line is worth to a // person, and a line on stderr costs the program nothing. crate::term::say::step!( Auth, "waiting up to {LOGIN_TIMEOUT_SECS}s for the callback on {redirect}" );
// This login's own `state`: the key that identifies the entry // `start_auth` just wrote, so it can be taken back out if the login never // completes, and so a callback carrying somebody else's state — or none — // can be told from this login's. Nobody else may clean the entry up — see // `prune_stale_sessions` — so if this function does not, it stays until // the next `auth logout --all`. // // The URL first, for a server with no PAR endpoint, and the store // otherwise: under PAR the `state` is in the POST body and never appears // in the URL at all, which is every server atgc has met. let state = state_param(&auth_url).or_else(|| state_written_by(&states_before, &auth_state_keys())); crate::logging::debug::log(match &state { Some(_) => "identified this login's authorization state".to_string(), None => "could not identify this login's authorization state: its store entry will \ be left behind, and any callback on this port will be acted on" .to_string(), });
let waited = tokio::time::timeout( std::time::Duration::from_secs(LOGIN_TIMEOUT_SECS), wait_for_callback(&listener, state.as_deref()), ) .await; let (mut stream, params) = match waited { // A callback that arrived and was not usable — a denied consent, or // a redirect carrying neither a code nor an error — is as finished // as a timeout, and leaves the same entry behind. It used to return // straight through the `?` without cleaning up, which is the one of // the three exits that had no `discard_auth_state`. Ok(Err(e)) => { discard_auth_state(state.as_deref()).await; return Err(e); } Ok(Ok(pair)) => pair, Err(_) => { crate::logging::oauth::emit(crate::logging::oauth::Event::CallbackFailed { reason: format!("timed out after {LOGIN_TIMEOUT_SECS}s"), }); discard_auth_state(state.as_deref()).await; bail!("timed out waiting for the browser login"); } };
// The exchange runs on a task of its own so that a panic inside it // arrives here as a `JoinError` instead of ending the process. That is // not hypothetical: jacquard used to `expect` on the scope string in the // token response, and an authorization server granting one scope it // could not parse aborted atgc mid-callback — exit 101, a backtrace on // the terminal, and a browser holding a connection error because the // loopback server had died with the process. A panic in here is still a // bug; what this decides is that it is a *failed login* — one page, one // message, status 1 — rather than a dead port and two screens of // nothing. `oauth` is moved in because nothing below needs it again. let capture = PanicCapture::install(); let exchanged = tokio::spawn(async move { oauth.callback(params).await }).await; drop(capture);
let exchanged = match exchanged { Ok(result) => result, Err(join) => { // The hook's own words when there are any — they carry the file // and line, which is the half of a panic worth quoting in a bug // report — and the payload alone when there are not. let detail = PanicCapture::taken().unwrap_or_else(|| join_failure(join)); crate::logging::oauth::emit(crate::logging::oauth::Event::SessionEstablishFailed { error: crate::logging::oauth::Scrubbed::text(&detail), }); let shown = crate::logging::oauth::clip(&crate::logging::oauth::scrub_text(&detail), 2048); respond_html(&mut stream, 500, &failure_page(&shown)).await; discard_auth_state(state.as_deref()).await; bail!( "login failed: atgc panicked completing the token exchange\n{shown}\n\ this is a bug in atgc: `atgc report bug` files it, and \ `atgc logs oauth --incident` is what to paste" ); } };
match exchanged { Ok(session) => { let (did, session_id) = jacquard::client::AgentSession::session_info(&session) .await .context("session established but no session info")?; let did = did.as_str().to_string(); crate::logging::oauth::emit(crate::logging::oauth::Event::SessionEstablished { did: did.clone(), session_id: session_id.as_deref().unwrap_or_default().to_string(), }); if let Some(id) = session_id.as_deref() { // What this login may clear out. Only sessions that cannot be // the other plane's: a grant made before planes existed // records no session id, so on such a machine the first agent // login must remove nothing at all rather than guess and // delete the person's. let other = match plane { crate::config::account::Plane::Agent => crate::config::account::Plane::Human, crate::config::account::Plane::Human => crate::config::account::Plane::Agent, }; // // Read inside the closure, not before it. `prune_stale_sessions` // takes the config lock and calls this from under it, and that // lock covers the whole directory — so a registry read from // there cannot be overtaken, while one taken beforehand can. // The window is small and the loss is not: two logins racing on // one account, one per plane, and the first would prune the // second's brand-new session because the grant naming it was // written after this read. `load` takes no lock of its own, so // reading here nests nothing. let removable = |session_id: &str| { crate::config::account::may_remove_session( session_id, crate::config::account::session_id(&did, plane).as_deref(), crate::config::account::grant(&did, other).as_ref(), ) }; prune_stale_sessions(&did, id, &removable).await?; }
// Recorded here, before anything slow, and that placement is the // point. Between the exchange and this line the session already // exists in the store; until this line nothing says which plane it // belongs to. A crash in that window — a Ctrl-C, a hung // `Identity::fetch`, a closed laptop — leaves an agent's session // that reads as a person's, because an unattributed session is // exactly what a grant made before planes looks like and the // fallback treats it as theirs. So the window is a lock // acquisition rather than a network round trip and an HTTP // response. // // After the prune, not before: the prune needs the *previous* // session id on this plane to know what it may clear away, and // this overwrites it. crate::config::account::remember_grant( &did, plane, &client_id, session_id.as_deref().unwrap_or_default(), ) .await?;
let who = Identity::fetch(&did).await; respond_html(&mut stream, 200, &success_page(&who, &did)).await;
// Record the account. Logging in is additive and stops there: // it used to move the default pointer as well, which meant // authorizing a second account silently changed who atgc acted // as everywhere, including in scripts that had not been touched. // Adding a credential and choosing an identity are different // decisions, and only the first one was asked for here. crate::config::account::remember(&did, who.handle.as_deref()).await?;
// Names the plane, not just the account. The step line above said // which one this login was for before the browser opened, but the // success line is the one a person reads afterwards and the one // they will remember having seen -- and "which of the two grants // did I just make" is the question the whole feature turns on. // The plane is named only when it is the surprising one. A person // logging in as themselves has learned nothing from being told so, // and the sentence that told them read "logged in as @you for a // person". let as_whom = match plane { crate::config::account::Plane::Agent => ", as an agent", crate::config::account::Plane::Human => "", }; said( piped, &format!( "logged in as {}{as_whom}", crate::config::account::display_account(&did, who.handle.as_deref()), ), );
// Said once, here, where the grant is new. Nothing refuses the // login over it — see `unreadable_scopes` — but a word atgc // cannot interpret is a word it cannot check a command against // later, and silence would make that look like a scope nobody // asked for. let unreadable = unreadable_scopes(&did, plane); if !unreadable.is_empty() { crate::term::say::warning!( Auth, "this grant carries {} atgc cannot read: {}", if unreadable.len() == 1 { "a scope".to_string() } else { format!("{} scopes", unreadable.len()) }, unreadable.join(" ") ); }
let known = crate::config::account::known()?; let others: Vec<String> = known .iter() .filter(|a| a.did != did) .map(|a| crate::config::account::display_account(&a.did, a.handle.as_deref())) .collect(); if !others.is_empty() { said(piped, &format!("still logged in: {}", others.join(", "))); }
let mut standing = crate::config::account::Standing::read(&known); // The pointer still moves in exactly one case: there is no // usable pointer at all. Leaving it unset and relying on rule 5 // would look identical today and break tomorrow — a fresh // install would be fine with its sole account, and then the // *second* login would leave two accounts and no pointer, which // is the state selection refuses outright. Writing it now is // what makes "the first account stays the default" durable. if standing.pointer.is_none() { crate::config::account::set_default(&did).await?; standing.pointer = Some(did.clone()); // Only worth saying when it resolves something. On a fresh // install with one account there was nothing to resolve. if !others.is_empty() { said( piped, &format!( "default account is now {}", crate::config::account::display_account(&did, who.handle.as_deref()) ), ); } }
// What it takes to actually act as this account, computed from // the state right now rather than assumed to be `auth default` — // which is inert whenever ATGC_ACCOUNT or this checkout's own // identity is deciding. Nothing is printed when the answer is // "you already are". for line in crate::config::account::advise(&did, who.handle.as_deref(), &standing) .lines(&did, who.handle.as_deref()) { said(piped, &line); } Ok(()) } Err(e) => { crate::logging::oauth::emit(crate::logging::oauth::Event::SessionEstablishFailed { error: crate::logging::oauth::Scrubbed::of(&e), }); // The same sentence the terminal is about to get, on the screen // the person is actually looking at. Scrubbed rather than // formatted straight in: a token endpoint's error can echo the // request back, and this one is going into a document. let shown = crate::logging::oauth::clip( &crate::logging::oauth::scrub_text(&format!("{e}")), 2048, ); respond_html(&mut stream, 500, &failure_page(&shown)).await; // jacquard deletes the state itself when `callback` succeeds, and // not when it fails — so this is the path that would otherwise // leave one behind. discard_auth_state(state.as_deref()).await; // The worst of the three: the request that just failed is the // code-for-token exchange, so its body held the authorization // code and the PKCE verifier, and a token endpoint that quotes // the parameters back gets them printed by a plain `auth login`. bail!( "login failed: {}", crate::logging::oauth::Scrubbed::shown(&e) ); } }}
/// The words in this account's granted scope string that atgc cannot read.////// Read back out of the store rather than off the session, because the store/// is where the string the server actually sent is kept — jacquard's own/// parsed copy has already dropped whatever it could not spell.////// A grant is the authorization server's answer, and the scope vocabulary is/// the server's to extend, so a word this build's parser does not know is not/// grounds to refuse a working session; the vendored `Scopes::parse_lossy`/// keeps the rest and carries on. It is grounds to say something. The scope/// list is what decides whether a later command is refused, and/// [`crate::clients::tangled::scope`] reasons about it by name, so a token/// nothing here can interpret should be visible at the moment it arrives/// rather than in a 403 a week later.fn unreadable_scopes(did: &str, plane: crate::config::account::Plane) -> Vec<String> { let granted = crate::config::account::session_for(did, plane).and_then(|s| s.scope.clone()); granted.as_deref().map(unreadable_words).unwrap_or_default()}
/// The parsing half of [`unreadable_scopes`], one word at a time.////// Word by word rather than by handing the whole string to `Scopes::new`,/// which fails as a unit and so would name none of them.////// jacquard is asked first and is not the last word, because "atgc cannot/// read it" is a claim about atgc. A `repo` scope naming several collections/// is the standing example: jacquard's `RepoScope` holds one collection and/// so its parser rejects the whole token, while/// [`crate::clients::tangled::scope`] — which is what actually decides/// whether a command is refused — reads it fine. Warning about that one/// taught people to ignore the warning, and it named the only scope behind/// `atgc report`, so it read as a broken login every time.fn unreadable_words(scope: &str) -> Vec<String> { scope .split_whitespace() .filter(|word| Scopes::<DefaultStr>::new((*word).into()).is_err()) .filter(|word| !readable_repo_scope(word)) .map(str::to_string) .collect()}
/// Whether a word jacquard rejected is a `repo` scope atgc reads anyway.////// Each collection the token names is spelled back as the shorthand jacquard/// does accept, so the NSIDs are validated by the same parser as ever and/// the only thing forgiven is the multi-valued spelling itself.fn readable_repo_scope(word: &str) -> bool { crate::clients::tangled::scope::repo_collections(word).is_some_and(|named| { !named.is_empty() && named .iter() .all(|c| Scopes::<DefaultStr>::new(format!("repo:{c}").into()).is_ok()) })}
/// Forget one account, or every account.////// Purely local: tokens are dropped, not revoked, and expire on their own.////// The bare `atgc auth logout` used to wipe every session. With more than/// one account that is far too blunt, so it now logs out the *acting*/// account only and says what is left. That change can only ever destroy/// less than before, never more, and `--all` still does the old thing.async fn logout(account: Option<String>, all: bool, json: bool) -> Result<()> { // Which set of credentials this logout is giving up. Read once, here, and // used for both halves — the session store and the registry — so the two // cannot disagree about what was forgotten. let plane = crate::config::account::Plane::current(); crate::term::jsonout::init(json); let known = crate::config::account::known()?; if known.is_empty() { if json { crate::term::say::note!(Auth, "not logged in"); return crate::term::jsonout::emit(&LogoutJson { logged_out: Vec::new(), remaining: Vec::new(), }); } println!("not logged in"); return Ok(()); }
let targets: Vec<(String, Option<String>)> = if all { known .iter() .map(|a| (a.did.clone(), a.handle.clone())) .collect() } else { let selection = match account { Some(spec) => crate::config::account::lookup(&spec).await?, None => crate::config::account::select().await?, }; vec![(selection.did, selection.handle)] };
// Which of these this plane actually holds. Read before the store is // touched, because afterwards the answer is always "none" — and reporting // a logout for an account this plane was never logged in on tells somebody // their credentials are gone when they are not, which is a belief people // act on. It also contradicted the `still logged in` line two lines later. // // "Had something to give up" is a session *or* a recorded grant, not the // registry alone: an account known only from the store is what every // login made before the registry existed looks like, the fallback treats // its session as the person's, and the removal below will take it. Asking // only the registry answered no for exactly those, so the session went // and the report said nothing had — the same lie as the reverse, told the // other way round. let held: Vec<bool> = targets .iter() .map(|(did, _)| { all || crate::config::account::has_grant(did, plane) || crate::config::account::session_for(did, plane).is_some() }) .collect();
// Scoped, and deliberately so: `account::forget` below takes the same // lock, and `flock` does not let one process pass through its own hold. // The two edits are independent — a session and a registry entry — so // taking the lock twice in sequence is both correct and required. See // `crate::config::lock` on why nesting is banned rather than made re-entrant. // What could not be given up, for the report at the end. Only `--all` // fills it: a logout naming one account has nothing else to get on with. let mut failures: Vec<(String, anyhow::Error)> = Vec::new(); { // One identity at a time, each under its own lock. Sessions live in // the identity's own file now, so a logout of one account has no // business excluding another's refresh -- and that refresh is held // across a network round trip, so excluding it is expensive as well // as wrong. Nothing here ever holds two locks at once, which is what // keeps a fixed order from being needed. let mut before = 0usize; let mut after = 0usize; for (did, _) in &targets { // Per identity, and under `--all` a failure on one does not stop // the rest. "Forget everything" is usually asked in a hurry and // for a reason; one unreadable shard aborting the walk used to // leave the accounts before it logged out, the accounts after it // untouched, and an error that named neither group. Every failure // is collected and reported at the end instead, and the exit // status still says the command did not do all of what was asked. let outcome = async { let guard = crate::config::lock::take_async( crate::logging::oauth::Purpose::Store, crate::config::lock::Scope::Identity(did.clone()), ) .await?; let mut store = crate::clients::atproto::oauth::sessions::read_shard(did)?; let was = store.len(); // This plane's sessions, and only ones that cannot be the other // plane's. An agent and the person whose account it borrows hold // separate grants precisely so either can be given up without the // other noticing, and a logout that took both would make that // false at the moment it matters. `--all` is the exception: it is // the explicit "forget everything", so it takes every plane too. let other = match plane { crate::config::account::Plane::Agent => crate::config::account::Plane::Human, crate::config::account::Plane::Human => crate::config::account::Plane::Agent, }; let theirs = (!all) .then(|| crate::config::account::grant(did, other)) .flatten(); let ours = crate::config::account::session_id(did, plane); let prefix = format!("oauth:{did}/"); store.retain(|k, _| match k.strip_prefix(&prefix) { Some(session_id) => !crate::config::account::may_remove_session( session_id, ours.as_deref(), theirs.as_ref(), ), None => true, }); let now = store.len(); crate::clients::atproto::oauth::sessions::write_shard_for( &guard, did, &store, crate::logging::oauth::Reason::Logout, )?; Ok::<(usize, usize), anyhow::Error>((was, now)) } .await; match outcome { Ok((was, now)) => { before += was; after += now; } Err(e) if all => failures.push((did.clone(), e)), Err(e) => return Err(e), } } // Half-finished login states used to be cleared here too, on any // logout. They belong to *some* invocation, though: a concurrent // `atgc auth login` waiting on a consent screen has its PKCE // verifier in one, and dropping it makes that login fail at the // callback. Logging out one account is no reason to break another // process's login, so only `--all` -- which is already the explicit // "forget everything" -- still does it. Those have no identity, so // they are the global lock's business and a separate hold. if all { let guard = crate::config::lock::take_async( crate::logging::oauth::Purpose::Store, crate::config::lock::Scope::Global, ) .await?; crate::clients::atproto::oauth::sessions::drop_pending_states(&guard)?; } crate::logging::debug::log(format!("removed {} session store entries", before - after)); // The accounts actually given up, not the ones asked for. This log is // read to answer "when did this credential stop being usable", and an // entry naming an account whose sessions are still in the store // answers it wrongly -- in the direction that matters, since the // reader concludes a credential is dead when it is live. `before` and // `after` already count only what was written. crate::logging::oauth::emit(crate::logging::oauth::Event::Logout { all, targets: targets .iter() .map(|(did, _)| did.clone()) .filter(|did| !failures.iter().any(|(failed, _)| failed == did)) .collect(), keys_before: before, keys_after: after, }); }
for ((did, handle), held) in targets.iter().zip(&held) { let who = crate::config::account::display_account(did, handle.as_deref()); if !held { if !json { crate::term::say::note!( Auth, "{who} has no {}, so nothing here was given up", plane.bare_login() ); } continue; } // The plane's grant, not the account — unless `--all`, which means // forget everything. An account with no grants left on either plane // is removed entirely, which is what this always did back when there // was only one grant to have. // An account whose sessions could not be cleared is left whole. // Removing its registry entry now would take the `client_id` with it // and leave the credential file behind -- forgotten by the thing that // records accounts, still holding a refresh token. if failures.iter().any(|(failed, _)| failed == did) { continue; } let forgotten = match all { true => crate::config::account::forget(did).await, false => crate::config::account::forget_grant(did, plane).await, }; match forgotten { Ok(()) => { if !json { println!("logged out {who}"); } } Err(e) if all => failures.push((did.clone(), e)), Err(e) => return Err(e), } }
// Reported after the work, not instead of it: everything that could be // given up has been by now, and the exit status is what says the rest was // not. Naming each account is the point -- "some accounts failed" leaves // somebody who ran this to revoke credentials with no idea which ones are // still live. if !failures.is_empty() { let what = failures .iter() .map(|(did, e)| format!(" {did}: {e:#}")) .collect::<Vec<_>>() .join("\n"); anyhow::bail!( "logged out {} of {} account(s); these still hold credentials:\n{what}", targets.len() - failures.len(), targets.len() ); }
let left = crate::config::account::known()?; if json { return crate::term::jsonout::emit(&LogoutJson { logged_out: targets .iter() .zip(&held) .filter(|(_, held)| **held) .map(|((did, handle), _)| WhoJson { did: did.clone(), handle: handle.clone(), }) .collect(), remaining: left .iter() .map(|a| WhoJson { did: a.did.clone(), handle: a.handle.clone(), }) .collect(), }); } if left.is_empty() { println!("no accounts remain"); } else { println!( "still logged in: {}", left.iter() .map(|a| crate::config::account::display_account(&a.did, a.handle.as_deref())) .collect::<Vec<_>>() .join(", ") ); } Ok(())}
/// One logged-in account, as `auth status --json` prints it.#[derive(serde::Serialize, Debug, PartialEq)]pub(crate) struct AccountJson { pub did: String, /// Without the leading `@`, as everywhere else in `--json`. `null` when /// the DID document could not be read and nothing was cached. pub handle: Option<String>, /// Whether this is the account commands act as. Exactly one row can be /// `true`, and none is `true` when selection failed — the reason for /// which goes to stderr, since it is prose about the machine's state /// rather than a fact about any account. /// /// Named `resolved` and not `default`, which is a different thing one /// rank down: the default is what answers when nothing else does, and /// this is what actually answered, at whichever rank. A row can be /// resolved without being the default and the other way round. pub resolved: bool, /// `live`, `expired` or `missing` — one word, not the sentence the text /// view writes. pub session: &'static str, pub expires_at: Option<String>, /// Scopes this build asks for that this login does not carry, bare /// rather than as the "`command` will be refused" line the text view /// prints. Empty is the healthy case, and an empty list is also what a /// session recording no scope string at all reports: not knowing what /// was granted is not the same as knowing something is missing. pub scopes_missing: Vec<String>, /// Which planes this account is logged in on: `human`, `agent`, or both. /// /// A list rather than a word, because an account can hold one grant for /// the person who owns it and another for an agent acting on their /// behalf, and a caller filtering a fleet needs to see which it is /// looking at without running the command twice. pub planes: Vec<&'static str>,}
/// What `auth status --json` says about the account commands act as.////// The outcome of the five ranks, whichever one answered.#[derive(serde::Serialize, Debug, PartialEq)]pub(crate) struct ResolvedJson { pub did: String, pub handle: Option<String>, /// What chose this account — `--account`, `ATGC_ACCOUNT`, the repo's /// git config, `auth default`, or its being the only one. pub source: String, /// The host holding this account's records, from its DID document. pub pds: Option<String>, /// Every scope the session was granted, as the store recorded them. pub scopes: Vec<String>, pub bsky_url: String, pub tangled_url: String,}
/// `auth status --json`'s whole object.#[derive(serde::Serialize, Debug, PartialEq)]pub(crate) struct StatusJson { /// Every account atgc knows about, DID order. Empty when nothing is /// logged in — an empty list, not an absent one, so a caller counting /// accounts needs no special case. pub accounts: Vec<AccountJson>, /// `null` when no account could be selected, which is a thing this /// command exists to report rather than a failure to run it. pub resolved: Option<ResolvedJson>,}
/// Every account, with the active one marked, plus detail on the active one.////// This is deliberately the only listing command: an `auth list` that showed/// the same rows with less detail would just be a second place to look.async fn status(agent: bool, all: bool, refresh: bool, json: bool) -> Result<()> { crate::term::jsonout::init(json); // Which plane's logins this listing is about. // // People by default, and deliberately not "whichever plane this process // is on": somebody asking what is logged in on their machine wants their // own accounts, and making that depend on an environment variable would // mean one command showed two different things to the same person in two // terminals. A fleet's worth of agent logins buried among them is how the // listing stops being worth reading, so they are counted and named rather // than printed. // The default view is "not an agent's", rather than "has a human grant". // The difference is every account this command most needs to show: one // known only from the session store, one whose registry will not parse, // one granted before planes existed. None of those has a grant recorded // against a plane, and asking for a human grant would hide exactly the // accounts somebody runs `auth status` to find out about. use crate::config::account::Plane; // Both files, once, for the whole listing. // // Every question below used to read one of them per account, and asked // two or three per row: which planes it holds grants on, which session is // its own, what scopes that session carries. At three accounts that is // free. At ten thousand it was tens of thousands of parses of a two // megabyte registry and an eleven megabyte session store, and it made // this command take over five minutes with the network answering nothing // at all -- which is why bounding the round trips had not fixed it. let held = crate::config::account::Listing::read()?; let has = |did: &str, plane| held.has_grant(did, plane); // The rows report on the plane being listed, not on this process's. A // `live`/`expired` taken from the person's session while listing agents // would be describing a grant the reader did not ask about — and the two // are made at different moments, so they expire at different ones. let listing = match agent { true => Plane::Agent, false => Plane::Human, }; // Which plane a *row* describes. Ordinarily the one being listed — but // `--all` lists both, and forcing every row onto one plane would report // an agent-only account as having no session at all, in the mode whose // whole point is to leave nothing out. So there, each row speaks for the // grant it actually has, preferring this process's plane when it has both. let report_plane = |did: &str| match all { false => listing, true => { let mine = Plane::current(); match held.has_grant(did, mine) { true => mine, false => match held.has_grant(did, Plane::Agent) { true => Plane::Agent, false => Plane::Human, }, } } }; let all_known = held.known_with(report_plane); let (known, hidden): (Vec<_>, Vec<_>) = all_known.into_iter().partition(|a| { all || match agent { true => has(&a.did, Plane::Agent), false => !has(&a.did, Plane::Agent) || has(&a.did, Plane::Human), } }); if !hidden.is_empty() && !json { let (them, flag) = match agent { true => ("logged in for people", "atgc auth status"), false => ("logged in for agents", "atgc auth status --agent"), }; crate::term::say::note!(Auth, "{} more {them}; `{flag}` lists those", hidden.len()); } if known.is_empty() { // "Not logged in" is only true when nothing is. With `--agent` on a // machine full of people's logins it is the wrong sentence entirely: // the accounts are right there, just not on the plane that was asked // about, and telling somebody to log in when they already are is how // a filter reads as a fault. let empty = match (agent, hidden.is_empty()) { (true, false) => "no agent logins here: run `atgc auth login` from an agent, \ or `atgc auth login --agent <handle|did>` to make one from here" .to_string(), _ => "not logged in: run `atgc auth login <handle|did>`".to_string(), }; if json { crate::term::say::note!(Auth, "{empty}"); return crate::term::jsonout::emit(&StatusJson { accounts: Vec::new(), resolved: None, }); } println!("{empty}"); return Ok(()); }
// status is the "tell me the truth" command, so a handle the cache cannot // vouch for is re-read from the DID document and the cache repaired on // the way past. A DID doc lookup needs no token, so this still works for // an account whose session has run out. // // What is printed comes from the DID document either way, so the repair // is a side errand and `refresh_cached_handle` treats it as one: it // skips a registry another atgc holds the lock on, skips one it could // not read rather than overwriting it, and skips the write entirely // while the cache already agrees. This is the command somebody runs // *because* something about their accounts is wrong, and the loop is // once per account, so on a machine full of agents it was the likeliest // command in the tool to lose that race — first fatally, then loudly. // The warning went with the fatality: contention is the ordinary case // here, and a per-account warning about a cache nobody reads is noise // over a condition the reader cannot act on. It goes to the debug log. // // The lookups run concurrently and the repairs do not, which is the split // that matters: a DID document is a network round trip to somebody else's // host, and one per account in sequence is what made this command scale // with the number of accounts on the machine rather than with anything a // reader cares about. `refresh_cached_handle` takes the config lock, so // running *those* concurrently would trade N round trips for N processes // queueing on one file — and it writes nothing when the cache already // agrees, which is the ordinary case, so the sequential pass is mostly // no-ops. // // `buffered`, not `buffer_unordered`: the rows are printed in the order // `known()` produced them, and a listing whose order depends on which // host answered first is a listing nobody can diff against the last run. // [`crate::clients::atproto::handles::CONCURRENCY`] is reused rather than // paired with a second number, because it bounds the same work — DID // documents, several at once — and two constants for one question drift. // // Which rows are worth one is decided first, and most of them are not. // Concurrency bounded the round trips; it did not stop them being one per // account, and a machine that has provisioned an identity per agent is // heading for ten thousand of those behind a command somebody ran to read // three lines. The count is the symptom rather than the rule, though, and // a listing that changes character at the eleventh account is a listing // whose behavior nobody can predict from what they typed. Two facts about // the row decide instead: // // - An agent's handle is not re-read. An agent is named by its DID, the // handle is a label on a row, and renaming one is not something that // happens between two runs of this command. This is the policy already // written into `HANDLE_CACHE_TTL`, taken to its end. // - A person's is re-read only once the cache has gone stale, on the same // TTL and for the same reason: a stamp a few hours behind changes // nothing a reader can act on. // // `--refresh` spends them all regardless, which is where a handle that // really has moved gets picked up. The two together make the cost scale // with how many accounts are *stale* rather than how many exist, so the // fleet case costs nothing and the case somebody is actually debugging -- // one or two accounts, one of them wrong -- is unchanged. let now = chrono::Utc::now(); let worth_a_lookup = |account: &crate::config::account::Known| { refresh || !(report_plane(&account.did) == Plane::Agent || held.handle_is_fresh(&account.did, now)) }; let skipped = known.iter().filter(|a| !worth_a_lookup(a)).count();
use futures_util::stream::StreamExt; // Measured before any of this: 0.7s for a hundred accounts and 137s for a // thousand, with the network answering instantly. The cost was never the // latency, it was making the request at all. // // What it costs in exchange is a handle up to a day out of date, and for // an agent's, older. That is the right trade because a handle is the one // thing here that is not an identifier: nothing is keyed by it, every // record and remote and session is addressed by the DID, and a handle // that has actually moved is caught the next time this runs past the TTL, // or immediately with `--refresh`. crate::logging::debug::log(format!( "handles: {} of {} accounts read from cache", skipped, known.len() )); let fetched: Vec<(_, Option<String>)> = futures_util::stream::iter(known.iter().cloned()) .map(|account| async move { let handle = match worth_a_lookup(&account) { true => handle_from_did_doc(&account.did).await, // Not "no handle": the cached one is what the row prints, and // the `None` arm below already falls back to it. false => None, }; (account, handle) }) .buffered(crate::clients::atproto::handles::CONCURRENCY) .collect() .await; let resolved: std::collections::BTreeMap<String, String> = fetched .iter() .filter_map(|(a, h)| h.clone().map(|h| (a.did.clone(), h))) .collect();
// Said on stderr, where the rows are not, because a listing that quietly // stopped checking is a listing whose freshness the reader cannot see. if skipped > 0 && !json { crate::term::say::note!( Auth, "{skipped} handle{} read from cache; `--refresh` re-reads {} from the DID document", match skipped { 1 => "", _ => "s", }, match skipped { 1 => "it", _ => "them", }, ); }
let mut rows = Vec::new(); for account in known { let handle = match resolved.get(&account.did) { Some(handle) => { crate::config::account::refresh_cached_handle(&account.did, handle).await; Some(handle.clone()) } // Either the cache was young enough to keep, or it was stale and // the lookup did not answer. Both end here: the cached handle, or // nothing, and the DID is printed beside it regardless. None => account.handle.clone(), }; rows.push((account, handle)); }
// A failed selection (ambiguous, or a repo pointing at an account we do // not hold) must not stop status from printing — status is exactly what // you run to work out why selection failed. let selection = crate::config::account::select().await; let resolved_did = selection.as_ref().ok().map(|s| s.did.clone());
if json { // The scope string as the store recorded it, or `None` when it // recorded none — a distinction the two fields below both turn on, // since "we do not know what was granted" must not read as "nothing // was granted" in one and "everything is missing" in the other. // The plane being listed, for the same reason the rows' session // state is: a `scopes_missing` computed from the other grant is a // fact about a credential the reader did not ask about. let granted = |did: &str| -> Option<String> { held.session_for(did, listing).and_then(|s| s.scope.clone()) }; let words = |scope: Option<&str>| -> Vec<String> { scope.map_or_else(Vec::new, |s| { s.split_whitespace().map(str::to_string).collect() }) }; let accounts: Vec<AccountJson> = rows .iter() .map(|(account, handle)| AccountJson { did: account.did.clone(), handle: handle.clone(), resolved: Some(&account.did) == resolved_did.as_ref(), session: account.session.label(), expires_at: account.session.expires_at().map(str::to_string), // The bare scope names, off the same granted string the // text view turns into advice lines. planes: [Plane::Human, Plane::Agent] .into_iter() .filter(|p| held.has_grant(&account.did, *p)) .map(Plane::as_str) .collect(), scopes_missing: granted(&account.did).map_or_else(Vec::new, |scope| { missing_scopes(&scope) .into_iter() .map(str::to_string) .collect() }), }) .collect(); let resolved = match &selection { Ok(selection) => { let did = &selection.did; let handle = rows .iter() .find(|(a, _)| &a.did == did) .and_then(|(_, h)| h.clone()); let actor = handle.clone().unwrap_or_else(|| did.clone()); Some(ResolvedJson { did: did.clone(), handle, source: selection.source.describe().to_string(), pds: pds_from_did_doc(did).await, // The *acting* grant's scopes, which is this process's // plane and not whichever the listing is about: this block // describes the account commands would use, so reading it // off another plane would answer a question nobody asked. scopes: words( crate::config::account::session_for(did, Plane::current()) .and_then(|s| s.scope) .as_deref(), ), bsky_url: format!("https://bsky.app/profile/{actor}"), tangled_url: format!("{}/{actor}", appview()), }) } Err(e) => { // Why nothing is active is diagnosis, not a fact about an // account, so it goes where the other notes go. crate::term::say::note!(Account, "no active account: {e}"); None } }; return crate::term::jsonout::emit(&StatusJson { accounts, resolved }); }
for (account, handle) in &rows { let marker = if Some(&account.did) == resolved_did.as_ref() { "*" } else { " " }; println!( "{marker} {}", crate::config::account::display_account(&account.did, handle.as_deref()) ); println!(" {}", account.session.describe()); // A session that is perfectly healthy can still be unable to do a // thing this build offers, and nothing else says so until the write // fails. Per account, because the accounts on one machine were // logged in at different times and this is exactly where they differ. // This row's plane, like everything else about the row. let gap = scope_gap( held.session_for(&account.did, listing) .and_then(|s| s.scope) .as_deref(), ); if !gap.is_empty() { println!( " {} scope(s) atgc asks for that this login does not carry: \ log in again to add them:", gap.len() ); for line in &gap { println!(" {line}"); } } }
match &selection { Ok(selection) => { println!(); println!( "resolved: {} (via {})", selection.display(), selection.source.describe() ); let did = &selection.did; let handle = rows .iter() .find(|(a, _)| &a.did == did) .and_then(|(_, h)| h.clone()); if let Some(pds) = pds_from_did_doc(did).await { println!("pds: {pds}"); } let actor = handle.as_deref().unwrap_or(did); println!( "bsky: {}", crate::term::hyperlink::url(&format!("https://bsky.app/profile/{actor}")) ); println!( "tangled: {}", crate::term::hyperlink::url(&format!("{}/{actor}", appview())) ); print_session_details(did, listing)?; } Err(e) => { println!(); println!("no active account: {e}"); } } Ok(())}
/// The account a verb acted on, which is all three of `switch`, `refresh`/// and `token` have to say about *who*.////// One struct rather than three identical ones, and the same two field names/// [`AccountJson`] opens with, so a caller that can read a row of `auth/// status --json` can read any of these without learning a second spelling.#[derive(serde::Serialize, Debug, PartialEq)]pub(crate) struct WhoJson { pub did: String, /// Without the leading `@`, as everywhere else in `--json`. pub handle: Option<String>,}
impl WhoJson { fn of(selection: &crate::config::account::Selection) -> Self { Self { did: selection.did.clone(), handle: selection.handle.clone(), } }}
/// `auth refresh --json`.#[derive(serde::Serialize, Debug, PartialEq)]pub(crate) struct RefreshJson { #[serde(flatten)] pub who: WhoJson, /// `live`, `expired` or `missing`, the same closed set of words a row of /// `auth status --json` carries. pub session: &'static str, pub expires_at: Option<String>,}
/// `auth token --json`.#[derive(serde::Serialize, Debug, PartialEq)]pub(crate) struct TokenJson { #[serde(flatten)] pub who: WhoJson, pub access_token: String,}
/// `auth logout --json`.#[derive(serde::Serialize, Debug, PartialEq)]pub(crate) struct LogoutJson { /// The accounts this run forgot. `[]` rather than absent when there was /// nothing to forget, so a caller counting them needs no special case. pub logged_out: Vec<WhoJson>, /// The accounts still logged in afterwards. pub remaining: Vec<WhoJson>,}
/// Point the persisted "active account" at another account.async fn set_default(spec: &str, json: bool) -> Result<()> { crate::term::jsonout::init(json); let selection = crate::config::account::lookup(spec).await?; crate::config::account::set_default(&selection.did).await?; if !json { println!("default account is now {}", selection.display()); }
// The same argument the block below makes about the rest of the // precedence, applied to the plane: the pointer moved, and it can still // point at an account this side cannot act as. An agent naming a default // that only holds a person's login has set something every later command // will refuse, and the refusal will arrive somewhere else entirely, so it // is said here where the cause is. let plane = crate::config::account::Plane::current(); if !crate::config::account::has_grant(&selection.did, plane) { crate::term::say::warning!( Account, "{} has no {}, so commands here will be refused until one exists\n\ `atgc auth login {}` from here makes one", selection.display(), plane.bare_login(), selection .handle .as_deref() .unwrap_or(selection.did.as_str()), ); }
// Saying nothing here would be a trap: the pointer moved, but something // more specific may be deciding anyway. This used to check the checkout // and only the checkout; it now runs the same routine `auth login` does, // so ATGC_ACCOUNT is covered too and the two commands cannot drift apart // on what outranks what. // // `Advice::Switch` cannot come back here — the pointer was just set to // this account — so what prints is a shadow note or nothing. let known = crate::config::account::known()?; let standing = crate::config::account::Standing::read(&known); for line in crate::config::account::advise(&selection.did, selection.handle.as_deref(), &standing) .lines(&selection.did, selection.handle.as_deref()) { // Prose about what still outranks the pointer, which rule 2 puts on // stderr under `--json` and leaves on stdout otherwise: without the // flag these lines are part of the answer a person reads. match json { true => crate::term::say::note!(Auth, "{line}"), false => println!("{line}"), } } if json { return crate::term::jsonout::emit(&WhoJson::of(&selection)); } Ok(())}
/// Refresh the active account's access token, and nothing else.////// "Refresh" is jacquard's sense of it: the token is exchanged when it has/// expired or is close to it, and left alone when it is still healthy. So a/// run that reports the same expiry as before did its job — there was/// nothing to do. Verified against a live session; it reported the/// unexpired token unchanged, which is the no-op branch, not the exchange.////// No account argument on purpose: the account comes from the same/// precedence chain as every other command, so `--account`/`ATGC_ACCOUNT`/// still steer it while a bare invocation can only ever touch the account/// you are already acting as. It never opens a browser and never adds an/// account — if the grant is gone, that is a login, not a refresh.async fn refresh(json: bool) -> Result<()> { crate::term::jsonout::init(json); let selection = crate::config::account::select().await?; match json { true => crate::term::say::note!(Auth, "refreshing {}", selection.display()), false => println!("refreshing {}", selection.display()), } let agent = agent_for_did(&selection.did).await?; // Touch the session so a refresh actually happens rather than being // deferred to the next request. let _ = agent.info().await; let state = SessionState::of( crate::config::account::session_for( &selection.did, crate::config::account::Plane::current(), ) .as_ref(), ); if json { return crate::term::jsonout::emit(&RefreshJson { who: WhoJson::of(&selection), session: state.label(), expires_at: state.expires_at().map(str::to_string), }); } println!("{}", state.describe()); Ok(())}
/// Print the active account's access token on stdout, for hand-driving XRPC/// calls atgc does not implement yet.////// Caveat worth knowing before piping it into curl: these tokens are/// DPoP-bound, so a bare `Authorization: Bearer` will be rejected by the/// PDS. It is still the fastest way to inspect a session or feed a tool that/// can do DPoP itself.async fn token(json: bool) -> Result<()> { crate::term::jsonout::init(json); let selection = crate::config::account::select().await?; // Refresh first, so a stale token is never handed out. // The session that was just resumed, handed back rather than looked up // again: this prints an access token straight into another tool's // `Authorization` header, and a second lookup could name a different // session — a stale one, or on a machine where an agent and the account's // owner are both logged in, the other plane's entirely. let (_agent, stored) = resume(&selection.did).await?; // This identity's file. Reading every account's to print one account's // token made an unrelated corrupt shard fail a command that had already // found everything it needed. let store = crate::clients::atproto::oauth::sessions::read_shard(&selection.did)?; let token = store .get(&format!("oauth:{}/{}", selection.did, stored.session_id)) .and_then(|v| v["ClientSession"]["access_token"].as_str()) .context("the stored session has no access token")?; if json { return crate::term::jsonout::emit(&TokenJson { who: WhoJson::of(&selection), access_token: token.to_string(), }); } // Exactly the token and a newline without the flag: `atgc auth token` // is piped into other tools, and anything else on stdout would break // every one of them. println!("{token}"); Ok(())}
/// Scopes and access-token expiry for one account, read straight from the/// session store.fn print_session_details(did: &str, plane: crate::config::account::Plane) -> Result<()> { let Some(session) = crate::config::account::session_for(did, plane) else { println!("session: none stored for this account on this plane"); return Ok(()); }; if let Some(scope) = &session.scope { println!("scopes:"); for s in scope.split_whitespace() { println!(" {s}"); } // Beneath the granted list rather than instead of it: the two // together are the whole answer to "what can this session do", and // the missing half is the half that surprises people. let missing = missing_scopes(scope); if !missing.is_empty() { println!("scopes atgc now asks for and this login does not carry:"); for s in missing { match writer_of(s) { Some(command) => println!(" {s}: `{command}` will be refused"), None => println!(" {s}"), } } } } if let Some(expires_at) = &session.expires_at { // The human-readable form is already on the account's own line // above; this is the exact timestamp for when that matters. println!("access token expires: {expires_at}"); } Ok(())}
#[cfg(test)]mod tests { use super::{host_of, login_prompt, unreadable_words, write_auth_url}; use crate::term::noinput::BrowserOpen;
/// The grant that started all this. bsky.social answered a login with a /// scope string containing `repo:` — a `repo:` scope with no collection, /// which is an empty NSID — and jacquard's parser aborted the process on /// it. The session it belongs to is perfectly usable, so what is left to /// do is name the word and keep every other one. #[test] fn a_scope_word_that_does_not_parse_is_named_and_the_rest_are_kept() { assert_eq!( unreadable_words("atproto repo: repo:sh.tangled.repo blob:*/*"), vec!["repo:".to_string()] ); // The whole of what atgc itself asks for reads cleanly, which is what // makes a warning about anything else worth printing. assert!(unreadable_words(crate::clients::tangled::scope::SCOPES).is_empty()); assert!(unreadable_words("").is_empty()); }
/// The warning every current login printed, about a scope it holds. /// /// A permission set expands into a `repo` scope naming several /// collections at once, which jacquard's single-collection `RepoScope` /// cannot represent and its parser therefore rejects. atgc reads it — /// see `clients::tangled::scope::repo_collections` — so warning about it /// was false, and it fired on the one scope behind `atgc report`, which /// made a healthy login look broken every single time. #[test] fn a_repo_scope_naming_several_collections_is_not_unreadable() { let expanded = crate::lexicon::userinput::AUTH_BASIC_COLLECTIONS .iter() .map(|c| format!("collection={c}")) .collect::<Vec<_>>() .join("&"); assert!(unreadable_words(&format!("atproto repo?{expanded}")).is_empty());
// Forgiving the spelling is not forgiving the contents: a collection // that is not an NSID is still named, because it is spelled back as // the shorthand and put to the same parser as before. assert_eq!( unreadable_words("atproto repo?collection=nope&collection=app.userinput.edit"), vec!["repo?collection=nope&collection=app.userinput.edit".to_string()] ); // And a parameter atgc does not know is still unreadable, which is // the whole point of the warning surviving this change. assert_eq!( unreadable_words("repo?collection=app.userinput.edit&mystery=1"), vec!["repo?collection=app.userinput.edit&mystery=1".to_string()] ); }
/// Off a terminal, stdout is handed to the URL, and the URL is all it gets. /// /// The counterpart to `a_clickable_terminal_never_sees_the_url`: there, /// the escape is the point and the bare URL must not appear; here the /// bare URL is the point and the escape must not. A caller reads this /// line and opens it, so a label, a trailing space or an OSC 8 wrapper /// are each something it would have to strip — and the failure of /// stripping badly is a login that goes to the wrong place. #[test] fn the_printed_url_is_the_whole_line() { const URL: &str = "https://bsky.social/oauth/authorize?request_uri=urn:x&client_id=y"; let mut out = Vec::new(); write_auth_url(&mut out, URL).expect("a vector does not fail to write"); assert_eq!(String::from_utf8(out).expect("utf-8"), format!("{URL}\n")); }
/// The name of the site is the whole point of not printing the URL, so /// it has to survive a real authorization URL — query string, port and /// all — and a malformed one has to degrade rather than panic. #[test] fn the_host_is_taken_off_an_authorization_url() { assert_eq!( host_of("https://bsky.social/oauth/authorize?request_uri=urn:x&client_id=y"), Some("bsky.social") ); assert_eq!( host_of("https://pds.example:8443/x"), Some("pds.example:8443") ); assert_eq!(host_of("https://pds.example"), Some("pds.example")); assert_eq!(host_of("https://pds.example?a=b"), Some("pds.example")); assert_eq!(host_of("http://127.0.0.1:4000/cb"), Some("127.0.0.1:4000")); assert_eq!(host_of("https://user@pds.example/x"), Some("pds.example")); assert_eq!(host_of("https://"), None); assert_eq!(host_of("not a url"), None); }
/// **The reason this function exists.** A PAR authorization URL is /// hundreds of characters, and printing it every time made the one line /// that mattered unreadable. In a terminal that renders OSC 8 it never /// appears: the way in is a link, and what the user reads is which site /// their browser should be on. #[test] fn a_clickable_terminal_never_sees_the_url() { const URL: &str = "https://bsky.social/oauth/authorize?request_uri=urn:ietf:params:oauth:request_uri:abc123"; for opened in [ BrowserOpen::Opened, BrowserOpen::Failed, BrowserOpen::Skipped, ] { let out = login_prompt(URL, opened, true); // The one occurrence is inside the escape, where a terminal // consumes it — never text the reader has to scroll past. assert_eq!(out.matches(URL).count(), 1, "{out}"); assert!(out.contains(&format!("\x1b]8;;{URL}\x1b\\")), "{out}"); assert!(out.contains("bsky.social"), "{out}"); assert!(out.contains("click here"), "{out}"); } }
/// Off such a terminal the link would be invisible, and a login nobody /// can reach is worse than an ugly one — so the URL comes back, in /// every outcome, exactly as it was printed before. #[test] fn a_plain_terminal_still_gets_the_url() { const URL: &str = "https://bsky.social/oauth/authorize?request_uri=urn:x"; for opened in [ BrowserOpen::Opened, BrowserOpen::Failed, BrowserOpen::Skipped, ] { let out = login_prompt(URL, opened, false); assert!(out.contains(URL), "{out}"); assert!(!out.contains('\x1b'), "{out}"); } }
/// A browser that opened and one that did not are different situations /// for the person watching, and the line says which — while offering /// the way in either way, because "the launcher exited zero" is not /// "the page is on screen". #[test] fn the_line_says_whether_a_browser_opened() { const URL: &str = "https://pds.example/oauth/authorize?request_uri=urn:x"; let opened = login_prompt(URL, BrowserOpen::Opened, true); assert!( opened.starts_with("opened pds.example in your browser"), "{opened}" ); assert!(opened.contains("didn't open?"), "{opened}");
let failed = login_prompt(URL, BrowserOpen::Failed, true); assert!(failed.starts_with("could not open a browser"), "{failed}");
let skipped = login_prompt(URL, BrowserOpen::Skipped, true); assert!(skipped.starts_with("no browser was opened"), "{skipped}"); }}