Fix the core SSO authentication flow: instead of storing the Zitadel
access_token as the OpenBao token (which OpenBao doesn't recognize),
exchange the id_token with OpenBao's JWT auth method via
POST /v1/auth/{mount}/login to get a real OpenBao client token.
Changes:
- ZitadelOidcAuth: add openbao_url, jwt_auth_mount, jwt_role fields
- New exchange_jwt_for_openbao_token() method using reqwest (vaultrs
0.7.4 has no JWT auth module)
- process_token_response() now exchanges id_token when openbao_url is
set, falls back to access_token for backward compat
- OpenbaoSecretStore::new() accepts optional jwt_role + jwt_auth_mount
- All callers updated (lib.rs, openbao_chain example, harmony_sso)
This implements ADR 020-1 Step 6 (OpenBao JWT exchange).
193 lines
6.3 KiB
Rust
193 lines
6.3 KiB
Rust
//! End-to-end example: harmony_config with OpenBao as a ConfigSource
|
|
//!
|
|
//! This example demonstrates the full config resolution chain:
|
|
//! EnvSource → SqliteSource → StoreSource<OpenbaoSecretStore>
|
|
//!
|
|
//! When OpenBao is unreachable, the chain gracefully falls through to SQLite.
|
|
//!
|
|
//! **Prerequisites**:
|
|
//! - OpenBao must be initialized and unsealed
|
|
//! - KV v2 engine must be enabled at the `OPENBAO_KV_MOUNT` path (default: `secret`)
|
|
//! - Auth method must be enabled at the `OPENBAO_AUTH_MOUNT` path (default: `userpass`)
|
|
//!
|
|
//! **Environment variables**:
|
|
//! - `OPENBAO_URL` (required for OpenBao): URL of the OpenBao server
|
|
//! - `OPENBAO_TOKEN` (optional): Use token auth instead of userpass
|
|
//! - `OPENBAO_USERNAME` + `OPENBAO_PASSWORD` (optional): Userpass auth
|
|
//! - `OPENBAO_KV_MOUNT` (default: `secret`): KV v2 engine mount path
|
|
//! - `OPENBAO_AUTH_MOUNT` (default: `userpass`): Auth method mount path
|
|
//! - `OPENBAO_SKIP_TLS` (default: `false`): Skip TLS verification
|
|
//! - `HARMONY_SSO_URL` + `HARMONY_SSO_CLIENT_ID` (optional): Zitadel OIDC device flow (RFC 8628)
|
|
//!
|
|
//! **Run**:
|
|
//! ```bash
|
|
//! # Without OpenBao (SqliteSource only):
|
|
//! cargo run --example openbao_chain
|
|
//!
|
|
//! # With OpenBao (full chain):
|
|
//! export OPENBAO_URL="http://127.0.0.1:8200"
|
|
//! export OPENBAO_TOKEN="<your-token>"
|
|
//! cargo run --example openbao_chain
|
|
//! ```
|
|
//!
|
|
//! **Setup OpenBao** (if needed):
|
|
//! ```bash
|
|
//! # Port-forward to local OpenBao
|
|
//! kubectl port-forward svc/openbao -n openbao 8200:8200 &
|
|
//!
|
|
//! # Initialize (one-time)
|
|
//! kubectl exec -n openbao openbao-0 -- bao operator init
|
|
//!
|
|
//! # Enable KV and userpass (one-time)
|
|
//! kubectl exec -n openbao openbao-0 -- bao secrets enable -path=secret kv-v2
|
|
//! kubectl exec -n openbao openbao-0 -- bao auth enable userpass
|
|
//!
|
|
//! # Create test user
|
|
//! kubectl exec -n openbao openbao-0 -- bao write auth/userpass/users/testuser \
|
|
//! password="testpass" policies="default"
|
|
//! ```
|
|
|
|
use std::sync::Arc;
|
|
|
|
use harmony_config::{Config, ConfigManager, ConfigSource, EnvSource, SqliteSource, StoreSource};
|
|
use harmony_secret::OpenbaoSecretStore;
|
|
use schemars::JsonSchema;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
|
|
struct AppConfig {
|
|
host: String,
|
|
port: u16,
|
|
}
|
|
|
|
impl Default for AppConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
host: "localhost".to_string(),
|
|
port: 8080,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Config for AppConfig {
|
|
const KEY: &'static str = "AppConfig";
|
|
}
|
|
|
|
async fn build_manager() -> ConfigManager {
|
|
let sqlite = Arc::new(
|
|
SqliteSource::default()
|
|
.await
|
|
.expect("Failed to open SQLite database"),
|
|
);
|
|
|
|
let env_source: Arc<dyn ConfigSource> = Arc::new(EnvSource);
|
|
|
|
let openbao_url = std::env::var("OPENBAO_URL")
|
|
.or_else(|_| std::env::var("VAULT_ADDR"))
|
|
.ok();
|
|
|
|
match openbao_url {
|
|
Some(url) => {
|
|
let kv_mount =
|
|
std::env::var("OPENBAO_KV_MOUNT").unwrap_or_else(|_| "secret".to_string());
|
|
let auth_mount =
|
|
std::env::var("OPENBAO_AUTH_MOUNT").unwrap_or_else(|_| "userpass".to_string());
|
|
let skip_tls = std::env::var("OPENBAO_SKIP_TLS")
|
|
.map(|v| v == "true")
|
|
.unwrap_or(false);
|
|
|
|
match OpenbaoSecretStore::new(
|
|
url,
|
|
kv_mount,
|
|
auth_mount,
|
|
skip_tls,
|
|
std::env::var("OPENBAO_TOKEN").ok(),
|
|
std::env::var("OPENBAO_USERNAME").ok(),
|
|
std::env::var("OPENBAO_PASSWORD").ok(),
|
|
std::env::var("HARMONY_SSO_URL").ok(),
|
|
std::env::var("HARMONY_SSO_CLIENT_ID").ok(),
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
{
|
|
Ok(store) => {
|
|
let store_source: Arc<dyn ConfigSource> =
|
|
Arc::new(StoreSource::new("harmony".to_string(), store));
|
|
println!("OpenBao connected. Full chain: env → sqlite → openbao");
|
|
ConfigManager::new(vec![env_source, Arc::clone(&sqlite) as _, store_source])
|
|
}
|
|
Err(e) => {
|
|
eprintln!(
|
|
"Warning: OpenBao unavailable ({e}), using local chain: env → sqlite"
|
|
);
|
|
ConfigManager::new(vec![env_source, sqlite])
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
println!("OPENBAO_URL not set. Using local chain: env → sqlite");
|
|
ConfigManager::new(vec![env_source, sqlite])
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> anyhow::Result<()> {
|
|
env_logger::init();
|
|
|
|
let manager = build_manager().await;
|
|
|
|
println!("\n=== harmony_config OpenBao Chain Demo ===\n");
|
|
|
|
println!("1. Attempting to get AppConfig (expect NotFound on first run)...");
|
|
match manager.get::<AppConfig>().await {
|
|
Ok(config) => {
|
|
println!(" Found: {:?}", config);
|
|
}
|
|
Err(harmony_config::ConfigError::NotFound { .. }) => {
|
|
println!(" NotFound - no config stored yet");
|
|
}
|
|
Err(e) => {
|
|
println!(" Error: {:?}", e);
|
|
}
|
|
}
|
|
|
|
println!("\n2. Setting AppConfig via set()...");
|
|
let config = AppConfig {
|
|
host: "production.example.com".to_string(),
|
|
port: 443,
|
|
};
|
|
manager.set(&config).await?;
|
|
println!(" Set: {:?}", config);
|
|
|
|
println!("\n3. Getting AppConfig back...");
|
|
let retrieved: AppConfig = manager.get().await?;
|
|
println!(" Retrieved: {:?}", retrieved);
|
|
assert_eq!(config, retrieved);
|
|
|
|
println!("\n4. Demonstrating env override...");
|
|
println!(" HARMONY_CONFIG_AppConfig env var overrides all backends");
|
|
let env_config = AppConfig {
|
|
host: "env-override.example.com".to_string(),
|
|
port: 9090,
|
|
};
|
|
unsafe {
|
|
std::env::set_var(
|
|
"HARMONY_CONFIG_AppConfig",
|
|
serde_json::to_string(&env_config)?,
|
|
);
|
|
}
|
|
let from_env: AppConfig = manager.get().await?;
|
|
println!(" Got from env: {:?}", from_env);
|
|
assert_eq!(env_config.host, "env-override.example.com");
|
|
unsafe {
|
|
std::env::remove_var("HARMONY_CONFIG_AppConfig");
|
|
}
|
|
|
|
println!("\n=== Done! ===");
|
|
println!("Config persisted at ~/.local/share/harmony/config/config.db");
|
|
|
|
Ok(())
|
|
}
|