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