Skip to content

Commit 7a47739

Browse files
Refresh expired access tokens using the stored refresh token
resolve_token now redeems the persisted refresh_token via the OAuth2 refresh_token grant when the stored access token is expired or within a short skew window, then writes the new token back to the keyring. It falls back to the previous AUTH_TOKEN_EXPIRED behaviour only when no refresh token is available or the refresh is rejected. resolve_token is now async, so all call sites await it.
1 parent 3f13cbb commit 7a47739

20 files changed

Lines changed: 242 additions & 30 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Added
6+
7+
- Automatic refresh-token redemption. When the stored access token is expired (or within a short skew window of expiring), the CLI now silently exchanges the persisted `refresh_token` for a fresh access token via the OAuth2 `refresh_token` grant and updates the keyring, instead of failing with `AUTH_TOKEN_EXPIRED` roughly an hour after login. The previous re-login behaviour remains as a fallback when no refresh token is stored or the refresh request is rejected. This resolves the standing "automatic refresh-token handling" known limitation (#16).
8+
39
## v0.2.4 - 2026-06-04
410

511
### Changed

docs/faq.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ Teams/Graph can list chats that later fail message reads because the user is no
6363

6464
## Why did I get `AUTH_TOKEN_EXPIRED`?
6565

66-
The stored access token expired. The CLI requests `offline_access`, but automatic refresh-token handling is still a release-readiness gap. Run:
66+
The CLI automatically refreshes an expired access token using the stored refresh token (login requests `offline_access`), so this should be rare. You will still see `AUTH_TOKEN_EXPIRED` when no refresh token is stored or the refresh is rejected — for example the refresh token itself expired or was revoked. In that case, re-authenticate:
6767

6868
```bash
6969
teams auth login --device-code

docs/troubleshooting.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,16 @@ RUST_LOG=teams=debug teams chat list --output json
1818

1919
## `AUTH_TOKEN_EXPIRED`
2020

21-
Meaning: the keyring token is expired.
21+
Meaning: the keyring access token is expired and could not be refreshed automatically.
2222

23-
Current workaround:
23+
The CLI now silently redeems the stored refresh token when the access token is expired or about to expire, so this error normally only appears when no refresh token is stored or the refresh request is rejected (for example the refresh token expired or was revoked).
24+
25+
Resolution:
2426

2527
```bash
2628
teams auth login --device-code
2729
```
2830

29-
Release-readiness note: automatic refresh-token handling still needs to be completed and validated.
30-
3131
## `AUTH_FAILED` or login fails
3232

3333
Check:

src/auth/mod.rs

Lines changed: 170 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,25 @@ pub mod auth_code_pkce;
22
pub mod client_credentials;
33
pub mod device_code;
44
pub mod keyring;
5+
pub mod refresh;
56
pub mod token;
67

8+
use chrono::{Duration, Utc};
9+
10+
use crate::config::DEFAULT_DELEGATED_SCOPES;
711
use crate::error::{Result, TeamsError};
812
use token::TokenInfo;
913

14+
/// Refresh slightly ahead of the hard expiry so a token does not lapse
15+
/// mid-request.
16+
const EXPIRY_SKEW_SECS: i64 = 60;
17+
1018
/// Resolve an access token using the priority chain:
11-
/// 1. TEAMS_CLI_ACCESS_TOKEN env var
12-
/// 2. Keyring (stored token, check expiry)
19+
/// 1. TEAMS_CLI_ACCESS_TOKEN env var (used verbatim, never refreshed)
20+
/// 2. Keyring: if the stored token is (near) expired, silently redeem its
21+
/// refresh token; otherwise return it as-is
1322
/// 3. Error
14-
pub fn resolve_token(profile: &str) -> Result<TokenInfo> {
23+
pub async fn resolve_token(profile: &str) -> Result<TokenInfo> {
1524
// 1. Direct env var override
1625
if let Ok(token) = std::env::var("TEAMS_CLI_ACCESS_TOKEN") {
1726
return Ok(TokenInfo {
@@ -27,10 +36,18 @@ pub fn resolve_token(profile: &str) -> Result<TokenInfo> {
2736
// 2. Keyring
2837
match keyring::get_token(profile) {
2938
Ok(info) => {
30-
if info.is_expired() {
31-
Err(TeamsError::TokenExpired)
32-
} else {
33-
Ok(info)
39+
if !needs_refresh(&info) {
40+
return Ok(info);
41+
}
42+
// Token is at/near expiry: attempt a silent refresh-token
43+
// redemption. Any failure falls back to the original behaviour
44+
// (TokenExpired) so the observable exit code is unchanged.
45+
match attempt_refresh(profile, &info).await {
46+
Ok(refreshed) => Ok(refreshed),
47+
Err(e) => {
48+
tracing::debug!("Silent token refresh failed, treating as expired: {e}");
49+
Err(TeamsError::TokenExpired)
50+
}
3451
}
3552
}
3653
Err(_) => Err(TeamsError::AuthError(
@@ -39,6 +56,63 @@ pub fn resolve_token(profile: &str) -> Result<TokenInfo> {
3956
}
4057
}
4158

59+
/// Whether the token should be refreshed: already expired, or within the skew
60+
/// window of expiry. Tokens with no expiry information are assumed valid.
61+
fn needs_refresh(info: &TokenInfo) -> bool {
62+
info.is_expired()
63+
|| matches!(
64+
info.expires_at,
65+
Some(expires) if Utc::now() + Duration::seconds(EXPIRY_SKEW_SECS) >= expires
66+
)
67+
}
68+
69+
/// Silently redeem the stored refresh token for a fresh access token and persist
70+
/// it. Returns an error (mapped to `TokenExpired` by the caller) when the token
71+
/// cannot be refreshed — e.g. no refresh token, undecodable claims, or the
72+
/// identity platform rejecting the request.
73+
async fn attempt_refresh(profile: &str, info: &TokenInfo) -> Result<TokenInfo> {
74+
let refresh_token = info
75+
.refresh_token
76+
.as_deref()
77+
.ok_or(TeamsError::TokenExpired)?;
78+
79+
// The expired JWT still decodes (decoding ignores expiry); recover the
80+
// tenant + client id originally authenticated with from its claims.
81+
let claims = info.unverified_claims().ok_or(TeamsError::TokenExpired)?;
82+
let tenant_id = claims.tid.ok_or(TeamsError::TokenExpired)?;
83+
let client_id = claims
84+
.azp
85+
.or(claims.appid)
86+
.ok_or(TeamsError::TokenExpired)?;
87+
88+
let scope = refresh_scope(info.scope.as_deref());
89+
90+
let response =
91+
refresh::refresh_access_token(&client_id, &tenant_id, refresh_token, &scope).await?;
92+
let refreshed = response.into_token_info(profile);
93+
94+
// Best-effort persistence so the next invocation reuses the new token.
95+
let _ = keyring::store_token(profile, &refreshed);
96+
97+
Ok(refreshed)
98+
}
99+
100+
/// Build the scope string for the refresh request: reuse the originally granted
101+
/// scope (ensuring `offline_access` so future refreshes keep working), or fall
102+
/// back to the default delegated scopes.
103+
fn refresh_scope(existing: Option<&str>) -> String {
104+
match existing {
105+
Some(scope) if !scope.trim().is_empty() => {
106+
if scope.split_whitespace().any(|s| s == "offline_access") {
107+
scope.to_string()
108+
} else {
109+
format!("{scope} offline_access")
110+
}
111+
}
112+
_ => DEFAULT_DELEGATED_SCOPES.to_string(),
113+
}
114+
}
115+
42116
pub fn require_delegated_token(token: &TokenInfo, operation: &str) -> Result<()> {
43117
if let Some(claims) = token.unverified_claims() {
44118
if claims.auth_type() == "app-only" {
@@ -50,3 +124,92 @@ pub fn require_delegated_token(token: &TokenInfo, operation: &str) -> Result<()>
50124

51125
Ok(())
52126
}
127+
128+
#[cfg(test)]
129+
mod tests {
130+
use super::*;
131+
132+
fn token_with(
133+
expires_at: Option<chrono::DateTime<Utc>>,
134+
refresh_token: Option<&str>,
135+
scope: Option<&str>,
136+
) -> TokenInfo {
137+
TokenInfo {
138+
access_token: "not-a-jwt".into(),
139+
expires_at,
140+
token_type: "Bearer".into(),
141+
scope: scope.map(|s| s.to_string()),
142+
refresh_token: refresh_token.map(|s| s.to_string()),
143+
profile: "default".into(),
144+
}
145+
}
146+
147+
#[test]
148+
fn needs_refresh_true_when_expired() {
149+
let info = token_with(Some(Utc::now() - Duration::hours(1)), None, None);
150+
assert!(needs_refresh(&info));
151+
}
152+
153+
#[test]
154+
fn needs_refresh_true_within_skew_window() {
155+
// Expires in 30s, which is inside the 60s skew window.
156+
let info = token_with(Some(Utc::now() + Duration::seconds(30)), None, None);
157+
assert!(needs_refresh(&info));
158+
}
159+
160+
#[test]
161+
fn needs_refresh_false_when_well_in_future() {
162+
let info = token_with(Some(Utc::now() + Duration::hours(1)), None, None);
163+
assert!(!needs_refresh(&info));
164+
}
165+
166+
#[test]
167+
fn needs_refresh_false_without_expiry() {
168+
let info = token_with(None, None, None);
169+
assert!(!needs_refresh(&info));
170+
}
171+
172+
#[test]
173+
fn refresh_scope_falls_back_to_defaults_when_absent() {
174+
assert_eq!(refresh_scope(None), DEFAULT_DELEGATED_SCOPES);
175+
assert_eq!(refresh_scope(Some(" ")), DEFAULT_DELEGATED_SCOPES);
176+
}
177+
178+
#[test]
179+
fn refresh_scope_appends_offline_access_when_missing() {
180+
assert_eq!(
181+
refresh_scope(Some("User.Read Chat.ReadWrite")),
182+
"User.Read Chat.ReadWrite offline_access"
183+
);
184+
}
185+
186+
#[test]
187+
fn refresh_scope_preserves_existing_offline_access() {
188+
assert_eq!(
189+
refresh_scope(Some("User.Read offline_access")),
190+
"User.Read offline_access"
191+
);
192+
}
193+
194+
#[tokio::test]
195+
async fn attempt_refresh_without_refresh_token_is_token_expired() {
196+
// Expired token, no refresh token: must fail fast as TokenExpired with
197+
// no network call.
198+
let info = token_with(Some(Utc::now() - Duration::hours(1)), None, None);
199+
let err = attempt_refresh("default", &info).await.unwrap_err();
200+
assert!(matches!(err, TeamsError::TokenExpired));
201+
}
202+
203+
#[tokio::test]
204+
async fn attempt_refresh_with_undecodable_claims_is_token_expired() {
205+
// Has a refresh token, but the access token is not a decodable JWT so we
206+
// cannot recover tenant/client id: must fail fast as TokenExpired.
207+
let info = token_with(
208+
Some(Utc::now() - Duration::hours(1)),
209+
Some("a-refresh-token"),
210+
None,
211+
);
212+
let err = attempt_refresh("default", &info).await.unwrap_err();
213+
assert!(matches!(err, TeamsError::TokenExpired));
214+
}
215+
}

src/auth/refresh.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
use reqwest::Client;
2+
3+
use super::token::MsTokenResponse;
4+
use crate::error::{Result, TeamsError};
5+
6+
/// Redeem a stored refresh token for a fresh access token using the OAuth2
7+
/// `refresh_token` grant against the Microsoft identity platform.
8+
///
9+
/// Mirrors the error handling of [`super::device_code::authenticate`]: network
10+
/// failures surface as [`TeamsError::NetworkError`], non-2xx responses and parse
11+
/// failures as [`TeamsError::AuthError`].
12+
pub async fn refresh_access_token(
13+
client_id: &str,
14+
tenant_id: &str,
15+
refresh_token: &str,
16+
scope: &str,
17+
) -> Result<MsTokenResponse> {
18+
let token_url = format!("https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token");
19+
let http = Client::new();
20+
21+
let resp = http
22+
.post(&token_url)
23+
.form(&[
24+
("grant_type", "refresh_token"),
25+
("client_id", client_id),
26+
("refresh_token", refresh_token),
27+
("scope", scope),
28+
])
29+
.send()
30+
.await
31+
.map_err(TeamsError::NetworkError)?;
32+
33+
if !resp.status().is_success() {
34+
let body = resp.text().await.unwrap_or_default();
35+
return Err(TeamsError::AuthError(format!(
36+
"Token refresh failed: {body}"
37+
)));
38+
}
39+
40+
resp.json::<MsTokenResponse>()
41+
.await
42+
.map_err(|e| TeamsError::AuthError(format!("Failed to parse refresh response: {e}")))
43+
}

src/cli/app.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ pub async fn run(
4040
format: OutputFormat,
4141
pagination: &PaginationOpts,
4242
) -> Result<()> {
43-
let token = auth::resolve_token(profile)?;
43+
let token = auth::resolve_token(profile).await?;
4444
let client = GraphClient::new(token, &config.network)?;
4545

4646
match cmd {

src/cli/auth.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ pub async fn run(
242242
"byo"
243243
};
244244

245-
let token = auth::resolve_token(profile).ok();
245+
let token = auth::resolve_token(profile).await.ok();
246246
let claims = token.as_ref().and_then(|t| t.unverified_claims());
247247
let warnings = token_warnings(claims.as_ref());
248248
let admin_consent_url = delegated_admin_consent_url(&client_id, &tenant_id);
@@ -273,7 +273,7 @@ pub async fn run(
273273

274274
AuthCommand::Status => {
275275
let start = Instant::now();
276-
match auth::resolve_token(profile) {
276+
match auth::resolve_token(profile).await {
277277
Ok(token) => {
278278
let claims = token.unverified_claims();
279279
let msg = serde_json::json!({
@@ -312,7 +312,7 @@ pub async fn run(
312312
AuthCommand::Switch { name } => {
313313
let start = Instant::now();
314314
// Verify the profile has a token
315-
auth::resolve_token(&name)?;
315+
auth::resolve_token(&name).await?;
316316

317317
// Update config to set default profile
318318
let mut updated_config = config.clone();
@@ -359,7 +359,7 @@ pub async fn run(
359359
AuthCommand::Token {
360360
format: token_format,
361361
} => {
362-
let token = auth::resolve_token(profile)?;
362+
let token = auth::resolve_token(profile).await?;
363363
match token_format.as_str() {
364364
"json" => {
365365
let claims = token.unverified_claims();

src/cli/channel.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ pub async fn run(
104104
format: OutputFormat,
105105
pagination: &PaginationOpts,
106106
) -> Result<()> {
107-
let token = auth::resolve_token(profile)?;
107+
let token = auth::resolve_token(profile).await?;
108108
let client = GraphClient::new(token, &config.network)?;
109109

110110
match cmd {

src/cli/chat.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ pub async fn run(
9595
format: OutputFormat,
9696
pagination: &PaginationOpts,
9797
) -> Result<()> {
98-
let token = auth::resolve_token(profile)?;
98+
let token = auth::resolve_token(profile).await?;
9999
let client = GraphClient::new(token, &config.network)?;
100100

101101
match cmd {

src/cli/file.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ pub async fn run(
103103
format: OutputFormat,
104104
pagination: &PaginationOpts,
105105
) -> Result<()> {
106-
let token = auth::resolve_token(profile)?;
106+
let token = auth::resolve_token(profile).await?;
107107
let client = GraphClient::new(token, &config.network)?;
108108

109109
match cmd {

0 commit comments

Comments
 (0)