From 71f9be2ae0fb92155e6bbd66e6207ec4278115ba Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 5 Oct 2026 12:18:56 -0700 Subject: [PATCH] Use authenticated Astheno user-info and hide guests from the default Users list Accept Astheno OAuth access-token responses without an ID token and use the authenticated user-info subject. Validate every ID token supplied and keep signature, issuer, audience, and optional time checks on signed user-info. Add independently signed user-info coverage. Keep guest accounts in a separate Users filter, hidden by default. Assisted-by: gpt-6 --- dashboard/src/guest.rs | 71 +++++++++++++++++++++++----------- dashboard/tests/guest-jwt.json | 13 +++++++ dashboard/web/pages/Users.tsx | 5 ++- 3 files changed, 65 insertions(+), 24 deletions(-) diff --git a/dashboard/src/guest.rs b/dashboard/src/guest.rs index ac670598fc8c088c5a27f8d9cba5b8ff5d84baa7..60be1d70349053da8f8cdd86b0ba99140a64fbdf 100644 --- a/dashboard/src/guest.rs +++ b/dashboard/src/guest.rs @@ -156,16 +156,12 @@ fn signed_claims( keys: &Value, client: &str, nonce: &str, - require_nonce: bool, + id_token: bool, ) -> Result { let reject = |reason: &str| { eprintln!( "guest token verification: {} {reason}", - if require_nonce { - "id_token" - } else { - "userinfo" - } + if id_token { "id_token" } else { "userinfo" } ); Error::new( 502, @@ -272,11 +268,13 @@ fn signed_claims( ), ( "expiration", - claims["exp"].as_i64().is_none_or(|t| t <= time), + (id_token || claims.get("exp").is_some()) + && claims["exp"].as_i64().is_none_or(|t| t <= time), ), ( "issued_at", - claims["iat"].as_i64().is_none_or(|t| t > time + 60), + (id_token || claims.get("iat").is_some()) + && claims["iat"].as_i64().is_none_or(|t| t > time + 60), ), ( "not_before", @@ -284,10 +282,7 @@ fn signed_claims( .get("nbf") .is_some_and(|t| t.as_i64().is_none_or(|n| n > time + 60)), ), - ( - "nonce", - require_nonce && claims["nonce"].as_str() != Some(nonce), - ), + ("nonce", id_token && claims["nonce"].as_str() != Some(nonce)), ] { if invalid { return Err(reject(reason)); @@ -387,14 +382,14 @@ async fn exchange( } let keys = json_bytes(&response_bytes(http.get(format!("{ASTHENO}/api/jwks")).send().await?).await?)?; - let claims = signed_claims( - string(&token["id_token"]), - &keys, - client, - string(&flow["nonce"]), - true, - )?; - if let Some(hash) = claims["at_hash"].as_str() { + let claims = token + .get("id_token") + .map(|token| signed_claims(string(token), &keys, client, string(&flow["nonce"]), true)) + .transpose()?; + if let Some(hash) = claims + .as_ref() + .and_then(|claims| claims["at_hash"].as_str()) + { if hash != URL_SAFE_NO_PAD .encode(&Sha256::digest(string(&token["access_token"]).as_bytes())[..16]) @@ -417,7 +412,12 @@ async fn exchange( } else { signed_claims(std::str::from_utf8(&bytes)?, &keys, client, "", false)? }; - if profile["sub"] != claims["sub"] { + if string(&profile["sub"]).is_empty() + || string(&profile["sub"]).len() > 512 + || claims + .as_ref() + .is_some_and(|claims| profile["sub"] != claims["sub"]) + { return Err(Error::new( 502, "Astheno couldn't verify your account. Return to Shale and try again.", @@ -430,7 +430,7 @@ async fn exchange( .chars() .take(128) .collect(); - Ok((string(&claims["sub"]).to_owned(), name)) + Ok((string(&profile["sub"]).to_owned(), name)) } fn account(auth: &auth::Store, provider: &str, subject: &str, name: &str) -> Result { @@ -738,6 +738,33 @@ mod tests { ); } + #[test] + fn signed_userinfo_requires_identity_and_signature_but_not_id_token_times_or_nonce() { + let vector: Value = serde_json::from_str(include_str!("../tests/guest-jwt.json")).unwrap(); + let userinfo = &vector["userinfo"]; + let token = string(&userinfo["token"]); + assert_eq!( + signed_claims(token, &userinfo["keys"], "fixture", "", false).unwrap()["sub"], + "external-123" + ); + assert!(signed_claims(token, &userinfo["keys"], "fixture", "fixture-nonce", true).is_err()); + assert!(signed_claims(token, &userinfo["keys"], "other", "", false).is_err()); + assert!(signed_claims(token, &vector["keys"], "fixture", "", false).is_err()); + for reason in ["issuer", "audience", "expiry", "future", "subject"] { + assert!( + signed_claims( + string(&vector["invalid"][reason]), + &vector["keys"], + "fixture", + "", + false + ) + .is_err(), + "{reason}" + ); + } + } + #[test] fn identities_never_link_by_name_or_email_and_cannot_gain_credentials_or_groups() { let path = std::env::temp_dir().join(format!("guest-test-{}", uuid::Uuid::new_v4())); diff --git a/dashboard/tests/guest-jwt.json b/dashboard/tests/guest-jwt.json index 64b8fb4758cdcacc6b002791824c4e388f471500..1c01f8bb50fff9e0797944b5598c4dd2b2f73604 100644 --- a/dashboard/tests/guest-jwt.json +++ b/dashboard/tests/guest-jwt.json @@ -35,5 +35,18 @@ } ] } + }, + "userinfo": { + "token": "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJhdWQiOiJmaXh0dXJlIiwic3ViIjoiZXh0ZXJuYWwtMTIzIn0.9PwCTS9WYNerPhhXjvdNZjebCwjzEcbjnI98VrY4-aL7UPH7nSDzeoNi7if4o9ceMQpAqeyl75JOKzzywuNxNg", + "keys": { + "keys": [ + { + "kty": "EC", + "crv": "P-256", + "x": "U_HQ0pKE0R11g6ppRz4J03q53Ogdimnc87z4E5rf9cM", + "y": "De8q4fbKuTMicx5uJFeM_qz-g_zoZxWxTN-sKJpyHbc" + } + ] + } } } diff --git a/dashboard/web/pages/Users.tsx b/dashboard/web/pages/Users.tsx index a39a94c0ec4f64f8f32ab93d42069e16b28f2b5f..203231cd8fe1fe334e29124fd84df619b2e7c2c7 100644 --- a/dashboard/web/pages/Users.tsx +++ b/dashboard/web/pages/Users.tsx @@ -25,7 +25,7 @@ import "./Users.css"; const STEPS: [string, string][] = [["SETUP", "finish setup"], ["UPDATE_PASSWORD", "new password"], ["UPDATE_PROFILE", "check profile"]]; -const STATES = { all: "all", disabled: "disabled", pending: "setup pending" } as const; +const STATES = { all: "users", guests: "guests", disabled: "disabled", pending: "setup pending" } as const; type Params = { q?: string; group?: string; state?: keyof typeof STATES }; @@ -39,7 +39,8 @@ type User = Data["users"][number]; const fullName = (user: User) => [user.firstName, user.lastName].filter(Boolean).join(" "); const names = (user: User) => user.groups.map((group) => group.name); const inState = (user: User, state: keyof typeof STATES) => - state === "all" || (state === "disabled" ? !user.enabled : user.enabled && user.requiredActions.length > 0); + state === "guests" ? user.kind === "guest" : user.kind !== "guest" && + (state === "all" || (state === "disabled" ? !user.enabled : user.enabled && user.requiredActions.length > 0)); /** The apps a set of groups opens, and dashboard pages; `everything` when nothing is out of reach. */ function reach(groups: string[], services: ServiceSummary[]) { -- 2.54.0