Skip to content

Commit caacd27

Browse files
committed
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 <taylormutch@gmail.com>
1 parent 66220a7 commit caacd27

17 files changed

Lines changed: 2859 additions & 28 deletions

File tree

‎crates/openshell-server/README.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,39 @@ after one minute or a five-second blocked send. Each gateway process accepts at
231231
most 32 watches. Reconnect and relist on stream termination; revisions are opaque
232232
equality tokens, not resumable cursors or an ordering across gateway replicas.
233233

234+
## Gateway application authentication
235+
236+
Gateway-mediated application HTTP requests and WebSocket upgrades authenticate
237+
through the gateway chain or verified TLS client identity before endpoint lookup,
238+
independently of app-listener enablement. User access requires TCP-forwarding
239+
role/scope policy (`sandbox:write` when enforced) and current workspace membership;
240+
operators retain their platform grant. Internal principals are rejected. Only
241+
explicit local-development unauthenticated access bypasses this gate.
242+
243+
`/auth/services` hosts browser login on the control-plane origin. The application
244+
origin receives a single-use bootstrap code bound to a browser login nonce, then
245+
a distinct host-only, Secure/HttpOnly session cookie. Shared metadata contains
246+
only credential-driver handles and hashes; the configured credential driver
247+
stores the access token. Login codes expire after two minutes, sessions after
248+
at most ten minutes or token expiry. Every request revalidates the token and
249+
workspace authorization. Session deletion, credential revocation, and redemption
250+
CAS use the shared database. Expired metadata cleanup and admission are bounded.
251+
The application path consumes standard gateway bearer headers, or authenticates
252+
through a separate `X-OpenShell-Authorization`, browser session, or mTLS identity
253+
while preserving application bearer passthrough and cookies. Gateway headers and
254+
reserved cookies never reach the workload, and workloads cannot set reserved
255+
browser auth cookies. Browser ambient credentials require origin checks for
256+
unsafe methods and WebSocket upgrades. Direct supervisor ingress delegates
257+
application authentication to its ingress provider.
258+
259+
The optional real-Chrome login test uses an isolated profile and local test
260+
credentials. Run it with `OPENSHELL_BROWSER_E2E_CHROME` pointing to Chrome:
261+
262+
```shell
263+
OPENSHELL_BROWSER_E2E_CHROME='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' \
264+
cargo test -p openshell-server --lib gateway_service_auth_real_browser_cross_site_login -- --ignored
265+
```
266+
234267
## Application URL publication
235268

236269
`supervisor.app_listener.public_url` is an optional HTTPS origin matching the

‎crates/openshell-server/src/auth/http.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ struct ConnectParams {
5757
/// Create the auth router.
5858
pub fn router(state: Arc<ServerState>) -> Router {
5959
Router::new()
60+
.route(
61+
"/auth/services",
62+
get(service_browser_login).post(service_browser_login),
63+
)
6064
.route("/auth/connect", get(auth_connect))
6165
.route("/auth/oidc-config", get(oidc_config_handler))
6266
.route("/.well-known/jwks.json", get(gateway_jwks_handler))
@@ -67,6 +71,16 @@ pub fn router(state: Arc<ServerState>) -> Router {
6771
.with_state(state)
6872
}
6973

74+
async fn service_browser_login(
75+
State(state): State<Arc<ServerState>>,
76+
req: axum::extract::Request,
77+
) -> axum::response::Response {
78+
match super::service_session::control(&state, req).await {
79+
Ok(response) => response,
80+
Err(error) => super::service_session::error(&error),
81+
}
82+
}
83+
7084
/// Publish OIDC-shaped discovery metadata for extension services.
7185
///
7286
/// This lets an extension be configured with only the trusted gateway URL:

‎crates/openshell-server/src/auth/mod.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ pub mod principal;
2525
pub mod sandbox_jwt;
2626
pub mod sandbox_methods;
2727
pub mod sandbox_session;
28+
pub mod service_access;
29+
pub mod service_session;
2830
pub mod supervisor_token;
2931
pub mod workspace_authz;
3032

‎crates/openshell-server/src/auth/oidc.rs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -948,7 +948,7 @@ impl Authenticator for OidcAuthenticator {
948948
}
949949

950950
#[cfg(test)]
951-
mod tests {
951+
pub mod tests {
952952
use super::*;
953953

954954
fn transport_test_config(issuer: impl Into<String>) -> OidcConfig {
@@ -1418,7 +1418,7 @@ mod tests {
14181418
}
14191419

14201420
/// Sign `claims` with the test key, tagging the header with `kid`.
1421-
fn mint_rs256(claims: &serde_json::Value, kid: &str) -> String {
1421+
pub fn mint_rs256(claims: &serde_json::Value, kid: &str) -> String {
14221422
crate::install_jsonwebtoken_crypto_provider();
14231423

14241424
let mut header = jsonwebtoken::Header::new(Algorithm::RS256);
@@ -1428,7 +1428,7 @@ mod tests {
14281428
openshell_crypto::jwt::encode(&header, claims, &key).expect("sign RS256 token")
14291429
}
14301430

1431-
fn claims_for(issuer: &str, audience: &str, exp: i64) -> serde_json::Value {
1431+
pub fn claims_for(issuer: &str, audience: &str, exp: i64) -> serde_json::Value {
14321432
serde_json::json!({
14331433
"sub": "user-42",
14341434
"preferred_username": "ada",
@@ -1442,7 +1442,7 @@ mod tests {
14421442

14431443
/// Serve an OIDC discovery document and a JWKS carrying the test key, then
14441444
/// build a cache against them the same way production does.
1445-
async fn cache_with_mock_issuer(server: &wiremock::MockServer) -> JwksCache {
1445+
pub async fn cache_with_mock_issuer(server: &wiremock::MockServer) -> JwksCache {
14461446
use wiremock::matchers::{method, path};
14471447
use wiremock::{Mock, ResponseTemplate};
14481448

0 commit comments

Comments
 (0)