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:
2026-06-25 13:13:25 +00:00
parent 87f02eb4d1
commit 1c9d8f7648
3 changed files with 199 additions and 12 deletions
+8 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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');