Files

143 lines
5.6 KiB
Python

"""Detailed credential checks and OAuth revocation.
Callers resolve and authorize credentials before this boundary. These calls
never refresh tokens, persist Connection state, or retry provider operations
automatically.
Legacy true results preserve the existing validator's success semantics. Legacy
false results cannot distinguish invalid credentials from other failures, so
they do not invalidate credentials. Transport and protocol errors propagate as
plugin daemon exceptions. Connection lifecycle services own best-effort cleanup.
"""
import time
from collections.abc import Mapping
from contextlib import closing
from typing import Any
from pydantic import ValidationError
from clients.plugin.auth_entities import (
CredentialCheckResponse,
CredentialCheckResult,
OAuthRevokeResult,
)
from clients.plugin.identifiers import plugin_id_from_unique_identifier
from core.plugin.entities.plugin_daemon import CredentialType
from core.plugin.impl.base import BasePluginClient, use_plugin_daemon_request_timeout
from core.plugin.impl.exc import PluginDaemonInternalServerError
from models.provider_ids import ToolProviderID
from services.plugin.dispatch_identity import plugin_dispatch_headers
class PluginCredentialAuthClient(BasePluginClient):
def check_provider_credentials(
self,
*,
tenant_id: str,
user_id: str,
provider: str,
credentials: Mapping[str, Any],
credential_type: CredentialType,
plugin_unique_identifier: str | None = None,
) -> CredentialCheckResult:
"""Accept legacy success without interpreting legacy failure as invalid."""
match credential_type:
case CredentialType.API_KEY:
wire_credential_type = "api_key"
case CredentialType.OAUTH2:
wire_credential_type = "oauth2"
case _:
raise ValueError("Detailed checks require API key or OAuth2 credentials")
provider_id = ToolProviderID(provider)
result = self._dispatch(
tenant_id=tenant_id,
user_id=user_id,
provider_id=provider_id,
operation="tool/validate_credentials",
plugin_unique_identifier=plugin_unique_identifier,
data={
"credentials": dict(credentials),
"credential_type": wire_credential_type,
"result_format": "detailed",
},
response_type=CredentialCheckResponse,
)
return CredentialCheckResult(
status=result.status,
reason_code=result.reason_code,
retryable=result.retryable,
checked_at=int(time.time()),
)
def revoke_credentials(
self,
*,
tenant_id: str,
user_id: str,
provider: str,
plugin_unique_identifier: str,
system_credentials: Mapping[str, Any],
credentials: Mapping[str, Any],
) -> OAuthRevokeResult:
"""Return the provider result; callers handle best-effort cleanup on errors.
The caller supplies the OAuth client configuration used for the original
grant. App unbinding must not call this operation.
"""
provider_id = ToolProviderID(provider)
if plugin_id_from_unique_identifier(plugin_unique_identifier) != provider_id.plugin_id:
raise ValueError("OAuth revocation artifact does not match the Provider.")
return self._dispatch(
tenant_id=tenant_id,
user_id=user_id,
provider_id=provider_id,
operation="oauth/revoke_credentials",
plugin_unique_identifier=plugin_unique_identifier,
data={"system_credentials": dict(system_credentials), "credentials": dict(credentials)},
response_type=OAuthRevokeResult,
)
def _dispatch[T: CredentialCheckResponse | OAuthRevokeResult](
self,
*,
tenant_id: str,
user_id: str,
provider_id: ToolProviderID,
operation: str,
plugin_unique_identifier: str | None,
data: dict[str, Any],
response_type: type[T],
) -> T:
path = f"plugin/{tenant_id}/dispatch/{operation}"
headers = plugin_dispatch_headers(provider_id.plugin_id, plugin_unique_identifier)
payload = {"user_id": user_id, "data": {"provider": provider_id.provider_name, **data}}
if plugin_unique_identifier is not None:
if plugin_id_from_unique_identifier(plugin_unique_identifier) != provider_id.plugin_id:
raise ValueError("Authentication artifact does not match the Provider.")
with (
use_plugin_daemon_request_timeout(10),
closing(
self._request_with_plugin_daemon_response_stream(
"POST",
path,
dict[str, object],
headers=headers,
data=payload,
)
) as response,
):
for payload in response:
if (
response_type is CredentialCheckResponse
and payload.keys() == {"result"}
and payload["result"] is True
):
payload = {"status": "valid", "reason_code": None, "retryable": False}
try:
return response_type.model_validate(payload)
except ValidationError as exc:
raise PluginDaemonInternalServerError("Invalid authentication result from plugin daemon") from exc
raise PluginDaemonInternalServerError("No authentication result received from plugin daemon")