diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/access.rs | 44 | ||||
| -rw-r--r-- | src/api/auth.rs | 21 | ||||
| -rw-r--r-- | src/api/login.rs | 341 | ||||
| -rw-r--r-- | src/api/mod.rs | 8 | ||||
| -rw-r--r-- | src/api/users.rs | 33 | ||||
| -rw-r--r-- | src/config/mod.rs | 30 | ||||
| -rw-r--r-- | src/main.rs | 3 | ||||
| -rw-r--r-- | src/node.rs | 8 | ||||
| -rw-r--r-- | src/simtest/mod.rs | 3 | ||||
| -rw-r--r-- | src/state/mod.rs | 4 |
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, ®istry::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. |
