1use 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
42pub const PREFIX: &str = "/api/v1/auth/";
44pub const COOKIE: &str = "isb_session";
46pub const CSRF_HEADER: &str = "X-Isb-Csrf";
48
49pub type Router = crate::server::Routes;
51
52pub use config::{AgentFn, ApiConfig, Notice, Notifier, SuperadminFn};
53
54pub struct AuthApi {
56 store: Arc<AuthStore>,
57 cfg: ApiConfig,
58 setup: Mutex<Option<Vec<u8>>>,
60 #[cfg(test)]
61 setup_plain: Mutex<Option<String>>,
62 providers: Vec<Arc<Provider>>,
63 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 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 pub fn router(self: Arc<Self>) -> Router {
171 Arc::new(move |r: &Request| self.handle(r))
172 }
173
174 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 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 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 #[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 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 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 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 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 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 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 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 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(¬ice) {
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 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 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 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 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
874pub 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
886pub 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
916pub 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
926pub 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;