Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-server/src/forges.rs

//! **The forge connections this console holds on a person's behalf**, and the
//! one place a forge token exists in this process.
//!
//! Not to be confused with [`crate::forge`], which is gunnar's own review
//! model — signed comments and approvals on a pull request. That module is
//! about *gunnar's* forge; this one is about *somebody else's*: github.com,
//! codeberg.org, a GitLab, a Forgejo at `git.example.com`.
//!
//! # Why a token has to live here at all
//!
//! Mirroring starts with a question git cannot answer — **what repositories
//! does this account have?** The git protocol starts from a URL you already
//! know and has no concept of an account, so `git ls-remote` against
//! `github.com` is not a shorter version of this. Every forge answers it over
//! its own HTTPS REST API and nowhere else, and every one of them wants a
//! credential to answer it. `gunnar-forge` does the asking; something has to
//! hold the credential between "connect" and "import", and that something is a
//! process on the front VM rather than the browser.
//!
//! # Where the token is, and every place it is not
//!
//! It is a `String` field of [`Connection`], inside a `Mutex` inside
//! [`ForgeSessions`], in this process's memory. It is:
//!
//! * **never in the browser** — no response body of any route carries it, no
//!   cookie holds it, and
//!   `mirroring::the_token_is_in_no_response_body_and_in_no_log_line` drives a
//!   distinctive token through every route in this family and asserts the bytes
//!   appear in no body and no log line — after asserting that the token really
//!   did reach gunnar, so the absence is not vacuous;
//! * **never on disk** — [`ForgeSessions`] is in-memory, so a restart forgets
//!   every connection. That is the correct trade: a persisted forge token is a
//!   long-lived bearer credential for somebody's entire GitHub account sitting
//!   in a file, and reconnecting costs one paste;
//! * **never in a log line** — [`Connection`]'s `Debug` is hand-written and
//!   `ForgeSessions`' is too, so a `tracing` span that captured either prints a
//!   kind and an account;
//! * **never in an error** — `gunnar_forge::ForgeError` carries a forge name, a
//!   status and nothing else, by construction rather than by care (see that
//!   crate's `error` module), and the refusals minted here quote only those.
//!
//! # Keyed to the session, and what that means when there are no sessions
//!
//! The key is the caller's own name — [`crate::auth::middleware::Caller::name`]
//! — because that is the only stable identity this console has: a session is
//! `user | role | exp | auth_time | uv | epoch` with no id in it, and gunnar
//! sees one principal for the whole console and cannot tell two browsers apart.
//! Keying on the name means one person's connection is not another's, which is
//! the property that matters; it also means the same person in two tabs shares
//! one connection, which is what they would expect.
//!
//! The open-console posture (no passkey, no admin token, loopback only) has no
//! `Caller` at all and gets a key of its own. Anonymous traffic gets **no key
//! and no route** — none of these paths is on either anonymous allow-list, so
//! the refusal happens in the middleware before a handler exists to forget.
//!
//! # It expires
//!
//! A connection that has not been touched for [`IDLE`] is dropped on the next
//! access. A forge token with no expiry — which is what GitHub's classic
//! personal access tokens are — must not be held by a process for as long as
//! the process happens to run.

use std::collections::HashMap;
use std::fmt;
use std::sync::Mutex;
use std::time::{Duration, Instant};

use base64::Engine as _;
use gunnar_forge::{Forge, ForgeError, ForgeKind, Repo};

/// How long an untouched connection is kept before it is dropped.
///
/// An hour is the shape of the task: connect, look at the list, pick, import.
/// A person who wanders off for lunch pastes their token again, which is a
/// second of inconvenience against a credential this process would otherwise
/// hold until it restarts.
pub const IDLE: Duration = Duration::from_secs(60 * 60);

/// How long an OAuth `state` is accepted for.
///
/// The round trip is a redirect, a login and an approval screen: minutes, not
/// hours. A `state` that stayed valid for an hour would be an hour in which a
/// stolen callback URL still works.
pub const OAUTH_WINDOW: Duration = Duration::from_secs(10 * 60);

/// The `owner` key used when this console has no sessions at all.
///
/// `require_auth` takes THE ONE BYPASS when neither a passkey nor an admin
/// token is configured, and `open_console_is_confined` has already refused to
/// let that process start off loopback. There is exactly one human in that
/// posture, so there is exactly one key.
pub const OPEN_CONSOLE: &str = "\0open-console";

/// Parse a forge's lowercase name, as it appears in a path segment.
///
/// The names are `gunnar_forge::ForgeKind::as_str`'s own, so the console, the
/// CLI and every error message spell them the same way.
pub fn kind_from_name(name: &str) -> Option<ForgeKind> {
    match name {
        "github" => Some(ForgeKind::GitHub),
        "codeberg" => Some(ForgeKind::Codeberg),
        "gitlab" => Some(ForgeKind::GitLab),
        _ => None,
    }
}

/// **The Basic-auth username a token is paired with, per forge.**
///
/// `MirrorConfig.credential_username` is explicitly NOT a secret — gunnar's own
/// proto says so — and every forge documents its value. Empty would mean
/// `Bearer`, which none of these three accept for a git-over-HTTPS fetch.
///
/// * GitHub wants `x-access-token` and the token as the password; it is the
///   documented form for both a PAT and an OAuth access token.
/// * GitLab wants `oauth2` for an OAuth token, and accepts it for a PAT too,
///   which is why one value covers both doors.
/// * Gitea/Forgejo wants the **account name**, which is why this takes one.
pub fn credential_username(kind: ForgeKind, account: &str) -> String {
    match kind {
        ForgeKind::GitHub => "x-access-token".to_owned(),
        ForgeKind::GitLab => "oauth2".to_owned(),
        ForgeKind::Codeberg => account.to_owned(),
    }
}

/// Build the `gunnar_forge::Forge` for a kind and a base URL.
///
/// An empty base means the public instance; anything else is a self-hosted one
/// and goes through the `*_at` / `forgejo` constructors, which is the whole
/// difference between codeberg.org and a Forgejo at `git.example.com`.
pub fn forge_at(kind: ForgeKind, base: &str) -> Forge {
    let base = base.trim().trim_end_matches('/');
    match (kind, base.is_empty()) {
        (ForgeKind::GitHub, true) => Forge::github(),
        (ForgeKind::GitHub, false) => Forge::github_enterprise(base.to_owned()),
        (ForgeKind::Codeberg, true) => Forge::codeberg(),
        (ForgeKind::Codeberg, false) => Forge::forgejo(base.to_owned()),
        (ForgeKind::GitLab, true) => Forge::gitlab(),
        (ForgeKind::GitLab, false) => Forge::gitlab_at(base.to_owned()),
    }
}

/// One connected forge account.
///
/// # `Debug` prints no token, by hand
///
/// Derived would print one. This is the same argument `gunnar_forge::Forge`
/// makes for not having the field at all — a stored token is printed by `{:?}`,
/// by a panic message, and by every `tracing` span that captured the struct —
/// except that this struct *must* have the field, so the impl is written out
/// and a reviewer adding a field will already be looking at it.
pub struct Connection {
    /// Which forge.
    pub kind: ForgeKind,
    /// The site root, empty for the public instance.
    pub base: String,
    /// The account the token belongs to, taken from the owner segment of the
    /// first repository listed. Empty when the account has none.
    pub account: String,
    /// How the connection was made, for the panel: `token` or `oauth`.
    pub door: &'static str,
    /// **The credential.** See the module docs for every place it is not.
    token: String,
    /// **The refresh token, when the forge issued one**, and the instant the
    /// access token stops working.
    ///
    /// Both were parsed out of the token response and thrown away, which made
    /// this console unable to tell a live credential from a dead one: it held
    /// an access token past its hour, handed it to gunnar, and the mirror
    /// failed later with the forge's word for "unauthorized" and no way to tell
    /// that from a revoked application or a deleted account.
    ///
    /// The refresh token is HELD and not used. Refreshing is a second flow with
    /// its own failure modes, and the useful half of knowing the expiry is
    /// refusing to hand over a token that has passed it — see
    /// [`ForgeSessions::take_token`]. It is kept because it is part of the
    /// credential this console was given: dropping it would leave a live
    /// grant at the forge with no way to name it at [`ForgeSessions::forget`].
    refresh: String,
    /// When the access token dies, if the forge said.
    expires_at: Option<Instant>,
    /// The listing this token produced, held so the import resolves a chosen
    /// `full_name` to a clone URL **here** rather than trusting one the browser
    /// sent back. A browser that could name the URL could point a mirror at a
    /// host of its choosing.
    pub repos: Vec<Repo>,
    /// Last touched, for [`IDLE`].
    touched: Instant,
}

impl fmt::Debug for Connection {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Connection")
            .field("kind", &self.kind)
            .field("base", &self.base)
            .field("account", &self.account)
            .field("door", &self.door)
            .field("repos", &self.repos.len())
            .finish_non_exhaustive()
    }
}

impl Connection {
    /// True when this connection still holds a token that would work.
    ///
    /// False after an HTTPS import has handed it to gunnar — see
    /// [`ForgeSessions::take_token`], which is why the field is private and
    /// there is no getter that hands it out without taking it — and false once
    /// the forge's own `expires_in` has passed. An expired token is not a
    /// token: handing it to `Mirror.SetCredential` would seal a dead credential
    /// into gunnar, and the mirror would fail at its next sync with the forge's
    /// word for "unauthorized" and nothing to say which of the several reasons
    /// it was.
    pub fn has_token(&self) -> bool {
        !self.token.is_empty() && !self.expired()
    }

    /// Has the forge's own lifetime for this access token run out?
    ///
    /// `None` — no `expires_in` in the token response — is NOT expiry. A GitHub
    /// classic personal access token has no lifetime at all, and treating
    /// "unstated" as "over" would break the door most people use.
    pub fn expired(&self) -> bool {
        self.expires_at.is_some_and(|at| Instant::now() >= at)
    }
}

/// **What a forge granted**: the access token, and the two facts about it this
/// console used to discard.
///
/// A struct rather than three arguments because they arrive together, are
/// stored together, and are the kind of thing a positional call site gets
/// wrong once and silently.
#[derive(Clone)]
pub struct Granted {
    /// The access token.
    pub token: String,
    /// The refresh token, or empty when the forge issued none.
    pub refresh: String,
    /// The access token's stated lifetime, or `None` when it has none.
    pub expires_in_secs: Option<u64>,
}

impl fmt::Debug for Granted {
    /// Neither half, and the presence of each is all it says.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Granted")
            .field("has_refresh", &!self.refresh.is_empty())
            .field("expires_in_secs", &self.expires_in_secs)
            .finish_non_exhaustive()
    }
}

impl Granted {
    /// A credential with no stated lifetime and no refresh: what a pasted
    /// personal access token is.
    pub fn pasted(token: String) -> Granted {
        Granted {
            token,
            refresh: String::new(),
            expires_in_secs: None,
        }
    }
}

/// What one connection looks like to the browser. **No token, and no field one
/// could arrive in.**
#[derive(Debug, Clone, serde::Serialize)]
pub struct Connected {
    /// `github` | `codeberg` | `gitlab`.
    pub forge: &'static str,
    /// The site root, empty for the public instance.
    pub base: String,
    pub account: String,
    /// `token` or `oauth`.
    pub door: &'static str,
    /// How many repositories the listing found.
    pub repos: usize,
    /// **Whether a token is still held.** False after an HTTPS import handed it
    /// over; the picker says so rather than silently importing without a
    /// credential.
    pub holds_token: bool,
}

impl From<&Connection> for Connected {
    fn from(c: &Connection) -> Connected {
        Connected {
            forge: c.kind.as_str(),
            base: c.base.clone(),
            account: c.account.clone(),
            door: c.door,
            repos: c.repos.len(),
            holds_token: c.has_token(),
        }
    }
}

/// An OAuth round trip that has been started and not yet come back.
struct Pending {
    kind: ForgeKind,
    owner: String,
    at: Instant,
    /// **The PKCE code verifier for this dance.**
    ///
    /// Minted here, never sent to the browser, and presented at the token
    /// endpoint beside the code. It is what makes an intercepted authorization
    /// code useless to whoever intercepted it: the forge will not exchange a
    /// code unless the exchange also proves knowledge of the secret whose
    /// SHA-256 was pinned when the dance began, and that secret never left this
    /// process. The `state` parameter is a different guard for a different
    /// attack — it stops a callback this console did not begin; it does nothing
    /// about a code that leaks in a redirect chain, a proxy log or a referrer.
    verifier: String,
}

/// What [`ForgeSessions::begin_oauth`] hands the authorize URL.
///
/// Two values that must travel together: the `state` the callback comes back
/// with, and the `code_challenge` the forge pins this dance to. The verifier
/// behind the challenge stays here and is never in this struct, because this
/// struct's whole contents end up in a URL in somebody's address bar.
#[derive(Debug, Clone)]
pub struct Begun {
    /// The CSRF nonce, redeemed at the callback.
    pub state: String,
    /// `S256(verifier)`, base64url without padding.
    pub challenge: String,
}

/// The PKCE challenge for a verifier: base64url(sha256(verifier)), unpadded.
///
/// `S256` and never `plain`: the plain method puts the verifier itself in the
/// authorize URL, which is the address bar, the history and the referrer — the
/// exact places the code it is protecting might also leak, so it would protect
/// the code against nothing that could not take both.
pub fn code_challenge(verifier: &str) -> String {
    use sha2::Digest as _;
    B64URL.encode(sha2::Sha256::digest(verifier.as_bytes()))
}

/// Base64url without padding: what RFC 7636 specifies for both halves.
const B64URL: base64::engine::general_purpose::GeneralPurpose =
    base64::engine::general_purpose::URL_SAFE_NO_PAD;

/// **Every forge connection this process holds**, keyed by session owner.
///
/// One `Mutex` and not a lock per entry: the map is touched once per request on
/// a surface a handful of operators use, and a second lock would be a second
/// ordering to get wrong. Nothing here is awaited while the lock is held —
/// every network call happens outside it, because `gunnar-forge` is blocking
/// and runs on `spawn_blocking`.
#[derive(Default)]
pub struct ForgeSessions {
    held: Mutex<Held>,
}

#[derive(Default)]
struct Held {
    /// owner -> forge -> connection.
    connections: HashMap<String, HashMap<&'static str, Connection>>,
    /// OAuth `state` -> what it was minted for.
    pending: HashMap<String, Pending>,
}

impl fmt::Debug for ForgeSessions {
    /// Counts, never contents.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let held = self.held.lock().expect("the connection map");
        f.debug_struct("ForgeSessions")
            .field("owners", &held.connections.len())
            .field("pending", &held.pending.len())
            .finish_non_exhaustive()
    }
}

impl ForgeSessions {
    /// An empty set of connections.
    pub fn new() -> ForgeSessions {
        ForgeSessions::default()
    }

    /// Drop everything untouched for longer than [`IDLE`], and every expired
    /// `state`.
    ///
    /// Called at the top of every accessor rather than from a timer: a sweep
    /// that needs a task to run is a sweep that does not run in a test, and the
    /// map is small enough that walking it costs nothing worth measuring.
    fn sweep(held: &mut Held) {
        for forges in held.connections.values_mut() {
            forges.retain(|_, c| c.touched.elapsed() < IDLE);
        }
        held.connections.retain(|_, forges| !forges.is_empty());
        held.pending.retain(|_, p| p.at.elapsed() < OAUTH_WINDOW);
    }

    /// Hold a connection, replacing any previous one for the same forge.
    ///
    /// Replacing rather than refusing: connecting again is how a person fixes a
    /// token they pasted wrongly, and a second entry for the same forge would
    /// leave the wrong one reachable.
    pub fn connect(
        &self,
        owner: &str,
        kind: ForgeKind,
        base: String,
        granted: Granted,
        door: &'static str,
        repos: Vec<Repo>,
    ) -> Connected {
        let account = repos
            .first()
            .and_then(|r| r.full_name.split('/').next())
            .unwrap_or_default()
            .to_owned();
        let connection = Connection {
            kind,
            base,
            account,
            door,
            expires_at: granted
                .expires_in_secs
                .map(|secs| Instant::now() + Duration::from_secs(secs)),
            token: granted.token,
            refresh: granted.refresh,
            repos,
            touched: Instant::now(),
        };
        let summary = Connected::from(&connection);
        let mut held = self.held.lock().expect("the connection map");
        Self::sweep(&mut held);
        held.connections
            .entry(owner.to_owned())
            .or_default()
            .insert(kind.as_str(), connection);
        summary
    }

    /// What this owner has connected, in a stable order.
    pub fn connected(&self, owner: &str) -> Vec<Connected> {
        let mut held = self.held.lock().expect("the connection map");
        Self::sweep(&mut held);
        let mut out: Vec<Connected> = held
            .connections
            .get(owner)
            .map(|forges| forges.values().map(Connected::from).collect())
            .unwrap_or_default();
        out.sort_by(|a, b| a.forge.cmp(b.forge));
        out
    }

    /// One connection's listing and its summary, refreshing its idle clock.
    pub fn listing(&self, owner: &str, kind: ForgeKind) -> Option<(Connected, Vec<Repo>)> {
        let mut held = self.held.lock().expect("the connection map");
        Self::sweep(&mut held);
        let connection = held.connections.get_mut(owner)?.get_mut(kind.as_str())?;
        connection.touched = Instant::now();
        Some((Connected::from(&*connection), connection.repos.clone()))
    }

    /// **Take the token out**, leaving the connection behind without one.
    ///
    /// Taking and not borrowing, and that is the whole design: the import hands
    /// the token to `Mirror.SetCredential` ONCE, gunnar seals it, and this
    /// process has no further use for it. A `token()` that returned a copy
    /// would leave the credential here for as long as the connection lived, and
    /// the only thing standing between that and a leak would be every future
    /// caller remembering to drop it.
    ///
    /// `None` when there is no connection, or when the token has already been
    /// handed over. The second is not an error the console should hide: the
    /// picker says the connection no longer holds a token, and an HTTPS import
    /// refuses by name rather than registering a mirror that cannot
    /// authenticate.
    pub fn take_token(&self, owner: &str, kind: ForgeKind) -> Option<String> {
        let mut held = self.held.lock().expect("the connection map");
        Self::sweep(&mut held);
        let connection = held.connections.get_mut(owner)?.get_mut(kind.as_str())?;
        connection.touched = Instant::now();
        let token = std::mem::take(&mut connection.token);
        (!token.is_empty()).then_some(token)
    }

    /// Forget one connection.
    pub fn forget(&self, owner: &str, kind: ForgeKind) -> Option<Granted> {
        let mut held = self.held.lock().expect("the connection map");
        Self::sweep(&mut held);
        let gone = held
            .connections
            .get_mut(owner)
            .and_then(|forges| forges.remove(kind.as_str()));
        held.connections.retain(|_, forges| !forges.is_empty());
        gone.map(|connection| Granted {
            token: connection.token,
            refresh: connection.refresh,
            // Expiry has no meaning to the caller of this: what it wants the
            // credential FOR is to tell the forge to stop honouring it, and an
            // expired access token is still a live grant until the grant itself
            // is revoked.
            expires_in_secs: None,
        })
    }

    /// Mint an OAuth `state` for this owner and forge.
    ///
    /// Random, 32 bytes, hex. It is held HERE and compared on the callback, so
    /// a callback carrying a `state` this process did not mint — or one minted
    /// for somebody else — is refused before the authorization code is
    /// exchanged for anything.
    pub fn begin_oauth(&self, owner: &str, kind: ForgeKind) -> Begun {
        use rand::Rng as _;
        let mut bytes = [0u8; 32];
        rand::rng().fill_bytes(&mut bytes);
        let state: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
        rand::rng().fill_bytes(&mut bytes);
        let verifier = B64URL.encode(bytes);
        let challenge = code_challenge(&verifier);
        let mut held = self.held.lock().expect("the connection map");
        Self::sweep(&mut held);
        held.pending.insert(
            state.clone(),
            Pending {
                kind,
                owner: owner.to_owned(),
                at: Instant::now(),
                verifier,
            },
        );
        Begun { state, challenge }
    }

    /// **Redeem a `state`, once.** `None` for one this process did not mint,
    /// one that has expired, one already used, or one minted for a different
    /// session or a different forge.
    ///
    /// Removing on the way out is what makes it single-use: a callback URL that
    /// worked twice is a callback URL worth replaying.
    pub fn redeem_oauth(&self, owner: &str, kind: ForgeKind, state: &str) -> Option<String> {
        let mut held = self.held.lock().expect("the connection map");
        Self::sweep(&mut held);
        let pending = held.pending.get(state)?;
        if pending.owner != owner || pending.kind != kind {
            return None;
        }
        held.pending.remove(state).map(|p| p.verifier)
    }
}

/// One forge's OAuth application, as this console was configured with it.
///
/// # Why the whole thing is absent rather than disabled when unconfigured
///
/// A "Connect GitHub" button that answers "this console has no GitHub app" is a
/// promise printed at somebody who cannot act on it. The same shape as
/// [`crate::app::App::guest`] and the monetize routes: a surface that cannot be
/// served correctly is absent, and absence is the testable state.
pub struct OAuthApp {
    /// The forge this app is for.
    pub kind: ForgeKind,
    /// Public half. Appears in the authorize URL the browser is sent to.
    pub client_id: String,
    /// **Secret half. Never leaves this process.** Sent to the forge's token
    /// endpoint by the BFF and to nowhere else.
    client_secret: String,
    /// Where the browser is sent to approve.
    pub authorize_url: String,
    /// Where the BFF exchanges the code.
    pub token_url: String,
    /// What is asked for.
    pub scope: &'static str,
}

impl fmt::Debug for OAuthApp {
    /// Never the secret.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("OAuthApp")
            .field("kind", &self.kind)
            .field("client_id", &self.client_id)
            .field("authorize_url", &self.authorize_url)
            .finish_non_exhaustive()
    }
}

impl OAuthApp {
    /// An application with its endpoints given rather than looked up.
    ///
    /// [`ForgeApps::from_env`] uses [`app`], which knows where each of the
    /// three forges puts its authorize and token endpoints. This constructor is
    /// for the two callers that cannot: a test pointing the whole dance at a
    /// loopback double, and — the day it exists — a fourth forge whose
    /// endpoints do not follow any of the three shapes.
    pub fn at(
        kind: ForgeKind,
        client_id: impl Into<String>,
        client_secret: impl Into<String>,
        authorize_url: impl Into<String>,
        token_url: impl Into<String>,
        scope: &'static str,
    ) -> OAuthApp {
        OAuthApp {
            kind,
            client_id: client_id.into(),
            client_secret: client_secret.into(),
            authorize_url: authorize_url.into(),
            token_url: token_url.into(),
            scope,
        }
    }

    /// The secret, for the one call that needs it.
    pub fn secret(&self) -> &str {
        &self.client_secret
    }
}

/// The OAuth apps this console holds, and the origin its callbacks come back
/// to.
///
/// Empty is the ordinary state: **the token door works on every forge including
/// a self-hosted one and needs no app registration at all**, which is why it is
/// the baseline and this is the addition.
#[derive(Debug, Default)]
pub struct ForgeApps {
    apps: Vec<OAuthApp>,
    /// The exact browser origin, e.g. `https://gunnar.rs`. Without it there is
    /// no redirect URI to register and no OAuth app can exist.
    origin: String,
    /// **Whether this deployment may be pointed at a forge inside its own
    /// network.** Off by default; see [`ForgeApps::private_bases_allowed`].
    allow_private_bases: bool,
    /// **AIRGAP: whether forge import is switched on** (`GUNNAR_UI_FORGES=on`). Off by
    /// default, so a `forges` build still dials no forge until an operator says so; every
    /// forge door answers [`SWITCHED_OFF`] by name until then.
    on: bool,
}

/// The refusal every forge door answers while `GUNNAR_UI_FORGES` is not `on`.
pub const SWITCHED_OFF: &str = "forge import is switched off on this console: it dials the public forges \
                                (github.com, gitlab.com, …), so it waits for GUNNAR_UI_FORGES=on";

/// Pure: does `GUNNAR_UI_FORGES` say `on`?
pub fn switch_says_on(value: Option<&str>) -> bool {
    value.is_some_and(|v| v.trim().eq_ignore_ascii_case("on"))
}

impl ForgeApps {
    /// **May `POST /api/forges/connect` name a base that is not on the public
    /// internet?**
    ///
    /// No, unless `GUNNAR_UI_FORGE_ALLOW_PRIVATE_BASE` says so. The base of a
    /// self-hosted instance is the one address in this console that a BROWSER
    /// chooses and the SERVER dials, so unchecked it is a read-side SSRF — see
    /// `api::forges::vet`, which is where the refusal is written.
    ///
    /// The escape hatch exists because "our Forgejo is at
    /// `https://git.internal`" is a real deployment and not a mistake. It is a
    /// deliberate, named, deployment-wide decision rather than a per-request
    /// judgement, and turning it on turns the guard OFF — the variable's name
    /// says as much, and this doc is the place it is admitted.
    ///
    /// Carried on this struct rather than read at the point of use so it is a
    /// value a test can set, instead of a process-wide environment variable two
    /// tests in one binary would have to agree about.
    pub fn private_bases_allowed(&self) -> bool {
        self.allow_private_bases
    }

    /// The same set, switched on (`GUNNAR_UI_FORGES=on`). Tests, and [`ForgeApps::from_env`].
    pub fn switched_on(mut self) -> ForgeApps {
        self.on = true;
        self
    }

    /// **The forge doors' first question**: the refusal by name while the switch is off.
    pub fn refuse_unless_on(&self) -> Result<(), crate::api::ApiError> {
        if self.on {
            return Ok(());
        }
        Err(crate::api::ApiError(crate::grpc::Fail { code: "failedprecondition".to_owned(), message: SWITCHED_OFF.to_owned() }))
    }

    /// A set that may dial its own network. **Tests, and nothing else** — the
    /// forge in `crate::mirroring`'s harness is a mock server on loopback.
    pub fn allowing_private_bases() -> ForgeApps {
        ForgeApps {
            allow_private_bases: true,
            ..ForgeApps::default()
        }
    }
}

impl ForgeApps {
    /// Read the apps out of the environment.
    ///
    /// One pair of variables per forge, plus an optional base for a self-hosted
    /// instance:
    ///
    /// ```text
    /// GUNNAR_UI_FORGE_GITHUB_CLIENT_ID     GUNNAR_UI_FORGE_GITHUB_CLIENT_SECRET
    /// GUNNAR_UI_FORGE_GITLAB_CLIENT_ID     GUNNAR_UI_FORGE_GITLAB_CLIENT_SECRET
    /// GUNNAR_UI_FORGE_CODEBERG_CLIENT_ID   GUNNAR_UI_FORGE_CODEBERG_CLIENT_SECRET
    /// GUNNAR_UI_FORGE_GITLAB_BASE          GUNNAR_UI_FORGE_CODEBERG_BASE
    /// GUNNAR_UI_FORGE_ORIGIN               (defaults to GUNNAR_UI_WEBAUTHN_ORIGIN)
    /// ```
    ///
    /// A forge with only one half of the pair is **not** configured, and says
    /// so in the log: half an OAuth app is a button that fails at the token
    /// exchange, which is the worst possible place for it to fail.
    pub fn from_env() -> ForgeApps {
        let origin = std::env::var("GUNNAR_UI_FORGE_ORIGIN")
            .or_else(|_| std::env::var("GUNNAR_UI_WEBAUTHN_ORIGIN"))
            .unwrap_or_default()
            .trim()
            .trim_end_matches('/')
            .to_owned();
        let mut apps = Vec::new();
        for (kind, prefix) in [
            (ForgeKind::GitHub, "GITHUB"),
            (ForgeKind::GitLab, "GITLAB"),
            (ForgeKind::Codeberg, "CODEBERG"),
        ] {
            let id = std::env::var(format!("GUNNAR_UI_FORGE_{prefix}_CLIENT_ID"))
                .unwrap_or_default()
                .trim()
                .to_owned();
            let secret = std::env::var(format!("GUNNAR_UI_FORGE_{prefix}_CLIENT_SECRET"))
                .unwrap_or_default()
                .trim()
                .to_owned();
            if id.is_empty() && secret.is_empty() {
                continue;
            }
            if id.is_empty() || secret.is_empty() || origin.is_empty() {
                tracing::warn!(
                    forge = kind.as_str(),
                    "half an OAuth app is configured (a client id without a secret, or no \
                     GUNNAR_UI_FORGE_ORIGIN to build a redirect URI from), so this forge has \
                     no OAuth button. Pasting a token still works."
                );
                continue;
            }
            let base = std::env::var(format!("GUNNAR_UI_FORGE_{prefix}_BASE"))
                .unwrap_or_default()
                .trim()
                .trim_end_matches('/')
                .to_owned();
            apps.push(app(kind, &base, id, secret));
        }
        // The one variable that turns a guard OFF, so it is read here where
        // the rest of this console's forge configuration is read, and named in
        // `main`'s usage text like the rest.
        let allow_private_bases = std::env::var("GUNNAR_UI_FORGE_ALLOW_PRIVATE_BASE")
            .map(|v| !v.trim().is_empty() && v.trim() != "0")
            .unwrap_or(false);
        if allow_private_bases {
            tracing::warn!(
                "GUNNAR_UI_FORGE_ALLOW_PRIVATE_BASE is set: this console will dial a forge base \
                 on a private, loopback or link-local address if an admin names one. That is a \
                 server-side request forgery an admin can aim, and it is on by request."
            );
        }
        let on = switch_says_on(std::env::var("GUNNAR_UI_FORGES").ok().as_deref());
        if !on {
            tracing::info!("forge import is switched off (GUNNAR_UI_FORGES is not `on`): no forge is dialled");
        }
        ForgeApps {
            apps,
            origin,
            allow_private_bases,
            on,
        }
    }

    /// Build a set directly. Tests, and nothing else.
    pub fn of(apps: Vec<OAuthApp>, origin: impl Into<String>) -> ForgeApps {
        ForgeApps {
            apps,
            origin: origin.into(),
            allow_private_bases: false,
            on: false,
        }
    }

    /// The app for one forge, if this console has one.
    pub fn get(&self, kind: ForgeKind) -> Option<&OAuthApp> {
        self.apps.iter().find(|a| a.kind == kind)
    }

    /// Which forges have a button. The console renders exactly these.
    pub fn doors(&self) -> Vec<&'static str> {
        self.apps.iter().map(|a| a.kind.as_str()).collect()
    }

    /// Where a callback comes back to, for one forge.
    ///
    /// Built from the console's own origin, never from a request header: a
    /// redirect URI taken from `Host` is a redirect URI an attacker sets.
    pub fn redirect_uri(&self, kind: ForgeKind) -> String {
        format!("{}/api/forge/{}/callback", self.origin, kind.as_str())
    }
}

/// The three forges' OAuth endpoints, which are the only forge-specific thing
/// about the dance.
///
/// A fourth forge is one more arm here plus one more `(kind, prefix)` pair in
/// [`ForgeApps::from_env`] — the exchange, the `state` handling and the refusal
/// path are shared, exactly as `gunnar_forge`'s engine shares them.
fn app(kind: ForgeKind, base: &str, client_id: String, client_secret: String) -> OAuthApp {
    let (authorize_url, token_url, scope) = match kind {
        ForgeKind::GitHub => (
            "https://github.com/login/oauth/authorize".to_owned(),
            "https://github.com/login/oauth/access_token".to_owned(),
            // `repo` covers private repositories, which is the point: a picker
            // that could only see public ones would be a picker nobody needs.
            "repo",
        ),
        ForgeKind::GitLab => {
            let root = if base.is_empty() {
                "https://gitlab.com"
            } else {
                base
            };
            (
                format!("{root}/oauth/authorize"),
                format!("{root}/oauth/token"),
                "read_api",
            )
        }
        ForgeKind::Codeberg => {
            let root = if base.is_empty() {
                "https://codeberg.org"
            } else {
                base
            };
            (
                format!("{root}/login/oauth/authorize"),
                format!("{root}/login/oauth/access_token"),
                // Forgejo scopes its tokens by category; reading the repository
                // list is `read:repository`.
                "read:repository",
            )
        }
    };
    OAuthApp {
        kind,
        client_id,
        client_secret,
        authorize_url,
        token_url,
        scope,
    }
}

/// Percent-encode one query-string value.
///
/// Hand-rolled for the same reason `auth::percent_encode` is: one function
/// against a `url` dependency that would arrive with eighteen crates of ICU
/// behind it.
pub fn query_value(value: &str) -> String {
    value
        .bytes()
        .map(|b| match b {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                (b as char).to_string()
            }
            other => format!("%{other:02X}"),
        })
        .collect()
}

/// A forge refusal, in the forge's own words, on its way to the browser.
///
/// Deliberately the same shape as a gunnar refusal ([`crate::grpc::Fail`]): the
/// console has one failure box and one summary line, and a second error shape
/// would mean a second way of saying "the token was rejected".
///
/// `ForgeError`'s `Display` is used verbatim because that crate has already
/// done the work of making it safe to print — no variant carries a request, a
/// URL, a header map or a body, so there is nothing in it to redact.
pub fn fail(error: &ForgeError) -> crate::grpc::Fail {
    let code = match error {
        ForgeError::Unauthorized { .. } => "unauthenticated",
        ForgeError::RateLimited { .. } => "resourceexhausted",
        ForgeError::Transport { .. } => "unavailable",
        ForgeError::Malformed { .. } | ForgeError::TooManyPages { .. } => "internal",
        // 404 on the base URL of a self-hosted instance is the common one, and
        // it is the caller's own input that is wrong.
        ForgeError::Http { .. } => "invalidargument",
        // `ForgeError` is `#[non_exhaustive]`: a variant added upstream must
        // not stop this compiling, and "the forge refused" is the honest
        // fallback for one this build has never seen.
        _ => "internal",
    };
    crate::grpc::Fail {
        code: code.to_owned(),
        message: error.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn repo(full_name: &str) -> Repo {
        Repo {
            forge: ForgeKind::GitHub,
            full_name: full_name.to_owned(),
            clone_url_https: format!("https://github.com/{full_name}.git"),
            clone_url_ssh: format!("git@github.com:{full_name}.git"),
            default_branch: "main".to_owned(),
            private: false,
            archived: false,
            size_kib: Some(1),
            description: None,
        }
    }

    #[test]
    fn the_token_is_taken_once_and_then_gone() {
        // The property the import depends on: gunnar seals the credential, and
        // this process has no second copy to leak afterwards.
        let sessions = ForgeSessions::new();
        sessions.connect(
            "alice",
            ForgeKind::GitHub,
            String::new(),
            Granted::pasted("ghp_the-secret".to_owned()),
            "token",
            vec![repo("alice/gunnar")],
        );

        assert_eq!(
            sessions.take_token("alice", ForgeKind::GitHub).as_deref(),
            Some("ghp_the-secret")
        );
        assert_eq!(
            sessions.take_token("alice", ForgeKind::GitHub),
            None,
            "a token handed to gunnar must not still be here"
        );
        let (summary, repos) = sessions
            .listing("alice", ForgeKind::GitHub)
            .expect("the connection survives losing its token");
        assert!(!summary.holds_token, "and the picker is told");
        assert_eq!(repos.len(), 1, "the listing is what the picker still needs");
    }

    #[test]
    fn one_session_cannot_see_anothers_connection() {
        let sessions = ForgeSessions::new();
        sessions.connect(
            "alice",
            ForgeKind::GitHub,
            String::new(),
            Granted::pasted("ghp_mine".to_owned()),
            "token",
            vec![repo("alice/gunnar")],
        );

        assert!(sessions
            .listing("someone-else", ForgeKind::GitHub)
            .is_none());
        assert!(sessions
            .take_token("someone-else", ForgeKind::GitHub)
            .is_none());
        assert!(sessions.connected("someone-else").is_empty());
    }

    #[test]
    fn connecting_again_replaces_rather_than_duplicates() {
        // How a person fixes a token they pasted wrongly. A second entry would
        // leave the wrong one reachable.
        let sessions = ForgeSessions::new();
        for token in ["ghp_wrong", "ghp_right"] {
            sessions.connect(
                "alice",
                ForgeKind::GitHub,
                String::new(),
                Granted::pasted(token.to_owned()),
                "token",
                vec![repo("alice/gunnar")],
            );
        }

        assert_eq!(sessions.connected("alice").len(), 1);
        assert_eq!(
            sessions.take_token("alice", ForgeKind::GitHub).as_deref(),
            Some("ghp_right")
        );
    }

    #[test]
    fn a_state_is_single_use_and_belongs_to_one_session_and_one_forge() {
        let sessions = ForgeSessions::new();
        let state = sessions.begin_oauth("alice", ForgeKind::GitHub).state;

        assert!(sessions
            .redeem_oauth("someone-else", ForgeKind::GitHub, &state)
            .is_none());
        assert!(sessions
            .redeem_oauth("alice", ForgeKind::GitLab, &state)
            .is_none());
        assert!(sessions
            .redeem_oauth("alice", ForgeKind::GitHub, "not-a-state")
            .is_none());
        assert!(sessions
            .redeem_oauth("alice", ForgeKind::GitHub, &state)
            .is_some());
        assert!(
            sessions
                .redeem_oauth("alice", ForgeKind::GitHub, &state)
                .is_none(),
            "a callback URL that works twice is one worth replaying"
        );
    }

    #[test]
    fn a_forgotten_connection_keeps_no_token() {
        let sessions = ForgeSessions::new();
        sessions.connect(
            "alice",
            ForgeKind::GitHub,
            String::new(),
            Granted::pasted("ghp_secret".to_owned()),
            "token",
            vec![repo("alice/gunnar")],
        );

        assert!(sessions.forget("alice", ForgeKind::GitHub).is_some());
        assert!(sessions.forget("alice", ForgeKind::GitHub).is_none());
        assert!(sessions.take_token("alice", ForgeKind::GitHub).is_none());
    }

    #[test]
    fn neither_debug_impl_prints_a_token() {
        // The leak that costs nothing to have and everything to find: a
        // `tracing` span that captured the struct, a panic message, a `{:?}` in
        // a hurry.
        let sessions = ForgeSessions::new();
        sessions.connect(
            "alice",
            ForgeKind::GitHub,
            String::new(),
            Granted::pasted("ghp_the-secret".to_owned()),
            "token",
            vec![repo("alice/gunnar")],
        );

        assert!(!format!("{sessions:?}").contains("ghp_the-secret"));
        let app = app(
            ForgeKind::GitHub,
            "",
            "client-id".to_owned(),
            "client-secret-value".to_owned(),
        );
        assert!(!format!("{app:?}").contains("client-secret-value"));
    }

    #[test]
    fn a_forge_with_no_app_has_no_door() {
        // Absent, not disabled: a button that answers "this console has no
        // GitHub app" is a promise printed at somebody who cannot act on it.
        let apps = ForgeApps::of(
            vec![app(
                ForgeKind::GitHub,
                "",
                "id".to_owned(),
                "secret".to_owned(),
            )],
            "https://gunnar.rs",
        );

        assert_eq!(apps.doors(), vec!["github"]);
        assert!(apps.get(ForgeKind::GitLab).is_none());
        assert_eq!(
            apps.redirect_uri(ForgeKind::GitHub),
            "https://gunnar.rs/api/forge/github/callback"
        );
    }

    #[test]
    fn the_basic_auth_username_is_the_one_each_forge_documents() {
        assert_eq!(
            credential_username(ForgeKind::GitHub, "alice"),
            "x-access-token"
        );
        assert_eq!(credential_username(ForgeKind::GitLab, "alice"), "oauth2");
        // Forgejo wants the account, which is why the function takes one.
        assert_eq!(
            credential_username(ForgeKind::Codeberg, "alice"),
            "alice"
        );
    }

    #[test]
    fn a_forge_refusal_keeps_the_forges_own_words() {
        let refused = fail(&ForgeError::Unauthorized {
            forge: ForgeKind::GitHub,
        });

        assert_eq!(refused.code, "unauthenticated");
        assert!(refused.message.contains("github"));
        assert!(refused.message.contains("401"));
    }
}