From ac9c5784ee0d767f24b1a5605ab2f70ab3918e7a Mon Sep 17 00:00:00 2001 From: jubnl Date: Fri, 10 Apr 2026 05:58:39 +0200 Subject: [PATCH] feat(oauth): user scope selection on authorization consent screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an MCP client registers via DCR and redirects the user to authorize, the consent screen now shows checkboxes instead of a read-only scope list. The user can grant any subset of the scopes the client requested — the same level of control as when creating a client manually from user settings. - selectedScopes state initialized from validation.scopes (all pre-checked) - Group-level indeterminate checkbox to select/deselect an entire category - Approve button reflects selection count and is disabled when nothing selected - Auto-approve path (consent already on record) bypasses selection and passes the existing granted scopes directly --- client/src/pages/OAuthAuthorizePage.tsx | 107 +++++++++++++++++------- 1 file changed, 78 insertions(+), 29 deletions(-) diff --git a/client/src/pages/OAuthAuthorizePage.tsx b/client/src/pages/OAuthAuthorizePage.tsx index 6320bb84..1e4e0d4e 100644 --- a/client/src/pages/OAuthAuthorizePage.tsx +++ b/client/src/pages/OAuthAuthorizePage.tsx @@ -22,6 +22,7 @@ export default function OAuthAuthorizePage(): React.ReactElement { const [validation, setValidation] = useState(null) const [submitting, setSubmitting] = useState(false) const [errorMsg, setErrorMsg] = useState(null) + const [selectedScopes, setSelectedScopes] = useState([]) const params = new URLSearchParams(window.location.search) const clientId = params.get('client_id') || '' @@ -68,12 +69,14 @@ export default function OAuthAuthorizePage(): React.ReactElement { } if (!result.consentRequired) { - // Consent already on record — auto-approve silently + // Consent already on record — auto-approve silently with the full validated scope setPageState('auto_approving') - await submitConsent(true) + await submitConsent(true, result.scopes ?? []) return } + // Pre-select all scopes the client is requesting — user can deselect + setSelectedScopes(result.scopes ?? []) setPageState('consent') } catch (err: unknown) { setPageState('error') @@ -81,13 +84,14 @@ export default function OAuthAuthorizePage(): React.ReactElement { } } - async function submitConsent(approved: boolean) { + async function submitConsent(approved: boolean, scopes: string[] = selectedScopes) { setSubmitting(true) try { const result = await oauthApi.authorize({ client_id: clientId, redirect_uri: redirectUri, - scope, + // When approving, send only the scopes the user selected; deny uses original scope + scope: approved ? scopes.join(' ') : scope, state, code_challenge: codeChallenge, code_challenge_method: ccMethod, @@ -102,6 +106,20 @@ export default function OAuthAuthorizePage(): React.ReactElement { } } + function toggleScope(s: string) { + setSelectedScopes(prev => + prev.includes(s) ? prev.filter(x => x !== s) : [...prev, s] + ) + } + + function toggleGroup(groupScopes: string[], allSelected: boolean) { + setSelectedScopes(prev => + allSelected + ? prev.filter(s => !groupScopes.includes(s)) + : [...new Set([...prev, ...groupScopes])] + ) + } + function handleLoginRedirect() { const next = '/oauth/authorize?' + params.toString() window.location.href = '/login?redirect=' + encodeURIComponent(next) @@ -198,10 +216,14 @@ export default function OAuthAuthorizePage(): React.ReactElement {