Merge branch '116-scim' into 'main'

Implement SCIM API

Closes #116

See merge request yaal/canaille!197
This commit is contained in:
Éloi Rivard 2024-12-11 16:46:39 +00:00
commit 24a41af54d
21 changed files with 3237 additions and 2000 deletions

View file

@ -46,6 +46,11 @@ def setup_blueprints(app):
app.register_blueprint(canaille.oidc.endpoints.bp)
if "CANAILLE_SCIM" in app.config and app.config["CANAILLE_SCIM"]["ENABLE_SERVER"]:
import canaille.scim.endpoints
app.register_blueprint(canaille.scim.endpoints.bp)
def setup_flask(app):
csrf.init_app(app)

View file

@ -103,6 +103,13 @@ def settings_factory(config, env_file=None, env_prefix=""):
attributes["CANAILLE_OIDC"] = ((OIDCSettings | None), None)
if "CANAILLE_SCIM" in config or any(
var.startswith("CANAILLE_SCIM__") for var in os.environ
):
from canaille.scim.configuration import SCIMSettings
attributes["CANAILLE_SCIM"] = ((SCIMSettings | None), None)
Settings = create_model(
"Settings",
__base__=RootSettings,

View file

@ -40,6 +40,14 @@ if flask_themer:
@app.errorhandler(404)
def page_not_found(error):
# There is currently no way to make 404 handling generic
# https://flask.palletsprojects.com/en/stable/errorhandling/#handling
# However, the blueprint cannot handle 404 routing errors because the
# 404 occurs at the routing level before the blueprint can be determined.
if flask.request.path.startswith("/scim/"):
from canaille.scim.endpoints import http_error_handler
return http_error_handler(error)
return render_template("error.html", description=error, error_code=404), 404
@app.errorhandler(500)

View file

@ -10,6 +10,9 @@ from canaille.backends import Backend
def listify(value):
if value is None:
return []
return value if isinstance(value, list) else [value]

View file

View file

@ -0,0 +1,7 @@
from pydantic import BaseModel
class SCIMSettings(BaseModel):
"""SCIM settings."""
ENABLE_SERVER: bool = True

309
canaille/scim/endpoints.py Normal file
View file

@ -0,0 +1,309 @@
import json
from http import HTTPStatus
from authlib.integrations.flask_oauth2 import ResourceProtector
from authlib.integrations.flask_oauth2.errors import (
_HTTPException as AuthlibHTTPException,
)
from authlib.oauth2.rfc6750 import BearerTokenValidator
from flask import Blueprint
from flask import Response
from flask import abort
from flask import request
from pydantic import ValidationError
from scim2_models import Context
from scim2_models import EnterpriseUser
from scim2_models import Error
from scim2_models import ListResponse
from scim2_models import ResourceType
from scim2_models import Schema
from scim2_models import SearchRequest
from werkzeug.exceptions import HTTPException
from canaille import csrf
from canaille.app import models
from canaille.backends import Backend
from .models import Group
from .models import User
from .models import get_resource_types
from .models import get_schemas
from .models import get_service_provider_config
from .models import group_from_canaille_to_scim
from .models import group_from_scim_to_canaille
from .models import user_from_canaille_to_scim
from .models import user_from_scim_to_canaille
bp = Blueprint("scim", __name__, url_prefix="/scim/v2")
class SCIMBearerTokenValidator(BearerTokenValidator):
def authenticate_token(self, token_string: str):
token = Backend.instance.get(models.Token, access_token=token_string)
# At the moment, only client tokens are allowed, and not user tokens
return token if token and not token.subject else None
require_oauth = ResourceProtector()
require_oauth.register_token_validator(SCIMBearerTokenValidator())
@bp.after_request
def add_scim_content_type(response):
response.headers["Content-Type"] = "application/scim+json"
return response
@bp.errorhandler(HTTPException)
def http_error_handler(error):
obj = Error(detail=str(error), status=error.code)
return obj.model_dump(), obj.status
@bp.errorhandler(AuthlibHTTPException)
def oauth2_error(error):
body = json.loads(error.body)
obj = Error(
detail=f"{body['error']}: {body['error_description']}"
if "error_description" in body
else body["error"],
status=error.code,
)
return obj.model_dump(), error.code
@bp.errorhandler(ValidationError)
def scim_error_handler(error):
error_details = error.errors()[0]
obj = Error(status=400, detail=error_details["msg"])
# TODO: maybe the Pydantic <=> SCIM error code mapping could go in scim2_models
obj.scim_type = (
"invalidValue" if error_details["type"] == "required_error" else None
)
return obj.model_dump(), obj.status
def parse_search_request(request) -> SearchRequest:
"""Create a SearchRequest object from the request arguments."""
max_nb_items_per_page = 1000
count = (
min(request.args["count"], max_nb_items_per_page)
if request.args.get("count")
else None
)
req = SearchRequest(
attributes=request.args.get("attributes"),
excluded_attributes=request.args.get("excludedAttributes"),
start_index=request.args.get("startIndex"),
count=count,
)
return req
@bp.route("/Users", methods=["GET"])
@csrf.exempt
@require_oauth()
def query_users():
req = parse_search_request(request)
users = list(
Backend.instance.query(models.User)[req.start_index_0 : req.stop_index_0]
)
total = len(users)
scim_users = [user_from_canaille_to_scim(user) for user in users]
list_response = ListResponse[User[EnterpriseUser]](
start_index=req.start_index,
items_per_page=req.count,
total_results=total,
resources=scim_users,
)
payload = list_response.model_dump(
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
)
return payload
@bp.route("/Users/<user:user>", methods=["GET"])
@csrf.exempt
@require_oauth()
def query_user(user):
scim_user = user_from_canaille_to_scim(user)
return scim_user.model_dump(
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
)
@bp.route("/Groups", methods=["GET"])
@csrf.exempt
@require_oauth()
def query_groups():
req = parse_search_request(request)
groups = list(
Backend.instance.query(models.group)[req.start_index_0 : req.stop_index_0]
)
total = len(groups)
scim_groups = [group_from_canaille_to_scim(group) for group in groups]
list_response = ListResponse[Group](
start_index=req.start_index,
items_per_page=req.count,
total_results=total,
resources=scim_groups,
)
payload = list_response.model_dump(
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
)
return payload
@bp.route("/Groups/<group:group>", methods=["GET"])
@csrf.exempt
@require_oauth()
def query_group(group):
scim_group = group_from_canaille_to_scim(group)
return scim_group.model_dump(
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
)
@bp.route("/Schemas", methods=["GET"])
@csrf.exempt
@require_oauth()
def query_schemas():
req = parse_search_request(request)
schemas = list(get_schemas().values())[req.start_index_0 : req.stop_index_0]
response = ListResponse[Schema](
total_results=len(schemas),
items_per_page=req.count or len(schemas),
start_index=req.start_index,
resources=schemas,
)
return response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
@bp.route("/Schemas/<string:schema_id>", methods=["GET"])
@csrf.exempt
@require_oauth()
def query_schema(schema_id):
schema = get_schemas().get(schema_id)
if not schema:
abort(404)
return schema.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
@bp.route("/ResourceTypes", methods=["GET"])
@csrf.exempt
@require_oauth()
def query_resource_types():
req = parse_search_request(request)
resource_types = list(get_resource_types().values())[
req.start_index_0 : req.stop_index_0
]
response = ListResponse[ResourceType](
total_results=len(resource_types),
items_per_page=req.count or len(resource_types),
start_index=req.start_index,
resources=resource_types,
)
return response.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
@bp.route("/ResourceTypes/<string:resource_type_name>", methods=["GET"])
@csrf.exempt
@require_oauth()
def query_resource_type(resource_type_name):
resource_type = get_resource_types().get(resource_type_name)
if not resource_type:
abort(404)
return resource_type.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
@bp.route("/ServiceProviderConfig", methods=["GET"])
@csrf.exempt
@require_oauth()
def query_service_provider_config():
spc = get_service_provider_config()
return spc.model_dump(scim_ctx=Context.RESOURCE_QUERY_RESPONSE)
@bp.route("/Users", methods=["POST"])
@csrf.exempt
@require_oauth()
def create_user():
request_user = User[EnterpriseUser].model_validate(
request.json, scim_ctx=Context.RESOURCE_CREATION_REQUEST
)
user = user_from_scim_to_canaille(request_user, models.User())
Backend.instance.save(user)
response_user = user_from_canaille_to_scim(user)
payload = response_user.model_dump_json(scim_ctx=Context.RESOURCE_CREATION_RESPONSE)
return Response(payload, status=HTTPStatus.CREATED)
@bp.route("/Groups", methods=["POST"])
@csrf.exempt
@require_oauth()
def create_group():
request_group = Group.model_validate(
request.json, scim_ctx=Context.RESOURCE_CREATION_REQUEST
)
group = group_from_scim_to_canaille(request_group, models.Group())
Backend.instance.save(group)
response_group = group_from_canaille_to_scim(group)
payload = response_group.model_dump_json(
scim_ctx=Context.RESOURCE_CREATION_RESPONSE
)
return Response(payload, status=HTTPStatus.CREATED)
@bp.route("/Users/<user:user>", methods=["PUT"])
@csrf.exempt
@require_oauth()
def replace_user(user):
original_scim_user = user_from_canaille_to_scim(user)
request_scim_user = User[EnterpriseUser].model_validate(
request.json,
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
original=original_scim_user,
)
updated_user = user_from_scim_to_canaille(request_scim_user, user)
Backend.instance.save(updated_user)
response_scim_user = user_from_canaille_to_scim(updated_user)
payload = response_scim_user.model_dump(
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE
)
return payload
@bp.route("/Groups/<group:group>", methods=["PUT"])
@csrf.exempt
@require_oauth()
def replace_group(group):
original_scim_group = group_from_canaille_to_scim(group)
request_scim_group = Group.model_validate(
request.json,
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
original=original_scim_group,
)
updated_group = group_from_scim_to_canaille(request_scim_group, group)
Backend.instance.save(updated_group)
response_group = group_from_canaille_to_scim(updated_group)
payload = response_group.model_dump(scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE)
return payload
@bp.route("/Users/<user:user>", methods=["DELETE"])
@csrf.exempt
@require_oauth()
def delete_user(user):
Backend.instance.delete(user)
return "", HTTPStatus.NO_CONTENT
@bp.route("/Groups/<group:group>", methods=["DELETE"])
@csrf.exempt
@require_oauth()
def delete_group(group):
Backend.instance.delete(group)
return "", HTTPStatus.NO_CONTENT

291
canaille/scim/models.py Normal file
View file

@ -0,0 +1,291 @@
from flask import url_for
from scim2_models import AuthenticationScheme
from scim2_models import Bulk
from scim2_models import ChangePassword
from scim2_models import EnterpriseUser
from scim2_models import ETag
from scim2_models import Filter
from scim2_models import Group
from scim2_models import Meta
from scim2_models import Mutability
from scim2_models import Patch
from scim2_models import Required
from scim2_models import Resource
from scim2_models import ResourceType
from scim2_models import Schema
from scim2_models import SchemaExtension
from scim2_models import ServiceProviderConfig
from scim2_models import Sort
from scim2_models import User
from canaille.app import models
from canaille.backends import Backend
# At the difference of SCIM User, Canaille User need a 'family_name'
# (because the LDAP 'sn' is mandatory) and the 'user_name'
# attribute is immutable (because it is part of the LDAP DN).
user_schema = User.to_schema()
user_schema["name"].required = Required.true
user_schema["name"]["familyName"].required = Required.true
user_schema["userName"].mutability = Mutability.immutable
User = Resource.from_schema(user_schema)
# At the difference of the SCIM Group, Canaille Group must have a display_name.
# and 'members' cannot be null.
group_schema = Group.to_schema()
group_schema["displayName"].required = Required.true
group_schema["displayName"].mutability = Mutability.immutable
group_schema["members"].required = Required.true
group_schema["members"]["value"].required = Required.true
group_schema["members"]["$ref"].required = Required.true
Group = Resource.from_schema(group_schema)
def get_service_provider_config():
return ServiceProviderConfig(
meta=Meta(
resource_type="ServiceProviderConfig",
location=url_for("scim.query_service_provider_config", _external=True),
),
documentation_uri="https://canaille.readthedocs.io",
patch=Patch(supported=False),
bulk=Bulk(supported=False, max_operations=0, max_payload_size=0),
change_password=ChangePassword(supported=True),
filter=Filter(supported=False, max_results=0),
sort=Sort(supported=False),
etag=ETag(supported=False),
authentication_schemes=[
AuthenticationScheme(
name="OAuth Bearer Token",
description="Authentication scheme using the OAuth Bearer Token Standard",
spec_uri="http://www.rfc-editor.org/info/rfc6750",
documentation_uri="https://canaille.readthedocs.io",
type="oauthbearertoken",
primary=True,
),
],
)
def get_resource_types():
"""The resource types implemented by Canaille."""
return {
"User": ResourceType(
id="User",
name="User",
endpoint=url_for("scim.query_users", _external=True),
description="User accounts",
schema_="urn:ietf:params:scim:schemas:core:2.0:User",
schema_extensions=[
SchemaExtension(
schema_="urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
required=True,
)
],
meta=Meta(
resource_type="ResourceType",
location=url_for(
"scim.query_resource_type",
resource_type_name="User",
_external=True,
),
),
),
"Group": ResourceType(
id="Group",
name="Group",
endpoint=url_for("scim.query_groups", _external=True),
description="Group management",
schema_="urn:ietf:params:scim:schemas:core:2.0:Group",
meta=Meta(
resource_type="ResourceType",
location=url_for(
"scim.query_resource_type",
resource_type_name="Group",
_external=True,
),
),
),
}
def get_schemas():
schemas = {
"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig": ServiceProviderConfig.to_schema(),
"urn:ietf:params:scim:schemas:core:2.0:ResourceType": ResourceType.to_schema(),
"urn:ietf:params:scim:schemas:core:2.0:Schema": Schema.to_schema(),
"urn:ietf:params:scim:schemas:core:2.0:User": User.to_schema(),
"urn:ietf:params:scim:schemas:core:2.0:Group": Group.to_schema(),
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": EnterpriseUser.to_schema(),
}
for schema_id, schema in schemas.items():
schema.meta = Meta(
resource_type="Schema",
location=url_for("scim.query_schema", schema_id=schema_id, _external=True),
)
return schemas
def user_from_canaille_to_scim(user):
scim_user = User[EnterpriseUser](
meta=Meta(
resource_type="User",
created=user.created,
last_modified=user.last_modified,
location=url_for("scim.query_user", user=user, _external=True),
),
id=user.id,
user_name=user.user_name,
# password=user.password,
preferred_language=user.preferred_language,
name=User.Name(
formatted=user.formatted_name,
family_name=user.family_name,
given_name=user.given_name,
)
if (user.formatted_name or user.family_name or user.given_name)
else None,
display_name=user.display_name,
title=user.title,
profile_url=user.profile_url,
emails=[
User.Emails(
value=email,
primary=email == user.emails[0],
)
for email in user.emails or []
]
or None,
phone_numbers=[
User.PhoneNumbers(
value=phone_number, primary=phone_number == user.phone_numbers[0]
)
for phone_number in user.phone_numbers or []
]
or None,
addresses=[
User.Addresses(
formatted=user.formatted_address,
street_address=user.street,
postal_code=user.postal_code,
locality=user.locality,
region=user.region,
primary=True,
)
]
if (
user.formatted_address
or user.street
or user.postal_code
or user.locality
or user.region
)
else None,
photos=[
User.Photos(
value=url_for(
"core.account.photo", user=user, field="photo", _external=True
),
primary=True,
type=User.Photos.Type.photo,
)
]
if user.photo
else None,
groups=[
User.Groups(
value=group.id,
display=group.display_name,
ref=url_for("scim.query_group", group=group, _external=True),
)
for group in user.groups or []
]
or None,
)
scim_user[EnterpriseUser] = EnterpriseUser(
employee_number=user.employee_number,
organization=user.organization,
department=user.department,
)
return scim_user
def user_from_scim_to_canaille(scim_user: User, user):
user.user_name = scim_user.user_name
user.password = scim_user.password
user.preferred_language = scim_user.preferred_language
user.formatted_name = scim_user.name.formatted if scim_user.name else None
user.family_name = scim_user.name.family_name if scim_user.name else None
user.given_name = scim_user.name.given_name if scim_user.name else None
user.display_name = scim_user.display_name
user.title = scim_user.title
user.profile_url = scim_user.profile_url
user.emails = [email.value for email in scim_user.emails or []] or None
user.phone_numbers = [
phone_number.value for phone_number in scim_user.phone_numbers or []
] or None
user.formatted_address = (
scim_user.addresses[0].formatted if scim_user.addresses else None
)
user.street = scim_user.addresses[0].street_address if scim_user.addresses else None
user.postal_code = (
scim_user.addresses[0].postal_code if scim_user.addresses else None
)
user.locality = scim_user.addresses[0].locality if scim_user.addresses else None
user.region = scim_user.addresses[0].region if scim_user.addresses else None
# TODO: delete the photo
# if scim_user.photos and scim_user.photos[0].value:
# user.photo = scim_user.photos[0].value
user.employee_number = (
scim_user[EnterpriseUser].employee_number if scim_user[EnterpriseUser] else None
)
user.organization = (
scim_user[EnterpriseUser].organization if scim_user[EnterpriseUser] else None
)
user.department = (
scim_user[EnterpriseUser].department if scim_user[EnterpriseUser] else None
)
user.groups = [
Backend.instance.get(models.Group, group.value)
for group in scim_user.groups or []
if group.value
]
return user
def group_from_canaille_to_scim(group):
return Group(
id=group.id,
meta=Meta(
resource_type="Group",
created=group.created,
last_modified=group.last_modified,
location=url_for("scim.query_group", group=group, _external=True),
),
display_name=group.display_name,
members=[
Group.Members(
value=user.id,
type="User",
display=user.display_name,
ref=url_for("scim.query_user", user=user, _external=True),
)
for user in group.members or []
]
or None,
)
def group_from_scim_to_canaille(scim_group: Group, group):
group.display_name = scim_group.display_name
members = []
for member in scim_group.members or []:
# extract the user identifier from scim/v2/Users/<identifier>
identifier = member.ref.split("/")[-1]
members.append(Backend.instance.get(models.User, identifier))
group.members = members
return group

View file

@ -81,3 +81,5 @@ DYNAMIC_CLIENT_REGISTRATION_OPEN = true
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
"xxxxxxx-yyyyyyy-zzzzzz",
]
[CANAILLE_SCIM]

View file

@ -71,3 +71,5 @@ DYNAMIC_CLIENT_REGISTRATION_OPEN = true
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
"xxxxxxx-yyyyyyy-zzzzzz",
]
[CANAILLE_SCIM]

View file

@ -74,3 +74,5 @@ DYNAMIC_CLIENT_REGISTRATION_OPEN = true
DYNAMIC_CLIENT_REGISTRATION_TOKENS = [
"xxxxxxx-yyyyyyy-zzzzzz",
]
[CANAILLE_SCIM]

View file

@ -48,9 +48,52 @@ OpenID Connect
SCIM
----
- ❌ `RFC7642: System for Cross-domain Identity Management: Definitions, Overview, Concepts, and Requirements <https://www.rfc-editor.org/rfc/rfc7642>`_
- ❌ `RFC7643: System for Cross-domain Identity Management: Core Schema <https://www.rfc-editor.org/rfc/rfc7642>`_
- ❌ `RFC7644: System for Cross-domain Identity Management: Protocol <https://www.rfc-editor.org/rfc/rfc7642>`_
Canaille provides a basic SCIM server implementation.
- 🟠 `RFC7642: System for Cross-domain Identity Management: Definitions, Overview, Concepts, and Requirements <https://www.rfc-editor.org/rfc/rfc7642>`_
- 🟠 `RFC7643: System for Cross-domain Identity Management: Core Schema <https://www.rfc-editor.org/rfc/rfc7642>`_
- 🟠 `RFC7644: System for Cross-domain Identity Management: Protocol <https://www.rfc-editor.org/rfc/rfc7642>`_
Client-side implementation (i.e. broadcasting changes on users and groups among clients) and advanced features will be implemented in the future.
What's implemented
~~~~~~~~~~~~~~~~~~
Endpoints:
- /Users (GET, POST)
- /Users/<user_id> (GET, PUT, DELETE)
- /Groups (GET, POST)
- /Groups/<user_id> (GET, PUT, DELETE)
- /ServiceProviderConfig (GET)
- /Schemas (GET)
- /Schemas/<schema_id> (GET)
- /ResourceTypes (GET)
- /ResourceTypes/<resource_type_id> (GET)
Features:
- :rfc:`pagination <7644#section-3.4.2.4>`
.. _scim_unimplemented:
What is not implemented yet
~~~~~~~~~~~~~~~~~~~~~~~~~~~
Endpoints:
- /Users (PATCH)
- /Groups (PATCH)
- :rfc:`/Me <7644#section-3.11>` (GET, POST, PUT, PATCH, DELETE)
- :rfc:`/Bulk <7644#section-3.11>` (POST)
- :rfc:`/.search <7644#section-3.4.3>` (POST)
Features
- :rfc:`filtering <7644#section-3.4.2.2>`
- :rfc:`sorting <7644#section-3.4.2.3>`
- :rfc:`attributes selection <7644#section-3.4.2.5>`
- :rfc:`ETags <7644#section-3.14>`
Comparison with other providers
===============================
@ -64,7 +107,7 @@ Canaille voluntarily only implements the OpenID Connect protocol to keep its cod
| +-------+-----------+------+------+------+------+------+------+-------+
| | FLOSS | Language | LOC | OIDC | SAML | CAS | SCIM | LDAP | SQL |
+===============+=======+===========+======+======+======+======+======+======+=======+
| Canaille | ✅  | Python | 10k | ✅ | ❌ | ❌ | | ✅ | ✅ |
| Canaille | ✅  | Python | 10k | ✅ | ❌ | ❌ | 🟠 | ✅ | ✅ |
+---------------+-------+-----------+------+------+------+------+------+------+-------+
| `Auth0`_ | ❌  | ❔ | ❔ | ✅ | ✅ | ❌ | ✅ | ✅ | ❔ |
+---------------+-------+-----------+------+------+------+------+------+------+-------+

File diff suppressed because it is too large Load diff

View file

@ -7,4 +7,5 @@ Tutorial
install
deployment
databases
provisioning
troubleshooting

View file

@ -0,0 +1,19 @@
Provisioning
############
Canaille partially implemnet the :rfc:`SCIM <7642>` provisioning protocol at the ``/scim/v2`` endpoint.
At the moment, only the server part is implemented.
It allows client applications to manage user profiles directly in Canaille.
To allow clients to access the SCIM API, the client must have the ``client_credentials`` grant type configured.
This allows clients to ask an authentication token on their own behalf and use this token to perform queries.
Currently, user tokens are not supported.
.. todo::
Some SCIM :ref:`features and endpoints <scim_unimplemented>` are not implemented.
In addition to these, Canaille will implement in the future:
- Access control for clients on the SCIM API endpoint, to finely manage permissions depending on clients.
- Client-side implementation, to broadcast user and groups modifications among all the clients.

View file

@ -52,6 +52,11 @@ oidc = [
"authlib >= 1.3.0",
]
scim = [
"scim2-models>=0.2.2",
"authlib >= 1.3.0",
]
ldap = [
"python-ldap >= 3.4.0",
]
@ -113,16 +118,16 @@ dev = [
"pytest-lazy-fixtures >= 1.0.7",
"pytest-smtpd >= 0.1.0",
"pytest-xdist >= 3.3.1",
"scim2-tester>=0.1.7",
"slapd >= 0.1.5",
"time-machine >= 2.14.1",
"toml >= 0.10.0",
"tox-uv >= 1.16.0",
# Babel 2.14 does not directly depend on setuptools
# https://github.com/python-babel/babel/blob/40e60a1f6cf178d9f57fcc14f157ea1b2ab77361/CHANGES.rst?plain=1#L22-L24
# and neither python 3.12 due to PEP 632
# https://peps.python.org/pep-0632/
"setuptools >= 50.0.0; python_version>='3.12'"
# Babel 2.14 does not directly depend on setuptools
# https://github.com/python-babel/babel/blob/40e60a1f6cf178d9f57fcc14f157ea1b2ab77361/CHANGES.rst?plain=1#L22-L24
# and neither python 3.12 due to PEP 632
# https://peps.python.org/pep-0632/
"setuptools >= 50.0.0; python_version>='3.12'",
]
doc = [
"autodoc-pydantic >= 2.0.1",

0
tests/scim/__init__.py Normal file
View file

75
tests/scim/conftest.py Normal file
View file

@ -0,0 +1,75 @@
import datetime
import pytest
from scim2_client.engines.werkzeug import TestSCIMClient
from werkzeug.security import gen_salt
from werkzeug.test import Client
from canaille.app import models
from canaille.scim.endpoints import bp
@pytest.fixture
def configuration(configuration):
configuration["CANAILLE_SCIM"] = {
"ENABLE_SERVER": True,
}
configuration["CANAILLE"]["LOGGING"]["loggers"]["httpx"] = {
"level": "INFO",
}
configuration["CANAILLE"]["LOGGING"]["loggers"]["httpcore"] = {
"level": "INFO",
}
return configuration
@pytest.fixture
def oidc_client(testclient, backend):
c = models.Client(
client_id=gen_salt(24),
client_name="Some client",
contacts=["contact@mydomain.test"],
client_uri="https://mydomain.test",
redirect_uris=[
"https://mydomain.test/redirect1",
],
client_id_issued_at=datetime.datetime.now(datetime.timezone.utc),
client_secret=gen_salt(48),
grant_types=[
"client_credentials",
],
response_types=["code", "token", "id_token"],
scope=["openid", "email", "profile", "groups", "address", "phone"],
token_endpoint_auth_method="client_secret_basic",
)
backend.save(c)
yield c
backend.delete(c)
@pytest.fixture
def oidc_token(testclient, oidc_client, backend):
t = models.Token(
token_id=gen_salt(48),
access_token=gen_salt(48),
audience=[oidc_client],
client=oidc_client,
refresh_token=gen_salt(48),
scope=["openid", "profile"],
issue_date=datetime.datetime.now(datetime.timezone.utc),
lifetime=3600,
)
backend.save(t)
yield t
backend.delete(t)
@pytest.fixture
def scim_client(app, oidc_client, oidc_token):
return TestSCIMClient(
Client(app),
scim_prefix=bp.url_prefix,
environ={"headers": {"Authorization": f"Bearer {oidc_token.access_token}"}},
check_response_status_codes=False,
)

76
tests/scim/test_errors.py Normal file
View file

@ -0,0 +1,76 @@
import datetime
from scim2_client.engines.werkzeug import TestSCIMClient
from scim2_models import Error
from scim2_models import Resource
from werkzeug.security import gen_salt
from werkzeug.test import Client
from canaille.app import models
from canaille.scim.endpoints import bp
from canaille.scim.endpoints import get_resource_types
from canaille.scim.endpoints import get_schemas
from canaille.scim.endpoints import get_service_provider_config
def test_authentication_failure(app):
"""Test authentication with an invalid token."""
resource_models = [
Resource.from_schema(schema) for schema in get_schemas().values()
]
scim_client = TestSCIMClient(
Client(app),
scim_prefix=bp.url_prefix,
environ={"headers": {"Authorization": "Bearer invalid"}},
service_provider_config=get_service_provider_config(),
resource_types=get_resource_types().values(),
resource_models=resource_models,
)
User = scim_client.get_resource_model("User")
error = scim_client.query(User, raise_scim_errors=False)
assert isinstance(error, Error)
assert not error.scim_type
assert error.status == 401
def test_authentication_with_an_user_token(app, backend, oidc_client, user):
"""Test authentication with an user token."""
scim_token = models.Token(
token_id=gen_salt(48),
access_token=gen_salt(48),
subject=user,
audience=[oidc_client],
client=oidc_client,
refresh_token=gen_salt(48),
scope=["openid", "profile"],
issue_date=datetime.datetime.now(datetime.timezone.utc),
lifetime=3600,
)
backend.save(scim_token)
resource_models = [
Resource.from_schema(schema) for schema in get_schemas().values()
]
scim_client = TestSCIMClient(
Client(app),
scim_prefix=bp.url_prefix,
environ={"headers": {"Authorization": f"Bearer {scim_token.access_token}"}},
service_provider_config=get_service_provider_config(),
resource_types=get_resource_types().values(),
resource_models=resource_models,
)
User = scim_client.get_resource_model("User")
error = scim_client.query(User, raise_scim_errors=False)
assert isinstance(error, Error)
assert not error.scim_type
assert error.status == 401
def test_invalid_payload(app, backend, scim_client):
# TODO: push this test in scim2-tester
scim_client.discover()
User = scim_client.get_resource_model("User")
error = scim_client.create(User(), raise_scim_errors=False)
assert isinstance(error, Error)
assert error.scim_type == "invalidValue"
assert error.status == 400

View file

@ -0,0 +1,7 @@
from scim2_tester import Status
from scim2_tester import check_server
def test_scim_tester(scim_client):
results = check_server(scim_client, raise_exceptions=True)
assert all(result.status == Status.SUCCESS for result in results)

86
uv.lock
View file

@ -164,6 +164,10 @@ postgresql = [
{ name = "sqlalchemy-json" },
{ name = "sqlalchemy-utils" },
]
scim = [
{ name = "authlib" },
{ name = "scim2-models" },
]
sentry = [
{ name = "sentry-sdk" },
]
@ -199,6 +203,7 @@ dev = [
{ name = "pytest-lazy-fixtures" },
{ name = "pytest-smtpd" },
{ name = "pytest-xdist" },
{ name = "scim2-tester" },
{ name = "setuptools", marker = "python_full_version >= '3.12'" },
{ name = "slapd" },
{ name = "time-machine" },
@ -218,6 +223,7 @@ doc = [
[package.metadata]
requires-dist = [
{ name = "authlib", marker = "extra == 'oidc'", specifier = ">=1.3.0" },
{ name = "authlib", marker = "extra == 'scim'", specifier = ">=1.3.0" },
{ name = "email-validator", marker = "extra == 'front'", specifier = ">=2.0.0" },
{ name = "flask", specifier = ">=3.0.0" },
{ name = "flask-babel", marker = "extra == 'front'", specifier = ">=4.0.0" },
@ -234,6 +240,7 @@ requires-dist = [
{ name = "pytz", marker = "extra == 'front'", specifier = ">=2022.7" },
{ name = "qrcode", marker = "extra == 'otp'", specifier = ">=8.0" },
{ name = "requests", specifier = ">=2.32.3" },
{ name = "scim2-models", marker = "extra == 'scim'", specifier = ">=0.2.2" },
{ name = "sentry-sdk", marker = "extra == 'sentry'", specifier = ">=2.0.0" },
{ name = "smpplib", marker = "extra == 'sms'", specifier = ">=2.2.3" },
{ name = "sqlalchemy", marker = "extra == 'sqlite'", specifier = ">=2.0.23" },
@ -272,6 +279,7 @@ dev = [
{ name = "pytest-lazy-fixtures", specifier = ">=1.0.7" },
{ name = "pytest-smtpd", specifier = ">=0.1.0" },
{ name = "pytest-xdist", specifier = ">=3.3.1" },
{ name = "scim2-tester", specifier = ">=0.1.7" },
{ name = "setuptools", marker = "python_full_version >= '3.12'", specifier = ">=50.0.0" },
{ name = "slapd", specifier = ">=0.1.5" },
{ name = "time-machine", specifier = ">=2.14.1" },
@ -1307,6 +1315,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/51/72c18c55cf2f46ff4f91ebcc8f75aa30f7305f3d726be3f4ebffb4ae972b/pydantic-2.10.3-py3-none-any.whl", hash = "sha256:be04d85bbc7b65651c5f8e6b9976ed9c6f41782a55524cef079a34a0bb82144d", size = 456997 },
]
[package.optional-dependencies]
email = [
{ name = "email-validator" },
]
[[package]]
name = "pydantic-core"
version = "2.27.1"
@ -1621,6 +1634,42 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
]
[[package]]
name = "scim2-client"
version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "scim2-models" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/a0/208fb622495b174cfa11f9e856c19db73f2bbf3859519704cd35ff39dfce/scim2_client-0.5.1.tar.gz", hash = "sha256:836451f91baf8f0f3c7061dcc043e892d4e607c55c5803e779adc112b4bc2722", size = 85832 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/9f/66b3c5a61b156856f1019538757063a96e9cdd11b9d4f812505148c66d29/scim2_client-0.5.1-py3-none-any.whl", hash = "sha256:bf5566da5704228d24eebc89cd8a2adb038b19099956996d1e450a1d40df2d14", size = 22507 },
]
[[package]]
name = "scim2-models"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic", extra = ["email"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/42/e7f986b1ebfba7f8b6105764aec0a8c526100b0d8bfd9e28cf08432ad693/scim2_models-0.3.0.tar.gz", hash = "sha256:a1db62385e7820e67c94fd758246815397eb3b3bd1bca797a2a3ef346e81d827", size = 132910 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/ed/708165169928aae94a0596a557e4c674bcc781e429802fb951b9af1d21b1/scim2_models-0.3.0-py3-none-any.whl", hash = "sha256:1ff78d93b9ed0a4d89f04835777e1cee796e0d1881e71f8242a86af2ec9f0612", size = 40663 },
]
[[package]]
name = "scim2-tester"
version = "0.1.13"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "scim2-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c7/fa5672d24d68da5e1060e4f02ab946d7569457d1a5d969e72c0f6c9e78c6/scim2_tester-0.1.13.tar.gz", hash = "sha256:2697f1ca8938e9f4425b76803856f4053108fbe3aee65a1bff446ca178339319", size = 68200 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/4f/d81e4983089bf458c59a1dd85d8524adf77b5b0d9e7aba3f9f2057da0343/scim2_tester-0.1.13-py3-none-any.whl", hash = "sha256:88d1832c4d13b369184e2a0c1a1aed6b107679b591e47eab73441ba382cb5add", size = 18879 },
]
[[package]]
name = "sentry-sdk"
version = "2.19.2"
@ -2089,27 +2138,26 @@ wheels = [
[[package]]
name = "uv"
version = "0.5.7"
version = "0.5.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/1c/8c40ec75c26656bec9ada97833a437b49fd443b5d6dfd61d6dda8ad90cbe/uv-0.5.7.tar.gz", hash = "sha256:4d22a5046a6246af85c92257d110ed8fbcd98b16824e4efa9d825d001222b2cb", size = 2356161 }
sdist = { url = "https://files.pythonhosted.org/packages/14/31/24c4d8d0d15f5a596fefb39a45e5628e2a4ac4b9c0a6044b4710d118673a/uv-0.5.8.tar.gz", hash = "sha256:2ee40bc9c08fea0e71092838c0fc36df83f741807d8be9acf2fd4c4757b3171e", size = 2494559 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/15/4d05061146ef1ff909458f75812633944a144ebadf73ccd38bef127adc6a/uv-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:fb4a3ccbe13072b98919413ac8378dd3e2b5480352f75c349a4f71f423801485", size = 14208956 },
{ url = "https://files.pythonhosted.org/packages/ba/8f/dc99e8f026da8b3c74661ca60d424472b8fc73854be8dd0375c9a487474b/uv-0.5.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a4fc62749bda8e7ae62212b1d85cdf6c7bad41918b3c8ac5a6d730dd093d793d", size = 14205195 },
{ url = "https://files.pythonhosted.org/packages/fe/67/fba55047c34ceae31cf92f6286a8517749d8c86a2151620fccb4dfb01cba/uv-0.5.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:78c3c040e52c09a410b9788656d6e760d557f223058537081cb03a3e25ce89de", size = 13178700 },
{ url = "https://files.pythonhosted.org/packages/5c/af/476c4d3486690e3cd6a9d1e040e350aefcd374b6adf919228594c9e0d9d2/uv-0.5.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:76b514c79136e779cccf90cce5d60f317a0d42074e9f4c059f198ef435f2f6ab", size = 13438725 },
{ url = "https://files.pythonhosted.org/packages/a0/18/ab89b12e695e069f6a181f66fd22dfa66b3bb5b7508938a4d4a3bff6d214/uv-0.5.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a45648db157d2aaff859fe71ec738efea09b972b8864feb2fd61ef856a15b24f", size = 13987146 },
{ url = "https://files.pythonhosted.org/packages/60/72/0eedd9b4d25657124ee5715ec08a0b278716905dd4c2a79b2af5e742c421/uv-0.5.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c1e7b5bcc8b380e333e948c01f6f4c6203067b5de60a05f8ed786332af7a9132", size = 14513180 },
{ url = "https://files.pythonhosted.org/packages/9c/b3/feef463577bb31f692b2e52fdce76865d297fe1a4ae48d2bad855b255a67/uv-0.5.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:737a06b15c4e6b8ab7dd0a577ba766380bda4c18ba4ecfcfff37d336f1b03a00", size = 15216614 },
{ url = "https://files.pythonhosted.org/packages/99/dd/90e3360402610e1f687fc52c1c0b12906530986c7fe87d63414e0b8ac045/uv-0.5.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba25eb99891b95b5200d5e369b788d443fae370b097e7268a71e9ba753f2af3f", size = 15005351 },
{ url = "https://files.pythonhosted.org/packages/f2/c5/1fd7eafa61d2659ab4b27314e01eaa2cd62acb0f3a8bceb6420d38f3137f/uv-0.5.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:747c011da9f631354a1c89b62b19b8572e040d3fe01c6fb8d650facc7a09fdbb", size = 19537320 },
{ url = "https://files.pythonhosted.org/packages/12/77/36eb833476111af75ecc624d103662aba650b2b3c47abf4df5917697a5b1/uv-0.5.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a141b40444c4184efba9fdc10abb3c1cff32154c7f8b0ad46ddc180d65a82d90", size = 14678070 },
{ url = "https://files.pythonhosted.org/packages/a9/c6/7a70672f383ec639d178e0b1481048f181c05bbe372f23a66853a02e0346/uv-0.5.7-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:46b03a9a78438219fb3060c096773284e2f22417a9c1f8fdd602f0650b3355c2", size = 13637987 },
{ url = "https://files.pythonhosted.org/packages/98/d1/a7c80c0a582344cf63ad17c8c344c9194a2f4475f6b522adbdb3b8cb6ac6/uv-0.5.7-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:13961a8116515eb288c4f91849fba11ebda0dfeec44cc356e388b3b03b2dbbe1", size = 13974519 },
{ url = "https://files.pythonhosted.org/packages/84/23/55ef8f1fdd750aa1a123dac92bac249cbf8268bd9ab5b63b33580cd4dc23/uv-0.5.7-py3-none-musllinux_1_1_i686.whl", hash = "sha256:071b57c934bdee8d7502a70e9ea0739a10e9b2d1d0c67e923a09e7a23d9a181b", size = 14241488 },
{ url = "https://files.pythonhosted.org/packages/e8/42/0cb96aa85849e55f3dcf4080fec1c13e75eb6179cbff630e4ded22b455f6/uv-0.5.7-py3-none-musllinux_1_1_ppc64le.whl", hash = "sha256:1c5b89c64fb627f52f1e9c9bbc4dcc7bae29c4c5ab8eff46da3c966bbd4caed2", size = 16082215 },
{ url = "https://files.pythonhosted.org/packages/c5/d0/51e588ef932160f113a379781b7edf781d2a7e4667ff4a26b1f3146df359/uv-0.5.7-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b79e32438390add793bebc41b0729054e375be30bc53f124ee212d9c97affc39", size = 14809685 },
{ url = "https://files.pythonhosted.org/packages/cc/2b/5cc8622473e61b252211811ee6cb0471ac060dc4a36391747217a717a19a/uv-0.5.7-py3-none-win32.whl", hash = "sha256:d0600d2b2fbd9a9446bfbb7f03d88bc3d0293b949ce40e326429dd4fe246c926", size = 14074020 },
{ url = "https://files.pythonhosted.org/packages/e1/e0/2ce3eb10fab05d900b3434dce09f59f5ac0689e52ca4979e3bfd32e71b61/uv-0.5.7-py3-none-win_amd64.whl", hash = "sha256:27c630780e1856a70fbeb267e1ed6835268a1b50963ab9a984fafa4184389def", size = 15842701 },
{ url = "https://files.pythonhosted.org/packages/da/46/7a1310877b6ae012461c0bcc72629ee34a7c78749235ebf67d7856f24a91/uv-0.5.8-py3-none-linux_armv6l.whl", hash = "sha256:defd5da3685f43f74698634ffc197aaf9b836b8ba0de0e57b34d7bc74d856fa9", size = 14287864 },
{ url = "https://files.pythonhosted.org/packages/0f/b5/d02c8ce6bf46d648e9ef912308718a30ecff631904ba03acd11e5ec6412d/uv-0.5.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e146062e4cc39db334cbde38d56d2c6301dd9cf6739ce07ce5a4d71b4cbc2d00", size = 14290268 },
{ url = "https://files.pythonhosted.org/packages/fb/5e/7277f92ee0aa8549e41152d9a0a7863d84e7b7b8de9b08cb397bfe1e37f6/uv-0.5.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0f2bcdd00a49ad1669e217a2787448cac1653c9968d74bfa3732f3c25ca26f69", size = 13255149 },
{ url = "https://files.pythonhosted.org/packages/08/5b/72be4ba38e8e6cd2be60e97fd799629228afd3f46404767b0e1cfcf1236e/uv-0.5.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c91d0a2b8218af2aa0385b867da8c13a620db22077686793c7231f012cb40619", size = 13541600 },
{ url = "https://files.pythonhosted.org/packages/4d/cb/92485fea5f3fffb0f93820fe808b56ceeef1020ae234f8e2ba64f091ed4e/uv-0.5.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8058ab06d2f69355694f6e9a36edc45164474c516b4e2895bd67f8232d9022ed", size = 14090419 },
{ url = "https://files.pythonhosted.org/packages/ac/b0/09a3a3d93299728485121b975a84b893aebdb6b712f65f43491bba7f82d0/uv-0.5.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c56022edc0f61febbdef89e6f699a0e991932c493b7293635b4814e102d040d2", size = 14638200 },
{ url = "https://files.pythonhosted.org/packages/3c/52/1082d3ca50d336035b5ef6c54caa4936aa2a6ad050ea61fca3068dd986b3/uv-0.5.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:84f26ce1736d075d1df34f7c3f6b0b728cecd9a4da3e5160d5d887587830e7ce", size = 15336063 },
{ url = "https://files.pythonhosted.org/packages/06/b5/d9d9a95646ca2404da11fa8f1e9953827ad793d8b92b65bb870f4c0de541/uv-0.5.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a7956787658fb9253fba49741886409402a48039bee64b1697397d27284919af", size = 15068797 },
{ url = "https://files.pythonhosted.org/packages/96/18/f92f7bf7b8769f8010ae4a9b545a0a183a806133174f65c46996e23c8268/uv-0.5.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5989bbbbca072edc1875036c76aed74ec3dfc4741de7d1f060e181717efea6ac", size = 19540106 },
{ url = "https://files.pythonhosted.org/packages/a4/d8/757959dc58abfbf09afe024fbcf1ffb639b8537ea830d09a99d0300ee53c/uv-0.5.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b3076c79746d4f83257c9dea5ba0833b0711aeff8e6695670eadd140a0cf67f", size = 14760582 },
{ url = "https://files.pythonhosted.org/packages/be/20/8b97777fbe6b983a845237c3132e4b540b9dcde73c2bc7c7c6f96ff46f29/uv-0.5.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:aa03c338e19456d3a6544a94293bd2905837ae22720cc161c83ea0fd13c3b09f", size = 13738416 },
{ url = "https://files.pythonhosted.org/packages/b4/fe/fd462516eeb6d58acf5736ea4e7b1b397454344d99c9a0c279bb96436c7b/uv-0.5.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:8a8cbe1ffa0ef5c2f1c90622e07211a8f93f48daa2be1bd4592bb8cda52b0285", size = 14044658 },
{ url = "https://files.pythonhosted.org/packages/be/d0/215c4fcd68e02f39c50557829365e75e60de2c246884753f1382bd75513e/uv-0.5.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:365eb6bbb551c5623a73b1ed530f4e69083016f70f0cf5ca1a30ec66413bcda2", size = 14359764 },
{ url = "https://files.pythonhosted.org/packages/41/3e/3d96e9c41cee4acf16aee39f4cae81f5651754ac6ca383be2031efc90eeb/uv-0.5.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:56715389d240ac989af2188cd3bfc2b603d31b42330e915dacfe113b34d8e65b", size = 14943042 },
{ url = "https://files.pythonhosted.org/packages/51/3e/3826d2e7c653649eec649262d5548b7ed6bdb5af7bed2a8bb5a127ac67bd/uv-0.5.8-py3-none-win32.whl", hash = "sha256:f8ade0430b6618ae0e21e52f61f6f3943dd6f3184ef6dc4491087b27940427f9", size = 14201492 },
{ url = "https://files.pythonhosted.org/packages/2f/d3/8ab1383ceccbc9f31bb9a265f90dfda4f6214229768ea9608df8a8c66e15/uv-0.5.8-py3-none-win_amd64.whl", hash = "sha256:4a3325af8ed1effa7076967472c063b0000d609fd6f561c7751e43bab30297f1", size = 15995992 },
]
[[package]]