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
8 changes: 5 additions & 3 deletions docs/docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
28 changes: 25 additions & 3 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,6 +373,15 @@ 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)))
{
// Only name server-supported scopes; do not reflect arbitrary request values.
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 @@ -68,7 +68,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 @@ -171,13 +171,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
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading