diff --git a/docs/docs/api/index.md b/docs/docs/api/index.md index f6d8de618b..0d09c7ed55 100644 --- a/docs/docs/api/index.md +++ b/docs/docs/api/index.md @@ -16,11 +16,13 @@ For the MCP resource, `mcp:read` is required to connect. `projects:read`, `stack `offline_access` permits refresh token issuance so the client can obtain another access token without a new interactive authorization. It adds no read or write permission. Access tokens default to one hour and refresh tokens to 30 days; these lifetimes are configured with `OAuthServer:AccessTokenLifetimeMinutes` and `OAuthServer:RefreshTokenLifetimeDays`. -Dynamic registration without a scope uses `mcp:read projects:read stacks:read events:read offline_access`. It excludes `stacks:write`. An explicit registration scope is retained, so a client registered with only the four read scopes also lacks `offline_access`. +Dynamic registration without a scope uses every supported scope. A self-registered client can request any supported scope whatever it registered, because the user decides on the consent page what to grant. Once an administrator saves an application's scopes, that list is enforced. -### Resolving a scope error after registration +### Requesting scopes the application does not allow -If authorization reports scopes that are not allowed for the application, restart the client's authorization flow requesting an allowed subset. Keep `mcp:read` for MCP access. Changing checkboxes on a failed consent page does not validate a new request. +When an administrator has restricted an application, the consent page offers the requested scopes that list allows and omits the rest, so a client that requests everything discovery advertises can still be approved. The token response's `scope` lists what was granted. + +Authorization still fails when the application does not allow a required scope such as `mcp:read`, or when the request includes a scope the server does not support. Restart the client's authorization flow with a valid request. Changing checkboxes on a failed consent page does not validate a new request. Client software should register the scopes it will request. Each grant remains limited to the scopes and organizations approved by the user. Account **Applications** lists and revokes the user's grants. diff --git a/src/Exceptionless.Core/Services/OAuthService.cs b/src/Exceptionless.Core/Services/OAuthService.cs index fb0d7ce3c4..6db93b0bf9 100644 --- a/src/Exceptionless.Core/Services/OAuthService.cs +++ b/src/Exceptionless.Core/Services/OAuthService.cs @@ -64,6 +64,7 @@ public class OAuthService(OAuthServerOptions options, ICacheClient cacheClient, AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead, AuthorizationRoles.StacksRead, + AuthorizationRoles.StacksWrite, AuthorizationRoles.EventsRead, AuthorizationRoles.OfflineAccess ]; @@ -206,7 +207,8 @@ public Task ClearAccessTokenClientValidityCacheAsync(string? clientId) private async Task RefreshObservedApplicationAsync(OAuthApplication application) { - if (!String.Equals(application.CreatedByUserId, OAuthApplication.SystemUserId, StringComparison.Ordinal) + // Once an administrator saves an application, a metadata change must not replace their settings or reclassify it. + if (!IsSelfRegistered(application) || !String.Equals(application.Notes, ClientMetadataNotes, StringComparison.Ordinal) || !options.EnableClientIdMetadataDocuments || !OAuthClientMetadataService.TryCreateClientMetadataDocumentUri(application.ClientId, out _)) @@ -292,11 +294,19 @@ private static OAuthClientOptions MapClient(OAuthApplication application) ClientId = application.ClientId, Name = application.Name, RedirectUris = application.RedirectUris, - Scopes = application.Scopes, + // A self-registered client chose its own scopes, and could register again with all of them, so that list + // restricts nothing. The signed-in user's consent limits its grant. Scopes an administrator saved stay enforced. + Scopes = IsSelfRegistered(application) ? SupportedScopes : application.Scopes, IsDisabled = application.IsDisabled }.Normalize(); } + private static bool IsSelfRegistered(OAuthApplication application) + { + return String.Equals(application.CreatedByUserId, OAuthApplication.SystemUserId, StringComparison.Ordinal) + && String.Equals(application.UpdatedByUserId, OAuthApplication.SystemUserId, StringComparison.Ordinal); + } + private static string NormalizeClientName(string? clientName, string clientId) { string name = String.IsNullOrWhiteSpace(clientName) ? "OAuth Application" : clientName.Trim(); @@ -334,7 +344,10 @@ public IReadOnlyCollection NormalizeScopes(string? scopes) .ToArray(); } - public async Task ValidateAuthorizationRequestAsync(OAuthAuthorizeRequest request, string expectedResource, OAuthResourceDefinition resourceDefinition) + // narrowToClientScopes drops optional supported scopes an administrator has not allowed for this client, for building + // the consent page, because clients commonly request everything discovery advertises. Leave it false when issuing a + // code, which stores the scopes from the request as submitted. + public async Task ValidateAuthorizationRequestAsync(OAuthAuthorizeRequest request, string expectedResource, OAuthResourceDefinition resourceDefinition, bool narrowToClientScopes = false) { if (String.IsNullOrWhiteSpace(request.ClientId)) return OAuthValidationResult.Invalid("invalid_request", "Missing client_id."); @@ -360,6 +373,15 @@ public async Task ValidateAuthorizationRequestAsync(OAuth return OAuthValidationResult.Invalid("invalid_scope", "At least one scope is required."); var allowedScopes = GetAllowedScopes(client); + if (narrowToClientScopes) + { + requestedScopes = requestedScopes + .Where(s => allowedScopes.Contains(s, StringComparer.Ordinal) + || !SupportedScopes.Contains(s, StringComparer.Ordinal) + || resourceDefinition.RequiredScopes.Contains(s, StringComparer.Ordinal)) + .ToArray(); + } + if (requestedScopes.Any(s => !allowedScopes.Contains(s, StringComparer.Ordinal))) { // Only name server-supported scopes; do not reflect arbitrary request values. diff --git a/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs index 68b7a215b2..cbebb76673 100644 --- a/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs @@ -68,7 +68,7 @@ public Task Handle(CompleteOAuthAuthorization message) public async Task Handle(GetOAuthAuthorizeConsent message) { var request = message.Form.ToRequest(); - var validation = await ValidateAuthorizeRequestAsync(request); + var validation = await ValidateAuthorizeRequestAsync(request, narrowToClientScopes: true); if (!validation.IsValid) return OAuthError(validation.Error, validation.ErrorDescription); @@ -171,13 +171,13 @@ private static IResult OAuthError(string? error, string? description) }); } - private async Task ValidateAuthorizeRequestAsync(OAuthAuthorizeRequest request) + private async Task ValidateAuthorizeRequestAsync(OAuthAuthorizeRequest request, bool narrowToClientScopes = false) { if (!OAuthService.TryGetProtectedResource(request.Resource, GetOrigin(), out var resourceDefinition)) return AuthorizeRequestValidationResult.Invalid("invalid_target", "The requested resource is not supported."); string resource = GetResource(resourceDefinition); - var validation = await oauthService.ValidateAuthorizationRequestAsync(request, resource, resourceDefinition); + var validation = await oauthService.ValidateAuthorizationRequestAsync(request, resource, resourceDefinition, narrowToClientScopes); if (!validation.IsValid) return AuthorizeRequestValidationResult.Invalid(validation.Error, validation.ErrorDescription); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/oauth-live-consent.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/oauth-live-consent.e2e.ts index 5fc610ec80..4ba67fa7b7 100644 --- a/src/Exceptionless.Web/ClientApp/e2e/tests/oauth-live-consent.e2e.ts +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/oauth-live-consent.e2e.ts @@ -51,7 +51,7 @@ test('AuthorizeConsent_ExpandedClientRegistration_RequiresFreshMemberGrant', asy expect(metadataResponse.status()).toBe(200); const { resource } = (await metadataResponse.json()) as { resource: string }; - await test.step('register a restricted client and show its actual denied-scope response', async () => { + await test.step('register a read-only client and still offer every requested scope', async () => { // Act const registrationResponse = await request.post(`${environment.apiUrl}/oauth/register`, { data: { @@ -76,16 +76,16 @@ test('AuthorizeConsent_ExpandedClientRegistration_RequiresFreshMemberGrant', asy expect(forbidden.status()).toBe(403); await page.goto(authorizationUrl(allScopes)); - await expect(page.getByText(/^Scopes not allowed for this application: stacks:write, offline_access\./)).toBeVisible(); - await expect(page.getByRole('button', { exact: true, name: 'Approve' })).toBeDisabled(); - await expect(page.getByRole('checkbox', { name: /Stacks Write/ })).toBeDisabled(); - await expect(page.getByRole('checkbox', { name: /Offline Access/ })).toBeDisabled(); - await expect(page.getByRole('checkbox', { exact: true, name: e2eScenario.organizationName })).toBeDisabled(); + await expect(page.getByRole('button', { exact: true, name: 'Approve' })).toBeEnabled(); + await expect(page.getByRole('checkbox', { name: /Projects Read/ })).toBeEnabled(); + await expect(page.getByRole('checkbox', { name: /Stacks Write/ })).toBeEnabled(); + await expect(page.getByRole('checkbox', { name: /Offline Access/ })).toBeEnabled(); + await expect(page.getByText('not allowed for this application')).toHaveCount(0); }); await administratorContext.addInitScript((token) => window.localStorage.setItem('satellizer_token', token), administratorToken); const administratorPage = await administratorContext.newPage(); - await test.step('find the failed registration through Not authorized', async () => { + await test.step('find the unapproved registration through Not authorized', async () => { // Act await administratorPage.goto(`/system/oauth-applications?criteria=${encodeURIComponent(applicationName)}`); // Assert @@ -100,7 +100,7 @@ test('AuthorizeConsent_ExpandedClientRegistration_RequiresFreshMemberGrant', asy await expect(administratorPage.getByLabel('Client ID', { exact: true })).toHaveValue(clientId!); }); - const limitedGrant = await test.step('consent to an allowed subset without offline access', async () => { + const limitedGrant = await test.step('consent to a read-only subset without offline access', async () => { // Act const tokens = await approveAndExchange(readScopes); // Assert diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs index 8773bd090d..4f9ca2f92b 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs @@ -226,7 +226,7 @@ public async Task RegisterAsync_ValidDynamicClient_ReturnsClientAndPersistsAppli } [Fact] - public async Task RegisterAsync_WithoutScope_DefaultsToReadOnlyScopes() + public async Task RegisterAsync_WithoutScope_DefaultsToAllScopes() { using var client = CreateHttpClient(); @@ -246,7 +246,7 @@ public async Task RegisterAsync_WithoutScope_DefaultsToReadOnlyScopes() Assert.Contains(AuthorizationRoles.ProjectsRead, registration.Scope); Assert.Contains(AuthorizationRoles.StacksRead, registration.Scope); Assert.Contains(AuthorizationRoles.EventsRead, registration.Scope); - Assert.DoesNotContain(AuthorizationRoles.StacksWrite, registration.Scope); + Assert.Contains(AuthorizationRoles.StacksWrite, registration.Scope); Assert.Contains(AuthorizationRoles.OfflineAccess, registration.Scope); var application = await _oauthApplicationRepository.GetByClientIdAsync(registration.ClientId, o => o.ImmediateConsistency()); @@ -255,7 +255,7 @@ public async Task RegisterAsync_WithoutScope_DefaultsToReadOnlyScopes() Assert.Contains(AuthorizationRoles.ProjectsRead, application.Scopes); Assert.Contains(AuthorizationRoles.StacksRead, application.Scopes); Assert.Contains(AuthorizationRoles.EventsRead, application.Scopes); - Assert.DoesNotContain(AuthorizationRoles.StacksWrite, application.Scopes); + Assert.Contains(AuthorizationRoles.StacksWrite, application.Scopes); Assert.Contains(AuthorizationRoles.OfflineAccess, application.Scopes); } @@ -610,53 +610,99 @@ public async Task CompleteAuthorizeAsync_WithoutOrganizations_ReturnsBadRequest( } [Theory] - [InlineData(null, "stacks:write")] - [InlineData("mcp:read projects:read stacks:read events:read", "stacks:write, offline_access")] - [InlineData("mcp:read projects:read stacks:read stacks:write events:read offline_access", null)] - public async Task AuthorizeAsync_DynamicClientScopes_ValidatesConsentAndFinalRequest(string? registeredScopes, string? deniedScopes) + [InlineData(null)] + [InlineData("mcp:read projects:read stacks:read events:read")] + public async Task AuthorizeAsync_SelfRegisteredClient_GrantsEveryApprovedScopeAndKeepsThemOnRefresh(string? registeredScopes) { - // Arrange: reproduce both omitted registration scope and an explicit read-only client. + // Arrange: a client's own registration, including one saved before stacks:write was a default. using var client = CreateHttpClient(); - var registrationResponse = await client.PostAsJsonAsync("oauth/register", new OAuthClientRegistrationRequest - { - ClientName = "Scope Regression Client", - RedirectUris = [RedirectUri], - Scope = registeredScopes - }, TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Created, registrationResponse.StatusCode); - var registration = await DeserializeResponseAsync(registrationResponse); - Assert.NotNull(registration); + var registration = await RegisterScopeTestClientAsync(client, registeredScopes); string requestedScopes = String.Join(' ', OAuthService.SupportedScopes); - // Both endpoints enforce the same client policy before issuing a code. - foreach (string endpoint in new[] { "oauth/authorize/consent", "oauth/authorize" }) - { - using var request = CreateAuthorizeJsonRequest(PkceVerifier, clientId: registration.ClientId, scope: requestedScopes, organizationIds: [SampleDataService.TEST_ORG_ID]); - request.RequestUri = new Uri(endpoint, UriKind.Relative); - // Act - var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + // Act + var consent = await GetConsentAsync(client, registration.ClientId, requestedScopes); + var token = await IssueTokenAsync(clientId: registration.ClientId, scope: requestedScopes, organizationIds: [SampleDataService.TEST_ORG_ID]); - // Assert - Assert.Equal(deniedScopes is null ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.StatusCode); - if (deniedScopes is not null) - { - var error = await response.DeserializeAsync(ensureSuccess: false); - Assert.NotNull(error); - Assert.Equal("invalid_scope", error.Error); - Assert.Equal($"Scopes not allowed for this application: {deniedScopes}. Restart authorization with scopes allowed for this application.", error.ErrorDescription); - Assert.DoesNotContain("redirect_uri", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - } - } + // Assert + Assert.Equal(OAuthService.SupportedScopes, consent.Scopes); + Assert.Contains(AuthorizationRoles.StacksWrite, token.Scope); + Assert.NotNull(token.RefreshToken); - // Act: authorize an allowed subset without changing the registration. - var token = await IssueTokenAsync(clientId: registration.ClientId, scope: AuthorizationRoles.McpRead, organizationIds: [SampleDataService.TEST_ORG_ID]); + // Act: refresh limits a grant to the client's allowed scopes, so it must not strip what was approved. + using var refreshContent = CreateRefreshTokenContent(token.RefreshToken, registration.ClientId); + var refreshResponse = await client.PostAsync("oauth/token", refreshContent, TestContext.Current.CancellationToken); // Assert - Assert.Null(token.RefreshToken); - Assert.Equal(AuthorizationRoles.McpRead, token.Scope); + Assert.Equal(HttpStatusCode.OK, refreshResponse.StatusCode); + var refreshed = await DeserializeResponseAsync(refreshResponse); + Assert.NotNull(refreshed); + Assert.Equal(token.Scope, refreshed.Scope); + } + + [Fact] + public async Task AuthorizeAsync_AdministratorRestrictedClient_NarrowsConsentAndRejectsDisallowedScopes() + { + // Arrange: an administrator saved a read-only list for a client that requests everything discovery advertises. + using var client = CreateHttpClient(); + var registration = await RegisterScopeTestClientAsync(client, registeredScopes: null); + string[] restrictedScopes = [AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead, AuthorizationRoles.StacksRead, AuthorizationRoles.EventsRead]; var application = await _oauthApplicationRepository.GetByClientIdAsync(registration.ClientId, o => o.ImmediateConsistency()); Assert.NotNull(application); - Assert.Equal(registration.Scope.Split(' '), application.Scopes); + application.Scopes = restrictedScopes; + application.UpdatedByUserId = TestConstants.UserId; + await _oauthApplicationRepository.SaveAsync(application, o => o.ImmediateConsistency()); + string requestedScopes = String.Join(' ', OAuthService.SupportedScopes); + + // Act + var consent = await GetConsentAsync(client, registration.ClientId, requestedScopes); + + // Assert + Assert.Equal(restrictedScopes, consent.Scopes); + + // Act: issuing a code stores the submitted scopes, so the final request is never narrowed. + using var finalRequest = CreateAuthorizeJsonRequest(PkceVerifier, clientId: registration.ClientId, scope: requestedScopes, organizationIds: [SampleDataService.TEST_ORG_ID]); + var finalResponse = await client.SendAsync(finalRequest, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, finalResponse.StatusCode); + var error = await finalResponse.DeserializeAsync(ensureSuccess: false); + Assert.NotNull(error); + Assert.Equal("invalid_scope", error.Error); + Assert.Equal("Scopes not allowed for this application: stacks:write, offline_access. Restart authorization with scopes allowed for this application.", error.ErrorDescription); + Assert.DoesNotContain("redirect_uri", await finalResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + + // Act: approve what consent offered. + string approvedScopes = String.Join(' ', consent.Scopes); + var token = await IssueTokenAsync(clientId: registration.ClientId, scope: approvedScopes, organizationIds: [SampleDataService.TEST_ORG_ID]); + + // Assert + Assert.Null(token.RefreshToken); + Assert.Equal(approvedScopes, token.Scope); + } + + private async Task RegisterScopeTestClientAsync(HttpClient client, string? registeredScopes) + { + var response = await client.PostAsJsonAsync("oauth/register", new OAuthClientRegistrationRequest + { + ClientName = "Scope Regression Client", + RedirectUris = [RedirectUri], + Scope = registeredScopes + }, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var registration = await DeserializeResponseAsync(response); + Assert.NotNull(registration); + return registration; + } + + private async Task GetConsentAsync(HttpClient client, string clientId, string scope) + { + using var request = CreateAuthorizeJsonRequest(PkceVerifier, clientId: clientId, scope: scope, organizationIds: [SampleDataService.TEST_ORG_ID]); + request.RequestUri = new Uri("oauth/authorize/consent", UriKind.Relative); + var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var consent = await DeserializeResponseAsync(response); + Assert.NotNull(consent); + return consent; } [Fact] @@ -821,7 +867,7 @@ public async Task CompleteAuthorizeAsync_ClientMetadataDocument_PersistsObserved } [Fact] - public async Task CompleteAuthorizeAsync_ClientMetadataDocumentWithoutScope_DefaultsToReadOnlyScopes() + public async Task CompleteAuthorizeAsync_ClientMetadataDocumentWithoutScope_DefaultsToAllScopes() { await CreateAuthorizationCodeAsync( PkceVerifier, @@ -836,7 +882,7 @@ await CreateAuthorizationCodeAsync( Assert.Contains(AuthorizationRoles.ProjectsRead, application.Scopes); Assert.Contains(AuthorizationRoles.StacksRead, application.Scopes); Assert.Contains(AuthorizationRoles.EventsRead, application.Scopes); - Assert.DoesNotContain(AuthorizationRoles.StacksWrite, application.Scopes); + Assert.Contains(AuthorizationRoles.StacksWrite, application.Scopes); Assert.Contains(AuthorizationRoles.OfflineAccess, application.Scopes); } @@ -866,6 +912,41 @@ public async Task CompleteAuthorizeAsync_ObservedClientMetadataDocumentMissingNe Assert.Contains(AuthorizationRoles.StacksWrite, refreshedApplication.Scopes); } + [Fact] + public async Task CompleteAuthorizeAsync_AdministratorEditedMetadataClient_KeepsRestrictionWhenMetadataDiffers() + { + // Arrange: the saved name differs from the metadata document, which would otherwise trigger a refresh. + await CreateAuthorizationCodeAsync(PkceVerifier, ClaudeLoopbackRedirectUri, clientId: ClaudeMetadataClientId); + string[] restrictedScopes = [AuthorizationRoles.McpRead, AuthorizationRoles.ProjectsRead]; + var application = await _oauthApplicationRepository.GetByClientIdAsync(ClaudeMetadataClientId, o => o.ImmediateConsistency()); + Assert.NotNull(application); + application.Name = "Administrator Reviewed Client"; + application.Scopes = restrictedScopes; + application.UpdatedByUserId = TestConstants.UserId; + await _oauthApplicationRepository.SaveAsync(application, o => o.ImmediateConsistency()); + + using var client = CreateHttpClient(); + using var request = CreateAuthorizeJsonRequest( + PkceVerifier, + ClaudeLoopbackRedirectUri, + clientId: ClaudeMetadataClientId, + scope: String.Join(' ', OAuthService.SupportedScopes)); + + // Act + var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.DeserializeAsync(ensureSuccess: false); + Assert.NotNull(error); + Assert.Equal("invalid_scope", error.Error); + var savedApplication = await _oauthApplicationRepository.GetByClientIdAsync(ClaudeMetadataClientId, o => o.ImmediateConsistency()); + Assert.NotNull(savedApplication); + Assert.Equal("Administrator Reviewed Client", savedApplication.Name); + Assert.Equal(restrictedScopes, savedApplication.Scopes); + Assert.Equal(TestConstants.UserId, savedApplication.UpdatedByUserId); + } + [Fact] public async Task CompleteAuthorizeAsync_ClientMetadataDocumentLoopbackRedirectUriWithPort_ReturnsRedirectUri() { diff --git a/tests/Exceptionless.Tests/Services/OAuthAuthorizationValidationTests.cs b/tests/Exceptionless.Tests/Services/OAuthAuthorizationValidationTests.cs index 8e877f4f86..346937a7cf 100644 --- a/tests/Exceptionless.Tests/Services/OAuthAuthorizationValidationTests.cs +++ b/tests/Exceptionless.Tests/Services/OAuthAuthorizationValidationTests.cs @@ -132,7 +132,6 @@ public void ToIdentity_OAuthGrant_DoesNotInheritGlobalRoleOrForeignOrganization( } [Theory] - [InlineData(null, AllScopes, "stacks:write")] [InlineData(FourReadScopes, AllScopes, "stacks:write, offline_access")] [InlineData(AllScopes, AllScopes, null)] [InlineData(FourReadScopes, FourReadScopes, null)] @@ -157,6 +156,28 @@ public async Task ValidateAuthorizationRequestAsync_ClientScopeCombinations_Enfo } } + [Theory] + [InlineData(FourReadScopes, AllScopes, FourReadScopes, null)] + [InlineData(AllScopes, AllScopes, AllScopes, null)] + [InlineData("projects:read", "mcp:read projects:read", null, "Scopes not allowed for this application: mcp:read.")] + [InlineData("mcp:read", "projects:read offline_access", null, "One or more required resource scopes are missing.")] + [InlineData(AllScopes, "mcp:read unknown", null, "One or more scopes are not allowed for this application.")] + public async Task ValidateAuthorizationRequestAsync_NarrowToClientScopes_DropsOnlyOptionalSupportedScopes(string? clientScopes, string requestScopes, string? expectedScopes, string? expectedError) + { + // Arrange + var (service, _) = CreateService(clientScopes); + + // Act + var result = await service.ValidateAuthorizationRequestAsync(CreateRequest(requestScopes), Resource, OAuthService.McpResource, narrowToClientScopes: true); + + // Assert + Assert.Equal(expectedScopes is not null, result.IsValid); + if (expectedScopes is not null) + Assert.Equal(expectedScopes.Split(' '), result.Scopes); + else + Assert.StartsWith(expectedError!, result.ErrorDescription); + } + [Theory] [InlineData("")] [InlineData("offline_access")]