authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 01:30:37-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 15:36:02-07:00
log235cd050154d9faf139ce8975ab370f5432db911
tree6e8c006534e2a077d387ba9fd76e61886b7f43d2
parent4dd72bcd7c26fc5de14d5198985891f111f1f29f
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

Add restricted Shale guest sign-in and preserve SnowSignOn theme


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: String14 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}
35fn set_cookie(name: &str, value: &str, ttl: i64) -> String {35pub(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}
38fn row(db: &Connection, statement: &str, key: &str) -> Result<Value> {38fn 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 = headers425 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}
547561
548pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Response> {562pub 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 @@
1use crate::*;
2use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
3use openssl::{
4 bn::{BigNum, BigNumContext},
5 ec::{EcGroup, EcKey, EcPoint},
6 ecdsa::EcdsaSig,
7 hash::MessageDigest,
8 nid::Nid,
9 pkey::PKey,
10 sign::Verifier,
11};
12use rusqlite::{Connection, OptionalExtension, params as sql};
13use sha2::{Digest, Sha256};
14
15const COOKIE: &str = "__Host-snow-guest";
16const ASTHENO: &str = "https://identity.astheno.software";
17
18pub fn is_guest(user: &Value) -> bool {
19 user["kind"] == "guest"
20}
21
22pub fn initialise(db: &Connection) -> Result<()> {
23 db.execute_batch("CREATE TABLE IF NOT EXISTS guest_providers (id TEXT PRIMARY KEY, client_id TEXT NOT NULL, secret TEXT NOT NULL);
24 CREATE TABLE IF NOT EXISTS external_identities (provider TEXT NOT NULL, subject TEXT NOT NULL, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, PRIMARY KEY(provider,subject), UNIQUE(user_id));
25 CREATE TRIGGER IF NOT EXISTS guest_no_groups BEFORE INSERT ON memberships WHEN (SELECT json_extract(profile,'$.kind') FROM users WHERE id=NEW.user_id)='guest' BEGIN SELECT RAISE(ABORT,'guest accounts cannot join groups'); END;
26 CREATE TRIGGER IF NOT EXISTS guest_no_credentials BEFORE INSERT ON credentials WHEN (SELECT json_extract(profile,'$.kind') FROM users WHERE id=NEW.user_id)='guest' BEGIN SELECT RAISE(ABORT,'guest accounts use external sign-in'); END;
27 CREATE TRIGGER IF NOT EXISTS guest_kind_fixed BEFORE UPDATE OF profile ON users WHEN json_extract(OLD.profile,'$.kind')='guest' AND coalesce(json_extract(NEW.profile,'$.kind'),'')!='guest' BEGIN SELECT RAISE(ABORT,'guest account kind is fixed'); END;")?;
28 Ok(())
29}
30
31fn known(provider: &str) -> Result<&str> {
32 match provider {
33 "github" => Ok("GitHub"),
34 "astheno" => Ok("Astheno"),
35 _ => Err(Error::new(
36 404,
37 "Choose GitHub or Astheno on the Shale sign-in page.",
38 )),
39 }
40}
41
42/// Root-owned CLI only. Secrets stay in the private accounts database and its backups.
43pub fn provision(auth: &auth::Store, input: Value) -> Result<Value> {
44 let provider = string(&input["provider"]);
45 known(provider)?;
46 let mut db = auth.db.lock().unwrap();
47 let tx = db.transaction()?;
48 tx.execute(
49 "DELETE FROM pending WHERE kind='guest' AND json_extract(data,'$.provider')=?",
50 [provider],
51 )?;
52 tx.execute("DELETE FROM sessions WHERE user_id IN (SELECT user_id FROM external_identities WHERE provider=?)", [provider])?;
53 if input["enabled"] == false {
54 tx.execute("DELETE FROM guest_providers WHERE id=?", [provider])?;
55 } else {
56 let id = string(&input["clientId"]);
57 let secret = string(&input["clientSecret"]);
58 if id.is_empty()
59 || id.len() > 256
60 || secret.len() < 16
61 || secret.len() > 2048
62 || id.chars().chain(secret.chars()).any(char::is_control)
63 {
64 return Err(Error::new(
65 400,
66 "Provide the registered client ID and secret.",
67 ));
68 }
69 tx.execute("INSERT INTO guest_providers VALUES (?,?,?) ON CONFLICT(id) DO UPDATE SET client_id=excluded.client_id,secret=excluded.secret", sql![provider,id,secret])?;
70 }
71 tx.commit()?;
72 Ok(
73 json!({"provider":provider,"enabled":input["enabled"]!=false,"callback":format!("{}auth/guest/callback/{provider}",auth.origin)}),
74 )
75}
76
77pub fn providers(auth: &auth::Store, next: &str) -> Result<Value> {
78 if oidc::guest_target(auth, next).is_err() {
79 return Ok(json!([]));
80 }
81 let db = auth.db.lock().unwrap();
82 let mut query = db.prepare("SELECT id FROM guest_providers ORDER BY id")?;
83 let ids = query
84 .query_map([], |r| r.get::<_, String>(0))?
85 .collect::<std::result::Result<Vec<_>, _>>()?;
86 Ok(json!(
87 ids.iter()
88 .map(|id| json!({"id":id,"name":known(id).unwrap_or(id)}))
89 .collect::<Vec<_>>()
90 ))
91}
92
93fn registration(auth: &auth::Store, provider: &str) -> Result<(String, String)> {
94 auth.db.lock().unwrap().query_row("SELECT client_id,secret FROM guest_providers WHERE id=?", [provider], |r|Ok((r.get(0)?,r.get(1)?)))
95 .optional()?.ok_or_else(|| Error::new(503, "This sign-in provider isn't available. Use your Snowglobe account or try again later."))
96}
97
98async fn response_bytes(mut response: reqwest::Response) -> Result<Vec<u8>> {
99 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
118fn 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.
128fn 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
231async 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
366fn 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
405fn 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
418async 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
561pub 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)]
577mod 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;
4mod core;4mod core;
5mod deploys;5mod deploys;
6mod files;6mod files;
7mod guest;
7mod host;8mod host;
8mod index;9mod index;
9mod mcp;10mod 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::OPTIONS586 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"] == true499 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}
5555
56fn guests_allowed(id: &str, config: &Value) -> bool {
57 config["allowGuests"] == true && (id == "shale" || id.starts_with("shale-preview-"))
58}
59
60pub(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
56pub fn username(auth: &auth::Store, user_id: &str, client_id: &str) -> Result<String> {97pub 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)> = tx216 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> {
203fn claims(user: &Value, config: &Value, scopes: &str) -> Value {262fn 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(
296fn tokens(db: &Connection, auth: &auth::Store, data: &Value, nonce: Option<&str>) -> Result<Value> {366fn 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 = auth479 let auth_time: i64 = auth
404 .db480 .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;
238241
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 }));
341344
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 }));
346369
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>
950973
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. */
2body { font: 16px "Name Sans", sans-serif; }2body { 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 @@
1import { createEffect, createResource, createSignal, onCleanup, onMount, Show } from "solid-js";1import { createEffect, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js";
2import { authReason, authRequest } from "../auth.ts";2import { authReason, authRequest } from "../auth.ts";
3import theme from "../../../service/keycloak/theme/login/resources/css/styles.css?inline";3import theme from "../sso/css/styles.css?inline";
4import adjustments from "./SignIn.css?inline";4import adjustments from "./SignIn.css?inline";
55
6export function SignIn() {6export 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() {
109109
110function StateTag(props: { user: User }) {110function 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}
122125
...@@ -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 open524 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-in536 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>
561568
dashboard/web/sso/css/styles.css created+345
...@@ -0,0 +1,345 @@
1@import "https://file.paperclover.net/.static/font.css";
2
3/* reset */
4html,
5body {
6 height: 100%;
7}
8
9h1,
10h2,
11h3,
12h4,
13h5,
14h6 {
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 */
54body {
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}
115a {
116 color: var(--header);
117 text-decoration: dotted underline;
118}
119a: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,
137a,
138#kc-form-buttons,
139.pf-v5-c-helper-text__item-text,
140.pf-v5-c-alert__title,
141input[type="submit"],
142input[type="button"],
143button {
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}
288input[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 @@
1https://safebooru.org/index.php?page=post&s=view&id=4405346
2dark 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 @@
1export interface User {1export 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.yaml20 ../dashboard/web ../dashboard/package.json ../dashboard/pnpm-lock.yaml
21 ../dashboard/tsconfig.json ../dashboard/vite.config.ts21 ../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.stderr51 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.stderr57 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
4Register a GitHub OAuth App at https://github.com/settings/developers with homepage
5https://shale.paperclover.net and callback
6https://snowglobe.paperclover.net/auth/guest/callback/github.
7For Astheno Identity, register a confidential OpenID Connect client with callback
8https://snowglobe.paperclover.net/auth/guest/callback/astheno and openid/profile scopes.
9GitHub requests only read:user. Neither provider grants repository access.
10
11Run: python3 tools/guest-provider.py github --client-id CLIENT_ID
12The secret is read with a hidden prompt and passed over SSH stdin, never in argv.
13Disable: python3 tools/guest-provider.py github --disable
14"""
15import argparse
16import getpass
17import json
18import os
19import subprocess
20
21parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
22parser.add_argument('provider', choices=['github', 'astheno'])
23parser.add_argument('--client-id')
24parser.add_argument('--disable', action='store_true')
25parser.add_argument('--host', default=os.environ.get('STUDIO_DEPLOY_HOST', 'root@zenith'))
26parser.add_argument('--port', default=os.environ.get('STUDIO_DEPLOY_PORT', '22'))
27args = parser.parse_args()
28if not args.disable and not args.client_id:
29 parser.error('--client-id is required unless --disable is used')
30payload = {'provider': args.provider, 'enabled': not args.disable}
31if not args.disable:
32 payload.update(clientId=args.client_id, clientSecret=getpass.getpass('Client secret: '))
33result = 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)
36if result.returncode:
37 raise SystemExit('Provider configuration did not complete. Check the dashboard and SSH connection.')
38print(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
5050
51The 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.51The 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.
5252
53Production 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
53This 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.55This 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.
5456
55The 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.57The 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"] = hostname1012 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: