Skip to content

Commit 52110e2

Browse files
authored
Merge pull request #11 from osodevops/feat/default-auth-diagnostics
Improve default auth and token diagnostics
2 parents 9277ea0 + b41160a commit 52110e2

5 files changed

Lines changed: 162 additions & 28 deletions

File tree

README.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,12 @@ can still bring their own Entra app by passing `--client-id` and `--tenant-id`
162162
or configuring a profile; see
163163
[`docs/auth-implementation-plan.md`](docs/auth-implementation-plan.md).
164164

165+
This CLI calls Microsoft Graph. Every token used by Graph commands must be a
166+
Microsoft Graph access token. Tokens captured from the Microsoft Teams web or
167+
desktop client, including `fossteams/teams-token` files such as
168+
`~/.config/fossteams/token-teams.jwt`, are issued for Teams-specific audiences
169+
and will fail against Graph with `InvalidAuthenticationToken: Invalid audience`.
170+
165171
```bash
166172
# Browser-based login with OSO's public client app
167173
teams auth login
@@ -187,7 +193,7 @@ export TEAMS_CLI_CLIENT_SECRET=<client-secret>
187193
export TEAMS_CLI_TENANT_ID=<tenant-id>
188194
teams auth login --client-credentials
189195

190-
# Pass a pre-obtained token directly
196+
# Pass a pre-obtained Microsoft Graph token directly
191197
export TEAMS_CLI_ACCESS_TOKEN=<access-token>
192198
teams team list # no login step needed
193199

@@ -200,6 +206,20 @@ Tokens are cached in the OS keyring — subsequent commands reuse the session wi
200206

201207
**Credential resolution order**: CLI flags > environment variables > config file profiles.
202208

209+
### Why not import Teams client tokens?
210+
211+
Tools such as `fossteams/teams-token` are attractive because they avoid Entra
212+
app registration and consent setup, especially in tenants where users cannot
213+
approve third-party apps themselves. That is a real usability signal: `teams
214+
auth login` should be easy to diagnose, should support browser and device-code
215+
flows clearly, and should not make users reverse-engineer token audiences.
216+
217+
The supported fix is better Graph-native auth, not reusing Teams client tokens.
218+
A Teams, Skype, ChatSvcAgg, or ID token cannot be converted into a Graph token
219+
by this CLI. Use delegated login, device-code login, client credentials for
220+
supported app-only Graph operations, or `TEAMS_CLI_ACCESS_TOKEN` only when the
221+
token was explicitly acquired for Microsoft Graph.
222+
203223
## Agent Workflow Patterns
204224

205225
### Pattern 1: Read-Act-Respond
@@ -509,7 +529,7 @@ auth_flow = "device-code"
509529
| `TEAMS_CLI_CLIENT_ID` | Azure AD application (client) ID |
510530
| `TEAMS_CLI_CLIENT_SECRET` | Azure AD client secret |
511531
| `TEAMS_CLI_TENANT_ID` | Azure AD tenant ID |
512-
| `TEAMS_CLI_ACCESS_TOKEN` | Pre-obtained access token (skips login entirely) |
532+
| `TEAMS_CLI_ACCESS_TOKEN` | Pre-obtained Microsoft Graph access token (skips login entirely) |
513533
| `RUST_LOG` | Tracing filter (e.g., `debug`, `teams=trace`) |
514534

515535
## 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 audience = self
49+
.token
50+
.unverified_claims()
51+
.and_then(|claims| claims.audience())
52+
.unwrap_or_else(|| "unknown or opaque token".to_string());
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/token.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
22
use chrono::{DateTime, Utc};
33
use serde::{Deserialize, Serialize};
44

5+
const GRAPH_AUDIENCES: &[&str] = &[
6+
"https://graph.microsoft.com",
7+
"https://graph.microsoft.com/",
8+
"00000003-0000-0000-c000-000000000000",
9+
];
10+
511
#[derive(Debug, Clone, Serialize, Deserialize)]
612
pub struct TokenInfo {
713
pub access_token: String,
@@ -49,6 +55,18 @@ impl TokenClaims {
4955
"unknown"
5056
}
5157
}
58+
59+
pub fn audience(&self) -> Option<String> {
60+
match self.aud.as_ref()? {
61+
serde_json::Value::String(audience) => Some(audience.clone()),
62+
other => Some(other.to_string()),
63+
}
64+
}
65+
66+
pub fn is_graph_audience(&self) -> Option<bool> {
67+
let audience = self.audience()?;
68+
Some(GRAPH_AUDIENCES.iter().any(|expected| *expected == audience))
69+
}
5270
}
5371

5472
impl TokenInfo {
@@ -164,6 +182,7 @@ mod tests {
164182
#[test]
165183
fn decodes_delegated_jwt_claims() {
166184
let payload = serde_json::json!({
185+
"aud": "https://graph.microsoft.com",
167186
"tid": "tenant-id",
168187
"oid": "user-id",
169188
"scp": "User.Read ChatMessage.Send"
@@ -177,6 +196,26 @@ mod tests {
177196

178197
assert_eq!(claims.tid.as_deref(), Some("tenant-id"));
179198
assert_eq!(claims.auth_type(), "delegated");
199+
assert_eq!(
200+
claims.audience().as_deref(),
201+
Some("https://graph.microsoft.com")
202+
);
203+
assert_eq!(claims.is_graph_audience(), Some(true));
204+
}
205+
206+
#[test]
207+
fn identifies_non_graph_audience() {
208+
let payload = serde_json::json!({
209+
"aud": "5e3ce6c0-2b1f-4285-8d4b-75ee78787346"
210+
});
211+
let token = format!(
212+
"header.{}.signature",
213+
URL_SAFE_NO_PAD.encode(payload.to_string())
214+
);
215+
216+
let claims = decode_unverified_claims(&token).unwrap();
217+
218+
assert_eq!(claims.is_graph_audience(), Some(false));
180219
}
181220

182221
#[test]

src/cli/auth.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,36 @@ pub enum AuthCommand {
8080
},
8181
}
8282

83+
fn token_diagnostics(claims: Option<&auth::token::TokenClaims>) -> Option<serde_json::Value> {
84+
claims.map(|claims| {
85+
serde_json::json!({
86+
"audience": claims.audience(),
87+
"is_graph_audience": claims.is_graph_audience(),
88+
"auth_type": claims.auth_type(),
89+
"tenant_id": claims.tid,
90+
"app_id": claims.appid.clone().or_else(|| claims.azp.clone()),
91+
"user": claims.preferred_username.clone().or_else(|| claims.upn.clone()),
92+
})
93+
})
94+
}
95+
96+
fn token_warnings(claims: Option<&auth::token::TokenClaims>) -> Vec<String> {
97+
let mut warnings = Vec::new();
98+
99+
if let Some(claims) = claims {
100+
if claims.is_graph_audience() == Some(false) {
101+
let audience = claims
102+
.audience()
103+
.unwrap_or_else(|| "unknown audience".into());
104+
warnings.push(format!(
105+
"Token audience is '{audience}', not Microsoft Graph. Graph commands require a Microsoft Graph access token."
106+
));
107+
}
108+
}
109+
110+
warnings
111+
}
112+
83113
pub async fn run(
84114
cmd: AuthCommand,
85115
config: &ConfigFile,
@@ -189,17 +219,21 @@ pub async fn run(
189219

190220
let token = auth::resolve_token(profile).ok();
191221
let claims = token.as_ref().and_then(|t| t.unverified_claims());
222+
let warnings = token_warnings(claims.as_ref());
192223
let msg = serde_json::json!({
193224
"profile": profile,
194225
"auth_app": auth_app,
195226
"client_id": client_id,
196227
"tenant_id": tenant_id,
197228
"admin_consent_url": format!("https://login.microsoftonline.com/{tenant_id}/adminconsent?client_id={client_id}"),
198229
"authenticated": token.is_some(),
230+
"warnings": warnings,
199231
"token": token.as_ref().map(|t| serde_json::json!({
200232
"expires_at": t.expires_at.map(|e| e.to_rfc3339()),
201233
"scope": t.scope,
202234
"auth_type": claims.as_ref().map(|c| c.auth_type()).unwrap_or("unknown"),
235+
"audience": claims.as_ref().and_then(|c| c.audience()),
236+
"is_graph_audience": claims.as_ref().and_then(|c| c.is_graph_audience()),
203237
"tenant_id": claims.as_ref().and_then(|c| c.tid.clone()),
204238
"app_id": claims.as_ref().and_then(|c| c.appid.clone()).or_else(|| claims.as_ref().and_then(|c| c.azp.clone())),
205239
"user": claims.as_ref().and_then(|c| c.preferred_username.clone()).or_else(|| claims.as_ref().and_then(|c| c.upn.clone())),
@@ -213,11 +247,14 @@ pub async fn run(
213247
let start = Instant::now();
214248
match auth::resolve_token(profile) {
215249
Ok(token) => {
250+
let claims = token.unverified_claims();
216251
let msg = serde_json::json!({
217252
"authenticated": true,
218253
"profile": profile,
219254
"expires_at": token.expires_at.map(|e| e.to_rfc3339()),
220255
"scope": token.scope,
256+
"token_diagnostics": token_diagnostics(claims.as_ref()),
257+
"warnings": token_warnings(claims.as_ref()),
221258
});
222259
output::print_success(format, &msg, start);
223260
Ok(())
@@ -297,10 +334,12 @@ pub async fn run(
297334
let token = auth::resolve_token(profile)?;
298335
match token_format.as_str() {
299336
"json" => {
337+
let claims = token.unverified_claims();
300338
let msg = serde_json::json!({
301339
"access_token": token.access_token,
302340
"token_type": token.token_type,
303341
"expires_at": token.expires_at.map(|e| e.to_rfc3339()),
342+
"token_diagnostics": token_diagnostics(claims.as_ref()),
304343
});
305344
let start = Instant::now();
306345
output::print_success(format, &msg, start);

tests/cli.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use assert_cmd::Command;
2+
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
23
use predicates::prelude::*;
34
use std::fs;
45

@@ -80,6 +81,29 @@ fn auth_doctor_reports_oso_default_without_login() {
8081
);
8182
}
8283

84+
#[test]
85+
fn auth_doctor_reports_token_audience() {
86+
let payload = serde_json::json!({
87+
"aud": "https://graph.microsoft.com",
88+
"tid": "tenant-1",
89+
"scp": "User.Read"
90+
});
91+
let token = format!(
92+
"header.{}.signature",
93+
URL_SAFE_NO_PAD.encode(payload.to_string())
94+
);
95+
96+
teams()
97+
.args(["auth", "doctor", "--output", "json"])
98+
.env("TEAMS_CLI_ACCESS_TOKEN", token)
99+
.assert()
100+
.success()
101+
.stdout(
102+
predicate::str::contains("https://graph.microsoft.com")
103+
.and(predicate::str::contains("\"is_graph_audience\": true")),
104+
);
105+
}
106+
83107
#[test]
84108
fn completions_generates_output() {
85109
teams()

0 commit comments

Comments
 (0)