authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 09:09:40-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 15:36:02-07:00
logc3d894fed29df8d1a864de6a5bd1d0fda57882f5
tree082baddbd76ecb82cb690e2a289cdba37808c1b3
parentb62d003aab62b3dafb0dd9b662a2996a526b38ba
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

Accept Astheno signing keys without key IDs and simplify guest buttons

Verify the unique compatible P-256 key when the signed header omits kid. Keep signature and claim checks and refuse ambiguous keys. Show the providers as two existing-theme buttons. Assisted-by: gpt-6

4 files changed, 70 insertions(+), 17 deletions(-)

dashboard/src/guest.rs+55-14
......@@ -148,7 +148,10 @@ fn signed_claims(
148148 let header: Value =
149149 serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[0]).map_err(|_| reject())?)
150150 .map_err(|_| reject())?;
151 if header["alg"] != "ES256"
151 if header
152 .get("kid")
153 .is_some_and(|id| id.as_str().is_none_or(str::is_empty))
154 || header["alg"] != "ES256"
152155 || header.get("crit").is_some()
153156 || header.get("jku").is_some()
154157 || header.get("jwk").is_some()
......@@ -158,8 +161,9 @@ fn signed_claims(
158161 let matching: Vec<_> = array(&keys["keys"])
159162 .iter()
160163 .filter(|key| {
161 key["kid"].is_string()
162 && key["kid"] == header["kid"]
164 header
165 .get("kid")
166 .is_none_or(|id| key.get("kid") == Some(id))
163167 && key["kty"] == "EC"
164168 && key["crv"] == "P-256"
165169 && key.get("alg").is_none_or(|v| v == "ES256")
......@@ -544,17 +548,23 @@ async fn handle(app: &App, request: Request) -> Result<Response> {
544548 ],
545549 )
546550 .into_response()),
547 Err(_) => Ok((
548 StatusCode::FOUND,
549 [
550 (
551 "location",
552 format!("/sign-in?next={}&guest_error=1", encoded(&next)),
553 ),
554 ("set-cookie", auth::set_cookie(COOKIE, "", 0)),
555 ],
556 )
557 .into_response()),
551 Err(error) => {
552 eprintln!(
553 "guest callback {provider}: {}: {}",
554 error.status, error.message
555 );
556 Ok((
557 StatusCode::FOUND,
558 [
559 (
560 "location",
561 format!("/sign-in?next={}&guest_error=1", encoded(&next)),
562 ),
563 ("set-cookie", auth::set_cookie(COOKIE, "", 0)),
564 ],
565 )
566 .into_response())
567 }
558568 }
559569}
560570
......@@ -585,6 +595,37 @@ mod tests {
585595 let claims =
586596 signed_claims(token, &vector["keys"], "fixture", "fixture-nonce", true).unwrap();
587597 assert_eq!(claims["sub"], "external-123");
598 let unnamed = &vector["withoutKid"];
599 let unnamed_token = string(&unnamed["token"]);
600 assert_eq!(
601 signed_claims(
602 unnamed_token,
603 &unnamed["keys"],
604 "fixture",
605 "fixture-nonce",
606 true
607 )
608 .unwrap()["sub"],
609 "external-123"
610 );
611 let mut ambiguous = unnamed["keys"].clone();
612 ambiguous["keys"]
613 .as_array_mut()
614 .unwrap()
615 .push(unnamed["keys"]["keys"][0].clone());
616 assert!(
617 signed_claims(unnamed_token, &ambiguous, "fixture", "fixture-nonce", true).is_err()
618 );
619 assert!(
620 signed_claims(
621 unnamed_token,
622 &vector["keys"],
623 "fixture",
624 "fixture-nonce",
625 true
626 )
627 .is_err()
628 );
588629 for (name, token) in vector["invalid"].as_object().unwrap() {
589630 assert!(
590631 signed_claims(
dashboard/tests/guest-jwt.json+13
......@@ -22,5 +22,18 @@
2222 "subject": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiIiLCJhdWQiOiJmaXh0dXJlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsIm5vbmNlIjoiZml4dHVyZS1ub25jZSJ9.LvYyzvg0HAzhAWKo76lWyPktuoxSdmXySfXjiIWea40O0fdqGa-Du73lHzTLUJ3BDS5yWd5gF3doHbYrhbL4TA",
2323 "authorizedParty": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiJleHRlcm5hbC0xMjMiLCJhdWQiOiJmaXh0dXJlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsIm5vbmNlIjoiZml4dHVyZS1ub25jZSIsImF6cCI6Im90aGVyIn0.W8C8sSVCpLn32JAZ6VKoJyUV9Ew_-eBsatAtynsnzJpj8l28sKv_bo8lGPq5Ef3HTxiqEGtgdWRR1wO92k_-Tw",
2424 "multipleAudience": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiJleHRlcm5hbC0xMjMiLCJhdWQiOlsiZml4dHVyZSIsIm90aGVyIl0sImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo0MTAyNDQ0ODAwLCJub25jZSI6ImZpeHR1cmUtbm9uY2UifQ.D3HM97jMyMnqatXGcjDRopQIYe-ZKjzMuvVNt5dkqFW-tqXKXJlNXGd1hfpCc8XIdK4pL_iRaYwIbnMnVfZoQA"
25 },
26 "withoutKid": {
27 "token": "eyJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiJleHRlcm5hbC0xMjMiLCJhdWQiOiJmaXh0dXJlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsIm5vbmNlIjoiZml4dHVyZS1ub25jZSJ9.EOWeGwhkb5C0H0844bjW3hfieiTusaMAnGsrXjR9xZsVA59uy-zggNrWBQWf6v_eyb2aWZDNwMDcVo240GPyGQ",
28 "keys": {
29 "keys": [
30 {
31 "kty": "EC",
32 "crv": "P-256",
33 "x": "CGn5dXeweSkJttck59RKlf4xg3en2nz1wepSXpOywvU",
34 "y": "poKWPsMzUkytFzQmVtH7ps0sajMsZrCuYbeKAoOCw60"
35 }
36 ]
37 }
2538 }
2639}
dashboard/web/pages/SignIn.css+1-1
......@@ -9,5 +9,5 @@ body { font: 16px "Name Sans", sans-serif; }
99.pf-v5-c-login__main .checkbox label:has(:focus-visible) { outline: 2px solid var(--primary); }
1010.setup-intro { margin-bottom: 1rem; text-align: center; }
1111.guest-providers { margin-top: 1.5rem; }
12.guest-providers p { text-align: center; }
12.guest-providers a { display: flex; align-items: center; justify-content: center; text-decoration: none; }
1313.guest-providers a + a { margin-top: .75rem; }
dashboard/web/pages/SignIn.tsx+1-2
......@@ -110,8 +110,7 @@ export function SignIn() {
110110 <Show when={!setup}><a id="authenticateWebAuthnButton" href="#" class="pf-v5-c-button pf-m-secondary pf-m-block" aria-disabled={busy() || !status()} onClick={(event) => { event.preventDefault(); void passkey(); }}>sign in with passkey</a></Show>
111111 <Show when={!setup && status()?.providers?.length}>
112112 <div id="kc-social-providers" class="guest-providers">
113 <p>sign in as a Shale guest</p>
114 <For each={status()?.providers}>{(provider) => <a rel="external" class="pf-v5-c-button pf-m-secondary pf-m-block" href={`/auth/guest/start/${provider.id}?next=${encodeURIComponent(params.get("next") ?? "")}`}>continue with {provider.name}</a>}</For>
113 <For each={status()?.providers}>{(provider) => <a rel="external" class="pf-v5-c-button pf-m-primary pf-m-block" href={`/auth/guest/start/${provider.id}?next=${encodeURIComponent(params.get("next") ?? "")}`}>continue with {provider.name}</a>}</For>
115114 </div>
116115 </Show>
117116 </Show>