229 lines
9.4 KiB
Rust
229 lines
9.4 KiB
Rust
//! Refresh orchestration for a connected xAI / Grok subscription's OAuth
|
|
//! tokens.
|
|
//!
|
|
//! The tokens themselves live in [`ApiKeyManager`] (the request-building
|
|
//! source of truth, persisted to secure storage under `GrokOAuthTokens`).
|
|
//! This module owns the network-facing refresh lifecycle — converting a
|
|
//! [`TokenResponse`] into stored [`GrokTokens`], proactively refreshing the
|
|
//! access token shortly before it expires, and rescheduling the next refresh.
|
|
//!
|
|
//! The Grok subscription is BYO auth, so background refresh follows the BYO
|
|
//! API key policy. That policy lives in the app layer (workspace settings),
|
|
//! which this crate has no visibility into; the app wires it in via
|
|
//! [`ApiKeyManager::set_grok_refresh_allowed`].
|
|
//!
|
|
//! The network/protocol side of the connect flow (authorize URL, loopback
|
|
//! callback server, token exchange/refresh) lives in the [`oauth`] submodule.
|
|
|
|
pub mod oauth;
|
|
|
|
use std::time::{Duration, SystemTime};
|
|
|
|
use galaxyui_core::r#async::Timer;
|
|
use galaxyui_core::ModelContext;
|
|
|
|
use self::oauth::TokenResponse;
|
|
use crate::api_keys::{ApiKeyManager, GrokTokens};
|
|
|
|
/// Refresh the access token this long before its hard expiry so a request
|
|
/// never races the expiration. Possibly-expired tokens are still sent (the
|
|
/// server is the authority on validity), so this lead time is purely about
|
|
/// keeping the token fresh, not about when it stops being sent.
|
|
const REFRESH_LEAD_TIME: Duration = Duration::from_secs(5 * 60);
|
|
|
|
/// Builds [`GrokTokens`] from a token-endpoint [`TokenResponse`], computing the
|
|
/// absolute `expires_at` from the relative `expires_in`. Values not present in
|
|
/// the response are carried over from `previous`: the refresh token when xAI
|
|
/// doesn't return a new one (refresh-token rotation is optional in OAuth 2.0),
|
|
/// and `connected_at` so it keeps reflecting the initial connection time
|
|
/// (initialized to now when there are no previous tokens, i.e. a fresh
|
|
/// connect).
|
|
pub fn grok_tokens_from_response(
|
|
response: TokenResponse,
|
|
previous: Option<&GrokTokens>,
|
|
) -> GrokTokens {
|
|
let expires_at = response
|
|
.expires_in
|
|
.and_then(|secs| u64::try_from(secs).ok())
|
|
.and_then(|secs| SystemTime::now().checked_add(Duration::from_secs(secs)));
|
|
GrokTokens {
|
|
access_token: response.access_token,
|
|
refresh_token: response
|
|
.refresh_token
|
|
.or_else(|| previous.and_then(|tokens| tokens.refresh_token.clone())),
|
|
expires_at,
|
|
connected_at: previous
|
|
.and_then(|tokens| tokens.connected_at)
|
|
.or_else(|| Some(SystemTime::now())),
|
|
}
|
|
}
|
|
|
|
impl ApiKeyManager {
|
|
/// Persists freshly obtained tokens (e.g. right after the connect flow) and
|
|
/// schedules the next proactive refresh.
|
|
pub fn store_grok_tokens(&mut self, response: TokenResponse, ctx: &mut ModelContext<Self>) {
|
|
apply_grok_tokens(self, response, ctx);
|
|
}
|
|
|
|
/// Updates whether background refresh of the stored Grok tokens is
|
|
/// allowed. The Grok subscription is BYO auth, so refresh follows the same
|
|
/// policy gate as request injection ([`Self::api_keys_for_request`]):
|
|
/// tokens that can never be sent shouldn't be kept fresh. The policy lives
|
|
/// in the app layer, which calls this at startup and whenever the policy
|
|
/// may have changed (e.g. team data arriving, or a workspace switch).
|
|
///
|
|
/// Schedules a refresh on a disabled -> enabled transition (refreshing
|
|
/// immediately if the token has already (nearly) expired); in-flight
|
|
/// timers re-check the flag when they fire. Repeated calls with an
|
|
/// unchanged value are no-ops, so duplicate timers can't pile up.
|
|
pub fn set_grok_refresh_allowed(&mut self, allowed: bool, ctx: &mut ModelContext<Self>) {
|
|
if self.grok_refresh_allowed == allowed {
|
|
return;
|
|
}
|
|
self.grok_refresh_allowed = allowed;
|
|
if allowed {
|
|
schedule_grok_token_refresh(self, ctx);
|
|
}
|
|
}
|
|
|
|
/// Request-time safety net: kicks off a background refresh of the stored
|
|
/// Grok tokens when they are nearing (or already past) expiry, so
|
|
/// upcoming requests can authenticate even if the proactive refresh loop
|
|
/// never armed or died (e.g. a stale BYO policy at startup, or an earlier
|
|
/// failed refresh). The triggering request still carries the currently
|
|
/// stored token — the server is the authority on its validity.
|
|
///
|
|
/// `byo_allowed` is the BYO API key policy as freshly evaluated by the
|
|
/// caller at request time. It also re-syncs the stored policy mirror,
|
|
/// which can go stale between `TeamsChanged` events; a disabled ->
|
|
/// enabled transition re-arms the proactive refresh loop.
|
|
pub fn refresh_grok_tokens_if_needed(
|
|
&mut self,
|
|
byo_allowed: bool,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.set_grok_refresh_allowed(byo_allowed, ctx);
|
|
if !byo_allowed || self.grok_refresh_in_flight {
|
|
return;
|
|
}
|
|
let Some(tokens) = self.grok_tokens() else {
|
|
return;
|
|
};
|
|
if !tokens.needs_refresh(REFRESH_LEAD_TIME) {
|
|
return;
|
|
}
|
|
let Some(refresh_token) = tokens.refresh_token.clone() else {
|
|
return;
|
|
};
|
|
log::info!(
|
|
"Grok OAuth token is nearing or past expiry at request time; refreshing in background"
|
|
);
|
|
spawn_grok_refresh(self, refresh_token, ctx);
|
|
}
|
|
}
|
|
|
|
/// Stores the tokens from `response` (carrying over the previous refresh token
|
|
/// and connection time when absent) and schedules the next proactive refresh.
|
|
fn apply_grok_tokens(
|
|
manager: &mut ApiKeyManager,
|
|
response: TokenResponse,
|
|
ctx: &mut ModelContext<ApiKeyManager>,
|
|
) {
|
|
let tokens = grok_tokens_from_response(response, manager.grok_tokens());
|
|
manager.set_grok_tokens(Some(tokens), ctx);
|
|
schedule_grok_token_refresh(manager, ctx);
|
|
}
|
|
|
|
/// Schedules a one-shot proactive refresh [`REFRESH_LEAD_TIME`] before the
|
|
/// current token's expiry (immediately if already within that window).
|
|
///
|
|
/// No-op when there's nothing to refresh against (no tokens, no refresh token,
|
|
/// or no known expiry). Reschedules itself after each successful refresh, so a
|
|
/// single call establishes an ongoing refresh loop for the lifetime of the
|
|
/// connection.
|
|
fn schedule_grok_token_refresh(manager: &mut ApiKeyManager, ctx: &mut ModelContext<ApiKeyManager>) {
|
|
// When the BYO API key policy is disabled the token is never sent, so
|
|
// don't refresh it in the background either. `set_grok_refresh_allowed`
|
|
// re-establishes the loop if the policy is later enabled.
|
|
if !manager.grok_refresh_allowed {
|
|
return;
|
|
}
|
|
let Some(tokens) = manager.grok_tokens() else {
|
|
return;
|
|
};
|
|
let Some(refresh_token) = tokens.refresh_token.clone() else {
|
|
return;
|
|
};
|
|
let Some(expires_at) = tokens.expires_at else {
|
|
// No expiry signal, so there's nothing to schedule against.
|
|
return;
|
|
};
|
|
|
|
let now = SystemTime::now();
|
|
let fire_at = expires_at.checked_sub(REFRESH_LEAD_TIME).unwrap_or(now);
|
|
let delay = fire_at.duration_since(now).unwrap_or(Duration::ZERO);
|
|
|
|
ctx.spawn(
|
|
async move {
|
|
Timer::after(delay).await;
|
|
},
|
|
move |manager, _output, ctx| {
|
|
// The BYO policy may have flipped off while we slept;
|
|
// `set_grok_refresh_allowed` restarts the loop if it flips back
|
|
// on.
|
|
if !manager.grok_refresh_allowed {
|
|
return;
|
|
}
|
|
// The stored token may have changed (reconnect/disconnect) while we
|
|
// slept; only refresh if our refresh token is still the current one.
|
|
let still_current = manager
|
|
.grok_tokens()
|
|
.and_then(|t| t.refresh_token.as_deref())
|
|
== Some(refresh_token.as_str());
|
|
if still_current {
|
|
spawn_grok_refresh(manager, refresh_token, ctx);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Kicks off a background token refresh using `refresh_token`, applying the
|
|
/// result (which reschedules the next refresh) or logging the failure.
|
|
///
|
|
/// No-op when a refresh is already in flight, so the proactive timer and the
|
|
/// request-time safety net can't issue overlapping refreshes.
|
|
fn spawn_grok_refresh(
|
|
manager: &mut ApiKeyManager,
|
|
refresh_token: String,
|
|
ctx: &mut ModelContext<ApiKeyManager>,
|
|
) {
|
|
if manager.grok_refresh_in_flight {
|
|
return;
|
|
}
|
|
manager.grok_refresh_in_flight = true;
|
|
ctx.spawn(
|
|
async move { oauth::refresh_access_token(&refresh_token).await },
|
|
|manager, result, ctx| {
|
|
manager.grok_refresh_in_flight = false;
|
|
match result {
|
|
Ok(response) => {
|
|
log::info!(
|
|
"Refreshed Grok OAuth token (expires_in={:?}, has_refresh_token={})",
|
|
response.expires_in,
|
|
response.refresh_token.is_some(),
|
|
);
|
|
apply_grok_tokens(manager, response, ctx);
|
|
}
|
|
Err(err) => {
|
|
// Leave the existing (possibly expired) token in place; the
|
|
// server remains the authority and will reject it if it's
|
|
// truly invalid. The request-time safety net
|
|
// (`ApiKeyManager::refresh_grok_tokens_if_needed`) retries
|
|
// on the next request.
|
|
log::error!("Failed to refresh Grok OAuth token: {err:#}");
|
|
}
|
|
}
|
|
},
|
|
);
|
|
}
|