From 7c777d813f384ae360fead6058160cfb7b84ea06 Mon Sep 17 00:00:00 2001 From: overtrue Date: Fri, 10 Jul 2026 10:21:01 +0800 Subject: [PATCH] fix(console): revoke server-side sessions on logout --- .github/workflows/ci.yml | 3 + README.md | 8 +- console-web/README.md | 2 +- deploy/k8s-dev/console-deployment.yaml | 2 + deploy/rustfs-operator/README.md | 27 +- .../templates/console-deployment.yaml | 5 + .../templates/console-secret.yaml | 2 +- deploy/rustfs-operator/values.yaml | 5 +- docs/operator-user-guide.md | 13 +- docs/operator-user-guide.zh-CN.md | 13 +- e2e/src/framework/deploy.rs | 15 +- e2e/tests/sts_manifest.rs | 64 +++ src/console/error.rs | 26 + src/console/handlers/auth.rs | 390 +++++++++++++- src/console/handlers/events.rs | 2 +- src/console/middleware/auth.rs | 35 +- src/console/openapi.rs | 26 +- src/console/state.rs | 477 ++++++++++++++++-- 18 files changed, 1003 insertions(+), 112 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61249bf..8a0d3e5 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,6 +105,9 @@ jobs: - name: Run clippy lints run: cargo clippy --all-features -- -D warnings + - name: Set up Helm + uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0 + - name: Check Rust-native e2e harness run: make e2e-check diff --git a/README.md b/README.md index f93f9e3..d3314c7 100755 --- a/README.md +++ b/README.md @@ -132,8 +132,12 @@ kubectl --context kind-rustfs-e2e -n rustfs-system port-forward svc/rustfs-opera Get a login token for the e2e Console. The Console login form expects a Kubernetes ServiceAccount bearer token with permissions granted to the Console -ServiceAccount. After login, the Console stores it in an encrypted session -cookie; users do not pass this token on later API requests: +ServiceAccount. After login, the Console encrypts it in process and stores only +a random session ID in the browser cookie; users do not pass this token on later +API requests: + +The Console must run as one process with a Recreate rollout. The Helm chart +enforces this; custom deployments must preserve both constraints. ```bash TOKEN=$(kubectl --context kind-rustfs-e2e -n rustfs-system create token rustfs-operator-console --duration=24h) diff --git a/console-web/README.md b/console-web/README.md index df94527..de9ba1b 100755 --- a/console-web/README.md +++ b/console-web/README.md @@ -21,7 +21,7 @@ Run the operator console HTTP API (e.g. `CONSOLE_COOKIE_SECURE=false cargo run - Login uses a Kubernetes ServiceAccount bearer token. For a local e2e cluster, generate one with `kubectl -n rustfs-system create token rustfs-operator-console --duration=24h` and paste the printed token into the login form. After login, the backend stores -it in an encrypted `session` cookie. +the encrypted token in process and puts only a random session ID in the cookie. ## Build diff --git a/deploy/k8s-dev/console-deployment.yaml b/deploy/k8s-dev/console-deployment.yaml index 16e87e0..8ed2180 100755 --- a/deploy/k8s-dev/console-deployment.yaml +++ b/deploy/k8s-dev/console-deployment.yaml @@ -9,6 +9,8 @@ metadata: labels: app.kubernetes.io/name: rustfs-operator-console spec: + strategy: + type: Recreate replicas: 1 selector: matchLabels: diff --git a/deploy/rustfs-operator/README.md b/deploy/rustfs-operator/README.md index 788034a..9d5fdcc 100755 --- a/deploy/rustfs-operator/README.md +++ b/deploy/rustfs-operator/README.md @@ -317,14 +317,23 @@ Serve the Console service under **one HTTPS host**: `CONSOLE_COOKIE_SECURE=false` in `console.env`; do not use that setting for production. -No CORS configuration is needed on the backend for this setup. - -Console sessions are encrypted stateless cookies. Users paste a Kubernetes -ServiceAccount bearer token only during login; after validation, the Console -stores that token inside an encrypted `session` cookie for later API requests. -If you run multiple Console replicas, keep `console.jwtSecret` stable and shared -across all replicas. The chart reuses the existing generated Secret on upgrade -when `console.jwtSecret` is not set. +No CORS configuration is needed on the backend for this setup. The reverse +proxy must preserve the public Host/authority; if it rewrites the Host, add the +public Console origin to `CORS_ALLOWED_ORIGINS` so logout origin checks succeed. + +Console sessions are stored in process. Users paste a Kubernetes ServiceAccount +bearer token only during login; after validation, the Console encrypts that +token in memory and stores only a random session ID in the browser cookie. +Logout removes the session immediately. The chart requires +`console.replicas=1` and uses a Recreate rollout; restarts and upgrades +invalidate existing sessions and require users to sign in again. Custom +deployments must preserve the same replica and rollout constraints. + +When upgrading from an older release configured with multiple Console replicas, +set `console.replicas=1` first and expect brief downtime plus forced sign-in. +Before rolling back to a release without server-side sessions, scale the Console +Deployment to zero, perform the rollback, then restore one replica so the two +cookie formats never overlap. ### Backend CORS (when frontend is on a different host) @@ -336,7 +345,7 @@ console: - name: CORS_ALLOWED_ORIGINS value: "https://ui.example.com" # Required when the frontend and API are cross-site, so browsers send the - # encrypted session cookie on credentialed CORS requests. + # session cookie on credentialed CORS requests. - name: CONSOLE_COOKIE_SAME_SITE value: "None" ``` diff --git a/deploy/rustfs-operator/templates/console-deployment.yaml b/deploy/rustfs-operator/templates/console-deployment.yaml index 23b30ca..597723c 100755 --- a/deploy/rustfs-operator/templates/console-deployment.yaml +++ b/deploy/rustfs-operator/templates/console-deployment.yaml @@ -1,3 +1,6 @@ +{{- if and .Values.console.enabled (ne (toString .Values.console.replicas) "1") -}} +{{- fail "console.replicas must be 1 because Console sessions are stored in process" -}} +{{- end -}} {{- if .Values.console.enabled -}} apiVersion: apps/v1 kind: Deployment @@ -12,6 +15,8 @@ metadata: {{- toYaml . | nindent 4 }} {{- end }} spec: + strategy: + type: Recreate replicas: {{ .Values.console.replicas }} selector: matchLabels: diff --git a/deploy/rustfs-operator/templates/console-secret.yaml b/deploy/rustfs-operator/templates/console-secret.yaml index 0bdf0dc..8186c0d 100755 --- a/deploy/rustfs-operator/templates/console-secret.yaml +++ b/deploy/rustfs-operator/templates/console-secret.yaml @@ -20,7 +20,7 @@ metadata: app.kubernetes.io/component: console type: Opaque data: - # Secret used to encrypt Console session cookies + # Secret used to encrypt in-process Console session data # Generate with: openssl rand -base64 32 jwt-secret: {{ $jwtSecret | quote }} {{- end }} diff --git a/deploy/rustfs-operator/values.yaml b/deploy/rustfs-operator/values.yaml index 15b49da..cf35ebd 100755 --- a/deploy/rustfs-operator/values.yaml +++ b/deploy/rustfs-operator/values.yaml @@ -149,7 +149,7 @@ console: # Enable console deployment enabled: true - # Number of console replicas + # Console sessions are stored in process, so this must remain 1. replicas: 1 # Console server port @@ -158,8 +158,7 @@ console: # Log level for console (trace, debug, info, warn, error) logLevel: info - # Secret used to encrypt Console session cookies. - # All Console replicas must use the same secret. + # Secret used to encrypt in-process Console session data. # If not set, an existing chart Secret is reused. # On first install, a random secret is generated. # Generate with: openssl rand -base64 32 diff --git a/docs/operator-user-guide.md b/docs/operator-user-guide.md index f8252e9..88c1752 100644 --- a/docs/operator-user-guide.md +++ b/docs/operator-user-guide.md @@ -118,7 +118,7 @@ Common chart sections: | `operator` | Operator Deployment replicas, image, resources, probes, metrics, scheduling, leader election, and tenant monitoring. | | `sts` | Operator STS endpoint, service port, TokenReview audience, and TLS handling. | | `serviceAccount` / `rbac` | Operator ServiceAccount and RBAC creation. | -| `console` | Operator Console backend/UI Deployment, service, session cookie secret, ingress, resources, and optional split frontend. | +| `console` | Operator Console backend/UI Deployment, service, session encryption secret, ingress, resources, and optional split frontend. | | `namespace` | Namespace override for chart resources; defaults to the Helm release namespace. | | `commonLabels` / `commonAnnotations` | Labels and annotations added to chart-managed resources. | @@ -145,7 +145,7 @@ operator: console: enabled: true - replicas: 2 + replicas: 1 jwtSecret: "" ingress: enabled: true @@ -164,7 +164,8 @@ sts: Notes: - `operator.leaderElect` can be unset. The chart enables leader election automatically when `operator.replicas > 1`. -- Keep `console.jwtSecret` stable when running multiple Console replicas. If unset, the chart generates or reuses a Secret. +- Keep `console.replicas=1`. Console sessions are stored in process, and the chart uses a Recreate rollout so upgrades invalidate existing sessions without mixing incompatible cookie formats. Custom deployments must preserve both constraints. +- When upgrading from a release configured with multiple Console replicas, set `console.replicas=1` first; expect brief Console downtime and require users to sign in again. Before rolling back to a release without server-side sessions, scale the Console Deployment to zero, perform the rollback, then restore one replica so incompatible cookie formats never overlap. - Keep `CONSOLE_COOKIE_SECURE` enabled for production HTTPS. Only disable it for local HTTP testing. - `sts.tls.auto=true` lets the operator create the `sts-tls` Secret when missing. @@ -574,7 +575,7 @@ console: - host: console.example.com ``` -The unified operator image serves both `/` and `/api/v1` from the Console service. No backend CORS configuration is needed for this mode. +The unified operator image serves both `/` and `/api/v1` from the Console service. No backend CORS configuration is needed for this mode. The reverse proxy must preserve the public Host/authority; if it rewrites the Host, add the public Console origin to `CORS_ALLOWED_ORIGINS` so logout origin checks succeed. Console login uses a Kubernetes ServiceAccount bearer token. For the chart-managed Console ServiceAccount: @@ -582,7 +583,7 @@ Console login uses a Kubernetes ServiceAccount bearer token. For the chart-manag kubectl -n rustfs-system create token rustfs-operator-console --duration=24h ``` -Paste the token into the login form. The Console stores the validated token in an encrypted session cookie. +Paste the token into the login form. The Console keeps the encrypted token in process and places only a random session ID in the browser cookie. Logging out removes that session immediately; restarting or upgrading the Console invalidates all existing sessions. For local port-forward testing: @@ -805,7 +806,7 @@ For the RustFS Tenant Console, use the Tenant admin credentials from `spec.creds - Do not model hot/warm/cold tiers as pools inside one Tenant. - Use separate Tenants for separate clusters, administrative boundaries, or performance isolation. - Keep the Operator Console on HTTPS in production. -- Keep `console.jwtSecret` stable for multi-replica Console deployments. +- Keep `console.replicas=1`; Console restarts and upgrades intentionally require users to sign in again. - Use `ServiceMonitor` and `PrometheusRule` only when Prometheus Operator is installed. - Keep Tenant examples under version control, but never commit raw Secret values. - Check `status.conditions` before debugging lower-level StatefulSets. diff --git a/docs/operator-user-guide.zh-CN.md b/docs/operator-user-guide.zh-CN.md index 8f7ee2b..6c8c1a0 100644 --- a/docs/operator-user-guide.zh-CN.md +++ b/docs/operator-user-guide.zh-CN.md @@ -120,7 +120,7 @@ helm upgrade --install rustfs-operator deploy/rustfs-operator/ \ | `operator` | Operator Deployment 副本数、镜像、资源、探针、metrics、调度、leader election 和 Tenant monitor。 | | `sts` | Operator STS 端点、Service 端口、TokenReview audience 和 TLS。 | | `serviceAccount` / `rbac` | Operator ServiceAccount 和 RBAC 创建策略。 | -| `console` | Operator Console 后端/UI Deployment、Service、session cookie 密钥、Ingress、资源和可选独立前端。 | +| `console` | Operator Console 后端/UI Deployment、Service、session 加密密钥、Ingress、资源和可选独立前端。 | | `namespace` | Chart 资源命名空间覆盖;默认使用 Helm release namespace。 | | `commonLabels` / `commonAnnotations` | 添加到 Chart 管理资源上的统一 label 和 annotation。 | @@ -147,7 +147,7 @@ operator: console: enabled: true - replicas: 2 + replicas: 1 jwtSecret: "" ingress: enabled: true @@ -166,7 +166,8 @@ sts: 配置说明: - `operator.leaderElect` 可以不配置;当 `operator.replicas > 1` 时 Chart 会自动启用 leader election。 -- 多副本 Console 部署需要保持 `console.jwtSecret` 稳定;不设置时 Chart 会生成或复用已有 Secret。 +- 保持 `console.replicas=1`。Console 会话保存在进程内,Chart 使用 Recreate 升级策略,避免新旧 cookie 格式混跑;升级时现有会话会失效。自定义 Deployment 也必须保持这两个约束。 +- 从多 Console 副本的旧版本升级前,先把 `console.replicas` 调整为 `1`;升级会有短暂 Console 不可用,用户需要重新登录。回滚到不支持服务端会话的旧版本前,先把 Console Deployment 缩容到零,完成回滚后再恢复一个副本,避免两种 cookie 格式同时在线。 - 生产环境应使用 HTTPS 并保持 `CONSOLE_COOKIE_SECURE` 启用。仅本地 HTTP 调试时才关闭。 - `sts.tls.auto=true` 时,Operator 会在缺失时创建 `sts-tls` Secret。 @@ -576,7 +577,7 @@ console: - host: console.example.com ``` -统一 Operator 镜像会通过同一个 Console Service 提供 `/` 和 `/api/v1`。该模式不需要后端 CORS 配置。 +统一 Operator 镜像会通过同一个 Console Service 提供 `/` 和 `/api/v1`。该模式不需要后端 CORS 配置。反向代理必须保留公网 Host/authority;如果代理会改写 Host,请把公网 Console origin 加入 `CORS_ALLOWED_ORIGINS`,确保登出请求的 origin 校验通过。 Console 登录需要 Kubernetes ServiceAccount bearer token。Chart 管理的 Console ServiceAccount 可以这样生成短期 token: @@ -584,7 +585,7 @@ Console 登录需要 Kubernetes ServiceAccount bearer token。Chart 管理的 Co kubectl -n rustfs-system create token rustfs-operator-console --duration=24h ``` -将 token 粘贴到 Console 登录页。Console 会把验证后的 token 存入加密 session cookie。 +将 token 粘贴到 Console 登录页。Console 会把验证后的 token 加密保存在进程内,浏览器 cookie 仅保存随机 session ID。登出会立即删除该会话;Console 重启或升级会使全部现有会话失效。 本地 port-forward 调试: @@ -807,7 +808,7 @@ RustFS Tenant Console 登录失败时,应使用 `spec.credsSecret` 或 RustFS - 不要把一个 Tenant 内的多个 pool 当作冷热分层。 - 独立集群、独立管理边界或独立性能隔离应使用多个 Tenant。 - 生产环境 Operator Console 使用 HTTPS。 -- 多副本 Console 部署保持 `console.jwtSecret` 稳定。 +- 保持 `console.replicas=1`;Console 重启或升级后,用户需要重新登录。 - 仅在安装 Prometheus Operator 后启用 `ServiceMonitor` 和 `PrometheusRule`。 - Tenant YAML 可以进入版本控制,但不要提交明文 Secret 值。 - 优先查看 `status.conditions`,再进一步排查 StatefulSet 和 Pod。 diff --git a/e2e/src/framework/deploy.rs b/e2e/src/framework/deploy.rs index 832f2c1..a1bd221 100644 --- a/e2e/src/framework/deploy.rs +++ b/e2e/src/framework/deploy.rs @@ -151,10 +151,13 @@ fn patch_images_and_tags(manifest: &str, image: &str, fallback: &str) -> String #[cfg(test)] mod tests { use super::{ - E2E_CONSOLE_WEB_IMAGE_TAG_DEFAULT, E2E_CONTROL_PLANE_DEPLOYMENTS, + CONSOLE_DEPLOYMENT, E2E_CONSOLE_WEB_IMAGE_TAG_DEFAULT, E2E_CONTROL_PLANE_DEPLOYMENTS, E2E_OPERATOR_IMAGE_TAG_DEFAULT, patch_images_and_tags, }; + const CONSOLE_DEPLOYMENT_TEMPLATE: &str = + include_str!("../../../deploy/rustfs-operator/templates/console-deployment.yaml"); + #[test] fn patch_images_prefers_explicit_runtime_image_tags() { let operator = patch_images_and_tags( @@ -185,4 +188,14 @@ mod tests { ] ); } + + #[test] + fn console_session_deployments_are_single_replica_and_recreate() { + assert!(CONSOLE_DEPLOYMENT.contains("replicas: 1")); + assert!(CONSOLE_DEPLOYMENT.contains("strategy:\n type: Recreate")); + assert!( + CONSOLE_DEPLOYMENT_TEMPLATE.contains("ne (toString .Values.console.replicas) \"1\"") + ); + assert!(CONSOLE_DEPLOYMENT_TEMPLATE.contains("strategy:\n type: Recreate")); + } } diff --git a/e2e/tests/sts_manifest.rs b/e2e/tests/sts_manifest.rs index 57c2752..31cdf87 100644 --- a/e2e/tests/sts_manifest.rs +++ b/e2e/tests/sts_manifest.rs @@ -174,6 +174,61 @@ fn helm_template_renders_sts_enabled_disabled_and_rejects_external_plaintext() { assert!(external_stderr.contains("operator STS currently supports only ClusterIP")); } +#[test] +fn console_session_deployments_enforce_single_recreate_process() { + let dev_manifest = std::fs::read_to_string("../deploy/k8s-dev/console-deployment.yaml") + .expect("k8s dev Console deployment exists"); + let dev_deployment = find_yaml_document(&dev_manifest, "Deployment", "rustfs-operator-console") + .expect("k8s dev Console deployment is rendered"); + assert_eq!(dev_deployment["spec"]["replicas"].as_i64(), Some(1)); + assert_eq!( + dev_deployment["spec"]["strategy"]["type"].as_str(), + Some("Recreate") + ); + + let Some(default_render) = helm_template(&[]) else { + return; + }; + assert!( + default_render.status.success(), + "default helm template should render successfully: {}", + String::from_utf8_lossy(&default_render.stderr) + ); + let default_stdout = String::from_utf8(default_render.stdout).expect("helm stdout is utf8"); + let deployment = find_yaml_document(&default_stdout, "Deployment", "rustfs-operator-console") + .expect("Helm Console deployment is rendered"); + assert_eq!(deployment["spec"]["replicas"].as_i64(), Some(1)); + assert_eq!( + deployment["spec"]["strategy"]["type"].as_str(), + Some("Recreate") + ); + + for replicas in ["2", "true"] { + let render = helm_template(&["--set", &format!("console.replicas={replicas}")]) + .expect("helm remains available"); + assert!(!render.status.success()); + assert!( + String::from_utf8_lossy(&render.stderr).contains( + "console.replicas must be 1 because Console sessions are stored in process" + ) + ); + } + + let disabled_render = helm_template(&[ + "--set", + "console.enabled=false", + "--set", + "console.replicas=2", + ]) + .expect("helm remains available"); + assert!(disabled_render.status.success()); + let disabled_stdout = + String::from_utf8(disabled_render.stdout).expect("disabled helm stdout is utf8"); + assert!( + find_yaml_document(&disabled_stdout, "Deployment", "rustfs-operator-console").is_none() + ); +} + fn helm_template(args: &[&str]) -> Option { if !helm_is_available() { eprintln!("skipping helm template assertions: helm binary is not available"); @@ -214,3 +269,12 @@ fn assert_yaml_documents_parse(yaml: &str, name: &str) { "{name} should contain at least one yaml document" ); } + +fn find_yaml_document(yaml: &str, kind: &str, name: &str) -> Option { + yaml.split("---") + .filter_map(|document| serde_yaml_ng::from_str::(document).ok()) + .find(|document| { + document["kind"].as_str() == Some(kind) + && document["metadata"]["name"].as_str() == Some(name) + }) +} diff --git a/src/console/error.rs b/src/console/error.rs index 49d9d45..da18825 100755 --- a/src/console/error.rs +++ b/src/console/error.rs @@ -40,6 +40,9 @@ pub enum Error { #[snafu(display("Conflict: {}", message))] Conflict { message: String }, + #[snafu(display("Too many requests: {}", message))] + TooManyRequests { message: String }, + #[snafu(display("Action required: {}", message))] ActionRequired { status: StatusCode, @@ -181,6 +184,14 @@ impl Error { Vec::new(), None, ), + Error::TooManyRequests { message } => ( + StatusCode::TOO_MANY_REQUESTS, + "TooManyRequests".to_string(), + "RateLimitExceeded".to_string(), + message, + Vec::new(), + None, + ), Error::ActionRequired { status, code, @@ -288,6 +299,21 @@ mod tests { Ok(()) } + #[test] + fn too_many_requests_maps_to_stable_error_contract() { + let (status, response) = Error::TooManyRequests { + message: "Too many active Console sessions".to_string(), + } + .into_response_parts(); + + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(response.code, "TooManyRequests"); + assert_eq!(response.reason, "RateLimitExceeded"); + assert_eq!(response.message, "Too many active Console sessions"); + assert!(response.next_actions.is_empty()); + assert!(response.details.is_none()); + } + #[test] fn action_required_maps_to_stable_error_contract() -> std::result::Result<(), serde_json::Error> { diff --git a/src/console/handlers/auth.rs b/src/console/handlers/auth.rs index a51a14d..d2c4d0a 100755 --- a/src/console/handlers/auth.rs +++ b/src/console/handlers/auth.rs @@ -12,18 +12,27 @@ // See the License for the specific language governing permissions and // limitations under the License. -use axum::{Extension, Json, extract::State, http::header, response::IntoResponse}; +use axum::{ + Extension, Json, + extract::{OriginalUri, State}, + http::{HeaderMap, HeaderName, Uri, header}, + response::IntoResponse, +}; use kube::Client; -use snafu::ResultExt; use crate::console::{ - error::{self, Error, Result}, + error::{Error, Result}, models::auth::{LoginRequest, LoginResponse, SessionResponse}, - state::{AppState, Claims, SESSION_TTL_SECONDS}, + state::{ + AppState, Claims, MAX_SESSION_TOKEN_BYTES, SESSION_TTL_SECONDS, SessionError, + session_cookie_value, + }, }; use crate::types::v1alpha1::tenant::Tenant; -/// Exchange a Kubernetes bearer token for an encrypted session cookie. +type LoginHttpResponse = ([(HeaderName, String); 1], Json); + +/// Exchange a Kubernetes bearer token for a server-side Console session. // TOKEN=$(kubectl create token rustfs-operator-console -n rustfs-system --duration=24h) // curl -X POST http://localhost:9090/api/v1/login \ // -H "Content-Type: application/json" \ @@ -34,6 +43,12 @@ pub async fn login( ) -> Result { tracing::info!("Console login attempt"); + if req.token.len() > MAX_SESSION_TOKEN_BYTES { + return Err(Error::BadRequest { + message: format!("Kubernetes bearer token exceeds {MAX_SESSION_TOKEN_BYTES} bytes"), + }); + } + // Validate the bearer token by building a client let client = create_k8s_client(&req.token).await?; @@ -51,17 +66,20 @@ pub async fn login( } })?; - let token = state - .create_session(req.token) - .context(error::SessionSnafu)?; - - // HttpOnly session cookie - let cookie = session_cookie(&token); - - let headers = [(header::SET_COOKIE, cookie)]; + complete_validated_login(&state, req.token) +} +fn complete_validated_login(state: &AppState, k8s_token: String) -> Result { + let session_id = state + .create_session(k8s_token) + .map_err(|source| match source { + SessionError::Capacity | SessionError::PerTokenCapacity => Error::TooManyRequests { + message: "Too many active Console sessions".to_string(), + }, + source => Error::Session { source }, + })?; Ok(( - headers, + [(header::SET_COOKIE, session_cookie(&session_id))], Json(LoginResponse { success: true, message: "Login successful".to_string(), @@ -69,21 +87,349 @@ pub async fn login( )) } -/// Clear the session cookie. -pub async fn logout() -> impl IntoResponse { +pub async fn logout( + State(state): State, + OriginalUri(uri): OriginalUri, + headers: HeaderMap, +) -> Result { + if !is_trusted_logout_request(&headers, &uri) { + return Err(Error::Forbidden { + message: "Cross-site logout request denied".to_string(), + }); + } + + if let Some(session_id) = headers + .get(header::COOKIE) + .and_then(|value| value.to_str().ok()) + .and_then(session_cookie_value) + { + state + .revoke_session(session_id) + .map_err(|source| Error::Session { source })?; + } + let cookie = expired_session_cookie(); let headers = [(header::SET_COOKIE, cookie)]; - ( + Ok(( headers, Json(LoginResponse { success: true, message: "Logout successful".to_string(), }), + )) +} + +fn is_trusted_logout_request(headers: &HeaderMap, uri: &Uri) -> bool { + let allowed_origins = std::env::var("CORS_ALLOWED_ORIGINS").unwrap_or_default(); + is_trusted_logout_request_with_config( + headers, + uri, + &allowed_origins, + session_cookie_is_secure(), ) } -/// Return session validity and expiry from encrypted cookie claims. +fn is_trusted_logout_request_with_config( + headers: &HeaderMap, + uri: &Uri, + allowed_origins: &str, + secure: bool, +) -> bool { + let origin = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()); + if let Some(origin) = origin { + let scheme = if secure { "https" } else { "http" }; + let same_origin = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .into_iter() + .chain(uri.authority().map(|authority| authority.as_str())) + .any(|authority| origin.eq_ignore_ascii_case(&format!("{scheme}://{authority}"))); + let allowed_origin = allowed_origins + .split(',') + .map(str::trim) + .any(|allowed| allowed.eq_ignore_ascii_case(origin)); + return same_origin || allowed_origin; + } + + !headers + .get("sec-fetch-site") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.eq_ignore_ascii_case("cross-site")) +} + +#[cfg(test)] +mod tests { + use super::{ + complete_validated_login, is_trusted_logout_request, is_trusted_logout_request_with_config, + login, logout, + }; + use crate::console::{ + middleware::auth::auth_middleware, + state::{AppState, MAX_ACTIVE_SESSIONS, MAX_SESSION_TOKEN_BYTES, MAX_SESSIONS_PER_TOKEN}, + }; + use axum::{ + Router, + body::Body, + http::{HeaderMap, Request, StatusCode, Uri, header}, + middleware, + response::IntoResponse, + routing::{get, post}, + }; + use tower::ServiceExt; + + #[test] + fn session_cookie_contains_only_a_random_reference_id() { + let state = AppState::new("test-secret".to_string()); + let (headers, response) = + complete_validated_login(&state, "sensitive-k8s-token".to_string()) + .expect("session is created"); + let cookie = &headers[0].1; + let cookie_value = cookie + .split(';') + .next() + .and_then(|value| value.strip_prefix("session=")) + .expect("session cookie has a value"); + + assert_eq!(cookie_value.len(), 32); + assert!( + cookie_value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + ); + assert!(!cookie.contains("sensitive-k8s-token")); + assert!(response.success); + } + + #[test] + fn per_token_session_capacity_maps_to_too_many_requests() { + let state = AppState::new("test-secret".to_string()); + for _ in 0..MAX_SESSIONS_PER_TOKEN { + state + .create_session("shared-token".to_string()) + .expect("session is within the per-token limit"); + } + + let response = complete_validated_login(&state, "shared-token".to_string()) + .expect_err("per-token capacity is enforced") + .into_response(); + + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + } + + #[test] + fn global_session_capacity_maps_to_too_many_requests() { + let state = AppState::new("test-secret".to_string()); + for token_index in 0..(MAX_ACTIVE_SESSIONS / MAX_SESSIONS_PER_TOKEN) { + for _ in 0..MAX_SESSIONS_PER_TOKEN { + state + .create_session(format!("token-{token_index}")) + .expect("session is within the global limit"); + } + } + + let response = complete_validated_login(&state, "overflow-token".to_string()) + .expect_err("global capacity is enforced") + .into_response(); + + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + } + + #[test] + fn cross_site_fetch_metadata_is_not_trusted_without_origin() { + let mut headers = HeaderMap::new(); + headers.insert( + "sec-fetch-site", + "cross-site".parse().expect("valid header"), + ); + + assert!(!is_trusted_logout_request( + &headers, + &Uri::from_static("/api/v1/logout") + )); + } + + #[test] + fn http2_authority_is_accepted_for_same_origin_logout() { + let mut headers = HeaderMap::new(); + headers.insert( + header::ORIGIN, + "https://console.example.com".parse().expect("valid origin"), + ); + let uri: Uri = "https://console.example.com/api/v1/logout" + .parse() + .expect("valid URI"); + + assert!(is_trusted_logout_request(&headers, &uri)); + } + + #[test] + fn logout_origin_must_match_cookie_scheme_or_allowlist() { + let mut headers = HeaderMap::new(); + headers.insert( + header::HOST, + "console.example.com".parse().expect("valid host"), + ); + headers.insert( + header::ORIGIN, + "http://console.example.com".parse().expect("valid origin"), + ); + let uri = Uri::from_static("/api/v1/logout"); + + assert!(!is_trusted_logout_request_with_config( + &headers, &uri, "", true + )); + assert!(is_trusted_logout_request_with_config( + &headers, &uri, "", false + )); + assert!(is_trusted_logout_request_with_config( + &headers, + &uri, + "https://ui.example.com,http://console.example.com", + true, + )); + } + + #[tokio::test] + async fn logout_accepts_same_origin_host_and_revokes_session() + -> std::result::Result<(), Box> { + let state = AppState::new("test-secret".to_string()); + let session_id = state.create_session("k8s-token".to_string())?; + let app = Router::new() + .route("/api/v1/logout", post(logout)) + .with_state(state.clone()); + + let response = app + .oneshot( + Request::post("/api/v1/logout") + .header(header::COOKIE, format!("session={session_id}")) + .header(header::HOST, "console.example.com") + .header(header::ORIGIN, "https://console.example.com") + .body(Body::empty())?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + assert!(state.resolve_session(&session_id)?.is_none()); + Ok(()) + } + + #[tokio::test] + async fn login_rejects_oversized_bearer_token_before_validation() + -> std::result::Result<(), Box> { + let state = AppState::new("test-secret".to_string()); + let app = Router::new() + .route("/api/v1/login", post(login)) + .with_state(state); + let body = serde_json::to_vec(&serde_json::json!({ + "token": "x".repeat(MAX_SESSION_TOKEN_BYTES + 1) + }))?; + + let response = app + .oneshot( + Request::post("/api/v1/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body))?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + Ok(()) + } + + #[tokio::test] + async fn logout_revokes_replayed_session_cookie() + -> std::result::Result<(), Box> { + let state = AppState::new("test-secret".to_string()); + let session_id = state.create_session("k8s-token".to_string())?; + let cookie = format!("session={session_id}"); + let app = Router::new() + .route("/api/v1/logout", post(logout)) + .route("/api/v1/protected", get(|| async { "ok" })) + .with_state(state.clone()) + .layer(middleware::from_fn_with_state(state, auth_middleware)); + + let logout_response = app + .clone() + .oneshot( + Request::post("/api/v1/logout") + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::empty())?, + ) + .await?; + assert_eq!(logout_response.status(), StatusCode::OK); + assert!( + logout_response + .headers() + .get(header::SET_COOKIE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("Max-Age=0")) + ); + + let replay_response = app + .oneshot( + Request::get("/api/v1/protected") + .header(header::COOKIE, cookie) + .body(Body::empty())?, + ) + .await?; + assert_eq!(replay_response.status(), StatusCode::UNAUTHORIZED); + Ok(()) + } + + #[tokio::test] + async fn logout_rejects_untrusted_cross_site_origin() + -> std::result::Result<(), Box> { + let state = AppState::new("test-secret".to_string()); + let session_id = state.create_session("k8s-token".to_string())?; + let cookie = format!("session={session_id}"); + let app = Router::new() + .route("/api/v1/logout", post(logout)) + .with_state(state.clone()); + + let response = app + .oneshot( + Request::post("/api/v1/logout") + .header(header::COOKIE, cookie) + .header(header::HOST, "console.example.com") + .header(header::ORIGIN, "https://attacker.example") + .body(Body::empty())?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!(state.resolve_session(&session_id)?.is_some()); + Ok(()) + } + + #[tokio::test] + async fn logout_accepts_legacy_empty_post_without_browser_origin() + -> std::result::Result<(), Box> { + let state = AppState::new("test-secret".to_string()); + let session_id = state.create_session("k8s-token".to_string())?; + let cookie = format!("session={session_id}"); + let app = Router::new() + .route("/api/v1/logout", post(logout)) + .with_state(state.clone()); + + let response = app + .oneshot( + Request::post("/api/v1/logout") + .header(header::COOKIE, cookie) + .body(Body::empty())?, + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + assert!(state.resolve_session(&session_id)?.is_none()); + Ok(()) + } +} + +/// Return session validity and expiry from server-side claims. pub async fn session_check(Extension(claims): Extension) -> Json { let expires_at = i64::try_from(claims.exp) .ok() @@ -115,7 +461,7 @@ async fn create_k8s_client(token: &str) -> Result { fn session_cookie(token: &str) -> String { let same_site = console_cookie_same_site(); - let secure = if console_cookie_secure() || same_site == "None" { + let secure = if session_cookie_is_secure() { "; Secure" } else { "" @@ -127,7 +473,7 @@ fn session_cookie(token: &str) -> String { fn expired_session_cookie() -> String { let same_site = console_cookie_same_site(); - let secure = if console_cookie_secure() || same_site == "None" { + let secure = if session_cookie_is_secure() { "; Secure" } else { "" @@ -135,6 +481,10 @@ fn expired_session_cookie() -> String { format!("session=; Path=/; HttpOnly; SameSite={same_site}; Max-Age=0{secure}") } +fn session_cookie_is_secure() -> bool { + console_cookie_secure() || console_cookie_same_site() == "None" +} + fn console_cookie_secure() -> bool { match std::env::var("CONSOLE_COOKIE_SECURE") { Ok(value) => !matches!( diff --git a/src/console/handlers/events.rs b/src/console/handlers/events.rs index 1776c54..f0f51bc 100755 --- a/src/console/handlers/events.rs +++ b/src/console/handlers/events.rs @@ -37,7 +37,7 @@ use tokio_stream::wrappers::ReceiverStream; /// SSE stream of merged tenant-scoped Kubernetes events (PRD §5.1). /// -/// Uses the same encrypted `session` cookie as other console routes. Payloads use named SSE events: +/// Uses the same server-side `session` cookie as other console routes. Payloads use named SSE events: /// - `snapshot`: JSON [`EventListResponse`] /// - `stream_error`: JSON `{"message":"..."}` (watch/snapshot failures) pub async fn stream_tenant_events( diff --git a/src/console/middleware/auth.rs b/src/console/middleware/auth.rs index a277857..020930b 100755 --- a/src/console/middleware/auth.rs +++ b/src/console/middleware/auth.rs @@ -20,11 +20,11 @@ use axum::{ }; use crate::console::error::Error; -use crate::console::state::AppState; +use crate::console::state::{AppState, session_cookie_value}; -/// Encrypted cookie session middleware. +/// Server-side cookie session middleware. /// -/// Reads the `session` cookie, validates the encrypted claims, and inserts `Claims` into request extensions. +/// Reads the `session` ID, resolves encrypted claims, and inserts `Claims` into request extensions. pub async fn auth_middleware( State(state): State, mut request: Request, @@ -55,11 +55,12 @@ pub async fn auth_middleware( .and_then(|v| v.to_str().ok()) .unwrap_or(""); - let token = parse_session_cookie(cookies) + let token = session_cookie_value(cookies) .ok_or_else(|| unauthorized_response("Missing or invalid session"))?; let claims = state - .resolve_session(&token) + .resolve_session(token) + .map_err(|source| Error::Session { source }.into_response())? .ok_or_else(|| unauthorized_response("Missing or invalid session"))?; // Stash claims for handlers @@ -75,18 +76,6 @@ fn unauthorized_response(message: &str) -> Response { .into_response() } -/// Extract `session=` from a raw `Cookie` header value. -fn parse_session_cookie(cookies: &str) -> Option { - cookies.split(';').find_map(|cookie| { - let parts: Vec<&str> = cookie.trim().splitn(2, '=').collect(); - if parts.len() == 2 && parts[0] == "session" { - Some(parts[1].to_string()) - } else { - None - } - }) -} - #[cfg(test)] mod tests { use super::*; @@ -102,18 +91,6 @@ mod tests { use crate::console::state::AppState; - #[test] - fn test_parse_session_cookie() { - let cookies = "session=test_token; other=value"; - assert_eq!( - parse_session_cookie(cookies), - Some("test_token".to_string()) - ); - - let cookies = "other=value"; - assert_eq!(parse_session_cookie(cookies), None); - } - #[tokio::test] async fn missing_session_returns_standard_error_contract() -> Result<(), Box> { diff --git a/src/console/openapi.rs b/src/console/openapi.rs index baca60b..22522ea 100644 --- a/src/console/openapi.rs +++ b/src/console/openapi.rs @@ -180,12 +180,34 @@ use crate::types::v1alpha1::status::provisioning::{ pub struct ApiDoc; // --- Auth --- -#[utoipa::path(post, path = "/api/v1/login", request_body = LoginRequest, responses((status = 200, body = LoginResponse)), tag = "auth")] +#[utoipa::path( + post, + path = "/api/v1/login", + request_body = LoginRequest, + responses( + (status = 200, body = LoginResponse), + (status = 400, body = ConsoleErrorResponse), + (status = 401, body = ConsoleErrorResponse), + (status = 429, body = ConsoleErrorResponse), + (status = 500, body = ConsoleErrorResponse) + ), + tag = "auth" +)] fn api_login(_body: Json) -> Json { unimplemented!("Documentation only") } -#[utoipa::path(post, path = "/api/v1/logout", responses((status = 200)), tag = "auth")] +#[utoipa::path( + post, + path = "/api/v1/logout", + responses( + (status = 200, body = LoginResponse), + (status = 400, body = ConsoleErrorResponse), + (status = 403, body = ConsoleErrorResponse), + (status = 500, body = ConsoleErrorResponse) + ), + tag = "auth" +)] fn api_logout() {} #[utoipa::path(get, path = "/api/v1/session", responses((status = 200, body = SessionResponse)), tag = "auth")] diff --git a/src/console/state.rs b/src/console/state.rs index b77474e..5a21829 100755 --- a/src/console/state.rs +++ b/src/console/state.rs @@ -21,25 +21,42 @@ use ring::{ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use snafu::Snafu; -use std::sync::Arc; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; pub const SESSION_TTL_SECONDS: usize = 12 * 3600; +pub(crate) const MAX_SESSION_TOKEN_BYTES: usize = 16 * 1024; const SESSION_AAD: &[u8] = b"rustfs-operator-console-session-v1"; const SESSION_KEY_CONTEXT: &[u8] = b"rustfs-operator-console-session-key-v1"; const SESSION_NONCE_LEN: usize = 12; +const SESSION_ID_BYTES: usize = 16; +const SESSION_ID_LEN: usize = SESSION_ID_BYTES * 2; +pub(crate) const MAX_ACTIVE_SESSIONS: usize = 4096; +pub(crate) const MAX_SESSIONS_PER_TOKEN: usize = 8; /// Shared Axum application state. /// /// Holds global config such as the Console session encryption secret. #[derive(Clone)] pub struct AppState { - /// Symmetric key source for encrypting session cookies. + /// Symmetric key source for encrypting server-side session data. pub jwt_secret: Arc, /// Optional Kubernetes client used by control-plane APIs that need cluster access. /// /// Most unit tests run without a live cluster, so this is optional. pub kube_client: Option, + + sessions: Arc>>>, +} + +#[derive(Clone)] +struct StoredSession { + sealed_claims: String, + token_fingerprint: [u8; 32], + expires_at: usize, } impl AppState { @@ -48,6 +65,7 @@ impl AppState { Self { jwt_secret: Arc::new(jwt_secret), kube_client: None, + sessions: Arc::new(Mutex::new(HashMap::new())), } } @@ -58,34 +76,94 @@ impl AppState { } pub fn create_session(&self, k8s_token: String) -> Result { + if k8s_token.len() > MAX_SESSION_TOKEN_BYTES { + return Err(SessionError::TokenTooLarge); + } let iat = current_timestamp(); let exp = iat.saturating_add(SESSION_TTL_SECONDS); + let session_id = generate_session_id()?; + let token_fingerprint = Sha256::digest(k8s_token.as_bytes()).into(); let claims = SessionClaims { + session_id: session_id.clone(), k8s_token, exp, iat, }; - seal_session_token(&self.jwt_secret, &claims) + let sealed_claims = seal_session_token(&self.jwt_secret, &claims)?; + let mut sessions = self.sessions.lock().map_err(|_| SessionError::StoreLock)?; + sessions.retain(|_, session| session.expires_at > iat); + if sessions.len() >= MAX_ACTIVE_SESSIONS { + return Err(SessionError::Capacity); + } + if sessions + .values() + .filter(|session| session.token_fingerprint == token_fingerprint) + .count() + >= MAX_SESSIONS_PER_TOKEN + { + return Err(SessionError::PerTokenCapacity); + } + match sessions.entry(session_id.clone()) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(Arc::new(StoredSession { + sealed_claims, + token_fingerprint, + expires_at: exp, + })); + } + std::collections::hash_map::Entry::Occupied(_) => { + return Err(SessionError::IdCollision); + } + } + Ok(session_id) } - pub fn resolve_session(&self, token: &str) -> Option { - let session_claims = match open_session_token(&self.jwt_secret, token) { - Ok(claims) => claims, - Err(error) => { - tracing::warn!(%error, "Console session token validation failed"); - return None; - } + pub fn resolve_session(&self, session_id: &str) -> Result, SessionError> { + if !is_valid_session_id(session_id) { + return Ok(None); + } + let Some(stored_session) = self + .sessions + .lock() + .map_err(|_| SessionError::StoreLock)? + .get(session_id) + .cloned() + else { + return Ok(None); }; + let session_claims = + match open_session_token(&self.jwt_secret, &stored_session.sealed_claims) { + Ok(claims) => claims, + Err(error) => { + tracing::warn!(%error, "Console session token validation failed"); + return Ok(None); + } + }; + if session_claims.session_id != session_id { + return Ok(None); + } let now = current_timestamp(); - if session_claims.exp < now { - return None; + if session_claims.exp <= now { + self.revoke_session(session_id)?; + return Ok(None); } - Some(Claims { + Ok(Some(Claims { k8s_token: session_claims.k8s_token, exp: session_claims.exp, iat: session_claims.iat, - }) + })) + } + + pub fn revoke_session(&self, session_id: &str) -> Result<(), SessionError> { + if !is_valid_session_id(session_id) { + return Ok(()); + } + self.sessions + .lock() + .map_err(|_| SessionError::StoreLock)? + .remove(session_id); + Ok(()) } } @@ -97,10 +175,10 @@ pub struct Claims { pub iat: usize, } -/// Encrypted browser cookie session claims. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct SessionClaims { + pub session_id: String, pub k8s_token: String, pub exp: usize, pub iat: usize, @@ -131,12 +209,49 @@ pub enum SessionError { #[snafu(display("failed to decrypt session token"))] Decrypt, + + #[snafu(display("session store lock is poisoned"))] + StoreLock, + + #[snafu(display("generated duplicate session identifier"))] + IdCollision, + + #[snafu(display("maximum active Console sessions reached"))] + Capacity, + + #[snafu(display("maximum active Console sessions for this token reached"))] + PerTokenCapacity, + + #[snafu(display("Kubernetes bearer token exceeds the session size limit"))] + TokenTooLarge, } fn current_timestamp() -> usize { usize::try_from(chrono::Utc::now().timestamp()).unwrap_or(0) } +fn generate_session_id() -> Result { + let mut session_id = [0u8; SESSION_ID_BYTES]; + SystemRandom::new() + .fill(&mut session_id) + .map_err(|_| SessionError::Random)?; + Ok(hex::encode(session_id)) +} + +fn is_valid_session_id(session_id: &str) -> bool { + session_id.len() == SESSION_ID_LEN + && session_id + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +pub(crate) fn session_cookie_value(cookies: &str) -> Option<&str> { + cookies.split(';').find_map(|cookie| { + let (name, value) = cookie.trim().split_once('=')?; + (name == "session").then_some(value) + }) +} + fn seal_session_token(jwt_secret: &str, claims: &SessionClaims) -> Result { let mut nonce_bytes = [0u8; SESSION_NONCE_LEN]; SystemRandom::new() @@ -208,48 +323,348 @@ mod tests { let claims = state .resolve_session(&token) + .expect("session store is available") .expect("encrypted session resolves"); assert_eq!(claims.k8s_token, "sensitive-k8s-token"); } #[test] - fn session_cookie_token_resolves_across_replicas_with_same_secret() { - let first_replica = AppState::new("shared-secret".to_string()); - let second_replica = AppState::new("shared-secret".to_string()); - let token = first_replica + fn cloned_state_shares_session_store() { + let state = AppState::new("shared-secret".to_string()); + let cloned_state = state.clone(); + let token = state .create_session("replica-safe-token".to_string()) .expect("session token is encrypted"); - let claims = second_replica + let claims = cloned_state .resolve_session(&token) + .expect("session store is available") .expect("same secret resolves session"); assert_eq!(claims.k8s_token, "replica-safe-token"); } + #[test] + fn process_restart_drops_existing_sessions() { + let first_process = AppState::new("shared-secret".to_string()); + let session_id = first_process + .create_session("k8s-token".to_string()) + .expect("session is created"); + let restarted_process = AppState::new("shared-secret".to_string()); + + assert!( + restarted_process + .resolve_session(&session_id) + .expect("session store is available") + .is_none() + ); + } + #[test] fn session_cookie_token_rejects_different_secret() { let first_replica = AppState::new("first-secret".to_string()); - let second_replica = AppState::new("second-secret".to_string()); + let second_replica = AppState { + jwt_secret: Arc::new("second-secret".to_string()), + kube_client: None, + sessions: first_replica.sessions.clone(), + }; let token = first_replica .create_session("replica-safe-token".to_string()) .expect("session token is encrypted"); - assert!(second_replica.resolve_session(&token).is_none()); + assert!( + second_replica + .resolve_session(&token) + .expect("session store is available") + .is_none() + ); } #[test] - fn session_cookie_token_rejects_tampering() { + fn revoked_session_cookie_no_longer_resolves() { let state = AppState::new("test-secret".to_string()); let token = state .create_session("sensitive-k8s-token".to_string()) .expect("session token is encrypted"); - let mut token_bytes = URL_SAFE_NO_PAD - .decode(&token) - .expect("session token decodes"); - let last_byte = token_bytes.last_mut().expect("session token is non-empty"); - *last_byte ^= 1; - let tampered_token = URL_SAFE_NO_PAD.encode(token_bytes); - - assert!(state.resolve_session(&tampered_token).is_none()); + + state.revoke_session(&token).expect("session is revoked"); + + assert!( + state + .resolve_session(&token) + .expect("session store is available") + .is_none() + ); + } + + #[test] + fn revoking_one_session_keeps_other_sessions_valid() { + let state = AppState::new("test-secret".to_string()); + let revoked = state + .create_session("first-token".to_string()) + .expect("first session is created"); + let active = state + .create_session("second-token".to_string()) + .expect("second session is created"); + + state + .revoke_session(&revoked) + .expect("first session is revoked"); + + assert!( + state + .resolve_session(&revoked) + .expect("session store is available") + .is_none() + ); + assert_eq!( + state + .resolve_session(&active) + .expect("session store is available") + .expect("second session remains active") + .k8s_token, + "second-token" + ); + } + + #[test] + fn session_payload_cannot_be_moved_to_another_cookie_id() { + let state = AppState::new("test-secret".to_string()); + let first = state + .create_session("first-token".to_string()) + .expect("first session is created"); + let second = state + .create_session("second-token".to_string()) + .expect("second session is created"); + let mut sessions = state + .sessions + .lock() + .expect("session store lock is available"); + let copied_payload = sessions + .get(&second) + .expect("second session is stored") + .sealed_claims + .clone(); + let first_session = + Arc::make_mut(sessions.get_mut(&first).expect("first session is stored")); + first_session.sealed_claims = copied_payload; + drop(sessions); + + assert!( + state + .resolve_session(&first) + .expect("session store is available") + .is_none() + ); + } + + #[test] + fn tampered_session_payload_does_not_resolve() { + let state = AppState::new("test-secret".to_string()); + let session_id = state + .create_session("k8s-token".to_string()) + .expect("session is created"); + let mut sessions = state + .sessions + .lock() + .expect("session store lock is available"); + let stored_session = + Arc::make_mut(sessions.get_mut(&session_id).expect("session is stored")); + let replacement = if stored_session.sealed_claims.starts_with('A') { + "B" + } else { + "A" + }; + stored_session.sealed_claims.replace_range(..1, replacement); + drop(sessions); + + assert!( + state + .resolve_session(&session_id) + .expect("session store is available") + .is_none() + ); + } + + #[test] + fn request_time_expiry_revokes_session() { + let state = AppState::new("test-secret".to_string()); + let session_id = "0123456789abcdef0123456789abcdef".to_string(); + let claims = SessionClaims { + session_id: session_id.clone(), + k8s_token: "expired-token".to_string(), + exp: current_timestamp(), + iat: current_timestamp().saturating_sub(1), + }; + let sealed_claims = seal_session_token(&state.jwt_secret, &claims) + .expect("expired session claims are encrypted"); + state + .sessions + .lock() + .expect("session store lock is available") + .insert( + session_id.clone(), + Arc::new(StoredSession { + sealed_claims, + token_fingerprint: [0; 32], + expires_at: usize::MAX, + }), + ); + + assert!( + state + .resolve_session(&session_id) + .expect("session store is available") + .is_none() + ); + assert!( + state + .sessions + .lock() + .expect("session store lock is available") + .get(&session_id) + .is_none() + ); + } + + #[test] + fn generated_session_id_is_valid() { + let session_id = generate_session_id().expect("session id is generated"); + + assert_eq!(session_id.len(), SESSION_ID_LEN); + assert!(is_valid_session_id(&session_id)); + } + + #[test] + fn invalid_session_ids_are_rejected() { + for session_id in [ + "", + "0123456789abcdef0123456789abcde", + "0123456789abcdef0123456789abcdef0", + "0123456789abcdef0123456789abcdeg", + "0123456789ABCDEF0123456789ABCDEF", + ] { + assert!(!is_valid_session_id(session_id)); + } + } + + #[test] + fn active_session_count_is_bounded() { + let state = AppState::new("test-secret".to_string()); + let mut sessions = state + .sessions + .lock() + .expect("session store lock is available"); + for index in 0..(MAX_ACTIVE_SESSIONS - 1) { + sessions.insert( + format!("{index:032x}"), + Arc::new(StoredSession { + sealed_claims: String::new(), + token_fingerprint: [0; 32], + expires_at: usize::MAX, + }), + ); + } + drop(sessions); + + state + .create_session("last-token".to_string()) + .expect("last available session slot is usable"); + + assert!(matches!( + state.create_session("another-token".to_string()), + Err(SessionError::Capacity) + )); + } + + #[test] + fn expired_sessions_do_not_consume_global_capacity() { + let state = AppState::new("test-secret".to_string()); + let mut sessions = state + .sessions + .lock() + .expect("session store lock is available"); + for index in 0..MAX_ACTIVE_SESSIONS { + sessions.insert( + format!("{index:032x}"), + Arc::new(StoredSession { + sealed_claims: String::new(), + token_fingerprint: [0; 32], + expires_at: 0, + }), + ); + } + drop(sessions); + + state + .create_session("new-token".to_string()) + .expect("expired sessions release capacity during login"); + } + + #[test] + fn oversized_session_token_is_rejected() { + let state = AppState::new("test-secret".to_string()); + + state + .create_session("x".repeat(MAX_SESSION_TOKEN_BYTES)) + .expect("token at the size limit is accepted"); + + assert!(matches!( + state.create_session("x".repeat(MAX_SESSION_TOKEN_BYTES + 1)), + Err(SessionError::TokenTooLarge) + )); + } + + #[test] + fn sessions_per_token_are_bounded() { + let state = AppState::new("test-secret".to_string()); + for _ in 0..MAX_SESSIONS_PER_TOKEN { + state + .create_session("shared-token".to_string()) + .expect("session is created within the per-token limit"); + } + + assert!(matches!( + state.create_session("shared-token".to_string()), + Err(SessionError::PerTokenCapacity) + )); + + state + .create_session("different-token".to_string()) + .expect("one token cannot consume another token's session allowance"); + } + + #[test] + fn expired_sessions_do_not_consume_per_token_capacity() { + let state = AppState::new("test-secret".to_string()); + let token = "shared-token"; + let token_fingerprint = Sha256::digest(token.as_bytes()).into(); + let mut sessions = state + .sessions + .lock() + .expect("session store lock is available"); + for index in 0..MAX_SESSIONS_PER_TOKEN { + sessions.insert( + format!("{index:032x}"), + Arc::new(StoredSession { + sealed_claims: String::new(), + token_fingerprint, + expires_at: 0, + }), + ); + } + drop(sessions); + + state + .create_session(token.to_string()) + .expect("expired sessions release the token allowance during login"); + } + + #[test] + fn session_cookie_value_extracts_session_id() { + assert_eq!( + session_cookie_value("session=test_token; other=value"), + Some("test_token") + ); + assert_eq!(session_cookie_value("other=value"), None); } }