From 56d626d1988520c596bfb97c2f3af05d410d09aa Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 3 Oct 2026 17:12:57 -0500 Subject: [PATCH 1/6] Explain OAuth client scope consent failures --- docs/docs/api/index.md | 26 +++ .../Services/OAuthService.cs | 9 +- .../ClientApp/e2e/tests/oauth-consent.e2e.ts | 61 +++++++ .../(auth)/oauth/authorize/+page.svelte | 18 +- .../authorize/authorize-page.svelte.test.ts | 132 +++++++++++++++ .../oauth/authorize/oauth-error.test.ts | 22 +++ .../(auth)/oauth/authorize/oauth-error.ts | 17 ++ .../Api/Endpoints/OAuthEndpointTests.cs | 93 ++++++++++ .../OAuthAuthorizationValidationTests.cs | 159 ++++++++++++++++++ 9 files changed, 523 insertions(+), 14 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/e2e/tests/oauth-consent.e2e.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/authorize-page.svelte.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/oauth-error.test.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/oauth-error.ts create mode 100644 tests/Exceptionless.Tests/Services/OAuthAuthorizationValidationTests.cs diff --git a/docs/docs/api/index.md b/docs/docs/api/index.md index 9a74360c85..1f9ab86f43 100644 --- a/docs/docs/api/index.md +++ b/docs/docs/api/index.md @@ -8,6 +8,32 @@ Our [API](https://api.exceptionless.io) utilizes Swagger and Swashbuckle to auto To view the full API documentation, visit and click on the `API Documentation` link to be taken to the API documentation. With Swagger, you will have examples for all endpoints that include required parameters and potential responses. +## MCP OAuth access + +OAuth discovery advertises the scopes the server supports. Each OAuth application has its own allowed scopes, and each authorization grant contains only the scopes and organizations approved by the user. Discovery does not grant every advertised permission to an application. + +For the MCP resource, `mcp:read` is required to connect. `projects:read`, `stacks:read`, and `events:read` permit the corresponding read tools. `stacks:write` permits changing stack status (open, fixed, ignored, or discarded), snoozing stacks, changing whether future occurrences are critical, and adding or removing reference links. It does not grant global administration or access to organizations outside the grant and the user's current membership. + +`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; self-hosted administrators can configure these lifetimes 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`. + +### Resolving a scope error after registration + +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. + +To enable additional scopes, a **global administrator** must: + +1. Open **System → OAuth Apps** (`/next/system/oauth-applications` in version 8.10.0; `/system/oauth-applications` after the Svelte app moves to the root). +2. Change the default **Authorized** filter to **Not authorized** or **All applications** if the application has never completed consent. +3. Find the application using the exact client ID from the authorization request, then select **Edit application**. +4. Review and select **Stacks Write** and/or **Offline Access** as needed, then select **Save Changes**. +5. Restart authorization using the **same client ID**, request the newly allowed scopes, and approve the appropriate organizations. + +Ordinary members can consent to access for their own organizations but cannot change global OAuth application configuration. Account **Applications** lists and revokes the user's grants; it does not enable client scopes. A global administrator's OAuth grant also receives only its approved scopes, without the administrator role. + +Enabling a scope on an application does not expand existing access or refresh tokens. Refreshing an existing grant keeps its original scopes intersected with the application's currently allowed scopes. Fresh consent is required to add write access, or to obtain refresh tokens for a grant originally created without `offline_access`. + --- [Next > Getting Started](/docs/api/api-getting-started) diff --git a/src/Exceptionless.Core/Services/OAuthService.cs b/src/Exceptionless.Core/Services/OAuthService.cs index 77e7cb1446..03e6dab361 100644 --- a/src/Exceptionless.Core/Services/OAuthService.cs +++ b/src/Exceptionless.Core/Services/OAuthService.cs @@ -361,7 +361,14 @@ public async Task ValidateAuthorizationRequestAsync(OAuth var allowedScopes = GetAllowedScopes(client); if (requestedScopes.Any(s => !allowedScopes.Contains(s, StringComparer.Ordinal))) - return OAuthValidationResult.Invalid("invalid_scope", "One or more scopes are not allowed for this client."); + { + // Only name server-supported scopes; do not reflect arbitrary request values. + var disallowedScopes = SupportedScopes.Where(s => requestedScopes.Contains(s, StringComparer.Ordinal) && !allowedScopes.Contains(s, StringComparer.Ordinal)).ToArray(); + string description = disallowedScopes.Length > 0 + ? $"Scopes not allowed for this application: {String.Join(", ", disallowedScopes)}." + : "One or more scopes are not allowed for this application."; + return OAuthValidationResult.Invalid("invalid_scope", $"{description} Restart authorization with fewer scopes or ask a global administrator to review the application in System → OAuth Apps. After saving changes, restart authorization using the same client ID."); + } if (resourceDefinition.RequiredScopes.Any(s => !requestedScopes.Contains(s, StringComparer.Ordinal))) return OAuthValidationResult.Invalid("invalid_scope", "One or more required resource scopes are missing."); diff --git a/src/Exceptionless.Web/ClientApp/e2e/tests/oauth-consent.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/oauth-consent.e2e.ts new file mode 100644 index 0000000000..e1077cad2d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/oauth-consent.e2e.ts @@ -0,0 +1,61 @@ +import { expect, test } from '@playwright/test'; + +test('OAuth consent displays scope-policy errors and validates a restarted request before approval', async ({ page }) => { + const errorDescription = + 'Scopes not allowed for this application: stacks:write, offline_access. Restart authorization with fewer scopes or ask a global administrator to review the application in System → OAuth Apps. After saving changes, restart authorization using the same client ID.'; + let consentRequests = 0; + let authorizationRequests = 0; + await page.addInitScript(() => window.localStorage.setItem('satellizer_token', 'test-consent-session')); + // This browser regression exercises the production page and FetchClient with HTTP responses. + // Service policy and issuance are covered separately by OAuthEndpointTests. + await page.route('**/api/v2/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path.endsWith('/users/me')) { + await route.fulfill({ json: { email_address: 'member@example.test', full_name: 'Member' } }); + } else if (path.endsWith('/organizations')) { + await route.fulfill({ json: [{ id: '000000000000000000000001', name: 'Test Organization' }] }); + } else if (path.endsWith('/oauth/authorize/consent')) { + consentRequests++; + const body = route.request().postDataJSON() as { scope: string }; + if (body.scope.includes('stacks:write')) { + await route.fulfill({ json: { error: 'invalid_scope', error_description: errorDescription }, status: 400 }); + } else { + await route.fulfill({ json: { client_name: 'Test Client', required_scopes: ['mcp:read'], scopes: body.scope.split(' ') } }); + } + } else if (path.endsWith('/oauth/authorize')) { + authorizationRequests++; + await route.fulfill({ json: { error: 'invalid_scope', error_description: errorDescription }, status: 400 }); + } else { + await route.abort(); + } + }); + + const parameters = new URLSearchParams({ + client_id: 'test-client', + code_challenge: 'a'.repeat(43), + code_challenge_method: 'S256', + redirect_uri: 'http://localhost/callback', + resource: 'http://localhost/mcp', + response_type: 'code', + scope: 'mcp:read projects:read stacks:read stacks:write events:read offline_access' + }); + await page.goto(`/oauth/authorize?${parameters}`); + await expect(page.getByText(errorDescription, { exact: true })).toBeVisible(); + await expect(page.getByText('Required', { exact: true })).toHaveCount(1); + await expect(page.getByRole('button', { exact: true, name: 'Approve' })).toBeDisabled(); + await page.getByRole('checkbox', { name: /Stacks Write/ }).click(); + await page.getByRole('checkbox', { name: /Offline Access/ }).click(); + await expect(page.getByRole('button', { exact: true, name: 'Approve' })).toBeDisabled(); + expect(consentRequests).toBe(1); + expect(authorizationRequests).toBe(0); + await page.screenshot({ fullPage: true, path: test.info().outputPath('scope-error.png') }); + + parameters.set('scope', 'mcp:read'); + await page.goto(`/oauth/authorize?${parameters}`); + await expect(page.getByText('Test Client', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { exact: true, name: 'Approve' })).toBeEnabled(); + expect(consentRequests).toBe(2); + await page.getByRole('button', { exact: true, name: 'Approve' }).click(); + await expect(page.getByText(errorDescription, { exact: true })).toBeVisible(); + expect(authorizationRequests).toBe(1); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte index 307732eb24..89ec2296a1 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/+page.svelte @@ -18,6 +18,8 @@ import { useFetchClient } from '@foundatiofx/fetchclient'; import { SvelteSet } from 'svelte/reactivity'; + import { getOAuthErrorMessage } from './oauth-error'; + interface OAuthAuthorizeConsentResponse { client_id?: string; client_name?: string; @@ -194,12 +196,7 @@ return; } - errorMessage = - response.data?.error_description || - response.data?.error || - response.problem?.detail || - response.problem?.title || - 'Unable to authorize application.'; + errorMessage = getOAuthErrorMessage(response, 'Unable to authorize application.'); } function cancelAuthorization() { @@ -252,7 +249,7 @@ function getRequiredScopes(resourceValue: string): string[] { if (resourceValue.endsWith('/mcp')) { - return [mcpReadScope, offlineAccessScope]; + return [mcpReadScope]; } return []; @@ -290,12 +287,7 @@ } consentDetails = null; - consentErrorMessage = - response.data?.error_description || - response.data?.error || - response.problem?.detail || - response.problem?.title || - 'Unable to load application details.'; + consentErrorMessage = getOAuthErrorMessage(response, 'Unable to load application details.'); } async function redirectToLogin(): Promise { diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/authorize-page.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/authorize-page.svelte.test.ts new file mode 100644 index 0000000000..7020631420 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/authorize-page.svelte.test.ts @@ -0,0 +1,132 @@ +import { FetchClient } from '@foundatiofx/fetchclient'; +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import AuthorizePage from './+page.svelte'; + +const mocks = vi.hoisted(() => ({ + clearSession: vi.fn(), + goto: vi.fn(), + page: { url: new URL('http://localhost/oauth/authorize') }, + useFetchClient: vi.fn() +})); + +vi.mock('$app/environment', () => ({ browser: true })); +vi.mock('$app/navigation', () => ({ goto: mocks.goto })); +vi.mock('$app/paths', () => ({ resolve: (path: string) => path.replace('/(auth)', '') })); +vi.mock('$app/state', () => ({ page: mocks.page })); +vi.mock('$features/auth/index.svelte', () => ({ accessToken: { current: 'test-token' } })); +vi.mock('$features/auth/session.svelte', () => ({ clearAuthenticationSession: mocks.clearSession })); +vi.mock('$features/organizations/api.svelte', () => ({ + getOrganizationsQuery: () => ({ data: { data: [{ id: 'organization-1', name: 'Test Organization' }] }, isError: false, isLoading: false }) +})); +vi.mock('$features/users/api.svelte', () => ({ + getMeQuery: () => ({ data: { email_address: 'member@example.test', full_name: 'Member' }, isError: false, isLoading: false }) +})); +vi.mock('@foundatiofx/fetchclient', async (importOriginal) => ({ + ...(await importOriginal()), + useFetchClient: mocks.useFetchClient +})); + +const scopeError = + 'Scopes not allowed for this application: stacks:write, offline_access. Restart authorization with fewer scopes or ask a global administrator to review the application in System → OAuth Apps.'; + +function consentResponse() { + return jsonResponse({ client_id: 'test-client', client_name: 'Test Client', required_scopes: ['mcp:read'], scopes: ['mcp:read', 'offline_access'] }); +} + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status }); +} + +describe('OAuth authorization', () => { + beforeEach(() => { + mocks.page.url = new URL( + 'http://localhost/oauth/authorize?client_id=test-client&redirect_uri=http://localhost/callback&resource=http://localhost/mcp&response_type=code&code_challenge=test&code_challenge_method=S256&scope=mcp:read+offline_access+stacks:write' + ); + }); + + it('shows the actual FetchClient OAuth error on failed consent and keeps approval disabled after scope edits', async () => { + const fetch = vi.fn().mockResolvedValue(jsonResponse({ error: 'invalid_scope', error_description: scopeError }, 400)); + mocks.useFetchClient.mockReturnValue(new FetchClient({ baseUrl: 'http://localhost/api/v2/', fetch })); + render(AuthorizePage); + + expect(await screen.findByText(scopeError)).toBeVisible(); + expect(screen.getByRole('button', { name: 'Approve' })).toBeDisabled(); + expect(screen.getAllByText('Required')).toHaveLength(1); + await fireEvent.click(screen.getByRole('checkbox', { name: /Stacks Write/ })); + expect(screen.getByRole('button', { name: 'Approve' })).toBeDisabled(); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it('shows the actual FetchClient OAuth error when final authorization fails', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(consentResponse()) + .mockResolvedValueOnce(jsonResponse({ error: 'invalid_scope', error_description: scopeError }, 400)); + mocks.useFetchClient.mockReturnValue(new FetchClient({ baseUrl: 'http://localhost/api/v2/', fetch })); + render(AuthorizePage); + await waitFor(() => expect(screen.getByRole('button', { name: 'Approve' })).toBeEnabled()); + await fireEvent.click(screen.getByRole('button', { name: 'Approve' })); + + expect(await screen.findByText(scopeError)).toBeVisible(); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it.each(['consent', 'approval'])('returns to login with the original query after session expiry during %s', async (stage) => { + const fetch = vi.fn(); + if (stage === 'approval') { + fetch.mockResolvedValueOnce(consentResponse()); + } + + fetch.mockResolvedValueOnce(jsonResponse({}, 401)); + mocks.useFetchClient.mockReturnValue(new FetchClient({ baseUrl: 'http://localhost/api/v2/', fetch })); + render(AuthorizePage); + if (stage === 'approval') { + await waitFor(() => expect(screen.getByRole('button', { name: 'Approve' })).toBeEnabled()); + await fireEvent.click(screen.getByRole('button', { name: 'Approve' })); + } + + await waitFor(() => expect(mocks.clearSession).toHaveBeenCalledOnce()); + expect(mocks.goto).toHaveBeenCalledWith(`/login?redirect=${encodeURIComponent(mocks.page.url.pathname + mocks.page.url.search)}`, { + replaceState: true + }); + }); + + it('sends one authorization request while approval is pending and cancel sends none', async () => { + let complete: (response: Response) => void = () => {}; + const fetch = vi + .fn() + .mockResolvedValueOnce(consentResponse()) + .mockImplementationOnce( + () => + new Promise((resolve) => { + complete = resolve; + }) + ); + mocks.useFetchClient.mockReturnValue(new FetchClient({ baseUrl: 'http://localhost/api/v2/', fetch })); + render(AuthorizePage); + const approve = screen.getByRole('button', { name: 'Approve' }); + await waitFor(() => expect(approve).toBeEnabled()); + await fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(await screen.findByText('Authorization canceled. You can close this tab.')).toBeVisible(); + expect(fetch).toHaveBeenCalledOnce(); + await fireEvent.click(approve); + await fireEvent.click(approve); + expect(approve).toBeDisabled(); + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); + complete(jsonResponse({ error: 'invalid_scope', error_description: scopeError }, 400)); + expect(await screen.findByText(scopeError)).toBeVisible(); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('renders an error as text without creating HTML', async () => { + const description = ''; + const fetch = vi.fn().mockResolvedValue(jsonResponse({ error_description: description }, 400)); + mocks.useFetchClient.mockReturnValue(new FetchClient({ baseUrl: 'http://localhost/api/v2/', fetch })); + const { container } = render(AuthorizePage); + expect(await screen.findByText(description)).toBeVisible(); + expect(container.querySelector('img[src="x"]')).toBeNull(); + expect(screen.getByRole('button', { name: 'Approve' })).toBeDisabled(); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/oauth-error.test.ts b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/oauth-error.test.ts new file mode 100644 index 0000000000..9ede6ca18d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/oauth-error.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { getOAuthErrorMessage } from './oauth-error'; + +describe('OAuth error messages', () => { + it.each([ + [{ error_description: 'OAuth description' }, 'OAuth description'], + [{ error: 'invalid_scope' }, 'invalid_scope'], + [{ detail: 'Problem detail' }, 'Problem detail'], + [{ title: 'Problem title' }, 'Problem title'], + [{ detail: 7, error: [], error_description: {}, title: false }, 'Fallback'], + [{ detail: 'Problem detail', error_description: ' ' }, 'Problem detail'], + [null, 'Fallback'], + ['Malformed body', 'Fallback'] + ])('handles OAuth, ordinary problem details, and malformed bodies: %j', (problem, expected) => { + expect(getOAuthErrorMessage({ data: null, problem }, 'Fallback')).toBe(expected); + }); + + it('retains the description from a parsed response body', () => { + expect(getOAuthErrorMessage({ data: { error_description: 'Description' }, problem: { title: 'Title' } }, 'Fallback')).toBe('Description'); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/oauth-error.ts b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/oauth-error.ts new file mode 100644 index 0000000000..a33e834879 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/routes/(auth)/oauth/authorize/oauth-error.ts @@ -0,0 +1,17 @@ +export function getOAuthErrorMessage(response: { data: unknown; problem: unknown }, fallback: string): string { + for (const body of [response.data, response.problem]) { + if (!body || typeof body !== 'object') { + continue; + } + + const error = body as Record; + for (const key of ['error_description', 'error', 'detail', 'title']) { + const value = error[key]; + if (typeof value === 'string' && value.trim()) { + return value; + } + } + } + + return fallback; +} diff --git a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs index adcbce177c..033adf9b90 100644 --- a/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs +++ b/tests/Exceptionless.Tests/Api/Endpoints/OAuthEndpointTests.cs @@ -608,6 +608,22 @@ public async Task CompleteAuthorizeAsync_WithoutOrganizations_ReturnsBadRequest( Assert.Equal("Select at least one organization.", error.ErrorDescription); } + [Fact] + public async Task CompleteAuthorizeAsync_ForeignOrganization_ReturnsBadRequestWithoutCode() + { + using var client = CreateHttpClient(); + using var request = CreateAuthorizeJsonRequest(PkceVerifier, organizationIds: [ObjectId.GenerateNewId().ToString()]); + + var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var error = await response.DeserializeAsync(ensureSuccess: false); + Assert.NotNull(error); + Assert.Equal("invalid_request", error.Error); + Assert.Equal("One or more selected organizations are not available to the current user.", error.ErrorDescription); + Assert.DoesNotContain("redirect_uri", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + } + [Fact] public async Task CompleteAuthorizeAsync_WithoutResource_ReturnsBadRequest() { @@ -735,6 +751,52 @@ public async Task GetAuthorizeConsentAsync_ValidRequest_ReturnsValidatedClientDe Assert.Empty(consent.RequiredScopes); } + [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) + { + // Arrange: reproduce both omitted registration scope and an explicit read-only client. + 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); + string requestedScopes = String.Join(' ', OAuthService.SupportedScopes); + + // Act and assert: 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); + var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + 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 fewer scopes or ask a global administrator to review the application in System → OAuth Apps. After saving changes, restart authorization using the same client ID.", error.ErrorDescription); + Assert.DoesNotContain("redirect_uri", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + } + } + + // An allowed subset works without changing the client's configured scopes and without offline access. + var token = await IssueTokenAsync(clientId: registration.ClientId, scope: AuthorizationRoles.McpRead, organizationIds: [SampleDataService.TEST_ORG_ID]); + Assert.Null(token.RefreshToken); + Assert.Equal(AuthorizationRoles.McpRead, token.Scope); + var application = await _oauthApplicationRepository.GetByClientIdAsync(registration.ClientId, o => o.ImmediateConsistency()); + Assert.NotNull(application); + Assert.Equal(registration.Scope.Split(' '), application.Scopes); + } + [Fact] public async Task CompleteAuthorizeAsync_ClientMetadataDocument_PersistsObservedApplication() { @@ -1316,6 +1378,37 @@ public async Task TokenAsync_RefreshToken_RotatesRefreshToken() Assert.Equal(spentToken.GrantId, refreshedStoredToken.GrantId); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TokenAsync_ExpandedClientScopes_RequiresFreshConsent(bool originalOfflineAccess) + { + string originalScopes = originalOfflineAccess ? "mcp:read projects:read offline_access" : "mcp:read projects:read"; + var token = await IssueTokenAsync(scope: originalScopes); + Assert.Equal(originalOfflineAccess, token.RefreshToken is not null); + + await SetStoredOAuthApplicationScopesAsync(ClientId, OAuthService.SupportedScopes.ToArray()); + + var storedToken = await GetStoredOAuthTokenAsync(token.AccessToken); + Assert.NotNull(storedToken); + Assert.DoesNotContain(AuthorizationRoles.StacksWrite, storedToken.Scopes); + Assert.Equal(originalOfflineAccess, storedToken.RefreshTokenHash is not null); + if (originalOfflineAccess) + { + using var client = CreateHttpClient(); + using var content = CreateRefreshTokenContent(token.RefreshToken); + var response = await client.PostAsync("oauth/token", content, TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var refreshed = await DeserializeResponseAsync(response); + Assert.NotNull(refreshed); + Assert.Equal(originalScopes, refreshed.Scope); + } + + var freshToken = await IssueTokenAsync(scope: String.Join(' ', OAuthService.SupportedScopes)); + Assert.Contains(AuthorizationRoles.StacksWrite, freshToken.Scope); + Assert.NotNull(freshToken.RefreshToken); + } + [Fact] public async Task TokenAsync_RecentlyUsedRefreshToken_IssuesNewToken() { diff --git a/tests/Exceptionless.Tests/Services/OAuthAuthorizationValidationTests.cs b/tests/Exceptionless.Tests/Services/OAuthAuthorizationValidationTests.cs new file mode 100644 index 0000000000..af3882044b --- /dev/null +++ b/tests/Exceptionless.Tests/Services/OAuthAuthorizationValidationTests.cs @@ -0,0 +1,159 @@ +using System.Reflection; +using System.Security.Claims; +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Configuration; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; +using Xunit; + +namespace Exceptionless.Tests.Services; + +public sealed class OAuthAuthorizationValidationTests +{ + private const string Resource = "http://localhost/mcp"; + private const string RedirectUri = "http://localhost/callback"; + private const string FourReadScopes = "mcp:read projects:read stacks:read events:read"; + private const string AllScopes = "mcp:read projects:read stacks:read stacks:write events:read offline_access"; + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void ToIdentity_OAuthGrant_DoesNotInheritGlobalRoleOrForeignOrganization(bool globalAdministrator, bool writeScope) + { + var user = new User + { + Id = "user-1", + EmailAddress = "member@example.test", + Roles = new HashSet(globalAdministrator ? [AuthorizationRoles.User, AuthorizationRoles.GlobalAdmin] : [AuthorizationRoles.User]), + OrganizationIds = new HashSet(["organization-1"]) + }; + var token = new OAuthToken + { + Id = "token-1", + ClientId = "client-1", + Resource = Resource, + Scopes = writeScope ? [AuthorizationRoles.McpRead, AuthorizationRoles.StacksWrite, AuthorizationRoles.OfflineAccess] : [AuthorizationRoles.McpRead], + OrganizationIds = ["organization-1", "foreign-organization"] + }; + + var identity = user.ToIdentity(token); + + Assert.DoesNotContain(identity.Claims, claim => claim.Type == ClaimTypes.Role && claim.Value == AuthorizationRoles.GlobalAdmin); + Assert.Equal(writeScope, identity.HasClaim(ClaimTypes.Role, AuthorizationRoles.StacksWrite)); + Assert.Equal("organization-1", identity.FindFirst(IdentityUtils.OrganizationIdsClaim)?.Value); + user.OrganizationIds.Clear(); + Assert.Empty(user.GetActiveOAuthOrganizationIds(token)); + } + + [Theory] + [InlineData(null, AllScopes, "stacks:write")] + [InlineData(FourReadScopes, AllScopes, "stacks:write, offline_access")] + [InlineData(AllScopes, AllScopes, null)] + [InlineData(FourReadScopes, FourReadScopes, null)] + [InlineData(FourReadScopes, "mcp:read", null)] + public async Task ValidateAuthorizationRequestAsync_ClientScopeCombinations_EnforcesAllowedScopes(string? clientScopes, string requestScopes, string? deniedScopes) + { + // Arrange + var (service, application) = CreateService(clientScopes); + string[] originalScopes = application.Scopes.ToArray(); + + // Act + var result = await service.ValidateAuthorizationRequestAsync(CreateRequest(requestScopes), Resource, OAuthService.McpResource); + + // Assert + Assert.Equal(deniedScopes is null, result.IsValid); + Assert.Equal(originalScopes, application.Scopes); + if (deniedScopes is not null) + { + Assert.Equal("invalid_scope", result.Error); + Assert.Equal($"Scopes not allowed for this application: {deniedScopes}. Restart authorization with fewer scopes or ask a global administrator to review the application in System → OAuth Apps. After saving changes, restart authorization using the same client ID.", result.ErrorDescription); + Assert.Empty(result.Scopes); + } + } + + [Theory] + [InlineData("")] + [InlineData("offline_access")] + [InlineData("projects:read")] + [InlineData("mcp:read ")] + public async Task ValidateAuthorizationRequestAsync_InvalidScopes_DeniesWithoutReflectingUnknownScopes(string scopes) + { + var (service, _) = CreateService(AllScopes); + + var result = await service.ValidateAuthorizationRequestAsync(CreateRequest(scopes), Resource, OAuthService.McpResource); + + Assert.False(result.IsValid); + Assert.Equal("invalid_scope", result.Error); + Assert.DoesNotContain("")] public async Task ValidateAuthorizationRequestAsync_InvalidScopes_DeniesWithoutReflectingUnknownScopes(string scopes) { + // Arrange var (service, _) = CreateService(AllScopes); + // Act var result = await service.ValidateAuthorizationRequestAsync(CreateRequest(scopes), Resource, OAuthService.McpResource); + // Assert Assert.False(result.IsValid); Assert.Equal("invalid_scope", result.Error); Assert.DoesNotContain("