Compare commits

...

3 commits

Author SHA1 Message Date
Félix Rohrlich
6c1557cf27 feat: Added proper SCIM access token verification by client 2024-12-30 22:47:48 +01:00
Félix Rohrlich
6e64f51ad4 feat: Achieved communication with SCIM client 2024-12-17 18:11:08 +01:00
Félix Rohrlich
efe79505fd feat: initialize scim client feature 2024-12-16 16:40:36 +01:00
11 changed files with 580 additions and 30 deletions

1
.gitignore vendored
View file

@ -24,3 +24,4 @@ canaille/conf/*.pem
canaille/conf/*.pub canaille/conf/*.pub
canaille/conf/*.key canaille/conf/*.key
.vscode .vscode
dump.json

View file

@ -1,5 +1,4 @@
import ldap.filter import ldap.filter
from flask import current_app
import canaille.core.models import canaille.core.models
import canaille.oidc.models import canaille.oidc.models
@ -51,8 +50,7 @@ class User(canaille.core.models.User, LDAPObject):
return super().match_filter(filter) return super().match_filter(filter)
def save(self): def save(self):
if current_app.features.has_otp and not self.secret_token: super().save()
self.initialize_otp()
group_attr = self.python_attribute_to_ldap("groups") group_attr = self.python_attribute_to_ldap("groups")
if group_attr not in self.changes: if group_attr not in self.changes:

View file

@ -5,7 +5,6 @@ from typing import Any
from flask import current_app from flask import current_app
import canaille.backends.memory.models
from canaille.backends import Backend from canaille.backends import Backend
from canaille.backends import get_lockout_delay_message from canaille.backends import get_lockout_delay_message
@ -138,12 +137,9 @@ class MemoryBackend(Backend):
return results[0] if results else None return results[0] if results else None
def save(self, instance): def save(self, instance):
if ( # run the instance save callback if existing
isinstance(instance, canaille.backends.memory.models.User) if hasattr(instance, "save"):
and current_app.features.has_otp instance.save()
and not instance.secret_token
):
instance.initialize_otp()
if not instance.id: if not instance.id:
instance.id = str(uuid.uuid4()) instance.id = str(uuid.uuid4())
@ -160,14 +156,11 @@ class MemoryBackend(Backend):
def delete(self, instance): def delete(self, instance):
# run the instance delete callback if existing # run the instance delete callback if existing
delete_callback = instance.delete() if hasattr(instance, "delete") else iter([]) if hasattr(instance, "delete"):
next(delete_callback, None) instance.delete()
self.index_delete(instance) self.index_delete(instance)
# run the instance delete callback again if existing
next(delete_callback, None)
def reload(self, instance): def reload(self, instance):
# run the instance reload callback if existing # run the instance reload callback if existing
reload_callback = instance.reload() if hasattr(instance, "reload") else iter([]) reload_callback = instance.reload() if hasattr(instance, "reload") else iter([])

View file

@ -134,15 +134,12 @@ class SQLBackend(Backend):
def delete(self, instance): def delete(self, instance):
# run the instance delete callback if existing # run the instance delete callback if existing
save_callback = instance.delete() if hasattr(instance, "delete") else iter([]) if hasattr(instance, "delete"):
next(save_callback, None) instance.delete()
SQLBackend.instance.db_session.delete(instance) SQLBackend.instance.db_session.delete(instance)
SQLBackend.instance.db_session.commit() SQLBackend.instance.db_session.commit()
# run the instance delete callback again if existing
next(save_callback, None)
def reload(self, instance): def reload(self, instance):
# run the instance reload callback if existing # run the instance reload callback if existing
reload_callback = instance.reload() if hasattr(instance, "reload") else iter([]) reload_callback = instance.reload() if hasattr(instance, "reload") else iter([])

View file

@ -2,7 +2,6 @@ import datetime
import typing import typing
import uuid import uuid
from flask import current_app
from sqlalchemy import Boolean from sqlalchemy import Boolean
from sqlalchemy import Column from sqlalchemy import Column
from sqlalchemy import ForeignKey from sqlalchemy import ForeignKey
@ -113,10 +112,6 @@ class User(canaille.core.models.User, Base, SqlAlchemyModel):
TZDateTime(timezone=True), nullable=True TZDateTime(timezone=True), nullable=True
) )
def save(self):
if current_app.features.has_otp and not self.secret_token:
self.initialize_otp()
@property @property
def password_failure_timestamps(self): def password_failure_timestamps(self):
if self._password_failure_timestamps: if self._password_failure_timestamps:

View file

@ -4,11 +4,18 @@ from typing import Annotated
from typing import ClassVar from typing import ClassVar
from flask import current_app from flask import current_app
from httpx import Client as httpx_client
from scim2_client.engines.httpx import SyncSCIMClient
from scim2_models import SearchRequest
from werkzeug.security import gen_salt
from canaille.app import models
from canaille.backends import Backend
from canaille.backends.models import Model from canaille.backends.models import Model
from canaille.core.configuration import Permission from canaille.core.configuration import Permission
from canaille.core.mails import send_one_time_password_mail from canaille.core.mails import send_one_time_password_mail
from canaille.core.sms import send_one_time_password_sms from canaille.core.sms import send_one_time_password_sms
from canaille.scim.models import user_from_canaille_to_scim_for_client
HOTP_LOOK_AHEAD_WINDOW = 10 HOTP_LOOK_AHEAD_WINDOW = 10
OTP_DIGITS = 6 OTP_DIGITS = 6
@ -282,6 +289,14 @@ class User(Model):
_writable_fields = None _writable_fields = None
_permissions = None _permissions = None
def save(self):
if current_app.features.has_otp and not self.secret_token:
self.initialize_otp()
self.propagate_scim_changes()
def delete(self):
self.propagate_scim_delete()
def has_password(self) -> bool: def has_password(self) -> bool:
"""Check whether a password has been set for the user.""" """Check whether a password has been set for the user."""
return self.password is not None return self.password is not None
@ -486,6 +501,87 @@ class User(Model):
).total_seconds() ).total_seconds()
return max(calculated_delay - time_since_last_failed_bind, 0) return max(calculated_delay - time_since_last_failed_bind, 0)
def propagate_scim_changes(self):
for client in self.get_clients():
scim_tokens = Backend.instance.query(
models.Token, client=client, subject=None
)
valid_scim_tokens = [
token
for token in scim_tokens
if not token.is_expired() and not token.is_revoked()
]
if valid_scim_tokens:
scim_token = valid_scim_tokens[0]
else:
scim_token = models.Token(
token_id=gen_salt(48),
access_token=gen_salt(48),
subject=None,
audience=[client],
client=client,
refresh_token=gen_salt(48),
scope=["openid", "profile"],
issue_date=datetime.datetime.now(datetime.timezone.utc),
lifetime=3600,
)
Backend.instance.save(scim_token)
client_httpx = httpx_client(
base_url=client.client_uri,
headers={"Authorization": f"Bearer {scim_token.access_token}"},
)
scim = SyncSCIMClient(client_httpx)
scim.discover()
User = scim.get_resource_model("User")
EnterpriseUser = User.get_extension_model("EnterpriseUser")
user = user_from_canaille_to_scim_for_client(self, User, EnterpriseUser)
req = SearchRequest(filter=f'userName eq "{self.user_name}"')
response = scim.query(User, search_request=req)
if not response.resources:
try:
scim.create(user)
except Exception:
current_app.logger.warning(
f"SCIM User {self.user_name} creation for client {client.client_name} failed"
)
else:
user.id = response.resources[0].id
try:
scim.replace(user)
except:
current_app.logger.warning(
f"SCIM User {self.user_name} update for client {client.client_name} failed"
)
req = SearchRequest(filter=f'userName eq "{self.user_name}"')
response = scim.query(User, search_request=req)
def propagate_scim_delete(self):
client = httpx_client(
base_url="http://localhost:8080",
headers={"Authorization": "Bearer MON_SUPER_TOKEN"},
)
scim = SyncSCIMClient(client)
scim.discover()
User = scim.get_resource_model("User")
try:
scim.delete(User, self.scim_id)
except:
current_app.logger.warning(f"SCIM User {self.user_name} delete failed")
def get_clients(self):
if self.id:
consents = Backend.instance.query(models.Consent, subject=self)
consented_clients = {t.client for t in consents}
preconsented_clients = [
client
for client in Backend.instance.query(models.Client)
if client.preconsent and client not in consented_clients
]
return list(consented_clients) + list(preconsented_clients)
return []
class Group(Model): class Group(Model):
"""User model, based on the `SCIM Group schema """User model, based on the `SCIM Group schema

View file

@ -394,18 +394,20 @@ class IntrospectionEndpoint(_IntrospectionEndpoint):
def introspect_token(self, token): def introspect_token(self, token):
audience = [aud.client_id for aud in token.audience] audience = [aud.client_id for aud in token.audience]
return { response = {
"active": True, "active": True,
"client_id": token.client.client_id, "client_id": token.client.client_id,
"token_type": token.type, "token_type": token.type,
"username": token.subject.formatted_name,
"scope": token.get_scope(), "scope": token.get_scope(),
"sub": token.subject.user_name,
"aud": audience, "aud": audience,
"iss": get_issuer(), "iss": get_issuer(),
"exp": token.get_expires_at(), "exp": token.get_expires_at(),
"iat": token.get_issued_at(), "iat": token.get_issued_at(),
} }
if token.subject:
response["username"] = token.subject.formatted_name
response["sub"] = token.subject.user_name
return response
class ClientManagementMixin: class ClientManagementMixin:

View file

@ -289,3 +289,86 @@ def group_from_scim_to_canaille(scim_group: Group, group):
group.members = members group.members = members
return group return group
def user_from_canaille_to_scim_for_client(user, User, EnterpriseUser):
scim_user = User(
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,
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

View file

@ -1,20 +1,113 @@
import json
import uuid
from http import HTTPStatus
from urllib.parse import urlsplit from urllib.parse import urlsplit
from urllib.parse import urlunsplit from urllib.parse import urlunsplit
import requests
from authlib.common.errors import AuthlibBaseError from authlib.common.errors import AuthlibBaseError
from authlib.integrations.flask_client import OAuth from authlib.integrations.flask_client import OAuth
from authlib.integrations.flask_oauth2 import ResourceProtector
from authlib.integrations.flask_oauth2.errors import (
_HTTPException as AuthlibHTTPException,
)
from authlib.oauth2.rfc7662 import IntrospectTokenValidator
from authlib.oidc.discovery import get_well_known_url from authlib.oidc.discovery import get_well_known_url
from flask import Blueprint
from flask import Flask from flask import Flask
from flask import Response
from flask import abort
from flask import current_app from flask import current_app
from flask import flash from flask import flash
from flask import redirect from flask import redirect
from flask import render_template from flask import render_template
from flask import request
from flask import session from flask import session
from flask import url_for from flask import url_for
from pydantic import ValidationError
from scim2_models import Context
from scim2_models import EnterpriseUser
from scim2_models import Error
from scim2_models import Group
from scim2_models import ListResponse
from scim2_models import ResourceType
from scim2_models import Schema
from scim2_models import SearchRequest
from scim2_models import User
from werkzeug.exceptions import HTTPException
from canaille import csrf
from canaille.scim.models import get_resource_types
from canaille.scim.models import get_schemas
from canaille.scim.models import get_service_provider_config
bp = Blueprint("scim", __name__)
oauth = OAuth() oauth = OAuth()
class SCIMBearerTokenValidator(IntrospectTokenValidator):
def introspect_token(self, token_string: str):
url = current_app.config["OAUTH_AUTH_SERVER"] + "/oauth/introspect"
data = {"token": token_string, "token_type_hint": "access_token"}
auth = (
current_app.config["OAUTH_CLIENT_ID"],
current_app.config["OAUTH_CLIENT_SECRET"],
)
resp = requests.post(url, data=data, auth=auth)
resp.raise_for_status()
return resp.json()
require_oauth = ResourceProtector()
require_oauth.register_token_validator(SCIMBearerTokenValidator())
class ClientBackend:
users: list[User[EnterpriseUser]] = []
groups: list[Group] = []
tokens: list = []
def get_user_by_id(self, id):
for user in self.users:
if user.id == id:
return user
return None
def add_user(self, user):
user.id = str(uuid.uuid4())
self.users.append(user)
def replace_user(self, user):
for i, saved_user in enumerate(self.users):
if saved_user.id == user.id:
self.users[i] = user
break
def delete_user(self, user):
for saved_user in self.users:
if saved_user.user_name == user.userName:
self.users.remove(saved_user)
break
def add_group(self, group):
self.groups.append(group)
def replace_group(self, group):
for saved_group in self.groups:
if saved_group.groupName == group.groupName:
saved_group = group
break
def delete_group(self, group):
for saved_group in self.groups:
if saved_group.group_name == group.groupName:
self.groups.remove(saved_group)
break
backend = ClientBackend()
def setup_routes(app): def setup_routes(app):
@app.route("/") @app.route("/")
@app.route("/tos") @app.route("/tos")
@ -84,6 +177,207 @@ def setup_routes(app):
flash("You have been successfully logged out", "success") flash("You have been successfully logged out", "success")
return redirect(url_for("index")) return redirect(url_for("index"))
@bp.route("/Users")
@csrf.exempt
@require_oauth()
def query_users():
req = parse_search_request(request)
users = backend.users[req.start_index_0 : req.stop_index_0]
total = len(users)
list_response = ListResponse[User[EnterpriseUser]](
start_index=req.start_index,
items_per_page=req.count,
total_results=total,
resources=users,
)
payload = list_response.model_dump(
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
)
return payload
@bp.route("/Users/<string:id>", methods=["GET"])
@csrf.exempt
@require_oauth()
def query_user(id):
user = backend.get_user_by_id(id)
if user:
return user.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():
user = User[EnterpriseUser].model_validate(
request.json, scim_ctx=Context.RESOURCE_CREATION_REQUEST
)
backend.add_user(user)
payload = user.model_dump_json(scim_ctx=Context.RESOURCE_CREATION_RESPONSE)
return Response(payload, status=HTTPStatus.CREATED)
@bp.route("/Users/<string:id>", methods=["PUT"])
@csrf.exempt
@require_oauth()
def replace_user(id):
user = backend.get_user_by_id(id)
request_scim_user = User[EnterpriseUser].model_validate(
request.json,
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
original=user,
)
request_scim_user.id = user.id
backend.replace_user(request_scim_user)
payload = request_scim_user.model_dump(
scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE
)
return payload
@bp.route("/Users/<string:id>", methods=["DELETE"])
@csrf.exempt
@require_oauth()
def delete_user(id):
user = backend.get_user_by_id(id)
backend.delete_user(user)
return "", HTTPStatus.NO_CONTENT
@bp.route("/Groups", methods=["GET"])
@csrf.exempt
@require_oauth()
def query_groups():
req = parse_search_request(request)
groups = backend.groups[req.start_index_0 : req.stop_index_0]
total = len(groups)
list_response = ListResponse[Group](
start_index=req.start_index,
items_per_page=req.count,
total_results=total,
resources=groups,
)
payload = list_response.model_dump(
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
)
return payload
@bp.route("/Groups/<string:groupName>", methods=["GET"])
@csrf.exempt
@require_oauth()
def query_group(groupName):
return groupName.model_dump(
scim_ctx=Context.RESOURCE_QUERY_RESPONSE,
)
@bp.route("/Groups/<string:groupName>", methods=["PUT"])
@csrf.exempt
@require_oauth()
def replace_group(groupName):
group = Group.model_validate(
request.json,
scim_ctx=Context.RESOURCE_REPLACEMENT_REQUEST,
original=groupName,
)
backend.save_group(group)
payload = group.model_dump(scim_ctx=Context.RESOURCE_REPLACEMENT_RESPONSE)
return payload
@bp.route("/Groups/<string:groupName>", methods=["DELETE"])
@csrf.exempt
@require_oauth()
def delete_group(groupName):
backend.delete_group(groupName)
return "", HTTPStatus.NO_CONTENT
@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
app.register_blueprint(bp)
def setup_oauth(app): def setup_oauth(app):
oauth.init_app(app) oauth.init_app(app)
@ -119,3 +413,20 @@ def set_parameter_in_url_query(url, **kwargs):
split[3] = parameters split[3] = parameters
return urlunsplit(split) return urlunsplit(split)
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

View file

@ -32,7 +32,9 @@ requires-python = ">=3.10"
dependencies = [ dependencies = [
"flask >= 3.0.0", "flask >= 3.0.0",
"flask-wtf >= 1.2.1", "flask-wtf >= 1.2.1",
"httpx>=0.28.1",
"pydantic-settings >= 2.0.3", "pydantic-settings >= 2.0.3",
"q>=2.7",
"requests>=2.32.3", "requests>=2.32.3",
"wtforms >= 3.1.1", "wtforms >= 3.1.1",
] ]

76
uv.lock
View file

@ -37,6 +37,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
] ]
[[package]]
name = "anyio"
version = "4.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/40/318e58f669b1a9e00f5c4453910682e2d9dd594334539c7b7817dabb765f/anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48", size = 177076 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/7a/4daaf3b6c08ad7ceffea4634ec206faeff697526421c20f07628c7372156/anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352", size = 93052 },
]
[[package]] [[package]]
name = "atpublic" name = "atpublic"
version = "5.0" version = "5.0"
@ -126,7 +141,9 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "flask" }, { name = "flask" },
{ name = "flask-wtf" }, { name = "flask-wtf" },
{ name = "httpx" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "q" },
{ name = "requests" }, { name = "requests" },
{ name = "wtforms" }, { name = "wtforms" },
] ]
@ -229,6 +246,7 @@ requires-dist = [
{ name = "flask-babel", marker = "extra == 'front'", specifier = ">=4.0.0" }, { name = "flask-babel", marker = "extra == 'front'", specifier = ">=4.0.0" },
{ name = "flask-themer", marker = "extra == 'front'", specifier = ">=2.0.0" }, { name = "flask-themer", marker = "extra == 'front'", specifier = ">=2.0.0" },
{ name = "flask-wtf", specifier = ">=1.2.1" }, { name = "flask-wtf", specifier = ">=1.2.1" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "otpauth", marker = "extra == 'otp'", specifier = ">=2.1.1" }, { name = "otpauth", marker = "extra == 'otp'", specifier = ">=2.1.1" },
{ name = "passlib", marker = "extra == 'mysql'", specifier = ">=1.7.4" }, { name = "passlib", marker = "extra == 'mysql'", specifier = ">=1.7.4" },
{ name = "passlib", marker = "extra == 'postgresql'", specifier = ">=1.7.4" }, { name = "passlib", marker = "extra == 'postgresql'", specifier = ">=1.7.4" },
@ -238,6 +256,7 @@ requires-dist = [
{ name = "pydantic-settings", specifier = ">=2.0.3" }, { name = "pydantic-settings", specifier = ">=2.0.3" },
{ name = "python-ldap", marker = "extra == 'ldap'", specifier = ">=3.4.0" }, { name = "python-ldap", marker = "extra == 'ldap'", specifier = ">=3.4.0" },
{ name = "pytz", marker = "extra == 'front'", specifier = ">=2022.7" }, { name = "pytz", marker = "extra == 'front'", specifier = ">=2022.7" },
{ name = "q", specifier = ">=2.7" },
{ name = "qrcode", marker = "extra == 'otp'", specifier = ">=8.0" }, { name = "qrcode", marker = "extra == 'otp'", specifier = ">=8.0" },
{ name = "requests", specifier = ">=2.32.3" }, { name = "requests", specifier = ">=2.32.3" },
{ name = "scim2-models", marker = "extra == 'scim'", specifier = ">=0.2.2" }, { name = "scim2-models", marker = "extra == 'scim'", specifier = ">=0.2.2" },
@ -549,7 +568,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/65/13d9e76ca19b0ba5603d71ac8424b5694415b348e719db277b5edc985ff5/cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb", size = 3915420 }, { url = "https://files.pythonhosted.org/packages/98/65/13d9e76ca19b0ba5603d71ac8424b5694415b348e719db277b5edc985ff5/cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb", size = 3915420 },
{ url = "https://files.pythonhosted.org/packages/b1/07/40fe09ce96b91fc9276a9ad272832ead0fddedcba87f1190372af8e3039c/cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b", size = 4154498 }, { url = "https://files.pythonhosted.org/packages/b1/07/40fe09ce96b91fc9276a9ad272832ead0fddedcba87f1190372af8e3039c/cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b", size = 4154498 },
{ url = "https://files.pythonhosted.org/packages/75/ea/af65619c800ec0a7e4034207aec543acdf248d9bffba0533342d1bd435e1/cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543", size = 3932569 }, { url = "https://files.pythonhosted.org/packages/75/ea/af65619c800ec0a7e4034207aec543acdf248d9bffba0533342d1bd435e1/cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543", size = 3932569 },
{ url = "https://files.pythonhosted.org/packages/4e/d5/9cc182bf24c86f542129565976c21301d4ac397e74bf5a16e48241aab8a6/cryptography-44.0.0-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:60eb32934076fa07e4316b7b2742fa52cbb190b42c2df2863dbc4230a0a9b385", size = 4164756 },
{ url = "https://files.pythonhosted.org/packages/c7/af/d1deb0c04d59612e3d5e54203159e284d3e7a6921e565bb0eeb6269bdd8a/cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e", size = 4016721 }, { url = "https://files.pythonhosted.org/packages/c7/af/d1deb0c04d59612e3d5e54203159e284d3e7a6921e565bb0eeb6269bdd8a/cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e", size = 4016721 },
{ url = "https://files.pythonhosted.org/packages/bd/69/7ca326c55698d0688db867795134bdfac87136b80ef373aaa42b225d6dd5/cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e", size = 4240915 }, { url = "https://files.pythonhosted.org/packages/bd/69/7ca326c55698d0688db867795134bdfac87136b80ef373aaa42b225d6dd5/cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e", size = 4240915 },
{ url = "https://files.pythonhosted.org/packages/ef/d4/cae11bf68c0f981e0413906c6dd03ae7fa864347ed5fac40021df1ef467c/cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053", size = 2757925 }, { url = "https://files.pythonhosted.org/packages/ef/d4/cae11bf68c0f981e0413906c6dd03ae7fa864347ed5fac40021df1ef467c/cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053", size = 2757925 },
@ -560,7 +578,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/c7/c656eb08fd22255d21bc3129625ed9cd5ee305f33752ef2278711b3fa98b/cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289", size = 3915417 }, { url = "https://files.pythonhosted.org/packages/d0/c7/c656eb08fd22255d21bc3129625ed9cd5ee305f33752ef2278711b3fa98b/cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289", size = 3915417 },
{ url = "https://files.pythonhosted.org/packages/ef/82/72403624f197af0db6bac4e58153bc9ac0e6020e57234115db9596eee85d/cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7", size = 4155160 }, { url = "https://files.pythonhosted.org/packages/ef/82/72403624f197af0db6bac4e58153bc9ac0e6020e57234115db9596eee85d/cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7", size = 4155160 },
{ url = "https://files.pythonhosted.org/packages/a2/cd/2f3c440913d4329ade49b146d74f2e9766422e1732613f57097fea61f344/cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c", size = 3932331 }, { url = "https://files.pythonhosted.org/packages/a2/cd/2f3c440913d4329ade49b146d74f2e9766422e1732613f57097fea61f344/cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c", size = 3932331 },
{ url = "https://files.pythonhosted.org/packages/31/d9/90409720277f88eb3ab72f9a32bfa54acdd97e94225df699e7713e850bd4/cryptography-44.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9abcc2e083cbe8dde89124a47e5e53ec38751f0d7dfd36801008f316a127d7ba", size = 4165207 },
{ url = "https://files.pythonhosted.org/packages/7f/df/8be88797f0a1cca6e255189a57bb49237402b1880d6e8721690c5603ac23/cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64", size = 4017372 }, { url = "https://files.pythonhosted.org/packages/7f/df/8be88797f0a1cca6e255189a57bb49237402b1880d6e8721690c5603ac23/cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64", size = 4017372 },
{ url = "https://files.pythonhosted.org/packages/af/36/5ccc376f025a834e72b8e52e18746b927f34e4520487098e283a719c205e/cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285", size = 4239657 }, { url = "https://files.pythonhosted.org/packages/af/36/5ccc376f025a834e72b8e52e18746b927f34e4520487098e283a719c205e/cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285", size = 4239657 },
{ url = "https://files.pythonhosted.org/packages/46/b0/f4f7d0d0bcfbc8dd6296c1449be326d04217c57afb8b2594f017eed95533/cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417", size = 2758672 }, { url = "https://files.pythonhosted.org/packages/46/b0/f4f7d0d0bcfbc8dd6296c1449be326d04217c57afb8b2594f017eed95533/cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417", size = 2758672 },
@ -784,6 +801,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", size = 1142112 }, { url = "https://files.pythonhosted.org/packages/ac/38/08cc303ddddc4b3d7c628c3039a61a3aae36c241ed01393d00c2fd663473/greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6", size = 1142112 },
] ]
[[package]]
name = "h11"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 },
]
[[package]] [[package]]
name = "honcho" name = "honcho"
version = "2.0.0" version = "2.0.0"
@ -796,6 +822,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/1c/25631fc359955569e63f5446dbb7022c320edf9846cbe892ee5113433a7e/honcho-2.0.0-py3-none-any.whl", hash = "sha256:56dcd04fc72d362a4befb9303b1a1a812cba5da283526fbc6509be122918ddf3", size = 22093 }, { url = "https://files.pythonhosted.org/packages/48/1c/25631fc359955569e63f5446dbb7022c320edf9846cbe892ee5113433a7e/honcho-2.0.0-py3-none-any.whl", hash = "sha256:56dcd04fc72d362a4befb9303b1a1a812cba5da283526fbc6509be122918ddf3", size = 22093 },
] ]
[[package]]
name = "httpcore"
version = "1.0.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
[[package]] [[package]]
name = "identify" name = "identify"
version = "2.6.3" version = "2.6.3"
@ -1607,6 +1661,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
] ]
[[package]]
name = "q"
version = "2.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/90/2649ecc3b4b335e62de4a0c3762c7cd7b2f77a023c5c00649f549cebb56c/q-2.7.tar.gz", hash = "sha256:8e0b792f6658ab9e1133b5ea17af1b530530e60124cf9743bc0fa051b8c64f4e", size = 7946 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/f0/ae942c0530d02092702211fd36d9a465e203f732789c84d0b96fbebe3039/q-2.7-py2.py3-none-any.whl", hash = "sha256:8388a3ef7e79b3b6224189e44ddba8dc1a6e9ed3212ce96f83f6056fa532459c", size = 10390 },
]
[[package]] [[package]]
name = "qrcode" name = "qrcode"
version = "8.0" version = "8.0"
@ -1749,6 +1812,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/72/14/1c4107f0e625a4f95c5412b82f20ea05665c515d74e724898d4c3a8c7ac5/smtpdfix-0.5.2-py3-none-any.whl", hash = "sha256:85045723df6bb492af2ddbadfb0b8f8b5c52f69bb6fd4f6bca78c4f182d6c2b7", size = 17294 }, { url = "https://files.pythonhosted.org/packages/72/14/1c4107f0e625a4f95c5412b82f20ea05665c515d74e724898d4c3a8c7ac5/smtpdfix-0.5.2-py3-none-any.whl", hash = "sha256:85045723df6bb492af2ddbadfb0b8f8b5c52f69bb6fd4f6bca78c4f182d6c2b7", size = 17294 },
] ]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
]
[[package]] [[package]]
name = "snowballstemmer" name = "snowballstemmer"
version = "2.2.0" version = "2.2.0"