diff --git a/Cargo.lock b/Cargo.lock
index 5d768fe07..ed64b4495 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4857,8 +4857,10 @@ dependencies = [
name = "rustgis-tiles"
version = "0.4.0"
dependencies = [
+ "base64 0.23.1",
"brotli",
"brotli-decompressor",
+ "getrandom 0.4.3",
"image",
"log",
"miniz_oxide 0.8.9",
diff --git a/apps/rustgis-web/Cargo.toml b/apps/rustgis-web/Cargo.toml
index 37c018158..6f4f0f602 100644
--- a/apps/rustgis-web/Cargo.toml
+++ b/apps/rustgis-web/Cargo.toml
@@ -25,4 +25,4 @@ rfd = { workspace = true, features = ["file-handle-inner"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "Node", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "File", "Url", "Location", "Event", "EventTarget", "KeyboardEvent", "MessageEvent", "BeforeUnloadEvent", "Storage", "Response", "Headers", "AbortController", "AbortSignal", "RequestInit", "RequestRedirect", "ReadableStream", "ReadableStreamDefaultReader", "ImageBitmap", "ImageBitmapOptions", "PremultiplyAlpha", "ColorSpaceConversion", "OffscreenCanvas", "OffscreenCanvasRenderingContext2d", "ImageData"] }
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "Node", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "File", "Url", "Location", "Event", "EventTarget", "KeyboardEvent", "MessageEvent", "BeforeUnloadEvent", "Storage", "Crypto", "RequestCredentials", "ReferrerPolicy", "Response", "Headers", "AbortController", "AbortSignal", "RequestInit", "RequestRedirect", "ReadableStream", "ReadableStreamDefaultReader", "ImageBitmap", "ImageBitmapOptions", "PremultiplyAlpha", "ColorSpaceConversion", "OffscreenCanvas", "OffscreenCanvasRenderingContext2d", "ImageData"] }
diff --git a/apps/rustgis-web/index.html b/apps/rustgis-web/index.html
index 3341b449a..ffae55d36 100644
--- a/apps/rustgis-web/index.html
+++ b/apps/rustgis-web/index.html
@@ -7,6 +7,7 @@
+
diff --git a/apps/rustgis-web/oauth-callback.html b/apps/rustgis-web/oauth-callback.html
new file mode 100644
index 000000000..4b747de0b
--- /dev/null
+++ b/apps/rustgis-web/oauth-callback.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+ RustGIS: signing in
+
+
+
+ Signing in to ArcGIS…
+ RustGIS is finishing the sign-in. This window closes by itself.
+
+
diff --git a/apps/rustgis-web/src/arcgis.rs b/apps/rustgis-web/src/arcgis.rs
new file mode 100644
index 000000000..199662393
--- /dev/null
+++ b/apps/rustgis-web/src/arcgis.rs
@@ -0,0 +1,196 @@
+//! Signing in to ArcGIS in the browser: the portal's sign-in page in a pop-up window, which comes
+//! back to the app's own `oauth-callback.html` (same origin, so its address can be read here),
+//! then the code is exchanged for tokens with the browser's fetch (ArcGIS answers web pages from
+//! any origin) and the session is kept as on the desktop (see `rustgis_tiles::arcgis_auth`;
+//! requests carry the token as their `token` parameter, see `arcgis_auth::with_token`).
+//! Access tokens are renewed before they expire, by the fetcher.
+
+use std::cell::Cell;
+
+use rustgis_ui_egui::Slot;
+use rustgis_ui_egui::rustgis_engine::render::rustgis_tiles::{arcgis_auth, cache};
+use wasm_bindgen::JsCast as _;
+
+/// How long a request to the portal may take before it is given up.
+const REQUEST_MS: i32 = 30_000;
+/// The largest answer read from the portal.
+const MAX_ANSWER: usize = 4 << 20;
+/// How long the user has to finish the portal's page.
+const SIGN_IN_MS: f64 = 5.0 * 60_000.0;
+
+thread_local! {
+ /// A sign-in is under way (one at a time).
+ static SIGNING_IN: Cell = const { Cell::new(false) };
+ /// A renewal is under way: the fetches meanwhile use the token still valid.
+ static RENEWING: Cell = const { Cell::new(false) };
+}
+
+fn err(e: wasm_bindgen::JsValue) -> String {
+ format!("{e:?}")
+}
+
+/// 32 random bytes from the browser's crypto, as hex (a PKCE verifier or a state).
+fn random() -> Result {
+ let crypto = web_sys::window().ok_or("no window")?.crypto().map_err(err)?;
+ let mut bytes = [0u8; 32];
+ crypto.get_random_values_with_u8_array(&mut bytes).map_err(err)?;
+ Ok(bytes.iter().map(|b| format!("{b:02x}")).collect())
+}
+
+/// The callback page next to the app's page (`…/index.html` → `…/oauth-callback.html`).
+fn redirect() -> Result {
+ let location = web_sys::window().ok_or("no window")?.location();
+ let origin = location.origin().map_err(err)?;
+ let path = location.pathname().map_err(err)?;
+ let dir = path.rsplit_once('/').map_or("", |(d, _)| d);
+ Ok(format!("{origin}{dir}/{}", arcgis_auth::WEB_CALLBACK))
+}
+
+/// Waits `ms`; an error when no timer can be set (the caller gives up rather than spin).
+async fn sleep(ms: i32) -> Result<(), String> {
+ let window = web_sys::window().ok_or("no window")?;
+ let mut timed = Ok(());
+ let promise = js_sys::Promise::new(&mut |resolve, _| {
+ timed = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms).map(drop);
+ });
+ timed.map_err(|_| "the browser has no timer for the sign-in".to_string())?;
+ wasm_bindgen_futures::JsFuture::from(promise).await.map(drop).map_err(err)
+}
+
+/// A request to the portal: a form POSTed, or a GET. Never redirected, never with the page's
+/// cookies.
+async fn request(url: &str, form: Option<&str>) -> Result, String> {
+ let window = web_sys::window().ok_or("no window")?;
+ let init = web_sys::RequestInit::new();
+ init.set_redirect(web_sys::RequestRedirect::Error);
+ init.set_credentials(web_sys::RequestCredentials::Omit);
+ let headers = web_sys::Headers::new().map_err(err)?;
+ headers.set("Accept", "application/json").map_err(err)?;
+ if let Some(body) = form {
+ init.set_method("POST");
+ headers.set("Content-Type", "application/x-www-form-urlencoded").map_err(err)?;
+ init.set_body(&wasm_bindgen::JsValue::from_str(body));
+ }
+ init.set_headers(&headers);
+ // A portal that never answers is given up, so a sign-in or a renewal can't hang for good.
+ let abort = web_sys::AbortController::new().map_err(err)?;
+ init.set_signal(Some(&abort.signal()));
+ let stop = {
+ let abort = abort.clone();
+ wasm_bindgen::closure::Closure::once_into_js(move || abort.abort())
+ };
+ let timer = window.set_timeout_with_callback_and_timeout_and_arguments_0(stop.unchecked_ref(), REQUEST_MS).ok();
+ let read = async {
+ let resp: web_sys::Response = wasm_bindgen_futures::JsFuture::from(window.fetch_with_str_and_init(url, &init))
+ .await
+ .map_err(|_| "couldn't reach the portal".to_string())?
+ .dyn_into()
+ .map_err(err)?;
+ // A server error says nothing of the request: it counts as the portal not being reached.
+ if resp.status() >= 500 {
+ return Err(format!("the portal answered HTTP {}", resp.status()));
+ }
+ let length = resp.headers().get("content-length").ok().flatten().and_then(|l| l.parse::().ok());
+ if length.is_some_and(|n| n > MAX_ANSWER) {
+ return Err("the portal's answer is too large".into());
+ }
+ let buf = wasm_bindgen_futures::JsFuture::from(resp.array_buffer().map_err(err)?)
+ .await
+ .map_err(|_| "couldn't reach the portal".to_string())?;
+ let bytes = js_sys::Uint8Array::new(&buf);
+ if bytes.length() as usize > MAX_ANSWER {
+ return Err("the portal's answer is too large".into());
+ }
+ Ok(bytes.to_vec())
+ };
+ let result = read.await;
+ if let Some(t) = timer {
+ window.clear_timeout_with_handle(t);
+ }
+ result
+}
+
+/// Signs in to `portal` (blank: ArcGIS Online) with `client_id` (blank: RustGIS's own app on
+/// ArcGIS Online): opens the portal's page in a pop-up and, when it comes back, keeps the session.
+/// The slot gets who signed in, or why not. Called from a click (the pop-up must open right away).
+pub fn sign_in(portal: String, client_id: String, slot: Slot>, ctx: egui::Context) {
+ let put = move |r: Result| {
+ *slot.lock().unwrap_or_else(|e| e.into_inner()) = Some(r);
+ ctx.request_repaint();
+ };
+ if SIGNING_IN.get() {
+ return put(Err("a sign-in is already open: finish it in its window, or close that window".into()));
+ }
+ let started = (|| {
+ let flow = arcgis_auth::web_sign_in(&portal, &client_id, &redirect()?, &random()?, &random()?)?;
+ let window = web_sys::window().ok_or("no window")?;
+ let popup = window
+ .open_with_url_and_target_and_features(&flow.url, "rustgis-arcgis", "popup,width=520,height=720")
+ .map_err(err)?
+ .ok_or("the browser blocked the sign-in window: allow pop-ups for this site, then Sign in again")?;
+ Ok::<_, String>((flow, popup))
+ })();
+ let (flow, popup) = match started {
+ Ok(f) => f,
+ Err(e) => return put(Err(e)),
+ };
+ SIGNING_IN.set(true);
+ wasm_bindgen_futures::spawn_local(async move {
+ let result = finish(&flow, &popup).await;
+ let _ = popup.close();
+ SIGNING_IN.set(false);
+ put(result);
+ });
+}
+
+async fn finish(flow: &arcgis_auth::WebSignIn, popup: &web_sys::Window) -> Result {
+ let deadline = js_sys::Date::now() + SIGN_IN_MS;
+ let (endpoint, body) = loop {
+ sleep(300).await?;
+ if popup.closed().unwrap_or(true) {
+ return Err("the sign-in window was closed".into());
+ }
+ if js_sys::Date::now() > deadline {
+ return Err("the sign-in timed out: finish it within five minutes".into());
+ }
+ // The portal's pages are another origin, so their address can't be read: only the
+ // callback page's, once the portal sends the window back.
+ let Ok(href) = popup.location().href() else { continue };
+ if let Some(step) = flow.exchange(&href) {
+ break step?;
+ }
+ };
+ let grant = arcgis_auth::parse_grant(&request(&endpoint, Some(&body)).await?, js_sys::Date::now() as i64 / 1000)?;
+ // The token as the request's parameter, like the fetcher's: a header's CORS preflight can be refused.
+ let discovery = arcgis_auth::with_token_value(&arcgis_auth::discovery_url(&flow.portal), &grant.access_token);
+ let discovered = match discovery {
+ Some(url) => request(&url, None).await.map_err(|e| log::warn!("{}: couldn't read what the sign-in covers: {e}", flow.portal)).ok(),
+ None => None,
+ };
+ let scopes = arcgis_auth::discovered_scopes(&flow.portal, discovered.as_deref());
+ let username = grant.username.clone();
+ arcgis_auth::sign_in(&flow.portal, &flow.client_id, grant, scopes);
+ // Tiles and documents refused before the sign-in are asked for again.
+ cache::clear();
+ Ok(username)
+}
+
+/// Renews the session covering `url` when its access token is about to expire (one renewal at a
+/// time; the fetches meanwhile use the token, still valid for a minute).
+pub async fn renew_if_due(url: &str) {
+ if RENEWING.get() {
+ return;
+ }
+ let Some(r) = arcgis_auth::renewal_due(url) else { return };
+ /// Clears the flag however the renewal ends, the fetch that started it dropped included.
+ struct Renewing;
+ impl Drop for Renewing {
+ fn drop(&mut self) {
+ RENEWING.set(false);
+ }
+ }
+ RENEWING.set(true);
+ let _renewing = Renewing;
+ let answer = request(&r.endpoint, Some(&r.body)).await;
+ arcgis_auth::renewal_done(&r, answer.as_deref().map_err(String::clone));
+}
diff --git a/apps/rustgis-web/src/main.rs b/apps/rustgis-web/src/main.rs
index 170bd8abe..3934d4414 100644
--- a/apps/rustgis-web/src/main.rs
+++ b/apps/rustgis-web/src/main.rs
@@ -14,6 +14,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
// Pure logic, tested natively; only the browser build uses it.
+#[cfg(target_arch = "wasm32")]
+mod arcgis;
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
mod embed;
#[cfg(target_arch = "wasm32")]
diff --git a/apps/rustgis-web/src/web.rs b/apps/rustgis-web/src/web.rs
index 961a4c8b7..add1f603d 100644
--- a/apps/rustgis-web/src/web.rs
+++ b/apps/rustgis-web/src/web.rs
@@ -570,6 +570,15 @@ async fn fetch_bytes(url: &str) -> Result, String> {
// is signed (the bucket's CORS rules must allow the Authorization and x-amz-* headers).
let prepared = rustgis_ui_egui::rustgis_engine::render::rustgis_tiles::s3::prepare(url)?;
let url = prepared.url.as_str();
+ // An ArcGIS sign-in's token, for the portal and the servers it covers, renewed first when it
+ // is about to expire. It goes as the request's `token` parameter, not a header: ArcGIS's
+ // query endpoints answer the header's CORS preflight with an error.
+ if !prepared.signed {
+ crate::arcgis::renew_if_due(url).await;
+ }
+ let arcgis = if prepared.signed { None } else { rustgis_ui_egui::rustgis_engine::render::rustgis_tiles::arcgis_auth::with_token(url) };
+ // Only the request carries it: messages name the address without it.
+ let request = arcgis.as_deref().unwrap_or(url);
let asked = match (range, tail) {
(Some((a, b)), _) => Some(format!("bytes={a}-{b}")),
(None, Some(n)) => Some(format!("bytes=-{n}")),
@@ -591,12 +600,17 @@ async fn fetch_bytes(url: &str) -> Result, String> {
headers.set(k, v).map_err(err)?;
}
init.set_headers(&headers);
- // A signed request goes only where its credentials are for.
- if prepared.signed {
- init.set_redirect(web_sys::RequestRedirect::Error);
- }
}
- let promise = window.fetch_with_str_and_init(url, &init);
+ // A signed or authorized request goes only where its credentials are for.
+ if prepared.signed || arcgis.is_some() {
+ init.set_redirect(web_sys::RequestRedirect::Error);
+ }
+ // The token's address goes nowhere else: no referrer from it, no cookies with it.
+ if arcgis.is_some() {
+ init.set_referrer_policy(web_sys::ReferrerPolicy::NoReferrer);
+ init.set_credentials(web_sys::RequestCredentials::Omit);
+ }
+ let promise = window.fetch_with_str_and_init(request, &init);
// A refused fetch is an opaque TypeError ("Failed to fetch") by design: the browser doesn't
// say why, so the likely reasons are named, with the browser's own message but not its stack.
let resp: web_sys::Response = unless_stalled(promise, abort.as_ref())
@@ -812,7 +826,9 @@ fn services(inbox: Inbox, font_inbox: Inbox, notes: Notes, ctx: egui::Context, e
}),
)
};
+ let arcgis_ctx = ctx.clone();
Services {
+ arcgis_sign_in: Some(Box::new(move |portal, client_id, slot| crate::arcgis::sign_in(portal, client_id, slot, arcgis_ctx.clone()))),
load_pref: Some(load_pref),
save_pref: Some(save_pref),
// No keychain in the browser: script on the page could read anything stored (#754).
diff --git a/crates/data/src/ogc.rs b/crates/data/src/ogc.rs
index 16e9359a9..ad8ca84a0 100644
--- a/crates/data/src/ogc.rs
+++ b/crates/data/src/ogc.rs
@@ -301,10 +301,16 @@ pub fn parse(url: &str, body: &[u8]) -> Result {
if text.starts_with('{') || text.starts_with('[') {
let v: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("the service's JSON: {e}"))?;
if let Some(e) = v.get("error") {
- return Err(format!(
- "the service answered with an error: {}",
- e.get("message").and_then(|m| m.as_str()).unwrap_or("(no message)")
- ));
+ let message = e.get("message").and_then(|m| m.as_str()).unwrap_or("(no message)");
+ // ArcGIS's "Token Required" (499) and "Invalid Token" (498): say how to sign in.
+ let hint = match e.get("code").and_then(serde_json::Value::as_i64) {
+ Some(499) => {
+ " (the service is secured: sign in to its ArcGIS portal with Sign in to ArcGIS or arcgis.signIn, or add its address with ?token=…)"
+ }
+ Some(498) => " (the ArcGIS token was refused, perhaps expired: sign in to the portal again)",
+ _ => "",
+ };
+ return Err(format!("the service answered with an error: {message}{hint}"));
}
if is_arcgis(url) || v.get("currentVersion").is_some() {
return arcgis(url, &v);
@@ -2237,6 +2243,10 @@ mod tests {
.unwrap_err()
.contains("Token Required")
);
+ // A secured service's refusal says how to sign in.
+ let e = parse("https://x.org/arcgis/rest/services/A/FeatureServer", br#"{"error": {"code": 499, "message": "Token Required"}}"#)
+ .unwrap_err();
+ assert!(e.contains("arcgis.signIn"), "{e}");
}
#[test]
diff --git a/crates/engine/src/cmd/arcgis.rs b/crates/engine/src/cmd/arcgis.rs
new file mode 100644
index 000000000..3d7fc6ab9
--- /dev/null
+++ b/crates/engine/src/cmd/arcgis.rs
@@ -0,0 +1,124 @@
+//! Signing in to ArcGIS Online or an ArcGIS Enterprise portal, so its secured services can be
+//! read, and tokens given for an ArcGIS Server. Sessions are kept in memory for the session only,
+//! never in the project (see `rustgis_tiles::arcgis_auth`).
+
+use serde_json::{Value, json};
+
+use super::{f, ps};
+use crate::render::rustgis_tiles::arcgis_auth::{self, Info};
+use crate::{CmdResult, CommandSpec as C, Session};
+
+pub fn commands() -> Vec {
+ vec![
+ C::new("arcgis.signIn", "Sign In to ArcGIS", "Home › Add › Service", sign_in).view().params(
+ r#"{"portal"?: "the portal's address: an ArcGIS Online organization's (https://myorg.maps.arcgis.com) or an ArcGIS Enterprise portal's (https://gis.example.org/portal); ArcGIS Online when blank", "clientId"?: "the client id of an OAuth app registered on the portal (Developer credentials) whose redirect URLs list http://127.0.0.1:7290/arcgis/callback; RustGIS's own app when absent on ArcGIS Online (required for ArcGIS Enterprise)", "port"?: "the callback server's port (7290; the redirect URL registered must name it)"} → the sign-in page to open in the browser; the session is kept when the browser comes back (arcgis.status tells). Desktop and command line only"#,
+ ),
+ C::new("arcgis.token", "Use ArcGIS Token", "Home › Add › Service", token).view().params(
+ r#"{"url": "an ArcGIS REST address on https:// (a service's, …/rest/services, or a portal's …/sharing/rest)", "token": "an ArcGIS token for it"} — kept for the session only, never in the project; sent to that ArcGIS Server's (or portal's) requests only. A service address given to service.layers or service.add with ?token=… does the same"#,
+ ),
+ C::new("arcgis.signOut", "Sign Out of ArcGIS", "Home › Add › Service", sign_out)
+ .view()
+ .params(r#"{"portal"?: "a signed-in portal's address, or a server a token was given for (arcgis.status lists them); every one when absent"}"#),
+ C::new("arcgis.cancelSignIn", "Cancel ArcGIS Sign-In", "Home › Add › Service", |_s, _p| {
+ #[cfg(not(target_arch = "wasm32"))]
+ arcgis_auth::cancel_sign_in();
+ Ok(status())
+ })
+ .view(),
+ C::new("arcgis.status", "ArcGIS Sign-In Status", "Home › Add › Service", |_s, _p| Ok(status())).view(),
+ ]
+}
+
+fn info_json(i: &Info) -> Value {
+ json!({
+ "portal": i.portal,
+ "username": i.username,
+ "expires": i.expires_at.map(crate::render::rustgis_tiles::earthdata::date_of),
+ "renews": i.renews,
+ "kind": if i.token { "token" } else { "signIn" },
+ })
+}
+
+/// The sessions kept, and the sign-in under way or how the last one ended.
+pub fn status() -> Value {
+ let sessions: Vec = arcgis_auth::list().iter().map(info_json).collect();
+ json!({"sessions": sessions, "redirectUri": arcgis_auth::redirect_uri(arcgis_auth::PORT), "signIn": progress()})
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn progress() -> Value {
+ use arcgis_auth::Progress;
+ match arcgis_auth::progress() {
+ Progress::Idle => json!({"state": "idle"}),
+ Progress::Pending { portal } => json!({"state": "pending", "portal": portal}),
+ Progress::Done(i) => json!({"state": "signedIn", "portal": i.portal, "username": i.username}),
+ Progress::Failed(e) => json!({"state": "failed", "error": e}),
+ }
+}
+
+/// The browser doesn't sign in itself (see `start`).
+#[cfg(target_arch = "wasm32")]
+fn progress() -> Value {
+ json!({"state": "idle"})
+}
+
+fn sign_in(_s: &mut Session, p: &Value) -> CmdResult {
+ let portal = arcgis_auth::portal_base(ps(p, "portal").unwrap_or(""))?;
+ // RustGIS's own app on ArcGIS Online when none is given; an Enterprise portal registers one.
+ let client_id = arcgis_auth::client_id_for(&portal, ps(p, "clientId").unwrap_or("")).ok_or(
+ "clientId is required for an ArcGIS Enterprise portal: the client id of an OAuth app registered on it (Content › New item › Developer credentials)",
+ )?;
+ let port = match f(p, "port") {
+ Some(n) if (1024.0..=65535.0).contains(&n) && n.fract() == 0.0 => n as u16,
+ Some(_) => return Err("port is a whole number from 1024 to 65535".into()),
+ None => arcgis_auth::PORT,
+ };
+ start(&portal, &client_id, port)
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn start(portal: &str, client_id: &str, port: u16) -> CmdResult {
+ let url = arcgis_auth::start_sign_in(portal, client_id, port)?;
+ Ok(json!({"url": url, "portal": portal, "redirectUri": arcgis_auth::redirect_uri(port), "state": "pending"}))
+}
+
+#[cfg(target_arch = "wasm32")]
+fn start(_portal: &str, _client_id: &str, _port: u16) -> CmdResult {
+ Err("signing in to ArcGIS needs the desktop app; in the browser, give a token (arcgis.token, or the service's address with ?token=…)"
+ .into())
+}
+
+fn token(_s: &mut Session, p: &Value) -> CmdResult {
+ let url = ps(p, "url").map(str::trim).filter(|u| !u.is_empty()).ok_or("url is required (an ArcGIS REST address)")?;
+ let token = ps(p, "token").map(str::trim).filter(|t| !t.is_empty()).ok_or("token is required")?;
+ let root = arcgis_auth::add_token(url, token)?;
+ crate::render::rustgis_tiles::cache::clear();
+ Ok(json!({"server": root, "sessions": status()["sessions"]}))
+}
+
+fn sign_out(_s: &mut Session, p: &Value) -> CmdResult {
+ // Only an absent (or blank) portal means every session: junk is refused, not taken for it.
+ if p.get("portal").is_some_and(|v| !v.is_null() && !v.is_string()) {
+ return Err("portal is a signed-in portal's address (arcgis.status lists them)".into());
+ }
+ let portal = match ps(p, "portal").map(str::trim).filter(|s| !s.is_empty()) {
+ // A portal's address in any of its forms, or a server root as arcgis.status lists it.
+ Some(given) => {
+ let base = arcgis_auth::portal_base(given).unwrap_or_else(|_| given.to_string());
+ let known = arcgis_auth::list();
+ let pick = [base.as_str(), given.trim_end_matches('/')].into_iter().find(|c| known.iter().any(|i| i.portal == *c));
+ Some(pick.ok_or_else(|| format!("{given}: not signed in (arcgis.status lists the sessions)"))?.to_string())
+ }
+ None => None,
+ };
+ let (revocations, gone) = arcgis_auth::sign_out(portal.as_deref());
+ // Tiles and documents read with the token are cached by address: they go with it.
+ if gone > 0 {
+ crate::render::rustgis_tiles::cache::clear();
+ }
+ #[cfg(not(target_arch = "wasm32"))]
+ arcgis_auth::revoke_all(revocations);
+ #[cfg(target_arch = "wasm32")]
+ drop(revocations);
+ Ok(json!({"signedOut": gone, "sessions": status()["sessions"]}))
+}
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index 3f7857505..18fe434b5 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -1,5 +1,6 @@
//! Every command, grouped by ribbon tab.
+pub mod arcgis;
pub mod catalogs;
pub mod clipboard;
pub mod connections;
@@ -105,6 +106,7 @@ pub fn all() -> Vec {
v.extend(webdata::commands());
v.extend(embeddings::commands());
v.extend(earthdata::commands());
+ v.extend(arcgis::commands());
v.extend(nldi::commands());
v.extend(time::commands());
v
diff --git a/crates/engine/src/cmd/service.rs b/crates/engine/src/cmd/service.rs
index 976487667..e5cf44204 100644
--- a/crates/engine/src/cmd/service.rs
+++ b/crates/engine/src/cmd/service.rs
@@ -48,6 +48,35 @@ fn url_of(p: &Value) -> Result<&str, String> {
Ok(url)
}
+/// `p` with its ArcGIS service address's `?token=…` taken out and kept for the session (it then
+/// goes, as the request's credential, to every request to that server: the service's layers, tiles and feature
+/// pages), so the address the layer keeps has no token in it. `None` when there is none.
+pub(crate) fn without_token(p: &Value) -> Result, String> {
+ use crate::render::rustgis_tiles::arcgis_auth;
+ let Some(url) = ps(p, "url").map(str::trim) else { return Ok(None) };
+ let has_token = url
+ .split_once('?')
+ .is_some_and(|(_, q)| q.split('&').any(|kv| kv.split('=').next().is_some_and(|k| k.eq_ignore_ascii_case("token"))));
+ // ArcGIS REST addresses only: another kind of service's `token` stays in its address.
+ let path = url.split(['?', '#']).next().unwrap_or(url).to_ascii_lowercase();
+ let arcgis = match ps(p, "type") {
+ Some("arcgis") => true,
+ None | Some("auto") => path.contains("/rest/services") || path.contains("/sharing/rest"),
+ Some(_) => false,
+ };
+ if !has_token || !arcgis || arcgis_auth::server_root(url).is_none() {
+ return Ok(None);
+ }
+ let before = arcgis_auth::authorization(url);
+ let mut owned = p.clone();
+ owned["url"] = json!(arcgis_auth::adopt_url_token(url)?);
+ // What was cached unauthorized (refused tiles) is asked for again with the new token.
+ if arcgis_auth::authorization(url) != before {
+ crate::render::rustgis_tiles::cache::clear();
+ }
+ Ok(Some(owned))
+}
+
/// Capability discovery is shared by supplied-document GUI requests and native commands.
/// Sources stay alongside layers so a landing page and a collection may use the same name.
#[derive(Default)]
@@ -291,6 +320,8 @@ pub fn service_json(svc: &Service) -> Value {
}
pub(crate) fn layers(_s: &mut Session, p: &Value) -> CmdResult {
+ let owned = without_token(p)?;
+ let p = owned.as_ref().unwrap_or(p);
let found = capabilities(p)?;
let mut out = service_json(&found.service);
if let Some(layers) = out.get_mut("layers").and_then(Value::as_array_mut) {
@@ -307,6 +338,8 @@ pub(crate) fn layers(_s: &mut Session, p: &Value) -> CmdResult {
/// Adds one of a service's layers: map layers as tiles (an overlay), a style as a style, features
/// downloaded.
pub(crate) fn add(s: &mut Session, p: &Value) -> CmdResult {
+ let owned = without_token(p)?;
+ let p = owned.as_ref().unwrap_or(p);
let url = url_of(p)?.to_string();
let want = ps(p, "layer").map(str::trim).filter(|l| !l.is_empty()).ok_or("layer is required")?;
let found = capabilities(p)?;
diff --git a/crates/engine/src/cmd/tiles.rs b/crates/engine/src/cmd/tiles.rs
index 771f1e9c7..b54d644be 100644
--- a/crates/engine/src/cmd/tiles.rs
+++ b/crates/engine/src/cmd/tiles.rs
@@ -54,6 +54,11 @@ pub const NETWORK: &[&str] = &[
"nldi.basin",
"nldi.sources",
"nldi.navigate",
+ // A sign-in reaches the portal, and a sign-out revokes its token there; a token is sent to
+ // the server it is given for.
+ "arcgis.signIn",
+ "arcgis.signOut",
+ "arcgis.token",
];
pub fn commands() -> Vec {
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 9be9ce8e4..677432ba3 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -1720,6 +1720,11 @@ fn hostile_params_never_panic() {
| "boundaries.countries"
// Asks Source Cooperative for its featured products.
| "sourcecoop.products"
+ // Starts a sign-in's callback server (RustGIS's own app on ArcGIS Online).
+ | "arcgis.signIn"
+ // Signs every session out, which the process's other tests use (its junk is
+ // covered by an_arcgis_token_in_a_service_address_reaches_its_layers).
+ | "arcgis.signOut"
) {
continue;
}
@@ -8363,6 +8368,53 @@ fn service_times_and_directories() {
assert!(e.contains("service.layers"), "{e}");
}
+/// A secured ArcGIS service given by an address with `?token=…` (#914): the token is kept for its
+/// server and sent, as the request's credential, to the addresses built from the service's document (its tiles,
+/// its layers' queries), and no address the session lists or keeps carries it.
+#[test]
+fn an_arcgis_token_in_a_service_address_reaches_its_layers() {
+ use crate::render::rustgis_tiles::arcgis_auth;
+ let mut s = Session::new(Project::default());
+ let base = "https://secure914.example.org/server/rest/services/Roads/MapServer";
+ let doc = r#"{"currentVersion": 11, "mapName": "Roads", "capabilities": "Map,Query,Data", "singleFusedMapCache": true,
+ "tileInfo": {"rows": 256, "spatialReference": {"wkid": 102100}, "lods": [{"level": 0, "resolution": 156543.033928041}]},
+ "layers": [{"id": 0, "name": "Highways"}]}"#;
+ let v = s.run("service.layers", &json!({"url": format!("{base}?f=json&token=secret.Token-914"), "text": doc})).unwrap();
+ assert!(!v.to_string().contains("secret.Token-914"), "{v}");
+ let tiles = format!("{base}/tile/0/0/0");
+ let query = format!("{base}/0/query?where=1%3D1&f=geojson");
+ assert_eq!(arcgis_auth::authorization(&tiles).as_deref(), Some("Bearer secret.Token-914"));
+ assert_eq!(arcgis_auth::authorization(&query).as_deref(), Some("Bearer secret.Token-914"));
+ // Another server never gets it.
+ assert_eq!(arcgis_auth::authorization("https://other914.example.org/server/rest/services/Roads/MapServer/tile/0/0/0"), None);
+ // Added, the tile layer keeps the address without the token.
+ let layer =
+ v["layers"].as_array().unwrap().iter().find(|l| l["offer"]["type"] == "tiles").unwrap()["name"].as_str().unwrap().to_string();
+ s.run("service.add", &json!({"url": format!("{base}?token=secret.Token-914"), "layer": layer, "text": doc})).unwrap();
+ let project = format!("{:?}", s.project);
+ assert!(project.contains("secure914.example.org") && !project.contains("secret.Token-914"));
+ // Listed and forgotten by arcgis.status and arcgis.signOut.
+ let status = s.run("arcgis.status", &json!({})).unwrap();
+ assert!(
+ status["sessions"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .any(|x| x["portal"] == "https://secure914.example.org/server" && x["kind"] == "token")
+ );
+ assert!(!status.to_string().contains("secret.Token-914"));
+ s.run("arcgis.signOut", &json!({"portal": "https://secure914.example.org/server/"})).unwrap();
+ assert_eq!(arcgis_auth::authorization(&tiles), None);
+ // Junk is refused rather than taken for "every session".
+ for junk in [json!({"portal": 7}), json!({"portal": [true]}), json!({"portal": {"a": 1}})] {
+ assert!(s.run("arcgis.signOut", &junk).is_err());
+ }
+ // Another kind of service keeps its own token parameter.
+ let wms = "https://wms914.example.org/rest/services/wms?token=keep-it";
+ let kept = crate::cmd::service::without_token(&json!({"url": wms, "type": "wms"})).unwrap();
+ assert!(kept.is_none() && arcgis_auth::authorization(wms).is_none());
+}
+
/// Services from their capabilities: a WMS layer becomes a tile layer, an OGC API collection's
/// features are downloaded (from a local server); unknown layers and documents are refused.
#[test]
diff --git a/crates/tiles/Cargo.toml b/crates/tiles/Cargo.toml
index 15cd2e6c3..fab939831 100644
--- a/crates/tiles/Cargo.toml
+++ b/crates/tiles/Cargo.toml
@@ -26,10 +26,13 @@ ruzstd = { workspace = true }
# S3 requests are signed (Signature Version 4) and its listings are XML.
sha2 = { workspace = true }
roxmltree = { workspace = true }
+# ArcGIS sign-ins: PKCE challenges and random states.
+base64 = { workspace = true }
# Fetching tiles on worker threads. The browser fetches them itself (see `set_fetcher`).
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
ureq = { version = "3", default-features = false, features = ["rustls", "gzip"] }
+getrandom = "0.4"
[dev-dependencies]
# Tests write Brotli-compressed PMTiles archives.
diff --git a/crates/tiles/src/arcgis_auth.rs b/crates/tiles/src/arcgis_auth.rs
new file mode 100644
index 000000000..aa535bd6f
--- /dev/null
+++ b/crates/tiles/src/arcgis_auth.rs
@@ -0,0 +1,1079 @@
+//! Signing in to ArcGIS: an ArcGIS Online organization or an ArcGIS Enterprise portal, so that
+//! its secured services (which otherwise answer error 499, "Token Required") can be read.
+//!
+//! The sign-in is OAuth 2.0 authorization code with PKCE (S256) in the system browser: the user
+//! signs in on the portal's own page (its SSO and MFA included), so RustGIS never sees a password.
+//! The portal sends the browser back to a short-lived server on this computer at
+//! [`redirect_uri`] (`http://127.0.0.1:7290/arcgis/callback` by default). ArcGIS accepts only
+//! the redirect addresses an app registration lists, exactly, so the portal's administrator
+//! registers that address with the OAuth client id the user gives.
+//!
+//! A token kept here goes, in the `X-Esri-Authorization` header ([`with_token`]'s `token`
+//! parameter in the browser, so the portal's and any proxy's request logs may keep it there, as
+//! with ArcGIS's own web apps), only to the addresses its
+//! session covers ([`Scope`]): the portal itself and, for ArcGIS Online, the organization's
+//! hosted services (`*.arcgis.com//…`); for ArcGIS Enterprise, the portal's host and
+//! its federated servers. A token given with a service's address (`…/FeatureServer?token=…`,
+//! [`adopt_url_token`]) covers that ArcGIS Server only. Tokens live in memory for the session:
+//! never in a project, a connection or the settings. Access tokens are renewed with the refresh
+//! token before they expire, and a sign-out revokes the refresh token.
+//!
+//! The requests and answers are built and read here (sans I/O); the callback server, the
+//! renewal and the revocation are desktop only.
+
+use std::sync::Mutex;
+
+use serde_json::Value;
+
+/// ArcGIS Online's portal.
+pub const ONLINE: &str = "https://www.arcgis.com";
+/// The callback server's port, unless another is given.
+pub const PORT: u16 = 7290;
+/// The callback's path.
+pub const CALLBACK: &str = "/arcgis/callback";
+/// An access token is renewed this long before it expires (seconds).
+const REFRESH_MARGIN: i64 = 60;
+/// After the portal couldn't be reached for a renewal, how long until it is tried again (seconds).
+const RETRY_RENEWAL: i64 = 30;
+/// RustGIS's own OAuth app on ArcGIS Online, used when no client id is given for an ArcGIS Online
+/// portal (an Enterprise portal registers its own). Its redirect URLs are the desktop's
+/// [`redirect_uri`] for [`PORT`] and the web app's callback page (`oauth-callback.html`) at
+/// https://web.rustgis.app and `trunk serve`'s http://127.0.0.1:8772. A client id isn't a secret.
+pub const DEFAULT_CLIENT_ID: &str = "nD2lKYqFqsaddfuD";
+/// The web app's callback page, next to its index.
+pub const WEB_CALLBACK: &str = "oauth-callback.html";
+/// The session asked for: two weeks (the longest ArcGIS gives), in minutes.
+const EXPIRATION_MINUTES: &str = "20160";
+/// The longest token kept (ArcGIS's are a few hundred characters).
+const MAX_TOKEN: usize = 8 << 10;
+/// The most sessions kept (one per portal or server).
+const MAX_SESSIONS: usize = 64;
+/// The most federated servers a session covers.
+const MAX_SERVERS: usize = 64;
+
+/// The redirect address of a sign-in whose callback server listens on `port`.
+pub fn redirect_uri(port: u16) -> String {
+ format!("http://127.0.0.1:{port}{CALLBACK}")
+}
+
+/// An HTTPS address's origin (`https://host[:port]`, lowercase) and path (`/` when empty),
+/// without its query or fragment. `None` for anything else, or an address with user info.
+fn split(url: &str) -> Option<(String, String)> {
+ let rest = url.get(..8).filter(|s| s.eq_ignore_ascii_case("https://")).and_then(|_| url.get(8..))?;
+ let rest = rest.split(['?', '#']).next()?;
+ let (authority, path) = rest.find('/').map_or((rest, "/"), |i| rest.split_at(i));
+ if authority.is_empty() || authority.contains(['@', '\\', '[', ']']) || url.chars().any(|c| c.is_whitespace() || c.is_control()) {
+ return None;
+ }
+ let authority = authority.to_ascii_lowercase();
+ let (host, port) = authority.split_once(':').map_or((authority.as_str(), None), |(h, p)| (h, Some(p)));
+ let host = host.trim_end_matches('.');
+ if host.is_empty() || port.is_some_and(|p| p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit())) {
+ return None;
+ }
+ let origin = match port.filter(|p| *p != "443") {
+ Some(p) => format!("https://{host}:{p}"),
+ None => format!("https://{host}"),
+ };
+ Some((origin, path.to_string()))
+}
+
+fn host_of(origin: &str) -> &str {
+ let h = origin.strip_prefix("https://").unwrap_or(origin);
+ h.split(':').next().unwrap_or(h)
+}
+
+/// A portal's base address: `https://www.arcgis.com` for blank, an organization's
+/// (`https://myorg.maps.arcgis.com`) or an Enterprise portal's (`https://gis.example.org/portal`).
+/// Takes the base, its home page or any `/sharing/rest…` address. Only HTTPS: tokens go there.
+pub fn portal_base(input: &str) -> Result {
+ let raw = input.trim();
+ if raw.is_empty() {
+ return Ok(ONLINE.to_string());
+ }
+ let (origin, path) = split(raw).ok_or_else(|| format!("{raw}: a portal is given by its https:// address"))?;
+ if origin == "https://arcgis.com" {
+ return Ok(ONLINE.to_string());
+ }
+ let mut kept = Vec::new();
+ for part in path.split('/').filter(|p| !p.is_empty()) {
+ if ["sharing", "home", "apps", "rest"].iter().any(|s| part.eq_ignore_ascii_case(s)) {
+ break;
+ }
+ kept.push(part);
+ }
+ // An ArcGIS Online organization's portal is at its root.
+ if is_online_host(host_of(&origin)) {
+ kept.clear();
+ }
+ Ok(if kept.is_empty() { origin } else { format!("{origin}/{}", kept.join("/")) })
+}
+
+fn is_online_host(host: &str) -> bool {
+ host == "arcgis.com" || host.ends_with(".arcgis.com")
+}
+
+/// Whether a portal base is ArcGIS Online (www.arcgis.com or an organization's address).
+pub fn is_online(portal: &str) -> bool {
+ split(portal).is_some_and(|(origin, _)| is_online_host(host_of(&origin)))
+}
+
+/// A portal's OAuth 2.0 endpoint (`authorize`, `token` or `revokeToken`). An ArcGIS Online
+/// organization's tokens are asked of www.arcgis.com (the only one a web page may read the
+/// answers of); its users sign in on its own page.
+pub fn endpoint(portal: &str, name: &str) -> String {
+ let base = if name != "authorize" && is_online(portal) { ONLINE } else { portal };
+ format!("{base}/sharing/rest/oauth2/{name}")
+}
+
+/// Percent-encodes a form or query value.
+fn encode(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ for b in s.bytes() {
+ if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) {
+ out.push(b as char);
+ } else {
+ out.push_str(&format!("%{b:02X}"));
+ }
+ }
+ out
+}
+
+fn form(params: &[(&str, &str)]) -> String {
+ params.iter().map(|(k, v)| format!("{k}={}", encode(v))).collect::>().join("&")
+}
+
+/// Decodes a query value (`+` is a space).
+fn decode(s: &str) -> String {
+ decode_with(s, true)
+}
+
+/// Decodes `%XX` escapes, and `+` as a space when `plus` (form data, not a pasted token).
+fn decode_with(s: &str, plus: bool) -> String {
+ let bytes = s.as_bytes();
+ let mut out = Vec::with_capacity(bytes.len());
+ let mut i = 0;
+ while let Some(&b) = bytes.get(i) {
+ match b {
+ b'+' if plus => out.push(b' '),
+ b'%' => {
+ let hex = bytes.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
+ if let Some(v) = hex {
+ out.push(v);
+ i += 2;
+ } else {
+ out.push(b'%');
+ }
+ }
+ _ => out.push(b),
+ }
+ i += 1;
+ }
+ String::from_utf8_lossy(&out).into_owned()
+}
+
+/// A query's parameters, decoded.
+pub fn query_params(query: &str) -> Vec<(String, String)> {
+ query.split('&').filter(|p| !p.is_empty()).map(|p| p.split_once('=').unwrap_or((p, ""))).map(|(k, v)| (decode(k), decode(v))).collect()
+}
+
+/// Whether `id` looks like an OAuth client id (ArcGIS's are 16 letters and digits).
+pub fn valid_client_id(id: &str) -> bool {
+ !id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b))
+}
+
+fn valid_token(t: &str) -> bool {
+ !t.is_empty() && t.len() <= MAX_TOKEN && t.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.~+/=".contains(&b))
+}
+
+/// The PKCE challenge (S256) of a verifier.
+pub fn pkce_challenge(verifier: &str) -> String {
+ use base64::Engine;
+ use sha2::Digest;
+ base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sha2::Sha256::digest(verifier.as_bytes()))
+}
+
+/// The portal's sign-in page.
+pub fn authorize_url(portal: &str, client_id: &str, redirect_uri: &str, challenge: &str, state: &str) -> String {
+ let query = form(&[
+ ("client_id", client_id),
+ ("response_type", "code"),
+ ("redirect_uri", redirect_uri),
+ ("code_challenge", challenge),
+ ("code_challenge_method", "S256"),
+ ("state", state),
+ ("expiration", EXPIRATION_MINUTES),
+ ]);
+ format!("{}?{query}", endpoint(portal, "authorize"))
+}
+
+/// The form exchanging the browser's code for tokens.
+pub fn exchange_body(client_id: &str, code: &str, redirect_uri: &str, verifier: &str) -> String {
+ form(&[
+ ("f", "json"),
+ ("grant_type", "authorization_code"),
+ ("client_id", client_id),
+ ("code", code),
+ ("redirect_uri", redirect_uri),
+ ("code_verifier", verifier),
+ ])
+}
+
+/// The form renewing an access token.
+pub fn refresh_body(client_id: &str, refresh_token: &str) -> String {
+ form(&[("f", "json"), ("grant_type", "refresh_token"), ("client_id", client_id), ("refresh_token", refresh_token)])
+}
+
+/// The form revoking a refresh token (or, without one, the access token).
+pub fn revoke_body(client_id: &str, token: &str, refresh: bool) -> String {
+ let hint = if refresh { "refresh_token" } else { "access_token" };
+ form(&[("f", "json"), ("client_id", client_id), ("auth_token", token), ("token_type_hint", hint)])
+}
+
+/// The tokens a token request gave.
+#[derive(Clone, PartialEq, Eq)]
+pub struct Grant {
+ pub access_token: String,
+ pub refresh_token: Option,
+ /// Unix seconds.
+ pub expires_at: i64,
+ pub username: String,
+}
+
+impl std::fmt::Debug for Grant {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("Grant").field("username", &self.username).field("expires_at", &self.expires_at).finish_non_exhaustive()
+ }
+}
+
+/// Reads a token answer: ArcGIS reports a refusal as HTTP 200 with an `error` object.
+pub fn parse_grant(body: &[u8], now: i64) -> Result {
+ let v: Value = serde_json::from_slice(body).map_err(|_| "the portal's token answer isn't JSON".to_string())?;
+ if let Some(e) = v.get("error") {
+ let why = e
+ .get("error_description")
+ .or_else(|| e.get("message"))
+ .and_then(Value::as_str)
+ .or_else(|| e.get("details").and_then(Value::as_array).and_then(|d| d.first()).and_then(Value::as_str))
+ .unwrap_or("(no message)");
+ return Err(format!("the portal refused the sign-in: {}", why.chars().take(300).collect::()));
+ }
+ let token = v.get("access_token").and_then(Value::as_str).filter(|t| valid_token(t)).ok_or("the portal gave no access token")?;
+ let refresh = v.get("refresh_token").and_then(Value::as_str).filter(|t| valid_token(t)).map(str::to_string);
+ let lifetime = v.get("expires_in").and_then(Value::as_i64).filter(|s| *s > 0).unwrap_or(1800).min(366 * 86_400);
+ let username = v.get("username").and_then(Value::as_str).unwrap_or("").chars().take(200).collect();
+ Ok(Grant { access_token: token.to_string(), refresh_token: refresh, expires_at: now.saturating_add(lifetime), username })
+}
+
+/// Where a session's token may go.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum Scope {
+ /// Addresses at this origin whose path starts with this (lowercase, ending in `/`).
+ Prefix { origin: String, path: String },
+ /// An ArcGIS Online organization's hosted services: an `*.arcgis.com` address with its id as
+ /// its path's first or second part (`services1.arcgis.com//…`, `tiles.arcgis.com/tiles//…`).
+ Org(String),
+}
+
+impl Scope {
+ fn prefix(url: &str) -> Option {
+ let (origin, path) = split(url)?;
+ let mut path = path.to_ascii_lowercase();
+ if !path.ends_with('/') {
+ path.push('/');
+ }
+ Some(Scope::Prefix { origin, path })
+ }
+
+ fn covers(&self, origin: &str, path: &str) -> bool {
+ // A path whose dot segments a client would resolve (`/server/../other/`) is judged by
+ // where it leads, so it gets no token at all.
+ let lower = path.to_ascii_lowercase();
+ if lower.split('/').any(|seg| {
+ let seg = seg.replace("%2e", ".");
+ seg == "." || seg == ".."
+ }) {
+ return false;
+ }
+ match self {
+ Scope::Prefix { origin: o, path: p } => {
+ if o != origin {
+ return false;
+ }
+ let lower = path.to_ascii_lowercase();
+ lower.starts_with(p.as_str()) || format!("{lower}/") == *p
+ }
+ Scope::Org(id) => {
+ is_online_host(host_of(origin))
+ && !origin.trim_start_matches("https://").contains(':')
+ && path.split('/').filter(|p| !p.is_empty()).take(2).any(|p| p == id)
+ }
+ }
+ }
+}
+
+/// What a session's token covers: the portal, then its organization's hosted services (ArcGIS
+/// Online, `org` is its id) or its host and federated `servers` (ArcGIS Enterprise).
+pub fn scopes_for(portal: &str, org: Option<&str>, servers: &[String]) -> Vec {
+ let mut scopes = Vec::new();
+ if let Some((origin, _)) = split(portal) {
+ // An Enterprise portal's own host serves its federated servers too (behind one web adaptor).
+ scopes.push(Scope::Prefix { origin, path: "/".into() });
+ }
+ if is_online(portal) {
+ if let Some(s) = Scope::prefix(&format!("{ONLINE}/sharing/rest/")) {
+ scopes.push(s);
+ }
+ if let Some(id) = org.filter(|id| !id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric())) {
+ scopes.push(Scope::Org(id.to_string()));
+ }
+ }
+ for s in servers.iter().take(MAX_SERVERS).filter_map(|s| Scope::prefix(s)) {
+ if !scopes.contains(&s) {
+ scopes.push(s);
+ }
+ }
+ scopes
+}
+
+/// The federated servers a portal's `portals/self/servers` answer lists.
+pub fn parse_servers(body: &[u8]) -> Vec {
+ let v: Value = serde_json::from_slice(body).unwrap_or_default();
+ let Some(servers) = v.get("servers").and_then(Value::as_array) else { return Vec::new() };
+ servers
+ .iter()
+ .take(MAX_SERVERS)
+ .filter_map(|s| s.get("url").and_then(Value::as_str))
+ .filter(|u| split(u).is_some())
+ .map(str::to_string)
+ .collect()
+}
+
+/// The organization id a portal's `portals/self` answer gives (ArcGIS Online).
+pub fn parse_org(body: &[u8]) -> Option {
+ let v: Value = serde_json::from_slice(body).ok()?;
+ v.get("id").and_then(Value::as_str).map(str::to_string)
+}
+
+/// One sign-in, or a token given for a server.
+#[derive(Clone)]
+struct Session {
+ /// The portal's base, or the server's root for a token given with an address.
+ portal: String,
+ client_id: String,
+ username: String,
+ access_token: String,
+ /// Unix seconds; `i64::MAX` when unknown (a token given with an address).
+ expires_at: i64,
+ refresh_token: Option,
+ scopes: Vec,
+ /// Unix seconds before which a renewal isn't tried again (after the portal couldn't be reached).
+ retry_at: i64,
+}
+
+fn store() -> &'static Mutex> {
+ static S: std::sync::OnceLock>> = std::sync::OnceLock::new();
+ S.get_or_init(Mutex::default)
+}
+
+fn sessions() -> std::sync::MutexGuard<'static, Vec> {
+ store().lock().unwrap_or_else(|e| e.into_inner())
+}
+
+fn now_secs() -> i64 {
+ web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
+}
+
+fn keep(session: Session) {
+ let mut all = sessions();
+ // A token given for a portal's own address doesn't replace a sign-in to it (nor the reverse).
+ all.retain(|s| !(s.portal == session.portal && s.client_id.is_empty() == session.client_id.is_empty()));
+ if all.len() >= MAX_SESSIONS {
+ all.remove(0);
+ }
+ all.push(session);
+}
+
+/// Keeps a sign-in to `portal` for the session, its token covering `scopes`.
+pub fn sign_in(portal: &str, client_id: &str, grant: Grant, scopes: Vec) {
+ keep(Session {
+ portal: portal.to_string(),
+ client_id: client_id.to_string(),
+ username: grant.username,
+ access_token: grant.access_token,
+ expires_at: grant.expires_at,
+ refresh_token: grant.refresh_token,
+ scopes,
+ retry_at: 0,
+ });
+}
+
+/// The ArcGIS Server root of a service's address, which a token given with it covers:
+/// everything before its `rest` part (`https://host/server/rest/services/…` → `https://host/server/`).
+pub fn server_root(url: &str) -> Option {
+ let (origin, path) = split(url)?;
+ let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
+ let rest = parts.iter().position(|p| p.eq_ignore_ascii_case("rest") || p.eq_ignore_ascii_case("sharing"))?;
+ let root = parts.get(..rest)?.join("/");
+ Some(if root.is_empty() { format!("{origin}/") } else { format!("{origin}/{root}/") })
+}
+
+/// Keeps `token` for the ArcGIS Server (or portal) that `url` is on, for the session. Returns
+/// the server root it covers.
+pub fn add_token(url: &str, token: &str) -> Result {
+ let token = token.trim();
+ if !valid_token(token) {
+ return Err("an ArcGIS token is one word of letters, digits and - _ . ~ + / =".into());
+ }
+ let root = server_root(url).ok_or_else(|| {
+ format!("{}: a token is given with an ArcGIS REST address on https:// (…/rest/services/… or …/sharing/rest)", url.trim())
+ })?;
+ let scopes = Scope::prefix(&root).into_iter().collect();
+ keep(Session {
+ portal: root.trim_end_matches('/').to_string(),
+ client_id: String::new(),
+ username: String::new(),
+ access_token: token.to_string(),
+ expires_at: i64::MAX,
+ refresh_token: None,
+ scopes,
+ retry_at: 0,
+ });
+ Ok(root)
+}
+
+/// A service's address with its `token` parameter taken out and kept ([`add_token`]): the token
+/// then goes (in a header on the desktop, as the request's `token` parameter in the browser) to
+/// every request to that server (the service's layers, its tiles and
+/// its feature pages), and the address kept in the project has no token in it.
+pub fn adopt_url_token(url: &str) -> Result {
+ let Some((base, query)) = url.split_once('?') else { return Ok(url.to_string()) };
+ let (query, fragment) = query.split_once('#').map_or((query, None), |(q, f)| (q, Some(f)));
+ let mut token = None;
+ let kept: Vec<&str> = query
+ .split('&')
+ .filter(|p| {
+ let (k, v) = p.split_once('=').unwrap_or((p, ""));
+ if k.eq_ignore_ascii_case("token") {
+ // A token is pasted, not form-encoded: its `+` stays a `+`.
+ token = Some(decode_with(v, false));
+ false
+ } else {
+ true
+ }
+ })
+ .collect();
+ let Some(token) = token else { return Ok(url.to_string()) };
+ if !token.trim().is_empty() {
+ add_token(base, &token)?;
+ }
+ let mut out = base.to_string();
+ if !kept.is_empty() {
+ out.push('?');
+ out.push_str(&kept.join("&"));
+ }
+ if let Some(f) = fragment {
+ out.push('#');
+ out.push_str(f);
+ }
+ Ok(out)
+}
+
+/// The session covering `url`, by index, the most specific one first: a token given for a
+/// server over a portal's sign-in.
+fn covering(all: &[Session], url: &str) -> Option {
+ let (origin, path) = split(url)?;
+ all.iter()
+ .enumerate()
+ .filter(|(_, s)| s.scopes.iter().any(|sc| sc.covers(&origin, &path)))
+ .max_by_key(|(_, s)| (s.client_id.is_empty(), s.portal.len()))
+ .map(|(i, _)| i)
+}
+
+/// The `X-Esri-Authorization` header value a request to `url` carries: the token of the session
+/// covering it, while it is valid. See [`authorization_fresh`] for one renewed first.
+pub fn authorization(url: &str) -> Option {
+ let all = sessions();
+ let s = all.get(covering(&all, url)?)?;
+ (s.expires_at > now_secs()).then(|| format!("Bearer {}", s.access_token))
+}
+
+/// `url` with the token of the session covering it as its `token` parameter, for the browser:
+/// ArcGIS's `query` endpoints answer a CORS preflight with an error, so a request carrying the
+/// header can't be sent from a web page, while a plain GET needs no preflight. Only the request
+/// carries it: the address a layer or the cache keeps has no token. `None` when nothing covers it.
+pub fn with_token(url: &str) -> Option {
+ let token = {
+ let all = sessions();
+ let s = all.get(covering(&all, url)?)?;
+ (s.expires_at > now_secs()).then(|| s.access_token.clone())?
+ };
+ with_token_value(url, &token)
+}
+
+/// `url` with `token` as its `token` parameter; `None` when it carries one already (the user's).
+pub fn with_token_value(url: &str, token: &str) -> Option {
+ let query = url.split('#').next().unwrap_or(url).split_once('?').map_or("", |(_, q)| q);
+ if query.split('&').any(|kv| kv.split('=').next().is_some_and(|k| k.eq_ignore_ascii_case("token"))) {
+ return None;
+ }
+ let (base, fragment) = url.split_once('#').map_or((url, None), |(b, f)| (b, Some(f)));
+ let joint = if base.contains('?') { if base.ends_with('?') || base.ends_with('&') { "" } else { "&" } } else { "?" };
+ let mut out = format!("{base}{joint}token={}", encode(token));
+ if let Some(f) = fragment {
+ out.push('#');
+ out.push_str(f);
+ }
+ Some(out)
+}
+
+/// A renewal to send: the token request renewing the session covering an address.
+#[derive(Clone, PartialEq, Eq)]
+pub struct Renewal {
+ pub portal: String,
+ /// The token endpoint, and the form to POST to it.
+ pub endpoint: String,
+ pub body: String,
+ refresh: String,
+}
+
+/// The renewal the session covering `url` needs: it has a refresh token, its access token is
+/// about to expire, and its portal wasn't just found unreachable.
+pub fn renewal_due(url: &str) -> Option {
+ let all = sessions();
+ let s = all.get(covering(&all, url)?)?;
+ let refresh = s.refresh_token.as_ref()?;
+ let now = now_secs();
+ (s.expires_at - now <= REFRESH_MARGIN && now >= s.retry_at).then(|| Renewal {
+ portal: s.portal.clone(),
+ endpoint: endpoint(&s.portal, "token"),
+ body: refresh_body(&s.client_id, refresh),
+ refresh: refresh.clone(),
+ })
+}
+
+/// Lands a renewal's answer: a new access token is kept; a refusal ends the session; an
+/// unreachable portal (`Err`) keeps it and holds off renewing for a while. Nothing changes for a
+/// session signed out or replaced since.
+pub fn renewal_done(r: &Renewal, answer: Result<&[u8], String>) {
+ let mut all = sessions();
+ let Some(i) = all.iter().position(|s| s.portal == r.portal && s.refresh_token.as_deref() == Some(r.refresh.as_str())) else { return };
+ // An answer that isn't a JSON object (a proxy's error page) says nothing of the refresh
+ // token: tried again later, as an unreachable portal is.
+ let answer = answer.and_then(|body| {
+ if serde_json::from_slice::(body).is_ok_and(|v| v.is_object()) {
+ Ok(body)
+ } else {
+ Err("the portal's answer isn't JSON".to_string())
+ }
+ });
+ match answer.map(|body| parse_grant(body, now_secs())) {
+ Ok(Ok(grant)) => {
+ if let Some(s) = all.get_mut(i) {
+ s.access_token = grant.access_token;
+ s.expires_at = grant.expires_at;
+ if grant.refresh_token.is_some() {
+ s.refresh_token = grant.refresh_token;
+ }
+ }
+ }
+ Ok(Err(e)) => {
+ log::warn!("{}: the ArcGIS sign-in ended: {e}", r.portal);
+ all.remove(i);
+ }
+ Err(e) => {
+ log::warn!("{}: couldn't renew the ArcGIS sign-in: {e}", r.portal);
+ if let Some(s) = all.get_mut(i) {
+ s.retry_at = now_secs() + RETRY_RENEWAL;
+ }
+ }
+ }
+}
+
+/// The client id a sign-in to `portal` uses: the one given, else RustGIS's own for ArcGIS Online.
+pub fn client_id_for(portal: &str, given: &str) -> Option {
+ let given = given.trim();
+ if !given.is_empty() {
+ return Some(given.to_string());
+ }
+ is_online(portal).then(|| DEFAULT_CLIENT_ID.to_string())
+}
+
+/// The document telling what a portal's token covers: `portals/self` for ArcGIS Online (its
+/// organization's id), `portals/self/servers` for ArcGIS Enterprise (its federated servers).
+pub fn discovery_url(portal: &str) -> String {
+ if is_online(portal) {
+ format!("{portal}/sharing/rest/portals/self?f=json")
+ } else {
+ format!("{portal}/sharing/rest/portals/self/servers?f=json")
+ }
+}
+
+/// What a portal's token covers, from its [`discovery_url`] answer (`None` when it couldn't be
+/// read: the portal's own host then).
+pub fn discovered_scopes(portal: &str, answer: Option<&[u8]>) -> Vec {
+ if is_online(portal) {
+ scopes_for(portal, answer.and_then(parse_org).as_deref(), &[])
+ } else {
+ scopes_for(portal, None, &answer.map(parse_servers).unwrap_or_default())
+ }
+}
+
+/// A browser sign-in's start: the page to open, the redirect its callback page has, and what
+/// finishing needs. `verifier` and `state` are random (the browser's crypto).
+pub struct WebSignIn {
+ pub url: String,
+ pub portal: String,
+ pub client_id: String,
+ pub redirect: String,
+ verifier: String,
+ state: String,
+}
+
+/// Starts a sign-in in the browser, whose app's callback page is `redirect`.
+pub fn web_sign_in(portal: &str, client_id: &str, redirect: &str, verifier: &str, state: &str) -> Result {
+ let portal = portal_base(portal)?;
+ let client_id = client_id_for(&portal, client_id)
+ .filter(|c| valid_client_id(c))
+ .ok_or("give the OAuth client id of an app registered on the portal (Content › New item › Developer credentials)")?;
+ let url = authorize_url(&portal, &client_id, redirect, &pkce_challenge(verifier), state);
+ Ok(WebSignIn { url, portal, client_id, redirect: redirect.to_string(), verifier: verifier.to_string(), state: state.to_string() })
+}
+
+impl WebSignIn {
+ /// The token request (endpoint and form) for the callback page's address, once it holds the
+ /// portal's answer: `None` while it doesn't (still signing in), an error for a refusal or a
+ /// callback that isn't this sign-in's.
+ pub fn exchange(&self, callback: &str) -> Option> {
+ let (page, query) = callback.split_once('?')?;
+ if page != self.redirect {
+ return None;
+ }
+ let params = query_params(query.split('#').next().unwrap_or(query));
+ let get = |k: &str| params.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str()).unwrap_or("");
+ if params.iter().filter(|(k, _)| k == "state").count() != 1 || get("state") != self.state {
+ return Some(Err("the sign-in's answer isn't this sign-in's (try again)".into()));
+ }
+ if !get("error").is_empty() {
+ return Some(Err(if get("error") == "access_denied" {
+ "the sign-in was declined".into()
+ } else {
+ format!("the portal said {}", get("error"))
+ }));
+ }
+ let code = get("code");
+ if code.is_empty() {
+ return Some(Err("the portal gave no authorization code".into()));
+ }
+ Some(Ok((endpoint(&self.portal, "token"), exchange_body(&self.client_id, code, &self.redirect, &self.verifier))))
+ }
+}
+
+/// What may be shown about a session: never its tokens.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Info {
+ pub portal: String,
+ pub username: String,
+ /// Unix seconds; `None` when unknown (a token given with an address).
+ pub expires_at: Option,
+ /// Whether it renews itself.
+ pub renews: bool,
+ /// A token given with an address, not a sign-in.
+ pub token: bool,
+}
+
+/// The sessions kept.
+pub fn list() -> Vec {
+ sessions()
+ .iter()
+ .map(|s| Info {
+ portal: s.portal.clone(),
+ username: s.username.clone(),
+ expires_at: (s.expires_at != i64::MAX).then_some(s.expires_at),
+ renews: s.refresh_token.is_some(),
+ token: s.client_id.is_empty(),
+ })
+ .collect()
+}
+
+/// The session signed in to `portal` (a base as [`portal_base`] gives), if any.
+pub fn signed_in(portal: &str) -> Option {
+ list().into_iter().find(|s| s.portal == portal && !s.token)
+}
+
+/// A revocation to send after a sign-out: the endpoint and its form.
+#[derive(Clone, PartialEq, Eq)]
+pub struct Revocation {
+ pub endpoint: String,
+ pub body: String,
+}
+
+/// Forgets the session of `portal` (a portal base or a server root), or every session for
+/// `None`. Returns the revocations to send (sign-ins only) and how many were forgotten.
+pub fn sign_out(portal: Option<&str>) -> (Vec, usize) {
+ let mut all = sessions();
+ let mut revocations = Vec::new();
+ let before = all.len();
+ all.retain(|s| {
+ let gone = portal.is_none_or(|p| p.trim_end_matches('/') == s.portal);
+ if gone && !s.client_id.is_empty() {
+ let (token, refresh) = match &s.refresh_token {
+ Some(r) => (r.as_str(), true),
+ None => (s.access_token.as_str(), false),
+ };
+ revocations.push(Revocation { endpoint: endpoint(&s.portal, "revokeToken"), body: revoke_body(&s.client_id, token, refresh) });
+ }
+ !gone
+ });
+ let gone = before - all.len();
+ (revocations, gone)
+}
+
+// ---------------------------------------------------------------------------------------------
+// The desktop and the command line: the callback server, renewing and revoking.
+
+#[cfg(not(target_arch = "wasm32"))]
+pub use native::*;
+
+#[cfg(not(target_arch = "wasm32"))]
+mod native {
+ use std::io::{Read, Write};
+ use std::net::{TcpListener, TcpStream};
+ use std::sync::atomic::{AtomicBool, Ordering};
+ use std::sync::{Arc, Mutex};
+ use std::time::{Duration, Instant};
+
+ use super::{Info, Revocation};
+
+ /// How long the browser has to come back.
+ const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(5 * 60);
+ const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
+
+ /// The sign-in under way, or how the last one ended.
+ #[derive(Clone, Debug, Default, PartialEq, Eq)]
+ pub enum Progress {
+ #[default]
+ Idle,
+ /// Waiting for the browser to come back from `portal`.
+ Pending {
+ portal: String,
+ },
+ Done(Info),
+ Failed(String),
+ }
+
+ struct Flow {
+ progress: Progress,
+ cancel: Option>,
+ }
+
+ fn flow() -> std::sync::MutexGuard<'static, Flow> {
+ static F: Mutex = Mutex::new(Flow { progress: Progress::Idle, cancel: None });
+ F.lock().unwrap_or_else(|e| e.into_inner())
+ }
+
+ /// The sign-in under way, or how the last one ended.
+ pub fn progress() -> Progress {
+ flow().progress.clone()
+ }
+
+ /// Stops a sign-in under way.
+ pub fn cancel_sign_in() {
+ let mut f = flow();
+ if let Some(c) = f.cancel.take() {
+ c.store(true, Ordering::SeqCst);
+ }
+ if matches!(f.progress, Progress::Pending { .. }) {
+ f.progress = Progress::Failed("the sign-in was cancelled".into());
+ }
+ }
+
+ fn random_token() -> Result {
+ use base64::Engine;
+ let mut bytes = [0u8; 32];
+ getrandom::fill(&mut bytes).map_err(|e| format!("no random numbers: {e}"))?;
+ Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes))
+ }
+
+ fn agent() -> ureq::Agent {
+ // A token request is never redirected: a redirect would replay the code or the refresh token.
+ ureq::Agent::config_builder()
+ .timeout_global(Some(REQUEST_TIMEOUT))
+ .user_agent(concat!("RustGIS/", env!("CARGO_PKG_VERSION"), " (+https://rustgis.app)"))
+ .max_redirects(0)
+ .build()
+ .into()
+ }
+
+ /// POSTs a form; a refusal's answer (an OAuth `error`, often with HTTP 400) is returned for
+ /// the caller to read, while a server error (5xx) counts as the portal not being reached.
+ fn post_form(url: &str, body: &str) -> Result, String> {
+ let resp = agent()
+ .post(url)
+ .config()
+ .http_status_as_error(false)
+ .build()
+ .header("Content-Type", "application/x-www-form-urlencoded")
+ .header("Accept", "application/json")
+ .send(body)
+ .map_err(|e| format!("couldn't reach the portal: {e}"))?;
+ let status = resp.status().as_u16();
+ if status >= 500 {
+ return Err(format!("the portal answered HTTP {status}"));
+ }
+ resp.into_body().with_config().limit(1 << 20).read_to_vec().map_err(|e| e.to_string())
+ }
+
+ fn get_json(url: &str, token: &str) -> Result, String> {
+ let resp = agent()
+ .get(url)
+ .header("X-Esri-Authorization", &format!("Bearer {token}"))
+ .header("Accept", "application/json")
+ .call()
+ .map_err(|e| e.to_string())?;
+ resp.into_body().with_config().limit(4 << 20).read_to_vec().map_err(|e| e.to_string())
+ }
+
+ /// Starts signing in to `portal` (blank: ArcGIS Online) with an OAuth app's `client_id`:
+ /// binds the callback server on `port` and gives the portal's sign-in page to open in the
+ /// browser. The session is kept when the browser comes back ([`progress`] tells).
+ pub fn start_sign_in(portal: &str, client_id: &str, port: u16) -> Result {
+ let portal = super::portal_base(portal)?;
+ let client_id = super::client_id_for(&portal, client_id).unwrap_or_default();
+ let client_id = client_id.as_str();
+ if !super::valid_client_id(client_id) {
+ return Err("give the OAuth client id of an app registered on the portal (Content › New item › Developer credentials)".into());
+ }
+ cancel_sign_in();
+ let redirect = super::redirect_uri(port);
+ let listener = bind(port)?;
+ listener.set_nonblocking(true).map_err(|e| e.to_string())?;
+ let verifier = random_token()?;
+ let state = random_token()?;
+ let url = super::authorize_url(&portal, client_id, &redirect, &super::pkce_challenge(&verifier), &state);
+ let cancel = Arc::new(AtomicBool::new(false));
+ {
+ let mut f = flow();
+ f.progress = Progress::Pending { portal: portal.clone() };
+ f.cancel = Some(cancel.clone());
+ }
+ let pending = Pending { portal, client_id: client_id.to_string(), redirect, verifier, state };
+ std::thread::spawn(move || {
+ let result = serve(&listener, &cancel, &pending);
+ let mut f = flow();
+ // A newer sign-in replaced this one: its outcome isn't this one's to tell.
+ if !cancel.load(Ordering::SeqCst) {
+ f.progress = match result {
+ Ok(info) => Progress::Done(info),
+ Err(e) => Progress::Failed(e),
+ };
+ f.cancel = None;
+ }
+ });
+ Ok(url)
+ }
+
+ /// Binds the callback server. A sign-in just cancelled lets go of the port when its thread
+ /// next looks (within a few seconds): until then, try again.
+ fn bind(port: u16) -> Result {
+ let deadline = Instant::now() + Duration::from_secs(3);
+ loop {
+ match TcpListener::bind(("127.0.0.1", port)) {
+ Ok(l) => return Ok(l),
+ Err(e) if e.kind() == std::io::ErrorKind::AddrInUse && Instant::now() < deadline => {
+ std::thread::sleep(Duration::from_millis(50))
+ }
+ Err(e) => {
+ return Err(format!(
+ "can't start the sign-in's callback server on 127.0.0.1:{port}: {e} (another program may be using the port)"
+ ));
+ }
+ }
+ }
+ }
+
+ struct Pending {
+ portal: String,
+ client_id: String,
+ redirect: String,
+ verifier: String,
+ state: String,
+ }
+
+ fn serve(listener: &TcpListener, cancel: &AtomicBool, p: &Pending) -> Result {
+ let deadline = Instant::now() + SIGN_IN_TIMEOUT;
+ loop {
+ if cancel.load(Ordering::SeqCst) {
+ return Err("the sign-in was cancelled".into());
+ }
+ if Instant::now() >= deadline {
+ return Err("the sign-in timed out: the browser didn't come back within five minutes".into());
+ }
+ let mut stream = match listener.accept() {
+ Ok((s, _)) => s,
+ Err(e)
+ if matches!(
+ e.kind(),
+ std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted | std::io::ErrorKind::ConnectionAborted
+ ) =>
+ {
+ std::thread::sleep(Duration::from_millis(50));
+ continue;
+ }
+ Err(e) => return Err(format!("the sign-in's callback server failed: {e}")),
+ };
+ let _ = stream.set_nonblocking(false);
+ let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
+ let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));
+ let Some(target) = request_target(&mut stream) else {
+ respond(&mut stream, 400, "Bad request");
+ continue;
+ };
+ let (path, query) = target.split_once('?').unwrap_or((target.as_str(), ""));
+ if path != super::CALLBACK {
+ respond(&mut stream, 404, "Not found");
+ continue;
+ }
+ let params = super::query_params(query);
+ let get = |k: &str| params.iter().find(|(n, _)| n == k).map(|(_, v)| v.as_str()).unwrap_or("");
+ if params.iter().filter(|(k, _)| k == "state").count() != 1 || get("state") != p.state {
+ respond(&mut stream, 400, "State mismatch");
+ continue;
+ }
+ if !get("error").is_empty() {
+ let why = if get("error") == "access_denied" {
+ "the sign-in was declined".to_string()
+ } else {
+ format!("the portal said {}", get("error"))
+ };
+ respond(&mut stream, 200, &page("Sign-in failed", &why));
+ return Err(why);
+ }
+ let code = get("code");
+ if code.is_empty() {
+ respond(&mut stream, 400, "Missing authorization code");
+ continue;
+ }
+ let result = finish(p, code, cancel);
+ match &result {
+ Ok(info) => {
+ let who = if info.username.is_empty() { String::new() } else { format!(" as {}", info.username) };
+ respond(
+ &mut stream,
+ 200,
+ &page("Signed in", &format!("RustGIS is signed in to {}{who}. You can close this tab.", info.portal)),
+ );
+ }
+ Err(e) => respond(&mut stream, 200, &page("Sign-in failed", e)),
+ }
+ return result;
+ }
+ }
+
+ /// Exchanges the code, finds what the token covers and keeps the session.
+ fn finish(p: &Pending, code: &str, cancel: &AtomicBool) -> Result {
+ let body = post_form(&super::endpoint(&p.portal, "token"), &super::exchange_body(&p.client_id, code, &p.redirect, &p.verifier))?;
+ let grant = super::parse_grant(&body, super::now_secs())?;
+ let scopes = discover(&p.portal, &grant.access_token);
+ if cancel.load(Ordering::SeqCst) {
+ revoke_all(vec![Revocation {
+ endpoint: super::endpoint(&p.portal, "revokeToken"),
+ body: match &grant.refresh_token {
+ Some(r) => super::revoke_body(&p.client_id, r, true),
+ None => super::revoke_body(&p.client_id, &grant.access_token, false),
+ },
+ }]);
+ return Err("the sign-in was cancelled".into());
+ }
+ super::sign_in(&p.portal, &p.client_id, grant, scopes);
+ // Tiles and documents refused before the sign-in are asked for again.
+ crate::cache::clear();
+ super::signed_in(&p.portal).ok_or_else(|| "the sign-in wasn't kept".into())
+ }
+
+ /// What a portal's token covers: its organization's id (ArcGIS Online) or its federated
+ /// servers (ArcGIS Enterprise). What can't be read leaves the portal's own host.
+ fn discover(portal: &str, token: &str) -> Vec {
+ let answer = get_json(&super::discovery_url(portal), token).ok();
+ super::discovered_scopes(portal, answer.as_deref())
+ }
+
+ /// Like [`authorization`](super::authorization), renewing the access token first when it is
+ /// about to expire. A refused renewal ends the session; an unreachable portal keeps it.
+ pub fn authorization_fresh(url: &str) -> Option {
+ if super::renewal_due(url).is_some() {
+ // One renewal at a time: the others wait for it, then find the token fresh.
+ static RENEWING: Mutex<()> = Mutex::new(());
+ let _one = RENEWING.lock().unwrap_or_else(|e| e.into_inner());
+ if let Some(r) = super::renewal_due(url) {
+ let answer = post_form(&r.endpoint, &r.body);
+ super::renewal_done(&r, answer.as_deref().map_err(String::clone));
+ }
+ }
+ super::authorization(url)
+ }
+
+ /// Sends revocations off the caller's thread, on a best-effort basis.
+ pub fn revoke_all(revocations: Vec) {
+ if revocations.is_empty() {
+ return;
+ }
+ std::thread::spawn(move || {
+ for r in revocations {
+ let _ = post_form(&r.endpoint, &r.body);
+ }
+ });
+ }
+
+ /// The request line's target of an HTTP request.
+ fn request_target(stream: &mut TcpStream) -> Option {
+ let mut buf = Vec::new();
+ let mut chunk = [0u8; 2048];
+ // A client trickling bytes doesn't hold the server past a few seconds.
+ let until = Instant::now() + Duration::from_secs(5);
+ while !buf.windows(2).any(|w| w == b"\r\n") {
+ if Instant::now() >= until {
+ return None;
+ }
+ let n = stream.read(&mut chunk).ok()?;
+ if n == 0 || buf.len() > 16 << 10 {
+ return None;
+ }
+ buf.extend_from_slice(chunk.get(..n)?);
+ }
+ let line = std::str::from_utf8(&buf).ok()?.lines().next()?;
+ let mut parts = line.split(' ');
+ (parts.next()? == "GET").then_some(())?;
+ parts.next().map(str::to_string)
+ }
+
+ fn respond(stream: &mut TcpStream, status: u16, body: &str) {
+ let reason = match status {
+ 200 => "OK",
+ 400 => "Bad Request",
+ _ => "Not Found",
+ };
+ let kind = if body.starts_with(" String {
+ s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
+ }
+
+ fn page(title: &str, text: &str) -> String {
+ format!(
+ "RustGIS: {t} {t} {m}
",
+ t = escape(title),
+ m = escape(text)
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/tiles/src/arcgis_auth/tests.rs b/crates/tiles/src/arcgis_auth/tests.rs
new file mode 100644
index 000000000..33c4d5280
--- /dev/null
+++ b/crates/tiles/src/arcgis_auth/tests.rs
@@ -0,0 +1,240 @@
+use super::*;
+
+// The sessions are shared by the process's tests: each test uses its own hosts.
+
+fn grant(token: &str, expires_at: i64) -> Grant {
+ Grant { access_token: token.into(), refresh_token: Some("refresh".into()), expires_at, username: "ana".into() }
+}
+
+#[test]
+fn portal_bases() {
+ assert_eq!(portal_base("").unwrap(), ONLINE);
+ assert_eq!(portal_base("https://arcgis.com/home").unwrap(), ONLINE);
+ assert_eq!(portal_base("https://MyOrg.maps.arcgis.com/home/content.html").unwrap(), "https://myorg.maps.arcgis.com");
+ assert_eq!(portal_base("https://gis.example.org/portal/sharing/rest?f=json").unwrap(), "https://gis.example.org/portal");
+ assert_eq!(portal_base("https://gis.example.org:7443/arcgis/home/").unwrap(), "https://gis.example.org:7443/arcgis");
+ assert_eq!(portal_base("https://gis.example.org:443/portal").unwrap(), "https://gis.example.org/portal");
+ // Tokens travel to the portal: never over plain HTTP, never with user info.
+ for bad in ["http://gis.example.org/portal", "https://user@gis.example.org/portal", "https://", "https://gis.example.org:x/", "ftp://a"]
+ {
+ assert!(portal_base(bad).is_err(), "{bad}");
+ }
+ assert!(is_online("https://myorg.maps.arcgis.com") && is_online(ONLINE) && !is_online("https://gis.example.org/portal"));
+}
+
+#[test]
+fn endpoints_and_requests() {
+ // An organization's users sign in on its page; its tokens come from www.arcgis.com.
+ assert_eq!(endpoint("https://myorg.maps.arcgis.com", "authorize"), "https://myorg.maps.arcgis.com/sharing/rest/oauth2/authorize");
+ assert_eq!(endpoint("https://myorg.maps.arcgis.com", "token"), "https://www.arcgis.com/sharing/rest/oauth2/token");
+ assert_eq!(endpoint("https://gis.example.org/portal", "token"), "https://gis.example.org/portal/sharing/rest/oauth2/token");
+ let url = authorize_url("https://gis.example.org/portal", "abc123", &redirect_uri(PORT), "chal", "st&te");
+ assert!(url.starts_with("https://gis.example.org/portal/sharing/rest/oauth2/authorize?client_id=abc123&response_type=code"));
+ assert!(url.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A7290%2Farcgis%2Fcallback"));
+ assert!(url.contains("code_challenge=chal&code_challenge_method=S256&state=st%26te"));
+ let body = exchange_body("abc", "c/d", "http://127.0.0.1:7290/arcgis/callback", "v");
+ assert!(body.contains("grant_type=authorization_code") && body.contains("code=c%2Fd") && body.contains("code_verifier=v"));
+ assert!(refresh_body("abc", "r").contains("grant_type=refresh_token&client_id=abc&refresh_token=r"));
+ assert!(revoke_body("abc", "r", true).ends_with("auth_token=r&token_type_hint=refresh_token"));
+ // RFC 7636's example.
+ assert_eq!(pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
+ assert!(valid_client_id("3IKyMtbdeceo0JCr") && !valid_client_id("") && !valid_client_id("a b") && !valid_client_id("a&b"));
+}
+
+#[test]
+fn token_answers() {
+ let g = parse_grant(br#"{"access_token":"tok.1","refresh_token":"ref","expires_in":1800,"username":"ana"}"#, 100).unwrap();
+ assert_eq!(
+ (g.access_token.as_str(), g.refresh_token.as_deref(), g.expires_at, g.username.as_str()),
+ ("tok.1", Some("ref"), 1900, "ana")
+ );
+ // Refusals come as HTTP 200 with an error object.
+ let e = parse_grant(br#"{"error":{"code":400,"error":"invalid_request","error_description":"Invalid PKCE code_verifier"}}"#, 0)
+ .unwrap_err();
+ assert!(e.contains("Invalid PKCE code_verifier"), "{e}");
+ assert!(parse_grant(b"", 0).is_err());
+ assert!(parse_grant(br#"{"access_token":"bad token\r\nX: y"}"#, 0).is_err());
+ // No lifetime: half an hour.
+ assert_eq!(parse_grant(br#"{"access_token":"t"}"#, 0).unwrap().expires_at, 1800);
+ // Its Debug form hides the tokens.
+ assert!(!format!("{g:?}").contains("tok.1"));
+}
+
+#[test]
+fn an_online_sign_in_covers_its_organization_only() {
+ let portal = "https://scopeorg.maps.arcgis.com";
+ sign_in(portal, "client", grant("online-token", now_secs() + 3600), scopes_for(portal, Some("Org1d"), &[]));
+ let bearer = Some("Bearer online-token".to_string());
+ assert_eq!(authorization("https://scopeorg.maps.arcgis.com/sharing/rest/content/items/x?f=json"), bearer);
+ assert_eq!(authorization("https://services1.arcgis.com/Org1d/arcgis/rest/services/A/FeatureServer/0/query?f=json"), bearer);
+ assert_eq!(authorization("https://tiles.arcgis.com/tiles/Org1d/arcgis/rest/services/B/MapServer/tile/1/0/0"), bearer);
+ // Another organization's services, Esri's basemaps, look-alike hosts and plain HTTP don't get it.
+ for url in [
+ "https://services1.arcgis.com/0ther/arcgis/rest/services/A/FeatureServer",
+ "https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/1/0/0",
+ "https://basemaps.arcgis.com/arcgis/rest/services/World_Basemap_v2/VectorTileServer",
+ "https://services1.arcgis.com.evil.example/Org1d/arcgis/rest/services/A/FeatureServer",
+ "https://evil.example/Org1d/arcgis/rest/services/A/FeatureServer",
+ "http://services1.arcgis.com/Org1d/arcgis/rest/services/A/FeatureServer",
+ "https://user@services1.arcgis.com/Org1d/arcgis/rest/services/A/FeatureServer",
+ "https://services1.arcgis.com/x/y/Org1d/A/FeatureServer",
+ ] {
+ assert_eq!(authorization(url), None, "{url}");
+ }
+ assert!(signed_in(portal).is_some_and(|i| i.username == "ana" && i.renews && !i.token));
+ let (revocations, gone) = sign_out(Some(portal));
+ assert_eq!(gone, 1);
+ assert!(revocations.len() == 1 && revocations[0].endpoint == "https://www.arcgis.com/sharing/rest/oauth2/revokeToken");
+ assert_eq!(authorization("https://services1.arcgis.com/Org1d/arcgis/rest/services/A/FeatureServer"), None);
+}
+
+#[test]
+fn an_enterprise_sign_in_covers_its_host_and_federated_servers() {
+ let portal = "https://ent.example.org/portal";
+ let servers = parse_servers(br#"{"servers":[{"url":"https://ent.example.org/server"},{"url":"https://maps.ent-other.example.org/arcgis"},{"url":"http://plain.example.org/server"}]}"#);
+ assert_eq!(servers, ["https://ent.example.org/server", "https://maps.ent-other.example.org/arcgis"]);
+ sign_in(portal, "client", grant("ent-token", now_secs() + 3600), scopes_for(portal, None, &servers));
+ let bearer = Some("Bearer ent-token".to_string());
+ assert_eq!(authorization("https://ent.example.org/server/rest/services/Roads/MapServer?f=json"), bearer);
+ assert_eq!(authorization("https://maps.ent-other.example.org/ArcGIS/rest/services/A/FeatureServer"), bearer);
+ assert_eq!(authorization("https://maps.ent-other.example.org/other/rest/services"), None);
+ assert_eq!(authorization("https://ent.example.org.evil.example/server/rest/services"), None);
+ // An expired token without renewal isn't sent.
+ sign_in(portal, "client", grant("old", now_secs() - 1), scopes_for(portal, None, &[]));
+ assert_eq!(authorization("https://ent.example.org/server/rest/services"), None);
+ sign_out(Some(portal));
+}
+
+#[test]
+fn a_token_in_a_service_address_reaches_its_layers() {
+ // The reported case (#914): a service's address with ?token=… works in the browser, but its
+ // layers, tiles and feature pages are fetched from addresses built without it.
+ let url = "https://tokenserver.example.org/server/rest/services/Secure/MapServer?f=json&token=abc.DEF-123";
+ let kept = adopt_url_token(url).unwrap();
+ assert_eq!(kept, "https://tokenserver.example.org/server/rest/services/Secure/MapServer?f=json");
+ let bearer = Some("Bearer abc.DEF-123".to_string());
+ assert_eq!(authorization("https://tokenserver.example.org/server/rest/services/Secure/MapServer/tile/3/2/1"), bearer);
+ assert_eq!(
+ authorization("https://tokenserver.example.org/server/rest/services/Secure/MapServer/0/query?where=1%3D1&f=geojson"),
+ bearer
+ );
+ assert_eq!(authorization("https://tokenserver.example.org/other/rest/services"), None);
+ // A path leading elsewhere by its dot segments gets none.
+ assert_eq!(authorization("https://tokenserver.example.org/server/../other/rest/services"), None);
+ assert_eq!(authorization("https://tokenserver.example.org/server/%2E%2E/other/rest/services"), None);
+ assert!(list().iter().any(|i| i.portal == "https://tokenserver.example.org/server" && i.token && i.expires_at.is_none()));
+ // An address without a token is unchanged; a hostile one is refused.
+ assert_eq!(
+ adopt_url_token("https://a.example.org/server/rest/services?f=json").unwrap(),
+ "https://a.example.org/server/rest/services?f=json"
+ );
+ assert!(adopt_url_token("https://b.example.org/server/rest/services?token=a%0D%0Ab").is_err());
+ assert!(adopt_url_token("http://c.example.org/server/rest/services?token=abc").is_err());
+ assert_eq!(
+ adopt_url_token("https://d.example.org/server/rest/services/X/MapServer?token=").unwrap(),
+ "https://d.example.org/server/rest/services/X/MapServer"
+ );
+ assert_eq!(
+ server_root("https://services1.arcgis.com/Org/arcgis/rest/services/A/FeatureServer").as_deref(),
+ Some("https://services1.arcgis.com/Org/arcgis/")
+ );
+ assert_eq!(server_root("https://e.example.org/rest/services").as_deref(), Some("https://e.example.org/"));
+ assert_eq!(server_root("https://e.example.org/data.geojson"), None);
+ sign_out(Some("https://tokenserver.example.org/server/"));
+ assert_eq!(authorization("https://tokenserver.example.org/server/rest/services/Secure/MapServer/tile/3/2/1"), None);
+}
+
+#[test]
+fn a_server_token_wins_over_a_portal_sign_in() {
+ let portal = "https://both.example.org/portal";
+ sign_in(portal, "client", grant("portal-token", now_secs() + 3600), scopes_for(portal, None, &[]));
+ add_token("https://both.example.org/server/rest/services", "server-token").unwrap();
+ assert_eq!(authorization("https://both.example.org/server/rest/services/A/MapServer").as_deref(), Some("Bearer server-token"));
+ assert_eq!(authorization("https://both.example.org/portal/sharing/rest/portals/self").as_deref(), Some("Bearer portal-token"));
+ // The browser sends it as the request's parameter instead (ArcGIS's query endpoints refuse
+ // the header's CORS preflight).
+ assert_eq!(
+ with_token("https://both.example.org/server/rest/services/A/FeatureServer/0/query?where=1%3D1&f=geojson").as_deref(),
+ Some("https://both.example.org/server/rest/services/A/FeatureServer/0/query?where=1%3D1&f=geojson&token=server-token")
+ );
+ assert_eq!(
+ with_token("https://both.example.org/server/rest/services/A/MapServer/tile/1/0/0#x").as_deref(),
+ Some("https://both.example.org/server/rest/services/A/MapServer/tile/1/0/0?token=server-token#x")
+ );
+ assert_eq!(with_token("https://elsewhere.example.org/server/rest/services"), None);
+ // An address with a token of its own keeps it, alone.
+ assert_eq!(with_token("https://both.example.org/server/rest/services/A/MapServer?token=users"), None);
+ sign_out(Some(portal));
+ sign_out(Some("https://both.example.org/server"));
+ // A token for the portal's own address sits beside its sign-in, never replacing it.
+ let portal = "https://same.example.org/portal";
+ sign_in(portal, "client", grant("signin-token", now_secs() + 3600), scopes_for(portal, None, &[]));
+ add_token("https://same.example.org/portal/sharing/rest/portals/self", "pasted-token").unwrap();
+ let kinds: Vec = list().iter().filter(|i| i.portal == portal).map(|i| i.token).collect();
+ assert!(kinds.len() == 2 && kinds.contains(&true) && kinds.contains(&false), "{kinds:?}");
+ let (revocations, gone) = sign_out(Some(portal));
+ assert_eq!((revocations.len(), gone), (1, 2));
+}
+
+#[test]
+fn a_pasted_token_keeps_its_plus_signs() {
+ adopt_url_token("https://plus.example.org/server/rest/services/A/MapServer?token=ab+cd%2Fef").unwrap();
+ assert_eq!(authorization("https://plus.example.org/server/rest/services/A/MapServer/tile/0/0/0").as_deref(), Some("Bearer ab+cd/ef"));
+ sign_out(Some("https://plus.example.org/server"));
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+#[test]
+fn a_sign_in_refuses_bad_input_before_listening() {
+ assert!(start_sign_in("http://gis.example.org/portal", "abc", 0).is_err());
+ // An Enterprise portal registers its own app: no default.
+ assert!(start_sign_in("https://gis.example.org/portal", "", 0).unwrap_err().contains("client id"));
+}
+
+#[test]
+fn arcgis_online_has_a_default_client_id() {
+ assert_eq!(client_id_for(ONLINE, "").as_deref(), Some(DEFAULT_CLIENT_ID));
+ assert_eq!(client_id_for("https://myorg.maps.arcgis.com", " ").as_deref(), Some(DEFAULT_CLIENT_ID));
+ assert_eq!(client_id_for("https://myorg.maps.arcgis.com", "mine").as_deref(), Some("mine"));
+ assert_eq!(client_id_for("https://gis.example.org/portal", ""), None);
+}
+
+#[test]
+fn a_browser_sign_in_reads_its_callback_page() {
+ let redirect = "https://web.rustgis.app/oauth-callback.html";
+ let w = web_sign_in("https://myorg.maps.arcgis.com", "", redirect, "verifier", "st8").unwrap();
+ assert!(w.url.starts_with("https://myorg.maps.arcgis.com/sharing/rest/oauth2/authorize?client_id=nD2lKYqFqsaddfuD&"));
+ assert!(w.url.contains("redirect_uri=https%3A%2F%2Fweb.rustgis.app%2Foauth-callback.html"));
+ // Still on the portal's pages, or blank: not yet.
+ assert!(w.exchange("about:blank").is_none() && w.exchange(redirect).is_none());
+ assert!(w.exchange("https://evil.example/oauth-callback.html?code=c&state=st8").is_none());
+ let (endpoint, body) = w.exchange(&format!("{redirect}?code=the%2Fcode&state=st8")).unwrap().unwrap();
+ assert_eq!(endpoint, "https://www.arcgis.com/sharing/rest/oauth2/token");
+ assert!(body.contains("code=the%2Fcode") && body.contains("code_verifier=verifier") && body.contains("client_id=nD2lKYqFqsaddfuD"));
+ assert!(w.exchange(&format!("{redirect}?code=c&state=other")).unwrap().is_err());
+ assert!(w.exchange(&format!("{redirect}?error=access_denied&state=st8")).unwrap().unwrap_err().contains("declined"));
+ assert!(web_sign_in("https://gis.example.org/portal", "", redirect, "v", "s").is_err());
+}
+
+#[test]
+fn renewals_land_or_end_the_session() {
+ let portal = "https://renew.example.org/portal";
+ let url = "https://renew.example.org/server/rest/services/A/MapServer";
+ sign_in(portal, "client", grant("old", now_secs() + 10), scopes_for(portal, None, &[]));
+ let r = renewal_due(url).unwrap();
+ assert!(r.endpoint.ends_with("/portal/sharing/rest/oauth2/token") && r.body.contains("grant_type=refresh_token"));
+ // Unreachable: kept, and not tried again at once.
+ renewal_done(&r, Err("offline".into()));
+ assert!(renewal_due(url).is_none() && authorization(url).is_some());
+ // A proxy's error page: kept too.
+ renewal_done(&r, Ok(b"502 Bad Gateway"));
+ assert!(authorization(url).is_some());
+ // A new access token.
+ renewal_done(&r, Ok(br#"{"access_token":"new","expires_in":1800}"#));
+ assert_eq!(authorization(url).as_deref(), Some("Bearer new"));
+ // Refused: the session ends.
+ sign_in(portal, "client", grant("old", now_secs() + 10), scopes_for(portal, None, &[]));
+ let r = renewal_due(url).unwrap();
+ renewal_done(&r, Ok(br#"{"error":{"code":400,"error":"invalid_grant"}}"#));
+ assert!(authorization(url).is_none() && signed_in(portal).is_none());
+}
diff --git a/crates/tiles/src/cache.rs b/crates/tiles/src/cache.rs
index 5b272d8f4..fdaf9caeb 100644
--- a/crates/tiles/src/cache.rs
+++ b/crates/tiles/src/cache.rs
@@ -1159,6 +1159,13 @@ mod native {
{
req = req.header("Authorization", &auth);
}
+ // An ArcGIS sign-in's token goes to the portal and the servers it covers, asked for at
+ // each hop like Earthdata's.
+ if !prepared.signed
+ && let Some(auth) = crate::arcgis_auth::authorization_fresh(&at)
+ {
+ req = req.header("X-Esri-Authorization", &auth);
+ }
// An error status is answered, not failed: its headers and body say who refused.
let mut resp = req.config().http_status_as_error(false).build().call().map_err(unanswered)?;
let status = resp.status().as_u16();
diff --git a/crates/tiles/src/lib.rs b/crates/tiles/src/lib.rs
index fabc1cd7b..b3f4d4949 100644
--- a/crates/tiles/src/lib.rs
+++ b/crates/tiles/src/lib.rs
@@ -8,6 +8,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod address;
+pub mod arcgis_auth;
pub mod boundaries;
pub mod cache;
pub mod cog;
diff --git a/crates/ui-egui/src/arcgisui.rs b/crates/ui-egui/src/arcgisui.rs
new file mode 100644
index 000000000..4abc578b8
--- /dev/null
+++ b/crates/ui-egui/src/arcgisui.rs
@@ -0,0 +1,218 @@
+//! Sign in to ArcGIS, in Add From Service: an ArcGIS Online organization or an ArcGIS Enterprise
+//! portal, through its own sign-in page in the browser (`arcgis.signIn`), so its secured services
+//! can be added. Who is signed in, and Sign out (`arcgis.signOut`). The portal's address and the
+//! OAuth client id are remembered; tokens never are (see `rustgis_tiles::arcgis_auth`).
+
+use crate::RustGisApp;
+use crate::theme::Tokens;
+use rustgis_engine::render::rustgis_tiles::arcgis_auth;
+use serde_json::json;
+
+const PORTAL_PREF: &str = "arcgis_portal";
+const CLIENT_PREF: &str = "arcgis_client_id";
+
+#[derive(Default)]
+pub struct State {
+ pub portal: String,
+ pub client_id: String,
+ /// The remembered portal and client id were read.
+ loaded: bool,
+ pub error: Option,
+ /// The browser's sign-in under way: who signed in, or why not.
+ #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
+ pending: Option>>,
+}
+
+/// Whether Add From Service shows the sign-in: the ArcGIS REST type, an ArcGIS-looking address,
+/// or a session kept.
+pub fn wanted(app: &RustGisApp) -> bool {
+ let address = app.dialogs.text.to_ascii_lowercase();
+ // A service known as ArcGIS by its answer (behind a proxy's address), or one refused for a token.
+ let answered = app.ui.service.as_ref().is_some_and(|c| {
+ c.listing.as_ref().and_then(|v| v.get("kind")).and_then(serde_json::Value::as_str).is_some_and(|k| k.starts_with("ArcGIS"))
+ || c.error.as_deref().is_some_and(|e| e.contains("Token Required") || e.contains("ArcGIS token"))
+ });
+ app.dialogs.mode == "arcgis"
+ || address.contains("/rest/services")
+ || address.contains("arcgis")
+ || answered
+ || !arcgis_auth::list().is_empty()
+}
+
+/// The sign-in's controls, folded under a header.
+pub fn section(app: &mut RustGisApp, ui: &mut egui::Ui) {
+ if !app.ui.arcgis.loaded {
+ app.ui.arcgis.loaded = true;
+ if let Some(load) = &app.services.load_pref {
+ app.ui.arcgis.portal = load(PORTAL_PREF).unwrap_or_default();
+ app.ui.arcgis.client_id = load(CLIENT_PREF).unwrap_or_default();
+ }
+ }
+ let t = Tokens::get(ui.ctx());
+ let sessions = arcgis_auth::list();
+ egui::CollapsingHeader::new(tl!("Sign in to ArcGIS")).id_salt("arcgis_sign_in").default_open(!sessions.is_empty()).show(ui, |ui| {
+ let mut sign_out = None;
+ for s in &sessions {
+ ui.horizontal(|ui| {
+ let text = match (s.token, s.username.is_empty()) {
+ (true, _) => crate::i18n::fmt("A token for {server}", &[("server", &s.portal)]),
+ (false, true) => crate::i18n::fmt("Signed in to {portal}", &[("portal", &s.portal)]),
+ (false, false) => crate::i18n::fmt("Signed in to {portal} as {user}", &[("portal", &s.portal), ("user", &s.username)]),
+ };
+ ui.label(text);
+ if ui.button(tl!("Sign out")).clicked() {
+ sign_out = Some(s.portal.clone());
+ }
+ });
+ }
+ if let Some(portal) = sign_out
+ && let Err(e) = app.run("arcgis.signOut", json!({"portal": portal}))
+ {
+ app.ui.arcgis.error = Some(e);
+ }
+ form(app, ui, &t);
+ if let Some(e) = &app.ui.arcgis.error {
+ ui.colored_label(t.red, e);
+ }
+ });
+}
+
+/// The portal and client id fields, with what to register.
+fn fields(app: &mut RustGisApp, ui: &mut egui::Ui) {
+ prefill(&mut app.ui.arcgis);
+ let redirect = if cfg!(target_arch = "wasm32") {
+ format!("…/{}", arcgis_auth::WEB_CALLBACK)
+ } else {
+ arcgis_auth::redirect_uri(arcgis_auth::PORT)
+ };
+ ui.label(
+ egui::RichText::new(crate::i18n::fmt(
+ "For ArcGIS Online, the client ID is RustGIS's own app. For an ArcGIS Enterprise portal, register an OAuth app on it (Content › New item › Developer credentials) whose redirect URL is {redirect}, and give its client ID.",
+ &[("redirect", &redirect)],
+ ))
+ .weak(),
+ );
+ egui::Grid::new("arcgis_sign_in_form").num_columns(2).spacing(egui::vec2(10.0, 8.0)).show(ui, |ui| {
+ let label = ui.label(tl!("Portal"));
+ ui.add(egui::TextEdit::singleline(&mut app.ui.arcgis.portal).hint_text("https://myorg.maps.arcgis.com").desired_width(320.0))
+ .labelled_by(label.id)
+ .on_hover_text(tl!("An ArcGIS Online organization's or an ArcGIS Enterprise portal's address; ArcGIS Online when blank"));
+ ui.end_row();
+ let label = ui.label(tl!("Client ID"));
+ ui.add(egui::TextEdit::singleline(&mut app.ui.arcgis.client_id).hint_text(tl!("The portal's OAuth app")).desired_width(320.0))
+ .labelled_by(label.id);
+ ui.end_row();
+ });
+}
+
+/// Shows RustGIS's own client id for an ArcGIS Online portal (blank, or an organization's
+/// address) when none is given, and takes it away for an Enterprise portal, which registers its own.
+/// A client id the user types is kept.
+fn prefill(s: &mut State) {
+ let online = arcgis_auth::portal_base(&s.portal).is_ok_and(|p| arcgis_auth::is_online(&p));
+ let id = s.client_id.trim();
+ if online && id.is_empty() {
+ s.client_id = arcgis_auth::DEFAULT_CLIENT_ID.to_string();
+ } else if !online && id == arcgis_auth::DEFAULT_CLIENT_ID {
+ s.client_id.clear();
+ }
+}
+
+fn remember(app: &RustGisApp, portal: &str, client_id: &str) {
+ if let Some(save) = &app.services.save_pref {
+ save(PORTAL_PREF, portal);
+ save(CLIENT_PREF, client_id);
+ }
+}
+
+/// The browser: the sign-in runs in a pop-up window (`Services::arcgis_sign_in`).
+#[cfg(target_arch = "wasm32")]
+fn form(app: &mut RustGisApp, ui: &mut egui::Ui, t: &Tokens) {
+ fields(app, ui);
+ let done = app.ui.arcgis.pending.as_ref().and_then(|slot| slot.lock().unwrap_or_else(|e| e.into_inner()).take());
+ if let Some(result) = done {
+ app.ui.arcgis.pending = None;
+ app.ui.arcgis.error = result.err();
+ }
+ if app.ui.arcgis.pending.is_some() {
+ ui.horizontal(|ui| {
+ ui.spinner();
+ ui.label(tl!("Finish signing in in the pop-up window…"));
+ });
+ return;
+ }
+ if crate::widgets::dialog_button(ui, tl!("Sign in"), Some(t.accent), egui::vec2(96.0, 28.0)).clicked() {
+ let (portal, client_id) = (app.ui.arcgis.portal.trim().to_string(), app.ui.arcgis.client_id.trim().to_string());
+ app.ui.arcgis.error = None;
+ if let Some(sign_in) = &app.services.arcgis_sign_in {
+ let slot = crate::Slot::default();
+ sign_in(portal.clone(), client_id.clone(), slot.clone());
+ app.ui.arcgis.pending = Some(slot);
+ remember(app, &portal, &client_id);
+ }
+ }
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn form(app: &mut RustGisApp, ui: &mut egui::Ui, t: &Tokens) {
+ use arcgis_auth::Progress;
+ fields(app, ui);
+ match arcgis_auth::progress() {
+ Progress::Pending { .. } => {
+ ui.horizontal(|ui| {
+ ui.spinner();
+ ui.label(tl!("Finish signing in in the browser…"));
+ if ui.button(tl!("Cancel")).clicked() {
+ let _ = app.run("arcgis.cancelSignIn", json!({}));
+ }
+ });
+ // The browser comes back on another thread: look again soon.
+ ui.ctx().request_repaint_after(std::time::Duration::from_millis(250));
+ }
+ progress => {
+ if let Progress::Failed(e) = &progress {
+ ui.colored_label(t.red, e);
+ }
+ if crate::widgets::dialog_button(ui, tl!("Sign in"), Some(t.accent), egui::vec2(96.0, 28.0)).clicked() {
+ start(app);
+ }
+ }
+ }
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn start(app: &mut RustGisApp) {
+ app.ui.arcgis.error = None;
+ let (portal, client_id) = (app.ui.arcgis.portal.trim().to_string(), app.ui.arcgis.client_id.trim().to_string());
+ match app.run("arcgis.signIn", json!({"portal": portal, "clientId": client_id})) {
+ Ok(v) => {
+ remember(app, &portal, &client_id);
+ app.ui.open_url = v.get("url").and_then(serde_json::Value::as_str).map(str::to_string);
+ }
+ Err(e) => app.ui.arcgis.error = Some(e),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn arcgis_online_shows_the_default_client_id() {
+ let mut s = State::default();
+ prefill(&mut s);
+ assert_eq!(s.client_id, arcgis_auth::DEFAULT_CLIENT_ID);
+ s.portal = "https://myorg.maps.arcgis.com".into();
+ prefill(&mut s);
+ assert_eq!(s.client_id, arcgis_auth::DEFAULT_CLIENT_ID);
+ // An Enterprise portal registers its own.
+ s.portal = "https://gis.example.org/portal".into();
+ prefill(&mut s);
+ assert!(s.client_id.is_empty());
+ // One typed is kept, whichever the portal.
+ s.client_id = "mine".into();
+ s.portal.clear();
+ prefill(&mut s);
+ assert_eq!(s.client_id, "mine");
+ }
+}
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 5ed128f27..dc9e568a6 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -741,6 +741,10 @@ fn add_service(app: &mut RustGisApp, ui: &mut egui::Ui) -> bool {
ui.horizontal(|ui| {
connect |= crate::widgets::dialog_button(ui, tl!("Connect"), Some(t.accent), vec2(96.0, 28.0)).clicked();
});
+ // A secured ArcGIS service: sign in to its portal first.
+ if crate::arcgisui::wanted(app) {
+ crate::arcgisui::section(app, ui);
+ }
if connect {
let url = app.dialogs.text.trim().to_string();
app.dialogs.error = None;
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 00445bda9..16c664263 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -11,6 +11,7 @@ pub mod i18n;
#[cfg(not(target_arch = "wasm32"))]
mod agentthreads;
mod analysispane;
+mod arcgisui;
#[cfg(not(target_arch = "wasm32"))]
mod assistantkeys;
mod assistantpane;
@@ -239,6 +240,10 @@ pub struct Services {
/// Makes an HTTP request (the AI assistant's calls to its model) and puts the response, of any
/// status, in the slot (the browser's fetch; the desktop sends it on a thread).
pub http: Option>)>>,
+ /// Signs in to an ArcGIS portal (its address and an OAuth client id, either blank for ArcGIS
+ /// Online and RustGIS's own app) in a pop-up window, putting who signed in, or why not, in the
+ /// slot (the browser; the desktop runs `arcgis.signIn`).
+ pub arcgis_sign_in: Option>)>>,
}
/// A new private folder for one print job: unique, created fresh (never an existing path) and,
@@ -611,6 +616,8 @@ pub struct UiState {
pub embeddings: embeddingsui::State,
/// NASA Earthaccess: the dataset, the granules found and the request under way.
pub earthaccess: earthaccessui::State,
+ /// Add From Service › Sign in to ArcGIS.
+ pub arcgis: arcgisui::State,
/// USGS NLDI: the point traced, its COMID, the navigation and the request under way.
pub nldi: nldiui::State,
/// Find Place searches and Geocode Addresses runs under way.
@@ -743,6 +750,7 @@ impl Default for UiState {
opendata: Default::default(),
embeddings: Default::default(),
earthaccess: Default::default(),
+ arcgis: Default::default(),
nldi: Default::default(),
geocode: Default::default(),
nav: Default::default(),
diff --git a/docs/user-guide/data-formats.md b/docs/user-guide/data-formats.md
index 1ebcff760..a7ee78ff4 100644
--- a/docs/user-guide/data-formats.md
+++ b/docs/user-guide/data-formats.md
@@ -198,6 +198,38 @@ and the same documents to `service.add` to distinguish layers with the same name
follows at most four documents and rejects loops and disallowed addresses. Collection lists
aren't paged. On the desktop, feature downloads follow their `next` pages (see the table above).
+### Secured ArcGIS services
+
+A secured ArcGIS service answers error 499, "Token Required". There are two ways in:
+
+- **Sign in to ArcGIS** (in Add From Service, shown for ArcGIS addresses; `arcgis.signIn`) signs in
+ to ArcGIS Online, an ArcGIS Online organization (`https://myorg.maps.arcgis.com`) or an ArcGIS
+ Enterprise portal (`https://gis.example.org/portal`) on the portal's own page, with its single
+ sign-on and multi-factor checks: RustGIS never sees the password. It uses OAuth 2.0 with PKCE.
+ ArcGIS Online needs no setup: leave **Client ID** blank and RustGIS's own app is used. An
+ Enterprise portal needs the client ID of an app registered on it (**Content › New item ›
+ Developer credentials › OAuth 2.0 credentials**) whose redirect URLs list RustGIS's callback:
+ `http://127.0.0.1:7290/arcgis/callback` for the desktop app and the command line (give
+ `"port"` to `arcgis.signIn` for another port, and register that address instead), and
+ `oauth-callback.html` next to the web app's page for the browser (for example
+ `https://web.rustgis.app/oauth-callback.html`), where the sign-in opens in a pop-up window.
+ ArcGIS accepts only an address the app registers, port and path included. The portal and
+ client ID are remembered; the session is not.
+- **A token with the address**: a service address given with `?token=…` (from the portal, or
+ `arcgis.token` with `"url"` and `"token"`) keeps the token for that ArcGIS Server and takes it
+ out of the address the layer keeps.
+
+A token is sent (in the `X-Esri-Authorization` header; in the browser, as the request's `token`
+parameter, since ArcGIS's query endpoints refuse the header from web pages) only where its session
+reaches: the portal
+and, for ArcGIS Online, the organization's own hosted services (`services*.arcgis.com//…`,
+`tiles.arcgis.com/tiles//…`), or, for ArcGIS Enterprise, the portal's host and its
+federated servers; a token given with an address goes to that server alone. Its layers, tiles and
+feature pages are then read with it. Tokens live in memory for the session, never in a project or
+a connection, and a sign-in renews itself until the portal's session ends (two weeks at most).
+**Sign out** (`arcgis.signOut`) forgets it (and, on the desktop, revokes it with the portal); `arcgis.status` lists
+the sessions.
+
### Catalogues
The same dialog's **Catalogue search (CSW)** type searches a CSW 2.0.2 catalogue (GeoNetwork,