Skip to content

feat(oauth): let a person grant only some of the permissions a connected app asks for - #1327

Merged
mforce merged 9 commits into
mainfrom
feat/consent-partial-scopes
Oct 18, 2026
Merged

mforce merged 9 commits into
mainfrom
feat/consent-partial-scopes

Conversation

@mforce

@mforce mforce commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

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)

  1. Checkboxes show on every interactive authorization, including a reconnect of an app that is already fully approved. This replaces the 2026-10-09 screen that asked a reconnecting app only for the password. Every approval still spends a password check. The screen says that earlier connections keep their access until Disconnect in Account › Connected apps.
  2. On a reconnect, the permissions allowed before start ticked. The others start unticked and marked New. On a first connection, every permission the role can use starts ticked. Declined permissions are not stored.
  3. A permission the person's role cannot use shows Not allowed for your role, has no checkbox and is never granted. The server decides which permissions are usable, from each MCP tool's own role policy. The SPA's hand-kept role list is gone.

Design

R is what the app requested, U is what the person's role can use, and G is what they grant.

  • G ⊆ R ∩ U, checked in one place. ConsentSelection.Read parses the new X-Cluckwork-Consent-Scopes header, a JSON array. It refuses a scope outside R or U, malformed JSON, or a repeated header with 400 OAuth.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.
  • U comes from the tools. CluckworkMcp.UsableScopesAsync reads 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.
  • Nothing ticked is Cancel. [] returns access_denied to 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.
  • Exact-set reuse. An approval reuses only a valid authorization whose scopes equal G and audits User.AppReconnected. Otherwise it creates one holding G and audits User.AppConnected, including when G is narrower. Earlier authorizations and their tokens are untouched until Disconnect.
  • Audit details carry scopes (G) and requested (R).
  • The token response's scope is G. OpenIddict emits it from the principal. No handler was added, and a test asserts it.
  • Hidden tools stay JSON-RPC errors. A call to a tool outside G returns 200 with JSON-RPC -32600, never a 403 insufficient_scope, so clients do not start re-authorization.
  • The SPA freezes the request and the selection before the password check. The checkboxes are disabled while Allow runs. A wrong password keeps the choices, and a new request starts afresh. The consent request now loads through useLatestLoad and 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.cs and the Authorize handler in OAuthEndpoints.cs.
  • CluckworkMcp.UsableScopesAsync.
  • web/src/routes/ConnectPage.tsx. The scope catalog web/src/auth/oauthScopes.ts lost its roles list.
  • Tests: OAuthConsentTests.PartialConsent.cs and Mcp/McpPartialConsentTests.cs.
  • Docs: decision records 798, 799 and 1311, src/AGENTS.md, Help and both glossaries in en, es and tl, and specs/product/GLOSSARY.md.
  • tools/verify/mcp/client.ts can now untick or tick scopes, cancel an empty choice, and approve another client's authorize URL.

Accepted deviations

  • Test-only scopes get a probe MCP tool (TestScopes.AddTestScope). It is registered only in the OAuthFailClosedTests and AuditConnectedAppTests hosts. No production walk sees it, and the guard that counts SDK tools is unchanged.
  • Tests that need a token holding a scope the role cannot use mint it with alterIssued. Each call site says that the token models one approved before a role change. RoleBlockedScope_IsRefused_BeforeTheProofIsSpent pins the real refusal at consent.
  • A first load with no choice and no password shows the screen even when the role can use none of the requested scopes, with only Cancel, so the person sees why.
  • Claude Code's model step was not run. Asking Claude Code to submit an entry, and checking that it offers no tool and no sign-in prompt, is untested. See Gaps.

Live run

The live run used a stack built at 042bc41d, with the all-blocked frames recaptured at dbe3f394. 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.

  • Claude Code 2.1.296, SDK arm v2 by default, with no scope override. The design's source research read Claude Code 2.1.302, not the version run here. It asked for all five scopes. After unticking Submit daily entries, Record farm operations and Record expenses, its stored token scope is daily-entries:write farm:read. The stored authorization and audit row hold the same, and requested lists all five. With that token, tools/list shows the nine read tools and cluckwork_record_daily_entry. claude mcp list reports Connected, with no prompt for more permissions.
  • Reconnect. The screen showed Read farm data and Record daily entries ticked, and the other three unticked and New. Allow reused the authorization and audited Reconnected. A further reconnect that ticked Submit daily entries audited Connected, and cluckwork_submit_daily_entry appeared.
  • A fully approved app still showed checkboxes. A narrower approval gave a farm:read token while the older token kept all 15 tools. Disconnect then refused both with 401.
  • Worker. Record expenses shows Not allowed for your role with no checkbox. A crafted request selecting expenses:write got 400 before the password check was spent. The same grant then approved farm:read, and a replay got 403.
  • Gemini CLI 0.64.0 with no oauth.scopes asked for all five scopes. After the same partial grant, its /mcp list shows cluckwork - Ready (10 tools), the same ten.
  • A direct call to cluckwork_submit_daily_entry with the narrow token returned HTTP 200 with JSON-RPC -32600 and no WWW-Authenticate header.

Tests

  • Integration (filtered). The OAuth and MCP suites pass: 584 tests in the mutation run's 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.
  • Web. CI's web job passed 3903 tests in 162 files, with typecheck and build. Its precache budget check reports 1532.43 KiB of the 1600 KiB ceiling.
  • Mutations. tools/oauth/mutation-check.sh ran 33 distinct rows: 19 new and 14 existing, 6 of them re-anchored. 32 rows were killed by their named assertion. mcp-tools-list-unfiltered was killed by the SDK rejecting tools/list because its authorization filter had not run, not by its named assertion. SUITE_MIN rose from 355 to the measured 584.

Gaps

  • Claude Code was not asked to submit an entry, because its scratch config had no model login, so its behavior with a model is untested. The agent's one attempt to find out whether a scratch config could reach a model (a one-word claude -p prompt and claude 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.
  • Claude's hosted connector was not tested.

Accepted costs

  • One authorization row per distinct granted set, so the stored row always equals the token's scopes.
  • Narrowing a connection leaves older tokens working until Disconnect.
  • An older SPA's approval now drops scopes the role cannot use, which it used to grant without effect.
  • A new SPA served against a rolled-back API would grant all of R, because the old API ignores the header. Both ship in one image.

Screenshots are in the first three comments: consent in English, consent in Spanish, then Connected apps and Help.

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.
@mforce

mforce commented Oct 18, 2026

Copy link
Copy Markdown
Owner Author

Consent screen, English, before and after

Captured at 1:1, light theme, 1280 and 390 wide. Before: f0df35fc (main). After: 042bc41d, except the all-blocked state at dbe3f394; the later commits 19f5bebb and 4d0b2ad0 change no markup.

First connection: every usable permission ticked

1280 390
Before en first before 1280 en first before 390
After en first after 1280 en first after 390

Partial choice: Submit, operations and expenses unticked

1280 390
After (new state) en partial-choice after 1280 en partial-choice after 390

Reconnect after a partial grant: allowed before ticked, the rest New

1280 390
After (new state) en reconnect-after-partial after 1280 en reconnect-after-partial after 390

Reconnect of a fully approved app

1280 390
Before en approved-reconnect before 1280 en approved-reconnect before 390
After en approved-reconnect after 1280 en approved-reconnect after 390

Worker: Record expenses not allowed, no checkbox

1280 390
Before en worker before 1280 en worker before 390
After en worker after 1280 en worker after 390

Nothing ticked: only Cancel

1280 390
After (new state) en nothing-ticked after 1280 en nothing-ticked after 390

Worker asking only for expenses: nothing can be ticked

1280 390
After (new state) en all-blocked after 1280 en all-blocked after 390

@mforce

mforce commented Oct 18, 2026

Copy link
Copy Markdown
Owner Author

Consent screen, Spanish, before and after

Captured at 1:1, light theme, 1280 and 390 wide. Before: f0df35fc (main). After: 042bc41d, except the all-blocked state at dbe3f394; the later commits 19f5bebb and 4d0b2ad0 change no markup.

First connection: every usable permission ticked

1280 390
Before es first before 1280 es first before 390
After es first after 1280 es first after 390

Partial choice: Submit, operations and expenses unticked

1280 390
After (new state) es partial-choice after 1280 es partial-choice after 390

Reconnect after a partial grant: allowed before ticked, the rest New

1280 390
After (new state) es reconnect-after-partial after 1280 es reconnect-after-partial after 390

Reconnect of a fully approved app

1280 390
Before es approved-reconnect before 1280 es approved-reconnect before 390
After es approved-reconnect after 1280 es approved-reconnect after 390

Worker: Record expenses not allowed, no checkbox

1280 390
Before es worker before 1280 es worker before 390
After es worker after 1280 es worker after 390

Nothing ticked: only Cancel

1280 390
After (new state) es nothing-ticked after 1280 es nothing-ticked after 390

Worker asking only for expenses: nothing can be ticked

1280 390
After (new state) es all-blocked after 1280 es all-blocked after 390

@mforce

mforce commented Oct 18, 2026

Copy link
Copy Markdown
Owner Author

Connected apps and Help, before and after

Captured at 1:1, light theme. Before: f0df35fc (main). After: 042bc41d; no later commit changes these screens.

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

Before After
en connected apps before en connected apps after

Account › Connected apps, Spanish

Before After
es connected apps before es connected apps after

Help, connected-app items, English

Before After
1280 en help before 1280 en help after 1280
390 en help before 390 en help after 390

Help, connected-app items, Spanish

Before After
1280 es help before 1280 es help after 1280
390 es help before 390 es help after 390

@mforce
mforce merged commit ce7807e into main Oct 18, 2026
22 of 23 checks passed
@mforce
mforce deleted the feat/consent-partial-scopes branch October 18, 2026 22:36
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.

Consent: let the person grant only some of the requested permissions

1 participant