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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
@@ -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}'")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<OauthReadClientsResponse> {
|
||||
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<OauthRevokeClientResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/oauth/clients/{client_id}',
|
||||
path: {
|
||||
client_id: data.clientId
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class PrivateService {
|
||||
|
||||
@@ -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<RegisteredClient>;
|
||||
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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user