feat: OAuth client scope assignment in admin panel
- Create client: ctxd.read / ctxd.write checkboxes - Client list: show scopes, edit via PATCH /oauth/clients/:id - Authorize grants intersection of client allowed scopes and request - CLI oauth-client-create --scope; DCR default ctxd.read ctxd.write
This commit is contained in:
+8
-2
@@ -351,6 +351,10 @@ def cmd_oauth_client_create(args):
|
||||
client = store.register_client({
|
||||
"client_name": args.name or "Claude MCP Client",
|
||||
"redirect_uris": redirects,
|
||||
"scopes": getattr(args, "scopes", None) or (
|
||||
[s for s in (args.scope or "").split() if s]
|
||||
or ["ctxd.read", "ctxd.write"]
|
||||
),
|
||||
})
|
||||
issuer = (cfg.oauth_issuer or "").rstrip("/") or "https://ctxd.cubecraftcreations.com"
|
||||
print(json.dumps({
|
||||
@@ -358,7 +362,8 @@ def cmd_oauth_client_create(args):
|
||||
"client_secret": client["client_secret"],
|
||||
"client_name": client.get("client_name"),
|
||||
"redirect_uris": client.get("redirect_uris"),
|
||||
"connector_url": f"{issuer}/readonly/sse",
|
||||
"scope": client.get("scope"),
|
||||
"connector_url": f"{issuer}/mcp",
|
||||
"authorization_server": issuer,
|
||||
"note": "Claude usually registers via POST /oauth/register automatically; save client_secret now — it is not shown again.",
|
||||
}, indent=2))
|
||||
@@ -371,7 +376,7 @@ def cmd_oauth_client_list(args):
|
||||
cfg = CtxConfig.from_home(args.home)
|
||||
store = OAuthStore(cfg)
|
||||
for c in store.list_clients_public():
|
||||
print(f"{c.get('client_id')} {c.get('client_name', '')} redirects={c.get('redirect_uris')}")
|
||||
print(f"{c.get('client_id')} {c.get('client_name', '')} scope={c.get('scope', '')} redirects={c.get('redirect_uris')}")
|
||||
|
||||
|
||||
def cmd_oauth_client_revoke(args):
|
||||
@@ -559,6 +564,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
sp.set_defaults(func=cmd_oauth_client_create)
|
||||
sp.add_argument("--name", "-n", default="Claude MCP Client", help="Client display name")
|
||||
sp.add_argument("--redirect-uri", action="append", dest="redirect_uri", help="Redirect URI (default: Claude MCP callback; repeat for multiple)")
|
||||
sp.add_argument("--scope", default="ctxd.read ctxd.write", help="Allowed scopes (space-separated: ctxd.read ctxd.write)")
|
||||
sp.add_argument("--home")
|
||||
|
||||
# oauth-client-list
|
||||
|
||||
+94
-5
@@ -67,6 +67,40 @@ def _now() -> int:
|
||||
# Claude remote MCP OAuth redirect (Dynamic Client Registration default)
|
||||
CLAUDE_MCP_REDIRECT_URI = "https://claude.ai/api/mcp/auth_callback"
|
||||
|
||||
OAUTH_SCOPES_ALLOWED = frozenset({"ctxd.read", "ctxd.write"})
|
||||
|
||||
|
||||
def normalize_client_scope(payload: dict, *, default: str = "ctxd.read") -> str:
|
||||
"""Return space-separated allowed scopes from payload scope or scopes[]."""
|
||||
parts: list[str] = []
|
||||
if isinstance(payload.get("scopes"), list):
|
||||
parts = [str(s).strip() for s in payload["scopes"] if str(s).strip() in OAUTH_SCOPES_ALLOWED]
|
||||
else:
|
||||
raw = payload.get("scope", default)
|
||||
if isinstance(raw, list):
|
||||
parts = [str(s).strip() for s in raw if str(s).strip() in OAUTH_SCOPES_ALLOWED]
|
||||
else:
|
||||
parts = [s for s in str(raw).split() if s in OAUTH_SCOPES_ALLOWED]
|
||||
if not parts:
|
||||
raise ValueError("at least one scope required: ctxd.read, ctxd.write")
|
||||
order = {"ctxd.read": 0, "ctxd.write": 1}
|
||||
return " ".join(sorted(set(parts), key=lambda x: order.get(x, 9)))
|
||||
|
||||
|
||||
def intersect_oauth_scopes(client: dict, requested: str) -> str:
|
||||
"""Grant only scopes both requested and allowed on the client registration."""
|
||||
allowed = {s for s in (client.get("scope") or "ctxd.read").split() if s in OAUTH_SCOPES_ALLOWED}
|
||||
if not allowed:
|
||||
allowed = {"ctxd.read"}
|
||||
req = {s for s in (requested or "").split() if s in OAUTH_SCOPES_ALLOWED}
|
||||
if not req:
|
||||
req = allowed
|
||||
granted = req & allowed
|
||||
if not granted:
|
||||
return ""
|
||||
order = {"ctxd.read": 0, "ctxd.write": 1}
|
||||
return " ".join(sorted(granted, key=lambda x: order.get(x, 9)))
|
||||
|
||||
|
||||
def _is_public_oauth_as_route(path: str, method: str) -> bool:
|
||||
"""OAuth authorization-server endpoints (public). Admin /oauth/clients stays on HTTPServer."""
|
||||
@@ -120,8 +154,12 @@ class OAuthStore:
|
||||
redirect_uris = payload.get("redirect_uris") or []
|
||||
if not isinstance(redirect_uris, list) or not redirect_uris:
|
||||
raise ValueError("redirect_uris required")
|
||||
reg = dict(payload)
|
||||
if "scope" not in reg and "scopes" not in reg:
|
||||
reg["scope"] = "ctxd.read ctxd.write"
|
||||
client_id = "ctxd_" + secrets.token_urlsafe(24)
|
||||
client_secret = secrets.token_urlsafe(32)
|
||||
scope_str = normalize_client_scope(reg)
|
||||
client = {
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
@@ -132,7 +170,7 @@ class OAuthStore:
|
||||
"grant_types": payload.get("grant_types", ["authorization_code", "refresh_token"]),
|
||||
"response_types": payload.get("response_types", ["code"]),
|
||||
"token_endpoint_auth_method": payload.get("token_endpoint_auth_method", "client_secret_post"),
|
||||
"scope": payload.get("scope", "ctxd.read"),
|
||||
"scope": scope_str,
|
||||
}
|
||||
self.state.setdefault("clients", {})[client_id] = client
|
||||
self._save()
|
||||
@@ -169,6 +207,19 @@ class OAuthStore:
|
||||
self._save()
|
||||
return True
|
||||
|
||||
def update_client(self, client_id: str, payload: dict) -> dict | None:
|
||||
"""Update admin-editable client fields (scope, client_name)."""
|
||||
clients = self.state.get("clients", {})
|
||||
client = clients.get(client_id)
|
||||
if not client:
|
||||
return None
|
||||
if "client_name" in payload and payload["client_name"]:
|
||||
client["client_name"] = str(payload["client_name"]).strip()
|
||||
if "scope" in payload or "scopes" in payload:
|
||||
client["scope"] = normalize_client_scope(payload)
|
||||
self._save()
|
||||
return {k: v for k, v in client.items() if k != "client_secret"}
|
||||
|
||||
def create_code(self, *, client_id: str, redirect_uri: str, code_challenge: str, scope: str, resource: str | None, approved_by_user_id: str | None = None) -> str:
|
||||
code = secrets.token_urlsafe(32)
|
||||
self.state.setdefault("codes", {})[code] = {
|
||||
@@ -1305,10 +1356,14 @@ class HTTPServer:
|
||||
if isinstance(redirect_uris, str):
|
||||
redirect_uris = [redirect_uris]
|
||||
try:
|
||||
reg_payload = dict(payload)
|
||||
if "scope" not in reg_payload and "scopes" not in reg_payload:
|
||||
reg_payload.setdefault("scope", "ctxd.read ctxd.write")
|
||||
client = self.oauth_store.register_client({
|
||||
"client_name": payload.get("client_name") or "MCP connector",
|
||||
"redirect_uris": redirect_uris,
|
||||
"scope": payload.get("scope") or "ctxd.read",
|
||||
"scope": reg_payload.get("scope"),
|
||||
"scopes": reg_payload.get("scopes"),
|
||||
})
|
||||
except ValueError as e:
|
||||
return (400, {"Content-Type": "application/json"}, json.dumps({"error": str(e)}))
|
||||
@@ -1345,6 +1400,31 @@ class HTTPServer:
|
||||
self._conn.commit()
|
||||
return (200, {"Content-Type": "application/json"}, json.dumps({"ok": True, "client_id": client_id}))
|
||||
|
||||
# PATCH /oauth/clients/<client_id> — update scopes / name (admin only)
|
||||
if method == "PATCH" and path.startswith("/oauth/clients/"):
|
||||
if auth.get("type") == "session" and not self._is_admin(auth):
|
||||
return (403, {"Content-Type": "application/json"}, json.dumps({"error": "admin required"}))
|
||||
if not self.oauth_store:
|
||||
return (503, {"Content-Type": "application/json"}, json.dumps({"error": "oauth unavailable"}))
|
||||
client_id = path[len("/oauth/clients/"):].strip()
|
||||
if not client_id:
|
||||
return (400, {"Content-Type": "application/json"}, json.dumps({"error": "client_id required"}))
|
||||
payload = json.loads(body or b"{}")
|
||||
try:
|
||||
updated = self.oauth_store.update_client(client_id, payload)
|
||||
except ValueError as e:
|
||||
return (400, {"Content-Type": "application/json"}, json.dumps({"error": str(e)}))
|
||||
if not updated:
|
||||
return (404, {"Content-Type": "application/json"}, json.dumps({"error": "client not found"}))
|
||||
actor = self._auth_user_id(auth)
|
||||
_db.audit_log(
|
||||
self._conn, actor, "update",
|
||||
f"Updated OAuth client {client_id} scopes={updated.get('scope', '')}",
|
||||
agent_id="oauth", entity_type="oauth_client", entity_id=client_id,
|
||||
)
|
||||
self._conn.commit()
|
||||
return (200, {"Content-Type": "application/json"}, json.dumps(updated))
|
||||
|
||||
return (404, {"Content-Type": "text/plain"}, f"Not found: {method} {path}")
|
||||
|
||||
|
||||
@@ -1519,11 +1599,20 @@ class CombinedApp:
|
||||
if not ok_approve or not approver:
|
||||
await self._send_html(send, 403, "<h1>Forbidden</h1><p>Sign in to CTXD as an admin in this browser, or provide a valid OAuth approval key.</p>")
|
||||
return
|
||||
granted_scope = intersect_oauth_scopes(client, scope_value)
|
||||
if not granted_scope:
|
||||
target = error_redirect("invalid_scope")
|
||||
if target:
|
||||
await send({"type": "http.response.start", "status": 302, "headers": [(b"location", target.encode())]})
|
||||
await send({"type": "http.response.body", "body": b""})
|
||||
else:
|
||||
await self._send_json(send, 400, {"error": "invalid_scope"})
|
||||
return
|
||||
code = self.oauth_store.create_code(
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
code_challenge=code_challenge,
|
||||
scope=scope_value,
|
||||
scope=granted_scope,
|
||||
resource=resource,
|
||||
approved_by_user_id=approver,
|
||||
)
|
||||
@@ -1531,11 +1620,11 @@ class CombinedApp:
|
||||
self.http_handler._conn,
|
||||
approver,
|
||||
"update",
|
||||
f"Approved OAuth read-only access for {client.get('client_name', client_id)}",
|
||||
f"Approved OAuth access for {client.get('client_name', client_id)}",
|
||||
agent_id="oauth",
|
||||
entity_type="oauth_client",
|
||||
entity_id=client_id,
|
||||
details={"redirect_uri": redirect_uri, "scope": scope_value},
|
||||
details={"redirect_uri": redirect_uri, "scope": granted_scope},
|
||||
)
|
||||
self.http_handler._conn.commit()
|
||||
q = {"code": code}
|
||||
|
||||
+97
-5
@@ -869,6 +869,39 @@ body {
|
||||
word-break: break-all;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.admin-oauth-row .oauth-client-scopes {
|
||||
color: var(--accent3);
|
||||
font-size: 0.58rem;
|
||||
margin-top: 0.2rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.oauth-scope-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem 1.25rem;
|
||||
margin: 0.35rem 0 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.oauth-scope-row label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink);
|
||||
margin: 0;
|
||||
}
|
||||
.admin-oauth-row .oauth-row-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.admin-oauth-row .oauth-row-actions button {
|
||||
font-size: 0.58rem;
|
||||
padding: 0.25rem 0.45rem;
|
||||
}
|
||||
.dialog.dialog-admin button.oauth-revoke,
|
||||
.dialog.dialog-admin .manage-user-actions button {
|
||||
font-family: var(--font);
|
||||
@@ -1266,12 +1299,17 @@ body {
|
||||
</div>
|
||||
</div>
|
||||
<div id="admin-oauth-sub-create" class="admin-oauth-subpanel">
|
||||
<p class="dialog-hint">Register OAuth clients for any MCP or OAuth consumer. Public connector URL: <code>/readonly/sse</code>. Many clients auto-register via <code>POST /oauth/register</code>; use this form when you need explicit <code>client_id</code> / <code>client_secret</code> (secret shown once).</p>
|
||||
<p class="dialog-hint">Register OAuth clients for MCP connectors. Public URL: <code>/mcp</code>. Scopes cap what tokens may grant at authorize time.</p>
|
||||
<label>client name</label>
|
||||
<input type="text" id="admin-oauth-name" value="MCP connector" autocomplete="off">
|
||||
<label>redirect uri</label>
|
||||
<input type="url" id="admin-oauth-redirect" placeholder="https://your-app.example/oauth/callback" autocomplete="off">
|
||||
<p class="dialog-hint" style="margin-top:0.25rem">Use the callback URL your OAuth client documents.</p>
|
||||
<label>allowed scopes</label>
|
||||
<div class="oauth-scope-row">
|
||||
<label><input type="checkbox" id="admin-oauth-scope-read" checked> ctxd.read</label>
|
||||
<label><input type="checkbox" id="admin-oauth-scope-write" checked> ctxd.write</label>
|
||||
</div>
|
||||
<div class="actions" style="justify-content: flex-start; margin: 0.5rem 0;">
|
||||
<button class="primary" type="button" onclick="createOAuthClient()">generate client id / secret</button>
|
||||
</div>
|
||||
@@ -1729,6 +1767,17 @@ async function deleteManagedUser() {
|
||||
}
|
||||
}
|
||||
|
||||
function oauthScopesFromCheckboxes(readId, writeId) {
|
||||
const scopes = [];
|
||||
if (document.getElementById(readId).checked) scopes.push('ctxd.read');
|
||||
if (document.getElementById(writeId).checked) scopes.push('ctxd.write');
|
||||
return scopes;
|
||||
}
|
||||
|
||||
function parseClientScope(scopeStr) {
|
||||
return new Set((scopeStr || '').split(/\s+/).filter(Boolean));
|
||||
}
|
||||
|
||||
async function loadOAuthClients() {
|
||||
const el = document.getElementById('admin-oauth-list');
|
||||
el.innerHTML = '<div class="loading">loading oauth clients…</div>';
|
||||
@@ -1740,14 +1789,25 @@ async function loadOAuthClients() {
|
||||
}
|
||||
el.innerHTML = clients.map(c => {
|
||||
const cid = c.client_id || '';
|
||||
const scopeSet = parseClientScope(c.scope);
|
||||
const esc = escapeHtml(cid);
|
||||
return `
|
||||
<div class="admin-oauth-row">
|
||||
<div class="admin-oauth-row" data-client-id="${esc}">
|
||||
<div class="oauth-client-meta">
|
||||
<div class="oauth-client-id">${escapeHtml(cid)}</div>
|
||||
<div class="oauth-client-id">${esc}</div>
|
||||
<div class="oauth-client-name">${escapeHtml(c.client_name || '')}</div>
|
||||
<div class="oauth-client-uri">${escapeHtml((c.redirect_uris || []).join(', '))}</div>
|
||||
<div class="oauth-client-scopes">scopes: ${escapeHtml(c.scope || 'ctxd.read')}</div>
|
||||
<div class="oauth-scope-row oauth-scope-edit" id="oauth-scope-edit-${esc}" style="display:none">
|
||||
<label><input type="checkbox" class="oauth-edit-read" data-cid="${esc}" ${scopeSet.has('ctxd.read') ? 'checked' : ''}> ctxd.read</label>
|
||||
<label><input type="checkbox" class="oauth-edit-write" data-cid="${esc}" ${scopeSet.has('ctxd.write') ? 'checked' : ''}> ctxd.write</label>
|
||||
<button type="button" class="primary" data-save-scopes="${esc}" onclick="saveOAuthClientScopes(this.dataset.saveScopes)">save scopes</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="oauth-row-actions">
|
||||
<button type="button" data-toggle-scopes="${esc}" onclick="toggleOAuthScopeEdit(this.dataset.toggleScopes)">scopes</button>
|
||||
<button type="button" class="oauth-revoke" data-client-id="${esc}" onclick="revokeOAuthClient(this.dataset.clientId)">revoke</button>
|
||||
</div>
|
||||
<button type="button" class="oauth-revoke" data-client-id="${escapeHtml(cid)}" onclick="revokeOAuthClient(this.dataset.clientId)">revoke</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
@@ -1757,6 +1817,32 @@ async function loadOAuthClients() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOAuthScopeEdit(clientId) {
|
||||
const panel = document.getElementById(`oauth-scope-edit-${clientId}`);
|
||||
if (!panel) return;
|
||||
panel.style.display = panel.style.display === 'none' ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
async function saveOAuthClientScopes(clientId) {
|
||||
if (!clientId) return;
|
||||
const row = document.querySelector(`.admin-oauth-row[data-client-id="${CSS.escape(clientId)}"]`);
|
||||
if (!row) return;
|
||||
const scopes = [];
|
||||
if (row.querySelector('.oauth-edit-read')?.checked) scopes.push('ctxd.read');
|
||||
if (row.querySelector('.oauth-edit-write')?.checked) scopes.push('ctxd.write');
|
||||
if (!scopes.length) {
|
||||
showToast('select at least one scope', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api('PATCH', `/oauth/clients/${encodeURIComponent(clientId)}`, { scopes });
|
||||
showToast(`scopes updated · ${clientId}`, 'success');
|
||||
await loadOAuthClients();
|
||||
} catch (e) {
|
||||
if (e.message !== 'unauthorized') showToast('scope update failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeOAuthClient(clientId) {
|
||||
if (!clientId) return;
|
||||
if (!confirm(`Revoke OAuth client "${clientId}"?\n\nPending auth codes and access/refresh tokens for this client are invalidated.`)) return;
|
||||
@@ -1777,7 +1863,12 @@ async function createOAuthClient() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const client = await api('POST', '/oauth/clients', { client_name: name, redirect_uris: [redirect] });
|
||||
const scopes = oauthScopesFromCheckboxes('admin-oauth-scope-read', 'admin-oauth-scope-write');
|
||||
if (!scopes.length) {
|
||||
showToast('select at least one scope', 'error');
|
||||
return;
|
||||
}
|
||||
const client = await api('POST', '/oauth/clients', { client_name: name, redirect_uris: [redirect], scopes });
|
||||
const pre = document.getElementById('admin-oauth-result');
|
||||
pre.style.display = 'block';
|
||||
pre.textContent = [
|
||||
@@ -1787,6 +1878,7 @@ async function createOAuthClient() {
|
||||
`authorization_server: ${client.authorization_server || ''}`,
|
||||
`client_id: ${client.client_id || ''}`,
|
||||
`client_secret: ${client.client_secret || ''}`,
|
||||
`scope: ${client.scope || scopes.join(' ')}`,
|
||||
`redirect_uris: ${JSON.stringify(client.redirect_uris || [])}`,
|
||||
].join('\n');
|
||||
showToast('oauth client created — copy secret from admin panel', 'success');
|
||||
|
||||
Reference in New Issue
Block a user