From f239c884b6dbf49a24ad067945bd9ade1fea3da2 Mon Sep 17 00:00:00 2001 From: stroblme Date: Sun, 16 Aug 2026 16:29:47 +0200 Subject: [PATCH] Add OAuth client list and revoke endpoints Superuser-only management for agents that registered themselves: list them with whether anyone approved them, and withdraw one without rotating the signing key and cutting off every other agent. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A --- backend/app/api/routes/oauth.py | 96 +++++++++++++++++++++++++- backend/tests/api/routes/test_oauth.py | 41 +++++++++++ frontend/src/client/schemas.gen.ts | 65 +++++++++++++++++ frontend/src/client/sdk.gen.ts | 41 ++++++++++- frontend/src/client/types.gen.ts | 25 +++++++ 5 files changed, 264 insertions(+), 4 deletions(-) diff --git a/backend/app/api/routes/oauth.py b/backend/app/api/routes/oauth.py index 63cda8a..80e9895 100644 --- a/backend/app/api/routes/oauth.py +++ b/backend/app/api/routes/oauth.py @@ -31,14 +31,21 @@ from datetime import datetime, timedelta, timezone from typing import Any from urllib.parse import urlencode, urlparse -from fastapi import APIRouter, Depends, Form, Request +from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi.responses import JSONResponse -from sqlmodel import select +from pydantic import BaseModel +from sqlmodel import col, select -from app.api.deps import CurrentUser, SessionDep, get_current_user +from app.api.deps import ( + CurrentUser, + SessionDep, + get_current_active_superuser, + get_current_user, +) from app.core import security from app.core.config import settings from app.models import ( + Message, OAuthAuthorizationCode, OAuthAuthorizeInfo, OAuthAuthorizeRequest, @@ -444,3 +451,86 @@ def _revoke_family( ).all(): token.revoked = True session.add(token) + + +# ----------------------------------------------------------------------------- +# Management +# +# Registration is open, so without these the only way to withdraw one agent's +# access was rotating the signing key — which cuts off every agent at once. +# They answer in FastAPI's error shape rather than OAuth's, because the +# dashboard reads them and no OAuth client ever does, and they keep working +# with MCP switched off: that is exactly when leftover clients want clearing. +# ----------------------------------------------------------------------------- + + +class RegisteredClient(BaseModel): + """A registered agent, and whether anyone actually let it in.""" + + id: uuid.UUID + client_name: str + redirect_uris: list[str] + created_at: datetime + #: Live refresh tokens. Zero means it registered and was never approved. + active_tokens: int + last_authorized_at: datetime | None + + +class RegisteredClients(BaseModel): + data: list[RegisteredClient] + count: int + + +@router.get( + "/clients", + response_model=RegisteredClients, + dependencies=[Depends(get_current_active_superuser)], +) +def read_clients(session: SessionDep) -> Any: + """Every agent that registered itself, newest first.""" + # ponytail: counts live tokens in Python; a GROUP BY if this ever grows. + # It also keeps the expiry comparison off a naive Postgres column. + now = _now() + live: dict[uuid.UUID, list[datetime]] = defaultdict(list) + for token in session.exec( + select(OAuthRefreshToken).where(col(OAuthRefreshToken.revoked).is_(False)) + ).all(): + if _aware(token.expires_at) > now: + live[token.client_id].append(_aware(token.created_at)) + + data = [ + RegisteredClient( + id=client.id, + client_name=client.client_name, + redirect_uris=client.redirect_uris, + created_at=_aware(client.created_at), + active_tokens=len(live[client.id]), + last_authorized_at=max(live[client.id], default=None), + ) + for client in session.exec( + select(OAuthClient).order_by(col(OAuthClient.created_at).desc()) + ).all() + ] + return RegisteredClients(data=data, count=len(data)) + + +@router.delete( + "/clients/{client_id}", + response_model=Message, + dependencies=[Depends(get_current_active_superuser)], +) +def revoke_client(client_id: uuid.UUID, session: SessionDep) -> Any: + """Withdraw one agent's access, leaving every other agent alone. + + Deleting the client cascades to its codes and refresh tokens, so it can + get nothing new and cannot come back without registering again. An access + token already in its hands keeps working until it expires + (``MCP_TOKEN_EXPIRE_MINUTES``) — those are stateless by design. + """ + client = session.get(OAuthClient, client_id) + if client is None: + raise HTTPException(status_code=404, detail="No such client") + name = client.client_name + session.delete(client) + session.commit() + return Message(message=f"Revoked '{name}'") diff --git a/backend/tests/api/routes/test_oauth.py b/backend/tests/api/routes/test_oauth.py index d4b2ce3..1ee34ef 100644 --- a/backend/tests/api/routes/test_oauth.py +++ b/backend/tests/api/routes/test_oauth.py @@ -224,3 +224,44 @@ def test_everything_is_refused_while_mcp_is_off( client.post(f"{PREFIX}/token", data={"grant_type": "authorization_code"}).status_code == 403 ) + + +def test_one_agent_can_be_revoked_without_touching_the_others( + client: TestClient, + superuser_token_headers: dict[str, str], + normal_user_token_headers: dict[str, str], +) -> None: + registered = register(client, client_name="Doomed agent") + client_id = registered["client_id"] + verifier, challenge = pkce() + code = approve(client, superuser_token_headers, client_id, challenge) + tokens = exchange(client, client_id, code, verifier).json() + + def listed() -> list[dict]: + response = client.get(f"{PREFIX}/clients", headers=superuser_token_headers) + assert response.status_code == 200 + return response.json()["data"] + + entry = next(c for c in listed() if c["id"] == client_id) + assert entry["client_name"] == "Doomed agent" + # The column that tells an approved agent from one that only registered. + assert entry["active_tokens"] == 1 + + # Withdrawing access is a superuser's job. + assert ( + client.get(f"{PREFIX}/clients", headers=normal_user_token_headers).status_code + == 403 + ) + + revoked = client.delete( + f"{PREFIX}/clients/{client_id}", headers=superuser_token_headers + ) + assert revoked.status_code == 200, revoked.text + assert all(c["id"] != client_id for c in listed()) + + # Its refresh token went with it, so it cannot mint itself a new one. + refreshed = client.post( + f"{PREFIX}/token", + data={"grant_type": "refresh_token", "refresh_token": tokens["refresh_token"]}, + ) + assert refreshed.status_code == 400 diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 5a71f31..2d94b00 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -1375,6 +1375,71 @@ export const PrivateUserCreateSchema = { title: 'PrivateUserCreate' } as const; +export const RegisteredClientSchema = { + properties: { + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + client_name: { + type: 'string', + title: 'Client Name' + }, + redirect_uris: { + items: { + type: 'string' + }, + type: 'array', + title: 'Redirect Uris' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + active_tokens: { + type: 'integer', + title: 'Active Tokens' + }, + last_authorized_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Last Authorized At' + } + }, + type: 'object', + required: ['id', 'client_name', 'redirect_uris', 'created_at', 'active_tokens', 'last_authorized_at'], + title: 'RegisteredClient', + description: 'A registered agent, and whether anyone actually let it in.' +} as const; + +export const RegisteredClientsSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/RegisteredClient' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'RegisteredClients' +} as const; + export const RuleSchema = { properties: { events: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index e47247c..b58ce10 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse } from './types.gen'; +import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse } from './types.gen'; export class AlertsService { /** @@ -943,6 +943,45 @@ export class OauthService { } }); } + + /** + * Read Clients + * Every agent that registered itself, newest first. + * @returns RegisteredClients Successful Response + * @throws ApiError + */ + public static readClients(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/oauth/clients' + }); + } + + /** + * Revoke Client + * Withdraw one agent's access, leaving every other agent alone. + * + * Deleting the client cascades to its codes and refresh tokens, so it can + * get nothing new and cannot come back without registering again. An access + * token already in its hands keeps working until it expires + * (``MCP_TOKEN_EXPIRE_MINUTES``) — those are stateless by design. + * @param data The data for the request. + * @param data.clientId + * @returns Message Successful Response + * @throws ApiError + */ + public static revokeClient(data: OauthRevokeClientData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/oauth/clients/{client_id}', + path: { + client_id: data.clientId + }, + errors: { + 422: 'Validation Error' + } + }); + } } export class PrivateService { diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index db5655e..bbfa5ee 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -453,6 +453,23 @@ export type PrivateUserCreate = { is_verified?: boolean; }; +/** + * A registered agent, and whether anyone actually let it in. + */ +export type RegisteredClient = { + id: string; + client_name: string; + redirect_uris: Array<(string)>; + created_at: string; + active_tokens: number; + last_authorized_at: (string | null); +}; + +export type RegisteredClients = { + data: Array; + count: number; +}; + /** * Which events go to which channels. */ @@ -855,6 +872,14 @@ export type OauthTokenData = { export type OauthTokenResponse = (unknown); +export type OauthReadClientsResponse = (RegisteredClients); + +export type OauthRevokeClientData = { + clientId: string; +}; + +export type OauthRevokeClientResponse = (Message); + export type PrivateCreateUserData = { requestBody: PrivateUserCreate; };