Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/admin/client-id-metadata-documents.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ When a client presents an HTTPS-URL `client_id` that isn't a known stored client
1. Validates the URL shape — `https` scheme, has a path, no fragment, no userinfo, no dot-segments.
2. Fetches it over an **SSRF-hardened** transport (see below), capped at **5 KB**, `Accept: application/json`, ~5 s timeout, **no redirects**.
3. Validates the document (table below).
4. Synthesizes an in-memory public PKCE client with a deterministic id derived from the URL, JWT access tokens, and the redirect URIs / scopes the document declares.
4. Synthesizes an in-memory public PKCE client with a deterministic id derived from the URL, JWT access tokens, the redirect URIs the document declares, and the realm's **dynamic-client scopes** (see below) — narrowed to the document's `scope` when it has one.
5. Caches the validated document per URL, respecting `Cache-Control` (clamped to between 5 minutes and 24 hours; failures are never cached).

No database row is created. A refresh after the cache expires re-fetches and re-validates the live document — if the URL becomes unreachable, refresh fails and the client must re-authenticate.
Expand All @@ -58,7 +58,9 @@ CIMD shares DCR's resource/scope opt-in surface — flipping the master toggle d
| Per-API allow-list | [OAuth APIs](./oauth-apis) → **Allow DCR** checkbox per row | Off |
| Per-Scope allow-list | [OAuth Scopes](./oauth-scopes) → **Allow DCR Clients** checkbox per row | Off |

A CIMD client must send a `resource=` parameter, and the target resource server must have **Allow DCR** enabled — otherwise the token endpoint rejects with `invalid_target`. App-scoped scopes are reachable only when **Allow DCR Clients** is ticked, exactly as for DCR clients (a CIMD client is realm-wide and has no App link of its own). Global scopes (`openid`, `email`, `profile`, …) are always reachable.
A CIMD client must send a `resource=` parameter, and the target resource server must have **Allow DCR** enabled — otherwise the token endpoint rejects with `invalid_target`. The scopes it can hold and request are the realm's **dynamic-client scopes**: every scope with **Allow DCR Clients** ticked (app-scoped or not — a CIMD client is realm-wide and has no App link of its own) plus the standard scopes other than `modgud.management` (`openid`, `profile`, `email`, `phone`, `address`, `offline_access`, `roles`, `permissions`). Same rule as for [DCR clients](./dynamic-client-registration#how-the-per-scope-flag-interacts-with-app-scoped-scopes).

A document **without** `scope` — the normal case: Claude Code's document is one static file for every MCP server in the world and cannot know one server's scopes, the client reads them from the server's protected-resource metadata at run time — holds that whole set. A document **with** `scope` holds the intersection; it can narrow what its client may ask for, never widen it. The set is read live on every resolve, so ticking **Allow DCR Clients** on a scope takes effect at once, even for an already-cached document. Requesting a scope outside the set fails at `/connect/authorize` with `invalid_scope` and a description naming the scope and the flag.

## Enabling CIMD for a realm

Expand All @@ -67,7 +69,7 @@ A CIMD client must send a `resource=` parameter, and the target resource server
- **Access-token lifetime** (default 15 min) — shorter than admin-created clients on purpose; a leaked token from an unverified, domain-bound client has a smaller blast radius.
- **Refresh-token lifetime** (default 7 d).
3. **OAuth APIs → your MCP-server API** → tick **Allow DCR**.
4. **OAuth Scopes → the scope(s) the MCP server gates** → tick **Allow DCR Clients** (for app-scoped scopes).
4. **OAuth Scopes → the scope(s) the MCP server gates** → tick **Allow DCR Clients**.

## What's accepted in the metadata document

Expand All @@ -79,7 +81,7 @@ A CIMD client must send a `resource=` parameter, and the target resource server
| `token_endpoint_auth_method` | `none` or omitted. **v1 is public-only** — a `client_secret*` method or any `client_secret` field is rejected. |
| `grant_types` | Subset of `{authorization_code, refresh_token}`; must include `authorization_code`. |
| `response_types` | Subset of `{code}`. |
| `scope` | Optional, space-delimited. The scopes the client may request (still subject to the opt-in gates above). |
| `scope` | Optional, space-delimited. An **upper bound**: the client holds these scopes intersected with the realm's dynamic-client scopes. Omitted (as every static MCP-client document does), the client holds the whole set. |
| `client_name` | Optional. Used as the display name; the consent screen also shows the URL hostname regardless. |

A document that fails any rule is rejected and never cached; the authorize request fails as "unknown client".
Expand Down
16 changes: 11 additions & 5 deletions docs/admin/dynamic-client-registration.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,18 @@ The master toggle just turns the registration endpoint on. The per-API flag cont

### How the per-Scope flag interacts with app-scoped scopes

Most scopes you create in Modgud are **app-scoped** — they belong to one [Application](./applications) (`Scope.AppId` is set). Non-DCR clients are restricted to scopes whose `AppId` matches one of their own linked Apps. **DCR clients have no `AppId`** by design (they're realm-wide public PKCE clients), so the per-Scope `Allow DCR Clients` flag replaces the app-link check for them:
Most scopes you create in Modgud are **app-scoped** — they belong to one [Application](./applications) (`Scope.AppId` is set). Non-DCR clients are restricted to scopes whose `AppId` matches one of their own linked Apps. **DCR clients have no `AppId`** by design (they're realm-wide public PKCE clients), so the per-Scope `Allow DCR Clients` flag replaces the app-link check for them. The scopes a DCR client can hold and request — its **dynamic-client scopes** — are:

- **Global scopes** (`AppId = null` — the OIDC standards `openid`, `email`, `profile`, … plus any cross-app scope you create): always reachable by DCR clients.
- **App-scoped scope with `Allow DCR Clients = true`**: reachable by DCR clients. The realm-admin has explicitly opted this scope in for anonymous-registrant access.
- **App-scoped scope with `Allow DCR Clients = false`** (default): `/connect/authorize` rejects the request with `invalid_scope` before the user ever sees the consent screen. The agent gets a clear error description.
- **Any scope with `Allow DCR Clients = true`**, app-scoped or global. The realm-admin has explicitly opted this scope in for anonymous-registrant access.
- **The standard scopes other than `modgud.management`**: `openid`, `profile`, `email`, `phone`, `address`, `offline_access`, `roles`, `permissions`. They cannot carry the flag (standard scopes are immutable) and name no resource privilege of their own. The management-API selector is never available to a dynamic client.

The combined effect: enabling DCR safely requires you to walk through your existing scopes once and decide which ones agents are allowed to ask for. Until you tick `Allow DCR Clients` on at least one app-scoped scope (or create a fresh global scope), DCR clients can only request the OIDC standard scopes.
A custom scope **without** the flag (default) — app-scoped or global — is not: `/connect/authorize` rejects the request with `invalid_scope` before the user ever sees the consent screen, with a description naming the scope and the flag to enable.

The combined effect: enabling DCR safely requires you to walk through your existing scopes once and decide which ones agents are allowed to ask for. Until you tick `Allow DCR Clients` on at least one scope, DCR clients can only request the standard scopes.

### What a registration gets when it omits `scope`

RFC 7591 leaves it to the server ("a default set of scopes"). Modgud's default is the realm's dynamic-client scopes as they are at registration time; a registration **with** `scope` gets the intersection of what it asked for and that set — its own list is an upper bound, never a grant. The `201` response echoes the registered set in `scope`, so an agent can see what it holds. A client keeps the set it was registered with; a scope opted in later needs a re-registration (or a CIMD document, which is resolved live).

## Enabling DCR for a realm

Expand All @@ -80,6 +85,7 @@ After these four steps, an agent that POSTs to `/connect/register` with a valid
| `token_endpoint_auth_method` | Must be `none` (or omitted). Public PKCE only — no secret-storage. |
| `grant_types` | Subset of `{authorization_code, refresh_token}`. |
| `response_types` | Subset of `{code}`. No implicit / hybrid flows. |
| `scope` | Optional, space-delimited. An upper bound intersected with the realm's dynamic-client scopes; omitted, the client gets the whole set. The response echoes what was registered. |

On success the endpoint returns `201 Created` with the assigned `client_id` per RFC 7591 §3.2.1. On rejection it returns `400 Bad Request` with `{ error, error_description }` per §3.2.2. Hitting the rate-limit returns `429`.

Expand Down
76 changes: 70 additions & 6 deletions src/dotnet/Modgud.Api.Tests/Authorization/CimdFullFlowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,66 @@ public async Task Loopback_redirect_with_an_ephemeral_port_is_accepted_for_a_cim
Assert.Contains(AllowedAudience, new JwtSecurityTokenHandler().ReadJwtToken(accessToken).Audiences);
}

/// <summary>
/// A static CIMD document has no <c>scope</c> — Claude Code's is one document
/// for every MCP server in the world and cannot know one server's scopes; the
/// client learns them from the server's protected-resource metadata at run
/// time. Such a client must hold the realm's dynamic-client scope set (the
/// opted-in scopes plus the open standard scopes), so the per-scope opt-in is
/// reachable at all. It used to hold nothing, and OpenIddict refused every
/// scope but <c>openid</c>/<c>offline_access</c> with ID2051 before the opt-in
/// was ever consulted (reported from the field). A scope the admin did not
/// opt in stays refused — with Modgud's own message, not the opaque one — and
/// a document that does declare <c>scope</c> cannot name its way past that.
/// </summary>
[Fact]
public async Task A_document_without_scope_holds_the_realms_dynamic_client_scopes()
{
await SeedAsync();
const string notOptedIn = "cimd-not-opted-in-scope";
await CreateScopeAsync(notOptedIn, allowDynamicClients: false);
Factory.CimdDocuments[_clientIdUrl] = JsonSerializer.Serialize(new Dictionary<string, object?>
{
["client_id"] = _clientIdUrl,
["client_name"] = "Claude Code",
["redirect_uris"] = new[] { "http://localhost/callback", "http://127.0.0.1/callback" },
["grant_types"] = new[] { "authorization_code", "refresh_token" },
["response_types"] = new[] { "code" },
["token_endpoint_auth_method"] = "none",
});
const string callback = "http://localhost:40489/callback";

// The opted-in scope and the OIDC identity scopes reach consent.
Assert.StartsWith("/consent?ticket=", await DriveAuthorizeAsync(_clientIdUrl, $"openid {ScopeName}", callback));
Assert.StartsWith("/consent?ticket=", await DriveAuthorizeAsync(_clientIdUrl, "openid profile email offline_access", callback));

// A scope without the opt-in is refused — by Modgud, naming the flag. Request
// validation errors are rendered by OpenIddict as a page, not redirected.
await AssertRefusedScopeAsync(_clientIdUrl, $"openid {notOptedIn}", callback, "AllowDynamicRegistrationClients");

// The management selector is never a dynamic client's to ask for.
await AssertRefusedScopeAsync(_clientIdUrl, "openid modgud.management", callback, "not available to dynamically registered clients");

// And the whole flow completes on the default set.
var (accessToken, _) = await DriveCimdAuthCodeFlowAsync(_clientIdUrl, $"openid {ScopeName}", AllowedAudience, redirectUri: callback);
Assert.Contains(AllowedAudience, new JwtSecurityTokenHandler().ReadJwtToken(accessToken).Audiences);

// A document that declares scope gets the intersection, never more.
var declaring = $"https://cimd-app.test/oauth/{Guid.NewGuid():N}/declaring.json";
Factory.CimdDocuments[declaring] = BuildDocument(declaring, $"openid {ScopeName} {notOptedIn}");
Assert.StartsWith("/consent?ticket=", await DriveAuthorizeAsync(declaring, $"openid {ScopeName}"));
await AssertRefusedScopeAsync(declaring, $"openid {notOptedIn}", RedirectUri, "AllowDynamicRegistrationClients");
}

private async Task AssertRefusedScopeAsync(string clientId, string scope, string redirectUri, string expectedDescriptionPart)
{
var resp = await DriveAuthorizeResponseAsync(clientId, scope, redirectUri);
var body = await resp.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(resp.StatusCode == HttpStatusCode.BadRequest, $"expected 400, got {(int)resp.StatusCode} {resp.Headers.Location}\n{body}");
Assert.Contains("invalid_scope", body);
Assert.Contains(expectedDescriptionPart, body);
}

[Fact]
public async Task Consent_surfaces_hostname_and_unverified_marker()
{
Expand Down Expand Up @@ -237,16 +297,18 @@ private async Task CreateAllowedApiAsync()
Assert.False(result.IsError, DescribeErrors(result.Errors));
}

private async Task CreateAllowedScopeAsync()
private Task CreateAllowedScopeAsync() => CreateScopeAsync(ScopeName, allowDynamicClients: true);

private async Task CreateScopeAsync(string name, bool allowDynamicClients)
{
using var scope = NewSystemTenantScope();
var oauthAdmin = scope.ServiceProvider.GetRequiredService<OAuthAdminService>();
var result = await oauthAdmin.CreateScopeAsync(new CreateOAuthScopeDto
{
Name = ScopeName,
DisplayName = ScopeName,
Name = name,
DisplayName = name,
Resources = new List<string> { AllowedAudience },
AllowDynamicRegistrationClients = true,
AllowDynamicRegistrationClients = allowDynamicClients,
}, TestContext.Current.CancellationToken);
Assert.False(result.IsError, DescribeErrors(result.Errors));
}
Expand Down Expand Up @@ -372,13 +434,15 @@ private async Task<HttpResponseMessage> DriveCimdFlowThroughToTokenAsync(
/// <summary>Drives just the authorize GET; returns the redirect Location
/// (or null when the response isn't a redirect).</summary>
private async Task<string?> DriveAuthorizeAsync(string clientId, string scope, string? redirectUri = null)
=> (await DriveAuthorizeResponseAsync(clientId, scope, redirectUri)).Headers.Location?.ToString();

private async Task<HttpResponseMessage> DriveAuthorizeResponseAsync(string clientId, string scope, string? redirectUri = null)
{
var challenge = GeneratePkceS256Challenge(GeneratePkceVerifier());
var cookieClient = await CreateAuthenticatedClientAsync("tu", "TestPass1234");
var resp = await cookieClient.GetAsync(
return await cookieClient.GetAsync(
BuildAuthorizeUri(clientId, scope, challenge, AllowedAudience, redirectUri ?? RedirectUri),
TestContext.Current.CancellationToken);
return resp.Headers.Location?.ToString();
}

private async Task<bool> DiscoveryHasCimdFlagAsync()
Expand Down
Loading
Loading