One authority answers 'which credential may ride this request': credential_class / manager_for / credential_for / bearer_resolver_for, keyed by (platform, base_url). SessionCredential is an opaque type with no production constructor, so a new call site cannot re-introduce the session-bearer leak. Platform-scoped tests extended across all bearer channels (session, aux, summary, subagent override). Verified: cargo check --workspace --all-targets clean; kigi-shell and kigi-tui suites green (6611+ tests).
194 lines
9.2 KiB
Rust
194 lines
9.2 KiB
Rust
//! Process-global per-provider OAuth `AuthManager` pool for INFERENCE-time auth.
|
|
//!
|
|
//! A session binds its primary (Kimi / first-party) [`AuthManager`] for the
|
|
//! subscription path, but a `uses_oauth` platform that carries an
|
|
//! [`kigi_models::OAuthConfig`] (xai-grok today) needs its OWN scope-keyed
|
|
//! manager for every per-turn decision — bearer resolution, proactive /
|
|
//! on-expiry refresh, and 401 recovery. Reusing the Kimi manager for a grok
|
|
//! turn would transmit the Kimi subscription bearer to `api.x.ai` (a
|
|
//! cross-provider leak, guaranteed 401) and, without proactive refresh, would
|
|
//! 401 every turn once the ~1h grok token expired until a process restart.
|
|
//!
|
|
//! The pool is the SINGLE SOURCE OF TRUTH: one long-lived `AuthManager` per
|
|
//! generic-oauth scope, each wired with the SAME lifecycle as the primary Kimi
|
|
//! manager (`configure_refresher()` + `start_proactive_refresh()`) so the
|
|
//! on-disk token stays fresh and a 401 recovers via the provider's own manager.
|
|
//! Managers are built ON DEMAND from the on-disk token ([`global_manager_for`]),
|
|
//! so a login landing AFTER a session spawned self-heals — no frozen per-session
|
|
//! snapshot.
|
|
//!
|
|
//! ROUTING LIVES ELSEWHERE. This module is only the pool; the decision of which
|
|
//! credential governs a request belongs to the single chokepoint,
|
|
//! [`crate::auth::credential_authority::CredentialAuthority`]. Keeping the two
|
|
//! apart is deliberate: three rounds of leaks came from routing rules being
|
|
//! re-derived per call site.
|
|
//!
|
|
//! SECURITY: access/refresh tokens and resolved bearers are NEVER logged here.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::Path;
|
|
use std::sync::{Arc, OnceLock};
|
|
|
|
use parking_lot::Mutex;
|
|
|
|
use crate::auth::AuthManager;
|
|
|
|
/// Process-wide pool of live per-scope OAuth managers.
|
|
///
|
|
/// Auth is process-global (one user), so a single manager per scope is correct
|
|
/// and lets the proactive-refresh task start exactly once per scope no matter
|
|
/// how many sessions spawn. Keyed by the OAuth `scope_key` (`oauth/xai`, …).
|
|
fn oauth_manager_pool() -> &'static Mutex<HashMap<&'static str, Arc<AuthManager>>> {
|
|
static POOL: OnceLock<Mutex<HashMap<&'static str, Arc<AuthManager>>>> = OnceLock::new();
|
|
POOL.get_or_init(|| Mutex::new(HashMap::new()))
|
|
}
|
|
|
|
/// The kigi home EVERY OAuth-provider construction in this crate resolves from
|
|
/// — the pool, the catalog fetch's per-platform token resolution, the
|
|
/// aux/summary token routing and the session's inference manager — so they can
|
|
/// never read different homes.
|
|
///
|
|
/// Production: [`crate::util::kigi_home::kigi_home`]. LIB TESTS: a
|
|
/// per-process path under the system temp dir that is deliberately **never
|
|
/// created**. The pool is process-global and every manager it builds starts a
|
|
/// never-cancelled proactive-refresh loop, so a unit test resolving the real
|
|
/// `~/.kigi` would read the developer's stored OAuth tokens and, 60 s later,
|
|
/// fire REAL refresh requests against them. Deliberately not a per-test opt-in
|
|
/// that can be forgotten: `kigi_home()` is itself a `OnceLock` an earlier test
|
|
/// has usually already resolved to the real home, so setting `KIGI_SHARE_DIR`
|
|
/// in a test cannot pin it after the fact.
|
|
///
|
|
/// M4 — THE LIMIT, STATED: `cfg(test)` is set only for THIS crate's `--lib`
|
|
/// tests. `crates/codegen/kigi-shell/tests/*.rs` link the library built WITHOUT
|
|
/// it, so for an integration test this resolves the real home unless that test
|
|
/// binary itself isolates one, which it must do through the two overrides the
|
|
/// auth stack already honours and BEFORE anything resolves `kigi_home()`:
|
|
/// `KIGI_SHARE_DIR` (read by `kigi_home()`, a `OnceLock`) or `KIGI_AUTH_PATH`
|
|
/// (read by [`AuthManager::new_oauth_provider`], which pins the token file
|
|
/// outright and so overrides this home entirely). 12 of the 28 integration
|
|
/// binaries under `crates/codegen/kigi-shell/tests/` set `KIGI_SHARE_DIR`; the
|
|
/// other 16 never reach an OAuth-platform inference path today, which is a
|
|
/// property of those tests, not a guarantee of this function. No
|
|
/// production-readable env override is added here on purpose: a knob that
|
|
/// redirects where OAuth tokens are read from is not worth a test convenience.
|
|
///
|
|
/// M8: this used to be a `static OnceLock<TempDir>`. Statics are never dropped,
|
|
/// so that leaked one temp directory per test binary — against the project's
|
|
/// "tests are TempDir self-cleaning" discipline. Nothing is created here
|
|
/// instead, and nothing in the lib-test suite creates it: a manager reads a
|
|
/// missing `auth.json` as "no session", and the only two paths that WRITE one
|
|
/// are a successful token refresh (which needs a stored refresh token that by
|
|
/// construction does not exist here) and a completed device login
|
|
/// ([`crate::agent::mvp_agent::MvpAgent::authenticate_oauth_platform`], which
|
|
/// M4 repointed at this same home). Both require the network, so no lib test
|
|
/// performs either. That is an observation about the suite, not an invariant of
|
|
/// this function — [`tests::test_pool_home_is_disposable_and_never_the_real_home`]
|
|
/// asserts the directory does not exist and is the tripwire if one ever does
|
|
/// (the path is per-PROCESS under the system temp dir, so the blast radius of a
|
|
/// future login-driving test is one disposable directory, never `~/.kigi`).
|
|
pub(crate) fn pool_home() -> std::path::PathBuf {
|
|
#[cfg(test)]
|
|
{
|
|
std::env::temp_dir().join(format!("kigi-oauth-pool-test-{}", std::process::id()))
|
|
}
|
|
#[cfg(not(test))]
|
|
crate::util::kigi_home::kigi_home()
|
|
}
|
|
|
|
/// Get-or-create the process-global manager for `oauth`, wiring the same
|
|
/// refresher + proactive-refresh lifecycle as the primary Kimi manager the
|
|
/// FIRST time a scope is seen. The manager reads the on-disk token at
|
|
/// construction (thereafter kept fresh by the proactive-refresh loop), so a
|
|
/// grok login that lands after this scope was first built is adopted on the
|
|
/// manager's own refresh tick — no session ever needs re-spawning.
|
|
///
|
|
/// MUST be called from within a Tokio runtime (the proactive-refresh loop
|
|
/// spawns a task, mirroring the primary).
|
|
pub(crate) fn global_manager_for(
|
|
kigi_home: &Path,
|
|
oauth: &'static kigi_models::OAuthConfig,
|
|
) -> Arc<AuthManager> {
|
|
let mut pool = oauth_manager_pool().lock();
|
|
if let Some(existing) = pool.get(oauth.scope_key) {
|
|
return existing.clone();
|
|
}
|
|
let manager = Arc::new(AuthManager::new_oauth_provider(kigi_home, oauth));
|
|
manager.configure_refresher();
|
|
// Never-cancelled token = process-lifetime, matching the api-server /
|
|
// per-session eager-refresh sites that pass a fresh token.
|
|
manager.start_proactive_refresh(tokio_util::sync::CancellationToken::new());
|
|
pool.insert(oauth.scope_key, manager.clone());
|
|
manager
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn oauth_for(id: &str) -> &'static kigi_models::OAuthConfig {
|
|
kigi_models::PlatformId::parse(id)
|
|
.expect("known platform")
|
|
.oauth()
|
|
.expect("subscription-OAuth platform carries an OAuthConfig")
|
|
}
|
|
|
|
/// Each subscription-OAuth platform gets its OWN process-global pooled
|
|
/// manager, and no two share one. (Which credential governs a REQUEST is
|
|
/// not decided here — see
|
|
/// [`crate::auth::credential_authority::CredentialAuthority`].)
|
|
#[tokio::test]
|
|
async fn every_oauth_scope_gets_its_own_pooled_manager() {
|
|
let home = tempfile::tempdir().unwrap();
|
|
let ids = [
|
|
"xai-grok",
|
|
"claude-pro-max",
|
|
"github-copilot",
|
|
"openai-codex",
|
|
];
|
|
let managers: Vec<_> = ids
|
|
.iter()
|
|
.map(|id| global_manager_for(home.path(), oauth_for(id)))
|
|
.collect();
|
|
for (i, a) in managers.iter().enumerate() {
|
|
assert!(
|
|
Arc::ptr_eq(a, &global_manager_for(home.path(), oauth_for(ids[i]))),
|
|
"{}: the pool must return the SAME manager for a scope",
|
|
ids[i]
|
|
);
|
|
for (j, b) in managers.iter().enumerate() {
|
|
if i != j {
|
|
assert!(
|
|
!Arc::ptr_eq(a, b),
|
|
"{} and {} must not share a pooled manager",
|
|
ids[i],
|
|
ids[j]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// M8: the test pool home is a per-process path that is never created, so a
|
|
/// test binary leaves nothing behind (and never resolves the developer's
|
|
/// real `~/.kigi`, whose stored OAuth tokens the pool would otherwise read
|
|
/// and proactively refresh over the network).
|
|
///
|
|
/// This is also the tripwire for the cleanup claim in [`pool_home`]: a
|
|
/// completed device login through `authenticate_oauth_platform` WOULD create
|
|
/// this directory, so if a lib test ever drives one, this assertion fires
|
|
/// and the cleanup has to be added rather than silently regressing the
|
|
/// "tests are TempDir self-cleaning" discipline.
|
|
#[test]
|
|
fn test_pool_home_is_disposable_and_never_the_real_home() {
|
|
let home = pool_home();
|
|
assert!(
|
|
home.starts_with(std::env::temp_dir()),
|
|
"the test pool home must live under the system temp dir, got {home:?}"
|
|
);
|
|
assert!(
|
|
!home.exists(),
|
|
"the test pool home must not be created — nothing to clean up"
|
|
);
|
|
}
|
|
}
|