M1/F1: Kimi Code OAuth device-code flow
Replace the xAI OAuth stack with the Kimi device authorization grant:
- kimi_oauth.rs wire layer (device_authorization + token poll + refresh
against kigi_env::oauth_host(); client_id per PRD; retryable statuses
429/5xx with backoff; expired_token restarts authorization)
- X-Msh-Device-{Name,Model,Id} headers; device_id minted uuid4-hex at
~/.kigi/device_id (0600)
- Storage: system keyring service `kigi`, entry `oauth/kimi-code`
(macOS/Windows native backends), atomic-file fallback under ~/.kigi;
official client's keyring/~/.kimi never touched
- Refresh manager: 60s tick, threshold max(300, expires_in*0.5),
401-tombstone keyed by rejected refresh token with 300s cooldown and
rotation auto-clear, cross-process lock with sibling-adoption
triple-check, sleep/wake forced refresh
- Deleted xAI machinery: enterprise OIDC (PKCE/JWKS/teams), devbox login,
external auth provider, JWT tier gating + subscription paywall stack,
X-XAI-Token-Auth marker headers, ZDR gates, /user enrichment
- kigi login / TUI /login both drive the device flow; login-host display
now derives from kigi_env::oauth_host()
- 264 auth unit/wiremock tests; live contract probe of
auth.kimi.com/api/oauth/device_authorization matches the wire shapes
Gates: check/clippy --all-targets clean, fmt, deny ok, kigi-shell lib
5131 tests green.
This commit is contained in:
@@ -0,0 +1,626 @@
|
||||
//! Kimi Code OAuth wire protocol (PRD F1).
|
||||
//!
|
||||
//! Three calls against `{host}` (= `kigi_env::oauth_host()`), all
|
||||
//! `application/x-www-form-urlencoded` POSTs carrying the device-identity
|
||||
//! headers from [`super::device`]:
|
||||
//!
|
||||
//! - `POST /api/oauth/device_authorization` — form `client_id`
|
||||
//! - `POST /api/oauth/token` (poll) — form `client_id` + `device_code` +
|
||||
//! `grant_type=urn:ietf:params:oauth:grant-type:device_code`
|
||||
//! - `POST /api/oauth/token` (refresh) — form `client_id` +
|
||||
//! `grant_type=refresh_token` + `refresh_token`, with exponential backoff
|
||||
//! over the retryable statuses {429, 500, 502, 503, 504} (3 tries) and
|
||||
//! 401/403 mapped to [`RefreshError::Unauthorized`].
|
||||
//!
|
||||
//! Ported from kimi-cli `auth/oauth.py` (the authoritative reference).
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::device::device_headers;
|
||||
use super::model::{AuthMode, KimiAuth};
|
||||
|
||||
/// Kimi Code OAuth client id (fixed for the official device-flow client).
|
||||
pub(crate) const KIMI_CODE_CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098";
|
||||
|
||||
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];
|
||||
|
||||
/// Result of `POST /api/oauth/device_authorization`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeviceAuthorization {
|
||||
pub user_code: String,
|
||||
pub device_code: String,
|
||||
/// Bare verification page (may be absent; the complete URI is required).
|
||||
pub verification_uri: Option<String>,
|
||||
/// Verification page with the user code pre-filled — what we display
|
||||
/// and open in the browser.
|
||||
pub verification_uri_complete: String,
|
||||
/// Device-code lifetime; `None` when the server omits it.
|
||||
pub expires_in: Option<i64>,
|
||||
/// Poll interval in seconds (server default 5; floored at 1 by callers).
|
||||
pub interval: i64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DeviceAuthorizationResponse {
|
||||
user_code: String,
|
||||
device_code: String,
|
||||
#[serde(default)]
|
||||
verification_uri: Option<String>,
|
||||
verification_uri_complete: String,
|
||||
#[serde(default)]
|
||||
expires_in: Option<i64>,
|
||||
#[serde(default)]
|
||||
interval: Option<i64>,
|
||||
}
|
||||
|
||||
/// Successful token payload (device grant and refresh grant share it).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct TokenResponse {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub expires_in: i64,
|
||||
#[serde(default)]
|
||||
pub scope: Option<String>,
|
||||
#[serde(default)]
|
||||
pub token_type: Option<String>,
|
||||
}
|
||||
|
||||
impl TokenResponse {
|
||||
/// Materialize the credential: `expires_at = now + expires_in`.
|
||||
pub(crate) fn into_auth(self) -> KimiAuth {
|
||||
let now = Utc::now();
|
||||
KimiAuth {
|
||||
key: self.access_token,
|
||||
auth_mode: AuthMode::OAuth,
|
||||
create_time: now,
|
||||
user_id: String::new(),
|
||||
email: None,
|
||||
refresh_token: Some(self.refresh_token),
|
||||
expires_at: Some(now + Duration::seconds(self.expires_in)),
|
||||
expires_in: Some(self.expires_in),
|
||||
scope: self.scope,
|
||||
token_type: self.token_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct OAuthErrorBody {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
#[serde(default)]
|
||||
error_description: Option<String>,
|
||||
}
|
||||
|
||||
/// One poll tick against the token endpoint.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum DevicePollResult {
|
||||
/// 200 with an access token — login complete.
|
||||
Success(Box<KimiAuth>),
|
||||
/// `error == "expired_token"` — restart the whole device authorization.
|
||||
Expired,
|
||||
/// Any other non-200 outcome (`authorization_pending`, `slow_down`,
|
||||
/// unknown errors) — wait and poll again. `slow_down` additionally bumps
|
||||
/// the caller's interval.
|
||||
Pending {
|
||||
error: String,
|
||||
description: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn oauth_url(host: &str, path: &str) -> String {
|
||||
format!("{}{path}", host.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
/// Attach the device-identity headers to a request.
|
||||
fn with_device_headers(
|
||||
mut builder: reqwest::RequestBuilder,
|
||||
) -> anyhow::Result<reqwest::RequestBuilder> {
|
||||
for (name, value) in device_headers()? {
|
||||
builder = builder.header(name, value);
|
||||
}
|
||||
Ok(builder)
|
||||
}
|
||||
|
||||
/// Defend against control characters / non-https redirects from a
|
||||
/// compromised or mis-configured OAuth host.
|
||||
fn validate_verification_uri(uri: &str) -> anyhow::Result<()> {
|
||||
if uri.chars().any(|c| c.is_ascii_control()) {
|
||||
anyhow::bail!("Server returned invalid verification URI");
|
||||
}
|
||||
let parsed = url::Url::parse(uri)
|
||||
.map_err(|_| anyhow::anyhow!("Server returned invalid verification URI"))?;
|
||||
match parsed.scheme() {
|
||||
"https" => Ok(()),
|
||||
"http" if matches!(parsed.host_str(), Some("localhost") | Some("127.0.0.1")) => Ok(()),
|
||||
_ => anyhow::bail!("Server returned unsupported verification URI scheme"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST {host}/api/oauth/device_authorization` — start a device login.
|
||||
pub(crate) async fn request_device_authorization(
|
||||
host: &str,
|
||||
) -> anyhow::Result<DeviceAuthorization> {
|
||||
let url = oauth_url(host, "/api/oauth/device_authorization");
|
||||
tracing::info!(url = %url, "auth: requesting device authorization");
|
||||
let resp = with_device_headers(crate::http::shared_client().post(&url))?
|
||||
.form(&[("client_id", KIMI_CODE_CLIENT_ID)])
|
||||
.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");
|
||||
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-])");
|
||||
}
|
||||
validate_verification_uri(&parsed.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"
|
||||
);
|
||||
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: parsed.verification_uri_complete,
|
||||
expires_in: parsed.expires_in.filter(|&e| e > 0),
|
||||
interval: parsed.interval.unwrap_or(5),
|
||||
})
|
||||
}
|
||||
|
||||
/// One poll of `POST {host}/api/oauth/token` with the device grant.
|
||||
///
|
||||
/// 5xx and network/decode failures are errors (kimi-cli parity: the login
|
||||
/// loop surfaces them); everything else maps onto [`DevicePollResult`].
|
||||
pub(crate) async fn poll_device_token(
|
||||
host: &str,
|
||||
device_code: &str,
|
||||
) -> anyhow::Result<DevicePollResult> {
|
||||
let url = oauth_url(host, "/api/oauth/token");
|
||||
let resp = with_device_headers(crate::http::shared_client().post(&url))?
|
||||
.form(&[
|
||||
("client_id", KIMI_CODE_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::<TokenResponse>(&body) {
|
||||
tracing::info!("auth: device poll succeeded, access token issued");
|
||||
return Ok(DevicePollResult::Success(Box::new(tokens.into_auth())));
|
||||
}
|
||||
// 200 without an access token: treat as still-pending (kimi-cli
|
||||
// requires "access_token" in the payload before accepting).
|
||||
tracing::warn!("auth: device poll returned 200 without access_token; continuing");
|
||||
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");
|
||||
return Ok(DevicePollResult::Expired);
|
||||
}
|
||||
tracing::debug!(error = %error, "auth: device poll pending");
|
||||
Ok(DevicePollResult::Pending {
|
||||
error,
|
||||
description: err.error_description,
|
||||
})
|
||||
}
|
||||
|
||||
/// Why a refresh call terminally or transiently failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum RefreshError {
|
||||
/// 401/403 — the refresh token was rejected. Triggers the tombstone
|
||||
/// cooldown in the manager.
|
||||
#[error("token refresh unauthorized (HTTP {status}): {description}")]
|
||||
Unauthorized { status: u16, description: String },
|
||||
/// Non-retryable non-200 status.
|
||||
#[error("token refresh failed (HTTP {status}): {description}")]
|
||||
Fatal { status: u16, description: String },
|
||||
/// Retry budget exhausted over retryable statuses / network blips.
|
||||
#[error("token refresh failed after {MAX_REFRESH_RETRIES} attempts: {last_error}")]
|
||||
Exhausted { last_error: String },
|
||||
/// Local failure before the wire (e.g. device-id creation failed).
|
||||
#[error(transparent)]
|
||||
Local(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
/// `POST {host}/api/oauth/token` with `grant_type=refresh_token`.
|
||||
///
|
||||
/// Retries the retryable statuses and network errors with exponential
|
||||
/// backoff (`2^attempt` seconds); 401/403 returns immediately as
|
||||
/// [`RefreshError::Unauthorized`].
|
||||
pub(crate) async fn refresh_token(
|
||||
host: &str,
|
||||
refresh_token: &str,
|
||||
) -> Result<KimiAuth, RefreshError> {
|
||||
let url = oauth_url(host, "/api/oauth/token");
|
||||
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"
|
||||
);
|
||||
tokio::time::sleep(backoff).await;
|
||||
}
|
||||
tracing::info!(attempt, "auth: token refresh attempt");
|
||||
let send_result = with_device_headers(crate::http::shared_client().post(&url))?
|
||||
.form(&[
|
||||
("client_id", KIMI_CODE_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::<TokenResponse>(&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 wiremock::matchers::{body_string_contains, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn token_json(access: &str, refresh: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"access_token": access,
|
||||
"refresh_token": refresh,
|
||||
"expires_in": 3600,
|
||||
"scope": "kimi-code",
|
||||
"token_type": "bearer",
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn device_authorization_parses_wire_payload() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/oauth/device_authorization"))
|
||||
.and(body_string_contains(format!(
|
||||
"client_id={KIMI_CODE_CLIENT_ID}"
|
||||
)))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"user_code": "ABCD-1234",
|
||||
"device_code": "dev-code-1",
|
||||
"verification_uri": "https://auth.kimi.com/device",
|
||||
"verification_uri_complete": "https://auth.kimi.com/device?code=ABCD-1234",
|
||||
"expires_in": 600,
|
||||
"interval": 7,
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let auth = request_device_authorization(&server.uri()).await.unwrap();
|
||||
assert_eq!(auth.user_code, "ABCD-1234");
|
||||
assert_eq!(auth.device_code, "dev-code-1");
|
||||
assert_eq!(auth.interval, 7);
|
||||
assert_eq!(auth.expires_in, Some(600));
|
||||
assert_eq!(
|
||||
auth.verification_uri_complete,
|
||||
"https://auth.kimi.com/device?code=ABCD-1234"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn device_authorization_sends_device_headers() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/oauth/device_authorization"))
|
||||
.and(wiremock::matchers::header_exists("X-Msh-Device-Name"))
|
||||
.and(wiremock::matchers::header_exists("X-Msh-Device-Model"))
|
||||
.and(wiremock::matchers::header_exists("X-Msh-Device-Id"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"user_code": "AAAA",
|
||||
"device_code": "d",
|
||||
"verification_uri_complete": "https://auth.kimi.com/device?code=AAAA",
|
||||
"interval": 5,
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
request_device_authorization(&server.uri()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn device_authorization_defaults_interval_to_five() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/oauth/device_authorization"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"user_code": "AAAA",
|
||||
"device_code": "d",
|
||||
"verification_uri_complete": "https://auth.kimi.com/device?code=AAAA",
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let auth = request_device_authorization(&server.uri()).await.unwrap();
|
||||
assert_eq!(auth.interval, 5);
|
||||
assert_eq!(auth.expires_in, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn device_authorization_rejects_bad_verification_uri() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/oauth/device_authorization"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"user_code": "AAAA",
|
||||
"device_code": "d",
|
||||
"verification_uri_complete": "javascript:alert(1)",
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let err = request_device_authorization(&server.uri())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("verification URI"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn device_authorization_surfaces_http_errors() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/oauth/device_authorization"))
|
||||
.respond_with(ResponseTemplate::new(400).set_body_string("nope"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let err = request_device_authorization(&server.uri())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("HTTP 400"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_success_builds_auth_with_expiry() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/oauth/token"))
|
||||
.and(body_string_contains("grant_type=urn"))
|
||||
.and(body_string_contains("device_code=dev-1"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(token_json("at-1", "rt-1")))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let result = poll_device_token(&server.uri(), "dev-1").await.unwrap();
|
||||
let DevicePollResult::Success(auth) = result else {
|
||||
panic!("expected success, got {result:?}");
|
||||
};
|
||||
assert_eq!(auth.key, "at-1");
|
||||
assert_eq!(auth.refresh_token.as_deref(), Some("rt-1"));
|
||||
assert_eq!(auth.expires_in, Some(3600));
|
||||
let remaining = auth.expires_at.unwrap() - chrono::Utc::now();
|
||||
assert!(
|
||||
(3590..=3600).contains(&remaining.num_seconds()),
|
||||
"expires_at must be ~now+expires_in, got {remaining:?}"
|
||||
);
|
||||
assert_eq!(auth.scope.as_deref(), Some("kimi-code"));
|
||||
assert_eq!(auth.token_type.as_deref(), Some("bearer"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_maps_expired_token_to_restart() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/oauth/token"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(400)
|
||||
.set_body_json(serde_json::json!({ "error": "expired_token" })),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let result = poll_device_token(&server.uri(), "dev-1").await.unwrap();
|
||||
assert!(matches!(result, DevicePollResult::Expired), "{result:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_maps_pending_and_unknown_errors_to_pending() {
|
||||
let server = MockServer::start().await;
|
||||
for error in ["authorization_pending", "slow_down", "surprise_error"] {
|
||||
server.reset().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/oauth/token"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(400).set_body_json(serde_json::json!({ "error": error })),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let result = poll_device_token(&server.uri(), "dev-1").await.unwrap();
|
||||
match result {
|
||||
DevicePollResult::Pending { error: got, .. } => assert_eq!(got, error),
|
||||
other => panic!("expected pending for {error}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn poll_server_error_is_an_error() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(502))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let err = poll_device_token(&server.uri(), "dev-1").await.unwrap_err();
|
||||
assert!(err.to_string().contains("server error"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_success_round_trip() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/oauth/token"))
|
||||
.and(body_string_contains("grant_type=refresh_token"))
|
||||
.and(body_string_contains("refresh_token=rt-old"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(token_json("at-new", "rt-new")))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let auth = refresh_token(&server.uri(), "rt-old").await.unwrap();
|
||||
assert_eq!(auth.key, "at-new");
|
||||
assert_eq!(auth.refresh_token.as_deref(), Some("rt-new"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_401_maps_to_unauthorized_without_retry() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/oauth/token"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(401).set_body_json(
|
||||
serde_json::json!({ "error_description": "refresh token revoked" }),
|
||||
),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let err = refresh_token(&server.uri(), "rt-dead").await.unwrap_err();
|
||||
match err {
|
||||
RefreshError::Unauthorized {
|
||||
status,
|
||||
description,
|
||||
} => {
|
||||
assert_eq!(status, 401);
|
||||
assert_eq!(description, "refresh token revoked");
|
||||
}
|
||||
other => panic!("expected Unauthorized, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_retries_retryable_status_then_succeeds() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(503))
|
||||
.up_to_n_times(1)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(token_json("at-2", "rt-2")))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let auth = refresh_token(&server.uri(), "rt-old").await.unwrap();
|
||||
assert_eq!(auth.key, "at-2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_exhausts_after_three_retryable_failures() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(500))
|
||||
.expect(3)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let err = refresh_token(&server.uri(), "rt-old").await.unwrap_err();
|
||||
assert!(matches!(err, RefreshError::Exhausted { .. }), "{err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_non_retryable_status_is_fatal_without_retry() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/oauth/token"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(400)
|
||||
.set_body_json(serde_json::json!({ "error_description": "bad request" })),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let err = refresh_token(&server.uri(), "rt-old").await.unwrap_err();
|
||||
match err {
|
||||
RefreshError::Fatal {
|
||||
status,
|
||||
description,
|
||||
} => {
|
||||
assert_eq!(status, 400);
|
||||
assert_eq!(description, "bad request");
|
||||
}
|
||||
other => panic!("expected Fatal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user