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