authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 12:18:56-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 15:36:02-07:00
log71f9be2ae0fb92155e6bbd66e6207ec4278115ba
tree016c305f93d84a488c0f3f6e32f4aee642051cf4
parentb299d590ed68d0c8d7bece74fb2c69e5e0f7f287
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

3 files changed, 65 insertions(+), 24 deletions(-)

dashboard/src/guest.rs+49-22
......@@ -156,16 +156,12 @@ fn signed_claims(
156156 keys: &Value,
157157 client: &str,
158158 nonce: &str,
159 require_nonce: bool,
159 id_token: bool,
160160) -> Result<Value> {
161161 let reject = |reason: &str| {
162162 eprintln!(
163163 "guest token verification: {} {reason}",
164 if require_nonce {
165 "id_token"
166 } else {
167 "userinfo"
168 }
164 if id_token { "id_token" } else { "userinfo" }
169165 );
170166 Error::new(
171167 502,
......@@ -272,11 +268,13 @@ fn signed_claims(
272268 ),
273269 (
274270 "expiration",
275 claims["exp"].as_i64().is_none_or(|t| t <= time),
271 (id_token || claims.get("exp").is_some())
272 && claims["exp"].as_i64().is_none_or(|t| t <= time),
276273 ),
277274 (
278275 "issued_at",
279 claims["iat"].as_i64().is_none_or(|t| t > time + 60),
276 (id_token || claims.get("iat").is_some())
277 && claims["iat"].as_i64().is_none_or(|t| t > time + 60),
280278 ),
281279 (
282280 "not_before",
......@@ -284,10 +282,7 @@ fn signed_claims(
284282 .get("nbf")
285283 .is_some_and(|t| t.as_i64().is_none_or(|n| n > time + 60)),
286284 ),
287 (
288 "nonce",
289 require_nonce && claims["nonce"].as_str() != Some(nonce),
290 ),
285 ("nonce", id_token && claims["nonce"].as_str() != Some(nonce)),
291286 ] {
292287 if invalid {
293288 return Err(reject(reason));
......@@ -387,14 +382,14 @@ async fn exchange(
387382 }
388383 let keys =
389384 json_bytes(&response_bytes(http.get(format!("{ASTHENO}/api/jwks")).send().await?).await?)?;
390 let claims = signed_claims(
391 string(&token["id_token"]),
392 &keys,
393 client,
394 string(&flow["nonce"]),
395 true,
396 )?;
397 if let Some(hash) = claims["at_hash"].as_str() {
385 let claims = token
386 .get("id_token")
387 .map(|token| signed_claims(string(token), &keys, client, string(&flow["nonce"]), true))
388 .transpose()?;
389 if let Some(hash) = claims
390 .as_ref()
391 .and_then(|claims| claims["at_hash"].as_str())
392 {
398393 if hash
399394 != URL_SAFE_NO_PAD
400395 .encode(&Sha256::digest(string(&token["access_token"]).as_bytes())[..16])
......@@ -417,7 +412,12 @@ async fn exchange(
417412 } else {
418413 signed_claims(std::str::from_utf8(&bytes)?, &keys, client, "", false)?
419414 };
420 if profile["sub"] != claims["sub"] {
415 if string(&profile["sub"]).is_empty()
416 || string(&profile["sub"]).len() > 512
417 || claims
418 .as_ref()
419 .is_some_and(|claims| profile["sub"] != claims["sub"])
420 {
421421 return Err(Error::new(
422422 502,
423423 "Astheno couldn't verify your account. Return to Shale and try again.",
......@@ -430,7 +430,7 @@ async fn exchange(
430430 .chars()
431431 .take(128)
432432 .collect();
433 Ok((string(&claims["sub"]).to_owned(), name))
433 Ok((string(&profile["sub"]).to_owned(), name))
434434}
435435
436436fn account(auth: &auth::Store, provider: &str, subject: &str, name: &str) -> Result<String> {
......@@ -738,6 +738,33 @@ mod tests {
738738 );
739739 }
740740
741 #[test]
742 fn signed_userinfo_requires_identity_and_signature_but_not_id_token_times_or_nonce() {
743 let vector: Value = serde_json::from_str(include_str!("../tests/guest-jwt.json")).unwrap();
744 let userinfo = &vector["userinfo"];
745 let token = string(&userinfo["token"]);
746 assert_eq!(
747 signed_claims(token, &userinfo["keys"], "fixture", "", false).unwrap()["sub"],
748 "external-123"
749 );
750 assert!(signed_claims(token, &userinfo["keys"], "fixture", "fixture-nonce", true).is_err());
751 assert!(signed_claims(token, &userinfo["keys"], "other", "", false).is_err());
752 assert!(signed_claims(token, &vector["keys"], "fixture", "", false).is_err());
753 for reason in ["issuer", "audience", "expiry", "future", "subject"] {
754 assert!(
755 signed_claims(
756 string(&vector["invalid"][reason]),
757 &vector["keys"],
758 "fixture",
759 "",
760 false
761 )
762 .is_err(),
763 "{reason}"
764 );
765 }
766 }
767
741768 #[test]
742769 fn identities_never_link_by_name_or_email_and_cannot_gain_credentials_or_groups() {
743770 let path = std::env::temp_dir().join(format!("guest-test-{}", uuid::Uuid::new_v4()));
dashboard/tests/guest-jwt.json+13
......@@ -35,5 +35,18 @@
3535 }
3636 ]
3737 }
38 },
39 "userinfo": {
40 "token": "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJhdWQiOiJmaXh0dXJlIiwic3ViIjoiZXh0ZXJuYWwtMTIzIn0.9PwCTS9WYNerPhhXjvdNZjebCwjzEcbjnI98VrY4-aL7UPH7nSDzeoNi7if4o9ceMQpAqeyl75JOKzzywuNxNg",
41 "keys": {
42 "keys": [
43 {
44 "kty": "EC",
45 "crv": "P-256",
46 "x": "U_HQ0pKE0R11g6ppRz4J03q53Ogdimnc87z4E5rf9cM",
47 "y": "De8q4fbKuTMicx5uJFeM_qz-g_zoZxWxTN-sKJpyHbc"
48 }
49 ]
50 }
3851 }
3952}
dashboard/web/pages/Users.tsx+3-2
......@@ -25,7 +25,7 @@ import "./Users.css";
2525
2626const STEPS: [string, string][] = [["SETUP", "finish setup"], ["UPDATE_PASSWORD", "new password"], ["UPDATE_PROFILE", "check profile"]];
2727
28const STATES = { all: "all", disabled: "disabled", pending: "setup pending" } as const;
28const STATES = { all: "users", guests: "guests", disabled: "disabled", pending: "setup pending" } as const;
2929
3030type Params = { q?: string; group?: string; state?: keyof typeof STATES };
3131
......@@ -39,7 +39,8 @@ type User = Data["users"][number];
3939const fullName = (user: User) => [user.firstName, user.lastName].filter(Boolean).join(" ");
4040const names = (user: User) => user.groups.map((group) => group.name);
4141const inState = (user: User, state: keyof typeof STATES) =>
42 state === "all" || (state === "disabled" ? !user.enabled : user.enabled && user.requiredActions.length > 0);
42 state === "guests" ? user.kind === "guest" : user.kind !== "guest" &&
43 (state === "all" || (state === "disabled" ? !user.enabled : user.enabled && user.requiredActions.length > 0));
4344
4445/** The apps a set of groups opens, and dashboard pages; `everything` when nothing is out of reach. */
4546function reach(groups: string[], services: ServiceSummary[]) {