Skip to main content

isb_server/auth/
external.rs

1//! Ways in besides a password: external identities (OAuth/OIDC) and
2//! passkeys, and the rules that tie them to users.
3//!
4//! Signing in with a provider ([`AuthStore::external_sign_in`]):
5//! 1. a known identity (provider, subject) signs its user in;
6//! 2. else a **verified** email that matches a user links the identity to
7//!    that user and signs them in;
8//! 3. else, with a verified email, an account is created only when the
9//!    email has a pending invitation (the invitation token may ride the
10//!    flow, and must then be for that email) or open sign-up is on. Every
11//!    pending invitation for the email is accepted. Never before first-run
12//!    setup.
13//!
14//! An unverified email never links and never signs up. A user always keeps
15//! one way in: the last of password, identities and passkeys cannot be
16//! removed.
17
18use 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/// What a provider says about the person signing in.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct ExternalIdentity {
31    /// `github`, `google`, `oidc:<issuer>`.
32    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/// A linked identity.
40#[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/// How an external sign-in found its user.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
54#[serde(rename_all = "snake_case")]
55pub enum SignIn {
56    /// The identity was already linked.
57    Existing,
58    /// Linked to the user with the same verified email.
59    Linked,
60    /// A new account (by invitation, or open sign-up).
61    Created,
62}
63
64/// A registered passkey (the public key stays in the store).
65#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
66pub struct Passkey {
67    pub id: i64,
68    pub user_id: i64,
69    /// base64url, as the browser knows it.
70    pub credential_id: String,
71    pub name: String,
72    pub alg: i64,
73    pub sign_count: u32,
74    pub transports: Vec<String>,
75    /// The authenticator model's AAGUID, hex (all zeros for many).
76    pub aaguid: String,
77    pub created_at: i64,
78    pub last_used: Option<i64>,
79}
80
81/// A passkey with what verifying an assertion needs.
82#[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
136/// How many ways in a user has: a password, identities, passkeys.
137fn 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    // ---- external identities ----
152
153    /// Sign in with a provider's identity, by the rules in the module docs.
154    /// `invite` is an invitation token carried through the flow.
155    #[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        // 1. A known identity.
177        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        // Nothing below happens on an unverified email.
198        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        // 2. A user with this verified email.
206        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        // 3. A new account, by invitation or open sign-up.
223        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    /// Link an identity to a signed-in user. Refused when it already
285    /// belongs to someone else.
286    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    /// Remove one of a user's identities, unless it is their last way in.
336    /// False when there was no such identity.
337    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    // ---- passkeys ----
360
361    /// The WebAuthn user handle of `user_id`'s passkeys, if they have any.
362    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    /// Store a verified registration.
374    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    /// Record a sign-in with a passkey: the new counter, if nobody else
457    /// moved it meanwhile (two racing assertions cannot both pass).
458    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    /// Remove one of a user's passkeys, unless it is their last way in.
472    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
521/// A pending invitation by token: `(id, email)`.
522fn 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    // (hash, id, email, expires, accepted)
532    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
546/// Every pending invitation for `email`: `(id, org, role)`.
547fn 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;