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
2 changes: 1 addition & 1 deletion .github/workflows/website.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
- name: Setup Deno
uses: denoland/setup-deno@v2
with:
deno-version: 2.5.7
deno-version: 2.9.7
cache: true

- name: Require telemetry configuration for deployment
Expand Down
20 changes: 20 additions & 0 deletions docs/docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,26 @@ Our [API](https://api.exceptionless.io) utilizes Swagger and Swashbuckle to auto

To view the full API documentation, visit <https://api.exceptionless.io> 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. Access remains limited to the organizations in 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; these lifetimes are configured with `OAuthServer:AccessTokenLifetimeMinutes` and `OAuthServer:RefreshTokenLifetimeDays`.

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.

### Requesting scopes the application does not allow

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.

Existing access and refresh tokens do not gain additional permissions automatically. Fresh consent is required for additional access, including refresh tokens when the original grant did not include `offline_access`.

---

[Next > Getting Started](/docs/api/api-getting-started)
2 changes: 2 additions & 0 deletions src/Exceptionless.AppHost/Exceptionless.AppHost.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
<PackageReference Include="Aspire.Hosting.Redis" Version="13.4.6" />
<PackageReference Include="AspNetCore.HealthChecks.Elasticsearch" Version="9.0.0" />
<PackageReference Include="CommunityToolkit.Aspire.Hosting.Deno" Version="13.4.0" />
<!-- Override Aspire's transitive dependency with a patched MessagePack 2.5 release. -->
<PackageReference Include="MessagePack" Version="2.5.307" />
</ItemGroup>

<ItemGroup>
Expand Down
37 changes: 33 additions & 4 deletions src/Exceptionless.Core/Services/OAuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ public class OAuthService(OAuthServerOptions options, ICacheClient cacheClient,
AuthorizationRoles.McpRead,
AuthorizationRoles.ProjectsRead,
AuthorizationRoles.StacksRead,
AuthorizationRoles.StacksWrite,
AuthorizationRoles.EventsRead,
AuthorizationRoles.OfflineAccess
];
Expand Down Expand Up @@ -206,7 +207,8 @@ public Task ClearAccessTokenClientValidityCacheAsync(string? clientId)

private async Task<OAuthApplication> 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 _))
Expand Down Expand Up @@ -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);
Comment thread
ejsmith marked this conversation as resolved.
Resolved
}

private static string NormalizeClientName(string? clientName, string clientId)
{
string name = String.IsNullOrWhiteSpace(clientName) ? "OAuth Application" : clientName.Trim();
Expand Down Expand Up @@ -334,7 +344,10 @@ public IReadOnlyCollection<string> NormalizeScopes(string? scopes)
.ToArray();
}

public async Task<OAuthValidationResult> 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<OAuthValidationResult> ValidateAuthorizationRequestAsync(OAuthAuthorizeRequest request, string expectedResource, OAuthResourceDefinition resourceDefinition, bool narrowToClientScopes = false)
{
if (String.IsNullOrWhiteSpace(request.ClientId))
return OAuthValidationResult.Invalid("invalid_request", "Missing client_id.");
Expand All @@ -360,8 +373,24 @@ public async Task<OAuthValidationResult> 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)))
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 scopes allowed for this application.");
}

if (resourceDefinition.RequiredScopes.Any(s => !requestedScopes.Contains(s, StringComparer.Ordinal)))
return OAuthValidationResult.Invalid("invalid_scope", "One or more required resource scopes are missing.");
Expand Down
2 changes: 2 additions & 0 deletions src/Exceptionless.Insulation/Exceptionless.Insulation.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.ExceptionLess" Version="5.0.0" />
<!-- MimeKit (via MailKit) allows 10.0.0, which has GHSA-2fmv-rv2c-59x7 and GHSA-846f-f764-76mx. -->
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
<PackageReference Include="YamlDotNet" Version="18.1.0" />
</ItemGroup>
<ItemGroup>
Expand Down
6 changes: 3 additions & 3 deletions src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public Task<IResult> Handle(CompleteOAuthAuthorization message)
public async Task<IResult> 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);

Expand Down Expand Up @@ -170,13 +170,13 @@ private static IResult OAuthError(string? error, string? description)
});
}

private async Task<AuthorizeRequestValidationResult> ValidateAuthorizeRequestAsync(OAuthAuthorizeRequest request)
private async Task<AuthorizeRequestValidationResult> 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);

Expand Down
159 changes: 159 additions & 0 deletions src/Exceptionless.Web/ClientApp/e2e/tests/oauth-consent.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { expect, test } from '@playwright/test';

test('AuthorizeConsent_InvalidAndRestartedRequests_UsesOnlyCurrentValidation', async ({ page }) => {
// Arrange
const errorDescription =
'Scopes not allowed for this application: stacks:write, offline_access. Restart authorization with scopes allowed for this application.';
let consentRequests = 0;
let authorizationRequests = 0;
let completeStaleConsent: (() => void) | undefined;
let completeSameQueryConsent: (() => void) | undefined;
let sameQueryRequests = 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 { client_id: string; scope: string };
if (body.client_id === 'same-query-client') {
sameQueryRequests++;
}
if (body.client_id === 'stale-client' || (body.client_id === 'same-query-client' && sameQueryRequests === 1)) {
await new Promise<void>((resolve) => {
if (body.client_id === 'same-query-client') {
completeSameQueryConsent = resolve;
} else {
completeStaleConsent = resolve;
}
});
await route.fulfill({ json: { error: 'invalid_scope', error_description: 'Stale request error' }, status: 400 });
return;
}
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'
});
try {
// Act
await page.goto(`/next/oauth/authorize?${parameters}`);
// Assert
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 expect(page.getByRole('checkbox', { name: /Stacks Write/ })).toBeDisabled();
await expect(page.getByRole('checkbox', { name: /Offline Access/ })).toBeDisabled();
await expect(page.getByRole('checkbox', { name: 'Test Organization' })).toBeDisabled();
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') });

// Act: restart with an allowed request.
parameters.set('scope', 'mcp:read');
await page.goto(`/next/oauth/authorize?${parameters}`);
// Assert
await expect(page.getByText('Test Client', { exact: true })).toBeVisible();
await expect(page.getByRole('checkbox', { name: 'Test Organization' })).toBeEnabled();
await expect(page.getByRole('button', { exact: true, name: 'Approve' })).toBeEnabled();
expect(consentRequests).toBe(2);
// Act
await page.getByRole('button', { exact: true, name: 'Approve' }).click();
// Assert
await expect(page.getByText(errorDescription, { exact: true })).toBeVisible();
expect(authorizationRequests).toBe(1);

async function navigateWithQuery() {
await page.evaluate((href) => {
document.getElementById('oauth-query-link')?.remove();
const link = document.createElement('a');
link.id = 'oauth-query-link';
link.href = href;
link.textContent = 'Change authorization request';
document.body.append(link);
}, `/next/oauth/authorize?${parameters}`);
await page.getByRole('link', { name: 'Change authorization request' }).click();
}

// Act: a new query must clear the preceding request's error.
parameters.set('scope', 'mcp:read projects:read');
await navigateWithQuery();
await expect(page.getByRole('button', { exact: true, name: 'Approve' })).toBeEnabled();
// Assert
await expect(page.getByText(errorDescription, { exact: true })).toHaveCount(0);
await expect(page.getByRole('checkbox', { name: /Projects Read/ })).toBeEnabled();

// Act: hold an obsolete request while navigating to another client.
parameters.set('client_id', 'stale-client');
await navigateWithQuery();
await expect.poll(() => Boolean(completeStaleConsent)).toBe(true);
// Assert
await expect(page.getByRole('checkbox', { name: /Projects Read/ })).toBeDisabled();
await expect(page.getByRole('checkbox', { name: 'Test Organization' })).toBeDisabled();
await expect(page.getByRole('button', { exact: true, name: 'Approve' })).toBeDisabled();
parameters.set('client_id', 'test-client');
await navigateWithQuery();
await expect(page.getByRole('button', { exact: true, name: 'Approve' })).toBeEnabled();
const staleResponse = page.waitForResponse(
(response) => response.url().endsWith('/oauth/authorize/consent') && response.request().postDataJSON().client_id === 'stale-client'
);
// Act
completeStaleConsent?.();
await (await staleResponse).finished();
// Assert
await expect(page.getByText('Stale request error', { exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { exact: true, name: 'Approve' })).toBeEnabled();

// Arrange: A1 and A2 have identical queries; URL equality cannot reject A1.
parameters.set('client_id', 'same-query-client');
await navigateWithQuery();
await expect.poll(() => Boolean(completeSameQueryConsent)).toBe(true);
await expect(page.getByRole('checkbox', { name: /Projects Read/ })).toBeDisabled();

// Act: navigate A → B → A, letting A2 validate before A1 completes.
parameters.set('client_id', 'test-client');
await navigateWithQuery();
await expect(page.getByRole('button', { exact: true, name: 'Approve' })).toBeEnabled();
parameters.set('client_id', 'same-query-client');
await navigateWithQuery();
await expect.poll(() => sameQueryRequests).toBe(2);
await expect(page.getByRole('button', { exact: true, name: 'Approve' })).toBeEnabled();
const sameQueryResponse = page.waitForResponse(
(response) => response.url().endsWith('/oauth/authorize/consent') && response.request().postDataJSON().client_id === 'same-query-client'
);
completeSameQueryConsent?.();
await (await sameQueryResponse).finished();

// Assert
await expect(page.getByText('Stale request error', { exact: true })).toHaveCount(0);
await expect(page.getByRole('checkbox', { name: /Projects Read/ })).toBeEnabled();
await expect(page.getByRole('checkbox', { name: 'Test Organization' })).toBeEnabled();
await expect(page.getByRole('button', { exact: true, name: 'Approve' })).toBeEnabled();
} finally {
completeSameQueryConsent?.();
completeStaleConsent?.();
}
});
Loading
Loading