chore: remove k8s auth in favor of k8s jwks endpoint (#2216)

# What does this PR do?

Kubernetes since 1.20 exposes a JWKS endpoint that we can use with our
recent oauth2 recent implementation.
The CI test has been kept intact for validation.

Signed-off-by: Sébastien Han <seb@redhat.com>
This commit is contained in:
Sébastien Han 2025-05-21 16:23:54 +02:00 committed by GitHub
parent 2890243107
commit c25acedbcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 147 additions and 359 deletions

View file

@ -23,7 +23,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
auth-provider: [kubernetes] auth-provider: [oauth2_token]
fail-fast: false # we want to run all tests regardless of failure fail-fast: false # we want to run all tests regardless of failure
steps: steps:
@ -47,29 +47,53 @@ jobs:
uses: medyagh/setup-minikube@cea33675329b799adccc9526aa5daccc26cd5052 # v0.0.19 uses: medyagh/setup-minikube@cea33675329b799adccc9526aa5daccc26cd5052 # v0.0.19
- name: Start minikube - name: Start minikube
if: ${{ matrix.auth-provider == 'kubernetes' }} if: ${{ matrix.auth-provider == 'oauth2_token' }}
run: | run: |
minikube start minikube start
kubectl get pods -A kubectl get pods -A
- name: Configure Kube Auth - name: Configure Kube Auth
if: ${{ matrix.auth-provider == 'kubernetes' }} if: ${{ matrix.auth-provider == 'oauth2_token' }}
run: | run: |
kubectl create namespace llama-stack kubectl create namespace llama-stack
kubectl create serviceaccount llama-stack-auth -n llama-stack kubectl create serviceaccount llama-stack-auth -n llama-stack
kubectl create rolebinding llama-stack-auth-rolebinding --clusterrole=admin --serviceaccount=llama-stack:llama-stack-auth -n llama-stack kubectl create rolebinding llama-stack-auth-rolebinding --clusterrole=admin --serviceaccount=llama-stack:llama-stack-auth -n llama-stack
kubectl create token llama-stack-auth -n llama-stack > llama-stack-auth-token kubectl create token llama-stack-auth -n llama-stack > llama-stack-auth-token
cat <<EOF | kubectl apply -f -
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: allow-anonymous-openid
rules:
- nonResourceURLs: ["/openid/v1/jwks"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: allow-anonymous-openid
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: allow-anonymous-openid
subjects:
- kind: User
name: system:anonymous
apiGroup: rbac.authorization.k8s.io
EOF
- name: Set Kubernetes Config - name: Set Kubernetes Config
if: ${{ matrix.auth-provider == 'kubernetes' }} if: ${{ matrix.auth-provider == 'oauth2_token' }}
run: | run: |
echo "KUBERNETES_API_SERVER_URL=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')" >> $GITHUB_ENV echo "KUBERNETES_API_SERVER_URL=$(kubectl get --raw /.well-known/openid-configuration| jq -r .jwks_uri)" >> $GITHUB_ENV
echo "KUBERNETES_CA_CERT_PATH=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.certificate-authority}')" >> $GITHUB_ENV echo "KUBERNETES_CA_CERT_PATH=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.certificate-authority}')" >> $GITHUB_ENV
echo "KUBERNETES_ISSUER=$(kubectl get --raw /.well-known/openid-configuration| jq -r .issuer)" >> $GITHUB_ENV
echo "KUBERNETES_AUDIENCE=$(kubectl create token default --duration=1h | cut -d. -f2 | base64 -d | jq -r '.aud[0]')" >> $GITHUB_ENV
- name: Set Kube Auth Config and run server - name: Set Kube Auth Config and run server
env: env:
INFERENCE_MODEL: "meta-llama/Llama-3.2-3B-Instruct" INFERENCE_MODEL: "meta-llama/Llama-3.2-3B-Instruct"
if: ${{ matrix.auth-provider == 'kubernetes' }} if: ${{ matrix.auth-provider == 'oauth2_token' }}
run: | run: |
run_dir=$(mktemp -d) run_dir=$(mktemp -d)
cat <<'EOF' > $run_dir/run.yaml cat <<'EOF' > $run_dir/run.yaml
@ -81,7 +105,8 @@ jobs:
port: 8321 port: 8321
EOF EOF
yq eval '.server.auth = {"provider_type": "${{ matrix.auth-provider }}"}' -i $run_dir/run.yaml yq eval '.server.auth = {"provider_type": "${{ matrix.auth-provider }}"}' -i $run_dir/run.yaml
yq eval '.server.auth.config = {"api_server_url": "${{ env.KUBERNETES_API_SERVER_URL }}", "ca_cert_path": "${{ env.KUBERNETES_CA_CERT_PATH }}"}' -i $run_dir/run.yaml yq eval '.server.auth.config = {"tls_cafile": "${{ env.KUBERNETES_CA_CERT_PATH }}", "issuer": "${{ env.KUBERNETES_ISSUER }}", "audience": "${{ env.KUBERNETES_AUDIENCE }}"}' -i $run_dir/run.yaml
yq eval '.server.auth.config.jwks = {"uri": "${{ env.KUBERNETES_API_SERVER_URL }}"}' -i $run_dir/run.yaml
cat $run_dir/run.yaml cat $run_dir/run.yaml
source .venv/bin/activate source .venv/bin/activate

View file

@ -118,11 +118,6 @@ server:
port: 8321 # Port to listen on (default: 8321) port: 8321 # Port to listen on (default: 8321)
tls_certfile: "/path/to/cert.pem" # Optional: Path to TLS certificate for HTTPS tls_certfile: "/path/to/cert.pem" # Optional: Path to TLS certificate for HTTPS
tls_keyfile: "/path/to/key.pem" # Optional: Path to TLS key for HTTPS tls_keyfile: "/path/to/key.pem" # Optional: Path to TLS key for HTTPS
auth: # Optional: Authentication configuration
provider_type: "kubernetes" # Type of auth provider
config: # Provider-specific configuration
api_server_url: "https://kubernetes.default.svc"
ca_cert_path: "/path/to/ca.crt" # Optional: Path to CA certificate
``` ```
### Authentication Configuration ### Authentication Configuration
@ -135,7 +130,7 @@ Authorization: Bearer <token>
The server supports multiple authentication providers: The server supports multiple authentication providers:
#### Kubernetes Provider #### OAuth 2.0/OpenID Connect Provider with Kubernetes
The Kubernetes cluster must be configured to use a service account for authentication. The Kubernetes cluster must be configured to use a service account for authentication.
@ -146,14 +141,67 @@ kubectl create rolebinding llama-stack-auth-rolebinding --clusterrole=admin --se
kubectl create token llama-stack-auth -n llama-stack > llama-stack-auth-token kubectl create token llama-stack-auth -n llama-stack > llama-stack-auth-token
``` ```
Validates tokens against the Kubernetes API server: Make sure the `kube-apiserver` runs with `--anonymous-auth=true` to allow unauthenticated requests
and that the correct RoleBinding is created to allow the service account to access the necessary
resources. If that is not the case, you can create a RoleBinding for the service account to access
the necessary resources:
```yaml
# allow-anonymous-openid.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: allow-anonymous-openid
rules:
- nonResourceURLs: ["/openid/v1/jwks"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: allow-anonymous-openid
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: allow-anonymous-openid
subjects:
- kind: User
name: system:anonymous
apiGroup: rbac.authorization.k8s.io
```
And then apply the configuration:
```bash
kubectl apply -f allow-anonymous-openid.yaml
```
Validates tokens against the Kubernetes API server through the OIDC provider:
```yaml ```yaml
server: server:
auth: auth:
provider_type: "kubernetes" provider_type: "oauth2_token"
config: config:
api_server_url: "https://kubernetes.default.svc" # URL of the Kubernetes API server jwks:
ca_cert_path: "/path/to/ca.crt" # Optional: Path to CA certificate uri: "https://kubernetes.default.svc"
cache_ttl: 3600
tls_cafile: "/path/to/ca.crt"
issuer: "https://kubernetes.default.svc"
audience: "https://kubernetes.default.svc"
```
To find your cluster's audience, run:
```bash
kubectl create token default --duration=1h | cut -d. -f2 | base64 -d | jq .aud
```
For the issuer, you can use the OIDC provider's URL:
```bash
kubectl get --raw /.well-known/openid-configuration| jq .issuer
```
For the tls_cafile, you can use the CA certificate of the OIDC provider:
```bash
kubectl config view --minify -o jsonpath='{.clusters[0].cluster.certificate-authority}'
``` ```
The provider extracts user information from the JWT token: The provider extracts user information from the JWT token:

View file

@ -220,14 +220,14 @@ class LoggingConfig(BaseModel):
class AuthProviderType(str, Enum): class AuthProviderType(str, Enum):
"""Supported authentication provider types.""" """Supported authentication provider types."""
KUBERNETES = "kubernetes" OAUTH2_TOKEN = "oauth2_token"
CUSTOM = "custom" CUSTOM = "custom"
class AuthenticationConfig(BaseModel): class AuthenticationConfig(BaseModel):
provider_type: AuthProviderType = Field( provider_type: AuthProviderType = Field(
..., ...,
description="Type of authentication provider (e.g., 'kubernetes', 'custom')", description="Type of authentication provider",
) )
config: dict[str, Any] = Field( config: dict[str, Any] = Field(
..., ...,

View file

@ -8,7 +8,8 @@ import json
import httpx import httpx
from llama_stack.distribution.server.auth_providers import AuthProviderConfig, create_auth_provider from llama_stack.distribution.datatypes import AuthenticationConfig
from llama_stack.distribution.server.auth_providers import create_auth_provider
from llama_stack.log import get_logger from llama_stack.log import get_logger
logger = get_logger(name=__name__, category="auth") logger = get_logger(name=__name__, category="auth")
@ -77,7 +78,7 @@ class AuthenticationMiddleware:
access resources that don't have access_attributes defined. access resources that don't have access_attributes defined.
""" """
def __init__(self, app, auth_config: AuthProviderConfig): def __init__(self, app, auth_config: AuthenticationConfig):
self.app = app self.app = app
self.auth_provider = create_auth_provider(auth_config) self.auth_provider = create_auth_provider(auth_config)

View file

@ -4,13 +4,11 @@
# This source code is licensed under the terms described in the LICENSE file in # This source code is licensed under the terms described in the LICENSE file in
# the root directory of this source tree. # the root directory of this source tree.
import json
import ssl import ssl
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from asyncio import Lock from asyncio import Lock
from enum import Enum from pathlib import Path
from typing import Any
from urllib.parse import parse_qs from urllib.parse import parse_qs
import httpx import httpx
@ -18,7 +16,7 @@ from jose import jwt
from pydantic import BaseModel, Field, field_validator, model_validator from pydantic import BaseModel, Field, field_validator, model_validator
from typing_extensions import Self from typing_extensions import Self
from llama_stack.distribution.datatypes import AccessAttributes from llama_stack.distribution.datatypes import AccessAttributes, AuthenticationConfig, AuthProviderType
from llama_stack.log import get_logger from llama_stack.log import get_logger
logger = get_logger(name=__name__, category="auth") logger = get_logger(name=__name__, category="auth")
@ -76,21 +74,6 @@ class AuthRequest(BaseModel):
request: AuthRequestContext = Field(description="Context information about the request being authenticated") request: AuthRequestContext = Field(description="Context information about the request being authenticated")
class AuthProviderType(str, Enum):
"""Supported authentication provider types."""
KUBERNETES = "kubernetes"
CUSTOM = "custom"
OAUTH2_TOKEN = "oauth2_token"
class AuthProviderConfig(BaseModel):
"""Base configuration for authentication providers."""
provider_type: AuthProviderType = Field(..., description="Type of authentication provider")
config: dict[str, Any] = Field(..., description="Provider-specific configuration")
class AuthProvider(ABC): class AuthProvider(ABC):
"""Abstract base class for authentication providers.""" """Abstract base class for authentication providers."""
@ -105,83 +88,6 @@ class AuthProvider(ABC):
pass pass
class KubernetesAuthProviderConfig(BaseModel):
api_server_url: str
ca_cert_path: str | None = None
class KubernetesAuthProvider(AuthProvider):
"""Kubernetes authentication provider that validates tokens against the Kubernetes API server."""
def __init__(self, config: KubernetesAuthProviderConfig):
self.config = config
self._client = None
async def _get_client(self):
"""Get or create a Kubernetes client."""
if self._client is None:
# kubernetes-client has not async support, see:
# https://github.com/kubernetes-client/python/issues/323
from kubernetes import client
from kubernetes.client import ApiClient
# Configure the client
configuration = client.Configuration()
configuration.host = self.config.api_server_url
if self.config.ca_cert_path:
configuration.ssl_ca_cert = self.config.ca_cert_path
configuration.verify_ssl = bool(self.config.ca_cert_path)
# Create API client
self._client = ApiClient(configuration)
return self._client
async def validate_token(self, token: str, scope: dict | None = None) -> TokenValidationResult:
"""Validate a Kubernetes token and return access attributes."""
try:
client = await self._get_client()
# Set the token in the client
client.set_default_header("Authorization", f"Bearer {token}")
# Make a request to validate the token
# We use the /api endpoint which requires authentication
from kubernetes.client import CoreV1Api
api = CoreV1Api(client)
api.get_api_resources(_request_timeout=3.0) # Set timeout for this specific request
# If we get here, the token is valid
# Extract user info from the token claims
import base64
# Decode the token (without verification since we've already validated it)
token_parts = token.split(".")
payload = json.loads(base64.b64decode(token_parts[1] + "=" * (-len(token_parts[1]) % 4)))
# Extract user information from the token
username = payload.get("sub", "")
groups = payload.get("groups", [])
return TokenValidationResult(
principal=username,
access_attributes=AccessAttributes(
roles=[username], # Use username as a role
teams=groups, # Use Kubernetes groups as teams
),
)
except Exception as e:
logger.exception("Failed to validate Kubernetes token")
raise ValueError("Invalid or expired token") from e
async def close(self):
"""Close the HTTP client."""
if self._client:
self._client.close()
self._client = None
def get_attributes_from_claims(claims: dict[str, str], mapping: dict[str, str]) -> AccessAttributes: def get_attributes_from_claims(claims: dict[str, str], mapping: dict[str, str]) -> AccessAttributes:
attributes = AccessAttributes() attributes = AccessAttributes()
for claim_key, attribute_key in mapping.items(): for claim_key, attribute_key in mapping.items():
@ -212,11 +118,13 @@ class OAuth2IntrospectionConfig(BaseModel):
client_id: str client_id: str
client_secret: str client_secret: str
send_secret_in_body: bool = False send_secret_in_body: bool = False
tls_cafile: str | None = None
class OAuth2TokenAuthProviderConfig(BaseModel): class OAuth2TokenAuthProviderConfig(BaseModel):
audience: str = "llama-stack" audience: str = "llama-stack"
verify_tls: bool = True
tls_cafile: Path | None = None
issuer: str | None = Field(default=None, description="The OIDC issuer URL.")
claims_mapping: dict[str, str] = Field( claims_mapping: dict[str, str] = Field(
default_factory=lambda: { default_factory=lambda: {
"sub": "roles", "sub": "roles",
@ -265,16 +173,14 @@ class OAuth2TokenAuthProvider(AuthProvider):
async def validate_token(self, token: str, scope: dict | None = None) -> TokenValidationResult: async def validate_token(self, token: str, scope: dict | None = None) -> TokenValidationResult:
if self.config.jwks: if self.config.jwks:
return await self.validate_jwt_token(token, self.config.jwks, scope) return await self.validate_jwt_token(token, scope)
if self.config.introspection: if self.config.introspection:
return await self.introspect_token(token, self.config.introspection, scope) return await self.introspect_token(token, scope)
raise ValueError("One of jwks or introspection must be configured") raise ValueError("One of jwks or introspection must be configured")
async def validate_jwt_token( async def validate_jwt_token(self, token: str, scope: dict | None = None) -> TokenValidationResult:
self, token: str, config: OAuth2JWKSConfig, scope: dict | None = None
) -> TokenValidationResult:
"""Validate a token using the JWT token.""" """Validate a token using the JWT token."""
await self._refresh_jwks(config) await self._refresh_jwks()
try: try:
header = jwt.get_unverified_header(token) header = jwt.get_unverified_header(token)
@ -288,7 +194,7 @@ class OAuth2TokenAuthProvider(AuthProvider):
key_data, key_data,
algorithms=[algorithm], algorithms=[algorithm],
audience=self.config.audience, audience=self.config.audience,
options={"verify_exp": True}, issuer=self.config.issuer,
) )
except Exception as exc: except Exception as exc:
raise ValueError(f"Invalid JWT token: {token}") from exc raise ValueError(f"Invalid JWT token: {token}") from exc
@ -302,26 +208,27 @@ class OAuth2TokenAuthProvider(AuthProvider):
access_attributes=access_attributes, access_attributes=access_attributes,
) )
async def introspect_token( async def introspect_token(self, token: str, scope: dict | None = None) -> TokenValidationResult:
self, token: str, config: OAuth2IntrospectionConfig, scope: dict | None = None
) -> TokenValidationResult:
"""Validate a token using token introspection as defined by RFC 7662.""" """Validate a token using token introspection as defined by RFC 7662."""
form = { form = {
"token": token, "token": token,
} }
if config.send_secret_in_body: if self.config.introspection is None:
form["client_id"] = config.client_id raise ValueError("Introspection is not configured")
form["client_secret"] = config.client_secret
if self.config.introspection.send_secret_in_body:
form["client_id"] = self.config.introspection.client_id
form["client_secret"] = self.config.introspection.client_secret
auth = None auth = None
else: else:
auth = (config.client_id, config.client_secret) auth = (self.config.introspection.client_id, self.config.introspection.client_secret)
ssl_ctxt = None ssl_ctxt = None
if config.tls_cafile: if self.config.tls_cafile:
ssl_ctxt = ssl.create_default_context(cafile=config.tls_cafile) ssl_ctxt = ssl.create_default_context(cafile=self.config.tls_cafile.as_posix())
try: try:
async with httpx.AsyncClient(verify=ssl_ctxt) as client: async with httpx.AsyncClient(verify=ssl_ctxt) as client:
response = await client.post( response = await client.post(
config.url, self.config.introspection.url,
data=form, data=form,
auth=auth, auth=auth,
timeout=10.0, # Add a reasonable timeout timeout=10.0, # Add a reasonable timeout
@ -352,11 +259,24 @@ class OAuth2TokenAuthProvider(AuthProvider):
async def close(self): async def close(self):
pass pass
async def _refresh_jwks(self, config: OAuth2JWKSConfig) -> None: async def _refresh_jwks(self) -> None:
"""
Refresh the JWKS cache.
This is a simple cache that expires after a certain amount of time (defined by `cache_ttl`).
If the cache is expired, we refresh the JWKS from the JWKS URI.
Notes: for Kubernetes which doesn't fully implement the OIDC protocol:
* It doesn't have user authentication flows
* It doesn't have refresh tokens
"""
async with self._jwks_lock: async with self._jwks_lock:
if time.time() - self._jwks_at > config.cache_ttl: if self.config.jwks is None:
async with httpx.AsyncClient() as client: raise ValueError("JWKS is not configured")
res = await client.get(config.uri, timeout=5) if time.time() - self._jwks_at > self.config.jwks.cache_ttl:
verify = self.config.tls_cafile.as_posix() if self.config.tls_cafile else self.config.verify_tls
async with httpx.AsyncClient(verify=verify) as client:
res = await client.get(self.config.jwks.uri, timeout=5)
res.raise_for_status() res.raise_for_status()
jwks_data = res.json()["keys"] jwks_data = res.json()["keys"]
updated = {} updated = {}
@ -443,13 +363,11 @@ class CustomAuthProvider(AuthProvider):
self._client = None self._client = None
def create_auth_provider(config: AuthProviderConfig) -> AuthProvider: def create_auth_provider(config: AuthenticationConfig) -> AuthProvider:
"""Factory function to create the appropriate auth provider.""" """Factory function to create the appropriate auth provider."""
provider_type = config.provider_type.lower() provider_type = config.provider_type.lower()
if provider_type == "kubernetes": if provider_type == "custom":
return KubernetesAuthProvider(KubernetesAuthProviderConfig.model_validate(config.config))
elif provider_type == "custom":
return CustomAuthProvider(CustomAuthProviderConfig.model_validate(config.config)) return CustomAuthProvider(CustomAuthProviderConfig.model_validate(config.config))
elif provider_type == "oauth2_token": elif provider_type == "oauth2_token":
return OAuth2TokenAuthProvider(OAuth2TokenAuthProviderConfig.model_validate(config.config)) return OAuth2TokenAuthProvider(OAuth2TokenAuthProviderConfig.model_validate(config.config))

View file

@ -40,7 +40,6 @@ dependencies = [
"tiktoken", "tiktoken",
"pillow", "pillow",
"h11>=0.16.0", "h11>=0.16.0",
"kubernetes",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View file

@ -4,19 +4,16 @@ annotated-types==0.7.0
anyio==4.8.0 anyio==4.8.0
attrs==25.1.0 attrs==25.1.0
blobfile==3.0.0 blobfile==3.0.0
cachetools==5.5.2
certifi==2025.1.31 certifi==2025.1.31
charset-normalizer==3.4.1 charset-normalizer==3.4.1
click==8.1.8 click==8.1.8
colorama==0.4.6 ; sys_platform == 'win32' colorama==0.4.6 ; sys_platform == 'win32'
distro==1.9.0 distro==1.9.0
durationpy==0.9
ecdsa==0.19.1 ecdsa==0.19.1
exceptiongroup==1.2.2 ; python_full_version < '3.11' exceptiongroup==1.2.2 ; python_full_version < '3.11'
filelock==3.17.0 filelock==3.17.0
fire==0.7.0 fire==0.7.0
fsspec==2024.12.0 fsspec==2024.12.0
google-auth==2.38.0
h11==0.16.0 h11==0.16.0
httpcore==1.0.9 httpcore==1.0.9
httpx==0.28.1 httpx==0.28.1
@ -26,14 +23,12 @@ jinja2==3.1.6
jiter==0.8.2 jiter==0.8.2
jsonschema==4.23.0 jsonschema==4.23.0
jsonschema-specifications==2024.10.1 jsonschema-specifications==2024.10.1
kubernetes==32.0.1
llama-stack-client==0.2.7 llama-stack-client==0.2.7
lxml==5.3.1 lxml==5.3.1
markdown-it-py==3.0.0 markdown-it-py==3.0.0
markupsafe==3.0.2 markupsafe==3.0.2
mdurl==0.1.2 mdurl==0.1.2
numpy==2.2.3 numpy==2.2.3
oauthlib==3.2.2
openai==1.71.0 openai==1.71.0
packaging==24.2 packaging==24.2
pandas==2.2.3 pandas==2.2.3
@ -41,7 +36,6 @@ pillow==11.1.0
prompt-toolkit==3.0.50 prompt-toolkit==3.0.50
pyaml==25.1.0 pyaml==25.1.0
pyasn1==0.4.8 pyasn1==0.4.8
pyasn1-modules==0.4.1
pycryptodomex==3.21.0 pycryptodomex==3.21.0
pydantic==2.10.6 pydantic==2.10.6
pydantic-core==2.27.2 pydantic-core==2.27.2
@ -54,7 +48,6 @@ pyyaml==6.0.2
referencing==0.36.2 referencing==0.36.2
regex==2024.11.6 regex==2024.11.6
requests==2.32.3 requests==2.32.3
requests-oauthlib==2.0.0
rich==13.9.4 rich==13.9.4
rpds-py==0.22.3 rpds-py==0.22.3
rsa==4.9 rsa==4.9
@ -68,4 +61,3 @@ typing-extensions==4.12.2
tzdata==2025.1 tzdata==2025.1
urllib3==2.3.0 urllib3==2.3.0
wcwidth==0.2.13 wcwidth==0.2.13
websocket-client==1.8.0

View file

@ -11,12 +11,10 @@ import pytest
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from llama_stack.distribution.datatypes import AccessAttributes from llama_stack.distribution.datatypes import AuthenticationConfig
from llama_stack.distribution.server.auth import AuthenticationMiddleware from llama_stack.distribution.server.auth import AuthenticationMiddleware
from llama_stack.distribution.server.auth_providers import ( from llama_stack.distribution.server.auth_providers import (
AuthProviderConfig,
AuthProviderType, AuthProviderType,
TokenValidationResult,
get_attributes_from_claims, get_attributes_from_claims,
) )
@ -62,7 +60,7 @@ def invalid_token():
@pytest.fixture @pytest.fixture
def http_app(mock_auth_endpoint): def http_app(mock_auth_endpoint):
app = FastAPI() app = FastAPI()
auth_config = AuthProviderConfig( auth_config = AuthenticationConfig(
provider_type=AuthProviderType.CUSTOM, provider_type=AuthProviderType.CUSTOM,
config={"endpoint": mock_auth_endpoint}, config={"endpoint": mock_auth_endpoint},
) )
@ -78,7 +76,7 @@ def http_app(mock_auth_endpoint):
@pytest.fixture @pytest.fixture
def k8s_app(): def k8s_app():
app = FastAPI() app = FastAPI()
auth_config = AuthProviderConfig( auth_config = AuthenticationConfig(
provider_type=AuthProviderType.KUBERNETES, provider_type=AuthProviderType.KUBERNETES,
config={"api_server_url": "https://kubernetes.default.svc"}, config={"api_server_url": "https://kubernetes.default.svc"},
) )
@ -118,7 +116,7 @@ def mock_scope():
@pytest.fixture @pytest.fixture
def mock_http_middleware(mock_auth_endpoint): def mock_http_middleware(mock_auth_endpoint):
mock_app = AsyncMock() mock_app = AsyncMock()
auth_config = AuthProviderConfig( auth_config = AuthenticationConfig(
provider_type=AuthProviderType.CUSTOM, provider_type=AuthProviderType.CUSTOM,
config={"endpoint": mock_auth_endpoint}, config={"endpoint": mock_auth_endpoint},
) )
@ -128,7 +126,7 @@ def mock_http_middleware(mock_auth_endpoint):
@pytest.fixture @pytest.fixture
def mock_k8s_middleware(): def mock_k8s_middleware():
mock_app = AsyncMock() mock_app = AsyncMock()
auth_config = AuthProviderConfig( auth_config = AuthenticationConfig(
provider_type=AuthProviderType.KUBERNETES, provider_type=AuthProviderType.KUBERNETES,
config={"api_server_url": "https://kubernetes.default.svc"}, config={"api_server_url": "https://kubernetes.default.svc"},
) )
@ -284,116 +282,13 @@ async def test_http_middleware_no_attributes(mock_http_middleware, mock_scope):
assert attributes["roles"] == ["test.jwt.token"] assert attributes["roles"] == ["test.jwt.token"]
# Kubernetes Tests
def test_missing_auth_header_k8s(k8s_client):
response = k8s_client.get("/test")
assert response.status_code == 401
assert "Missing or invalid Authorization header" in response.json()["error"]["message"]
def test_invalid_auth_header_format_k8s(k8s_client):
response = k8s_client.get("/test", headers={"Authorization": "InvalidFormat token123"})
assert response.status_code == 401
assert "Missing or invalid Authorization header" in response.json()["error"]["message"]
@patch("kubernetes.client.ApiClient")
def test_valid_k8s_authentication(mock_api_client, k8s_client, valid_token):
# Mock the Kubernetes client
mock_client = AsyncMock()
mock_api_client.return_value = mock_client
# Mock successful token validation
mock_client.set_default_header = AsyncMock()
# Mock the token validation to return valid access attributes
with patch("llama_stack.distribution.server.auth_providers.KubernetesAuthProvider.validate_token") as mock_validate:
mock_validate.return_value = TokenValidationResult(
principal="test-principal",
access_attributes=AccessAttributes(
roles=["admin"], teams=["ml-team"], projects=["llama-3"], namespaces=["research"]
),
)
response = k8s_client.get("/test", headers={"Authorization": f"Bearer {valid_token}"})
assert response.status_code == 200
assert response.json() == {"message": "Authentication successful"}
@patch("kubernetes.client.ApiClient")
def test_invalid_k8s_authentication(mock_api_client, k8s_client, invalid_token):
# Mock the Kubernetes client
mock_client = AsyncMock()
mock_api_client.return_value = mock_client
# Mock failed token validation by raising an exception
with patch("llama_stack.distribution.server.auth_providers.KubernetesAuthProvider.validate_token") as mock_validate:
mock_validate.side_effect = ValueError("Invalid or expired token")
response = k8s_client.get("/test", headers={"Authorization": f"Bearer {invalid_token}"})
assert response.status_code == 401
assert "Invalid or expired token" in response.json()["error"]["message"]
@pytest.mark.asyncio
async def test_k8s_middleware_with_access_attributes(mock_k8s_middleware, mock_scope):
middleware, mock_app = mock_k8s_middleware
mock_receive = AsyncMock()
mock_send = AsyncMock()
with patch("kubernetes.client.ApiClient") as mock_api_client:
mock_client = AsyncMock()
mock_api_client.return_value = mock_client
# Mock successful token validation
mock_client.set_default_header = AsyncMock()
# Mock token payload with access attributes
mock_token_parts = ["header", "eyJzdWIiOiJhZG1pbiIsImdyb3VwcyI6WyJtbC10ZWFtIl19", "signature"]
mock_scope["headers"][1] = (b"authorization", f"Bearer {'.'.join(mock_token_parts)}".encode())
await middleware(mock_scope, mock_receive, mock_send)
assert "user_attributes" in mock_scope
assert mock_scope["user_attributes"]["roles"] == ["admin"]
assert mock_scope["user_attributes"]["teams"] == ["ml-team"]
mock_app.assert_called_once_with(mock_scope, mock_receive, mock_send)
@pytest.mark.asyncio
async def test_k8s_middleware_no_attributes(mock_k8s_middleware, mock_scope):
"""Test middleware behavior with no access attributes"""
middleware, mock_app = mock_k8s_middleware
mock_receive = AsyncMock()
mock_send = AsyncMock()
with patch("kubernetes.client.ApiClient") as mock_api_client:
mock_client = AsyncMock()
mock_api_client.return_value = mock_client
# Mock successful token validation
mock_client.set_default_header = AsyncMock()
# Mock token payload without access attributes
mock_token_parts = ["header", "eyJzdWIiOiJhZG1pbiJ9", "signature"]
mock_scope["headers"][1] = (b"authorization", f"Bearer {'.'.join(mock_token_parts)}".encode())
await middleware(mock_scope, mock_receive, mock_send)
assert "user_attributes" in mock_scope
attributes = mock_scope["user_attributes"]
assert "roles" in attributes
assert attributes["roles"] == ["admin"]
mock_app.assert_called_once_with(mock_scope, mock_receive, mock_send)
# oauth2 token provider tests # oauth2 token provider tests
@pytest.fixture @pytest.fixture
def oauth2_app(): def oauth2_app():
app = FastAPI() app = FastAPI()
auth_config = AuthProviderConfig( auth_config = AuthenticationConfig(
provider_type=AuthProviderType.OAUTH2_TOKEN, provider_type=AuthProviderType.OAUTH2_TOKEN,
config={ config={
"jwks": { "jwks": {
@ -530,7 +425,7 @@ def mock_introspection_endpoint():
@pytest.fixture @pytest.fixture
def introspection_app(mock_introspection_endpoint): def introspection_app(mock_introspection_endpoint):
app = FastAPI() app = FastAPI()
auth_config = AuthProviderConfig( auth_config = AuthenticationConfig(
provider_type=AuthProviderType.OAUTH2_TOKEN, provider_type=AuthProviderType.OAUTH2_TOKEN,
config={ config={
"jwks": None, "jwks": None,
@ -549,7 +444,7 @@ def introspection_app(mock_introspection_endpoint):
@pytest.fixture @pytest.fixture
def introspection_app_with_custom_mapping(mock_introspection_endpoint): def introspection_app_with_custom_mapping(mock_introspection_endpoint):
app = FastAPI() app = FastAPI()
auth_config = AuthProviderConfig( auth_config = AuthenticationConfig(
provider_type=AuthProviderType.OAUTH2_TOKEN, provider_type=AuthProviderType.OAUTH2_TOKEN,
config={ config={
"jwks": None, "jwks": None,

98
uv.lock generated
View file

@ -676,15 +676,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408 }, { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408 },
] ]
[[package]]
name = "durationpy"
version = "0.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/31/e9/f49c4e7fccb77fa5c43c2480e09a857a78b41e7331a75e128ed5df45c56b/durationpy-0.9.tar.gz", hash = "sha256:fd3feb0a69a0057d582ef643c355c40d2fa1c942191f914d12203b1a01ac722a", size = 3186 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/a3/ac312faeceffd2d8f86bc6dcb5c401188ba5a01bc88e69bed97578a0dfcd/durationpy-0.9-py3-none-any.whl", hash = "sha256:e65359a7af5cedad07fb77a2dd3f390f8eb0b74cb845589fa6c057086834dd38", size = 3461 },
]
[[package]] [[package]]
name = "ecdsa" name = "ecdsa"
version = "0.19.1" version = "0.19.1"
@ -863,20 +854,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/9a/4114a9057db2f1462d5c8f8390ab7383925fe1ac012eaa42402ad65c2963/GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110", size = 207599 }, { url = "https://files.pythonhosted.org/packages/1d/9a/4114a9057db2f1462d5c8f8390ab7383925fe1ac012eaa42402ad65c2963/GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110", size = 207599 },
] ]
[[package]]
name = "google-auth"
version = "2.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cachetools" },
{ name = "pyasn1-modules" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/eb/d504ba1daf190af6b204a9d4714d457462b486043744901a6eeea711f913/google_auth-2.38.0.tar.gz", hash = "sha256:8285113607d3b80a3f1543b75962447ba8a09fe85783432a784fdeef6ac094c4", size = 270866 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/47/603554949a37bca5b7f894d51896a9c534b9eab808e2520a748e081669d0/google_auth-2.38.0-py2.py3-none-any.whl", hash = "sha256:e7dae6694313f434a2727bf2906f27ad259bae090d7aa896590d86feec3d9d4a", size = 210770 },
]
[[package]] [[package]]
name = "googleapis-common-protos" name = "googleapis-common-protos"
version = "1.67.0" version = "1.67.0"
@ -1324,28 +1301,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/fb/108ecd1fe961941959ad0ee4e12ee7b8b1477247f30b1fdfd83ceaf017f0/jupyter_core-5.7.2-py3-none-any.whl", hash = "sha256:4f7315d2f6b4bcf2e3e7cb6e46772eba760ae459cd1f59d29eb57b0a01bd7409", size = 28965 }, { url = "https://files.pythonhosted.org/packages/c9/fb/108ecd1fe961941959ad0ee4e12ee7b8b1477247f30b1fdfd83ceaf017f0/jupyter_core-5.7.2-py3-none-any.whl", hash = "sha256:4f7315d2f6b4bcf2e3e7cb6e46772eba760ae459cd1f59d29eb57b0a01bd7409", size = 28965 },
] ]
[[package]]
name = "kubernetes"
version = "32.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "durationpy" },
{ name = "google-auth" },
{ name = "oauthlib" },
{ name = "python-dateutil" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "requests-oauthlib" },
{ name = "six" },
{ name = "urllib3" },
{ name = "websocket-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b7/e8/0598f0e8b4af37cd9b10d8b87386cf3173cb8045d834ab5f6ec347a758b3/kubernetes-32.0.1.tar.gz", hash = "sha256:42f43d49abd437ada79a79a16bd48a604d3471a117a8347e87db693f2ba0ba28", size = 946691 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/10/9f8af3e6f569685ce3af7faab51c8dd9d93b9c38eba339ca31c746119447/kubernetes-32.0.1-py2.py3-none-any.whl", hash = "sha256:35282ab8493b938b08ab5526c7ce66588232df00ef5e1dbe88a419107dc10998", size = 1988070 },
]
[[package]] [[package]]
name = "levenshtein" name = "levenshtein"
version = "0.27.1" version = "0.27.1"
@ -1441,7 +1396,6 @@ dependencies = [
{ name = "huggingface-hub" }, { name = "huggingface-hub" },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "jsonschema" }, { name = "jsonschema" },
{ name = "kubernetes" },
{ name = "llama-stack-client" }, { name = "llama-stack-client" },
{ name = "openai" }, { name = "openai" },
{ name = "pillow" }, { name = "pillow" },
@ -1546,7 +1500,6 @@ requires-dist = [
{ name = "jinja2", specifier = ">=3.1.6" }, { name = "jinja2", specifier = ">=3.1.6" },
{ name = "jinja2", marker = "extra == 'codegen'", specifier = ">=3.1.6" }, { name = "jinja2", marker = "extra == 'codegen'", specifier = ">=3.1.6" },
{ name = "jsonschema" }, { name = "jsonschema" },
{ name = "kubernetes" },
{ name = "llama-stack-client", specifier = ">=0.2.7" }, { name = "llama-stack-client", specifier = ">=0.2.7" },
{ name = "llama-stack-client", marker = "extra == 'ui'", specifier = ">=0.2.7" }, { name = "llama-stack-client", marker = "extra == 'ui'", specifier = ">=0.2.7" },
{ name = "mcp", marker = "extra == 'test'" }, { name = "mcp", marker = "extra == 'test'" },
@ -1624,9 +1577,9 @@ dependencies = [
{ name = "tqdm" }, { name = "tqdm" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/cd/6b/31c07396c5b3010668e4eb38061a96ffacb47ec4b14d8aeb64c13856c485/llama_stack_client-0.2.7.tar.gz", hash = "sha256:11aee11fdd5e0e8caad07c0cce9c4d88640938844372e7e3453a91ea0757fcb3", size = 259273, upload-time = "2025-05-16T20:31:39.221Z" } sdist = { url = "https://files.pythonhosted.org/packages/cd/6b/31c07396c5b3010668e4eb38061a96ffacb47ec4b14d8aeb64c13856c485/llama_stack_client-0.2.7.tar.gz", hash = "sha256:11aee11fdd5e0e8caad07c0cce9c4d88640938844372e7e3453a91ea0757fcb3", size = 259273 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/69/6a5f4683afe355500df4376fdcbfb2fc1e6a0c3bcea5ff8f6114773a9acf/llama_stack_client-0.2.7-py3-none-any.whl", hash = "sha256:78b3f2abdb1770c7b1270a9c0ef58402a988401c564d2e6c83588779ac6fc38d", size = 292727, upload-time = "2025-05-16T20:31:37.587Z" }, { url = "https://files.pythonhosted.org/packages/ac/69/6a5f4683afe355500df4376fdcbfb2fc1e6a0c3bcea5ff8f6114773a9acf/llama_stack_client-0.2.7-py3-none-any.whl", hash = "sha256:78b3f2abdb1770c7b1270a9c0ef58402a988401c564d2e6c83588779ac6fc38d", size = 292727 },
] ]
[[package]] [[package]]
@ -2087,15 +2040,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/17/7f/d322a4125405920401450118dbdc52e0384026bd669939484670ce8b2ab9/numpy-2.2.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:783145835458e60fa97afac25d511d00a1eca94d4a8f3ace9fe2043003c678e4", size = 12839607 }, { url = "https://files.pythonhosted.org/packages/17/7f/d322a4125405920401450118dbdc52e0384026bd669939484670ce8b2ab9/numpy-2.2.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:783145835458e60fa97afac25d511d00a1eca94d4a8f3ace9fe2043003c678e4", size = 12839607 },
] ]
[[package]]
name = "oauthlib"
version = "3.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/fa/fbf4001037904031639e6bfbfc02badfc7e12f137a8afa254df6c4c8a670/oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918", size = 177352 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/80/cab10959dc1faead58dc8384a781dfbf93cb4d33d50988f7a69f1b7c9bbe/oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca", size = 151688 },
]
[[package]] [[package]]
name = "openai" name = "openai"
version = "1.71.0" version = "1.71.0"
@ -2608,18 +2552,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/1e/a94a8d635fa3ce4cfc7f506003548d0a2447ae76fd5ca53932970fe3053f/pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", size = 77145 }, { url = "https://files.pythonhosted.org/packages/62/1e/a94a8d635fa3ce4cfc7f506003548d0a2447ae76fd5ca53932970fe3053f/pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", size = 77145 },
] ]
[[package]]
name = "pyasn1-modules"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/67/6afbf0d507f73c32d21084a79946bfcfca5fbc62a72057e9c23797a737c9/pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c", size = 310028 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/89/bc88a6711935ba795a679ea6ebee07e128050d6382eaa35a0a47c8032bdc/pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd", size = 181537 },
]
[[package]] [[package]]
name = "pycparser" name = "pycparser"
version = "2.22" version = "2.22"
@ -2875,9 +2807,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pytest" }, { name = "pytest" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382 },
] ]
[[package]] [[package]]
@ -3256,19 +3188,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
] ]
[[package]]
name = "requests-oauthlib"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "oauthlib" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 },
]
[[package]] [[package]]
name = "rich" name = "rich"
version = "13.9.4" version = "13.9.4"
@ -4323,15 +4242,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 },
] ]
[[package]]
name = "websocket-client"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826 },
]
[[package]] [[package]]
name = "websockets" name = "websockets"
version = "15.0" version = "15.0"