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 {