1use crate::*;
2use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
3use base64::{
4 Engine,
5 engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE_NO_PAD},
6};
7use rusqlite::{Connection, OptionalExtension, params as sql};
8use std::os::unix::fs::PermissionsExt;
9use webauthn_rs::prelude::*;
10
11const COOKIE: &str = "__Host-snow-session";
12const FLOW_COOKIE: &str = "__Host-snow-flow";
13const SESSION_TTL: i64 = 30 * 86400;
14const GROUPS: &[&str] = &["infra-admin", "media", "media-manage", "metrics", "vm", "ai"];
15
16pub struct Store {
17 pub db: Mutex<Connection>,
18 pub origin: url::Url,
19 file: url::Url,
20 webauthn: Webauthn,
21 passwords: Semaphore,
22}
23
24pub fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
25 headers
26 .get("cookie")?
27 .to_str()
28 .ok()?
29 .split(';')
30 .find_map(|part| {
31 let (key, value) = part.trim().split_once('=')?;
32 (key == name).then(|| value.to_owned())
33 })
34}
35pub(crate) fn set_cookie(name: &str, value: &str, ttl: i64) -> String {
36 format!("{name}={value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={ttl}")
37}
38fn row(db: &Connection, statement: &str, key: &str) -> Result<Value> {
39 let value: Option<String> = db.query_row(statement, [key], |r| r.get(0)).optional()?;
40 Ok(value
41 .map(|s| serde_json::from_str(&s))
42 .transpose()?
43 .unwrap_or(Value::Null))
44}
45pub(crate) fn pending(db: &Connection, token: &str, kind: &str, consume: bool) -> Result<Value> {
46 let value: Option<String> = db
47 .query_row(
48 "SELECT data FROM pending WHERE hash=? AND kind=? AND expires>?",
49 sql![mcp::hash(token), kind, now() as i64],
50 |r| r.get(0),
51 )
52 .optional()?;
53 if consume && value.is_some() {
54 db.execute("DELETE FROM pending WHERE hash=?", [mcp::hash(token)])?;
55 }
56 Ok(value
57 .map(|s| serde_json::from_str(&s))
58 .transpose()?
59 .unwrap_or(Value::Null))
60}
61pub(crate) fn issue(db: &Connection, kind: &str, value: Value, ttl: i64) -> Result<String> {
62 db.execute("DELETE FROM pending WHERE expires<=?", [now() as i64])?;
63 let count: i64 = db.query_row("SELECT count(*) FROM pending", [], |r| r.get(0))?;
64 if count >= 4096 {
65 return Err(Error::new(
66 429,
67 "Too many sign-in requests. Try again in a few minutes.",
68 ));
69 }
70 let token = mcp::secret();
71 db.execute(
72 "INSERT INTO pending VALUES (?,?,?,?)",
73 sql![
74 mcp::hash(&token),
75 kind,
76 value.to_string(),
77 now() as i64 + ttl
78 ],
79 )?;
80 Ok(token)
81}
82pub fn user(db: &Connection, id: &str) -> Result<Value> {
83 let mut profile = row(db, "SELECT profile FROM users WHERE id=?", id)?;
84 if profile.is_null() {
85 return Err(Error::new(
86 404,
87 "This account no longer exists. Sign in again.",
88 ));
89 }
90 profile["id"] = json!(id);
91 let mut statement = db.prepare("SELECT roles.id, roles.name FROM roles JOIN memberships ON roles.id=memberships.role_id WHERE user_id=? ORDER BY roles.name")?;
92 profile["groups"] = json!(
93 statement
94 .query_map([id], |r| Ok(
95 json!({"id":r.get::<_,String>(0)?,"name":r.get::<_,String>(1)?})
96 ))?
97 .collect::<std::result::Result<Vec<_>, _>>()?
98 );
99 Ok(profile)
100}
101pub fn credentials(db: &Connection, id: &str) -> Result<Value> {
102 let mut statement = db.prepare(
103 "SELECT id, kind, label, created FROM credentials WHERE user_id=? ORDER BY created",
104 )?;
105 Ok(json!(statement.query_map([id], |r| Ok(json!({"id":r.get::<_,String>(0)?,"type":r.get::<_,String>(1)?,"userLabel":r.get::<_,Option<String>>(2)?,"createdDate":r.get::<_,i64>(3)?})))?.collect::<std::result::Result<Vec<_>,_>>()?))
106}
107pub fn save_user(db: &Connection, id: &str, mut profile: Value) -> Result<()> {
108 for key in [
109 "id",
110 "groups",
111 "sessions",
112 "credentials",
113 "picture",
114 "console",
115 ] {
116 profile.as_object_mut().unwrap().remove(key);
117 }
118 db.execute(
119 "UPDATE users SET profile=? WHERE id=?",
120 sql![profile.to_string(), id],
121 )
122 .map_err(|_| Error::new(409, "That username is already taken. Choose another."))?;
123 Ok(())
124}
125pub fn password_hash(password: &str) -> Result<String> {
126 let salt = SaltString::encode_b64(&rand::random::<[u8; 16]>())
127 .map_err(|_| Error::new(500, "Couldn't prepare password storage."))?;
128 Ok(Argon2::default()
129 .hash_password(password.as_bytes(), &salt)
130 .map_err(|_| Error::new(500, "Couldn't store the password."))?
131 .to_string())
132}
133pub fn set_password(db: &Connection, id: &str, hash: &str) -> Result<()> {
134 db.execute(
135 "DELETE FROM credentials WHERE user_id=? AND kind='password'",
136 [id],
137 )?;
138 db.execute(
139 "INSERT INTO credentials VALUES (?,?,?,?,?,?)",
140 sql![
141 uuid::Uuid::new_v4().to_string(),
142 id,
143 "password",
144 Option::<String>::None,
145 (now() * 1000.0) as i64,
146 json!({"phc":hash}).to_string()
147 ],
148 )?;
149 Ok(())
150}
151
152impl Store {
153 pub fn new(data: &std::path::Path, origin: &str, file: &str, rp: &str) -> Result<Self> {
154 let origin = url::Url::parse(origin)?;
155 let file = url::Url::parse(file)?;
156 if origin.scheme() != "https"
157 || file.scheme() != "https"
158 || origin.path() != "/"
159 || file.path() != "/"
160 || origin.origin() == file.origin()
161 {
162 return Err(Error::new(
163 500,
164 "Set separate HTTPS origins for Snowglobe and Files.",
165 ));
166 }
167 let rp_origin = url::Url::parse(&format!("https://{rp}"))?;
168 let webauthn = WebauthnBuilder::new(rp, &rp_origin)?
169 .append_allowed_origin(&origin)
170 .rp_name("snow globe")
171 .build()?;
172 std::fs::create_dir_all(data)?;
173 let path = data.canonicalize()?.join("accounts.sqlite");
174 let db = Connection::open_with_flags(
175 &path,
176 rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE
177 | rusqlite::OpenFlags::SQLITE_OPEN_CREATE
178 | rusqlite::OpenFlags::SQLITE_OPEN_NOFOLLOW,
179 )?;
180 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
181 db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;
182 CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, profile TEXT NOT NULL, username TEXT GENERATED ALWAYS AS (json_extract(profile,'$.username')) STORED UNIQUE);
183 CREATE UNIQUE INDEX IF NOT EXISTS verified_email ON users(lower(json_extract(profile,'$.email'))) WHERE json_extract(profile,'$.emailVerified')=1 AND json_extract(profile,'$.email') IS NOT NULL;
184 CREATE TABLE IF NOT EXISTS roles (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE);
185 CREATE TABLE IF NOT EXISTS memberships (user_id TEXT REFERENCES users(id) ON DELETE CASCADE, role_id TEXT REFERENCES roles(id), PRIMARY KEY(user_id,role_id));
186 CREATE TABLE IF NOT EXISTS credentials (id TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,kind TEXT NOT NULL,label TEXT,created INTEGER NOT NULL,data TEXT NOT NULL);
187 CREATE TABLE IF NOT EXISTS sessions (hash TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,client TEXT NOT NULL CHECK(client IN ('dashboard','file')),expires INTEGER NOT NULL,ip TEXT NOT NULL,created INTEGER NOT NULL,last_used INTEGER NOT NULL,auth_time INTEGER NOT NULL);
188 CREATE TABLE IF NOT EXISTS pending (hash TEXT PRIMARY KEY,kind TEXT NOT NULL,data TEXT NOT NULL,expires INTEGER NOT NULL);
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);")?;
191 oidc::initialise(&db)?;
192 guest::initialise(&db)?;
193 for name in GROUPS {
194 db.execute(
195 "INSERT OR IGNORE INTO roles(id,name) VALUES (?,?)",
196 sql![uuid::Uuid::new_v4().to_string(), name],
197 )?;
198 }
199 Ok(Self {
200 db: Mutex::new(db),
201 origin,
202 file,
203 webauthn,
204 passwords: Semaphore::new(2),
205 })
206 }
207 pub fn ready(&self) -> bool {
208 self.db
209 .lock()
210 .unwrap()
211 .query_row("SELECT EXISTS(SELECT 1 FROM users)", [], |r| r.get(0))
212 .unwrap_or(false)
213 }
214 pub fn import(&self, export: Value) -> Result<Value> {
215 if self
216 .webauthn
217 .get_allowed_origins()
218 .first()
219 .and_then(|u| u.host_str())
220 != export["rpId"].as_str()
221 {
222 return Err(Error::new(
223 400,
224 "The export's passkey domain does not match this server.",
225 ));
226 }
227 let mut db = self.db.lock().unwrap();
228 let digest = mcp::hash(&export.to_string());
229 if db.query_row(
230 "SELECT EXISTS(SELECT 1 FROM migration WHERE digest=?)",
231 [&digest],
232 |r| r.get::<_, bool>(0),
233 )? {
234 return Ok(
235 json!({"accounts":array(&export["users"]).len(),"credentials":array(&export["users"]).iter().map(|u|array(&u["credentials"]).len()).sum::<usize>()}),
236 );
237 }
238 if db.query_row("SELECT count(*) FROM users", [], |r| r.get::<_, i64>(0))? != 0 {
239 return Err(Error::new(
240 409,
241 "Accounts already exist. Import into an empty store.",
242 ));
243 }
244 let transaction = db.transaction()?;
245 for role in array(&export["roles"]) {
246 if GROUPS.contains(&string(&role["name"])) {
247 transaction.execute(
248 "INSERT INTO roles VALUES (?,?) ON CONFLICT(name) DO UPDATE SET id=excluded.id",
249 sql![string(&role["id"]), string(&role["name"])],
250 )?;
251 }
252 }
253 let mut count = 0;
254 for profile in array(&export["users"]) {
255 let id = string(&profile["id"]);
256 uuid::Uuid::parse_str(id)?;
257 string(&profile["username"])
258 .parse::<axum::http::HeaderValue>()
259 .map_err(|_| Error::new(400, "The source has an invalid username."))?;
260 let mut value = profile.clone();
261 value["requiredActions"] = json!(
262 array(&profile["requiredActions"])
263 .iter()
264 .filter(|v| **v == "UPDATE_PASSWORD" || **v == "UPDATE_PROFILE")
265 .collect::<Vec<_>>()
266 );
267 for key in ["id", "roles", "credentials"] {
268 value.as_object_mut().unwrap().remove(key);
269 }
270 transaction.execute(
271 "INSERT INTO users(id,profile) VALUES (?,?)",
272 sql![id, value.to_string()],
273 )?;
274 for role in array(&profile["roles"]) {
275 transaction.execute(
276 "INSERT INTO memberships SELECT ?,id FROM roles WHERE id=?",
277 sql![id, string(role)],
278 )?;
279 }
280 for credential in array(&profile["credentials"]) {
281 let kind = string(&credential["type"]);
282 let source = &credential["credentialData"];
283 let data = match kind {
284 "password" => {
285 if source["algorithm"] != "argon2"
286 || source["additionalParameters"]["type"][0] != "id"
287 {
288 return Err(Error::new(
289 500,
290 "The source uses an unsupported password format.",
291 ));
292 }
293 let parameters = &source["additionalParameters"];
294 let salt = STANDARD_NO_PAD
295 .encode(STANDARD.decode(string(&credential["secretData"]["salt"]))?);
296 let hash = STANDARD_NO_PAD
297 .encode(STANDARD.decode(string(&credential["secretData"]["value"]))?);
298 let phc = format!(
299 "$argon2id$v=19$m={},t={},p={}${}${}",
300 string(&parameters["memory"][0]),
301 source["hashIterations"],
302 string(&parameters["parallelism"][0]),
303 salt,
304 hash
305 );
306 PasswordHash::new(&phc).map_err(|_| {
307 Error::new(500, "The source password hash couldn't be imported.")
308 })?;
309 json!({"phc":phc})
310 }
311 "webauthn-passwordless" => {
312 let key: serde_cbor_2::Value = serde_cbor_2::from_slice(
313 &URL_SAFE_NO_PAD.decode(string(&source["credentialPublicKey"]))?,
314 )?;
315 let public_key = COSEKey::try_from(&key)?;
316 let cred = Credential {
317 cred_id: STANDARD.decode(string(&source["credentialId"]))?.into(),
318 cred: public_key,
319 counter: source["counter"].as_u64().unwrap_or(0).try_into()?,
320 transports: serde_json::from_value(source["transports"].clone())
321 .unwrap_or(None),
322 user_verified: true,
323 backup_eligible: false,
324 backup_state: false,
325 registration_policy: serde_json::from_value(json!("required"))?,
326 extensions: Default::default(),
327 attestation: Default::default(),
328 attestation_format: AttestationFormat::None,
329 };
330 // Keycloak omits backup flags; learn them only from the first verified assertion.
331 json!({"passkey":Passkey::from(cred),"handle":URL_SAFE_NO_PAD.encode(id.as_bytes()),"backupUnknown":true})
332 }
333 _ => {
334 return Err(Error::new(
335 500,
336 "The source has a credential type this import doesn't support.",
337 ));
338 }
339 };
340 transaction.execute(
341 "INSERT INTO credentials VALUES (?,?,?,?,?,?)",
342 sql![
343 string(&credential["id"]),
344 id,
345 kind,
346 credential["userLabel"].as_str(),
347 credential["createdDate"].as_i64().unwrap_or(0),
348 data.to_string()
349 ],
350 )?;
351 count += 1;
352 }
353 }
354 transaction.execute("INSERT INTO migration VALUES (?)", [digest])?;
355 transaction.commit()?;
356 Ok(json!({"accounts":array(&export["users"]).len(),"credentials":count}))
357 }
358 pub fn session(&self, headers: &HeaderMap, client: &str) -> Result<Value> {
359 let Some(token) = cookie(headers, COOKIE) else {
360 return Ok(Value::Null);
361 };
362 let db = self.db.lock().unwrap();
363 let id: Option<String> = db
364 .query_row(
365 "SELECT user_id FROM sessions WHERE hash=? AND client=? AND expires>?",
366 sql![mcp::hash(&token), client, now() as i64],
367 |r| r.get(0),
368 )
369 .optional()?;
370 let Some(id) = id else {
371 return Ok(Value::Null);
372 };
373 let user = user(&db, &id)?;
374 if user["enabled"] != true || (client == "file" && guest::is_guest(&user)) {
375 return Ok(Value::Null);
376 }
377 db.execute(
378 "UPDATE sessions SET last_used=? WHERE hash=? AND last_used<?",
379 sql![
380 (now() * 1000.0) as i64,
381 mcp::hash(&token),
382 (now() * 1000.0) as i64 - 60000
383 ],
384 )?;
385 Ok(user)
386 }
387 pub(crate) fn create_session(
388 &self,
389 id: &str,
390 client: &str,
391 headers: &HeaderMap,
392 password: Option<&Value>,
393 ) -> Result<String> {
394 let token = mcp::secret();
395 let db = self.db.lock().unwrap();
396 let profile = user(&db, id)?;
397 if profile["enabled"] != true || (client == "file" && guest::is_guest(&profile)) {
398 return Err(Error::new(403, "This account is disabled."));
399 }
400 if let Some(expected) = password {
401 if row(
402 &db,
403 "SELECT data FROM credentials WHERE user_id=? AND kind='password'",
404 id,
405 )? != *expected
406 {
407 return Err(Error::new(401, "Your password changed. Sign in again."));
408 }
409 }
410 db.execute("DELETE FROM sessions WHERE expires<=?", [now() as i64])?;
411 let ip = headers
412 .get("X-Studio-Client-IP")
413 .and_then(|v| v.to_str().ok())
414 .unwrap_or("unknown");
415 db.execute(
416 "INSERT INTO sessions VALUES (?,?,?,?,?,?,?,?)",
417 sql![
418 mcp::hash(&token),
419 id,
420 client,
421 now() as i64 + SESSION_TTL,
422 ip,
423 (now() * 1000.0) as i64,
424 (now() * 1000.0) as i64,
425 now() as i64
426 ],
427 )?;
428 Ok(set_cookie(COOKIE, &token, SESSION_TTL))
429 }
430 pub(crate) fn limit(&self, headers: &HeaderMap, name: &str) -> Result<()> {
431 let ip = headers
432 .get("X-Studio-Client-IP")
433 .and_then(|v| v.to_str().ok())
434 .unwrap_or("unknown");
435 let db = self.db.lock().unwrap();
436 db.execute("DELETE FROM attempts WHERE expires<=?", [now() as i64])?;
437 let address = mcp::hash(ip);
438 db.execute(
439 "INSERT INTO attempts VALUES (?,1,?) ON CONFLICT(key) DO UPDATE SET count=count+1",
440 sql![address, now() as i64 + 300],
441 )?;
442 let total: i64 =
443 db.query_row("SELECT count FROM attempts WHERE key=?", [address], |r| {
444 r.get(0)
445 })?;
446 if total > 100 {
447 return Err(Error::new(
448 429,
449 "Too many attempts. Try again in five minutes.",
450 ));
451 }
452 let key = mcp::hash(&format!("{ip}:{name}"));
453 db.execute(
454 "INSERT INTO attempts VALUES (?,1,?) ON CONFLICT(key) DO UPDATE SET count=count+1",
455 sql![key, now() as i64 + 300],
456 )?;
457 let count: i64 = db.query_row("SELECT count FROM attempts WHERE key=?", [key], |r| {
458 r.get(0)
459 })?;
460 if count > 20 {
461 return Err(Error::new(
462 429,
463 "Too many attempts. Try again in five minutes.",
464 ));
465 }
466 Ok(())
467 }
468 fn csrf(&self, headers: &HeaderMap, body: &Value) -> Result<()> {
469 if headers.get("origin").and_then(|v| v.to_str().ok())
470 != Some(self.origin.origin().ascii_serialization().as_str())
471 {
472 return Err(Error::new(403, "Open sign-in on Snowglobe and try again."));
473 }
474 let cookie = cookie(headers, FLOW_COOKIE).unwrap_or_default();
475 if cookie.is_empty()
476 || !bool::from(cookie.as_bytes().ct_eq(string(&body["csrf"]).as_bytes()))
477 || pending(&self.db.lock().unwrap(), &cookie, "csrf", false)?.is_null()
478 {
479 return Err(Error::new(
480 403,
481 "Sign-in expired. Reload the page and try again.",
482 ));
483 }
484 Ok(())
485 }
486 fn next(&self, id: &str, flow: &str, path: &str) -> Result<String> {
487 if flow.is_empty() {
488 if !path.starts_with('/')
489 || path.starts_with("//")
490 || path.contains('\\')
491 || path.chars().any(char::is_control)
492 {
493 return Ok("/".into());
494 }
495 return Ok(path.to_owned());
496 }
497 let db = self.db.lock().unwrap();
498 if guest::is_guest(&user(&db, id)?) {
499 return Err(Error::new(
500 403,
501 "Guest accounts can use Shale. Open Shale to continue.",
502 ));
503 }
504 if !array(&user(&db, id)?["requiredActions"]).is_empty() {
505 return Ok("/account".into());
506 }
507 let value = pending(&db, flow, "file", false)?;
508 if value.is_null() {
509 return Err(Error::new(
510 400,
511 "File sign-in expired. Open Files and try again.",
512 ));
513 }
514 let code = issue(&db, "handoff", json!({"user":id,"flow":flow}), 60)?;
515 Ok(format!(
516 "{}auth/file/callback?code={}",
517 self.file,
518 encoded(&code)
519 ))
520 }
521 pub fn sessions(db: &Connection, id: &str) -> Result<Value> {
522 let mut statement = db.prepare("SELECT hash,ip,created,last_used,client FROM sessions WHERE user_id=? AND expires>? ORDER BY last_used DESC")?;
523 Ok(json!(statement.query_map(sql![id,now() as i64],|r|Ok(json!({"id":r.get::<_,String>(0)?,"ipAddress":r.get::<_,String>(1)?,"start":r.get::<_,i64>(2)?,"lastAccess":r.get::<_,i64>(3)?,"clients":{"snow":r.get::<_,String>(4)?}})))?.collect::<std::result::Result<Vec<_>,_>>()?))
524 }
525 pub async fn hash_password(&self, password: &str) -> Result<String> {
526 let _slot = self
527 .passwords
528 .try_acquire()
529 .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?;
530 let password = password.to_owned();
531 tokio::task::spawn_blocking(move || password_hash(&password)).await?
532 }
533 pub fn recent(&self, headers: &HeaderMap) -> Result<()> {
534 let token = cookie(headers, COOKIE).unwrap_or_default();
535 let valid: bool = self.db.lock().unwrap().query_row("SELECT EXISTS(SELECT 1 FROM sessions WHERE hash=? AND client='dashboard' AND expires>? AND auth_time>?)",sql![mcp::hash(&token),now() as i64,now() as i64-900],|r|r.get(0))?;
536 if !valid {
537 return Err(Error::new(
538 403,
539 "Sign out and sign in again before changing sign-in methods.",
540 ));
541 }
542 Ok(())
543 }
544 pub fn setup_link(&self, id: &str) -> Result<String> {
545 let db = self.db.lock().unwrap();
546 let profile = user(&db, id)?;
547 if guest::is_guest(&profile) {
548 return Err(Error::new(
549 400,
550 "Guests sign in with their provider. Use the Shale sign-in page.",
551 ));
552 }
553 if profile["enabled"] != true {
554 return Err(Error::new(
555 400,
556 "Enable this account before creating a setup link.",
557 ));
558 }
559 db.execute(
560 "DELETE FROM pending WHERE kind='setup' AND json_extract(data,'$.user')=?",
561 [id],
562 )?;
563 let token = issue(&db, "setup", json!({"user":id}), 86400)?;
564 Ok(format!("{}sign-in?setup={}", self.origin, token))
565 }
566}
567
568pub async fn route(State(app): State<Arc<App>>, request: Request) -> Result<Response> {
569 if request.uri().path() == "/auth/shale/page" {
570 return shale_page::proxy(app, request).await;
571 }
572 if request.uri().path().starts_with("/auth/guest/") {
573 return Ok(guest::route(State(app), request).await);
574 }
575 if request.uri().path().starts_with("/auth/oidc/") {
576 return Ok(oidc::route(State(app), request).await);
577 }
578 let auth = &app.auth;
579 let path = request.uri().path().to_owned();
580 let method = request.method().clone();
581 let query: HashMap<String, String> =
582 url::form_urlencoded::parse(request.uri().query().unwrap_or_default().as_bytes())
583 .into_owned()
584 .collect();
585 let headers = request.headers().clone();
586 if path == "/auth/file/check" && method == Method::GET {
587 let user = auth.session(&headers, "file")?;
588 if user.is_null() || !array(&user["requiredActions"]).is_empty() {
589 return Ok(StatusCode::UNAUTHORIZED.into_response());
590 }
591 let groups = array(&user["groups"])
592 .iter()
593 .map(|g| string(&g["name"]))
594 .collect::<Vec<_>>()
595 .join(",");
596 return Ok((
597 StatusCode::NO_CONTENT,
598 [
599 (
600 "X-Auth-Request-Preferred-Username",
601 string(&user["username"]).to_owned(),
602 ),
603 ("X-Auth-Request-Groups", groups),
604 ],
605 )
606 .into_response());
607 }
608 if path == "/auth/file/sign-in" && method == Method::GET {
609 let target = query
610 .get("rd")
611 .map(String::as_str)
612 .unwrap_or(auth.file.as_str());
613 let destination = auth.file.join(target)?;
614 if destination.origin() != auth.file.origin()
615 || !destination.username().is_empty()
616 || destination.password().is_some()
617 {
618 return Err(Error::new(400, "Open Files to sign in."));
619 }
620 let flow = issue(
621 &auth.db.lock().unwrap(),
622 "file",
623 json!({"next":destination}),
624 300,
625 )?;
626 return Ok((
627 StatusCode::FOUND,
628 [
629 (
630 "location",
631 format!("{}auth/continue?flow={flow}", auth.origin),
632 ),
633 ("set-cookie", set_cookie(FLOW_COOKIE, &flow, 300)),
634 ],
635 )
636 .into_response());
637 }
638 if path == "/auth/continue" && method == Method::GET {
639 let flow = query.get("flow").cloned().unwrap_or_default();
640 let user = auth.session(&headers, "dashboard")?;
641 let next = if user.is_null() {
642 format!("/sign-in?flow={}", encoded(&flow))
643 } else {
644 auth.next(string(&user["id"]), &flow, "/")?
645 };
646 return Ok((StatusCode::FOUND, [("location", next)]).into_response());
647 }
648 if path == "/auth/file/callback" && method == Method::GET {
649 let token = query.get("code").cloned().unwrap_or_default();
650 let (id, next) = {
651 let mut db = auth.db.lock().unwrap();
652 let transaction = db.transaction()?;
653 let code = pending(&transaction, &token, "handoff", false)?;
654 let flow = cookie(&headers, FLOW_COOKIE).unwrap_or_default();
655 if code.is_null()
656 || flow.is_empty()
657 || !bool::from(flow.as_bytes().ct_eq(string(&code["flow"]).as_bytes()))
658 {
659 return Err(Error::new(
660 403,
661 "File sign-in expired. Open Files and try again.",
662 ));
663 }
664 let target = pending(&transaction, &flow, "file", true)?;
665 if target.is_null() {
666 return Err(Error::new(
667 403,
668 "File sign-in expired. Open Files and try again.",
669 ));
670 }
671 pending(&transaction, &token, "handoff", true)?;
672 let user = user(&transaction, string(&code["user"]))?;
673 if user["enabled"] != true {
674 return Err(Error::new(403, "This account is disabled. Contact Clover."));
675 }
676 let result = (
677 string(&code["user"]).to_owned(),
678 string(&target["next"]).to_owned(),
679 );
680 transaction.commit()?;
681 result
682 };
683 let session = auth.create_session(&id, "file", &headers, None)?;
684 return Ok((
685 StatusCode::FOUND,
686 [("location", next), ("set-cookie", session)],
687 )
688 .into_response());
689 }
690 if path == "/auth/status" && method == Method::GET {
691 let csrf = issue(&auth.db.lock().unwrap(), "csrf", json!({}), 900)?;
692 let mut value = json!({"csrf":csrf,"account":auth.session(&headers,"dashboard")?});
693 value["service"] = json!("snow globe");
694 if let Some(flow) = query.get("flow") {
695 if !pending(&auth.db.lock().unwrap(), flow, "file", false)?.is_null() {
696 value["service"] = json!("copyparty");
697 }
698 } else if let Ok((target, config)) = oidc::sign_in_target(
699 auth,
700 query.get("next").map(String::as_str).unwrap_or_default(),
701 ) {
702 value["service"] = config["name"].clone();
703 if target
704 .query_pairs()
705 .any(|(key, value)| key == "client_id" && value == "forward-auth")
706 {
707 if let Some(redirect) = target
708 .query_pairs()
709 .find(|(key, _)| key == "redirect_uri")
710 .and_then(|(_, value)| url::Url::parse(&value).ok())
711 {
712 value["service"] = json!(
713 redirect
714 .host_str()
715 .unwrap_or("snow globe")
716 .split('.')
717 .next()
718 .unwrap_or("snow globe")
719 );
720 if let Ok(catalog) = core::launcher(app.clone()).await {
721 if let Some(service) = array(&catalog.value).iter().find(|service| {
722 service["urls"].as_object().is_some_and(|urls| {
723 urls.values().any(|url| {
724 url.as_str()
725 == Some(redirect.origin().ascii_serialization().as_str())
726 })
727 })
728 }) {
729 value["service"] = service["name"].clone();
730 }
731 }
732 }
733 }
734 }
735 value["providers"] = guest::providers(
736 auth,
737 query.get("next").map(String::as_str).unwrap_or_default(),
738 )?;
739 if let Some(setup) = query.get("setup") {
740 let entry = pending(&auth.db.lock().unwrap(), setup, "setup", false)?;
741 if entry.is_null() {
742 return Err(Error::new(
743 410,
744 "This link expired. Ask Clover for a new one.",
745 ));
746 }
747 value["setup"] =
748 user(&auth.db.lock().unwrap(), string(&entry["user"]))?["username"].clone();
749 }
750 return Ok((
751 [
752 ("set-cookie", set_cookie(FLOW_COOKIE, &csrf, 900)),
753 ("cache-control", "no-store".into()),
754 ],
755 axum::Json(value),
756 )
757 .into_response());
758 }
759 let shale_logout = path == "/auth/shale/sign-out";
760 if path == "/auth/sign-out" || path == "/auth/file/sign-out" || shale_logout {
761 if method == Method::GET && path == "/auth/file/sign-out" {
762 return Ok(axum::response::Html("<!doctype html><html><meta name=viewport content='width=device-width'><title>Sign out of Files</title><body><form method=post action='/auth/file/sign-out'><button>sign out of Files</button></form></body></html>").into_response());
763 }
764 if method
765 != (if shale_logout {
766 Method::GET
767 } else {
768 Method::POST
769 })
770 {
771 return Err(Error::new(405, "Use the sign-out button."));
772 }
773 let expected = if shale_logout {
774 &app.shale.origin
775 } else if path.contains("/file/") {
776 &auth.file
777 } else {
778 &auth.origin
779 };
780 let source = if shale_logout {
781 headers
782 .get("referer")
783 .and_then(|v| v.to_str().ok())
784 .and_then(|v| url::Url::parse(v).ok())
785 .map(|v| v.origin().ascii_serialization())
786 } else {
787 headers
788 .get("origin")
789 .and_then(|v| v.to_str().ok())
790 .map(str::to_owned)
791 };
792 if source.as_deref() != Some(expected.origin().ascii_serialization().as_str()) {
793 return Err(Error::new(403, "Open your account to sign out."));
794 }
795 if let Some(token) = cookie(&headers, COOKIE) {
796 auth.db
797 .lock()
798 .unwrap()
799 .execute("DELETE FROM sessions WHERE hash=?", [mcp::hash(&token)])?;
800 }
801 if path.contains("/file/") || shale_logout {
802 return Ok((
803 StatusCode::SEE_OTHER,
804 [
805 ("set-cookie", set_cookie(COOKIE, "", 0)),
806 (
807 "location",
808 if shale_logout {
809 app.shale.origin.to_string()
810 } else {
811 "/".into()
812 },
813 ),
814 ],
815 )
816 .into_response());
817 }
818 return Ok((
819 [("set-cookie", set_cookie(COOKIE, "", 0))],
820 axum::Json(json!({"next":"/sign-in"})),
821 )
822 .into_response());
823 }
824 if method != Method::POST {
825 return Err(Error::new(404, "No sign-in action here."));
826 }
827 let body: Value =
828 serde_json::from_slice(&axum::body::to_bytes(request.into_body(), 128 * 1024).await?)
829 .map_err(|_| Error::new(400, "Reload the form and try again."))?;
830 auth.csrf(&headers, &body)?;
831 if path == "/auth/password" || path == "/auth/passkey/start" {
832 let name = string(&body["username"]).trim().to_lowercase();
833 if name.len() > 254 || (name.is_empty() && path == "/auth/password") {
834 return Err(Error::new(400, "Enter your username."));
835 }
836 auth.limit(&headers, &name)?;
837 let id: Option<String> = auth.db.lock().unwrap().query_row("SELECT id FROM users WHERE username=? OR (lower(json_extract(profile,'$.email'))=? AND json_extract(profile,'$.emailVerified')=1) ORDER BY username=? DESC LIMIT 1",sql![name,name,name],|r|r.get(0)).optional()?;
838 let user = id
839 .as_ref()
840 .map(|id| user(&auth.db.lock().unwrap(), id))
841 .transpose()?
842 .unwrap_or(Value::Null);
843 if path == "/auth/password" {
844 let password = string(&body["password"]).to_owned();
845 if password.len() > 1024 {
846 return Err(Error::new(400, "That password is too long."));
847 }
848 let data = row(
849 &auth.db.lock().unwrap(),
850 "SELECT data FROM credentials WHERE user_id=? AND kind='password'",
851 id.as_deref().unwrap_or(""),
852 )?;
853 let phc = string(&data["phc"]).to_owned();
854 let _slot = auth
855 .passwords
856 .try_acquire()
857 .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?;
858 let verified = tokio::task::spawn_blocking(move || {
859 if phc.is_empty() {
860 let _ = password_hash(&password);
861 return false;
862 }
863 PasswordHash::new(&phc).is_ok_and(|hash| {
864 Argon2::default()
865 .verify_password(password.as_bytes(), &hash)
866 .is_ok()
867 })
868 })
869 .await?;
870 if !verified || user["enabled"] != true {
871 return Err(Error::new(
872 401,
873 "That username or password doesn't match. Try again.",
874 ));
875 }
876 let id = id.unwrap();
877 let mut session = auth.create_session(&id, "dashboard", &headers, Some(&data))?;
878 if body["remember"] == false {
879 session = session.replace(&format!("; Max-Age={SESSION_TTL}"), "");
880 }
881 let next = if !array(&user["requiredActions"]).is_empty() {
882 "/account".into()
883 } else {
884 auth.next(&id, string(&body["flow"]), string(&body["next"]))?
885 };
886 return Ok(
887 ([("set-cookie", session)], axum::Json(json!({"next":next}))).into_response(),
888 );
889 }
890 if !name.is_empty() && user["enabled"] != true {
891 return Err(Error::new(
892 401,
893 "No passkey is available for that username. Try your password.",
894 ));
895 }
896 let keys = if let Some(id) = &id {
897 passkeys(&auth.db.lock().unwrap(), id)?
898 } else if name.is_empty() {
899 let db = auth.db.lock().unwrap();
900 let mut statement =
901 db.prepare("SELECT id FROM users WHERE json_extract(profile,'$.enabled')=1")?;
902 let ids = statement
903 .query_map([], |r| r.get::<_, String>(0))?
904 .collect::<std::result::Result<Vec<_>, _>>()?;
905 ids.iter()
906 .map(|id| passkeys(&db, id))
907 .collect::<Result<Vec<_>>>()?
908 .into_iter()
909 .flatten()
910 .collect()
911 } else {
912 Vec::new()
913 };
914 if keys.is_empty() {
915 return Err(Error::new(
916 401,
917 "No passkey is available for that username. Try your password.",
918 ));
919 }
920 let (mut options, state) = auth.webauthn.start_passkey_authentication(&keys)?;
921 if name.is_empty() {
922 options.public_key.allow_credentials.clear();
923 }
924 let token = issue(
925 &auth.db.lock().unwrap(),
926 "authentication",
927 json!({"user":id,"csrf":body["csrf"],"state":state,"flow":body["flow"],"next":body["next"],"remember":body["remember"]}),
928 300,
929 )?;
930 return Ok(axum::Json(json!({"options":options,"token":token})).into_response());
931 }
932 if path == "/auth/passkey/finish" {
933 let value = pending(
934 &auth.db.lock().unwrap(),
935 string(&body["token"]),
936 "authentication",
937 true,
938 )?;
939 if value.is_null() || value["csrf"] != body["csrf"] {
940 return Err(Error::new(403, "Passkey sign-in expired. Try again."));
941 }
942 let credential: PublicKeyCredential = serde_json::from_value(body["credential"].clone())
943 .map_err(|_| Error::new(400, "The browser couldn't return your passkey. Try again."))?;
944 let id = if let Some(id) = value["user"].as_str() {
945 id.to_owned()
946 } else {
947 let db = auth.db.lock().unwrap();
948 let mut statement = db.prepare(
949 "SELECT user_id,data FROM credentials WHERE kind='webauthn-passwordless'",
950 )?;
951 let rows = statement
952 .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
953 .collect::<std::result::Result<Vec<_>, _>>()?;
954 let mut found = None;
955 for (id, data) in rows {
956 let data: Value = serde_json::from_str(&data)?;
957 let passkey: Passkey = serde_json::from_value(data["passkey"].clone())?;
958 if passkey.cred_id().as_slice() == credential.get_credential_id()
959 && body["credential"]["response"]["userHandle"] == data["handle"]
960 {
961 if found.is_some() {
962 return Err(Error::new(
963 401,
964 "That passkey couldn't identify your account.",
965 ));
966 }
967 found = Some(id);
968 }
969 }
970 found.ok_or_else(|| {
971 Error::new(
972 401,
973 "That passkey couldn't identify your account. Try your password.",
974 )
975 })?
976 };
977 let mut state = value["state"].clone();
978 let mut allowed: Vec<Credential> =
979 serde_json::from_value(state["ast"]["credentials"].clone())?;
980 {
981 let db = auth.db.lock().unwrap();
982 let mut statement = db.prepare(
983 "SELECT data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'",
984 )?;
985 for stored in statement.query_map([&id], |r| r.get::<_, String>(0))? {
986 let stored: Value = serde_json::from_str(&stored?)?;
987 let passkey: Passkey = serde_json::from_value(stored["passkey"].clone())?;
988 if stored["backupUnknown"] == true
989 && passkey.cred_id().as_slice() == credential.get_credential_id()
990 {
991 let flags = credential
992 .response
993 .authenticator_data
994 .as_slice()
995 .get(32)
996 .copied()
997 .ok_or_else(|| Error::new(400, "The passkey response was incomplete."))?;
998 for key in &mut allowed {
999 if key.cred_id == *passkey.cred_id() {
1000 key.backup_eligible = flags & 8 != 0;
1001 key.backup_state = flags & 16 != 0;
1002 }
1003 }
1004 }
1005 }
1006 }
1007 state["ast"]["credentials"] = json!(allowed);
1008 let state: PasskeyAuthentication = serde_json::from_value(state)?;
1009 let result = auth
1010 .webauthn
1011 .finish_passkey_authentication(&credential, &state)
1012 .map_err(|error| {
1013 eprintln!("passkey authentication: {error:?}");
1014 Error::new(
1015 401,
1016 "That passkey couldn't sign in. Try again or use your password.",
1017 )
1018 })?;
1019 let id = id.as_str();
1020 {
1021 let db = auth.db.lock().unwrap();
1022 let user = user(&db, id)?;
1023 if user["enabled"] != true {
1024 return Err(Error::new(403, "This account is disabled. Contact Clover."));
1025 }
1026 let mut statement = db.prepare(
1027 "SELECT id,data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'",
1028 )?;
1029 let rows = statement
1030 .query_map([id], |r| {
1031 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
1032 })?
1033 .collect::<std::result::Result<Vec<_>, _>>()?;
1034 let mut matched = false;
1035 for (key, data) in rows {
1036 let mut data: Value = serde_json::from_str(&data)?;
1037 let mut passkey: Passkey = serde_json::from_value(data["passkey"].clone())?;
1038 if passkey.cred_id() == result.cred_id() {
1039 if let Some(handle) = body["credential"]["response"]["userHandle"].as_str() {
1040 if !handle.is_empty() && handle != string(&data["handle"]) {
1041 return Err(Error::new(
1042 401,
1043 "That passkey belongs to a different account.",
1044 ));
1045 }
1046 }
1047 matched = true;
1048 let current: Credential = passkey.clone().into();
1049 if (current.counter != 0 || result.counter() != 0)
1050 && result.counter() <= current.counter
1051 {
1052 return Err(Error::new(
1053 401,
1054 "This passkey returned an old counter. Try another sign-in method.",
1055 ));
1056 }
1057 if data["backupUnknown"] == true {
1058 let mut key: Credential = passkey.into();
1059 key.backup_eligible = result.backup_eligible();
1060 key.backup_state = result.backup_state();
1061 passkey = key.into();
1062 data.as_object_mut().unwrap().remove("backupUnknown");
1063 }
1064 passkey.update_credential(&result);
1065 data["passkey"] = json!(passkey);
1066 db.execute(
1067 "UPDATE credentials SET data=? WHERE id=?",
1068 sql![data.to_string(), key],
1069 )?;
1070 break;
1071 }
1072 }
1073 if !matched {
1074 return Err(Error::new(
1075 401,
1076 "This passkey was removed. Try another sign-in method.",
1077 ));
1078 }
1079 }
1080 let mut session = auth.create_session(id, "dashboard", &headers, None)?;
1081 if body.get("remember").unwrap_or(&value["remember"]) == false {
1082 session = session.replace(&format!("; Max-Age={SESSION_TTL}"), "");
1083 }
1084 let next =
1085 if !array(&self::user(&auth.db.lock().unwrap(), id)?["requiredActions"]).is_empty() {
1086 "/account".into()
1087 } else {
1088 auth.next(id, string(&value["flow"]), string(&value["next"]))?
1089 };
1090 return Ok(([("set-cookie", session)], axum::Json(json!({"next":next}))).into_response());
1091 }
1092 if path == "/auth/setup" {
1093 let email = string(&body["email"]).trim();
1094 if !email.contains('@') || email.len() > 254 {
1095 return Err(Error::new(400, "Enter your email address."));
1096 }
1097 let password = string(&body["password"]).to_owned();
1098 if password.chars().count() < 8 || password.len() > 1024 {
1099 return Err(Error::new(
1100 400,
1101 "Use a password with at least 8 characters.",
1102 ));
1103 }
1104 let _slot = auth
1105 .passwords
1106 .try_acquire()
1107 .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?;
1108 let hash = tokio::task::spawn_blocking(move || password_hash(&password)).await??;
1109 let id = {
1110 let mut db = auth.db.lock().unwrap();
1111 let transaction = db.transaction()?;
1112 let entry = pending(&transaction, string(&body["setup"]), "setup", true)?;
1113 if entry.is_null() {
1114 return Err(Error::new(
1115 410,
1116 "This link expired. Ask Clover for a new one.",
1117 ));
1118 }
1119 let id = string(&entry["user"]).to_owned();
1120 let mut profile = user(&transaction, &id)?;
1121 profile["email"] = json!(email);
1122 profile["emailVerified"] = json!(false);
1123 if profile["enabled"] != true {
1124 return Err(Error::new(
1125 403,
1126 "This account is disabled. Ask Clover for a new link.",
1127 ));
1128 }
1129 profile["requiredActions"] = json!([]);
1130 set_password(&transaction, &id, &hash)?;
1131 save_user(&transaction, &id, profile)?;
1132 transaction.execute("DELETE FROM sessions WHERE user_id=?", [&id])?;
1133 transaction.commit()?;
1134 id
1135 };
1136 users::revoke_connections(&app, &id)?;
1137 return Ok((
1138 [(
1139 "set-cookie",
1140 auth.create_session(&id, "dashboard", &headers, None)?,
1141 )],
1142 axum::Json(json!({"next":"/account?welcome=1"})),
1143 )
1144 .into_response());
1145 }
1146 let user = auth.session(&headers, "dashboard")?;
1147 if user.is_null() {
1148 return Err(Error::new(401, "Sign in to manage your account."));
1149 }
1150 if guest::is_guest(&user) {
1151 return Err(Error::new(
1152 403,
1153 "Guests sign in with their provider. Use the Shale sign-in page.",
1154 ));
1155 }
1156 let id = string(&user["id"]);
1157 if path == "/auth/passkey/register" {
1158 auth.recent(&headers)?;
1159 let db = auth.db.lock().unwrap();
1160 let keys = passkeys(&db, id)?;
1161 let ids = keys.iter().map(|key| key.cred_id().clone()).collect();
1162 let uuid = uuid::Uuid::parse_str(id)?;
1163 let (options, state) = auth.webauthn.start_passkey_registration(
1164 uuid,
1165 string(&user["username"]),
1166 string(&user["username"]),
1167 Some(ids),
1168 )?;
1169 let token = issue(
1170 &db,
1171 "registration",
1172 json!({"user":id,"csrf":body["csrf"],"state":state}),
1173 300,
1174 )?;
1175 return Ok(axum::Json(json!({"options":options,"token":token})).into_response());
1176 }
1177 if path == "/auth/passkey/save" {
1178 auth.recent(&headers)?;
1179 let db = auth.db.lock().unwrap();
1180 let value = pending(&db, string(&body["token"]), "registration", true)?;
1181 if value.is_null() || value["user"] != user["id"] || value["csrf"] != body["csrf"] {
1182 return Err(Error::new(403, "Passkey setup expired. Try again."));
1183 }
1184 let credential: RegisterPublicKeyCredential =
1185 serde_json::from_value(body["credential"].clone()).map_err(|_| {
1186 Error::new(400, "The browser couldn't create your passkey. Try again.")
1187 })?;
1188 let state: PasskeyRegistration = serde_json::from_value(value["state"].clone())?;
1189 let passkey = auth
1190 .webauthn
1191 .finish_passkey_registration(&credential, &state)
1192 .map_err(|_| Error::new(400, "That passkey couldn't be added. Try again."))?;
1193 let label = string(&body["label"]).trim();
1194 if label.len() > 100 {
1195 return Err(Error::new(400, "Use a shorter passkey name."));
1196 }
1197 db.execute("INSERT INTO credentials VALUES (?,?,?,?,?,?)",sql![uuid::Uuid::new_v4().to_string(),id,"webauthn-passwordless",if label.is_empty(){"passkey"}else{label},(now()*1000.0) as i64,json!({"passkey":passkey,"handle":URL_SAFE_NO_PAD.encode(uuid::Uuid::parse_str(id)?.as_bytes())}).to_string()])?;
1198 return Ok(StatusCode::NO_CONTENT.into_response());
1199 }
1200 if path == "/auth/password/change" {
1201 auth.recent(&headers)?;
1202 let data = row(
1203 &auth.db.lock().unwrap(),
1204 "SELECT data FROM credentials WHERE user_id=? AND kind='password'",
1205 id,
1206 )?;
1207 let phc = string(&data["phc"]).to_owned();
1208 let current = string(&body["current"]).to_owned();
1209 let password = string(&body["password"]).to_owned();
1210 if password.chars().count() < 8 || password.len() > 1024 || current.len() > 1024 {
1211 return Err(Error::new(
1212 400,
1213 "Use a password with at least 8 characters.",
1214 ));
1215 }
1216 auth.limit(&headers, id)?;
1217 let _slot = auth
1218 .passwords
1219 .try_acquire()
1220 .map_err(|_| Error::new(429, "Sign-in is busy. Try again in a moment."))?;
1221 let hash = tokio::task::spawn_blocking(move || {
1222 if !phc.is_empty()
1223 && !PasswordHash::new(&phc).is_ok_and(|hash| {
1224 Argon2::default()
1225 .verify_password(current.as_bytes(), &hash)
1226 .is_ok()
1227 })
1228 {
1229 return Err(Error::new(
1230 401,
1231 "Your current password doesn't match. Try again.",
1232 ));
1233 }
1234 password_hash(&password)
1235 })
1236 .await??;
1237 {
1238 let mut db = auth.db.lock().unwrap();
1239 let transaction = db.transaction()?;
1240 let mut profile = self::user(&transaction, id)?;
1241 if profile["enabled"] != true {
1242 return Err(Error::new(403, "This account is disabled."));
1243 }
1244 set_password(&transaction, id, &hash)?;
1245 profile["requiredActions"] = json!(
1246 array(&user["requiredActions"])
1247 .iter()
1248 .filter(|v| **v != "UPDATE_PASSWORD")
1249 .collect::<Vec<_>>()
1250 );
1251 save_user(&transaction, id, profile)?;
1252 transaction.execute("DELETE FROM sessions WHERE user_id=?", [id])?;
1253 transaction.commit()?;
1254 }
1255 users::revoke_connections(&app, id)?;
1256 return Ok((
1257 [(
1258 "set-cookie",
1259 auth.create_session(id, "dashboard", &headers, None)?,
1260 )],
1261 StatusCode::NO_CONTENT,
1262 )
1263 .into_response());
1264 }
1265 Err(Error::new(404, "No account action here."))
1266}
1267
1268fn passkeys(db: &Connection, id: &str) -> Result<Vec<Passkey>> {
1269 let mut statement = db
1270 .prepare("SELECT data FROM credentials WHERE user_id=? AND kind='webauthn-passwordless'")?;
1271 statement
1272 .query_map([id], |r| r.get::<_, String>(0))?
1273 .map(|data| {
1274 let value: Value = serde_json::from_str(&data?)?;
1275 Ok(serde_json::from_value(value["passkey"].clone())?)
1276 })
1277 .collect()
1278}