forked from Github-Mirrors/canaille
Compare commits
3 commits
main
...
116-scim-c
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6c1557cf27 | ||
![]() |
6e64f51ad4 | ||
![]() |
efe79505fd |
11 changed files with 580 additions and 30 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -24,3 +24,4 @@ canaille/conf/*.pem
|
|||
canaille/conf/*.pub
|
||||
canaille/conf/*.key
|
||||
.vscode
|
||||
dump.json
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import ldap.filter
|
||||
from flask import current_app
|
||||
|
||||
import canaille.core.models
|
||||
import canaille.oidc.models
|
||||
|
@ -51,8 +50,7 @@ class User(canaille.core.models.User, LDAPObject):
|
|||
return super().match_filter(filter)
|
||||
|
||||
def save(self):
|
||||
if current_app.features.has_otp and not self.secret_token:
|
||||
self.initialize_otp()
|
||||
super().save()
|
||||
|
||||
group_attr = self.python_attribute_to_ldap("groups")
|
||||
if group_attr not in self.changes:
|
||||
|
|
|
@ -5,7 +5,6 @@ from typing import Any
|
|||
|
||||
from flask import current_app
|
||||
|
||||
import canaille.backends.memory.models
|
||||
from canaille.backends import Backend
|
||||
from canaille.backends import get_lockout_delay_message
|
||||
|
||||
|
@ -138,12 +137,9 @@ class MemoryBackend(Backend):
|
|||
return results[0] if results else None
|
||||
|
||||
def save(self, instance):
|
||||
if (
|
||||
isinstance(instance, canaille.backends.memory.models.User)
|
||||
and current_app.features.has_otp
|
||||
and not instance.secret_token
|
||||
):
|
||||
instance.initialize_otp()
|
||||
# run the instance save callback if existing
|
||||
if hasattr(instance, "save"):
|
||||
instance.save()
|
||||
|
||||
if not instance.id:
|
||||
instance.id = str(uuid.uuid4())
|
||||
|
@ -160,14 +156,11 @@ class MemoryBackend(Backend):
|
|||
|
||||
def delete(self, instance):
|
||||
# run the instance delete callback if existing
|
||||
delete_callback = instance.delete() if hasattr(instance, "delete") else iter([])
|
||||
next(delete_callback, None)
|
||||
if hasattr(instance, "delete"):
|
||||
instance.delete()
|
||||
|
||||
self.index_delete(instance)
|
||||
|
||||
# run the instance delete callback again if existing
|
||||
next(delete_callback, None)
|
||||
|
||||
def reload(self, instance):
|
||||
# run the instance reload callback if existing
|
||||
reload_callback = instance.reload() if hasattr(instance, "reload") else iter([])
|
||||
|
|
|
@ -134,15 +134,12 @@ class SQLBackend(Backend):
|
|||
|
||||
def delete(self, instance):
|
||||
# run the instance delete callback if existing
|
||||
save_callback = instance.delete() if hasattr(instance, "delete") else iter([])
|
||||
next(save_callback, None)
|
||||
if hasattr(instance, "delete"):
|
||||
instance.delete()
|
||||
|
||||
SQLBackend.instance.db_session.delete(instance)
|
||||
SQLBackend.instance.db_session.commit()
|
||||
|
||||
# run the instance delete callback again if existing
|
||||
next(save_callback, None)
|
||||
|
||||
def reload(self, instance):
|
||||
# run the instance reload callback if existing
|
||||
reload_callback = instance.reload() if hasattr(instance, "reload") else iter([])
|
||||
|
|
|
@ -2,7 +2,6 @@ import datetime
|
|||
import typing
|
||||
import uuid
|
||||
|
||||
from flask import current_app
|
||||
from sqlalchemy import Boolean
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy import ForeignKey
|
||||
|
@ -113,10 +112,6 @@ class User(canaille.core.models.User, Base, SqlAlchemyModel):
|
|||
TZDateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
def save(self):
|
||||
if current_app.features.has_otp and not self.secret_token:
|
||||
self.initialize_otp()
|
||||
|
||||
@property
|
||||
def password_failure_timestamps(self):
|
||||
if self._password_failure_timestamps:
|
||||
|
|
|
@ -4,11 +4,18 @@ from typing import Annotated
|
|||
from typing import ClassVar
|
||||
|
||||
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.core.configuration import Permission
|
||||
from canaille.core.mails import send_one_time_password_mail
|
||||
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
|
||||
OTP_DIGITS = 6
|
||||
|
@ -282,6 +289,14 @@ class User(Model):
|
|||
_writable_fields = 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:
|
||||
"""Check whether a password has been set for the user."""
|
||||
return self.password is not None
|
||||
|
@ -486,6 +501,87 @@ class User(Model):
|
|||
).total_seconds()
|
||||
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):
|
||||
"""User model, based on the `SCIM Group schema
|
||||
|
|
|
@ -394,18 +394,20 @@ class IntrospectionEndpoint(_IntrospectionEndpoint):
|
|||
|
||||
def introspect_token(self, token):
|
||||
audience = [aud.client_id for aud in token.audience]
|
||||
return {
|
||||
response = {
|
||||
"active": True,
|
||||
"client_id": token.client.client_id,
|
||||
"token_type": token.type,
|
||||
"username": token.subject.formatted_name,
|
||||
"scope": token.get_scope(),
|
||||
"sub": token.subject.user_name,
|
||||
"aud": audience,
|
||||
"iss": get_issuer(),
|
||||
"exp": token.get_expires_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:
|
||||
|
|
|
@ -289,3 +289,86 @@ def group_from_scim_to_canaille(scim_group: Group, group):
|
|||
group.members = members
|
||||
|
||||
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
|
||||
|
|
|
@ -1,20 +1,113 @@
|
|||
import json
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.parse import urlunsplit
|
||||
|
||||
import requests
|
||||
from authlib.common.errors import AuthlibBaseError
|
||||
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 flask import Blueprint
|
||||
from flask import Flask
|
||||
from flask import Response
|
||||
from flask import abort
|
||||
from flask import current_app
|
||||
from flask import flash
|
||||
from flask import redirect
|
||||
from flask import render_template
|
||||
from flask import request
|
||||
from flask import session
|
||||
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()
|
||||
|
||||
|
||||
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):
|
||||
@app.route("/")
|
||||
@app.route("/tos")
|
||||
|
@ -84,6 +177,207 @@ def setup_routes(app):
|
|||
flash("You have been successfully logged out", "success")
|
||||
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):
|
||||
oauth.init_app(app)
|
||||
|
@ -119,3 +413,20 @@ def set_parameter_in_url_query(url, **kwargs):
|
|||
split[3] = parameters
|
||||
|
||||
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
|
||||
|
|
|
@ -32,7 +32,9 @@ requires-python = ">=3.10"
|
|||
dependencies = [
|
||||
"flask >= 3.0.0",
|
||||
"flask-wtf >= 1.2.1",
|
||||
"httpx>=0.28.1",
|
||||
"pydantic-settings >= 2.0.3",
|
||||
"q>=2.7",
|
||||
"requests>=2.32.3",
|
||||
"wtforms >= 3.1.1",
|
||||
]
|
||||
|
|
76
uv.lock
76
uv.lock
|
@ -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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "atpublic"
|
||||
version = "5.0"
|
||||
|
@ -126,7 +141,9 @@ source = { editable = "." }
|
|||
dependencies = [
|
||||
{ name = "flask" },
|
||||
{ name = "flask-wtf" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "q" },
|
||||
{ name = "requests" },
|
||||
{ name = "wtforms" },
|
||||
]
|
||||
|
@ -229,6 +246,7 @@ requires-dist = [
|
|||
{ name = "flask-babel", marker = "extra == 'front'", specifier = ">=4.0.0" },
|
||||
{ name = "flask-themer", marker = "extra == 'front'", specifier = ">=2.0.0" },
|
||||
{ name = "flask-wtf", specifier = ">=1.2.1" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "otpauth", marker = "extra == 'otp'", specifier = ">=2.1.1" },
|
||||
{ name = "passlib", marker = "extra == 'mysql'", 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 = "python-ldap", marker = "extra == 'ldap'", specifier = ">=3.4.0" },
|
||||
{ name = "pytz", marker = "extra == 'front'", specifier = ">=2022.7" },
|
||||
{ name = "q", specifier = ">=2.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" },
|
||||
|
@ -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/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/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/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 },
|
||||
|
@ -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/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/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/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 },
|
||||
|
@ -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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "honcho"
|
||||
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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "identify"
|
||||
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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "qrcode"
|
||||
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 },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "snowballstemmer"
|
||||
version = "2.2.0"
|
||||
|
|
Loading…
Reference in a new issue