Skip to content

fix(oauth): a loopback redirect URI matches on any port — local MCP clients can log in - #238

Merged
windischb merged 1 commit into
developfrom
fix/loopback-redirect-port
Sep 19, 2026
Merged

windischb merged 1 commit into
developfrom
fix/loopback-redirect-port

Conversation

@windischb

Copy link
Copy Markdown
Contributor

Summary

Reported from the field (amZettel, priority high): Claude Code could not log in against an MCP server behind Modgud — /connect/authorize answered invalid_request (ID2043), 'http://localhost:40489/callback' was not a valid redirect_uri. Its CIMD document registers http://localhost/callback port-less, as every local MCP client does (Cursor, VS Code, the MCP Inspector too): a native app cannot reserve a port, it takes an ephemeral one at request time, and RFC 8252 §7.3 requires the server to accept whatever port arrives. That blocked the one class of client the DCR/CIMD work exists for.

Cause

OpenIddict already implements the tolerance (loopback on both sides, registered URI without a port, same scheme and path) — but only for a client whose application type is native, and nothing in Modgud ever set it: the synthesized CIMD client was hard-wired web, DCR ignored application_type and created with null, admin-created clients had no way to say it. The field existed in the domain and the store served it; it was just never native.

Fix — one rule, in the one place OpenIddict asks

MartenApplicationStore.GetApplicationTypeAsync returns the effective type (OAuthApplicationTypes.Effective): a client with a loopback http redirect URI is native whatever it declared — only a native app can have such a URI in the first place (OIDC DCR §2) — otherwise the declared type stands.

Path Report's proposal This PR
CIMD set native at synthesize ✓
DCR, new registrations derive from application_type / URIs ✓
DCR, existing clients re-register work immediately
Admin-created clients — ✓

Rolling-safe (no stored value changes). Nothing is loosened: OpenIddict relaxes the port only between loopback URIs, only when the registered one carries none; https URIs still match exactly — the tests assert another path and a non-loopback host stay refused.

Around it: the CIMD parser reads an optional application_type (web | native, anything else invalid) and Synthesize uses the effective type instead of web; DCR reads application_type, rejects anything but the two literal values as invalid_client_metadata, records the declaration and echoes the effective value; CreateOAuthClientDto.ApplicationType carries it through. The registration validators are unchanged — they already knew the rule the runtime check did not.

Test plan

  • CimdFullFlowTests.Loopback_redirect_with_an_ephemeral_port_is_accepted_for_a_cimd_client — the exact Claude Code document; random ports on both loopback hosts and the port-less form pass, another path / a non-loopback host are refused, and the code exchange completes with the ported URI. Red with the store and CIMD resolver reverted, green with the fix.
  • DcrFullFlowTests.Loopback_redirect_with_an_ephemeral_port_is_accepted_for_a_dcr_client — native echoed, ephemeral port accepted, bogus application_type → invalid_client_metadata, an https-only client gets nothing invented
  • Unit tests: OAuthApplicationTypes.Effective / IsKnown, CIMD parser, DCR validator
  • Full backend suite green on a clean build (837 integration + 1693 unit)
  • CI on this PR

🤖 Generated with Claude Code

…lients can log in

Reported from the field (amZettel, 2026-09-19, priority high): Claude Code against an
MCP server behind Modgud failed at /connect/authorize with invalid_request (ID2043),
"'http://localhost:40489/callback' was not a valid redirect_uri". Its CIMD document
registers http://localhost/callback port-less, as every local MCP client does (Cursor,
VS Code, the MCP Inspector too): a native app cannot reserve a port, it takes an
ephemeral one at request time, and RFC 8252 §7.3 requires the server to accept whatever
port arrives. That blocked EVERY local MCP client — the one class of client the DCR/CIMD
work exists for.

OpenIddict already implements the tolerance (loopback on both sides, registered URI
without a port, same scheme and path), but only for a client whose application type is
"native" — and nothing in Modgud ever set it. The synthesized CIMD client was hard-wired
"web"; DCR never read application_type and created with null; admin-created clients had
no way to say it. Three places, one gap.

One rule, in the one place OpenIddict asks. MartenApplicationStore.GetApplicationTypeAsync
now returns the EFFECTIVE type (OAuthApplicationTypes.Effective): a client with a loopback
http redirect URI is native whatever it declared — only a native app can have such a URI
in the first place (OIDC DCR §2) — otherwise the declared type stands. That covers CIMD
documents, DCR registrations old and new (no re-registration needed) and admin-created
clients alike, is rolling-safe (no stored value changes), and loosens nothing else:
OpenIddict relaxes the port only between loopback URIs, https URIs still match exactly.

Around it: the CIMD parser reads an optional application_type (web | native, anything
else invalid) and Synthesize uses the effective type instead of "web"; DCR reads
application_type, rejects anything but the two literal values as invalid_client_metadata,
records the declaration on the client and echoes the effective value in the response;
CreateOAuthClientDto carries ApplicationType through so DCR (and the admin API) can set
it. The registration validators are unchanged — they already knew the rule the runtime
check did not.

Tests: Loopback_redirect_with_an_ephemeral_port_is_accepted_for_a_cimd_client (the exact
Claude Code document; random ports on both loopback hosts and the port-less form pass,
another path or a non-loopback host is still refused, and the code exchange completes with
the ported URI — red with the store and CIMD resolver reverted, green with the fix),
_for_a_dcr_client (native echoed, ephemeral port accepted, bogus type rejected, an
https-only client gets nothing invented), plus the unit tests on Effective/IsKnown and the
two parsers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@windischb
windischb merged commit 787e740 into develop Sep 19, 2026
8 checks passed
@windischb
windischb deleted the fix/loopback-redirect-port branch September 19, 2026 12:22
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