Split the registration request model from the registered-client record (#3181)
This commit is contained in:
parent
b9422f1c9b
commit
d3ffe87960
4 changed files with 573 additions and 129 deletions
|
|
@ -11,7 +11,7 @@ import string
|
|||
import time
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
from typing import Any, Protocol, get_args
|
||||
from urllib.parse import quote, urlencode, urljoin, urlparse
|
||||
|
||||
import anyio
|
||||
|
|
@ -19,7 +19,7 @@ import httpx2
|
|||
from mcp_types.version import is_version_at_least
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError
|
||||
from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
|
||||
from mcp.client.auth.utils import (
|
||||
build_oauth_authorization_server_metadata_discovery_urls,
|
||||
build_protected_resource_metadata_discovery_urls,
|
||||
|
|
@ -48,6 +48,7 @@ from mcp.shared.auth import (
|
|||
OAuthMetadata,
|
||||
OAuthToken,
|
||||
ProtectedResourceMetadata,
|
||||
TokenEndpointAuthMethod,
|
||||
)
|
||||
from mcp.shared.auth_utils import (
|
||||
calculate_token_expiry,
|
||||
|
|
@ -58,6 +59,55 @@ from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Methods a registered client's record may carry without a token request being an error,
|
||||
# derived from the set the SDK is willing to request so the two cannot drift. `None`/"none"
|
||||
# send no client secret. `private_key_jwt` sends none from here either: only
|
||||
# `PrivateKeyJWTOAuthProvider` signs the assertion, and only in its client-credentials
|
||||
# exchange, so its inherited refresh path must pass through here without raising - a refresh
|
||||
# the server then rejects falls back to a fresh client-credentials exchange, which signs.
|
||||
# Anything else is a method no client here can apply.
|
||||
_KNOWN_TOKEN_ENDPOINT_AUTH_METHODS: tuple[str | None, ...] = (None, *get_args(TokenEndpointAuthMethod))
|
||||
|
||||
# Methods that authenticate the token request with the minted `client_secret`; a
|
||||
# registration assigning one is only usable if the server issued that secret.
|
||||
_SECRET_TOKEN_ENDPOINT_AUTH_METHODS = ("client_secret_post", "client_secret_basic")
|
||||
|
||||
# Methods a registration completed by the authorization-code flow can act on. That flow
|
||||
# authenticates the token request with the minted client secret (or nothing); it holds no key
|
||||
# to sign a `private_key_jwt` assertion, so a server assigning that method has registered a
|
||||
# client this flow cannot use. `PrivateKeyJWTOAuthProvider` never registers dynamically.
|
||||
_REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS: tuple[str | None, ...] = tuple(
|
||||
method for method in _KNOWN_TOKEN_ENDPOINT_AUTH_METHODS if method != "private_key_jwt"
|
||||
)
|
||||
|
||||
|
||||
def check_registration_usable(client_info: OAuthClientInformationFull) -> None:
|
||||
"""Confirm a registration this flow completed is one it can act on.
|
||||
|
||||
RFC 7591 §3.2.1 lets the authorization server replace requested metadata and leaves it to
|
||||
the client to "check the values in the response to determine if the registration is
|
||||
sufficient for use". Two substitutions make the minted credentials unusable, and both are
|
||||
judged here - before the record is persisted or any interactive authorization begins -
|
||||
rather than surfacing later as an opaque failure at the token endpoint: a token-endpoint
|
||||
auth method the authorization-code flow cannot apply (one it does not implement, or
|
||||
`private_key_jwt`, whose assertion this flow has no key to sign), and a secret-based
|
||||
method the flow could apply but for which the server issued no `client_secret`.
|
||||
|
||||
Raises:
|
||||
OAuthRegistrationError: The server registered the client with a
|
||||
`token_endpoint_auth_method` this flow cannot apply, or with a secret-based
|
||||
method but no `client_secret`.
|
||||
"""
|
||||
method = client_info.token_endpoint_auth_method
|
||||
if method not in _REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS:
|
||||
raise OAuthRegistrationError(
|
||||
f"Authorization server registered the client with unsupported token_endpoint_auth_method {method!r}"
|
||||
)
|
||||
if method in _SECRET_TOKEN_ENDPOINT_AUTH_METHODS and client_info.client_secret is None:
|
||||
raise OAuthRegistrationError(
|
||||
f"Authorization server registered the client for {method!r} but issued no client_secret"
|
||||
)
|
||||
|
||||
|
||||
class PKCEParameters(BaseModel):
|
||||
"""PKCE (Proof Key for Code Exchange) parameters."""
|
||||
|
|
@ -190,6 +240,12 @@ class OAuthContext:
|
|||
|
||||
Returns:
|
||||
Tuple of (updated_data, updated_headers)
|
||||
|
||||
Raises:
|
||||
OAuthTokenError: The client record carries a `token_endpoint_auth_method` this
|
||||
client does not know. A dynamic registration assigning an unusable method is
|
||||
rejected earlier, by `check_registration_usable`; this fires for a stored or
|
||||
pre-registered record that reaches a token request with such a method.
|
||||
"""
|
||||
if headers is None:
|
||||
headers = {} # pragma: no cover
|
||||
|
|
@ -199,7 +255,7 @@ class OAuthContext:
|
|||
|
||||
auth_method = self.client_info.token_endpoint_auth_method
|
||||
|
||||
if auth_method == "client_secret_basic" and self.client_info.client_id and self.client_info.client_secret:
|
||||
if auth_method == "client_secret_basic" and self.client_info.client_secret:
|
||||
# URL-encode client ID and secret per RFC 6749 Section 2.3.1
|
||||
encoded_id = quote(self.client_info.client_id, safe="")
|
||||
encoded_secret = quote(self.client_info.client_secret, safe="")
|
||||
|
|
@ -208,11 +264,14 @@ class OAuthContext:
|
|||
headers["Authorization"] = f"Basic {encoded_credentials}"
|
||||
# Don't include client_secret in body for basic auth
|
||||
data = {k: v for k, v in data.items() if k != "client_secret"}
|
||||
elif auth_method == "client_secret_post" and self.client_info.client_id and self.client_info.client_secret:
|
||||
elif auth_method == "client_secret_post" and self.client_info.client_secret:
|
||||
# Include client_id and client_secret in request body (RFC 6749 §2.3.1)
|
||||
data["client_id"] = self.client_info.client_id
|
||||
data["client_secret"] = self.client_info.client_secret
|
||||
# For auth_method == "none", don't add any client_secret
|
||||
elif auth_method not in _KNOWN_TOKEN_ENDPOINT_AUTH_METHODS:
|
||||
raise OAuthTokenError(f"Registered client uses unsupported token_endpoint_auth_method {auth_method!r}")
|
||||
# For "none" (or absent), don't add any client_secret; "private_key_jwt" adds its
|
||||
# assertion in the provider that implements it, not here.
|
||||
|
||||
return data, headers
|
||||
|
||||
|
|
@ -664,6 +723,7 @@ class OAuthClientProvider(httpx2.Auth):
|
|||
)
|
||||
registration_response = yield registration_request
|
||||
client_information = await handle_registration_response(registration_response)
|
||||
check_registration_usable(client_information)
|
||||
# Only record the issuer when the registration above actually targeted
|
||||
# the discovered AS — either via its published registration_endpoint,
|
||||
# or because the resource-origin /register fallback is on the issuer's
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import re
|
||||
from typing import Any, cast
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from httpx2 import Request, Response
|
||||
from mcp_types import LATEST_PROTOCOL_VERSION
|
||||
from pydantic import AnyUrl, ValidationError
|
||||
from pydantic_core import from_json
|
||||
|
||||
from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
|
||||
from mcp.shared.auth import (
|
||||
|
|
@ -299,10 +301,17 @@ async def handle_registration_response(response: Response) -> OAuthClientInforma
|
|||
|
||||
try:
|
||||
content = await response.aread()
|
||||
client_info = OAuthClientInformationFull.model_validate_json(content)
|
||||
return client_info
|
||||
except ValidationError as e: # pragma: no cover
|
||||
raise OAuthRegistrationError(f"Invalid registration response: {e}")
|
||||
body = from_json(content)
|
||||
# `issuer` is the SDK's own binding of these credentials to the server they were
|
||||
# registered with (SEP-2352), stamped by the auth flow - never sourced from the
|
||||
# wire, so it is dropped before the body is parsed rather than trusted or cleared.
|
||||
if isinstance(body, dict):
|
||||
cast(dict[str, Any], body).pop("issuer", None)
|
||||
return OAuthClientInformationFull.model_validate(body)
|
||||
except ValueError as e:
|
||||
# `from_json` reports malformed bytes/JSON as ValueError, and pydantic's
|
||||
# ValidationError is itself a ValueError, so both parse layers surface here.
|
||||
raise OAuthRegistrationError(f"Invalid registration response: {e}") from e
|
||||
|
||||
|
||||
def is_valid_client_metadata_url(url: str | None) -> bool:
|
||||
|
|
@ -381,8 +390,8 @@ def create_client_info_from_metadata_url(
|
|||
|
||||
Args:
|
||||
client_metadata_url: The URL to use as the client_id
|
||||
redirect_uris: The redirect URIs from the client metadata (passed through for
|
||||
compatibility with OAuthClientInformationFull which inherits from OAuthClientMetadata)
|
||||
redirect_uris: The redirect URIs from the client metadata, recorded on the client
|
||||
information alongside the client_id
|
||||
|
||||
Returns:
|
||||
OAuthClientInformationFull with the URL as client_id
|
||||
|
|
|
|||
|
|
@ -50,6 +50,18 @@ class RegistrationHandler:
|
|||
# If auth method is None, default to client_secret_post
|
||||
if client_metadata.token_endpoint_auth_method is None:
|
||||
client_metadata.token_endpoint_auth_method = "client_secret_post"
|
||||
# This server authenticates token requests with the client secret it mints; it holds
|
||||
# no client key to verify a private_key_jwt assertion, so confirming that method would
|
||||
# register a client whose every token request is then rejected. Refuse it instead
|
||||
# (RFC 7591 §3.2.2), before minting credentials the client could never use.
|
||||
if client_metadata.token_endpoint_auth_method == "private_key_jwt":
|
||||
return PydanticJSONResponse(
|
||||
content=RegistrationErrorResponse(
|
||||
error="invalid_client_metadata",
|
||||
error_description="token_endpoint_auth_method 'private_key_jwt' is not supported",
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
client_secret = None
|
||||
if client_metadata.token_endpoint_auth_method != "none": # pragma: no branch
|
||||
|
|
@ -106,33 +118,27 @@ class RegistrationHandler:
|
|||
)
|
||||
|
||||
client_id_issued_at = int(time.time())
|
||||
client_secret_expires_at = (
|
||||
client_id_issued_at + self.options.client_secret_expiry_seconds
|
||||
if self.options.client_secret_expiry_seconds is not None
|
||||
else None
|
||||
)
|
||||
# RFC 7591 §3.2.1: client_secret_expires_at is REQUIRED whenever a client_secret is
|
||||
# issued, with 0 (not omission) meaning it never expires; a public client gets none.
|
||||
client_secret_expires_at = None
|
||||
if client_secret is not None:
|
||||
client_secret_expires_at = (
|
||||
client_id_issued_at + self.options.client_secret_expiry_seconds
|
||||
if self.options.client_secret_expiry_seconds is not None
|
||||
else 0
|
||||
)
|
||||
|
||||
client_info = OAuthClientInformationFull(
|
||||
client_id=client_id,
|
||||
client_id_issued_at=client_id_issued_at,
|
||||
client_secret=client_secret,
|
||||
client_secret_expires_at=client_secret_expires_at,
|
||||
# passthrough information from the client request
|
||||
redirect_uris=client_metadata.redirect_uris,
|
||||
token_endpoint_auth_method=client_metadata.token_endpoint_auth_method,
|
||||
grant_types=client_metadata.grant_types,
|
||||
response_types=client_metadata.response_types,
|
||||
client_name=client_metadata.client_name,
|
||||
client_uri=client_metadata.client_uri,
|
||||
logo_uri=client_metadata.logo_uri,
|
||||
scope=client_metadata.scope,
|
||||
contacts=client_metadata.contacts,
|
||||
tos_uri=client_metadata.tos_uri,
|
||||
policy_uri=client_metadata.policy_uri,
|
||||
jwks_uri=client_metadata.jwks_uri,
|
||||
jwks=client_metadata.jwks,
|
||||
software_id=client_metadata.software_id,
|
||||
software_version=client_metadata.software_version,
|
||||
# RFC 7591 §3.2.1: the response returns all registered metadata about the client, so
|
||||
# the record is the whole validated request plus the credentials minted here - built
|
||||
# from the request's dump so no metadata field can be silently omitted from the echo.
|
||||
client_info = OAuthClientInformationFull.model_validate(
|
||||
{
|
||||
**client_metadata.model_dump(),
|
||||
"client_id": client_id,
|
||||
"client_id_issued_at": client_id_issued_at,
|
||||
"client_secret": client_secret,
|
||||
"client_secret_expires_at": client_secret_expires_at,
|
||||
}
|
||||
)
|
||||
try:
|
||||
# Register client
|
||||
|
|
|
|||
|
|
@ -1,10 +1,27 @@
|
|||
from typing import Any, Literal
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
# RFC 7523 JWT bearer grant; SEP-990 leg 2 uses this to present the ID-JAG.
|
||||
JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
||||
|
||||
# Token-endpoint client authentication methods this SDK's clients request, and the set
|
||||
# `OAuthContext.prepare_token_auth` recognizes on a registered client (`private_key_jwt` is
|
||||
# applied by `PrivateKeyJWTOAuthProvider`; the rest send a client secret or nothing).
|
||||
TokenEndpointAuthMethod = Literal["none", "client_secret_post", "client_secret_basic", "private_key_jwt"]
|
||||
|
||||
# grant_types a client requests when it does not specify its own (RFC 7591 §2).
|
||||
DEFAULT_GRANT_TYPES = ["authorization_code", "refresh_token"]
|
||||
|
||||
|
||||
def _empty_str_to_none(v: object) -> object:
|
||||
# RFC 7591 §2 marks these URL fields OPTIONAL; a "" placeholder means absent, so it
|
||||
# must not fail AnyHttpUrl validation. (The registered-client record applies the same
|
||||
# rule to every member; this coercion serves the request model.)
|
||||
if v == "":
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
class OAuthToken(BaseModel):
|
||||
"""See https://datatracker.ietf.org/doc/html/rfc6749#section-5.1"""
|
||||
|
|
@ -47,32 +64,21 @@ class InvalidRedirectUriError(Exception):
|
|||
self.message = message
|
||||
|
||||
|
||||
class OAuthClientMetadata(BaseModel):
|
||||
"""RFC 7591 OAuth 2.0 Dynamic Client Registration Metadata.
|
||||
class OAuthClientMetadataBase(BaseModel):
|
||||
"""RFC 7591 OAuth 2.0 Dynamic Client Registration metadata shared verbatim by the
|
||||
registration request (`OAuthClientMetadata`) and the authorization server's record of a
|
||||
registered client (`OAuthClientInformationFull`). Fields whose acceptable values differ
|
||||
between the two - what this SDK sends versus what a third-party server may echo - are
|
||||
declared on each model rather than here.
|
||||
See https://datatracker.ietf.org/doc/html/rfc7591#section-2
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(url_preserve_empty_path=True)
|
||||
|
||||
redirect_uris: list[AnyUrl] | None = Field(..., min_length=1)
|
||||
# supported auth methods for the token endpoint
|
||||
token_endpoint_auth_method: (
|
||||
Literal["none", "client_secret_post", "client_secret_basic", "private_key_jwt"] | None
|
||||
) = None
|
||||
# supported grant_types of this implementation
|
||||
grant_types: list[
|
||||
Literal["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"] | str
|
||||
] = [
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
]
|
||||
# The MCP spec requires the "code" response type, but OAuth
|
||||
# servers may also return additional types they support
|
||||
response_types: list[str] = ["code"]
|
||||
scope: str | None = None
|
||||
# SEP-837: OIDC application_type. Defaults to "native" since MCP clients typically use
|
||||
# loopback redirect URIs; set "web" for remote browser-based clients on a non-local host.
|
||||
application_type: Literal["web", "native"] = "native"
|
||||
|
||||
# these fields are currently unused, but we support & store them for potential
|
||||
# future use
|
||||
|
|
@ -97,13 +103,76 @@ class OAuthClientMetadata(BaseModel):
|
|||
)
|
||||
@classmethod
|
||||
def _empty_string_optional_url_to_none(cls, v: object) -> object:
|
||||
# RFC 7591 §2 marks these URL fields OPTIONAL. Some authorization servers
|
||||
# echo omitted metadata back as "" instead of dropping the keys, which
|
||||
# AnyHttpUrl would otherwise reject — throwing away an otherwise valid
|
||||
# registration response. Treat "" as absent.
|
||||
if v == "":
|
||||
return None
|
||||
return v
|
||||
# These URL fields are OPTIONAL; an echoed "" would otherwise fail AnyHttpUrl
|
||||
# and throw away an otherwise valid registration response.
|
||||
return _empty_str_to_none(v)
|
||||
|
||||
|
||||
class OAuthClientMetadata(OAuthClientMetadataBase):
|
||||
"""RFC 7591 OAuth 2.0 Dynamic Client Registration request metadata: what an MCP
|
||||
client sends when it registers. Field values are narrowed to what this SDK will put
|
||||
on the wire; parsing the authorization server's response is `OAuthClientInformationFull`'s
|
||||
job. See https://datatracker.ietf.org/doc/html/rfc7591#section-2
|
||||
"""
|
||||
|
||||
redirect_uris: list[AnyUrl] | None = Field(..., min_length=1)
|
||||
# supported auth methods for the token endpoint
|
||||
token_endpoint_auth_method: TokenEndpointAuthMethod | None = None
|
||||
# supported grant_types of this implementation
|
||||
grant_types: list[
|
||||
Literal["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"] | str
|
||||
] = list(DEFAULT_GRANT_TYPES)
|
||||
# SEP-837: OIDC application_type. Defaults to "native" since MCP clients typically use
|
||||
# loopback redirect URIs; set "web" for remote browser-based clients on a non-local host.
|
||||
application_type: Literal["web", "native"] = "native"
|
||||
|
||||
|
||||
class OAuthClientInformationFull(OAuthClientMetadataBase):
|
||||
"""RFC 7591 OAuth 2.0 Dynamic Client Registration client information response
|
||||
(client information plus metadata) - the authorization server's record of a
|
||||
registered client. See https://datatracker.ietf.org/doc/html/rfc7591#section-3.2.1
|
||||
|
||||
A third-party authorization server "MAY reject or replace any of the client's
|
||||
requested metadata values submitted during the registration and substitute them with
|
||||
suitable values", so `application_type`, `token_endpoint_auth_method`, and `grant_types`
|
||||
are typed to accept any string the server echoes, and `redirect_uris` may be absent or
|
||||
empty. A member the server serializes as a placeholder - an explicit `null`, or `""` -
|
||||
is read as an omitted key, so the field's default applies rather than the parse failing.
|
||||
Whether a substituted value is usable is decided where the value is used, not at parse.
|
||||
`redirect_uris` elements are still parsed as URLs, as the authorization server compares
|
||||
them against a client's requested `redirect_uri`.
|
||||
"""
|
||||
|
||||
redirect_uris: list[AnyUrl] | None = None
|
||||
# RFC 7591 §3.2.1: the server may assign an auth method other than the one requested,
|
||||
# including methods this SDK does not implement, or omit it.
|
||||
token_endpoint_auth_method: str | None = None
|
||||
grant_types: list[str] = list(DEFAULT_GRANT_TYPES)
|
||||
# SEP-837: OIDC application_type. OIDC Registration §2 defines "web" and "native", but
|
||||
# servers echo other strings or an explicit null; the value is informational here.
|
||||
application_type: str | None = None
|
||||
|
||||
# RFC 7591 §3.2.1: client_id is REQUIRED in a client information response - a body
|
||||
# without one is not a registration, whatever else it echoes.
|
||||
client_id: str
|
||||
client_secret: str | None = None
|
||||
client_id_issued_at: int | None = None
|
||||
client_secret_expires_at: int | None = None
|
||||
# SEP-2352: the issuer these credentials were registered with, recorded by the SDK (not an
|
||||
# RFC 7591 field) to detect authorization-server migration and avoid cross-AS credential reuse.
|
||||
issuer: str | None = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _placeholder_members_read_as_omitted(cls, data: object) -> object:
|
||||
# Servers dump unset members of their client record as null, or echo them as "",
|
||||
# instead of omitting the keys. Either placeholder would otherwise fail the parse of a
|
||||
# list field (or read "" as an unrecognized method) and discard an already-provisioned
|
||||
# registration; a placeholder and an absent key mean the same thing.
|
||||
if isinstance(data, dict):
|
||||
members = cast(dict[str, Any], data)
|
||||
return {key: value for key, value in members.items() if value is not None and value != ""}
|
||||
return data
|
||||
|
||||
def validate_scope(self, requested_scope: str | None) -> list[str] | None:
|
||||
if requested_scope is None:
|
||||
|
|
@ -118,27 +187,15 @@ class OAuthClientMetadata(BaseModel):
|
|||
def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl:
|
||||
if redirect_uri is not None:
|
||||
# Validate redirect_uri against client's registered redirect URIs
|
||||
if self.redirect_uris is None or redirect_uri not in self.redirect_uris:
|
||||
if not self.redirect_uris or redirect_uri not in self.redirect_uris:
|
||||
raise InvalidRedirectUriError(f"Redirect URI '{redirect_uri}' not registered for client")
|
||||
return redirect_uri
|
||||
elif self.redirect_uris is not None and len(self.redirect_uris) == 1:
|
||||
elif self.redirect_uris and len(self.redirect_uris) == 1:
|
||||
return self.redirect_uris[0]
|
||||
else:
|
||||
raise InvalidRedirectUriError("redirect_uri must be specified when client has multiple registered URIs")
|
||||
|
||||
|
||||
class OAuthClientInformationFull(OAuthClientMetadata):
|
||||
"""RFC 7591 OAuth 2.0 Dynamic Client Registration full response
|
||||
(client information plus metadata).
|
||||
"""
|
||||
|
||||
client_id: str | None = None
|
||||
client_secret: str | None = None
|
||||
client_id_issued_at: int | None = None
|
||||
client_secret_expires_at: int | None = None
|
||||
# SEP-2352: the issuer these credentials were registered with, recorded by the SDK (not an
|
||||
# RFC 7591 field) to detect authorization-server migration and avoid cross-AS credential reuse.
|
||||
issuer: str | None = None
|
||||
raise InvalidRedirectUriError(
|
||||
"redirect_uri must be specified unless the client has exactly one registered URI"
|
||||
)
|
||||
|
||||
|
||||
class OAuthMetadata(BaseModel):
|
||||
|
|
|
|||
Loading…
Reference in a new issue