diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 27e5b80dd9..c96f8d571c 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -245,6 +245,10 @@ pub struct Config { /// OIDC configuration. When `Some`, the server validates Bearer JWTs. pub oidc: Option, + /// Edge JWT configuration. When `Some`, the server verifies the edge + /// proxy's token on every request. + pub edge_auth: Option, + /// Gateway user authentication behavior. pub auth: GatewayAuthConfig, @@ -516,6 +520,50 @@ pub struct OidcConfig { pub scopes_claim: String, } +/// Identity provider that signs the edge token. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EdgeAuthProvider { + /// Cloudflare Access (`cf-access-jwt-assertion` header or `CF_Authorization` cookie). + CloudflareAccess, +} + +/// Edge JWT authentication for gateways behind an authenticating proxy. +/// +/// When configured, the gateway verifies the edge token on every request +/// instead of trusting the network path to the proxy. Roles are optional; +/// leave `admin_role` and `user_role` empty for authentication-only mode, +/// where every verified caller is authorized. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EdgeAuthConfig { + /// Provider that issued the edge token. + pub provider: EdgeAuthProvider, + + /// Token issuer, for example `https://example-team.cloudflareaccess.com`. + pub issuer: String, + + /// Expected audience (`aud`), such as the Access application AUD tag. + pub audience: String, + + /// Signing-key cache TTL in seconds. Defaults to 3600 (1 hour). + #[serde(default = "default_jwks_ttl_secs")] + pub jwks_ttl_secs: u64, + + /// Dot-separated path to the roles array in the token claims, such as + /// `custom.groups`. Empty disables role extraction. + #[serde(default)] + pub roles_claim: String, + + /// Role that grants admin access. Empty disables role checks. + #[serde(default)] + pub admin_role: String, + + /// Role that grants standard user access. Empty disables role checks. + #[serde(default)] + pub user_role: String, +} + /// mTLS user authentication for gateway users. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -997,6 +1045,7 @@ impl Config { config_delivery_mode: ConfigDeliveryMode::default(), tls, oidc: None, + edge_auth: None, auth: GatewayAuthConfig::default(), enable_websocket_tunnel: false, gateway_interceptors: Vec::new(), @@ -1173,6 +1222,13 @@ impl Config { self } + /// Set the edge JWT configuration for proxy-authenticated gateways. + #[must_use] + pub fn with_edge_auth(mut self, edge_auth: EdgeAuthConfig) -> Self { + self.edge_auth = Some(edge_auth); + self + } + /// Derive browser-facing sandbox service domains from gateway server SANs. /// /// Wildcard DNS SANs such as `*.apps.example.com` enable service URLs diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 0588a9974d..5983f0fc32 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -69,11 +69,11 @@ pub mod transport_errors; pub mod upload; pub use config::{ - AppArmorProfile, Config, ExtensionBindingPolicy, GatewayAuthConfig, - GatewayInterceptorBindingOverride, GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, - GatewayInterceptorFailurePolicy, GatewayInterceptorPhaseConfig, GatewayJwtConfig, - GatewayProviderProfileSourceConfig, ImagePullPolicy, MtlsAuthConfig, OidcConfig, - PolicyValidationFailureMode, TlsConfig, UpstreamProxyConfig, + AppArmorProfile, Config, EdgeAuthConfig, EdgeAuthProvider, ExtensionBindingPolicy, + GatewayAuthConfig, GatewayInterceptorBindingOverride, GatewayInterceptorBindingPolicy, + GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayInterceptorPhaseConfig, + GatewayJwtConfig, GatewayProviderProfileSourceConfig, ImagePullPolicy, MtlsAuthConfig, + OidcConfig, PolicyValidationFailureMode, TlsConfig, UpstreamProxyConfig, }; pub use dynamic_string_allowlist::DynamicStringAllowlist; pub use error::{ComputeDriverError, Error, Result}; diff --git a/crates/openshell-server/src/auth/audit.rs b/crates/openshell-server/src/auth/audit.rs index 6c9136aa6f..8842dd1599 100644 --- a/crates/openshell-server/src/auth/audit.rs +++ b/crates/openshell-server/src/auth/audit.rs @@ -65,6 +65,7 @@ impl RequestOrigin { } use super::authenticator::{AuthFailureReason, AuthMechanism, AuthRejection, bearer_credential}; +use super::edge::edge_credential; fn severity(reason: AuthFailureReason) -> SeverityId { match reason { @@ -230,7 +231,8 @@ fn mechanism(principal: &Principal) -> Option { Principal::User(user) => match user.identity.provider { IdentityProvider::Oidc => Some(AuthMechanism::Oidc), IdentityProvider::Mtls => Some(AuthMechanism::Mtls), - IdentityProvider::CloudflareAccess | IdentityProvider::LocalDev => None, + IdentityProvider::CloudflareAccess => Some(AuthMechanism::CloudflareAccess), + IdentityProvider::LocalDev => None, }, Principal::Sandbox(sandbox) => Some(match sandbox.source { SandboxIdentitySource::BootstrapJwt { .. } => AuthMechanism::SandboxJwt, @@ -243,7 +245,7 @@ fn mechanism(principal: &Principal) -> Option { } /// A digest identifying the credential that established `principal`: the -/// operator's or mTLS user's certificate, or the bearer token. +/// operator's or mTLS user's certificate, the edge token, or the bearer token. /// /// Only verified material goes in, so headers the authenticator ignored /// cannot make a new logon. @@ -252,11 +254,16 @@ fn credential_digest( headers: &http::HeaderMap, peer_certificate_sha256: Option<&str>, ) -> Option<[u8; 32]> { + let edge_token; let credential = match principal { Principal::Operator(operator) => operator.certificate_sha256.as_str(), Principal::User(user) if user.identity.provider == IdentityProvider::Mtls => { peer_certificate_sha256.unwrap_or_default() } + Principal::User(user) if user.identity.provider == IdentityProvider::CloudflareAccess => { + edge_token = edge_credential(headers); + edge_token.as_deref().unwrap_or_default() + } _ => bearer_credential(headers).unwrap_or_default(), }; digest(principal, None, credential) @@ -628,6 +635,52 @@ pub fn emit_authorization_denial_for( #[cfg(test)] mod tests { use super::*; + use crate::auth::principal::UserPrincipal; + + fn edge_user() -> Principal { + Principal::User(UserPrincipal { + identity: super::super::identity::Identity { + subject: "user-1".to_string(), + display_name: None, + roles: vec![], + scopes: vec![], + provider: IdentityProvider::CloudflareAccess, + }, + }) + } + + fn edge_headers(pairs: &[(&'static str, &str)]) -> http::HeaderMap { + let mut headers = http::HeaderMap::new(); + for (name, value) in pairs { + headers.insert(*name, http::HeaderValue::from_str(value).unwrap()); + } + headers + } + + #[test] + fn edge_user_logon_is_keyed_by_the_edge_token() { + let user = edge_user(); + let by_header = credential_digest( + &user, + &edge_headers(&[("cf-access-jwt-assertion", "token-a")]), + None, + ); + let by_cookie = credential_digest( + &user, + &edge_headers(&[("cookie", "CF_Authorization=token-a")]), + None, + ); + let other_token = credential_digest( + &user, + &edge_headers(&[("cf-access-jwt-assertion", "token-b")]), + None, + ); + + assert!(by_header.is_some()); + assert_eq!(by_header, by_cookie, "same token, same logon"); + assert_ne!(by_header, other_token, "a new token is a new logon"); + assert_eq!(mechanism(&user), Some(AuthMechanism::CloudflareAccess)); + } #[test] fn credential_logs_on_again_after_the_window() { diff --git a/crates/openshell-server/src/auth/authenticator.rs b/crates/openshell-server/src/auth/authenticator.rs index c9fc367a3f..d5a21ebf99 100644 --- a/crates/openshell-server/src/auth/authenticator.rs +++ b/crates/openshell-server/src/auth/authenticator.rs @@ -22,6 +22,7 @@ //! - [`super::supervisor_token::SupervisorTokenAuthenticator`] — the shared //! supervisor's gateway-minted token for the assignment stream //! - [`super::oidc::OidcAuthenticator`] — user OIDC Bearer tokens +//! - [`super::edge::EdgeAuthenticator`]: the edge proxy's signed token use super::principal::Principal; use async_trait::async_trait; use std::sync::Arc; @@ -115,6 +116,8 @@ impl AuthFailureReason { pub enum AuthMechanism { Mtls, Oidc, + /// The signed token a Cloudflare Access proxy attaches to each request. + CloudflareAccess, SandboxJwt, ComputeDriver, GatewayPeer, @@ -131,6 +134,7 @@ impl AuthMechanism { match self { Self::Mtls => "mtls", Self::Oidc => "oidc", + Self::CloudflareAccess => "cloudflare_access", Self::SandboxJwt => "sandbox_jwt", Self::ComputeDriver => "compute_driver", Self::GatewayPeer => "k8s_service_account", diff --git a/crates/openshell-server/src/auth/authz.rs b/crates/openshell-server/src/auth/authz.rs index 943d4b79bb..41072fad1c 100644 --- a/crates/openshell-server/src/auth/authz.rs +++ b/crates/openshell-server/src/auth/authz.rs @@ -39,6 +39,23 @@ pub struct AuthzPolicy { } impl AuthzPolicy { + /// The policy for the configured user authentication mode, or `None` + /// when neither OIDC nor edge authentication is configured. + pub fn from_config(config: &openshell_core::Config) -> Option { + if let Some(oidc) = &config.oidc { + return Some(Self { + admin_role: oidc.admin_role.clone(), + user_role: oidc.user_role.clone(), + scopes_enabled: !oidc.scopes_claim.is_empty(), + }); + } + config.edge_auth.as_ref().map(|edge| Self { + admin_role: edge.admin_role.clone(), + user_role: edge.user_role.clone(), + scopes_enabled: false, + }) + } + /// Validate the policy configuration. /// /// Returns an error if only one of admin/user role is set — either @@ -48,7 +65,7 @@ impl AuthzPolicy { let user_set = !self.user_role.is_empty(); if admin_set != user_set { return Err(format!( - "OIDC RBAC misconfiguration: admin_role={:?}, user_role={:?}. \ + "RBAC misconfiguration: admin_role={:?}, user_role={:?}. \ Either set both roles (RBAC mode) or leave both empty (authentication-only mode).", self.admin_role, self.user_role, )); @@ -195,6 +212,42 @@ mod tests { } } + #[test] + fn from_config_selects_the_configured_user_authentication_mode() { + use openshell_core::{Config, EdgeAuthConfig, EdgeAuthProvider, OidcConfig}; + + assert!(AuthzPolicy::from_config(&Config::new(None)).is_none()); + + let edge = Config::new(None).with_edge_auth(EdgeAuthConfig { + provider: EdgeAuthProvider::CloudflareAccess, + issuer: "https://team.cloudflareaccess.com".to_string(), + audience: "aud".to_string(), + jwks_ttl_secs: 3600, + roles_claim: "groups".to_string(), + admin_role: "edge-admin".to_string(), + user_role: "edge-user".to_string(), + }); + let policy = AuthzPolicy::from_config(&edge).expect("edge policy"); + assert_eq!(policy.admin_role, "edge-admin"); + assert_eq!(policy.user_role, "edge-user"); + assert!(!policy.scopes_enabled); + + let oidc = OidcConfig { + issuer: "https://idp.example.com".to_string(), + dangerously_allow_insecure_http: false, + jwks_allowed_origins: Vec::new(), + audience: "cli".to_string(), + jwks_ttl_secs: 3600, + roles_claim: "roles".to_string(), + admin_role: "oidc-admin".to_string(), + user_role: "oidc-user".to_string(), + scopes_claim: "scope".to_string(), + }; + let policy = AuthzPolicy::from_config(&edge.with_oidc(oidc)).expect("oidc policy"); + assert_eq!(policy.admin_role, "oidc-admin"); + assert!(policy.scopes_enabled); + } + #[test] fn user_can_access_user_methods() { let id = identity_with_roles(&["openshell-user"]); diff --git a/crates/openshell-server/src/auth/edge.rs b/crates/openshell-server/src/auth/edge.rs new file mode 100644 index 0000000000..2c603752be --- /dev/null +++ b/crates/openshell-server/src/auth/edge.rs @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Edge token authentication. +//! +//! A gateway behind an authenticating proxy (Cloudflare Access) receives the +//! proxy's signed token on every request. This authenticator verifies that +//! token against the provider's published signing keys, so a caller that +//! bypasses the proxy is rejected rather than trusted by network path. +//! +//! Signature, issuer, audience, expiry, and key rotation are handled by +//! [`JwksCache`], the same verifier OIDC uses. + +use super::authenticator::{AuthRejection, Authentication, Authenticator}; +use super::http::cookie_value; +use super::oidc::JwksCache; +use async_trait::async_trait; +use std::borrow::Cow; +use std::sync::Arc; + +/// Header Cloudflare Access sets with the signed token. +pub const EDGE_TOKEN_HEADER: &str = "cf-access-jwt-assertion"; + +/// Cookie Cloudflare Access sets with the same signed token. +pub const EDGE_TOKEN_COOKIE: &str = "CF_Authorization"; + +/// The edge token on a request: the header, else the cookie. +pub fn edge_credential(headers: &http::HeaderMap) -> Option> { + let from_header = headers + .get(EDGE_TOKEN_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(Cow::Borrowed); + from_header.or_else(|| { + cookie_value(headers, EDGE_TOKEN_COOKIE) + .filter(|value| !value.is_empty()) + .map(Cow::Owned) + }) +} + +/// Reject settings that would let a caller skip the edge token. +/// +/// Edge authentication is the gateway's only user authentication mode when +/// configured, and an unauthenticated fallback would make the token optional. +pub fn validate_config(config: &openshell_core::Config) -> Result<(), String> { + if config.edge_auth.is_none() { + return Ok(()); + } + if config.oidc.is_some() { + return Err( + "edge_auth and OIDC cannot both be configured; choose one user authentication mode" + .to_string(), + ); + } + if config.auth.allow_unauthenticated_users { + return Err("edge_auth cannot be combined with allow_unauthenticated_users, which would accept callers without an edge token".to_string()); + } + Ok(()) +} + +/// Authenticator that validates the edge proxy's token. +/// +/// Returns `NotApplicable` when the request carries no edge token. A token +/// that fails verification ends the chain with `Unauthenticated`; it never +/// falls back to another identity. +pub struct EdgeAuthenticator { + cache: Arc, +} + +impl EdgeAuthenticator { + pub fn new(cache: Arc) -> Self { + Self { cache } + } +} + +#[async_trait] +impl Authenticator for EdgeAuthenticator { + async fn authenticate( + &self, + headers: &http::HeaderMap, + _path: &str, + ) -> Result { + let Some(token) = edge_credential(headers) else { + return Ok(Authentication::NotApplicable); + }; + self.cache.accept_user(&token).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::{HeaderMap, HeaderValue}; + + fn headers(pairs: &[(&'static str, &str)]) -> HeaderMap { + let mut map = HeaderMap::new(); + for (name, value) in pairs { + map.append(*name, HeaderValue::from_str(value).unwrap()); + } + map + } + + fn config_with_edge() -> openshell_core::Config { + openshell_core::Config::new(None).with_edge_auth(openshell_core::EdgeAuthConfig { + provider: openshell_core::EdgeAuthProvider::CloudflareAccess, + issuer: "https://team.cloudflareaccess.com".to_string(), + audience: "aud".to_string(), + jwks_ttl_secs: 3600, + roles_claim: String::new(), + admin_role: String::new(), + user_role: String::new(), + }) + } + + #[test] + fn edge_auth_rejects_fallbacks_that_make_the_token_optional() { + assert!(validate_config(&openshell_core::Config::new(None)).is_ok()); + assert!(validate_config(&config_with_edge()).is_ok()); + + let mut unauthenticated = config_with_edge(); + unauthenticated.auth.allow_unauthenticated_users = true; + let error = validate_config(&unauthenticated).expect_err("rejected"); + assert!(error.contains("allow_unauthenticated_users"), "{error}"); + + let with_oidc = config_with_edge().with_oidc(openshell_core::OidcConfig { + issuer: "https://idp.example.com".to_string(), + dangerously_allow_insecure_http: false, + jwks_allowed_origins: Vec::new(), + audience: "cli".to_string(), + jwks_ttl_secs: 3600, + roles_claim: String::new(), + admin_role: String::new(), + user_role: String::new(), + scopes_claim: String::new(), + }); + let error = validate_config(&with_oidc).expect_err("rejected"); + assert!(error.contains("cannot both be configured"), "{error}"); + } + + #[test] + fn credential_prefers_header_over_cookie() { + let map = headers(&[ + ("cf-access-jwt-assertion", "from-header"), + ("cookie", "CF_Authorization=from-cookie"), + ]); + assert_eq!(edge_credential(&map).as_deref(), Some("from-header")); + } + + #[test] + fn credential_falls_back_to_cookie() { + let map = headers(&[("cookie", "theme=dark; CF_Authorization=from-cookie; a=b")]); + assert_eq!(edge_credential(&map).as_deref(), Some("from-cookie")); + } + + #[test] + fn credential_reads_cookies_split_across_headers() { + let map = headers(&[ + ("cookie", "theme=dark"), + ("cookie", "CF_Authorization=from-cookie"), + ]); + assert_eq!(edge_credential(&map).as_deref(), Some("from-cookie")); + } + + #[test] + fn credential_ignores_empty_header_and_other_cookies() { + let map = headers(&[("cf-access-jwt-assertion", " "), ("cookie", "theme=dark")]); + assert!(edge_credential(&map).is_none()); + assert!(edge_credential(&HeaderMap::new()).is_none()); + } +} diff --git a/crates/openshell-server/src/auth/http.rs b/crates/openshell-server/src/auth/http.rs index 8f0bb7d11c..91a5c2cdce 100644 --- a/crates/openshell-server/src/auth/http.rs +++ b/crates/openshell-server/src/auth/http.rs @@ -193,10 +193,7 @@ async fn auth_connect( .into_response(); } - let cf_token = headers - .get("cookie") - .and_then(|v| v.to_str().ok()) - .and_then(|cookies| extract_cookie(cookies, "CF_Authorization")); + let cf_token = cookie_value(&headers, super::edge::EDGE_TOKEN_COOKIE); // Prefer the Host header (set by Cloudflare Tunnel / reverse proxies) // so the page shows the external URL the user actually connected through @@ -235,6 +232,16 @@ async fn auth_connect( } } +/// Find a named cookie across every `Cookie` header. HTTP/2 clients may send +/// cookies split over several header fields. +pub(super) fn cookie_value(headers: &HeaderMap, name: &str) -> Option { + headers + .get_all(header::COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .find_map(|cookies| extract_cookie(cookies, name)) +} + /// Extract a named cookie value from a `Cookie` header string. fn extract_cookie(cookies: &str, name: &str) -> Option { cookies.split(';').find_map(|c| { diff --git a/crates/openshell-server/src/auth/mod.rs b/crates/openshell-server/src/auth/mod.rs index f1800d5044..2e89194df8 100644 --- a/crates/openshell-server/src/auth/mod.rs +++ b/crates/openshell-server/src/auth/mod.rs @@ -4,6 +4,7 @@ //! Authentication and authorization for the gateway server. //! //! - `oidc`: JWT validation against OIDC providers (Keycloak, Entra ID, Okta) +//! - `edge`: signed-token validation for gateways behind an authenticating proxy //! - `authz`: Role-based and scope-based access control //! - `identity`: Provider-agnostic identity representation //! - `http`: HTTP endpoints for auth discovery and token exchange @@ -13,6 +14,7 @@ pub mod authenticator; pub mod authz; pub mod compute_driver; pub mod descriptor_authz; +pub mod edge; pub mod extension_mint_limit; pub mod guard; mod http; diff --git a/crates/openshell-server/src/auth/oidc.rs b/crates/openshell-server/src/auth/oidc.rs index 08f22d4b57..d136968760 100644 --- a/crates/openshell-server/src/auth/oidc.rs +++ b/crates/openshell-server/src/auth/oidc.rs @@ -16,7 +16,7 @@ use super::identity::{Identity, IdentityProvider}; use super::principal::{Principal, UserPrincipal}; use async_trait::async_trait; use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode_header}; -use openshell_core::OidcConfig; +use openshell_core::{EdgeAuthConfig, EdgeAuthProvider, OidcConfig}; use openshell_crypto::jwt::decode; use reqwest::Client; use serde::Deserialize; @@ -61,6 +61,61 @@ const KID_MISS_REFRESH_COOLDOWN: Duration = Duration::from_secs(1); const OIDC_DISCOVERY_MAX_BYTES: usize = 64 * 1024; const JWKS_MAX_BYTES: usize = 1024 * 1024; +/// Where Cloudflare Access publishes its signing keys, relative to the team +/// origin. Access issues no OIDC discovery document for edge tokens. +const CLOUDFLARE_ACCESS_JWKS_PATH: &str = "/cdn-cgi/access/certs"; + +/// The kind of credential a [`JwksCache`] verifies. It selects the audit +/// mechanism, the identity provider, and the claims that name the caller. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TokenKind { + Oidc, + CloudflareAccess, +} + +impl TokenKind { + fn mechanism(self) -> AuthMechanism { + match self { + Self::Oidc => AuthMechanism::Oidc, + Self::CloudflareAccess => AuthMechanism::CloudflareAccess, + } + } + + fn provider(self) -> IdentityProvider { + match self { + Self::Oidc => IdentityProvider::Oidc, + Self::CloudflareAccess => IdentityProvider::CloudflareAccess, + } + } + + fn label(self) -> &'static str { + match self { + Self::Oidc => "OIDC", + Self::CloudflareAccess => "edge token", + } + } + + /// Edge tokens may carry `nbf`; OIDC validation has never enforced it. + fn validates_nbf(self) -> bool { + self == Self::CloudflareAccess + } + + /// Cloudflare service tokens carry an empty `sub`, which would merge + /// every such caller into one identity. OIDC has always accepted it. + fn rejects_empty_subject(self) -> bool { + self == Self::CloudflareAccess + } + + /// The human-readable name for the caller: `preferred_username` for + /// OIDC, `email` for edge tokens. + fn display_name(self, claims: &mut OidcClaims) -> Option { + match self { + Self::Oidc => claims.preferred_username.take(), + Self::CloudflareAccess => claims.email.take(), + } + } +} + /// Cached JWKS key set fetched from the OIDC issuer. /// /// A `refresh_mutex` ensures that only one refresh runs at a time, @@ -83,6 +138,7 @@ pub struct JwksCache { refresh_mutex: tokio::sync::Mutex<()>, http: Client, config: OidcConfig, + kind: TokenKind, } impl std::fmt::Debug for JwksCache { @@ -148,6 +204,81 @@ fn validate_oidc_url( } } +/// Validate a token issuer URL: it must pass [`validate_oidc_url`] and carry +/// no query or fragment. +fn validate_issuer_url( + value: &str, + description: &str, + dangerously_allow_insecure_http: bool, +) -> Result { + let issuer = validate_oidc_url(value, description, dangerously_allow_insecure_http)?; + if issuer.query().is_some() || issuer.fragment().is_some() { + return Err(format!( + "{description} must not include a query or fragment" + )); + } + Ok(issuer) +} + +fn validate_jwks_ttl(jwks_ttl_secs: u64) -> Result<(), String> { + if jwks_ttl_secs == 0 { + return Err( + "jwks_ttl_secs must be greater than zero (0 would refresh on every request and \ + collapse the stale-key grace period to zero)" + .to_string(), + ); + } + Ok(()) +} + +fn jwks_http_client() -> Result { + openshell_crypto::tls::ensure_default_provider(); + Client::builder() + .timeout(Duration::from_secs(10)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| format!("failed to create HTTP client: {e}")) +} + +/// Validate edge auth settings and derive what a [`JwksCache`] needs: the +/// token validation settings, the provider's JWKS URL, and the token kind. +fn edge_auth_parts(config: &EdgeAuthConfig) -> Result<(OidcConfig, url::Url, TokenKind), String> { + validate_jwks_ttl(config.jwks_ttl_secs)?; + if config.audience.is_empty() { + return Err("edge auth audience must not be empty".to_string()); + } + if config.roles_claim.is_empty() + && !(config.admin_role.is_empty() && config.user_role.is_empty()) + { + return Err("edge auth admin_role and user_role require roles_claim".to_string()); + } + let issuer_url = validate_issuer_url(&config.issuer, "edge auth issuer", false)?; + if !matches!(issuer_url.path(), "" | "/") { + return Err("edge auth issuer must be the team origin without a path".to_string()); + } + let (kind, jwks_path) = match config.provider { + EdgeAuthProvider::CloudflareAccess => { + (TokenKind::CloudflareAccess, CLOUDFLARE_ACCESS_JWKS_PATH) + } + }; + let jwks_uri = issuer_url + .join(jwks_path) + .map_err(|error| format!("failed to construct edge auth JWKS URL: {error}"))?; + let jwt_config = OidcConfig { + // Tokens carry the lowercase origin without a trailing slash. + issuer: issuer_url.origin().ascii_serialization(), + dangerously_allow_insecure_http: false, + jwks_allowed_origins: Vec::new(), + audience: config.audience.clone(), + jwks_ttl_secs: config.jwks_ttl_secs, + roles_claim: config.roles_claim.clone(), + admin_role: config.admin_role.clone(), + user_role: config.user_role.clone(), + scopes_claim: String::new(), + }; + Ok((jwt_config, jwks_uri, kind)) +} + fn validate_jwks_origin( value: &str, dangerously_allow_insecure_http: bool, @@ -507,7 +638,6 @@ pub struct OidcClaims { #[serde(default)] pub preferred_username: Option, #[serde(default)] - #[allow(dead_code)] pub email: Option, /// Roles extracted from the configurable claim path. #[serde(skip)] @@ -578,33 +708,24 @@ impl JwksCache { /// Create a new JWKS cache, discovering the JWKS URI and fetching the /// initial key set. pub async fn new(config: &OidcConfig) -> Result { - openshell_crypto::tls::ensure_default_provider(); - let http = Client::builder() - .timeout(Duration::from_secs(10)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|e| format!("failed to create HTTP client: {e}"))?; + Self::new_with_client(config, jwks_http_client()?).await + } - Self::new_with_client(config, http).await + /// Create a cache that verifies the edge proxy's tokens, loading the + /// provider's published signing keys. + pub async fn for_edge_auth(config: &EdgeAuthConfig) -> Result { + let (jwt_config, jwks_uri, kind) = edge_auth_parts(config)?; + Self::from_jwks_uri(&jwt_config, jwks_uri, jwks_http_client()?, kind).await } async fn new_with_client(config: &OidcConfig, http: Client) -> Result { - if config.jwks_ttl_secs == 0 { - return Err( - "jwks_ttl_secs must be greater than zero (0 would refresh on every request and \ - collapse the stale-key grace period to zero)" - .to_string(), - ); - } + validate_jwks_ttl(config.jwks_ttl_secs)?; - let issuer_url = validate_oidc_url( + let issuer_url = validate_issuer_url( &config.issuer, "OIDC issuer", config.dangerously_allow_insecure_http, )?; - if issuer_url.query().is_some() || issuer_url.fragment().is_some() { - return Err("OIDC issuer must not include a query or fragment".to_string()); - } // Validate every configured exception at startup, even when the // discovery document ultimately uses the issuer origin. for allowed in &config.jwks_allowed_origins { @@ -640,6 +761,16 @@ impl JwksCache { info!(jwks_uri = %jwks_uri, "OIDC JWKS URI discovered"); + Self::from_jwks_uri(config, jwks_uri, http, TokenKind::Oidc).await + } + + /// Build a cache for a known JWKS URI and load its initial key set. + async fn from_jwks_uri( + config: &OidcConfig, + jwks_uri: url::Url, + http: Client, + kind: TokenKind, + ) -> Result { let cache = Self { keys: Arc::new(RwLock::new(HashMap::new())), jwks_uri: jwks_uri.to_string(), @@ -664,6 +795,7 @@ impl JwksCache { refresh_mutex: tokio::sync::Mutex::new(()), http, config: config.clone(), + kind, }; cache.refresh_keys().await?; @@ -799,16 +931,16 @@ impl JwksCache { self.refresh_if_stale().await.map_err(|e| { warn!(error = %e, "JWKS refresh failed"); - reject( + self.reject( AuthFailureReason::IdentityProviderUnavailable, - Status::internal("OIDC key refresh failed"), + Status::internal(format!("{} key refresh failed", self.kind.label())), ) })?; // Decode the header to find the key ID. let header = decode_header(token).map_err(|e| { debug!(error = %e, "Failed to decode JWT header"); - reject( + self.reject( AuthFailureReason::MalformedCredential, Status::unauthenticated("invalid token"), ) @@ -816,7 +948,7 @@ impl JwksCache { let kid = header.kid.ok_or_else(|| { debug!("JWT has no kid in header"); - reject( + self.reject( AuthFailureReason::MalformedCredential, Status::unauthenticated("invalid token: missing kid"), ) @@ -828,9 +960,9 @@ impl JwksCache { drop(keys_guard); self.refresh_keys_coalesced().await.map_err(|e| { warn!(error = %e, "JWKS refresh on kid miss failed"); - reject( + self.reject( AuthFailureReason::IdentityProviderUnavailable, - Status::internal("OIDC key refresh failed"), + Status::internal(format!("{} key refresh failed", self.kind.label())), ) })?; keys_guard = self.keys.read().await; @@ -841,7 +973,7 @@ impl JwksCache { .map(|(dk, alg)| (dk.clone(), *alg)) .ok_or_else(|| { debug!(kid = %kid, "JWT kid not found in JWKS"); - reject( + self.reject( AuthFailureReason::InvalidCredential, Status::unauthenticated("invalid token: unknown signing key"), ) @@ -854,7 +986,7 @@ impl JwksCache { expected = ?cached_algorithm, "JWT algorithm mismatch for cached signing key" ); - return Err(reject( + return Err(self.reject( AuthFailureReason::InvalidCredential, Status::unauthenticated("invalid token"), )); @@ -864,16 +996,23 @@ impl JwksCache { validation.set_issuer(&[&self.config.issuer]); validation.set_audience(&[&self.config.audience]); validation.set_required_spec_claims(&["iss", "aud", "exp", "sub"]); + validation.validate_nbf = self.kind.validates_nbf(); let token_data = decode::(token, &decoding_key, &validation).map_err(|e| { debug!(error = %e, "JWT validation failed"); - reject( + self.reject( decode_failure_reason(e.kind()), Status::unauthenticated(format!("invalid token: {e}")), ) })?; let mut claims = token_data.claims; + if self.kind.rejects_empty_subject() && claims.sub.is_empty() { + return Err(self.reject( + AuthFailureReason::InvalidCredential, + Status::unauthenticated("invalid token: empty subject"), + )); + } claims.extract_roles(&self.config.roles_claim); let scopes = if self.config.scopes_claim.is_empty() { @@ -882,18 +1021,27 @@ impl JwksCache { claims.extract_scopes(&self.config.scopes_claim) }; + let display_name = self.kind.display_name(&mut claims); Ok(Identity { subject: claims.sub, - display_name: claims.preferred_username, + display_name, roles: claims.roles, scopes, - provider: IdentityProvider::Oidc, + provider: self.kind.provider(), }) } -} -fn reject(reason: AuthFailureReason, status: Status) -> AuthRejection { - AuthRejection::new(AuthMechanism::Oidc, reason, status) + /// Verify `token` and accept the caller as a user principal. + pub(super) async fn accept_user(&self, token: &str) -> Result { + let identity = self.validate_token(token).await?; + Ok(Authentication::Accepted(Principal::User(UserPrincipal { + identity, + }))) + } + + fn reject(&self, reason: AuthFailureReason, status: Status) -> AuthRejection { + AuthRejection::new(self.kind.mechanism(), reason, status) + } } fn decode_failure_reason(kind: &jsonwebtoken::errors::ErrorKind) -> AuthFailureReason { @@ -940,10 +1088,7 @@ impl Authenticator for OidcAuthenticator { return Ok(Authentication::NotApplicable); }; - let identity = self.cache.validate_token(token).await?; - Ok(Authentication::Accepted(Principal::User(UserPrincipal { - identity, - }))) + self.cache.accept_user(token).await } } @@ -1504,6 +1649,21 @@ mod tests { assert_eq!(identity.provider, IdentityProvider::Oidc); } + #[tokio::test] + async fn oidc_token_with_empty_subject_is_still_accepted() { + let server = wiremock::MockServer::start().await; + let cache = cache_with_mock_issuer(&server).await; + + let mut claims = claims_for(&server.uri(), TEST_AUDIENCE, now_secs() + 3600); + claims["sub"] = serde_json::json!(""); + let identity = cache + .validate_token(&mint_rs256(&claims, TEST_KID)) + .await + .expect("only edge tokens reject an empty subject"); + + assert_eq!(identity.subject, ""); + } + #[tokio::test] async fn rs256_token_with_tampered_payload_is_rejected() { let server = wiremock::MockServer::start().await; @@ -2394,4 +2554,362 @@ mod tests { assert_eq!(err.status.code(), tonic::Code::Unauthenticated); } } + + /// Edge tokens: Cloudflare Access shaped claims (`aud` is an array, the + /// caller is named by `email`) verified against a mock key endpoint. + mod edge_auth { + use super::*; + use crate::auth::edge::EdgeAuthenticator; + use http::{HeaderMap, HeaderValue}; + use openshell_core::{EdgeAuthConfig, EdgeAuthProvider}; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + const TEAM: &str = "https://team.cloudflareaccess.com"; + const AUD_TAG: &str = "4714c1358e65fe4b"; + + fn edge_config() -> EdgeAuthConfig { + EdgeAuthConfig { + provider: EdgeAuthProvider::CloudflareAccess, + issuer: TEAM.to_owned(), + audience: AUD_TAG.to_owned(), + jwks_ttl_secs: 3600, + roles_claim: "custom.groups".to_owned(), + admin_role: String::new(), + user_role: String::new(), + } + } + + fn access_claims(exp: i64) -> serde_json::Value { + serde_json::json!({ + "sub": "user-1", + "email": "ada@example.com", + "iss": TEAM, + "aud": [AUD_TAG], + "exp": exp, + "type": "app", + "custom": { "groups": ["eng", "openshell-admin"] }, + }) + } + + /// Serve the Access key endpoint locally; only the key fetch is + /// redirected, so tokens still carry the real team issuer. + async fn edge_cache(server: &MockServer) -> JwksCache { + Mock::given(method("GET")) + .and(path(CLOUDFLARE_ACCESS_JWKS_PATH)) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "keys": [{ + "kid": TEST_KID, + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "n": TEST_RSA_KEY.modulus_b64, + "e": TEST_RSA_KEY.exponent_b64, + }], + }))) + .mount(server) + .await; + let (jwt_config, _, kind) = edge_auth_parts(&edge_config()).unwrap(); + let jwks_uri = + url::Url::parse(&format!("{}{CLOUDFLARE_ACCESS_JWKS_PATH}", server.uri())).unwrap(); + JwksCache::from_jwks_uri(&jwt_config, jwks_uri, jwks_http_client().unwrap(), kind) + .await + .expect("cache builds from the mock key endpoint") + } + + fn headers(pairs: &[(&'static str, &str)]) -> HeaderMap { + let mut map = HeaderMap::new(); + for (name, value) in pairs { + map.insert(*name, HeaderValue::from_str(value).unwrap()); + } + map + } + + #[tokio::test] + async fn valid_token_becomes_a_cloudflare_access_user() { + let server = MockServer::start().await; + let cache = edge_cache(&server).await; + let token = mint_rs256(&access_claims(now_secs() + 3600), TEST_KID); + + let identity = cache.validate_token(&token).await.expect("accepted"); + + assert_eq!(identity.subject, "user-1"); + assert_eq!(identity.display_name.as_deref(), Some("ada@example.com")); + assert_eq!(identity.provider, IdentityProvider::CloudflareAccess); + assert_eq!(identity.roles, ["eng", "openshell-admin"]); + } + + #[tokio::test] + async fn token_in_the_past_nbf_window_is_accepted_and_future_nbf_is_rejected() { + let server = MockServer::start().await; + let cache = edge_cache(&server).await; + + let mut claims = access_claims(now_secs() + 3600); + claims["nbf"] = serde_json::json!(now_secs() - 60); + cache + .validate_token(&mint_rs256(&claims, TEST_KID)) + .await + .expect("nbf in the past is valid"); + + claims["nbf"] = serde_json::json!(now_secs() + 3600); + let rejection = cache + .validate_token(&mint_rs256(&claims, TEST_KID)) + .await + .expect_err("nbf in the future must be rejected"); + assert_eq!(rejection.mechanism, AuthMechanism::CloudflareAccess); + } + + #[tokio::test] + async fn bad_tokens_are_rejected_with_the_edge_mechanism() { + let server = MockServer::start().await; + let cache = edge_cache(&server).await; + let live = now_secs() + 3600; + + let mut wrong_aud = access_claims(live); + wrong_aud["aud"] = serde_json::json!(["other-app"]); + let mut empty_sub = access_claims(live); + empty_sub["sub"] = serde_json::json!(""); + let mut wrong_iss = access_claims(live); + wrong_iss["iss"] = serde_json::json!("https://other.cloudflareaccess.com"); + let cases = [ + ( + mint_rs256(&wrong_aud, TEST_KID), + AuthFailureReason::AudienceOrIssuerMismatch, + ), + ( + mint_rs256(&wrong_iss, TEST_KID), + AuthFailureReason::AudienceOrIssuerMismatch, + ), + ( + mint_rs256(&empty_sub, TEST_KID), + AuthFailureReason::InvalidCredential, + ), + ( + mint_rs256(&access_claims(now_secs() - 3600), TEST_KID), + AuthFailureReason::ExpiredCredential, + ), + ( + mint_rs256(&access_claims(live), "unknown-kid"), + AuthFailureReason::InvalidCredential, + ), + ( + "not-a-jwt".to_owned(), + AuthFailureReason::MalformedCredential, + ), + ]; + for (token, reason) in cases { + let rejection = cache.validate_token(&token).await.expect_err("rejected"); + assert_eq!(rejection.mechanism, AuthMechanism::CloudflareAccess); + assert_eq!(rejection.reason, reason); + assert_eq!(rejection.status.code(), tonic::Code::Unauthenticated); + } + } + + /// Through the real chain construction, a request without a valid + /// edge token is rejected rather than treated as local development. + #[tokio::test] + async fn router_requires_an_edge_token_on_every_request() { + use crate::compute::new_test_runtime; + use crate::multiplex::{AuthGrpcRouter, build_authenticator_chain}; + use crate::persistence::Store; + use crate::sandbox_index::SandboxIndex; + use crate::sandbox_watch::SandboxWatchBus; + use crate::supervisor_session::SupervisorSessionRegistry; + use crate::tracing_bus::TracingLogBus; + use std::sync::Mutex; + use tower::Service; + + let server = MockServer::start().await; + let config = openshell_core::Config::new(None).with_edge_auth(edge_config()); + crate::auth::edge::validate_config(&config).expect("edge-only config is valid"); + let store = Arc::new(Store::connect("sqlite::memory:").await.unwrap()); + let compute = new_test_runtime(store.clone()).await; + let mut state = crate::ServerState::new( + config, + store, + compute, + SandboxIndex::new(), + SandboxWatchBus::new(), + TracingLogBus::new(), + Arc::new(SupervisorSessionRegistry::new()), + None, + ); + state.edge_auth_cache = Some(Arc::new(edge_cache(&server).await)); + + let reached = Arc::new(Mutex::new(Vec::new())); + let recorded = reached.clone(); + let inner = tower::service_fn(move |req: http::Request<()>| { + let principal = req.extensions().get::().cloned(); + recorded.lock().unwrap().push(principal); + async { + Ok::<_, std::convert::Infallible>(http::Response::new( + tonic::body::Body::empty(), + )) + } + }); + let mut router = AuthGrpcRouter::with_peer_identity( + inner, + build_authenticator_chain(&state), + crate::auth::authz::AuthzPolicy::from_config(&state.config), + None, + state.config.mtls_auth.enabled, + state.config.auth.allow_unauthenticated_users, + ); + let call = |header: Option<&str>| { + let mut request = + http::Request::builder().uri("/openshell.v1.OpenShell/ListSandboxes"); + if let Some(token) = header { + request = request.header(crate::auth::edge::EDGE_TOKEN_HEADER, token); + } + request.body(()).unwrap() + }; + let status = |response: &http::Response| { + response + .headers() + .get("grpc-status") + .map(|value| value.to_str().unwrap().to_owned()) + }; + + let unauthenticated = tonic::Code::Unauthenticated as i32; + for header in [None, Some("garbage")] { + let response = router.call(call(header)).await.unwrap(); + assert_eq!( + status(&response).as_deref(), + Some(unauthenticated.to_string().as_str()), + "token {header:?}" + ); + } + assert!(reached.lock().unwrap().is_empty(), "handler was reached"); + + let token = mint_rs256(&access_claims(now_secs() + 3600), TEST_KID); + let response = router.call(call(Some(&token))).await.unwrap(); + assert_eq!(status(&response), None); + let reached = reached.lock().unwrap(); + let Some(Some(Principal::User(user))) = reached.first() else { + panic!("expected a user principal, got {reached:?}"); + }; + assert_eq!(user.identity.subject, "user-1"); + assert_eq!(user.identity.provider, IdentityProvider::CloudflareAccess); + } + + #[tokio::test] + async fn token_signed_by_another_key_is_rejected() { + let server = MockServer::start().await; + let cache = edge_cache(&server).await; + crate::install_jsonwebtoken_crypto_provider(); + let mut header = jsonwebtoken::Header::new(Algorithm::RS256); + header.kid = Some(TEST_KID.to_owned()); + let other = TestRsaKey::generate(); + let token = openshell_crypto::jwt::encode( + &header, + &access_claims(now_secs() + 3600), + &jsonwebtoken::EncodingKey::from_rsa_pem(other.private_pem.as_bytes()).unwrap(), + ) + .unwrap(); + + let rejection = cache.validate_token(&token).await.expect_err("rejected"); + assert_eq!(rejection.reason, AuthFailureReason::InvalidCredential); + } + + #[tokio::test] + async fn authenticator_reads_header_or_cookie_and_ignores_bearer() { + let server = MockServer::start().await; + let authenticator = EdgeAuthenticator::new(Arc::new(edge_cache(&server).await)); + let token = mint_rs256(&access_claims(now_secs() + 3600), TEST_KID); + let cookie = format!("theme=dark; CF_Authorization={token}"); + + for map in [ + headers(&[("cf-access-jwt-assertion", &token)]), + headers(&[("cookie", &cookie)]), + ] { + match authenticator.authenticate(&map, "/x").await { + Ok(Authentication::Accepted(Principal::User(user))) => { + assert_eq!(user.identity.subject, "user-1"); + } + other => panic!("expected an accepted user, got {other:?}"), + } + } + + for map in [ + HeaderMap::new(), + headers(&[("authorization", "Bearer something")]), + ] { + let outcome = authenticator.authenticate(&map, "/x").await; + assert!(matches!(outcome, Ok(Authentication::NotApplicable))); + } + + let bad = headers(&[("cf-access-jwt-assertion", "garbage")]); + let rejection = authenticator + .authenticate(&bad, "/x") + .await + .expect_err("an invalid edge token ends the chain"); + assert_eq!(rejection.status.code(), tonic::Code::Unauthenticated); + } + + #[test] + fn config_is_validated_and_the_jwks_url_derived() { + let (jwt_config, jwks_uri, kind) = edge_auth_parts(&EdgeAuthConfig { + issuer: "https://Team.CloudflareAccess.com/".to_owned(), + ..edge_config() + }) + .expect("valid config"); + assert_eq!(kind, TokenKind::CloudflareAccess); + assert_eq!(jwt_config.issuer, TEAM); + assert_eq!( + jwks_uri.as_str(), + "https://team.cloudflareaccess.com/cdn-cgi/access/certs" + ); + + let invalid = [ + ( + EdgeAuthConfig { + issuer: "http://team.cloudflareaccess.com".to_owned(), + ..edge_config() + }, + "must use HTTPS", + ), + ( + EdgeAuthConfig { + issuer: format!("{TEAM}?x=1"), + ..edge_config() + }, + "query or fragment", + ), + ( + EdgeAuthConfig { + issuer: format!("{TEAM}/tenant"), + ..edge_config() + }, + "without a path", + ), + ( + EdgeAuthConfig { + audience: String::new(), + ..edge_config() + }, + "audience must not be empty", + ), + ( + EdgeAuthConfig { + jwks_ttl_secs: 0, + ..edge_config() + }, + "greater than zero", + ), + ( + EdgeAuthConfig { + roles_claim: String::new(), + admin_role: "admin".to_owned(), + user_role: "user".to_owned(), + ..edge_config() + }, + "require roles_claim", + ), + ]; + for (config, expected) in invalid { + let error = edge_auth_parts(&config).expect_err("rejected"); + assert!(error.contains(expected), "{error}"); + } + } + } } diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 7c5d5b111a..6c0062675d 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -668,6 +668,13 @@ fn prepare_server_config_with_drivers( }); } + if let Some(edge_auth) = file + .as_ref() + .and_then(|f| f.openshell.gateway.edge_auth.clone()) + { + config = config.with_edge_auth(edge_auth); + } + // `gateway_jwt` is configured through TOML in cluster deployments. Local // package-managed starts also auto-detect the JWT bundle written next to // the generated TLS bundle so upgrades pick up sandbox auth without a @@ -725,14 +732,16 @@ fn validate_user_auth(config: &openshell_core::Config) -> Result<()> { && !config.mtls_auth.enabled && !config.mtls_auth.operator_enabled && config.oidc.is_none() + && config.edge_auth.is_none() && config.gateway_jwt.is_none() { return Err(miette::miette!( "gateway has no user authentication configured\n\n\ - No OIDC issuer, mTLS user auth, operator auth, or gateway_jwt is set. \ + No OIDC issuer, mTLS user auth, operator auth, edge_auth, or gateway_jwt is set. \ Authenticated RPCs require credentials unless unauthenticated users are explicitly allowed.\n\n\ - Configure [openshell.gateway.oidc], [openshell.gateway.mtls_auth], or \ - [openshell.gateway.gateway_jwt], or explicitly opt in for trusted local development:\n\n\ + Configure [openshell.gateway.oidc], [openshell.gateway.mtls_auth], \ + [openshell.gateway.edge_auth], or [openshell.gateway.gateway_jwt], \ + or explicitly opt in for trusted local development:\n\n\ [openshell.gateway.auth]\n\ allow_unauthenticated_users = true" )); @@ -808,6 +817,7 @@ async fn run_from_args( .and_then(|tls| tls.client_ca_path.as_ref()) .is_some(); let has_oidc = prepared.config.oidc.is_some(); + let has_edge_auth = prepared.config.edge_auth.is_some(); if prepared.config.tls.is_none() { warn!("TLS disabled — listening on plaintext HTTP"); @@ -829,6 +839,19 @@ async fn run_from_args( if has_oidc { info!("OIDC authentication enabled"); } + if has_edge_auth { + info!("Edge JWT authentication enabled"); + } + if prepared + .config + .edge_auth + .as_ref() + .is_some_and(|edge| edge.admin_role.is_empty()) + { + warn!( + "edge_auth has no admin_role or user_role: every caller the proxy admits is a platform admin and workspace membership is not enforced" + ); + } if let Some(err) = otlp_error.as_ref().or(driver_otlp_error.as_ref()) { error!( error = %err, @@ -1525,7 +1548,7 @@ mod tests { fn user_auth_requires_a_mechanism_or_explicit_opt_in() { // Cover each mechanism on its own and in combination, with and without // the development opt-in. Only the entirely unconfigured case fails. - for mechanisms in 0..32 { + for mechanisms in 0..64 { let mut config = openshell_core::Config::new(None); config.auth.allow_unauthenticated_users = mechanisms & 1 != 0; config.mtls_auth.enabled = mechanisms & 2 != 0; @@ -1536,6 +1559,14 @@ mod tests { .unwrap(), ); } + if mechanisms & 32 != 0 { + config.edge_auth = Some( + toml::from_str( + "provider = 'cloudflare_access'\nissuer = 'https://team.cloudflareaccess.com'\naudience = 'aud'", + ) + .unwrap(), + ); + } if mechanisms & 16 != 0 { config.gateway_jwt = Some(toml::from_str( "signing_key_path = 'key.pem'\npublic_key_path = 'public.pem'\nkid_path = 'kid'" @@ -4260,6 +4291,44 @@ mem_mib = "not-a-number" }); } + #[test] + fn server_config_loads_edge_auth_from_file() { + let state = tempfile::tempdir().unwrap(); + let tls = tempfile::tempdir().unwrap(); + Environment::new() + .set("XDG_STATE_HOME", state.path().to_str().unwrap()) + .set("OPENSHELL_LOCAL_TLS_DIR", tls.path().to_str().unwrap()) + .run(|| { + let state = std::path::PathBuf::from(std::env::var_os("XDG_STATE_HOME").unwrap()); + let config_path = state.join("gateway.toml"); + std::fs::write( + &config_path, + r#" +[openshell] +version = 2 +[openshell.gateway.edge_auth] +provider = "cloudflare_access" +issuer = "https://team.cloudflareaccess.com" +audience = "aud-tag" +"#, + ) + .unwrap(); + let (mut args, matches) = parse_with_args(&[ + "openshell-gateway", + "--config", + config_path.to_str().unwrap(), + "--db-url", + "sqlite::memory:", + "--compute-driver", + "docker", + "--disable-tls", + ]); + let prepared = super::prepare_server_config(&mut args, &matches).unwrap(); + let edge = prepared.config.edge_auth.expect("edge_auth is set"); + assert_eq!(edge.audience, "aud-tag"); + }); + } + #[test] fn explicit_launch_signing_config_ignores_partial_local_bundle() { let directory = tempfile::tempdir().unwrap(); diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 807b7804b6..8ce29a31d5 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -30,8 +30,8 @@ use openshell_core::proto::{ SupervisorMiddlewareBinding, SupervisorMiddlewareBindingPolicy, SupervisorMiddlewareService, }; use openshell_core::{ - ExtensionBindingPolicy, GatewayAuthConfig, GatewayInterceptorConfig, GatewayJwtConfig, - GatewayProviderProfileSourceConfig, MtlsAuthConfig, OidcConfig, + EdgeAuthConfig, ExtensionBindingPolicy, GatewayAuthConfig, GatewayInterceptorConfig, + GatewayJwtConfig, GatewayProviderProfileSourceConfig, MtlsAuthConfig, OidcConfig, }; use serde::{Deserialize, Serialize}; @@ -186,6 +186,8 @@ pub struct GatewayFileSection { #[serde(default)] pub oidc: Option, #[serde(default)] + pub edge_auth: Option, + #[serde(default)] pub auth: Option, #[serde(default)] pub interceptors: Vec, @@ -1188,6 +1190,50 @@ compute_driver = 42 assert!(serialized.contains("compute_driver = \"docker\"")); } + #[test] + fn parses_edge_auth_table() { + let toml = r#" +[openshell] +version = 2 + +[openshell.gateway.edge_auth] +provider = "cloudflare_access" +issuer = "https://example-team.cloudflareaccess.com" +audience = "aud-tag" +roles_claim = "custom.groups" +"#; + let tmp = write_tmp(toml); + let file = load(tmp.path()).expect("edge_auth parses"); + let edge = file + .openshell + .gateway + .edge_auth + .as_ref() + .expect("edge_auth table"); + assert_eq!( + edge.provider, + openshell_core::EdgeAuthProvider::CloudflareAccess + ); + assert_eq!(edge.jwks_ttl_secs, 3600); + assert_eq!(edge.roles_claim, "custom.groups"); + assert!(edge.admin_role.is_empty() && edge.user_role.is_empty()); + } + + #[test] + fn rejects_unknown_edge_auth_provider() { + let toml = r#" +[openshell] +version = 2 + +[openshell.gateway.edge_auth] +provider = "other" +issuer = "https://example.com" +audience = "aud" +"#; + let tmp = write_tmp(toml); + assert!(load(tmp.path()).is_err()); + } + #[test] fn service_hostname_format_parses_and_serializes() { let file = toml::from_str::( diff --git a/crates/openshell-server/src/grpc/mutation_replay.rs b/crates/openshell-server/src/grpc/mutation_replay.rs index 1a39b3dea0..f5a8fc70e5 100644 --- a/crates/openshell-server/src/grpc/mutation_replay.rs +++ b/crates/openshell-server/src/grpc/mutation_replay.rs @@ -208,7 +208,14 @@ pub(super) async fn run( }, "", ), - IdentityProvider::CloudflareAccess => ("cloudflare_access", ""), + IdentityProvider::CloudflareAccess => ( + "cloudflare_access", + state + .config + .edge_auth + .as_ref() + .map_or("", |config| config.issuer.as_str()), + ), IdentityProvider::LocalDev => ("local_dev", ""), }; let caller = hash_json(&serde_json::json!([provider, issuer, identity.subject,]))?; diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 5c74f25f2b..24bb248849 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -385,6 +385,10 @@ pub struct ServerState { /// OIDC JWKS cache for JWT validation. `None` when OIDC is not configured. pub oidc_cache: Option>, + /// Signing-key cache for edge token validation. `None` when edge + /// authentication is not configured. + pub edge_auth_cache: Option>, + /// Typed extension JWT issuer and public verification metadata. pub extension_jwt_issuer: Option>, @@ -420,9 +424,10 @@ pub struct ServerState { /// on demand when the user source is configured. pub(crate) provider_profile_sources: provider_profile_sources::ProviderProfileSources, - /// OIDC admin role name for workspace-level authorization. - /// Empty when OIDC is not configured — `authorize_workspace()` treats - /// every authenticated user as Platform Admin in that case. + /// Admin role name for workspace-level authorization, from OIDC or edge + /// authentication. Empty when neither configures roles, in which case + /// `authorize_workspace()` treats every authenticated user as Platform + /// Admin. pub admin_role: String, } @@ -491,10 +496,8 @@ impl ServerState { let grpc_rate_limiter = multiplex::GrpcRateLimiter::from_config(&config); let reflection_service = reflection::build_gateway_reflection_service(&config) .expect("compiled public gateway descriptors must be valid"); - let admin_role = config - .oidc - .as_ref() - .map_or_else(String::new, |oidc| oidc.admin_role.clone()); + let admin_role = auth::authz::AuthzPolicy::from_config(&config) + .map_or_else(String::new, |policy| policy.admin_role); let config_delivery = config_delivery::ConfigDelivery::for_db_connections(store.max_connections()); Self { @@ -521,6 +524,7 @@ impl ServerState { extension_mint_limiter: auth::extension_mint_limit::ExtensionMintLimiter::default(), middleware_registry: Arc::new(MiddlewareRegistry::default()), oidc_cache, + edge_auth_cache: None, extension_jwt_issuer: None, sandbox_session_jwt_authority: None, compute_driver_authenticator: None, @@ -605,6 +609,7 @@ pub(crate) async fn run_server( compute_driver, legacy_compute_driver_env_seen: _, } = startup; + auth::edge::validate_config(&config).map_err(Error::config)?; let (shutdown_tx, shutdown_rx) = watch::channel(false); auth::descriptor_authz::init() @@ -694,15 +699,11 @@ pub(crate) async fn run_server( ) .await?; - let oidc_cache = if let Some(ref oidc) = config.oidc { - // Validate RBAC configuration before starting. - let policy = auth::authz::AuthzPolicy { - admin_role: oidc.admin_role.clone(), - user_role: oidc.user_role.clone(), - scopes_enabled: !oidc.scopes_claim.is_empty(), - }; + // Validate RBAC configuration before starting. + if let Some(policy) = auth::authz::AuthzPolicy::from_config(&config) { policy.validate().map_err(Error::config)?; - + } + let oidc_cache = if let Some(ref oidc) = config.oidc { let cache = auth::oidc::JwksCache::new(oidc) .await .map_err(|e| Error::config(format!("OIDC initialization failed: {e}")))?; @@ -711,6 +712,15 @@ pub(crate) async fn run_server( } else { None }; + let edge_auth_cache = if let Some(ref edge) = config.edge_auth { + let cache = auth::oidc::JwksCache::for_edge_auth(edge) + .await + .map_err(|e| Error::config(format!("edge auth initialization failed: {e}")))?; + info!("Edge JWT validation enabled (issuer: {})", edge.issuer); + Some(Arc::new(cache)) + } else { + None + }; let sandbox_index = SandboxIndex::new(); let sandbox_watch_bus = SandboxWatchBus::new(); @@ -797,6 +807,7 @@ pub(crate) async fn run_server( oidc_cache, credentials, ); + state.edge_auth_cache = edge_auth_cache; state.middleware_registry = middleware_registry; state.gateway_interceptors = gateway_interceptors; state.provider_profile_sources = provider_profile_sources; diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index d58192fba2..8b8aaa977d 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -47,6 +47,7 @@ use crate::{ auth::audit::{self, AuthzDenial, ConnectionSource, RequestOrigin}, auth::authenticator::{AuthFailureReason, AuthMechanism, Authentication, AuthenticatorChain}, auth::authz::AuthzPolicy, + auth::edge::EdgeAuthenticator, auth::identity::Identity, auth::oidc::{self, OidcAuthenticator}, auth::principal::{OperatorPrincipal, Principal, UserPrincipal}, @@ -305,11 +306,7 @@ impl MultiplexService { openshell, ); let reflection = self.state.reflection_service.clone(); - let authz_policy = self.state.config.oidc.as_ref().map(|oidc| AuthzPolicy { - admin_role: oidc.admin_role.clone(), - user_role: oidc.user_role.clone(), - scopes_enabled: !oidc.scopes_claim.is_empty(), - }); + let authz_policy = AuthzPolicy::from_config(&self.state.config); let authenticator_chain = build_authenticator_chain(&self.state); let grpc_service = AuthGrpcRouter::with_peer_identity( GrpcRouter::new(openshell, reflection), @@ -1054,6 +1051,8 @@ where /// 5. `OidcAuthenticator` — validates user Bearer tokens against the /// configured OIDC issuer. Returns `Unauthenticated` for missing /// Bearer headers so non-OIDC clients can't sneak through. +/// 6. `EdgeAuthenticator`: validates the edge proxy's signed token against +/// the configured issuer and audience. Never configured with OIDC. /// /// Once sandbox authentication is configured, callers must present an /// explicit credential for authenticated gRPC methods. Missing bearer auth @@ -1064,7 +1063,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); @@ -1086,6 +1085,9 @@ fn build_authenticator_chain(state: &ServerState) -> Option if let Some(cache) = state.oidc_cache.clone() { authenticators.push(Arc::new(OidcAuthenticator::new(cache))); } + if let Some(cache) = state.edge_auth_cache.clone() { + authenticators.push(Arc::new(EdgeAuthenticator::new(cache))); + } if authenticators.is_empty() { return None; } @@ -1130,7 +1132,7 @@ impl AuthGrpcRouter { Self::with_peer_identity(inner, authenticator_chain, authz_policy, None, false, false) } - fn with_peer_identity( + pub(crate) fn with_peer_identity( inner: S, authenticator_chain: Option, authz_policy: Option, @@ -4653,6 +4655,31 @@ mod tests { assert_redacted(&events[0], "ExpiredSignature"); } + #[tokio::test] + async fn rejected_edge_credential_emits_classified_authentication_failure() { + let path = "/openshell.v1.OpenShell/ListSandboxes"; + let (status, events) = call_with_rejection( + AuthRejection::new( + AuthMechanism::CloudflareAccess, + AuthFailureReason::AudienceOrIssuerMismatch, + tonic::Status::unauthenticated("invalid token: InvalidAudience"), + ), + path, + ) + .await; + + assert_eq!(status.code(), tonic::Code::Unauthenticated); + assert_eq!(events.len(), 1, "{events:?}"); + assert_router_authentication_failure( + &events[0], + path, + "audience_or_issuer_mismatch", + 3, + ); + assert_eq!(events[0]["auth_protocol_id"], 99); + assert_eq!(events[0]["auth_protocol"], "cloudflare_access"); + } + #[tokio::test] async fn rejected_sandbox_credential_emits_classified_authentication_failure() { let path = "/openshell.v1.OpenShell/GetSandboxProviderEnvironment"; diff --git a/docs/how-it-works/gateways/authentication.mdx b/docs/how-it-works/gateways/authentication.mdx index b764d3fbfe..2c2520521b 100644 --- a/docs/how-it-works/gateways/authentication.mdx +++ b/docs/how-it-works/gateways/authentication.mdx @@ -326,6 +326,8 @@ This is transparent to the user. All CLI commands work the same regardless of wh **Re-authentication**: If the token expires, run `openshell gateway login` to open the browser flow again and update the stored token. +By default the gateway trusts the proxy and does not check the token. Configure `[openshell.gateway.edge_auth]` to have the gateway verify the Cloudflare Access token on every request. See [Verify Cloudflare Access Tokens](/kubernetes/access-control#verify-cloudflare-access-tokens). + ### Plaintext When a gateway is deployed with `disable_tls = true` (set through Helm as diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index b3ba0b4701..1a4b0fa266 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -455,6 +455,21 @@ allow_unauthenticated_users = true `[openshell.gateway.auth] allow_unauthenticated_users = true` is an unsafe local-development and trusted-proxy escape hatch. It accepts user-facing CLI/API calls without OIDC or mTLS credentials while sandbox supervisors still authenticate with gateway-minted sandbox JWTs. Leave it false for shared and production gateways. +For a gateway behind Cloudflare Access, `[openshell.gateway.edge_auth]` verifies the Access token on every request instead: + +```toml +[openshell.gateway.edge_auth] +provider = "cloudflare_access" +issuer = "https://example-team.cloudflareaccess.com" +audience = "" +jwks_ttl_secs = 3600 # Must be greater than zero. +roles_claim = "custom.groups" # Optional. +admin_role = "openshell-admin" # Optional; set together with user_role. +user_role = "openshell-user" +``` + +The issuer must use HTTPS and the audience must not be empty. Leave `roles_claim`, `admin_role`, and `user_role` empty for authentication-only mode, where every verified caller is a platform admin and workspace membership is not enforced. Setting a role requires `roles_claim`, and `admin_role` and `user_role` must be set together. The gateway refuses to start when `edge_auth` is combined with `[openshell.gateway.oidc]` or `allow_unauthenticated_users`. See [Verify Cloudflare Access Tokens](/kubernetes/access-control#verify-cloudflare-access-tokens). + ## Service Routing Sandbox service URLs default to a readable composite hostname: diff --git a/docs/kubernetes/access-control.mdx b/docs/kubernetes/access-control.mdx index 70e5f7d32a..1fcba1ddf7 100644 --- a/docs/kubernetes/access-control.mdx +++ b/docs/kubernetes/access-control.mdx @@ -14,6 +14,7 @@ The OpenShell gateway supports these access-control models for human callers on |---|---| | mTLS | Gateways with a configured client CA and no OIDC issuer. The gateway maps a verified client certificate to a user identity. | | OIDC (recommended) | Production deployments. Integrates with an existing identity provider, supports role-based access control, and gives each user their own identity without distributing certificates. | +| Cloudflare Access token verification (recommended behind Cloudflare Access) | Cloudflare Access authenticates callers in front of the gateway. The gateway verifies the Access token on every request and gives each user their own identity. | | Reverse-proxy auth termination | An access proxy (Cloudflare Access, ngrok, corporate SSO) authenticates callers in front of the gateway. The gateway trusts the proxy and skips its own client-cert check. | The Helm chart generates a gateway TLS certificate and a user client certificate at install time. Supervisor Pods project only `ca.crt` from the client Secret so they can authenticate the gateway; the client certificate and private key are not exposed to sandboxes. Supervisors authenticate their RPCs with gateway-minted sandbox JWTs. @@ -112,9 +113,30 @@ helm upgrade openshell \ The gateway listens on plaintext HTTP behind the trusted proxy, while sandbox supervisors still authenticate with gateway-minted sandbox JWTs. User-facing CLI/API calls without OIDC or mTLS credentials are accepted as an unauthenticated local developer principal. The proxy is responsible for authenticating callers and forwarding only authorized traffic. -Only enable unauthenticated users when the gateway is not reachable from outside a trusted local development environment or the proxy path is fully trusted. Never expose a plaintext, auth-disabled gateway to a public network. +Only enable unauthenticated users when the gateway is not reachable from outside a trusted local development environment or the proxy path is fully trusted. Never expose a plaintext, auth-disabled gateway to a public network. Behind Cloudflare Access, [verify the Access token](#verify-cloudflare-access-tokens) instead. +### Verify Cloudflare Access Tokens + +Cloudflare Access sends a signed token with every request. Configure the gateway to verify it so a gRPC caller that bypasses the proxy is rejected instead of accepted as the local developer principal. Do not set `allow_unauthenticated_users`; the gateway refuses to start with it, or with OIDC. + +```shell +helm upgrade openshell \ + oci://ghcr.io/nvidia/openshell/helm-chart \ + --version \ + --namespace openshell \ + --set server.disableTls=true \ + --set 'gatewayConfig.openshell\.gateway\.edge_auth.provider=cloudflare_access' \ + --set 'gatewayConfig.openshell\.gateway\.edge_auth.issuer=https://.cloudflareaccess.com' \ + --set 'gatewayConfig.openshell\.gateway\.edge_auth.audience=' +``` + +The audience is the AUD tag on the Access application's overview page. The gateway reads the token from the `cf-access-jwt-assertion` header or the `CF_Authorization` cookie. It checks the signature, issuer, audience, expiry, and `nbf` when present, using the keys that the team publishes at `/cdn-cgi/access/certs`. It refreshes the keys on an unknown key ID. A missing, invalid, or expired token returns `Unauthenticated`. + +A valid token becomes a user whose subject is the token `sub`. Run `openshell whoami` to see it. Access service tokens carry no subject and are rejected. + +Without `admin_role` and `user_role`, every caller that Access admits is a platform admin, and workspace membership is not enforced, as with mTLS users. Scope the Access policy accordingly. To enforce roles and membership, set `roles_claim` to the claim that carries them, such as `custom.groups`, and set both `admin_role` and `user_role`. For all fields, refer to [Gateway Configuration](/how-it-works/gateways/configuration). + Register the gateway with the CLI using the proxy's public URL. The browser-based login flow runs automatically on first use: ```shell