Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions crates/openshell-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,10 @@ pub struct Config {
/// Part of the programmatically-built [`Config`]; never deserialized directly.
#[derive(Debug, Clone)]
pub struct ServiceRoutingConfig {
/// Opt in to gateway authentication for proxied application requests.
/// Independent of the supervisor application listener.
pub auth_enabled: bool,

/// Base domains accepted for `sandbox--service.<domain>` routes.
/// The first domain is used when the gateway prints endpoint URLs.
pub base_domains: Vec<String>,
Expand Down Expand Up @@ -1262,6 +1266,13 @@ impl Config {
.then_some(self.name.as_str())
}

/// Opt in to configured gateway authentication on application requests.
#[must_use]
pub const fn with_service_auth(mut self, enabled: bool) -> Self {
self.service_routing.auth_enabled = enabled;
self
}

/// Enable or disable plaintext HTTP routing for loopback sandbox service
/// hostnames on TLS-enabled gateway listeners.
#[must_use]
Expand All @@ -1281,6 +1292,7 @@ impl Config {
impl Default for ServiceRoutingConfig {
fn default() -> Self {
Self {
auth_enabled: false,
base_domains: default_service_routing_domains(),
enable_loopback_service_http: default_enable_loopback_service_http(),
service_hostname_format: ServiceHostnameFormat::default(),
Expand Down Expand Up @@ -1820,6 +1832,8 @@ mod tests {
vec![DEFAULT_SERVICE_ROUTING_DOMAIN.to_string()]
);
assert!(cfg.service_routing.enable_loopback_service_http);
assert!(!cfg.service_routing.auth_enabled);
assert!(cfg.with_service_auth(true).service_routing.auth_enabled);
}

#[test]
Expand Down
3 changes: 2 additions & 1 deletion crates/openshell-credential-issuer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ This is the internal-refactoring increment of RFC phase 2, not remote readiness.

## Owners and compatibility

`SecretOwner` scopes each operation to a workspace and one of three kinds:
`SecretOwner` scopes each operation to a workspace and one of four kinds:

- `Provider`: the existing provider UUID and name.
- `ApplicationSession`: the gateway browser session storage identity (`service-session-<session ID>`), using the existing `gateway-service-session` driver scope.
- `Sandbox`: the sandbox UUID for its SSH host identity.
- `SupervisorInstance`: the sandbox UUID owning a launch-material assignment, not the shared supervisor route or host.

Expand Down
20 changes: 20 additions & 0 deletions crates/openshell-credential-issuer/src/owner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ pub enum SecretOwnerKind<'a> {
Sandbox {
id: &'a str,
},
/// Gateway-owned browser application session storage identity.
ApplicationSession {
id: &'a str,
},
/// Launch material is owned by a sandbox assignment, not the host/route.
SupervisorInstance {
sandbox_id: &'a str,
Expand Down Expand Up @@ -54,9 +58,20 @@ impl<'a> SecretOwner<'a> {
}
}

/// `id` is the existing `service-session-<session ID>` storage identity.
pub fn application_session(workspace: &'a str, id: &'a str) -> Self {
Self {
workspace,
kind: SecretOwnerKind::ApplicationSession { id },
}
}

pub(crate) fn legacy_scope(self) -> LegacyScope<'a> {
let (provider, id) = match self.kind {
SecretOwnerKind::Provider { id, name } => (Cow::Borrowed(name), id),
SecretOwnerKind::ApplicationSession { id } => {
(Cow::Borrowed("gateway-service-session"), id)
}
SecretOwnerKind::Sandbox { id } => (Cow::Owned(format!("sandbox-ssh-{id}")), id),
SecretOwnerKind::SupervisorInstance { sandbox_id } => (
Cow::Owned(format!("sandbox-instance-{sandbox_id}")),
Expand All @@ -83,6 +98,11 @@ mod tests {
"provider-name",
"provider-id",
),
(
SecretOwner::application_session("workspace-a", "service-session-session-id"),
"gateway-service-session",
"service-session-session-id",
),
(
SecretOwner::sandbox("workspace-a", "sandbox-id"),
"sandbox-ssh-sandbox-id",
Expand Down
39 changes: 39 additions & 0 deletions crates/openshell-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,45 @@ 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 service authentication defaults to disabled, independently of the app
listener. Disabled mode preserves application routing, reserved-looking paths,
and the pre-existing header/cookie policy. CLI `--service-auth-enabled` and
`OPENSHELL_SERVICE_AUTH_ENABLED` override the TOML setting.

With `[openshell.gateway].service_auth_enabled = true`, 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
Expand Down
19 changes: 18 additions & 1 deletion crates/openshell-server/src/auth/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,14 @@ fn sanitize_callback_host(raw: &Option<String>) -> (String, String) {

/// Create the auth router.
pub fn router(state: Arc<ServerState>) -> Router {
Router::new()
let mut router = Router::new();
if state.config.service_routing.auth_enabled {
router = router.route(
"/auth/services",
get(service_browser_login).post(service_browser_login),
);
}
router
.route("/auth/connect", get(auth_connect))
.route("/auth/oidc-config", get(oidc_config_handler))
.route("/.well-known/jwks.json", get(gateway_jwks_handler))
Expand All @@ -101,6 +108,16 @@ pub fn router(state: Arc<ServerState>) -> Router {
.with_state(state)
}

async fn service_browser_login(
State(state): State<Arc<ServerState>>,
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:
Expand Down
2 changes: 2 additions & 0 deletions crates/openshell-server/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
8 changes: 4 additions & 4 deletions crates/openshell-server/src/auth/oidc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -948,7 +948,7 @@ impl Authenticator for OidcAuthenticator {
}

#[cfg(test)]
mod tests {
pub mod tests {
use super::*;

fn transport_test_config(issuer: impl Into<String>) -> OidcConfig {
Expand Down Expand Up @@ -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);
Expand All @@ -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",
Expand All @@ -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};

Expand Down
Loading
Loading