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
18 changes: 18 additions & 0 deletions docs/docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ 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 `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.

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)
9 changes: 8 additions & 1 deletion src/Exceptionless.Core/Services/OAuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,14 @@ public async Task<OAuthValidationResult> 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 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
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(`/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(`/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);
}, `/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