feat(harmony_secret): SSO auth hardening — silent refresh, renewal, namespacing #302

Merged
johnride merged 1 commits from pr/harmony-secret-auth into master 2026-05-29 15:11:53 +00:00
6 changed files with 344 additions and 76 deletions

1
Cargo.lock generated
View File

@@ -4472,6 +4472,7 @@ dependencies = [
"tokio", "tokio",
"url", "url",
"vaultrs", "vaultrs",
"webbrowser",
] ]
[[package]] [[package]]

View File

@@ -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

View File

@@ -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();
} }

View File

@@ -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(_) => {
namespace: ns.to_string(), // Fall back to the legacy flat file written by older
key: key.to_string(), // 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(),
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());

View File

@@ -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,

View File

@@ -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,11 +162,27 @@ 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(&params)
.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(&params) .form(&params)
.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);
}
} }