Skip to content

Name the permissions a token holds when Graph answers 403 - #83

Open
aberoham wants to merge 2 commits into
osodevops:mainfrom
aberoham:fix/permission-denied-hint
Open

Name the permissions a token holds when Graph answers 403#83
aberoham wants to merge 2 commits into
osodevops:mainfrom
aberoham:fix/permission-denied-hint

Conversation

@aberoham

@aberoham aberoham commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Graph refuses a request whose token lacks the necessary permission with 403
and, in the case that prompted this, an empty message:

{"error":{"code":"Forbidden","message":"", ...}}

So the failure said nothing about what was missing and looked like any other
authorization refusal. Working it out meant running teams auth status and
comparing the scope list against the operation by hand.

The CLI already holds that list. A 403 now names the delegated scopes the
token carries, or its application roles when the token is app-only, and points
at auth doctor and auth login:

Permission denied: Microsoft Graph returned 403 with no message
Hint: this token carries the scopes: Presence.Read.All User.Read. If the one this operation needs is not in that list, run `teams auth doctor` to see what the profile resolves to, then `teams auth login` to consent to it.

The remedy differs by token type and by tenant. A delegated scope can be
consented interactively, or through auth consent-url where the tenant
reserves consent to an administrator, and the hint names both. An application
role is granted on the app registration and consented by an administrator, so
an app-only caller is told that rather than being pointed at auth login,
which cannot add one.

This follows auth_error_message, which already appends an audience hint to a
401, and covers all three places a 403 becomes an error: error_for_status,
request_with_retry and request_with_retry_no_content.

The CLI cannot say which permission the operation needs without a mapping
from every endpoint to its scopes, which would be a table to maintain and get
wrong. Listing what the token has is the part it can know for certain.

An opaque token yields no claims and gets no hint rather than a guess.

One tradeoff worth naming: this puts the token's full permission list into an
error that may end up in a CI log or an agent transcript. It is not a
credential, and auth status already prints the same list, but it is a wider
audience than before. Hiding it behind a verbose flag was the alternative and
would defeat the purpose, since the unattended caller is the one that cannot
go and look.

Tests

Five wiremock tests: a delegated token listing its scopes, an app-only token
listing its roles and getting the administrator remedy — asserted both by what
it says and by teams auth login being absent, so that branch cannot quietly
regress — an opaque token adding nothing, and one test per remaining call site,
a no-content write and a byte download. Reverting any one of the three call
sites alone fails its own test and no other — the byte download matters
because error_for_status is reached by post_for_location and get_bytes,
not by a paginated read, which routes through request_with_retry like any
other GET.

Fixes #80

🤖 Generated with Claude Code

https://claude.ai/code/session_01C736oc7CeNtRZmAJrXm8sG

aberoham and others added 2 commits August 28, 2026 16:01
Graph refused the presence writes with `Forbidden` and an empty `message`, so
the failure said nothing about which permission was missing and looked like
any other authorization refusal. Diagnosing it meant going and reading the
token's scopes by hand.

The CLI already holds them. A 403 now lists the delegated scopes the token
carries, or its application roles when the token is app-only, and says what
would add the missing one. The remedy differs by token type and by tenant: a
delegated scope can be consented interactively, or through `auth consent-url`
where the tenant reserves consent to an administrator; an application role is
granted on the app registration and consented by an administrator, so no
login can add one.

An opaque token yields no claims and gets no hint rather than a guess. This
follows the existing 401 audience hint, and covers all three places a 403 is
turned into an error — `error_for_status`, `request_with_retry` and
`request_with_retry_no_content` — each with a test that fails if its own call
site is reverted. The app-only test also asserts the login advice is absent,
so that branch cannot quietly regress to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C736oc7CeNtRZmAJrXm8sG
The test named for `error_for_status` drove `get_paged`, which routes a
single page through `request_with_retry` like any other GET, so it exercised
a copy of the 403 handling that another test already covered and would have
passed with `error_for_status` reverted.

`error_for_status` is reached by `post_for_location` and the byte download,
so the test now drives `get_bytes`. Reverting any one of the three call sites
alone fails its own test and no other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C736oc7CeNtRZmAJrXm8sG
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A 403 says nothing about which permission the token is missing

1 participant