Repository navigation
feat(oauth): let a person grant only some of the permissions a connected app asks for - #1327
Merged
Merged
Conversation
The server derives which scopes a role can use from the MCP tools' own policies, validates the choice before the step-up grant is spent, treats an empty choice as Cancel, and reuses only an authorization holding exactly the granted scopes. Mutation rows cover each rule.
…n en, es and tl The role list mirrored in the SPA is gone: blocked rows come from the server. Decision records 798, 799 and 1311 are amended.
…rt a request the person left
…ested permissions
…g to a request reads it afresh
Owner
Author
Owner
Author
Consent screen, Spanish, before and afterCaptured at 1:1, light theme, 1280 and 390 wide. Before: First connection: every usable permission ticked
Partial choice: Submit, operations and expenses unticked
Reconnect after a partial grant: allowed before ticked, the rest New
Reconnect of a fully approved app
Worker: Record expenses not allowed, no checkbox
Nothing ticked: only Cancel
Worker asking only for expenses: nothing can be ticked
|
Owner
Author
Connected apps and Help, before and afterCaptured at 1:1, light theme. Before: Connected apps after a partial grant lists the partial app as Read farm data and Record daily entries, next to a fully approved one. Account › Connected apps, English
Account › Connected apps, Spanish
Help, connected-app items, English
Help, connected-app items, Spanish
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.




















































A person approving a connected AI app can now untick any permission the app asks for and grant only the rest. The token, the stored authorization and the audit row hold exactly what was ticked. The app then sees only the matching MCP tools.
Closes #1326
The maintainer's decisions (2026-10-18)
Design
R is what the app requested, U is what the person's role can use, and G is what they grant.
ConsentSelection.Readparses the newX-Cluckwork-Consent-Scopesheader, a JSON array. It refuses a scope outside R or U, malformed JSON, or a repeated header with 400OAuth.InvalidConsentScopes. The check runs before the farm switch and before the step-up grant is spent. A request without the header comes from an older SPA and gets R ∩ U.CluckworkMcp.UsableScopesAsyncreads each registered tool's[Authorize]metadata, as the SDK's authorization filter does. A scope is usable when a tool requires it and the person meets that tool's role policies. A scope no tool requires is usable by nobody. There is no role-to-scope table on either side.[]returnsaccess_deniedto the app. No grant is spent and no row or audit is written, even with the farm switch off. Read farm data may be unticked.User.AppReconnected. Otherwise it creates one holding G and auditsUser.AppConnected, including when G is narrower. Earlier authorizations and their tokens are untouched until Disconnect.scopes(G) andrequested(R).scopeis G. OpenIddict emits it from the principal. No handler was added, and a test asserts it.-32600, never a 403insufficient_scope, so clients do not start re-authorization.useLatestLoadand aborts when the person leaves it. With nothing ticked, the password field, Allow and Details disappear. Details names only the ticked permissions.Where to start reviewing
src/Cluckwork.Api/Modules/Access/OAuth/ConsentSelection.csand theAuthorizehandler inOAuthEndpoints.cs.CluckworkMcp.UsableScopesAsync.web/src/routes/ConnectPage.tsx. The scope catalogweb/src/auth/oauthScopes.tslost itsroleslist.OAuthConsentTests.PartialConsent.csandMcp/McpPartialConsentTests.cs.src/AGENTS.md, Help and both glossaries in en, es and tl, andspecs/product/GLOSSARY.md.tools/verify/mcp/client.tscan now untick or tick scopes, cancel an empty choice, and approve another client's authorize URL.Accepted deviations
TestScopes.AddTestScope). It is registered only in theOAuthFailClosedTestsandAuditConnectedAppTestshosts. No production walk sees it, and the guard that counts SDK tools is unchanged.alterIssued. Each call site says that the token models one approved before a role change.RoleBlockedScope_IsRefused_BeforeTheProofIsSpentpins the real refusal at consent.Live run
The live run used a stack built at
042bc41d, with the all-blocked frames recaptured atdbe3f394. The later commits change only the consent page's loading and state handling, the verifier script and docs. Real clients went through a TLS proxy and real consent with step-up.daily-entries:write farm:read. The stored authorization and audit row hold the same, andrequestedlists all five. With that token,tools/listshows the nine read tools andcluckwork_record_daily_entry.claude mcp listreports Connected, with no prompt for more permissions.cluckwork_submit_daily_entryappeared.farm:readtoken while the older token kept all 15 tools. Disconnect then refused both with 401.expenses:writegot 400 before the password check was spent. The same grant then approvedfarm:read, and a replay got 403.oauth.scopesasked for all five scopes. After the same partial grant, its/mcp listshowscluckwork - Ready (10 tools), the same ten.cluckwork_submit_daily_entrywith the narrow token returned HTTP 200 with JSON-RPC-32600and noWWW-Authenticateheader.Tests
OAuth|ClientMetadata|Mcp.filter, green in the baseline and restore runs before and after each of the two mutation runs. The architecture guards pass in CI's Tests (application) job.tools/oauth/mutation-check.shran 33 distinct rows: 19 new and 14 existing, 6 of them re-anchored. 32 rows were killed by their named assertion.mcp-tools-list-unfilteredwas killed by the SDK rejecting tools/list because its authorization filter had not run, not by its named assertion.SUITE_MINrose from 355 to the measured 584.Gaps
claude -pprompt andclaude auth status) was refused by its automatic permission check as credential exploration, and no other way to give that config a model login was tried. The tool list read with its token and the direct hidden-tool call are separate checks. They show which tools the server offers that token and that a hidden call gets no scope challenge. They do not show what Claude Code does when asked to submit.Accepted costs
Screenshots are in the first three comments: consent in English, consent in Spanish, then Connected apps and Help.