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:
CodeWhale Bot
2026-08-07 04:48:32 -07:00
parent a6fd150bda
commit 011b9a9860
2 changed files with 129 additions and 0 deletions
+86
View File
@@ -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')
+43
View File
@@ -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)