Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -865,9 +865,17 @@ async fn exchange_refresh_token(
.as_deref()
.map_or_else(|| scopes.to_vec(), parse_scopes);

// RFC 6749 §6: the server MAY issue a new refresh token, in which case
// the client MUST discard the old one and replace it with the new one.
// When the server omits refresh_token from the response, the original
// refresh token remains valid and must be preserved.
let resolved_refresh_token = token_response
.refresh_token
.or_else(|| Some(refresh_token.to_string()));

Ok(crate::token::TokenSet::new(
token_response.access_token,
token_response.refresh_token,
resolved_refresh_token,
expires_at,
token_response
.token_type
Expand Down
53 changes: 53 additions & 0 deletions src/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,34 @@ impl FakeOAuthServer {
}
}

/// Like `start_with_refresh`, but the /token endpoint omits `refresh_token` from
/// the response body — matching the behavior of providers like Google that keep the
/// original refresh token valid without echoing it back (RFC 6749 §6).
pub async fn start_with_refresh_token_omitted_from_response(
token_value: impl Into<String>,
) -> Self {
let state = Arc::new((token_value.into(), DEFAULT_TOKEN_EXPIRY_SECS));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();

let state_clone = Arc::clone(&state);
let app = Router::new()
.route("/authorize", get(authorize_handler))
.route("/token", post(refresh_no_rt_handler))
.with_state(state_clone);

tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});

Self {
port,
access_token: state.0.clone(),
refresh_token: String::new(),
rsa_private_key: None,
}
}

/// Like `start`, but the /token endpoint includes an `id_token` in the response.
///
/// The `nonce` sent in the authorization request is captured and included in the
Expand Down Expand Up @@ -450,6 +478,31 @@ async fn refresh_token_handler(
}))
}

/// Like `refresh_token_handler` but omits `refresh_token` from the response,
/// simulating providers (e.g. Google) that don't echo the refresh token back.
async fn refresh_no_rt_handler(
State(state): State<Arc<(String, u64)>>,
Form(body): Form<HashMap<String, String>>,
) -> Result<Json<FakeTokenResponse>, StatusCode> {
let grant_type = body.get("grant_type").map_or("", String::as_str);

match grant_type {
"refresh_token" => match body.get("refresh_token") {
Some(rt) if !rt.is_empty() => {}
_ => return Err(StatusCode::BAD_REQUEST),
},
_ => return Err(StatusCode::BAD_REQUEST),
}

Ok(Json(FakeTokenResponse {
access_token: state.0.clone(),
token_type: "Bearer".to_string(),
expires_in: state.1,
refresh_token: None,
id_token: None,
}))
}

/// Shared state for the OIDC fake token endpoint.
struct OidcTokenState {
access_token: Arc<String>,
Expand Down
106 changes: 106 additions & 0 deletions tests/refresh_token_preservation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
#![expect(
clippy::panic,
clippy::expect_used,
reason = "tests do not need to meet production lint standards"
)]

//! Tests for refresh token preservation when the provider omits `refresh_token`
//! from the token response (RFC 6749 §6).
//!
//! Providers like Google keep the original refresh token valid and simply don't
//! echo it back in the refresh response. The library must preserve the original
//! refresh token in the returned `TokenSet` so that downstream CLIs can persist
//! it and use it for future refreshes.

use loopauth::{CliTokenClient, RefreshOutcome, test_support::FakeOAuthServer};
use std::time::Duration;

fn make_client(server: &FakeOAuthServer) -> CliTokenClient {
CliTokenClient::builder()
.client_id("test-client")
.auth_url(server.auth_url())
.token_url(server.token_url())
.build()
}

/// When the provider omits `refresh_token` from the refresh response,
/// `refresh()` must preserve the original refresh token in the returned `TokenSet`.
#[tokio::test]
async fn refresh_preserves_token_when_provider_omits_it() {
let server =
FakeOAuthServer::start_with_refresh_token_omitted_from_response("new_access").await;

let client = make_client(&server);
let result = client.refresh("rt_original").await;

match result {
Ok(token_set) => {
assert_eq!(
token_set.access_token().as_str(),
"new_access",
"access token should be the new value from the provider"
);
assert!(
token_set.refresh_token().is_some(),
"refresh token must be preserved when the provider omits it from the response"
);
assert_eq!(
token_set
.refresh_token()
.expect("just asserted Some")
.as_str(),
"rt_original",
"preserved refresh token must match the one that was sent"
);
}
Err(e) => panic!("expected Ok(token_set), got {e:?}"),
}
}

/// Same scenario but exercised through `refresh_if_expiring`, which is the
/// typical CLI code path: load expired tokens from disk, refresh, persist.
#[tokio::test]
async fn refresh_if_expiring_preserves_token_when_provider_omits_it() {
let server =
FakeOAuthServer::start_with_refresh_token_omitted_from_response("new_access").await;

let client = make_client(&server);

// Build an already-expired TokenSet with a refresh token, simulating
// what a CLI would load from disk.
let expired_tokens: loopauth::TokenSet<loopauth::Unvalidated> =
serde_json::from_value(serde_json::json!({
"access_token": "old_access",
"token_type": "Bearer",
"refresh_token": "rt_original",
"expires_at": 0
}))
.expect("deserialize expired token set");
let expired_tokens = expired_tokens.into_validated();

let outcome = client
.refresh_if_expiring(&expired_tokens, Duration::from_secs(300))
.await;

match outcome {
Ok(RefreshOutcome::Refreshed(new_tokens)) => {
assert_eq!(new_tokens.access_token().as_str(), "new_access");
assert!(
new_tokens.refresh_token().is_some(),
"refresh token must be preserved when the provider omits it from the response"
);
assert_eq!(
new_tokens
.refresh_token()
.expect("just asserted Some")
.as_str(),
"rt_original",
"preserved refresh token must match the one from the original TokenSet"
);
}
Ok(RefreshOutcome::NotNeeded) => {
panic!("expected Refreshed, got NotNeeded — token should be expired")
}
Err(e) => panic!("expected Refreshed, got error: {e:?}"),
}
}
Loading