crates/gunnar-ui-server/src/api/forges.rs
//! **Connecting a forge**, which is the step before a mirror exists.
//!
//! Nothing in this module reaches gunnar. It talks to github.com, codeberg.org
//! or a GitLab over their own HTTPS REST APIs through `gunnar-forge`, because
//! "what repositories does this account have" is a question the git protocol
//! cannot express: git starts from a URL you already know and has no concept of
//! an account. [`super::mirrors`] is where the answer turns into `Mirror.Add`.
//!
//! # Two doors, and the plain one is first on purpose
//!
//! **Paste a token.** Works on every forge, self-hosted included, needs no app
//! registration, and needs nothing of the operator running this console. It is
//! the honest baseline: a person with a personal access token can import
//! repositories on a box that has never heard of OAuth.
//!
//! **OAuth.** Nicer, and conditional on somebody having registered an
//! application and given this process its client id and secret. A forge with no
//! application configured has **no button** — absent, not disabled, the same
//! shape the monetize routes and the guest identity use. A disabled button is a
//! promise printed at somebody who cannot act on it.
//!
//! Both doors end in the same place: an access token in
//! [`crate::forges::ForgeSessions`], server-side, keyed to the session, and
//! never in a response body.
//!
//! # Every network call here is blocking, and that is deliberate
//!
//! `gunnar-forge` is built on `ureq` — pure Rust, rustls, no async runtime
//! dragged into a crate whose job is "issue a few GETs and stop". So every call
//! goes through [`tokio::task::spawn_blocking`], and the token is moved into
//! that closure rather than borrowed across an await point. One HTTP client in
//! the tree, and the one that already has `tests/token_never_leaks.rs` behind
//! it.
use axum::extract::{Path, Query, State};
use axum::response::{IntoResponse, Redirect, Response};
use axum::Json;
use gunnar_forge::{ForgeKind, Repo};
use serde::{Deserialize, Serialize};
use crate::api::{ApiError, ApiResult, Importer};
use crate::app::AppState;
use crate::forges::{self, Connected};
use crate::grpc::Fail;
/// One repository as the picker shows it.
///
/// `gunnar_forge::Repo`'s fields, unchanged and unrenamed. The crate already
/// flattened three forges into one shape and picked these fields *for* a
/// picker; a second vocabulary here would mean a person comparing `gunnar
/// mirror import` in a terminal with this table has to work out which of ours
/// is which of its.
#[derive(Debug, Clone, Serialize)]
pub struct ForgeRepo {
/// `github` | `codeberg` | `gitlab`.
pub forge: &'static str,
/// `owner/name`, whole — GitLab's can be `group/subgroup/project`, and
/// truncating it produces a name that does not resolve.
pub full_name: String,
/// The name a mirror would take in a namespace: the LAST segment.
pub name: String,
pub clone_url_https: String,
pub clone_url_ssh: String,
pub default_branch: String,
pub private: bool,
pub archived: bool,
/// `None` on GitLab, which reports nothing unless the caller is an
/// administrator asking for statistics. A `0` would make every GitLab
/// repository look empty in a table sorted by size, so the absence is
/// spelled out.
pub size_kib: Option<u64>,
pub description: Option<String>,
}
impl From<&Repo> for ForgeRepo {
fn from(r: &Repo) -> ForgeRepo {
ForgeRepo {
forge: r.forge.as_str(),
full_name: r.full_name.clone(),
name: r
.full_name
.rsplit('/')
.next()
.unwrap_or(&r.full_name)
.to_owned(),
clone_url_https: r.clone_url_https.clone(),
clone_url_ssh: r.clone_url_ssh.clone(),
default_branch: r.default_branch.clone(),
private: r.private,
archived: r.archived,
size_kib: r.size_kib,
description: r.description.clone(),
}
}
}
/// What `GET /api/forges` answers.
#[derive(Debug, Clone, Serialize)]
pub struct Forges {
/// What this session has connected. **Never a token.**
pub connected: Vec<Connected>,
/// Which forges this console has an OAuth application for. The console
/// renders exactly these buttons and no others.
pub oauth: Vec<&'static str>,
/// Every forge the token door works for, which is all of them.
pub tokens: [&'static str; 3],
}
/// `GET /api/forges` — which forges are connected, and which doors exist.
pub async fn list(who: Importer, State(state): State<AppState>) -> ApiResult<Forges> {
state.forge_apps.refuse_unless_on()?;
Ok(Json(Forges {
connected: state.forges.connected(&who.who),
oauth: state.forge_apps.doors(),
tokens: ["github", "codeberg", "gitlab"],
}))
}
/// What the console sends to connect with a pasted token.
#[derive(Debug, Clone, Deserialize)]
pub struct NewConnection {
/// `github` | `codeberg` | `gitlab`. `codeberg` also names every other
/// Forgejo/Gitea, which is why a `base` can accompany it.
pub forge: String,
/// The site root of a self-hosted instance, e.g. `https://git.example.com`.
/// Empty means the public one.
#[serde(default)]
pub base: String,
/// The personal access token. It goes into
/// [`crate::forges::ForgeSessions`] and comes back out of no route.
#[serde(default)]
pub token: String,
}
/// `POST /api/forges/connect` — the token door.
///
/// **The token is verified by using it.** There is no "check this token"
/// endpoint on any of the three forges, and inventing a cheaper probe would
/// mean a connection that succeeds and a picker that then fails. So the listing
/// IS the verification: a 401 comes straight back as the forge's own refusal
/// and nothing is held.
///
/// The response carries the connection summary and the repository count. It
/// carries no token, and there is no field on [`Connected`] one could arrive
/// in — `mirroring::the_token_is_in_no_response_body_and_in_no_log_line` drives
/// a distinctive token through this route and asserts the bytes appear in no
/// body this console sends and in no line it logs.
pub async fn connect(
who: Importer,
State(state): State<AppState>,
Json(new): Json<NewConnection>,
) -> ApiResult<Connected> {
state.forge_apps.refuse_unless_on()?;
let kind = kind(&new.forge)?;
let token = new.token.trim().to_owned();
if token.is_empty() {
return Err(ApiError(Fail {
code: "invalidargument".to_owned(),
message: "paste the personal access token you want this console to list with"
.to_owned(),
}));
}
let base = new.base.trim().trim_end_matches('/').to_owned();
vet(&base, state.forge_apps.private_bases_allowed()).await?;
let repos = fetch(kind, base.clone(), token.clone()).await?;
let summary = state.forges.connect(
&who.who,
kind,
base,
forges::Granted::pasted(token),
"token",
repos,
);
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "forge.connected",
user = %who.who,
forge = summary.forge,
door = "token",
account = %summary.account,
repos = summary.repos,
"a forge was connected"
);
Ok(Json(summary))
}
/// `DELETE /api/forges/{forge}` — forget the connection and the token with it.
pub async fn forget(
who: Importer,
State(state): State<AppState>,
Path(forge): Path<String>,
) -> ApiResult<Forgotten> {
state.forge_apps.refuse_unless_on()?;
let kind = kind(&forge)?;
let held = state.forges.forget(&who.who, kind);
let gone = held.is_some();
if let Some(granted) = held {
// **And tell the forge, where the forge has somewhere to be told.**
// Dropping the token made this console stop holding a credential; it
// did nothing to the GRANT behind it, which stays live at the forge
// until its own expiry — up to a year for a GitHub OAuth app — and
// keeps appearing in the person's authorized-applications list as an
// access they believe they revoked by pressing Disconnect here.
//
// Best effort, and after the local drop rather than before: the
// console's promise is that it holds nothing, and that promise must not
// depend on a third party's endpoint being up. A refusal is logged with
// the forge's status and never with the token.
let revoked = match state.forge_apps.get(kind) {
Some(app) => revoke_at_forge(kind, app, granted).await,
// A pasted personal access token, or a forge this console has no
// application for. There is nothing to revoke with: the credential
// is the person's own and the place to retire it is the forge's own
// settings page.
None => Revoked::NotOurs,
};
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "forge.forgotten",
user = %who.who,
forge = kind.as_str(),
revoked = revoked.as_str(),
"a forge connection was dropped"
);
}
// Forgetting a connection that is not there is a SUCCESS: the caller asked
// for the console to hold nothing for that forge and it holds nothing.
Ok(Json(Forgotten { forgotten: gone }))
}
/// What became of the grant at the forge when a connection was dropped.
///
/// Four outcomes and not a bool, because "we did not try" and "we tried and it
/// said no" are different facts about somebody's account and an operator
/// reading the audit line needs to know which.
#[derive(Debug, PartialEq, Eq)]
enum Revoked {
/// The forge accepted the revocation.
Yes,
/// The forge was asked and refused, or could not be reached.
Refused,
/// This forge has no revocation endpoint this console knows.
NoEndpoint,
/// Not this console's grant to revoke: a pasted token, or no OAuth app.
NotOurs,
}
impl Revoked {
fn as_str(&self) -> &'static str {
match self {
Revoked::Yes => "yes",
Revoked::Refused => "refused",
Revoked::NoEndpoint => "no-endpoint",
Revoked::NotOurs => "not-ours",
}
}
}
/// **Where a forge takes a revocation, and what it wants sent there.**
///
/// Pure and separate from the dialling, so the decision can be asserted without
/// a forge — and the decision is the part with the per-vendor knowledge in it:
///
/// * **GitLab** implements RFC 7009 at `<site>/oauth/revoke`: a form carrying
/// the client credentials and the token. The refresh token is sent when there
/// is one, because revoking a refresh token revokes the access tokens minted
/// from it and the reverse is not true.
/// * **GitHub** does not implement RFC 7009. It has
/// `DELETE /applications/{client_id}/grant`, authorised with the application's
/// OWN credentials as HTTP Basic, which deletes the whole grant — every token
/// and the authorization itself. That is the right verb here: this is
/// Disconnect, not "expire my current token".
/// * **Codeberg/Forgejo/Gitea** publishes no revocation endpoint. Saying so is
/// the honest answer; inventing a URL would produce a 404 that looks like a
/// refusal and an audit line that says the wrong thing.
fn revocation(kind: ForgeKind, app: &forges::OAuthApp, granted: &forges::Granted) -> Option<Call> {
let site = site_root(&app.authorize_url);
match kind {
ForgeKind::GitLab => Some(Call::Form {
url: format!("{site}/oauth/revoke"),
fields: vec![
("client_id".to_owned(), app.client_id.clone()),
("client_secret".to_owned(), app.secret().to_owned()),
(
"token".to_owned(),
match granted.refresh.is_empty() {
true => granted.token.clone(),
false => granted.refresh.clone(),
},
),
],
}),
ForgeKind::GitHub => Some(Call::DeleteGrant {
url: format!(
"https://api.github.com/applications/{}/grant",
app.client_id
),
basic: (app.client_id.clone(), app.secret().to_owned()),
access_token: granted.token.clone(),
}),
_ => None,
}
}
/// The two shapes a revocation takes, so one function can dial either.
#[derive(Debug, PartialEq, Eq)]
enum Call {
/// RFC 7009: a form post.
Form {
url: String,
fields: Vec<(String, String)>,
},
/// GitHub's: a DELETE with the app's own Basic credentials and the token in
/// the body.
DeleteGrant {
url: String,
basic: (String, String),
access_token: String,
},
}
/// Dial the revocation, if there is one to dial.
///
/// Thirty seconds like [`exchange`], on `spawn_blocking` for the same reason:
/// `ureq` is blocking and this is an async handler. Errors are swallowed into
/// [`Revoked::Refused`] and never rendered — `ureq`'s `Display` embeds the URL,
/// and these URLs carry a client secret in the form body.
async fn revoke_at_forge(
kind: ForgeKind,
app: &forges::OAuthApp,
granted: forges::Granted,
) -> Revoked {
let Some(call) = revocation(kind, app, &granted) else {
return Revoked::NoEndpoint;
};
let done = tokio::task::spawn_blocking(move || {
// ureq 3 (2026-10-02) with ureq 2's defaults: no proxy from the
// environment and at most 5 redirects.
let agent = ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(30)))
.proxy(None)
.max_redirects(5)
.build()
.new_agent();
match call {
Call::Form { url, fields } => {
let form: Vec<(&str, &str)> = fields
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
agent.post(&url).send_form(form).is_ok()
}
Call::DeleteGrant {
url,
basic,
access_token,
} => {
use base64::Engine as _;
let credential = base64::engine::general_purpose::STANDARD
.encode(format!("{}:{}", basic.0, basic.1));
// The body by hand rather than through ureq's `json` feature,
// which this crate does not enable: it is one field, and a
// feature flag turned on for one call site is a dependency
// surface turned on for the whole build.
let body = serde_json::json!({ "access_token": access_token }).to_string();
// GitHub's grant revocation is a DELETE *with* a JSON body; ureq 3
// only lets a DELETE carry one when asked to.
agent
.delete(&url)
.header("Authorization", &format!("Basic {credential}"))
.header("Accept", "application/vnd.github+json")
.header("Content-Type", "application/json")
.force_send_body()
.send(&body)
.is_ok()
}
}
})
.await;
match done {
Ok(true) => Revoked::Yes,
_ => Revoked::Refused,
}
}
/// What a forget answered.
#[derive(Debug, Clone, Serialize)]
pub struct Forgotten {
/// False when there was nothing to forget, which is not a failure.
pub forgotten: bool,
}
/// What `GET /api/forges/{forge}/repos` answers.
#[derive(Debug, Clone, Serialize)]
pub struct Listing {
pub connection: Connected,
pub repos: Vec<ForgeRepo>,
}
/// `GET /api/forges/{forge}/repos` — the account's repositories.
///
/// Served from the listing the connection already made rather than re-asking
/// the forge, and that is not only a cache. **The import resolves a chosen
/// `full_name` against this same held list**, so the clone URL a mirror is
/// pointed at comes from the forge's own answer and never from the browser. A
/// browser that could name the URL could point a mirror at a host of its
/// choosing; `?refresh=true` re-asks the forge when a person has just made a
/// repository.
///
/// Archived repositories are in the answer, marked. Hiding them here would make
/// the toggle in the picker a second round trip, and a mirror of an archived
/// repository is often exactly what somebody wants.
///
/// There is no `?refresh=`. Re-listing would need the token *without taking
/// it*, and the whole design of [`crate::forges::ForgeSessions::take_token`] is
/// that there is no such accessor — a `token()` that handed out a copy would
/// leave the credential reachable for as long as the connection lived. A person
/// who has just made a repository upstream connects again, which is one paste
/// and re-lists as a side effect.
pub async fn repos(
who: Importer,
State(state): State<AppState>,
Path(forge): Path<String>,
) -> ApiResult<Listing> {
state.forge_apps.refuse_unless_on()?;
let kind = kind(&forge)?;
let (connection, held) = state
.forges
.listing(&who.who, kind)
.ok_or_else(|| no_connection(kind))?;
Ok(Json(Listing {
connection,
repos: held.iter().map(ForgeRepo::from).collect(),
}))
}
// ───────────────────────────────────────────────────────────────────────────
// The OAuth door
// ───────────────────────────────────────────────────────────────────────────
/// `GET /api/forge/{forge}/authorize` — start the dance.
///
/// A 302 to the forge, carrying a `state` this process minted and is holding
/// against this session. The response is a redirect and not JSON because the
/// browser has to *navigate*: an XHR to github.com would be blocked by CORS and
/// would not show the person the approval screen, which is the entire point of
/// the flow.
pub async fn authorize(
who: Importer,
State(state): State<AppState>,
Path(forge): Path<String>,
) -> Response {
if let Err(off) = state.forge_apps.refuse_unless_on() {
return off.into_response();
}
let kind = match kind(&forge) {
Ok(kind) => kind,
Err(e) => return e.into_response(),
};
let Some(app) = state.forge_apps.get(kind) else {
// Not a 501 with an explanation: a console that has no application for
// this forge renders no button for it, so reaching this at all means a
// hand-typed URL.
return ApiError(Fail {
code: "unimplemented".to_owned(),
message: format!(
"this console has no OAuth application for {}. Paste a personal access token \
instead — it works on every forge.",
kind.as_str()
),
})
.into_response();
};
let begun = state.forges.begin_oauth(&who.who, kind);
let url = authorize_url(app, &state.forge_apps.redirect_uri(kind), &begun);
Redirect::to(&url).into_response()
}
/// **The URL the browser is sent to, with the dance pinned to a secret this
/// process keeps.**
///
/// Pure, so the parameters can be asserted without a browser — which matters
/// because the two that were missing are invisible from every other angle: a
/// console with no PKCE looks and behaves exactly like one with it, right up
/// until an authorization code is intercepted.
///
/// `code_challenge` + `code_challenge_method=S256` (RFC 7636). A forge that
/// does not implement PKCE ignores both, which is why they can be sent
/// unconditionally to all three: an unknown query parameter on an authorize URL
/// is not an error anywhere, and the alternative — a per-forge switch — is a
/// list to keep in step with three vendors' release notes.
fn authorize_url(app: &forges::OAuthApp, redirect_uri: &str, begun: &forges::Begun) -> String {
format!(
"{}?client_id={}&redirect_uri={}&scope={}&state={}&response_type=code\
&code_challenge={}&code_challenge_method=S256",
app.authorize_url,
forges::query_value(&app.client_id),
forges::query_value(redirect_uri),
forges::query_value(app.scope),
forges::query_value(&begun.state),
forges::query_value(&begun.challenge),
)
}
/// What the forge sends back.
#[derive(Debug, Clone, Deserialize)]
pub struct Callback {
#[serde(default)]
pub code: String,
#[serde(default)]
pub state: String,
}
/// `GET /api/forge/{forge}/callback` — the way back.
///
/// **The `state` is checked before the code is exchanged for anything.** It was
/// minted by [`authorize`], is held server-side against this session and this
/// forge, is single-use, and expires in ten minutes. A callback carrying one
/// this process did not mint — the cross-site request forgery this parameter
/// exists for — is refused here, and the refusal costs the forge nothing
/// because no exchange happens.
///
/// The exchange itself is the BFF's: the client secret is in this process and
/// the access token comes back to this process. Neither ever reaches the
/// browser, which is the whole reason the authorization-code flow is the one
/// worth implementing rather than the implicit one.
///
/// It answers with a redirect rather than JSON because the browser arrived here
/// by navigating. Failures redirect too, carrying a short word from a closed
/// set — never the forge's message, which would be putting somebody else's text
/// into this console's address bar and from there into history.
pub async fn callback(
who: Importer,
State(state): State<AppState>,
Path(forge): Path<String>,
Query(back): Query<Callback>,
) -> Response {
if let Err(off) = state.forge_apps.refuse_unless_on() {
return off.into_response();
}
let kind = match kind(&forge) {
Ok(kind) => kind,
Err(e) => return e.into_response(),
};
let Some(app) = state.forge_apps.get(kind) else {
return landing(kind, Some("no-app"));
};
let verifier = match back.state.is_empty() {
true => None,
false => state.forges.redeem_oauth(&who.who, kind, &back.state),
};
let Some(verifier) = verifier else {
tracing::warn!(
target: crate::auth::AUDIT_TARGET,
event = "forge.oauth_state_refused",
user = %who.who,
forge = kind.as_str(),
"an OAuth callback arrived with a state this console did not mint, or one already \
used; no code was exchanged"
);
return landing(kind, Some("bad-state"));
};
if back.code.is_empty() {
return landing(kind, Some("no-code"));
}
let exchange = exchange(
app.token_url.clone(),
app.client_id.clone(),
app.secret().to_owned(),
back.code.clone(),
state.forge_apps.redirect_uri(kind),
verifier,
)
.await;
let granted = match exchange {
Ok(granted) => granted,
Err(word) => return landing(kind, Some(word)),
};
// A self-hosted instance's base is the one its application was configured
// with, which `authorize_url` already carries. Derived from there and never
// from a query parameter: a base the browser could set is a base that
// decides which host this console sends the token it just obtained to.
let base = site_root(&app.authorize_url);
match fetch(kind, base.clone(), granted.token.clone()).await {
Ok(repos) => {
let summary = state
.forges
.connect(&who.who, kind, base, granted, "oauth", repos);
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "forge.connected",
user = %who.who,
forge = summary.forge,
door = "oauth",
account = %summary.account,
repos = summary.repos,
"a forge was connected"
);
landing(kind, None)
}
Err(_) => landing(kind, Some("listing-refused")),
}
}
/// Where the browser lands after the dance, with a word for what happened.
///
/// The word comes from a closed set and never from a forge: putting somebody
/// else's message into this console's address bar puts it into history and into
/// every referrer.
fn landing(kind: ForgeKind, problem: Option<&str>) -> Response {
let to = match problem {
None => format!("/mirrors/import?forge={}", kind.as_str()),
Some(word) => format!("/mirrors/import?forge={}&problem={word}", kind.as_str()),
};
Redirect::to(&to).into_response()
}
/// The site root an authorize URL belongs to: `https://host`.
fn site_root(authorize_url: &str) -> String {
let (scheme, rest) = match authorize_url.split_once("://") {
Some(split) => split,
None => return String::new(),
};
let host = rest.split('/').next().unwrap_or_default();
let root = format!("{scheme}://{host}");
// github.com is `Forge::github()`'s business, not a base: the public API is
// at api.github.com and only the enterprise constructor takes a URL.
if root == "https://github.com"
|| root == "https://gitlab.com"
|| root == "https://codeberg.org"
{
String::new()
} else {
root
}
}
/// **Exchange an authorization code for an access token.**
///
/// One implementation for all three forges, because they agree about this part:
/// a form-encoded POST with `Accept: application/json`, answered with an object
/// carrying `access_token`. GitHub ignores `grant_type`, GitLab and Forgejo
/// require it, so it is always sent.
///
/// Errors come back as a **word**, never as the forge's text: this value ends
/// up in a redirect, and a redirect is an address bar.
async fn exchange(
token_url: String,
client_id: String,
client_secret: String,
code: String,
redirect_uri: String,
verifier: String,
) -> Result<forges::Granted, &'static str> {
let answer = tokio::task::spawn_blocking(move || {
// ureq 3 (2026-10-02) with ureq 2's defaults: no proxy from the
// environment and at most 5 redirects.
ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(30)))
.proxy(None)
.max_redirects(5)
.build()
.new_agent()
.post(&token_url)
.header("Accept", "application/json")
.send_form([
("grant_type", "authorization_code"),
("client_id", &client_id),
("client_secret", &client_secret),
("code", &code),
("redirect_uri", &redirect_uri),
// The other half of the PKCE pair. The forge hashes it and
// compares against the `code_challenge` it pinned at
// `authorize`; a code lifted out of a redirect chain by
// somebody who does not have this string cannot be spent.
("code_verifier", &verifier),
])
// The body, and NOT the error: ureq's own `Display` for a transport
// failure embeds the URL, and this URL had a client secret in its
// form body a moment ago. The same rule `gunnar_forge::error`
// states, restated because this is the one HTTP call that crate
// does not make for us.
.map_err(|_| "exchange-failed")
.and_then(|r| r.into_body().read_to_string().map_err(|_| "exchange-unreadable"))
})
.await
.map_err(|_| "exchange-failed")??;
parse_granted(&answer)
}
/// **What the token endpoint said, all three parts of it.**
///
/// Separate and pure so it can be asserted without a forge. Two of the three
/// fields were being parsed past and dropped:
///
/// * `refresh_token` — the other half of the credential this console was given.
/// Dropping it left a live grant at the forge that nothing here could name,
/// which is why `forget` could not revoke anything.
/// * `expires_in` — the only thing that distinguishes a live access token from
/// a dead one. Without it the console held an hour-old GitLab token as though
/// it were a GitHub classic PAT, handed it to gunnar, and the mirror failed
/// later with an "unauthorized" that could equally have meant a revoked
/// application or a deleted account.
///
/// A forge that answers 200 with `{"error":"bad_verification_code"}` — which
/// GitHub does — has no `access_token` and lands on `exchange-refused`. The
/// word is ours; the forge's own text is deliberately dropped rather than
/// carried into a redirect.
fn parse_granted(body: &str) -> Result<forges::Granted, &'static str> {
let parsed: serde_json::Value =
serde_json::from_str(body).map_err(|_| "exchange-unreadable")?;
let token = parsed
.get("access_token")
.and_then(serde_json::Value::as_str)
.filter(|t| !t.is_empty())
.ok_or("exchange-refused")?
.to_owned();
Ok(forges::Granted {
token,
refresh: parsed
.get("refresh_token")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_owned(),
// A number, or a string of digits: GitLab sends the first and some
// Gitea builds send the second, and a console that understood only one
// of them would silently treat the other as "never expires".
expires_in_secs: parsed.get("expires_in").and_then(|v| {
v.as_u64()
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
}),
})
}
// ───────────────────────────────────────────────────────────────────────────
// Shared
// ───────────────────────────────────────────────────────────────────────────
// ───────────────────────────────────────────────────────────────────────────
// Where a self-hosted forge is allowed to be
// ───────────────────────────────────────────────────────────────────────────
/// **The one address in this module a BROWSER chooses, and it is dialled by
/// the server.**
///
/// `NewConnection.base` is the site root of a self-hosted instance, and
/// [`forges::forge_at`] hands it straight to `gunnar-forge`, which issues a GET
/// from inside whatever network this console runs on. Unchecked, that is a
/// read-side SSRF with the answer returned to the caller: `http://169.254.169.254`
/// is a cloud instance's credential endpoint, `http://127.0.0.1:9000` is
/// whatever else the box is running, and `10.0.0.0/8` is the rest of the
/// estate. The route needs admin, which is why the finding is Medel and not
/// higher — but an admin of the CONSOLE is not the same person as an
/// administrator of the network it sits in, and "connect your Forgejo" is not a
/// request to probe the datacentre.
///
/// Two checks, and they answer different questions:
///
/// * **the syntax** ([`parse_base`]) — https only, a host and nothing
/// pretending to be one, no userinfo smuggled into the authority;
/// * **the address** — every address the name resolves to must be public.
///
/// `allow_private` is [`crate::forges::ForgeApps::private_bases_allowed`], and
/// it turns BOTH off: "our Forgejo is at `https://git.internal`" is a real
/// deployment, so there is a named, deployment-wide way to say so
/// (`GUNNAR_UI_FORGE_ALLOW_PRIVATE_BASE`). It is off unless an operator asked,
/// and turning it on is turning this guard off.
///
/// # What this does NOT close, said out loud
///
/// The name is resolved here and resolved AGAIN by `ureq` when it dials, so a
/// DNS answer that changes between the two — a rebinding record with a
/// one-second TTL — passes this check and connects somewhere else. Closing that
/// needs the resolved address to be the thing dialled, which means a custom
/// connector inside `gunnar-forge`, not a check in front of it. This raises the
/// cost from "type a URL" to "run a DNS server", which is worth having; it is
/// not a wall.
async fn vet(base: &str, allow_private: bool) -> Result<(), ApiError> {
if base.is_empty() {
// The public instance. No caller-named host is dialled at all.
return Ok(());
}
let (host, port) = parse_base(base, allow_private)?;
// `ToSocketAddrs` is the platform resolver and it BLOCKS, which is the same
// reason every other call in this module goes through `spawn_blocking`.
let named = host.clone();
let resolved = tokio::task::spawn_blocking(move || {
use std::net::ToSocketAddrs as _;
(named.as_str(), port)
.to_socket_addrs()
.map(|addrs| addrs.map(|a| a.ip()).collect::<Vec<_>>())
})
.await
.map_err(|_| {
refuse(
"internal",
"the console could not resolve that host".to_owned(),
)
})?
.map_err(|_| {
refuse(
"invalidargument",
format!("{host} does not resolve to an address from this console"),
)
})?;
if resolved.is_empty() {
return Err(refuse(
"invalidargument",
format!("{host} resolves to no address at all"),
));
}
// EVERY address, not the first: a name with one public A record and one
// private one is the cheapest way past a check that stops at the first.
for address in &resolved {
if !is_public(*address) && !allow_private {
return Err(refuse(
"invalidargument",
format!(
"{host} resolves to {address}, which is a private, loopback or link-local \
address. This console will not fetch from inside its own network on a URL \
a browser chose."
),
));
}
}
Ok(())
}
/// The host and port of a base URL, or the refusal that says what was wrong.
///
/// Pure, so the whole table below is testable without a network.
fn parse_base(base: &str, allow_private: bool) -> Result<(String, u16), ApiError> {
let rest =
match base.split_once("://") {
Some(("https", rest)) => rest,
// Plain http only where the deployment has already said it may dial
// its own network: that is the "our Forgejo is on the LAN" case and
// it is the same named decision, not a second one.
Some(("http", rest)) if allow_private => rest,
// Refused rather than upgraded. A personal access token travels on this
// connection, and a console that quietly rewrote `http` to `https`
// would be answering a question the operator did not ask; one that
// accepted `http` would put the token on the wire in clear.
Some(("http", _)) => return Err(refuse(
"invalidargument",
"a forge base must be https://. The token you paste travels on this connection, \
and http:// puts it on the wire in clear."
.to_owned(),
)),
Some((scheme, _)) => {
return Err(refuse(
"invalidargument",
format!("{scheme}:// is not a scheme this console will dial; use https://"),
))
}
None => {
return Err(refuse(
"invalidargument",
format!(
"{base:?} is not a URL. A self-hosted forge's base is its site root, \
e.g. https://git.example.com"
),
))
}
};
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
if authority.is_empty() {
return Err(refuse(
"invalidargument",
"that base has no host in it".to_owned(),
));
}
// Userinfo in a base is a credential going somewhere this console did not
// put it, and it is also how `https://github.com@evil.test/` is spelled.
if authority.contains('@') {
return Err(refuse(
"invalidargument",
"a forge base carries no user name or password: paste the token in the token field"
.to_owned(),
));
}
let (host, port) = split_host_port(authority)?;
// Names that are not on the public internet by definition. `.local` is
// mDNS, `.internal` is what cloud providers hand out inside a VPC, and
// `.home.arpa` is the home-network name RFC 8375 reserved.
let lower = host.to_ascii_lowercase();
let private_name = lower == "localhost"
|| [
".localhost",
".local",
".internal",
".home.arpa",
".localdomain",
]
.iter()
.any(|suffix| lower.ends_with(suffix));
if private_name && !allow_private {
return Err(refuse(
"invalidargument",
format!(
"{host} is a name inside this machine or its network, not a forge on the \
internet"
),
));
}
Ok((host, port))
}
/// `host`, `host:port`, `[::1]` or `[::1]:443`.
fn split_host_port(authority: &str) -> Result<(String, u16), ApiError> {
let bad = |what: &str| {
refuse(
"invalidargument",
format!("{authority:?} is not a host this console can dial: {what}"),
)
};
if let Some(rest) = authority.strip_prefix('[') {
let (inside, after) = rest.split_once(']').ok_or_else(|| bad("unclosed ["))?;
let port = match after {
"" => 443,
p => p
.strip_prefix(':')
.and_then(|p| p.parse().ok())
.ok_or_else(|| bad("that is not a port"))?,
};
return Ok((inside.to_owned(), port));
}
match authority.rsplit_once(':') {
Some((host, port)) => {
let port = port.parse().map_err(|_| bad("that is not a port"))?;
Ok((host.to_owned(), port))
}
None => Ok((authority.to_owned(), 443)),
}
}
/// **Is this an address on the public internet?**
///
/// Written out rather than taken from `IpAddr::is_global`, which is still
/// unstable. Erring towards "no" is the whole point: an address family this
/// console has never heard of is not a forge.
fn is_public(address: std::net::IpAddr) -> bool {
use std::net::IpAddr;
match address {
IpAddr::V4(v4) => {
let [a, b, ..] = v4.octets();
!(v4.is_private() // 10/8, 172.16/12, 192.168/16
|| v4.is_loopback() // 127/8
|| v4.is_link_local() // 169.254/16 — the metadata address
|| v4.is_broadcast()
|| v4.is_documentation()
|| v4.is_unspecified()
|| v4.is_multicast()
|| (a == 100 && (64..128).contains(&b)) // 100.64/10, carrier NAT
|| (a == 192 && b == 0) // 192.0.0/24, protocol assignments
|| (a == 198 && (18..20).contains(&b)) // 198.18/15, benchmarking
|| a >= 240) // 240/4, reserved
}
// An IPv4 address wearing an IPv6 hat is still that address, and
// `::ffff:127.0.0.1` is the oldest way past a check that forgets it.
IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
Some(v4) => is_public(IpAddr::V4(v4)),
None => {
let first = v6.segments()[0];
!(v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
|| (first & 0xfe00) == 0xfc00 // fc00::/7, unique local
|| (first & 0xffc0) == 0xfe80) // fe80::/10, link local
}
},
}
}
fn refuse(code: &str, message: String) -> ApiError {
ApiError(Fail {
code: code.to_owned(),
message,
})
}
/// Parse a forge name, or refuse by name.
fn kind(name: &str) -> Result<ForgeKind, ApiError> {
forges::kind_from_name(name.trim()).ok_or_else(|| {
ApiError(Fail {
code: "invalidargument".to_owned(),
message: format!(
"{name:?} is not a forge this console knows. It knows github, codeberg (and \
every other Forgejo/Gitea) and gitlab."
),
})
})
}
/// The refusal for a forge that was never connected.
fn no_connection(kind: ForgeKind) -> ApiError {
ApiError(Fail {
code: "notfound".to_owned(),
message: format!(
"this console is not holding a {} connection for you. Connect one — paste a token, \
or use the button if this console has an application for it.",
kind.as_str()
),
})
}
/// List an account's repositories, off the async runtime.
///
/// `gunnar-forge` is blocking, so this is the seam. The token is **moved** into
/// the closure rather than borrowed: there is no await point it could be held
/// across, and no way for it to end up captured by a future that outlives the
/// call.
async fn fetch(kind: ForgeKind, base: String, token: String) -> Result<Vec<Repo>, ApiError> {
tokio::task::spawn_blocking(move || forges::forge_at(kind, &base).list_repos(&token))
.await
.map_err(|_| {
ApiError(Fail {
code: "internal".to_owned(),
message: "the console could not run the forge listing".to_owned(),
})
})?
.map_err(|e| ApiError(forges::fail(&e)))
}
#[cfg(test)]
mod tests {
use super::*;
// ── Where a self-hosted forge may be (GUI-007) ──────────────────────────
/// ★ **The console will not be pointed at its own network.**
///
/// **Seen red.** Before [`vet`], `connect` took `base` exactly as the body
/// spelled it and handed it to `forges::forge_at`, which dials. Every line
/// below reached `ureq` and came back to the caller as either a forge
/// listing or an error carrying what the address said — a read-side SSRF
/// with the answer returned. `169.254.169.254` is the row that matters
/// most: on every cloud this could ever run on, that address hands out
/// credentials.
#[tokio::test]
async fn a_base_inside_the_network_is_refused_before_anything_is_dialled() {
for base in [
"https://127.0.0.1",
"https://127.0.0.1:9000",
"https://169.254.169.254",
"https://10.1.2.3",
"https://192.168.1.1",
"https://172.16.0.5",
"https://100.64.0.1",
"https://[::1]",
"https://[::1]:8443",
// The oldest way past a check that forgets IPv4-mapped addresses.
"https://[::ffff:127.0.0.1]",
"https://localhost:3000",
"https://forge.internal",
"https://nas.local",
] {
let refused = vet(base, false)
.await
.err()
.unwrap_or_else(|| panic!("{base} was accepted"));
assert_eq!(
refused.0.http_status(),
http::StatusCode::BAD_REQUEST,
"{base}: {}",
refused.0.message
);
}
}
/// The other half of the same gate: a scheme that is not https, and a
/// userinfo smuggled into the authority.
#[test]
fn only_https_is_dialled_and_a_base_carries_no_credential() {
for (base, expected) in [
("http://git.example.com", "in clear"),
("file:///etc/passwd", "not a scheme"),
("gopher://git.example.com", "not a scheme"),
("git.example.com", "not a URL"),
// `https://github.com@evil.test/` reads as github.com to a human
// and dials evil.test.
("https://github.com@evil.test", "no user name or password"),
] {
let refused = parse_base(base, false)
.err()
.unwrap_or_else(|| panic!("{base} was accepted"));
assert!(
refused.0.message.contains(expected),
"{base} was refused for the wrong reason: {}",
refused.0.message
);
}
}
/// And an ordinary self-hosted forge still parses, port and all. A gate
/// that refuses everything is the identity-value trap.
#[test]
fn an_ordinary_self_hosted_base_still_parses() {
assert_eq!(
parse_base("https://git.example.com", false).expect("accepted"),
("git.example.com".to_owned(), 443)
);
assert_eq!(
parse_base("https://git.example.com:8443/gitlab", false).expect("accepted"),
("git.example.com".to_owned(), 8443)
);
assert_eq!(
parse_base("https://[2606:4700::1111]:8443", false).expect("accepted"),
("2606:4700::1111".to_owned(), 8443)
);
}
/// ★ **And the deployment that really is on a private network can say so,
/// once, by name.**
///
/// The guard would otherwise refuse the honest case — "our Forgejo is at
/// `https://git.internal`" — and a guard that refuses what the feature is
/// for gets turned off wholesale by the first person it inconveniences.
/// `GUNNAR_UI_FORGE_ALLOW_PRIVATE_BASE` is that switch, it is a deployment
/// decision rather than a per-request one, and turning it on turns this
/// guard off. `crate::mirroring`'s harness is its other user: the forge
/// there is a mock server on loopback.
#[tokio::test]
async fn a_deployment_that_says_so_may_dial_its_own_network() {
assert!(vet("https://127.0.0.1:9000", true).await.is_ok());
assert_eq!(
parse_base("http://192.168.1.10:3000", true).expect("accepted"),
("192.168.1.10".to_owned(), 3000),
"the LAN case is plain http as often as not, and it is the same decision"
);
// …and it is OFF by default, which is the half that matters.
assert!(!crate::forges::ForgeApps::default().private_bases_allowed());
}
/// The empty base is the public instance and dials no caller-named host at
/// all, so it is not gated — and must not be, or the ordinary
/// github.com/codeberg.org/gitlab.com connection would need DNS to work.
#[tokio::test]
async fn the_public_instance_needs_no_vetting() {
assert!(vet("", false).await.is_ok());
}
#[test]
fn a_real_forges_address_is_public() {
use std::net::IpAddr;
// github.com and codeberg.org as they resolved on 2026-09-11, so the
// predicate is proved to say YES to something as well as NO.
for public in ["140.82.121.4", "217.197.91.145", "2606:4700::6810:85e5"] {
assert!(
is_public(public.parse::<IpAddr>().expect("an address")),
"{public} was called private"
);
}
}
#[test]
fn a_repositorys_mirror_name_is_its_last_segment() {
// GitLab's `path_with_namespace` can be `group/subgroup/project`, and a
// mirror takes a namespace of its own, so the name is the leaf.
let deep = ForgeRepo::from(&Repo {
forge: ForgeKind::GitLab,
full_name: "group/subgroup/project".to_owned(),
clone_url_https: "https://gitlab.com/group/subgroup/project.git".to_owned(),
clone_url_ssh: "git@gitlab.com:group/subgroup/project.git".to_owned(),
default_branch: "main".to_owned(),
private: true,
archived: false,
size_kib: None,
description: None,
});
assert_eq!(deep.full_name, "group/subgroup/project");
assert_eq!(deep.name, "project");
assert_eq!(deep.size_kib, None, "GitLab reports no size; 0 would lie");
}
#[test]
fn a_public_instances_authorize_url_yields_no_base() {
// `Forge::github()` and `Forge::github_enterprise()` are the whole
// difference, and it is decided by whether there is a base at all.
assert_eq!(site_root("https://github.com/login/oauth/authorize"), "");
assert_eq!(site_root("https://codeberg.org/login/oauth/authorize"), "");
assert_eq!(
site_root("https://git.example.com/login/oauth/authorize"),
"https://git.example.com"
);
}
fn an_app() -> forges::OAuthApp {
forges::OAuthApp::at(
ForgeKind::GitHub,
"Iv1.the-client",
"the-client-secret",
"https://github.com/login/oauth/authorize",
"https://github.com/login/oauth/access_token",
"repo",
)
}
/// ★ **The authorize URL pins the dance to a secret the browser never
/// sees.**
///
/// Without PKCE an authorization code is a bearer credential for the length
/// of one exchange, and it travels where the browser travels: an address
/// bar, a history entry, a `Referer`, a proxy log, an operating system's
/// URL handler. `state` does not help — it proves the console began the
/// dance, not that the party finishing it is the one that began it.
///
/// So `code_challenge=S256(verifier)` goes out with the redirect and the
/// verifier stays here. The challenge is asserted to BE the hash of what
/// the store kept, because a challenge that is merely present and unrelated
/// would pass every other test and protect nothing.
///
/// Red before: the URL carried `client_id`, `redirect_uri`, `scope`,
/// `state` and `response_type` — and neither PKCE parameter.
#[test]
fn the_authorize_url_carries_a_pkce_challenge_that_matches_the_kept_verifier() {
let sessions = forges::ForgeSessions::new();
let begun = sessions.begin_oauth("alice", ForgeKind::GitHub);
let url = authorize_url(
&an_app(),
"https://console.example/api/forge/github/callback",
&begun,
);
assert!(
url.contains("code_challenge_method=S256"),
"the plain method puts the verifier in the address bar it is protecting: {url}"
);
assert!(
url.contains(&format!(
"code_challenge={}",
forges::query_value(&begun.challenge)
)),
"{url}"
);
// The verifier itself is NOT in the URL — which is the whole point.
let verifier = sessions
.redeem_oauth("alice", ForgeKind::GitHub, &begun.state)
.expect("the state redeems");
assert!(
!url.contains(&verifier),
"the verifier travelled with the browser, so it protects nothing: {url}"
);
assert_eq!(
forges::code_challenge(&verifier),
begun.challenge,
"the challenge is not the hash of the verifier that was kept, so the forge \
would refuse every exchange"
);
assert!(
!verifier.is_empty() && verifier.len() >= 43,
"RFC 7636 wants 43-128 characters of entropy, and got {}",
verifier.len()
);
}
/// ★ **The token response is read whole: the refresh token and the expiry
/// too.**
///
/// Both were parsed past. The expiry is what tells a live access token from
/// a dead one — without it a one-hour GitLab token was held like a GitHub
/// classic PAT and handed to gunnar hours later, and the mirror failed with
/// an "unauthorized" that could equally have been a revoked app. The
/// refresh token is the half that names the grant; dropping it is why
/// Disconnect could not revoke anything.
///
/// Red before: `exchange` returned a bare `String` and there was nothing to
/// assert either field on.
#[test]
fn a_token_response_is_read_whole_and_not_just_its_access_token() {
let granted = parse_granted(
r#"{"access_token":"gho_live","refresh_token":"ghr_later","expires_in":3600}"#,
)
.expect("a well-formed token response");
assert_eq!(granted.token, "gho_live");
assert_eq!(granted.refresh, "ghr_later");
assert_eq!(granted.expires_in_secs, Some(3600));
// Some Gitea builds send the number as a string. A console that read
// only one of the two spellings would treat the other as "never
// expires", which is the failure this exists to prevent.
let as_text = parse_granted(r#"{"access_token":"t","expires_in":"7200"}"#)
.expect("a string expiry is still an expiry");
assert_eq!(as_text.expires_in_secs, Some(7200));
// And no expiry stated is not expiry. A GitHub classic PAT has none.
let forever = parse_granted(r#"{"access_token":"ghp_classic"}"#).expect("no expiry");
assert_eq!(forever.expires_in_secs, None);
assert_eq!(forever.refresh, "");
// A 200 that is really a refusal, which is what GitHub sends.
assert_eq!(
parse_granted(r#"{"error":"bad_verification_code"}"#).unwrap_err(),
"exchange-refused"
);
}
/// ★ **An expired access token is not handed onward.**
///
/// `has_token` is what the panel renders and what the import path asks
/// before it takes the credential. Reading it as "a token is present"
/// rather than "a token that would work is present" is how a dead
/// credential got sealed into gunnar by `Mirror.SetCredential`, to fail at
/// the next sync with a message naming none of the several possible causes.
#[test]
fn an_access_token_past_its_stated_life_is_not_a_token_any_more() {
let sessions = forges::ForgeSessions::new();
let summary = sessions.connect(
"alice",
ForgeKind::GitHub,
String::new(),
forges::Granted {
token: "gho_live".to_owned(),
refresh: String::new(),
// Already over by the time it is stored.
expires_in_secs: Some(0),
},
"oauth",
Vec::new(),
);
assert!(
!summary.holds_token,
"a connection whose token expired still reported that it held one"
);
let live = sessions.connect(
"alice",
ForgeKind::GitLab,
String::new(),
forges::Granted::pasted("glpat_no_expiry".to_owned()),
"token",
Vec::new(),
);
assert!(
live.holds_token,
"a token with no stated lifetime was treated as expired, which would break \
every pasted personal access token"
);
}
/// ★ **Disconnect tells the forge, where the forge has somewhere to be
/// told.**
///
/// Dropping the token made the console hold nothing; it left the GRANT live
/// at the forge — up to a year for a GitHub OAuth app — and still listed
/// among the person's authorized applications as an access they believe
/// they revoked.
///
/// Asserted on the request that would be sent rather than on a forge
/// answering it: the per-vendor knowledge is the decision, and the two
/// vendors disagree about everything except that there is one. GitHub
/// deletes the whole grant with the application's own credentials; GitLab
/// implements RFC 7009 and is given the REFRESH token, because revoking a
/// refresh token revokes what was minted from it and the reverse is not
/// true.
///
/// Red before: `ForgeSessions::forget` answered a bool and dropped the
/// credential, so there was nothing left to revoke with and no function to
/// assert on.
#[test]
fn disconnecting_revokes_the_grant_where_the_forge_has_an_endpoint_for_it() {
let granted = forges::Granted {
token: "gho_live".to_owned(),
refresh: "ghr_later".to_owned(),
expires_in_secs: Some(3600),
};
let github = revocation(ForgeKind::GitHub, &an_app(), &granted).expect("github revokes");
assert_eq!(
github,
Call::DeleteGrant {
url: "https://api.github.com/applications/Iv1.the-client/grant".to_owned(),
basic: ("Iv1.the-client".to_owned(), "the-client-secret".to_owned()),
access_token: "gho_live".to_owned(),
},
"GitHub does not implement RFC 7009; the grant is what Disconnect means"
);
let gitlab_app = forges::OAuthApp::at(
ForgeKind::GitLab,
"the-app",
"the-secret",
"https://gitlab.example/oauth/authorize",
"https://gitlab.example/oauth/token",
"api",
);
let gitlab = revocation(ForgeKind::GitLab, &gitlab_app, &granted).expect("gitlab revokes");
let Call::Form { url, fields } = gitlab else {
panic!("gitlab takes a form post");
};
assert_eq!(url, "https://gitlab.example/oauth/revoke");
assert!(
fields.contains(&("token".to_owned(), "ghr_later".to_owned())),
"the refresh token is what revokes the family: {fields:?}"
);
// Codeberg/Forgejo publishes none. Saying so is the honest answer; a
// guessed URL would 404 and be logged as a refusal, which says
// something false about somebody's account.
let codeberg_app = forges::OAuthApp::at(
ForgeKind::Codeberg,
"app",
"secret",
"https://codeberg.org/login/oauth/authorize",
"https://codeberg.org/login/oauth/access_token",
"write:repository",
);
assert!(revocation(ForgeKind::Codeberg, &codeberg_app, &granted).is_none());
}
#[test]
fn an_unknown_forge_is_refused_by_name() {
let refused = kind("bitbucket").expect_err("not one of the three");
assert_eq!(refused.0.code, "invalidargument");
assert!(refused.0.message.contains("bitbucket"));
}
}