Skip to content

Commit 98e8cb5

Browse files
committed
Harden token refresh fallback
1 parent 7a47739 commit 98e8cb5

5 files changed

Lines changed: 215 additions & 21 deletions

File tree

docs/auth.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ Tokens are stored in the operating system keyring:
194194

195195
The config file stores profile settings, not access tokens.
196196

197-
Current known gap: automatic refresh-token handling is not yet release-grade. If a token expires and refresh does not happen, commands return `AUTH_TOKEN_EXPIRED` and the user must run `teams auth login` again.
197+
The CLI automatically redeems the stored refresh token when an access token is expired or near expiry, then updates the keyring with the refreshed token. If no refresh token is stored, or the identity platform rejects the refresh request, commands return `AUTH_TOKEN_EXPIRED` and the user must run `teams auth login` again.
198198

199199
## Diagnostics
200200

docs/man/teams-auth.7

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,9 @@ Tenant ID or tenant domain.
122122
Test-only switch used to avoid OS keyring access in automated tests. Do not use
123123
for real login sessions.
124124
.SH KNOWN GAPS
125-
Automatic refresh-token handling must be completed and validated before public
126-
commercial release. If a stored access token expires today, run:
125+
Stored access tokens are refreshed automatically when a refresh token is
126+
available. If the refresh token is missing, expired, revoked, or rejected by
127+
the identity platform, run:
127128
.PP
128129
.nf
129130
teams auth login --device-code

docs/release-readiness.md

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Live read-only validation passed against the OSO profile:
2929
Known live behavior:
3030

3131
- Some meeting chats can appear in `chat list` but reject message reads with `403` if the user is no longer in the roster.
32-
- Stored token expiry currently requires manual re-login.
32+
- Stored token expiry is handled through refresh-token redemption when a refresh token is available. `AUTH_TOKEN_EXPIRED` still means the refresh token is missing, expired, revoked, or rejected by the identity platform.
3333

3434
Entra app registration status as of 2026-05-27:
3535

@@ -120,15 +120,14 @@ Dependabot is configured to group GitHub Actions updates into one PR so the comp
120120
These must be resolved before marketing this as production-ready for external customers:
121121

122122
1. Publisher verification for the OSO Entra app.
123-
2. Automatic refresh-token handling and tests.
124-
3. Windows live validation using Windows Credential Manager.
125-
4. Controlled write/read smoke test in a dedicated Teams test channel.
126-
5. Documented admin-consent onboarding flow for customer tenants.
127-
6. Clear policy for unsupported Graph operations, tenant restrictions, and destructive commands.
128-
7. Security review of token storage, logs, and exported token behavior.
129-
8. Versioned release notes and upgrade guidance.
130-
9. Public website HTTPS fixed for `https://msteamscli.com/`; HTTP is live, but the current TLS certificate does not cover the hostname.
131-
10. Terms of service URL published and added to the Entra app branding.
123+
2. Windows live validation using Windows Credential Manager.
124+
3. Controlled write/read smoke test in a dedicated Teams test channel.
125+
4. Documented admin-consent onboarding flow for customer tenants.
126+
5. Clear policy for unsupported Graph operations, tenant restrictions, and destructive commands.
127+
6. Security review of token storage, logs, and exported token behavior.
128+
7. Versioned release notes and upgrade guidance.
129+
8. Public website HTTPS fixed for `https://msteamscli.com/`; HTTP is live, but the current TLS certificate does not cover the hostname.
130+
9. Terms of service URL published and added to the Entra app branding.
132131

133132
## Microsoft official trust checklist
134133

src/auth/mod.rs

Lines changed: 110 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,12 @@ pub async fn resolve_token(profile: &str) -> Result<TokenInfo> {
4040
return Ok(info);
4141
}
4242
// 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.
43+
// redemption. If the token is still inside the skew window, keep
44+
// using it on refresh failure; only hard-expired tokens become
45+
// TokenExpired.
4546
match attempt_refresh(profile, &info).await {
4647
Ok(refreshed) => Ok(refreshed),
47-
Err(e) => {
48-
tracing::debug!("Silent token refresh failed, treating as expired: {e}");
49-
Err(TeamsError::TokenExpired)
50-
}
48+
Err(e) => handle_refresh_failure(&info, e),
5149
}
5250
}
5351
Err(_) => Err(TeamsError::AuthError(
@@ -66,6 +64,16 @@ fn needs_refresh(info: &TokenInfo) -> bool {
6664
)
6765
}
6866

67+
fn handle_refresh_failure(info: &TokenInfo, error: TeamsError) -> Result<TokenInfo> {
68+
if info.is_expired() {
69+
tracing::debug!("Silent token refresh failed for expired token: {error}");
70+
Err(TeamsError::TokenExpired)
71+
} else {
72+
tracing::debug!("Silent token refresh failed before expiry, using current token: {error}");
73+
Ok(info.clone())
74+
}
75+
}
76+
6977
/// Silently redeem the stored refresh token for a fresh access token and persist
7078
/// it. Returns an error (mapped to `TokenExpired` by the caller) when the token
7179
/// cannot be refreshed — e.g. no refresh token, undecodable claims, or the
@@ -89,14 +97,32 @@ async fn attempt_refresh(profile: &str, info: &TokenInfo) -> Result<TokenInfo> {
8997

9098
let response =
9199
refresh::refresh_access_token(&client_id, &tenant_id, refresh_token, &scope).await?;
92-
let refreshed = response.into_token_info(profile);
100+
let refreshed = refreshed_token_info(profile, info, response);
93101

94102
// Best-effort persistence so the next invocation reuses the new token.
95103
let _ = keyring::store_token(profile, &refreshed);
96104

97105
Ok(refreshed)
98106
}
99107

108+
fn refreshed_token_info(
109+
profile: &str,
110+
previous: &TokenInfo,
111+
response: token::MsTokenResponse,
112+
) -> TokenInfo {
113+
let mut refreshed = response.into_token_info(profile);
114+
115+
if refreshed.refresh_token.is_none() {
116+
refreshed.refresh_token.clone_from(&previous.refresh_token);
117+
}
118+
119+
if refreshed.scope.is_none() {
120+
refreshed.scope.clone_from(&previous.scope);
121+
}
122+
123+
refreshed
124+
}
125+
100126
/// Build the scope string for the refresh request: reuse the originally granted
101127
/// scope (ensuring `offline_access` so future refreshes keep working), or fall
102128
/// back to the default delegated scopes.
@@ -191,6 +217,83 @@ mod tests {
191217
);
192218
}
193219

220+
#[test]
221+
fn refresh_failure_returns_current_token_inside_skew_window() {
222+
let info = token_with(
223+
Some(Utc::now() + Duration::seconds(30)),
224+
Some("a-refresh-token"),
225+
Some("User.Read offline_access"),
226+
);
227+
228+
let resolved =
229+
handle_refresh_failure(&info, TeamsError::AuthError("temporary failure".into()))
230+
.unwrap();
231+
232+
assert_eq!(resolved.access_token, info.access_token);
233+
assert_eq!(resolved.refresh_token, info.refresh_token);
234+
}
235+
236+
#[test]
237+
fn refresh_failure_expires_hard_expired_token() {
238+
let info = token_with(
239+
Some(Utc::now() - Duration::seconds(1)),
240+
Some("a-refresh-token"),
241+
Some("User.Read offline_access"),
242+
);
243+
244+
let err =
245+
handle_refresh_failure(&info, TeamsError::AuthError("rejected".into())).unwrap_err();
246+
247+
assert!(matches!(err, TeamsError::TokenExpired));
248+
}
249+
250+
#[test]
251+
fn refreshed_token_info_preserves_previous_refresh_token_and_scope_when_missing() {
252+
let previous = token_with(
253+
Some(Utc::now() - Duration::hours(1)),
254+
Some("old-refresh"),
255+
Some("User.Read offline_access"),
256+
);
257+
let response = token::MsTokenResponse {
258+
access_token: "new-access".into(),
259+
token_type: "Bearer".into(),
260+
expires_in: 3600,
261+
scope: None,
262+
refresh_token: None,
263+
};
264+
265+
let refreshed = refreshed_token_info("work", &previous, response);
266+
267+
assert_eq!(refreshed.access_token, "new-access");
268+
assert_eq!(refreshed.profile, "work");
269+
assert_eq!(refreshed.scope.as_deref(), Some("User.Read offline_access"));
270+
assert_eq!(refreshed.refresh_token.as_deref(), Some("old-refresh"));
271+
}
272+
273+
#[test]
274+
fn refreshed_token_info_uses_rotated_refresh_token_and_scope_when_present() {
275+
let previous = token_with(
276+
Some(Utc::now() - Duration::hours(1)),
277+
Some("old-refresh"),
278+
Some("User.Read offline_access"),
279+
);
280+
let response = token::MsTokenResponse {
281+
access_token: "new-access".into(),
282+
token_type: "Bearer".into(),
283+
expires_in: 3600,
284+
scope: Some("User.Read Chat.ReadWrite offline_access".into()),
285+
refresh_token: Some("new-refresh".into()),
286+
};
287+
288+
let refreshed = refreshed_token_info("work", &previous, response);
289+
290+
assert_eq!(
291+
refreshed.scope.as_deref(),
292+
Some("User.Read Chat.ReadWrite offline_access")
293+
);
294+
assert_eq!(refreshed.refresh_token.as_deref(), Some("new-refresh"));
295+
}
296+
194297
#[tokio::test]
195298
async fn attempt_refresh_without_refresh_token_is_token_expired() {
196299
// Expired token, no refresh token: must fail fast as TokenExpired with

src/auth/refresh.rs

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,19 @@ pub async fn refresh_access_token(
1616
scope: &str,
1717
) -> Result<MsTokenResponse> {
1818
let token_url = format!("https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token");
19+
refresh_access_token_at(&token_url, client_id, refresh_token, scope).await
20+
}
21+
22+
async fn refresh_access_token_at(
23+
token_url: &str,
24+
client_id: &str,
25+
refresh_token: &str,
26+
scope: &str,
27+
) -> Result<MsTokenResponse> {
1928
let http = Client::new();
2029

2130
let resp = http
22-
.post(&token_url)
31+
.post(token_url)
2332
.form(&[
2433
("grant_type", "refresh_token"),
2534
("client_id", client_id),
@@ -41,3 +50,85 @@ pub async fn refresh_access_token(
4150
.await
4251
.map_err(|e| TeamsError::AuthError(format!("Failed to parse refresh response: {e}")))
4352
}
53+
54+
#[cfg(test)]
55+
mod tests {
56+
use super::*;
57+
use wiremock::matchers::{method, path};
58+
use wiremock::{Mock, MockServer, ResponseTemplate};
59+
60+
#[tokio::test]
61+
async fn refresh_access_token_posts_refresh_grant_and_parses_response() {
62+
let server = MockServer::start().await;
63+
Mock::given(method("POST"))
64+
.and(path("/token"))
65+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
66+
"access_token": "new-access",
67+
"token_type": "Bearer",
68+
"expires_in": 3600,
69+
"scope": "User.Read offline_access",
70+
"refresh_token": "new-refresh"
71+
})))
72+
.mount(&server)
73+
.await;
74+
75+
let response = refresh_access_token_at(
76+
&format!("{}/token", server.uri()),
77+
"client-id",
78+
"old-refresh",
79+
"User.Read offline_access",
80+
)
81+
.await
82+
.unwrap();
83+
84+
let requests = server.received_requests().await.unwrap();
85+
assert_eq!(requests.len(), 1);
86+
87+
let body = std::str::from_utf8(&requests[0].body).unwrap();
88+
let form: std::collections::HashMap<String, String> =
89+
url::form_urlencoded::parse(body.as_bytes())
90+
.into_owned()
91+
.collect();
92+
93+
assert_eq!(
94+
form.get("grant_type").map(String::as_str),
95+
Some("refresh_token")
96+
);
97+
assert_eq!(form.get("client_id").map(String::as_str), Some("client-id"));
98+
assert_eq!(
99+
form.get("refresh_token").map(String::as_str),
100+
Some("old-refresh")
101+
);
102+
assert_eq!(
103+
form.get("scope").map(String::as_str),
104+
Some("User.Read offline_access")
105+
);
106+
107+
assert_eq!(response.access_token, "new-access");
108+
assert_eq!(response.token_type, "Bearer");
109+
assert_eq!(response.expires_in, 3600);
110+
assert_eq!(response.scope.as_deref(), Some("User.Read offline_access"));
111+
assert_eq!(response.refresh_token.as_deref(), Some("new-refresh"));
112+
}
113+
114+
#[tokio::test]
115+
async fn refresh_access_token_maps_non_success_to_auth_error() {
116+
let server = MockServer::start().await;
117+
Mock::given(method("POST"))
118+
.and(path("/token"))
119+
.respond_with(ResponseTemplate::new(400).set_body_string("invalid_grant"))
120+
.mount(&server)
121+
.await;
122+
123+
let err = refresh_access_token_at(
124+
&format!("{}/token", server.uri()),
125+
"client-id",
126+
"old-refresh",
127+
"User.Read offline_access",
128+
)
129+
.await
130+
.unwrap_err();
131+
132+
assert!(matches!(err, TeamsError::AuthError(message) if message.contains("invalid_grant")));
133+
}
134+
}

0 commit comments

Comments
 (0)