1use rusqlite::{OptionalExtension, Row, TransactionBehavior, params};
19use serde::Serialize;
20
21use super::secret::{self, TokenKind};
22use super::{
23 AuthError, AuthResult, AuthStore, Role, USER_COLS, User, clean_name, ensure_org_tx,
24 normalize_email, org_col, role_col, user_row,
25};
26use crate::org::OrgId;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct ExternalIdentity {
31 pub provider: String,
33 pub subject: String,
34 pub email: Option<String>,
35 pub email_verified: bool,
36 pub name: Option<String>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
41pub struct Identity {
42 pub id: i64,
43 pub user_id: i64,
44 pub provider: String,
45 pub subject: String,
46 pub email: Option<String>,
47 pub email_verified: bool,
48 pub created_at: i64,
49 pub last_used: Option<i64>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
54#[serde(rename_all = "snake_case")]
55pub enum SignIn {
56 Existing,
58 Linked,
60 Created,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
66pub struct Passkey {
67 pub id: i64,
68 pub user_id: i64,
69 pub credential_id: String,
71 pub name: String,
72 pub alg: i64,
73 pub sign_count: u32,
74 pub transports: Vec<String>,
75 pub aaguid: String,
77 pub created_at: i64,
78 pub last_used: Option<i64>,
79}
80
81#[derive(Debug, Clone)]
83pub struct StoredPasskey {
84 pub passkey: Passkey,
85 pub user_handle: Vec<u8>,
86 pub public_key: Vec<u8>,
87}
88
89const IDENTITY_COLS: &str =
90 "id, user_id, provider, subject, email, email_verified, created_at, last_used";
91
92fn identity_row(r: &Row) -> rusqlite::Result<Identity> {
93 Ok(Identity {
94 id: r.get(0)?,
95 user_id: r.get(1)?,
96 provider: r.get(2)?,
97 subject: r.get(3)?,
98 email: r.get(4)?,
99 email_verified: r.get(5)?,
100 created_at: r.get(6)?,
101 last_used: r.get(7)?,
102 })
103}
104
105const PASSKEY_COLS: &str = "id, user_id, credential_id, name, alg, sign_count, transports, aaguid, created_at, last_used, user_handle, public_key";
106
107fn passkey_row(r: &Row) -> rusqlite::Result<StoredPasskey> {
108 let cred: Vec<u8> = r.get(2)?;
109 let transports: String = r.get(6)?;
110 let count: i64 = r.get(5)?;
111 Ok(StoredPasskey {
112 passkey: Passkey {
113 id: r.get(0)?,
114 user_id: r.get(1)?,
115 credential_id: super::webauthn::b64(&cred),
116 name: r.get(3)?,
117 alg: r.get(4)?,
118 sign_count: count.clamp(0, u32::MAX as i64) as u32,
119 transports: serde_json::from_str(&transports).unwrap_or_default(),
120 aaguid: r.get(7)?,
121 created_at: r.get(8)?,
122 last_used: r.get(9)?,
123 },
124 user_handle: r.get(10)?,
125 public_key: r.get(11)?,
126 })
127}
128
129fn refused(code: &'static str, message: impl Into<String>) -> AuthError {
130 AuthError::Refused {
131 code,
132 message: message.into(),
133 }
134}
135
136fn ways_in(conn: &rusqlite::Connection, user_id: i64) -> AuthResult<i64> {
138 Ok(conn.query_row(
139 "SELECT (SELECT COUNT(*) FROM users WHERE id = ?1 AND password_hash IS NOT NULL)
140 + (SELECT COUNT(*) FROM user_identities WHERE user_id = ?1)
141 + (SELECT COUNT(*) FROM passkeys WHERE user_id = ?1)",
142 [user_id],
143 |r| r.get(0),
144 )?)
145}
146
147const LAST_WAY_IN: &str =
148 "this is your last way to sign in; add a password, a passkey or another provider first";
149
150impl AuthStore {
151 #[expect(
156 clippy::too_many_lines,
157 reason = "predates the lint ratchet; split it when next changed"
158 )]
159 pub fn external_sign_in(
160 &self,
161 ext: &ExternalIdentity,
162 invite: Option<&str>,
163 open_signup: bool,
164 ) -> AuthResult<(User, SignIn)> {
165 if ext.provider.is_empty() || ext.subject.is_empty() {
166 return Err(AuthError::Internal(
167 "external identity without a subject".into(),
168 ));
169 }
170 let email = ext.email.as_deref().and_then(|e| normalize_email(e).ok());
171 let verified = ext.email_verified && email.is_some();
172 let now = self.now();
173 let mut db = self.db();
174 let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?;
175
176 let known: Option<(i64, i64)> = tx
178 .query_row(
179 "SELECT id, user_id FROM user_identities WHERE provider = ?1 AND subject = ?2",
180 params![ext.provider, ext.subject],
181 |r| Ok((r.get(0)?, r.get(1)?)),
182 )
183 .optional()?;
184 if let Some((iid, uid)) = known {
185 let user = user_in(&tx, uid)?;
186 if user.disabled {
187 return Err(refused("account_disabled", "this account is disabled"));
188 }
189 tx.execute(
190 "UPDATE user_identities SET email = ?2, email_verified = ?3, last_used = ?4 WHERE id = ?1",
191 params![iid, email, verified, now],
192 )?;
193 tx.commit()?;
194 return Ok((user, SignIn::Existing));
195 }
196
197 let Some(email) = email.filter(|_| verified) else {
199 return Err(refused(
200 "unverified_email",
201 "your account with this provider has no verified email address; verify one there (for GitHub, the primary address) and try again",
202 ));
203 };
204
205 let existing: Option<User> = tx
207 .query_row(
208 &format!("SELECT {USER_COLS} FROM users u WHERE u.email = ?1"),
209 [&email],
210 |r| user_row(r, 0),
211 )
212 .optional()?;
213 if let Some(user) = existing {
214 if user.disabled {
215 return Err(refused("account_disabled", "this account is disabled"));
216 }
217 insert_identity(&tx, user.id, ext, Some(&email), true, now)?;
218 tx.commit()?;
219 return Ok((user, SignIn::Linked));
220 }
221
222 let users: i64 = tx.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))?;
224 if users == 0 {
225 return Err(refused(
226 "setup_required",
227 "isb has no admin yet; finish first-run setup before signing in with a provider",
228 ));
229 }
230 if let Some(token) = invite {
231 let inv = invitation_by_token(&tx, token, now)?
232 .ok_or(AuthError::InvalidToken("invitation"))?;
233 if inv.1 != email {
234 return Err(refused(
235 "invitation_mismatch",
236 format!(
237 "this invitation is for {}, and your provider account's verified email is {email}",
238 inv.1
239 ),
240 ));
241 }
242 }
243 let pending = pending_invitations(&tx, &email, now)?;
244 if pending.is_empty() && !open_signup {
245 return Err(refused(
246 "signup_closed",
247 format!("{email} has no account here; ask an org admin for an invitation"),
248 ));
249 }
250 let name = ext
251 .name
252 .as_deref()
253 .map(|n| {
254 n.chars()
255 .filter(|c| !c.is_control())
256 .take(100)
257 .collect::<String>()
258 })
259 .and_then(|n| clean_name(&n).ok())
260 .unwrap_or_default();
261 tx.execute(
262 "INSERT INTO users (email, name, created_at) VALUES (?1, ?2, ?3)",
263 params![email, name, now],
264 )?;
265 let uid = tx.last_insert_rowid();
266 insert_identity(&tx, uid, ext, Some(&email), true, now)?;
267 for (id, org, role) in pending {
268 tx.execute(
269 "UPDATE invitations SET accepted_at = ?2, accepted_by = ?3 WHERE id = ?1",
270 params![id, now, uid],
271 )?;
272 ensure_org_tx(&tx, &org, now)?;
273 tx.execute(
274 "INSERT INTO memberships (user_id, org, role, created_at) VALUES (?1, ?2, ?3, ?4)
275 ON CONFLICT (user_id, org) DO NOTHING",
276 params![uid, org.as_str(), role.as_str(), now],
277 )?;
278 }
279 let user = user_in(&tx, uid)?;
280 tx.commit()?;
281 Ok((user, SignIn::Created))
282 }
283
284 pub fn link_identity(&self, user_id: i64, ext: &ExternalIdentity) -> AuthResult<Identity> {
287 let email = ext.email.as_deref().and_then(|e| normalize_email(e).ok());
288 let verified = ext.email_verified && email.is_some();
289 let now = self.now();
290 let mut db = self.db();
291 let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?;
292 let owner: Option<i64> = tx
293 .query_row(
294 "SELECT user_id FROM user_identities WHERE provider = ?1 AND subject = ?2",
295 params![ext.provider, ext.subject],
296 |r| r.get(0),
297 )
298 .optional()?;
299 match owner {
300 Some(u) if u != user_id => {
301 return Err(refused(
302 "identity_taken",
303 "that provider account is already linked to another isb user",
304 ));
305 }
306 Some(_) => {
307 tx.execute(
308 "UPDATE user_identities SET email = ?3, email_verified = ?4, last_used = ?5
309 WHERE provider = ?1 AND subject = ?2",
310 params![ext.provider, ext.subject, email, verified, now],
311 )?;
312 }
313 None => insert_identity(&tx, user_id, ext, email.as_deref(), verified, now)?,
314 }
315 let id = tx.query_row(
316 &format!(
317 "SELECT {IDENTITY_COLS} FROM user_identities WHERE provider = ?1 AND subject = ?2"
318 ),
319 params![ext.provider, ext.subject],
320 identity_row,
321 )?;
322 tx.commit()?;
323 Ok(id)
324 }
325
326 pub fn list_identities(&self, user_id: i64) -> AuthResult<Vec<Identity>> {
327 let db = self.db();
328 let mut st = db.prepare(&format!(
329 "SELECT {IDENTITY_COLS} FROM user_identities WHERE user_id = ?1 ORDER BY id"
330 ))?;
331 let rows = st.query_map([user_id], identity_row)?;
332 Ok(rows.collect::<rusqlite::Result<_>>()?)
333 }
334
335 pub fn unlink_identity(&self, user_id: i64, id: i64) -> AuthResult<bool> {
338 let mut db = self.db();
339 let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?;
340 let exists: bool = tx
341 .query_row(
342 "SELECT 1 FROM user_identities WHERE id = ?1 AND user_id = ?2",
343 params![id, user_id],
344 |_| Ok(true),
345 )
346 .optional()?
347 .unwrap_or(false);
348 if !exists {
349 return Ok(false);
350 }
351 if ways_in(&tx, user_id)? <= 1 {
352 return Err(AuthError::Conflict(LAST_WAY_IN.into()));
353 }
354 tx.execute("DELETE FROM user_identities WHERE id = ?1", [id])?;
355 tx.commit()?;
356 Ok(true)
357 }
358
359 pub fn passkey_user_handle(&self, user_id: i64) -> AuthResult<Option<Vec<u8>>> {
363 Ok(self
364 .db()
365 .query_row(
366 "SELECT user_handle FROM passkeys WHERE user_id = ?1 ORDER BY id LIMIT 1",
367 [user_id],
368 |r| r.get(0),
369 )
370 .optional()?)
371 }
372
373 pub fn add_passkey(
375 &self,
376 user_id: i64,
377 user_handle: &[u8],
378 reg: &super::webauthn::Registration,
379 name: &str,
380 transports: &[String],
381 ) -> AuthResult<Passkey> {
382 let name: String = name
383 .trim()
384 .chars()
385 .filter(|c| !c.is_control())
386 .take(100)
387 .collect();
388 let transports: Vec<&String> = transports
389 .iter()
390 .filter(|t| t.len() <= 32 && t.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-'))
391 .take(8)
392 .collect();
393 let aaguid: String = reg.aaguid.iter().map(|b| format!("{b:02x}")).collect();
394 let now = self.now();
395 let db = self.db();
396 let r = db.execute(
397 "INSERT INTO passkeys (credential_id, user_id, user_handle, public_key, alg, sign_count,
398 transports, aaguid, name, created_at)
399 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
400 params![
401 reg.credential_id,
402 user_id,
403 user_handle,
404 reg.public_key_cose,
405 reg.alg,
406 i64::from(reg.sign_count),
407 serde_json::to_string(&transports).unwrap_or_else(|_| "[]".into()),
408 aaguid,
409 name,
410 now
411 ],
412 );
413 match r {
414 Ok(_) => {}
415 Err(rusqlite::Error::SqliteFailure(e, _))
416 if e.code == rusqlite::ErrorCode::ConstraintViolation =>
417 {
418 return Err(AuthError::Conflict(
419 "that passkey is already registered".into(),
420 ));
421 }
422 Err(e) => return Err(e.into()),
423 }
424 let id = db.last_insert_rowid();
425 Ok(db
426 .query_row(
427 &format!("SELECT {PASSKEY_COLS} FROM passkeys WHERE id = ?1"),
428 [id],
429 passkey_row,
430 )?
431 .passkey)
432 }
433
434 pub fn passkey_by_credential(&self, credential_id: &[u8]) -> AuthResult<Option<StoredPasskey>> {
435 Ok(self
436 .db()
437 .query_row(
438 &format!("SELECT {PASSKEY_COLS} FROM passkeys WHERE credential_id = ?1"),
439 [credential_id],
440 passkey_row,
441 )
442 .optional()?)
443 }
444
445 pub fn list_passkeys(&self, user_id: i64) -> AuthResult<Vec<Passkey>> {
446 let db = self.db();
447 let mut st = db.prepare(&format!(
448 "SELECT {PASSKEY_COLS} FROM passkeys WHERE user_id = ?1 ORDER BY id"
449 ))?;
450 let rows = st.query_map([user_id], passkey_row)?;
451 Ok(rows
452 .map(|r| r.map(|p| p.passkey))
453 .collect::<rusqlite::Result<_>>()?)
454 }
455
456 pub fn use_passkey(&self, id: i64, old_count: u32, new_count: u32) -> AuthResult<()> {
459 let n = self.db().execute(
460 "UPDATE passkeys SET sign_count = ?3, last_used = ?4 WHERE id = ?1 AND sign_count = ?2",
461 params![id, i64::from(old_count), i64::from(new_count), self.now()],
462 )?;
463 if n == 0 {
464 return Err(AuthError::PasskeyRejected(
465 "the signature counter moved during sign-in".into(),
466 ));
467 }
468 Ok(())
469 }
470
471 pub fn delete_passkey(&self, user_id: i64, id: i64) -> AuthResult<bool> {
473 let mut db = self.db();
474 let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?;
475 let exists: bool = tx
476 .query_row(
477 "SELECT 1 FROM passkeys WHERE id = ?1 AND user_id = ?2",
478 params![id, user_id],
479 |_| Ok(true),
480 )
481 .optional()?
482 .unwrap_or(false);
483 if !exists {
484 return Ok(false);
485 }
486 if ways_in(&tx, user_id)? <= 1 {
487 return Err(AuthError::Conflict(LAST_WAY_IN.into()));
488 }
489 tx.execute("DELETE FROM passkeys WHERE id = ?1", [id])?;
490 tx.commit()?;
491 Ok(true)
492 }
493}
494
495fn user_in(conn: &rusqlite::Connection, id: i64) -> AuthResult<User> {
496 conn.query_row(
497 &format!("SELECT {USER_COLS} FROM users u WHERE u.id = ?1"),
498 [id],
499 |r| user_row(r, 0),
500 )
501 .optional()?
502 .ok_or_else(|| AuthError::NotFound(format!("user {id}")))
503}
504
505fn insert_identity(
506 conn: &rusqlite::Connection,
507 user_id: i64,
508 ext: &ExternalIdentity,
509 email: Option<&str>,
510 verified: bool,
511 now: i64,
512) -> AuthResult<()> {
513 conn.execute(
514 "INSERT INTO user_identities (user_id, provider, subject, email, email_verified, created_at, last_used)
515 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)",
516 params![user_id, ext.provider, ext.subject, email, verified, now],
517 )?;
518 Ok(())
519}
520
521fn invitation_by_token(
523 conn: &rusqlite::Connection,
524 token: &str,
525 now: i64,
526) -> AuthResult<Option<(i64, String)>> {
527 if !secret::well_formed(token, TokenKind::Invitation) {
528 return Ok(None);
529 }
530 let hash = secret::hash_token(token);
531 type InvRow = (Vec<u8>, i64, String, i64, Option<i64>);
533 let found: Option<InvRow> = conn
534 .query_row(
535 "SELECT token_hash, id, email, expires_at, accepted_at FROM invitations WHERE token_hash = ?1",
536 [&hash],
537 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
538 )
539 .optional()?;
540 Ok(found.and_then(|(stored, id, email, expires, accepted)| {
541 (secret::ct_eq(&stored, &hash) && accepted.is_none() && now < expires)
542 .then_some((id, email))
543 }))
544}
545
546fn pending_invitations(
548 conn: &rusqlite::Connection,
549 email: &str,
550 now: i64,
551) -> AuthResult<Vec<(i64, OrgId, Role)>> {
552 let mut st = conn.prepare(
553 "SELECT id, org, role FROM invitations
554 WHERE email = ?1 AND accepted_at IS NULL AND expires_at > ?2 ORDER BY id",
555 )?;
556 let rows = st.query_map(params![email, now], |r| {
557 Ok((r.get(0)?, org_col(r, 1)?, role_col(r, 2)?))
558 })?;
559 Ok(rows.collect::<rusqlite::Result<_>>()?)
560}
561
562#[cfg(test)]
563mod tests;