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"));
}
}