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,