aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-07 22:35:09 +0100
committertv <tv@krebsco.de>2026-11-07 22:35:09 +0100
commitec70060ae5fa665953741da921e28031b56bc5e0 (patch)
treecd1f6f5ae90d3ad87b9667da20d6e85b62394ab1 /src
parenteed8f86912f53f549d9f9068f9bdf2c8e23aa0f7 (diff)
auth: logging in, and tokens as bearers
POST /auth/login (by htpasswd or the proxy), /auth/refresh, /auth/logout, GET /auth/whoami; every request may bring Authorization: Bearer instead of a password. --token-lifetime and --session-lifetime. Auth plan M2.
Diffstat (limited to 'src')
-rw-r--r--src/api/access.rs44
-rw-r--r--src/api/auth.rs21
-rw-r--r--src/api/login.rs341
-rw-r--r--src/api/mod.rs8
-rw-r--r--src/api/users.rs33
-rw-r--r--src/config/mod.rs30
-rw-r--r--src/main.rs3
-rw-r--r--src/node.rs8
-rw-r--r--src/simtest/mod.rs3
-rw-r--r--src/state/mod.rs4
10 files changed, 490 insertions, 5 deletions
diff --git a/src/api/access.rs b/src/api/access.rs
index 63aa87b..29d796a 100644
--- a/src/api/access.rs
+++ b/src/api/access.rs
@@ -47,6 +47,33 @@ impl Access {
}
}
+ /// As capabilities: `Caps` as they are; in a swarm without a registry, what every
+ /// user may do there (the swarm's data, protocol data to read, and to write for
+ /// `--protocol-writers`), to narrow by a token's limit.
+ pub(crate) fn caps(&self, state: &AppState, user: &str) -> Caps {
+ use crate::access::Capability;
+ match self {
+ Self::Caps(caps) => caps.clone(),
+ Self::Everything => {
+ let protocol: &[&str] = if state.protocol_writers.contains(user) {
+ &["read", "write"]
+ } else {
+ &["read"]
+ };
+ let cap = |path: Vec<String>, rights: &[&str]| Capability {
+ path,
+ rights: rights.iter().map(|right| (*right).to_owned()).collect(),
+ by: String::new(),
+ since: 0,
+ };
+ Caps::new(vec![
+ cap(Vec::new(), &["read", "write"]),
+ cap(meta::path(&[]), protocol),
+ ])
+ }
+ }
+ }
+
pub(crate) fn visible(&self, path: &[String]) -> Visible {
match self {
Self::Everything => Visible::All,
@@ -209,6 +236,8 @@ enum Need {
Store,
/// Looking at this node (its figures, peers, dashboard): any capability.
Observe,
+ /// Logging in, refreshing, asking who one is: any registered user.
+ Auth,
/// A right on protocol data.
Protocol(Right, Vec<String>),
}
@@ -224,6 +253,8 @@ fn needed(method: &Method, path: &str) -> Need {
let under = |prefix: &str| path == prefix || path.starts_with(&format!("{prefix}/"));
if under("/store") || under("/stream") {
Need::Store
+ } else if under("/auth") {
+ Need::Auth
} else if (path == "/peers" && looking) || path == "/admin/stats" || under("/admin/dashboard") {
Need::Observe
} else if under("/peers") {
@@ -288,7 +319,7 @@ pub(crate) async fn authorize(
}
};
let allowed = match needed(request.method(), request.uri().path()) {
- Need::Store => true,
+ Need::Store | Need::Auth => true,
Need::Observe => match &access {
Access::Everything => true,
Access::Caps(caps) => !caps.is_empty(),
@@ -303,6 +334,15 @@ pub(crate) async fn authorize(
None,
);
}
+ // A token that says less: only what it allows too.
+ let access = match request
+ .extensions()
+ .get::<super::login::Token>()
+ .and_then(|token| token.claims.limit.clone())
+ {
+ None => access,
+ Some(limit) => Access::Caps(access.caps(&state, &user).within(limit)),
+ };
request.extensions_mut().insert(access);
next.run(request).await
}
@@ -338,6 +378,8 @@ mod tests {
let read = |parts: &[&str]| Need::Protocol(Right::Read, meta::path(parts));
let write = |parts: &[&str]| Need::Protocol(Right::Write, meta::path(parts));
assert_eq!(needed(&Method::GET, "/store"), Need::Store);
+ assert_eq!(needed(&Method::POST, "/auth/login"), Need::Auth);
+ assert_eq!(needed(&Method::GET, "/auth/whoami"), Need::Auth);
assert_eq!(needed(&Method::PUT, "/store/a/b"), Need::Store);
assert_eq!(needed(&Method::GET, "/stream/a"), Need::Store);
assert_eq!(needed(&Method::GET, "/storehouse"), read(&[]));
diff --git a/src/api/auth.rs b/src/api/auth.rs
index f85b64e..69c7ddb 100644
--- a/src/api/auth.rs
+++ b/src/api/auth.rs
@@ -203,10 +203,16 @@ fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> {
Some((user.to_owned(), pass.to_owned()))
}
-/// The user a request authenticated as (a request extension, set by [`basic_auth`]).
+/// The user a request authenticated as (a request extension, set by [`basic_auth`], or
+/// by `login::bearer` for a token).
#[derive(Clone, Debug)]
pub(crate) struct AuthenticatedUser(pub(crate) String);
+/// How the request's user proved who it is (a request extension, beside
+/// `AuthenticatedUser`): `htpasswd`, `header` (a trusted proxy's), or `token`.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) struct Via(pub(crate) &'static str);
+
/// The user the trusted proxy the request came from names, if it does.
fn proxied_user(state: &AuthState, headers: &HeaderMap, from: Option<Peer>) -> Option<String> {
let proxy = state.proxy.as_ref()?;
@@ -279,15 +285,21 @@ pub(crate) async fn basic_auth(
mut request: Request<Body>,
next: axum::middleware::Next,
) -> Response {
+ // A token, taken already (`login::bearer`).
+ if request.extensions().get::<AuthenticatedUser>().is_some() {
+ return next.run(request).await;
+ }
let from = request
.extensions()
.get::<ConnectInfo<Peer>>()
.map(|ConnectInfo(peer)| *peer);
let user = match proxied_user(&state, &headers, from) {
- Some(user) => Some(user),
- None => authenticate(&state, &headers).await,
+ Some(user) => Some((user, Via("header"))),
+ None => authenticate(&state, &headers)
+ .await
+ .map(|user| (user, Via("htpasswd"))),
};
- let Some(user) = user else {
+ let Some((user, via)) = user else {
sleep(Duration::from_millis(1500)).await;
// A challenge only where basic authentication can answer it.
return match state.users {
@@ -302,6 +314,7 @@ pub(crate) async fn basic_auth(
};
request.extensions_mut().insert(AuthenticatedUser(user));
+ request.extensions_mut().insert(via);
next.run(request).await
}
diff --git a/src/api/login.rs b/src/api/login.rs
new file mode 100644
index 0000000..09b8f2e
--- /dev/null
+++ b/src/api/login.rs
@@ -0,0 +1,341 @@
+//! `/auth`: logging in on this node for a token every node of the swarm takes
+//! (`access::token`, `access.md`, Tokens), and using it.
+//!
+//! * `POST /auth/login`, as a user this node lets in (`--htpasswd`, `--user-header`):
+//! a token, signed by this node, valid for `--token-lifetime`, refreshable until
+//! `--session-lifetime` after the login. Optionally less than the user may do
+//! (`{"limit": [{"path": [...], "rights": [...]}, …]}`).
+//! * `POST /auth/refresh`, with a token: a new one for the same login, signed by this
+//! node, optionally narrower still (`limit`, within the token's).
+//! * `POST /auth/logout`: every token of the user, of every login, no longer taken
+//! (`tokensNotBefore` in its registry entry).
+//! * `GET /auth/whoami`: who the request's user is, how it proved it, what it may do.
+//!
+//! Every request may bring a token instead of a password: `Authorization: Bearer ct1…`
+//! (`bearer`).
+
+use std::sync::Arc;
+
+use axum::{
+ Extension, Json,
+ body::Body,
+ extract::State,
+ http::{Request, StatusCode, header::AUTHORIZATION},
+ response::{IntoResponse, Response},
+};
+use serde::Deserialize;
+use serde_json::{Value as JsonValue, json};
+use tracing::{debug, info};
+
+use super::access::Access;
+use super::auth::{AuthenticatedUser, Via};
+use super::users::CapRequest;
+use super::{ApiError, error_response};
+use crate::access::token::{self, Claims};
+use crate::access::{Capability, Right, registry};
+use crate::state::AppState;
+
+/// The token a request came with, as taken (a request extension, set by `bearer`).
+#[derive(Clone, Debug)]
+pub(crate) struct Token {
+ pub(crate) claims: Arc<Claims>,
+}
+
+/// A request with `Authorization: Bearer <token>`: its user is the token's, if this
+/// node takes it (`token::verify`); `401`, saying why, if not. Others go on to
+/// `auth::basic_auth`.
+pub(crate) async fn bearer(
+ State(state): State<AppState>,
+ mut request: Request<Body>,
+ next: axum::middleware::Next,
+) -> Response {
+ let Some(text) = request
+ .headers()
+ .get(AUTHORIZATION)
+ .and_then(|value| value.to_str().ok())
+ .and_then(|value| value.strip_prefix("Bearer "))
+ .map(str::to_owned)
+ else {
+ return next.run(request).await;
+ };
+ let Some(swarm) = state.swarm_id().await else {
+ return error_response(
+ StatusCode::SERVICE_UNAVAILABLE,
+ "not in a swarm yet: tokens are taken once it is",
+ None,
+ );
+ };
+ let now = state.clock.now_millis().as_u64();
+ let verified = {
+ let replica = state.doc.read().await;
+ token::verify(&replica.doc, &text, &swarm, now)
+ };
+ match verified {
+ Ok(claims) => {
+ request
+ .extensions_mut()
+ .insert(AuthenticatedUser(claims.user.clone()));
+ request.extensions_mut().insert(Via("token"));
+ request.extensions_mut().insert(Token {
+ claims: Arc::new(claims),
+ });
+ next.run(request).await
+ }
+ Err(refused) => {
+ debug!(reason = %refused, "Refused a token");
+ error_response(
+ StatusCode::UNAUTHORIZED,
+ format!("token refused: {refused}"),
+ None,
+ )
+ }
+ }
+}
+
+#[derive(Debug, Default, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(crate) struct LimitRequest {
+ #[serde(default)]
+ limit: Option<Vec<CapRequest>>,
+}
+
+impl LimitRequest {
+ fn limit(&self) -> Result<Option<Vec<Capability>>, ApiError> {
+ self.limit
+ .as_ref()
+ .map(|caps| caps.iter().map(|cap| cap.capability("", 0)).collect())
+ .transpose()
+ }
+}
+
+/// `limit` as a token may say it, within `earlier`'s (a refreshed one says no more): each
+/// of its capabilities at or below one of `earlier`'s, with some of its rights.
+fn within(limit: &[Capability], earlier: &[Capability]) -> bool {
+ limit.iter().all(|cap| {
+ earlier
+ .iter()
+ .any(|outer| cap.path.starts_with(&outer.path) && cap.rights.is_subset(&outer.rights))
+ })
+}
+
+/// `claims` (issued now, by this node), signed, as the answer to a login or refresh.
+fn answer(state: &AppState, claims: &Claims) -> Result<Response, ApiError> {
+ let key = state
+ .node_key
+ .as_ref()
+ .ok_or(ApiError(StatusCode::SERVICE_UNAVAILABLE, None))?;
+ Ok(Json(json!({
+ "token": token::issue(key, claims),
+ "user": claims.user,
+ "issuer": claims.issuer,
+ "expiresAt": claims.exp,
+ "sessionExpiresAt": claims.session_exp,
+ "limit": claims.limit,
+ }))
+ .into_response())
+}
+
+/// The swarm, now, and this node's ID, to issue a token; `503` while not ready.
+async fn issuing(state: &AppState) -> Result<(String, u64), ApiError> {
+ if !state.is_ready().await || state.node_key.is_none() {
+ return Err(StatusCode::SERVICE_UNAVAILABLE.into());
+ }
+ let swarm = state
+ .swarm_id()
+ .await
+ .ok_or(ApiError(StatusCode::SERVICE_UNAVAILABLE, None))?;
+ Ok((swarm, state.clock.now_millis().as_u64()))
+}
+
+/// `POST /auth/login`.
+pub(crate) async fn login(
+ State(state): State<AppState>,
+ Extension(user): Extension<AuthenticatedUser>,
+ Extension(via): Extension<Via>,
+ body: Option<Json<LimitRequest>>,
+) -> Result<Response, ApiError> {
+ if via == Via("token") {
+ return Err(ApiError(
+ StatusCode::BAD_REQUEST,
+ Some("log in with a password or through the proxy; a token is refreshed at /auth/refresh".to_owned()),
+ ));
+ }
+ let limit = body.map(|Json(body)| body).unwrap_or_default().limit()?;
+ let (swarm, now) = issuing(&state).await?;
+ {
+ let replica = state.doc.read().await;
+ if registry::exists(&replica.doc)
+ && registry::user(&replica.doc, &user.0).is_none_or(|entry| entry.disabled)
+ {
+ return Err(ApiError(
+ StatusCode::FORBIDDEN,
+ Some("not a registered, enabled user (docs/access.md)".to_owned()),
+ ));
+ }
+ }
+ let session_exp = now.saturating_add(state.session_lifetime.as_millis() as u64);
+ let claims = Claims {
+ v: 1,
+ swarm,
+ issuer: state.local_id().to_owned(),
+ user: user.0.clone(),
+ login: via.0.to_owned(),
+ iat: now,
+ exp: now
+ .saturating_add(state.token_lifetime.as_millis() as u64)
+ .min(session_exp),
+ session: token::new_session(),
+ session_exp,
+ limit,
+ };
+ info!(user = %user.0, login = via.0, session = %claims.session, "Logged in");
+ answer(&state, &claims)
+}
+
+/// `POST /auth/refresh`.
+pub(crate) async fn refresh(
+ State(state): State<AppState>,
+ token: Option<Extension<Token>>,
+ body: Option<Json<LimitRequest>>,
+) -> Result<Response, ApiError> {
+ let Some(Extension(token)) = token else {
+ return Err(ApiError(
+ StatusCode::BAD_REQUEST,
+ Some("refreshing takes a token (Authorization: Bearer …)".to_owned()),
+ ));
+ };
+ let (swarm, now) = issuing(&state).await?;
+ let earlier = &token.claims;
+ if earlier.session_exp <= now {
+ return Err(ApiError(
+ StatusCode::UNAUTHORIZED,
+ Some("the login ended: log in again".to_owned()),
+ ));
+ }
+ let limit = match body.map(|Json(body)| body).unwrap_or_default().limit()? {
+ None => earlier.limit.clone(),
+ Some(limit) => {
+ if earlier
+ .limit
+ .as_ref()
+ .is_some_and(|earlier| !within(&limit, earlier))
+ {
+ return Err(ApiError(
+ StatusCode::BAD_REQUEST,
+ Some("a refreshed token says no more than the one it comes from".to_owned()),
+ ));
+ }
+ Some(limit)
+ }
+ };
+ let claims = Claims {
+ v: 1,
+ swarm,
+ issuer: state.local_id().to_owned(),
+ iat: now,
+ exp: now
+ .saturating_add(state.token_lifetime.as_millis() as u64)
+ .min(earlier.session_exp),
+ limit,
+ ..(**earlier).clone()
+ };
+ answer(&state, &claims)
+}
+
+/// `POST /auth/logout`.
+pub(crate) async fn logout(
+ State(state): State<AppState>,
+ Extension(user): Extension<AuthenticatedUser>,
+) -> Result<StatusCode, ApiError> {
+ revoke_tokens(&state, &user.0, &user.0).await?;
+ Ok(StatusCode::NO_CONTENT)
+}
+
+/// Makes every token of user `name` issued until now no longer taken
+/// (`registry::User::tokens_not_before`), as `by`. `409` in a swarm without a
+/// registry: there, a token is taken until it expires.
+pub(crate) async fn revoke_tokens(state: &AppState, name: &str, by: &str) -> Result<(), ApiError> {
+ if !state.is_ready().await {
+ return Err(StatusCode::SERVICE_UNAVAILABLE.into());
+ }
+ let mut entry = registry::user_path(name);
+ // Issued in this very millisecond: revoked too.
+ let after = state.clock.now_millis().as_u64() + 1;
+ state
+ .mutate_doc_for(Some(by.to_owned()), move |doc| {
+ if !registry::exists(&*doc) {
+ return Err(ApiError(
+ StatusCode::CONFLICT,
+ Some(
+ "no user registry: tokens are taken until they expire (docs/access.md)"
+ .to_owned(),
+ ),
+ ));
+ }
+ if crate::document::store::read_path(&*doc, &entry)?.is_none() {
+ return Err(ApiError(StatusCode::NOT_FOUND, None));
+ }
+ entry.push("tokensNotBefore".to_owned());
+ crate::document::store::write_path(doc, &entry, &json!(after))?;
+ Ok(())
+ })
+ .await?;
+ info!(user = %name, by = %by, "Revoked every token of a user");
+ Ok(())
+}
+
+/// `GET /auth/whoami`.
+pub(crate) async fn whoami(
+ State(state): State<AppState>,
+ Extension(user): Extension<AuthenticatedUser>,
+ Extension(via): Extension<Via>,
+ Extension(access): Extension<Access>,
+ token: Option<Extension<Token>>,
+) -> Json<JsonValue> {
+ let replica = state.doc.read().await;
+ let entry = registry::user(&replica.doc, &user.0);
+ let token = token.map(|Extension(token)| {
+ json!({
+ "issuer": token.claims.issuer,
+ "login": token.claims.login,
+ "issuedAt": token.claims.iat,
+ "expiresAt": token.claims.exp,
+ "session": token.claims.session,
+ "sessionExpiresAt": token.claims.session_exp,
+ "limit": token.claims.limit,
+ })
+ });
+ Json(json!({
+ "user": user.0,
+ "via": via.0,
+ "registry": registry::exists(&replica.doc),
+ "caps": entry.as_ref().map(|entry| &entry.caps),
+ "disabled": entry.as_ref().map(|entry| entry.disabled),
+ "managesUsers": access.allows(Right::Write, &registry::users_dir()),
+ "token": token,
+ }))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn cap(path: &[&str], rights: &[&str]) -> Capability {
+ Capability {
+ path: path.iter().map(|s| (*s).to_owned()).collect(),
+ rights: rights.iter().map(|s| (*s).to_owned()).collect(),
+ by: String::new(),
+ since: 0,
+ }
+ }
+
+ #[test]
+ fn a_refreshed_token_says_no_more() {
+ let earlier = [cap(&["notes"], &["read", "write"])];
+ assert!(within(&[cap(&["notes", "a"], &["read"])], &earlier));
+ assert!(within(&[cap(&["notes"], &["read", "write"])], &earlier));
+ assert!(!within(&[cap(&[], &["read"])], &earlier));
+ assert!(!within(&[cap(&["logs"], &["read"])], &earlier));
+ assert!(!within(&[cap(&["notes"], &["read", "grant"])], &earlier));
+ }
+}
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 03b4fdd..b62163e 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -12,6 +12,7 @@ mod document;
mod health;
pub(crate) mod listen;
mod log;
+mod login;
mod peers;
mod stats;
mod store;
@@ -62,7 +63,12 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) ->
.route("/admin/changes", get(changes::get_changes))
.route("/admin/changes/{*key}", get(changes::get_changes_at))
.route("/admin/config", get(config::get_config))
+ .route("/auth/login", axum::routing::post(login::login))
+ .route("/auth/refresh", axum::routing::post(login::refresh))
+ .route("/auth/logout", axum::routing::post(login::logout))
+ .route("/auth/whoami", get(login::whoami))
.route("/admin/users", get(users::list).post(users::add_several))
+ .route("/admin/users/{name}/tokens", delete(users::revoke_tokens))
.route(
"/admin/users/{name}",
get(users::get).post(users::add).delete(users::remove),
@@ -97,6 +103,8 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) ->
access::authorize,
))
.layer(middleware::from_fn_with_state(auth_state, basic_auth))
+ // Outside authentication: a token, if the request brings one, names the user.
+ .layer(middleware::from_fn_with_state(state.clone(), login::bearer))
// The hand-written spec (`docs/openapi.yaml`), and the Swagger UI showing it.
.merge(
// Relative to the UI's page (`/swagger-ui/`), so it is found behind a proxy that
diff --git a/src/api/users.rs b/src/api/users.rs
index e164ecd..7c65eb5 100644
--- a/src/api/users.rs
+++ b/src/api/users.rs
@@ -9,6 +9,8 @@
//! * `DELETE /admin/users/{name}`: removes a user (`disabled` keeps who wrote what
//! readable: usually the better choice).
//! * `PUT /admin/users/{name}/disabled`: `true` or `false`.
+//! * `DELETE /admin/users/{name}/tokens`: every token of the user no longer taken
+//! (`login::revoke_tokens`).
//! * `POST /admin/users/{name}/caps`: grants a capability (`{"path": […], "rights":
//! […]}`), answered with its ID; `DELETE /admin/users/{name}/caps/{id}` revokes one.
//! * `POST /admin/users`: adds several at once, by name, each with the capabilities
@@ -47,6 +49,24 @@ pub(crate) struct CapRequest {
}
impl CapRequest {
+ /// As a capability, granted `by` at `since`.
+ pub(crate) fn capability(
+ &self,
+ by: &str,
+ since: u64,
+ ) -> Result<crate::access::Capability, ApiError> {
+ let rights = self.rights()?;
+ Ok(crate::access::Capability {
+ path: self.path.clone(),
+ rights: rights
+ .iter()
+ .map(|right| right.as_str().to_owned())
+ .collect(),
+ by: by.to_owned(),
+ since,
+ })
+ }
+
fn rights(&self) -> Result<Vec<Right>, ApiError> {
if self.rights.is_empty() {
return Err(bad("a capability gives at least one right"));
@@ -300,6 +320,19 @@ pub(crate) async fn revoke(
Ok(StatusCode::NO_CONTENT)
}
+/// `DELETE /admin/users/{name}/tokens`: every token of the user, of every login, no
+/// longer taken (`login::revoke_tokens`).
+pub(crate) async fn revoke_tokens(
+ State(state): State<AppState>,
+ Extension(user): Extension<AuthenticatedUser>,
+ Extension(access): Extension<Access>,
+ Path(name): Path<String>,
+) -> Result<StatusCode, ApiError> {
+ may_manage(&state, &user.0, &access)?;
+ super::login::revoke_tokens(&state, &name, &user.0).await?;
+ Ok(StatusCode::NO_CONTENT)
+}
+
/// `POST /admin/users`.
pub(crate) async fn add_several(
State(state): State<AppState>,
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 5306c5e..60ad413 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -124,6 +124,20 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[
"HTTP API users (from htpasswd or user-header) who may write protocol data under /store/_continuum (remake records, members, forget records), e.g. to repair a stuck remake, in a swarm without a user registry. Such writes act on the whole swarm and are logged. Epoch metadata and the swarm ID are never writable. With a registry its capabilities decide instead (docs/access.md), but for a registry nobody may manage users of any more: then these may do everything on this node, to set it right.",
),
option(
+ "token-lifetime",
+ Kind::Duration,
+ "DURATION",
+ Some("12h"),
+ "How long a token this node issues (POST /auth/login, /auth/refresh) is taken, by every node of the swarm (docs/access.md, Tokens). Refreshed before it ends, it lasts until the login's end (session-lifetime).",
+ ),
+ option(
+ "session-lifetime",
+ Kind::Duration,
+ "DURATION",
+ Some("7d"),
+ "How long after a login (POST /auth/login) its tokens may be refreshed; then the user logs in again.",
+ ),
+ option(
"bootstrap-users",
Kind::List,
"USER,[USER...]",
@@ -543,6 +557,16 @@ pub(crate) struct Settings {
pub(crate) protocol_writers: Vec<String>,
#[serde(default)]
pub(crate) bootstrap_users: Vec<String>,
+ #[serde(
+ default = "default_token_lifetime",
+ with = "crate::units::duration_option"
+ )]
+ pub(crate) token_lifetime: Duration,
+ #[serde(
+ default = "default_session_lifetime",
+ with = "crate::units::duration_option"
+ )]
+ pub(crate) session_lifetime: Duration,
#[serde(default)]
pub(crate) debug_api: bool,
#[serde(default = "default_log_format")]
@@ -700,6 +724,12 @@ fn default_document_append() -> Duration {
fn default_document_rewrite() -> Duration {
Duration::from_secs(3600)
}
+fn default_token_lifetime() -> Duration {
+ Duration::from_secs(12 * 3600)
+}
+fn default_session_lifetime() -> Duration {
+ Duration::from_secs(7 * 24 * 3600)
+}
fn default_keepalive() -> Duration {
Duration::from_secs(60)
}
diff --git a/src/main.rs b/src/main.rs
index fb3c0ad..12e57dc 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -251,6 +251,9 @@ async fn main() -> Result<()> {
swarm_key,
protocol_writers: args.protocol_writers.iter().cloned().collect(),
first_users,
+ node_key: Some(Arc::clone(&node_key)),
+ token_lifetime: args.token_lifetime,
+ session_lifetime: args.session_lifetime,
files,
state_file,
document_file,
diff --git a/src/node.rs b/src/node.rs
index 8ba1373..c3be694 100644
--- a/src/node.rs
+++ b/src/node.rs
@@ -64,6 +64,11 @@ pub(crate) struct NodeConfig {
/// (`access::registry::first_users`); empty elsewhere, and where none were named
/// (the simulations: no registry).
pub(crate) first_users: crate::access::registry::FirstUsers,
+ /// To sign the tokens this node issues (`access::token`); none in the simulations.
+ pub(crate) node_key: Option<Arc<crate::identity::NodeKey>>,
+ /// `--token-lifetime`, `--session-lifetime`.
+ pub(crate) token_lifetime: Duration,
+ pub(crate) session_lifetime: Duration,
pub(crate) files: Arc<Files>,
pub(crate) state_file: Option<Arc<StateFile>>,
pub(crate) document_file: Option<Arc<DocumentFile>>,
@@ -144,6 +149,9 @@ impl Node {
links: Default::default(),
protocol_writers: Arc::new(config.protocol_writers),
first_users: Arc::new(config.first_users),
+ node_key: config.node_key,
+ token_lifetime: config.token_lifetime,
+ session_lifetime: config.session_lifetime,
state_file: config.state_file,
document_file: config.document_file,
files: config.files,
diff --git a/src/simtest/mod.rs b/src/simtest/mod.rs
index 5a202b5..c68fe57 100644
--- a/src/simtest/mod.rs
+++ b/src/simtest/mod.rs
@@ -213,6 +213,9 @@ impl Sim {
swarm_key: None,
protocol_writers: HashSet::new(),
first_users: Vec::new(),
+ node_key: None,
+ token_lifetime: Duration::from_secs(3600),
+ session_lifetime: Duration::from_secs(86400),
files: Arc::new(Files::default()),
state_file: None,
document_file: None,
diff --git a/src/state/mod.rs b/src/state/mod.rs
index c420361..223c0c9 100644
--- a/src/state/mod.rs
+++ b/src/state/mod.rs
@@ -145,6 +145,10 @@ pub(crate) struct AppState {
pub(crate) protocol_writers: Arc<std::collections::HashSet<String>>,
/// The swarm's first users, recorded by a genesis node (`NodeConfig::first_users`).
pub(crate) first_users: Arc<crate::access::registry::FirstUsers>,
+ /// Signs the tokens this node issues (`NodeConfig::node_key`).
+ pub(crate) node_key: Option<Arc<crate::identity::NodeKey>>,
+ pub(crate) token_lifetime: Duration,
+ pub(crate) session_lifetime: Duration,
/// This node's own files, and how much it read and wrote them.
pub(crate) files: Arc<crate::files::Files>,
/// This node's document on disk (`--document-file`), if configured.