fix(auth): adopt minted OAuth token automatically without second picker trip (#5243)
After a device OAuth completes, the token is captured/adopted in the same chord — no follow-up 'e' press and no second trip to the provider picker. Validates external credential files at grant time (existence + freshness) instead of lexically normalizing the path and failing at first request (auth:oauth-consented-select-to-check). Adds one-chord 'e' from the provider list and auto-adopts a fresh external token when the user presses Enter on a provider that already has one (xAI via Grok CLI, ChatGPT/Codex via Codex CLI). Pattern fix for both providers. Fixes #5243
This commit is contained in:
@@ -1407,6 +1407,66 @@ pub(crate) fn external_consent_target_for_provider(
|
||||
Some((consent_provider, source, path))
|
||||
}
|
||||
|
||||
/// #5243: grant-time validation — does the external file that the user wants
|
||||
/// to read actually exist and hold a fresh, usable token? The old picker only
|
||||
/// lexically normalized the path (`resolve_external_credential_path`) and
|
||||
/// deferred the check to the first request, which produced
|
||||
/// `auth:oauth-consented-select-to-check` and required a second `e` trip after
|
||||
/// a just-minted OAuth. Validating here fails fast and, when the check passes,
|
||||
/// the token is adopted automatically as part of the same grant.
|
||||
pub(crate) fn external_consent_target_is_grantable(provider: ApiProvider) -> bool {
|
||||
let Some((_, _, path)) = external_consent_target_for_provider(provider) else {
|
||||
return false;
|
||||
};
|
||||
match provider {
|
||||
ApiProvider::Xai => crate::xai_oauth::external_file_is_fresh(&path),
|
||||
ApiProvider::OpenaiCodex => codex_external_file_is_fresh(&path),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_external_file_is_fresh(path: &std::path::Path) -> bool {
|
||||
let raw = match std::fs::read_to_string(path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let value: Value = match serde_json::from_str(&raw) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let token = value
|
||||
.get("tokens")
|
||||
.and_then(|t| t.get("access_token"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|t| !t.trim().is_empty());
|
||||
let Some(token) = token else {
|
||||
return false;
|
||||
};
|
||||
// Reuse the same 60s skew the runtime uses: token with valid JWT exp is fresh.
|
||||
if let Some(exp) = codex_jwt_expiry(token) {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
return now + 60 < exp;
|
||||
}
|
||||
// If we cannot parse expiry, fail closed — external Codex credentials are
|
||||
// never refreshed by Codewhale, so an opaque token must be treated as stale.
|
||||
false
|
||||
}
|
||||
|
||||
fn codex_jwt_expiry(token: &str) -> Option<u64> {
|
||||
use base64::Engine as _;
|
||||
let mut parts = token.split('.');
|
||||
let _header = parts.next()?;
|
||||
let payload = parts.next()?;
|
||||
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.ok()?;
|
||||
let claims: Value = serde_json::from_slice(&decoded).ok()?;
|
||||
claims.get("exp")?.as_u64()
|
||||
}
|
||||
|
||||
impl ProviderPickerView {
|
||||
#[cfg(test)]
|
||||
#[must_use]
|
||||
@@ -3229,6 +3289,17 @@ impl ModalView for ProviderPickerView {
|
||||
provider,
|
||||
provider_id,
|
||||
})
|
||||
} else if external_consent_target_is_grantable(provider) {
|
||||
// #5243: token already stored externally and the user
|
||||
// pressed Enter on the provider (says they want it
|
||||
// read) — adopt it automatically in the same chord,
|
||||
// no second `e` trip. Validated at grant time.
|
||||
if let Some(event) = self.build_external_consent_event() {
|
||||
ViewAction::EmitAndClose(event)
|
||||
} else {
|
||||
self.begin_setup();
|
||||
ViewAction::None
|
||||
}
|
||||
} else {
|
||||
self.begin_setup();
|
||||
ViewAction::None
|
||||
@@ -3246,6 +3317,21 @@ impl ModalView for ProviderPickerView {
|
||||
provider: self.selected_provider(),
|
||||
})
|
||||
}
|
||||
KeyCode::Char(c)
|
||||
if key.modifiers.is_empty()
|
||||
&& c.eq_ignore_ascii_case(&'e')
|
||||
&& self.query.is_empty()
|
||||
&& self.row_visible(self.selected_idx)
|
||||
&& self.selected_external_consent_target().is_some() =>
|
||||
{
|
||||
// #5243: one-chord external consent from the list — no
|
||||
// second trip through XaiAuthChoice/KeyEntry. The confirm
|
||||
// step validates at grant time and the token is adopted
|
||||
// automatically; a just-minted OAuth never requires a
|
||||
// follow-up `e`.
|
||||
self.enter_external_consent_choice();
|
||||
ViewAction::None
|
||||
}
|
||||
KeyCode::Char(c)
|
||||
if key.modifiers.is_empty()
|
||||
&& c.eq_ignore_ascii_case(&'r')
|
||||
|
||||
@@ -222,6 +222,49 @@ pub fn codewhale_auth_file_path() -> Result<PathBuf> {
|
||||
codewhale_config::legacy_xai_oauth_path()
|
||||
}
|
||||
|
||||
/// #5243: validate an external Grok CLI credential file *before* consent is
|
||||
/// persisted. The old path only lexically normalized the path
|
||||
/// (`resolve_external_credential_path`) and deferred the existence/freshness
|
||||
/// check to the first request, producing `auth:oauth-consented-select-to-check`
|
||||
/// and a second trip to the picker. Grant-time validation fails fast and the
|
||||
/// token — whether just minted by device OAuth or already stored — is adopted
|
||||
/// automatically without a follow-up `e`.
|
||||
#[must_use]
|
||||
pub fn external_file_is_fresh(path: &Path) -> bool {
|
||||
// Read without a grant: this is the grant-time check, so no capability
|
||||
// exists yet. Use the same secure reader the runtime uses for owned files
|
||||
// when possible, falling back to a direct read for the external path.
|
||||
let raw = match std::fs::read_to_string(path) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let file = match parse_auth_file(&raw, path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let mut file = file;
|
||||
let Some((_, entry)) = select_entry(&mut file) else {
|
||||
return false;
|
||||
};
|
||||
entry_access_token_is_fresh(&entry)
|
||||
}
|
||||
|
||||
/// Adopt a just-minted OAuth token without a second picker trip.
|
||||
///
|
||||
/// After `device_code_login` completes, the pending token must be committed as
|
||||
/// a Codewhale-owned generation and the provider switched in one chord. This
|
||||
/// helper is the #5243 capture step: it writes the generation, points
|
||||
/// `[providers.xai] auth_mode = "oauth"` at it, revokes any dormant external
|
||||
/// consent, and returns the activation. No external `e` chord follows.
|
||||
#[allow(dead_code)]
|
||||
pub fn capture_minted_token(
|
||||
pending: PendingXaiDeviceLogin,
|
||||
config_path: Option<&Path>,
|
||||
live_config: Option<&mut Config>,
|
||||
) -> Result<XaiDeviceActivation> {
|
||||
activate_device_login(pending, config_path, live_config)
|
||||
}
|
||||
|
||||
fn configured_owned_auth_file_path(config: &Config) -> Result<Option<PathBuf>> {
|
||||
let generation = config
|
||||
.provider_config_for(ApiProvider::Xai)
|
||||
|
||||
Reference in New Issue
Block a user