Skip to main content

isb_server/auth/
http.rs

1//! The identity endpoints under `/api/v1/auth/`, JSON in and out, as a
2//! router `isb serve` mounts on its TCP listener next to `/mcp` and
3//! `/healthz`.
4//!
5//! - **Browser sessions** ride in the `isb_session` cookie: HttpOnly,
6//!   SameSite=Lax, Path=/, and Secure unless the request came over plain
7//!   loopback HTTP (no `X-Forwarded-Proto: https`, a loopback `Host`), so
8//!   `http://localhost` development works and anything through a tunnel or a
9//!   TLS proxy gets a Secure cookie.
10//! - **API tokens** ride in `Authorization: Bearer isb_tok_...`. A request
11//!   carrying `Authorization` is judged by it alone; cookies are ignored.
12//! - **CSRF**: every request other than GET/HEAD must carry
13//!   `X-Isb-Csrf: 1`, unless it carries `Authorization: Bearer`. A browser
14//!   sends a custom header cross-origin only after a CORS preflight, which
15//!   isb never grants, so a forged form or fetch from another site is
16//!   refused before it does anything. Login and setup are covered too (login
17//!   CSRF signs a victim into the attacker's account).
18//! - **First-run setup** needs a one-time setup token the daemon writes to
19//!   `<state>/setup-token` (0600) at startup while no user exists, so whoever
20//!   reaches the port first cannot claim the platform. `isb user create
21//!   --admin` on the host is the other way in.
22//! - **External sign-in and passkeys** are in the `external` submodule.
23
24use std::net::IpAddr;
25use std::path::PathBuf;
26use std::sync::{Arc, Mutex};
27
28use serde::Deserialize;
29use serde_json::{Value, json};
30
31use super::oauth::{Provider, ProviderConfig};
32use super::secret::{self, TokenKind};
33use super::webauthn::RelyingParty;
34use super::{AuthError, AuthStore, LoginMeta, NewSession, Principal, Role, ops};
35use crate::org::OrgId;
36use crate::server::http::{Peer, Request, Response};
37
38/// Every endpoint lives under this prefix.
39pub const PREFIX: &str = "/api/v1/auth/";
40/// The session cookie.
41pub const COOKIE: &str = "isb_session";
42/// The anti-CSRF header the web UI sends on every state-changing request.
43pub const CSRF_HEADER: &str = "X-Isb-Csrf";
44
45/// Answers the requests it owns, `None` for the rest.
46pub type Router = crate::server::Routes;
47
48/// Something worth telling a user out of band.
49#[derive(Debug, Clone)]
50pub enum Notice {
51    PasswordReset {
52        email: String,
53        token: String,
54        /// The reset page, when the public URL is known.
55        link: Option<String>,
56    },
57}
58
59/// Delivers a [`Notice`] (email, chat). `Err` is logged, never shown to the
60/// requester, who gets the same answer either way.
61pub type Notifier = Arc<dyn Fn(&Notice) -> Result<(), String> + Send + Sync>;
62
63/// The superadmin behind a request, if any: a superadmin token, or a
64/// listed tailnet or Access identity (the daemon's gate).
65pub type SuperadminFn = Arc<dyn Fn(&Request) -> Option<Arc<super::Superadmin>> + Send + Sync>;
66
67/// The tailnet or Access agent identity behind a request, if an org maps
68/// it (the daemon's gate).
69pub type AgentFn = Arc<dyn Fn(&Request) -> Option<Principal> + Send + Sync>;
70
71#[derive(Clone, Default)]
72pub struct ApiConfig {
73    /// Who is an org's tailnet or Access agent. Asked last, and only for a
74    /// request with no bearer token and no session cookie.
75    pub agent: Option<AgentFn>,
76    /// Which front doors this server has, for `agent_identity_list`.
77    pub agent_ways: super::agent_identities::AgentWays,
78    /// Where users reach isb (`https://isb.example.com`), for the links in
79    /// invitations and resets. Without it the token alone is returned.
80    pub public_url: Option<String>,
81    /// Delivers password resets. Without one, the reset token is written to
82    /// stderr (the daemon's journal) with a note saying so.
83    pub notifier: Option<Notifier>,
84    /// Where the first-run setup token is written while setup is needed.
85    pub setup_token_file: Option<PathBuf>,
86    /// External sign-in providers (they need `public_url` for their
87    /// callback URL).
88    pub providers: Vec<ProviderConfig>,
89    /// Let anyone with a verified email from a provider make an account.
90    /// Off: after the first admin, accounts come by invitation.
91    pub open_signup: bool,
92    /// Where sign-ins, token, invitation, member and user changes are
93    /// recorded.
94    pub audit: Option<Arc<crate::audit::AuditLog>>,
95    /// Who is a superadmin. A superadmin is signed in as its principal
96    /// (ahead of any session cookie) and is a platform admin here.
97    pub superadmin: Option<SuperadminFn>,
98}
99
100impl std::fmt::Debug for ApiConfig {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.debug_struct("ApiConfig")
103            .field("public_url", &self.public_url)
104            .field("notifier", &self.notifier.is_some())
105            .field("setup_token_file", &self.setup_token_file)
106            .field("providers", &self.providers)
107            .field("open_signup", &self.open_signup)
108            .field("audit", &self.audit.is_some())
109            .field("superadmin", &self.superadmin.is_some())
110            .field("agent", &self.agent.is_some())
111            .finish()
112    }
113}
114
115/// The endpoints, over one store.
116pub struct AuthApi {
117    store: Arc<AuthStore>,
118    cfg: ApiConfig,
119    /// SHA-256 of the pending setup token, while setup is needed.
120    setup: Mutex<Option<Vec<u8>>>,
121    #[cfg(test)]
122    setup_plain: Mutex<Option<String>>,
123    providers: Vec<Arc<Provider>>,
124    /// Passkeys' relying party, from `public_url`.
125    rp: Option<RelyingParty>,
126    flows: external::Pending<external::Flow>,
127    challenges: external::Pending<external::Challenge>,
128}
129
130impl std::fmt::Debug for AuthApi {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        f.debug_struct("AuthApi").field("cfg", &self.cfg).finish()
133    }
134}
135
136impl AuthApi {
137    /// Build the endpoints. While no user exists, this mints the one-time
138    /// setup token and writes it to `cfg.setup_token_file`.
139    pub fn new(store: Arc<AuthStore>, cfg: ApiConfig) -> Result<AuthApi, AuthError> {
140        let public = cfg
141            .public_url
142            .as_deref()
143            .map(|u| u.trim().trim_end_matches('/').to_string())
144            .filter(|u| !u.is_empty());
145        let rp = match public.as_deref().map(RelyingParty::from_public_url) {
146            Some(Ok(rp)) => Some(rp),
147            Some(Err(e)) => {
148                eprintln!("isb serve: passkeys are off: {e}");
149                None
150            }
151            None => None,
152        };
153        let providers: Vec<Arc<Provider>> = match &public {
154            Some(_) => {
155                let st = store.clone();
156                let clock: super::Clock = Arc::new(move || st.now());
157                cfg.providers
158                    .iter()
159                    .map(|p| Arc::new(Provider::new(p.clone(), clock.clone())))
160                    .collect()
161            }
162            None => {
163                if !cfg.providers.is_empty() {
164                    eprintln!(
165                        "isb serve: sign-in with providers is off: it needs ISB_PUBLIC_URL for the callback URL"
166                    );
167                }
168                Vec::new()
169            }
170        };
171        for p in &providers {
172            eprintln!(
173                "isb serve: sign-in with {}: callback URL {}{PREFIX}oauth/{}/callback",
174                p.cfg.label,
175                public.as_deref().unwrap_or(""),
176                p.cfg.id
177            );
178        }
179        let api = AuthApi {
180            store,
181            cfg,
182            setup: Mutex::new(None),
183            #[cfg(test)]
184            setup_plain: Mutex::new(None),
185            providers,
186            rp,
187            flows: Default::default(),
188            challenges: Default::default(),
189        };
190        if api.store.setup_needed()? {
191            let (token, hash) = secret::new_token(TokenKind::Setup)?;
192            match &api.cfg.setup_token_file {
193                Some(p) => {
194                    write_secret_file(p, &token)?;
195                    eprintln!(
196                        "isb serve: first-run setup is open: the setup token is in {} \
197                         (or create the first admin with `isb user create EMAIL --admin`)",
198                        p.display()
199                    );
200                }
201                None => eprintln!(
202                    "isb serve: first-run setup needs `isb user create EMAIL --admin` on this host"
203                ),
204            }
205            *api.setup.lock().unwrap_or_else(|e| e.into_inner()) = Some(hash);
206            #[cfg(test)]
207            {
208                *api.setup_plain.lock().unwrap() = Some(token);
209            }
210        } else {
211            api.forget_setup_file();
212        }
213        Ok(api)
214    }
215
216    pub fn store(&self) -> &Arc<AuthStore> {
217        &self.store
218    }
219
220    #[cfg(test)]
221    pub(crate) fn setup_token(&self) -> Option<String> {
222        self.setup_plain.lock().unwrap().clone()
223    }
224
225    /// This API as a [`Router`].
226    pub fn router(self: Arc<Self>) -> Router {
227        Arc::new(move |r: &Request| self.handle(r))
228    }
229
230    /// The caller behind `req`, if any: a superadmin (whose principal is
231    /// its isb user's, or synthetic), else [`AuthStore::principal_from_request`].
232    pub fn principal(&self, req: &Request) -> Option<Principal> {
233        if let Some(s) = self.cfg.superadmin.as_ref().and_then(|f| f(req)) {
234            return Some(s.principal.clone());
235        }
236        if let Some(p) = self.store.principal_from_request(req) {
237            return Some(p);
238        }
239        self.agent_principal(req)
240    }
241
242    /// Answer `req` if its path is under [`PREFIX`].
243    pub fn handle(&self, req: &Request) -> Option<Response> {
244        let rest = req
245            .path
246            .strip_prefix(PREFIX)
247            .or_else(|| (req.path == PREFIX.trim_end_matches('/')).then_some(""))?;
248        let r = self.route(req, rest);
249        Some(r.header("Cache-Control", "no-store"))
250    }
251
252    fn route(&self, req: &Request, rest: &str) -> Response {
253        let m = req.method.as_str();
254        if !matches!(m, "GET" | "HEAD") && !csrf_ok(req) {
255            return error_response(
256                403,
257                "csrf",
258                &format!("state-changing requests need the {CSRF_HEADER}: 1 header"),
259            );
260        }
261        let seg: Vec<&str> = rest.split('/').collect();
262        let writes = !matches!(m, "GET" | "HEAD");
263        let audited = writes || matches!(seg.as_slice(), ["oauth", _, "callback"]);
264        // Who was there before the request (a sign-out ends the session),
265        // and what a revocation is about to remove.
266        let before = audited.then(|| self.principal(req)).flatten();
267        let restricted = writes && before.as_ref().is_some_and(|p| p.restricted());
268        let token_org = match (m, seg.as_slice()) {
269            ("DELETE", ["tokens", id]) => id
270                .parse()
271                .ok()
272                .and_then(|id| self.store.api_token(id).ok())
273                .and_then(|t| t.org),
274            _ => None,
275        };
276        NOTED.with(|n| n.set(None));
277        let resp = if restricted {
278            error_response(
279                403,
280                "forbidden",
281                "this token's scopes do not cover changing accounts, tokens or members (it needs admin)",
282            )
283        } else {
284            self.dispatch(req, m, &seg)
285        };
286        if audited {
287            if let Some(log) = &self.cfg.audit {
288                for e in self.audit_entries(req, &seg, &resp, before.as_ref(), token_org) {
289                    if let Err(err) = log.append(e) {
290                        eprintln!("isb serve: {err}");
291                    }
292                }
293            }
294        }
295        resp
296    }
297
298    fn dispatch(&self, req: &Request, m: &str, seg: &[&str]) -> Response {
299        let r = match (m, seg) {
300            ("GET", ["setup"]) => self.get_setup(),
301            ("POST", ["setup"]) => self.post_setup(req),
302            ("POST", ["login"]) => self.login(req),
303            ("POST", ["logout"]) => self.logout(req),
304            ("GET", ["me"]) => self.with_principal(req, |p| self.me(p)),
305            ("GET", ["sessions"]) => self.with_principal(req, |p| self.sessions(p)),
306            ("DELETE", ["sessions", id]) => {
307                self.with_principal(req, |p| self.delete_session(p, id))
308            }
309            ("POST", ["invitations"]) => self.with_principal(req, |p| self.invite(req, p)),
310            ("POST", ["invitations", "inspect"]) => self.inspect_invitation(req),
311            ("POST", ["invitations", "accept"]) => self.accept(req),
312            ("GET", ["tokens"]) => self.with_principal(req, |p| self.tokens(p)),
313            ("POST", ["tokens"]) => self.with_principal(req, |p| self.create_token(req, p)),
314            ("DELETE", ["tokens", id]) => self.with_principal(req, |p| self.delete_token(p, id)),
315            ("GET", ["ssh-keys"]) => self.with_principal(req, |p| self.ssh_keys(p)),
316            ("POST", ["ssh-keys"]) => self.with_principal(req, |p| self.add_ssh_key(req, p)),
317            ("DELETE", ["ssh-keys", id]) => {
318                self.with_principal(req, |p| self.delete_ssh_key(p, id))
319            }
320            ("POST", ["password"]) => self.with_principal(req, |p| self.password(req, p)),
321            ("POST", ["password-reset", "request"]) => self.reset_request(req),
322            ("POST", ["password-reset", "confirm"]) => self.reset_confirm(req),
323            ("GET", ["providers"]) => self.providers_list(),
324            ("GET", ["oauth", p, "start"]) => return self.oauth_start_get(req, p),
325            ("POST", ["oauth", p, "start"]) => self.oauth_start_post(req, p),
326            ("GET", ["oauth", p, "callback"]) => return self.oauth_callback(req, p),
327            ("GET", ["identities"]) => self.with_principal(req, |p| self.identities(p)),
328            ("DELETE", ["identities", id]) => {
329                self.with_principal(req, |p| self.delete_identity(p, id))
330            }
331            ("GET", ["passkeys"]) => self.with_principal(req, |p| self.passkeys(p)),
332            ("DELETE", ["passkeys", id]) => {
333                self.with_principal(req, |p| self.delete_passkey(p, id))
334            }
335            ("POST", ["passkeys", "register", "options"]) => {
336                self.with_principal(req, |p| self.passkey_register_options(p))
337            }
338            ("POST", ["passkeys", "register", "verify"]) => {
339                self.with_principal(req, |p| self.passkey_register_verify(req, p))
340            }
341            ("POST", ["passkeys", "login", "options"]) => self.passkey_login_options(req),
342            ("POST", ["passkeys", "login", "verify"]) => self.passkey_login_verify(req),
343            ("GET", ["admin", "users"]) => self.with_principal(req, |p| self.admin_users(p)),
344            ("PATCH", ["admin", "users", id]) => {
345                self.with_principal(req, |p| self.admin_user_update(req, p, id))
346            }
347            (_, ["orgs", org, rest @ ..]) => {
348                let org = match OrgId::new(*org) {
349                    Ok(o) => o,
350                    Err(e) => return error_response(400, "invalid", &e.to_string()),
351                };
352                self.with_principal(req, |p| self.org_route(req, p, &org, rest))
353            }
354            _ => return not_found_or_405(seg),
355        };
356        match r {
357            Ok(resp) => resp,
358            Err(e) => auth_error(e),
359        }
360    }
361
362    /// What a state-changing request did, as audit rows (usually one).
363    #[expect(
364        clippy::too_many_lines,
365        reason = "predates the lint ratchet; split it when next changed"
366    )]
367    fn audit_entries(
368        &self,
369        req: &Request,
370        seg: &[&str],
371        resp: &Response,
372        before: Option<&Principal>,
373        token_org: Option<OrgId>,
374    ) -> Vec<crate::audit::NewEntry> {
375        use crate::audit::{Actor, NewEntry, Origin};
376        let m = req.method.as_str();
377        let body: Value = serde_json::from_slice(&req.body).unwrap_or(Value::Null);
378        let answer: Value = serde_json::from_slice(&resp.body).unwrap_or(Value::Null);
379        let field = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).map(String::from);
380        let mut outcome = if resp.status < 400 {
381            "ok".to_string()
382        } else {
383            field(&answer, "error").unwrap_or_else(|| format!("http_{}", resp.status))
384        };
385        let mut details = serde_json::Map::new();
386        let (action, org, target): (&str, Option<String>, Option<String>) = match (m, seg) {
387            ("POST", ["setup"]) => ("auth.setup", None, field(&body, "email")),
388            ("POST", ["login"]) => {
389                details.insert("method".into(), json!("password"));
390                ("auth.login", None, None)
391            }
392            ("POST", ["logout"]) => ("auth.logout", None, None),
393            ("DELETE", ["sessions", id]) => ("auth.session_revoke", None, Some(id.to_string())),
394            ("POST", ["invitations"]) => {
395                if let Some(r) = field(&body, "role") {
396                    details.insert("role".into(), json!(r));
397                }
398                (
399                    "auth.invitation_create",
400                    field(&body, "org"),
401                    field(&body, "email"),
402                )
403            }
404            ("POST", ["invitations", "accept"]) => (
405                "auth.invitation_accept",
406                answer["membership"]["org"].as_str().map(String::from),
407                None,
408            ),
409            ("POST", ["tokens"]) => {
410                if let Some(id) = answer["info"]["id"].as_i64() {
411                    details.insert("id".into(), json!(id));
412                }
413                if let Some(s) = body.get("scopes").and_then(Value::as_array) {
414                    details.insert("scopes_count".into(), json!(s.len()));
415                }
416                (
417                    "auth.token_create",
418                    field(&body, "org"),
419                    field(&body, "name"),
420                )
421            }
422            ("DELETE", ["tokens", id]) => (
423                "auth.token_revoke",
424                token_org.map(|o| o.to_string()),
425                Some(id.to_string()),
426            ),
427            ("POST", ["ssh-keys"]) => {
428                if let Some(id) = answer["ssh_key"]["id"].as_i64() {
429                    details.insert("id".into(), json!(id));
430                }
431                if let Some(a) = answer["ssh_key"]["algorithm"].as_str() {
432                    details.insert("kind".into(), json!(a));
433                }
434                (
435                    "auth.ssh_key_add",
436                    None,
437                    answer["ssh_key"]["fingerprint"].as_str().map(String::from),
438                )
439            }
440            ("DELETE", ["ssh-keys", id]) => ("auth.ssh_key_remove", None, Some(id.to_string())),
441            ("POST", ["password"]) => ("auth.password_change", None, None),
442            ("POST", ["password-reset", "request"]) => {
443                ("auth.password_reset_request", None, field(&body, "email"))
444            }
445            ("POST", ["password-reset", "confirm"]) => ("auth.password_reset", None, None),
446            ("GET", ["oauth", p, "callback"]) => {
447                details.insert("provider".into(), json!(p));
448                let loc = resp.get_header("location").unwrap_or("");
449                if let Some(code) = loc
450                    .split(['?', '&'])
451                    .find_map(|kv| kv.strip_prefix("error="))
452                {
453                    outcome = code.to_string();
454                }
455                let session_set = resp.headers.iter().any(|(k, v)| {
456                    k.eq_ignore_ascii_case("set-cookie") && v.starts_with("isb_session=")
457                });
458                let link = !session_set && before.is_some();
459                (
460                    if link {
461                        "auth.identity_link"
462                    } else {
463                        "auth.login"
464                    },
465                    None,
466                    None,
467                )
468            }
469            ("DELETE", ["identities", id]) => ("auth.identity_unlink", None, Some(id.to_string())),
470            ("POST", ["passkeys", "register", "verify"]) => (
471                "auth.passkey_add",
472                None,
473                answer["passkey"]["id"].as_i64().map(|i| i.to_string()),
474            ),
475            ("DELETE", ["passkeys", id]) => ("auth.passkey_remove", None, Some(id.to_string())),
476            ("POST", ["passkeys", "login", "verify"]) => {
477                details.insert("method".into(), json!("passkey"));
478                ("auth.login", None, None)
479            }
480            ("PATCH", ["admin", "users", id]) => {
481                // One row per change asked for.
482                let base = |action: &str| NewEntry {
483                    org: None,
484                    actor: before.map(Actor::from_principal).unwrap_or_default(),
485                    origin: Origin::default(),
486                    action: action.into(),
487                    target: Some(id.to_string()),
488                    details: json!({"email": answer["user"]["email"]}),
489                    outcome: outcome.clone(),
490                };
491                let mut out = Vec::new();
492                match body.get("disabled").and_then(Value::as_bool) {
493                    Some(true) => out.push(base("auth.user_disable")),
494                    Some(false) => out.push(base("auth.user_enable")),
495                    None => {}
496                }
497                match body.get("platform_admin").and_then(Value::as_bool) {
498                    Some(true) => out.push(base("auth.platform_admin_grant")),
499                    Some(false) => out.push(base("auth.platform_admin_revoke")),
500                    None => {}
501                }
502                let origin = self.origin(req);
503                return out
504                    .into_iter()
505                    .map(|mut e| {
506                        e.origin = origin.clone();
507                        e
508                    })
509                    .collect();
510            }
511            ("PUT", ["orgs", org, "members", uid]) => {
512                if let Some(r) = field(&body, "role") {
513                    details.insert("role".into(), json!(r));
514                }
515                (
516                    "auth.role_change",
517                    Some(org.to_string()),
518                    Some(uid.to_string()),
519                )
520            }
521            ("DELETE", ["orgs", org, "members", uid]) => (
522                "auth.member_remove",
523                Some(org.to_string()),
524                Some(uid.to_string()),
525            ),
526            ("PUT", ["orgs", org, "agent-identities"]) => {
527                for k in ["kind", "role"] {
528                    if let Some(v) = field(&body, k) {
529                        details.insert(k.into(), json!(v));
530                    }
531                }
532                (
533                    "auth.agent_identity_set",
534                    Some(org.to_string()),
535                    answer["identity"]["subject"].as_str().map(String::from),
536                )
537            }
538            ("DELETE", ["orgs", org, "agent-identities", id]) => (
539                "auth.agent_identity_remove",
540                Some(org.to_string()),
541                Some(id.to_string()),
542            ),
543            ("DELETE", ["orgs", org, "invitations", id]) => (
544                "auth.invitation_revoke",
545                Some(org.to_string()),
546                Some(id.to_string()),
547            ),
548            _ => return Vec::new(),
549        };
550        // Who: the user a sign-in signed in, else who was signed in, else
551        // the address someone claimed.
552        let noted = NOTED.with(|n| n.take());
553        let actor = match (noted.and_then(|id| self.store.user(id).ok()), before) {
554            (Some(u), _) => Actor {
555                name: u.email.clone(),
556                kind: Some(crate::audit::ActorKind::Person),
557                user_id: Some(u.id),
558                email: Some(u.email),
559                ..Default::default()
560            },
561            (None, Some(p)) => Actor::from_principal(p),
562            (None, None) => match field(&body, "email") {
563                Some(e) => Actor::claimed(&e),
564                None => Actor::anonymous("anonymous"),
565            },
566        };
567        vec![NewEntry {
568            org,
569            actor,
570            origin: self.origin(req),
571            action: action.into(),
572            target,
573            details: Value::Object(details),
574            outcome,
575        }]
576    }
577
578    fn origin(&self, req: &Request) -> crate::audit::Origin {
579        let bearer = req.header("authorization").is_some();
580        crate::audit::Origin {
581            surface: if bearer { "rest" } else { "web" }.into(),
582            ip: client_ip(req),
583            user_agent: req.header("user-agent").map(String::from),
584            request_id: req
585                .header("x-request-id")
586                .or_else(|| req.header("cf-ray"))
587                .filter(|s| s.len() <= 64)
588                .map(String::from),
589        }
590    }
591
592    fn with_principal(
593        &self,
594        req: &Request,
595        f: impl FnOnce(&Principal) -> Result<Response, AuthError>,
596    ) -> Result<Response, AuthError> {
597        match self.principal(req) {
598            Some(p) => f(&p),
599            None => Ok(error_response(401, "unauthenticated", "sign in first")),
600        }
601    }
602
603    // ---- setup ----
604
605    fn get_setup(&self) -> Result<Response, AuthError> {
606        let needed = self.store.setup_needed()?;
607        if !needed {
608            self.forget_setup_file();
609        }
610        Ok(Response::json(200, &json!({"needed": needed})))
611    }
612
613    fn post_setup(&self, req: &Request) -> Result<Response, AuthError> {
614        #[derive(Deserialize)]
615        struct B {
616            setup_token: String,
617            email: String,
618            #[serde(default)]
619            name: String,
620            password: String,
621        }
622        self.store.limit_ip(client_ip(req).as_deref())?;
623        let b: B = body(req)?;
624        if !self.store.setup_needed()? {
625            self.forget_setup_file();
626            return Err(AuthError::Conflict("setup is already done".into()));
627        }
628        let ok = self
629            .setup
630            .lock()
631            .unwrap_or_else(|e| e.into_inner())
632            .as_ref()
633            .is_some_and(|h| {
634                secret::well_formed(&b.setup_token, TokenKind::Setup)
635                    && secret::ct_eq(h, &secret::hash_token(&b.setup_token))
636            });
637        if !ok {
638            return Err(AuthError::InvalidToken("setup token"));
639        }
640        let user = self
641            .store
642            .create_first_admin(&b.email, &b.name, &b.password)?;
643        *self.setup.lock().unwrap_or_else(|e| e.into_inner()) = None;
644        self.forget_setup_file();
645        eprintln!(
646            "isb serve: first-run setup done: {} is platform admin",
647            user.email
648        );
649        let s = self.store.start_session(user.id, meta(req))?;
650        self.session_response(req, 201, &s)
651    }
652
653    fn forget_setup_file(&self) {
654        if let Some(p) = &self.cfg.setup_token_file {
655            let _ = std::fs::remove_file(p);
656        }
657    }
658
659    // ---- sessions ----
660
661    fn login(&self, req: &Request) -> Result<Response, AuthError> {
662        #[derive(Deserialize)]
663        struct B {
664            email: String,
665            password: String,
666        }
667        let b: B = body(req)?;
668        let s = self.store.login(&b.email, &b.password, meta(req))?;
669        self.session_response(req, 200, &s)
670    }
671
672    /// The user, their orgs and the session, with the cookie set.
673    fn session_response(
674        &self,
675        req: &Request,
676        status: u16,
677        s: &NewSession,
678    ) -> Result<Response, AuthError> {
679        let user = self.store.user(s.session.user_id)?;
680        note_user(user.id);
681        let memberships = self.store.memberships(user.id)?;
682        let max_age = (s.session.expires_at - self.store.now()).max(0);
683        Ok(Response::json(
684            status,
685            &json!({
686                "user": user,
687                "memberships": memberships,
688                "session": {
689                    "id": s.session.id,
690                    "expires_at": s.session.expires_at,
691                    "idle_expires_at": s.session.idle_expires_at,
692                },
693            }),
694        )
695        .header("Set-Cookie", session_cookie(req, &s.token, max_age)))
696    }
697
698    fn logout(&self, req: &Request) -> Result<Response, AuthError> {
699        if req.header("authorization").is_none() {
700            if let Some(t) = cookie(req, COOKIE) {
701                self.store.logout(&t)?;
702            }
703        }
704        Ok(Response::new(204).header("Set-Cookie", session_cookie(req, "", 0)))
705    }
706
707    fn me(&self, p: &Principal) -> Result<Response, AuthError> {
708        Ok(Response::json(200, &ops::me(&self.store, p)?))
709    }
710
711    fn sessions(&self, p: &Principal) -> Result<Response, AuthError> {
712        Ok(Response::json(200, &ops::sessions(&self.store, p)?))
713    }
714
715    fn delete_session(&self, p: &Principal, id: &str) -> Result<Response, AuthError> {
716        ops::revoke_session(&self.store, p, parse_id(id)?)?;
717        Ok(Response::new(204))
718    }
719
720    // ---- invitations ----
721
722    fn invite(&self, req: &Request, p: &Principal) -> Result<Response, AuthError> {
723        #[derive(Deserialize)]
724        struct B {
725            org: OrgId,
726            email: String,
727            #[serde(default)]
728            role: Option<Role>,
729        }
730        let b: B = body(req)?;
731        let public = self.cfg.public_url.as_deref();
732        let v = ops::invite(&self.store, p, &b.org, &b.email, b.role, public)?;
733        Ok(Response::json(201, &v))
734    }
735
736    fn inspect_invitation(&self, req: &Request) -> Result<Response, AuthError> {
737        #[derive(Deserialize)]
738        struct B {
739            token: String,
740        }
741        self.store.limit_ip(client_ip(req).as_deref())?;
742        let b: B = body(req)?;
743        let inv = self
744            .store
745            .invitation(&b.token)?
746            .ok_or(AuthError::InvalidToken("invitation"))?;
747        let exists = self.store.user_by_email(&inv.email)?.is_some();
748        Ok(Response::json(
749            200,
750            &json!({
751                "org": inv.org,
752                "email": inv.email,
753                "role": inv.role,
754                "expires_at": inv.expires_at,
755                "account_exists": exists,
756            }),
757        ))
758    }
759
760    fn accept(&self, req: &Request) -> Result<Response, AuthError> {
761        #[derive(Deserialize)]
762        struct B {
763            token: String,
764            #[serde(default)]
765            name: String,
766            #[serde(default)]
767            password: Option<String>,
768        }
769        self.store.limit_ip(client_ip(req).as_deref())?;
770        let b: B = body(req)?;
771        if let Some(p) = self.principal(req) {
772            let a = self.store.accept_invitation_as(&b.token, p.user.id)?;
773            note_user(a.user.id);
774            return Ok(Response::json(
775                200,
776                &json!({"user": a.user, "membership": a.membership, "created": false}),
777            ));
778        }
779        let pw = b
780            .password
781            .ok_or_else(|| AuthError::Invalid("password is required".into()))?;
782        let a = self.store.accept_invitation(&b.token, &b.name, &pw)?;
783        note_user(a.user.id);
784        let s = self.store.start_session(a.user.id, meta(req))?;
785        let max_age = (s.session.expires_at - self.store.now()).max(0);
786        Ok(Response::json(
787            200,
788            &json!({"user": a.user, "membership": a.membership, "created": a.created}),
789        )
790        .header("Set-Cookie", session_cookie(req, &s.token, max_age)))
791    }
792
793    // ---- API tokens ----
794
795    fn tokens(&self, p: &Principal) -> Result<Response, AuthError> {
796        Ok(Response::json(200, &ops::tokens(&self.store, p, None)?))
797    }
798
799    fn create_token(&self, req: &Request, p: &Principal) -> Result<Response, AuthError> {
800        let v = ops::create_token(&self.store, p, body(req)?)?;
801        Ok(Response::json(201, &v))
802    }
803
804    fn delete_token(&self, p: &Principal, id: &str) -> Result<Response, AuthError> {
805        ops::revoke_token(&self.store, p, parse_id(id)?)?;
806        Ok(Response::new(204))
807    }
808
809    // ---- SSH keys ----
810
811    fn ssh_keys(&self, p: &Principal) -> Result<Response, AuthError> {
812        Ok(Response::json(200, &ops::ssh_keys(&self.store, p)?))
813    }
814
815    fn add_ssh_key(&self, req: &Request, p: &Principal) -> Result<Response, AuthError> {
816        #[derive(Deserialize)]
817        struct B {
818            public_key: String,
819            #[serde(default)]
820            name: Option<String>,
821        }
822        let b: B = body(req)?;
823        let v = ops::add_ssh_key(&self.store, p, &b.public_key, b.name.as_deref())?;
824        Ok(Response::json(201, &v))
825    }
826
827    fn delete_ssh_key(&self, p: &Principal, id: &str) -> Result<Response, AuthError> {
828        ops::delete_ssh_key(&self.store, p, parse_id(id)?)?;
829        Ok(Response::new(204))
830    }
831
832    // ---- passwords ----
833
834    fn password(&self, req: &Request, p: &Principal) -> Result<Response, AuthError> {
835        #[derive(Deserialize)]
836        struct B {
837            current_password: String,
838            new_password: String,
839        }
840        let Some(sid) = p.session_id() else {
841            return Err(AuthError::Forbidden(
842                "change a password from a signed-in session, not with an API token".into(),
843            ));
844        };
845        let b: B = body(req)?;
846        self.store
847            .change_password(p.user.id, &b.current_password, &b.new_password, Some(sid))?;
848        Ok(Response::new(204))
849    }
850
851    fn reset_request(&self, req: &Request) -> Result<Response, AuthError> {
852        #[derive(Deserialize)]
853        struct B {
854            email: String,
855        }
856        self.store.limit_ip(client_ip(req).as_deref())?;
857        let b: B = body(req)?;
858        if let Some(token) = self.store.request_password_reset(&b.email)? {
859            let email = b.email.trim().to_lowercase();
860            let link = self.link("reset-password", &token);
861            let notice = Notice::PasswordReset {
862                email: email.clone(),
863                token: token.clone(),
864                link: link.clone(),
865            };
866            match &self.cfg.notifier {
867                Some(n) => {
868                    if let Err(e) = n(&notice) {
869                        eprintln!("isb serve: password reset for {email}: delivery failed: {e}");
870                    }
871                }
872                None => eprintln!(
873                    "isb serve: password reset for {email} (no mailer is configured, so it is \
874                     logged here; hand it over yourself): {}",
875                    link.unwrap_or(token)
876                ),
877            }
878        }
879        // The same answer whether or not the account exists.
880        Ok(Response::json(202, &json!({"ok": true})))
881    }
882
883    fn reset_confirm(&self, req: &Request) -> Result<Response, AuthError> {
884        #[derive(Deserialize)]
885        struct B {
886            token: String,
887            password: String,
888        }
889        self.store.limit_ip(client_ip(req).as_deref())?;
890        let b: B = body(req)?;
891        let u = self.store.reset_password(&b.token, &b.password)?;
892        note_user(u.id);
893        Ok(Response::new(204))
894    }
895
896    // ---- org administration ----
897
898    // ---- platform administration ----
899
900    /// Every user, with their orgs and when they were last active.
901    fn admin_users(&self, p: &Principal) -> Result<Response, AuthError> {
902        Ok(Response::json(200, &ops::users(&self.store, p)?))
903    }
904
905    fn admin_user_update(
906        &self,
907        req: &Request,
908        p: &Principal,
909        id: &str,
910    ) -> Result<Response, AuthError> {
911        let id = parse_id(id)?;
912        let b: ops::UserChange = body(req)?;
913        Ok(Response::json(
914            200,
915            &ops::update_user(&self.store, p, id, &b)?,
916        ))
917    }
918
919    fn link(&self, page: &str, token: &str) -> Option<String> {
920        ops::link(self.cfg.public_url.as_deref(), page, token)
921    }
922}
923
924impl AuthStore {
925    /// The caller behind an HTTP request: `Authorization: Bearer isb_tok_...`
926    /// (an API token) when that header is present, else the `isb_session`
927    /// cookie. A bad `Authorization` never falls back to the cookie.
928    pub fn principal_from_request(&self, req: &Request) -> Option<Principal> {
929        let r = if let Some(a) = req.header("authorization") {
930            let (scheme, token) = a.trim().split_once(' ')?;
931            if !scheme.eq_ignore_ascii_case("bearer") {
932                return None;
933            }
934            self.authenticate_token(token.trim())
935        } else {
936            let t = cookie(req, COOKIE)?;
937            self.authenticate_session(&t)
938        };
939        r.unwrap_or_else(|e| {
940            eprintln!("isb serve: authentication failed: {e}");
941            None
942        })
943    }
944}
945
946thread_local! {
947    /// The user a request signed in (or reset, or accepted as), for its
948    /// audit row: handlers run on the request's thread.
949    static NOTED: std::cell::Cell<Option<i64>> = const { std::cell::Cell::new(None) };
950}
951
952pub(crate) fn note_user(id: i64) {
953    NOTED.with(|n| n.set(Some(id)));
954}
955
956fn csrf_ok(req: &Request) -> bool {
957    let bearer = req
958        .header("authorization")
959        .and_then(|a| a.trim().split_once(' '))
960        .is_some_and(|(s, _)| s.eq_ignore_ascii_case("bearer"));
961    bearer || req.header(CSRF_HEADER).is_some_and(|v| v.trim() == "1")
962}
963
964/// The value of cookie `name`, if sent.
965pub fn cookie(req: &Request, name: &str) -> Option<String> {
966    req.headers
967        .iter()
968        .filter(|(k, _)| k.eq_ignore_ascii_case("cookie"))
969        .flat_map(|(_, v)| v.split(';'))
970        .filter_map(|c| c.trim().split_once('='))
971        .find(|(k, _)| *k == name)
972        .map(|(_, v)| v.trim().trim_matches('"').to_string())
973        .filter(|v| !v.is_empty())
974}
975
976/// True when the request came straight to loopback over plain HTTP: a
977/// loopback peer, a loopback `Host`, and no proxy saying it was HTTPS.
978pub fn plain_loopback_http(req: &Request) -> bool {
979    let peer_local = match &req.peer {
980        Peer::Tcp(a) => a.ip().is_loopback(),
981        Peer::Unix { .. } => true,
982    };
983    let forwarded_https = req
984        .header("x-forwarded-proto")
985        .is_some_and(|p| p.trim().eq_ignore_ascii_case("https"))
986        || req
987            .header("cf-visitor")
988            .is_some_and(|v| v.contains("\"https\""));
989    let host = req.header("host").unwrap_or("");
990    let host_name = if host.starts_with('[') {
991        host.split(']')
992            .next()
993            .map(|h| format!("{h}]"))
994            .unwrap_or_default()
995    } else {
996        host.split(':').next().unwrap_or("").to_string()
997    };
998    let host_local = host_name.eq_ignore_ascii_case("localhost")
999        || host_name
1000            .trim_matches(['[', ']'])
1001            .parse::<IpAddr>()
1002            .is_ok_and(|ip| ip.is_loopback());
1003    peer_local && host_local && !forwarded_https
1004}
1005
1006/// `Set-Cookie` for the session (an empty token with max-age 0 clears it).
1007pub fn session_cookie(req: &Request, token: &str, max_age: i64) -> String {
1008    let secure = if plain_loopback_http(req) {
1009        ""
1010    } else {
1011        "; Secure"
1012    };
1013    format!("{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}")
1014}
1015
1016/// The client's address: `Cf-Connecting-IP` when the peer is loopback (the
1017/// tunnel; Cloudflare sets that header and clients cannot), else the peer.
1018pub fn client_ip(req: &Request) -> Option<String> {
1019    match &req.peer {
1020        Peer::Tcp(a) if a.ip().is_loopback() => Some(
1021            req.header("cf-connecting-ip")
1022                .map(|s| s.trim().to_string())
1023                .filter(|s| s.parse::<IpAddr>().is_ok())
1024                .unwrap_or_else(|| a.ip().to_string()),
1025        ),
1026        Peer::Tcp(a) => Some(a.ip().to_string()),
1027        Peer::Unix { .. } => None,
1028    }
1029}
1030
1031fn meta(req: &Request) -> LoginMeta {
1032    LoginMeta {
1033        user_agent: req.header("user-agent").map(str::to_string),
1034        ip: client_ip(req),
1035    }
1036}
1037
1038fn body<T: serde::de::DeserializeOwned>(req: &Request) -> Result<T, AuthError> {
1039    serde_json::from_slice(&req.body).map_err(|e| AuthError::Invalid(format!("request body: {e}")))
1040}
1041
1042fn parse_id(s: &str) -> Result<i64, AuthError> {
1043    s.parse()
1044        .map_err(|_| AuthError::Invalid(format!("{s:?} is not an id")))
1045}
1046
1047fn write_secret_file(p: &std::path::Path, content: &str) -> Result<(), AuthError> {
1048    use std::io::Write;
1049    use std::os::unix::fs::OpenOptionsExt;
1050    let _ = std::fs::remove_file(p);
1051    let mut f = std::fs::OpenOptions::new()
1052        .write(true)
1053        .create_new(true)
1054        .mode(0o600)
1055        .open(p)
1056        .map_err(|e| AuthError::io(format!("write {}", p.display()), e))?;
1057    f.write_all(format!("{content}\n").as_bytes())
1058        .map_err(|e| AuthError::io(format!("write {}", p.display()), e))
1059}
1060
1061fn error_response(status: u16, code: &str, message: &str) -> Response {
1062    Response::json(status, &json!({"error": code, "message": message}))
1063}
1064
1065fn auth_error(e: AuthError) -> Response {
1066    let (status, code) = match &e {
1067        AuthError::InvalidCredentials => (401, "invalid_credentials"),
1068        AuthError::InvalidToken(_) => (400, "invalid_token"),
1069        AuthError::RateLimited { .. } => (429, "rate_limited"),
1070        AuthError::Forbidden(_) => (403, "forbidden"),
1071        AuthError::NotFound(_) => (404, "not_found"),
1072        AuthError::Conflict(_) => (409, "conflict"),
1073        AuthError::Invalid(_) => (400, "invalid"),
1074        AuthError::Refused { code, .. } => (403, *code),
1075        AuthError::PasskeyRejected(_) => (401, "passkey_rejected"),
1076        AuthError::Internal(_) | AuthError::Db(_) => {
1077            eprintln!("isb serve: auth: {e}");
1078            return error_response(500, "internal", "internal error");
1079        }
1080    };
1081    let r = error_response(status, code, &e.to_string());
1082    match e {
1083        AuthError::RateLimited { retry_after } => r.header("Retry-After", retry_after.to_string()),
1084        _ => r,
1085    }
1086}
1087
1088fn not_found_or_405(seg: &[&str]) -> Response {
1089    let allow = match seg {
1090        ["setup"] => "GET, POST",
1091        ["login" | "logout" | "invitations" | "password"] => "POST",
1092        ["invitations" | "password-reset", _] => "POST",
1093        ["me" | "sessions" | "providers" | "identities" | "passkeys"] => "GET",
1094        ["tokens" | "ssh-keys"] => "GET, POST",
1095        [
1096            "sessions" | "tokens" | "identities" | "passkeys" | "ssh-keys",
1097            _,
1098        ] => "DELETE",
1099        ["admin", "users"] => "GET",
1100        ["admin", "users", _] => "PATCH",
1101        ["oauth", _, "start"] => "GET, POST",
1102        ["oauth", _, "callback"] => "GET",
1103        ["passkeys", "register" | "login", "options" | "verify"] => "POST",
1104        _ => return error_response(404, "not_found", "no such endpoint"),
1105    };
1106    error_response(405, "method_not_allowed", "method not allowed").header("Allow", allow)
1107}
1108
1109fn org_405(seg: &[&str]) -> Response {
1110    let allow = match seg {
1111        ["members" | "invitations" | "tokens"] => "GET",
1112        ["members", _] => "PUT, DELETE",
1113        ["invitations", _] => "DELETE",
1114        _ => return error_response(404, "not_found", "no such endpoint"),
1115    };
1116    error_response(405, "method_not_allowed", "method not allowed").header("Allow", allow)
1117}
1118
1119mod external;
1120mod org;
1121pub mod spec;
1122pub use external::{LOGIN_PAGE, OAUTH_COOKIE, safe_next};
1123
1124#[cfg(test)]
1125mod tests;
1126
1127#[cfg(test)]
1128mod audit_tests;