1use crate::*;
2use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
3use openssl::{
4 bn::{BigNum, BigNumContext},
5 ec::{EcGroup, EcKey, EcPoint},
6 ecdsa::EcdsaSig,
7 hash::MessageDigest,
8 nid::Nid,
9 pkey::PKey,
10 sign::Verifier,
11};
12use rusqlite::{Connection, OptionalExtension, params as sql};
13use sha2::{Digest, Sha256};
14
15const COOKIE: &str = "__Host-snow-guest";
16const ASTHENO: &str = "https://identity.astheno.software";
17
18pub fn is_guest(user: &Value) -> bool {
19 user["kind"] == "guest"
20}
21
22pub fn initialise(db: &Connection) -> Result<()> {
23 db.execute_batch("CREATE TABLE IF NOT EXISTS guest_providers (id TEXT PRIMARY KEY, client_id TEXT NOT NULL, secret TEXT NOT NULL);
24 CREATE TABLE IF NOT EXISTS external_identities (provider TEXT NOT NULL, subject TEXT NOT NULL, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, PRIMARY KEY(provider,subject), UNIQUE(user_id));
25 CREATE TRIGGER IF NOT EXISTS guest_no_groups BEFORE INSERT ON memberships WHEN (SELECT json_extract(profile,'$.kind') FROM users WHERE id=NEW.user_id)='guest' BEGIN SELECT RAISE(ABORT,'guest accounts cannot join groups'); END;
26 CREATE TRIGGER IF NOT EXISTS guest_no_credentials BEFORE INSERT ON credentials WHEN (SELECT json_extract(profile,'$.kind') FROM users WHERE id=NEW.user_id)='guest' BEGIN SELECT RAISE(ABORT,'guest accounts use external sign-in'); END;
27 CREATE TRIGGER IF NOT EXISTS guest_kind_fixed BEFORE UPDATE OF profile ON users WHEN json_extract(OLD.profile,'$.kind')='guest' AND coalesce(json_extract(NEW.profile,'$.kind'),'')!='guest' BEGIN SELECT RAISE(ABORT,'guest account kind is fixed'); END;")?;
28 Ok(())
29}
30
31fn known(provider: &str) -> Result<&str> {
32 match provider {
33 "github" => Ok("GitHub"),
34 "astheno" => Ok("Astheno"),
35 _ => Err(Error::new(
36 404,
37 "Choose GitHub or Astheno on the Shale sign-in page.",
38 )),
39 }
40}
41
42/// Root-owned CLI only. Secrets stay in the private accounts database and its backups.
43pub fn provision(auth: &auth::Store, input: Value) -> Result<Value> {
44 let provider = string(&input["provider"]);
45 known(provider)?;
46 let mut db = auth.db.lock().unwrap();
47 let tx = db.transaction()?;
48 tx.execute(
49 "DELETE FROM pending WHERE kind='guest' AND json_extract(data,'$.provider')=?",
50 [provider],
51 )?;
52 tx.execute("DELETE FROM sessions WHERE user_id IN (SELECT user_id FROM external_identities WHERE provider=?)", [provider])?;
53 if input["enabled"] == false {
54 tx.execute("DELETE FROM guest_providers WHERE id=?", [provider])?;
55 } else {
56 let id = string(&input["clientId"]);
57 let secret = string(&input["clientSecret"]);
58 if id.is_empty()
59 || id.len() > 256
60 || secret.len() < 16
61 || secret.len() > 2048
62 || id.chars().chain(secret.chars()).any(char::is_control)
63 {
64 return Err(Error::new(
65 400,
66 "Provide the registered client ID and secret.",
67 ));
68 }
69 tx.execute("INSERT INTO guest_providers VALUES (?,?,?) ON CONFLICT(id) DO UPDATE SET client_id=excluded.client_id,secret=excluded.secret", sql![provider,id,secret])?;
70 }
71 tx.commit()?;
72 Ok(
73 json!({"provider":provider,"enabled":input["enabled"]!=false,"callback":format!("{}auth/guest/callback/{provider}",auth.origin)}),
74 )
75}
76
77pub fn providers(auth: &auth::Store, next: &str) -> Result<Value> {
78 if oidc::guest_target(auth, next).is_err() {
79 return Ok(json!([]));
80 }
81 let db = auth.db.lock().unwrap();
82 let mut query = db.prepare("SELECT id FROM guest_providers ORDER BY id")?;
83 let ids = query
84 .query_map([], |r| r.get::<_, String>(0))?
85 .collect::<std::result::Result<Vec<_>, _>>()?;
86 Ok(json!(
87 ids.iter()
88 .map(|id| json!({"id":id,"name":known(id).unwrap_or(id)}))
89 .collect::<Vec<_>>()
90 ))
91}
92
93fn registration(auth: &auth::Store, provider: &str) -> Result<(String, String)> {
94 auth.db.lock().unwrap().query_row("SELECT client_id,secret FROM guest_providers WHERE id=?", [provider], |r|Ok((r.get(0)?,r.get(1)?)))
95 .optional()?.ok_or_else(|| Error::new(503, "This sign-in provider isn't available. Use your Snowglobe account or try again later."))
96}
97
98async fn response_bytes(mut response: reqwest::Response) -> Result<Vec<u8>> {
99 let status = response.status();
100 let endpoint = format!(
101 "{}{}",
102 response.url().host_str().unwrap_or_default(),
103 response.url().path()
104 );
105 if response.content_length().is_some_and(|n| n > 65536) {
106 return Err(Error::new(
107 502,
108 "The provider couldn't complete sign-in. Return to Shale and try again.",
109 ));
110 }
111 let mut data = Vec::new();
112 while let Some(chunk) = response.chunk().await? {
113 if data.len() + chunk.len() > 65536 {
114 return Err(Error::new(
115 502,
116 "The provider couldn't complete sign-in. Return to Shale and try again.",
117 ));
118 }
119 data.extend_from_slice(&chunk);
120 }
121 if !status.is_success() {
122 let error: Value = serde_json::from_slice(&data).unwrap_or(Value::Null);
123 let code = string(&error["error_description"])
124 .split(':')
125 .next()
126 .unwrap_or_default();
127 let code = if code.starts_with('E')
128 && code.len() <= 8
129 && code[1..].bytes().all(|c| c.is_ascii_digit())
130 {
131 code
132 } else {
133 "unknown"
134 };
135 eprintln!("guest provider response: {endpoint} status {status} code {code}");
136 return Err(Error::new(
137 502,
138 "The provider couldn't complete sign-in. Return to Shale and try again.",
139 ));
140 }
141 Ok(data)
142}
143
144fn json_bytes(bytes: &[u8]) -> Result<Value> {
145 serde_json::from_slice(bytes).map_err(|_| {
146 Error::new(
147 502,
148 "The provider couldn't complete sign-in. Return to Shale and try again.",
149 )
150 })
151}
152
153/// Astheno signs ID tokens with P-256. Never trust claims before verifying the signature.
154fn signed_claims(
155 token: &str,
156 keys: &Value,
157 client: &str,
158 nonce: &str,
159 id_token: bool,
160) -> Result<Value> {
161 let reject = |reason: &str| {
162 eprintln!(
163 "guest token verification: {} {reason}",
164 if id_token { "id_token" } else { "userinfo" }
165 );
166 Error::new(
167 502,
168 "The provider couldn't verify your sign-in. Return to Shale and try again.",
169 )
170 };
171 if token.len() > 32768 {
172 return Err(reject("size"));
173 }
174 let parts: Vec<_> = token.split('.').collect();
175 if parts.len() != 3 {
176 return Err(reject("format"));
177 }
178 let header: Value = serde_json::from_slice(
179 &URL_SAFE_NO_PAD
180 .decode(parts[0])
181 .map_err(|_| reject("header_encoding"))?,
182 )
183 .map_err(|_| reject("header_json"))?;
184 if header
185 .get("kid")
186 .is_some_and(|id| id.as_str().is_none_or(str::is_empty))
187 || header["alg"] != "ES256"
188 || header.get("crit").is_some()
189 || header.get("jku").is_some()
190 || header.get("jwk").is_some()
191 {
192 return Err(reject("header"));
193 }
194 let matching: Vec<_> = array(&keys["keys"])
195 .iter()
196 .filter(|key| {
197 header
198 .get("kid")
199 .is_none_or(|id| key.get("kid") == Some(id))
200 && key["kty"] == "EC"
201 && key["crv"] == "P-256"
202 && key.get("alg").is_none_or(|v| v == "ES256")
203 && key.get("use").is_none_or(|v| v == "sig")
204 })
205 .collect();
206 if matching.len() != 1 {
207 return Err(reject("key_selection"));
208 }
209 let key = matching[0];
210 let x = URL_SAFE_NO_PAD
211 .decode(string(&key["x"]))
212 .map_err(|_| reject("key_x"))?;
213 let y = URL_SAFE_NO_PAD
214 .decode(string(&key["y"]))
215 .map_err(|_| reject("key_y"))?;
216 if x.len() != 32 || y.len() != 32 {
217 return Err(reject("key_coordinates"));
218 }
219 let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1)?;
220 let mut point = EcPoint::new(&group)?;
221 let x = BigNum::from_slice(&x)?;
222 let y = BigNum::from_slice(&y)?;
223 let mut context = BigNumContext::new()?;
224 point.set_affine_coordinates_gfp(&group, &x, &y, &mut context)?;
225 let ec = EcKey::from_public_key(&group, &point)?;
226 ec.check_key()?;
227 let key = PKey::from_ec_key(ec)?;
228 let raw = URL_SAFE_NO_PAD
229 .decode(parts[2])
230 .map_err(|_| reject("signature_encoding"))?;
231 if raw.len() != 64 {
232 return Err(reject("signature_length"));
233 }
234 let signature = EcdsaSig::from_private_components(
235 BigNum::from_slice(&raw[..32])?,
236 BigNum::from_slice(&raw[32..])?,
237 )?
238 .to_der()?;
239 let mut verify = Verifier::new(MessageDigest::sha256(), &key)?;
240 verify.update(format!("{}.{}", parts[0], parts[1]).as_bytes())?;
241 if !verify.verify(&signature)? {
242 return Err(reject("signature"));
243 }
244 let claims: Value = serde_json::from_slice(
245 &URL_SAFE_NO_PAD
246 .decode(parts[1])
247 .map_err(|_| reject("claims_encoding"))?,
248 )
249 .map_err(|_| reject("claims_json"))?;
250 let time = now() as i64;
251 let audience = claims["aud"].as_str().is_some_and(|a| a == client)
252 || array(&claims["aud"])
253 .iter()
254 .any(|a| a.as_str() == Some(client));
255 for (reason, invalid) in [
256 ("issuer", claims["iss"] != ASTHENO),
257 ("audience", !audience),
258 (
259 "authorized_party",
260 (claims["aud"].is_array()
261 && array(&claims["aud"]).len() > 1
262 && claims["azp"] != client)
263 || claims.get("azp").is_some_and(|a| a != client),
264 ),
265 (
266 "subject",
267 string(&claims["sub"]).is_empty() || string(&claims["sub"]).len() > 512,
268 ),
269 (
270 "expiration",
271 (id_token || claims.get("exp").is_some())
272 && claims["exp"].as_i64().is_none_or(|t| t <= time),
273 ),
274 (
275 "issued_at",
276 (id_token || claims.get("iat").is_some())
277 && claims["iat"].as_i64().is_none_or(|t| t > time + 60),
278 ),
279 (
280 "not_before",
281 claims
282 .get("nbf")
283 .is_some_and(|t| t.as_i64().is_none_or(|n| n > time + 60)),
284 ),
285 ("nonce", id_token && claims["nonce"].as_str() != Some(nonce)),
286 ] {
287 if invalid {
288 return Err(reject(reason));
289 }
290 }
291 Ok(claims)
292}
293
294async fn exchange(
295 http: &reqwest::Client,
296 provider: &str,
297 client: &str,
298 secret: &str,
299 code: &str,
300 callback: &str,
301 flow: &Value,
302) -> Result<(String, String, Option<String>)> {
303 let form = [
304 ("client_id", client),
305 ("client_secret", secret),
306 ("grant_type", "authorization_code"),
307 ("code", code),
308 ("redirect_uri", callback),
309 ("code_verifier", string(&flow["verifier"])),
310 ];
311 if provider == "github" {
312 let token = json_bytes(
313 &response_bytes(
314 http.post("https://github.com/login/oauth/access_token")
315 .form(&form)
316 .send()
317 .await?,
318 )
319 .await?,
320 )?;
321 if token["token_type"]
322 .as_str()
323 .is_none_or(|s| !s.eq_ignore_ascii_case("bearer"))
324 || string(&token["access_token"]).is_empty()
325 || string(&token["scope"])
326 .split([',', ' '])
327 .filter(|s| !s.is_empty())
328 .any(|s| s != "read:user")
329 {
330 return Err(Error::new(
331 502,
332 "GitHub couldn't complete sign-in. Return to Shale and try again.",
333 ));
334 }
335 let profile = json_bytes(
336 &response_bytes(
337 http.get("https://api.github.com/user")
338 .bearer_auth(string(&token["access_token"]))
339 .send()
340 .await?,
341 )
342 .await?,
343 )?;
344 let id = profile["id"].as_u64().filter(|n| *n > 0).ok_or_else(|| {
345 Error::new(
346 502,
347 "GitHub couldn't verify your account. Return to Shale and try again.",
348 )
349 })?;
350 let login = profile["login"]
351 .as_str()
352 .filter(|s| !s.is_empty() && s.len() <= 64)
353 .ok_or_else(|| {
354 Error::new(
355 502,
356 "GitHub couldn't verify your account. Return to Shale and try again.",
357 )
358 })?;
359 return Ok((id.to_string(), login.to_owned(), None));
360 }
361 let mut form = form.to_vec();
362 form.push(("state", "none"));
363 let token = json_bytes(
364 &response_bytes(
365 http.post(format!("{ASTHENO}/api/token"))
366 .basic_auth(client, Some(secret))
367 .form(&form)
368 .send()
369 .await?,
370 )
371 .await?,
372 )?;
373 if string(&token["access_token"]).is_empty()
374 || token["token_type"]
375 .as_str()
376 .is_none_or(|s| !s.eq_ignore_ascii_case("bearer"))
377 {
378 return Err(Error::new(
379 502,
380 "Astheno couldn't complete sign-in. Return to Shale and try again.",
381 ));
382 }
383 let keys =
384 json_bytes(&response_bytes(http.get(format!("{ASTHENO}/api/jwks")).send().await?).await?)?;
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 {
393 if hash
394 != URL_SAFE_NO_PAD
395 .encode(&Sha256::digest(string(&token["access_token"]).as_bytes())[..16])
396 {
397 return Err(Error::new(
398 502,
399 "Astheno couldn't verify your sign-in. Return to Shale and try again.",
400 ));
401 }
402 }
403 let bytes = response_bytes(
404 http.get(format!("{ASTHENO}/api/userinfo"))
405 .bearer_auth(string(&token["access_token"]))
406 .send()
407 .await?,
408 )
409 .await?;
410 let profile = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
411 json_bytes(&bytes)?
412 } else {
413 signed_claims(std::str::from_utf8(&bytes)?, &keys, client, "", false)?
414 };
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 {
421 return Err(Error::new(
422 502,
423 "Astheno couldn't verify your account. Return to Shale and try again.",
424 ));
425 }
426 let name = profile["preferred_username"]
427 .as_str()
428 .or(profile["name"].as_str())
429 .unwrap_or("Astheno guest")
430 .chars()
431 .take(128)
432 .collect();
433 let picture = profile["picture"]
434 .as_str()
435 .filter(|value| value.len() <= 2048)
436 .and_then(|value| url::Url::parse(value).ok())
437 .filter(|url| {
438 url.scheme() == "https"
439 && url.host_str().is_some()
440 && url.username().is_empty()
441 && url.password().is_none()
442 })
443 .map(String::from);
444 Ok((string(&profile["sub"]).to_owned(), name, picture))
445}
446
447fn account(
448 auth: &auth::Store,
449 provider: &str,
450 subject: &str,
451 name: &str,
452 picture: Option<&str>,
453) -> Result<String> {
454 let mut db = auth.db.lock().unwrap();
455 let tx = db.transaction()?;
456 let existing: Option<String> = tx
457 .query_row(
458 "SELECT user_id FROM external_identities WHERE provider=? AND subject=?",
459 sql![provider, subject],
460 |r| r.get(0),
461 )
462 .optional()?;
463 if let Some(id) = existing {
464 let profile = auth::user(&tx, &id)?;
465 if !is_guest(&profile) || profile["enabled"] != true {
466 return Err(Error::new(
467 403,
468 "This guest account is disabled. Contact Clover.",
469 ));
470 }
471 tx.execute(
472 "UPDATE users SET profile=json_patch(profile,?) WHERE id=?",
473 sql![json!({"firstName":name,"attributes":{"picture":picture.map(|picture| vec![picture])}}).to_string(), id],
474 )?;
475 tx.commit()?;
476 return Ok(id);
477 }
478 let id = uuid::Uuid::new_v4().to_string();
479 let suffix = if provider == "github" {
480 subject.to_owned()
481 } else {
482 mcp::hash(subject)[..24].to_owned()
483 };
484 let mut profile = json!({"kind":"guest","guestProvider":provider,"username":format!("guest-{provider}-{suffix}"),"enabled":true,"email":null,"emailVerified":false,"firstName":name,"lastName":null,"requiredActions":[],"attributes":{},"createdTimestamp":(now()*1000.0) as i64});
485 if let Some(picture) = picture {
486 profile["attributes"]["picture"] = json!([picture]);
487 }
488 tx.execute(
489 "INSERT INTO users(id,profile) VALUES (?,?)",
490 sql![id, profile.to_string()],
491 )?;
492 tx.execute(
493 "INSERT INTO external_identities VALUES (?,?,?)",
494 sql![provider, subject, id],
495 )?;
496 tx.commit()?;
497 Ok(id)
498}
499
500fn fields(query: &str) -> Result<HashMap<String, String>> {
501 let mut map = HashMap::new();
502 for (k, v) in url::form_urlencoded::parse(query.as_bytes()) {
503 if v.len() > 8192 || map.insert(k.into_owned(), v.into_owned()).is_some() {
504 return Err(Error::new(
505 400,
506 "Sign-in expired. Return to Shale and try again.",
507 ));
508 }
509 }
510 Ok(map)
511}
512
513async fn handle(app: &App, request: Request) -> Result<Response> {
514 if request.method() != Method::GET {
515 return Err(Error::new(405, "Use the Shale sign-in page."));
516 }
517 let auth = &app.auth;
518 let headers = request.headers();
519 let parts: Vec<_> = request.uri().path().split('/').collect();
520 if parts.len() != 5 {
521 return Err(Error::new(404, "Use the Shale sign-in page."));
522 }
523 let provider = parts[4];
524 known(provider)?;
525 let (client, secret) = registration(auth, provider)?;
526 let query = fields(request.uri().query().unwrap_or_default())?;
527 let current = auth.session(headers, "dashboard")?;
528 if !current.is_null() && !is_guest(&current) {
529 return Err(Error::new(
530 403,
531 "You're signed into Snowglobe. Open Shale to use your account.",
532 ));
533 }
534 let callback = format!("{}auth/guest/callback/{provider}", auth.origin);
535 if parts[3] == "start" {
536 auth.limit(headers, "guest")?;
537 let next = oidc::guest_target(
538 auth,
539 query.get("next").map(String::as_str).unwrap_or_default(),
540 )?;
541 let verifier = mcp::secret();
542 let nonce = mcp::secret();
543 let state = auth::issue(
544 &auth.db.lock().unwrap(),
545 "guest",
546 json!({"provider":provider,"next":next,"verifier":verifier,"nonce":nonce}),
547 300,
548 )?;
549 let mut target = url::Url::parse(if provider == "github" {
550 "https://github.com/login/oauth/authorize"
551 } else {
552 "https://identity.astheno.software/authorize"
553 })?;
554 target.query_pairs_mut().extend_pairs([
555 ("client_id", client.as_str()),
556 ("redirect_uri", &callback),
557 ("response_type", "code"),
558 (
559 "scope",
560 if provider == "github" {
561 "read:user"
562 } else {
563 "openid profile"
564 },
565 ),
566 ("state", &state),
567 ("nonce", &nonce),
568 (
569 "code_challenge",
570 &URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())),
571 ),
572 ("code_challenge_method", "S256"),
573 ]);
574 return Ok((
575 StatusCode::FOUND,
576 [
577 ("location", target.to_string()),
578 ("set-cookie", auth::set_cookie(COOKIE, &state, 300)),
579 ],
580 )
581 .into_response());
582 }
583 if parts[3] != "callback" {
584 return Err(Error::new(404, "Use the Shale sign-in page."));
585 }
586 let state = query.get("state").map(String::as_str).unwrap_or_default();
587 let binding = auth::cookie(headers, COOKIE).unwrap_or_default();
588 if state.is_empty() || !bool::from(state.as_bytes().ct_eq(binding.as_bytes())) {
589 return Err(Error::new(
590 403,
591 "Sign-in expired. Return to Shale and try again.",
592 ));
593 }
594 let flow = {
595 let db = auth.db.lock().unwrap();
596 let flow = auth::pending(&db, state, "guest", false)?;
597 if flow.is_null() || flow["provider"] != provider {
598 return Err(Error::new(
599 403,
600 "Sign-in expired. Return to Shale and try again.",
601 ));
602 }
603 auth::pending(&db, state, "guest", true)?
604 };
605 let next = oidc::guest_target(auth, string(&flow["next"]))?;
606 let result = async {
607 let code = query
608 .get("code")
609 .filter(|s| !s.is_empty() && s.len() <= 4096)
610 .ok_or_else(|| {
611 Error::new(
612 400,
613 "Sign-in wasn't completed. Return to Shale and try again.",
614 )
615 })?;
616 let http = reqwest::Client::builder()
617 .timeout(Duration::from_secs(10))
618 .redirect(reqwest::redirect::Policy::none())
619 .user_agent("Snowglobe guest sign-in")
620 .default_headers({
621 let mut headers = HeaderMap::new();
622 headers.insert("accept", "application/json".parse().unwrap());
623 headers
624 })
625 .build()?;
626 let (subject, name, picture) =
627 exchange(&http, provider, &client, &secret, code, &callback, &flow).await?;
628 let id = account(auth, provider, &subject, &name, picture.as_deref())?;
629 auth.create_session(&id, "dashboard", headers, None)
630 }
631 .await;
632 match result {
633 Ok(session) => Ok((
634 StatusCode::FOUND,
635 [("location", next), ("set-cookie", session)],
636 axum::response::AppendHeaders([("set-cookie", auth::set_cookie(COOKIE, "", 0))]),
637 )
638 .into_response()),
639 Err(error) => {
640 eprintln!(
641 "guest callback {provider}: {}: {}",
642 error.status, error.message
643 );
644 Ok((
645 StatusCode::FOUND,
646 [
647 (
648 "location",
649 format!("/sign-in?next={}&guest_error=1", encoded(&next)),
650 ),
651 ("set-cookie", auth::set_cookie(COOKIE, "", 0)),
652 ],
653 )
654 .into_response())
655 }
656 }
657}
658
659pub async fn route(State(app): State<Arc<App>>, request: Request) -> Response {
660 let mut response = match handle(&app, request).await {
661 Ok(response) => response,
662 Err(error) => Error::new(
663 error.status,
664 "Guest sign-in couldn't continue. Return to Shale and try again.",
665 )
666 .into_response(),
667 };
668 response
669 .headers_mut()
670 .insert("cache-control", "no-store".parse().unwrap());
671 response
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677
678 // The P-256 tokens were produced independently with Python cryptography.
679 #[test]
680 fn external_signature_issuer_audience_nonce_and_time_are_required() {
681 let vector: Value = serde_json::from_str(include_str!("../tests/guest-jwt.json")).unwrap();
682 let token = string(&vector["valid"]);
683 let claims =
684 signed_claims(token, &vector["keys"], "fixture", "fixture-nonce", true).unwrap();
685 assert_eq!(claims["sub"], "external-123");
686 let unnamed = &vector["withoutKid"];
687 let unnamed_token = string(&unnamed["token"]);
688 assert_eq!(
689 signed_claims(
690 unnamed_token,
691 &unnamed["keys"],
692 "fixture",
693 "fixture-nonce",
694 true
695 )
696 .unwrap()["sub"],
697 "external-123"
698 );
699 let mut ambiguous = unnamed["keys"].clone();
700 ambiguous["keys"]
701 .as_array_mut()
702 .unwrap()
703 .push(unnamed["keys"]["keys"][0].clone());
704 assert!(
705 signed_claims(unnamed_token, &ambiguous, "fixture", "fixture-nonce", true).is_err()
706 );
707 assert!(
708 signed_claims(
709 unnamed_token,
710 &vector["keys"],
711 "fixture",
712 "fixture-nonce",
713 true
714 )
715 .is_err()
716 );
717 for (name, token) in vector["invalid"].as_object().unwrap() {
718 assert!(
719 signed_claims(
720 string(token),
721 &vector["keys"],
722 "fixture",
723 "fixture-nonce",
724 true
725 )
726 .is_err(),
727 "{name}"
728 );
729 }
730 let mut corrupt = token.to_owned().into_bytes();
731 let index = token.rfind('.').unwrap() + 3;
732 corrupt[index] = if corrupt[index] == b'A' { b'B' } else { b'A' };
733 assert!(
734 signed_claims(
735 std::str::from_utf8(&corrupt).unwrap(),
736 &vector["keys"],
737 "fixture",
738 "fixture-nonce",
739 true
740 )
741 .is_err()
742 );
743 assert!(
744 signed_claims(token, &json!({"keys":[]}), "fixture", "fixture-nonce", true).is_err()
745 );
746 let mut duplicate = vector["keys"].clone();
747 duplicate["keys"]
748 .as_array_mut()
749 .unwrap()
750 .push(vector["keys"]["keys"][0].clone());
751 assert!(signed_claims(token, &duplicate, "fixture", "fixture-nonce", true).is_err());
752 let mut parts: Vec<_> = token.split('.').map(str::to_owned).collect();
753 parts[0] = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","kid":"test-key"}"#);
754 assert!(
755 signed_claims(
756 &parts.join("."),
757 &vector["keys"],
758 "fixture",
759 "fixture-nonce",
760 true
761 )
762 .is_err()
763 );
764 }
765
766 #[test]
767 fn signed_userinfo_requires_identity_and_signature_but_not_id_token_times_or_nonce() {
768 let vector: Value = serde_json::from_str(include_str!("../tests/guest-jwt.json")).unwrap();
769 let userinfo = &vector["userinfo"];
770 let token = string(&userinfo["token"]);
771 assert_eq!(
772 signed_claims(token, &userinfo["keys"], "fixture", "", false).unwrap()["sub"],
773 "external-123"
774 );
775 assert!(signed_claims(token, &userinfo["keys"], "fixture", "fixture-nonce", true).is_err());
776 assert!(signed_claims(token, &userinfo["keys"], "other", "", false).is_err());
777 assert!(signed_claims(token, &vector["keys"], "fixture", "", false).is_err());
778 for reason in ["issuer", "audience", "expiry", "future", "subject"] {
779 assert!(
780 signed_claims(
781 string(&vector["invalid"][reason]),
782 &vector["keys"],
783 "fixture",
784 "",
785 false
786 )
787 .is_err(),
788 "{reason}"
789 );
790 }
791 }
792
793 #[test]
794 fn identities_never_link_by_name_or_email_and_cannot_gain_credentials_or_groups() {
795 let path = std::env::temp_dir().join(format!("guest-test-{}", uuid::Uuid::new_v4()));
796 let auth = auth::Store::new(
797 &path,
798 "https://snowglobe.paperclover.net",
799 "https://file.paperclover.net",
800 "auth.paperclover.net",
801 )
802 .unwrap();
803 {
804 let db = auth.db.lock().unwrap();
805 db.execute("INSERT INTO users(id,profile) VALUES ('owner',?)", [json!({"username":"clover","enabled":true,"email":"same@example.invalid","emailVerified":true}).to_string()]).unwrap();
806 db.execute("INSERT INTO roles VALUES ('admin','infra-admin') ON CONFLICT(name) DO UPDATE SET id=excluded.id", [])
807 .unwrap();
808 }
809 let first = account(&auth, "github", "123", "clover", None).unwrap();
810 let repeat = account(&auth, "github", "123", "renamed", None).unwrap();
811 let other = account(
812 &auth,
813 "astheno",
814 "123",
815 "clover",
816 Some("https://identity.astheno.software/avatar/123"),
817 )
818 .unwrap();
819 assert_eq!(first, repeat);
820 assert_ne!(first, "owner");
821 assert_ne!(first, other);
822 assert_eq!(
823 auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]["picture"][0],
824 "https://identity.astheno.software/avatar/123"
825 );
826 account(&auth, "astheno", "123", "clover", None).unwrap();
827 assert!(
828 auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]
829 .get("picture")
830 .is_none()
831 );
832 {
833 let db = auth.db.lock().unwrap();
834 let profile = auth::user(&db, &first).unwrap();
835 assert!(is_guest(&profile));
836 assert_eq!(profile["firstName"], "renamed");
837 assert!(profile["email"].is_null());
838 assert_eq!(profile["groups"], json!([]));
839 assert!(
840 db.execute("INSERT INTO memberships VALUES (?,'admin')", [&first])
841 .is_err()
842 );
843 assert!(auth::set_password(&db, &first, "a-password-hash").is_err());
844 assert!(
845 db.execute(
846 "UPDATE users SET profile=json_remove(profile,'$.kind') WHERE id=?",
847 [&first]
848 )
849 .is_err()
850 );
851 db.execute(
852 "UPDATE users SET profile=json_set(profile,'$.enabled',json('false')) WHERE id=?",
853 [&first],
854 )
855 .unwrap();
856 }
857 assert!(account(&auth, "github", "123", "clover", None).is_err());
858 assert!(
859 auth.create_session(&other, "file", &HeaderMap::new(), None)
860 .is_err()
861 );
862 assert!(auth.setup_link(&other).is_err());
863 drop(auth);
864 std::fs::remove_dir_all(path).unwrap();
865 }
866}