Skip to content

Commit 0d24345

Browse files
committed
Improve default auth and token diagnostics
1 parent e109311 commit 0d24345

6 files changed

Lines changed: 417 additions & 41 deletions

File tree

README.md

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,16 @@ cargo install --git https://github.com/osodevops/ms-teams-cli
7474

7575
## Agent Authentication
7676

77+
This CLI calls Microsoft Graph. Every token used by commands must be a Microsoft Graph access token. Tokens captured from the Microsoft Teams web or desktop client, including `fossteams/teams-token` files such as `~/.config/fossteams/token-teams.jwt`, are issued for Teams-specific audiences and will fail against Graph with `InvalidAuthenticationToken: Invalid audience`.
78+
79+
For normal delegated use, the CLI defaults to the OSO public client app and the `organizations` authority:
80+
81+
```bash
82+
teams auth login # Browser-based login
83+
teams auth login --device-code # SSH, containers, and remote terminals
84+
teams auth doctor --output json # Inspect client, tenant, token source, and token audience
85+
```
86+
7787
For AI agents and automation, use **client credentials** (fully headless, no browser):
7888

7989
```bash
@@ -83,7 +93,7 @@ export TEAMS_CLI_CLIENT_SECRET=your-secret
8393
export TEAMS_CLI_TENANT_ID=your-tenant-id
8494
teams auth login --client-credentials
8595

86-
# Option 2: Pass a pre-obtained token directly
96+
# Option 2: Pass a pre-obtained Microsoft Graph token directly
8797
export TEAMS_CLI_ACCESS_TOKEN=eyJ0eXAi...
8898
teams team list # no login step needed
8999

@@ -98,14 +108,25 @@ Tokens are cached in the OS keyring — subsequent commands reuse the session wi
98108

99109
```bash
100110
# Browser-based login (Authorization Code + PKCE)
101-
teams auth login --client-id <client-id> --tenant-id <tenant-id>
111+
teams auth login
102112

103113
# Device code flow (headless/SSH, still requires a human to approve once)
104-
teams auth login --device-code --client-id <client-id> --tenant-id <tenant-id>
114+
teams auth login --device-code
105115
```
106116

107117
**Credential resolution order**: CLI flags > environment variables > config file profiles.
108118

119+
### Why not import Teams client tokens?
120+
121+
Tools such as `fossteams/teams-token` are attractive because they avoid Entra app registration and consent setup, especially in tenants where users cannot approve third-party apps themselves. That is a real usability signal: `teams auth login` should be easy to diagnose, should support browser and device-code flows clearly, and should not make users reverse-engineer token audiences.
122+
123+
The supported fix is better Graph-native auth, not reusing Teams client tokens. A Teams, Skype, ChatSvcAgg, or ID token cannot be converted into a Graph token by this CLI. Use one of these paths instead:
124+
125+
- Delegated login with an approved Entra app for human-driven commands.
126+
- Device-code login for SSH, containers, and remote terminals.
127+
- Client credentials for app-only Microsoft Graph operations that support application permissions.
128+
- `TEAMS_CLI_ACCESS_TOKEN` only when the token was explicitly acquired for Microsoft Graph.
129+
109130
## Agent Workflow Patterns
110131

111132
### Pattern 1: Read-Act-Respond
@@ -413,7 +434,7 @@ auth_flow = "client-credentials"
413434
| `TEAMS_CLI_CLIENT_ID` | Azure AD application (client) ID |
414435
| `TEAMS_CLI_CLIENT_SECRET` | Azure AD client secret |
415436
| `TEAMS_CLI_TENANT_ID` | Azure AD tenant ID |
416-
| `TEAMS_CLI_ACCESS_TOKEN` | Pre-obtained access token (skips login entirely) |
437+
| `TEAMS_CLI_ACCESS_TOKEN` | Pre-obtained Microsoft Graph access token (skips login entirely) |
417438
| `RUST_LOG` | Tracing filter (e.g., `debug`, `teams=trace`) |
418439

419440
## Verbosity

src/api/client.rs

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,40 @@ impl GraphClient {
3737
})
3838
}
3939

40+
fn auth_error_message(&self, body: &str) -> String {
41+
let mut message = if body.trim().is_empty() {
42+
"Authentication failed (401)".to_string()
43+
} else {
44+
format!("Authentication failed (401): {body}")
45+
};
46+
47+
if body.contains("Invalid audience") || body.contains("InvalidAuthenticationToken") {
48+
let diagnostics = self.token.diagnostics();
49+
let audience = diagnostics
50+
.audience
51+
.as_deref()
52+
.unwrap_or("unknown or opaque token");
53+
54+
message.push_str(&format!(
55+
"\nHint: Microsoft Graph rejected this token audience. This CLI requires a Microsoft Graph access token; observed audience: {audience}. Run `teams auth login`, `teams auth login --device-code`, or provide a Graph token via `TEAMS_CLI_ACCESS_TOKEN`. Teams client tokens such as `~/.config/fossteams/token-teams.jwt` are not supported."
56+
));
57+
}
58+
59+
message
60+
}
61+
62+
fn error_for_status(&self, status: StatusCode, body: String) -> TeamsError {
63+
match status {
64+
StatusCode::UNAUTHORIZED => TeamsError::AuthError(self.auth_error_message(&body)),
65+
StatusCode::FORBIDDEN => TeamsError::PermissionDenied(body),
66+
StatusCode::NOT_FOUND => TeamsError::NotFound(body),
67+
_ => TeamsError::ApiError {
68+
status: status.as_u16(),
69+
message: body,
70+
},
71+
}
72+
}
73+
4074
/// GET request with retry logic.
4175
pub async fn get<T: DeserializeOwned>(&self, url: &str, query: &[(&str, &str)]) -> Result<T> {
4276
self.request_with_retry(|this| {
@@ -170,10 +204,7 @@ impl GraphClient {
170204

171205
if !status.is_success() && status != StatusCode::ACCEPTED {
172206
let body = resp.text().await.unwrap_or_default();
173-
return Err(TeamsError::ApiError {
174-
status: status.as_u16(),
175-
message: body,
176-
});
207+
return Err(self.error_for_status(status, body));
177208
}
178209

179210
let location = resp
@@ -250,10 +281,7 @@ impl GraphClient {
250281

251282
if !status.is_success() {
252283
let body = resp.text().await.unwrap_or_default();
253-
return Err(TeamsError::ApiError {
254-
status: status.as_u16(),
255-
message: body,
256-
});
284+
return Err(self.error_for_status(status, body));
257285
}
258286

259287
let bytes = resp.bytes().await.map_err(TeamsError::NetworkError)?;
@@ -320,18 +348,6 @@ impl GraphClient {
320348
return Err(TeamsError::RateLimited { retry_after });
321349
}
322350

323-
if status == StatusCode::UNAUTHORIZED {
324-
return Err(TeamsError::AuthError(
325-
"Authentication failed (401)".to_string(),
326-
));
327-
}
328-
if status == StatusCode::FORBIDDEN {
329-
return Err(TeamsError::PermissionDenied("Forbidden (403)".to_string()));
330-
}
331-
if status == StatusCode::NOT_FOUND {
332-
return Err(TeamsError::NotFound("Not found (404)".to_string()));
333-
}
334-
335351
if status.is_server_error() {
336352
if attempt < max_retries {
337353
let delay = backoff_base.pow(attempt);
@@ -403,9 +419,7 @@ impl GraphClient {
403419
// Auth errors
404420
if status == StatusCode::UNAUTHORIZED {
405421
let body = resp.text().await.unwrap_or_default();
406-
return Err(TeamsError::AuthError(format!(
407-
"Authentication failed (401): {body}"
408-
)));
422+
return Err(TeamsError::AuthError(self.auth_error_message(&body)));
409423
}
410424
if status == StatusCode::FORBIDDEN {
411425
let body = resp.text().await.unwrap_or_default();
@@ -515,9 +529,7 @@ impl GraphClient {
515529

516530
if status == StatusCode::UNAUTHORIZED {
517531
let body = resp.text().await.unwrap_or_default();
518-
return Err(TeamsError::AuthError(format!(
519-
"Authentication failed (401): {body}"
520-
)));
532+
return Err(TeamsError::AuthError(self.auth_error_message(&body)));
521533
}
522534
if status == StatusCode::FORBIDDEN {
523535
let body = resp.text().await.unwrap_or_default();

src/auth/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ pub mod token;
77
use crate::error::{Result, TeamsError};
88
use token::TokenInfo;
99

10+
/// Default multi-tenant public client app for delegated CLI auth.
11+
pub const DEFAULT_PUBLIC_CLIENT_ID: &str = "fba1b5d0-fdd0-4fe2-9729-9ccdc38f9595";
12+
pub const DEFAULT_TENANT_ID: &str = "organizations";
13+
1014
/// Resolve an access token using the priority chain:
1115
/// 1. TEAMS_CLI_ACCESS_TOKEN env var
1216
/// 2. Keyring (stored token, check expiry)

src/auth/token.rs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1+
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
12
use chrono::{DateTime, Utc};
23
use serde::{Deserialize, Serialize};
34

5+
const GRAPH_AUDIENCES: &[&str] = &[
6+
"https://graph.microsoft.com",
7+
"https://graph.microsoft.com/",
8+
"00000003-0000-0000-c000-000000000000",
9+
];
10+
411
#[derive(Debug, Clone, Serialize, Deserialize)]
512
pub struct TokenInfo {
613
pub access_token: String,
@@ -25,6 +32,98 @@ impl TokenInfo {
2532
pub fn bearer_header(&self) -> String {
2633
format!("Bearer {}", self.access_token)
2734
}
35+
36+
pub fn diagnostics(&self) -> TokenDiagnostics {
37+
TokenDiagnostics::from_access_token(&self.access_token)
38+
}
39+
}
40+
41+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42+
pub struct TokenDiagnostics {
43+
#[serde(skip_serializing_if = "Option::is_none")]
44+
pub audience: Option<String>,
45+
#[serde(skip_serializing_if = "Option::is_none")]
46+
pub tenant_id: Option<String>,
47+
#[serde(skip_serializing_if = "Option::is_none")]
48+
pub app_id: Option<String>,
49+
#[serde(skip_serializing_if = "Option::is_none")]
50+
pub scopes: Option<Vec<String>>,
51+
#[serde(skip_serializing_if = "Option::is_none")]
52+
pub roles: Option<Vec<String>>,
53+
#[serde(skip_serializing_if = "Option::is_none")]
54+
pub expires_at: Option<DateTime<Utc>>,
55+
#[serde(skip_serializing_if = "Option::is_none")]
56+
pub is_graph_audience: Option<bool>,
57+
pub decoded: bool,
58+
}
59+
60+
impl TokenDiagnostics {
61+
fn from_access_token(token: &str) -> Self {
62+
let Some(payload) = decode_jwt_payload(token) else {
63+
return Self {
64+
audience: None,
65+
tenant_id: None,
66+
app_id: None,
67+
scopes: None,
68+
roles: None,
69+
expires_at: None,
70+
is_graph_audience: None,
71+
decoded: false,
72+
};
73+
};
74+
75+
let audience = payload
76+
.get("aud")
77+
.and_then(|v| v.as_str())
78+
.map(str::to_string);
79+
let tenant_id = payload
80+
.get("tid")
81+
.and_then(|v| v.as_str())
82+
.map(str::to_string);
83+
let app_id = payload
84+
.get("appid")
85+
.or_else(|| payload.get("azp"))
86+
.and_then(|v| v.as_str())
87+
.map(str::to_string);
88+
let scopes = payload.get("scp").and_then(|v| v.as_str()).map(|s| {
89+
s.split_whitespace()
90+
.map(str::to_string)
91+
.collect::<Vec<String>>()
92+
});
93+
let roles = payload
94+
.get("roles")
95+
.and_then(|v| v.as_array())
96+
.map(|roles| {
97+
roles
98+
.iter()
99+
.filter_map(|role| role.as_str().map(str::to_string))
100+
.collect::<Vec<String>>()
101+
});
102+
let expires_at = payload
103+
.get("exp")
104+
.and_then(|v| v.as_i64())
105+
.and_then(|ts| DateTime::<Utc>::from_timestamp(ts, 0));
106+
let is_graph_audience = audience
107+
.as_deref()
108+
.map(|aud| GRAPH_AUDIENCES.iter().any(|expected| *expected == aud));
109+
110+
Self {
111+
audience,
112+
tenant_id,
113+
app_id,
114+
scopes,
115+
roles,
116+
expires_at,
117+
is_graph_audience,
118+
decoded: true,
119+
}
120+
}
121+
}
122+
123+
fn decode_jwt_payload(token: &str) -> Option<serde_json::Value> {
124+
let payload = token.split('.').nth(1)?;
125+
let decoded = URL_SAFE_NO_PAD.decode(payload).ok()?;
126+
serde_json::from_slice(&decoded).ok()
28127
}
29128

30129
/// Raw token response from Microsoft Identity Platform
@@ -57,6 +156,12 @@ impl MsTokenResponse {
57156
mod tests {
58157
use super::*;
59158

159+
fn jwt_with_payload(payload: serde_json::Value) -> String {
160+
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#);
161+
let payload = URL_SAFE_NO_PAD.encode(payload.to_string());
162+
format!("{header}.{payload}.")
163+
}
164+
60165
fn make_token(expires_at: Option<DateTime<Utc>>) -> TokenInfo {
61166
TokenInfo {
62167
access_token: "test-token".into(),
@@ -92,6 +197,65 @@ mod tests {
92197
assert_eq!(token.bearer_header(), "Bearer test-token");
93198
}
94199

200+
#[test]
201+
fn diagnostics_identifies_graph_audience() {
202+
let token = TokenInfo {
203+
access_token: jwt_with_payload(serde_json::json!({
204+
"aud": "https://graph.microsoft.com",
205+
"tid": "tenant-1",
206+
"appid": "app-1",
207+
"scp": "User.Read Team.ReadBasic.All",
208+
"exp": 1_893_456_000i64
209+
})),
210+
expires_at: None,
211+
token_type: "Bearer".into(),
212+
scope: None,
213+
refresh_token: None,
214+
profile: "default".into(),
215+
};
216+
217+
let diagnostics = token.diagnostics();
218+
assert!(diagnostics.decoded);
219+
assert_eq!(
220+
diagnostics.audience.as_deref(),
221+
Some("https://graph.microsoft.com")
222+
);
223+
assert_eq!(diagnostics.tenant_id.as_deref(), Some("tenant-1"));
224+
assert_eq!(diagnostics.app_id.as_deref(), Some("app-1"));
225+
assert_eq!(diagnostics.is_graph_audience, Some(true));
226+
assert_eq!(
227+
diagnostics.scopes,
228+
Some(vec!["User.Read".into(), "Team.ReadBasic.All".into()])
229+
);
230+
}
231+
232+
#[test]
233+
fn diagnostics_identifies_non_graph_audience() {
234+
let token = TokenInfo {
235+
access_token: jwt_with_payload(serde_json::json!({
236+
"aud": "5e3ce6c0-2b1f-4285-8d4b-75ee78787346",
237+
"roles": ["Some.Role"]
238+
})),
239+
expires_at: None,
240+
token_type: "Bearer".into(),
241+
scope: None,
242+
refresh_token: None,
243+
profile: "default".into(),
244+
};
245+
246+
let diagnostics = token.diagnostics();
247+
assert_eq!(diagnostics.is_graph_audience, Some(false));
248+
assert_eq!(diagnostics.roles, Some(vec!["Some.Role".into()]));
249+
}
250+
251+
#[test]
252+
fn diagnostics_handles_opaque_tokens() {
253+
let token = make_token(None);
254+
let diagnostics = token.diagnostics();
255+
assert!(!diagnostics.decoded);
256+
assert_eq!(diagnostics.is_graph_audience, None);
257+
}
258+
95259
#[test]
96260
fn ms_token_response_conversion() {
97261
let resp = MsTokenResponse {

0 commit comments

Comments
 (0)