Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/rustgis-web/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
1 change: 1 addition & 0 deletions apps/rustgis-web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<link rel="icon" href="https://assets.rustgis.app/images/favicon.png">
<!-- Installable as an app (docs/installation.md › Install the web app). -->
<link data-trunk rel="copy-file" href="manifest.webmanifest" />
<link data-trunk rel="copy-file" href="oauth-callback.html" />
<link data-trunk rel="copy-file" href="../../LICENSE" />
<link data-trunk rel="copy-file" href="../../THIRD-PARTY-LICENSES.txt" />
<link rel="manifest" href="manifest.webmanifest">
Expand Down
15 changes: 15 additions & 0 deletions apps/rustgis-web/oauth-callback.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<title>RustGIS: signing in</title>
</head>
<!-- An ArcGIS sign-in comes back here: the RustGIS window that opened this one reads this
page's address (the portal's answer) and closes it. Nothing here runs or loads anything. -->
<body style="font-family: sans-serif; max-width: 32em; margin: 4em auto">
<h1>Signing in to ArcGIS…</h1>
<p>RustGIS is finishing the sign-in. This window closes by itself.</p>
</body>
</html>
196 changes: 196 additions & 0 deletions apps/rustgis-web/src/arcgis.rs
Original file line number Diff line number Diff line change
@@ -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<bool> = const { Cell::new(false) };
/// A renewal is under way: the fetches meanwhile use the token still valid.
static RENEWING: Cell<bool> = 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<String, String> {
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<String, String> {
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<Vec<u8>, 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::<usize>().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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

redirect() drops the last path segment unconditionally. If the app is served at a path with no trailing slash and no file name (e.g. https://host/app), pathname is /app, so dir is "" and the redirect becomes https://host/oauth-callback.html, which is outside the app's directory. The portal then rejects it because it isn't a registered redirect URL, or the callback page 404s. The …/index.html and …/ cases work. Low confidence this is hit in practice: it only matters for deployments under a sub-path without a trailing slash. Consider treating a final segment without a . as a directory.

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<Result<String, String>>, ctx: egui::Context) {
let put = move |r: Result<String, String>| {
*slot.lock().unwrap_or_else(|e| e.into_inner()) = Some(r);
ctx.request_repaint();
Comment thread
giswqs marked this conversation as resolved.
};
if SIGNING_IN.get() {
return put(Err("a sign-in is already open: finish it in its window, or close that window".into()));
Comment thread
giswqs marked this conversation as resolved.
}
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<String, String> {
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();
Comment thread
giswqs marked this conversation as resolved.
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);
Comment thread
giswqs marked this conversation as resolved.
let _renewing = Renewing;
let answer = request(&r.endpoint, Some(&r.body)).await;
arcgis_auth::renewal_done(&r, answer.as_deref().map_err(String::clone));
Comment thread
giswqs marked this conversation as resolved.
}
2 changes: 2 additions & 0 deletions apps/rustgis-web/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
26 changes: 21 additions & 5 deletions apps/rustgis-web/src/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,15 @@ async fn fetch_bytes(url: &str) -> Result<Vec<u8>, 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);
Comment thread
giswqs marked this conversation as resolved.
let asked = match (range, tail) {
(Some((a, b)), _) => Some(format!("bytes={a}-{b}")),
(None, Some(n)) => Some(format!("bytes=-{n}")),
Expand All @@ -591,12 +600,17 @@ async fn fetch_bytes(url: &str) -> Result<Vec<u8>, 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())
Expand Down Expand Up @@ -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).
Expand Down
18 changes: 14 additions & 4 deletions crates/data/src/ogc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,10 +301,16 @@ pub fn parse(url: &str, body: &[u8]) -> Result<Service, String> {
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)",
Comment thread
giswqs marked this conversation as resolved.
_ => "",
};
return Err(format!("the service answered with an error: {message}{hint}"));
}
if is_arcgis(url) || v.get("currentVersion").is_some() {
return arcgis(url, &v);
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading