1use 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
43pub const PREFIX: &str = "/api/v1/auth/";
45pub const COOKIE: &str = "isb_session";
47pub const CSRF_HEADER: &str = "X-Isb-Csrf";
49
50pub type Router = crate::server::Routes;
52
53#[derive(Debug, Clone)]
55pub enum Notice {
56 PasswordReset {
57 email: String,
58 token: String,
59 link: Option<String>,
61 },
62}
63
64pub type Notifier = Arc<dyn Fn(&Notice) -> Result<(), String> + Send + Sync>;
67
68pub type SuperadminFn = Arc<dyn Fn(&Request) -> Option<Arc<super::Superadmin>> + Send + Sync>;
71
72pub type AgentFn = Arc<dyn Fn(&Request) -> Option<Principal> + Send + Sync>;
75
76#[derive(Clone, Default)]
77pub struct ApiConfig {
78 pub agent: Option<AgentFn>,
81 pub agent_ways: super::agent_identities::AgentWays,
83 pub public_url: Option<String>,
86 pub notifier: Option<Notifier>,
89 pub setup_token_file: Option<PathBuf>,
91 pub edge: Option<super::edge::EdgeFn>,
94 pub providers: Vec<ProviderConfig>,
97 pub open_signup: bool,
100 pub audit: Option<Arc<crate::audit::AuditLog>>,
103 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
124pub struct AuthApi {
126 store: Arc<AuthStore>,
127 cfg: ApiConfig,
128 setup: Mutex<Option<Vec<u8>>>,
130 #[cfg(test)]
131 setup_plain: Mutex<Option<String>>,
132 providers: Vec<Arc<Provider>>,
133 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 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 pub fn router(self: Arc<Self>) -> Router {
241 Arc::new(move |r: &Request| self.handle(r))
242 }
243
244 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 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 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 #[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 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 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 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 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 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 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 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 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(¬ice) {
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 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 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 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 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
943pub 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
955pub 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
985pub 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
995pub 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;