Consent that means it, and a way to end one OAuth authorization - #236
Merged
windischb merged 3 commits intoSep 18, 2026
Merged
Conversation
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
deleted the
feat/consent-remember-and-authorization-revoke
branch
September 18, 2026 11:06
This was referenced Sep 18, 2026
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>
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.
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=falsenow 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, sooi_au_idstays 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) andDELETE /api/app/{id}/authorizations/{authorizationId}, behind the newoauth-authorization:read|revokepermission and bound to the client'sAppIds, 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 writessecurity.authorization_revokedonce per real revoke.IdandSubjectare 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 inDcrFullFlowTests(second authorize re-prompts with the sameoi_au_id, approved redirect works once, scope subset completes). Full backend suite locally: 1668 unit + 834 integration, all green.🤖 Generated with Claude Code