From 56a3dca1874fba17c3d174be451af9478b3bfb9d Mon Sep 17 00:00:00 2001 From: tv Date: Sat, 7 Nov 2026 22:35:22 +0100 Subject: dashboard: a token handed over to another member's dashboard Opening another member's dashboard passes a token reading only, for five minutes, in the URL's fragment; its login page begins a session with it. Tokens may be asked for with a shorter lifetime. Auth plan M3 (hand-off). --- docs/access.md | 4 +++- docs/dashboard.md | 6 ++++-- docs/openapi.yaml | 3 +++ src/api/dashboard/dashboard.html | 17 ++++++++++++++--- src/api/dashboard/login.html | 18 ++++++++++++++++++ src/api/login.rs | 26 ++++++++++++++++++++------ 6 files changed, 62 insertions(+), 12 deletions(-) diff --git a/docs/access.md b/docs/access.md index 44c89f3..1ee9994 100644 --- a/docs/access.md +++ b/docs/access.md @@ -99,7 +99,9 @@ curl -H "Authorization: Bearer ct1.…" http://other-node:3000/store a capability granted or revoked counts on a node as soon as the change reaches it. * **How long**: `--token-lifetime` (12 hours), refreshed (`POST /auth/refresh`, on any node, which signs the new one) until `--session-lifetime` (7 days) after the login; - then the user logs in again. A refreshed token may say less (`limit`), never more. + then the user logs in again. A refreshed token may say less (`limit`), never more, + and live less (`lifetime`, in seconds): a dashboard opening another member's hands it + one reading only, for five minutes ([`dashboard.md`](dashboard.md)). * **Revoking**: a user disabled or removed is refused at once; all of a user's tokens, by the user (`POST /auth/logout`) or by one who may manage users (`DELETE /admin/users/{name}/tokens`), which sets `_continuum/logouts/`: tokens issued diff --git a/docs/dashboard.md b/docs/dashboard.md index 271592a..322abc6 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -200,8 +200,10 @@ measured. * **Right-click a node**: its menu: subscribing, and opening its dashboard. * **Open a node's dashboard** (its menu): in this page, where the node publishes one (`--dashboard-url`, in the member list; e.g. behind a reverse proxy, - [`operations.md`](operations.md)). The other dashboard asks for its own credentials - where its proxy or node does. It opens as this one was left: the map's nodes where + [`operations.md`](operations.md)). A token from this node goes along, reading only, + for five minutes: where the browser has no session at the other dashboard, its login + page begins one with it (reading only; log in there to do more), unless its proxy asks + for credentials of its own. It opens as this one was left: the map's nodes where they were (on a dynamic map they then move to their places among the other node's links, smoothly; new ones are placed among them), the view, the pins, what is in focus, the legend shown or hidden, what is open in the figures and in the document, diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9b75618..d3715b1 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1962,6 +1962,9 @@ components: limit: type: array items: { $ref: '#/components/schemas/CapabilityRequest' } + lifetime: + type: integer + description: Seconds the token lives, if less than `token-lifetime` (a token to hand over, say). TokenAnswer: type: object properties: diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index fa4d436..17ff44b 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -2058,7 +2058,7 @@ if (handOver) { // The fragment that opens another node's dashboard (`id`'s) as this page is now // (`handOver`). What is in focus stays, but `id` itself: its page is its own. Also this // node's own page, reloaded for a new version (`id` null). -function handOff(id) { +function handOff(id, handed) { const settings = {}; try { for (let i = 0; i < localStorage.length; i++) { @@ -2076,6 +2076,9 @@ function handOff(id) { legend: !document.getElementById("legends").hidden, figures: [...open], document: [...documentOpen], + // A token reading only, for five minutes: the other node's login page begins a + // session with it (`login.html`), where the browser has none there. + token: handed || undefined, })); } // `[x, y]` turned by `angle`. @@ -2446,11 +2449,19 @@ addEventListener("keydown", event => { // opened in this page; else its halo flashes red, larger than usual, and "no dashboard" // rises from it in the same red, both fading out. This node's own: here already. const FLASH_MS = 1200; -function openDashboard(id) { +const READ_ALL = [{ path: [], rights: ["read"] }, { path: ["_continuum"], rights: ["read"] }]; +async function openDashboard(id) { const member = state.graph.members[id]; if (!member || member.self) return; if (member.dashboard) { - location.assign(member.dashboard.split("#")[0] + handOff(id)); + let handed = null; + if (token) { + try { + handed = (await tokenCall("../auth/refresh", token.token, + { limit: READ_ALL, lifetime: 300 })).token; + } catch (_) { /* the other dashboard asks for a login then */ } + } + location.assign(member.dashboard.split("#")[0] + handOff(id, handed)); return; } const layer = document.getElementById("flashes"); diff --git a/src/api/dashboard/login.html b/src/api/dashboard/login.html index 77668ee..e09d31c 100644 --- a/src/api/dashboard/login.html +++ b/src/api/dashboard/login.html @@ -73,6 +73,24 @@ document.getElementById("token").addEventListener("submit", async event => { why.textContent = ""; try { await begin(event.target.token.value.trim()); } catch (error) { failed(error); } }); +// Opened from another member's dashboard (`handOff`), sent here for want of a session: +// its token, reading only, begins one; the rest goes on to the dashboard. +(async () => { + const HAND_OFF = "#continuum="; + if (!location.hash.startsWith(HAND_OFF)) return; + const fragment = location.hash; + try { history.replaceState(null, "", location.pathname + location.search); } catch (_) {} + let handed; + try { handed = JSON.parse(decodeURIComponent(fragment.slice(HAND_OFF.length))); } catch (_) { return; } + if (!handed || typeof handed.token !== "string") return; + try { + await call("auth/session", "POST", "Bearer " + handed.token); + delete handed.token; + location.replace("admin/dashboard" + HAND_OFF + encodeURIComponent(JSON.stringify(handed))); + } catch (error) { + why.textContent = "The token handed over isn't taken (" + error.message + "): log in."; + } +})(); document.getElementById("out").addEventListener("click", async () => { await fetch("auth/session", { method: "DELETE", credentials: "same-origin" }).catch(() => {}); why.textContent = "Logged out of this browser."; diff --git a/src/api/login.rs b/src/api/login.rs index 1907bac..3b4ee32 100644 --- a/src/api/login.rs +++ b/src/api/login.rs @@ -146,6 +146,9 @@ pub(crate) async fn bearer( pub(crate) struct LimitRequest { #[serde(default)] limit: Option>, + /// A shorter life than `--token-lifetime`, in seconds: a token to hand over, say. + #[serde(default)] + lifetime: Option, } impl LimitRequest { @@ -209,7 +212,8 @@ pub(crate) async fn login( Some("log in with a password or through the proxy; a token is refreshed at /auth/refresh".to_owned()), )); } - let limit = body.map(|Json(body)| body).unwrap_or_default().limit()?; + let body = body.map(|Json(body)| body).unwrap_or_default(); + let limit = body.limit()?; let (swarm, now) = issuing(&state).await?; { let replica = state.doc.read().await; @@ -222,11 +226,17 @@ pub(crate) async fn login( )); } } - let claims = new_login(&state, swarm, now, &user.0, via.0, limit); + let claims = new_login(&state, swarm, now, &user.0, via.0, limit, body.lifetime); info!(user = %user.0, login = via.0, session = %claims.session, "Logged in"); answer(&state, &claims) } +/// How long a token lives: `--token-lifetime`, or less if `asked` (seconds) is less. +fn lifetime_ms(state: &AppState, asked: Option) -> u64 { + let lifetime = state.token_lifetime.as_millis() as u64; + asked.map_or(lifetime, |secs| secs.saturating_mul(1000).min(lifetime)) +} + /// The claims of a new login of `user`, by `login`, now: a session of its own. fn new_login( state: &AppState, @@ -235,6 +245,7 @@ fn new_login( user: &str, login: &str, limit: Option>, + lifetime: Option, ) -> Claims { let session_exp = now.saturating_add(state.session_lifetime.as_millis() as u64); Claims { @@ -245,7 +256,7 @@ fn new_login( login: login.to_owned(), iat: now, exp: now - .saturating_add(state.token_lifetime.as_millis() as u64) + .saturating_add(lifetime_ms(state, lifetime)) .min(session_exp), session: token::new_session(), session_exp, @@ -292,6 +303,7 @@ pub(crate) async fn login_key( ) -> Result { let limit = LimitRequest { limit: request.limit, + lifetime: None, } .limit()?; let (swarm, now) = issuing(&state).await?; @@ -322,7 +334,7 @@ pub(crate) async fn login_key( ApiError(StatusCode::UNAUTHORIZED, Some(why)) })?; } - let claims = new_login(&state, swarm, now, &request.user, "key", limit); + let claims = new_login(&state, swarm, now, &request.user, "key", limit, None); info!(user = %request.user, login = "key", session = %claims.session, "Logged in"); answer(&state, &claims) } @@ -396,7 +408,9 @@ async fn refreshed( Some("the login ended: log in again".to_owned()), )); } - let limit = match body.map(|Json(body)| body).unwrap_or_default().limit()? { + let body = body.map(|Json(body)| body).unwrap_or_default(); + let lifetime = body.lifetime; + let limit = match body.limit()? { None => earlier.limit.clone(), Some(limit) => { if earlier @@ -418,7 +432,7 @@ async fn refreshed( issuer: state.local_id().to_owned(), iat: now, exp: now - .saturating_add(state.token_lifetime.as_millis() as u64) + .saturating_add(lifetime_ms(state, lifetime)) .min(earlier.session_exp), limit, ..(**earlier).clone() -- cgit v1.3.1