//! Generic RFC-8628 device-code OAuth wire, driven by a registry //! [`kigi_models::OAuthConfig`] (xai-grok today; Copilot/Claude later). //! //! Three `application/x-www-form-urlencoded` POSTs against `{auth_host}`: //! //! - `POST {device_path}` — form `client_id` + `scope` + the optional //! `extra_device_field` (e.g. `referrer=kigi`) //! - `POST {token_path}` (poll) — form `client_id` + `device_code` + //! `grant_type=urn:ietf:params:oauth:grant-type:device_code` //! - `POST {token_path}` (refresh) — form `client_id` + //! `grant_type=refresh_token` + `refresh_token`, with the same exponential //! backoff / status handling as the Kimi wire. //! //! Unlike [`super::kimi_oauth`] this sends NO X-Msh device headers — just the //! shared kigi `User-Agent` and `Accept: application/json`. Access/refresh //! tokens are NEVER logged (only non-secret events: requested, poll succeeded, //! refreshed). use kigi_models::OAuthConfig; use serde::Deserialize; use super::kimi_oauth::{ DeviceAuthorization, DevicePollResult, RefreshError, TokenResponse, validate_verification_uri, }; const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code"; const REFRESH_GRANT_TYPE: &str = "refresh_token"; /// Refresh retry budget over the retryable statuses / network blips. const MAX_REFRESH_RETRIES: u32 = 3; /// HTTP statuses worth retrying a refresh for (kimi-cli parity). const RETRYABLE_REFRESH_STATUSES: [u16; 5] = [429, 500, 502, 503, 504]; #[derive(Deserialize)] struct DeviceAuthorizationResponse { user_code: String, device_code: String, #[serde(default)] verification_uri: Option, /// Optional here (the Kimi wire requires it): Pi's xAI response may omit /// `verification_uri_complete` and carry only `verification_uri`. #[serde(default)] verification_uri_complete: Option, #[serde(default)] expires_in: Option, #[serde(default)] interval: Option, } #[derive(Deserialize, Default)] struct OAuthErrorBody { #[serde(default)] error: Option, #[serde(default)] error_description: Option, } fn oauth_url(host: &str, path: &str) -> String { format!("{}{path}", host.trim_end_matches('/')) } /// The device-authorization form fields: `client_id`, `scope`, and the /// optional non-standard `extra_device_field`. fn device_form(cfg: &OAuthConfig) -> Vec<(&'static str, &'static str)> { let mut form = vec![("client_id", cfg.client_id), ("scope", cfg.scope)]; if let Some((name, value)) = cfg.extra_device_field { form.push((name, value)); } form } /// `POST {auth_host}{device_path}` — start a device login. pub(crate) async fn request_device_authorization( cfg: &OAuthConfig, ) -> anyhow::Result { let url = oauth_url(cfg.auth_host, cfg.device_path); tracing::info!(url = %url, "auth: requesting device authorization (generic oauth)"); let resp = crate::http::shared_client() .post(&url) .header("Accept", "application/json") .form(&device_form(cfg)) .send() .await?; let status = resp.status(); if !status.is_success() { let body = resp.text().await.unwrap_or_default(); tracing::warn!(%status, "auth: device authorization failed (generic oauth)"); anyhow::bail!("Device authorization failed (HTTP {status}): {body}"); } let parsed: DeviceAuthorizationResponse = resp.json().await?; if !parsed .user_code .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-') { anyhow::bail!("Server returned invalid user_code format (expected [A-Z0-9-])"); } // Pi forces the displayed URI to https; we require a valid https (or // localhost) verification target, preferring the pre-filled complete form. let verification_uri_complete = parsed .verification_uri_complete .clone() .or_else(|| parsed.verification_uri.clone()) .ok_or_else(|| anyhow::anyhow!("Server returned no verification URI"))?; validate_verification_uri(&verification_uri_complete)?; if let Some(ref uri) = parsed.verification_uri { validate_verification_uri(uri)?; } tracing::info!( user_code = %parsed.user_code, interval = parsed.interval.unwrap_or(5), expires_in = ?parsed.expires_in, "auth: device authorization issued (generic oauth)" ); Ok(DeviceAuthorization { user_code: parsed.user_code, device_code: parsed.device_code, verification_uri: parsed.verification_uri.filter(|u| !u.is_empty()), verification_uri_complete, expires_in: parsed.expires_in.filter(|&e| e > 0), interval: parsed.interval.unwrap_or(5), }) } /// One poll of `POST {auth_host}{token_path}` with the device grant. pub(crate) async fn poll_device_token( cfg: &OAuthConfig, device_code: &str, ) -> anyhow::Result { let url = oauth_url(cfg.auth_host, cfg.token_path); let resp = crate::http::shared_client() .post(&url) .header("Accept", "application/json") .form(&[ ("client_id", cfg.client_id), ("device_code", device_code), ("grant_type", DEVICE_GRANT_TYPE), ]) .send() .await .map_err(|e| anyhow::anyhow!("Token polling request failed: {e}"))?; let status = resp.status(); if status.is_server_error() { anyhow::bail!("Token polling server error: {status}"); } let body = resp.bytes().await?; if status.is_success() { if let Ok(tokens) = serde_json::from_slice::(&body) { tracing::info!("auth: device poll succeeded, access token issued (generic oauth)"); return Ok(DevicePollResult::Success(Box::new(tokens.into_auth()))); } tracing::warn!( "auth: device poll returned 200 without access_token; continuing (generic oauth)" ); return Ok(DevicePollResult::Pending { error: "missing_access_token".to_owned(), description: None, }); } let err: OAuthErrorBody = serde_json::from_slice(&body).unwrap_or_default(); let error = err.error.unwrap_or_else(|| "unknown_error".to_owned()); if error == "expired_token" { tracing::info!( "auth: device code expired; restarting device authorization (generic oauth)" ); return Ok(DevicePollResult::Expired); } tracing::debug!(error = %error, "auth: device poll pending (generic oauth)"); Ok(DevicePollResult::Pending { error, description: err.error_description, }) } /// `POST {auth_host}{token_path}` with `grant_type=refresh_token`. Retries the /// retryable statuses / network errors with exponential backoff; 401/403 /// returns immediately as [`RefreshError::Unauthorized`]. pub(crate) async fn refresh_token( cfg: &OAuthConfig, refresh_token: &str, ) -> Result { let url = oauth_url(cfg.auth_host, cfg.token_path); let mut last_error = String::from("no attempt made"); for attempt in 0..MAX_REFRESH_RETRIES { if attempt > 0 { let backoff = std::time::Duration::from_secs(1 << (attempt - 1)); tracing::warn!( attempt, backoff_secs = backoff.as_secs(), last_error = %last_error, "auth: retrying token refresh (generic oauth)" ); tokio::time::sleep(backoff).await; } tracing::info!(attempt, "auth: token refresh attempt (generic oauth)"); let send_result = crate::http::shared_client() .post(&url) .header("Accept", "application/json") .form(&[ ("client_id", cfg.client_id), ("grant_type", REFRESH_GRANT_TYPE), ("refresh_token", refresh_token), ]) .send() .await; let resp = match send_result { Ok(resp) => resp, Err(e) => { last_error = format!("network error: {e}"); continue; } }; let status = resp.status().as_u16(); let body = resp.bytes().await.unwrap_or_default(); if status == 401 || status == 403 { let err: OAuthErrorBody = serde_json::from_slice(&body).unwrap_or_default(); return Err(RefreshError::Unauthorized { status, description: err .error_description .unwrap_or_else(|| "Token refresh unauthorized.".to_owned()), }); } if status == 200 { return match serde_json::from_slice::(&body) { Ok(tokens) => Ok(tokens.into_auth()), Err(e) => Err(RefreshError::Fatal { status, description: format!("malformed token payload: {e}"), }), }; } let err: OAuthErrorBody = serde_json::from_slice(&body).unwrap_or_default(); let description = err .error_description .unwrap_or_else(|| format!("Token refresh failed (HTTP {status}).")); if RETRYABLE_REFRESH_STATUSES.contains(&status) { last_error = description; continue; } return Err(RefreshError::Fatal { status, description, }); } Err(RefreshError::Exhausted { last_error }) } #[cfg(test)] mod tests { use super::*; use kigi_models::XAI_OAUTH_CONFIG; use wiremock::matchers::{body_string_contains, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; /// An OAuthConfig pointed at a mock server (copies XAI's client_id/scope/ /// paths but overrides the host). fn mock_cfg(host: &'static str) -> OAuthConfig { OAuthConfig { auth_host: host, ..XAI_OAUTH_CONFIG } } fn token_json(access: &str, refresh: &str) -> serde_json::Value { serde_json::json!({ "access_token": access, "refresh_token": refresh, "expires_in": 3600, "scope": "grok-cli:access", "token_type": "bearer", }) } #[tokio::test] async fn device_authorization_sends_client_scope_and_referrer() { let server = MockServer::start().await; let host: &'static str = Box::leak(server.uri().into_boxed_str()); Mock::given(method("POST")) .and(path("/oauth2/device/code")) .and(body_string_contains( "client_id=b1a00492-073a-47ea-816f-4c329264a828", )) .and(body_string_contains("scope=openid")) .and(body_string_contains("referrer=kigi")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "user_code": "GROK-1234", "device_code": "dev-xai-1", "verification_uri": "https://x.ai/device", "verification_uri_complete": "https://x.ai/device?user_code=GROK-1234", "expires_in": 900, "interval": 5, }))) .expect(1) .mount(&server) .await; let auth = request_device_authorization(&mock_cfg(host)).await.unwrap(); assert_eq!(auth.user_code, "GROK-1234"); assert_eq!(auth.device_code, "dev-xai-1"); assert_eq!( auth.verification_uri_complete, "https://x.ai/device?user_code=GROK-1234" ); assert_eq!(auth.expires_in, Some(900)); } /// A response with only `verification_uri` (no `_complete`) still yields a /// valid display URI. #[tokio::test] async fn device_authorization_falls_back_to_verification_uri() { let server = MockServer::start().await; let host: &'static str = Box::leak(server.uri().into_boxed_str()); Mock::given(method("POST")) .and(path("/oauth2/device/code")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "user_code": "GROK-9", "device_code": "d", "verification_uri": "https://x.ai/device", }))) .mount(&server) .await; let auth = request_device_authorization(&mock_cfg(host)).await.unwrap(); assert_eq!(auth.verification_uri_complete, "https://x.ai/device"); assert_eq!(auth.interval, 5, "default interval"); } #[tokio::test] async fn poll_success_builds_auth_with_expiry() { let server = MockServer::start().await; let host: &'static str = Box::leak(server.uri().into_boxed_str()); Mock::given(method("POST")) .and(path("/oauth2/token")) .and(body_string_contains("grant_type=urn")) .and(body_string_contains("device_code=dev-xai-1")) .respond_with( ResponseTemplate::new(200).set_body_json(token_json("grok-at", "grok-rt")), ) .expect(1) .mount(&server) .await; let result = poll_device_token(&mock_cfg(host), "dev-xai-1") .await .unwrap(); let DevicePollResult::Success(auth) = result else { panic!("expected success, got {result:?}"); }; assert_eq!(auth.key, "grok-at"); assert_eq!(auth.refresh_token.as_deref(), Some("grok-rt")); assert_eq!(auth.expires_in, Some(3600)); } #[tokio::test] async fn poll_maps_authorization_pending_to_pending() { let server = MockServer::start().await; let host: &'static str = Box::leak(server.uri().into_boxed_str()); Mock::given(method("POST")) .and(path("/oauth2/token")) .respond_with( ResponseTemplate::new(400) .set_body_json(serde_json::json!({ "error": "authorization_pending" })), ) .mount(&server) .await; let result = poll_device_token(&mock_cfg(host), "dev-xai-1") .await .unwrap(); match result { DevicePollResult::Pending { error, .. } => assert_eq!(error, "authorization_pending"), other => panic!("expected pending, got {other:?}"), } } #[tokio::test] async fn refresh_success_round_trip() { let server = MockServer::start().await; let host: &'static str = Box::leak(server.uri().into_boxed_str()); Mock::given(method("POST")) .and(path("/oauth2/token")) .and(body_string_contains("grant_type=refresh_token")) .and(body_string_contains("refresh_token=grok-rt-old")) .respond_with( ResponseTemplate::new(200).set_body_json(token_json("grok-at-new", "grok-rt-new")), ) .expect(1) .mount(&server) .await; let auth = refresh_token(&mock_cfg(host), "grok-rt-old").await.unwrap(); assert_eq!(auth.key, "grok-at-new"); assert_eq!(auth.refresh_token.as_deref(), Some("grok-rt-new")); } #[tokio::test] async fn refresh_401_maps_to_unauthorized() { let server = MockServer::start().await; let host: &'static str = Box::leak(server.uri().into_boxed_str()); Mock::given(method("POST")) .and(path("/oauth2/token")) .respond_with( ResponseTemplate::new(401) .set_body_json(serde_json::json!({ "error_description": "refresh revoked" })), ) .expect(1) .mount(&server) .await; let err = refresh_token(&mock_cfg(host), "grok-rt-dead") .await .unwrap_err(); match err { RefreshError::Unauthorized { status, description, } => { assert_eq!(status, 401); assert_eq!(description, "refresh revoked"); } other => panic!("expected Unauthorized, got {other:?}"), } } }