use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use serde::Deserialize;
use serde_json::{Value, json};
use super::oauth::{Provider, ProviderConfig};
use super::secret::{self, TokenKind};
use super::webauthn::RelyingParty;
use super::{AuthError, AuthStore, LoginMeta, NewSession, Principal, Role, ops};
use crate::org::OrgId;
use crate::server::http::{Peer, Request, Response};
pub const PREFIX: &str = "/api/v1/auth/";
pub const COOKIE: &str = "isb_session";
pub const CSRF_HEADER: &str = "X-Isb-Csrf";
pub type Router = crate::server::Routes;
#[derive(Debug, Clone)]
pub enum Notice {
PasswordReset {
email: String,
token: String,
link: Option<String>,
},
}
pub type Notifier = Arc<dyn Fn(&Notice) -> Result<(), String> + Send + Sync>;
pub type SuperadminFn = Arc<dyn Fn(&Request) -> Option<Arc<super::Superadmin>> + Send + Sync>;
pub type AgentFn = Arc<dyn Fn(&Request) -> Option<Principal> + Send + Sync>;
#[derive(Clone, Default)]
pub struct ApiConfig {
pub agent: Option<AgentFn>,
pub agent_ways: super::agent_identities::AgentWays,
pub public_url: Option<String>,
pub notifier: Option<Notifier>,
pub setup_token_file: Option<PathBuf>,
pub edge: Option<super::edge::EdgeFn>,
pub providers: Vec<ProviderConfig>,
pub open_signup: bool,
pub audit: Option<Arc<crate::audit::AuditLog>>,
pub superadmin: Option<SuperadminFn>,
}
impl std::fmt::Debug for ApiConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ApiConfig")
.field("public_url", &self.public_url)
.field("notifier", &self.notifier.is_some())
.field("setup_token_file", &self.setup_token_file)
.field("providers", &self.providers)
.field("open_signup", &self.open_signup)
.field("audit", &self.audit.is_some())
.field("superadmin", &self.superadmin.is_some())
.field("agent", &self.agent.is_some())
.field("edge", &self.edge.is_some())
.finish()
}
}
pub struct AuthApi {
store: Arc<AuthStore>,
cfg: ApiConfig,
setup: Mutex<Option<Vec<u8>>>,
#[cfg(test)]
setup_plain: Mutex<Option<String>>,
providers: Vec<Arc<Provider>>,
rp: Option<RelyingParty>,
flows: external::Pending<external::Flow>,
challenges: external::Pending<external::Challenge>,
}
impl std::fmt::Debug for AuthApi {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthApi").field("cfg", &self.cfg).finish()
}
}
impl AuthApi {
pub fn new(store: Arc<AuthStore>, cfg: ApiConfig) -> Result<AuthApi, AuthError> {
let public = cfg
.public_url
.as_deref()
.map(|u| u.trim().trim_end_matches('/').to_string())
.filter(|u| !u.is_empty());
let rp = match public.as_deref().map(RelyingParty::from_public_url) {
Some(Ok(rp)) => Some(rp),
Some(Err(e)) => {
eprintln!("isb serve: passkeys are off: {e}");
None
}
None => None,
};
let providers: Vec<Arc<Provider>> = match &public {
Some(_) => {
let st = store.clone();
let clock: super::Clock = Arc::new(move || st.now());
cfg.providers
.iter()
.map(|p| Arc::new(Provider::new(p.clone(), clock.clone())))
.collect()
}
None => {
if !cfg.providers.is_empty() {
eprintln!(
"isb serve: sign-in with providers is off: it needs ISB_PUBLIC_URL for the callback URL"
);
}
Vec::new()
}
};
for p in &providers {
eprintln!(
"isb serve: sign-in with {}: callback URL {}{PREFIX}oauth/{}/callback",
p.cfg.label,
public.as_deref().unwrap_or(""),
p.cfg.id
);
}
let api = AuthApi {
store,
cfg,
setup: Mutex::new(None),
#[cfg(test)]
setup_plain: Mutex::new(None),
providers,
rp,
flows: Default::default(),
challenges: Default::default(),
};
if api.store.setup_needed()? {
let (token, hash) = secret::new_token(TokenKind::Setup)?;
match &api.cfg.setup_token_file {
Some(p) => {
write_secret_file(p, &token)?;
let link = match &public {
Some(u) => format!("{u}/setup#{token}"),
None => format!("/setup#{token}"),
};
eprintln!(
"isb serve: first-run setup is open. Someone a tailnet or Cloudflare Access \
listener verified can claim it at /setup; otherwise open {link} \
(the token is also in {}), or run `isb user create EMAIL` on this host",
p.display()
);
}
None => eprintln!(
"isb serve: first-run setup needs `isb user create EMAIL --admin` on this host"
),
}
*api.setup.lock().unwrap_or_else(|e| e.into_inner()) = Some(hash);
#[cfg(test)]
{
*api.setup_plain.lock().unwrap() = Some(token);
}
} else {
api.forget_setup_file();
}
Ok(api)
}
pub fn store(&self) -> &Arc<AuthStore> {
&self.store
}
#[cfg(test)]
pub(crate) fn setup_token(&self) -> Option<String> {
self.setup_plain.lock().unwrap().clone()
}
pub fn router(self: Arc<Self>) -> Router {
Arc::new(move |r: &Request| self.handle(r))
}
pub fn principal(&self, req: &Request) -> Option<Principal> {
if let Some(s) = self.cfg.superadmin.as_ref().and_then(|f| f(req)) {
return Some(s.principal.clone());
}
if let Some(p) = self.store.principal_from_request(req) {
return Some(p);
}
self.agent_principal(req)
}
pub fn handle(&self, req: &Request) -> Option<Response> {
let rest = req
.path
.strip_prefix(PREFIX)
.or_else(|| (req.path == PREFIX.trim_end_matches('/')).then_some(""))?;
let r = self.route(req, rest);
Some(r.header("Cache-Control", "no-store"))
}
fn route(&self, req: &Request, rest: &str) -> Response {
let m = req.method.as_str();
if !matches!(m, "GET" | "HEAD") && !csrf_ok(req) {
return error_response(
403,
"csrf",
&format!("state-changing requests need the {CSRF_HEADER}: 1 header"),
);
}
let seg: Vec<&str> = rest.split('/').collect();
let writes = !matches!(m, "GET" | "HEAD");
let audited = writes || matches!(seg.as_slice(), ["oauth", _, "callback"]);
let before = audited.then(|| self.principal(req)).flatten();
let restricted = writes && before.as_ref().is_some_and(|p| p.restricted());
let token_org = match (m, seg.as_slice()) {
("DELETE", ["tokens", id]) => id
.parse()
.ok()
.and_then(|id| self.store.api_token(id).ok())
.and_then(|t| t.org),
_ => None,
};
NOTED.with(|n| n.set(None));
let resp = if restricted {
error_response(
403,
"forbidden",
"this token's scopes do not cover changing accounts, tokens or members (it needs admin)",
)
} else {
self.dispatch(req, m, &seg)
};
if audited {
if let Some(log) = &self.cfg.audit {
for e in self.audit_entries(req, &seg, &resp, before.as_ref(), token_org) {
if let Err(err) = log.append(e) {
eprintln!("isb serve: {err}");
}
}
}
}
resp
}
fn dispatch(&self, req: &Request, m: &str, seg: &[&str]) -> Response {
let r = match (m, seg) {
("GET", ["setup"]) => self.get_setup(req),
("GET", ["edge"]) => Ok(self.get_edge(req)),
("POST", ["edge"]) => self.post_edge(req),
("POST", ["setup"]) => self.post_setup(req),
("POST", ["login"]) => self.login(req),
("POST", ["logout"]) => self.logout(req),
("GET", ["me"]) => self.with_principal(req, |p| self.me(p)),
("GET", ["sessions"]) => self.with_principal(req, |p| self.sessions(p)),
("DELETE", ["sessions", id]) => {
self.with_principal(req, |p| self.delete_session(p, id))
}
("POST", ["invitations"]) => self.with_principal(req, |p| self.invite(req, p)),
("POST", ["invitations", "inspect"]) => self.inspect_invitation(req),
("POST", ["invitations", "accept"]) => self.accept(req),
("GET", ["tokens"]) => self.with_principal(req, |p| self.tokens(p)),
("POST", ["tokens"]) => self.with_principal(req, |p| self.create_token(req, p)),
("DELETE", ["tokens", id]) => self.with_principal(req, |p| self.delete_token(p, id)),
("GET", ["ssh-keys"]) => self.with_principal(req, |p| self.ssh_keys(p)),
("POST", ["ssh-keys"]) => self.with_principal(req, |p| self.add_ssh_key(req, p)),
("DELETE", ["ssh-keys", id]) => {
self.with_principal(req, |p| self.delete_ssh_key(p, id))
}
("POST", ["password"]) => self.with_principal(req, |p| self.password(req, p)),
("POST", ["password-reset", "request"]) => self.reset_request(req),
("POST", ["password-reset", "confirm"]) => self.reset_confirm(req),
("GET", ["providers"]) => self.providers_list(),
("GET", ["oauth", p, "start"]) => return self.oauth_start_get(req, p),
("POST", ["oauth", p, "start"]) => self.oauth_start_post(req, p),
("GET", ["oauth", p, "callback"]) => return self.oauth_callback(req, p),
("GET", ["identities"]) => self.with_principal(req, |p| self.identities(p)),
("DELETE", ["identities", id]) => {
self.with_principal(req, |p| self.delete_identity(p, id))
}
("GET", ["passkeys"]) => self.with_principal(req, |p| self.passkeys(p)),
("DELETE", ["passkeys", id]) => {
self.with_principal(req, |p| self.delete_passkey(p, id))
}
("POST", ["passkeys", "register", "options"]) => {
self.with_principal(req, |p| self.passkey_register_options(p))
}
("POST", ["passkeys", "register", "verify"]) => {
self.with_principal(req, |p| self.passkey_register_verify(req, p))
}
("POST", ["passkeys", "login", "options"]) => self.passkey_login_options(req),
("POST", ["passkeys", "login", "verify"]) => self.passkey_login_verify(req),
("GET", ["admin", "users"]) => self.with_principal(req, |p| self.admin_users(p)),
("PATCH", ["admin", "users", id]) => {
self.with_principal(req, |p| self.admin_user_update(req, p, id))
}
(_, ["orgs", org, rest @ ..]) => {
let org = match OrgId::new(*org) {
Ok(o) => o,
Err(e) => return error_response(400, "invalid", &e.to_string()),
};
self.with_principal(req, |p| self.org_route(req, p, &org, rest))
}
_ => return not_found_or_405(seg),
};
match r {
Ok(resp) => resp,
Err(e) => auth_error(e),
}
}
#[expect(
clippy::too_many_lines,
reason = "predates the lint ratchet; split it when next changed"
)]
fn audit_entries(
&self,
req: &Request,
seg: &[&str],
resp: &Response,
before: Option<&Principal>,
token_org: Option<OrgId>,
) -> Vec<crate::audit::NewEntry> {
use crate::audit::{Actor, NewEntry, Origin};
let m = req.method.as_str();
let body: Value = serde_json::from_slice(&req.body).unwrap_or(Value::Null);
let answer: Value = serde_json::from_slice(&resp.body).unwrap_or(Value::Null);
let field = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).map(String::from);
let mut outcome = if resp.status < 400 {
"ok".to_string()
} else {
field(&answer, "error").unwrap_or_else(|| format!("http_{}", resp.status))
};
let mut details = serde_json::Map::new();
let (action, org, target): (&str, Option<String>, Option<String>) = match (m, seg) {
("POST", ["setup"]) => {
let by = if body.get("setup_token").is_some_and(|t| !t.is_null()) {
"setup_token"
} else {
"edge"
};
details.insert("method".into(), json!(by));
(
"auth.setup",
None,
field(&answer["user"], "email").or_else(|| field(&body, "email")),
)
}
("POST", ["edge"]) => {
if let Some(e) = self.edge(req) {
details.insert("method".into(), json!(e.provider()));
details.insert("subject".into(), json!(e.name));
}
("auth.login", None, None)
}
("POST", ["login"]) => {
details.insert("method".into(), json!("password"));
("auth.login", None, None)
}
("POST", ["logout"]) => ("auth.logout", None, None),
("DELETE", ["sessions", id]) => ("auth.session_revoke", None, Some(id.to_string())),
("POST", ["invitations"]) => {
if let Some(r) = field(&body, "role") {
details.insert("role".into(), json!(r));
}
(
"auth.invitation_create",
field(&body, "org"),
field(&body, "email"),
)
}
("POST", ["invitations", "accept"]) => (
"auth.invitation_accept",
answer["membership"]["org"].as_str().map(String::from),
None,
),
("POST", ["tokens"]) => {
if let Some(id) = answer["info"]["id"].as_i64() {
details.insert("id".into(), json!(id));
}
if let Some(s) = body.get("scopes").and_then(Value::as_array) {
details.insert("scopes_count".into(), json!(s.len()));
}
(
"auth.token_create",
field(&body, "org"),
field(&body, "name"),
)
}
("DELETE", ["tokens", id]) => (
"auth.token_revoke",
token_org.map(|o| o.to_string()),
Some(id.to_string()),
),
("POST", ["ssh-keys"]) => {
if let Some(id) = answer["ssh_key"]["id"].as_i64() {
details.insert("id".into(), json!(id));
}
if let Some(a) = answer["ssh_key"]["algorithm"].as_str() {
details.insert("kind".into(), json!(a));
}
(
"auth.ssh_key_add",
None,
answer["ssh_key"]["fingerprint"].as_str().map(String::from),
)
}
("DELETE", ["ssh-keys", id]) => ("auth.ssh_key_remove", None, Some(id.to_string())),
("POST", ["password"]) => ("auth.password_change", None, None),
("POST", ["password-reset", "request"]) => {
("auth.password_reset_request", None, field(&body, "email"))
}
("POST", ["password-reset", "confirm"]) => ("auth.password_reset", None, None),
("GET", ["oauth", p, "callback"]) => {
details.insert("provider".into(), json!(p));
let loc = resp.get_header("location").unwrap_or("");
if let Some(code) = loc
.split(['?', '&'])
.find_map(|kv| kv.strip_prefix("error="))
{
outcome = code.to_string();
}
let session_set = resp.headers.iter().any(|(k, v)| {
k.eq_ignore_ascii_case("set-cookie") && v.starts_with("isb_session=")
});
let link = !session_set && before.is_some();
(
if link {
"auth.identity_link"
} else {
"auth.login"
},
None,
None,
)
}
("DELETE", ["identities", id]) => ("auth.identity_unlink", None, Some(id.to_string())),
("POST", ["passkeys", "register", "verify"]) => (
"auth.passkey_add",
None,
answer["passkey"]["id"].as_i64().map(|i| i.to_string()),
),
("DELETE", ["passkeys", id]) => ("auth.passkey_remove", None, Some(id.to_string())),
("POST", ["passkeys", "login", "verify"]) => {
details.insert("method".into(), json!("passkey"));
("auth.login", None, None)
}
("PATCH", ["admin", "users", id]) => {
let base = |action: &str| NewEntry {
org: None,
actor: before.map(Actor::from_principal).unwrap_or_default(),
origin: Origin::default(),
action: action.into(),
target: Some(id.to_string()),
details: json!({"email": answer["user"]["email"]}),
outcome: outcome.clone(),
};
let mut out = Vec::new();
match body.get("disabled").and_then(Value::as_bool) {
Some(true) => out.push(base("auth.user_disable")),
Some(false) => out.push(base("auth.user_enable")),
None => {}
}
match body.get("platform_admin").and_then(Value::as_bool) {
Some(true) => out.push(base("auth.platform_admin_grant")),
Some(false) => out.push(base("auth.platform_admin_revoke")),
None => {}
}
let origin = self.origin(req);
return out
.into_iter()
.map(|mut e| {
e.origin = origin.clone();
e
})
.collect();
}
("PUT", ["orgs", org, "members", uid]) => {
if let Some(r) = field(&body, "role") {
details.insert("role".into(), json!(r));
}
(
"auth.role_change",
Some(org.to_string()),
Some(uid.to_string()),
)
}
("DELETE", ["orgs", org, "members", uid]) => (
"auth.member_remove",
Some(org.to_string()),
Some(uid.to_string()),
),
("PUT", ["orgs", org, "agent-identities"]) => {
for k in ["kind", "role"] {
if let Some(v) = field(&body, k) {
details.insert(k.into(), json!(v));
}
}
(
"auth.agent_identity_set",
Some(org.to_string()),
answer["identity"]["subject"].as_str().map(String::from),
)
}
("DELETE", ["orgs", org, "agent-identities", id]) => (
"auth.agent_identity_remove",
Some(org.to_string()),
Some(id.to_string()),
),
("DELETE", ["orgs", org, "invitations", id]) => (
"auth.invitation_revoke",
Some(org.to_string()),
Some(id.to_string()),
),
_ => return Vec::new(),
};
let noted = NOTED.with(|n| n.take());
let actor = match (noted.and_then(|id| self.store.user(id).ok()), before) {
(Some(u), _) => Actor {
name: u.email.clone(),
kind: Some(crate::audit::ActorKind::Person),
user_id: Some(u.id),
email: Some(u.email),
..Default::default()
},
(None, Some(p)) => Actor::from_principal(p),
(None, None) => match field(&body, "email") {
Some(e) => Actor::claimed(&e),
None => Actor::anonymous("anonymous"),
},
};
vec![NewEntry {
org,
actor,
origin: self.origin(req),
action: action.into(),
target,
details: Value::Object(details),
outcome,
}]
}
fn origin(&self, req: &Request) -> crate::audit::Origin {
let bearer = req.header("authorization").is_some();
crate::audit::Origin {
surface: if bearer { "rest" } else { "web" }.into(),
ip: client_ip(req),
user_agent: req.header("user-agent").map(String::from),
request_id: req
.header("x-request-id")
.or_else(|| req.header("cf-ray"))
.filter(|s| s.len() <= 64)
.map(String::from),
}
}
fn with_principal(
&self,
req: &Request,
f: impl FnOnce(&Principal) -> Result<Response, AuthError>,
) -> Result<Response, AuthError> {
match self.principal(req) {
Some(p) => f(&p),
None => Ok(error_response(401, "unauthenticated", "sign in first")),
}
}
fn login(&self, req: &Request) -> Result<Response, AuthError> {
#[derive(Deserialize)]
struct B {
email: String,
password: String,
}
let b: B = body(req)?;
let s = self.store.login(&b.email, &b.password, meta(req))?;
self.session_response(req, 200, &s)
}
fn session_response(
&self,
req: &Request,
status: u16,
s: &NewSession,
) -> Result<Response, AuthError> {
let user = self.store.user(s.session.user_id)?;
note_user(user.id);
let memberships = self.store.memberships(user.id)?;
let max_age = (s.session.expires_at - self.store.now()).max(0);
Ok(Response::json(
status,
&json!({
"user": user,
"memberships": memberships,
"session": {
"id": s.session.id,
"expires_at": s.session.expires_at,
"idle_expires_at": s.session.idle_expires_at,
},
}),
)
.header("Set-Cookie", session_cookie(req, &s.token, max_age)))
}
fn logout(&self, req: &Request) -> Result<Response, AuthError> {
if req.header("authorization").is_none() {
if let Some(t) = cookie(req, COOKIE) {
self.store.logout(&t)?;
}
}
Ok(Response::new(204).header("Set-Cookie", session_cookie(req, "", 0)))
}
fn me(&self, p: &Principal) -> Result<Response, AuthError> {
Ok(Response::json(200, &ops::me(&self.store, p)?))
}
fn sessions(&self, p: &Principal) -> Result<Response, AuthError> {
Ok(Response::json(200, &ops::sessions(&self.store, p)?))
}
fn delete_session(&self, p: &Principal, id: &str) -> Result<Response, AuthError> {
ops::revoke_session(&self.store, p, parse_id(id)?)?;
Ok(Response::new(204))
}
fn invite(&self, req: &Request, p: &Principal) -> Result<Response, AuthError> {
#[derive(Deserialize)]
struct B {
org: OrgId,
email: String,
#[serde(default)]
role: Option<Role>,
}
let b: B = body(req)?;
let public = self.cfg.public_url.as_deref();
let v = ops::invite(&self.store, p, &b.org, &b.email, b.role, public)?;
Ok(Response::json(201, &v))
}
fn inspect_invitation(&self, req: &Request) -> Result<Response, AuthError> {
#[derive(Deserialize)]
struct B {
token: String,
}
self.store.limit_ip(client_ip(req).as_deref())?;
let b: B = body(req)?;
let inv = self
.store
.invitation(&b.token)?
.ok_or(AuthError::InvalidToken("invitation"))?;
let exists = self.store.user_by_email(&inv.email)?.is_some();
Ok(Response::json(
200,
&json!({
"org": inv.org,
"email": inv.email,
"role": inv.role,
"expires_at": inv.expires_at,
"account_exists": exists,
}),
))
}
fn accept(&self, req: &Request) -> Result<Response, AuthError> {
#[derive(Deserialize)]
struct B {
token: String,
#[serde(default)]
name: String,
#[serde(default)]
password: Option<String>,
}
self.store.limit_ip(client_ip(req).as_deref())?;
let b: B = body(req)?;
if let Some(p) = self.principal(req) {
let a = self.store.accept_invitation_as(&b.token, p.user.id)?;
note_user(a.user.id);
return Ok(Response::json(
200,
&json!({"user": a.user, "membership": a.membership, "created": false}),
));
}
let pw = b
.password
.ok_or_else(|| AuthError::Invalid("password is required".into()))?;
let a = self.store.accept_invitation(&b.token, &b.name, &pw)?;
note_user(a.user.id);
let s = self.store.start_session(a.user.id, meta(req))?;
let max_age = (s.session.expires_at - self.store.now()).max(0);
Ok(Response::json(
200,
&json!({"user": a.user, "membership": a.membership, "created": a.created}),
)
.header("Set-Cookie", session_cookie(req, &s.token, max_age)))
}
fn tokens(&self, p: &Principal) -> Result<Response, AuthError> {
Ok(Response::json(200, &ops::tokens(&self.store, p, None)?))
}
fn create_token(&self, req: &Request, p: &Principal) -> Result<Response, AuthError> {
let v = ops::create_token(&self.store, p, body(req)?)?;
Ok(Response::json(201, &v))
}
fn delete_token(&self, p: &Principal, id: &str) -> Result<Response, AuthError> {
ops::revoke_token(&self.store, p, parse_id(id)?)?;
Ok(Response::new(204))
}
fn ssh_keys(&self, p: &Principal) -> Result<Response, AuthError> {
Ok(Response::json(200, &ops::ssh_keys(&self.store, p)?))
}
fn add_ssh_key(&self, req: &Request, p: &Principal) -> Result<Response, AuthError> {
#[derive(Deserialize)]
struct B {
public_key: String,
#[serde(default)]
name: Option<String>,
}
let b: B = body(req)?;
let v = ops::add_ssh_key(&self.store, p, &b.public_key, b.name.as_deref())?;
Ok(Response::json(201, &v))
}
fn delete_ssh_key(&self, p: &Principal, id: &str) -> Result<Response, AuthError> {
ops::delete_ssh_key(&self.store, p, parse_id(id)?)?;
Ok(Response::new(204))
}
fn password(&self, req: &Request, p: &Principal) -> Result<Response, AuthError> {
#[derive(Deserialize)]
struct B {
current_password: String,
new_password: String,
}
let Some(sid) = p.session_id() else {
return Err(AuthError::Forbidden(
"change a password from a signed-in session, not with an API token".into(),
));
};
let b: B = body(req)?;
self.store
.change_password(p.user.id, &b.current_password, &b.new_password, Some(sid))?;
Ok(Response::new(204))
}
fn reset_request(&self, req: &Request) -> Result<Response, AuthError> {
#[derive(Deserialize)]
struct B {
email: String,
}
self.store.limit_ip(client_ip(req).as_deref())?;
let b: B = body(req)?;
if let Some(token) = self.store.request_password_reset(&b.email)? {
let email = b.email.trim().to_lowercase();
let link = self.link("reset-password", &token);
let notice = Notice::PasswordReset {
email: email.clone(),
token: token.clone(),
link: link.clone(),
};
match &self.cfg.notifier {
Some(n) => {
if let Err(e) = n(¬ice) {
eprintln!("isb serve: password reset for {email}: delivery failed: {e}");
}
}
None => eprintln!(
"isb serve: password reset for {email} (no mailer is configured, so it is \
logged here; hand it over yourself): {}",
link.unwrap_or(token)
),
}
}
Ok(Response::json(202, &json!({"ok": true})))
}
fn reset_confirm(&self, req: &Request) -> Result<Response, AuthError> {
#[derive(Deserialize)]
struct B {
token: String,
password: String,
}
self.store.limit_ip(client_ip(req).as_deref())?;
let b: B = body(req)?;
let u = self.store.reset_password(&b.token, &b.password)?;
note_user(u.id);
Ok(Response::new(204))
}
fn admin_users(&self, p: &Principal) -> Result<Response, AuthError> {
Ok(Response::json(200, &ops::users(&self.store, p)?))
}
fn admin_user_update(
&self,
req: &Request,
p: &Principal,
id: &str,
) -> Result<Response, AuthError> {
let id = parse_id(id)?;
let b: ops::UserChange = body(req)?;
Ok(Response::json(
200,
&ops::update_user(&self.store, p, id, &b)?,
))
}
fn link(&self, page: &str, token: &str) -> Option<String> {
ops::link(self.cfg.public_url.as_deref(), page, token)
}
}
impl AuthStore {
pub fn principal_from_request(&self, req: &Request) -> Option<Principal> {
let r = if let Some(a) = req.header("authorization") {
let (scheme, token) = a.trim().split_once(' ')?;
if !scheme.eq_ignore_ascii_case("bearer") {
return None;
}
self.authenticate_token(token.trim())
} else {
let t = cookie(req, COOKIE)?;
self.authenticate_session(&t)
};
r.unwrap_or_else(|e| {
eprintln!("isb serve: authentication failed: {e}");
None
})
}
}
thread_local! {
static NOTED: std::cell::Cell<Option<i64>> = const { std::cell::Cell::new(None) };
}
pub(crate) fn note_user(id: i64) {
NOTED.with(|n| n.set(Some(id)));
}
fn csrf_ok(req: &Request) -> bool {
let bearer = req
.header("authorization")
.and_then(|a| a.trim().split_once(' '))
.is_some_and(|(s, _)| s.eq_ignore_ascii_case("bearer"));
bearer || req.header(CSRF_HEADER).is_some_and(|v| v.trim() == "1")
}
pub fn cookie(req: &Request, name: &str) -> Option<String> {
req.headers
.iter()
.filter(|(k, _)| k.eq_ignore_ascii_case("cookie"))
.flat_map(|(_, v)| v.split(';'))
.filter_map(|c| c.trim().split_once('='))
.find(|(k, _)| *k == name)
.map(|(_, v)| v.trim().trim_matches('"').to_string())
.filter(|v| !v.is_empty())
}
pub fn plain_loopback_http(req: &Request) -> bool {
let peer_local = match &req.peer {
Peer::Tcp(a) => a.ip().is_loopback(),
Peer::Unix { .. } => true,
};
let forwarded_https = req
.header("x-forwarded-proto")
.is_some_and(|p| p.trim().eq_ignore_ascii_case("https"))
|| req
.header("cf-visitor")
.is_some_and(|v| v.contains("\"https\""));
let host = req.header("host").unwrap_or("");
let host_name = if host.starts_with('[') {
host.split(']')
.next()
.map(|h| format!("{h}]"))
.unwrap_or_default()
} else {
host.split(':').next().unwrap_or("").to_string()
};
let host_local = host_name.eq_ignore_ascii_case("localhost")
|| host_name
.trim_matches(['[', ']'])
.parse::<IpAddr>()
.is_ok_and(|ip| ip.is_loopback());
peer_local && host_local && !forwarded_https
}
pub fn session_cookie(req: &Request, token: &str, max_age: i64) -> String {
let secure = if plain_loopback_http(req) {
""
} else {
"; Secure"
};
format!("{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}")
}
pub fn client_ip(req: &Request) -> Option<String> {
match &req.peer {
Peer::Tcp(a) if a.ip().is_loopback() => Some(
req.header("cf-connecting-ip")
.map(|s| s.trim().to_string())
.filter(|s| s.parse::<IpAddr>().is_ok())
.unwrap_or_else(|| a.ip().to_string()),
),
Peer::Tcp(a) => Some(a.ip().to_string()),
Peer::Unix { .. } => None,
}
}
fn meta(req: &Request) -> LoginMeta {
LoginMeta {
user_agent: req.header("user-agent").map(str::to_string),
ip: client_ip(req),
}
}
fn body<T: serde::de::DeserializeOwned>(req: &Request) -> Result<T, AuthError> {
serde_json::from_slice(&req.body).map_err(|e| AuthError::Invalid(format!("request body: {e}")))
}
fn parse_id(s: &str) -> Result<i64, AuthError> {
s.parse()
.map_err(|_| AuthError::Invalid(format!("{s:?} is not an id")))
}
fn write_secret_file(p: &std::path::Path, content: &str) -> Result<(), AuthError> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let _ = std::fs::remove_file(p);
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(p)
.map_err(|e| AuthError::io(format!("write {}", p.display()), e))?;
f.write_all(format!("{content}\n").as_bytes())
.map_err(|e| AuthError::io(format!("write {}", p.display()), e))
}
fn error_response(status: u16, code: &str, message: &str) -> Response {
Response::json(status, &json!({"error": code, "message": message}))
}
fn auth_error(e: AuthError) -> Response {
let (status, code) = match &e {
AuthError::InvalidCredentials => (401, "invalid_credentials"),
AuthError::InvalidToken(_) => (400, "invalid_token"),
AuthError::RateLimited { .. } => (429, "rate_limited"),
AuthError::Forbidden(_) => (403, "forbidden"),
AuthError::NotFound(_) => (404, "not_found"),
AuthError::Conflict(_) => (409, "conflict"),
AuthError::Invalid(_) => (400, "invalid"),
AuthError::Refused { code, .. } => (403, *code),
AuthError::PasskeyRejected(_) => (401, "passkey_rejected"),
AuthError::Internal(_) | AuthError::Db(_) => {
eprintln!("isb serve: auth: {e}");
return error_response(500, "internal", "internal error");
}
};
let r = error_response(status, code, &e.to_string());
match e {
AuthError::RateLimited { retry_after } => r.header("Retry-After", retry_after.to_string()),
_ => r,
}
}
fn not_found_or_405(seg: &[&str]) -> Response {
let allow = match seg {
["setup" | "edge"] => "GET, POST",
["login" | "logout" | "invitations" | "password"] => "POST",
["invitations" | "password-reset", _] => "POST",
["me" | "sessions" | "providers" | "identities" | "passkeys"] => "GET",
["tokens" | "ssh-keys"] => "GET, POST",
[
"sessions" | "tokens" | "identities" | "passkeys" | "ssh-keys",
_,
] => "DELETE",
["admin", "users"] => "GET",
["admin", "users", _] => "PATCH",
["oauth", _, "start"] => "GET, POST",
["oauth", _, "callback"] => "GET",
["passkeys", "register" | "login", "options" | "verify"] => "POST",
_ => return error_response(404, "not_found", "no such endpoint"),
};
error_response(405, "method_not_allowed", "method not allowed").header("Allow", allow)
}
fn org_405(seg: &[&str]) -> Response {
let allow = match seg {
["members" | "invitations" | "tokens"] => "GET",
["members", _] => "PUT, DELETE",
["invitations", _] => "DELETE",
_ => return error_response(404, "not_found", "no such endpoint"),
};
error_response(405, "method_not_allowed", "method not allowed").header("Allow", allow)
}
mod external;
mod org;
mod setup;
pub mod spec;
pub use external::{LOGIN_PAGE, OAUTH_COOKIE, safe_next};
#[cfg(test)]
mod tests;
#[cfg(test)]
mod audit_tests;