Skip to content

Consent that means it, and a way to end one OAuth authorization - #236

Merged
windischb merged 3 commits into
developfrom
feat/consent-remember-and-authorization-revoke
Sep 18, 2026
Merged

windischb merged 3 commits into
developfrom
feat/consent-remember-and-authorization-revoke

Conversation

@windischb

Copy link
Copy Markdown
Contributor

Why

A consumer (resource server + BFF) wants to show its users their connected systems (an MCP host, a Home Assistant instance) and let them disconnect one. It keys its own per-connection state on the token's sub + oi_au_id. Two things stood in the way, and a third turned up while testing.

What changes

AllowRememberConsent=false now does something. The flag was stored, exported in the manifest and documented ("forced off for DCR clients, so the AS never skips consent"), but nothing read it at runtime: a remembered authorization skipped the consent screen for every client, DCR included. Authorize now honours it. Every fresh authorize flow of such a client shows the screen again (RFC 8252 §8.6, relevant for public clients with loopback redirects). The existing authorization is still reused, so oi_au_id stays stable across re-consents. The post-consent re-entry gets through by redeeming its approved ticket once (?consent_ticket=), read from the raw query so it also works under PAR. CIMD clients get the flag forced off like DCR clients.

Unticking an optional scope no longer loops. The consent screen lets the user drop every non-required scope, and the server stored the authorization for that subset. The re-entry then looked for an authorization covering the full requested list, found none, and sent the user back to /consent, forever. This affected every explicit-consent client. The approved ticket now carries the scopes the user kept and the grant is issued for exactly those.

Management API: list and revoke an App's authorizations. GET /api/app/{id}/authorizations (subject, limit, after) and DELETE /api/app/{id}/authorizations/{authorizationId}, behind the new oauth-authorization:read|revoke permission and bound to the client's AppIds, like the scope read. An authorization is in an App's reach when one of its scopes is app-scoped to the App or lists one of its OAuth APIs as a resource; anything else answers 404. Revoke takes the tokens first, then the authorization, then a native client session built on it. It is idempotent and writes security.authorization_revoked once per real revoke. Id and Subject are returned as they appear in the token, not as ShortGuids, because the consumer joins on them.

Behaviour change to be aware of

DCR and CIMD clients that re-run the authorize flow now see the consent screen each time. Clients that keep and refresh their tokens are unaffected.

Docs

Management API page (new section + table rows), DCR, CIMD and OAuth-client pages. The scope-read example on the Management API page showed camelCase keys; the API emits PascalCase, corrected.

Tests

AppAuthorizationsApiTests (reach, paging, revoke + idempotent audit, 404 outside reach, foreign App 403, read-only permission 403) and three new cases in DcrFullFlowTests (second authorize re-prompts with the same oi_au_id, approved redirect works once, scope subset completes). Full backend suite locally: 1668 unit + 834 integration, all green.

🤖 Generated with Claude Code

windischb and others added 3 commits September 18, 2026 10:07
The flag was stored, exported and documented, but nothing read it: a
remembered authorization skipped the consent screen for every client,
DCR included, although the DCR docs promise the opposite.

Authorize now honours it. The existing authorization is still reused, so
the grant's oi_au_id survives a re-consent; the post-consent re-entry
redeems its approved ticket once (?consent_ticket=) instead of leaning
on the remembered-authorization shortcut. CIMD clients get the flag
forced off like DCR clients.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A consuming application that lets its users disconnect a connected
system could only stop the access token on its own side; the refresh
token kept living in Modgud, and RFC 7009 is out of reach because the
connected system is the OAuth client, not the resource server.

GET/DELETE /api/app/{id}/authorizations join the Management API behind
the new oauth-authorization:read|revoke permission, bound to the
client's AppIds like the scope read. An authorization is in reach when
one of its scopes is app-scoped to the App or targets one of its OAuth
APIs; everything else answers 404. Revoke takes the authorization, all
its tokens and a native client session built on it, is idempotent, and
writes security.authorization_revoked.

The management-api scope example showed camelCase keys; the API emits
PascalCase.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The consent screen lets the user drop every non-required scope, and the
server stored the authorization for that subset. The authorize re-entry
then looked for an authorization covering the FULL requested list,
found none, and sent the user to /consent again, forever.

The approved ticket now carries the scopes the user kept. The re-entry
redeems it for every explicit-consent client and issues the grant for
exactly those scopes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@windischb
windischb merged commit ea08418 into develop Sep 18, 2026
8 checks passed
@windischb
windischb deleted the feat/consent-remember-and-authorization-revoke branch September 18, 2026 11:06
windischb added a commit that referenced this pull request Sep 24, 2026
…e, one draft bar (#242)

* fix(oauth): a dynamic client remembers consent when its identity is assured

Since #236 AllowRememberConsent is enforced, and DCR and CIMD always stored
it false, so every dynamic client saw the consent screen on every authorize
- claude.ai and ChatGPT included.

For a dynamic client the stored flag is no longer consulted; RFC 8252 §8.6
decides (DynamicClientConsent): a remembered authorization skips the screen
when the request's redirect is https on a real host, or the client is
confidential (private_key_jwt, a DCR secret). A public client redirecting
to loopback or a private-use scheme (Claude Code, VS Code, Zed, Cursor) is
still asked every time: its client_id is public and any local process can
listen on a loopback port - on any port since #238. Admin-created clients
keep their own flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(provisioning)!: an apply never deletes what the manifest leaves out

Full-sync prune is removed from every manifest route (control plane,
data plane, drafts), together with the two-step confirmation the 0.14
betas carried (409 Manifest.ConfirmationRequired, confirmation tokens,
parked review drafts, PruneOnApply). An apply is an additive merge by id;
deleting is a staged deletion in a draft, which the plan shows before the
apply - for the admin UI and a Management API client alike. The plan
loses its Prune field and the "protected" action. A service-account
credential is never deleted through the clients section: it leaves
through its account's Credentials list, the only place the plan shows it.

Found in the pre-release review and fixed on the way:
- Draft endpoints took the caller from NameIdentifier, which a bearer
  principal does not carry: every Management API call answered 500.
  They read sub first now.
- Staging matched a draft entry by natural key only, so renaming an
  entity inside a draft appended a second entry with the same id.
  Staging matches by Id first.
- A credential with an Id in the manifest was issued under a random id;
  the next apply of the same file re-issued its client_id and failed on
  ClientIdAlreadyExists. The issue op now takes the pinned id.
- About fifteen strings of the staging UI had no German translation, and
  a few English fallbacks were German.

BREAKING CHANGE: ?prune=true is no longer read; a script sending it gets
the additive merge and no deletions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(provisioning): what a click-through of the draft UI turned up

- A dormant group (empty BoundTo) came out of export -> apply into
  another realm bound to the system app: the export wrote the empty list
  as absent, and a create reads absent as ['modgud']. Empty BoundTo,
  Capabilities and job Parameters now export as empty, which also removes
  the phantom "(empty) -> []" plan changes for untouched groups and jobs.
- A credential secret created by a draft apply vanished when the apply
  ran from the staging bar on another admin page (toast only). The bar
  now opens the drafts workspace, which shows the one-time secrets.
- The service-account grid was empty after an apply until a reload: the
  post-apply refresh skipped service accounts, jobs and the inbox policy,
  and the applier dispatched no ServiceAccount event. Both fixed; the
  refresh no longer requests positions with the feature off (404).
- A staged deletion can be taken back from its plan entry (the plan note
  said "unstage", the modal had no way to), and the previous apply's
  outcome no longer stands above the next draft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ui): one footer bar for the draft, the export basket moves to the header

A UI review of the draft surface (screenshots of every state) found two
look-alike footer bars stacked on each other doing unrelated things: the
export basket's "Exportieren…" sat right above "Draft anwenden", both
primary, and a draft could only be discarded from inside the workspace or
after parking it.

- The export selection is a header chip (icon + count) with a panel:
  per-entry remove, Clear, and a secondary "Download as manifest…".
- The staging bar is the only footer bar: accent stripe, the draft name
  plus "n staged" / "m errors" as one link to the review, Discard (with a
  danger confirm, from any admin page), Park, and Apply as the single
  primary. A disabled Apply says why. On narrow windows Discard and Park
  move into a "⋯" menu. The workspace no longer repeats Apply / Discard.
- Confirmations say what they do ("Anwenden", "Verwerfen", "Abbrechen")
  instead of the library's "OK" / "Cancel"; the library's popconfirm arrow
  (never positioned, it covered the title's first letter) is hidden.
- Lists show the staged state as "New" / "Changed" / "To be deleted", and
  their delete action reads "Stage deletion" while staging.
- A refused staged deletion shows its intent and reason on the plan card;
  its modal shows the refusal as an error and "Undo the deletion" as the
  primary action, without the contradicting "applying deletes it" note.
- Auto-named drafts read "Draft by <user> · <local time>" (the stored name
  carries UTC); parked/shared drafts are counted in the sidebar.
- Selective export: group roles no longer render as [object Object], the
  hint wraps, singular/plural wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant