feat: Entra Agent ID auth provider + governance leak cleanup

New crate: bascule-auth-agent-id
  Microsoft Entra Agent ID authentication for AI agents
  Validates OAuth tokens against Entra JWKS (60min cache)
  Extracts agent metadata: type, blueprint, sponsor, scopes
  Detects on-behalf-of (delegated) agents
  Token-as-password pattern for SSH auth

Cleanup:
  Removed all governance-specific references from comments
  SessionHandler trait is the only extension point
  Zero substrate/chronicle/gsap dependencies
  Config example uses neutral terminology

Config:
  [auth.agent_id] section for Entra configuration
  tenant_id, audiences, multi_tenant fields

3 crates: bascule-core, bascule-server, bascule-auth-agent-id
938 lines total, 5.6MB binary, 0 substrate deps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tyler King 2026-04-04 22:35:32 -04:00
parent bfa26cfd15
commit 02142f7be4
8 changed files with 1270 additions and 54 deletions

1029
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
[workspace] [workspace]
members = ["crates/bascule-core", "crates/bascule-server"] members = ["crates/bascule-core", "crates/bascule-server", "crates/bascule-auth-agent-id"]
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
@ -23,3 +23,6 @@ uuid = { version = "1", features = ["v4"] }
rand = "0.8" rand = "0.8"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
portable-pty = "0.8" portable-pty = "0.8"
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
jsonwebtoken = "9"

View file

@ -9,7 +9,7 @@ listen_addr = "0.0.0.0:2222"
# Shell command to spawn for each session # Shell command to spawn for each session
# Default: /bin/bash # Default: /bin/bash
# shell_command = "/bin/bash" # shell_command = "/bin/bash"
# shell_command = "/usr/local/bin/gsh" # Governed shell # shell_command = "/usr/local/bin/custom-shell"
# Authentication # Authentication
[auth] [auth]
@ -17,7 +17,7 @@ mode = "accept-all" # "accept-all" (dev only), "authorized-keys"
# authorized_keys_path = "/etc/bascule/authorized_keys" # authorized_keys_path = "/etc/bascule/authorized_keys"
# Session banner (optional) # Session banner (optional)
# banner = "Welcome to the governed shell." # banner = "Welcome to Bascule."
# Max concurrent sessions (0 = unlimited) # Max concurrent sessions (0 = unlimited)
# max_sessions = 100 # max_sessions = 100

View file

@ -0,0 +1,18 @@
[package]
name = "bascule-auth-agent-id"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Microsoft Entra Agent ID authentication provider for Bascule"
[dependencies]
bascule-core = { path = "../bascule-core" }
async-trait = { workspace = true }
serde = { workspace = true }
serde_json = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
jsonwebtoken = "9"
tokio = { workspace = true }
tracing = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }

View file

@ -0,0 +1,237 @@
//! Microsoft Entra Agent ID authentication provider for Bascule.
//!
//! Validates AI agent identity tokens issued by Microsoft Entra.
//! Agents authenticate via OAuth 2.0 client_credentials and present
//! their token as the SSH password (token-as-password pattern).
//!
//! Agent metadata extracted from token claims:
//! - Agent application/client ID
//! - Display name and agent type
//! - Blueprint ID (agent type template)
//! - Sponsor (human or org that registered the agent)
//! - Delegation chain (on-behalf-of claims)
//! - Scopes and roles
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use tokio::sync::RwLock;
use bascule_core::auth::AuthProvider;
/// Metadata extracted from an Entra Agent ID token.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AgentIdentity {
pub agent_id: String,
pub display_name: String,
pub agent_type: Option<String>,
pub blueprint_id: Option<String>,
pub sponsor: Option<String>,
pub on_behalf_of: Option<String>,
pub tenant_id: String,
pub scopes: Vec<String>,
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
pub tags: HashMap<String, String>,
}
/// Microsoft Entra Agent ID authentication provider.
pub struct EntraAgentIdProvider {
tenant_id: String,
expected_audiences: Vec<String>,
jwks_cache: Arc<RwLock<Option<JwksCache>>>,
allow_multi_tenant: bool,
}
struct JwksCache {
keys: jsonwebtoken::jwk::JwkSet,
fetched_at: Instant,
}
impl EntraAgentIdProvider {
/// Create a provider for a specific Entra tenant.
pub fn new(tenant_id: &str, expected_audiences: Vec<String>) -> Self {
Self {
tenant_id: tenant_id.to_string(),
expected_audiences,
jwks_cache: Arc::new(RwLock::new(None)),
allow_multi_tenant: false,
}
}
/// Create a multi-tenant provider (accepts agents from any tenant).
pub fn multi_tenant(expected_audiences: Vec<String>) -> Self {
Self {
tenant_id: "common".to_string(),
expected_audiences,
jwks_cache: Arc::new(RwLock::new(None)),
allow_multi_tenant: true,
}
}
async fn get_jwks(&self) -> anyhow::Result<jsonwebtoken::jwk::JwkSet> {
{
let cache = self.jwks_cache.read().await;
if let Some(ref c) = *cache {
if c.fetched_at.elapsed() < Duration::from_secs(3600) {
return Ok(c.keys.clone());
}
}
}
let url = format!(
"https://login.microsoftonline.com/{}/discovery/v2.0/keys",
self.tenant_id
);
let client = reqwest::Client::new();
let jwks: jsonwebtoken::jwk::JwkSet = client.get(&url).send().await?.json().await?;
{
let mut cache = self.jwks_cache.write().await;
*cache = Some(JwksCache {
keys: jwks.clone(),
fetched_at: Instant::now(),
});
}
Ok(jwks)
}
async fn validate_token(&self, token: &str) -> anyhow::Result<AgentIdentity> {
let jwks = self.get_jwks().await?;
let header = jsonwebtoken::decode_header(token)?;
let kid = header
.kid
.as_deref()
.ok_or_else(|| anyhow::anyhow!("Token missing kid header"))?;
let jwk = jwks
.keys
.iter()
.find(|k| k.common.key_id.as_deref() == Some(kid))
.ok_or_else(|| anyhow::anyhow!("Key ID not found in JWKS"))?;
let decoding_key = jsonwebtoken::DecodingKey::from_jwk(jwk)?;
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);
validation.set_audience(&self.expected_audiences);
let tenant_issuer = format!(
"https://login.microsoftonline.com/{}/v2.0",
self.tenant_id
);
if self.allow_multi_tenant {
let issuers = [
tenant_issuer,
"https://login.microsoftonline.com/common/v2.0".to_string(),
];
validation.set_issuer(&issuers);
} else {
validation.set_issuer(&[tenant_issuer]);
}
let token_data =
jsonwebtoken::decode::<serde_json::Value>(token, &decoding_key, &validation)?;
let claims = token_data.claims;
let agent_id = claims
.get("azp")
.or_else(|| claims.get("appid"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let display_name = claims
.get("app_displayname")
.or_else(|| claims.get("name"))
.and_then(|v| v.as_str())
.unwrap_or(&agent_id)
.to_string();
let tenant_id = claims
.get("tid")
.and_then(|v| v.as_str())
.unwrap_or(&self.tenant_id)
.to_string();
let scopes = if let Some(roles) = claims.get("roles") {
roles
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
} else if let Some(scp) = claims.get("scp") {
scp.as_str()
.map(|s| s.split(' ').map(|s| s.to_string()).collect())
.unwrap_or_default()
} else {
vec![]
};
let mut tags = HashMap::new();
for key in ["agent_type", "blueprint_id", "sponsor"] {
if let Some(val) = claims.get(key).and_then(|v| v.as_str()) {
tags.insert(key.to_string(), val.to_string());
}
}
let on_behalf_of = claims
.get("oid")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.filter(|_| claims.get("azp").is_some());
let expires_at = claims
.get("exp")
.and_then(|v| v.as_i64())
.and_then(|ts| chrono::DateTime::from_timestamp(ts, 0));
Ok(AgentIdentity {
agent_id,
display_name,
agent_type: tags.get("agent_type").cloned(),
blueprint_id: tags.get("blueprint_id").cloned(),
sponsor: tags.get("sponsor").cloned(),
on_behalf_of,
tenant_id,
scopes,
expires_at,
tags,
})
}
}
#[async_trait]
impl AuthProvider for EntraAgentIdProvider {
async fn check_password(
&self,
user: &str,
password: &str,
) -> bool {
match self.validate_token(password).await {
Ok(identity) => {
tracing::info!(
agent_id = %identity.agent_id,
display_name = %identity.display_name,
agent_type = ?identity.agent_type,
tenant = %identity.tenant_id,
"Agent authenticated via Entra Agent ID"
);
true
}
Err(e) => {
tracing::debug!(user = %user, error = %e, "Agent ID token validation failed");
false
}
}
}
fn principal_for_user(&self, user: &str) -> String {
format!("agent:{}", user)
}
}

View file

@ -39,6 +39,21 @@ pub struct AuthConfig {
/// Path to authorized_keys file (for authorized-keys mode). /// Path to authorized_keys file (for authorized-keys mode).
pub authorized_keys_path: Option<String>, pub authorized_keys_path: Option<String>,
/// AI agent authentication via Microsoft Entra Agent ID.
pub agent_id: Option<AgentIdConfig>,
}
#[derive(Debug, Deserialize)]
pub struct AgentIdConfig {
/// Entra tenant ID.
pub tenant_id: String,
/// Expected token audiences.
#[serde(default)]
pub audiences: Vec<String>,
/// Accept agents from any tenant (multi-tenant mode).
#[serde(default)]
pub multi_tenant: bool,
} }
impl Default for BasculeConfig { impl Default for BasculeConfig {

View file

@ -1,4 +1,4 @@
//! SessionHandler trait — the extension point for governance, audit, and custom logic. //! SessionHandler trait — the extension point for audit, policy, and custom logic.
//! //!
//! Implement this trait to add behavior to Bascule sessions without modifying //! Implement this trait to add behavior to Bascule sessions without modifying
//! the SSH proxy core. The default implementation accepts everything and adds nothing. //! the SSH proxy core. The default implementation accepts everything and adds nothing.
@ -11,8 +11,8 @@ use crate::session::SessionInfo;
/// Hook point for extending Bascule's behavior. /// Hook point for extending Bascule's behavior.
/// ///
/// The open-source version ships with [`DefaultHandler`] (passthrough). /// The open-source version ships with [`DefaultHandler`] (passthrough).
/// Governance extensions implement this trait to add authorization contexts, /// Custom handlers implement this trait to add authorization checks,
/// completion receipts, operational posture checks, and audit trails. /// session recording, command filtering, or audit trails.
#[async_trait] #[async_trait]
pub trait SessionHandler: Send + Sync + 'static { pub trait SessionHandler: Send + Sync + 'static {
/// Called after SSH authentication succeeds, before shell spawn. /// Called after SSH authentication succeeds, before shell spawn.
@ -48,7 +48,7 @@ pub trait SessionHandler: Send + Sync + 'static {
} }
} }
/// Default handler — no governance, pure SSH proxy. /// Default handler — pure SSH proxy passthrough.
/// Accepts all sessions, adds no environment, logs nothing. /// Accepts all sessions, adds no environment, logs nothing.
pub struct DefaultHandler; pub struct DefaultHandler;

View file

@ -3,11 +3,11 @@
//! Provides a pluggable SSH server with: //! Provides a pluggable SSH server with:
//! - Configurable authentication (SSH keys, OIDC, accept-all) //! - Configurable authentication (SSH keys, OIDC, accept-all)
//! - PTY bridging to any shell command //! - PTY bridging to any shell command
//! - SessionHandler trait for extending behavior (audit, governance, recording) //! - SessionHandler trait for extending behavior (audit, policy, recording)
//! //!
//! The open-source core ships with `DefaultHandler` (passthrough). //! The core ships with `DefaultHandler` (passthrough).
//! Governance extensions (ACs, CRs, DEFCON, Chronicle) implement //! Custom handlers implement `SessionHandler` in separate crates
//! `SessionHandler` in separate crates. //! to add authorization, audit trails, or policy enforcement.
pub mod auth; pub mod auth;
pub mod config; pub mod config;