diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 143a555509..4db8c628cf 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -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 diff --git a/docs/docs/api/index.md b/docs/docs/api/index.md index 9a74360c85..0d09c7ed55 100644 --- a/docs/docs/api/index.md +++ b/docs/docs/api/index.md @@ -8,6 +8,26 @@ 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. 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) diff --git a/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj b/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj index 16cc44ac96..418e059f2d 100644 --- a/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj +++ b/src/Exceptionless.AppHost/Exceptionless.AppHost.csproj @@ -14,6 +14,8 @@ + + diff --git a/src/Exceptionless.Core/Services/OAuthService.cs b/src/Exceptionless.Core/Services/OAuthService.cs index 77e7cb1446..84905a51df 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,8 +373,24 @@ 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))) - 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."); diff --git a/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj b/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj index fed9bcf58b..60ca09b144 100644 --- a/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj +++ b/src/Exceptionless.Insulation/Exceptionless.Insulation.csproj @@ -19,6 +19,8 @@ + + diff --git a/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs b/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs index d8738a7235..8e38f96906 100644 --- a/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs +++ b/src/Exceptionless.Web/Api/Handlers/OAuthHandler.cs @@ -67,7 +67,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); @@ -170,13 +170,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-consent.e2e.ts b/src/Exceptionless.Web/ClientApp/e2e/tests/oauth-consent.e2e.ts new file mode 100644 index 0000000000..bd3d7116a7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/e2e/tests/oauth-consent.e2e.ts @@ -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((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?.(); + } +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/validation.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/validation.test.ts index 2a8675cda7..f001fcea7b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/validation.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/validation.test.ts @@ -3,7 +3,40 @@ import { describe, expect, it } from 'vitest'; import { getProblemMessage, problemDetailsToFormErrors } from './validation'; describe('getProblemMessage', () => { - it('returns the title from a plain problem details payload', () => { + it('GetProblemMessage_NonProblemError_ReturnsFallback', () => { + // Arrange + const error = new Error('boom'); + + // Act + const actual = getProblemMessage(error, 'Please try again.'); + + // Assert + expect(actual).toBe('Please try again.'); + }); + + it.each([ + [{ error_description: 'OAuth description' }, 'OAuth description'], + [{ error: 'invalid_scope' }, 'invalid_scope'], + [{ error: 'invalid_scope', error_description: 'OAuth description', title: 'Bad Request' }, 'OAuth description'], + [{ 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'] + ])('GetProblemMessage_OAuthPayload_ReturnsSafeMessage: %j', (problem, expected) => { + // Arrange + const fallback = 'Fallback'; + + // Act + const actual = getProblemMessage(problem, fallback); + + // Assert + expect(actual).toBe(expected); + }); + + it('GetProblemMessage_ProblemPayload_ReturnsTitle', () => { + // Arrange const problem = { instance: 'DELETE /api/v2/organizations/6a121886ad40fc0017a40d3c', status: 400, @@ -12,35 +45,49 @@ describe('getProblemMessage', () => { type: 'https://tools.ietf.org/html/rfc9110#section-15.5.1' }; - expect(getProblemMessage(problem, 'Please try again.')).toBe('An organization cannot be deleted if it has a subscription.'); + // Act + const actual = getProblemMessage(problem, 'Please try again.'); + + // Assert + expect(actual).toBe('An organization cannot be deleted if it has a subscription.'); }); - it('prefers validation errors when present', () => { + it('GetProblemMessage_ValidationErrors_PrefersValidationMessage', () => { + // Arrange const problem = { - errors: { - general: ['The uploaded file is too large.'] - }, + errors: { general: ['The uploaded file is too large.'] }, status: 422, title: 'Validation failed.' }; - expect(getProblemMessage(problem, 'Please try again.')).toBe('The uploaded file is too large.'); - }); + // Act + const actual = getProblemMessage(problem, 'Please try again.'); - it('falls back when the error is not problem details shaped', () => { - expect(getProblemMessage(new Error('boom'), 'Please try again.')).toBe('Please try again.'); + // Assert + expect(actual).toBe('The uploaded file is too large.'); }); }); describe('problemDetailsToFormErrors', () => { - it('uses the resolved problem message for non-validation form errors', () => { - const problem = { - status: 400, - title: 'An organization cannot be deleted if it has a subscription.' - }; + it.each([{ status: 400 }])('ProblemDetailsToFormErrors_NoFieldErrors_ReturnsProblemMessage: %j', (details) => { + // Arrange + const problem = { ...details, title: 'An organization cannot be deleted if it has a subscription.' }; + + // Act + const actual = problemDetailsToFormErrors(problem as never); + + // Assert + expect(actual).toEqual({ form: 'An organization cannot be deleted if it has a subscription.' }); + }); + + it('ProblemDetailsToFormErrors_SpecificFieldErrors_OmitsGenericTitle', () => { + // Arrange + const problem = { errors: { version: ['Version is invalid.'] }, status: 422, title: 'Validation failed.' }; + + // Act + const actual = problemDetailsToFormErrors(problem as never); - expect(problemDetailsToFormErrors(problem as never)).toEqual({ - form: 'An organization cannot be deleted if it has a subscription.' - }); + // Assert + expect(actual).toEqual({ fields: { version: 'Version is invalid.' } }); }); }); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/validation.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/validation.ts index e58afef852..3075c238e7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/validation.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/validation.ts @@ -117,6 +117,16 @@ export function getFormErrorMessages(errors?: unknown[]): string | string[] | un } export function getProblemMessage(error: unknown, fallback: string): string { + // OAuth uses its own error format; FetchClient also stores these bodies in response.problem. + if (error && typeof error === 'object') { + if ('error_description' in error && isNonEmptyString(error.error_description)) { + return error.error_description; + } + if ('error' in error && isNonEmptyString(error.error)) { + return error.error; + } + } + if (!isProblemDetailsLike(error)) { return fallback; } 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..1a937a7d26 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 @@ -15,6 +15,7 @@ import { clearAuthenticationSession } from '$features/auth/session.svelte'; import { getOrganizationsQuery } from '$features/organizations/api.svelte'; import { getMeQuery } from '$features/users/api.svelte'; + import { getProblemMessage } from '$shared/validation'; import { useFetchClient } from '@foundatiofx/fetchclient'; import { SvelteSet } from 'svelte/reactivity'; @@ -57,6 +58,7 @@ let isLoadingConsent = $state(false); let loadedConsentKey = $state(null); let initializedOrganizationSelectionKey = $state(null); + let consentRequestId = 0; const selectedOrganizationIds = new SvelteSet(); const selectedScopes = new SvelteSet(); @@ -85,7 +87,10 @@ const hasSelectedOrganizations = $derived(selectedOrganizationIds.size > 0); const hasSelectedResourceScope = $derived(selectedScopeValues.some((scope) => scope !== offlineAccessScope)); const hasRequiredScopes = $derived(missingRequiredScopes.length === 0 && requiredScopes.every((scope) => selectedScopes.has(scope))); - const canApprove = $derived(!isLoadingConsent && !consentErrorMessage && hasSelectedOrganizations && hasSelectedResourceScope && hasRequiredScopes); + const canSelectConsent = $derived(Boolean(consentDetails) && !isLoadingConsent && !consentErrorMessage && !isAuthorizing); + const canApprove = $derived( + Boolean(consentDetails) && !isLoadingConsent && !consentErrorMessage && hasSelectedOrganizations && hasSelectedResourceScope && hasRequiredScopes + ); $effect(() => { if (!browser || accessToken.current) { @@ -194,12 +199,7 @@ return; } - errorMessage = - response.data?.error_description || - response.data?.error || - response.problem?.detail || - response.problem?.title || - 'Unable to authorize application.'; + errorMessage = getProblemMessage(response.data, getProblemMessage(response.problem, 'Unable to authorize application.')); } function cancelAuthorization() { @@ -252,7 +252,7 @@ function getRequiredScopes(resourceValue: string): string[] { if (resourceValue.endsWith('/mcp')) { - return [mcpReadScope, offlineAccessScope]; + return [mcpReadScope]; } return []; @@ -267,7 +267,12 @@ } async function loadConsentDetails(): Promise { + // A → B → A navigation can leave an older request with the same query; only the newest response owns this state. + const requestId = ++consentRequestId; + const consentKey = page.url.search; isLoadingConsent = true; + consentDetails = null; + errorMessage = null; consentErrorMessage = null; const client = useFetchClient(); const response = await client.postJSON( @@ -278,6 +283,10 @@ } ); + if (requestId !== consentRequestId || consentKey !== page.url.search) { + return; + } + isLoadingConsent = false; if (response.ok && response.data) { consentDetails = response.data; @@ -290,12 +299,7 @@ } consentDetails = null; - consentErrorMessage = - response.data?.error_description || - response.data?.error || - response.problem?.detail || - response.problem?.title || - 'Unable to load application details.'; + consentErrorMessage = getProblemMessage(response.data, getProblemMessage(response.problem, 'Unable to load application details.')); } async function redirectToLogin(): Promise { @@ -365,6 +369,7 @@