diff --git a/config/OpenID.pkl b/config/OpenID.pkl index 33fdf96dc88976dd96028bf9471f3831761959e1..9cdb9f2908fc3957c354286f142062a8cfc1e7ec 100644 --- a/config/OpenID.pkl +++ b/config/OpenID.pkl @@ -14,4 +14,6 @@ class Client extends service.Requirement { name: String redirectUris: Listing usernameAliases: Mapping = new {} + allowGuests: Boolean = false + usernameRequired: Boolean = false } diff --git a/dashboard/src/auth.rs b/dashboard/src/auth.rs index f960a18c630dcf071a6001285a14b4d3d9936d37..4fc322b632604ce76585a6a5a07d9b368f22272d 100644 --- a/dashboard/src/auth.rs +++ b/dashboard/src/auth.rs @@ -32,7 +32,7 @@ pub fn cookie(headers: &HeaderMap, name: &str) -> Option { (key == name).then(|| value.to_owned()) }) } -fn set_cookie(name: &str, value: &str, ttl: i64) -> String { +pub(crate) fn set_cookie(name: &str, value: &str, ttl: i64) -> String { format!("{name}={value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={ttl}") } fn row(db: &Connection, statement: &str, key: &str) -> Result { @@ -189,6 +189,7 @@ impl Store { CREATE TABLE IF NOT EXISTS migration (digest TEXT PRIMARY KEY); CREATE TABLE IF NOT EXISTS attempts (key TEXT PRIMARY KEY,count INTEGER NOT NULL,expires INTEGER NOT NULL);")?; oidc::initialise(&db)?; + guest::initialise(&db)?; Ok(Self { db: Mutex::new(db), origin, @@ -364,7 +365,7 @@ impl Store { return Ok(Value::Null); }; let user = user(&db, &id)?; - if user["enabled"] != true { + if user["enabled"] != true || (client == "file" && guest::is_guest(&user)) { return Ok(Value::Null); } db.execute( @@ -377,7 +378,7 @@ impl Store { )?; Ok(user) } - fn create_session( + pub(crate) fn create_session( &self, id: &str, client: &str, @@ -386,7 +387,8 @@ impl Store { ) -> Result { let token = mcp::secret(); let db = self.db.lock().unwrap(); - if user(&db, id)?["enabled"] != true { + let profile = user(&db, id)?; + if profile["enabled"] != true || (client == "file" && guest::is_guest(&profile)) { return Err(Error::new(403, "This account is disabled.")); } if let Some(expected) = password { @@ -419,7 +421,7 @@ impl Store { )?; Ok(set_cookie(COOKIE, &token, SESSION_TTL)) } - fn limit(&self, headers: &HeaderMap, name: &str) -> Result<()> { + pub(crate) fn limit(&self, headers: &HeaderMap, name: &str) -> Result<()> { let ip = headers .get("X-Studio-Client-IP") .and_then(|v| v.to_str().ok()) @@ -487,6 +489,12 @@ impl Store { return Ok(path.to_owned()); } let db = self.db.lock().unwrap(); + if guest::is_guest(&user(&db, id)?) { + return Err(Error::new( + 403, + "Guest accounts can use Shale. Open Shale to continue.", + )); + } if !array(&user(&db, id)?["requiredActions"]).is_empty() { return Ok("/account".into()); } @@ -530,6 +538,12 @@ impl Store { pub fn setup_link(&self, id: &str) -> Result { let db = self.db.lock().unwrap(); let profile = user(&db, id)?; + if guest::is_guest(&profile) { + return Err(Error::new( + 400, + "Guests sign in with their provider. Use the Shale sign-in page.", + )); + } if profile["enabled"] != true { return Err(Error::new( 400, @@ -546,6 +560,9 @@ impl Store { } pub async fn route(State(app): State>, request: Request) -> Result { + if request.uri().path().starts_with("/auth/guest/") { + return Ok(guest::route(State(app), request).await); + } if request.uri().path().starts_with("/auth/oidc/") { return Ok(oidc::route(State(app), request).await); } @@ -664,6 +681,10 @@ pub async fn route(State(app): State>, request: Request) -> Result>, request: Request) -> Result bool { + user["kind"] == "guest" +} + +pub fn initialise(db: &Connection) -> Result<()> { + db.execute_batch("CREATE TABLE IF NOT EXISTS guest_providers (id TEXT PRIMARY KEY, client_id TEXT NOT NULL, secret TEXT NOT NULL); + 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)); + 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; + 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; + 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;")?; + Ok(()) +} + +fn known(provider: &str) -> Result<&str> { + match provider { + "github" => Ok("GitHub"), + "astheno" => Ok("Astheno"), + _ => Err(Error::new( + 404, + "Choose GitHub or Astheno on the Shale sign-in page.", + )), + } +} + +/// Root-owned CLI only. Secrets stay in the private accounts database and its backups. +pub fn provision(auth: &auth::Store, input: Value) -> Result { + let provider = string(&input["provider"]); + known(provider)?; + let mut db = auth.db.lock().unwrap(); + let tx = db.transaction()?; + tx.execute( + "DELETE FROM pending WHERE kind='guest' AND json_extract(data,'$.provider')=?", + [provider], + )?; + tx.execute("DELETE FROM sessions WHERE user_id IN (SELECT user_id FROM external_identities WHERE provider=?)", [provider])?; + if input["enabled"] == false { + tx.execute("DELETE FROM guest_providers WHERE id=?", [provider])?; + } else { + let id = string(&input["clientId"]); + let secret = string(&input["clientSecret"]); + if id.is_empty() + || id.len() > 256 + || secret.len() < 16 + || secret.len() > 2048 + || id.chars().chain(secret.chars()).any(char::is_control) + { + return Err(Error::new( + 400, + "Provide the registered client ID and secret.", + )); + } + 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])?; + } + tx.commit()?; + Ok( + json!({"provider":provider,"enabled":input["enabled"]!=false,"callback":format!("{}auth/guest/callback/{provider}",auth.origin)}), + ) +} + +pub fn providers(auth: &auth::Store, next: &str) -> Result { + if oidc::guest_target(auth, next).is_err() { + return Ok(json!([])); + } + let db = auth.db.lock().unwrap(); + let mut query = db.prepare("SELECT id FROM guest_providers ORDER BY id")?; + let ids = query + .query_map([], |r| r.get::<_, String>(0))? + .collect::, _>>()?; + Ok(json!( + ids.iter() + .map(|id| json!({"id":id,"name":known(id).unwrap_or(id)})) + .collect::>() + )) +} + +fn registration(auth: &auth::Store, provider: &str) -> Result<(String, String)> { + auth.db.lock().unwrap().query_row("SELECT client_id,secret FROM guest_providers WHERE id=?", [provider], |r|Ok((r.get(0)?,r.get(1)?))) + .optional()?.ok_or_else(|| Error::new(503, "This sign-in provider isn't available. Use your Snowglobe account or try again later.")) +} + +async fn response_bytes(mut response: reqwest::Response) -> Result> { + if !response.status().is_success() || response.content_length().is_some_and(|n| n > 65536) { + return Err(Error::new( + 502, + "The provider couldn't complete sign-in. Return to Shale and try again.", + )); + } + let mut data = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if data.len() + chunk.len() > 65536 { + return Err(Error::new( + 502, + "The provider couldn't complete sign-in. Return to Shale and try again.", + )); + } + data.extend_from_slice(&chunk); + } + Ok(data) +} + +fn json_bytes(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|_| { + Error::new( + 502, + "The provider couldn't complete sign-in. Return to Shale and try again.", + ) + }) +} + +/// Astheno signs ID tokens with P-256. Never trust claims before verifying the signature. +fn signed_claims( + token: &str, + keys: &Value, + client: &str, + nonce: &str, + require_nonce: bool, +) -> Result { + let reject = || { + Error::new( + 502, + "The provider couldn't verify your sign-in. Return to Shale and try again.", + ) + }; + if token.len() > 32768 { + return Err(reject()); + } + let parts: Vec<_> = token.split('.').collect(); + if parts.len() != 3 { + return Err(reject()); + } + let header: Value = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[0]).map_err(|_| reject())?) + .map_err(|_| reject())?; + if header["alg"] != "ES256" + || header.get("crit").is_some() + || header.get("jku").is_some() + || header.get("jwk").is_some() + { + return Err(reject()); + } + let matching: Vec<_> = array(&keys["keys"]) + .iter() + .filter(|key| { + key["kid"].is_string() + && key["kid"] == header["kid"] + && key["kty"] == "EC" + && key["crv"] == "P-256" + && key.get("alg").is_none_or(|v| v == "ES256") + && key.get("use").is_none_or(|v| v == "sig") + }) + .collect(); + if matching.len() != 1 { + return Err(reject()); + } + let key = matching[0]; + let x = URL_SAFE_NO_PAD + .decode(string(&key["x"])) + .map_err(|_| reject())?; + let y = URL_SAFE_NO_PAD + .decode(string(&key["y"])) + .map_err(|_| reject())?; + if x.len() != 32 || y.len() != 32 { + return Err(reject()); + } + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1)?; + let mut point = EcPoint::new(&group)?; + let x = BigNum::from_slice(&x)?; + let y = BigNum::from_slice(&y)?; + let mut context = BigNumContext::new()?; + point.set_affine_coordinates_gfp(&group, &x, &y, &mut context)?; + let ec = EcKey::from_public_key(&group, &point)?; + ec.check_key()?; + let key = PKey::from_ec_key(ec)?; + let raw = URL_SAFE_NO_PAD.decode(parts[2]).map_err(|_| reject())?; + if raw.len() != 64 { + return Err(reject()); + } + let signature = EcdsaSig::from_private_components( + BigNum::from_slice(&raw[..32])?, + BigNum::from_slice(&raw[32..])?, + )? + .to_der()?; + let mut verify = Verifier::new(MessageDigest::sha256(), &key)?; + verify.update(format!("{}.{}", parts[0], parts[1]).as_bytes())?; + if !verify.verify(&signature)? { + return Err(reject()); + } + let claims: Value = + serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).map_err(|_| reject())?) + .map_err(|_| reject())?; + let time = now() as i64; + let audience = claims["aud"].as_str().is_some_and(|a| a == client) + || array(&claims["aud"]) + .iter() + .any(|a| a.as_str() == Some(client)); + if claims["iss"] != ASTHENO + || !audience + || (claims["aud"].is_array() && array(&claims["aud"]).len() > 1 && claims["azp"] != client) + || claims.get("azp").is_some_and(|a| a != client) + || string(&claims["sub"]).is_empty() + || string(&claims["sub"]).len() > 512 + || claims["exp"].as_i64().is_none_or(|t| t <= time) + || claims["iat"].as_i64().is_none_or(|t| t > time + 60) + || claims + .get("nbf") + .is_some_and(|t| t.as_i64().is_none_or(|n| n > time + 60)) + || (require_nonce && claims["nonce"].as_str() != Some(nonce)) + { + return Err(reject()); + } + Ok(claims) +} + +async fn exchange( + http: &reqwest::Client, + provider: &str, + client: &str, + secret: &str, + code: &str, + callback: &str, + flow: &Value, +) -> Result<(String, String)> { + let form = [ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", callback), + ("code_verifier", string(&flow["verifier"])), + ]; + if provider == "github" { + let mut form = form.to_vec(); + form.extend([("client_id", client), ("client_secret", secret)]); + let token = json_bytes( + &response_bytes( + http.post("https://github.com/login/oauth/access_token") + .form(&form) + .send() + .await?, + ) + .await?, + )?; + if token["token_type"] + .as_str() + .is_none_or(|s| !s.eq_ignore_ascii_case("bearer")) + || string(&token["access_token"]).is_empty() + || string(&token["scope"]) + .split([',', ' ']) + .filter(|s| !s.is_empty()) + .any(|s| s != "read:user") + { + return Err(Error::new( + 502, + "GitHub couldn't complete sign-in. Return to Shale and try again.", + )); + } + let profile = json_bytes( + &response_bytes( + http.get("https://api.github.com/user") + .bearer_auth(string(&token["access_token"])) + .send() + .await?, + ) + .await?, + )?; + let id = profile["id"].as_u64().filter(|n| *n > 0).ok_or_else(|| { + Error::new( + 502, + "GitHub couldn't verify your account. Return to Shale and try again.", + ) + })?; + let login = profile["login"] + .as_str() + .filter(|s| !s.is_empty() && s.len() <= 64) + .ok_or_else(|| { + Error::new( + 502, + "GitHub couldn't verify your account. Return to Shale and try again.", + ) + })?; + return Ok((id.to_string(), login.to_owned())); + } + let token = json_bytes( + &response_bytes( + http.post(format!("{ASTHENO}/api/token")) + .basic_auth(client, Some(secret)) + .form(&form) + .send() + .await?, + ) + .await?, + )?; + if string(&token["access_token"]).is_empty() + || token["token_type"] + .as_str() + .is_none_or(|s| !s.eq_ignore_ascii_case("bearer")) + { + return Err(Error::new( + 502, + "Astheno couldn't complete sign-in. Return to Shale and try again.", + )); + } + let keys = + json_bytes(&response_bytes(http.get(format!("{ASTHENO}/api/jwks")).send().await?).await?)?; + let claims = signed_claims( + string(&token["id_token"]), + &keys, + client, + string(&flow["nonce"]), + true, + )?; + if let Some(hash) = claims["at_hash"].as_str() { + if hash + != URL_SAFE_NO_PAD + .encode(&Sha256::digest(string(&token["access_token"]).as_bytes())[..16]) + { + return Err(Error::new( + 502, + "Astheno couldn't verify your sign-in. Return to Shale and try again.", + )); + } + } + let bytes = response_bytes( + http.get(format!("{ASTHENO}/api/userinfo")) + .bearer_auth(string(&token["access_token"])) + .send() + .await?, + ) + .await?; + let profile = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') { + json_bytes(&bytes)? + } else { + signed_claims(std::str::from_utf8(&bytes)?, &keys, client, "", false)? + }; + if profile["sub"] != claims["sub"] { + return Err(Error::new( + 502, + "Astheno couldn't verify your account. Return to Shale and try again.", + )); + } + let name = profile["preferred_username"] + .as_str() + .or(profile["name"].as_str()) + .unwrap_or("Astheno guest") + .chars() + .take(128) + .collect(); + Ok((string(&claims["sub"]).to_owned(), name)) +} + +fn account(auth: &auth::Store, provider: &str, subject: &str, name: &str) -> Result { + let mut db = auth.db.lock().unwrap(); + let tx = db.transaction()?; + let existing: Option = tx + .query_row( + "SELECT user_id FROM external_identities WHERE provider=? AND subject=?", + sql![provider, subject], + |r| r.get(0), + ) + .optional()?; + if let Some(id) = existing { + let profile = auth::user(&tx, &id)?; + if !is_guest(&profile) || profile["enabled"] != true { + return Err(Error::new( + 403, + "This guest account is disabled. Contact Clover.", + )); + } + return Ok(id); + } + let id = uuid::Uuid::new_v4().to_string(); + let suffix = if provider == "github" { + subject.to_owned() + } else { + mcp::hash(subject)[..24].to_owned() + }; + 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}); + tx.execute( + "INSERT INTO users(id,profile) VALUES (?,?)", + sql![id, profile.to_string()], + )?; + tx.execute( + "INSERT INTO external_identities VALUES (?,?,?)", + sql![provider, subject, id], + )?; + tx.commit()?; + Ok(id) +} + +fn fields(query: &str) -> Result> { + let mut map = HashMap::new(); + for (k, v) in url::form_urlencoded::parse(query.as_bytes()) { + if v.len() > 8192 || map.insert(k.into_owned(), v.into_owned()).is_some() { + return Err(Error::new( + 400, + "Sign-in expired. Return to Shale and try again.", + )); + } + } + Ok(map) +} + +async fn handle(app: &App, request: Request) -> Result { + if request.method() != Method::GET { + return Err(Error::new(405, "Use the Shale sign-in page.")); + } + let auth = &app.auth; + let headers = request.headers(); + let parts: Vec<_> = request.uri().path().split('/').collect(); + if parts.len() != 5 { + return Err(Error::new(404, "Use the Shale sign-in page.")); + } + let provider = parts[4]; + known(provider)?; + let (client, secret) = registration(auth, provider)?; + let query = fields(request.uri().query().unwrap_or_default())?; + let current = auth.session(headers, "dashboard")?; + if !current.is_null() && !is_guest(¤t) { + return Err(Error::new( + 403, + "You're signed into Snowglobe. Open Shale to use your account.", + )); + } + let callback = format!("{}auth/guest/callback/{provider}", auth.origin); + if parts[3] == "start" { + auth.limit(headers, "guest")?; + let next = oidc::guest_target( + auth, + query.get("next").map(String::as_str).unwrap_or_default(), + )?; + let verifier = mcp::secret(); + let nonce = mcp::secret(); + let state = auth::issue( + &auth.db.lock().unwrap(), + "guest", + json!({"provider":provider,"next":next,"verifier":verifier,"nonce":nonce}), + 300, + )?; + let mut target = url::Url::parse(if provider == "github" { + "https://github.com/login/oauth/authorize" + } else { + "https://identity.astheno.software/authorize" + })?; + target.query_pairs_mut().extend_pairs([ + ("client_id", client.as_str()), + ("redirect_uri", &callback), + ("response_type", "code"), + ( + "scope", + if provider == "github" { + "read:user" + } else { + "openid profile" + }, + ), + ("state", &state), + ("nonce", &nonce), + ( + "code_challenge", + &URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())), + ), + ("code_challenge_method", "S256"), + ]); + return Ok(( + StatusCode::FOUND, + [ + ("location", target.to_string()), + ("set-cookie", auth::set_cookie(COOKIE, &state, 300)), + ], + ) + .into_response()); + } + if parts[3] != "callback" { + return Err(Error::new(404, "Use the Shale sign-in page.")); + } + let state = query.get("state").map(String::as_str).unwrap_or_default(); + let binding = auth::cookie(headers, COOKIE).unwrap_or_default(); + if state.is_empty() || !bool::from(state.as_bytes().ct_eq(binding.as_bytes())) { + return Err(Error::new( + 403, + "Sign-in expired. Return to Shale and try again.", + )); + } + let flow = { + let db = auth.db.lock().unwrap(); + let flow = auth::pending(&db, state, "guest", false)?; + if flow.is_null() || flow["provider"] != provider { + return Err(Error::new( + 403, + "Sign-in expired. Return to Shale and try again.", + )); + } + auth::pending(&db, state, "guest", true)? + }; + let next = oidc::guest_target(auth, string(&flow["next"]))?; + let result = async { + let code = query + .get("code") + .filter(|s| !s.is_empty() && s.len() <= 4096) + .ok_or_else(|| { + Error::new( + 400, + "Sign-in wasn't completed. Return to Shale and try again.", + ) + })?; + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .redirect(reqwest::redirect::Policy::none()) + .user_agent("Snowglobe guest sign-in") + .default_headers({ + let mut headers = HeaderMap::new(); + headers.insert("accept", "application/json".parse().unwrap()); + headers + }) + .build()?; + let (subject, name) = + exchange(&http, provider, &client, &secret, code, &callback, &flow).await?; + let id = account(auth, provider, &subject, &name)?; + auth.create_session(&id, "dashboard", headers, None) + } + .await; + match result { + Ok(session) => Ok(( + StatusCode::FOUND, + [ + ("location", next), + ("set-cookie", session), + ("set-cookie", auth::set_cookie(COOKIE, "", 0)), + ], + ) + .into_response()), + Err(_) => Ok(( + StatusCode::FOUND, + [ + ( + "location", + format!("/sign-in?next={}&guest_error=1", encoded(&next)), + ), + ("set-cookie", auth::set_cookie(COOKIE, "", 0)), + ], + ) + .into_response()), + } +} + +pub async fn route(State(app): State>, request: Request) -> Response { + let mut response = match handle(&app, request).await { + Ok(response) => response, + Err(error) => Error::new( + error.status, + "Guest sign-in couldn't continue. Return to Shale and try again.", + ) + .into_response(), + }; + response + .headers_mut() + .insert("cache-control", "no-store".parse().unwrap()); + response +} + +#[cfg(test)] +mod tests { + use super::*; + + // The P-256 tokens were produced independently with Python cryptography. + #[test] + fn external_signature_issuer_audience_nonce_and_time_are_required() { + let vector: Value = serde_json::from_str(include_str!("../tests/guest-jwt.json")).unwrap(); + let token = string(&vector["valid"]); + let claims = + signed_claims(token, &vector["keys"], "fixture", "fixture-nonce", true).unwrap(); + assert_eq!(claims["sub"], "external-123"); + for (name, token) in vector["invalid"].as_object().unwrap() { + assert!( + signed_claims( + string(token), + &vector["keys"], + "fixture", + "fixture-nonce", + true + ) + .is_err(), + "{name}" + ); + } + let mut corrupt = token.to_owned().into_bytes(); + let index = token.rfind('.').unwrap() + 3; + corrupt[index] = if corrupt[index] == b'A' { b'B' } else { b'A' }; + assert!( + signed_claims( + std::str::from_utf8(&corrupt).unwrap(), + &vector["keys"], + "fixture", + "fixture-nonce", + true + ) + .is_err() + ); + assert!( + signed_claims(token, &json!({"keys":[]}), "fixture", "fixture-nonce", true).is_err() + ); + let mut duplicate = vector["keys"].clone(); + duplicate["keys"] + .as_array_mut() + .unwrap() + .push(vector["keys"]["keys"][0].clone()); + assert!(signed_claims(token, &duplicate, "fixture", "fixture-nonce", true).is_err()); + let mut parts: Vec<_> = token.split('.').map(str::to_owned).collect(); + parts[0] = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","kid":"test-key"}"#); + assert!( + signed_claims( + &parts.join("."), + &vector["keys"], + "fixture", + "fixture-nonce", + true + ) + .is_err() + ); + } + + #[test] + fn identities_never_link_by_name_or_email_and_cannot_gain_credentials_or_groups() { + let path = std::env::temp_dir().join(format!("guest-test-{}", uuid::Uuid::new_v4())); + let auth = auth::Store::new( + &path, + "https://snowglobe.paperclover.net", + "https://file.paperclover.net", + "auth.paperclover.net", + ) + .unwrap(); + { + let db = auth.db.lock().unwrap(); + db.execute("INSERT INTO users(id,profile) VALUES ('owner',?)", [json!({"username":"clover","enabled":true,"email":"same@example.invalid","emailVerified":true}).to_string()]).unwrap(); + db.execute("INSERT INTO roles VALUES ('admin','infra-admin')", []) + .unwrap(); + } + let first = account(&auth, "github", "123", "clover").unwrap(); + let repeat = account(&auth, "github", "123", "renamed").unwrap(); + let other = account(&auth, "astheno", "123", "clover").unwrap(); + assert_eq!(first, repeat); + assert_ne!(first, "owner"); + assert_ne!(first, other); + { + let db = auth.db.lock().unwrap(); + let profile = auth::user(&db, &first).unwrap(); + assert!(is_guest(&profile)); + assert!(profile["email"].is_null()); + assert_eq!(profile["groups"], json!([])); + assert!( + db.execute("INSERT INTO memberships VALUES (?,'admin')", [&first]) + .is_err() + ); + assert!(auth::set_password(&db, &first, "a-password-hash").is_err()); + assert!( + db.execute( + "UPDATE users SET profile=json_remove(profile,'$.kind') WHERE id=?", + [&first] + ) + .is_err() + ); + db.execute( + "UPDATE users SET profile=json_set(profile,'$.enabled',json('false')) WHERE id=?", + [&first], + ) + .unwrap(); + } + assert!(account(&auth, "github", "123", "clover").is_err()); + assert!( + auth.create_session(&other, "file", &HeaderMap::new(), None) + .is_err() + ); + assert!(auth.setup_link(&other).is_err()); + drop(auth); + std::fs::remove_dir_all(path).unwrap(); + } +} diff --git a/dashboard/src/main.rs b/dashboard/src/main.rs index 989b06bada3f0397fb9b65317640592c52979637..dcb5c295334b76e58bdd6e78fc5bfb87e8ef76b4 100644 --- a/dashboard/src/main.rs +++ b/dashboard/src/main.rs @@ -4,6 +4,7 @@ mod cache; mod core; mod deploys; mod files; +mod guest; mod host; mod index; mod mcp; @@ -405,6 +406,13 @@ async fn main() -> std::result::Result<(), Box> { ) .map_err(|error| std::io::Error::other(error.message))?; if let Some(path) = std::env::args().skip(1).next() { + if path == "--guest-provider" { + let input = serde_json::from_reader(std::io::stdin())?; + let output = guest::provision(&auth, input) + .map_err(|error| std::io::Error::other(error.message))?; + println!("{output}"); + return Ok(()); + } if path == "--oidc-client" { let input = serde_json::from_reader(std::io::stdin())?; let output = oidc::provision(&auth, input) @@ -552,6 +560,27 @@ async fn main() -> std::result::Result<(), Box> { .into_response() }; } + if guest::is_guest(&account) { + return if path.starts_with("/api/") { + Error::new( + 403, + "Guest accounts can use Shale. Open Shale to continue.", + ) + .into_response() + } else { + ( + StatusCode::FOUND, + [( + "location", + format!( + "https://shale.{}/", + env("STUDIO_DOMAIN", "studio.test") + ), + )], + ) + .into_response() + }; + } if !matches!( *request.method(), Method::GET | Method::HEAD | Method::OPTIONS diff --git a/dashboard/src/mcp.rs b/dashboard/src/mcp.rs index 83fae79198c6c91ba79ebad4bd2439b978d50186..a6ec6068d48455f07de89e686e473e0dbe3755f8 100644 --- a/dashboard/src/mcp.rs +++ b/dashboard/src/mcp.rs @@ -497,6 +497,7 @@ pub(crate) fn active_owner(app: &App, grant: &Value) -> Result { Err(error) => return Err(error), }; Ok(profile["enabled"] == true + && !guest::is_guest(&profile) && (grant["resource"] != app.mcp.resource("observability") || array(&profile["groups"]) .iter() diff --git a/dashboard/src/oidc.rs b/dashboard/src/oidc.rs index f5cf21bce53f932c515596ce8b47132e5377d141..b0efeba4717e5a82db699875d2934f927479a131 100644 --- a/dashboard/src/oidc.rs +++ b/dashboard/src/oidc.rs @@ -53,6 +53,47 @@ fn client(db: &Connection, id: &str) -> Result { .ok_or_else(|| invalid("invalid_client")) } +fn guests_allowed(id: &str, config: &Value) -> bool { + config["allowGuests"] == true && (id == "shale" || id.starts_with("shale-preview-")) +} + +pub(crate) fn guest_target(auth: &auth::Store, next: &str) -> Result { + if next.len() > 8192 || next.contains('\\') || next.chars().any(char::is_control) { + return Err(invalid("invalid_request")); + } + let target = auth + .origin + .join(next) + .map_err(|_| invalid("invalid_request"))?; + if target.origin() != auth.origin.origin() + || target.path() != "/auth/oidc/authorize" + || target.fragment().is_some() + || !target.username().is_empty() + || target.password().is_some() + { + return Err(invalid("invalid_request")); + } + let query = fields(target.query().unwrap_or_default())?; + let id = query + .get("client_id") + .map(String::as_str) + .unwrap_or_default(); + let config = client(&auth.db.lock().unwrap(), id)?; + if !guests_allowed(id, &config) + || query.get("response_type").map(String::as_str) != Some("code") + || !array(&config["redirectUris"]) + .iter() + .any(|v| v.as_str() == query.get("redirect_uri").map(String::as_str)) + { + return Err(invalid("access_denied")); + } + Ok(format!( + "{}?{}", + target.path(), + target.query().unwrap_or_default() + )) +} + pub fn username(auth: &auth::Store, user_id: &str, client_id: &str) -> Result { let db = auth.db.lock().unwrap(); let user = auth::user(&db, user_id)?; @@ -152,8 +193,26 @@ pub fn provision(auth: &auth::Store, input: Value) -> Result { .filter(|s| s.len() >= 24) .map(str::to_owned) .unwrap_or_else(mcp::secret); - let config = - json!({"name":request["name"], "redirectUris":redirects, "usernameAliases":aliases}); + let allow_guests = request["allowGuests"] == true; + if allow_guests + && (!(id == "shale" || id.starts_with("shale-preview-")) + || redirects.iter().any(|v| { + url::Url::parse(string(v)).is_ok_and(|u| { + let site = auth + .origin + .host_str() + .unwrap() + .strip_prefix("snowglobe.") + .unwrap_or_default(); + u.path() != "/-/callback" + || u.host_str() != Some(format!("{id}.{site}").as_str()) + || u.query().is_some() + }) + })) + { + return Err(invalid("access_denied")); + } + let config = json!({"name":request["name"], "redirectUris":redirects, "usernameAliases":aliases, "allowGuests":allow_guests, "usernameRequired":request["usernameRequired"]==true}); let old: Option<(String, String)> = tx .query_row( "SELECT secret_hash,config FROM oidc_clients WHERE id=?", @@ -203,16 +262,27 @@ fn jwt(db: &Connection, claims: &Value) -> Result { fn claims(user: &Value, config: &Value, scopes: &str) -> Value { let mut value = json!({"sub":user["id"]}); let scopes: Vec<_> = scopes.split_whitespace().collect(); - if scopes.contains(&"profile") { + // Shale requests only openid but needs a stable username to bind its account. + if scopes.contains(&"profile") || config["usernameRequired"] == true { let username = string(&user["username"]); value["preferred_username"] = config["usernameAliases"][username] .as_str() .map(|v| json!(v)) .unwrap_or_else(|| json!(username)); - let name = format!("{} {}", string(&user["firstName"]), string(&user["lastName"])); - if !name.trim().is_empty() { value["name"] = json!(name.trim()); } - for (claim,field) in [("given_name","firstName"),("family_name","lastName")] { - if !string(&user[field]).is_empty() { value[claim] = user[field].clone(); } + } + if scopes.contains(&"profile") { + let name = format!( + "{} {}", + string(&user["firstName"]), + string(&user["lastName"]) + ); + if !name.trim().is_empty() { + value["name"] = json!(name.trim()); + } + for (claim, field) in [("given_name", "firstName"), ("family_name", "lastName")] { + if !string(&user[field]).is_empty() { + value[claim] = user[field].clone(); + } } } if scopes.contains(&"email") && !string(&user["email"]).is_empty() { @@ -296,6 +366,9 @@ fn authenticated_client( fn tokens(db: &Connection, auth: &auth::Store, data: &Value, nonce: Option<&str>) -> Result { let user = eligible(db, string(&data["user"]), string(&data["session"]))?; let config = client(db, string(&data["client"]))?; + if guest::is_guest(&user) && !guests_allowed(string(&data["client"]), &config) { + return Err(invalid("access_denied")); + } let scope = string(&data["scope"]); let access = mcp::secret(); let family = string(&data["family"]); @@ -399,6 +472,9 @@ async fn handle(app: &App, request: Request) -> Result { return Err(invalid("invalid_request")); } let user = auth.session(&headers, "dashboard")?; + if guest::is_guest(&user) && !guests_allowed(id, &config) { + return Err(Error::new(403, "access_denied")); + } let session = mcp::hash(&auth::cookie(&headers, "__Host-snow-session").unwrap_or_default()); let auth_time: i64 = auth .db @@ -507,12 +583,13 @@ async fn handle(app: &App, request: Request) -> Result { 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()?; let (user, client_id, session, scope) = data.ok_or_else(|| Error::new(401, "invalid_token"))?; - return Ok(axum::Json(claims( - &eligible(&db, &user, &session).map_err(|_| Error::new(401, "invalid_token"))?, - &client(&db, &client_id)?, - &scope, - )) - .into_response()); + let profile = + eligible(&db, &user, &session).map_err(|_| Error::new(401, "invalid_token"))?; + let config = client(&db, &client_id)?; + if guest::is_guest(&profile) && !guests_allowed(&client_id, &config) { + return Err(Error::new(401, "invalid_token")); + } + return Ok(axum::Json(claims(&profile, &config, &scope)).into_response()); } if method != Method::POST || !matches!(path.as_str(), "/auth/oidc/token" | "/auth/oidc/revoke") { diff --git a/dashboard/src/users.rs b/dashboard/src/users.rs index 1f356017ccbb204b1b6a1e63b65f33ed15c7872c..7eed4ac7e256d704d0c8b4327db91fe287090a78 100644 --- a/dashboard/src/users.rs +++ b/dashboard/src/users.rs @@ -210,6 +210,15 @@ pub async fn route( let mut db = app.auth.db.lock().unwrap(); let transaction = db.transaction()?; let mut user = auth::user(&transaction, id)?; + if guest::is_guest(&user) + && matches!(parts, [_, "password"] | [_, "groups", _]) + && method != Method::GET + { + return Err(Error::new( + 400, + "Guests can use Shale only. Invite a separate account for other services.", + )); + } let own = user["username"] == me["name"]; let value = match parts { [_] if method == Method::PATCH => { diff --git a/dashboard/tests/guest-jwt.json b/dashboard/tests/guest-jwt.json new file mode 100644 index 0000000000000000000000000000000000000000..ffac1183599b864b77c595a56ccac68cc6803060 --- /dev/null +++ b/dashboard/tests/guest-jwt.json @@ -0,0 +1,26 @@ +{ + "keys": { + "keys": [ + { + "kty": "EC", + "crv": "P-256", + "kid": "test-key", + "alg": "ES256", + "use": "sig", + "x": "1nEiAOMIPlqUavM6Gr0pUwIdbZY-RNwRk7dPidTrHyA", + "y": "SdqTpMg5HzJFKqMOpBobpIg9rzNPyYqWCOkiqT-MECc" + } + ] + }, + "valid": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiJleHRlcm5hbC0xMjMiLCJhdWQiOiJmaXh0dXJlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsIm5vbmNlIjoiZml4dHVyZS1ub25jZSJ9.nXkYh8OQL25LxMMJY8wmpcUPjCRmI3jdO1TciKZSctWaRFAD9g2DTloulVT6GJ0-0c9fm-2YlBKMX-xA0AZKBQ", + "invalid": { + "issuer": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2V2aWwuaW52YWxpZCIsInN1YiI6ImV4dGVybmFsLTEyMyIsImF1ZCI6ImZpeHR1cmUiLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwibm9uY2UiOiJmaXh0dXJlLW5vbmNlIn0.YzADf1EGii-hK8mQBJ07eLfxYrgRjwHAsaLaWei8dtVXUVqH82xCIFnxruxprzoJJGQbTaIhpOSSSPSMRSevpQ", + "audience": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiJleHRlcm5hbC0xMjMiLCJhdWQiOiJvd25lci1jbGllbnQiLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6NDEwMjQ0NDgwMCwibm9uY2UiOiJmaXh0dXJlLW5vbmNlIn0.ph9cC8BNj31XMp1MFwVM1K1eq2xrqfe5hGRlDsB5_TFrd3sGrKixWZJGJ3KnBu18JCT-HxDNhigdeY7xHOBB-Q", + "nonce": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiJleHRlcm5hbC0xMjMiLCJhdWQiOiJmaXh0dXJlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsIm5vbmNlIjoiYW5vdGhlciJ9.d58kIFpSesd-Cqd5wwQ9XHoOtTWMzMUfvIr_8F26ZaLa2TccwhM2028h42Sg1QhSeIMSBOelOEcARQYPgO-w_Q", + "expiry": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiJleHRlcm5hbC0xMjMiLCJhdWQiOiJmaXh0dXJlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjEsIm5vbmNlIjoiZml4dHVyZS1ub25jZSJ9.eyYxr_J4Pz_B-8YxllVPfBuuy0ysZcBnwd4VKjqnuo1Coc3fD5b1ecsB1iqXO4ToO12kgMXQewZlE_WjmzBcZA", + "future": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiJleHRlcm5hbC0xMjMiLCJhdWQiOiJmaXh0dXJlIiwiaWF0Ijo0MTAyNDQ0ODAwLCJleHAiOjQxMDI0NDQ4MDAsIm5vbmNlIjoiZml4dHVyZS1ub25jZSJ9.Pt7LcZgxxtH8qDYLuUEeEKNRjNCL3cbnJk6qq86-lhunbd8sgTR8USPVys2WPZAdrZBIXxYXfl43iEKybN9W6A", + "subject": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiIiLCJhdWQiOiJmaXh0dXJlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsIm5vbmNlIjoiZml4dHVyZS1ub25jZSJ9.LvYyzvg0HAzhAWKo76lWyPktuoxSdmXySfXjiIWea40O0fdqGa-Du73lHzTLUJ3BDS5yWd5gF3doHbYrhbL4TA", + "authorizedParty": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiJleHRlcm5hbC0xMjMiLCJhdWQiOiJmaXh0dXJlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjQxMDI0NDQ4MDAsIm5vbmNlIjoiZml4dHVyZS1ub25jZSIsImF6cCI6Im90aGVyIn0.W8C8sSVCpLn32JAZ6VKoJyUV9Ew_-eBsatAtynsnzJpj8l28sKv_bo8lGPq5Ef3HTxiqEGtgdWRR1wO92k_-Tw", + "multipleAudience": "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5In0.eyJpc3MiOiJodHRwczovL2lkZW50aXR5LmFzdGhlbm8uc29mdHdhcmUiLCJzdWIiOiJleHRlcm5hbC0xMjMiLCJhdWQiOlsiZml4dHVyZSIsIm90aGVyIl0sImlhdCI6MTcwMDAwMDAwMCwiZXhwIjo0MTAyNDQ0ODAwLCJub25jZSI6ImZpeHR1cmUtbm9uY2UifQ.D3HM97jMyMnqatXGcjDRopQIYe-ZKjzMuvVNt5dkqFW-tqXKXJlNXGd1hfpCc8XIdK4pL_iRaYwIbnMnVfZoQA" + } +} diff --git a/dashboard/web/components/Explorer.tsx b/dashboard/web/components/Explorer.tsx index bdb0411521da1518bebeef6d363471f8095e755b..bde2fb70480ce14f5667d0b3978cd6b0037b40cf 100644 --- a/dashboard/web/components/Explorer.tsx +++ b/dashboard/web/components/Explorer.tsx @@ -234,6 +234,9 @@ export function Explorer(props: { id: string; client: Client; root: string }) { const [mapShown, setMapShown] = createSignal(localStorage.getItem(mapKey) !== "hidden"); let anchor: string | undefined; let table: HTMLTableElement | undefined; + let filesScroll!: HTMLDivElement; + let lastCursor: string | undefined; + let revealCursor: string | undefined; let patternInput!: HTMLInputElement; const known = new WeakMap(); @@ -339,9 +342,29 @@ export function Explorer(props: { id: string; client: Client; root: string }) { refreshTree(); })); + // A listing update must not scroll back to an old selection after the user scrolled elsewhere. + // Keep a new cursor pending until its row arrives, then reveal it once in this scroller only. createEffect(on([cursor, rows], ([path, all]) => { + if (path !== lastCursor) { + lastCursor = path; + revealCursor = path; + } + if (!path || revealCursor !== path) return; const index = all.findIndex((row) => row.path === path); - if (index >= 0) queueMicrotask(() => table?.querySelector(`[data-row="${index}"]`)?.scrollIntoView({ block: "nearest" })); + if (index < 0) return; + revealCursor = undefined; + queueMicrotask(() => { + if (cursor() !== path) return; + const currentIndex = rows().findIndex((row) => row.path === path); + const row = table?.querySelector(`[data-row="${currentIndex}"]`); + if (!row || !filesScroll) return; + const rect = row.getBoundingClientRect(); + const view = filesScroll.getBoundingClientRect(); + const top = view.top + filesScroll.clientTop + (table?.tHead?.getBoundingClientRect().height ?? 0); + const bottom = view.top + filesScroll.clientTop + filesScroll.clientHeight; + if (rect.top < top) filesScroll.scrollTop += rect.top - top; + else if (rect.bottom > bottom) filesScroll.scrollTop += rect.bottom - bottom; + }); })); const select = (row: Row, how: "only" | "toggle" | "range") => { @@ -948,7 +971,7 @@ export function Explorer(props: { id: string; client: Client; root: string }) { -
+
diff --git a/dashboard/web/pages/SignIn.css b/dashboard/web/pages/SignIn.css index 6dccbaa012e2320eb43676e97a642d0532726fd1..02663f3d794840d8c5c386c6ef90e13654dc2c5a 100644 --- a/dashboard/web/pages/SignIn.css +++ b/dashboard/web/pages/SignIn.css @@ -1,4 +1,4 @@ -/* The original Keycloak theme stays shared; these adapt its document to the dashboard shell. */ +/* The original Snow Sign On theme stays intact; these adapt its document to the dashboard shell. */ body { font: 16px "Name Sans", sans-serif; } #root { display: contents; } .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; } .pf-v5-c-login__main .checkbox input { display: block; position: absolute; opacity: 0; } .pf-v5-c-login__main .checkbox label:has(:focus-visible) { outline: 2px solid var(--primary); } .setup-intro { margin-bottom: 1rem; text-align: center; } +.guest-providers { margin-top: 1.5rem; } +.guest-providers p { text-align: center; } +.guest-providers a + a { margin-top: .75rem; } diff --git a/dashboard/web/pages/SignIn.tsx b/dashboard/web/pages/SignIn.tsx index 1b383a26dfc4bcf6f536102dd9937227923271fb..5bd7f819e55e42fbdcb4c09c165a148f395fcab7 100644 --- a/dashboard/web/pages/SignIn.tsx +++ b/dashboard/web/pages/SignIn.tsx @@ -1,19 +1,22 @@ -import { createEffect, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"; +import { createEffect, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"; import { authReason, authRequest } from "../auth.ts"; -import theme from "../../../service/keycloak/theme/login/resources/css/styles.css?inline"; +import theme from "../sso/css/styles.css?inline"; import adjustments from "./SignIn.css?inline"; export function SignIn() { const params = new URLSearchParams(window.location.search); const setup = params.get("setup"); - const [status, { refetch }] = createResource(() => authRequest<{ csrf: string; setup?: string }>(`status${setup ? `?setup=${encodeURIComponent(setup)}` : ""}`)); + const statusQuery = new URLSearchParams(); + if (setup) statusQuery.set("setup", setup); + if (params.get("next")) statusQuery.set("next", params.get("next")!); + const [status, { refetch }] = createResource(() => authRequest<{ csrf: string; setup?: string; providers?: { id: string; name: string }[] }>(`status?${statusQuery}`)); const [username, setUsername] = createSignal(""); const [password, setPassword] = createSignal(""); const [email, setEmail] = createSignal(""); const [remember, setRemember] = createSignal(false); const [visible, setVisible] = createSignal(false); const [busy, setBusy] = createSignal(false); - const [error, setError] = createSignal(""); + const [error, setError] = createSignal(params.has("guest_error") ? "Guest sign-in wasn't completed. Choose a provider to try again." : ""); const [notice, setNotice] = createSignal(""); let conditional: AbortController | undefined; let disposed = false; @@ -105,6 +108,12 @@ export function SignIn() {
{ event.preventDefault(); void passkey(); }}>sign in with passkey + +
+

sign in as a Shale guest

+ {(provider) => continue with {provider.name}} +
+
diff --git a/dashboard/web/pages/Users.tsx b/dashboard/web/pages/Users.tsx index 4de213dee5f6d1dfc5da63940b0997b1227450df..a39a94c0ec4f64f8f32ab93d42069e16b28f2b5f 100644 --- a/dashboard/web/pages/Users.tsx +++ b/dashboard/web/pages/Users.tsx @@ -109,6 +109,8 @@ export function Users() { function StateTag(props: { user: User }) { return ( + <> + Shale guest disabled}> + ); } @@ -510,7 +513,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) {
{(group) => ( - @@ -521,17 +524,20 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { can open nothing while disabled

}> + Shale only

}> everything

}>
+

sign-in - +

+ signs in with {props.user.guestProvider === "github" ? "GitHub" : "Astheno"}

}> }> {(list) => { @@ -556,6 +562,7 @@ function Profile(props: { user: User; data: Data; refetch: () => unknown }) { {" "}

+
diff --git a/dashboard/web/sso/css/styles.css b/dashboard/web/sso/css/styles.css new file mode 100644 index 0000000000000000000000000000000000000000..b2f24b7690c8bb824e36271ee47d1dc3d71c7620 --- /dev/null +++ b/dashboard/web/sso/css/styles.css @@ -0,0 +1,345 @@ +@import "https://file.paperclover.net/.static/font.css"; + +/* reset */ +html, +body { + height: 100%; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: unset; + font-weight: unset; +} + +:where( + html, + body, + p, + ol, + ul, + li, + dl, + dt, + dd, + blockquote, + figure, + fieldset, + legend, + textarea, + pre, + iframe, + hr, + h1, + h2, + h3, + h4, + h5, + h6 +) { + margin: 0; + padding: 0; +} + +*, :after, :before { + box-sizing: border-box; + font: unset; +} + +/* root */ +body { + color-scheme: light dark; + background-color: #f9feff; + background-image: url(../img/miku-light.png); + background-size: auto 100%; + background-position: right top; + background-repeat: no-repeat; + color: black; + --text: black; + + --header: light-dark(#1a46cd, #938cff); + --primary: light-dark(#00238f, #938cff); +} +@media (prefers-color-scheme: dark) { + body { + background-image: url(../img/miku-dark.png); + background-color: #2f4b67; + color: white; + --text: white; + } +} +@media (max-width: 1313px) { + body { + background-position: right calc(-100px + 50%) top; + } +} + +/* sidebar */ +.pf-v5-c-login__main { + max-width: 30rem; + padding: 2rem; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + --bg: light-dark(rgba(30, 30, 30, 0.1), rgba(0, 0, 0, 0.3)); + background-color: var(--bg); + border-style: solid; + border-right-width: 4px; + border-color: var(--bg); + backdrop-filter: blur(4px); + overflow-y: auto; +} +.kc-logo-text { + font-size: 3rem; + text-align: center; + color: var(--header); +} +#kc-page-title, #kc-info-wrapper { + text-align: center; + color: rgb(from var(--text) r g b / 0.8); +} +#kc-page-title { + margin-bottom: 1rem; +} +#kc-info-wrapper { + margin-top: 1rem; +} +#kc-form-options { + margin-bottom: 1rem; +} +a { + color: var(--header); + text-decoration: dotted underline; +} +a:hover { + text-decoration: underline; + background-color: rgb(from var(--header) r g b / 0.2); +} + +/* branding */ +.kc-logo-text::before { + display: block; + content: " "; + width: 30%; + aspect-ratio: 1; + margin: auto; + background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9Ii0yIC0yIDI4IDI4IiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCBkPSJNMTAgMjBMOC43NSAxNy41TDYgMThNMTAgNEw4Ljc1IDYuNUw2IDZNMTQgMjBMMTUuMjUgMTcuNUwxOCAxOE0xNCA0TDE1LjI1IDYuNUwxOCA2TTE3IDIxTDE0IDE1TTE0IDE1SDEwTTE0IDE1TDE1LjUgMTJNMTAgMTVMNyAyMU0xMCAxNUw4LjUgMTJNMTcgM0wxNCA5TTE0IDlMMTUuNSAxMk0xNCA5SDEwTTE1LjUgMTJIMjJNMiAxMkg4LjVNOC41IDEyTDEwIDlNMTAgOUw3IDNNMjAgMTBMMTguNSAxMkwyMCAxNE00IDEwTDUuNSAxMkw0IDE0IiBzdHJva2U9InVybCgjcGFpbnQwX2xpbmVhcl81NDhfMTkpIiBzdHJva2Utd2lkdGg9IjMiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8cGF0aCBkPSJNMTAgMjBMOC43NSAxNy41TDYgMThNMTAgNEw4Ljc1IDYuNUw2IDZNMTQgMjBMMTUuMjUgMTcuNUwxOCAxOE0xNCA0TDE1LjI1IDYuNUwxOCA2TTE3IDIxTDE0IDE1TTE0IDE1SDEwTTE0IDE1TDE1LjUgMTJNMTAgMTVMNyAyMU0xMCAxNUw4LjUgMTJNMTcgM0wxNCA5TTE0IDlMMTUuNSAxMk0xNCA5SDEwTTE1LjUgMTJIMjJNMiAxMkg4LjVNOC41IDEyTDEwIDlNMTAgOUw3IDNNMjAgMTBMMTguNSAxMkwyMCAxNE00IDEwTDUuNSAxMkw0IDE0IiBzdHJva2U9InVybCgjcGFpbnQxX2xpbmVhcl81NDhfMTkpIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8ZGVmcz4KPGxpbmVhckdyYWRpZW50IGlkPSJwYWludDBfbGluZWFyXzU0OF8xOSIgeDE9IjQiIHkxPSItMiIgeDI9IjE4LjUiIHkyPSIyNSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPgo8c3RvcCBzdG9wLWNvbG9yPSIjMjIzRDk5Ii8+CjxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzE1NDM5MiIvPgo8L2xpbmVhckdyYWRpZW50Pgo8bGluZWFyR3JhZGllbnQgaWQ9InBhaW50MV9saW5lYXJfNTQ4XzE5IiB4MT0iMTAiIHkxPSItMyIgeDI9IjE4IiB5Mj0iMjciIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KPHN0b3Agc3RvcC1jb2xvcj0iI0YyRTNGRiIvPgo8c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNGMkY4RkYiLz4KPC9saW5lYXJHcmFkaWVudD4KPC9kZWZzPgo8L3N2Zz4K); + background-size: cover; +} +#kc-page-title, +#kc-info-wrapper, +.checkbox, +a, +#kc-form-buttons, +.pf-v5-c-helper-text__item-text, +.pf-v5-c-alert__title, +input[type="submit"], +input[type="button"], +button { + text-transform: lowercase; +} + +/* text input */ +.pf-v5-c-form__group { + margin-bottom: 1rem; +} +.pf-v5-c-form__group input:not([type="checkbox"]) { + width: 100%; +} +.pf-v5-c-form__group > div:first-child { + display: flex; +} +.pf-v5-c-form__label { + margin-bottom: 0.25rem; + text-transform: lowercase; + user-select: none; + display: block; +} +.pf-v5-c-input-group { + display: flex; + border-radius: 8px; +} +.pf-v5-c-form-control { + background-color: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.15)); + padding: 2px; + appearance: none; + border: 2px solid var(--text); + font-size: inherit; + color: var(--text); + text-indent: 8px; + height: 38px; + border-radius: 8px; + flex: 1; +} +.pf-v5-c-form-control:has(+ button) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.pf-v5-c-form-control + button { + appearance: none; + border-top-right-radius: 8px; + border-bottom-right-radius: 8px; + width: 38px; + border: 2px solid var(--text); + border-left: none; + background-color: light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.15)); + transition: background-color 0.1s linear; +} +.pf-v5-c-form-control + button:hover { + background-color: light-dark(rgba(0, 0, 0, 0.2), rgba(255, 255, 255, 0.2)); +} +.pf-v5-c-form__group:has(input:focus-visible) + > :is(input, .pf-v5-c-input-group) { + outline: 2px solid rgb(from var(--primary) r g b / 0.5); +} +.pf-v5-c-form__group:has(input:focus-visible) * { + border-color: var(--primary); + outline: none; +} +.pf-v5-c-form__group:has(input:focus-visible) .pf-v5-c-form__label { + color: var(--header); +} +.pf-v5-c-helper-text__item-text { + display: block; + margin-top: 0.5rem; +} +.pf-m-error { + color: light-dark(#c80000, #f56666); +} + +/* checkbox */ +.checkbox input { + display: none; +} +.checkbox label { + display: flex; + align-items: center; + user-select: none; + cursor: pointer; +} +.checkbox label:before { + content: " "; + display: block; + width: 24px; + height: 24px; + margin-right: 0.5rem; + margin-left: -2px; + background-color: var(--text); + + mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXNxdWFyZS1pY29uIGx1Y2lkZS1zcXVhcmUiPjxyZWN0IHdpZHRoPSIxOCIgaGVpZ2h0PSIxOCIgeD0iMyIgeT0iMyIgcng9IjIiLz48L3N2Zz4=); + mask-size: cover; +} +.checkbox label:has(:checked):before { + background-color: var(--primary); + mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXNxdWFyZS1jaGVjay1iaWctaWNvbiBsdWNpZGUtc3F1YXJlLWNoZWNrLWJpZyI+PHBhdGggZD0iTTIxIDEwLjY1NlYxOWEyIDIgMCAwIDEtMiAySDVhMiAyIDAgMCAxLTItMlY1YTIgMiAwIDAgMSAyLTJoMTIuMzQ0Ii8+PHBhdGggZD0ibTkgMTEgMyAzTDIyIDQiLz48L3N2Zz4=); +} +.checkbox label:has(:checked) { + color: var(--primary); +} + +/* buttons */ +.pf-v5-c-button.pf-m-primary { + background-color: var(--primary); + display: block; + border: none; + height: 30px; + border-radius: 8px; + color: white; +} +.pf-v5-c-button.pf-m-block { + display: block; + width: 100%; +} + +/* alert */ +.pf-v5-c-alert { + border-radius: 8px; + padding: 8px; + align-items: center; + margin-bottom: 1rem; + text-align: center; + text-wrap: balance; +} +.alert-error { + background-color: light-dark(#ff010182, #f56666a1); +} +.alert-warning, .alert-info { + background-color: rgb(from var(--primary) r g b / 0.5); +} + +/* icons */ +[data-password-toggle] { + display: grid; + align-items: center; + justify-content: center; +} +[data-password-toggle] i { + display: block; + width: 24px; + height: 24px; + background-color: var(--text); + mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWV5ZS1pY29uIGx1Y2lkZS1leWUiPjxwYXRoIGQ9Ik0yLjA2MiAxMi4zNDhhMSAxIDAgMCAxIDAtLjY5NiAxMC43NSAxMC43NSAwIDAgMSAxOS44NzYgMCAxIDEgMCAwIDEgMCAuNjk2IDEwLjc1IDEwLjc1IDAgMCAxLTE5Ljg3NiAwIi8+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMyIvPjwvc3ZnPg==); +} +input[type="text"] + [data-password-toggle] i { + mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWV5ZS1vZmYtaWNvbiBsdWNpZGUtZXllLW9mZiI+PHBhdGggZD0iTTEwLjczMyA1LjA3NmExMC43NDQgMTAuNzQ0IDAgMCAxIDExLjIwNSA2LjU3NSAxIDEgMCAwIDEgMCAuNjk2IDEwLjc0NyAxMC43NDcgMCAwIDEtMS40NDQgMi40OSIvPjxwYXRoIGQ9Ik0xNC4wODQgMTQuMTU4YTMgMyAwIDAgMS00LjI0Mi00LjI0MiIvPjxwYXRoIGQ9Ik0xNy40NzkgMTcuNDk5YTEwLjc1IDEwLjc1IDAgMCAxLTE1LjQxNy01LjE1MSAxIDEgMCAwIDEgMC0uNjk2IDEwLjc1IDEwLjc1IDAgMCAxIDQuNDQ2LTUuMTQzIi8+PHBhdGggZD0ibTIgMiAyMCAyMCIvPjwvc3ZnPg==); +} + +@media (max-width: 799px) { + body { + background-position: left 65% bottom 60%; + background-size: auto 150%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + } + body:before { + display: block; + content: ""; + flex: 1.5; + } + .pf-v5-c-login__main { + flex: 1 1 40%; + max-width: 25rem; + min-width: 80%; + border-width: 4px; + border-bottom-width: 0; + border-top-left-radius: 32px; + border-top-right-radius: 32px; + corner-shape: superellipse(0); + justify-content: space-between; + --bg: light-dark(rgba(255, 255, 255, 0.7), rgba(0.2, 0, 0.2, 0.6)); + /* the logo straddles the card's top edge; overflow-y: auto would clip + the half that sticks out above (#35) */ + overflow-y: visible; + } + .pf-v5-c-login__main:after { + display: block; + content: ""; + } + .kc-logo-text::before { + position: absolute; + left: 50%; + width: 100px; + transform: translate(-50%, calc(-50% - 35px)); + } +} + +#credit { + position: fixed; + bottom: 2px; + right: 2px; + font-size: 14px; +} +#credit a:not(:hover) { + opacity: 0.5; +} + +.subtitle:has(.subtitle .required) { + display: none; +} diff --git a/dashboard/web/sso/img/license.txt b/dashboard/web/sso/img/license.txt new file mode 100644 index 0000000000000000000000000000000000000000..e3dfb05a4dc66f6de256016ed332ea9d27d56d04 --- /dev/null +++ b/dashboard/web/sso/img/license.txt @@ -0,0 +1,2 @@ +https://safebooru.org/index.php?page=post&s=view&id=4405346 +dark mode by paper clover diff --git a/dashboard/web/sso/img/miku-dark.png b/dashboard/web/sso/img/miku-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..635e025142cb94b89f97ac79c6b460ca2c8294f4 Binary files /dev/null and b/dashboard/web/sso/img/miku-dark.png differ diff --git a/dashboard/web/sso/img/miku-light.png b/dashboard/web/sso/img/miku-light.png new file mode 100644 index 0000000000000000000000000000000000000000..5dc5ef1e1b250627c999b4fbe296af62cc2dea82 Binary files /dev/null and b/dashboard/web/sso/img/miku-light.png differ diff --git a/dashboard/web/types/users.ts b/dashboard/web/types/users.ts index c2bfd03c592d2b61d83b73dbaf4a62ed51b5ed6e..c2fe55f6805286ea52827ef80a94abb6bc61fa7e 100644 --- a/dashboard/web/types/users.ts +++ b/dashboard/web/types/users.ts @@ -1,6 +1,8 @@ export interface User { id: string; username: string; + kind?: "guest"; + guestProvider?: "github" | "astheno"; email: string | null; firstName: string | null; lastName: string | null; diff --git a/nixos/dashboard.nix b/nixos/dashboard.nix index bcdba0ad1b3b646f0ea864cd896fa0a26b78242c..8c221112cf3da7aae250cce7fd66b7145070adc1 100644 --- a/nixos/dashboard.nix +++ b/nixos/dashboard.nix @@ -19,8 +19,6 @@ let fileset = lib.fileset.unions [ ../dashboard/web ../dashboard/package.json ../dashboard/pnpm-lock.yaml ../dashboard/tsconfig.json ../dashboard/vite.config.ts - ../service/keycloak/theme/login/resources/css - ../service/keycloak/theme/login/resources/img ]; }; sourceRoot = "source/dashboard"; diff --git a/tools/dashboard-oidc-test.py b/tools/dashboard-oidc-test.py index cfd7fef9e09df0cb1b547b39febad4b3afeb28cc..a7e58ff10a301f3ec7d07426a7fca42479badf2f 100644 --- a/tools/dashboard-oidc-test.py +++ b/tools/dashboard-oidc-test.py @@ -49,13 +49,13 @@ def main(): binary = str(args.binary.resolve()) result = subprocess.run([binary, '--import-accounts', str(data / 'source.json')], env=environment, capture_output=True, text=True) assert result.returncode == 0, result.stderr - def provision(client, redirects, aliases=None, status=0): + def provision(client, redirects, aliases=None, status=0, guests=False, username=False): result = subprocess.run([binary, '--oidc-client'], env=environment, capture_output=True, text=True, input=json.dumps({'request': {'kind': 'client', 'clientId': client, 'name': client, - 'redirectUris': redirects, 'usernameAliases': aliases or {}}, + 'redirectUris': redirects, 'usernameAliases': aliases or {}, 'allowGuests': guests, 'usernameRequired': username}, 'existing': {'clientId': client, 'clientSecret': secret}})) assert result.returncode == status, result.stderr - provision('shale', [callback], {'oidc-test': 'clover'}) + provision('shale', [callback], {'oidc-test': 'clover'}, guests=True, username=True) provision('other', ['https://jelly.paperclover.net/callback']) provision('bad', ['https://evil.example/callback'], status=1) provision('bad', ['https://shale.paperclover.net/*'], status=1) @@ -164,9 +164,54 @@ def main(): request('/auth/sign-out', 'POST', {}) request('/auth/oidc/userinfo', extra=bearer(tokens['access_token']), status=401) request('/auth/oidc/token', 'POST', {**refresh, 'refresh_token': tokens['refresh_token']}, form=True, status=400) + # The real Shale client requests only openid; its registered mapping still supplies a username. + request('/auth/password', 'POST', login) + minimal = {**unbound, 'scope': 'openid'} + tokens = request('/auth/oidc/token', 'POST', {'grant_type': 'authorization_code', 'code': code(minimal), 'redirect_uri': callback}, extra=basic, form=True) + assert request('/auth/oidc/userinfo', extra=bearer(tokens['access_token'])) == {'sub': actor, 'preferred_username': 'clover'} + request('/auth/sign-out', 'POST', {}) + # Provider UI is offered only for an exact registered Shale authorization request. + configured = subprocess.run([binary, '--guest-provider'], env=environment, capture_output=True, text=True, + input=json.dumps({'provider': 'github', 'clientId': 'fixture', 'clientSecret': secret})) + assert configured.returncode == 0, configured.stderr + target = authorize_path(minimal) + assert request('/auth/status')['providers'] == [] + assert request('/auth/status?' + urllib.parse.urlencode({'next': target}))['providers'] == [{'id':'github','name':'GitHub'}] + request('/auth/guest/start/github?' + urllib.parse.urlencode({'next': '/'}), status=400) + started = request('/auth/guest/start/github?' + urllib.parse.urlencode({'next': target}), status=302) + external = urllib.parse.urlparse(started['location']); parameters = urllib.parse.parse_qs(external.query) + assert external.netloc == 'github.com' and parameters['scope'] == ['read:user'] and parameters['code_challenge_method'] == ['S256'] + state = parameters['state'][0] + request('/auth/guest/callback/github?state=' + state, session=False, status=403) + request('/auth/guest/callback/astheno?state=' + state, status=503) + declined = request('/auth/guest/callback/github?' + urllib.parse.urlencode({'state':state,'error':'access_denied'}), status=302)['location'] + assert 'guest_error=1' in declined + request('/auth/guest/callback/github?state=' + state, status=403) + # A guest's SSO cookie cannot open dashboard APIs, Files, credentials, or other OIDC clients. + guest, session_token = str(uuid.uuid4()), b64(os.urandom(32)) + db = sqlite3.connect(data / 'accounts.sqlite') + profile = {'kind':'guest','guestProvider':'github','username':'guest-github-123','enabled':True,'email':None,'emailVerified':False,'firstName':'clover','lastName':None,'requiredActions':[]} + db.execute('INSERT INTO users(id,profile) VALUES (?,?)', (guest,json.dumps(profile))) + stamp = int(time.time()); session_hash = hashlib.sha256(session_token.encode()).hexdigest() + db.execute('INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?)',(session_hash,guest,'dashboard',stamp+3600,'127.0.0.1',stamp,stamp,stamp)); db.commit() + cookies['__Host-snow-session'] = session_token + guest_csrf = request('/auth/status')['csrf'] + request('/api/me', status=403) + assert request('/', status=302)['location'] == 'https://shale.paperclover.net/' + request('/auth/file/check', status=401) + request('/auth/passkey/register','POST',{'csrf':guest_csrf},status=403) + request(authorize_path({**unbound, 'client_id':'other','redirect_uri':'https://jelly.paperclover.net/callback'}), status=403) + tokens = request('/auth/oidc/token','POST',{'grant_type':'authorization_code','code':code(minimal),'redirect_uri':callback},extra=basic,form=True) + identity = request('/auth/oidc/userinfo',extra=bearer(tokens['access_token'])) + assert identity == {'sub':guest,'preferred_username':'guest-github-123'} + provision('shale',[callback],{'oidc-test':'clover'},username=True) + request('/auth/oidc/userinfo',extra=bearer(tokens['access_token']),status=401) + request('/auth/oidc/token','POST',{'grant_type':'refresh_token','refresh_token':tokens['refresh_token']},extra=basic,form=True,status=400) + db.close() print(json.dumps({'signature_nonce_alias_claims': 'passed', 'client_redirect_pkce_binding': 'passed', 'code_one_use': 'passed', 'refresh_rotation_reuse_revocation': 'passed', 'basic_client_auth': 'passed', - 'forced_login': 'passed', 'disabled_account': 'passed', 'restart_key_persistence': 'passed', 'logout_revocation': 'passed'})) + 'forced_login': 'passed', 'disabled_account': 'passed', 'restart_key_persistence': 'passed', 'logout_revocation': 'passed', + 'shale_openid_username_mapping': 'passed', 'guest_flow_cookie_and_provider_binding': 'passed', 'guest_scope_and_service_boundaries': 'passed'})) finally: if server and server.poll() is None: stop() log.close() diff --git a/tools/guest-provider.py b/tools/guest-provider.py new file mode 100644 index 0000000000000000000000000000000000000000..c9211f1441b8f8e745e0f2ef8c050c8a1e0bc933 --- /dev/null +++ b/tools/guest-provider.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Configure Shale guest login using an existing provider registration. + +Register a GitHub OAuth App at https://github.com/settings/developers with homepage +https://shale.paperclover.net and callback +https://snowglobe.paperclover.net/auth/guest/callback/github. +For Astheno Identity, register a confidential OpenID Connect client with callback +https://snowglobe.paperclover.net/auth/guest/callback/astheno and openid/profile scopes. +GitHub requests only read:user. Neither provider grants repository access. + +Run: python3 tools/guest-provider.py github --client-id CLIENT_ID +The secret is read with a hidden prompt and passed over SSH stdin, never in argv. +Disable: python3 tools/guest-provider.py github --disable +""" +import argparse +import getpass +import json +import os +import subprocess + +parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) +parser.add_argument('provider', choices=['github', 'astheno']) +parser.add_argument('--client-id') +parser.add_argument('--disable', action='store_true') +parser.add_argument('--host', default=os.environ.get('STUDIO_DEPLOY_HOST', 'root@zenith')) +parser.add_argument('--port', default=os.environ.get('STUDIO_DEPLOY_PORT', '22')) +args = parser.parse_args() +if not args.disable and not args.client_id: + parser.error('--client-id is required unless --disable is used') +payload = {'provider': args.provider, 'enabled': not args.disable} +if not args.disable: + payload.update(clientId=args.client_id, clientSecret=getpass.getpass('Client secret: ')) +result = subprocess.run(['ssh', '-p', args.port, args.host, + 'podman exec -i studio-dashboard /bin/home-dashboard --guest-provider'], + input=json.dumps(payload), text=True, capture_output=True) +if result.returncode: + raise SystemExit('Provider configuration did not complete. Check the dashboard and SSH connection.') +print(result.stdout.strip()) diff --git a/tools/shale-migration.md b/tools/shale-migration.md index d1860d91131fd989a84c40a6158ab007691b0436..bad757fc4bceb839737a72926d70b20e19d008ce 100644 --- a/tools/shale-migration.md +++ b/tools/shale-migration.md @@ -50,6 +50,8 @@ Six occupied numbers are remapped: `chat` #1→#6 and #2→#7; `home-infra` #1 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. +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. + 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. 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. diff --git a/tools/studio.py b/tools/studio.py index 696a29a33dcbf38246c5dca49a2e9a78d58cfb00..bc748bffeecaa6f35f1d20104c8e456a855c6def 100644 --- a/tools/studio.py +++ b/tools/studio.py @@ -1012,6 +1012,9 @@ def main(): task["http"]["hostname"] = hostname task["http"]["plainHostnames"] = [stage + "-" + host for host in task["http"]["plainHostnames"]] task["env"] = {key: value.replace(original_host, hostname) for key, value in task["env"].items()} + for request in data["inputs"].values(): + if request["provider"] == "snowglobe": + request["redirectUris"] = [uri.replace(original_host, hostname) for uri in request["redirectUris"]] for assignment in args.env: key, separator, value = assignment.partition("=") if not separator or len(data["containers"]) != 1: