feat(harmony_secret): SSO auth hardening — silent refresh, renewal, namespacing #302
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -4472,6 +4472,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"url",
|
"url",
|
||||||
"vaultrs",
|
"vaultrs",
|
||||||
|
"webbrowser",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ schemars = "0.8"
|
|||||||
vaultrs = "0.7.4"
|
vaultrs = "0.7.4"
|
||||||
reqwest = { workspace = true, features = ["json"] }
|
reqwest = { workspace = true, features = ["json"] }
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
|
# Used by ZitadelOidcAuth to best-effort launch the device-flow
|
||||||
|
# URL in the operator's browser. Failure to open is non-fatal —
|
||||||
|
# the URL is already printed to the terminal.
|
||||||
|
webbrowser = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
pretty_assertions.workspace = true
|
pretty_assertions.workspace = true
|
||||||
|
|||||||
@@ -41,6 +41,4 @@ lazy_static! {
|
|||||||
env::var("HARMONY_SSO_CLIENT_ID").ok();
|
env::var("HARMONY_SSO_CLIENT_ID").ok();
|
||||||
pub static ref HARMONY_SSO_CLIENT_SECRET: Option<String> =
|
pub static ref HARMONY_SSO_CLIENT_SECRET: Option<String> =
|
||||||
env::var("HARMONY_SSO_CLIENT_SECRET").ok();
|
env::var("HARMONY_SSO_CLIENT_SECRET").ok();
|
||||||
pub static ref HARMONY_SECRETS_URL: Option<String> =
|
|
||||||
env::var("HARMONY_SECRETS_URL").ok();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use log::{debug, info};
|
use log::{debug, info, warn};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::{SecretStore, SecretStoreError};
|
use crate::{SecretStore, SecretStoreError};
|
||||||
@@ -8,43 +8,73 @@ use crate::{SecretStore, SecretStoreError};
|
|||||||
pub struct LocalFileSecretStore;
|
pub struct LocalFileSecretStore;
|
||||||
|
|
||||||
impl LocalFileSecretStore {
|
impl LocalFileSecretStore {
|
||||||
/// Helper to consistently generate the secret file path.
|
/// Current on-disk layout: `<base>/<ns>/<key>.json`.
|
||||||
fn get_file_path(base_dir: &Path, ns: &str, key: &str) -> PathBuf {
|
///
|
||||||
|
/// Nesting by namespace gives each `harmony_secret` consumer its own
|
||||||
|
/// directory under `~/.local/share/harmony/secrets/`, so an example's
|
||||||
|
/// state can be deleted (e.g. `rm -rf .../secrets/harmony-sso-example/`)
|
||||||
|
/// without touching unrelated namespaces such as production deployments.
|
||||||
|
fn current_file_path(base_dir: &Path, ns: &str, key: &str) -> PathBuf {
|
||||||
|
base_dir.join(ns).join(format!("{key}.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy flat layout `<base>/<ns>_<key>.json`, kept only as a read
|
||||||
|
/// fallback so existing installs don't lose secrets after the
|
||||||
|
/// upgrade. Writes always use the current layout.
|
||||||
|
fn legacy_file_path(base_dir: &Path, ns: &str, key: &str) -> PathBuf {
|
||||||
base_dir.join(format!("{ns}_{key}.json"))
|
base_dir.join(format!("{ns}_{key}.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn base_dir() -> PathBuf {
|
||||||
|
directories::BaseDirs::new()
|
||||||
|
.expect("Could not find a valid home directory")
|
||||||
|
.data_dir()
|
||||||
|
.join("harmony")
|
||||||
|
.join("secrets")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl SecretStore for LocalFileSecretStore {
|
impl SecretStore for LocalFileSecretStore {
|
||||||
async fn get_raw(&self, ns: &str, key: &str) -> Result<Vec<u8>, SecretStoreError> {
|
async fn get_raw(&self, ns: &str, key: &str) -> Result<Vec<u8>, SecretStoreError> {
|
||||||
let data_dir = directories::BaseDirs::new()
|
let data_dir = Self::base_dir();
|
||||||
.expect("Could not find a valid home directory")
|
let file_path = Self::current_file_path(&data_dir, ns, key);
|
||||||
.data_dir()
|
|
||||||
.join("harmony")
|
|
||||||
.join("secrets");
|
|
||||||
|
|
||||||
let file_path = Self::get_file_path(&data_dir, ns, key);
|
|
||||||
debug!(
|
debug!(
|
||||||
"LOCAL_STORE: Getting key '{key}' from namespace '{ns}' at {}",
|
"LOCAL_STORE: Getting key '{key}' from namespace '{ns}' at {}",
|
||||||
file_path.display()
|
file_path.display()
|
||||||
);
|
);
|
||||||
|
|
||||||
tokio::fs::read(&file_path)
|
match tokio::fs::read(&file_path).await {
|
||||||
.await
|
Ok(bytes) => Ok(bytes),
|
||||||
.map_err(|_| SecretStoreError::NotFound {
|
Err(_) => {
|
||||||
|
// Fall back to the legacy flat file written by older
|
||||||
|
// versions of harmony_secret. We do not auto-migrate
|
||||||
|
// on read (a subsequent `set_raw` will write the new
|
||||||
|
// location); operators can `mv` the file or wait for
|
||||||
|
// the next write to converge.
|
||||||
|
let legacy = Self::legacy_file_path(&data_dir, ns, key);
|
||||||
|
match tokio::fs::read(&legacy).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
warn!(
|
||||||
|
"LOCAL_STORE: Read from legacy flat path {}; the next set \
|
||||||
|
will persist to the namespaced layout at {}",
|
||||||
|
legacy.display(),
|
||||||
|
file_path.display()
|
||||||
|
);
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
Err(_) => Err(SecretStoreError::NotFound {
|
||||||
namespace: ns.to_string(),
|
namespace: ns.to_string(),
|
||||||
key: key.to_string(),
|
key: key.to_string(),
|
||||||
})
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn set_raw(&self, ns: &str, key: &str, val: &[u8]) -> Result<(), SecretStoreError> {
|
async fn set_raw(&self, ns: &str, key: &str, val: &[u8]) -> Result<(), SecretStoreError> {
|
||||||
let data_dir = directories::BaseDirs::new()
|
let data_dir = Self::base_dir();
|
||||||
.expect("Could not find a valid home directory")
|
let file_path = Self::current_file_path(&data_dir, ns, key);
|
||||||
.data_dir()
|
|
||||||
.join("harmony")
|
|
||||||
.join("secrets");
|
|
||||||
|
|
||||||
let file_path = Self::get_file_path(&data_dir, ns, key);
|
|
||||||
info!(
|
info!(
|
||||||
"LOCAL_STORE: Setting key '{key}' in namespace '{ns}' at {}",
|
"LOCAL_STORE: Setting key '{key}' in namespace '{ns}' at {}",
|
||||||
file_path.display()
|
file_path.display()
|
||||||
@@ -67,6 +97,20 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn current_path_nests_by_namespace() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let p = LocalFileSecretStore::current_file_path(dir.path(), "harmony-sso-example", "Foo");
|
||||||
|
assert_eq!(p, dir.path().join("harmony-sso-example").join("Foo.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn legacy_path_is_flat() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let p = LocalFileSecretStore::legacy_file_path(dir.path(), "harmony-sso-example", "Foo");
|
||||||
|
assert_eq!(p, dir.path().join("harmony-sso-example_Foo.json"));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_set_and_get_raw_successfully() {
|
async fn test_set_and_get_raw_successfully() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
@@ -74,17 +118,12 @@ mod tests {
|
|||||||
let key = "test-key";
|
let key = "test-key";
|
||||||
let value = b"{\"data\":\"test-value\"}";
|
let value = b"{\"data\":\"test-value\"}";
|
||||||
|
|
||||||
// To test the store directly, we override the base directory logic.
|
let file_path = LocalFileSecretStore::current_file_path(dir.path(), ns, key);
|
||||||
// For this test, we'll manually construct the path within our temp dir.
|
|
||||||
let file_path = LocalFileSecretStore::get_file_path(dir.path(), ns, key);
|
|
||||||
|
|
||||||
// Manually write to the temp path to simulate the store's behavior
|
|
||||||
tokio::fs::create_dir_all(file_path.parent().unwrap())
|
tokio::fs::create_dir_all(file_path.parent().unwrap())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
tokio::fs::write(&file_path, value).await.unwrap();
|
tokio::fs::write(&file_path, value).await.unwrap();
|
||||||
|
|
||||||
// Now, test get_raw by reading from that same temp path (by mocking the path logic)
|
|
||||||
let retrieved_value = tokio::fs::read(&file_path).await.unwrap();
|
let retrieved_value = tokio::fs::read(&file_path).await.unwrap();
|
||||||
assert_eq!(retrieved_value, value);
|
assert_eq!(retrieved_value, value);
|
||||||
}
|
}
|
||||||
@@ -95,8 +134,7 @@ mod tests {
|
|||||||
let ns = "test-ns";
|
let ns = "test-ns";
|
||||||
let key = "non-existent-key";
|
let key = "non-existent-key";
|
||||||
|
|
||||||
// We need to check if reading a non-existent file gives the correct error
|
let file_path = LocalFileSecretStore::current_file_path(dir.path(), ns, key);
|
||||||
let file_path = LocalFileSecretStore::get_file_path(dir.path(), ns, key);
|
|
||||||
let result = tokio::fs::read(&file_path).await;
|
let result = tokio::fs::read(&file_path).await;
|
||||||
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
|
|||||||
@@ -77,10 +77,34 @@ impl OpenbaoSecretStore {
|
|||||||
|
|
||||||
// 2. Try to load cached token
|
// 2. Try to load cached token
|
||||||
let cache_path = Self::get_token_cache_path(&base_url);
|
let cache_path = Self::get_token_cache_path(&base_url);
|
||||||
if let Ok(cached_token) = Self::load_cached_token(&cache_path) {
|
if let Ok(mut cached_token) = Self::load_cached_token(&cache_path) {
|
||||||
debug!("OPENBAO_STORE: Found cached token, validating...");
|
debug!("OPENBAO_STORE: Found cached token, validating...");
|
||||||
if Self::validate_token(&base_url, skip_tls, &cached_token.client_token).await {
|
if Self::validate_token(&base_url, skip_tls, &cached_token.client_token).await {
|
||||||
info!("OPENBAO_STORE: Cached token is valid");
|
info!("OPENBAO_STORE: Cached token is valid");
|
||||||
|
|
||||||
|
// Best-effort renewal: extend the server-side lease so
|
||||||
|
// sessions roll forward without an OIDC re-auth. The
|
||||||
|
// token has already been validated above, so a renew
|
||||||
|
// failure (server-side policy denying renewal, network
|
||||||
|
// blip, etc.) doesn't block the caller — we just keep
|
||||||
|
// the validated cached token as-is and let the next
|
||||||
|
// invocation try again.
|
||||||
|
match Self::renew_self(&base_url, skip_tls, &cached_token.client_token).await {
|
||||||
|
Ok(new_lease) => {
|
||||||
|
info!("OPENBAO_STORE: cached token renewed, lease_duration={new_lease}s");
|
||||||
|
cached_token.lease_duration = Some(new_lease);
|
||||||
|
if let Err(e) = Self::cache_token(&cache_path, &cached_token) {
|
||||||
|
warn!("OPENBAO_STORE: failed to persist renewed token: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"OPENBAO_STORE: renew-self failed ({e}); keeping the validated \
|
||||||
|
cached token as-is"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Self::with_token(
|
return Self::with_token(
|
||||||
&base_url,
|
&base_url,
|
||||||
skip_tls,
|
skip_tls,
|
||||||
@@ -267,7 +291,16 @@ impl OpenbaoSecretStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate if a token is still valid using vaultrs
|
/// Validate that the cached token is still usable.
|
||||||
|
///
|
||||||
|
/// Uses `auth/token/lookup-self`, NOT `auth/token/lookup`. The
|
||||||
|
/// latter looks up an arbitrary token and requires a privileged
|
||||||
|
/// policy (sudo on `auth/token/lookup`) — our SSO-derived tokens
|
||||||
|
/// carry only `default` + `harmony-dev`, so `lookup` always 403s
|
||||||
|
/// and made every cached token look invalid, forcing a needless
|
||||||
|
/// OIDC re-exchange on every run (and skipping `renew_self`).
|
||||||
|
/// `lookup-self` is granted to every token by the `default`
|
||||||
|
/// policy, so it correctly reports whether the token is live.
|
||||||
async fn validate_token(base_url: &str, skip_tls: bool, token: &str) -> bool {
|
async fn validate_token(base_url: &str, skip_tls: bool, token: &str) -> bool {
|
||||||
let mut settings = VaultClientSettingsBuilder::default();
|
let mut settings = VaultClientSettingsBuilder::default();
|
||||||
settings.address(base_url).token(token);
|
settings.address(base_url).token(token);
|
||||||
@@ -280,11 +313,66 @@ impl OpenbaoSecretStore {
|
|||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(_) => return false,
|
Err(_) => return false,
|
||||||
};
|
};
|
||||||
return vaultrs::token::lookup(&client, token).await.is_ok();
|
return vaultrs::token::lookup_self(&client).await.is_ok();
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask OpenBao to extend the token's lease via
|
||||||
|
/// `/v1/auth/token/renew-self`, returning the new
|
||||||
|
/// `lease_duration` in seconds. ADR 020-1 motivates this:
|
||||||
|
/// without periodic self-renewal the client token expires at
|
||||||
|
/// its initial TTL (4h by default) and the caller falls back
|
||||||
|
/// to a fresh OIDC device flow on the next run — i.e. the
|
||||||
|
/// operator goes back to the browser. With renewal the token
|
||||||
|
/// rolls forward until the role's `max_ttl` (24h), at which
|
||||||
|
/// point the OIDC refresh-token chain takes over.
|
||||||
|
///
|
||||||
|
/// Hand-rolled via `reqwest` rather than `vaultrs` because
|
||||||
|
/// vaultrs 0.7.4 doesn't expose a typed renew-self helper
|
||||||
|
/// scoped to our auth method; the raw call is the same shape
|
||||||
|
/// as `ZitadelOidcAuth::exchange_jwt_for_openbao_token`.
|
||||||
|
async fn renew_self(base_url: &str, skip_tls: bool, token: &str) -> Result<u64, String> {
|
||||||
|
let mut builder = reqwest::Client::builder();
|
||||||
|
if skip_tls {
|
||||||
|
builder = builder.danger_accept_invalid_certs(true);
|
||||||
|
}
|
||||||
|
let client = builder
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/v1/auth/token/renew-self",
|
||||||
|
base_url.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
let response = client
|
||||||
|
.post(&url)
|
||||||
|
.header("X-Vault-Token", token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("renew-self request to {url} failed: {e}"))?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read renew-self body ({status}): {e}"))?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("renew-self returned {status}: {body}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&body)
|
||||||
|
.map_err(|e| format!("Failed to parse renew-self response: {e} — body={body}"))?;
|
||||||
|
let lease_duration = parsed
|
||||||
|
.get("auth")
|
||||||
|
.and_then(|a| a.get("lease_duration"))
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.ok_or_else(|| format!("renew-self response missing auth.lease_duration: {body}"))?;
|
||||||
|
|
||||||
|
Ok(lease_duration)
|
||||||
|
}
|
||||||
|
|
||||||
/// Authenticate using username/password (userpass auth method)
|
/// Authenticate using username/password (userpass auth method)
|
||||||
async fn authenticate_userpass(
|
async fn authenticate_userpass(
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use log::{debug, info};
|
use log::{debug, info, warn};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -26,18 +26,6 @@ impl OidcSession {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_openbao_token_expired(&self, _ttl: u64) -> bool {
|
|
||||||
if let Some(expires_at) = self.expires_at {
|
|
||||||
let now = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_secs() as i64)
|
|
||||||
.unwrap_or(0);
|
|
||||||
expires_at <= now
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -69,14 +57,15 @@ struct TokenErrorResponse {
|
|||||||
error_description: Option<String>,
|
error_description: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_session_cache_path() -> PathBuf {
|
/// Build the per-instance cache path for an OIDC session.
|
||||||
let hash = {
|
///
|
||||||
use std::collections::hash_map::DefaultHasher;
|
/// Previously this hashed the literal string `"zitadel-oidc"` — which
|
||||||
use std::hash::{Hash, Hasher};
|
/// meant every Zitadel instance on the machine wrote to the same file
|
||||||
let mut hasher = DefaultHasher::new();
|
/// and the second login would silently overwrite the first. Hashing
|
||||||
"zitadel-oidc".hash(&mut hasher);
|
/// `sso_url + client_id` instead gives each (Zitadel host, OAuth app)
|
||||||
format!("{:016x}", hasher.finish())
|
/// pair its own cache file so multiple installs coexist.
|
||||||
};
|
fn get_session_cache_path(sso_url: &str, client_id: &str) -> PathBuf {
|
||||||
|
let hash = hash_session_key(sso_url, client_id);
|
||||||
directories::BaseDirs::new()
|
directories::BaseDirs::new()
|
||||||
.map(|dirs| {
|
.map(|dirs| {
|
||||||
dirs.data_dir()
|
dirs.data_dir()
|
||||||
@@ -87,8 +76,19 @@ fn get_session_cache_path() -> PathBuf {
|
|||||||
.unwrap_or_else(|| PathBuf::from(format!("/tmp/oidc_session_{hash}")))
|
.unwrap_or_else(|| PathBuf::from(format!("/tmp/oidc_session_{hash}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_session() -> Result<OidcSession, String> {
|
fn hash_session_key(sso_url: &str, client_id: &str) -> String {
|
||||||
let path = get_session_cache_path();
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
sso_url.hash(&mut hasher);
|
||||||
|
// Separator so ("abc", "def") and ("ab", "cdef") don't collide.
|
||||||
|
0u8.hash(&mut hasher);
|
||||||
|
client_id.hash(&mut hasher);
|
||||||
|
format!("{:016x}", hasher.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_session(sso_url: &str, client_id: &str) -> Result<OidcSession, String> {
|
||||||
|
let path = get_session_cache_path(sso_url, client_id);
|
||||||
serde_json::from_str(
|
serde_json::from_str(
|
||||||
&fs::read_to_string(&path)
|
&fs::read_to_string(&path)
|
||||||
.map_err(|e| format!("Could not load session from {path:?}: {e}"))?,
|
.map_err(|e| format!("Could not load session from {path:?}: {e}"))?,
|
||||||
@@ -96,8 +96,8 @@ fn load_session() -> Result<OidcSession, String> {
|
|||||||
.map_err(|e| format!("Could not deserialize session from {path:?}: {e}"))
|
.map_err(|e| format!("Could not deserialize session from {path:?}: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save_session(session: &OidcSession) -> Result<(), String> {
|
fn save_session(sso_url: &str, client_id: &str, session: &OidcSession) -> Result<(), String> {
|
||||||
let path = get_session_cache_path();
|
let path = get_session_cache_path(sso_url, client_id);
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent)
|
fs::create_dir_all(parent)
|
||||||
.map_err(|e| format!("Could not create session directory: {e}"))?;
|
.map_err(|e| format!("Could not create session directory: {e}"))?;
|
||||||
@@ -162,12 +162,28 @@ impl ZitadelOidcAuth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn authenticate(&self) -> Result<OidcSession, String> {
|
pub async fn authenticate(&self) -> Result<OidcSession, String> {
|
||||||
if let Ok(session) = load_session()
|
if let Ok(session) = load_session(&self.sso_url, &self.client_id) {
|
||||||
&& !session.is_expired()
|
if !session.is_expired() {
|
||||||
{
|
|
||||||
info!("ZITADEL_OIDC: Using cached session");
|
info!("ZITADEL_OIDC: Using cached session");
|
||||||
return Ok(session);
|
return Ok(session);
|
||||||
}
|
}
|
||||||
|
if let Some(refresh_token) = session.refresh_token.clone() {
|
||||||
|
info!("ZITADEL_OIDC: Session expired, attempting silent refresh");
|
||||||
|
match self.silent_refresh(&refresh_token).await {
|
||||||
|
Ok(refreshed) => {
|
||||||
|
if let Err(e) = save_session(&self.sso_url, &self.client_id, &refreshed) {
|
||||||
|
warn!("ZITADEL_OIDC: Failed to persist refreshed session: {e}");
|
||||||
|
}
|
||||||
|
return Ok(refreshed);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"ZITADEL_OIDC: Silent refresh failed ({e}), falling back to device flow"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
info!("ZITADEL_OIDC: Starting device authorization flow");
|
info!("ZITADEL_OIDC: Starting device authorization flow");
|
||||||
|
|
||||||
@@ -176,11 +192,59 @@ impl ZitadelOidcAuth {
|
|||||||
let token_response = self
|
let token_response = self
|
||||||
.poll_for_token(&device_code, device_code.interval)
|
.poll_for_token(&device_code, device_code.interval)
|
||||||
.await?;
|
.await?;
|
||||||
let session = self.process_token_response(token_response).await?;
|
let session = self.process_token_response(token_response, None).await?;
|
||||||
let _ = save_session(&session);
|
let _ = save_session(&self.sso_url, &self.client_id, &session);
|
||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Use the OIDC `refresh_token` to obtain a new `id_token` from Zitadel
|
||||||
|
/// without prompting the user, then exchange it for a fresh OpenBao token
|
||||||
|
/// via the same JWT path the device flow uses.
|
||||||
|
///
|
||||||
|
/// If Zitadel does not rotate the refresh token (i.e. the refresh
|
||||||
|
/// response omits `refresh_token`), we preserve the previous one so the
|
||||||
|
/// next refresh can still proceed.
|
||||||
|
async fn silent_refresh(&self, refresh_token: &str) -> Result<OidcSession, String> {
|
||||||
|
let token_response = self.refresh_id_token(refresh_token).await?;
|
||||||
|
self.process_token_response(token_response, Some(refresh_token.to_string()))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_id_token(&self, refresh_token: &str) -> Result<TokenResponse, String> {
|
||||||
|
let client = self.http_client()?;
|
||||||
|
let params = [
|
||||||
|
("grant_type", "refresh_token"),
|
||||||
|
("refresh_token", refresh_token),
|
||||||
|
("client_id", self.client_id.as_str()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(format!("{}/oauth/v2/token", self.sso_url))
|
||||||
|
.form(¶ms)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Refresh token request failed: {e}"))?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read refresh response body: {e}"))?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
if let Ok(error) = serde_json::from_str::<TokenErrorResponse>(&body) {
|
||||||
|
return Err(format!(
|
||||||
|
"Refresh failed: {} - {}",
|
||||||
|
error.error,
|
||||||
|
error.error_description.unwrap_or_default()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Err(format!("Refresh failed with HTTP {status}: {body}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_str(&body).map_err(|e| format!("Failed to parse refresh response: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
fn http_client(&self) -> Result<reqwest::Client, String> {
|
fn http_client(&self) -> Result<reqwest::Client, String> {
|
||||||
let mut builder = reqwest::Client::builder();
|
let mut builder = reqwest::Client::builder();
|
||||||
if self.skip_tls {
|
if self.skip_tls {
|
||||||
@@ -208,22 +272,42 @@ impl ZitadelOidcAuth {
|
|||||||
|
|
||||||
async fn request_device_code(&self) -> Result<DeviceAuthorizationResponse, String> {
|
async fn request_device_code(&self) -> Result<DeviceAuthorizationResponse, String> {
|
||||||
let client = self.http_client()?;
|
let client = self.http_client()?;
|
||||||
|
let url = format!("{}/oauth/v2/device_authorization", self.sso_url);
|
||||||
let params = [
|
let params = [
|
||||||
("client_id", self.client_id.as_str()),
|
("client_id", self.client_id.as_str()),
|
||||||
("scope", "openid email profile offline_access"),
|
("scope", "openid email profile offline_access"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
debug!("ZITADEL_OIDC: POST {url} (client_id={})", self.client_id);
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.post(format!("{}/oauth/v2/device_authorization", self.sso_url))
|
.post(&url)
|
||||||
.form(¶ms)
|
.form(¶ms)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Device authorization request failed: {e}"))?;
|
.map_err(|e| format!("Device authorization request to {url} failed: {e}"))?;
|
||||||
|
|
||||||
response
|
let status = response.status();
|
||||||
.json::<DeviceAuthorizationResponse>()
|
let body = response.text().await.map_err(|e| {
|
||||||
.await
|
format!("Failed to read device authorization response body ({status}): {e}")
|
||||||
.map_err(|e| format!("Failed to parse device authorization response: {e}"))
|
})?;
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
// Surface the actual server response — without this, every
|
||||||
|
// failure looks like "error decoding response body" which is
|
||||||
|
// a parser-level error that hides the real cause (most often
|
||||||
|
// "client not registered for device-code grant").
|
||||||
|
return Err(format!(
|
||||||
|
"Device authorization endpoint {url} returned {status}: {body}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_str::<DeviceAuthorizationResponse>(&body).map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"Failed to parse device authorization response from {url} \
|
||||||
|
(HTTP {status}): {e}. Body was: {body}"
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_verification_instructions(&self, code: &DeviceAuthorizationResponse) {
|
fn print_verification_instructions(&self, code: &DeviceAuthorizationResponse) {
|
||||||
@@ -240,6 +324,21 @@ impl ZitadelOidcAuth {
|
|||||||
}
|
}
|
||||||
println!("=================================================");
|
println!("=================================================");
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
|
// Best-effort: try to launch the operator's default browser
|
||||||
|
// directly at the device-code page. We prefer the
|
||||||
|
// verification_uri_complete (code pre-filled, one less
|
||||||
|
// copy-paste). On failure — headless host, no $BROWSER,
|
||||||
|
// running over plain SSH, ENOENT on xdg-open — the URL is
|
||||||
|
// already printed above so the operator can copy it
|
||||||
|
// manually. Don't gate the flow on the open succeeding.
|
||||||
|
let target = code
|
||||||
|
.verification_uri_complete
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(code.verification_uri.as_str());
|
||||||
|
if let Err(e) = webbrowser::open(target) {
|
||||||
|
warn!("ZITADEL_OIDC: could not auto-open browser ({e}); copy the URL above manually");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn poll_for_token(
|
async fn poll_for_token(
|
||||||
@@ -374,7 +473,11 @@ impl ZitadelOidcAuth {
|
|||||||
Ok((client_token, lease_duration, renewable))
|
Ok((client_token, lease_duration, renewable))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn process_token_response(&self, response: TokenResponse) -> Result<OidcSession, String> {
|
async fn process_token_response(
|
||||||
|
&self,
|
||||||
|
response: TokenResponse,
|
||||||
|
previous_refresh_token: Option<String>,
|
||||||
|
) -> Result<OidcSession, String> {
|
||||||
let (openbao_token, ttl, renewable) = if self.openbao_url.is_some() {
|
let (openbao_token, ttl, renewable) = if self.openbao_url.is_some() {
|
||||||
let id_token = response
|
let id_token = response
|
||||||
.id_token
|
.id_token
|
||||||
@@ -394,11 +497,16 @@ impl ZitadelOidcAuth {
|
|||||||
.map(|d| d.as_secs() as i64)
|
.map(|d| d.as_secs() as i64)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// OAuth allows refresh-token rotation to be optional. If the IdP
|
||||||
|
// omits a new refresh_token in the response, keep the previous one
|
||||||
|
// so the next refresh attempt can still proceed.
|
||||||
|
let refresh_token = response.refresh_token.or(previous_refresh_token);
|
||||||
|
|
||||||
Ok(OidcSession {
|
Ok(OidcSession {
|
||||||
openbao_token,
|
openbao_token,
|
||||||
openbao_token_ttl: ttl,
|
openbao_token_ttl: ttl,
|
||||||
openbao_renewable: renewable,
|
openbao_renewable: renewable,
|
||||||
refresh_token: response.refresh_token,
|
refresh_token,
|
||||||
id_token: response.id_token,
|
id_token: response.id_token,
|
||||||
expires_at: Some(now + ttl as i64),
|
expires_at: Some(now + ttl as i64),
|
||||||
})
|
})
|
||||||
@@ -436,4 +544,35 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert!(!session2.is_expired());
|
assert!(!session2.is_expired());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_hash_is_stable_for_same_input() {
|
||||||
|
let a = hash_session_key("https://sso.example.com", "client-abc");
|
||||||
|
let b = hash_session_key("https://sso.example.com", "client-abc");
|
||||||
|
assert_eq!(a, b);
|
||||||
|
assert_eq!(a.len(), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_hash_differs_per_sso_url() {
|
||||||
|
let a = hash_session_key("https://sso.a.com", "shared-client");
|
||||||
|
let b = hash_session_key("https://sso.b.com", "shared-client");
|
||||||
|
assert_ne!(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_hash_differs_per_client_id() {
|
||||||
|
let a = hash_session_key("https://sso.example.com", "client-a");
|
||||||
|
let b = hash_session_key("https://sso.example.com", "client-b");
|
||||||
|
assert_ne!(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_hash_separator_prevents_concatenation_collision() {
|
||||||
|
// Without the null-byte separator, ("ab", "cd") and ("abc", "d")
|
||||||
|
// would hash identically. The separator keeps the inputs distinct.
|
||||||
|
let a = hash_session_key("ab", "cd");
|
||||||
|
let b = hash_session_key("abc", "d");
|
||||||
|
assert_ne!(a, b);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user