//! 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 { 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> { 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, 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::::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 `; 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, /// 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 = 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 { 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 { scope .split_whitespace() .filter(|word| Scopes::::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::::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, 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)> = 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 = 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::>() .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::>() .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, /// 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, /// 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, /// 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, /// 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, /// Every scope the session was granted, as the store recorded them. pub scopes: Vec, 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, /// `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, } /// 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 ` to make one from here" .to_string(), _ => "not logged in: run `atgc auth login `".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)> = 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 = 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 { held.session_for(did, listing).and_then(|s| s.scope.clone()) }; let words = |scope: Option<&str>| -> Vec { scope.map_or_else(Vec::new, |s| { s.split_whitespace().map(str::to_string).collect() }) }; let accounts: Vec = 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, } 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, } /// `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, /// The accounts still logged in afterwards. pub remaining: Vec, } /// 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::>() .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}"); } }