diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 7f5d4d1ec0..8825a800b7 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, @@ -454,6 +458,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)] @@ -934,6 +982,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(), @@ -1110,6 +1159,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 aa7613554d..7518f7da29 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -68,11 +68,11 @@ pub mod transport_errors; pub mod upload; pub use config::{ - AppArmorProfile, Config, GatewayAuthConfig, GatewayInterceptorBindingOverride, - GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, - GatewayInterceptorPhaseConfig, GatewayJwtConfig, GatewayProviderProfileSourceConfig, - ImagePullPolicy, MtlsAuthConfig, OidcConfig, PolicyValidationFailureMode, TlsConfig, - UpstreamProxyConfig, + AppArmorProfile, Config, EdgeAuthConfig, EdgeAuthProvider, 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..e02870210d --- /dev/null +++ b/crates/openshell-server/src/auth/edge.rs @@ -0,0 +1,165 @@ +// 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::extract_cookie; +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(|| { + headers + .get(http::header::COOKIE) + .and_then(|value| value.to_str().ok()) + .and_then(|cookies| extract_cookie(cookies, 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_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..67b5dec595 100644 --- a/crates/openshell-server/src/auth/http.rs +++ b/crates/openshell-server/src/auth/http.rs @@ -196,7 +196,7 @@ async fn auth_connect( let cf_token = headers .get("cookie") .and_then(|v| v.to_str().ok()) - .and_then(|cookies| extract_cookie(cookies, "CF_Authorization")); + .and_then(|cookies| extract_cookie(cookies, 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 @@ -236,7 +236,7 @@ async fn auth_connect( } /// Extract a named cookie value from a `Cookie` header string. -fn extract_cookie(cookies: &str, name: &str) -> Option { +pub(super) fn extract_cookie(cookies: &str, name: &str) -> Option { cookies.split(';').find_map(|c| { let mut parts = c.trim().splitn(2, '='); let key = parts.next()?.trim(); 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..a49df6dcc3 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,55 @@ 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 + } + + /// 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 +132,7 @@ pub struct JwksCache { refresh_mutex: tokio::sync::Mutex<()>, http: Client, config: OidcConfig, + kind: TokenKind, } impl std::fmt::Debug for JwksCache { @@ -148,6 +198,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 +632,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 +702,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 +755,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 +789,7 @@ impl JwksCache { refresh_mutex: tokio::sync::Mutex::new(()), http, config: config.clone(), + kind, }; cache.refresh_keys().await?; @@ -799,16 +925,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 +942,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 +954,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 +967,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 +980,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 +990,25 @@ 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; + // A token without a subject would collapse every caller into one + // identity. Cloudflare service tokens carry an empty `sub`. + if 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 +1017,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 +1084,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 } } @@ -2394,4 +2535,275 @@ 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); + } + } + + #[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 5b2ce01c13..f9e37a5ef2 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -644,6 +644,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 @@ -719,6 +726,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"); @@ -740,6 +748,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, @@ -762,10 +783,11 @@ async fn run_from_args( && !prepared.config.mtls_auth.enabled && !prepared.config.mtls_auth.operator_enabled && !has_oidc + && !has_edge_auth && prepared.config.gateway_jwt.is_none() { warn!( - "Neither mTLS user auth nor OIDC nor sandbox JWT auth is configured — \ + "Neither mTLS user auth nor OIDC nor edge JWT nor sandbox JWT auth is configured; \ the gateway has no authentication mechanism" ); } @@ -3764,6 +3786,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 817a6a02ad..3fd72f4903 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -28,7 +28,7 @@ use std::path::{Path, PathBuf}; use base64::Engine as _; use openshell_core::proto::SupervisorMiddlewareService; use openshell_core::{ - GatewayAuthConfig, GatewayInterceptorConfig, GatewayJwtConfig, + EdgeAuthConfig, GatewayAuthConfig, GatewayInterceptorConfig, GatewayJwtConfig, GatewayProviderProfileSourceConfig, MtlsAuthConfig, OidcConfig, }; use serde::{Deserialize, Serialize}; @@ -179,6 +179,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, @@ -1094,6 +1096,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 parses_full_example() { let toml = r#" 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 f1f991e4ae..0ea464bf4d 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -379,6 +379,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>, @@ -414,9 +418,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, } @@ -481,10 +486,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 { @@ -510,6 +513,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, @@ -593,6 +597,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() @@ -682,15 +687,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}")))?; @@ -699,6 +700,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(); @@ -783,6 +793,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 baf9b53ef3..0dd6dbcf86 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 @@ -1087,6 +1086,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; } @@ -4590,6 +4592,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 7f16929759..38fdc7f6b9 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -341,6 +341,21 @@ External compute drivers declare this requirement by adding `openshell.compute.l `[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). + ## Database Pool `[openshell.gateway] database_max_connections` sets the connection ceiling for the gateway's database pool. One pool is shared by every database-backed RPC, so this value is also the gateway's ceiling on concurrent database work. When the pool is saturated, callers queue on connection acquisition and sqlx logs that the time to acquire a connection exceeded its slow threshold. The background readiness monitor draws from the same pool, so sustained saturation can push its database ping past the check timeout and flip `/readyz` to not-ready while the gateway is otherwise serving. 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