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