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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions crates/openshell-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,10 @@ pub struct Config {
/// OIDC configuration. When `Some`, the server validates Bearer JWTs.
pub oidc: Option<OidcConfig>,

/// Edge JWT configuration. When `Some`, the server verifies the edge
/// proxy's token on every request.
pub edge_auth: Option<EdgeAuthConfig>,

/// Gateway user authentication behavior.
pub auth: GatewayAuthConfig,

Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions crates/openshell-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
57 changes: 55 additions & 2 deletions crates/openshell-server/src/auth/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -230,7 +231,8 @@ fn mechanism(principal: &Principal) -> Option<AuthMechanism> {
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,
Expand All @@ -243,7 +245,7 @@ fn mechanism(principal: &Principal) -> Option<AuthMechanism> {
}

/// 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.
Expand All @@ -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)
Expand Down Expand Up @@ -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() {
Expand Down
4 changes: 4 additions & 0 deletions crates/openshell-server/src/auth/authenticator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand Down
55 changes: 54 additions & 1 deletion crates/openshell-server/src/auth/authz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self> {
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
Expand All @@ -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,
));
Expand Down Expand Up @@ -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"]);
Expand Down
Loading
Loading