From c35e581780020a47035e5cc41298a45f3ce0299d Mon Sep 17 00:00:00 2001 From: Taylor Mutch Date: Mon, 26 Oct 2026 21:07:09 -0700 Subject: [PATCH] feat(auth): authenticate gateway-mediated application requests Require configured gateway authentication for application HTTP requests and WebSocket upgrades, with or without the independent app listener. Add browser sessions, application credential separation, shared revocation, request authorization, documentation, and browser coverage. Closes #4722 Signed-off-by: Taylor Mutch --- crates/openshell-server/README.md | 33 + crates/openshell-server/src/auth/http.rs | 14 + crates/openshell-server/src/auth/mod.rs | 2 + crates/openshell-server/src/auth/oidc.rs | 8 +- .../src/auth/service_access.rs | 466 ++++++ .../src/auth/service_session.rs | 1372 +++++++++++++++++ crates/openshell-server/src/credentials.rs | 80 + crates/openshell-server/src/lib.rs | 112 +- crates/openshell-server/src/multiplex.rs | 17 +- .../openshell-server/src/service_routing.rs | 522 ++++++- .../tests/browser-service-auth.cjs | 82 + .../openshell-supervisor/src/app_listener.rs | 28 +- docs/how-it-works/gateways/authentication.mdx | 105 +- docs/how-it-works/gateways/configuration.mdx | 11 + docs/how-it-works/sandboxes/overview.mdx | 12 +- rfc/0017-sandbox-service-exposure/README.md | 6 +- skills/debug-openshell-cluster/SKILL.md | 17 + 17 files changed, 2859 insertions(+), 28 deletions(-) create mode 100644 crates/openshell-server/src/auth/service_access.rs create mode 100644 crates/openshell-server/src/auth/service_session.rs create mode 100644 crates/openshell-server/tests/browser-service-auth.cjs diff --git a/crates/openshell-server/README.md b/crates/openshell-server/README.md index 062c07dd9d..2e3890f8f1 100644 --- a/crates/openshell-server/README.md +++ b/crates/openshell-server/README.md @@ -231,6 +231,39 @@ after one minute or a five-second blocked send. Each gateway process accepts at most 32 watches. Reconnect and relist on stream termination; revisions are opaque equality tokens, not resumable cursors or an ordering across gateway replicas. +## Gateway application authentication + +Gateway-mediated application HTTP requests and WebSocket upgrades authenticate +through the gateway chain or verified TLS client identity before endpoint lookup, +independently of app-listener enablement. User access requires TCP-forwarding +role/scope policy (`sandbox:write` when enforced) and current workspace membership; +operators retain their platform grant. Internal principals are rejected. Only +explicit local-development unauthenticated access bypasses this gate. + +`/auth/services` hosts browser login on the control-plane origin. The application +origin receives a single-use bootstrap code bound to a browser login nonce, then +a distinct host-only, Secure/HttpOnly session cookie. Shared metadata contains +only credential-driver handles and hashes; the configured credential driver +stores the access token. Login codes expire after two minutes, sessions after +at most ten minutes or token expiry. Every request revalidates the token and +workspace authorization. Session deletion, credential revocation, and redemption +CAS use the shared database. Expired metadata cleanup and admission are bounded. +The application path consumes standard gateway bearer headers, or authenticates +through a separate `X-OpenShell-Authorization`, browser session, or mTLS identity +while preserving application bearer passthrough and cookies. Gateway headers and +reserved cookies never reach the workload, and workloads cannot set reserved +browser auth cookies. Browser ambient credentials require origin checks for +unsafe methods and WebSocket upgrades. Direct supervisor ingress delegates +application authentication to its ingress provider. + +The optional real-Chrome login test uses an isolated profile and local test +credentials. Run it with `OPENSHELL_BROWSER_E2E_CHROME` pointing to Chrome: + +```shell +OPENSHELL_BROWSER_E2E_CHROME='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' \ + cargo test -p openshell-server --lib gateway_service_auth_real_browser_cross_site_login -- --ignored +``` + ## Application URL publication `supervisor.app_listener.public_url` is an optional HTTPS origin matching the diff --git a/crates/openshell-server/src/auth/http.rs b/crates/openshell-server/src/auth/http.rs index 8f0bb7d11c..538a3c9107 100644 --- a/crates/openshell-server/src/auth/http.rs +++ b/crates/openshell-server/src/auth/http.rs @@ -57,6 +57,10 @@ struct ConnectParams { /// Create the auth router. pub fn router(state: Arc) -> Router { Router::new() + .route( + "/auth/services", + get(service_browser_login).post(service_browser_login), + ) .route("/auth/connect", get(auth_connect)) .route("/auth/oidc-config", get(oidc_config_handler)) .route("/.well-known/jwks.json", get(gateway_jwks_handler)) @@ -67,6 +71,16 @@ pub fn router(state: Arc) -> Router { .with_state(state) } +async fn service_browser_login( + State(state): State>, + req: axum::extract::Request, +) -> axum::response::Response { + match super::service_session::control(&state, req).await { + Ok(response) => response, + Err(error) => super::service_session::error(&error), + } +} + /// Publish OIDC-shaped discovery metadata for extension services. /// /// This lets an extension be configured with only the trusted gateway URL: diff --git a/crates/openshell-server/src/auth/mod.rs b/crates/openshell-server/src/auth/mod.rs index f1800d5044..f4ae2d08a9 100644 --- a/crates/openshell-server/src/auth/mod.rs +++ b/crates/openshell-server/src/auth/mod.rs @@ -25,6 +25,8 @@ pub mod principal; pub mod sandbox_jwt; pub mod sandbox_methods; pub mod sandbox_session; +pub mod service_access; +pub mod service_session; pub mod supervisor_token; pub mod workspace_authz; diff --git a/crates/openshell-server/src/auth/oidc.rs b/crates/openshell-server/src/auth/oidc.rs index 08f22d4b57..9e19a9431c 100644 --- a/crates/openshell-server/src/auth/oidc.rs +++ b/crates/openshell-server/src/auth/oidc.rs @@ -948,7 +948,7 @@ impl Authenticator for OidcAuthenticator { } #[cfg(test)] -mod tests { +pub mod tests { use super::*; fn transport_test_config(issuer: impl Into) -> OidcConfig { @@ -1418,7 +1418,7 @@ mod tests { } /// Sign `claims` with the test key, tagging the header with `kid`. - fn mint_rs256(claims: &serde_json::Value, kid: &str) -> String { + pub fn mint_rs256(claims: &serde_json::Value, kid: &str) -> String { crate::install_jsonwebtoken_crypto_provider(); let mut header = jsonwebtoken::Header::new(Algorithm::RS256); @@ -1428,7 +1428,7 @@ mod tests { openshell_crypto::jwt::encode(&header, claims, &key).expect("sign RS256 token") } - fn claims_for(issuer: &str, audience: &str, exp: i64) -> serde_json::Value { + pub fn claims_for(issuer: &str, audience: &str, exp: i64) -> serde_json::Value { serde_json::json!({ "sub": "user-42", "preferred_username": "ada", @@ -1442,7 +1442,7 @@ mod tests { /// Serve an OIDC discovery document and a JWKS carrying the test key, then /// build a cache against them the same way production does. - async fn cache_with_mock_issuer(server: &wiremock::MockServer) -> JwksCache { + pub async fn cache_with_mock_issuer(server: &wiremock::MockServer) -> JwksCache { use wiremock::matchers::{method, path}; use wiremock::{Mock, ResponseTemplate}; diff --git a/crates/openshell-server/src/auth/service_access.rs b/crates/openshell-server/src/auth/service_access.rs new file mode 100644 index 0000000000..76236d8c1e --- /dev/null +++ b/crates/openshell-server/src/auth/service_access.rs @@ -0,0 +1,466 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Authentication for gateway-mediated application traffic. Direct supervisor +//! ingress has its own ingress-provider authentication contract. + +use super::{ + audit::{self, AuthzDenial, ConnectionSource, RequestOrigin}, + authenticator::{AuthFailureReason, AuthMechanism, Authentication, AuthenticatorChain}, + authz::AuthzPolicy, + identity::{Identity, IdentityProvider}, + principal::{OperatorPrincipal, Principal, UserPrincipal}, + workspace_authz::{MinWorkspaceRole, authorize_workspace}, +}; +use crate::ServerState; +use http::{HeaderMap, header}; +use tonic::Status; + +const APPLICATION_PATH: &str = "/openshell/application"; +// Application requests can perform arbitrary operations, just like a TCP +// forward. Reuse that operation's global role and sandbox:write scope policy. +const ACCESS_POLICY: &str = "/openshell.v1.OpenShell/ForwardTcp"; + +/// Only populated from a verified TLS connection, never forwarded HTTP headers. +#[derive(Clone, Default)] +pub struct ServicePeer { + pub secure: bool, + pub identity: Option, + pub certificate_sha256: Option, + pub source: Option, +} + +pub async fn authorize( + state: &ServerState, + headers: &HeaderMap, + peer: &ServicePeer, + workspace: &str, +) -> Result { + audit::with_request( + APPLICATION_PATH.to_string(), + RequestOrigin::new(peer.source, headers), + authorize_with_chain( + state, + headers, + peer, + workspace, + crate::multiplex::build_authenticator_chain(state), + ), + ) + .await +} + +async fn authorize_with_chain( + state: &ServerState, + headers: &HeaderMap, + peer: &ServicePeer, + workspace: &str, + chain: Option, +) -> Result { + let origin = RequestOrigin::new(peer.source, headers); + let principal = authenticate(state, headers, peer, chain).await.map_err( + |(status, mechanism, reason)| { + audit::emit_authentication_failure(APPLICATION_PATH, &origin, mechanism, reason); + status + }, + )?; + if (state.config.mtls_auth.enabled || state.config.mtls_auth.operator_enabled) + && let Some(fingerprint) = &peer.certificate_sha256 + { + super::service_session::check_certificate(state, fingerprint).await?; + } + if let Some(token) = super::authenticator::bearer_credential(headers) { + super::service_session::check_token(state, token).await?; + } + audit::emit_logon( + &state.logons, + APPLICATION_PATH, + &origin, + &principal, + headers, + peer.certificate_sha256.as_deref(), + ); + match &principal { + Principal::User(user) => { + if let Some(oidc) = &state.config.oidc { + let policy = AuthzPolicy { + admin_role: oidc.admin_role.clone(), + user_role: oidc.user_role.clone(), + scopes_enabled: !oidc.scopes_claim.is_empty(), + }; + if let Err(denial) = policy.check(&user.identity, ACCESS_POLICY) { + audit::emit_authorization_denial(&principal, denial.denial); + return Err(denial.status); + } + } + } + Principal::Operator(_) => {} + _ => { + return Err(audit::deny( + &principal, + AuthzDenial::MethodNotPermitted, + "application access requires a user identity", + )); + } + } + authorize_workspace( + &state.store, + &state.admin_role, + &principal, + workspace, + MinWorkspaceRole::User, + ) + .await?; + Ok(principal) +} + +type AuthenticationError = (Status, Option, AuthFailureReason); + +async fn authenticate( + state: &ServerState, + headers: &HeaderMap, + peer: &ServicePeer, + chain: Option, +) -> Result { + let reject = |message, reason| (Status::unauthenticated(message), None, reason); + let has_authorization = headers.contains_key(header::AUTHORIZATION); + if headers + .get_all(header::AUTHORIZATION) + .iter() + .any(|value| value.as_bytes().len() > 16384) + { + return Err(reject( + "authorization header exceeds limit", + AuthFailureReason::MalformedCredential, + )); + } + // Reject ambiguous credentials before any authenticator can select one. + if headers.get_all(header::AUTHORIZATION).iter().count() > 1 { + return Err(reject( + "ambiguous authorization headers", + AuthFailureReason::ConflictingCredentials, + )); + } + if state.config.mtls_auth.operator_enabled + && let Some(identity) = peer.identity.as_ref().filter(|identity| { + identity.provider == IdentityProvider::Mtls + && identity.roles.iter().any(|role| role == "operator") + }) + && let Some(certificate_sha256) = &peer.certificate_sha256 + { + if has_authorization { + return Err(reject( + "operator certificates must not be combined with authorization headers", + AuthFailureReason::ConflictingCredentials, + )); + } + return Ok(Principal::Operator(OperatorPrincipal { + identity: identity.clone(), + certificate_sha256: certificate_sha256.clone(), + })); + } + if let Some(chain) = chain { + match chain.authenticate(headers, APPLICATION_PATH).await { + Ok(Authentication::Accepted(principal)) => return Ok(principal), + Err(rejection) => { + return Err(( + rejection.status, + Some(rejection.mechanism), + rejection.reason, + )); + } + Ok(Authentication::Declined(decline)) => { + return Err(( + Status::unauthenticated("invalid application access credential"), + Some(decline.mechanism), + decline.reason, + )); + } + Ok(Authentication::NotApplicable) => {} + } + } + if has_authorization { + return Err(reject( + "invalid authorization header", + AuthFailureReason::MalformedCredential, + )); + } + if state.config.mtls_auth.enabled { + if let Some(identity) = &peer.identity { + return Ok(Principal::User(UserPrincipal { + identity: identity.clone(), + })); + } + // Match the configured gateway's explicit local-development bypass. + if !state.config.auth.allow_unauthenticated_users { + return Err(reject( + "missing client certificate", + AuthFailureReason::MissingCredential, + )); + } + } + if state.config.auth.allow_unauthenticated_users { + return Ok(crate::multiplex::unauthenticated_dev_user_principal()); + } + Err(reject( + "authentication required", + AuthFailureReason::MissingCredential, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::authenticator::{AuthRejection, Authenticator}; + use crate::grpc::test_support::test_server_state; + use async_trait::async_trait; + use openshell_core::proto::{WorkspaceMember, WorkspaceRole, datamodel::v1::ObjectMeta}; + use std::sync::Arc; + + #[derive(Clone)] + struct TestAuth(Result); + + #[async_trait] + impl Authenticator for TestAuth { + async fn authenticate( + &self, + _headers: &HeaderMap, + path: &str, + ) -> Result { + assert_eq!(path, APPLICATION_PATH); + self.0.clone() + } + } + + fn chain(outcome: Result) -> AuthenticatorChain { + AuthenticatorChain::new(vec![Arc::new(TestAuth(outcome))]) + } + + fn identity() -> Identity { + Identity { + subject: "alice".into(), + display_name: None, + roles: vec!["openshell-user".into()], + scopes: vec!["sandbox:write".into()], + provider: IdentityProvider::Oidc, + } + } + + async fn state() -> Arc { + let mut state = test_server_state().await; + let inner = Arc::get_mut(&mut state).unwrap(); + inner.config.auth.allow_unauthenticated_users = false; + inner.admin_role = "openshell-admin".into(); + inner.config.oidc = Some(serde_json::from_value(serde_json::json!({ + "issuer": "https://issuer.example.com", "audience": "openshell", "scopes_claim": "scope" + })).unwrap()); + state + } + + async fn member(state: &ServerState) { + state + .store + .put_message(&WorkspaceMember { + metadata: Some(ObjectMeta { + id: uuid::Uuid::new_v4().to_string(), + name: "alice".into(), + workspace: "default".into(), + ..Default::default() + }), + principal_subject: "alice".into(), + role: WorkspaceRole::User.into(), + }) + .await + .unwrap(); + } + + async fn access( + state: &ServerState, + identity: Identity, + workspace: &str, + ) -> Result { + authorize_with_chain( + state, + &HeaderMap::new(), + &ServicePeer::default(), + workspace, + Some(chain(Ok(Authentication::Accepted(Principal::User( + UserPrincipal { identity }, + ))))), + ) + .await + } + + #[tokio::test] + async fn service_access_enforces_workspace_role_and_scope() { + let state = state().await; + assert_eq!( + access(&state, identity(), "default") + .await + .unwrap_err() + .code(), + tonic::Code::PermissionDenied + ); + member(&state).await; + access(&state, identity(), "default").await.unwrap(); + assert_eq!( + access(&state, identity(), "other") + .await + .unwrap_err() + .code(), + tonic::Code::PermissionDenied + ); + let mut user = identity(); + user.scopes = vec!["sandbox:read".into()]; + assert_eq!( + access(&state, user, "default").await.unwrap_err().code(), + tonic::Code::PermissionDenied + ); + let mut user = identity(); + user.roles.clear(); + assert_eq!( + access(&state, user, "default").await.unwrap_err().code(), + tonic::Code::PermissionDenied + ); + } + + #[tokio::test] + async fn service_access_rejects_internal_principals_even_with_workspace_access() { + let state = state().await; + for principal in [ + crate::auth::principal::test_support::sandbox("sandbox-id"), + Principal::Peer(crate::auth::principal::PeerPrincipal { + replica_id: "peer".into(), + pod_uid: "uid".into(), + }), + Principal::Anonymous, + ] { + let error = authorize_with_chain( + &state, + &HeaderMap::new(), + &ServicePeer::default(), + "default", + Some(chain(Ok(Authentication::Accepted(principal)))), + ) + .await + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + } + } + + #[tokio::test] + async fn service_access_rejects_missing_invalid_expired_and_duplicate_credentials() { + let mut state = state().await; + let peer = ServicePeer::default(); + assert_eq!( + authorize_with_chain( + &state, + &HeaderMap::new(), + &peer, + "default", + Some(chain(Ok(Authentication::NotApplicable))) + ) + .await + .unwrap_err() + .code(), + tonic::Code::Unauthenticated + ); + // Invalid credentials never become a local-development identity. + Arc::get_mut(&mut state) + .unwrap() + .config + .auth + .allow_unauthenticated_users = true; + for reason in [ + AuthFailureReason::InvalidCredential, + AuthFailureReason::ExpiredCredential, + ] { + assert_eq!( + authorize_with_chain( + &state, + &HeaderMap::new(), + &peer, + "default", + Some(chain(Err(AuthRejection::new( + AuthMechanism::Oidc, + reason, + Status::unauthenticated("rejected") + )))) + ) + .await + .unwrap_err() + .code(), + tonic::Code::Unauthenticated + ); + } + let mut headers = HeaderMap::new(); + headers.append(header::AUTHORIZATION, "Bearer first".parse().unwrap()); + headers.append(header::AUTHORIZATION, "Bearer second".parse().unwrap()); + assert_eq!( + authorize_with_chain( + &state, + &headers, + &peer, + "default", + Some(chain(Ok(Authentication::Accepted(Principal::User( + UserPrincipal { + identity: identity() + } + ))))) + ) + .await + .unwrap_err() + .code(), + tonic::Code::Unauthenticated + ); + } + + #[tokio::test] + async fn service_access_uses_only_verified_mtls_identity_and_operator_fingerprint() { + let mut state = state().await; + Arc::get_mut(&mut state).unwrap().config.mtls_auth.enabled = true; + member(&state).await; + let mut user = identity(); + user.provider = IdentityProvider::Mtls; + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-client-cert", "alice".parse().unwrap()); + assert!( + authorize_with_chain(&state, &headers, &ServicePeer::default(), "default", None) + .await + .is_err() + ); + let peer = ServicePeer { + identity: Some(user.clone()), + ..Default::default() + }; + authorize_with_chain(&state, &headers, &peer, "default", None) + .await + .unwrap(); + user.roles = vec!["operator".into()]; + Arc::get_mut(&mut state) + .unwrap() + .config + .mtls_auth + .operator_enabled = true; + let peer = ServicePeer { + identity: Some(user), + certificate_sha256: Some("verified-fingerprint".into()), + source: None, + secure: true, + }; + assert!(matches!( + authorize_with_chain(&state, &HeaderMap::new(), &peer, "other", None) + .await + .unwrap(), + Principal::Operator(_) + )); + headers.insert(header::AUTHORIZATION, "Bearer user".parse().unwrap()); + assert_eq!( + authorize_with_chain(&state, &headers, &peer, "other", None) + .await + .unwrap_err() + .code(), + tonic::Code::Unauthenticated + ); + } +} diff --git a/crates/openshell-server/src/auth/service_session.rs b/crates/openshell-server/src/auth/service_session.rs new file mode 100644 index 0000000000..5785a0db8e --- /dev/null +++ b/crates/openshell-server/src/auth/service_session.rs @@ -0,0 +1,1372 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Browser login happens on the control-plane origin. Only a single-use, +//! host-bound code reaches the application origin. Session secrets live in the +//! configured credential driver; the shared store contains non-secret metadata. + +use super::{ + principal::Principal, + service_access::{self, ServicePeer}, +}; +use crate::{ + ServerState, + persistence::{ObjectRecord, WriteCondition}, +}; +use axum::{ + body::{Body, to_bytes}, + response::{IntoResponse, Response}, +}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header}; +use openshell_core::proto::CredentialHandle; +use prost::Message; +use serde::{Deserialize, Serialize}; +use tonic::Status; + +pub const PREFIX: &str = "/.openshell/service-auth"; +pub const CONTROL: &str = "/auth/services"; +pub const COOKIE: &str = "__Host-openshell-service-session"; +pub const GATEWAY_AUTH: &str = "x-openshell-authorization"; +const LOGIN_COOKIE: &str = "__Host-openshell-service-login"; +const SESSION: &str = "gateway_application_session"; +const REVOKED: &str = "gateway_application_revocation"; +const REVOCATION_SCOPE: &str = "__gateway_application_auth"; + +#[derive(Serialize, Deserialize)] +struct Session { + host: String, + expires: u64, + pending_until: u64, + login_state: String, + handle: String, + credential_id: String, + cookie_key: String, + redeemed: bool, + revoked: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Login { + service_origin: String, + state: String, +} + +fn now() -> u64 { + openshell_core::time::now_ms().max(0).cast_unsigned() / 1000 +} +fn internal() -> Status { + Status::unavailable("application authentication unavailable") +} +fn digest(value: &str) -> Result { + openshell_crypto::sha256(value.as_bytes()) + .map(|hash| URL_SAFE_NO_PAD.encode(hash)) + .map_err(|_| internal()) +} +fn random() -> Result { + openshell_crypto::random_bytes::<32>() + .map(|bytes| URL_SAFE_NO_PAD.encode(bytes)) + .map_err(|_| internal()) +} +fn session(record: &ObjectRecord) -> Result { + serde_json::from_slice(&record.payload).map_err(|_| internal()) +} +fn credential_handle(value: &Session) -> Result { + CredentialHandle::decode( + URL_SAFE_NO_PAD + .decode(&value.handle) + .map_err(|_| internal())? + .as_slice(), + ) + .map_err(|_| internal()) +} + +// Parsing only, after the configured authenticator verifies the credential. +// Expiration is also checked here without the JWT library's clock-skew leeway. +fn token_expiry(token: &str) -> Result { + let payload = token + .split('.') + .nth(1) + .ok_or_else(|| Status::unauthenticated("invalid token"))?; + let bytes = URL_SAFE_NO_PAD + .decode(payload) + .map_err(|_| Status::unauthenticated("invalid token"))?; + let value: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|_| Status::unauthenticated("invalid token"))?; + value + .get("exp") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| Status::unauthenticated("token expiration required")) +} + +pub async fn check_token(state: &ServerState, token: &str) -> Result<(), Status> { + if token.len() > 16384 || token_expiry(token)? <= now() { + return Err(Status::unauthenticated("expired or invalid credential")); + } + if state + .store + .get(REVOKED, &digest(token)?) + .await + .map_err(|_| internal())? + .is_some() + { + return Err(Status::unauthenticated( + "revoked application access credential", + )); + } + Ok(()) +} + +pub async fn check_certificate(state: &ServerState, fingerprint: &str) -> Result<(), Status> { + if state + .store + .get(REVOKED, &format!("mtls-{fingerprint}")) + .await + .map_err(|_| internal())? + .is_some() + { + return Err(Status::unauthenticated( + "revoked application client certificate", + )); + } + Ok(()) +} + +fn cookie(headers: &HeaderMap, wanted: &str) -> Result, Status> { + let mut found = None; + for value in headers.get_all(header::COOKIE) { + for item in value + .to_str() + .map_err(|_| Status::unauthenticated("invalid cookie"))? + .split(';') + { + if let Some((name, value)) = item.trim().split_once('=') + && name == wanted + { + if found.is_some() || value.len() != 43 { + return Err(Status::unauthenticated( + "ambiguous or invalid session cookie", + )); + } + found = Some(value.to_string()); + } + } + } + Ok(found) +} + +fn origin(value: &str) -> Result { + let url = url::Url::parse(value).map_err(|_| Status::invalid_argument("invalid origin"))?; + if url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + { + return Err(Status::invalid_argument("HTTPS origin required")); + } + Ok(url) +} +fn request_origin(req: &Request) -> Result { + origin(&format!( + "https://{}", + crate::service_routing::request_host(req) + .ok_or_else(|| Status::invalid_argument("missing host"))? + )) +} +fn secure(req: &Request) -> Result<(), Status> { + if req + .extensions() + .get::() + .is_some_and(|p| p.secure) + { + Ok(()) + } else { + Err(Status::permission_denied( + "browser authentication requires gateway HTTPS", + )) + } +} +fn browser_origin(req: &Request, required: bool) -> Result<(), Status> { + if req + .headers() + .get("sec-fetch-site") + .is_some_and(|v| v != "same-origin" && v != "none") + { + return Err(Status::permission_denied( + "cross-origin application request", + )); + } + match req.headers().get(header::ORIGIN) { + Some(value) + if value + .to_str() + .ok() + .and_then(|s| origin(s).ok()) + .is_some_and(|u| { + request_origin(req).is_ok_and(|ours| ours.origin() == u.origin()) + }) => + { + Ok(()) + } + None if !required => Ok(()), + _ => Err(Status::permission_denied("same-origin request required")), + } +} +fn ambient_request(req: &Request) -> Result<(), Status> { + browser_origin( + req, + !matches!(*req.method(), Method::GET | Method::HEAD) + || req.headers().contains_key(header::UPGRADE), + ) +} + +async fn active_token( + state: &ServerState, + host: &str, + workspace: &str, + value: &str, +) -> Result { + let key = digest(value)?; + let record = state + .store + .get(SESSION, &key) + .await + .map_err(|_| internal())? + .ok_or_else(|| Status::unauthenticated("session expired or revoked"))?; + let session = session(&record)?; + if !session.redeemed + || session.cookie_key != key + || session.revoked + || session.host != host + || record.workspace != workspace + || session.expires <= now() + { + return Err(Status::unauthenticated("session expired or wrong host")); + } + state + .credentials + .resolve_application_token( + workspace, + &session.credential_id, + credential_handle(&session)?, + ) + .await +} + +pub async fn authorize_request( + state: &ServerState, + req: &mut Request, + workspace: &str, +) -> Result, Status> { + let peer = req + .extensions() + .get::() + .cloned() + .unwrap_or_default(); + let session = cookie(req.headers(), COOKIE)?; + let explicit = req.headers().get(GATEWAY_AUTH).cloned(); + if req.headers().get_all(GATEWAY_AUTH).iter().count() > 1 + || (explicit.is_some() && session.is_some()) + { + return Err(Status::unauthenticated("ambiguous gateway credentials")); + } + let mut headers = req.headers().clone(); + let mut consumed = false; + let token = if let Some(value) = session { + secure(req)?; + ambient_request(req)?; + let host = request_origin(req)?.origin().ascii_serialization(); + let token = active_token(state, &host, workspace, &value).await?; + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| internal())?, + ); + Some(token) + } else if let Some(value) = explicit { + headers.insert(header::AUTHORIZATION, value); + super::authenticator::bearer_credential(&headers).map(str::to_string) + } else if peer.identity.is_some() + && (state.config.mtls_auth.enabled || state.config.mtls_auth.operator_enabled) + { + ambient_request(req)?; + headers.remove(header::AUTHORIZATION); + None + } else if state.config.auth.allow_unauthenticated_users + && state.config.oidc.is_none() + && !state.config.mtls_auth.enabled + { + // Explicit local development has no gateway user bearer in this header. + headers.remove(header::AUTHORIZATION); + None + } else { + consumed = true; + super::authenticator::bearer_credential(&headers).map(str::to_string) + }; + service_access::authorize(state, &headers, &peer, workspace).await?; + if consumed + || token.as_deref().is_some_and(|gateway| { + super::authenticator::bearer_credential(req.headers()) == Some(gateway) + }) + { + req.headers_mut().remove(header::AUTHORIZATION); + } + Ok(token) +} + +async fn cleanup(state: &ServerState, workspace: &str) -> Result<(), Status> { + for record in state + .store + .list(SESSION, workspace, 1024, 0) + .await + .map_err(|_| internal())? + { + let value = session(&record)?; + if value.revoked + || value.expires <= now() + || !value.redeemed && value.pending_until <= now() + { + state + .credentials + .delete_application_token( + workspace, + &value.credential_id, + credential_handle(&value)?, + ) + .await?; + state + .store + .delete(SESSION, &record.id) + .await + .map_err(|_| internal())?; + } + } + for record in state + .store + .list(REVOKED, REVOCATION_SCOPE, 1024, 0) + .await + .map_err(|_| internal())? + { + if serde_json::from_slice::(&record.payload).map_err(|_| internal())? <= now() { + state + .store + .delete(REVOKED, &record.id) + .await + .map_err(|_| internal())?; + } + } + Ok(()) +} + +async fn service_origin(state: &ServerState, value: &str) -> Result<(url::Url, String), Status> { + let origin = origin(value)?; + let target = + crate::service_routing::resolve_service_host(state, origin.host_str().unwrap_or_default()) + .await + .ok_or_else(|| Status::invalid_argument("unknown service origin"))?; + Ok((origin, target.workspace)) +} +fn parameters(req: &Request) -> Result, Status> { + let mut params = std::collections::HashMap::new(); + for (key, value) in + url::form_urlencoded::parse(req.uri().query().unwrap_or_default().as_bytes()) + { + if params + .insert(key.into_owned(), value.into_owned()) + .is_some() + { + return Err(Status::invalid_argument("duplicate parameter")); + } + } + Ok(params) +} +fn redirect(url: &url::Url) -> Result { + let mut response = StatusCode::SEE_OTHER.into_response(); + response.headers_mut().insert( + header::LOCATION, + HeaderValue::from_str(url.as_str()).map_err(|_| internal())?, + ); + Ok(no_cache(response)) +} + +pub async fn control(state: &ServerState, mut req: Request) -> Result { + secure(&req)?; + let control_origin = request_origin(&req)?; + if req.method() == Method::GET { + let params = parameters(&req)?; + let (mut target, _) = service_origin( + state, + params.get("service_origin").map_or("", String::as_str), + ) + .await?; + let Some(login_state) = params.get("state") else { + target.set_path(&format!("{PREFIX}/start")); + target.query_pairs_mut().append_pair( + "login_origin", + &control_origin.origin().ascii_serialization(), + ); + return redirect(&target); + }; + if login_state.len() != 43 { + return Err(Status::invalid_argument("invalid login state")); + } + let nonce = random()?; + let body = serde_json::to_string(&LoginBody { + service_origin: target.origin().ascii_serialization(), + state: login_state.clone(), + }) + .map_err(|_| internal())? + .replace('<', "\\u003c"); + let html = format!( + r#"OpenShell service sign in

Sign in to a sandbox service

Use a gateway access token from your configured identity provider. The service receives only a host-bound browser session.

"# + ); + let mut response = html.into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + response.headers_mut().insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_str(&format!("default-src 'none'; script-src 'nonce-{nonce}'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")).map_err(|_| internal())?); + return Ok(no_cache(response)); + } + browser_origin(&req, true)?; + let peer = req + .extensions() + .get::() + .cloned() + .unwrap_or_default(); + let bytes = to_bytes(std::mem::replace(req.body_mut(), Body::empty()), 4096) + .await + .map_err(|_| Status::invalid_argument("invalid login request"))?; + let login: Login = serde_json::from_slice(&bytes) + .map_err(|_| Status::invalid_argument("invalid login request"))?; + let (mut target, workspace) = service_origin(state, &login.service_origin).await?; + if login.state.len() != 43 { + return Err(Status::invalid_argument("invalid login state")); + } + let principal = service_access::authorize(state, req.headers(), &peer, &workspace).await?; + if !matches!(principal, Principal::User(ref user) if user.identity.provider == super::identity::IdentityProvider::Oidc) + { + return Err(Status::permission_denied( + "browser token sessions require an OIDC user", + )); + } + let token = super::authenticator::bearer_credential(req.headers()) + .ok_or_else(|| Status::unauthenticated("gateway token required"))?; + cleanup(state, &workspace).await?; + let value = random()?; + let key = digest(&value)?; + let credential = state + .credentials + .store_application_token(&workspace, &key, token) + .await?; + let record = Session { + host: target.origin().ascii_serialization(), + expires: token_expiry(token)?.min(now() + 600), + pending_until: now() + 120, + login_state: digest(&login.state)?, + handle: URL_SAFE_NO_PAD.encode(credential.encode_to_vec()), + credential_id: key.clone(), + cookie_key: String::new(), + redeemed: false, + revoked: false, + }; + let payload = serde_json::to_vec(&record).map_err(|_| internal())?; + let result = state + .store + .create_if_workspace_count_below(SESSION, &key, &key, &workspace, &payload, None, 1024) + .await; + if !matches!(result, Ok(Some(_))) { + state + .credentials + .delete_application_token(&workspace, &key, credential) + .await?; + return Err(Status::resource_exhausted("browser session unavailable")); + } + target.set_path(&format!("{PREFIX}/redeem")); + target.query_pairs_mut().append_pair("code", &value); + Ok(no_cache( + axum::Json(serde_json::json!({"redirect": target.as_str()})).into_response(), + )) +} + +#[derive(Serialize)] +struct LoginBody { + service_origin: String, + state: String, +} + +pub async fn handle( + state: &ServerState, + req: &mut Request, + workspace: &str, +) -> Result { + secure(req)?; + let path = req.uri().path().to_string(); + if path == format!("{PREFIX}/start") && req.method() == Method::GET { + let params = parameters(req)?; + let mut login = origin(params.get("login_origin").map_or("", String::as_str))?; + if crate::service_routing::resolve_service_host(state, login.host_str().unwrap_or_default()) + .await + .is_some() + { + return Err(Status::invalid_argument( + "login must use the control-plane origin", + )); + } + login.set_path(CONTROL); + let nonce = random()?; + login + .query_pairs_mut() + .append_pair( + "service_origin", + &request_origin(req)?.origin().ascii_serialization(), + ) + .append_pair("state", &nonce); + return with_cookie(redirect(&login)?, LOGIN_COOKIE, &nonce, 120, "Lax"); + } + if path == format!("{PREFIX}/redeem") && req.method() == Method::GET { + let params = parameters(req)?; + let code = params + .get("code") + .filter(|c| c.len() == 43) + .ok_or_else(|| Status::unauthenticated("invalid login code"))?; + let key = digest(code)?; + let record = state + .store + .get(SESSION, &key) + .await + .map_err(|_| internal())? + .ok_or_else(|| Status::unauthenticated("expired login code"))?; + let mut value = session(&record)?; + let nonce = cookie(req.headers(), LOGIN_COOKIE)? + .ok_or_else(|| Status::unauthenticated("missing login state"))?; + if record.workspace != workspace + || value.host != request_origin(req)?.origin().ascii_serialization() + || value.redeemed + || value.revoked + || value.pending_until <= now() + || value.expires <= now() + || value.login_state != digest(&nonce)? + { + return Err(Status::unauthenticated("invalid login code or state")); + } + let token = state + .credentials + .resolve_application_token(workspace, &value.credential_id, credential_handle(&value)?) + .await?; + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| internal())?, + ); + service_access::authorize(state, &headers, &ServicePeer::default(), workspace).await?; + value.redeemed = true; + let session_cookie = random()?; + let session_key = digest(&session_cookie)?; + value.cookie_key.clone_from(&session_key); + let payload = serde_json::to_vec(&value).map_err(|_| internal())?; + state + .store + .put_if( + SESSION, + &key, + &key, + workspace, + &payload, + None, + WriteCondition::MatchResourceVersion(record.resource_version), + ) + .await + .map_err(|_| Status::unauthenticated("login code already consumed"))?; + if state + .store + .create_if_workspace_count_below( + SESSION, + &session_key, + &session_key, + workspace, + &payload, + None, + 1024, + ) + .await + .map_err(|_| internal())? + .is_none() + { + return Err(Status::resource_exhausted("browser session limit reached")); + } + state + .store + .delete(SESSION, &key) + .await + .map_err(|_| internal())?; + let response = with_cookie( + browser_complete()?, + COOKIE, + &session_cookie, + value.expires.saturating_sub(now()), + "Strict", + )?; + return with_cookie(response, LOGIN_COOKIE, "", 0, "Lax"); + } + browser_origin(req, true)?; + if path == format!("{PREFIX}/session") && req.method() == Method::DELETE { + if let Some(cookie) = cookie(req.headers(), COOKIE)? { + let key = digest(&cookie)?; + if let Some(record) = state + .store + .get(SESSION, &key) + .await + .map_err(|_| internal())? + { + let mut value = session(&record)?; + if record.workspace != workspace + || value.host != request_origin(req)?.origin().ascii_serialization() + { + return Err(Status::permission_denied("wrong session host")); + } + value.revoked = true; + state + .store + .put_if( + SESSION, + &key, + &key, + workspace, + &serde_json::to_vec(&value).map_err(|_| internal())?, + None, + WriteCondition::MatchResourceVersion(record.resource_version), + ) + .await + .map_err(|_| internal())?; + state + .credentials + .delete_application_token( + workspace, + &value.credential_id, + credential_handle(&value)?, + ) + .await?; + state + .store + .delete(SESSION, &key) + .await + .map_err(|_| internal())?; + } + } + return with_cookie( + StatusCode::NO_CONTENT.into_response(), + COOKIE, + "", + 0, + "Strict", + ); + } + if path == format!("{PREFIX}/credential") && req.method() == Method::DELETE { + // A service-scoped browser session must not administer the underlying + // gateway credential's access to other services. + if cookie(req.headers(), COOKIE)?.is_some() { + return Err(Status::permission_denied( + "explicit gateway credential required for credential revocation", + )); + } + let token = authorize_request(state, req, workspace).await?; + let (key, expires) = if let Some(token) = token { + (digest(&token)?, token_expiry(&token)?) + } else { + let fingerprint = req + .extensions() + .get::() + .and_then(|p| p.certificate_sha256.as_ref()) + .ok_or_else(|| Status::invalid_argument("gateway credential required"))?; + (format!("mtls-{fingerprint}"), u64::MAX) + }; + cleanup(state, workspace).await?; + if state + .store + .create_if_workspace_count_below( + REVOKED, + &key, + &key, + REVOCATION_SCOPE, + &serde_json::to_vec(&expires).map_err(|_| internal())?, + None, + 1024, + ) + .await + .map_err(|_| internal())? + .is_none() + { + return Err(Status::resource_exhausted("revocation limit reached")); + } + return with_cookie( + StatusCode::NO_CONTENT.into_response(), + COOKIE, + "", + 0, + "Strict", + ); + } + Ok(StatusCode::NOT_FOUND.into_response()) +} + +fn no_cache(mut response: Response) -> Response { + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response.headers_mut().insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); + response.headers_mut().insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + response +} + +fn browser_complete() -> Result { + // A document on the service origin starts a fresh navigation, so Strict + // cookies are sent even when login began on a different site. A chain of + // HTTP redirects would retain the original cross-site navigation context. + let nonce = random()?; + let mut response = format!("OpenShell sign in complete

Signed in.

Open service").into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + response.headers_mut().insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_str(&format!("default-src 'none'; script-src 'nonce-{nonce}'; frame-ancestors 'none'; base-uri 'none'")).map_err(|_| internal())?); + Ok(no_cache(response)) +} +fn with_cookie( + mut response: Response, + name: &str, + value: &str, + seconds: u64, + same_site: &str, +) -> Result { + response.headers_mut().append( + header::SET_COOKIE, + HeaderValue::from_str(&format!( + "{name}={value}; Path=/; Secure; HttpOnly; SameSite={same_site}; Max-Age={seconds}" + )) + .map_err(|_| internal())?, + ); + Ok(no_cache(response)) +} +pub fn strip_response_cookies(headers: &mut HeaderMap) { + let allowed: Vec<_> = headers + .get_all(header::SET_COOKIE) + .iter() + .filter(|value| { + value + .to_str() + .ok() + .and_then(|v| v.split_once('=')) + .is_some_and(|(name, _)| !name.trim().starts_with("__Host-openshell-")) + }) + .cloned() + .collect(); + headers.remove(header::SET_COOKIE); + for value in allowed { + headers.append(header::SET_COOKIE, value); + } +} + +pub fn error(status: &Status) -> Response { + let code = match status.code() { + tonic::Code::Unauthenticated => StatusCode::UNAUTHORIZED, + tonic::Code::PermissionDenied => StatusCode::FORBIDDEN, + tonic::Code::InvalidArgument => StatusCode::BAD_REQUEST, + tonic::Code::ResourceExhausted => StatusCode::TOO_MANY_REQUESTS, + _ => StatusCode::SERVICE_UNAVAILABLE, + }; + let mut response = + crate::service_routing::service_error_response(code, "Application authentication failed"); + if code == StatusCode::UNAUTHORIZED { + response + .headers_mut() + .insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer")); + } + no_cache(response) +} + +#[cfg(test)] +pub mod tests { + use super::*; + use crate::auth::oidc::tests::{cache_with_mock_issuer, claims_for, mint_rs256}; + use crate::grpc::test_support::test_server_state; + use http_body_util::BodyExt; + use openshell_core::proto::{WorkspaceMember, WorkspaceRole, datamodel::v1::ObjectMeta}; + use std::sync::Arc; + use tower::ServiceExt; + + const APP: &str = "https://default--demo--web.services.example.com"; + const GATEWAY: &str = "https://gateway.example.com"; + + pub async fn fixture() -> (wiremock::MockServer, Arc, String) { + let server = wiremock::MockServer::start().await; + let cache = Arc::new(cache_with_mock_issuer(&server).await); + let mut state = test_server_state().await; + let inner = Arc::get_mut(&mut state).unwrap(); + inner.config.auth.allow_unauthenticated_users = false; + inner.config.service_routing.base_domains = vec!["services.example.com".into()]; + inner.config.oidc = Some(serde_json::from_value(serde_json::json!({"issuer":server.uri(),"audience":"openshell-cli","scopes_claim":"scope"})).unwrap()); + inner.admin_role = "openshell-admin".into(); + inner.oidc_cache = Some(cache); + state + .store + .put_message(&WorkspaceMember { + metadata: Some(ObjectMeta { + id: "member-user-42".into(), + name: "user-42".into(), + workspace: "default".into(), + ..Default::default() + }), + principal_subject: "user-42".into(), + role: WorkspaceRole::User.into(), + }) + .await + .unwrap(); + let token = mint_rs256( + &claims_for( + &server.uri(), + "openshell-cli", + i64::try_from(now() + 3600).unwrap(), + ), + "test-signing-key", + ); + (server, state, token) + } + + fn request(method: Method, url: &str) -> Request { + let url = url::Url::parse(url).unwrap(); + let mut request = Request::builder() + .method(method) + .uri(url.as_str()) + .header(header::HOST, url.authority()) + .body(Body::empty()) + .unwrap(); + request.extensions_mut().insert(ServicePeer { + secure: true, + ..Default::default() + }); + request + } + async fn send(state: &Arc, request: Request) -> Response { + crate::http::http_router(state.clone()) + .oneshot(request) + .await + .unwrap() + } + fn location(response: &Response) -> String { + response.headers()[header::LOCATION] + .to_str() + .unwrap() + .to_string() + } + fn response_cookie(response: &Response, name: &str) -> String { + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + .find(|v| v.starts_with(&format!("{name}="))) + .unwrap() + .split(';') + .next() + .unwrap() + .to_string() + } + pub async fn browser_login(state: &Arc, token: &str) -> String { + browser_login_for(state, token, APP).await + } + + async fn browser_login_for(state: &Arc, token: &str, app_origin: &str) -> String { + let mut url = url::Url::parse(&format!("{GATEWAY}{CONTROL}")).unwrap(); + url.query_pairs_mut() + .append_pair("service_origin", app_origin); + let redirect = send(state, request(Method::GET, url.as_str())).await; + assert_eq!(redirect.status(), StatusCode::SEE_OTHER); + let start = send(state, request(Method::GET, &location(&redirect))).await; + let nonce_cookie = response_cookie(&start, LOGIN_COOKIE); + let login_url = location(&start); + let login_url = url::Url::parse(&login_url).unwrap(); + let params: std::collections::HashMap<_, _> = + login_url.query_pairs().into_owned().collect(); + let page = send(state, request(Method::GET, login_url.as_str())).await; + assert_eq!(page.status(), StatusCode::OK); + assert!( + page.headers()[header::CONTENT_SECURITY_POLICY] + .to_str() + .unwrap() + .contains("frame-ancestors 'none'") + ); + let mut login = request(Method::POST, &format!("{GATEWAY}{CONTROL}")); + login + .headers_mut() + .insert(header::ORIGIN, GATEWAY.parse().unwrap()); + login.headers_mut().insert( + header::AUTHORIZATION, + format!("Bearer {token}").parse().unwrap(), + ); + *login.body_mut() = Body::from( + serde_json::to_vec( + &serde_json::json!({"service_origin":app_origin,"state":params["state"]}), + ) + .unwrap(), + ); + let response = send(state, login).await; + assert_eq!(response.status(), StatusCode::OK); + let json: serde_json::Value = + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()) + .unwrap(); + let redeem_url = json["redirect"].as_str().unwrap(); + assert!(!redeem_url.contains(token)); + let missing = send(state, request(Method::GET, redeem_url)).await; + assert_eq!(missing.status(), StatusCode::UNAUTHORIZED); + let mut redeem = request(Method::GET, redeem_url); + redeem + .headers_mut() + .insert(header::COOKIE, nonce_cookie.parse().unwrap()); + let mut racing = request(Method::GET, redeem_url); + racing + .headers_mut() + .insert(header::COOKIE, nonce_cookie.parse().unwrap()); + let (left, right) = tokio::join!(send(state, redeem), send(state, racing)); + let response = if left.status() == StatusCode::OK { + assert_eq!(right.status(), StatusCode::UNAUTHORIZED); + left + } else { + assert_eq!(left.status(), StatusCode::UNAUTHORIZED); + assert_eq!(right.status(), StatusCode::OK); + right + }; + assert_eq!(response.status(), StatusCode::OK); + let cookie = response_cookie(&response, COOKIE); + let flags = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .next() + .unwrap() + .to_str() + .unwrap(); + assert!(flags.contains("Secure; HttpOnly; SameSite=Strict")); + let mut replay = request(Method::GET, redeem_url); + replay + .headers_mut() + .insert(header::COOKIE, nonce_cookie.parse().unwrap()); + assert_eq!(send(state, replay).await.status(), StatusCode::UNAUTHORIZED); + let code = url::Url::parse(redeem_url) + .unwrap() + .query_pairs() + .find(|(k, _)| k == "code") + .unwrap() + .1 + .into_owned(); + let mut leaked_code = request(Method::GET, &format!("{app_origin}/")); + leaked_code + .headers_mut() + .insert(header::COOKIE, format!("{COOKIE}={code}").parse().unwrap()); + assert_eq!( + send(state, leaked_code).await.status(), + StatusCode::UNAUTHORIZED, + "bootstrap code is never an active cookie" + ); + let records = state.store.list(SESSION, "default", 1024, 0).await.unwrap(); + assert_eq!(records.len(), 1); + assert!(!String::from_utf8_lossy(&records[0].payload).contains(token)); + cookie + } + + #[tokio::test] + async fn hashed_service_browser_login_preserves_origin_and_authentication() { + use openshell_core::config::ServiceHostnameFormat; + let (_issuer, mut state, token) = fixture().await; + let inner = Arc::get_mut(&mut state).unwrap(); + inner.config.name = "gateway-a".into(); + inner.config.service_routing.service_hostname_format = ServiceHostnameFormat::Hashed; + inner.service_route_index = Arc::new(crate::service_routing::ServiceRouteIndex::new(Some( + "gateway-a".into(), + ))); + inner + .service_route_index + .register("default", "demo", "web") + .unwrap(); + state + .store + .put_message(&openshell_core::proto::ServiceEndpoint { + metadata: Some(ObjectMeta { + id: "hashed-web".into(), + name: "demo--web".into(), + workspace: "default".into(), + ..Default::default() + }), + sandbox_id: "sandbox-demo".into(), + sandbox: "demo".into(), + name: "web".into(), + target_port: 8080, + domain: true, + ..Default::default() + }) + .await + .unwrap(); + let label = crate::service_routing::service_route_hash_label( + "gateway-a", + "default", + "demo", + Some("web"), + ); + let app_origin = format!("https://{label}.services.example.com"); + assert_eq!( + service_origin(&state, &app_origin).await.unwrap().1, + "default" + ); + // Hashes from another installation must not resolve on this gateway. + let other = crate::service_routing::service_route_hash_label( + "gateway-b", + "default", + "demo", + Some("web"), + ); + assert!( + service_origin(&state, &format!("https://{other}.services.example.com")) + .await + .is_err() + ); + let mut start = request( + Method::GET, + &format!("{app_origin}{PREFIX}/start?login_origin={app_origin}"), + ); + assert_eq!( + handle(&state, &mut start, "default") + .await + .unwrap_err() + .code(), + tonic::Code::InvalidArgument + ); + let cookie = browser_login_for(&state, &token, &app_origin).await; + let mut application = request(Method::GET, &format!("{app_origin}/")); + application + .headers_mut() + .insert(header::COOKIE, cookie.parse().unwrap()); + authorize_request(&state, &mut application, "default") + .await + .unwrap(); + let anonymous = send(&state, request(Method::GET, &format!("{app_origin}/"))).await; + assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn browser_login_separates_gateway_and_application_credentials_and_checks_csrf() { + let (_issuer, state, token) = fixture().await; + let cookie = browser_login(&state, &token).await; + let mut app = request(Method::GET, &format!("{APP}/")); + app.headers_mut().insert( + header::COOKIE, + format!("{cookie}; application_session=app-secret") + .parse() + .unwrap(), + ); + app.headers_mut().insert( + header::AUTHORIZATION, + "Bearer application-token".parse().unwrap(), + ); + authorize_request(&state, &mut app, "default") + .await + .unwrap(); + assert_eq!( + app.headers()[header::AUTHORIZATION], + "Bearer application-token" + ); + // Authenticated request reaches endpoint lookup; no such endpoint exists. + assert_eq!(send(&state, app).await.status(), StatusCode::NOT_FOUND); + for (method, upgrade) in [(Method::POST, false), (Method::GET, true)] { + for origin in [None, Some("https://evil.example.com"), Some("null")] { + let mut req = request(method.clone(), &format!("{APP}/")); + req.headers_mut() + .insert(header::COOKIE, cookie.parse().unwrap()); + if upgrade { + req.headers_mut() + .insert(header::UPGRADE, "websocket".parse().unwrap()); + } + if let Some(origin) = origin { + req.headers_mut() + .insert(header::ORIGIN, origin.parse().unwrap()); + } + assert_eq!(send(&state, req).await.status(), StatusCode::FORBIDDEN); + } + let mut req = request(method, &format!("{APP}/")); + req.headers_mut() + .insert(header::COOKIE, cookie.parse().unwrap()); + req.headers_mut() + .insert(header::ORIGIN, APP.parse().unwrap()); + if upgrade { + req.headers_mut() + .insert(header::UPGRADE, "websocket".parse().unwrap()); + } + assert_eq!(send(&state, req).await.status(), StatusCode::NOT_FOUND); + } + } + + #[tokio::test] + async fn gateway_headers_authenticate_without_forwarding_tokens_and_discovery_scope_is_rejected() + { + let (issuer, state, token) = fixture().await; + let mut direct = request(Method::GET, &format!("{APP}/")); + direct.headers_mut().insert( + header::AUTHORIZATION, + format!("Bearer {token}").parse().unwrap(), + ); + authorize_request(&state, &mut direct, "default") + .await + .unwrap(); + assert!(!direct.headers().contains_key(header::AUTHORIZATION)); + let mut separate = request(Method::GET, &format!("{APP}/")); + separate + .headers_mut() + .insert(GATEWAY_AUTH, format!("Bearer {token}").parse().unwrap()); + separate.headers_mut().insert( + header::AUTHORIZATION, + "Bearer application-token".parse().unwrap(), + ); + authorize_request(&state, &mut separate, "default") + .await + .unwrap(); + assert_eq!( + separate.headers()[header::AUTHORIZATION], + "Bearer application-token" + ); + let mut claims = claims_for( + &issuer.uri(), + "openshell-cli", + i64::try_from(now() + 3600).unwrap(), + ); + claims["scope"] = serde_json::json!("sandbox:read"); + let discovery = mint_rs256(&claims, "test-signing-key"); + separate + .headers_mut() + .insert(GATEWAY_AUTH, format!("Bearer {discovery}").parse().unwrap()); + assert_eq!(send(&state, separate).await.status(), StatusCode::FORBIDDEN); + for invalid in [ + "not-a-token".to_string(), + mint_rs256( + &claims_for( + &issuer.uri(), + "openshell-cli", + i64::try_from(now()).unwrap() - 1, + ), + "test-signing-key", + ), + ] { + let mut req = request(Method::GET, &format!("{APP}/")); + req.headers_mut().insert( + header::AUTHORIZATION, + format!("Bearer {invalid}").parse().unwrap(), + ); + assert_eq!(send(&state, req).await.status(), StatusCode::UNAUTHORIZED); + } + } + + async fn replica(state: &Arc) -> Arc { + Arc::new(ServerState::new_with_credentials( + state.config.clone(), + state.store.clone(), + crate::compute::new_test_runtime(state.store.clone()).await, + crate::sandbox_index::SandboxIndex::new(), + crate::sandbox_watch::SandboxWatchBus::new(), + crate::tracing_bus::TracingLogBus::new(), + Arc::new(crate::supervisor_session::SupervisorSessionRegistry::new()), + state.oidc_cache.clone(), + state.credentials.clone(), + )) + } + fn application(cookie: &str) -> Request { + let mut req = request(Method::GET, &format!("{APP}/")); + req.headers_mut() + .insert(header::COOKIE, cookie.parse().unwrap()); + req + } + + #[tokio::test] + async fn sessions_recheck_workspace_and_logout_and_token_revocation_across_replicas() { + let (_issuer, state, token) = fixture().await; + let cookie = browser_login(&state, &token).await; + let other = replica(&state).await; + assert_eq!( + send(&other, application(&cookie)).await.status(), + StatusCode::NOT_FOUND + ); + let mut logout = request(Method::DELETE, &format!("{APP}{PREFIX}/session")); + logout + .headers_mut() + .insert(header::COOKIE, cookie.parse().unwrap()); + logout + .headers_mut() + .insert(header::ORIGIN, APP.parse().unwrap()); + assert_eq!(send(&state, logout).await.status(), StatusCode::NO_CONTENT); + assert_eq!( + send(&other, application(&cookie)).await.status(), + StatusCode::UNAUTHORIZED + ); + let cookie = browser_login(&state, &token).await; + let mut revoke = request(Method::DELETE, &format!("{APP}{PREFIX}/credential")); + revoke.headers_mut().insert( + header::AUTHORIZATION, + format!("Bearer {token}").parse().unwrap(), + ); + revoke + .headers_mut() + .insert(header::ORIGIN, APP.parse().unwrap()); + assert_eq!(send(&state, revoke).await.status(), StatusCode::NO_CONTENT); + assert_eq!( + send(&other, application(&cookie)).await.status(), + StatusCode::UNAUTHORIZED + ); + let mut replay = request(Method::GET, &format!("{APP}/")); + replay.headers_mut().insert( + header::AUTHORIZATION, + format!("Bearer {token}").parse().unwrap(), + ); + assert_eq!( + send(&other, replay).await.status(), + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn sessions_reject_wrong_hosts_expiry_removed_membership_and_spoofed_headers() { + let (_issuer, state, token) = fixture().await; + let cookie = browser_login(&state, &token).await; + let mut wrong = application(&cookie); + wrong.headers_mut().insert( + header::HOST, + "default--other--web.services.example.com".parse().unwrap(), + ); + assert_eq!(send(&state, wrong).await.status(), StatusCode::UNAUTHORIZED); + state + .store + .delete("workspace_member", "member-user-42") + .await + .unwrap(); + assert_eq!( + send(&state, application(&cookie)).await.status(), + StatusCode::FORBIDDEN + ); + let record = state + .store + .list(SESSION, "default", 1, 0) + .await + .unwrap() + .remove(0); + let mut value = session(&record).unwrap(); + value.expires = now(); + state + .store + .put_if( + SESSION, + &record.id, + &record.name, + "default", + &serde_json::to_vec(&value).unwrap(), + None, + WriteCondition::Unconditional, + ) + .await + .unwrap(); + assert_eq!( + send(&state, application(&cookie)).await.status(), + StatusCode::UNAUTHORIZED + ); + let mut spoof = request(Method::GET, &format!("{APP}/")); + spoof + .headers_mut() + .insert("x-forwarded-client-cert", "forged".parse().unwrap()); + spoof + .headers_mut() + .insert("x-openshell-auth-source", "oidc".parse().unwrap()); + assert_eq!(send(&state, spoof).await.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn mtls_revocation_and_explicit_development_auth_work_with_either_listener_mode() { + use crate::auth::identity::{Identity, IdentityProvider}; + let mut state = test_server_state().await; + let inner = Arc::get_mut(&mut state).unwrap(); + inner.config.service_routing.base_domains = vec!["services.example.com".into()]; + inner.config.auth.allow_unauthenticated_users = false; + inner.config.mtls_auth.enabled = true; + let peer = ServicePeer { + secure: true, + identity: Some(Identity { + subject: "mtls-user".into(), + display_name: None, + roles: vec![], + scopes: vec![], + provider: IdentityProvider::Mtls, + }), + certificate_sha256: Some("verified-certificate-digest".into()), + source: None, + }; + for enabled in [false, true] { + Arc::get_mut(&mut state) + .unwrap() + .config + .supervisor_app_listener + .enabled = enabled; + let mut req = request(Method::GET, &format!("{APP}/")); + req.extensions_mut().insert(peer.clone()); + assert_eq!(send(&state, req).await.status(), StatusCode::NOT_FOUND); + assert_eq!( + send(&state, request(Method::GET, &format!("{APP}/"))) + .await + .status(), + StatusCode::UNAUTHORIZED + ); + } + let other = replica(&state).await; + let mut revoke = request(Method::DELETE, &format!("{APP}{PREFIX}/credential")); + revoke.extensions_mut().insert(peer.clone()); + revoke + .headers_mut() + .insert(header::ORIGIN, APP.parse().unwrap()); + assert_eq!(send(&state, revoke).await.status(), StatusCode::NO_CONTENT); + let mut req = request(Method::GET, &format!("{APP}/")); + req.extensions_mut().insert(peer); + assert_eq!(send(&other, req).await.status(), StatusCode::UNAUTHORIZED); + drop(other); + let inner = Arc::get_mut(&mut state).unwrap(); + inner.config.mtls_auth.enabled = false; + inner.config.auth.allow_unauthenticated_users = true; + assert_eq!( + send(&state, request(Method::GET, &format!("{APP}/"))) + .await + .status(), + StatusCode::NOT_FOUND + ); + let mut invalid = request(Method::GET, &format!("{APP}/")); + invalid + .headers_mut() + .insert(GATEWAY_AUTH, "Bearer invalid".parse().unwrap()); + assert_eq!( + send(&state, invalid).await.status(), + StatusCode::UNAUTHORIZED + ); + let mut application = request(Method::GET, &format!("{APP}/")); + application.headers_mut().insert( + header::AUTHORIZATION, + "Bearer application-token".parse().unwrap(), + ); + authorize_request(&state, &mut application, "default") + .await + .unwrap(); + assert_eq!( + application.headers()[header::AUTHORIZATION], + "Bearer application-token" + ); + } +} diff --git a/crates/openshell-server/src/credentials.rs b/crates/openshell-server/src/credentials.rs index 17cfe96c28..645eab1653 100644 --- a/crates/openshell-server/src/credentials.rs +++ b/crates/openshell-server/src/credentials.rs @@ -144,6 +144,86 @@ pub struct CredentialRuntime { } impl CredentialRuntime { + /// Gateway-private browser session secrets use the configured credential + /// driver, rather than persisting bearer material in session metadata. + pub async fn store_application_token( + &self, + workspace: &str, + id: &str, + token: &str, + ) -> Result { + let driver_name = self.registry.storage_owner_name(); + let mut handle = self + .connected_driver(&driver_name)? + .store_credential(StoreCredentialRequest { + provider: "gateway-service-session".into(), + credential_key: "gateway-access-token".into(), + value: token.into(), + existing_handle: None, + workspace: workspace.into(), + provider_id: format!("service-session-{id}"), + object_id: format!("service-session-{id}"), + }) + .await?; + if handle.handle.is_empty() { + return Err(Status::internal("empty application credential handle")); + } + handle.driver = driver_name; + Ok(handle) + } + + pub async fn resolve_application_token( + &self, + workspace: &str, + id: &str, + handle: CredentialHandle, + ) -> Result { + let values = self + .connected_driver(&handle.driver)? + .resolve_credentials(vec![ResolveCredentialRequest { + request_id: "application-token".into(), + provider: "gateway-service-session".into(), + credential_key: "gateway-access-token".into(), + handle: Some(handle), + workspace: workspace.into(), + provider_id: format!("service-session-{id}"), + }]) + .await?; + if values.len() != 1 + || values[0].request_id != "application-token" + || values[0].value.is_empty() + { + return Err(Status::unauthenticated( + "application credential unavailable", + )); + } + if let Some(expires) = &values[0].expiration_time + && openshell_core::time::timestamp_to_millis(expires) + .map_err(|_| Status::unavailable("invalid application credential expiration"))? + <= openshell_core::time::now_ms() + { + return Err(Status::unauthenticated("expired application credential")); + } + Ok(values.into_iter().next().unwrap().value) + } + + pub async fn delete_application_token( + &self, + workspace: &str, + id: &str, + handle: CredentialHandle, + ) -> Result<(), Status> { + self.connected_driver(&handle.driver)? + .delete_credential(DeleteCredentialRequest { + provider: "gateway-service-session".into(), + credential_key: "gateway-access-token".into(), + handle: Some(handle), + workspace: workspace.into(), + provider_id: format!("service-session-{id}"), + }) + .await + } + pub fn from_config(config: &Config) -> CoreResult { Self::from_config_with_optional_store(config, None) } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 3720cee880..a219fc4a90 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -2543,13 +2543,15 @@ mod tests { .expect("failed to create test store"), ); let compute = crate::compute::new_test_runtime(store.clone()).await; + let mut config = Config::new(None) + .with_database_url("sqlite::memory:?cache=shared") + .with_bind_address(bind_addr) + .with_server_sans(["*.dev.openshell.localhost"]) + .with_loopback_service_http(enable_loopback_service_http) + .with_credential_drivers(["test-static"]); + config.auth.allow_unauthenticated_users = true; Arc::new(ServerState::new( - Config::new(None) - .with_database_url("sqlite::memory:?cache=shared") - .with_bind_address(bind_addr) - .with_server_sans(["*.dev.openshell.localhost"]) - .with_loopback_service_http(enable_loopback_service_http) - .with_credential_drivers(["test-static"]), + config, store, compute, crate::sandbox_index::SandboxIndex::new(), @@ -2853,6 +2855,104 @@ mod tests { stop_listener(shutdown, handle).await; } + #[tokio::test] + async fn service_auth_uses_real_tls_client_identity_with_listener_disabled_or_enabled() { + use rustls::pki_types::{CertificateDer, ServerName}; + let dir = tempdir().unwrap(); + let (ca, ca_key) = generate_test_certs_with_ca(dir.path()); + let mut params = rcgen::CertificateParams::new(Vec::::new()).unwrap(); + params + .distinguished_name + .push(rcgen::DnType::CommonName, "application-user"); + params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ClientAuth]; + let client_key = openshell_crypto::pki::generate_keypair().unwrap(); + let client = openshell_crypto::pki::signed_by(params, &client_key, &ca, &ca_key).unwrap(); + let client_cert = CertificateDer::from(client.der().to_vec()); + let key_pem = client_key.serialize_pem().unwrap(); + let client_key = rustls_pemfile::private_key(&mut std::io::Cursor::new(key_pem)) + .unwrap() + .unwrap(); + for enabled in [false, true] { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let mut state = test_state(addr, false).await; + let inner = Arc::get_mut(&mut state).unwrap(); + inner.config.auth.allow_unauthenticated_users = false; + inner.config.mtls_auth.enabled = true; + inner.config.supervisor_app_listener.enabled = enabled; + inner.config.tls = Some(openshell_core::TlsConfig { + cert_path: dir.path().join("server-cert.pem"), + key_path: dir.path().join("server-key.pem"), + client_ca_path: Some(dir.path().join("ca.pem")), + require_client_auth: false, + external_cert_path: None, + external_key_path: None, + external_server_names: vec![], + }); + let acceptor = TlsAcceptor::from_files( + &dir.path().join("server-cert.pem"), + &dir.path().join("server-key.pem"), + Some(&dir.path().join("ca.pem")), + false, + None, + None, + vec![], + ) + .unwrap(); + let (shutdown, rx) = watch::channel(false); + let handle = tokio::spawn(serve_gateway_listener( + BoundGatewayListener { + listener, + address: addr, + }, + MultiplexService::new(state), + Some(acceptor), + false, + rx, + )); + for authenticated in [false, true] { + let mut roots = rustls::RootCertStore::empty(); + roots.add(CertificateDer::from(ca.der().to_vec())).unwrap(); + let builder = openshell_crypto::tls::client_builder().with_root_certificates(roots); + let config = if authenticated { + builder + .with_client_auth_cert(vec![client_cert.clone()], client_key.clone_key()) + .unwrap() + } else { + builder.with_no_client_auth() + }; + let stream = openshell_core::net::connect_tcp_nodelay_best_effort(&[addr]) + .await + .unwrap(); + let mut stream = tokio_rustls::TlsConnector::from(Arc::new(config)) + .connect(ServerName::try_from("localhost").unwrap(), stream) + .await + .unwrap(); + stream + .write_all( + service_request(addr, &[("X-Forwarded-Client-Cert", "forged")]).as_bytes(), + ) + .await + .unwrap(); + let mut response = vec![]; + tokio::time::timeout(Duration::from_secs(5), stream.read_to_end(&mut response)) + .await + .unwrap() + .unwrap(); + let response = String::from_utf8_lossy(&response); + assert!( + response.starts_with(if authenticated { + "HTTP/1.1 404" + } else { + "HTTP/1.1 401" + }), + "listener={enabled}, authenticated={authenticated}: {response}" + ); + } + stop_listener(shutdown, handle).await; + } + } + #[tokio::test] async fn plaintext_service_http_does_not_expose_grpc_gateway() { let (addr, shutdown, handle, _tls_dir) = diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index d58192fba2..f85e75ffcf 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -319,18 +319,25 @@ impl MultiplexService { .config .mtls_auth .enabled - .then_some(peer_identity) + .then_some(peer_identity.clone()) .flatten(), self.state.config.mtls_auth.enabled, self.state.config.auth.allow_unauthenticated_users, ) - .with_peer_certificate(certificate_sha256) + .with_peer_certificate(certificate_sha256.clone()) .with_operator(operator) .with_connection_source(source) .with_logons(self.state.logons.clone()); let grpc_service = GrpcRateLimitService::new(grpc_service, self.state.grpc_rate_limiter.clone()); - let http_service = http_router(self.state.clone()); + let http_service = http_router(self.state.clone()).layer(axum::Extension( + crate::auth::service_access::ServicePeer { + secure: self.state.config.tls.is_some(), + identity: peer_identity, + certificate_sha256, + source: Some(source), + }, + )); let grpc_service = request_id_middleware!(grpc_service); let http_service = request_id_middleware!(http_service); @@ -1064,7 +1071,7 @@ where /// /// When no bearer authenticators are configured, the chain is left as `None`. /// The router still requires an mTLS principal or the explicit development opt-in. -fn build_authenticator_chain(state: &ServerState) -> Option { +pub fn build_authenticator_chain(state: &ServerState) -> Option { let mut authenticators: Vec> = Vec::new(); if let Some(peer) = state.peer_authenticator.clone() { authenticators.push(peer); @@ -1176,7 +1183,7 @@ impl AuthGrpcRouter { } } -fn unauthenticated_dev_user_principal() -> Principal { +pub fn unauthenticated_dev_user_principal() -> Principal { Principal::User(UserPrincipal { identity: Identity { subject: "unauthenticated-local-dev".to_string(), diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index ece2add1e0..acee881604 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -182,7 +182,11 @@ pub fn endpoint_url( fn endpoint_scheme(config: &openshell_core::Config) -> &'static str { if config.tls.is_none() || (config.bind_address.ip().is_loopback() - && config.service_routing.enable_loopback_service_http) + && config.service_routing.enable_loopback_service_http + && !config.mtls_auth.enabled + && !config.mtls_auth.operator_enabled + && config.oidc.is_none() + && !config.supervisor_app_listener.enabled) { "http" } else { @@ -566,7 +570,7 @@ pub async fn is_sandbox_service_request_with_state( pub async fn proxy_sandbox_service_request( state: Arc, - req: Request, + mut req: Request, ) -> impl IntoResponse { let Some(host) = request_host(&req).map(str::to_string) else { return StatusCode::NOT_FOUND.into_response(); @@ -581,6 +585,22 @@ pub async fn proxy_sandbox_service_request( } = target; let service_name = service.unwrap_or_default(); + if req + .uri() + .path() + .starts_with(crate::auth::service_session::PREFIX) + { + return match crate::auth::service_session::handle(&state, &mut req, &workspace).await { + Ok(response) => response, + Err(error) => crate::auth::service_session::error(&error), + }; + } + if let Err(error) = + crate::auth::service_session::authorize_request(&state, &mut req, &workspace).await + { + return crate::auth::service_session::error(&error); + } + match proxy_to_endpoint(state, req, &workspace, sandbox_name, service_name).await { Ok(response) => response.into_response(), Err(err) => err.into_response(), @@ -857,11 +877,13 @@ async fn proxy_to_endpoint( } }); - let (parts, _) = response.into_parts(); + let (mut parts, _) = response.into_parts(); + crate::auth::service_session::strip_response_cookies(&mut parts.headers); return Ok(Response::from_parts(parts, Body::empty())); } - let (parts, body) = response.into_parts(); + let (mut parts, body) = response.into_parts(); + crate::auth::service_session::strip_response_cookies(&mut parts.headers); Ok(Response::from_parts(parts, Body::new(body))) } @@ -1140,7 +1162,13 @@ fn is_gateway_auth_header( ) -> bool { match name.as_str() { "authorization" => authorization_mode != ServiceAuthorizationMode::BearerPassthrough, - "cf-access-jwt-assertion" + "x-openshell-authorization" + | "x-openshell-auth-source" + | "x-openshell-principal" + | "x-openshell-user" + | "x-openshell-roles" + | "cf-access-authenticated-user-email" + | "cf-access-jwt-assertion" | "x-forwarded-client-cert" | "x-ssl-client-cert" | "x-client-cert" => true, @@ -1167,7 +1195,8 @@ fn sanitize_cookie_header(value: &HeaderValue) -> Option { } fn is_gateway_auth_cookie(name: &str) -> bool { - name.eq_ignore_ascii_case("CF_Authorization") || name.eq_ignore_ascii_case("cf-authorization") + name.eq_ignore_ascii_case("CF_Authorization") + || name.to_ascii_lowercase().starts_with("__host-openshell-") } pub fn emit_service_endpoint_config_event(endpoint: &ServiceEndpoint, url: &str, created: bool) { @@ -1424,6 +1453,475 @@ mod tests { use super::*; use openshell_core::config::ServiceHostnameFormat; + #[tokio::test] + async fn gateway_application_auth_gates_http_and_websocket_on_both_routers() { + use crate::auth::{ + identity::{Identity, IdentityProvider}, + service_access::ServicePeer, + }; + use crate::grpc::test_support::test_server_state; + use tower::ServiceExt; + + let mut state = test_server_state().await; + let inner = Arc::get_mut(&mut state).unwrap(); + inner.config.supervisor_app_listener.enabled = true; + inner.config.mtls_auth.enabled = true; + inner.config.auth.allow_unauthenticated_users = false; + inner.config.service_routing = config(); + let peer = ServicePeer { + identity: Some(Identity { + subject: "verified-user".into(), + display_name: None, + roles: vec![], + scopes: vec![], + provider: IdentityProvider::Mtls, + }), + ..Default::default() + }; + for router in [ + crate::http::http_router(state.clone()), + crate::http::service_http_router(state.clone()), + ] { + for path in ["/", "/auth/login", "/grpc.health.v1.Health/Check"] { + for websocket in [false, true] { + let mut request = Request::builder() + .uri(path) + .header(header::HOST, "default--unknown.dev.openshell.localhost") + .header("x-forwarded-client-cert", "forged-user"); + if websocket { + request = request + .header(header::CONNECTION, "upgrade") + .header(header::UPGRADE, "websocket"); + } + let response = router + .clone() + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.headers()[header::WWW_AUTHENTICATE], "Bearer"); + } + } + let request = || { + Request::builder() + .uri("/") + .header(header::HOST, "default--unknown.dev.openshell.localhost") + .body(Body::empty()) + .unwrap() + }; + // A verified peer passes the auth gate and reaches endpoint lookup. + let response = router + .clone() + .layer(axum::Extension(peer.clone())) + .oneshot(request()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + Arc::get_mut(&mut state).unwrap().admin_role = "openshell-admin".into(); + let response = crate::http::http_router(state.clone()) + .layer(axum::Extension(peer)) + .oneshot( + Request::builder() + .uri("/") + .header(header::HOST, "default--unknown.dev.openshell.localhost") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "workspace authorization precedes endpoint lookup" + ); + Arc::get_mut(&mut state) + .unwrap() + .config + .supervisor_app_listener + .enabled = false; + let response = crate::http::http_router(state) + .oneshot( + Request::builder() + .uri("/") + .header(header::HOST, "default--unknown.dev.openshell.localhost") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "listener-disabled gateways enforce configured authentication" + ); + } + + #[test] + fn authenticated_gateway_never_forwards_gateway_credentials() { + let request = || { + Request::builder() + .uri("/") + .header(header::AUTHORIZATION, "Bearer gateway-secret") + .header("cf-access-jwt-assertion", "edge-secret") + .body(Body::empty()) + .unwrap() + }; + let mode = effective_authorization_mode(ServiceAuthorizationMode::Strip as i32); + let upstream = build_upstream_request(request(), 8080, false, mode).unwrap(); + assert!(!upstream.headers().contains_key(header::AUTHORIZATION)); + assert!(!upstream.headers().contains_key("cf-access-jwt-assertion")); + let mode = effective_authorization_mode(ServiceAuthorizationMode::BearerPassthrough as i32); + let upstream = build_upstream_request(request(), 8080, false, mode).unwrap(); + assert_eq!( + upstream.headers()[header::AUTHORIZATION], + "Bearer gateway-secret" + ); + } + + #[tokio::test] + async fn authenticated_gateway_forwards_application_request_without_gateway_identity() { + use crate::auth::{ + identity::{Identity, IdentityProvider}, + service_access::ServicePeer, + }; + use crate::grpc::test_support::test_server_state; + use http_body_util::BodyExt; + use hyper::service::service_fn; + use tower::ServiceExt; + + let mut state = test_server_state().await; + let inner = Arc::get_mut(&mut state).unwrap(); + inner.config.supervisor_app_listener.enabled = true; + inner.config.mtls_auth.enabled = true; + inner.config.auth.allow_unauthenticated_users = false; + inner.config.service_routing = config(); + let mut endpoint = endpoint(); + endpoint.authorization_mode = ServiceAuthorizationMode::BearerPassthrough as i32; + state.store.put_message(&endpoint).await.unwrap(); + let mut sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sandbox-id".into(), + name: "my-sandbox".into(), + workspace: "default".into(), + ..Default::default() + }), + ..Default::default() + }; + sandbox.set_phase(SandboxPhase::Ready as i32); + state.store.put_message(&sandbox).await.unwrap(); + + let (client_io, server_io) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(server_io), + service_fn(|request: Request| async move { + assert_eq!(request.method(), Method::POST); + assert_eq!(request.uri().path(), "/app"); + assert!(!request.headers().contains_key(header::AUTHORIZATION)); + assert!(!request.headers().contains_key("x-forwarded-client-cert")); + assert!(!request.headers().contains_key("cf-access-jwt-assertion")); + let body = request.into_body().collect().await.unwrap().to_bytes(); + Ok::<_, std::convert::Infallible>(Response::new(Body::from(body))) + }), + ) + .await + .unwrap(); + }); + let (mut sender, connection) = + hyper::client::conn::http1::handshake(TokioIo::new(client_io)) + .await + .unwrap(); + let connection = tokio::spawn(connection); + sender.ready().await.unwrap(); + state + .service_upstreams + .put(&upstream_pool_key("endpoint-id", 8080), sender); + let peer = ServicePeer { + identity: Some(Identity { + subject: "verified-user".into(), + display_name: None, + roles: vec![], + scopes: vec![], + provider: IdentityProvider::Mtls, + }), + ..Default::default() + }; + let response = crate::http::http_router(state) + .layer(axum::Extension(peer)) + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/app") + .header( + header::ORIGIN, + "https://default--my-sandbox--web.dev.openshell.localhost", + ) + .header( + header::HOST, + "default--my-sandbox--web.dev.openshell.localhost", + ) + .header("x-forwarded-client-cert", "forged-user") + .header("cf-access-jwt-assertion", "edge-token") + .body(Body::from("application-payload")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + "application-payload" + ); + connection.abort(); + server.abort(); + } + + #[tokio::test] + async fn gateway_bearer_and_browser_sessions_forward_only_application_credentials() { + use crate::auth::service_session::{ + COOKIE, GATEWAY_AUTH, + tests::{browser_login, fixture}, + }; + use http_body_util::BodyExt; + use hyper::service::service_fn; + use tower::ServiceExt; + let (_issuer, state, token) = fixture().await; + let cookie = browser_login(&state, &token).await; + let mut endpoint = endpoint(); + endpoint.sandbox = "demo".into(); + endpoint.metadata.as_mut().unwrap().name = "demo--web".into(); + endpoint.authorization_mode = ServiceAuthorizationMode::BearerPassthrough.into(); + state.store.put_message(&endpoint).await.unwrap(); + let mut sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sandbox-id".into(), + name: "demo".into(), + workspace: "default".into(), + ..Default::default() + }), + ..Default::default() + }; + sandbox.set_phase(SandboxPhase::Ready.into()); + state.store.put_message(&sandbox).await.unwrap(); + let (client_io, server_io) = tokio::io::duplex(8192); + let server = tokio::spawn(async move { + hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(server_io), + service_fn(|request: Request| async move { + assert_eq!(request.method(), Method::POST); + assert_eq!( + request.headers()[header::COOKIE], + "application_session=app-cookie" + ); + assert!(!request.headers().contains_key(GATEWAY_AUTH)); + assert!(!request.headers().contains_key("x-openshell-auth-source")); + assert!( + request + .headers() + .get(header::AUTHORIZATION) + .is_none_or(|v| v == "Bearer application-token") + ); + let body = request.into_body().collect().await.unwrap().to_bytes(); + let mut response = Response::new(Body::from(body)); + response.headers_mut().append( + header::SET_COOKIE, + "application_session=updated; HttpOnly".parse().unwrap(), + ); + response.headers_mut().append( + header::SET_COOKIE, + format!("{COOKIE}=forged; Secure; HttpOnly; Path=/") + .parse() + .unwrap(), + ); + Ok::<_, std::convert::Infallible>(response) + }), + ) + .await + .unwrap(); + }); + let (mut sender, connection) = + hyper::client::conn::http1::handshake(TokioIo::new(client_io)) + .await + .unwrap(); + let connection = tokio::spawn(connection); + sender.ready().await.unwrap(); + state + .service_upstreams + .put(&upstream_pool_key("endpoint-id", 8080), sender); + for method in ["session", "separate-header", "authorization"] { + let mut request = Request::builder() + .method(Method::POST) + .uri("/application") + .header(header::HOST, "default--demo--web.services.example.com") + .header( + header::ORIGIN, + "https://default--demo--web.services.example.com", + ) + .header("x-openshell-auth-source", "forged"); + if method == "session" { + request = request + .header( + header::COOKIE, + format!("{cookie}; application_session=app-cookie"), + ) + .header(header::AUTHORIZATION, "Bearer application-token"); + } else { + request = request.header(header::COOKIE, "application_session=app-cookie"); + request = if method == "separate-header" { + request + .header(GATEWAY_AUTH, format!("Bearer {token}")) + .header(header::AUTHORIZATION, "Bearer application-token") + } else { + request.header(header::AUTHORIZATION, format!("Bearer {token}")) + }; + } + let mut request = request.body(Body::from("payload")).unwrap(); + request + .extensions_mut() + .insert(crate::auth::service_access::ServicePeer { + secure: true, + ..Default::default() + }); + let response = crate::http::http_router(state.clone()) + .oneshot(request) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{method}"); + assert_eq!( + response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .count(), + 1 + ); + assert!( + response.headers()[header::SET_COOKIE] + .to_str() + .unwrap() + .starts_with("application_session=") + ); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + "payload" + ); + } + connection.abort(); + server.abort(); + } + + #[tokio::test] + #[ignore = "requires OPENSHELL_BROWSER_E2E_CHROME and Node.js"] + async fn gateway_service_auth_real_browser_cross_site_login() { + use crate::auth::service_session::tests::fixture; + use http_body_util::BodyExt; + use hyper::service::service_fn; + let (_issuer, mut state, token) = fixture().await; + let dir = tempfile::tempdir().unwrap(); + crate::tls_test_utils::generate_test_certs_with_ca(dir.path()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let inner = Arc::get_mut(&mut state).unwrap(); + inner.config.bind_address = addr; + inner.config.tls = Some(openshell_core::TlsConfig { + cert_path: dir.path().join("server-cert.pem"), + key_path: dir.path().join("server-key.pem"), + client_ca_path: None, + require_client_auth: false, + external_cert_path: None, + external_key_path: None, + external_server_names: vec![], + }); + let mut endpoint = endpoint(); + endpoint.sandbox = "demo".into(); + endpoint.metadata.as_mut().unwrap().name = "demo--web".into(); + state.store.put_message(&endpoint).await.unwrap(); + let mut sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sandbox-id".into(), + name: "demo".into(), + workspace: "default".into(), + ..Default::default() + }), + ..Default::default() + }; + sandbox.set_phase(SandboxPhase::Ready.into()); + state.store.put_message(&sandbox).await.unwrap(); + let (client_io, server_io) = tokio::io::duplex(8192); + let application = tokio::spawn(async move { + hyper::server::conn::http1::Builder::new().serve_connection(TokioIo::new(server_io),service_fn(|request:Request|async move { + assert!(!request.headers().contains_key(header::AUTHORIZATION)); + assert!(request.headers().get(header::COOKIE).is_none_or(|v|!v.to_str().unwrap().contains("__Host-openshell-"))); + let _=request.into_body().collect().await.unwrap(); + let mut response=Response::new(Body::from("Sandbox application

Application reached

")); + response.headers_mut().insert(header::CONTENT_TYPE,"text/html".parse().unwrap()); + response.headers_mut().insert(header::SET_COOKIE,"application_session=app-cookie; Secure; HttpOnly; SameSite=Strict".parse().unwrap()); + Ok::<_,std::convert::Infallible>(response) + })).await.unwrap(); + }); + let (mut sender, connection) = + hyper::client::conn::http1::handshake(TokioIo::new(client_io)) + .await + .unwrap(); + let connection = tokio::spawn(connection); + sender.ready().await.unwrap(); + state + .service_upstreams + .put(&upstream_pool_key("endpoint-id", 8080), sender); + let acceptor = crate::TlsAcceptor::from_files( + &dir.path().join("server-cert.pem"), + &dir.path().join("server-key.pem"), + None, + false, + None, + None, + vec![], + ) + .unwrap(); + let (shutdown, rx) = tokio::sync::watch::channel(false); + let server = tokio::spawn(crate::serve_gateway_listener( + crate::BoundGatewayListener { + listener, + address: addr, + }, + crate::multiplex::MultiplexService::new(state), + Some(acceptor), + false, + rx, + )); + let mut child = tokio::process::Command::new("node") + .arg(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/browser-service-auth.cjs" + )) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let mut stdin = child.stdin.take().unwrap(); + stdin.write_all(&serde_json::to_vec(&serde_json::json!({"gateway":format!("https://gateway.example.test:{}",addr.port()),"application":format!("https://default--demo--web.services.example.com:{}",addr.port()),"token":token})).unwrap()).await.unwrap(); + drop(stdin); + let output = tokio::time::timeout(Duration::from_secs(75), child.wait_with_output()) + .await + .unwrap() + .unwrap(); + let _ = shutdown.send(true); + server.await.unwrap(); + connection.abort(); + application.abort(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("PASS browser cross-site login")); + } + fn endpoint() -> ServiceEndpoint { ServiceEndpoint { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -1479,6 +1977,18 @@ mod tests { ); } + #[test] + fn authenticated_gateway_url_preserves_tls_on_loopback() { + let mut cfg = openshell_core::Config::new(Some(tls_config())) + .with_bind_address("127.0.0.1:8080".parse().unwrap()) + .with_server_sans(["*.dev.openshell.localhost"]); + cfg.supervisor_app_listener.enabled = true; + assert_eq!( + endpoint_url(&cfg, "default", "my-sandbox", "web").as_deref(), + Some("https://default--my-sandbox--web.dev.openshell.localhost:8080/") + ); + } + #[test] fn endpoint_url_omits_service_label_for_empty_service_name() { let cfg = openshell_core::Config::new(Some(tls_config())) diff --git a/crates/openshell-server/tests/browser-service-auth.cjs b/crates/openshell-server/tests/browser-service-auth.cjs new file mode 100644 index 0000000000..d2739d2319 --- /dev/null +++ b/crates/openshell-server/tests/browser-service-auth.cjs @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Optional real-browser fixture. CDP uses anonymous pipes, a disposable profile, +// loopback-only hostname resolution, and a disposable test issuer credential. +const { spawn } = require('node:child_process'); +const { mkdtempSync, rmSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); + +async function main() { + let input = ''; + for await (const chunk of process.stdin) input += chunk; + const { gateway, application, token } = JSON.parse(input); + const profile = mkdtempSync(join(tmpdir(), 'openshell-auth-browser-')); + const chrome = spawn(process.env.OPENSHELL_BROWSER_E2E_CHROME, [ + '--headless=new', '--remote-debugging-pipe', '--no-first-run', + '--no-default-browser-check', '--disable-background-networking', + '--ignore-certificate-errors', '--no-proxy-server', + '--host-resolver-rules=MAP gateway.example.test 127.0.0.1, MAP *.services.example.com 127.0.0.1', + `--user-data-dir=${profile}`, + ], { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] }); + const pending = new Map(); + let next = 0; + let buffered = ''; + chrome.stdio[4].on('data', chunk => { + buffered += chunk.toString(); + for (let end; (end = buffered.indexOf('\0')) >= 0;) { + const message = JSON.parse(buffered.slice(0, end)); + buffered = buffered.slice(end + 1); + const waiter = pending.get(message.id); + if (waiter) { + pending.delete(message.id); + if (message.error) waiter.reject(new Error(message.error.message)); + else waiter.resolve(message.result); + } + } + }); + const call = (method, params = {}, sessionId) => new Promise((resolve, reject) => { + const id = ++next; + const timeout = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, 10000); + pending.set(id, { resolve: result => { clearTimeout(timeout); resolve(result); }, reject: error => { clearTimeout(timeout); reject(error); } }); + chrome.stdio[3].write(JSON.stringify({ id, method, params, sessionId }) + '\0'); + }); + try { + const { targetId } = await call('Target.createTarget', { url: 'about:blank' }); + const { sessionId } = await call('Target.attachToTarget', { targetId, flatten: true }); + await call('Page.enable', {}, sessionId); + const evaluate = async expression => { + const result = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, sessionId); + if (result.exceptionDetails) throw new Error('Browser evaluation failed'); + return result.result.value; + }; + const waitFor = async expression => { + const until = Date.now() + 15000; + while (Date.now() < until) { + try { if (await evaluate(expression)) return; } catch { /* Navigation can replace the execution context. */ } + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error(`Browser flow did not converge: ${await evaluate('location.origin + " " + document.title')}`); + }; + await call('Page.navigate', { url: `${gateway}/auth/services?service_origin=${encodeURIComponent(application)}` }, sessionId); + await waitFor(`location.origin === ${JSON.stringify(gateway)} && !!document.getElementById('token')`); + await evaluate(`document.getElementById('token').value=${JSON.stringify(token)};document.getElementById('login').requestSubmit();true`); + await waitFor(`location.origin === ${JSON.stringify(application)} && !!document.getElementById('application')`); + const { cookies } = await call('Network.getCookies', { urls: [application] }, sessionId); + const session = cookies.find(cookie => cookie.name === '__Host-openshell-service-session'); + if (!session || !session.secure || !session.httpOnly || session.sameSite !== 'Strict') throw new Error('Missing protected session cookie'); + if ((await evaluate('document.cookie')).includes('__Host-openshell-')) throw new Error('Gateway cookie exposed to application JavaScript'); + if (await evaluate(`fetch('/echo',{method:'POST',body:'application request'}).then(r=>r.status)`) !== 200) throw new Error('Authenticated application POST failed'); + if (await evaluate(`fetch('/.openshell/service-auth/session',{method:'DELETE'}).then(r=>r.status)`) !== 204) throw new Error('Logout failed'); + if (await evaluate(`fetch('/').then(r=>r.status)`) !== 401) throw new Error('Logged-out browser retained access'); + process.stdout.write('PASS browser cross-site login, Strict/HttpOnly cookies, authenticated POST, and logout\n'); + } finally { + if (chrome.exitCode === null && chrome.signalCode === null) { + chrome.kill('SIGKILL'); + await new Promise(resolve => chrome.once('exit', resolve)); + } + rmSync(profile, { recursive: true, force: true }); + } +} +main().catch(error => { process.stderr.write(error.message + '\n'); process.exitCode = 1; }); diff --git a/crates/openshell-supervisor/src/app_listener.rs b/crates/openshell-supervisor/src/app_listener.rs index 8208d2ba42..6f4f24f1c4 100644 --- a/crates/openshell-supervisor/src/app_listener.rs +++ b/crates/openshell-supervisor/src/app_listener.rs @@ -733,6 +733,8 @@ fn gateway_header(name: &str) -> bool { matches!( name, "proxy-authorization" + | "x-openshell-authorization" + | "x-openshell-auth-source" | "cf-access-jwt-assertion" | "cf-access-authenticated-user-email" | "x-openshell-principal" @@ -815,6 +817,10 @@ fn sanitize_headers(headers: &mut http::HeaderMap, passthrough: bool, websocket: cookie.split_once('=').is_some_and(|(name, _)| { !name.trim().eq_ignore_ascii_case("CF_Authorization") && !name.trim().eq_ignore_ascii_case("cf-authorization") + && !name + .trim() + .to_ascii_lowercase() + .starts_with("__host-openshell-") }) }) .map(str::trim) @@ -1447,14 +1453,34 @@ mod tests { let mut headers = http::HeaderMap::new(); headers.insert( header::COOKIE, - "app=session; CF_Authorization=secret".parse().unwrap(), + "app=session; CF_Authorization=secret; __Host-openshell-service-session=secret; __Host-openshell-service-login=secret".parse().unwrap(), ); headers.insert("x-forwarded-client-cert", "secret".parse().unwrap()); + headers.insert( + "x-openshell-authorization", + "Bearer secret".parse().unwrap(), + ); + headers.insert("x-openshell-auth-source", "forged".parse().unwrap()); headers.insert(header::CONNECTION, "x-internal".parse().unwrap()); headers.insert("x-internal", "secret".parse().unwrap()); + headers.insert( + header::AUTHORIZATION, + "Bearer application-token".parse().unwrap(), + ); + let mut passthrough = headers.clone(); + assert!(sanitize_headers(&mut passthrough, true, false)); + assert_eq!( + passthrough[header::AUTHORIZATION], + "Bearer application-token" + ); + assert_eq!(passthrough[header::COOKIE], "app=session"); + assert!(!passthrough.contains_key("x-openshell-authorization")); assert!(sanitize_headers(&mut headers, false, false)); + assert!(!headers.contains_key(header::AUTHORIZATION)); assert_eq!(headers[header::COOKIE], "app=session"); assert!(!headers.contains_key("x-forwarded-client-cert")); + assert!(!headers.contains_key("x-openshell-authorization")); + assert!(!headers.contains_key("x-openshell-auth-source")); assert!(!headers.contains_key("x-internal")); } #[tokio::test] diff --git a/docs/how-it-works/gateways/authentication.mdx b/docs/how-it-works/gateways/authentication.mdx index b764d3fbfe..0801bc6e7b 100644 --- a/docs/how-it-works/gateways/authentication.mdx +++ b/docs/how-it-works/gateways/authentication.mdx @@ -8,7 +8,110 @@ keywords: "Generative AI, Cybersecurity, Gateway, Authentication, mTLS, OIDC, Op position: 1 --- -This page describes how the CLI resolves a gateway, authenticates with it, and where credentials are stored. For how to deploy or register gateways, refer to [Gateways](/how-it-works/gateways/overview). +This page describes gateway authentication for CLI clients and sandbox service requests. For how to deploy or register gateways, refer to [Gateways](/how-it-works/gateways/overview). + +## Sandbox service requests + +Gateway-mediated HTTP requests and WebSocket upgrades authenticate with the +configured gateway identity and require workspace user access. OIDC users need +the configured user or admin role and, when scope enforcement is enabled, +`sandbox:write` or `openshell:all`, matching TCP forwarding. Discovery-only access +does not grant application access. This applies with the supervisor app listener +enabled or disabled. Only explicitly enabled unauthenticated local development +bypasses the gate. Existing unauthenticated service clients must supply gateway +credentials in authenticated deployments. + +For a request that uses only gateway bearer authentication: + +```shell +curl --header "Authorization: Bearer ${GATEWAY_ACCESS_TOKEN}" \ + https://default--my-sandbox--web.gateway.example.com/ +``` + +The gateway consumes that header. When an application also needs its own bearer +token, enable the service's `bearer-passthrough` mode and separate the credentials: + +```shell +curl --header "X-OpenShell-Authorization: Bearer ${GATEWAY_ACCESS_TOKEN}" \ + --header "Authorization: Bearer ${APPLICATION_ACCESS_TOKEN}" \ + https://default--my-sandbox--web.gateway.example.com/ +``` + +A verified mTLS identity or browser session can also supply gateway authentication +while `Authorization` carries the application's token. The gateway strips its +own headers and reserved cookies before forwarding. Application cookies retain +their normal behavior; applications cannot overwrite reserved gateway cookies. +Missing, invalid, expired, or gateway-revoked credentials return 401 before any +application connection. Role, scope, membership, and origin denials return 403. + +### Browser sessions + +Open the control-plane gateway's sign-in page with the gateway-mediated service +origin as `service_origin`, for example: + +```text +https://gateway.example.com/auth/services?service_origin=https%3A%2F%2Fdefault--my-sandbox--web.gateway.example.com +``` + +The browser first visits the service to establish login state, then returns to +the control-plane sign-in page. Verify that page's gateway origin and target +service, and supply an access token obtained through your configured identity +provider. The gateway sends a single-use code to the service origin and installs +a host-only, Secure, HttpOnly, SameSite=Strict session cookie. Application +JavaScript never receives the gateway token. Configured mTLS browser users can +instead access the service directly with their client certificate. + +Browser login requires HTTPS to the gateway itself; forwarded scheme or identity +headers cannot enable it. The gateway stores token material through its configured +credential driver, which must support storing session secrets. Replicas must +share the database and credential backend/key. Direct bearer and mTLS requests +do not require browser session storage. +Login codes expire after two minutes. Sessions expire after ten minutes or the +token's expiration, whichever comes first; sign in again rather than silently +refreshing the token. Every request revalidates the token and current workspace +membership. Each workspace admits at most 1,024 pending and active sessions; +expired sessions are cleaned up during login. + +Cookie and mTLS requests require a matching Origin for unsafe HTTP methods and +WebSocket upgrades. Cross-origin browser requests are rejected. Headless mTLS +clients making unsafe requests must also provide the matching Origin. Explicit +bearer headers avoid ambient browser credentials. WebSockets authenticate at +upgrade; an established connection is not continuously reauthenticated. + +### Logout and credential revocation + +From application-origin JavaScript, log out with: + +```javascript +await fetch('/.openshell/service-auth/session', { method: 'DELETE' }); +``` + +The gateway deletes the session in the shared store and expires its cookie, so +replaying that cookie against another replica fails. To revoke application access +for the current gateway bearer token or verified client certificate, use +`DELETE /.openshell/service-auth/credential` on the service origin. Include the +matching Origin and an explicit gateway bearer header or verified mTLS identity. +A service-scoped browser cookie can revoke only its own session. For example: + +```shell +curl --request DELETE \ + --header "Origin: https://default--my-sandbox--web.gateway.example.com" \ + --header "Authorization: Bearer ${GATEWAY_ACCESS_TOKEN}" \ + https://default--my-sandbox--web.gateway.example.com/.openshell/service-auth/credential +``` + +Revocation is durable across gateway replicas and rejects subsequent application +requests, including browser sessions using that token. Obtain a new token or +rotate the client certificate to regain access. Revocation targets gateway +application access; it does not revoke the identity provider's token or alter +control-plane RPC credentials. Offline OIDC JWT validation cannot discover an +identity provider's individual-token revocations before token expiry or key +changes; use this gateway revocation endpoint for immediate application cutoff. +The gateway retains certificate revocations and removes expired token revocations; +it admits at most 1,024 outstanding application credential revocations. + +Direct supervisor app-listener access delegates authentication to the ingress +provider. Gateway browser sessions and credentials do not authorize that path. ## Gateway Resolution diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index ea2cf456b6..ce0fbbb63d 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -1786,3 +1786,14 @@ URL does not mean that a supervisor installed the route or ingress is ready. Upgrade the CLI alongside the gateway when enabling `public_url`. Older CLI versions can replace the public application port with the gateway connection's port. + +Gateway-mediated service URLs use configured gateway authentication and workspace +access controls with the app listener enabled or disabled. See +[application request authentication](/how-it-works/gateways/authentication#sandbox-service-requests) +for browser sessions, application credential passthrough, and revocation. +Direct app-listener authentication belongs to the ingress provider. + +TLS gateways advertise HTTPS for authenticated gateway service access, including +on loopback, so clients can present an mTLS identity. The separate plaintext +loopback service listener cannot accept a client-certificate identity or browser +sessions; use an explicit gateway bearer credential there. diff --git a/docs/how-it-works/sandboxes/overview.mdx b/docs/how-it-works/sandboxes/overview.mdx index aadfdbe281..76330eaa46 100644 --- a/docs/how-it-works/sandboxes/overview.mdx +++ b/docs/how-it-works/sandboxes/overview.mdx @@ -579,9 +579,15 @@ Pass an optional service name to create a named service URL: openshell service expose my-sandbox 8080 web ``` -OpenShell strips the incoming `Authorization` header by default. Opt a service -into forwarding one valid bearer credential unchanged when the application -performs its own authentication: +Gateway-mediated requests require configured gateway authentication and workspace +access whether the app listener is enabled or disabled. Gateway credentials are +consumed before forwarding. Use a browser session, mTLS identity, or separate +`X-OpenShell-Authorization` gateway header when the application also needs an +`Authorization` header. See [sandbox service authentication](/how-it-works/gateways/authentication#sandbox-service-requests). +Direct app-listener authentication belongs to the ingress provider. + +OpenShell strips application `Authorization` headers by default. Opt a service +into forwarding one valid application bearer credential unchanged: ```shell openshell service expose my-sandbox 4500 \ diff --git a/rfc/0017-sandbox-service-exposure/README.md b/rfc/0017-sandbox-service-exposure/README.md index 287d89e0d1..361f0cab99 100644 --- a/rfc/0017-sandbox-service-exposure/README.md +++ b/rfc/0017-sandbox-service-exposure/README.md @@ -152,13 +152,15 @@ The ingress policy schema needs definition. Existing egress rules must not impli Application TLS is configured separately from gateway TLS. Operators provide DNS and certificates; `svc.openshell.internal` is an example domain. Certificate provisioning and rotation remain open questions. -SNI selects a route; it does not authenticate a user. Applications can handle authentication, or operators can apply an edge access policy. Preserve existing header handling: strip `Authorization` by default, allow explicit `bearer-passthrough`, and strip gateway/edge identity headers and edge authentication cookies. Native OpenShell user/workspace authorization is not the proposed default for application requests. +SNI selects a route; it does not authenticate a user. Direct app-listener access delegates authentication to the ingress provider; implementing that authentication is outside this proposal. Preserve existing listener header handling: strip `Authorization` by default, allow explicit `bearer-passthrough`, and strip gateway/edge identity headers and edge authentication cookies. + +Gateway-mediated application HTTP requests and WebSocket upgrades use configured gateway authentication and workspace authorization independently of app-listener enablement, as tracked by #4722. Use the TCP-forwarding access policy, including `sandbox:write` when scopes are enforced. Keep gateway credentials separate from application bearer passthrough and cookies; consume and strip gateway material before forwarding. Browser sessions require a control-plane login flow, origin/CSRF protections, and revocation. Missing, invalid, expired, or revoked credentials fail before application dial; insufficient permissions return 403. WebSockets authenticate at upgrade. Explicit unauthenticated local-development configurations retain their documented behavior. A passthrough proxy cannot inspect HTTP credentials. An edge that terminates TLS must reconnect with the correct service SNI and authority. Restrict supervisor listener access to prevent bypassing required edge authentication; use authenticated backend transport where network restrictions are insufficient. ### Deployment and compatibility -Enable the new path explicitly and retain existing gateway-mode behavior. CLI/SDK URLs must use the application ingress domain and public port independently of the gateway endpoint. Status should distinguish configured, advertised, and unavailable routes. Domain migrations require coordinated DNS, TLS, and client changes. +Enable the app-listener path explicitly. Gateway-mediated service clients independently require configured gateway credentials and workspace access; TLS gateway URLs retain HTTPS on loopback to support client certificates. CLI/SDK URLs must use the application ingress domain and public port independently of the gateway endpoint. Status should distinguish configured, advertised, and unavailable routes. Domain migrations require coordinated DNS, TLS, and client changes. ## Implementation plan diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 298f8db66a..d55fcb9557 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -1639,3 +1639,20 @@ Service responses identify the application and gateway paths separately. A configured application URL must retain its public port even when the CLI uses a gateway port-forward. Inspect discovery and ingress acceptance separately: URL publication alone proves neither installation nor public reachability. + +Gateway-mediated service HTTP requests and WebSocket upgrades require configured +gateway authentication and workspace user access with the app listener disabled +or enabled. OIDC scope enforcement requires `sandbox:write` (or `openshell:all`), +as for TCP forwarding. Check HTTP 401 for missing/invalid/expired/revoked +credentials and 403 for role/scope/membership/origin denial. Browser login starts +at the control-plane `/auth/services?service_origin=...`; the app host receives +only a single-use code and a Secure/HttpOnly cookie. Check shared database and +credential-driver availability across replicas. Browser sessions expire within +ten minutes and reauthorize on every request. DELETE the same-origin app paths +`/.openshell/service-auth/session` to log out or +`/.openshell/service-auth/credential` to revoke the current application-access +credential. `X-OpenShell-Authorization` separates gateway bearer auth from an +application Authorization header. Gateway headers and reserved cookies are +stripped; application cookies and explicit bearer passthrough remain available. +Only explicit local-development unauthenticated access bypasses gateway auth. +Direct listener authentication belongs to the ingress provider.