Changes: - Detect the herdr multiplexer - Mark /gboom as non-production code - Bound peak memory when loading a large session - Add a subagent lifecycle soak bounding threads, fds, and heap - Stream inherited replay to bound fork memory - Copy full plan from plan approval with y - Stop armed signature verification from deleting the managed-deny smoke policy - Add source-tagged terminal version telemetry - Show the UI instantly and fetch models and settings in the background - Session test helpers - computer_reason on the ConversationHistoryDone trailer
2072 lines
79 KiB
Rust
2072 lines
79 KiB
Rust
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
use std::sync::Arc;
|
|
|
|
use tokio::io::AsyncBufReadExt as _;
|
|
use tokio::sync::{mpsc, oneshot};
|
|
|
|
use crate::auth::config::LEGACY_AUTH_SCOPE;
|
|
use crate::auth::{AuthManager, GrokAuth, GrokComConfig, parse_output};
|
|
use crate::util::grok_home;
|
|
|
|
pub type StderrCallback = Box<dyn Fn(&str)>;
|
|
|
|
/// Reject a cached credential for reuse if it lacks `oidc_issuer`, has a
|
|
/// mismatched issuer, or its team principal violates the `force_login_team_uuid`
|
|
/// pin — so interactive login starts fresh instead of reusing a stale/wrong-team
|
|
/// session.
|
|
fn is_cached_credential_compatible(auth: &GrokAuth, grok_com_config: &GrokComConfig) -> bool {
|
|
let expected_issuer = grok_com_config
|
|
.oidc
|
|
.as_ref()
|
|
.map(|c| c.issuer.as_str())
|
|
.or_else(|| grok_com_config.oauth2.as_ref().map(|c| c.issuer.as_str()));
|
|
let issuer_compatible = match (auth.oidc_issuer.as_deref(), expected_issuer) {
|
|
(Some(actual), Some(expected)) => actual == expected,
|
|
(None, Some(_)) => false,
|
|
_ => true,
|
|
};
|
|
if !issuer_compatible {
|
|
return false;
|
|
}
|
|
if let Some(policy) = crate::auth::oidc::login_principal_policy(grok_com_config) {
|
|
let actual = crate::auth::oidc::peek_access_token_principal_id(&auth.key);
|
|
if crate::auth::oidc::enforce_login_principal(Some(&policy), actual.as_deref()).is_err() {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
/// CLI-flag override for the interactive login transport.
|
|
///
|
|
/// `--oauth` forces the loopback-callback flow; `--device-auth` forces the
|
|
/// device flow. `None` falls through to env / config / default.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum LoginTransportOverride {
|
|
/// No CLI override — resolve from env / config / default.
|
|
#[default]
|
|
None,
|
|
/// `--oauth`: force the loopback-callback flow.
|
|
ForceLoopback,
|
|
/// `--device-auth`: force the RFC 8628 device flow.
|
|
ForceDevice,
|
|
/// Transport already resolved (and logged) upstream; the inner flow honors
|
|
/// the carried value (`true` = device, `false` = loopback) without
|
|
/// re-resolving, so it's never re-logged or mis-attributed to `cli`.
|
|
Preresolved(bool),
|
|
}
|
|
|
|
impl LoginTransportOverride {
|
|
/// Resolve from the `--oauth` / `--device-auth` flags. `--oauth` wins if
|
|
/// both are somehow set. Single source of truth for both the CLI
|
|
/// (`run_cli_login`) and ACP (`AuthRequestMeta`) entry points.
|
|
pub fn from_flags(force_loopback: bool, force_device: bool) -> Self {
|
|
if force_loopback {
|
|
Self::ForceLoopback
|
|
} else if force_device {
|
|
Self::ForceDevice
|
|
} else {
|
|
Self::None
|
|
}
|
|
}
|
|
|
|
/// Map to a `BoolFlag` CLI value (`Some(true)` = device, `Some(false)` =
|
|
/// loopback, `None` = no override).
|
|
fn as_cli_bool(self) -> Option<bool> {
|
|
match self {
|
|
Self::None => None,
|
|
Self::ForceLoopback => Some(false),
|
|
Self::ForceDevice => Some(true),
|
|
// Not a CLI decision — must never be reported as the `cli` tier.
|
|
Self::Preresolved(_) => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `[auth] login_device_flow` from a config snapshot (shared with the proxy-URL read).
|
|
fn config_login_device_flow(effective: Option<&toml::Value>) -> Option<bool> {
|
|
effective.and_then(|cfg| cfg.get("auth")?.get("login_device_flow")?.as_bool())
|
|
}
|
|
|
|
/// Device-flow precedence: CLI > env > config > remote feature flag > loopback.
|
|
/// Returns the deciding tier so the caller can log which one chose the transport.
|
|
fn resolve_device_flow(
|
|
login_override: LoginTransportOverride,
|
|
config: Option<bool>,
|
|
remote: Option<bool>,
|
|
) -> crate::agent::config::Resolved<bool> {
|
|
crate::agent::config::BoolFlag::env("GROK_LOGIN_DEVICE_FLOW")
|
|
.cli(login_override.as_cli_bool())
|
|
.config(config)
|
|
.feature_flag(remote)
|
|
.default(false)
|
|
.resolve()
|
|
}
|
|
|
|
/// Whether `run_cli_login` should use the device flow for `config`: only the
|
|
/// xAI OAuth2 provider supports it. Enterprise OIDC (`oidc=Some`) always uses
|
|
/// the loopback flow, mirroring `run_auth_flow_inner`'s precedence.
|
|
async fn cli_should_use_device(
|
|
config: &GrokComConfig,
|
|
login_override: LoginTransportOverride,
|
|
) -> bool {
|
|
!crate::auth::oidc::is_configured(config) && should_use_device_flow(login_override).await
|
|
}
|
|
|
|
/// Whether interactive xAI OAuth2 login uses the RFC 8628 device flow (vs loopback).
|
|
///
|
|
/// Precedence: CLI (`--oauth`/`--device-auth`) > `GROK_LOGIN_DEVICE_FLOW` env >
|
|
/// `[auth] login_device_flow` config > `grok_build_login_device_flow` remote feature flag > loopback.
|
|
async fn should_use_device_flow(login_override: LoginTransportOverride) -> bool {
|
|
// Already resolved (and logged) upstream — honor it without re-resolving or
|
|
// emitting a second transport log.
|
|
if let LoginTransportOverride::Preresolved(use_device) = login_override {
|
|
return use_device;
|
|
}
|
|
let resolved = if login_override.as_cli_bool().is_some() {
|
|
// CLI flag wins outright, so skip the config load and the remote settings fetch.
|
|
resolve_device_flow(login_override, None, None)
|
|
} else {
|
|
// Read once to gate the fetch; resolve_device_flow reads it again for the decision.
|
|
let env = crate::agent::config::env_bool("GROK_LOGIN_DEVICE_FLOW");
|
|
// One config snapshot feeds both the `[auth]` tier and the proxy URL.
|
|
let effective = crate::config::load_effective_config().ok();
|
|
let config = config_login_device_flow(effective.as_ref());
|
|
// Only hit remote settings when env/config haven't already pinned the transport.
|
|
let remote = if env.is_none() && config.is_none() {
|
|
let proxy_url = effective
|
|
.as_ref()
|
|
.map(crate::agent::config::EndpointsConfig::from_config_value)
|
|
.unwrap_or_default()
|
|
.proxy_url();
|
|
// Bound the whole fetch — including the one-time agent_id lookup — so a
|
|
// slow/hung agent_id or proxy can never stall login; time out to loopback.
|
|
tokio::time::timeout(
|
|
std::time::Duration::from_secs(2),
|
|
crate::remote::fetch_login_device_flow(&proxy_url),
|
|
)
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
} else {
|
|
None
|
|
};
|
|
resolve_device_flow(login_override, config, remote)
|
|
};
|
|
tracing::info!(
|
|
transport = if resolved.value { "device" } else { "loopback" },
|
|
source = %resolved.source,
|
|
"login: resolved interactive transport",
|
|
);
|
|
resolved.value
|
|
}
|
|
|
|
/// How login presents itself; surfaced to the TUI via `x.ai/auth/get_url`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum AuthUrlMode {
|
|
/// Loopback-callback flow — TUI shows a copyable URL + paste box.
|
|
Loopback,
|
|
/// External auth provider opened its own browser — TUI shows a waiting status.
|
|
Command,
|
|
/// RFC 8628 device flow — TUI shows the device code + copyable URL, no paste box.
|
|
Device,
|
|
}
|
|
|
|
impl AuthUrlMode {
|
|
/// Wire string for the `x.ai/auth/get_url` ACP response.
|
|
pub fn as_wire_str(self) -> &'static str {
|
|
match self {
|
|
Self::Loopback => "loopback",
|
|
Self::Command => "command",
|
|
Self::Device => "device",
|
|
}
|
|
}
|
|
|
|
/// Back-compat flag for older clients that only read `external_provider`.
|
|
pub fn is_external_provider(self) -> bool {
|
|
matches!(self, Self::Command)
|
|
}
|
|
}
|
|
|
|
/// Auth URL pushed from the auth flow to the TUI.
|
|
pub struct AuthUrlInfo {
|
|
pub url: String,
|
|
pub mode: AuthUrlMode,
|
|
}
|
|
|
|
/// Channels for interactive login between the auth flow and the TUI/extension.
|
|
pub struct AuthChannels {
|
|
pub url_tx: Option<oneshot::Sender<AuthUrlInfo>>,
|
|
pub code_rx: mpsc::Receiver<String>,
|
|
}
|
|
|
|
async fn run_external_auth_provider(
|
|
command: &str,
|
|
auth_manager: &Arc<AuthManager>,
|
|
is_refresh: bool,
|
|
on_stderr: Option<StderrCallback>,
|
|
) -> anyhow::Result<(GrokAuth, bool)> {
|
|
let inherit_stderr = on_stderr.is_none();
|
|
tracing::info!(
|
|
cmd = %command,
|
|
is_refresh,
|
|
inherit_stderr,
|
|
"auth: running external auth provider"
|
|
);
|
|
|
|
let mut cmd = tokio::process::Command::new("sh");
|
|
cmd.args(["-c", command])
|
|
.stdin(std::process::Stdio::null())
|
|
.stdout(std::process::Stdio::piped())
|
|
.kill_on_drop(true);
|
|
// TODO: `kill_on_drop` SIGKILLs only the direct `sh` child; a provider that
|
|
// backgrounds work (setsid / `&`) leaks the grandchild on shutdown-cancel.
|
|
// Proper fix: pgid-kill via xai-tty-utils.
|
|
|
|
// TUI: pipe stderr and forward via callback — inherit would corrupt the
|
|
// alternate screen. CLI / headless: inherit so URLs and progress appear in
|
|
// real time; piping without a reader hides output and can deadlock the child.
|
|
if inherit_stderr {
|
|
cmd.stderr(std::process::Stdio::inherit());
|
|
} else {
|
|
cmd.stderr(std::process::Stdio::piped());
|
|
}
|
|
|
|
if is_refresh {
|
|
cmd.env("GROK_AUTH_EXPIRED", "1");
|
|
}
|
|
|
|
xai_grok_tools::util::detach_command(&mut cmd);
|
|
cmd.envs(xai_grok_tools::util::pager_env());
|
|
|
|
let mut child = cmd
|
|
.spawn()
|
|
.map_err(|e| anyhow::anyhow!("failed to start auth provider `{command}`: {e}"))?;
|
|
|
|
let stderr_task = if let Some(cb) = on_stderr {
|
|
let stderr = child.stderr.take().expect("stderr was set to piped");
|
|
Some(tokio::task::spawn_local(async move {
|
|
let mut reader = tokio::io::BufReader::new(stderr);
|
|
let mut line = String::new();
|
|
loop {
|
|
line.clear();
|
|
match reader.read_line(&mut line).await {
|
|
Ok(0) => break,
|
|
Ok(_) => {
|
|
let trimmed = line.trim_end();
|
|
tracing::debug!(line = trimmed, "auth: provider stderr");
|
|
cb(trimmed);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "auth: error reading provider stderr");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let output = tokio::time::timeout(
|
|
std::time::Duration::from_secs(300),
|
|
child.wait_with_output(),
|
|
)
|
|
.await
|
|
.map_err(|_| anyhow::anyhow!("external auth provider `{command}` timed out after 300s"))?
|
|
.map_err(|e| anyhow::anyhow!("external auth provider `{command}` IO error: {e}"))?;
|
|
|
|
if let Some(task) = stderr_task {
|
|
let _ = task.await;
|
|
}
|
|
|
|
let mut auth = parse_output(&output)
|
|
.map_err(|e| anyhow::anyhow!("external auth provider `{command}`: {e}"))?;
|
|
|
|
// Verify the team pin before any persist (parity with the OIDC / device-code
|
|
// completion paths). A mismatch fails the login and writes nothing.
|
|
let principal_policy =
|
|
crate::auth::oidc::login_principal_policy(auth_manager.grok_com_config());
|
|
crate::auth::oidc::enforce_login_principal(
|
|
principal_policy.as_ref(),
|
|
crate::auth::oidc::peek_access_token_principal_id(&auth.key).as_deref(),
|
|
)?;
|
|
|
|
// Token output has no profile; carry it forward, or fetch it when reauth cleared prev.
|
|
match (is_refresh, auth_manager.current_or_expired()) {
|
|
(true, Some(prev)) => auth.carry_user_profile_from(&prev),
|
|
_ => auth_manager.enrich_auth_inline(&mut auth).await,
|
|
}
|
|
|
|
let auth = auth_manager
|
|
.update(auth)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("failed to save external auth credentials: {e}"))?;
|
|
|
|
tracing::info!(
|
|
user_id = %auth.user_id,
|
|
email = ?auth.email,
|
|
"auth: external provider login complete"
|
|
);
|
|
|
|
Ok((auth, true))
|
|
}
|
|
|
|
/// GUI auth: bridges external provider stderr to `url_tx`, pipes code submission via `code_rx`.
|
|
pub async fn run_auth_flow_with_stderr_bridge(
|
|
auth_manager: &Arc<AuthManager>,
|
|
grok_com_config: &GrokComConfig,
|
|
channels: AuthChannels,
|
|
reauth: bool,
|
|
force_interactive: bool,
|
|
login_override: LoginTransportOverride,
|
|
) -> anyhow::Result<(GrokAuth, bool)> {
|
|
let url_tx = Rc::new(RefCell::new(channels.url_tx));
|
|
let stderr_lines: Rc<RefCell<Vec<String>>> = Rc::new(RefCell::new(Vec::new()));
|
|
|
|
let writer = stderr_lines.clone();
|
|
let on_stderr: StderrCallback = Box::new(move |line: &str| {
|
|
writer.borrow_mut().push(line.to_owned());
|
|
});
|
|
|
|
let reader = stderr_lines.clone();
|
|
let url_tx_bridge = url_tx.clone();
|
|
let bridge = async move {
|
|
loop {
|
|
tokio::task::yield_now().await;
|
|
let content = {
|
|
let lines = reader.borrow();
|
|
if lines.is_empty() {
|
|
None
|
|
} else {
|
|
Some(lines.join("\n"))
|
|
}
|
|
};
|
|
if let Some(joined) = content
|
|
&& let Some(tx) = url_tx_bridge.borrow_mut().take()
|
|
{
|
|
// The external binary may print preamble text alongside the
|
|
// URL (e.g. "Visit the following link to sign in: https://…").
|
|
// Extract just the first https:// URL so the TUI displays a
|
|
// clean, clickable link.
|
|
let url = joined
|
|
.split_whitespace()
|
|
.find(|w| w.starts_with("https://"))
|
|
.map(|u| u.to_owned())
|
|
.unwrap_or(joined);
|
|
let _ = tx.send(AuthUrlInfo {
|
|
url,
|
|
mode: AuthUrlMode::Command,
|
|
});
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
}
|
|
};
|
|
|
|
if force_interactive {
|
|
let auth = run_auth_flow_interactive(
|
|
auth_manager,
|
|
grok_com_config,
|
|
Some(on_stderr),
|
|
Some(url_tx),
|
|
Some(channels.code_rx),
|
|
login_override,
|
|
);
|
|
tokio::select! {
|
|
r = auth => r,
|
|
_ = bridge => {
|
|
tracing::error!("auth stderr bridge exited unexpectedly during interactive login");
|
|
Err(anyhow::anyhow!("Login failed. Please try again."))
|
|
},
|
|
}
|
|
} else {
|
|
let auth = run_auth_flow(
|
|
auth_manager,
|
|
grok_com_config,
|
|
reauth,
|
|
Some(on_stderr),
|
|
Some(url_tx),
|
|
Some(channels.code_rx),
|
|
login_override,
|
|
);
|
|
tokio::select! {
|
|
r = auth => r,
|
|
_ = bridge => {
|
|
tracing::error!("auth stderr bridge exited unexpectedly during login");
|
|
Err(anyhow::anyhow!("Login failed. Please try again."))
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Full auth chain: cache → refresh → external provider → interactive (OIDC/OAuth2/legacy).
|
|
/// When `url_tx` and `code_rx` are `None`, falls back to stderr/stdin (CLI mode).
|
|
pub async fn run_auth_flow(
|
|
auth_manager: &Arc<AuthManager>,
|
|
grok_com_config: &GrokComConfig,
|
|
reauth: bool,
|
|
on_stderr: Option<StderrCallback>,
|
|
url_tx: Option<Rc<RefCell<Option<oneshot::Sender<AuthUrlInfo>>>>>,
|
|
code_rx: Option<mpsc::Receiver<String>>,
|
|
login_override: LoginTransportOverride,
|
|
) -> anyhow::Result<(GrokAuth, bool)> {
|
|
run_auth_flow_inner(
|
|
auth_manager,
|
|
grok_com_config,
|
|
reauth,
|
|
false,
|
|
on_stderr,
|
|
url_tx,
|
|
code_rx,
|
|
login_override,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Like [`run_auth_flow`] but with `force_interactive`: skip cached
|
|
/// credentials without clearing them. Used by `/login` for mid-session
|
|
/// re-auth where abandoning the flow must not disrupt the session.
|
|
pub async fn run_auth_flow_interactive(
|
|
auth_manager: &Arc<AuthManager>,
|
|
grok_com_config: &GrokComConfig,
|
|
on_stderr: Option<StderrCallback>,
|
|
url_tx: Option<Rc<RefCell<Option<oneshot::Sender<AuthUrlInfo>>>>>,
|
|
code_rx: Option<mpsc::Receiver<String>>,
|
|
login_override: LoginTransportOverride,
|
|
) -> anyhow::Result<(GrokAuth, bool)> {
|
|
run_auth_flow_inner(
|
|
auth_manager,
|
|
grok_com_config,
|
|
false,
|
|
true,
|
|
on_stderr,
|
|
url_tx,
|
|
code_rx,
|
|
login_override,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn run_auth_flow_inner(
|
|
auth_manager: &Arc<AuthManager>,
|
|
grok_com_config: &GrokComConfig,
|
|
reauth: bool,
|
|
force_interactive: bool,
|
|
on_stderr: Option<StderrCallback>,
|
|
url_tx: Option<Rc<RefCell<Option<oneshot::Sender<AuthUrlInfo>>>>>,
|
|
code_rx: Option<mpsc::Receiver<String>>,
|
|
login_override: LoginTransportOverride,
|
|
) -> anyhow::Result<(GrokAuth, bool)> {
|
|
tracing::info!(
|
|
has_oidc = grok_com_config.oidc.is_some(),
|
|
has_oauth2 = grok_com_config.oauth2.is_some(),
|
|
has_external_auth = grok_com_config.auth_provider_command.is_some(),
|
|
reauth,
|
|
"auth: starting auth flow"
|
|
);
|
|
|
|
if reauth {
|
|
auth_manager.clear()?;
|
|
// Also remove the legacy accounts.x.ai scope so stale tokens
|
|
// don't linger alongside the fresh OIDC credential.
|
|
let _ = auth_manager.remove_scope(LEGACY_AUTH_SCOPE);
|
|
}
|
|
|
|
if !force_interactive && let Some(auth) = auth_manager.current() {
|
|
if is_cached_credential_compatible(&auth, grok_com_config) {
|
|
tracing::info!(auth_mode = ?auth.auth_mode, "auth: using cached credentials");
|
|
xai_grok_telemetry::unified_log::info(
|
|
"auth: using cached credentials",
|
|
None,
|
|
Some(serde_json::json!({ "auth_mode": format!("{:?}", auth.auth_mode) })),
|
|
);
|
|
return Ok((auth, false));
|
|
}
|
|
tracing::info!(
|
|
auth_mode = ?auth.auth_mode,
|
|
"auth: cached credential incompatible with requested flow, proceeding to interactive login"
|
|
);
|
|
// Remove the stale legacy credential from disk so it doesn't
|
|
// linger alongside the new OIDC entry after re-authentication.
|
|
if auth.auth_mode == super::AuthMode::WebLogin
|
|
&& let Err(e) = auth_manager.remove_scope(LEGACY_AUTH_SCOPE)
|
|
{
|
|
tracing::warn!(error = ?e, "auth: failed to remove legacy scope entry (non-fatal)");
|
|
}
|
|
}
|
|
|
|
if !force_interactive && !reauth && auth_manager.is_expired() {
|
|
// Acquire the cross-process file lock so we don't race with
|
|
// OidcRefresher instances in sibling processes. Without this,
|
|
// two processes can send the same refresh_token simultaneously,
|
|
// triggering IdP refresh-token-family revocation (reuse detection).
|
|
let _file_lock = auth_manager
|
|
.try_lock_auth_file_async(crate::auth::manager::AUTH_LOCK_TIMEOUT)
|
|
.await;
|
|
|
|
// Read disk first — another process may have already refreshed.
|
|
let disk_auth = auth_manager.read_disk_auth();
|
|
let disk_expired = disk_auth.as_ref().is_some_and(crate::auth::is_expired);
|
|
xai_grok_telemetry::unified_log::info(
|
|
"auth run_auth_flow expired path",
|
|
None,
|
|
Some(serde_json::json!({
|
|
"got_lock": _file_lock.is_some(),
|
|
"disk_found": disk_auth.is_some(),
|
|
"disk_expired": disk_expired,
|
|
})),
|
|
);
|
|
if disk_auth.as_ref().is_some_and(|d| {
|
|
!crate::auth::is_expired(d) && is_cached_credential_compatible(d, grok_com_config)
|
|
}) {
|
|
xai_grok_telemetry::unified_log::info(
|
|
"auth run_auth_flow using valid disk token",
|
|
None,
|
|
None,
|
|
);
|
|
let d = disk_auth.unwrap();
|
|
let ret = d.clone();
|
|
auth_manager.hot_swap(d);
|
|
return Ok((ret, false));
|
|
}
|
|
|
|
// Disk token not usable. Try the full auth() dispatcher which
|
|
// handles OIDC refresh, external binary, disk re-read — all
|
|
// through refresh_chain (single mutation point).
|
|
match auth_manager.auth().await {
|
|
Ok(fresh) => return Ok((fresh, false)),
|
|
Err(e) => {
|
|
// Defer to consumer-level refresh if disk has a refresh_token.
|
|
if let Some(d) = disk_auth.filter(|d| {
|
|
matches!(
|
|
&e,
|
|
crate::auth::error::AuthError::Refresh(
|
|
crate::auth::error::RefreshTokenError::Transient(_)
|
|
)
|
|
) && d.refresh_token.is_some()
|
|
}) {
|
|
xai_grok_telemetry::unified_log::warn(
|
|
"auth run_auth_flow refresh failed, deferring to consumer refresh",
|
|
None,
|
|
Some(serde_json::json!({
|
|
"error": format!("{e}"),
|
|
})),
|
|
);
|
|
let ret = d.clone();
|
|
auth_manager.hot_swap(d);
|
|
return Ok((ret, false));
|
|
}
|
|
xai_grok_telemetry::unified_log::warn(
|
|
"auth run_auth_flow refresh failed, falling through to interactive",
|
|
None,
|
|
Some(serde_json::json!({
|
|
"error": format!("{e}"),
|
|
})),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(ref cmd) = grok_com_config.auth_provider_command {
|
|
let is_refresh = reauth || auth_manager.is_expired();
|
|
match run_external_auth_provider(cmd, auth_manager, is_refresh, on_stderr).await {
|
|
Ok(result) => return Ok(result),
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
error = %e,
|
|
"auth: external auth provider failed, falling through to interactive login"
|
|
);
|
|
eprintln!("Signing in with browser instead...");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Devbox auto-migration: before interactive login (which requires a
|
|
// browser and won't work on headless devboxes), try minting OIDC
|
|
// credentials via the remote devbox login helper.
|
|
// preferred_method=api_key: never auto-mint OIDC (fail-closed). Explicit
|
|
// `grok login --devbox` uses run_devbox_login and is not gated here.
|
|
if !grok_com_config.blocks_automatic_oidc()
|
|
&& crate::auth::devbox_login::is_devbox_environment()
|
|
{
|
|
tracing::info!("auth: devbox detected, attempting devbox login before interactive flow");
|
|
match crate::auth::devbox_login::mint_devbox_auth(auth_manager).await {
|
|
Ok(new_auth) => match auth_manager.save_without_enrichment(new_auth).await {
|
|
Ok(auth) => {
|
|
let _ = auth_manager.remove_scope(LEGACY_AUTH_SCOPE);
|
|
xai_grok_telemetry::unified_log::info(
|
|
"auth: devbox migration in auth flow succeeded",
|
|
None,
|
|
Some(serde_json::json!({
|
|
"user_id": auth.user_id,
|
|
"auth_mode": format!("{:?}", auth.auth_mode),
|
|
})),
|
|
);
|
|
return Ok((auth, true));
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "auth: devbox migration save failed in auth flow");
|
|
}
|
|
},
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "auth: devbox login failed, falling through to interactive");
|
|
}
|
|
}
|
|
}
|
|
|
|
let url_tx = url_tx.and_then(|rc| rc.borrow_mut().take());
|
|
let mut channels = code_rx.map(|code_rx| AuthChannels { url_tx, code_rx });
|
|
|
|
// Enterprise OIDC keeps loopback (customer IdPs may lack a device endpoint).
|
|
// xAI OAuth2 also defaults to loopback; the device flow (robust on
|
|
// remote/SSH where the loopback redirect can't reach the CLI) is opt-in via
|
|
// --device-auth / GROK_LOGIN_DEVICE_FLOW / [auth] login_device_flow.
|
|
if crate::auth::oidc::is_configured(grok_com_config) {
|
|
return crate::auth::oidc::run_login_flow(grok_com_config, auth_manager, channels).await;
|
|
}
|
|
|
|
if let Some(ref oauth2_cfg) = grok_com_config.oauth2 {
|
|
if should_use_device_flow(login_override).await {
|
|
// On `NotEnabled` (no device endpoint) `channels` is untouched,
|
|
// so we can fall back to loopback below.
|
|
match crate::auth::device_code::run_device_code_login_channels(
|
|
&oauth2_cfg.issuer,
|
|
&oauth2_cfg.client_id,
|
|
&oauth2_cfg.scopes,
|
|
auth_manager,
|
|
&mut channels,
|
|
)
|
|
.await
|
|
{
|
|
Err(e)
|
|
if matches!(
|
|
e.downcast_ref::<crate::auth::device_code::DeviceCodeError>(),
|
|
Some(crate::auth::device_code::DeviceCodeError::NotEnabled)
|
|
) =>
|
|
{
|
|
tracing::warn!(
|
|
"auth: device flow unavailable (404), falling back to loopback login"
|
|
);
|
|
}
|
|
other => return other,
|
|
}
|
|
}
|
|
return crate::auth::oidc::run_login_flow_with_config(
|
|
&oauth2_cfg.as_oidc(),
|
|
auth_manager,
|
|
channels,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
tracing::error!(
|
|
"auth: no OAuth2 configuration available (neither enterprise OIDC nor xAI OAuth2 configured)"
|
|
);
|
|
anyhow::bail!(
|
|
"No OAuth2 configuration available. Run `grok login` to authenticate, or contact your administrator if you use enterprise SSO."
|
|
)
|
|
}
|
|
|
|
/// Non-interactive auth refresh: returns valid credentials if available without
|
|
/// ever triggering interactive login (browser, device code, etc.).
|
|
///
|
|
/// Tries in order:
|
|
/// 1. Cached credentials (non-expired)
|
|
/// 2. OIDC silent refresh (if expired token has a refresh_token)
|
|
/// 3. External auth provider command (if configured)
|
|
///
|
|
/// Returns `None` when no valid credentials can be obtained non-interactively.
|
|
pub async fn try_ensure_fresh_auth(grok_com_config: &GrokComConfig) -> Option<GrokAuth> {
|
|
try_ensure_fresh_auth_with(&build_startup_auth_manager(grok_com_config)).await
|
|
}
|
|
|
|
/// Builds and configures the startup `AuthManager`; the policy helpers below
|
|
/// take it injected so tests can substitute their own.
|
|
fn build_startup_auth_manager(grok_com_config: &GrokComConfig) -> Arc<AuthManager> {
|
|
let auth_manager = Arc::new(AuthManager::new(
|
|
&grok_home::grok_home(),
|
|
grok_com_config.clone(),
|
|
));
|
|
// auth()'s OIDC/external refresh needs the refresher configured first.
|
|
auth_manager.configure_refresher(grok_com_config.auth_provider_command.clone(), None);
|
|
auth_manager
|
|
}
|
|
|
|
/// Policy: cached-valid creds, else silent refresh (no interactive login).
|
|
async fn try_ensure_fresh_auth_with(auth_manager: &Arc<AuthManager>) -> Option<GrokAuth> {
|
|
match auth_manager.auth().await {
|
|
Ok(auth) => Some(auth),
|
|
Err(e) => {
|
|
tracing::debug!(error = %e, "try_ensure_fresh_auth: no valid credentials available");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Readiness-path auth: a bounded refresh plus the expired-but-refreshable
|
|
/// cached session, but no cold mint (which can run a provider command up to
|
|
/// `STARTUP_AUTH_TIMEOUT`). Minting is deferred to the post-readiness
|
|
/// background task, so readiness waits at most `STARTUP_AUTH_REFRESH_TIMEOUT`.
|
|
pub(crate) async fn try_noninteractive_auth_no_mint(
|
|
grok_com_config: &GrokComConfig,
|
|
) -> Option<GrokAuth> {
|
|
try_noninteractive_auth_no_mint_with(&build_startup_auth_manager(grok_com_config)).await
|
|
}
|
|
|
|
/// Policy behind [`try_noninteractive_auth_no_mint`], with the `AuthManager`
|
|
/// injected for tests.
|
|
async fn try_noninteractive_auth_no_mint_with(auth_manager: &Arc<AuthManager>) -> Option<GrokAuth> {
|
|
match tokio::time::timeout(
|
|
crate::http::STARTUP_AUTH_REFRESH_TIMEOUT,
|
|
try_ensure_fresh_auth_with(auth_manager),
|
|
)
|
|
.await
|
|
{
|
|
Ok(Some(auth)) => return Some(auth),
|
|
Ok(None) => {}
|
|
Err(_elapsed) => {
|
|
tracing::warn!(
|
|
timeout_secs = crate::http::STARTUP_AUTH_REFRESH_TIMEOUT.as_secs(),
|
|
"boot auth refresh timed out; using cached/expired session (mint deferred to background)"
|
|
);
|
|
}
|
|
}
|
|
// Expired-but-refreshable cached session self-heals on the first 401; no
|
|
// cold mint on the readiness path.
|
|
expired_refreshable_session(auth_manager)
|
|
}
|
|
|
|
/// A cached, refreshable session (not BYOK/ApiKey). Reached only after fresh
|
|
/// auth failed, so in practice the token is expired but recoverable on 401.
|
|
fn expired_refreshable_session(auth_manager: &AuthManager) -> Option<GrokAuth> {
|
|
auth_manager
|
|
.current_or_expired()
|
|
.filter(|a| a.is_xai_auth() && a.refresh_token.is_some())
|
|
}
|
|
|
|
/// Cold-start mint via non-interactive providers (external command, devbox);
|
|
/// `None` when none is available. Persists the result into `auth_manager` (disk
|
|
/// and in-memory) so per-request `auth()` self-heals. Carries no timeout of its
|
|
/// own: the readiness-path caller imposes `STARTUP_AUTH_TIMEOUT`, while the
|
|
/// leader's background re-mint runs uncapped (only the provider's ~300s ceiling).
|
|
pub(crate) async fn mint_session_noninteractive(
|
|
auth_manager: &Arc<AuthManager>,
|
|
) -> Option<GrokAuth> {
|
|
let grok_com_config = auth_manager.grok_com_config();
|
|
// preferred_method=api_key: never auto-mint OIDC (fail-closed).
|
|
if grok_com_config.blocks_automatic_oidc() {
|
|
tracing::debug!(
|
|
"mint_session_noninteractive: skipped (preferred_method=api_key blocks automatic OIDC)"
|
|
);
|
|
return None;
|
|
}
|
|
|
|
if let Some(cmd) = grok_com_config.auth_provider_command.as_deref() {
|
|
match run_external_auth_provider(cmd, auth_manager, false, None).await {
|
|
Ok((auth, _)) => return Some(auth),
|
|
Err(e) => {
|
|
tracing::debug!(error = %e, "mint_session_noninteractive: external provider failed");
|
|
}
|
|
}
|
|
}
|
|
|
|
if crate::auth::devbox_login::is_devbox_environment() {
|
|
match crate::auth::devbox_login::mint_devbox_auth(auth_manager).await {
|
|
Ok(new_auth) => return Some(persist_or_use_minted(auth_manager, new_auth).await),
|
|
Err(e) => {
|
|
tracing::debug!(error = %e, "mint_session_noninteractive: devbox mint failed");
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
/// Persist a minted token; on persist failure, return it unpersisted rather
|
|
/// than dropping a valid credential.
|
|
async fn persist_or_use_minted(auth_manager: &AuthManager, new_auth: GrokAuth) -> GrokAuth {
|
|
match auth_manager.save_without_enrichment(new_auth.clone()).await {
|
|
Ok(auth) => {
|
|
let _ = auth_manager.remove_scope(LEGACY_AUTH_SCOPE);
|
|
auth
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "mint persist failed; using unpersisted token");
|
|
new_auth
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Print the CLI "signed in" confirmation, clearing the spinner line first.
|
|
fn report_signed_in(auth: &GrokAuth) {
|
|
eprint!("\r\x1b[K");
|
|
match auth.email {
|
|
Some(ref email) => eprintln!("✓ Signed in as {email}"),
|
|
None => eprintln!("✓ Signed in"),
|
|
}
|
|
}
|
|
|
|
/// CLI auth entrypoint. For GUI, use `run_auth_flow_with_stderr_bridge`.
|
|
pub async fn ensure_authenticated(
|
|
grok_com_config: &GrokComConfig,
|
|
reauth: bool,
|
|
message_prefix: Option<&str>,
|
|
) -> anyhow::Result<GrokAuth> {
|
|
ensure_authenticated_with_override(
|
|
grok_com_config,
|
|
reauth,
|
|
message_prefix,
|
|
LoginTransportOverride::None,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Like [`ensure_authenticated`] but with an explicit login-transport override
|
|
/// (from `--oauth` / `--device-auth`). Used by `run_cli_login`.
|
|
pub async fn ensure_authenticated_with_override(
|
|
grok_com_config: &GrokComConfig,
|
|
reauth: bool,
|
|
message_prefix: Option<&str>,
|
|
login_override: LoginTransportOverride,
|
|
) -> anyhow::Result<GrokAuth> {
|
|
let grok_home = grok_home::grok_home();
|
|
let auth_manager = Arc::new(AuthManager::new(&grok_home, grok_com_config.clone()));
|
|
|
|
// If not re-authing, accept any valid non-WebLogin credential.
|
|
// WebLogin tokens are always skipped — they must be migrated to OIDC.
|
|
if !reauth && let Some(auth) = auth_manager.current() {
|
|
if auth.auth_mode != super::AuthMode::WebLogin {
|
|
return Ok(auth);
|
|
}
|
|
tracing::info!("auth: skipping cached WebLogin credential, will migrate to OIDC");
|
|
auth_manager.clear_in_memory();
|
|
let _ = auth_manager.remove_scope(LEGACY_AUTH_SCOPE);
|
|
}
|
|
|
|
// Context only — the flow below prints the "Signing in…" line itself.
|
|
if let Some(msg) = message_prefix {
|
|
eprintln!("{msg}");
|
|
}
|
|
|
|
let (auth, did_auth) = run_auth_flow(
|
|
&auth_manager,
|
|
grok_com_config,
|
|
reauth,
|
|
None,
|
|
None,
|
|
None,
|
|
login_override,
|
|
)
|
|
.await?;
|
|
|
|
if did_auth {
|
|
report_signed_in(&auth);
|
|
}
|
|
|
|
Ok(auth)
|
|
}
|
|
|
|
/// Decides *whether to prompt* for an interactive login (the wire credential is
|
|
/// chosen separately by `ShellAuthCredentialProvider`).
|
|
///
|
|
/// With `has_noninteractive_auth`, only refresh a cached token best-effort (no
|
|
/// browser, no cold mint); otherwise require an interactive login.
|
|
pub async fn ensure_authenticated_or_noninteractive(
|
|
grok_com_config: &GrokComConfig,
|
|
has_noninteractive_auth: bool,
|
|
message_prefix: Option<&str>,
|
|
) -> anyhow::Result<Option<GrokAuth>> {
|
|
if has_noninteractive_auth {
|
|
Ok(try_ensure_fresh_auth(grok_com_config).await)
|
|
} else {
|
|
ensure_authenticated(grok_com_config, false, message_prefix)
|
|
.await
|
|
.map(Some)
|
|
}
|
|
}
|
|
|
|
/// Unified `grok login` handler for CLI entry points (tui, pager).
|
|
///
|
|
/// Precedence: `--oauth` forces loopback, `--device-auth` forces device,
|
|
/// otherwise `GROK_LOGIN_DEVICE_FLOW` env / `[auth] login_device_flow` config /
|
|
/// loopback default. Both transports run through `run_auth_flow_inner` so the
|
|
/// external auth provider and devbox auto-migration are tried first.
|
|
pub async fn run_cli_login(
|
|
config: &crate::agent::config::Config,
|
|
oauth: bool,
|
|
device_auth: bool,
|
|
devbox: bool,
|
|
) -> anyhow::Result<()> {
|
|
let login_override = LoginTransportOverride::from_flags(oauth, device_auth);
|
|
|
|
// Mirror `run_auth_flow_inner`'s precedence: enterprise OIDC (oidc=Some,
|
|
// oauth2=None) always uses the loopback flow; only the xAI OAuth2 provider
|
|
// supports the device flow. Without this guard, `grok login` on an
|
|
// enterprise-OIDC deployment would wrongly enter the device branch (which
|
|
// requires `oauth2`) and error.
|
|
let authenticated = if devbox {
|
|
super::devbox_login::run_devbox_login(config).await?
|
|
} else if cli_should_use_device(&config.grok_com_config, login_override).await {
|
|
if config.grok_com_config.oauth2.is_none() {
|
|
// No OIDC and no oauth2 here, so `--oauth` can't help.
|
|
anyhow::bail!("Sign-in is not available for this deployment. Set XAI_API_KEY instead.");
|
|
}
|
|
let grok_home = grok_home::grok_home();
|
|
let auth_manager = Arc::new(AuthManager::new(&grok_home, config.grok_com_config.clone()));
|
|
// Route through the shared inner flow (not `run_device_code_login`
|
|
// directly) so the external auth provider and devbox auto-migration run
|
|
// before the interactive device login. `force_interactive` skips the
|
|
// up-front clear, so abandoning the device prompt doesn't log the user
|
|
// out; on `NotEnabled` it falls back to loopback.
|
|
// Already resolved/logged above; pass `Preresolved(true)` so the inner flow
|
|
// honors device without a second fetch or a duplicate `cli`-attributed log.
|
|
let (auth, did_auth) = run_auth_flow_interactive(
|
|
&auth_manager,
|
|
&config.grok_com_config,
|
|
None,
|
|
None,
|
|
None,
|
|
LoginTransportOverride::Preresolved(true),
|
|
)
|
|
.await?;
|
|
if did_auth {
|
|
report_signed_in(&auth);
|
|
}
|
|
auth
|
|
} else {
|
|
// OIDC has no device endpoint, so `--device-auth` falls back here.
|
|
if device_auth && crate::auth::oidc::is_configured(&config.grok_com_config) {
|
|
eprintln!(
|
|
"Device-code login isn't available for your SSO provider; using browser sign-in."
|
|
);
|
|
}
|
|
// Loopback. `reauth=true` clears creds up front (legacy-scope hygiene),
|
|
// so abandoning logs you out — unlike the device branch above.
|
|
// Already resolved/logged above; pass `Preresolved(false)` so the inner
|
|
// flow honors loopback without a duplicate `cli`-attributed log.
|
|
ensure_authenticated_with_override(
|
|
&config.grok_com_config,
|
|
true,
|
|
None,
|
|
LoginTransportOverride::Preresolved(false),
|
|
)
|
|
.await?
|
|
};
|
|
|
|
// Sync this principal's config now rather than waiting for the background
|
|
// tick. Stay quiet about absence/failure during login — confirm only when
|
|
// config was actually applied; `grok setup` reports the no-config case.
|
|
let outcome = crate::managed_config::post_login_sync(Some(authenticated)).await;
|
|
match outcome {
|
|
crate::managed_config::ManagedConfigSync::Updated { is_team: true } => {
|
|
eprintln!("Applied your team's managed configuration.");
|
|
}
|
|
crate::managed_config::ManagedConfigSync::Updated { is_team: false } => {
|
|
eprintln!("Applied your deployment's managed configuration.");
|
|
}
|
|
_ => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Result of a logout operation. Used by both the CLI subcommand and
|
|
/// the ACP `/logout` slash command so the presentation layer can format
|
|
/// the outcome without duplicating the auth logic.
|
|
pub struct LogoutResult {
|
|
/// `true` if a cached OAuth session was found and cleared.
|
|
pub was_logged_in: bool,
|
|
/// Email of the session that was cleared (if available).
|
|
pub email: Option<String>,
|
|
/// `true` if `XAI_API_KEY` / `GROK_CODE_XAI_API_KEY` env var is set.
|
|
pub api_key_still_set: bool,
|
|
}
|
|
|
|
/// Core logout logic shared by the CLI subcommand and the ACP handler.
|
|
///
|
|
/// When `scope` is `None`, clears the default scope (same as `/logout`
|
|
/// in the TUI). When `Some`, removes only that scope entry.
|
|
pub fn perform_logout(
|
|
auth_manager: &AuthManager,
|
|
scope: Option<&str>,
|
|
) -> std::io::Result<LogoutResult> {
|
|
let auth = auth_manager.current_or_expired();
|
|
let email = auth.as_ref().and_then(|a| a.email.clone());
|
|
let was_logged_in = auth.is_some();
|
|
// Intentional credential removal must be attributable in
|
|
// unified.jsonl, so a later "auth.json entry gone" can be
|
|
// distinguished from accidental loss (deleted/corrupt file).
|
|
xai_grok_telemetry::unified_log::info(
|
|
"auth: logout",
|
|
None,
|
|
Some(serde_json::json!({
|
|
"was_logged_in": was_logged_in,
|
|
"scope": scope.unwrap_or("(current)"),
|
|
"user_id": auth.as_ref().map(|a| a.user_id.clone()),
|
|
})),
|
|
);
|
|
if was_logged_in {
|
|
// Order matters for the no-leak guarantee (flush-on-logout
|
|
// parity). Clear the external OTEL identity attrs FIRST so any
|
|
// record emitted from here on cannot carry the prior user's ids; THEN
|
|
// flush already-queued records (which were built with their ids during
|
|
// the active session — that is correct); THEN clear credentials.
|
|
// Clearing identity before the flush closes the window in which a
|
|
// concurrent emission between flush and identity-reset would still
|
|
// stamp the prior user's ids onto a customer-collector record.
|
|
xai_grok_telemetry::external::set_identity(
|
|
xai_grok_telemetry::external::IdentityAttrs::default(),
|
|
);
|
|
xai_grok_telemetry::external::flush();
|
|
if let Some(scope) = scope {
|
|
auth_manager.remove_scope(scope)?;
|
|
} else {
|
|
auth_manager.clear()?;
|
|
}
|
|
// Clear the synced files if no principal remains to own them. A scoped
|
|
// logout that leaves a team (or a deployment key) signed in keeps them.
|
|
crate::managed_config::clear_orphan();
|
|
}
|
|
Ok(LogoutResult {
|
|
was_logged_in,
|
|
email,
|
|
api_key_still_set: crate::agent::auth_method::has_xai_api_key_env(),
|
|
})
|
|
}
|
|
|
|
/// `grok logout` CLI handler. Calls [`perform_logout`] and formats
|
|
/// the result to stderr.
|
|
pub fn run_cli_logout(config: &crate::agent::config::Config) -> anyhow::Result<()> {
|
|
let grok_home = grok_home::grok_home();
|
|
let auth_manager = AuthManager::new(&grok_home, config.grok_com_config.clone());
|
|
let result = perform_logout(&auth_manager, None)
|
|
.map_err(|e| anyhow::anyhow!("Failed to clear auth: {e}"))?;
|
|
if !result.was_logged_in {
|
|
eprintln!("No cached session to log out of.");
|
|
if result.api_key_still_set {
|
|
eprintln!("You are authenticated via XAI_API_KEY (environment variable).");
|
|
}
|
|
return Ok(());
|
|
}
|
|
if let Some(email) = result.email {
|
|
eprintln!("Logged out (was signed in as {email})");
|
|
} else {
|
|
eprintln!("Logged out");
|
|
}
|
|
if result.api_key_still_set {
|
|
eprintln!("XAI_API_KEY is still set and will be used for authentication.");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::auth::AuthMode;
|
|
use crate::auth::config::XAI_OAUTH2_ISSUER;
|
|
use crate::env::EnvVarGuard;
|
|
use chrono::Utc;
|
|
|
|
/// Run `f` with `GROK_LOGIN_DEVICE_FLOW` set to `value` (unset for `None`).
|
|
/// `EnvVarGuard` serializes the process env and restores it on drop, so
|
|
/// `resolve_device_flow` reads the env tier from a known state.
|
|
fn with_device_flow_env<T>(value: Option<bool>, f: impl FnOnce() -> T) -> T {
|
|
let _guard = match value {
|
|
Some(true) => EnvVarGuard::set("GROK_LOGIN_DEVICE_FLOW", "true"),
|
|
Some(false) => EnvVarGuard::set("GROK_LOGIN_DEVICE_FLOW", "false"),
|
|
None => EnvVarGuard::remove("GROK_LOGIN_DEVICE_FLOW"),
|
|
};
|
|
f()
|
|
}
|
|
|
|
// A grok.com first-party (x.ai-issuer) OIDC session — `is_xai_auth()` true.
|
|
fn oidc_session(key: &str, refresh: Option<&str>) -> GrokAuth {
|
|
GrokAuth {
|
|
key: key.into(),
|
|
auth_mode: AuthMode::Oidc,
|
|
oidc_issuer: Some(XAI_OAUTH2_ISSUER.to_string()),
|
|
refresh_token: refresh.map(str::to_string),
|
|
..GrokAuth::test_default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn expired_refreshable_session_gate() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mgr = AuthManager::new(dir.path(), GrokComConfig::default());
|
|
|
|
// Expired but refreshable → returned. Guards a `current_or_expired()` ->
|
|
// `current()` regression that would disable the relay on a transient blip.
|
|
mgr.hot_swap(GrokAuth {
|
|
expires_at: Some(Utc::now() - chrono::Duration::hours(1)),
|
|
..oidc_session("expired-but-refreshable", Some("rt"))
|
|
});
|
|
assert!(
|
|
mgr.current().is_none(),
|
|
"precondition: token must be expired"
|
|
);
|
|
assert_eq!(
|
|
expired_refreshable_session(&mgr).map(|a| a.key),
|
|
Some("expired-but-refreshable".to_string())
|
|
);
|
|
|
|
// No refresh token → rejected: never hand the relay a token it can't
|
|
// recover on 401 (the gate `for_session` doesn't check this).
|
|
mgr.hot_swap(oidc_session("no-rt", None));
|
|
assert!(expired_refreshable_session(&mgr).is_none());
|
|
|
|
// An expired first-party *external* credential with a refresh token
|
|
// is likewise recoverable — 401 recovery re-runs the provider binary
|
|
// (the refresh token is a recoverability marker, not a grant input).
|
|
mgr.hot_swap(GrokAuth {
|
|
auth_mode: AuthMode::External,
|
|
expires_at: Some(Utc::now() - chrono::Duration::hours(1)),
|
|
..oidc_session("expired-external", Some("rt"))
|
|
});
|
|
assert_eq!(
|
|
expired_refreshable_session(&mgr).map(|a| a.key),
|
|
Some("expired-external".to_string())
|
|
);
|
|
|
|
// Third-party external (no x.ai issuer) stays excluded.
|
|
mgr.hot_swap(GrokAuth {
|
|
oidc_issuer: None,
|
|
auth_mode: AuthMode::External,
|
|
expires_at: Some(Utc::now() - chrono::Duration::hours(1)),
|
|
..oidc_session("expired-external-3p", Some("rt"))
|
|
});
|
|
assert!(expired_refreshable_session(&mgr).is_none());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn persist_or_use_minted_returns_token_when_save_fails() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
// Read-only grok_home: reading a missing auth.json succeeds (empty), but
|
|
// writing fails — exercising the save-failure path.
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap();
|
|
let mgr = Arc::new(AuthManager::new(dir.path(), GrokComConfig::default()));
|
|
let minted = oidc_session("minted-token", Some("rt"));
|
|
|
|
let save = mgr.save_without_enrichment(minted.clone()).await;
|
|
// Root bypasses 0o500, so the write can't be forced to fail there — skip
|
|
// explicitly. Non-root MUST see the save fail (or this proves nothing).
|
|
if unsafe { libc::geteuid() } == 0 {
|
|
return;
|
|
}
|
|
assert!(
|
|
save.is_err(),
|
|
"non-root: save into a read-only dir must fail"
|
|
);
|
|
let out = persist_or_use_minted(&mgr, minted).await;
|
|
assert_eq!(
|
|
out.key, "minted-token",
|
|
"must return the unpersisted minted token"
|
|
);
|
|
}
|
|
|
|
/// Proxy URL on a closed port: inline enrichment fails fast instead of
|
|
/// reaching outside the test.
|
|
fn dead_proxy_url() -> String {
|
|
let port = {
|
|
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
l.local_addr().unwrap().port()
|
|
};
|
|
format!("http://127.0.0.1:{port}")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mint_session_noninteractive_uses_external_provider() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let cfg = GrokComConfig {
|
|
auth_provider_command: Some("printf '%s' xai-ext-token".to_string()),
|
|
..GrokComConfig::default()
|
|
};
|
|
let mgr = Arc::new(
|
|
AuthManager::new(dir.path(), cfg.clone()).with_proxy_base_url(&dead_proxy_url()),
|
|
);
|
|
|
|
let auth = mint_session_noninteractive(&mgr).await;
|
|
assert_eq!(auth.map(|a| a.key), Some("xai-ext-token".to_string()));
|
|
}
|
|
|
|
/// External-provider output is team-pinned before persist (parity with OIDC
|
|
/// / device-code): a wrong-team token is rejected and nothing is written.
|
|
#[tokio::test]
|
|
async fn external_provider_rejects_wrong_team_and_persists_nothing() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mgr = Arc::new(
|
|
AuthManager::new(dir.path(), pinned_cfg("team-good"))
|
|
.with_proxy_base_url(&dead_proxy_url()),
|
|
);
|
|
let cmd = format!("printf '%s' {}", team_jwt("team-wrong"));
|
|
|
|
assert!(
|
|
run_external_auth_provider(&cmd, &mgr, false, None)
|
|
.await
|
|
.is_err(),
|
|
"wrong-team external token must be rejected"
|
|
);
|
|
assert!(
|
|
mgr.current_or_expired().is_none(),
|
|
"rejected external login must persist nothing"
|
|
);
|
|
assert!(
|
|
!dir.path().join("auth.json").exists(),
|
|
"rejected external login must not write auth.json"
|
|
);
|
|
}
|
|
|
|
/// A matching-team external token is accepted and persisted.
|
|
#[tokio::test]
|
|
async fn external_provider_accepts_matching_team() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let jwt = team_jwt("team-good");
|
|
let mgr = Arc::new(
|
|
AuthManager::new(dir.path(), pinned_cfg("team-good"))
|
|
.with_proxy_base_url(&dead_proxy_url()),
|
|
);
|
|
let cmd = format!("printf '%s' {jwt}");
|
|
|
|
let (auth, _) = run_external_auth_provider(&cmd, &mgr, false, None)
|
|
.await
|
|
.expect("matching-team external token must be accepted");
|
|
assert_eq!(auth.key, jwt);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn external_reauth_without_prev_auth_enriches_inline() {
|
|
// Regression: reauth clears the manager before the provider runs with
|
|
// is_refresh=true; flags must then come from /user, not default empty.
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
let app = axum::Router::new().route(
|
|
"/user",
|
|
axum::routing::get(|| async {
|
|
axum::Json(serde_json::json!({
|
|
"userId": "u-1",
|
|
"teamBlockedReasons": ["BLOCKED_REASON_NO_LOGS"],
|
|
}))
|
|
}),
|
|
);
|
|
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mgr = Arc::new(
|
|
AuthManager::new(dir.path(), GrokComConfig::default())
|
|
.with_proxy_base_url(&format!("http://127.0.0.1:{port}")),
|
|
);
|
|
assert!(mgr.current_or_expired().is_none(), "precondition: no auth");
|
|
|
|
let (auth, _) = run_external_auth_provider("printf '%s' fresh-token", &mgr, true, None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(auth.key, "fresh-token");
|
|
assert!(auth.is_zdr_team(), "flags must come from /user fetch");
|
|
assert_eq!(auth.user_id, "u-1");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn external_refresh_carries_profile_without_network() {
|
|
// Carry path must not need /user: dead proxy port, flags from prev.
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mgr = Arc::new(
|
|
AuthManager::new(dir.path(), GrokComConfig::default())
|
|
.with_proxy_base_url(&dead_proxy_url()),
|
|
);
|
|
mgr.hot_swap(GrokAuth {
|
|
team_blocked_reasons: vec!["BLOCKED_REASON_NO_LOGS".into()],
|
|
organization_id: Some("org-1".into()),
|
|
..oidc_session("old-token", None)
|
|
});
|
|
|
|
let (auth, _) = run_external_auth_provider("printf '%s' fresh-token", &mgr, true, None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(auth.key, "fresh-token");
|
|
assert!(auth.is_zdr_team(), "flags must carry from previous auth");
|
|
assert_eq!(auth.user_id, "test-user");
|
|
assert_eq!(auth.organization_id.as_deref(), Some("org-1"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn device_flow_still_runs_external_provider() {
|
|
// Regression: with the device flow opted into (--device-auth), the
|
|
// external auth provider must still run first. `run_cli_login`'s device
|
|
// branch goes through `run_auth_flow_interactive`, so that path must
|
|
// pick up the provider instead of starting an interactive device login.
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let cfg = GrokComConfig {
|
|
auth_provider_command: Some("printf '%s' xai-ext-token".to_string()),
|
|
// oauth2=Some, oidc=None → the device flow is available (opt-in).
|
|
..GrokComConfig::default()
|
|
};
|
|
assert!(
|
|
cli_should_use_device(&cfg, LoginTransportOverride::ForceDevice).await,
|
|
"precondition: --device-auth resolves to the device flow"
|
|
);
|
|
let mgr = Arc::new(
|
|
AuthManager::new(dir.path(), cfg.clone()).with_proxy_base_url(&dead_proxy_url()),
|
|
);
|
|
let (auth, did_auth) = run_auth_flow_interactive(
|
|
&mgr,
|
|
&cfg,
|
|
None,
|
|
None,
|
|
None,
|
|
LoginTransportOverride::ForceDevice,
|
|
)
|
|
.await
|
|
.expect("external provider should satisfy login without device flow");
|
|
assert_eq!(
|
|
auth.key, "xai-ext-token",
|
|
"external provider token must win"
|
|
);
|
|
assert!(did_auth);
|
|
}
|
|
|
|
#[test]
|
|
fn login_transport_override_maps_to_cli_bool() {
|
|
// `--oauth` → loopback, `--device-auth` → device, no flag → no override.
|
|
assert_eq!(LoginTransportOverride::None.as_cli_bool(), None);
|
|
assert_eq!(
|
|
LoginTransportOverride::ForceLoopback.as_cli_bool(),
|
|
Some(false)
|
|
);
|
|
assert_eq!(
|
|
LoginTransportOverride::ForceDevice.as_cli_bool(),
|
|
Some(true)
|
|
);
|
|
// Pre-resolved transports are honored upstream, never via the CLI tier, so
|
|
// they must not present a CLI value (which would mis-label the source as cli).
|
|
assert_eq!(
|
|
LoginTransportOverride::Preresolved(true).as_cli_bool(),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
LoginTransportOverride::Preresolved(false).as_cli_bool(),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn preresolved_bypasses_resolver_and_is_never_cli() {
|
|
// Regression for the double-log / source=cli bug: the inner flow must
|
|
// honor `Preresolved` WITHOUT re-running the resolver. Each case pins the
|
|
// opposite env value, so a leak into the resolver would flip the result —
|
|
// returning the carried value proves the early return (and no second log).
|
|
{
|
|
let _guard = EnvVarGuard::set("GROK_LOGIN_DEVICE_FLOW", "false");
|
|
assert!(
|
|
should_use_device_flow(LoginTransportOverride::Preresolved(true)).await,
|
|
"Preresolved(true) honors device without re-resolving"
|
|
);
|
|
}
|
|
{
|
|
let _guard = EnvVarGuard::set("GROK_LOGIN_DEVICE_FLOW", "true");
|
|
assert!(
|
|
!should_use_device_flow(LoginTransportOverride::Preresolved(false)).await,
|
|
"Preresolved(false) honors loopback without re-resolving"
|
|
);
|
|
assert!(
|
|
should_use_device_flow(LoginTransportOverride::None).await,
|
|
"the resolver path still honors env (sole resolution)"
|
|
);
|
|
}
|
|
// Even if it reached the resolver it carries no CLI value, so a remote
|
|
// decision is the remote tier, never cli.
|
|
with_device_flow_env(None, || {
|
|
assert_eq!(
|
|
resolve_device_flow(LoginTransportOverride::Preresolved(true), None, Some(true))
|
|
.source,
|
|
crate::agent::config::ConfigSource::Remote,
|
|
"Preresolved must never resolve as the cli tier"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn from_flags_prefers_oauth_over_device() {
|
|
// `--oauth` (loopback) wins if both are set — a defensive guard for the
|
|
// ACP meta path (clap already blocks both flags on the CLI).
|
|
assert_eq!(
|
|
LoginTransportOverride::from_flags(true, true),
|
|
LoginTransportOverride::ForceLoopback
|
|
);
|
|
assert_eq!(
|
|
LoginTransportOverride::from_flags(true, false),
|
|
LoginTransportOverride::ForceLoopback
|
|
);
|
|
assert_eq!(
|
|
LoginTransportOverride::from_flags(false, true),
|
|
LoginTransportOverride::ForceDevice
|
|
);
|
|
assert_eq!(
|
|
LoginTransportOverride::from_flags(false, false),
|
|
LoginTransportOverride::None
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enterprise_oidc_never_uses_device_flow() {
|
|
// oidc=Some, oauth2=None: `grok login` must use loopback, not device —
|
|
// even when --device-auth forces device (which would otherwise be true).
|
|
// ForceDevice short-circuits the remote settings fetch, so this stays hermetic.
|
|
let cfg = GrokComConfig {
|
|
oidc: Some(crate::auth::OidcAuthConfig {
|
|
issuer: "https://idp.example".into(),
|
|
client_id: "client".into(),
|
|
scopes: vec!["openid".into()],
|
|
audience: None,
|
|
}),
|
|
oauth2: None,
|
|
..GrokComConfig::default()
|
|
};
|
|
assert!(
|
|
!cli_should_use_device(&cfg, LoginTransportOverride::ForceDevice).await,
|
|
"enterprise OIDC must stay on loopback"
|
|
);
|
|
// The xAI OAuth2 provider (oidc=None, oauth2=Some) does use device.
|
|
let xai = GrokComConfig::default();
|
|
assert!(xai.oauth2.is_some() && xai.oidc.is_none());
|
|
assert!(cli_should_use_device(&xai, LoginTransportOverride::ForceDevice).await);
|
|
}
|
|
|
|
#[test]
|
|
fn device_flow_precedence_cli_beats_env_config_remote() {
|
|
// CLI flag wins over a *conflicting* env + config + remote feature flag.
|
|
with_device_flow_env(Some(true), || {
|
|
assert!(
|
|
!resolve_device_flow(
|
|
LoginTransportOverride::ForceLoopback,
|
|
Some(true),
|
|
Some(true)
|
|
)
|
|
.value,
|
|
"--oauth must force loopback even when env+config+remote say device"
|
|
);
|
|
});
|
|
with_device_flow_env(Some(false), || {
|
|
assert!(
|
|
resolve_device_flow(
|
|
LoginTransportOverride::ForceDevice,
|
|
Some(false),
|
|
Some(false)
|
|
)
|
|
.value,
|
|
"--device-auth must force device even when env+config+remote say loopback"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn device_flow_precedence_env_beats_config() {
|
|
// No CLI flag: env wins over a conflicting config.
|
|
with_device_flow_env(Some(false), || {
|
|
assert!(!resolve_device_flow(LoginTransportOverride::None, Some(true), None).value);
|
|
});
|
|
with_device_flow_env(Some(true), || {
|
|
assert!(resolve_device_flow(LoginTransportOverride::None, Some(false), None).value);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn device_flow_env_beats_remote() {
|
|
// env sits above the remote feature flag.
|
|
with_device_flow_env(Some(false), || {
|
|
assert!(
|
|
!resolve_device_flow(LoginTransportOverride::None, None, Some(true)).value,
|
|
"env=loopback must win over remote=device"
|
|
);
|
|
});
|
|
with_device_flow_env(Some(true), || {
|
|
assert!(
|
|
resolve_device_flow(LoginTransportOverride::None, None, Some(false)).value,
|
|
"env=device must win over remote=loopback"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn device_flow_config_beats_remote() {
|
|
// Local config sits above the remote feature flag (env unset so config decides).
|
|
with_device_flow_env(None, || {
|
|
assert!(
|
|
!resolve_device_flow(LoginTransportOverride::None, Some(false), Some(true)).value,
|
|
"config=loopback must win over remote=device"
|
|
);
|
|
assert!(
|
|
resolve_device_flow(LoginTransportOverride::None, Some(true), Some(false)).value,
|
|
"config=device must win over remote=loopback"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn device_flow_precedence_config_then_default() {
|
|
// No CLI flag, no env: config decides; absent everything → loopback.
|
|
with_device_flow_env(None, || {
|
|
assert!(!resolve_device_flow(LoginTransportOverride::None, Some(false), None).value);
|
|
assert!(resolve_device_flow(LoginTransportOverride::None, Some(true), None).value);
|
|
assert!(
|
|
!resolve_device_flow(LoginTransportOverride::None, None, None).value,
|
|
"default is loopback"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn device_flow_remote_then_default() {
|
|
// No CLI flag, no env, no config: the remote feature flag drives the rollout.
|
|
with_device_flow_env(None, || {
|
|
assert!(
|
|
resolve_device_flow(LoginTransportOverride::None, None, Some(true)).value,
|
|
"remote=device rolls device-auth in when nothing local is set"
|
|
);
|
|
assert!(
|
|
!resolve_device_flow(LoginTransportOverride::None, None, Some(false)).value,
|
|
"remote=loopback keeps loopback when nothing local is set"
|
|
);
|
|
// remote settings unavailable / flag unset → None → hardcoded loopback default.
|
|
assert!(
|
|
!resolve_device_flow(LoginTransportOverride::None, None, None).value,
|
|
"remote settings unavailable falls back to the loopback default"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn device_flow_records_deciding_tier() {
|
|
// The resolver records which tier decided, so the rollout ramp can log it.
|
|
use crate::agent::config::ConfigSource;
|
|
with_device_flow_env(Some(false), || {
|
|
assert_eq!(
|
|
resolve_device_flow(LoginTransportOverride::ForceDevice, Some(false), None).source,
|
|
ConfigSource::Cli,
|
|
"an explicit CLI flag is reported as the cli tier"
|
|
);
|
|
});
|
|
with_device_flow_env(Some(true), || {
|
|
assert_eq!(
|
|
resolve_device_flow(LoginTransportOverride::None, None, Some(false)).source,
|
|
ConfigSource::Env
|
|
);
|
|
});
|
|
with_device_flow_env(None, || {
|
|
assert_eq!(
|
|
resolve_device_flow(LoginTransportOverride::None, Some(true), Some(false)).source,
|
|
ConfigSource::Config
|
|
);
|
|
assert_eq!(
|
|
resolve_device_flow(LoginTransportOverride::None, None, Some(true)).source,
|
|
ConfigSource::Remote,
|
|
"the remote feature flag is reported as the remote tier"
|
|
);
|
|
assert_eq!(
|
|
resolve_device_flow(LoginTransportOverride::None, None, None).source,
|
|
ConfigSource::Default
|
|
);
|
|
});
|
|
}
|
|
|
|
fn legacy_auth() -> GrokAuth {
|
|
GrokAuth {
|
|
key: "k".into(),
|
|
auth_mode: AuthMode::WebLogin,
|
|
create_time: Utc::now(),
|
|
user_id: "u".into(),
|
|
email: None,
|
|
first_name: None,
|
|
last_name: None,
|
|
profile_image_asset_id: None,
|
|
principal_type: None,
|
|
principal_id: None,
|
|
team_id: None,
|
|
team_name: None,
|
|
team_role: None,
|
|
organization_id: None,
|
|
organization_name: None,
|
|
organization_role: None,
|
|
user_blocked_reason: None,
|
|
team_blocked_reasons: vec![],
|
|
coding_data_retention_opt_out: false,
|
|
has_grok_code_access: None,
|
|
refresh_token: None,
|
|
expires_at: None,
|
|
oidc_issuer: None,
|
|
oidc_client_id: None,
|
|
}
|
|
}
|
|
|
|
fn oidc_auth(issuer: &str) -> GrokAuth {
|
|
GrokAuth {
|
|
oidc_issuer: Some(issuer.into()),
|
|
auth_mode: AuthMode::Oidc,
|
|
..legacy_auth()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn weblogin_cred_is_never_compatible() {
|
|
let cfg = GrokComConfig::default();
|
|
assert!(!is_cached_credential_compatible(&legacy_auth(), &cfg));
|
|
}
|
|
|
|
#[test]
|
|
fn oidc_cred_with_matching_issuer_is_compatible() {
|
|
let cfg = GrokComConfig::default();
|
|
assert!(is_cached_credential_compatible(
|
|
&oidc_auth(XAI_OAUTH2_ISSUER),
|
|
&cfg,
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn external_cred_compatibility_follows_issuer() {
|
|
let cfg = GrokComConfig::default();
|
|
|
|
// A first-party external credential (provider emitted the issuer) is
|
|
// reused by interactive login like an OIDC session instead of
|
|
// re-running the provider.
|
|
assert!(is_cached_credential_compatible(
|
|
&GrokAuth {
|
|
auth_mode: AuthMode::External,
|
|
..oidc_auth(XAI_OAUTH2_ISSUER)
|
|
},
|
|
&cfg,
|
|
));
|
|
|
|
// Without an issuer (bare-token providers), external credentials stay
|
|
// incompatible and interactive login starts fresh, as before.
|
|
assert!(!is_cached_credential_compatible(
|
|
&GrokAuth {
|
|
auth_mode: AuthMode::External,
|
|
oidc_issuer: None,
|
|
..legacy_auth()
|
|
},
|
|
&cfg,
|
|
));
|
|
}
|
|
|
|
fn ensure_crypto_provider() {
|
|
let _ = jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER.install_default();
|
|
}
|
|
|
|
fn team_jwt(principal_id: &str) -> String {
|
|
ensure_crypto_provider();
|
|
jsonwebtoken::encode(
|
|
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
|
|
&serde_json::json!({
|
|
"sub": "user-1",
|
|
"principal_type": "Team",
|
|
"principal_id": principal_id,
|
|
"exp": 9999999999u64,
|
|
}),
|
|
&jsonwebtoken::EncodingKey::from_secret(b"test-secret"),
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn pinned_cfg(team: &str) -> GrokComConfig {
|
|
GrokComConfig {
|
|
force_login_team_uuid: Some(crate::auth::config::ForceLoginTeam::Single(team.into())),
|
|
..GrokComConfig::default()
|
|
}
|
|
}
|
|
|
|
/// Under a team pin, a cached session for a different team is not reused by
|
|
/// interactive login — it falls through to a fresh, compliant login.
|
|
#[test]
|
|
fn cached_cred_with_wrong_team_is_incompatible() {
|
|
let auth = GrokAuth {
|
|
key: team_jwt("team-wrong"),
|
|
..oidc_auth(XAI_OAUTH2_ISSUER)
|
|
};
|
|
assert!(!is_cached_credential_compatible(
|
|
&auth,
|
|
&pinned_cfg("team-good")
|
|
));
|
|
}
|
|
|
|
/// A cached session for the pinned team is reused normally.
|
|
#[test]
|
|
fn cached_cred_with_matching_team_is_compatible() {
|
|
let auth = GrokAuth {
|
|
key: team_jwt("team-good"),
|
|
..oidc_auth(XAI_OAUTH2_ISSUER)
|
|
};
|
|
assert!(is_cached_credential_compatible(
|
|
&auth,
|
|
&pinned_cfg("team-good")
|
|
));
|
|
}
|
|
|
|
// ── run_auth_flow: expired path with disk token ─────────────────
|
|
|
|
/// When in-memory token is expired but disk has a valid token,
|
|
/// run_auth_flow should return the disk token without interactive login.
|
|
#[tokio::test]
|
|
async fn run_auth_flow_uses_valid_disk_token_when_expired() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let cfg = GrokComConfig::default();
|
|
|
|
// Write a valid token to disk via a second AuthManager (simulates
|
|
// a sibling process that already refreshed).
|
|
let writer = Arc::new(
|
|
AuthManager::new(dir.path(), cfg.clone()).with_proxy_base_url("http://127.0.0.1:1"),
|
|
);
|
|
let valid_disk = GrokAuth {
|
|
key: "fresh-token-from-disk".into(),
|
|
auth_mode: AuthMode::Oidc,
|
|
expires_at: Some(Utc::now() + chrono::Duration::hours(1)),
|
|
refresh_token: Some("new-rt".into()),
|
|
oidc_issuer: Some(XAI_OAUTH2_ISSUER.into()),
|
|
oidc_client_id: Some("client-1".into()),
|
|
..GrokAuth::test_default()
|
|
};
|
|
writer.update(valid_disk).await.unwrap();
|
|
|
|
// Primary manager: in-memory token is expired
|
|
let mgr = Arc::new(AuthManager::new(dir.path(), cfg.clone()));
|
|
let expired = GrokAuth {
|
|
key: "expired-access-token".into(),
|
|
auth_mode: AuthMode::Oidc,
|
|
expires_at: Some(Utc::now() - chrono::Duration::hours(1)),
|
|
refresh_token: Some("old-rt".into()),
|
|
oidc_issuer: Some(XAI_OAUTH2_ISSUER.into()),
|
|
oidc_client_id: Some("client-1".into()),
|
|
..GrokAuth::test_default()
|
|
};
|
|
mgr.hot_swap(expired);
|
|
assert!(mgr.is_expired());
|
|
|
|
let (auth, is_new_login) = run_auth_flow(
|
|
&mgr,
|
|
&cfg,
|
|
false, // not reauth
|
|
None,
|
|
None,
|
|
None,
|
|
LoginTransportOverride::None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(auth.key, "fresh-token-from-disk");
|
|
assert!(!is_new_login, "should not be a new login");
|
|
// In-memory should be updated via hot_swap
|
|
assert_eq!(mgr.current().unwrap().key, "fresh-token-from-disk");
|
|
}
|
|
|
|
/// When in-memory token is valid (not expired), run_auth_flow should
|
|
/// return it directly without checking disk.
|
|
#[tokio::test]
|
|
async fn run_auth_flow_returns_cached_when_valid() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let cfg = GrokComConfig::default();
|
|
let mgr = Arc::new(AuthManager::new(dir.path(), cfg.clone()));
|
|
|
|
let valid = GrokAuth {
|
|
key: "still-valid".into(),
|
|
auth_mode: AuthMode::Oidc,
|
|
expires_at: Some(Utc::now() + chrono::Duration::hours(1)),
|
|
oidc_issuer: Some(XAI_OAUTH2_ISSUER.into()),
|
|
oidc_client_id: Some("client-1".into()),
|
|
..GrokAuth::test_default()
|
|
};
|
|
mgr.hot_swap(valid);
|
|
|
|
let (auth, is_new_login) = run_auth_flow(
|
|
&mgr,
|
|
&cfg,
|
|
false,
|
|
None,
|
|
None,
|
|
None,
|
|
LoginTransportOverride::None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(auth.key, "still-valid");
|
|
assert!(!is_new_login);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn run_auth_flow_defers_to_consumer_refresh_on_transient_failure() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let cfg = GrokComConfig::default();
|
|
|
|
let writer = Arc::new(
|
|
AuthManager::new(dir.path(), cfg.clone()).with_proxy_base_url("http://127.0.0.1:1"),
|
|
);
|
|
let expired_with_rt = GrokAuth {
|
|
key: "expired-access-token".into(),
|
|
auth_mode: AuthMode::Oidc,
|
|
expires_at: Some(Utc::now() - chrono::Duration::hours(1)),
|
|
refresh_token: Some("valid-refresh-token".into()),
|
|
oidc_issuer: Some(XAI_OAUTH2_ISSUER.into()),
|
|
oidc_client_id: Some("client-1".into()),
|
|
..GrokAuth::test_default()
|
|
};
|
|
writer.update(expired_with_rt.clone()).await.unwrap();
|
|
|
|
let mgr = Arc::new(AuthManager::new(dir.path(), cfg.clone()));
|
|
mgr.hot_swap(expired_with_rt);
|
|
assert!(mgr.is_expired());
|
|
mgr.set_refresher(std::sync::Arc::new(AlwaysTransientRefresher));
|
|
|
|
let (auth, is_new_login) = run_auth_flow(
|
|
&mgr,
|
|
&cfg,
|
|
false, // not reauth
|
|
None,
|
|
None,
|
|
None,
|
|
LoginTransportOverride::None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(auth.key, "expired-access-token");
|
|
assert!(auth.refresh_token.is_some());
|
|
assert!(!is_new_login);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn run_auth_flow_falls_through_when_no_refresh_token() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
// Point the OAuth2 issuer at a non-routable address so the OIDC
|
|
// discovery fails immediately without opening a browser window.
|
|
let mut cfg = GrokComConfig::default();
|
|
cfg.oauth2.as_mut().unwrap().issuer = "http://127.0.0.1:1".into();
|
|
|
|
let writer = Arc::new(
|
|
AuthManager::new(dir.path(), cfg.clone()).with_proxy_base_url("http://127.0.0.1:1"),
|
|
);
|
|
let expired_no_rt = GrokAuth {
|
|
key: "expired-legacy".into(),
|
|
auth_mode: AuthMode::WebLogin,
|
|
expires_at: Some(Utc::now() - chrono::Duration::hours(1)),
|
|
refresh_token: None,
|
|
..GrokAuth::test_default()
|
|
};
|
|
writer.update(expired_no_rt.clone()).await.unwrap();
|
|
|
|
let mgr = Arc::new(AuthManager::new(dir.path(), cfg.clone()));
|
|
mgr.hot_swap(expired_no_rt);
|
|
assert!(mgr.is_expired());
|
|
|
|
mgr.set_refresher(std::sync::Arc::new(AlwaysTransientRefresher));
|
|
|
|
// Force device explicitly so the assertion doesn't depend on ambient
|
|
// GROK_LOGIN_DEVICE_FLOW / the real config file (the CLI override
|
|
// short-circuits the config read).
|
|
let result = run_auth_flow(
|
|
&mgr,
|
|
&cfg,
|
|
false,
|
|
None,
|
|
None,
|
|
None,
|
|
LoginTransportOverride::ForceDevice,
|
|
)
|
|
.await;
|
|
|
|
let err = result.unwrap_err();
|
|
// Device flow fall-through hits the device-code endpoint (not OIDC
|
|
// discovery).
|
|
assert!(
|
|
err.to_string().contains("/oauth2/device/code"),
|
|
"expected device-code request error (proves flow fell through to interactive login), got: {err}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_url_from_external_provider_stderr() {
|
|
let extract = |input: &str| -> String {
|
|
input
|
|
.split_whitespace()
|
|
.find(|w| w.starts_with("https://"))
|
|
.map(|u| u.to_owned())
|
|
.unwrap_or_else(|| input.to_owned())
|
|
};
|
|
|
|
// Preamble text with URL
|
|
assert_eq!(
|
|
extract(
|
|
"Visit the following link to sign into Grok: https://auth.example.com/login?code=abc"
|
|
),
|
|
"https://auth.example.com/login?code=abc"
|
|
);
|
|
|
|
// Multi-line with URL on second line
|
|
assert_eq!(
|
|
extract("Please sign in below\nhttps://auth.example.com/sso"),
|
|
"https://auth.example.com/sso"
|
|
);
|
|
|
|
// Just a bare URL
|
|
assert_eq!(
|
|
extract("https://auth.example.com/login"),
|
|
"https://auth.example.com/login"
|
|
);
|
|
|
|
// No URL at all — fallback to full content
|
|
assert_eq!(extract("some opaque output"), "some opaque output");
|
|
}
|
|
|
|
/// CLI `grok login` passes `on_stderr=None`; stderr must be inherited so
|
|
/// sign-in URLs appear in real time. Piped stderr with no reader deadlocks
|
|
/// once the child writes past the pipe buffer (~64 KiB).
|
|
#[tokio::test]
|
|
async fn external_provider_cli_path_does_not_deadlock_on_large_stderr() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mgr = Arc::new(
|
|
AuthManager::new(dir.path(), GrokComConfig::default())
|
|
.with_proxy_base_url(&dead_proxy_url()),
|
|
);
|
|
let cmd = r#"sh -c 'i=0; while [ $i -lt 2000 ]; do printf "%s" "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" >&2; i=$((i+1)); done; printf token'"#;
|
|
let (auth, _) = run_external_auth_provider(cmd, &mgr, false, None)
|
|
.await
|
|
.expect("CLI path must inherit stderr so large stderr does not deadlock");
|
|
assert_eq!(auth.key, "token");
|
|
}
|
|
|
|
struct AlwaysTransientRefresher;
|
|
|
|
#[async_trait::async_trait]
|
|
impl crate::auth::refresh::TokenRefresher for AlwaysTransientRefresher {
|
|
async fn refresh(
|
|
&self,
|
|
_reason: crate::auth::manager::RefreshReason,
|
|
) -> crate::auth::refresh::RefreshOutcome {
|
|
crate::auth::refresh::RefreshOutcome::TransientFailure {
|
|
message: "simulated network failure".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Faithful reproduction of the cached-token bypass: the exact
|
|
/// repro JWT (wrong team) cached in `auth.json` under a pin, driven through
|
|
/// the same `AuthManager::new` + `auth()` engine `try_ensure_fresh_auth`
|
|
/// uses. Must be rejected and cleared; fails on the pre-fix tree.
|
|
#[tokio::test]
|
|
async fn noninteractive_auth_rejects_wrong_team_cached_token() {
|
|
// {"principal_id":"team-wrong","sub":"user-1"} — note: no principal_type.
|
|
const REPRO_JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwcmluY2lwYWxfaWQiOiJ0ZWFtLXdyb25nIiwic3ViIjoidXNlci0xIn0.Signature";
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let cfg = GrokComConfig {
|
|
force_login_team_uuid: Some(crate::auth::config::ForceLoginTeam::AnyOf(vec![
|
|
"team-good".into(),
|
|
])),
|
|
..GrokComConfig::default()
|
|
};
|
|
|
|
// Persist the wrong-team session exactly as the repro's auth.json does.
|
|
let mut store = crate::auth::model::AuthStore::new();
|
|
store.insert(
|
|
cfg.auth_scope(),
|
|
GrokAuth {
|
|
key: REPRO_JWT.into(),
|
|
auth_mode: AuthMode::Oidc,
|
|
team_id: Some("team-wrong".into()),
|
|
expires_at: chrono::DateTime::from_timestamp(9_999_999_999, 0),
|
|
..GrokAuth::test_default()
|
|
},
|
|
);
|
|
let auth_path = dir.path().join("auth.json");
|
|
crate::auth::storage::write_auth_json(&auth_path, &store).unwrap();
|
|
|
|
// Same engine as `try_ensure_fresh_auth`.
|
|
let auth_manager = Arc::new(AuthManager::new(dir.path(), cfg.clone()));
|
|
auth_manager.configure_refresher(cfg.auth_provider_command.clone(), None);
|
|
|
|
assert!(
|
|
auth_manager.auth().await.is_err(),
|
|
"non-interactive auth must reject the wrong-team cached token"
|
|
);
|
|
assert!(
|
|
auth_manager.current().is_none(),
|
|
"wrong-team token must not be usable via current()"
|
|
);
|
|
assert!(
|
|
!auth_path.exists(),
|
|
"wrong-team auth.json must be cleared, forcing a compliant re-login"
|
|
);
|
|
}
|
|
|
|
/// Mock OIDC IdP whose `/token` endpoint never responds, so a refresh
|
|
/// attempt hangs until the caller bounds it.
|
|
async fn start_hanging_oidc_idp() -> (String, tokio::task::JoinHandle<()>) {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port());
|
|
let b = base.clone();
|
|
let app = axum::Router::new()
|
|
.route(
|
|
"/.well-known/openid-configuration",
|
|
axum::routing::get(move || {
|
|
let b = b.clone();
|
|
async move {
|
|
axum::Json(serde_json::json!({
|
|
"authorization_endpoint": format!("{b}/authorize"),
|
|
"token_endpoint": format!("{b}/token"),
|
|
}))
|
|
}
|
|
}),
|
|
)
|
|
.route(
|
|
"/token",
|
|
axum::routing::post(|| async {
|
|
// Never responds: the caller must bound the refresh.
|
|
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
|
|
axum::Json(serde_json::json!({}))
|
|
}),
|
|
);
|
|
let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
|
(base, handle)
|
|
}
|
|
|
|
/// The readiness-path `_no_mint` variant bounds the refresh (~5s) and never
|
|
/// engages the cold-mint fallback, so leader readiness can't block on a
|
|
/// provider command up to the 60s `STARTUP_AUTH_TIMEOUT` cap.
|
|
#[tokio::test]
|
|
async fn no_mint_readiness_auth_is_bounded() {
|
|
let (idp_base, server) = start_hanging_oidc_idp().await;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let cfg = GrokComConfig::default();
|
|
let am = Arc::new(AuthManager::new(dir.path(), cfg.clone()));
|
|
am.configure_refresher(cfg.auth_provider_command.clone(), None);
|
|
am.hot_swap(GrokAuth {
|
|
key: "expired".into(),
|
|
auth_mode: AuthMode::Oidc,
|
|
oidc_issuer: Some(idp_base.clone()),
|
|
oidc_client_id: Some("test-client".into()),
|
|
refresh_token: Some("rt".into()),
|
|
expires_at: Some(Utc::now() - chrono::Duration::hours(1)),
|
|
..GrokAuth::test_default()
|
|
});
|
|
|
|
let started = std::time::Instant::now();
|
|
let result = try_noninteractive_auth_no_mint_with(&am).await;
|
|
let elapsed = started.elapsed();
|
|
|
|
assert!(
|
|
elapsed >= crate::http::STARTUP_AUTH_REFRESH_TIMEOUT,
|
|
"expected a bounded refresh attempt (elapsed {elapsed:?})"
|
|
);
|
|
assert!(
|
|
elapsed < crate::http::STARTUP_AUTH_TIMEOUT,
|
|
"no-mint readiness auth must not engage the 60s cold-mint cap (elapsed {elapsed:?}); readiness would block on a provider command"
|
|
);
|
|
assert!(
|
|
result.is_none(),
|
|
"a non-xAI expired session is no first-party fallback and no mint runs on this path, so no auth is produced"
|
|
);
|
|
|
|
server.abort();
|
|
}
|
|
}
|