Skip to main content

isb_server/auth/
mod.rs

1//! Identity for `isb serve`: users, org memberships and roles, browser
2//! sessions, invitations, API tokens and password resets, in SQLite at
3//! `<state>/isb.db`.
4//!
5//! - Orgs are the trust boundary. A user is a member of an org with a
6//!   [`Role`]; what a role may do is the table in [`Role::permissions`], so
7//!   roles can be refined without touching the callers. A platform admin
8//!   spans orgs.
9//! - Every bearer secret (session, API token, invitation, reset) is 32 random
10//!   bytes shown once; only its SHA-256 is stored ([`secret`]).
11//! - Passwords are argon2id. Login failures never say which half was wrong,
12//!   cost the same either way, and are rate-limited per email and per IP.
13//! - The HTTP endpoints are in [`http`]; the CLI uses this API directly on the
14//!   same file (SQLite in WAL mode handles the daemon and the CLI at once).
15//!
16//! External sign-in ([`oauth`]) attaches rows to `user_identities`; passkeys
17//! ([`webauthn`]) live in `passkeys`; [`external`] manages both.
18
19mod actors;
20pub mod agent_identities;
21pub mod cbor;
22pub mod db;
23pub mod dev;
24pub mod edge;
25pub mod external;
26pub mod http;
27pub mod limit;
28pub mod oauth;
29pub mod oidc;
30pub mod ops;
31mod scope;
32pub mod secret;
33mod setup;
34pub mod ssh_keys;
35pub mod superadmin;
36pub mod webauthn;
37
38use std::path::{Path, PathBuf};
39use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
40use std::time::Duration;
41
42use rusqlite::{Connection, OptionalExtension, Row, TransactionBehavior, params};
43use serde::{Deserialize, Serialize};
44
45use crate::org::OrgId;
46pub use actors::WORKSPACE_ACTOR;
47use limit::{Rate, RateLimiter};
48pub use scope::Scope;
49use secret::{PasswordCost, TokenKind};
50pub use superadmin::{Superadmin, SuperadminSource, SuperadminToken};
51
52/// What the identity store can fail with. [`AuthError::InvalidCredentials`]
53/// is deliberately vague: it is the answer to every failed login.
54#[derive(Debug, thiserror::Error)]
55pub enum AuthError {
56    #[error("invalid email or password")]
57    InvalidCredentials,
58    /// A presented invitation or reset token that is unknown, used or expired.
59    #[error("{0} is invalid or has expired")]
60    InvalidToken(&'static str),
61    #[error("too many attempts; try again in {retry_after}s")]
62    RateLimited { retry_after: u64 },
63    #[error("{0}")]
64    Forbidden(String),
65    #[error("{0} not found")]
66    NotFound(String),
67    #[error("{0}")]
68    Conflict(String),
69    #[error("{0}")]
70    Invalid(String),
71    #[error("{0}")]
72    Internal(String),
73    /// An external sign-in that proved who the user is, but may not go on
74    /// (no verified email, no invitation, a disabled account). `code` is
75    /// stable, for the login page.
76    #[error("{message}")]
77    Refused { code: &'static str, message: String },
78    /// A passkey assertion or registration that did not verify.
79    #[error("passkey rejected: {0}")]
80    PasskeyRejected(String),
81    #[error("identity database: {0}")]
82    Db(#[from] rusqlite::Error),
83}
84
85impl AuthError {
86    pub(crate) fn io(step: String, e: std::io::Error) -> Self {
87        AuthError::Internal(format!("{step}: {e}"))
88    }
89}
90
91impl From<AuthError> for crate::Error {
92    fn from(e: AuthError) -> Self {
93        match e {
94            AuthError::NotFound(s) => crate::Error::NotFound(s),
95            e => crate::Error::Invalid(e.to_string()),
96        }
97    }
98}
99
100pub type AuthResult<T> = std::result::Result<T, AuthError>;
101
102/// A role in an org. Ordered by reach: an actor may grant roles up to its own.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum Role {
106    /// Reads the org: lists and inspects, no secret values, no deploys, no
107    /// exec or terminal.
108    Viewer,
109    Member,
110    Admin,
111    Owner,
112}
113
114/// Something a role allows within its org.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
116pub enum Permission {
117    /// List and inspect what is in the org (read-only tools), never secret
118    /// values.
119    ReadOrg,
120    /// Apps, stacks, sandboxes, secrets (read included), deploys, exec.
121    AdminOrg,
122    /// Add, change and remove members; invitations; every token in the org.
123    ManageMembers,
124    /// Delete the org.
125    DeleteOrg,
126}
127
128impl Role {
129    pub const ALL: [Role; 4] = [Role::Viewer, Role::Member, Role::Admin, Role::Owner];
130
131    /// What each role may do. The org is the boundary, so every member
132    /// administers what is in it; a viewer only reads.
133    pub fn permissions(self) -> &'static [Permission] {
134        use Permission::*;
135        match self {
136            Role::Viewer => &[ReadOrg],
137            Role::Member => &[ReadOrg, AdminOrg],
138            Role::Admin => &[ReadOrg, AdminOrg, ManageMembers],
139            Role::Owner => &[ReadOrg, AdminOrg, ManageMembers, DeleteOrg],
140        }
141    }
142
143    pub fn can(self, p: Permission) -> bool {
144        self.permissions().contains(&p)
145    }
146
147    pub fn as_str(self) -> &'static str {
148        match self {
149            Role::Viewer => "viewer",
150            Role::Member => "member",
151            Role::Admin => "admin",
152            Role::Owner => "owner",
153        }
154    }
155
156    pub fn parse(s: &str) -> AuthResult<Role> {
157        Role::ALL
158            .into_iter()
159            .find(|r| r.as_str() == s)
160            .ok_or_else(|| {
161                AuthError::Invalid(format!("role {s:?}: owner, admin, member or viewer"))
162            })
163    }
164}
165
166impl std::fmt::Display for Role {
167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        f.write_str(self.as_str())
169    }
170}
171
172/// A user, without the password hash (which never leaves the store).
173#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
174pub struct User {
175    pub id: i64,
176    pub email: String,
177    pub name: String,
178    pub platform_admin: bool,
179    pub created_at: i64,
180    pub disabled: bool,
181    /// False for a user who signs in only through an external identity.
182    pub has_password: bool,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
186pub struct Membership {
187    pub org: OrgId,
188    pub role: Role,
189}
190
191/// A browser session. `expires_at` is the absolute limit; it also ends when
192/// unused for the configured idle time ([`Session::idle_expires_at`]).
193#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
194pub struct Session {
195    pub id: i64,
196    pub user_id: i64,
197    pub created_at: i64,
198    pub last_seen: i64,
199    pub expires_at: i64,
200    pub idle_expires_at: i64,
201    pub user_agent: Option<String>,
202    pub ip: Option<String>,
203}
204
205/// A session just created: the token is in hand only now.
206#[derive(Debug, Clone)]
207pub struct NewSession {
208    pub token: String,
209    pub session: Session,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
213pub struct Invitation {
214    pub id: i64,
215    pub org: OrgId,
216    pub email: String,
217    pub role: Role,
218    /// `None` when made by the local CLI (or the inviter was deleted).
219    pub invited_by: Option<i64>,
220    pub created_at: i64,
221    pub expires_at: i64,
222    pub accepted_at: Option<i64>,
223}
224
225#[derive(Debug, Clone)]
226pub struct NewInvitation {
227    pub token: String,
228    pub invitation: Invitation,
229}
230
231/// What accepting an invitation did.
232#[derive(Debug, Clone)]
233pub struct Accepted {
234    pub user: User,
235    pub membership: Membership,
236    /// True when the invitation created the account.
237    pub created: bool,
238}
239
240/// An API token's metadata.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
242pub struct ApiToken {
243    pub id: i64,
244    pub name: String,
245    pub user_id: i64,
246    /// `None`: a platform token, acting with all of its (platform admin)
247    /// owner's access. `Some`: confined to that org.
248    pub org: Option<OrgId>,
249    pub created_at: i64,
250    pub last_used: Option<i64>,
251    pub expires_at: Option<i64>,
252    /// Restrictions on top of the role ([`Scope`]); empty: the role's
253    /// whole reach.
254    pub scopes: Vec<String>,
255}
256
257#[derive(Debug, Clone)]
258pub struct NewApiToken {
259    pub token: String,
260    pub info: ApiToken,
261}
262
263/// How a principal authenticated.
264#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
265#[serde(tag = "kind", rename_all = "snake_case")]
266pub enum PrincipalKind {
267    Session {
268        id: i64,
269    },
270    ApiToken {
271        id: i64,
272        org: Option<OrgId>,
273        /// The token's name, for audit rows.
274        #[serde(default)]
275        name: String,
276        /// Empty: the role's whole reach.
277        #[serde(default, skip_serializing_if = "Vec::is_empty")]
278        scopes: Vec<String>,
279    },
280    /// A Cloudflare Access identity whose email is this user's.
281    Access,
282    /// A superadmin ([`superadmin`]): a superadmin token or a tailnet
283    /// identity, acting as this (possibly synthetic) user.
284    Superadmin {
285        source: SuperadminSource,
286    },
287    /// An org's workspace (docs/concepts/workspaces.md): its token (`isb_ws_...`),
288    /// held by the agents that live in it. Nobody's account: a synthetic
289    /// principal confined to `org` with the role the workspace was given.
290    Workspace {
291        org: OrgId,
292        /// The workspace's name in its org.
293        name: String,
294    },
295    /// A tailnet or Cloudflare Access caller an org mapped to a role
296    /// ([`agent_identities`]): `label` is `tailnet:<login or node>` or
297    /// `access:<name>`. Nobody's account, confined to the orgs that mapped it.
298    Agent {
299        label: String,
300    },
301}
302
303/// An authenticated caller: who, how, and what they may reach. For an
304/// org-scoped token, `orgs` is that one org and `platform_admin` is false
305/// whatever the user is.
306#[derive(Debug, Clone, PartialEq, Eq)]
307pub struct Principal {
308    pub user: User,
309    pub kind: PrincipalKind,
310    pub orgs: Vec<(OrgId, Role)>,
311    pub platform_admin: bool,
312    /// Set on an org-bound endpoint for a superadmin or platform admin: an admin of this org only.
313    pub downscoped: Option<OrgId>,
314}
315
316impl Principal {
317    pub fn is_platform_admin(&self) -> bool {
318        self.platform_admin
319    }
320
321    pub fn role_in(&self, org: &OrgId) -> Option<Role> {
322        self.orgs.iter().find(|(o, _)| o == org).map(|(_, r)| *r)
323    }
324
325    fn can(&self, org: &OrgId, p: Permission) -> bool {
326        self.platform_admin || self.role_in(org).is_some_and(|r| r.can(p))
327    }
328
329    /// Apps, stacks, sandboxes and secrets in `org`.
330    pub fn can_admin_org(&self, org: &OrgId) -> bool {
331        self.can(org, Permission::AdminOrg)
332    }
333
334    /// Read-only tools in `org`.
335    pub fn can_read_org(&self, org: &OrgId) -> bool {
336        self.can(org, Permission::ReadOrg)
337    }
338
339    /// Members, invitations and every token in `org`.
340    pub fn can_manage_members(&self, org: &OrgId) -> bool {
341        self.can(org, Permission::ManageMembers)
342    }
343
344    pub fn can_delete_org(&self, org: &OrgId) -> bool {
345        self.can(org, Permission::DeleteOrg)
346    }
347
348    /// The highest role this principal may hand out in `org`.
349    pub fn max_grant(&self, org: &OrgId) -> Option<Role> {
350        if self.platform_admin {
351            return Some(Role::Owner);
352        }
353        self.role_in(org)
354            .filter(|r| r.can(Permission::ManageMembers))
355    }
356
357    pub fn session_id(&self) -> Option<i64> {
358        match self.kind {
359            PrincipalKind::Session { id } => Some(id),
360            PrincipalKind::ApiToken { .. }
361            | PrincipalKind::Access
362            | PrincipalKind::Superadmin { .. }
363            | PrincipalKind::Agent { .. }
364            | PrincipalKind::Workspace { .. } => None,
365        }
366    }
367}
368
369/// Where a login came from, kept on the session for the user to review.
370#[derive(Debug, Clone, Default)]
371pub struct LoginMeta {
372    pub user_agent: Option<String>,
373    pub ip: Option<String>,
374}
375
376/// Lifetimes, cost and rate limits.
377#[derive(Debug, Clone)]
378pub struct AuthConfig {
379    /// A session ends this long after login, used or not.
380    pub session_max_age: Duration,
381    /// A session ends after this long unused.
382    pub session_idle: Duration,
383    pub invitation_ttl: Duration,
384    pub reset_ttl: Duration,
385    pub password_cost: PasswordCost,
386    /// Login attempts per email.
387    pub login_per_email: Rate,
388    /// Unauthenticated attempts (login, setup, invitations, resets) per IP.
389    pub attempts_per_ip: Rate,
390    /// Password reset requests per email.
391    pub resets_per_email: Rate,
392}
393
394impl Default for AuthConfig {
395    fn default() -> Self {
396        AuthConfig {
397            session_max_age: Duration::from_secs(30 * 86400),
398            session_idle: Duration::from_secs(7 * 86400),
399            invitation_ttl: Duration::from_secs(7 * 86400),
400            reset_ttl: Duration::from_secs(3600),
401            password_cost: PasswordCost::default(),
402            login_per_email: Rate::new(5, 60),
403            attempts_per_ip: Rate::new(20, 6),
404            resets_per_email: Rate::new(3, 900),
405        }
406    }
407}
408
409/// `last_seen`/`last_used` are written at most this often per session or token.
410const TOUCH_EVERY: i64 = 60;
411
412pub type Clock = Arc<dyn Fn() -> i64 + Send + Sync>;
413
414/// The identity store. Cheap to share behind an `Arc`; one connection behind
415/// a mutex (requests are short, and argon2 runs outside the lock).
416pub struct AuthStore {
417    conn: Mutex<Connection>,
418    path: Option<PathBuf>,
419    cfg: AuthConfig,
420    clock: Clock,
421    per_email: RateLimiter,
422    per_ip: RateLimiter,
423    resets: RateLimiter,
424    dummy: OnceLock<String>,
425}
426
427impl std::fmt::Debug for AuthStore {
428    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429        f.debug_struct("AuthStore")
430            .field("path", &self.path)
431            .finish()
432    }
433}
434
435/// The identity database under a daemon state directory.
436pub fn db_path(state_dir: &Path) -> PathBuf {
437    state_dir.join("isb.db")
438}
439
440fn unix_now() -> i64 {
441    std::time::SystemTime::now()
442        .duration_since(std::time::UNIX_EPOCH)
443        .map(|d| d.as_secs() as i64)
444        .unwrap_or(0)
445}
446
447fn secs(d: Duration) -> i64 {
448    d.as_secs().min(i64::MAX as u64 / 2) as i64
449}
450
451/// Trimmed, lowercased, and plausibly an address. Not RFC 5322: the address
452/// is a login name and an invitation target, not something isb parses.
453pub fn normalize_email(email: &str) -> AuthResult<String> {
454    let e = email.trim().to_lowercase();
455    let ok = e.len() <= 254
456        && e.split_once('@').is_some_and(|(l, d)| {
457            !l.is_empty() && !d.is_empty() && !d.contains('@') && !d.starts_with('.')
458        })
459        && !e.chars().any(|c| c.is_whitespace() || c.is_control());
460    if ok {
461        Ok(e)
462    } else {
463        Err(AuthError::Invalid(format!(
464            "{email:?} is not an email address"
465        )))
466    }
467}
468
469pub(crate) fn clean_name(name: &str) -> AuthResult<String> {
470    let n = name.trim();
471    if n.chars().count() > 100 || n.chars().any(char::is_control) {
472        return Err(AuthError::Invalid(
473            "name: at most 100 characters, no control characters".into(),
474        ));
475    }
476    Ok(n.to_string())
477}
478
479fn clip(s: Option<String>, n: usize) -> Option<String> {
480    s.map(|s| s.chars().filter(|c| !c.is_control()).take(n).collect())
481}
482
483pub(crate) const USER_COLS: &str = "u.id, u.email, u.name, u.platform_admin, u.created_at, u.disabled, u.password_hash IS NOT NULL";
484
485pub(crate) fn user_row(r: &Row, at: usize) -> rusqlite::Result<User> {
486    Ok(User {
487        id: r.get(at)?,
488        email: r.get(at + 1)?,
489        name: r.get(at + 2)?,
490        platform_admin: r.get(at + 3)?,
491        created_at: r.get(at + 4)?,
492        disabled: r.get(at + 5)?,
493        has_password: r.get(at + 6)?,
494    })
495}
496
497pub(crate) fn org_col(r: &Row, i: usize) -> rusqlite::Result<OrgId> {
498    let s: String = r.get(i)?;
499    OrgId::new(s).map_err(|e| {
500        rusqlite::Error::FromSqlConversionFailure(i, rusqlite::types::Type::Text, Box::new(e))
501    })
502}
503
504fn opt_org_col(r: &Row, i: usize) -> rusqlite::Result<Option<OrgId>> {
505    match r.get::<_, Option<String>>(i)? {
506        None => Ok(None),
507        Some(_) => org_col(r, i).map(Some),
508    }
509}
510
511pub(crate) fn role_col(r: &Row, i: usize) -> rusqlite::Result<Role> {
512    let s: String = r.get(i)?;
513    Role::parse(&s).map_err(|e| {
514        rusqlite::Error::FromSqlConversionFailure(i, rusqlite::types::Type::Text, Box::new(e))
515    })
516}
517
518const INVITATION_COLS: &str =
519    "id, org, email, role, invited_by, created_at, expires_at, accepted_at";
520
521fn invitation_row(r: &Row) -> rusqlite::Result<Invitation> {
522    Ok(Invitation {
523        id: r.get(0)?,
524        org: org_col(r, 1)?,
525        email: r.get(2)?,
526        role: role_col(r, 3)?,
527        invited_by: r.get(4)?,
528        created_at: r.get(5)?,
529        expires_at: r.get(6)?,
530        accepted_at: r.get(7)?,
531    })
532}
533
534const TOKEN_COLS: &str = "id, name, user_id, org, created_at, last_used, expires_at, scopes";
535
536fn token_row(r: &Row) -> rusqlite::Result<ApiToken> {
537    Ok(ApiToken {
538        id: r.get(0)?,
539        name: r.get(1)?,
540        user_id: r.get(2)?,
541        org: opt_org_col(r, 3)?,
542        created_at: r.get(4)?,
543        last_used: r.get(5)?,
544        expires_at: r.get(6)?,
545        scopes: scopes_col(r.get(7)?),
546    })
547}
548
549fn scopes_col(v: Option<String>) -> Vec<String> {
550    v.and_then(|s| serde_json::from_str(&s).ok())
551        .unwrap_or_default()
552}
553
554impl AuthStore {
555    /// Open (creating and migrating) the database at `path`, default config.
556    pub fn open(path: impl AsRef<Path>) -> AuthResult<AuthStore> {
557        Self::open_with(path, AuthConfig::default())
558    }
559
560    pub fn open_with(path: impl AsRef<Path>, cfg: AuthConfig) -> AuthResult<AuthStore> {
561        let path = path.as_ref();
562        let conn = db::open(path)?;
563        Ok(Self::build(conn, Some(path.to_path_buf()), cfg))
564    }
565
566    /// A throwaway in-memory store (tests, previews).
567    pub fn in_memory(cfg: AuthConfig) -> AuthResult<AuthStore> {
568        Ok(Self::build(db::open_in_memory()?, None, cfg))
569    }
570
571    fn build(conn: Connection, path: Option<PathBuf>, cfg: AuthConfig) -> AuthStore {
572        AuthStore {
573            conn: Mutex::new(conn),
574            path,
575            per_email: RateLimiter::new(cfg.login_per_email),
576            per_ip: RateLimiter::new(cfg.attempts_per_ip),
577            resets: RateLimiter::new(cfg.resets_per_email),
578            cfg,
579            clock: Arc::new(unix_now),
580            dummy: OnceLock::new(),
581        }
582    }
583
584    /// Replace the clock (unix seconds), for tests of expiry.
585    pub fn with_clock(mut self, clock: Clock) -> Self {
586        self.clock = clock;
587        self
588    }
589
590    pub fn config(&self) -> &AuthConfig {
591        &self.cfg
592    }
593
594    pub fn path(&self) -> Option<&Path> {
595        self.path.as_deref()
596    }
597
598    pub fn now(&self) -> i64 {
599        (self.clock)()
600    }
601
602    pub(crate) fn db(&self) -> MutexGuard<'_, Connection> {
603        self.conn.lock().unwrap_or_else(|e| e.into_inner())
604    }
605
606    fn hash_pw(&self, pw: &str) -> AuthResult<String> {
607        secret::check_password_policy(pw)?;
608        secret::hash_password(pw, self.cfg.password_cost)
609    }
610
611    fn rate(&self, l: &RateLimiter, key: &str) -> AuthResult<()> {
612        l.take(key, self.now() as f64)
613            .map_err(|retry_after| AuthError::RateLimited { retry_after })
614    }
615
616    /// Count one unauthenticated attempt from `ip` (login, setup, invitation
617    /// acceptance, password resets).
618    pub fn limit_ip(&self, ip: Option<&str>) -> AuthResult<()> {
619        match ip {
620            Some(ip) => self.rate(&self.per_ip, ip),
621            None => Ok(()),
622        }
623    }
624
625    // ---- users ----
626
627    /// Create a user. `password` may be `None` for an account that will sign
628    /// in through an external identity or reset its password.
629    pub fn create_user(
630        &self,
631        email: &str,
632        name: &str,
633        password: Option<&str>,
634        platform_admin: bool,
635    ) -> AuthResult<User> {
636        let email = normalize_email(email)?;
637        let name = clean_name(name)?;
638        let hash = password.map(|p| self.hash_pw(p)).transpose()?;
639        let now = self.now();
640        let db = self.db();
641        let r = db.execute(
642            "INSERT INTO users (email, name, password_hash, platform_admin, created_at)
643             VALUES (?1, ?2, ?3, ?4, ?5)",
644            params![email, name, hash, platform_admin, now],
645        );
646        match r {
647            Ok(_) => {
648                let id = db.last_insert_rowid();
649                drop(db);
650                self.user(id)
651            }
652            Err(rusqlite::Error::SqliteFailure(e, _))
653                if e.code == rusqlite::ErrorCode::ConstraintViolation =>
654            {
655                Err(AuthError::Conflict(format!(
656                    "a user with email {email} exists"
657                )))
658            }
659            Err(e) => Err(e.into()),
660        }
661    }
662
663    pub fn user(&self, id: i64) -> AuthResult<User> {
664        self.db()
665            .query_row(
666                &format!("SELECT {USER_COLS} FROM users u WHERE u.id = ?1"),
667                [id],
668                |r| user_row(r, 0),
669            )
670            .optional()?
671            .ok_or_else(|| AuthError::NotFound(format!("user {id}")))
672    }
673
674    pub fn user_by_email(&self, email: &str) -> AuthResult<Option<User>> {
675        let email = normalize_email(email)?;
676        Ok(self
677            .db()
678            .query_row(
679                &format!("SELECT {USER_COLS} FROM users u WHERE u.email = ?1"),
680                [email],
681                |r| user_row(r, 0),
682            )
683            .optional()?)
684    }
685
686    pub fn list_users(&self) -> AuthResult<Vec<User>> {
687        let db = self.db();
688        let mut st = db.prepare(&format!("SELECT {USER_COLS} FROM users u ORDER BY u.id"))?;
689        let rows = st.query_map([], |r| user_row(r, 0))?;
690        Ok(rows.collect::<rusqlite::Result<_>>()?)
691    }
692
693    /// Disable (or re-enable) a user. Disabling ends their sessions; their
694    /// tokens stop working while disabled.
695    pub fn set_disabled(&self, user_id: i64, disabled: bool) -> AuthResult<()> {
696        let db = self.db();
697        let n = db.execute(
698            "UPDATE users SET disabled = ?2 WHERE id = ?1",
699            params![user_id, disabled],
700        )?;
701        if n == 0 {
702            return Err(AuthError::NotFound(format!("user {user_id}")));
703        }
704        if disabled {
705            db.execute("DELETE FROM sessions WHERE user_id = ?1", [user_id])?;
706        }
707        Ok(())
708    }
709
710    /// When a user last did anything: the latest of their sessions' last use
711    /// and their tokens' last use (`None`: never, or nothing left to tell).
712    pub fn last_active(&self, user_id: i64) -> AuthResult<Option<i64>> {
713        Ok(self.db().query_row(
714            "SELECT MAX(t) FROM (
715                 SELECT MAX(last_seen) AS t FROM sessions WHERE user_id = ?1
716                 UNION ALL
717                 SELECT MAX(last_used) FROM api_tokens WHERE user_id = ?1)",
718            [user_id],
719            |r| r.get(0),
720        )?)
721    }
722
723    /// Enabled platform admins other than `except`.
724    pub fn other_platform_admins(&self, except: i64) -> AuthResult<i64> {
725        Ok(self.db().query_row(
726            "SELECT COUNT(*) FROM users WHERE platform_admin = 1 AND disabled = 0 AND id != ?1",
727            [except],
728            |r| r.get(0),
729        )?)
730    }
731
732    pub fn set_platform_admin(&self, user_id: i64, admin: bool) -> AuthResult<()> {
733        let n = self.db().execute(
734            "UPDATE users SET platform_admin = ?2 WHERE id = ?1",
735            params![user_id, admin],
736        )?;
737        if n == 0 {
738            return Err(AuthError::NotFound(format!("user {user_id}")));
739        }
740        Ok(())
741    }
742
743    /// Set a password without the old one (the local CLI, an admin). Ends
744    /// every session of the user.
745    pub fn set_password(&self, user_id: i64, password: &str) -> AuthResult<()> {
746        let hash = self.hash_pw(password)?;
747        let db = self.db();
748        let n = db.execute(
749            "UPDATE users SET password_hash = ?2 WHERE id = ?1",
750            params![user_id, hash],
751        )?;
752        if n == 0 {
753            return Err(AuthError::NotFound(format!("user {user_id}")));
754        }
755        db.execute("DELETE FROM sessions WHERE user_id = ?1", [user_id])?;
756        Ok(())
757    }
758
759    /// Change a password, proving the current one. Ends every other session
760    /// (`keep` is the caller's own).
761    pub fn change_password(
762        &self,
763        user_id: i64,
764        current: &str,
765        new: &str,
766        keep: Option<i64>,
767    ) -> AuthResult<()> {
768        let stored: Option<String> = self
769            .db()
770            .query_row(
771                "SELECT password_hash FROM users WHERE id = ?1",
772                [user_id],
773                |r| r.get(0),
774            )
775            .optional()?
776            .flatten();
777        let ok = match &stored {
778            Some(h) => secret::verify_password(current, h),
779            None => {
780                secret::verify_password(current, self.dummy());
781                false
782            }
783        };
784        if !ok {
785            return Err(AuthError::Forbidden("current password is wrong".into()));
786        }
787        let hash = self.hash_pw(new)?;
788        let db = self.db();
789        db.execute(
790            "UPDATE users SET password_hash = ?2 WHERE id = ?1",
791            params![user_id, hash],
792        )?;
793        db.execute(
794            "DELETE FROM sessions WHERE user_id = ?1 AND id IS NOT ?2",
795            params![user_id, keep],
796        )?;
797        Ok(())
798    }
799
800    fn dummy(&self) -> &str {
801        secret::dummy_hash(&self.dummy, self.cfg.password_cost)
802    }
803
804    // ---- sessions ----
805
806    /// Check an email and password and start a session. Every failure (no
807    /// such user, wrong password, disabled, no password set) is
808    /// [`AuthError::InvalidCredentials`] and costs one argon2 verification.
809    pub fn login(&self, email: &str, password: &str, meta: LoginMeta) -> AuthResult<NewSession> {
810        let key = email.trim().to_lowercase();
811        self.limit_ip(meta.ip.as_deref())?;
812        self.rate(&self.per_email, &key)?;
813        let found: Option<(i64, Option<String>, bool)> = self
814            .db()
815            .query_row(
816                "SELECT id, password_hash, disabled FROM users WHERE email = ?1",
817                [&key],
818                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
819            )
820            .optional()?;
821        let user_id = match found {
822            Some((id, Some(h), disabled)) => {
823                let ok = secret::verify_password(password, &h);
824                (ok && !disabled).then_some(id)
825            }
826            _ => {
827                secret::verify_password(password, self.dummy());
828                None
829            }
830        };
831        let user_id = user_id.ok_or(AuthError::InvalidCredentials)?;
832        self.prune()?;
833        self.start_session(user_id, meta)
834    }
835
836    /// Start a session for a user already proven by other means (an
837    /// accepted invitation, a password reset, an external identity).
838    pub fn start_session(&self, user_id: i64, meta: LoginMeta) -> AuthResult<NewSession> {
839        let (token, hash) = secret::new_token(TokenKind::Session)?;
840        let now = self.now();
841        let expires = now + secs(self.cfg.session_max_age);
842        let (ua, ip) = (clip(meta.user_agent, 256), clip(meta.ip, 64));
843        let db = self.db();
844        db.execute(
845            "INSERT INTO sessions (token_hash, user_id, created_at, last_seen, expires_at, user_agent, ip)
846             VALUES (?1, ?2, ?3, ?3, ?4, ?5, ?6)",
847            params![hash, user_id, now, expires, ua, ip],
848        )?;
849        let id = db.last_insert_rowid();
850        Ok(NewSession {
851            token,
852            session: Session {
853                id,
854                user_id,
855                created_at: now,
856                last_seen: now,
857                expires_at: expires,
858                idle_expires_at: self.idle_end(now, expires),
859                user_agent: ua,
860                ip,
861            },
862        })
863    }
864
865    fn idle_end(&self, last_seen: i64, expires: i64) -> i64 {
866        (last_seen + secs(self.cfg.session_idle)).min(expires)
867    }
868
869    fn session_row(&self, r: &Row, at: usize) -> rusqlite::Result<Session> {
870        let last_seen: i64 = r.get(at + 3)?;
871        let expires: i64 = r.get(at + 4)?;
872        Ok(Session {
873            id: r.get(at)?,
874            user_id: r.get(at + 1)?,
875            created_at: r.get(at + 2)?,
876            last_seen,
877            expires_at: expires,
878            idle_expires_at: self.idle_end(last_seen, expires),
879            user_agent: r.get(at + 5)?,
880            ip: r.get(at + 6)?,
881        })
882    }
883
884    /// The live session for `token`, sliding its idle expiry. An expired one
885    /// is deleted; a disabled user has none.
886    pub fn session(&self, token: &str) -> AuthResult<Option<(User, Session)>> {
887        if !secret::well_formed(token, TokenKind::Session) {
888            return Ok(None);
889        }
890        let hash = secret::hash_token(token);
891        let now = self.now();
892        let db = self.db();
893        let found = db
894            .query_row(
895                &format!(
896                    "SELECT s.token_hash, s.id, s.user_id, s.created_at, s.last_seen, s.expires_at,
897                            s.user_agent, s.ip, {USER_COLS}
898                     FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ?1"
899                ),
900                [&hash],
901                |r| {
902                    Ok((
903                        r.get::<_, Vec<u8>>(0)?,
904                        self.session_row(r, 1)?,
905                        user_row(r, 8)?,
906                    ))
907                },
908            )
909            .optional()?;
910        let Some((stored, mut s, user)) = found else {
911            return Ok(None);
912        };
913        if !secret::ct_eq(&stored, &hash) {
914            return Ok(None);
915        }
916        if now >= s.expires_at || now >= s.idle_expires_at {
917            db.execute("DELETE FROM sessions WHERE id = ?1", [s.id])?;
918            return Ok(None);
919        }
920        if user.disabled {
921            return Ok(None);
922        }
923        if now - s.last_seen >= TOUCH_EVERY {
924            db.execute(
925                "UPDATE sessions SET last_seen = ?2 WHERE id = ?1",
926                params![s.id, now],
927            )?;
928            s.last_seen = now;
929            s.idle_expires_at = self.idle_end(now, s.expires_at);
930        }
931        Ok(Some((user, s)))
932    }
933
934    /// The principal for a Cloudflare Access identity: the enabled user with
935    /// that email, with all their memberships.
936    pub fn principal_for_email(&self, email: &str) -> AuthResult<Option<Principal>> {
937        let Some(user) = self.user_by_email(email)? else {
938            return Ok(None);
939        };
940        if user.disabled {
941            return Ok(None);
942        }
943        let orgs = self
944            .memberships(user.id)?
945            .into_iter()
946            .map(|m| (m.org, m.role))
947            .collect();
948        Ok(Some(Principal {
949            platform_admin: user.platform_admin,
950            user,
951            kind: PrincipalKind::Access,
952            orgs,
953            downscoped: None,
954        }))
955    }
956
957    /// The principal behind a session token.
958    pub fn authenticate_session(&self, token: &str) -> AuthResult<Option<Principal>> {
959        let Some((user, s)) = self.session(token)? else {
960            return Ok(None);
961        };
962        let orgs = self
963            .memberships(user.id)?
964            .into_iter()
965            .map(|m| (m.org, m.role))
966            .collect();
967        Ok(Some(Principal {
968            platform_admin: user.platform_admin,
969            user,
970            kind: PrincipalKind::Session { id: s.id },
971            orgs,
972            downscoped: None,
973        }))
974    }
975
976    /// End the session holding `token`. True if there was one.
977    pub fn logout(&self, token: &str) -> AuthResult<bool> {
978        if !secret::well_formed(token, TokenKind::Session) {
979            return Ok(false);
980        }
981        let n = self.db().execute(
982            "DELETE FROM sessions WHERE token_hash = ?1",
983            [secret::hash_token(token)],
984        )?;
985        Ok(n > 0)
986    }
987
988    /// A user's live sessions, newest first.
989    pub fn list_sessions(&self, user_id: i64) -> AuthResult<Vec<Session>> {
990        let now = self.now();
991        let db = self.db();
992        let mut st = db.prepare(
993            "SELECT id, user_id, created_at, last_seen, expires_at, user_agent, ip
994             FROM sessions WHERE user_id = ?1 ORDER BY id DESC",
995        )?;
996        let rows = st.query_map([user_id], |r| self.session_row(r, 0))?;
997        let all: Vec<Session> = rows.collect::<rusqlite::Result<_>>()?;
998        Ok(all
999            .into_iter()
1000            .filter(|s| now < s.expires_at && now < s.idle_expires_at)
1001            .collect())
1002    }
1003
1004    /// End one of a user's sessions. True if it existed.
1005    pub fn revoke_session(&self, user_id: i64, session_id: i64) -> AuthResult<bool> {
1006        let n = self.db().execute(
1007            "DELETE FROM sessions WHERE id = ?1 AND user_id = ?2",
1008            params![session_id, user_id],
1009        )?;
1010        Ok(n > 0)
1011    }
1012
1013    /// End all of a user's sessions but `keep`. Returns how many ended.
1014    pub fn revoke_sessions(&self, user_id: i64, keep: Option<i64>) -> AuthResult<usize> {
1015        Ok(self.db().execute(
1016            "DELETE FROM sessions WHERE user_id = ?1 AND id IS NOT ?2",
1017            params![user_id, keep],
1018        )?)
1019    }
1020
1021    /// Delete expired sessions, invitations and resets.
1022    pub fn prune(&self) -> AuthResult<()> {
1023        let now = self.now();
1024        let idle = secs(self.cfg.session_idle);
1025        self.db().execute_batch(&format!(
1026            "DELETE FROM sessions WHERE expires_at <= {now} OR last_seen + {idle} <= {now};
1027             DELETE FROM invitations WHERE accepted_at IS NULL AND expires_at <= {now};
1028             DELETE FROM password_resets WHERE expires_at <= {now} OR used_at IS NOT NULL;"
1029        ))?;
1030        Ok(())
1031    }
1032
1033    // ---- orgs and memberships ----
1034
1035    /// Record an org (idempotent). The org's runtime is not this module's;
1036    /// this row anchors memberships, invitations and tokens.
1037    pub fn ensure_org(&self, org: &OrgId) -> AuthResult<()> {
1038        ensure_org_tx(&self.db(), org, self.now())
1039    }
1040
1041    /// Forget an org: its memberships, invitations and tokens go with it.
1042    pub fn delete_org(&self, org: &OrgId) -> AuthResult<bool> {
1043        let n = self
1044            .db()
1045            .execute("DELETE FROM orgs WHERE name = ?1", [org.as_str()])?;
1046        Ok(n > 0)
1047    }
1048
1049    pub fn list_orgs(&self) -> AuthResult<Vec<OrgId>> {
1050        let db = self.db();
1051        let mut st = db.prepare("SELECT name FROM orgs ORDER BY name")?;
1052        let rows = st.query_map([], |r| org_col(r, 0))?;
1053        Ok(rows.collect::<rusqlite::Result<_>>()?)
1054    }
1055
1056    pub fn memberships(&self, user_id: i64) -> AuthResult<Vec<Membership>> {
1057        let db = self.db();
1058        let mut st =
1059            db.prepare("SELECT org, role FROM memberships WHERE user_id = ?1 ORDER BY org")?;
1060        let rows = st.query_map([user_id], |r| {
1061            Ok(Membership {
1062                org: org_col(r, 0)?,
1063                role: role_col(r, 1)?,
1064            })
1065        })?;
1066        Ok(rows.collect::<rusqlite::Result<_>>()?)
1067    }
1068
1069    pub fn list_members(&self, org: &OrgId) -> AuthResult<Vec<(User, Role)>> {
1070        let db = self.db();
1071        let mut st = db.prepare(&format!(
1072            "SELECT {USER_COLS}, m.role FROM memberships m JOIN users u ON u.id = m.user_id
1073             WHERE m.org = ?1 ORDER BY u.email"
1074        ))?;
1075        let rows = st.query_map([org.as_str()], |r| Ok((user_row(r, 0)?, role_col(r, 7)?)))?;
1076        Ok(rows.collect::<rusqlite::Result<_>>()?)
1077    }
1078
1079    /// Add `user` to `org` with `role`, or change their role. Refuses to
1080    /// demote the org's last owner.
1081    pub fn set_member(&self, org: &OrgId, user_id: i64, role: Role) -> AuthResult<()> {
1082        let now = self.now();
1083        let mut db = self.db();
1084        let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?;
1085        ensure_org_tx(&tx, org, now)?;
1086        if role != Role::Owner {
1087            refuse_last_owner(&tx, org, user_id, "demote")?;
1088        }
1089        tx.execute(
1090            "INSERT INTO memberships (user_id, org, role, created_at) VALUES (?1, ?2, ?3, ?4)
1091             ON CONFLICT (user_id, org) DO UPDATE SET role = excluded.role",
1092            params![user_id, org.as_str(), role.as_str(), now],
1093        )
1094        .map_err(|e| match e {
1095            rusqlite::Error::SqliteFailure(f, _)
1096                if f.code == rusqlite::ErrorCode::ConstraintViolation =>
1097            {
1098                AuthError::NotFound(format!("user {user_id}"))
1099            }
1100            e => e.into(),
1101        })?;
1102        tx.commit()?;
1103        Ok(())
1104    }
1105
1106    /// Remove `user` from `org`, and their tokens confined to it. Refuses to
1107    /// remove the last owner. True if they were a member.
1108    pub fn remove_member(&self, org: &OrgId, user_id: i64) -> AuthResult<bool> {
1109        let mut db = self.db();
1110        let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?;
1111        refuse_last_owner(&tx, org, user_id, "remove")?;
1112        let n = tx.execute(
1113            "DELETE FROM memberships WHERE org = ?1 AND user_id = ?2",
1114            params![org.as_str(), user_id],
1115        )?;
1116        tx.execute(
1117            "DELETE FROM api_tokens WHERE org = ?1 AND user_id = ?2",
1118            params![org.as_str(), user_id],
1119        )?;
1120        tx.commit()?;
1121        Ok(n > 0)
1122    }
1123
1124    // ---- invitations ----
1125
1126    /// Invite `email` to `org` as `role`. Replaces any pending invitation for
1127    /// the same address and org. Authorization is the caller's
1128    /// ([`Principal::max_grant`]); `invited_by` is `None` for the local CLI.
1129    pub fn create_invitation(
1130        &self,
1131        invited_by: Option<i64>,
1132        org: &OrgId,
1133        email: &str,
1134        role: Role,
1135    ) -> AuthResult<NewInvitation> {
1136        let email = normalize_email(email)?;
1137        let (token, hash) = secret::new_token(TokenKind::Invitation)?;
1138        let now = self.now();
1139        let expires = now + secs(self.cfg.invitation_ttl);
1140        let mut db = self.db();
1141        let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?;
1142        ensure_org_tx(&tx, org, now)?;
1143        tx.execute(
1144            "DELETE FROM invitations WHERE org = ?1 AND email = ?2 AND accepted_at IS NULL",
1145            params![org.as_str(), email],
1146        )?;
1147        tx.execute(
1148            "INSERT INTO invitations (token_hash, org, email, role, invited_by, created_at, expires_at)
1149             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1150            params![hash, org.as_str(), email, role.as_str(), invited_by, now, expires],
1151        )?;
1152        let id = tx.last_insert_rowid();
1153        tx.commit()?;
1154        Ok(NewInvitation {
1155            token,
1156            invitation: Invitation {
1157                id,
1158                org: org.clone(),
1159                email,
1160                role,
1161                invited_by,
1162                created_at: now,
1163                expires_at: expires,
1164                accepted_at: None,
1165            },
1166        })
1167    }
1168
1169    /// Pending (unaccepted, unexpired) invitations to `org`.
1170    pub fn list_invitations(&self, org: &OrgId) -> AuthResult<Vec<Invitation>> {
1171        let db = self.db();
1172        let mut st = db.prepare(&format!(
1173            "SELECT {INVITATION_COLS} FROM invitations
1174             WHERE org = ?1 AND accepted_at IS NULL AND expires_at > ?2 ORDER BY id"
1175        ))?;
1176        let rows = st.query_map(params![org.as_str(), self.now()], invitation_row)?;
1177        Ok(rows.collect::<rusqlite::Result<_>>()?)
1178    }
1179
1180    /// Withdraw a pending invitation. True if there was one.
1181    pub fn revoke_invitation(&self, org: &OrgId, id: i64) -> AuthResult<bool> {
1182        let n = self.db().execute(
1183            "DELETE FROM invitations WHERE id = ?1 AND org = ?2 AND accepted_at IS NULL",
1184            params![id, org.as_str()],
1185        )?;
1186        Ok(n > 0)
1187    }
1188
1189    /// The pending invitation for `token`, if it is valid.
1190    pub fn invitation(&self, token: &str) -> AuthResult<Option<Invitation>> {
1191        if !secret::well_formed(token, TokenKind::Invitation) {
1192            return Ok(None);
1193        }
1194        let hash = secret::hash_token(token);
1195        let found = self
1196            .db()
1197            .query_row(
1198                &format!(
1199                    "SELECT token_hash, {INVITATION_COLS} FROM invitations WHERE token_hash = ?1"
1200                ),
1201                [&hash],
1202                |r| {
1203                    let stored: Vec<u8> = r.get(0)?;
1204                    let inv = Invitation {
1205                        id: r.get(1)?,
1206                        org: org_col(r, 2)?,
1207                        email: r.get(3)?,
1208                        role: role_col(r, 4)?,
1209                        invited_by: r.get(5)?,
1210                        created_at: r.get(6)?,
1211                        expires_at: r.get(7)?,
1212                        accepted_at: r.get(8)?,
1213                    };
1214                    Ok((stored, inv))
1215                },
1216            )
1217            .optional()?;
1218        Ok(found.and_then(|(stored, inv)| {
1219            (secret::ct_eq(&stored, &hash)
1220                && inv.accepted_at.is_none()
1221                && self.now() < inv.expires_at)
1222                .then_some(inv)
1223        }))
1224    }
1225
1226    /// Accept an invitation without a session. A new address gets an account
1227    /// with `name` and `password`; an existing account must prove `password`
1228    /// (the invitation alone does not let anyone in as someone else).
1229    pub fn accept_invitation(
1230        &self,
1231        token: &str,
1232        name: &str,
1233        password: &str,
1234    ) -> AuthResult<Accepted> {
1235        let inv = self
1236            .invitation(token)?
1237            .ok_or(AuthError::InvalidToken("invitation"))?;
1238        let existing: Option<(i64, Option<String>, bool)> = self
1239            .db()
1240            .query_row(
1241                "SELECT id, password_hash, disabled FROM users WHERE email = ?1",
1242                [&inv.email],
1243                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1244            )
1245            .optional()?;
1246        let new_user = match &existing {
1247            Some((_, Some(h), disabled)) => {
1248                if !secret::verify_password(password, h) || *disabled {
1249                    return Err(AuthError::InvalidCredentials);
1250                }
1251                None
1252            }
1253            Some((_, None, _)) => {
1254                secret::verify_password(password, self.dummy());
1255                return Err(AuthError::InvalidCredentials);
1256            }
1257            None => Some((clean_name(name)?, self.hash_pw(password)?)),
1258        };
1259        self.finish_accept(&inv, existing.map(|e| e.0), new_user)
1260    }
1261
1262    /// Accept an invitation as the signed-in user, whose email must match.
1263    pub fn accept_invitation_as(&self, token: &str, user_id: i64) -> AuthResult<Accepted> {
1264        let inv = self
1265            .invitation(token)?
1266            .ok_or(AuthError::InvalidToken("invitation"))?;
1267        let user = self.user(user_id)?;
1268        if user.email != inv.email {
1269            return Err(AuthError::Forbidden(format!(
1270                "this invitation is for {}, and you are signed in as {}",
1271                inv.email, user.email
1272            )));
1273        }
1274        self.finish_accept(&inv, Some(user_id), None)
1275    }
1276
1277    fn finish_accept(
1278        &self,
1279        inv: &Invitation,
1280        user_id: Option<i64>,
1281        new_user: Option<(String, String)>,
1282    ) -> AuthResult<Accepted> {
1283        let now = self.now();
1284        let mut db = self.db();
1285        let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?;
1286        // Single use: whoever marks it first wins.
1287        let n = tx.execute(
1288            "UPDATE invitations SET accepted_at = ?2 WHERE id = ?1 AND accepted_at IS NULL",
1289            params![inv.id, now],
1290        )?;
1291        if n == 0 {
1292            return Err(AuthError::InvalidToken("invitation"));
1293        }
1294        let (uid, created) = match (user_id, new_user) {
1295            (Some(id), _) => (id, false),
1296            (None, Some((name, hash))) => {
1297                tx.execute(
1298                    "INSERT INTO users (email, name, password_hash, created_at) VALUES (?1, ?2, ?3, ?4)",
1299                    params![inv.email, name, hash, now],
1300                )
1301                .map_err(|e| match e {
1302                    rusqlite::Error::SqliteFailure(f, _)
1303                        if f.code == rusqlite::ErrorCode::ConstraintViolation =>
1304                    {
1305                        AuthError::Conflict(format!("a user with email {} exists", inv.email))
1306                    }
1307                    e => e.into(),
1308                })?;
1309                (tx.last_insert_rowid(), true)
1310            }
1311            (None, None) => return Err(AuthError::Internal("accept: no user".into())),
1312        };
1313        tx.execute(
1314            "UPDATE invitations SET accepted_by = ?2 WHERE id = ?1",
1315            params![inv.id, uid],
1316        )?;
1317        // Never lower an existing role by accepting a lesser invitation.
1318        let current: Option<Role> = tx
1319            .query_row(
1320                "SELECT role FROM memberships WHERE user_id = ?1 AND org = ?2",
1321                params![uid, inv.org.as_str()],
1322                |r| role_col(r, 0),
1323            )
1324            .optional()?;
1325        let role = current.map_or(inv.role, |c| c.max(inv.role));
1326        tx.execute(
1327            "INSERT INTO memberships (user_id, org, role, created_at) VALUES (?1, ?2, ?3, ?4)
1328             ON CONFLICT (user_id, org) DO UPDATE SET role = excluded.role",
1329            params![uid, inv.org.as_str(), role.as_str(), now],
1330        )?;
1331        tx.commit()?;
1332        drop(db);
1333        Ok(Accepted {
1334            user: self.user(uid)?,
1335            membership: Membership {
1336                org: inv.org.clone(),
1337                role,
1338            },
1339            created,
1340        })
1341    }
1342
1343    // ---- API tokens ----
1344
1345    /// Make an API token for `user_id`. Confined to `org` when given (the
1346    /// user must belong to it, or be a platform admin); a platform token
1347    /// (`org: None`) is for platform admins only. `expires: None` never expires.
1348    pub fn create_api_token(
1349        &self,
1350        user_id: i64,
1351        org: Option<&OrgId>,
1352        name: &str,
1353        expires: Option<Duration>,
1354    ) -> AuthResult<NewApiToken> {
1355        self.create_api_token_scoped(user_id, org, name, expires, &[])
1356    }
1357
1358    /// [`Self::create_api_token`], narrowed to `scopes` ([`Scope`]).
1359    pub fn create_api_token_scoped(
1360        &self,
1361        user_id: i64,
1362        org: Option<&OrgId>,
1363        name: &str,
1364        expires: Option<Duration>,
1365        scopes: &[String],
1366    ) -> AuthResult<NewApiToken> {
1367        let scopes = Scope::normalize(scopes)?;
1368        let name = name.trim();
1369        if name.is_empty() || name.chars().count() > 100 || name.chars().any(char::is_control) {
1370            return Err(AuthError::Invalid(
1371                "token name: 1 to 100 characters, no control characters".into(),
1372            ));
1373        }
1374        let user = self.user(user_id)?;
1375        if user.disabled {
1376            return Err(AuthError::Forbidden(format!(
1377                "user {} is disabled",
1378                user.email
1379            )));
1380        }
1381        match org {
1382            None if !user.platform_admin => {
1383                return Err(AuthError::Forbidden(
1384                    "only a platform admin can make a token without an org".into(),
1385                ));
1386            }
1387            Some(o) if !user.platform_admin && self.role_of(user_id, o)?.is_none() => {
1388                return Err(AuthError::Forbidden(format!(
1389                    "{} is not a member of org {o}",
1390                    user.email
1391                )));
1392            }
1393            _ => {}
1394        }
1395        let (token, hash) = secret::new_token(TokenKind::Api)?;
1396        let now = self.now();
1397        let expires_at = expires.map(|d| now + secs(d));
1398        let db = self.db();
1399        if let Some(o) = org {
1400            ensure_org_tx(&db, o, now)?;
1401        }
1402        db.execute(
1403            "INSERT INTO api_tokens (token_hash, name, user_id, org, created_at, expires_at, scopes)
1404             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1405            params![
1406                hash,
1407                name,
1408                user_id,
1409                org.map(OrgId::as_str),
1410                now,
1411                expires_at,
1412                (!scopes.is_empty()).then(|| serde_json::to_string(&scopes).unwrap_or_default())
1413            ],
1414        )?;
1415        Ok(NewApiToken {
1416            token,
1417            info: ApiToken {
1418                id: db.last_insert_rowid(),
1419                name: name.to_string(),
1420                user_id,
1421                org: org.cloned(),
1422                created_at: now,
1423                last_used: None,
1424                expires_at,
1425                scopes,
1426            },
1427        })
1428    }
1429
1430    fn role_of(&self, user_id: i64, org: &OrgId) -> AuthResult<Option<Role>> {
1431        Ok(self
1432            .db()
1433            .query_row(
1434                "SELECT role FROM memberships WHERE user_id = ?1 AND org = ?2",
1435                params![user_id, org.as_str()],
1436                |r| role_col(r, 0),
1437            )
1438            .optional()?)
1439    }
1440
1441    /// The principal behind an API token. Expired tokens, disabled users, and
1442    /// org tokens whose user has left the org authenticate nobody.
1443    pub fn authenticate_token(&self, token: &str) -> AuthResult<Option<Principal>> {
1444        if !secret::well_formed(token, TokenKind::Api) {
1445            return Ok(None);
1446        }
1447        let hash = secret::hash_token(token);
1448        let now = self.now();
1449        let found = self
1450            .db()
1451            .query_row(
1452                &format!(
1453                    "SELECT t.token_hash, t.id, t.org, t.expires_at, t.last_used, t.name, t.scopes,
1454                     {USER_COLS} FROM api_tokens t JOIN users u ON u.id = t.user_id
1455                     WHERE t.token_hash = ?1"
1456                ),
1457                [&hash],
1458                |r| {
1459                    Ok((
1460                        r.get::<_, Vec<u8>>(0)?,
1461                        r.get::<_, i64>(1)?,
1462                        opt_org_col(r, 2)?,
1463                        r.get::<_, Option<i64>>(3)?,
1464                        r.get::<_, Option<i64>>(4)?,
1465                        r.get::<_, String>(5)?,
1466                        scopes_col(r.get(6)?),
1467                        user_row(r, 7)?,
1468                    ))
1469                },
1470            )
1471            .optional()?;
1472        let Some((stored, id, org, expires, last_used, name, scopes, user)) = found else {
1473            return Ok(None);
1474        };
1475        if !secret::ct_eq(&stored, &hash) || expires.is_some_and(|e| now >= e) || user.disabled {
1476            return Ok(None);
1477        }
1478        let (orgs, platform_admin) = match &org {
1479            Some(o) => {
1480                let role = match self.role_of(user.id, o)? {
1481                    Some(r) => r,
1482                    // A platform admin reaches every org; confined here.
1483                    None if user.platform_admin => Role::Owner,
1484                    None => return Ok(None),
1485                };
1486                (vec![(o.clone(), role)], false)
1487            }
1488            None if user.platform_admin => (
1489                self.memberships(user.id)?
1490                    .into_iter()
1491                    .map(|m| (m.org, m.role))
1492                    .collect(),
1493                true,
1494            ),
1495            // A platform token whose user is no longer a platform admin.
1496            None => return Ok(None),
1497        };
1498        if last_used.is_none_or(|l| now - l >= TOUCH_EVERY) {
1499            self.db().execute(
1500                "UPDATE api_tokens SET last_used = ?2 WHERE id = ?1",
1501                params![id, now],
1502            )?;
1503        }
1504        Ok(Some(Principal {
1505            user,
1506            kind: PrincipalKind::ApiToken {
1507                id,
1508                org,
1509                name,
1510                scopes,
1511            },
1512            orgs,
1513            platform_admin,
1514            downscoped: None,
1515        }))
1516    }
1517
1518    pub fn api_token(&self, id: i64) -> AuthResult<ApiToken> {
1519        self.db()
1520            .query_row(
1521                &format!("SELECT {TOKEN_COLS} FROM api_tokens WHERE id = ?1"),
1522                [id],
1523                token_row,
1524            )
1525            .optional()?
1526            .ok_or_else(|| AuthError::NotFound(format!("token {id}")))
1527    }
1528
1529    /// A user's tokens.
1530    pub fn list_api_tokens(&self, user_id: i64) -> AuthResult<Vec<ApiToken>> {
1531        let db = self.db();
1532        let mut st = db.prepare(&format!(
1533            "SELECT {TOKEN_COLS} FROM api_tokens WHERE user_id = ?1 ORDER BY id"
1534        ))?;
1535        let rows = st.query_map([user_id], token_row)?;
1536        Ok(rows.collect::<rusqlite::Result<_>>()?)
1537    }
1538
1539    /// Every token confined to `org`, whoever made it.
1540    pub fn list_org_api_tokens(&self, org: &OrgId) -> AuthResult<Vec<ApiToken>> {
1541        let db = self.db();
1542        let mut st = db.prepare(&format!(
1543            "SELECT {TOKEN_COLS} FROM api_tokens WHERE org = ?1 ORDER BY id"
1544        ))?;
1545        let rows = st.query_map([org.as_str()], token_row)?;
1546        Ok(rows.collect::<rusqlite::Result<_>>()?)
1547    }
1548
1549    /// Every token (the local CLI).
1550    pub fn list_all_api_tokens(&self) -> AuthResult<Vec<ApiToken>> {
1551        let db = self.db();
1552        let mut st = db.prepare(&format!("SELECT {TOKEN_COLS} FROM api_tokens ORDER BY id"))?;
1553        let rows = st.query_map([], token_row)?;
1554        Ok(rows.collect::<rusqlite::Result<_>>()?)
1555    }
1556
1557    /// Delete a token. True if it existed. Authorization is the caller's.
1558    pub fn revoke_api_token(&self, id: i64) -> AuthResult<bool> {
1559        let n = self
1560            .db()
1561            .execute("DELETE FROM api_tokens WHERE id = ?1", [id])?;
1562        Ok(n > 0)
1563    }
1564
1565    // ---- password resets ----
1566
1567    /// A one-hour reset token for `email`, or `None` when there is no such
1568    /// (enabled) user. The caller delivers it, and must answer the same way
1569    /// either way so the endpoint does not reveal who has an account.
1570    pub fn request_password_reset(&self, email: &str) -> AuthResult<Option<String>> {
1571        let key = email.trim().to_lowercase();
1572        self.rate(&self.resets, &key)?;
1573        let Some(user) = self.user_by_email(&key).ok().flatten() else {
1574            return Ok(None);
1575        };
1576        if user.disabled {
1577            return Ok(None);
1578        }
1579        let (token, hash) = secret::new_token(TokenKind::PasswordReset)?;
1580        let now = self.now();
1581        let db = self.db();
1582        // Only the newest link works.
1583        db.execute("DELETE FROM password_resets WHERE user_id = ?1", [user.id])?;
1584        db.execute(
1585            "INSERT INTO password_resets (token_hash, user_id, created_at, expires_at)
1586             VALUES (?1, ?2, ?3, ?4)",
1587            params![hash, user.id, now, now + secs(self.cfg.reset_ttl)],
1588        )?;
1589        Ok(Some(token))
1590    }
1591
1592    /// Set a new password with a reset token. Single use; ends every session.
1593    pub fn reset_password(&self, token: &str, password: &str) -> AuthResult<User> {
1594        if !secret::well_formed(token, TokenKind::PasswordReset) {
1595            return Err(AuthError::InvalidToken("reset link"));
1596        }
1597        let hash_pw = self.hash_pw(password)?;
1598        let hash = secret::hash_token(token);
1599        let now = self.now();
1600        let mut db = self.db();
1601        let tx = db.transaction_with_behavior(TransactionBehavior::Immediate)?;
1602        // (hash, id, user, expires, used)
1603        type ResetRow = (Vec<u8>, i64, i64, i64, Option<i64>);
1604        let found: Option<ResetRow> = tx
1605            .query_row(
1606                "SELECT token_hash, id, user_id, expires_at, used_at FROM password_resets
1607                 WHERE token_hash = ?1",
1608                [&hash],
1609                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
1610            )
1611            .optional()?;
1612        let Some((stored, id, user_id, expires, used)) = found else {
1613            return Err(AuthError::InvalidToken("reset link"));
1614        };
1615        if !secret::ct_eq(&stored, &hash) || used.is_some() || now >= expires {
1616            return Err(AuthError::InvalidToken("reset link"));
1617        }
1618        tx.execute(
1619            "UPDATE password_resets SET used_at = ?2 WHERE id = ?1",
1620            params![id, now],
1621        )?;
1622        tx.execute(
1623            "UPDATE users SET password_hash = ?2 WHERE id = ?1",
1624            params![user_id, hash_pw],
1625        )?;
1626        tx.execute("DELETE FROM sessions WHERE user_id = ?1", [user_id])?;
1627        tx.commit()?;
1628        drop(db);
1629        self.user(user_id)
1630    }
1631}
1632
1633pub(crate) fn ensure_org_tx(conn: &Connection, org: &OrgId, now: i64) -> AuthResult<()> {
1634    conn.execute(
1635        "INSERT INTO orgs (name, created_at) VALUES (?1, ?2) ON CONFLICT (name) DO NOTHING",
1636        params![org.as_str(), now],
1637    )?;
1638    Ok(())
1639}
1640
1641/// An org keeps at least one owner while it has any.
1642fn refuse_last_owner(conn: &Connection, org: &OrgId, user_id: i64, verb: &str) -> AuthResult<()> {
1643    let is_owner: bool = conn
1644        .query_row(
1645            "SELECT role = 'owner' FROM memberships WHERE org = ?1 AND user_id = ?2",
1646            params![org.as_str(), user_id],
1647            |r| r.get(0),
1648        )
1649        .optional()?
1650        .unwrap_or(false);
1651    if !is_owner {
1652        return Ok(());
1653    }
1654    let owners: i64 = conn.query_row(
1655        "SELECT COUNT(*) FROM memberships WHERE org = ?1 AND role = 'owner'",
1656        [org.as_str()],
1657        |r| r.get(0),
1658    )?;
1659    if owners <= 1 {
1660        return Err(AuthError::Conflict(format!(
1661            "cannot {verb} the last owner of org {org}; make someone else owner first"
1662        )));
1663    }
1664    Ok(())
1665}
1666
1667#[cfg(test)]
1668mod tests;