| author | |
| committer | |
| log | 235cd050154d9faf139ce8975ab370f5432db911 |
| tree | 6e8c006534e2a077d387ba9fd76e61886b7f43d2 |
| parent | 4dd72bcd7c26fc5de14d5198985891f111f1f29f |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
22 files changed, 1373 insertions(+), 33 deletions(-)
config/OpenID.pkl+2| ... | @@ -14,4 +14,6 @@ class Client extends service.Requirement { | ... | @@ -14,4 +14,6 @@ class Client extends service.Requirement { |
| 14 | name: String | 14 | name: String |
| 15 | redirectUris: Listing<String> | 15 | redirectUris: Listing<String> |
| 16 | usernameAliases: Mapping<String, String> = new {} | 16 | usernameAliases: Mapping<String, String> = new {} |
| 17 | allowGuests: Boolean = false | ||
| 18 | usernameRequired: Boolean = false | ||
| 17 | } | 19 | } |
dashboard/src/auth.rs+32-5| ... | @@ -32,7 +32,7 @@ pub fn cookie(headers: &HeaderMap, name: &str) -> Option<String> { | ... | @@ -32,7 +32,7 @@ pub fn cookie(headers: &HeaderMap, name: &str) -> Option<String> { |
| 32 | (key == name).then(|| value.to_owned()) | 32 | (key == name).then(|| value.to_owned()) |
| 33 | }) | 33 | }) |
| 34 | } | 34 | } |
| 35 | fn set_cookie(name: &str, value: &str, ttl: i64) -> String { | 35 | pub(crate) fn set_cookie(name: &str, value: &str, ttl: i64) -> String { |
| 36 | format!("{name}={value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={ttl}") | 36 | format!("{name}={value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={ttl}") |
| 37 | } | 37 | } |
| 38 | fn row(db: &Connection, statement: &str, key: &str) -> Result<Value> { | 38 | fn row(db: &Connection, statement: &str, key: &str) -> Result<Value> { |
| ... | @@ -189,6 +189,7 @@ impl Store { | ... | @@ -189,6 +189,7 @@ impl Store { |
| 189 | CREATE TABLE IF NOT EXISTS migration (digest TEXT PRIMARY KEY); | 189 | CREATE TABLE IF NOT EXISTS migration (digest TEXT PRIMARY KEY); |
| 190 | CREATE TABLE IF NOT EXISTS attempts (key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires INTEGER NOT NULL);")?; | 190 | CREATE TABLE IF NOT EXISTS attempts (key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires INTEGER NOT NULL);")?; |
| 191 | oidc::initialise(&db)?; | 191 | oidc::initialise(&db)?; |
| 192 | guest::initialise(&db)?; | ||
| 192 | Ok(Self { | 193 | Ok(Self { |
| 193 | db: Mutex::new(db), | 194 | db: Mutex::new(db), |
| 194 | origin, | 195 | origin, |
| ... | @@ -364,7 +365,7 @@ impl Store { | ... | @@ -364,7 +365,7 @@ impl Store { |
| 364 | return Ok(Value::Null); | 365 | return Ok(Value::Null); |
| 365 | }; | 366 | }; |
| 366 | let user = user(&db, &id)?; | 367 | let user = user(&db, &id)?; |
| 367 | if user["enabled"] != true { | 368 | if user["enabled"] != true || (client == "file" && guest::is_guest(&user)) { |
| 368 | return Ok(Value::Null); | 369 | return Ok(Value::Null); |
| 369 | } | 370 | } |
| 370 | db.execute( | 371 | db.execute( |
| ... | @@ -377,7 +378,7 @@ impl Store { | ... | @@ -377,7 +378,7 @@ impl Store { |
| 377 | )?; | 378 | )?; |
| 378 | Ok(user) | 379 | Ok(user) |
| 379 | } | 380 | } |
| 380 | fn create_session( | 381 | pub(crate) fn create_session( |
| 381 | &self, | 382 | &self, |
| 382 | id: &str, | 383 | id: &str, |
| 383 | client: &str, | 384 | client: &str, |
| ... | @@ -386,7 +387,8 @@ impl Store { | ... | @@ -386,7 +387,8 @@ impl Store { |
| 386 | ) -> Result<String> { | 387 | ) -> Result<String> { |
| 387 | let token = mcp::secret(); | 388 | let token = mcp::secret(); |
| 388 | let db = self.db.lock().unwrap(); | 389 | let db = self.db.lock().unwrap(); |
| 389 | if user(&db, id)?["enabled"] != true { | 390 | let profile = user(&db, id)?; |
| 391 | if profile["enabled"] != true || (client == "file" && guest::is_guest(&profile)) { | ||
| 390 | return Err(Error::new(403, "This account is disabled.")); | 392 | return Err(Error::new(403, "This account is disabled.")); |
| 391 | } | 393 | } |
| 392 | if let Some(expected) = password { | 394 | if let Some(expected) = password { |
| ... | @@ -419,7 +421,7 @@ impl Store { | ... | @@ -419,7 +421,7 @@ impl Store { |
| 419 | )?; | 421 | )?; |
| 420 | Ok(set_cookie(COOKIE, &token, SESSION_TTL)) | 422 | Ok(set_cookie(COOKIE, &token, SESSION_TTL)) |
| 421 | } | 423 | } |
| 422 | fn limit(&self, headers: &HeaderMap, name: &str) -> Result<()> { | 424 | pub(crate) fn limit(&self, headers: &HeaderMap, name: &str) -> Result<()> { |
| 423 | let ip = headers | 425 | let ip = headers |
| 424 | .get("X-Studio-Client-IP") | 426 | .get("X-Studio-Client-IP") |
| 425 | .and_then(|v| v.to_str().ok()) | 427 | .and_then(|v| v.to_str().ok()) |
| ... | @@ -487,6 +489,12 @@ impl Store { | ... | @@ -487,6 +489,12 @@ impl Store { |
| 487 | return Ok(path.to_owned()); | 489 | return Ok(path.to_owned()); |
| 488 | } | 490 | } |
| 489 | let db = self.db.lock().unwrap(); | 491 | let db = self.db.lock().unwrap(); |
| 492 | if guest::is_guest(&user(&db, id)?) { | ||
| 493 | return Err(Error::new( | ||
| 494 | 403, | ||
| 495 | "Guest accounts can use Shale. Open Shale to continue.", | ||
| 496 | )); | ||
| 497 | } | ||
| 490 | if !array(&user(&db, id)?["requiredActions"]).is_empty() { | 498 | if !array(&user(&db, id)?["requiredActions"]).is_empty() { |
| 491 | return Ok("/account".into()); | 499 | return Ok("/account".into()); |
| 492 | } | 500 | } |
| ... | @@ -530,6 +538,12 @@ impl Store { | ... | @@ -530,6 +538,12 @@ impl Store { |
| 530 | pub fn setup_link(&self, id: &str) -> Result<String> { | 538 | pub fn setup_link(&self, id: &str) -> Result<String> { |
| 531 | let db = self.db.lock().unwrap(); | 539 | let db = self.db.lock().unwrap(); |
| 532 | let profile = user(&db, id)?; | 540 | let profile = user(&db, id)?; |
| 541 | if guest::is_guest(&profile) { | ||
| 542 | return Err(Error::new( | ||
| 543 | 400, | ||
| 544 | "Guests sign in with their provider. Use the Shale sign-in page.", | ||
| 545 | )); | ||
| 546 | } | ||
| 533 | if profile["enabled"] != true { | 547 | if profile["enabled"] != true { |
| 534 | return Err(Error::new( | 548 | return Err(Error::new( |
| 535 | 400, | 549 | 400, |
| ... | @@ -546,6 +560,9 @@ impl Store { | ... | @@ -546,6 +560,9 @@ impl Store { |
| 546 | } | 560 | } |
| 547 | 561 | ||
| 548 | pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Response> { | 562 | pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Response> { |
| 563 | if request.uri().path().starts_with("/auth/guest/") { | ||
| 564 | return Ok(guest::route(State(app), request).await); | ||
| 565 | } | ||
| 549 | if request.uri().path().starts_with("/auth/oidc/") { | 566 | if request.uri().path().starts_with("/auth/oidc/") { |
| 550 | return Ok(oidc::route(State(app), request).await); | 567 | return Ok(oidc::route(State(app), request).await); |
| 551 | } | 568 | } |
| ... | @@ -664,6 +681,10 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp | ... | @@ -664,6 +681,10 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp |
| 664 | if path == "/auth/status" && method == Method::GET { | 681 | if path == "/auth/status" && method == Method::GET { |
| 665 | let csrf = issue(&auth.db.lock().unwrap(), "csrf", json!({}), 900)?; | 682 | let csrf = issue(&auth.db.lock().unwrap(), "csrf", json!({}), 900)?; |
| 666 | let mut value = json!({"csrf":csrf,"account":auth.session(&headers,"dashboard")?}); | 683 | let mut value = json!({"csrf":csrf,"account":auth.session(&headers,"dashboard")?}); |
| 684 | value["providers"] = guest::providers( | ||
| 685 | auth, | ||
| 686 | query.get("next").map(String::as_str).unwrap_or_default(), | ||
| 687 | )?; | ||
| 667 | if let Some(setup) = query.get("setup") { | 688 | if let Some(setup) = query.get("setup") { |
| 668 | let entry = pending(&auth.db.lock().unwrap(), setup, "setup", false)?; | 689 | let entry = pending(&auth.db.lock().unwrap(), setup, "setup", false)?; |
| 669 | if entry.is_null() { | 690 | if entry.is_null() { |
| ... | @@ -1049,6 +1070,12 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp | ... | @@ -1049,6 +1070,12 @@ pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Resp |
| 1049 | if user.is_null() { | 1070 | if user.is_null() { |
| 1050 | return Err(Error::new(401, "Sign in to manage your account.")); | 1071 | return Err(Error::new(401, "Sign in to manage your account.")); |
| 1051 | } | 1072 | } |
| 1073 | if guest::is_guest(&user) { | ||
| 1074 | return Err(Error::new( | ||
| 1075 | 403, | ||
| 1076 | "Guests sign in with their provider. Use the Shale sign-in page.", | ||
| 1077 | )); | ||
| 1078 | } | ||
| 1052 | let id = string(&user["id"]); | 1079 | let id = string(&user["id"]); |
| 1053 | if path == "/auth/passkey/register" { | 1080 | if path == "/auth/passkey/register" { |
| 1054 | auth.recent(&headers)?; | 1081 | auth.recent(&headers)?; |
dashboard/src/guest.rs created+692| ... | @@ -0,0 +1,692 @@ | ||
| 1 | use crate::*; | ||
| 2 | use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; | ||
| 3 | use 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 | }; | ||
| 12 | use rusqlite::{Connection, OptionalExtension, params as sql}; | ||
| 13 | use sha2::{Digest, Sha256}; | ||
| 14 | |||
| 15 | const COOKIE: &str = "__Host-snow-guest"; | ||
| 16 | const ASTHENO: &str = "https://identity.astheno.software"; | ||
| 17 | |||
| 18 | pub fn is_guest(user: &Value) -> bool { | ||
| 19 | user["kind"] == "guest" | ||
| 20 | } | ||
| 21 | |||
| 22 | pub 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 | |||
| 31 | fn 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. | ||
| 43 | pub 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 | |||
| 77 | pub 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 | |||
| 93 | fn 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 | |||
| 98 | async fn response_bytes(mut response: reqwest::Response) -> Result<Vec<u8>> { | ||
| 99 | if !response.status().is_success() || response.content_length().is_some_and(|n| n > 65536) { | ||
| 100 | return Err(Error::new( | ||
| 101 | 502, | ||
| 102 | "The provider couldn't complete sign-in. Return to Shale and try again.", | ||
| 103 | )); | ||
| 104 | } | ||
| 105 | let mut data = Vec::new(); | ||
| 106 | while let Some(chunk) = response.chunk().await? { | ||
| 107 | if data.len() + chunk.len() > 65536 { | ||
| 108 | return Err(Error::new( | ||
| 109 | 502, | ||
| 110 | "The provider couldn't complete sign-in. Return to Shale and try again.", | ||
| 111 | )); | ||
| 112 | } | ||
| 113 | data.extend_from_slice(&chunk); | ||
| 114 | } | ||
| 115 | Ok(data) | ||
| 116 | } | ||
| 117 | |||
| 118 | fn json_bytes(bytes: &[u8]) -> Result<Value> { | ||
| 119 | serde_json::from_slice(bytes).map_err(|_| { | ||
| 120 | Error::new( | ||
| 121 | 502, | ||
| 122 | "The provider couldn't complete sign-in. Return to Shale and try again.", | ||
| 123 | ) | ||
| 124 | }) | ||
| 125 | } | ||
| 126 | |||
| 127 | /// Astheno signs ID tokens with P-256. Never trust claims before verifying the signature. | ||
| 128 | fn signed_claims( | ||
| 129 | token: &str, | ||
| 130 | keys: &Value, | ||
| 131 | client: &str, | ||
| 132 | nonce: &str, | ||
| 133 | require_nonce: bool, | ||
| 134 | ) -> Result<Value> { | ||
| 135 | let reject = || { | ||
| 136 | Error::new( | ||
| 137 | 502, | ||
| 138 | "The provider couldn't verify your sign-in. Return to Shale and try again.", | ||
| 139 | ) | ||
| 140 | }; | ||
| 141 | if token.len() > 32768 { | ||
| 142 | return Err(reject()); | ||
| 143 | } | ||
| 144 | let parts: Vec<_> = token.split('.').collect(); | ||
| 145 | if parts.len() != 3 { | ||
| 146 | return Err(reject()); | ||
| 147 | } | ||
| 148 | let header: Value = | ||
| 149 | serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[0]).map_err(|_| reject())?) | ||
| 150 | .map_err(|_| reject())?; | ||
| 151 | if header["alg"] != "ES256" | ||
| 152 | || header.get("crit").is_some() | ||
| 153 | || header.get("jku").is_some() | ||
| 154 | || header.get("jwk").is_some() | ||
| 155 | { | ||
| 156 | return Err(reject()); | ||
| 157 | } | ||
| 158 | let matching: Vec<_> = array(&keys["keys"]) | ||
| 159 | .iter() | ||
| 160 | .filter(|key| { | ||
| 161 | key["kid"].is_string() | ||
| 162 | && key["kid"] == header["kid"] | ||
| 163 | && key["kty"] == "EC" | ||
| 164 | && key["crv"] == "P-256" | ||
| 165 | && key.get("alg").is_none_or(|v| v == "ES256") | ||
| 166 | && key.get("use").is_none_or(|v| v == "sig") | ||
| 167 | }) | ||
| 168 | .collect(); | ||
| 169 | if matching.len() != 1 { | ||
| 170 | return Err(reject()); | ||
| 171 | } | ||
| 172 | let key = matching[0]; | ||
| 173 | let x = URL_SAFE_NO_PAD | ||
| 174 | .decode(string(&key["x"])) | ||
| 175 | .map_err(|_| reject())?; | ||
| 176 | let y = URL_SAFE_NO_PAD | ||
| 177 | .decode(string(&key["y"])) | ||
| 178 | .map_err(|_| reject())?; | ||
| 179 | if x.len() != 32 || y.len() != 32 { | ||
| 180 | return Err(reject()); | ||
| 181 | } | ||
| 182 | let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1)?; | ||
| 183 | let mut point = EcPoint::new(&group)?; | ||
| 184 | let x = BigNum::from_slice(&x)?; | ||
| 185 | let y = BigNum::from_slice(&y)?; | ||
| 186 | let mut context = BigNumContext::new()?; | ||
| 187 | point.set_affine_coordinates_gfp(&group, &x, &y, &mut context)?; | ||
| 188 | let ec = EcKey::from_public_key(&group, &point)?; | ||
| 189 | ec.check_key()?; | ||
| 190 | let key = PKey::from_ec_key(ec)?; | ||
| 191 | let raw = URL_SAFE_NO_PAD.decode(parts[2]).map_err(|_| reject())?; | ||
| 192 | if raw.len() != 64 { | ||
| 193 | return Err(reject()); | ||
| 194 | } | ||
| 195 | let signature = EcdsaSig::from_private_components( | ||
| 196 | BigNum::from_slice(&raw[..32])?, | ||
| 197 | BigNum::from_slice(&raw[32..])?, | ||
| 198 | )? | ||
| 199 | .to_der()?; | ||
| 200 | let mut verify = Verifier::new(MessageDigest::sha256(), &key)?; | ||
| 201 | verify.update(format!("{}.{}", parts[0], parts[1]).as_bytes())?; | ||
| 202 | if !verify.verify(&signature)? { | ||
| 203 | return Err(reject()); | ||
| 204 | } | ||
| 205 | let claims: Value = | ||
| 206 | serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).map_err(|_| reject())?) | ||
| 207 | .map_err(|_| reject())?; | ||
| 208 | let time = now() as i64; | ||
| 209 | let audience = claims["aud"].as_str().is_some_and(|a| a == client) | ||
| 210 | || array(&claims["aud"]) | ||
| 211 | .iter() | ||
| 212 | .any(|a| a.as_str() == Some(client)); | ||
| 213 | if claims["iss"] != ASTHENO | ||
| 214 | || !audience | ||
| 215 | || (claims["aud"].is_array() && array(&claims["aud"]).len() > 1 && claims["azp"] != client) | ||
| 216 | || claims.get("azp").is_some_and(|a| a != client) | ||
| 217 | || string(&claims["sub"]).is_empty() | ||
| 218 | || string(&claims["sub"]).len() > 512 | ||
| 219 | || claims["exp"].as_i64().is_none_or(|t| t <= time) | ||
| 220 | || claims["iat"].as_i64().is_none_or(|t| t > time + 60) | ||
| 221 | || claims | ||
| 222 | .get("nbf") | ||
| 223 | .is_some_and(|t| t.as_i64().is_none_or(|n| n > time + 60)) | ||
| 224 | || (require_nonce && claims["nonce"].as_str() != Some(nonce)) | ||
| 225 | { | ||
| 226 | return Err(reject()); | ||
| 227 | } | ||
| 228 | Ok(claims) | ||
| 229 | } | ||
| 230 | |||
| 231 | async fn exchange( | ||
| 232 | http: &reqwest::Client, | ||
| 233 | provider: &str, | ||
| 234 | client: &str, | ||
| 235 | secret: &str, | ||
| 236 | code: &str, | ||
| 237 | callback: &str, | ||
| 238 | flow: &Value, | ||
| 239 | ) -> Result<(String, String)> { | ||
| 240 | let form = [ | ||
| 241 | ("grant_type", "authorization_code"), | ||
| 242 | ("code", code), | ||
| 243 | ("redirect_uri", callback), | ||
| 244 | ("code_verifier", string(&flow["verifier"])), | ||
| 245 | ]; | ||
| 246 | if provider == "github" { | ||
| 247 | let mut form = form.to_vec(); | ||
| 248 | form.extend([("client_id", client), ("client_secret", secret)]); | ||
| 249 | let token = json_bytes( | ||
| 250 | &response_bytes( | ||
| 251 | http.post("https://github.com/login/oauth/access_token") | ||
| 252 | .form(&form) | ||
| 253 | .send() | ||
| 254 | .await?, | ||
| 255 | ) | ||
| 256 | .await?, | ||
| 257 | )?; | ||
| 258 | if token["token_type"] | ||
| 259 | .as_str() | ||
| 260 | .is_none_or(|s| !s.eq_ignore_ascii_case("bearer")) | ||
| 261 | || string(&token["access_token"]).is_empty() | ||
| 262 | || string(&token["scope"]) | ||
| 263 | .split([',', ' ']) | ||
| 264 | .filter(|s| !s.is_empty()) | ||
| 265 | .any(|s| s != "read:user") | ||
| 266 | { | ||
| 267 | return Err(Error::new( | ||
| 268 | 502, | ||
| 269 | "GitHub couldn't complete sign-in. Return to Shale and try again.", | ||
| 270 | )); | ||
| 271 | } | ||
| 272 | let profile = json_bytes( | ||
| 273 | &response_bytes( | ||
| 274 | http.get("https://api.github.com/user") | ||
| 275 | .bearer_auth(string(&token["access_token"])) | ||
| 276 | .send() | ||
| 277 | .await?, | ||
| 278 | ) | ||
| 279 | .await?, | ||
| 280 | )?; | ||
| 281 | let id = profile["id"].as_u64().filter(|n| *n > 0).ok_or_else(|| { | ||
| 282 | Error::new( | ||
| 283 | 502, | ||
| 284 | "GitHub couldn't verify your account. Return to Shale and try again.", | ||
| 285 | ) | ||
| 286 | })?; | ||
| 287 | let login = profile["login"] | ||
| 288 | .as_str() | ||
| 289 | .filter(|s| !s.is_empty() && s.len() <= 64) | ||
| 290 | .ok_or_else(|| { | ||
| 291 | Error::new( | ||
| 292 | 502, | ||
| 293 | "GitHub couldn't verify your account. Return to Shale and try again.", | ||
| 294 | ) | ||
| 295 | })?; | ||
| 296 | return Ok((id.to_string(), login.to_owned())); | ||
| 297 | } | ||
| 298 | let token = json_bytes( | ||
| 299 | &response_bytes( | ||
| 300 | http.post(format!("{ASTHENO}/api/token")) | ||
| 301 | .basic_auth(client, Some(secret)) | ||
| 302 | .form(&form) | ||
| 303 | .send() | ||
| 304 | .await?, | ||
| 305 | ) | ||
| 306 | .await?, | ||
| 307 | )?; | ||
| 308 | if string(&token["access_token"]).is_empty() | ||
| 309 | || token["token_type"] | ||
| 310 | .as_str() | ||
| 311 | .is_none_or(|s| !s.eq_ignore_ascii_case("bearer")) | ||
| 312 | { | ||
| 313 | return Err(Error::new( | ||
| 314 | 502, | ||
| 315 | "Astheno couldn't complete sign-in. Return to Shale and try again.", | ||
| 316 | )); | ||
| 317 | } | ||
| 318 | let keys = | ||
| 319 | json_bytes(&response_bytes(http.get(format!("{ASTHENO}/api/jwks")).send().await?).await?)?; | ||
| 320 | let claims = signed_claims( | ||
| 321 | string(&token["id_token"]), | ||
| 322 | &keys, | ||
| 323 | client, | ||
| 324 | string(&flow["nonce"]), | ||
| 325 | true, | ||
| 326 | )?; | ||
| 327 | if let Some(hash) = claims["at_hash"].as_str() { | ||
| 328 | if hash | ||
| 329 | != URL_SAFE_NO_PAD | ||
| 330 | .encode(&Sha256::digest(string(&token["access_token"]).as_bytes())[..16]) | ||
| 331 | { | ||
| 332 | return Err(Error::new( | ||
| 333 | 502, | ||
| 334 | "Astheno couldn't verify your sign-in. Return to Shale and try again.", | ||
| 335 | )); | ||
| 336 | } | ||
| 337 | } | ||
| 338 | let bytes = response_bytes( | ||
| 339 | http.get(format!("{ASTHENO}/api/userinfo")) | ||
| 340 | .bearer_auth(string(&token["access_token"])) | ||
| 341 | .send() | ||
| 342 | .await?, | ||
| 343 | ) | ||
| 344 | .await?; | ||
| 345 | let profile = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') { | ||
| 346 | json_bytes(&bytes)? | ||
| 347 | } else { | ||
| 348 | signed_claims(std::str::from_utf8(&bytes)?, &keys, client, "", false)? | ||
| 349 | }; | ||
| 350 | if profile["sub"] != claims["sub"] { | ||
| 351 | return Err(Error::new( | ||
| 352 | 502, | ||
| 353 | "Astheno couldn't verify your account. Return to Shale and try again.", | ||
| 354 | )); | ||
| 355 | } | ||
| 356 | let name = profile["preferred_username"] | ||
| 357 | .as_str() | ||
| 358 | .or(profile["name"].as_str()) | ||
| 359 | .unwrap_or("Astheno guest") | ||
| 360 | .chars() | ||
| 361 | .take(128) | ||
| 362 | .collect(); | ||
| 363 | Ok((string(&claims["sub"]).to_owned(), name)) | ||
| 364 | } | ||
| 365 | |||
| 366 | fn account(auth: &auth::Store, provider: &str, subject: &str, name: &str) -> Result<String> { | ||
| 367 | let mut db = auth.db.lock().unwrap(); | ||
| 368 | let tx = db.transaction()?; | ||
| 369 | let existing: Option<String> = tx | ||
| 370 | .query_row( | ||
| 371 | "SELECT user_id FROM external_identities WHERE provider=? AND subject=?", | ||
| 372 | sql![provider, subject], | ||
| 373 | |r| r.get(0), | ||
| 374 | ) | ||
| 375 | .optional()?; | ||
| 376 | if let Some(id) = existing { | ||
| 377 | let profile = auth::user(&tx, &id)?; | ||
| 378 | if !is_guest(&profile) || profile["enabled"] != true { | ||
| 379 | return Err(Error::new( | ||
| 380 | 403, | ||
| 381 | "This guest account is disabled. Contact Clover.", | ||
| 382 | )); | ||
| 383 | } | ||
| 384 | return Ok(id); | ||
| 385 | } | ||
| 386 | let id = uuid::Uuid::new_v4().to_string(); | ||
| 387 | let suffix = if provider == "github" { | ||
| 388 | subject.to_owned() | ||
| 389 | } else { | ||
| 390 | mcp::hash(subject)[..24].to_owned() | ||
| 391 | }; | ||
| 392 | let 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}); | ||
| 393 | tx.execute( | ||
| 394 | "INSERT INTO users(id,profile) VALUES (?,?)", | ||
| 395 | sql![id, profile.to_string()], | ||
| 396 | )?; | ||
| 397 | tx.execute( | ||
| 398 | "INSERT INTO external_identities VALUES (?,?,?)", | ||
| 399 | sql![provider, subject, id], | ||
| 400 | )?; | ||
| 401 | tx.commit()?; | ||
| 402 | Ok(id) | ||
| 403 | } | ||
| 404 | |||
| 405 | fn fields(query: &str) -> Result<HashMap<String, String>> { | ||
| 406 | let mut map = HashMap::new(); | ||
| 407 | for (k, v) in url::form_urlencoded::parse(query.as_bytes()) { | ||
| 408 | if v.len() > 8192 || map.insert(k.into_owned(), v.into_owned()).is_some() { | ||
| 409 | return Err(Error::new( | ||
| 410 | 400, | ||
| 411 | "Sign-in expired. Return to Shale and try again.", | ||
| 412 | )); | ||
| 413 | } | ||
| 414 | } | ||
| 415 | Ok(map) | ||
| 416 | } | ||
| 417 | |||
| 418 | async fn handle(app: &App, request: Request) -> Result<Response> { | ||
| 419 | if request.method() != Method::GET { | ||
| 420 | return Err(Error::new(405, "Use the Shale sign-in page.")); | ||
| 421 | } | ||
| 422 | let auth = &app.auth; | ||
| 423 | let headers = request.headers(); | ||
| 424 | let parts: Vec<_> = request.uri().path().split('/').collect(); | ||
| 425 | if parts.len() != 5 { | ||
| 426 | return Err(Error::new(404, "Use the Shale sign-in page.")); | ||
| 427 | } | ||
| 428 | let provider = parts[4]; | ||
| 429 | known(provider)?; | ||
| 430 | let (client, secret) = registration(auth, provider)?; | ||
| 431 | let query = fields(request.uri().query().unwrap_or_default())?; | ||
| 432 | let current = auth.session(headers, "dashboard")?; | ||
| 433 | if !current.is_null() && !is_guest(&current) { | ||
| 434 | return Err(Error::new( | ||
| 435 | 403, | ||
| 436 | "You're signed into Snowglobe. Open Shale to use your account.", | ||
| 437 | )); | ||
| 438 | } | ||
| 439 | let callback = format!("{}auth/guest/callback/{provider}", auth.origin); | ||
| 440 | if parts[3] == "start" { | ||
| 441 | auth.limit(headers, "guest")?; | ||
| 442 | let next = oidc::guest_target( | ||
| 443 | auth, | ||
| 444 | query.get("next").map(String::as_str).unwrap_or_default(), | ||
| 445 | )?; | ||
| 446 | let verifier = mcp::secret(); | ||
| 447 | let nonce = mcp::secret(); | ||
| 448 | let state = auth::issue( | ||
| 449 | &auth.db.lock().unwrap(), | ||
| 450 | "guest", | ||
| 451 | json!({"provider":provider,"next":next,"verifier":verifier,"nonce":nonce}), | ||
| 452 | 300, | ||
| 453 | )?; | ||
| 454 | let mut target = url::Url::parse(if provider == "github" { | ||
| 455 | "https://github.com/login/oauth/authorize" | ||
| 456 | } else { | ||
| 457 | "https://identity.astheno.software/authorize" | ||
| 458 | })?; | ||
| 459 | target.query_pairs_mut().extend_pairs([ | ||
| 460 | ("client_id", client.as_str()), | ||
| 461 | ("redirect_uri", &callback), | ||
| 462 | ("response_type", "code"), | ||
| 463 | ( | ||
| 464 | "scope", | ||
| 465 | if provider == "github" { | ||
| 466 | "read:user" | ||
| 467 | } else { | ||
| 468 | "openid profile" | ||
| 469 | }, | ||
| 470 | ), | ||
| 471 | ("state", &state), | ||
| 472 | ("nonce", &nonce), | ||
| 473 | ( | ||
| 474 | "code_challenge", | ||
| 475 | &URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())), | ||
| 476 | ), | ||
| 477 | ("code_challenge_method", "S256"), | ||
| 478 | ]); | ||
| 479 | return Ok(( | ||
| 480 | StatusCode::FOUND, | ||
| 481 | [ | ||
| 482 | ("location", target.to_string()), | ||
| 483 | ("set-cookie", auth::set_cookie(COOKIE, &state, 300)), | ||
| 484 | ], | ||
| 485 | ) | ||
| 486 | .into_response()); | ||
| 487 | } | ||
| 488 | if parts[3] != "callback" { | ||
| 489 | return Err(Error::new(404, "Use the Shale sign-in page.")); | ||
| 490 | } | ||
| 491 | let state = query.get("state").map(String::as_str).unwrap_or_default(); | ||
| 492 | let binding = auth::cookie(headers, COOKIE).unwrap_or_default(); | ||
| 493 | if state.is_empty() || !bool::from(state.as_bytes().ct_eq(binding.as_bytes())) { | ||
| 494 | return Err(Error::new( | ||
| 495 | 403, | ||
| 496 | "Sign-in expired. Return to Shale and try again.", | ||
| 497 | )); | ||
| 498 | } | ||
| 499 | let flow = { | ||
| 500 | let db = auth.db.lock().unwrap(); | ||
| 501 | let flow = auth::pending(&db, state, "guest", false)?; | ||
| 502 | if flow.is_null() || flow["provider"] != provider { | ||
| 503 | return Err(Error::new( | ||
| 504 | 403, | ||
| 505 | "Sign-in expired. Return to Shale and try again.", | ||
| 506 | )); | ||
| 507 | } | ||
| 508 | auth::pending(&db, state, "guest", true)? | ||
| 509 | }; | ||
| 510 | let next = oidc::guest_target(auth, string(&flow["next"]))?; | ||
| 511 | let result = async { | ||
| 512 | let code = query | ||
| 513 | .get("code") | ||
| 514 | .filter(|s| !s.is_empty() && s.len() <= 4096) | ||
| 515 | .ok_or_else(|| { | ||
| 516 | Error::new( | ||
| 517 | 400, | ||
| 518 | "Sign-in wasn't completed. Return to Shale and try again.", | ||
| 519 | ) | ||
| 520 | })?; | ||
| 521 | let http = reqwest::Client::builder() | ||
| 522 | .timeout(Duration::from_secs(10)) | ||
| 523 | .redirect(reqwest::redirect::Policy::none()) | ||
| 524 | .user_agent("Snowglobe guest sign-in") | ||
| 525 | .default_headers({ | ||
| 526 | let mut headers = HeaderMap::new(); | ||
| 527 | headers.insert("accept", "application/json".parse().unwrap()); | ||
| 528 | headers | ||
| 529 | }) | ||
| 530 | .build()?; | ||
| 531 | let (subject, name) = | ||
| 532 | exchange(&http, provider, &client, &secret, code, &callback, &flow).await?; | ||
| 533 | let id = account(auth, provider, &subject, &name)?; | ||
| 534 | auth.create_session(&id, "dashboard", headers, None) | ||
| 535 | } | ||
| 536 | .await; | ||
| 537 | match result { | ||
| 538 | Ok(session) => Ok(( | ||
| 539 | StatusCode::FOUND, | ||
| 540 | [ | ||
| 541 | ("location", next), | ||
| 542 | ("set-cookie", session), | ||
| 543 | ("set-cookie", auth::set_cookie(COOKIE, "", 0)), | ||
| 544 | ], | ||
| 545 | ) | ||
| 546 | .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()), | ||
| 558 | } | ||
| 559 | } | ||
| 560 | |||
| 561 | pub async fn route(State(app): State<Arc<App>>, request: Request) -> Response { | ||
| 562 | let mut response = match handle(&app, request).await { | ||
| 563 | Ok(response) => response, | ||
| 564 | Err(error) => Error::new( | ||
| 565 | error.status, | ||
| 566 | "Guest sign-in couldn't continue. Return to Shale and try again.", | ||
| 567 | ) | ||
| 568 | .into_response(), | ||
| 569 | }; | ||
| 570 | response | ||
| 571 | .headers_mut() | ||
| 572 | .insert("cache-control", "no-store".parse().unwrap()); | ||
| 573 | response | ||
| 574 | } | ||
| 575 | |||
| 576 | #[cfg(test)] | ||
| 577 | mod tests { | ||
| 578 | use super::*; | ||
| 579 | |||
| 580 | // The P-256 tokens were produced independently with Python cryptography. | ||
| 581 | #[test] | ||
| 582 | fn external_signature_issuer_audience_nonce_and_time_are_required() { | ||
| 583 | let vector: Value = serde_json::from_str(include_str!("../tests/guest-jwt.json")).unwrap(); | ||
| 584 | let token = string(&vector["valid"]); | ||
| 585 | let claims = | ||
| 586 | signed_claims(token, &vector["keys"], "fixture", "fixture-nonce", true).unwrap(); | ||
| 587 | assert_eq!(claims["sub"], "external-123"); | ||
| 588 | for (name, token) in vector["invalid"].as_object().unwrap() { | ||
| 589 | assert!( | ||
| 590 | signed_claims( | ||
| 591 | string(token), | ||
| 592 | &vector["keys"], | ||
| 593 | "fixture", | ||
| 594 | "fixture-nonce", | ||
| 595 | true | ||
| 596 | ) | ||
| 597 | .is_err(), | ||
| 598 | "{name}" | ||
| 599 | ); | ||
| 600 | } | ||
| 601 | let mut corrupt = token.to_owned().into_bytes(); | ||
| 602 | let index = token.rfind('.').unwrap() + 3; | ||
| 603 | corrupt[index] = if corrupt[index] == b'A' { b'B' } else { b'A' }; | ||
| 604 | assert!( | ||
| 605 | signed_claims( | ||
| 606 | std::str::from_utf8(&corrupt).unwrap(), | ||
| 607 | &vector["keys"], | ||
| 608 | "fixture", | ||
| 609 | "fixture-nonce", | ||
| 610 | true | ||
| 611 | ) | ||
| 612 | .is_err() | ||
| 613 | ); | ||
| 614 | assert!( | ||
| 615 | signed_claims(token, &json!({"keys":[]}), "fixture", "fixture-nonce", true).is_err() | ||
| 616 | ); | ||
| 617 | let mut duplicate = vector["keys"].clone(); | ||
| 618 | duplicate["keys"] | ||
| 619 | .as_array_mut() | ||
| 620 | .unwrap() | ||
| 621 | .push(vector["keys"]["keys"][0].clone()); | ||
| 622 | assert!(signed_claims(token, &duplicate, "fixture", "fixture-nonce", true).is_err()); | ||
| 623 | let mut parts: Vec<_> = token.split('.').map(str::to_owned).collect(); | ||
| 624 | parts[0] = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","kid":"test-key"}"#); | ||
| 625 | assert!( | ||
| 626 | signed_claims( | ||
| 627 | &parts.join("."), | ||
| 628 | &vector["keys"], | ||
| 629 | "fixture", | ||
| 630 | "fixture-nonce", | ||
| 631 | true | ||
| 632 | ) | ||
| 633 | .is_err() | ||
| 634 | ); | ||
| 635 | } | ||
| 636 | |||
| 637 | #[test] | ||
| 638 | fn identities_never_link_by_name_or_email_and_cannot_gain_credentials_or_groups() { | ||
| 639 | let path = std::env::temp_dir().join(format!("guest-test-{}", uuid::Uuid::new_v4())); | ||
| 640 | let auth = auth::Store::new( | ||
| 641 | &path, | ||
| 642 | "https://snowglobe.paperclover.net", | ||
| 643 | "https://file.paperclover.net", | ||
| 644 | "auth.paperclover.net", | ||
| 645 | ) | ||
| 646 | .unwrap(); | ||
| 647 | { | ||
| 648 | let db = auth.db.lock().unwrap(); | ||
| 649 | db.execute("INSERT INTO users(id,profile) VALUES ('owner',?)", [json!({"username":"clover","enabled":true,"email":"same@example.invalid","emailVerified":true}).to_string()]).unwrap(); | ||
| 650 | db.execute("INSERT INTO roles VALUES ('admin','infra-admin')", []) | ||
| 651 | .unwrap(); | ||
| 652 | } | ||
| 653 | let first = account(&auth, "github", "123", "clover").unwrap(); | ||
| 654 | let repeat = account(&auth, "github", "123", "renamed").unwrap(); | ||
| 655 | let other = account(&auth, "astheno", "123", "clover").unwrap(); | ||
| 656 | assert_eq!(first, repeat); | ||
| 657 | assert_ne!(first, "owner"); | ||
| 658 | assert_ne!(first, other); | ||
| 659 | { | ||
| 660 | let db = auth.db.lock().unwrap(); | ||
| 661 | let profile = auth::user(&db, &first).unwrap(); | ||
| 662 | assert!(is_guest(&profile)); | ||
| 663 | assert!(profile["email"].is_null()); | ||
| 664 | assert_eq!(profile["groups"], json!([])); | ||
| 665 | assert!( | ||
| 666 | db.execute("INSERT INTO memberships VALUES (?,'admin')", [&first]) | ||
| 667 | .is_err() | ||
| 668 | ); | ||
| 669 | assert!(auth::set_password(&db, &first, "a-password-hash").is_err()); | ||
| 670 | assert!( | ||
| 671 | db.execute( | ||
| 672 | "UPDATE users SET profile=json_remove(profile,'$.kind') WHERE id=?", | ||
| 673 | [&first] | ||
| 674 | ) | ||
| 675 | .is_err() | ||
| 676 | ); | ||
| 677 | db.execute( | ||
| 678 | "UPDATE users SET profile=json_set(profile,'$.enabled',json('false')) WHERE id=?", | ||
| 679 | [&first], | ||
| 680 | ) | ||
| 681 | .unwrap(); | ||
| 682 | } | ||
| 683 | assert!(account(&auth, "github", "123", "clover").is_err()); | ||
| 684 | assert!( | ||
| 685 | auth.create_session(&other, "file", &HeaderMap::new(), None) | ||
| 686 | .is_err() | ||
| 687 | ); | ||
| 688 | assert!(auth.setup_link(&other).is_err()); | ||
| 689 | drop(auth); | ||
| 690 | std::fs::remove_dir_all(path).unwrap(); | ||
| 691 | } | ||
| 692 | } | ||
dashboard/src/main.rs+29| ... | @@ -4,6 +4,7 @@ mod cache; | ... | @@ -4,6 +4,7 @@ mod cache; |
| 4 | mod core; | 4 | mod core; |
| 5 | mod deploys; | 5 | mod deploys; |
| 6 | mod files; | 6 | mod files; |
| 7 | mod guest; | ||
| 7 | mod host; | 8 | mod host; |
| 8 | mod index; | 9 | mod index; |
| 9 | mod mcp; | 10 | mod mcp; |
| ... | @@ -405,6 +406,13 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { | ... | @@ -405,6 +406,13 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { |
| 405 | ) | 406 | ) |
| 406 | .map_err(|error| std::io::Error::other(error.message))?; | 407 | .map_err(|error| std::io::Error::other(error.message))?; |
| 407 | if let Some(path) = std::env::args().skip(1).next() { | 408 | if let Some(path) = std::env::args().skip(1).next() { |
| 409 | if path == "--guest-provider" { | ||
| 410 | let input = serde_json::from_reader(std::io::stdin())?; | ||
| 411 | let output = guest::provision(&auth, input) | ||
| 412 | .map_err(|error| std::io::Error::other(error.message))?; | ||
| 413 | println!("{output}"); | ||
| 414 | return Ok(()); | ||
| 415 | } | ||
| 408 | if path == "--oidc-client" { | 416 | if path == "--oidc-client" { |
| 409 | let input = serde_json::from_reader(std::io::stdin())?; | 417 | let input = serde_json::from_reader(std::io::stdin())?; |
| 410 | let output = oidc::provision(&auth, input) | 418 | let output = oidc::provision(&auth, input) |
| ... | @@ -552,6 +560,27 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { | ... | @@ -552,6 +560,27 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> { |
| 552 | .into_response() | 560 | .into_response() |
| 553 | }; | 561 | }; |
| 554 | } | 562 | } |
| 563 | if guest::is_guest(&account) { | ||
| 564 | return if path.starts_with("/api/") { | ||
| 565 | Error::new( | ||
| 566 | 403, | ||
| 567 | "Guest accounts can use Shale. Open Shale to continue.", | ||
| 568 | ) | ||
| 569 | .into_response() | ||
| 570 | } else { | ||
| 571 | ( | ||
| 572 | StatusCode::FOUND, | ||
| 573 | [( | ||
| 574 | "location", | ||
| 575 | format!( | ||
| 576 | "https://shale.{}/", | ||
| 577 | env("STUDIO_DOMAIN", "studio.test") | ||
| 578 | ), | ||
| 579 | )], | ||
| 580 | ) | ||
| 581 | .into_response() | ||
| 582 | }; | ||
| 583 | } | ||
| 555 | if !matches!( | 584 | if !matches!( |
| 556 | *request.method(), | 585 | *request.method(), |
| 557 | Method::GET | Method::HEAD | Method::OPTIONS | 586 | Method::GET | Method::HEAD | Method::OPTIONS |
dashboard/src/mcp.rs+1| ... | @@ -497,6 +497,7 @@ pub(crate) fn active_owner(app: &App, grant: &Value) -> Result<bool> { | ... | @@ -497,6 +497,7 @@ pub(crate) fn active_owner(app: &App, grant: &Value) -> Result<bool> { |
| 497 | Err(error) => return Err(error), | 497 | Err(error) => return Err(error), |
| 498 | }; | 498 | }; |
| 499 | Ok(profile["enabled"] == true | 499 | Ok(profile["enabled"] == true |
| 500 | && !guest::is_guest(&profile) | ||
| 500 | && (grant["resource"] != app.mcp.resource("observability") | 501 | && (grant["resource"] != app.mcp.resource("observability") |
| 501 | || array(&profile["groups"]) | 502 | || array(&profile["groups"]) |
| 502 | .iter() | 503 | .iter() |
dashboard/src/oidc.rs+90-13| ... | @@ -53,6 +53,47 @@ fn client(db: &Connection, id: &str) -> Result<Value> { | ... | @@ -53,6 +53,47 @@ fn client(db: &Connection, id: &str) -> Result<Value> { |
| 53 | .ok_or_else(|| invalid("invalid_client")) | 53 | .ok_or_else(|| invalid("invalid_client")) |
| 54 | } | 54 | } |
| 55 | 55 | ||
| 56 | fn guests_allowed(id: &str, config: &Value) -> bool { | ||
| 57 | config["allowGuests"] == true && (id == "shale" || id.starts_with("shale-preview-")) | ||
| 58 | } | ||
| 59 | |||
| 60 | pub(crate) fn guest_target(auth: &auth::Store, next: &str) -> Result<String> { | ||
| 61 | if next.len() > 8192 || next.contains('\\') || next.chars().any(char::is_control) { | ||
| 62 | return Err(invalid("invalid_request")); | ||
| 63 | } | ||
| 64 | let target = auth | ||
| 65 | .origin | ||
| 66 | .join(next) | ||
| 67 | .map_err(|_| invalid("invalid_request"))?; | ||
| 68 | if target.origin() != auth.origin.origin() | ||
| 69 | || target.path() != "/auth/oidc/authorize" | ||
| 70 | || target.fragment().is_some() | ||
| 71 | || !target.username().is_empty() | ||
| 72 | || target.password().is_some() | ||
| 73 | { | ||
| 74 | return Err(invalid("invalid_request")); | ||
| 75 | } | ||
| 76 | let query = fields(target.query().unwrap_or_default())?; | ||
| 77 | let id = query | ||
| 78 | .get("client_id") | ||
| 79 | .map(String::as_str) | ||
| 80 | .unwrap_or_default(); | ||
| 81 | let config = client(&auth.db.lock().unwrap(), id)?; | ||
| 82 | if !guests_allowed(id, &config) | ||
| 83 | || query.get("response_type").map(String::as_str) != Some("code") | ||
| 84 | || !array(&config["redirectUris"]) | ||
| 85 | .iter() | ||
| 86 | .any(|v| v.as_str() == query.get("redirect_uri").map(String::as_str)) | ||
| 87 | { | ||
| 88 | return Err(invalid("access_denied")); | ||
| 89 | } | ||
| 90 | Ok(format!( | ||
| 91 | "{}?{}", | ||
| 92 | target.path(), | ||
| 93 | target.query().unwrap_or_default() | ||
| 94 | )) | ||
| 95 | } | ||
| 96 | |||
| 56 | pub fn username(auth: &auth::Store, user_id: &str, client_id: &str) -> Result<String> { | 97 | pub fn username(auth: &auth::Store, user_id: &str, client_id: &str) -> Result<String> { |
| 57 | let db = auth.db.lock().unwrap(); | 98 | let db = auth.db.lock().unwrap(); |
| 58 | let user = auth::user(&db, user_id)?; | 99 | let user = auth::user(&db, user_id)?; |
| ... | @@ -152,8 +193,26 @@ pub fn provision(auth: &auth::Store, input: Value) -> Result<Value> { | ... | @@ -152,8 +193,26 @@ pub fn provision(auth: &auth::Store, input: Value) -> Result<Value> { |
| 152 | .filter(|s| s.len() >= 24) | 193 | .filter(|s| s.len() >= 24) |
| 153 | .map(str::to_owned) | 194 | .map(str::to_owned) |
| 154 | .unwrap_or_else(mcp::secret); | 195 | .unwrap_or_else(mcp::secret); |
| 155 | let config = | 196 | let allow_guests = request["allowGuests"] == true; |
| 156 | json!({"name":request["name"], "redirectUris":redirects, "usernameAliases":aliases}); | 197 | if allow_guests |
| 198 | && (!(id == "shale" || id.starts_with("shale-preview-")) | ||
| 199 | || redirects.iter().any(|v| { | ||
| 200 | url::Url::parse(string(v)).is_ok_and(|u| { | ||
| 201 | let site = auth | ||
| 202 | .origin | ||
| 203 | .host_str() | ||
| 204 | .unwrap() | ||
| 205 | .strip_prefix("snowglobe.") | ||
| 206 | .unwrap_or_default(); | ||
| 207 | u.path() != "/-/callback" | ||
| 208 | || u.host_str() != Some(format!("{id}.{site}").as_str()) | ||
| 209 | || u.query().is_some() | ||
| 210 | }) | ||
| 211 | })) | ||
| 212 | { | ||
| 213 | return Err(invalid("access_denied")); | ||
| 214 | } | ||
| 215 | let config = json!({"name":request["name"], "redirectUris":redirects, "usernameAliases":aliases, "allowGuests":allow_guests, "usernameRequired":request["usernameRequired"]==true}); | ||
| 157 | let old: Option<(String, String)> = tx | 216 | let old: Option<(String, String)> = tx |
| 158 | .query_row( | 217 | .query_row( |
| 159 | "SELECT secret_hash,config FROM oidc_clients WHERE id=?", | 218 | "SELECT secret_hash,config FROM oidc_clients WHERE id=?", |
| ... | @@ -203,16 +262,27 @@ fn jwt(db: &Connection, claims: &Value) -> Result<String> { | ... | @@ -203,16 +262,27 @@ fn jwt(db: &Connection, claims: &Value) -> Result<String> { |
| 203 | fn claims(user: &Value, config: &Value, scopes: &str) -> Value { | 262 | fn claims(user: &Value, config: &Value, scopes: &str) -> Value { |
| 204 | let mut value = json!({"sub":user["id"]}); | 263 | let mut value = json!({"sub":user["id"]}); |
| 205 | let scopes: Vec<_> = scopes.split_whitespace().collect(); | 264 | let scopes: Vec<_> = scopes.split_whitespace().collect(); |
| 206 | if scopes.contains(&"profile") { | 265 | // Shale requests only openid but needs a stable username to bind its account. |
| 266 | if scopes.contains(&"profile") || config["usernameRequired"] == true { | ||
| 207 | let username = string(&user["username"]); | 267 | let username = string(&user["username"]); |
| 208 | value["preferred_username"] = config["usernameAliases"][username] | 268 | value["preferred_username"] = config["usernameAliases"][username] |
| 209 | .as_str() | 269 | .as_str() |
| 210 | .map(|v| json!(v)) | 270 | .map(|v| json!(v)) |
| 211 | .unwrap_or_else(|| json!(username)); | 271 | .unwrap_or_else(|| json!(username)); |
| 212 | let name = format!("{} {}", string(&user["firstName"]), string(&user["lastName"])); | 272 | } |
| 213 | if !name.trim().is_empty() { value["name"] = json!(name.trim()); } | 273 | if scopes.contains(&"profile") { |
| 214 | for (claim,field) in [("given_name","firstName"),("family_name","lastName")] { | 274 | let name = format!( |
| 215 | if !string(&user[field]).is_empty() { value[claim] = user[field].clone(); } | 275 | "{} {}", |
| 276 | string(&user["firstName"]), | ||
| 277 | string(&user["lastName"]) | ||
| 278 | ); | ||
| 279 | if !name.trim().is_empty() { | ||
| 280 | value["name"] = json!(name.trim()); | ||
| 281 | } | ||
| 282 | for (claim, field) in [("given_name", "firstName"), ("family_name", "lastName")] { | ||
| 283 | if !string(&user[field]).is_empty() { | ||
| 284 | value[claim] = user[field].clone(); | ||
| 285 | } | ||
| 216 | } | 286 | } |
| 217 | } | 287 | } |
| 218 | if scopes.contains(&"email") && !string(&user["email"]).is_empty() { | 288 | if scopes.contains(&"email") && !string(&user["email"]).is_empty() { |
| ... | @@ -296,6 +366,9 @@ fn authenticated_client( | ... | @@ -296,6 +366,9 @@ fn authenticated_client( |
| 296 | fn tokens(db: &Connection, auth: &auth::Store, data: &Value, nonce: Option<&str>) -> Result<Value> { | 366 | fn tokens(db: &Connection, auth: &auth::Store, data: &Value, nonce: Option<&str>) -> Result<Value> { |
| 297 | let user = eligible(db, string(&data["user"]), string(&data["session"]))?; | 367 | let user = eligible(db, string(&data["user"]), string(&data["session"]))?; |
| 298 | let config = client(db, string(&data["client"]))?; | 368 | let config = client(db, string(&data["client"]))?; |
| 369 | if guest::is_guest(&user) && !guests_allowed(string(&data["client"]), &config) { | ||
| 370 | return Err(invalid("access_denied")); | ||
| 371 | } | ||
| 299 | let scope = string(&data["scope"]); | 372 | let scope = string(&data["scope"]); |
| 300 | let access = mcp::secret(); | 373 | let access = mcp::secret(); |
| 301 | let family = string(&data["family"]); | 374 | let family = string(&data["family"]); |
| ... | @@ -399,6 +472,9 @@ async fn handle(app: &App, request: Request) -> Result<Response> { | ... | @@ -399,6 +472,9 @@ async fn handle(app: &App, request: Request) -> Result<Response> { |
| 399 | return Err(invalid("invalid_request")); | 472 | return Err(invalid("invalid_request")); |
| 400 | } | 473 | } |
| 401 | let user = auth.session(&headers, "dashboard")?; | 474 | let user = auth.session(&headers, "dashboard")?; |
| 475 | if guest::is_guest(&user) && !guests_allowed(id, &config) { | ||
| 476 | return Err(Error::new(403, "access_denied")); | ||
| 477 | } | ||
| 402 | let session = mcp::hash(&auth::cookie(&headers, "__Host-snow-session").unwrap_or_default()); | 478 | let session = mcp::hash(&auth::cookie(&headers, "__Host-snow-session").unwrap_or_default()); |
| 403 | let auth_time: i64 = auth | 479 | let auth_time: i64 = auth |
| 404 | .db | 480 | .db |
| ... | @@ -507,12 +583,13 @@ async fn handle(app: &App, request: Request) -> Result<Response> { | ... | @@ -507,12 +583,13 @@ async fn handle(app: &App, request: Request) -> Result<Response> { |
| 507 | let data: Option<(String,String,String,String)> = db.query_row("SELECT user_id,client_id,session_hash,scope FROM oidc_tokens WHERE hash=? AND kind='access' AND expires>?",sql![mcp::hash(token),now() as i64],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).optional()?; | 583 | let data: Option<(String,String,String,String)> = db.query_row("SELECT user_id,client_id,session_hash,scope FROM oidc_tokens WHERE hash=? AND kind='access' AND expires>?",sql![mcp::hash(token),now() as i64],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).optional()?; |
| 508 | let (user, client_id, session, scope) = | 584 | let (user, client_id, session, scope) = |
| 509 | data.ok_or_else(|| Error::new(401, "invalid_token"))?; | 585 | data.ok_or_else(|| Error::new(401, "invalid_token"))?; |
| 510 | return Ok(axum::Json(claims( | 586 | let profile = |
| 511 | &eligible(&db, &user, &session).map_err(|_| Error::new(401, "invalid_token"))?, | 587 | eligible(&db, &user, &session).map_err(|_| Error::new(401, "invalid_token"))?; |
| 512 | &client(&db, &client_id)?, | 588 | let config = client(&db, &client_id)?; |
| 513 | &scope, | 589 | if guest::is_guest(&profile) && !guests_allowed(&client_id, &config) { |
| 514 | )) | 590 | return Err(Error::new(401, "invalid_token")); |
| 515 | .into_response()); | 591 | } |
| 592 | return Ok(axum::Json(claims(&profile, &config, &scope)).into_response()); | ||
| 516 | } | 593 | } |
| 517 | if method != Method::POST || !matches!(path.as_str(), "/auth/oidc/token" | "/auth/oidc/revoke") | 594 | if method != Method::POST || !matches!(path.as_str(), "/auth/oidc/token" | "/auth/oidc/revoke") |
| 518 | { | 595 | { |
dashboard/src/users.rs+9| ... | @@ -210,6 +210,15 @@ pub async fn route( | ... | @@ -210,6 +210,15 @@ pub async fn route( |
| 210 | let mut db = app.auth.db.lock().unwrap(); | 210 | let mut db = app.auth.db.lock().unwrap(); |
| 211 | let transaction = db.transaction()?; | 211 | let transaction = db.transaction()?; |
| 212 | let mut user = auth::user(&transaction, id)?; | 212 | let mut user = auth::user(&transaction, id)?; |
| 213 | if guest::is_guest(&user) | ||
| 214 | && matches!(parts, [_, "password"] | [_, "groups", _]) | ||
| 215 | && method != Method::GET | ||
| 216 | { | ||
| 217 | return Err(Error::new( | ||
| 218 | 400, | ||
| 219 | "Guests can use Shale only. Invite a separate account for other services.", | ||
| 220 | )); | ||
| 221 | } | ||
| 213 | let own = user["username"] == me["name"]; | 222 | let own = user["username"] == me["name"]; |
| 214 | let value = match parts { | 223 | let value = match parts { |
| 215 | [_] if method == Method::PATCH => { | 224 | [_] if method == Method::PATCH => { |
dashboard/tests/guest-jwt.json created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | { | ||
| 2 | "keys": { | ||
| 3 | "keys": [ | ||
| 4 | { | ||
| 5 | "kty": "EC", | ||
| 6 | "crv": "P-256", | ||
| 7 | "kid": "test-key", | ||
| 8 | "alg": "ES256", | ||
| 9 | "use": "sig", | ||
| 10 | "x": "1nEiAOMIPlqUavM6Gr0pUwIdbZY-RNwRk7dPidTrHyA", | ||
| 11 | "y": "SdqTpMg5HzJFKqMOpBobpIg9rzNPyYqWCOkiqT-MECc" | ||
| 12 | } | ||
| 13 | ] | ||
| 14 | }, | ||
| 15 | "valid": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiJleHRlcm5hbC0xMjMiLCJhdWQiOiJmaXh0dXJlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsIm5vbmNlIjoiZml4dHVyZS1ub25jZSJ9.nXkYh8OQL25LxMMJY8wmpcUPjCRmI3jdO1TciKZSctWaRFAD9g2DTloulVT6GJ0-0c9fm-2YlBKMX-xA0AZKBQ", | ||
| 16 | "invalid": { | ||
| 17 | "issuer": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2V2aWwuaW52YWxpZCIsInN1YiI6ImV4dGVybmFsLTEyMyIsImF1ZCI6ImZpeHR1cmUiLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwibm9uY2UiOiJmaXh0dXJlLW5vbmNlIn0.YzADf1EGii-hK8mQBJ07eLfxYrgRjwHAsaLaWei8dtVXUVqH82xCIFnxruxprzoJJGQbTaIhpOSSSPSMRSevpQ", | ||
| 18 | "audience": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiJleHRlcm5hbC0xMjMiLCJhdWQiOiJvd25lci1jbGllbnQiLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwibm9uY2UiOiJmaXh0dXJlLW5vbmNlIn0.ph9cC8BNj31XMp1MFwVM1K1eq2xrqfe5hGRlDsB5_TFrd3sGrKixWZJGJ3KnBu18JCT-HxDNhigdeY7xHOBB-Q", | ||
| 19 | "nonce": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiJleHRlcm5hbC0xMjMiLCJhdWQiOiJmaXh0dXJlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsIm5vbmNlIjoiYW5vdGhlciJ9.d58kIFpSesd-Cqd5wwQ9XHoOtTWMzMUfvIr_8F26ZaLa2TccwhM2028h42Sg1QhSeIMSBOelOEcARQYPgO-w_Q", | ||
| 20 | "expiry": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiJleHRlcm5hbC0xMjMiLCJhdWQiOiJmaXh0dXJlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjEsIm5vbmNlIjoiZml4dHVyZS1ub25jZSJ9.eyYxr_J4Pz_B-8YxllVPfBuuy0ysZcBnwd4VKjqnuo1Coc3fD5b1ecsB1iqXO4ToO12kgMXQewZlE_WjmzBcZA", | ||
| 21 | "future": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiJleHRlcm5hbC0xMjMiLCJhdWQiOiJmaXh0dXJlIiwiaWF0Ijo0MTAyNDQ0ODAwLCJleHAiOjQxMDI0NDQ4MDAsIm5vbmNlIjoiZml4dHVyZS1ub25jZSJ9.Pt7LcZgxxtH8qDYLuUEeEKNRjNCL3cbnJk6qq86-lhunbd8sgTR8USPVys2WPZAdrZBIXxYXfl43iEKybN9W6A", | ||
| 22 | "subject": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiIiLCJhdWQiOiJmaXh0dXJlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsIm5vbmNlIjoiZml4dHVyZS1ub25jZSJ9.LvYyzvg0HAzhAWKo76lWyPktuoxSdmXySfXjiIWea40O0fdqGa-Du73lHzTLUJ3BDS5yWd5gF3doHbYrhbL4TA", | ||
| 23 | "authorizedParty": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiJleHRlcm5hbC0xMjMiLCJhdWQiOiJmaXh0dXJlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsIm5vbmNlIjoiZml4dHVyZS1ub25jZSIsImF6cCI6Im90aGVyIn0.W8C8sSVCpLn32JAZ6VKoJyUV9Ew_-eBsatAtynsnzJpj8l28sKv_bo8lGPq5Ef3HTxiqEGtgdWRR1wO92k_-Tw", | ||
| 24 | "multipleAudience": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiJleHRlcm5hbC0xMjMiLCJhdWQiOlsiZml4dHVyZSIsIm90aGVyIl0sImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo0MTAyNDQ0ODAwLCJub25jZSI6ImZpeHR1cmUtbm9uY2UifQ.D3HM97jMyMnqatXGcjDRopQIYe-ZKjzMuvVNt5dkqFW-tqXKXJlNXGd1hfpCc8XIdK4pL_iRaYwIbnMnVfZoQA" | ||
| 25 | } | ||
| 26 | } | ||
dashboard/web/components/Explorer.tsx+25-2| ... | @@ -234,6 +234,9 @@ export function Explorer(props: { id: string; client: Client; root: string }) { | ... | @@ -234,6 +234,9 @@ export function Explorer(props: { id: string; client: Client; root: string }) { |
| 234 | const [mapShown, setMapShown] = createSignal(localStorage.getItem(mapKey) !== "hidden"); | 234 | const [mapShown, setMapShown] = createSignal(localStorage.getItem(mapKey) !== "hidden"); |
| 235 | let anchor: string | undefined; | 235 | let anchor: string | undefined; |
| 236 | let table: HTMLTableElement | undefined; | 236 | let table: HTMLTableElement | undefined; |
| 237 | let filesScroll!: HTMLDivElement; | ||
| 238 | let lastCursor: string | undefined; | ||
| 239 | let revealCursor: string | undefined; | ||
| 237 | let patternInput!: HTMLInputElement; | 240 | let patternInput!: HTMLInputElement; |
| 238 | 241 | ||
| 239 | const known = new WeakMap<Entry, Row>(); | 242 | const known = new WeakMap<Entry, Row>(); |
| ... | @@ -339,9 +342,29 @@ export function Explorer(props: { id: string; client: Client; root: string }) { | ... | @@ -339,9 +342,29 @@ export function Explorer(props: { id: string; client: Client; root: string }) { |
| 339 | refreshTree(); | 342 | refreshTree(); |
| 340 | })); | 343 | })); |
| 341 | 344 | ||
| 345 | // A listing update must not scroll back to an old selection after the user scrolled elsewhere. | ||
| 346 | // Keep a new cursor pending until its row arrives, then reveal it once in this scroller only. | ||
| 342 | createEffect(on([cursor, rows], ([path, all]) => { | 347 | createEffect(on([cursor, rows], ([path, all]) => { |
| 348 | if (path !== lastCursor) { | ||
| 349 | lastCursor = path; | ||
| 350 | revealCursor = path; | ||
| 351 | } | ||
| 352 | if (!path || revealCursor !== path) return; | ||
| 343 | const index = all.findIndex((row) => row.path === path); | 353 | const index = all.findIndex((row) => row.path === path); |
| 344 | if (index >= 0) queueMicrotask(() => table?.querySelector(`[data-row="${index}"]`)?.scrollIntoView({ block: "nearest" })); | 354 | if (index < 0) return; |
| 355 | revealCursor = undefined; | ||
| 356 | queueMicrotask(() => { | ||
| 357 | if (cursor() !== path) return; | ||
| 358 | const currentIndex = rows().findIndex((row) => row.path === path); | ||
| 359 | const row = table?.querySelector(`[data-row="${currentIndex}"]`); | ||
| 360 | if (!row || !filesScroll) return; | ||
| 361 | const rect = row.getBoundingClientRect(); | ||
| 362 | const view = filesScroll.getBoundingClientRect(); | ||
| 363 | const top = view.top + filesScroll.clientTop + (table?.tHead?.getBoundingClientRect().height ?? 0); | ||
| 364 | const bottom = view.top + filesScroll.clientTop + filesScroll.clientHeight; | ||
| 365 | if (rect.top < top) filesScroll.scrollTop += rect.top - top; | ||
| 366 | else if (rect.bottom > bottom) filesScroll.scrollTop += rect.bottom - bottom; | ||
| 367 | }); | ||
| 345 | })); | 368 | })); |
| 346 | 369 | ||
| 347 | const select = (row: Row, how: "only" | "toggle" | "range") => { | 370 | const select = (row: Row, how: "only" | "toggle" | "range") => { |
| ... | @@ -948,7 +971,7 @@ export function Explorer(props: { id: string; client: Client; root: string }) { | ... | @@ -948,7 +971,7 @@ export function Explorer(props: { id: string; client: Client; root: string }) { |
| 948 | </label> | 971 | </label> |
| 949 | </div> | 972 | </div> |
| 950 | 973 | ||
| 951 | <div class="files-scroll"> | 974 | <div class="files-scroll" ref={filesScroll}> |
| 952 | <Loaded data={list} what="this folder" retry={refetch} skeleton={ | 975 | <Loaded data={list} what="this folder" retry={refetch} skeleton={ |
| 953 | <div class="files-skeleton"> | 976 | <div class="files-skeleton"> |
| 954 | <For each={[62, 48, 71, 55, 66, 40, 58]}> | 977 | <For each={[62, 48, 71, 55, 66, 40, 58]}> |
dashboard/web/pages/SignIn.css+4-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | /* The original Keycloak theme stays shared; these adapt its document to the dashboard shell. */ | 1 | /* The original Snow Sign On theme stays intact; these adapt its document to the dashboard shell. */ |
| 2 | body { font: 16px "Name Sans", sans-serif; } | 2 | body { font: 16px "Name Sans", sans-serif; } |
| 3 | #root { display: contents; } | 3 | #root { display: contents; } |
| 4 | .pf-v5-c-login__main button, .pf-v5-c-login__main input { font-family: inherit; } | 4 | .pf-v5-c-login__main button, .pf-v5-c-login__main input { font-family: inherit; } |
| ... | @@ -8,3 +8,6 @@ body { font: 16px "Name Sans", sans-serif; } | ... | @@ -8,3 +8,6 @@ body { font: 16px "Name Sans", sans-serif; } |
| 8 | .pf-v5-c-login__main .checkbox input { display: block; position: absolute; opacity: 0; } | 8 | .pf-v5-c-login__main .checkbox input { display: block; position: absolute; opacity: 0; } |
| 9 | .pf-v5-c-login__main .checkbox label:has(:focus-visible) { outline: 2px solid var(--primary); } | 9 | .pf-v5-c-login__main .checkbox label:has(:focus-visible) { outline: 2px solid var(--primary); } |
| 10 | .setup-intro { margin-bottom: 1rem; text-align: center; } | 10 | .setup-intro { margin-bottom: 1rem; text-align: center; } |
| 11 | .guest-providers { margin-top: 1.5rem; } | ||
| 12 | .guest-providers p { text-align: center; } | ||
| 13 | .guest-providers a + a { margin-top: .75rem; } |
dashboard/web/pages/SignIn.tsx+13-4| ... | @@ -1,19 +1,22 @@ | ... | @@ -1,19 +1,22 @@ |
| 1 | import { createEffect, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"; | 1 | import { createEffect, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"; |
| 2 | import { authReason, authRequest } from "../auth.ts"; | 2 | import { authReason, authRequest } from "../auth.ts"; |
| 3 | import theme from "../../../service/keycloak/theme/login/resources/css/styles.css?inline"; | 3 | import theme from "../sso/css/styles.css?inline"; |
| 4 | import adjustments from "./SignIn.css?inline"; | 4 | import adjustments from "./SignIn.css?inline"; |
| 5 | 5 | ||
| 6 | export function SignIn() { | 6 | export function SignIn() { |
| 7 | const params = new URLSearchParams(window.location.search); | 7 | const params = new URLSearchParams(window.location.search); |
| 8 | const setup = params.get("setup"); | 8 | const setup = params.get("setup"); |
| 9 | const [status, { refetch }] = createResource(() => authRequest<{ csrf: string; setup?: string }>(`status${setup ? `?setup=${encodeURIComponent(setup)}` : ""}`)); | 9 | const statusQuery = new URLSearchParams(); |
| 10 | if (setup) statusQuery.set("setup", setup); | ||
| 11 | if (params.get("next")) statusQuery.set("next", params.get("next")!); | ||
| 12 | const [status, { refetch }] = createResource(() => authRequest<{ csrf: string; setup?: string; providers?: { id: string; name: string }[] }>(`status?${statusQuery}`)); | ||
| 10 | const [username, setUsername] = createSignal(""); | 13 | const [username, setUsername] = createSignal(""); |
| 11 | const [password, setPassword] = createSignal(""); | 14 | const [password, setPassword] = createSignal(""); |
| 12 | const [email, setEmail] = createSignal(""); | 15 | const [email, setEmail] = createSignal(""); |
| 13 | const [remember, setRemember] = createSignal(false); | 16 | const [remember, setRemember] = createSignal(false); |
| 14 | const [visible, setVisible] = createSignal(false); | 17 | const [visible, setVisible] = createSignal(false); |
| 15 | const [busy, setBusy] = createSignal(false); | 18 | const [busy, setBusy] = createSignal(false); |
| 16 | const [error, setError] = createSignal(""); | 19 | const [error, setError] = createSignal(params.has("guest_error") ? "Guest sign-in wasn't completed. Choose a provider to try again." : ""); |
| 17 | const [notice, setNotice] = createSignal(""); | 20 | const [notice, setNotice] = createSignal(""); |
| 18 | let conditional: AbortController | undefined; | 21 | let conditional: AbortController | undefined; |
| 19 | let disposed = false; | 22 | let disposed = false; |
| ... | @@ -105,6 +108,12 @@ export function SignIn() { | ... | @@ -105,6 +108,12 @@ export function SignIn() { |
| 105 | </form> | 108 | </form> |
| 106 | </div></div> | 109 | </div></div> |
| 107 | <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> | 110 | <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> |
| 111 | <Show when={!setup && status()?.providers?.length}> | ||
| 112 | <div id="kc-social-providers" class="guest-providers"> | ||
| 113 | <p>sign in as a Shale guest</p> | ||
| 114 | <For each={status()?.providers}>{(provider) => <a 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> | ||
| 115 | </div> | ||
| 116 | </Show> | ||
| 108 | </Show> | 117 | </Show> |
| 109 | </div></div> | 118 | </div></div> |
| 110 | </div> | 119 | </div> |
dashboard/web/pages/Users.tsx+9-2| ... | @@ -109,6 +109,8 @@ export function Users() { | ... | @@ -109,6 +109,8 @@ export function Users() { |
| 109 | 109 | ||
| 110 | function StateTag(props: { user: User }) { | 110 | function StateTag(props: { user: User }) { |
| 111 | return ( | 111 | return ( |
| 112 | <> | ||
| 113 | <Show when={props.user.kind === "guest"}><span class="chip">Shale guest</span></Show> | ||
| 112 | <Show when={props.user.enabled} fallback={<span class="chip">disabled</span>}> | 114 | <Show when={props.user.enabled} fallback={<span class="chip">disabled</span>}> |
| 113 | <Show when={props.user.requiredActions.length}> | 115 | <Show when={props.user.requiredActions.length}> |
| 114 | <span class="chip warn" tabindex="0" | 116 | <span class="chip warn" tabindex="0" |
| ... | @@ -117,6 +119,7 @@ function StateTag(props: { user: User }) { | ... | @@ -117,6 +119,7 @@ function StateTag(props: { user: User }) { |
| 117 | </span> | 119 | </span> |
| 118 | </Show> | 120 | </Show> |
| 119 | </Show> | 121 | </Show> |
| 122 | </> | ||
| 120 | ); | 123 | ); |
| 121 | } | 124 | } |
| 122 | 125 | ||
| ... | @@ -510,7 +513,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { | ... | @@ -510,7 +513,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 510 | <div class="toggles" role="group" aria-label="Groups"> | 513 | <div class="toggles" role="group" aria-label="Groups"> |
| 511 | <For each={props.data.groups}> | 514 | <For each={props.data.groups}> |
| 512 | {(group) => ( | 515 | {(group) => ( |
| 513 | <button class="chip toggle" aria-pressed={member(group)} disabled={busy() === group.id} | 516 | <button class="chip toggle" aria-pressed={member(group)} disabled={props.user.kind === "guest" || busy() === group.id} |
| 514 | data-tip={groupTip(group.name, props.data)} onClick={() => toggleGroup(group)}> | 517 | data-tip={groupTip(group.name, props.data)} onClick={() => toggleGroup(group)}> |
| 515 | {group.name} | 518 | {group.name} |
| 516 | </button> | 519 | </button> |
| ... | @@ -521,17 +524,20 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { | ... | @@ -521,17 +524,20 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 521 | can open | 524 | can open |
| 522 | </h2> | 525 | </h2> |
| 523 | <Show when={props.user.enabled} fallback={<p class="muted">nothing while disabled</p>}> | 526 | <Show when={props.user.enabled} fallback={<p class="muted">nothing while disabled</p>}> |
| 527 | <Show when={props.user.kind !== "guest"} fallback={<p class="muted">Shale only</p>}> | ||
| 524 | <Show when={!access().everything} fallback={<p class="muted">everything</p>}> | 528 | <Show when={!access().everything} fallback={<p class="muted">everything</p>}> |
| 525 | <Grants apps={access().apps} pages={access().pages} used={used()} /> | 529 | <Grants apps={access().apps} pages={access().pages} used={used()} /> |
| 526 | </Show> | 530 | </Show> |
| 531 | </Show> | ||
| 527 | </Show> | 532 | </Show> |
| 528 | </section> | 533 | </section> |
| 529 | <section class="card"> | 534 | <section class="card"> |
| 530 | <h2 class="card-title"> | 535 | <h2 class="card-title"> |
| 531 | sign-in | 536 | sign-in |
| 532 | <span class="spacer" /> | 537 | <span class="spacer" /> |
| 533 | <button class="button small" onClick={setPassword}>set password</button> | 538 | <Show when={props.user.kind !== "guest"}><button class="button small" onClick={setPassword}>set password</button></Show> |
| 534 | </h2> | 539 | </h2> |
| 540 | <Show when={props.user.kind !== "guest"} fallback={<p>signs in with {props.user.guestProvider === "github" ? "GitHub" : "Astheno"}</p>}> | ||
| 535 | <Loaded data={credentials} what="sign-in methods" retry={credentialActions.refetch} | 541 | <Loaded data={credentials} what="sign-in methods" retry={credentialActions.refetch} |
| 536 | skeleton={<div class="skeleton" style={{ height: "60px" }} />}> | 542 | skeleton={<div class="skeleton" style={{ height: "60px" }} />}> |
| 537 | {(list) => { | 543 | {(list) => { |
| ... | @@ -556,6 +562,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { | ... | @@ -556,6 +562,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { |
| 556 | <button class="button small" disabled={!props.user.enabled || busy() === "link"} onClick={createLink}>create setup link</button>{" "} | 562 | <button class="button small" disabled={!props.user.enabled || busy() === "link"} onClick={createLink}>create setup link</button>{" "} |
| 557 | <button class="button small" disabled={busy() === "link"} onClick={revokeLink}>revoke link</button> | 563 | <button class="button small" disabled={busy() === "link"} onClick={revokeLink}>revoke link</button> |
| 558 | <Show when={setupLink()}><p style={{"overflow-wrap":"anywhere"}}><Copy value={setupLink()} /></p></Show> | 564 | <Show when={setupLink()}><p style={{"overflow-wrap":"anywhere"}}><Copy value={setupLink()} /></p></Show> |
| 565 | </Show> | ||
| 559 | </section> | 566 | </section> |
| 560 | </div> | 567 | </div> |
| 561 | 568 |
dashboard/web/sso/css/styles.css created+345| ... | @@ -0,0 +1,345 @@ | ||
| 1 | @import "https://file.paperclover.net/.static/font.css"; | ||
| 2 | |||
| 3 | /* reset */ | ||
| 4 | html, | ||
| 5 | body { | ||
| 6 | height: 100%; | ||
| 7 | } | ||
| 8 | |||
| 9 | h1, | ||
| 10 | h2, | ||
| 11 | h3, | ||
| 12 | h4, | ||
| 13 | h5, | ||
| 14 | h6 { | ||
| 15 | font-size: unset; | ||
| 16 | font-weight: unset; | ||
| 17 | } | ||
| 18 | |||
| 19 | :where( | ||
| 20 | html, | ||
| 21 | body, | ||
| 22 | p, | ||
| 23 | ol, | ||
| 24 | ul, | ||
| 25 | li, | ||
| 26 | dl, | ||
| 27 | dt, | ||
| 28 | dd, | ||
| 29 | blockquote, | ||
| 30 | figure, | ||
| 31 | fieldset, | ||
| 32 | legend, | ||
| 33 | textarea, | ||
| 34 | pre, | ||
| 35 | iframe, | ||
| 36 | hr, | ||
| 37 | h1, | ||
| 38 | h2, | ||
| 39 | h3, | ||
| 40 | h4, | ||
| 41 | h5, | ||
| 42 | h6 | ||
| 43 | ) { | ||
| 44 | margin: 0; | ||
| 45 | padding: 0; | ||
| 46 | } | ||
| 47 | |||
| 48 | *, :after, :before { | ||
| 49 | box-sizing: border-box; | ||
| 50 | font: unset; | ||
| 51 | } | ||
| 52 | |||
| 53 | /* root */ | ||
| 54 | body { | ||
| 55 | color-scheme: light dark; | ||
| 56 | background-color: #f9feff; | ||
| 57 | background-image: url(../img/miku-light.png); | ||
| 58 | background-size: auto 100%; | ||
| 59 | background-position: right top; | ||
| 60 | background-repeat: no-repeat; | ||
| 61 | color: black; | ||
| 62 | --text: black; | ||
| 63 | |||
| 64 | --header: light-dark(#1a46cd, #938cff); | ||
| 65 | --primary: light-dark(#00238f, #938cff); | ||
| 66 | } | ||
| 67 | @media (prefers-color-scheme: dark) { | ||
| 68 | body { | ||
| 69 | background-image: url(../img/miku-dark.png); | ||
| 70 | background-color: #2f4b67; | ||
| 71 | color: white; | ||
| 72 | --text: white; | ||
| 73 | } | ||
| 74 | } | ||
| 75 | @media (max-width: 1313px) { | ||
| 76 | body { | ||
| 77 | background-position: right calc(-100px + 50%) top; | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | /* sidebar */ | ||
| 82 | .pf-v5-c-login__main { | ||
| 83 | max-width: 30rem; | ||
| 84 | padding: 2rem; | ||
| 85 | height: 100%; | ||
| 86 | display: flex; | ||
| 87 | flex-direction: column; | ||
| 88 | justify-content: center; | ||
| 89 | --bg: light-dark(rgba(30, 30, 30, 0.1), rgba(0, 0, 0, 0.3)); | ||
| 90 | background-color: var(--bg); | ||
| 91 | border-style: solid; | ||
| 92 | border-right-width: 4px; | ||
| 93 | border-color: var(--bg); | ||
| 94 | backdrop-filter: blur(4px); | ||
| 95 | overflow-y: auto; | ||
| 96 | } | ||
| 97 | .kc-logo-text { | ||
| 98 | font-size: 3rem; | ||
| 99 | text-align: center; | ||
| 100 | color: var(--header); | ||
| 101 | } | ||
| 102 | #kc-page-title, #kc-info-wrapper { | ||
| 103 | text-align: center; | ||
| 104 | color: rgb(from var(--text) r g b / 0.8); | ||
| 105 | } | ||
| 106 | #kc-page-title { | ||
| 107 | margin-bottom: 1rem; | ||
| 108 | } | ||
| 109 | #kc-info-wrapper { | ||
| 110 | margin-top: 1rem; | ||
| 111 | } | ||
| 112 | #kc-form-options { | ||
| 113 | margin-bottom: 1rem; | ||
| 114 | } | ||
| 115 | a { | ||
| 116 | color: var(--header); | ||
| 117 | text-decoration: dotted underline; | ||
| 118 | } | ||
| 119 | a:hover { | ||
| 120 | text-decoration: underline; | ||
| 121 | background-color: rgb(from var(--header) r g b / 0.2); | ||
| 122 | } | ||
| 123 | |||
| 124 | /* branding */ | ||
| 125 | .kc-logo-text::before { | ||
| 126 | display: block; | ||
| 127 | content: " "; | ||
| 128 | width: 30%; | ||
| 129 | aspect-ratio: 1; | ||
| 130 | margin: auto; | ||
| 131 | background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9Ii0yIC0yIDI4IDI4IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJNMTAgMjBMOC43NSAxNy41TDYgMThNMTAgNEw4Ljc1IDYuNUw2IDZNMTQgMjBMMTUuMjUgMTcuNUwxOCAxOE0xNCA0TDE1LjI1IDYuNUwxOCA2TTE3IDIxTDE0IDE1TTE0IDE1SDEwTTE0IDE1TDE1LjUgMTJNMTAgMTVMNyAyMU0xMCAxNUw4LjUgMTJNMTcgM0wxNCA5TTE0IDlMMTUuNSAxMk0xNCA5SDEwTTE1LjUgMTJIMjJNMiAxMkg4LjVNOC41IDEyTDEwIDlNMTAgOUw3IDNNMjAgMTBMMTguNSAxMkwyMCAxNE00IDEwTDUuNSAxMkw0IDE0IiBzdHJva2U9InVybCgjcGFpbnQwX2xpbmVhcl81NDhfMTkpIiBzdHJva2Utd2lkdGg9IjMiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8cGF0aCBkPSJNMTAgMjBMOC43NSAxNy41TDYgMThNMTAgNEw4Ljc1IDYuNUw2IDZNMTQgMjBMMTUuMjUgMTcuNUwxOCAxOE0xNCA0TDE1LjI1IDYuNUwxOCA2TTE3IDIxTDE0IDE1TTE0IDE1SDEwTTE0IDE1TDE1LjUgMTJNMTAgMTVMNyAyMU0xMCAxNUw4LjUgMTJNMTcgM0wxNCA5TTE0IDlMMTUuNSAxMk0xNCA5SDEwTTE1LjUgMTJIMjJNMiAxMkg4LjVNOC41IDEyTDEwIDlNMTAgOUw3IDNNMjAgMTBMMTguNSAxMkwyMCAxNE00IDEwTDUuNSAxMkw0IDE0IiBzdHJva2U9InVybCgjcGFpbnQxX2xpbmVhcl81NDhfMTkpIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8ZGVmcz4KPGxpbmVhckdyYWRpZW50IGlkPSJwYWludDBfbGluZWFyXzU0OF8xOSIgeDE9IjQiIHkxPSItMiIgeDI9IjE4LjUiIHkyPSIyNSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgo8c3RvcCBzdG9wLWNvbG9yPSIjMjIzRDk5Ii8+CjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzE1NDM5MiIvPgo8L2xpbmVhckdyYWRpZW50Pgo8bGluZWFyR3JhZGllbnQgaWQ9InBhaW50MV9saW5lYXJfNTQ4XzE5IiB4MT0iMTAiIHkxPSItMyIgeDI9IjE4IiB5Mj0iMjciIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KPHN0b3Agc3RvcC1jb2xvcj0iI0YyRTNGRiIvPgo8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGMkY4RkYiLz4KPC9saW5lYXJHcmFkaWVudD4KPC9kZWZzPgo8L3N2Zz4K); | ||
| 132 | background-size: cover; | ||
| 133 | } | ||
| 134 | #kc-page-title, | ||
| 135 | #kc-info-wrapper, | ||
| 136 | .checkbox, | ||
| 137 | a, | ||
| 138 | #kc-form-buttons, | ||
| 139 | .pf-v5-c-helper-text__item-text, | ||
| 140 | .pf-v5-c-alert__title, | ||
| 141 | input[type="submit"], | ||
| 142 | input[type="button"], | ||
| 143 | button { | ||
| 144 | text-transform: lowercase; | ||
| 145 | } | ||
| 146 | |||
| 147 | /* text input */ | ||
| 148 | .pf-v5-c-form__group { | ||
| 149 | margin-bottom: 1rem; | ||
| 150 | } | ||
| 151 | .pf-v5-c-form__group input:not([type="checkbox"]) { | ||
| 152 | width: 100%; | ||
| 153 | } | ||
| 154 | .pf-v5-c-form__group > div:first-child { | ||
| 155 | display: flex; | ||
| 156 | } | ||
| 157 | .pf-v5-c-form__label { | ||
| 158 | margin-bottom: 0.25rem; | ||
| 159 | text-transform: lowercase; | ||
| 160 | user-select: none; | ||
| 161 | display: block; | ||
| 162 | } | ||
| 163 | .pf-v5-c-input-group { | ||
| 164 | display: flex; | ||
| 165 | border-radius: 8px; | ||
| 166 | } | ||
| 167 | .pf-v5-c-form-control { | ||
| 168 | background-color: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.15)); | ||
| 169 | padding: 2px; | ||
| 170 | appearance: none; | ||
| 171 | border: 2px solid var(--text); | ||
| 172 | font-size: inherit; | ||
| 173 | color: var(--text); | ||
| 174 | text-indent: 8px; | ||
| 175 | height: 38px; | ||
| 176 | border-radius: 8px; | ||
| 177 | flex: 1; | ||
| 178 | } | ||
| 179 | .pf-v5-c-form-control:has(+ button) { | ||
| 180 | border-top-right-radius: 0; | ||
| 181 | border-bottom-right-radius: 0; | ||
| 182 | } | ||
| 183 | .pf-v5-c-form-control + button { | ||
| 184 | appearance: none; | ||
| 185 | border-top-right-radius: 8px; | ||
| 186 | border-bottom-right-radius: 8px; | ||
| 187 | width: 38px; | ||
| 188 | border: 2px solid var(--text); | ||
| 189 | border-left: none; | ||
| 190 | background-color: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.15)); | ||
| 191 | transition: background-color 0.1s linear; | ||
| 192 | } | ||
| 193 | .pf-v5-c-form-control + button:hover { | ||
| 194 | background-color: light-dark(rgba(0, 0, 0, 0.2), rgba(255, 255, 255, 0.2)); | ||
| 195 | } | ||
| 196 | .pf-v5-c-form__group:has(input:focus-visible) | ||
| 197 | > :is(input, .pf-v5-c-input-group) { | ||
| 198 | outline: 2px solid rgb(from var(--primary) r g b / 0.5); | ||
| 199 | } | ||
| 200 | .pf-v5-c-form__group:has(input:focus-visible) * { | ||
| 201 | border-color: var(--primary); | ||
| 202 | outline: none; | ||
| 203 | } | ||
| 204 | .pf-v5-c-form__group:has(input:focus-visible) .pf-v5-c-form__label { | ||
| 205 | color: var(--header); | ||
| 206 | } | ||
| 207 | .pf-v5-c-helper-text__item-text { | ||
| 208 | display: block; | ||
| 209 | margin-top: 0.5rem; | ||
| 210 | } | ||
| 211 | .pf-m-error { | ||
| 212 | color: light-dark(#c80000, #f56666); | ||
| 213 | } | ||
| 214 | |||
| 215 | /* checkbox */ | ||
| 216 | .checkbox input { | ||
| 217 | display: none; | ||
| 218 | } | ||
| 219 | .checkbox label { | ||
| 220 | display: flex; | ||
| 221 | align-items: center; | ||
| 222 | user-select: none; | ||
| 223 | cursor: pointer; | ||
| 224 | } | ||
| 225 | .checkbox label:before { | ||
| 226 | content: " "; | ||
| 227 | display: block; | ||
| 228 | width: 24px; | ||
| 229 | height: 24px; | ||
| 230 | margin-right: 0.5rem; | ||
| 231 | margin-left: -2px; | ||
| 232 | background-color: var(--text); | ||
| 233 | |||
| 234 | mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXNxdWFyZS1pY29uIGx1Y2lkZS1zcXVhcmUiPjxyZWN0IHdpZHRoPSIxOCIgaGVpZ2h0PSIxOCIgeD0iMyIgeT0iMyIgcng9IjIiLz48L3N2Zz4=); | ||
| 235 | mask-size: cover; | ||
| 236 | } | ||
| 237 | .checkbox label:has(:checked):before { | ||
| 238 | background-color: var(--primary); | ||
| 239 | mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXNxdWFyZS1jaGVjay1iaWctaWNvbiBsdWNpZGUtc3F1YXJlLWNoZWNrLWJpZyI+PHBhdGggZD0iTTIxIDEwLjY1NlYxOWEyIDIgMCAwIDEtMiAySDVhMiAyIDAgMCAxLTItMlY1YTIgMiAwIDAgMSAyLTJoMTIuMzQ0Ii8+PHBhdGggZD0ibTkgMTEgMyAzTDIyIDQiLz48L3N2Zz4=); | ||
| 240 | } | ||
| 241 | .checkbox label:has(:checked) { | ||
| 242 | color: var(--primary); | ||
| 243 | } | ||
| 244 | |||
| 245 | /* buttons */ | ||
| 246 | .pf-v5-c-button.pf-m-primary { | ||
| 247 | background-color: var(--primary); | ||
| 248 | display: block; | ||
| 249 | border: none; | ||
| 250 | height: 30px; | ||
| 251 | border-radius: 8px; | ||
| 252 | color: white; | ||
| 253 | } | ||
| 254 | .pf-v5-c-button.pf-m-block { | ||
| 255 | display: block; | ||
| 256 | width: 100%; | ||
| 257 | } | ||
| 258 | |||
| 259 | /* alert */ | ||
| 260 | .pf-v5-c-alert { | ||
| 261 | border-radius: 8px; | ||
| 262 | padding: 8px; | ||
| 263 | align-items: center; | ||
| 264 | margin-bottom: 1rem; | ||
| 265 | text-align: center; | ||
| 266 | text-wrap: balance; | ||
| 267 | } | ||
| 268 | .alert-error { | ||
| 269 | background-color: light-dark(#ff010182, #f56666a1); | ||
| 270 | } | ||
| 271 | .alert-warning, .alert-info { | ||
| 272 | background-color: rgb(from var(--primary) r g b / 0.5); | ||
| 273 | } | ||
| 274 | |||
| 275 | /* icons */ | ||
| 276 | [data-password-toggle] { | ||
| 277 | display: grid; | ||
| 278 | align-items: center; | ||
| 279 | justify-content: center; | ||
| 280 | } | ||
| 281 | [data-password-toggle] i { | ||
| 282 | display: block; | ||
| 283 | width: 24px; | ||
| 284 | height: 24px; | ||
| 285 | background-color: var(--text); | ||
| 286 | mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWV5ZS1pY29uIGx1Y2lkZS1leWUiPjxwYXRoIGQ9Ik0yLjA2MiAxMi4zNDhhMSAxIDAgMCAxIDAtLjY5NiAxMC43NSAxMC43NSAwIDAgMSAxOS44NzYgMCAxIDEgMCAwIDEgMCAuNjk2IDEwLjc1IDEwLjc1IDAgMCAxLTE5Ljg3NiAwIi8+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMyIvPjwvc3ZnPg==); | ||
| 287 | } | ||
| 288 | input[type="text"] + [data-password-toggle] i { | ||
| 289 | mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWV5ZS1vZmYtaWNvbiBsdWNpZGUtZXllLW9mZiI+PHBhdGggZD0iTTEwLjczMyA1LjA3NmExMC43NDQgMTAuNzQ0IDAgMCAxIDExLjIwNSA2LjU3NSAxIDEgMCAwIDEgMCAuNjk2IDEwLjc0NyAxMC43NDcgMCAwIDEtMS40NDQgMi40OSIvPjxwYXRoIGQ9Ik0xNC4wODQgMTQuMTU4YTMgMyAwIDAgMS00LjI0Mi00LjI0MiIvPjxwYXRoIGQ9Ik0xNy40NzkgMTcuNDk5YTEwLjc1IDEwLjc1IDAgMCAxLTE1LjQxNy01LjE1MSAxIDEgMCAwIDEgMC0uNjk2IDEwLjc1IDEwLjc1IDAgMCAxIDQuNDQ2LTUuMTQzIi8+PHBhdGggZD0ibTIgMiAyMCAyMCIvPjwvc3ZnPg==); | ||
| 290 | } | ||
| 291 | |||
| 292 | @media (max-width: 799px) { | ||
| 293 | body { | ||
| 294 | background-position: left 65% bottom 60%; | ||
| 295 | background-size: auto 150%; | ||
| 296 | display: flex; | ||
| 297 | flex-direction: column; | ||
| 298 | align-items: center; | ||
| 299 | justify-content: flex-end; | ||
| 300 | } | ||
| 301 | body:before { | ||
| 302 | display: block; | ||
| 303 | content: ""; | ||
| 304 | flex: 1.5; | ||
| 305 | } | ||
| 306 | .pf-v5-c-login__main { | ||
| 307 | flex: 1 1 40%; | ||
| 308 | max-width: 25rem; | ||
| 309 | min-width: 80%; | ||
| 310 | border-width: 4px; | ||
| 311 | border-bottom-width: 0; | ||
| 312 | border-top-left-radius: 32px; | ||
| 313 | border-top-right-radius: 32px; | ||
| 314 | corner-shape: superellipse(0); | ||
| 315 | justify-content: space-between; | ||
| 316 | --bg: light-dark(rgba(255, 255, 255, 0.7), rgba(0.2, 0, 0.2, 0.6)); | ||
| 317 | /* the logo straddles the card's top edge; overflow-y: auto would clip | ||
| 318 | the half that sticks out above (#35) */ | ||
| 319 | overflow-y: visible; | ||
| 320 | } | ||
| 321 | .pf-v5-c-login__main:after { | ||
| 322 | display: block; | ||
| 323 | content: ""; | ||
| 324 | } | ||
| 325 | .kc-logo-text::before { | ||
| 326 | position: absolute; | ||
| 327 | left: 50%; | ||
| 328 | width: 100px; | ||
| 329 | transform: translate(-50%, calc(-50% - 35px)); | ||
| 330 | } | ||
| 331 | } | ||
| 332 | |||
| 333 | #credit { | ||
| 334 | position: fixed; | ||
| 335 | bottom: 2px; | ||
| 336 | right: 2px; | ||
| 337 | font-size: 14px; | ||
| 338 | } | ||
| 339 | #credit a:not(:hover) { | ||
| 340 | opacity: 0.5; | ||
| 341 | } | ||
| 342 | |||
| 343 | .subtitle:has(.subtitle .required) { | ||
| 344 | display: none; | ||
| 345 | } | ||
dashboard/web/sso/img/license.txt created+2| ... | @@ -0,0 +1,2 @@ | ||
| 1 | https://safebooru.org/index.php?page=post&s=view&id=4405346 | ||
| 2 | dark mode by paper clover | ||
dashboard/web/sso/img/miku-dark.png created| Binary files /dev/null and b/dashboard/web/sso/img/miku-dark.png differ | |||
dashboard/web/sso/img/miku-light.png created| Binary files /dev/null and b/dashboard/web/sso/img/miku-light.png differ | |||
dashboard/web/types/users.ts+2| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | export interface User { | 1 | export interface User { |
| 2 | id: string; | 2 | id: string; |
| 3 | username: string; | 3 | username: string; |
| 4 | kind?: "guest"; | ||
| 5 | guestProvider?: "github" | "astheno"; | ||
| 4 | email: string | null; | 6 | email: string | null; |
| 5 | firstName: string | null; | 7 | firstName: string | null; |
| 6 | lastName: string | null; | 8 | lastName: string | null; |
nixos/dashboard.nix-2| ... | @@ -19,8 +19,6 @@ let | ... | @@ -19,8 +19,6 @@ let |
| 19 | fileset = lib.fileset.unions [ | 19 | fileset = lib.fileset.unions [ |
| 20 | ../dashboard/web ../dashboard/package.json ../dashboard/pnpm-lock.yaml | 20 | ../dashboard/web ../dashboard/package.json ../dashboard/pnpm-lock.yaml |
| 21 | ../dashboard/tsconfig.json ../dashboard/vite.config.ts | 21 | ../dashboard/tsconfig.json ../dashboard/vite.config.ts |
| 22 | ../service/keycloak/theme/login/resources/css | ||
| 23 | ../service/keycloak/theme/login/resources/img | ||
| 24 | ]; | 22 | ]; |
| 25 | }; | 23 | }; |
| 26 | sourceRoot = "source/dashboard"; | 24 | sourceRoot = "source/dashboard"; |
tools/dashboard-oidc-test.py+49-4| ... | @@ -49,13 +49,13 @@ def main(): | ... | @@ -49,13 +49,13 @@ def main(): |
| 49 | binary = str(args.binary.resolve()) | 49 | binary = str(args.binary.resolve()) |
| 50 | result = subprocess.run([binary, '--import-accounts', str(data / 'source.json')], env=environment, capture_output=True, text=True) | 50 | result = subprocess.run([binary, '--import-accounts', str(data / 'source.json')], env=environment, capture_output=True, text=True) |
| 51 | assert result.returncode == 0, result.stderr | 51 | assert result.returncode == 0, result.stderr |
| 52 | def provision(client, redirects, aliases=None, status=0): | 52 | def provision(client, redirects, aliases=None, status=0, guests=False, username=False): |
| 53 | result = subprocess.run([binary, '--oidc-client'], env=environment, capture_output=True, text=True, | 53 | result = subprocess.run([binary, '--oidc-client'], env=environment, capture_output=True, text=True, |
| 54 | input=json.dumps({'request': {'kind': 'client', 'clientId': client, 'name': client, | 54 | input=json.dumps({'request': {'kind': 'client', 'clientId': client, 'name': client, |
| 55 | 'redirectUris': redirects, 'usernameAliases': aliases or {}}, | 55 | 'redirectUris': redirects, 'usernameAliases': aliases or {}, 'allowGuests': guests, 'usernameRequired': username}, |
| 56 | 'existing': {'clientId': client, 'clientSecret': secret}})) | 56 | 'existing': {'clientId': client, 'clientSecret': secret}})) |
| 57 | assert result.returncode == status, result.stderr | 57 | assert result.returncode == status, result.stderr |
| 58 | provision('shale', [callback], {'oidc-test': 'clover'}) | 58 | provision('shale', [callback], {'oidc-test': 'clover'}, guests=True, username=True) |
| 59 | provision('other', ['https://jelly.paperclover.net/callback']) | 59 | provision('other', ['https://jelly.paperclover.net/callback']) |
| 60 | provision('bad', ['https://evil.example/callback'], status=1) | 60 | provision('bad', ['https://evil.example/callback'], status=1) |
| 61 | provision('bad', ['https://shale.paperclover.net/*'], status=1) | 61 | provision('bad', ['https://shale.paperclover.net/*'], status=1) |
| ... | @@ -164,9 +164,54 @@ def main(): | ... | @@ -164,9 +164,54 @@ def main(): |
| 164 | request('/auth/sign-out', 'POST', {}) | 164 | request('/auth/sign-out', 'POST', {}) |
| 165 | request('/auth/oidc/userinfo', extra=bearer(tokens['access_token']), status=401) | 165 | request('/auth/oidc/userinfo', extra=bearer(tokens['access_token']), status=401) |
| 166 | request('/auth/oidc/token', 'POST', {**refresh, 'refresh_token': tokens['refresh_token']}, form=True, status=400) | 166 | request('/auth/oidc/token', 'POST', {**refresh, 'refresh_token': tokens['refresh_token']}, form=True, status=400) |
| 167 | # The real Shale client requests only openid; its registered mapping still supplies a username. | ||
| 168 | request('/auth/password', 'POST', login) | ||
| 169 | minimal = {**unbound, 'scope': 'openid'} | ||
| 170 | tokens = request('/auth/oidc/token', 'POST', {'grant_type': 'authorization_code', 'code': code(minimal), 'redirect_uri': callback}, extra=basic, form=True) | ||
| 171 | assert request('/auth/oidc/userinfo', extra=bearer(tokens['access_token'])) == {'sub': actor, 'preferred_username': 'clover'} | ||
| 172 | request('/auth/sign-out', 'POST', {}) | ||
| 173 | # Provider UI is offered only for an exact registered Shale authorization request. | ||
| 174 | configured = subprocess.run([binary, '--guest-provider'], env=environment, capture_output=True, text=True, | ||
| 175 | input=json.dumps({'provider': 'github', 'clientId': 'fixture', 'clientSecret': secret})) | ||
| 176 | assert configured.returncode == 0, configured.stderr | ||
| 177 | target = authorize_path(minimal) | ||
| 178 | assert request('/auth/status')['providers'] == [] | ||
| 179 | assert request('/auth/status?' + urllib.parse.urlencode({'next': target}))['providers'] == [{'id':'github','name':'GitHub'}] | ||
| 180 | request('/auth/guest/start/github?' + urllib.parse.urlencode({'next': '/'}), status=400) | ||
| 181 | started = request('/auth/guest/start/github?' + urllib.parse.urlencode({'next': target}), status=302) | ||
| 182 | external = urllib.parse.urlparse(started['location']); parameters = urllib.parse.parse_qs(external.query) | ||
| 183 | assert external.netloc == 'github.com' and parameters['scope'] == ['read:user'] and parameters['code_challenge_method'] == ['S256'] | ||
| 184 | state = parameters['state'][0] | ||
| 185 | request('/auth/guest/callback/github?state=' + state, session=False, status=403) | ||
| 186 | request('/auth/guest/callback/astheno?state=' + state, status=503) | ||
| 187 | declined = request('/auth/guest/callback/github?' + urllib.parse.urlencode({'state':state,'error':'access_denied'}), status=302)['location'] | ||
| 188 | assert 'guest_error=1' in declined | ||
| 189 | request('/auth/guest/callback/github?state=' + state, status=403) | ||
| 190 | # A guest's SSO cookie cannot open dashboard APIs, Files, credentials, or other OIDC clients. | ||
| 191 | guest, session_token = str(uuid.uuid4()), b64(os.urandom(32)) | ||
| 192 | db = sqlite3.connect(data / 'accounts.sqlite') | ||
| 193 | profile = {'kind':'guest','guestProvider':'github','username':'guest-github-123','enabled':True,'email':None,'emailVerified':False,'firstName':'clover','lastName':None,'requiredActions':[]} | ||
| 194 | db.execute('INSERT INTO users(id,profile) VALUES (?,?)', (guest,json.dumps(profile))) | ||
| 195 | stamp = int(time.time()); session_hash = hashlib.sha256(session_token.encode()).hexdigest() | ||
| 196 | db.execute('INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?)',(session_hash,guest,'dashboard',stamp+3600,'127.0.0.1',stamp,stamp,stamp)); db.commit() | ||
| 197 | cookies['__Host-snow-session'] = session_token | ||
| 198 | guest_csrf = request('/auth/status')['csrf'] | ||
| 199 | request('/api/me', status=403) | ||
| 200 | assert request('/', status=302)['location'] == 'https://shale.paperclover.net/' | ||
| 201 | request('/auth/file/check', status=401) | ||
| 202 | request('/auth/passkey/register','POST',{'csrf':guest_csrf},status=403) | ||
| 203 | request(authorize_path({**unbound, 'client_id':'other','redirect_uri':'https://jelly.paperclover.net/callback'}), status=403) | ||
| 204 | tokens = request('/auth/oidc/token','POST',{'grant_type':'authorization_code','code':code(minimal),'redirect_uri':callback},extra=basic,form=True) | ||
| 205 | identity = request('/auth/oidc/userinfo',extra=bearer(tokens['access_token'])) | ||
| 206 | assert identity == {'sub':guest,'preferred_username':'guest-github-123'} | ||
| 207 | provision('shale',[callback],{'oidc-test':'clover'},username=True) | ||
| 208 | request('/auth/oidc/userinfo',extra=bearer(tokens['access_token']),status=401) | ||
| 209 | request('/auth/oidc/token','POST',{'grant_type':'refresh_token','refresh_token':tokens['refresh_token']},extra=basic,form=True,status=400) | ||
| 210 | db.close() | ||
| 167 | print(json.dumps({'signature_nonce_alias_claims': 'passed', 'client_redirect_pkce_binding': 'passed', | 211 | print(json.dumps({'signature_nonce_alias_claims': 'passed', 'client_redirect_pkce_binding': 'passed', |
| 168 | 'code_one_use': 'passed', 'refresh_rotation_reuse_revocation': 'passed', 'basic_client_auth': 'passed', | 212 | 'code_one_use': 'passed', 'refresh_rotation_reuse_revocation': 'passed', 'basic_client_auth': 'passed', |
| 169 | 'forced_login': 'passed', 'disabled_account': 'passed', 'restart_key_persistence': 'passed', 'logout_revocation': 'passed'})) | 213 | 'forced_login': 'passed', 'disabled_account': 'passed', 'restart_key_persistence': 'passed', 'logout_revocation': 'passed', |
| 214 | 'shale_openid_username_mapping': 'passed', 'guest_flow_cookie_and_provider_binding': 'passed', 'guest_scope_and_service_boundaries': 'passed'})) | ||
| 170 | finally: | 215 | finally: |
| 171 | if server and server.poll() is None: stop() | 216 | if server and server.poll() is None: stop() |
| 172 | log.close() | 217 | log.close() |
tools/guest-provider.py created+38| ... | @@ -0,0 +1,38 @@ | ||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Configure Shale guest login using an existing provider registration. | ||
| 3 | |||
| 4 | Register a GitHub OAuth App at https://github.com/settings/developers with homepage | ||
| 5 | https://shale.paperclover.net and callback | ||
| 6 | https://snowglobe.paperclover.net/auth/guest/callback/github. | ||
| 7 | For Astheno Identity, register a confidential OpenID Connect client with callback | ||
| 8 | https://snowglobe.paperclover.net/auth/guest/callback/astheno and openid/profile scopes. | ||
| 9 | GitHub requests only read:user. Neither provider grants repository access. | ||
| 10 | |||
| 11 | Run: python3 tools/guest-provider.py github --client-id CLIENT_ID | ||
| 12 | The secret is read with a hidden prompt and passed over SSH stdin, never in argv. | ||
| 13 | Disable: python3 tools/guest-provider.py github --disable | ||
| 14 | """ | ||
| 15 | import argparse | ||
| 16 | import getpass | ||
| 17 | import json | ||
| 18 | import os | ||
| 19 | import subprocess | ||
| 20 | |||
| 21 | parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | ||
| 22 | parser.add_argument('provider', choices=['github', 'astheno']) | ||
| 23 | parser.add_argument('--client-id') | ||
| 24 | parser.add_argument('--disable', action='store_true') | ||
| 25 | parser.add_argument('--host', default=os.environ.get('STUDIO_DEPLOY_HOST', 'root@zenith')) | ||
| 26 | parser.add_argument('--port', default=os.environ.get('STUDIO_DEPLOY_PORT', '22')) | ||
| 27 | args = parser.parse_args() | ||
| 28 | if not args.disable and not args.client_id: | ||
| 29 | parser.error('--client-id is required unless --disable is used') | ||
| 30 | payload = {'provider': args.provider, 'enabled': not args.disable} | ||
| 31 | if not args.disable: | ||
| 32 | payload.update(clientId=args.client_id, clientSecret=getpass.getpass('Client secret: ')) | ||
| 33 | result = subprocess.run(['ssh', '-p', args.port, args.host, | ||
| 34 | 'podman exec -i studio-dashboard /bin/home-dashboard --guest-provider'], | ||
| 35 | input=json.dumps(payload), text=True, capture_output=True) | ||
| 36 | if result.returncode: | ||
| 37 | raise SystemExit('Provider configuration did not complete. Check the dashboard and SSH connection.') | ||
| 38 | print(result.stdout.strip()) | ||
tools/shale-migration.md+2| ... | @@ -50,6 +50,8 @@ Six occupied numbers are remapped: `chat` #1→#6 and #2→#7; `home-infra` #1 | ... | @@ -50,6 +50,8 @@ Six occupied numbers are remapped: `chat` #1→#6 and #2→#7; `home-infra` #1 |
| 50 | 50 | ||
| 51 | The writable ZFS rehearsal `shale-preview-0242cee6` starts from all 104 existing native issues and imports to 556 total. It preserves the original Clover OIDC identity. September's `r1616-ga87d2f5.zig.0.16.0` avoids the `r1758` anonymous deleted-comment crash, but its Markdown scanner uses a shared capture buffer and crashes under concurrent fenced-code rendering. The documented `NPROC=1` worker setting avoids that race. With this setting, 904 authenticated/anonymous imported-issue requests passed with eight clients, all 24 attachment hashes and access checks passed, and browser closing of a disposable copy of `chat` #1 succeeded. Production `chat` #1 remains untouched. | 51 | The writable ZFS rehearsal `shale-preview-0242cee6` starts from all 104 existing native issues and imports to 556 total. It preserves the original Clover OIDC identity. September's `r1616-ga87d2f5.zig.0.16.0` avoids the `r1758` anonymous deleted-comment crash, but its Markdown scanner uses a shared capture buffer and crashes under concurrent fenced-code rendering. The documented `NPROC=1` worker setting avoids that race. With this setting, 904 authenticated/anonymous imported-issue requests passed with eight clients, all 24 attachment hashes and access checks passed, and browser closing of a disposable copy of `chat` #1 succeeded. Production `chat` #1 remains untouched. |
| 52 | 52 | ||
| 53 | Production issue import completed on October 5 under release `dda941e618934ad9`, from committed main `51be72a9`. Recovery snapshot: `globe/prod/shale@before-forgejo-issues-20261005T080143Z-14c71a`. The private import report and SQLite backup live at `/var/lib/studio/forgejo-issue-migration/issue-import-c726euv2`. All 104 native issues survived alongside the 452 imported issues. After the route reload settled, 904 concurrent HTTPS issue reads passed (644 successful reads, 260 expected private-page denials), all 24 attachment hashes and access checks passed, SQLite integrity passed, and a forged-Origin request returned 403. Browser navigation confirmed historical timestamps, comments, labels, and status on a live imported issue. Native `chat` #1 remains Todo. The disposable Keycloak rehearsal was destroyed after verification; the production recovery snapshot and private evidence remain. | ||
| 54 | |||
| 53 | This older build predates hidden form CSRF tokens. The router requires the exact HTTPS Origin for every Shale request using the `SessionID` cookie and a mutating method. The guard precedes all Shale handlers inside an explicit Caddy `route`; otherwise default directive ordering can bypass it. Missing, wrong, and suffix-forged origins returned 403 without a database change on the clone. The valid site origin allowed a status change, while Basic-auth Git requests still reached Shale. The MCP adapter permits tokenless issue forms only with the exact verified `r1616` structural footer and no CSRF-token input anywhere on the page. Newer or mixed-token markup remains strict. | 55 | This older build predates hidden form CSRF tokens. The router requires the exact HTTPS Origin for every Shale request using the `SessionID` cookie and a mutating method. The guard precedes all Shale handlers inside an explicit Caddy `route`; otherwise default directive ordering can bypass it. Missing, wrong, and suffix-forged origins returned 403 without a database change on the clone. The valid site origin allowed a status change, while Basic-auth Git requests still reached Shale. The MCP adapter permits tokenless issue forms only with the exact verified `r1616` structural footer and no CSRF-token input anywhere on the page. Newer or mixed-token markup remains strict. |
| 54 | 56 | ||
| 55 | The October 4 transport check inspected the image pinned in [service.pkl](../service/shale/service.pkl) in disposable containers without mounting real app data. Its embedded Git endpoint and account settings use HTTP and personal access tokens; no SSH listener, authorized-key interface, or forced-command handler was found. The [official installation](https://astheno.software/shale/installation/) and [configuration reference](https://astheno.software/shale/reference/environment/) also expose HTTP serving and OAuth login without SSH configuration. A `git` account must either use a Shale-aware SSH bridge or await native SSH support. Direct filesystem Git commands would bypass Shale's authorization. | 57 | The October 4 transport check inspected the image pinned in [service.pkl](../service/shale/service.pkl) in disposable containers without mounting real app data. Its embedded Git endpoint and account settings use HTTP and personal access tokens; no SSH listener, authorized-key interface, or forced-command handler was found. The [official installation](https://astheno.software/shale/installation/) and [configuration reference](https://astheno.software/shale/reference/environment/) also expose HTTP serving and OAuth login without SSH configuration. A `git` account must either use a Shale-aware SSH bridge or await native SSH support. Direct filesystem Git commands would bypass Shale's authorization. |
tools/studio.py+3| ... | @@ -1012,6 +1012,9 @@ def main(): | ... | @@ -1012,6 +1012,9 @@ def main(): |
| 1012 | task["http"]["hostname"] = hostname | 1012 | task["http"]["hostname"] = hostname |
| 1013 | task["http"]["plainHostnames"] = [stage + "-" + host for host in task["http"]["plainHostnames"]] | 1013 | task["http"]["plainHostnames"] = [stage + "-" + host for host in task["http"]["plainHostnames"]] |
| 1014 | task["env"] = {key: value.replace(original_host, hostname) for key, value in task["env"].items()} | 1014 | task["env"] = {key: value.replace(original_host, hostname) for key, value in task["env"].items()} |
| 1015 | for request in data["inputs"].values(): | ||
| 1016 | if request["provider"] == "snowglobe": | ||
| 1017 | request["redirectUris"] = [uri.replace(original_host, hostname) for uri in request["redirectUris"]] | ||
| 1015 | for assignment in args.env: | 1018 | for assignment in args.env: |
| 1016 | key, separator, value = assignment.partition("=") | 1019 | key, separator, value = assignment.partition("=") |
| 1017 | if not separator or len(data["containers"]) != 1: | 1020 | if not separator or len(data["containers"]) != 1: |