diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..40cdb59 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,69 @@ +name: Run CI tests + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + test: + runs-on: ubuntu-latest + env: + KEYCLOAK_HOST: localhost + KEYCLOAK_PORT: 8080 + KEYCLOAK_ADMIN_USER: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KEYCLOAK_REALM: test-realm + KEYCLOAK_CLIENT_ID: test-client + KEYCLOAK_CLIENT_SECRET_KEY: f6974574-c773-4554-826d-06946cd55e98 + strategy: + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + keycloak-tag: + [ + "13.0.1", + "14.0.0", + "15.1.1", + "16.1.1", + "17.0.1-legacy", + "18.0.2-legacy", + "19.0.3-legacy", + ] + + services: + keycloak: + image: quay.io/keycloak/keycloak:${{ matrix.keycloak-tag }} + ports: + - 8080:8080 + - 9990:9990 + options: >- + -e "KEYCLOAK_USER=admin" + -e "KEYCLOAK_PASSWORD=admin" + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Set up the cache + uses: actions/cache@v3 + env: + cache-name: cache-python-packages + with: + path: .venv + key: poetry-${{ matrix.python-version }}-${{ hashFiles('poetry.lock') }} + + - name: Set up the project + run: | + pip install poetry + poetry config virtualenvs.in-project true + poetry install + + - name: Test with pytest + run: ./tests/start.sh diff --git a/README.md b/README.md index 29afa09..2823637 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,6 @@ This package should only be used in projects starting from scratch, since it ove 'CLIENT_ADMIN_ROLE': '', 'REALM_ADMIN_ROLE': '', 'EXEMPT_URIS': [], # URIS to be ignored by the package - 'GRAPHQL_ENDPOINT': 'graphql/' # Default graphQL endpoint } ``` @@ -51,9 +50,7 @@ This package should only be used in projects starting from scratch, since it ove AUTH_USER_MODEL = "django_keycloak.KeycloakUserAutoId" ``` -7. If you are using Graphene, add the `GRAPHQL_ENDPOINT` to settings and `KeycloakGrapheneMiddleware` to the Graphene's `MIDDLEWARE`. - -8. Configure Django-Rest-Framework authentication classes with `django_keycloak.authentication.KeycloakAuthentication`: +7. Configure Django-Rest-Framework authentication classes with `django_keycloak.authentication.KeycloakAuthentication`: ```python REST_FRAMEWORK = { diff --git a/django_keycloak/api/serializers.py b/django_keycloak/api/serializers.py deleted file mode 100644 index cc07a80..0000000 --- a/django_keycloak/api/serializers.py +++ /dev/null @@ -1,27 +0,0 @@ -from django.contrib.auth import get_user_model -from rest_framework import serializers - - -class GetTokenSerializer(serializers.Serializer): - username = serializers.CharField(required=True, write_only=True) - password = serializers.CharField(required=True, write_only=True) - - -class RefreshTokenSerializer(serializers.Serializer): - refresh_token = serializers.CharField(required=True, write_only=True) - - -class KeycloakUserAutoIdSerializer(serializers.ModelSerializer): - """ - Serializer for the user endpoint - """ - - class Meta: - model = get_user_model() - fields = ( - "id", - "username", - "first_name", - "last_name", - "email", - ) diff --git a/django_keycloak/authentication.py b/django_keycloak/authentication.py deleted file mode 100644 index 7de0446..0000000 --- a/django_keycloak/authentication.py +++ /dev/null @@ -1,64 +0,0 @@ -from django.contrib.auth import get_user_model -from django.contrib.auth.models import AnonymousUser -from rest_framework import exceptions, HTTP_HEADER_ENCODING -from rest_framework.authentication import BaseAuthentication - -from django_keycloak.keycloak import Connect - - -class KeycloakAuthentication(BaseAuthentication): - """ - All authentication classes should extend BaseAuthentication. - """ - - authenticate_header = "Bearer" - - def __init__(self): - self.keycloak = Connect() - - @staticmethod - def get_authorization_header(request): - """ - Return request's 'Authorization:' header, as a bytestring. - Hide some test client ickyness where the header can be unicode. - """ - auth = request.META.get("HTTP_AUTHORIZATION", b"") - if isinstance(auth, str): - # Work around django test client oddness - auth = auth.encode(HTTP_HEADER_ENCODING) - return auth - - @staticmethod - def get_token(request): - """Get the token from the HTTP request""" - auth_header = request.META.get("HTTP_AUTHORIZATION").split() - if len(auth_header) == 2: - return auth_header[1] - return None - - def authenticate(self, request): - """ - Authenticate the request and return a two-tuple of (user, token). - """ - auth_header = self.get_authorization_header(request) - - if auth_header: - token = self.get_token(request) - if token: - try: - user = get_user_model().objects.get_by_keycloak_id( - self.keycloak.get_user_id(token) - ) - except get_user_model().DoesNotExist: - raise exceptions.AuthenticationFailed("Invalid or expired token.") - - return user, token - return AnonymousUser(), None - - def authenticate_header(self, request): - """ - Return a string to be used as the value of the `WWW-Authenticate` - header in a `401 Unauthenticated` response, or `None` if the - authentication scheme should return `403 Permission Denied` responses. - """ - return self.authenticate_header diff --git a/django_keycloak/backends.py b/django_keycloak/backends.py deleted file mode 100644 index 53b7f35..0000000 --- a/django_keycloak/backends.py +++ /dev/null @@ -1,46 +0,0 @@ -from django.contrib.auth.backends import RemoteUserBackend -from django.contrib.auth import get_user_model -from django_keycloak.keycloak import Connect -from django_keycloak.models import KeycloakUserAutoId - - -class KeycloakAuthenticationBackend(RemoteUserBackend): - def authenticate(self, request, username=None, password=None): - keycloak = Connect() - token = keycloak.get_token_from_credentials(username, password).get( - "access_token" - ) - User = get_user_model() - if not keycloak.is_token_active(token): - return - try: - user = User.objects.get(username=username) - if isinstance(user, KeycloakUserAutoId): - # Get user info based on token - user_info = keycloak.get_user_info(token) - - # Get user info and update - user.first_name = user_info.get("given_name") - user.last_name = user_info.get("family_name") - user.email = user_info.get("email") - except User.DoesNotExist: - user = User.objects.create_user(username, password) - if keycloak.has_superuser_perm(token): - user.is_staff = True - user.is_superuser = True - else: - user.is_staff = False - user.is_superuser = False - user.save() - - return user - - def get_user(self, user_identifier): - User = get_user_model() - try: - return User.objects.get(username=user_identifier) - except User.DoesNotExist: - try: - return User.objects.get(id=user_identifier) - except User.DoesNotExist: - return None diff --git a/django_keycloak/decorators.py b/django_keycloak/decorators.py deleted file mode 100644 index 421b025..0000000 --- a/django_keycloak/decorators.py +++ /dev/null @@ -1,20 +0,0 @@ -from requests import HTTPError - -from .errors import KeycloakAPIError - - -def keycloak_api_error_handler(f): - """ - Decorator to wrapp all keycloak api calls - NOTE: remember to call res.raise_for_status() after the request - """ - - def wrapper(*args, **kwargs): - try: - f(*args, **kwargs) - except HTTPError as err: - raise KeycloakAPIError( - status=err.response.status_code, message=err.response.json() - ) - - return wrapper diff --git a/django_keycloak/enums.py b/django_keycloak/enums.py deleted file mode 100644 index 3028bef..0000000 --- a/django_keycloak/enums.py +++ /dev/null @@ -1,5 +0,0 @@ -USER_ACTION__VERIFY_EMAIL = "VERIFY_EMAIL" -USER_ACTION__UPDATE_PROFILE = "UPDATE_PROFILE" -USER_ACTION__CONFIGURE_TOTP = "CONFIGURE_TOTP" -USER_ACTION__UPDATE_PASSWORD = "UPDATE_PASSWORD" -USER_ACTION__TERMS_AND_CONDITIONS = "TERMS_AND_CONDITIONS" diff --git a/django_keycloak/errors.py b/django_keycloak/errors.py deleted file mode 100644 index 5f636e6..0000000 --- a/django_keycloak/errors.py +++ /dev/null @@ -1,8 +0,0 @@ -class KeycloakAPIError(Exception): - """ - This should be raised on KeycloakAPIErrors - """ - - def __init__(self, status, message): - self.status = status - self.message = message diff --git a/django_keycloak/keycloak.py b/django_keycloak/keycloak.py deleted file mode 100644 index 0c2dc74..0000000 --- a/django_keycloak/keycloak.py +++ /dev/null @@ -1,432 +0,0 @@ -from datetime import datetime, timezone -from functools import cached_property -from typing import Dict, List, Optional, Union -from urllib.parse import urlparse - -import jwt -import requests -from django.conf import settings -from jwt import PyJWKClient -from jwt.exceptions import InvalidTokenError -from cachetools.func import ttl_cache - -from .decorators import keycloak_api_error_handler -from .urls import ( - BASE_PATH, - KEYCLOAK_CREATE_USER, - KEYCLOAK_DELETE_USER, - KEYCLOAK_GET_TOKEN, - KEYCLOAK_GET_USER_BY_ID, - KEYCLOAK_GET_USER_CLIENT_ROLES_BY_ID, - KEYCLOAK_GET_USERS, - KEYCLOAK_INTROSPECT_TOKEN, - KEYCLOAK_OPENID_CONFIG, - KEYCLOAK_UPDATE_USER, - KEYCLOAK_USER_INFO, -) - - -class Connect: - """ - Keycloak connection and methods - """ - - def __init__( - self, - server_url=None, - base_path=None, - realm=None, - client_id=None, - client_uuid=None, - client_secret_key=None, - internal_url=None, - ): - # Load configuration from settings + args - try: - self.config = settings.KEYCLOAK_CONFIG - except AttributeError: - raise Exception("Missing KEYCLOAK_CONFIG on settings file.") - try: - self.server_url = server_url or self.config.get("SERVER_URL") - self.base_path = base_path or self.config.get("BASE_PATH", BASE_PATH) - self.realm = realm or self.config.get("REALM") - self.client_uuid = client_uuid or self.config.get("CLIENT_UUID") - self.client_id = client_id or self.config.get("CLIENT_ID") - self.client_secret_key = client_secret_key or self.config.get( - "CLIENT_SECRET_KEY" - ) - self.internal_url = internal_url or self.config.get("INTERNAL_URL") - self.client_admin_role = self.config.get("CLIENT_ADMIN_ROLE", "admin") - self.realm_admin_role = self.config.get("REALM_ADMIN_ROLE", "admin") - self.graphql_endpoint = self.config.get("GRAPHQL_ENDPOINT", None) - self.exempt_uris = self.config.get("EXEMPT_URIS", []) - # Flag if the token should be introspected or decoded - self.enable_token_decoding = self.config.get("DECODE_TOKEN", False) - # The percentage of a tokens valid duration until a new token is requested - self.token_timeout_factor = self.config.get("TOKEN_TIMEOUT_FACTOR", 0.9) - # A token belonging to a user and the respective introspection - self.cached_token = None - self.cached_introspect = None - # A token belonging to the client to perform user management - self._client_token = None - # Audiences for the different JWT uses - self.client_jwt_audience = self.config.get( - "CLIENT_JWT_AUDIENCE", "realm-management" - ) - self.user_jwt_audience = self.config.get("USER_JWT_AUDIENCE", "account") - - except KeyError as err: - raise Exception("KEYCLOAK configuration is not defined.") from err - - if not self.server_url: - raise Exception("SERVER_URL is not defined.") - - if not self.realm: - raise Exception("REALM is not defined.") - - if not self.client_id: - raise Exception("CLIENT_ID is not defined.") - - if not self.client_secret_key: - raise Exception("CLIENT_SECRET_KEY is not defined.") - - @cached_property - def openid_config(self): - """Collects the identity provider's configuration""" - return self._get_openid_config() - - @cached_property - def jwks_client(self): - """Collects the identity provider's public keys""" - return PyJWKClient(self.openid_config["jwks_uri"]) - - def introspect(self, token): - """ - @param token: request token - @return: introspected token - """ - if self.cached_introspect and self.cached_token == token: - return self.cached_introspect - - payload = { - "token": token, - "client_id": self.client_id, - "grant_type": "client_credentials", - "client_secret": self.client_secret_key, - } - server_url, headers = self._make_form_request_config() - - response = requests.request( - "POST", - KEYCLOAK_INTROSPECT_TOKEN.format(server_url, self.realm), - data=payload, - headers=headers, - ) - self.cached_token = token - self.cached_introspect = response.json() - return self.cached_introspect - - def get_token_from_credentials(self, username, password): - """ - Get Token for a user from credentials - """ - payload = { - "grant_type": "password", - "client_id": self.client_id, - "client_secret": self.client_secret_key, - "username": username, - "password": password, - "scope": "openid", - } - server_url, headers = self._make_form_request_config() - - response = requests.request( - "POST", - KEYCLOAK_GET_TOKEN.format(server_url, self.realm), - data=payload, - headers=headers, - ) - return response.json() - - def refresh_token_from_credentials(self, refresh_token): - """ - Refresh token - """ - payload = { - "grant_type": "refresh_token", - "client_id": self.client_id, - "client_secret": self.client_secret_key, - "refresh_token": refresh_token, - } - server_url, headers = self._make_form_request_config() - - response = requests.request( - "POST", - KEYCLOAK_GET_TOKEN.format(server_url, self.realm), - data=payload, - headers=headers, - ) - return response.json() - - def get_token(self): - """ - Get a client token based on client credentials - @return: - """ - - if not self._is_client_token_timed_out(): - return self._client_token - - payload = { - "grant_type": "client_credentials", - "client_id": self.client_id, - "client_secret": self.client_secret_key, - } - server_url, headers = self._make_form_request_config() - - response = requests.request( - "POST", - KEYCLOAK_GET_TOKEN.format(server_url, self.realm), - data=payload, - headers=headers, - ) - self._client_token = response.json().get("access_token") - return self._client_token - - def get_users(self, **params): - """ - Get users for realm - @return: - """ - server_url, headers = self._make_secure_json_request_config() - - response = requests.request( - "GET", - KEYCLOAK_GET_USERS.format(server_url, self.realm), - headers=headers, - params=params, - ) - return response.json() - - def get_user_info(self, token): - """ - Get user information token - """ - if self.enable_token_decoding: - return self._decode_token(token, self.user_jwt_audience) - - server_url, headers = self._make_secure_request_config(token) - response = requests.request( - "GET", - KEYCLOAK_USER_INFO.format(server_url, self.realm), - headers=headers, - ) - return response.json() - - def get_user_id(self, token): - """ - Verify if introspect token is active. - """ - if self.enable_token_decoding: - token_data = self._decode_token(token, self.user_jwt_audience) - else: - token_data = self.introspect(token) - return token_data.get("sub", None) - - def is_token_active(self, token): - """ - Verify if introspect token is active. - """ - if self.enable_token_decoding: - return bool(self._decode_token(token, audience=self.user_jwt_audience)) - introspect_token = self.introspect(token) - return introspect_token.get("active", False) - - def client_roles(self, token): - """ - Get client roles from token - """ - if self.enable_token_decoding: - token_data = self._decode_token(token, self.user_jwt_audience) - else: - token_data = self.introspect(token) - client_resources = token_data.get("resource_access").get(self.client_id) - return client_resources.get("roles", []) if client_resources else [] - - def realm_roles(self, token): - """ - Get realm roles from token - """ - if self.enable_token_decoding: - token_data = self._decode_token(token, self.user_jwt_audience) - else: - token_data = self.introspect(token) - return token_data.get("realm_access").get("roles", None) - - def client_scope(self, token): - """ - Get client scope from token - """ - return self.introspect(token).get("scope").split(" ") - - def has_superuser_perm(self, token): - """ - Check if token belongs to a user with superuser permissions - """ - if self.client_admin_role in self.client_roles(token): - return True - if self.realm_admin_role in self.realm_roles(token): - return True - return False - - def get_user_info_by_id(self, user_id): - """ - Get user info from the id - """ - server_url, headers = self._make_secure_json_request_config() - - response = requests.request( - "GET", - KEYCLOAK_GET_USER_BY_ID.format(server_url, self.realm, user_id), - headers=headers, - ) - return response.json() - - def get_user_client_roles_by_id(self, user_id): - """ - Get user client roles from the id - """ - - server_url, headers = self._make_secure_json_request_config() - - response = requests.request( - "GET", - KEYCLOAK_GET_USER_CLIENT_ROLES_BY_ID.format( - server_url, self.realm, user_id, self.client_uuid - ), - headers=headers, - ) - return response.json() - - @keycloak_api_error_handler - def update_user(self, user_id, **values): - """ - Update user with values - """ - server_url, headers = self._make_secure_json_request_config() - - current_user = self.get_user_info_by_id(user_id) - data = { - **current_user, - **values, - } - - url = KEYCLOAK_UPDATE_USER.format(server_url, self.realm, user_id) - res = requests.put(url, headers=headers, json=data) - res.raise_for_status() - return data - - @keycloak_api_error_handler - def create_user(self, **values): - server_url, headers = self._make_secure_json_request_config() - - url = KEYCLOAK_CREATE_USER.format(server_url, self.realm) - res = requests.post(url, headers=headers, json=values) - res.raise_for_status() - return res.headers["Location"].split("/")[-1] - - @keycloak_api_error_handler - def delete_user(self, user_id): - server_url, headers = self._make_secure_json_request_config() - - url = KEYCLOAK_DELETE_USER.format(server_url, self.realm, user_id) - res = requests.delete(url, headers=headers) - res.raise_for_status() - - def _is_client_token_timed_out(self): - """ - Checks if the cached token is timed out as given by the timeout factor - - If there is no cached token, returns true. Timeout is true if the current time is - later than (expiration time - issue time) * timeout factor + issue time. - """ - if not self._client_token: - return True - - # Get the public key from the identity provider, i.e., the keycloak server - decoded = self._decode_token(self._client_token, self.client_jwt_audience) - if not decoded: - return True - exp = datetime.fromtimestamp(decoded["exp"], tz=timezone.utc) - iat = datetime.fromtimestamp(decoded["iat"], tz=timezone.utc) - now = datetime.now(tz=timezone.utc) - - is_timed_out = (exp - iat) * self.token_timeout_factor + iat < now - return is_timed_out - - def _get_openid_config(self): - server_url, headers = self._make_json_request_config() - - response = requests.request( - "GET", - KEYCLOAK_OPENID_CONFIG.format(server_url, self.realm), - headers=headers, - ) - return response.json() - - def _make_secure_request_config(self, token): - server_url, headers = self._make_request_config() - if not token: - token = self.get_token() - headers["Authorization"] = f"Bearer {token}" - return [server_url, headers] - - def _make_secure_json_request_config(self, token=None): - server_url, headers = self._make_request_config() - if not token: - token = self.get_token() - headers["Content-Type"] = "application/json" - headers["Authorization"] = f"Bearer {token}" - return [server_url, headers] - - def _make_form_request_config(self): - server_url, headers = self._make_request_config() - headers["Content-Type"] = "application/x-www-form-urlencoded" - return [server_url, headers] - - def _make_json_request_config(self): - server_url, headers = self._make_request_config() - headers["Content-Type"] = "application/json" - return [server_url, headers] - - def _make_request_config(self): - server_url = f"{self.server_url}{self.base_path}" - headers = {} - if self.internal_url: - server_url = f"{self.internal_url}{self.base_path}" - headers["HOST"] = urlparse(self.server_url).netloc - return [server_url, headers] - - @ttl_cache(maxsize=128, ttl=60) - def _decode_token( - self, token: str, audience: Union[str, List[str]], raise_error=False - ) -> Optional[Dict]: - """ - Attempts to decode the token. Returns a Dict on success or none on failure - """ - try: - # Get the public key from the identity provider, i.e., the keycloak server - public_key = self.jwks_client.get_signing_key_from_jwt(token).key - # Decode and verify the JWT with the server's public key specified in the - # JWT's header, the required audience and the allowed crypto algorithms it - # published - return jwt.decode( - token, - public_key, - audience=audience, - algorithms=self.openid_config["id_token_signing_alg_values_supported"], - ) - except InvalidTokenError as err: - if raise_error: - raise err - return None diff --git a/django_keycloak/managers.py b/django_keycloak/managers.py deleted file mode 100644 index ee6747c..0000000 --- a/django_keycloak/managers.py +++ /dev/null @@ -1,143 +0,0 @@ -from django.contrib.auth.models import UserManager -from django.utils import timezone - -from django_keycloak.keycloak import Connect - - -class KeycloakUserManager(UserManager): - def __init__(self, *args, **kwargs): - self.keycloak = Connect() - super().__init__(*args, **kwargs) - - def _create_user_on_keycloak( - self, - username, - email, - password=None, - first_name=None, - last_name=None, - enabled=True, - actions=None, - ): - """Creates user on keycloak server, No state is changed on local db""" - keycloak = Connect() - values = {"username": username, "email": email, "enabled": enabled} - if password is not None: - values["credentials"] = [ - {"type": "password", "value": password, "temporary": False} - ] - if first_name is not None: - values["firstName"] = first_name - if last_name is not None: - values["lastName"] = last_name - if actions is not None: - values["requiredActions"] = actions - keycloak.create_user(**values) - return keycloak.get_users(username=username)[0] - - def create_user(self, username, password=None, **kwargs): - """ - Creates a local user if the user exists on keycloak - """ - token = self.keycloak.get_token_from_credentials(username, password).get( - "access_token" - ) - if token is None: - raise ValueError("Wrong credentials") - user = self.create_from_token(token, password) - return user - - def create_superuser(self, username, password=None, **kwargs): - """ - Creates a local super user if the user exists on keycloak and is superuser - """ - token = self.keycloak.get_token_from_credentials(username, password).get( - "access_token" - ) - if token is None: - raise ValueError("Wrong credentials") - if not self.keycloak.has_superuser_perm(token): - raise ValueError("You are not an administrator") - - user = self.create_from_token(token, password) - user.save(using=self._db) - return user - - def create_from_token(self, token, password=None, **kwargs): - """ - Create a new user from a valid token - """ - if not self.keycloak.is_token_active(token): - raise ValueError("Invalid token") - - user_info = self.keycloak.get_user_info(token) - - # set admin permissions if user is admin - is_staff = False - is_superuser = False - if self.keycloak.has_superuser_perm(token): - is_staff = True - is_superuser = True - - user = self.model( - id=user_info.get("sub"), - username=user_info.get("preferred_username"), - is_staff=is_staff, - is_superuser=is_superuser, - date_joined=timezone.now(), - **kwargs - ) - user.save(using=self._db) - return user - - def get_by_keycloak_id(self, keycloak_id): - return self.get(id=keycloak_id) - - def create_keycloak_user(self, *args, **kwargs): - keycloak_user = self._create_user_on_keycloak(*args, **kwargs) - return self.create( - id=keycloak_user.get("id"), - username=keycloak_user.get("username"), - ) - - -class KeycloakUserManagerAutoId(KeycloakUserManager): - def create_from_token(self, token, password=None, **kwargs): - """ - Create a new user from a valid token - """ - if not self.keycloak.is_token_active(token): - raise ValueError("Invalid token") - - user_info = self.keycloak.get_user_info(token) - - # set admin permissions if user is admin - is_staff = False - is_superuser = False - if self.keycloak.has_superuser_perm(token): - is_staff = True - is_superuser = True - - user = self.model( - keycloak_id=user_info.get("sub"), - username=user_info.get("preferred_username"), - first_name=user_info.get("given_name"), - last_name=user_info.get("family_name"), - email=user_info.get("email"), - is_staff=is_staff, - is_superuser=is_superuser, - date_joined=timezone.now(), - **kwargs - ) - user.save(using=self._db) - return user - - def get_by_keycloak_id(self, keycloak_id): - return self.get(keycloak_id=keycloak_id) - - def create_keycloak_user(self, *args, **kwargs): - keycloak_user = self._create_user_on_keycloak(*args, **kwargs) - return self.create( - username=keycloak_user.get("username"), - keycloak_id=keycloak_user.get("id"), - ) diff --git a/django_keycloak/middleware.py b/django_keycloak/middleware.py deleted file mode 100644 index 26980e6..0000000 --- a/django_keycloak/middleware.py +++ /dev/null @@ -1,155 +0,0 @@ -import logging -import re - -from django.contrib.auth import get_user_model -from django.http.response import JsonResponse -from django.utils.deprecation import MiddlewareMixin - -from django_keycloak.keycloak import Connect -from django_keycloak.models import KeycloakUserAutoId - - -class KeycloakMiddlewareMixin: - def append_user_info_to_request(self, request, token): - """Appends user info to the request""" - - if hasattr(request, "remote_user"): - return request - - user_info = self.keycloak.get_user_info(token) - request.remote_user = { - "client_roles": self.keycloak.client_roles(token), - "realm_roles": self.keycloak.realm_roles(token), - "client_scope": self.keycloak.client_scope(token), - "name": user_info.get("name"), - "given_name": user_info.get("given_name"), - "family_name": user_info.get("family_name"), - "username": user_info.get("preferred_username"), - "email": user_info.get("email"), - "email_verified": user_info.get("email_verified"), - } - - # Create or update user info - try: - user = get_user_model().objects.get_by_keycloak_id( - self.keycloak.get_user_id(token) - ) - - # Only KeycloakUserAutoId stores the user details locally - if isinstance(user, KeycloakUserAutoId): - user.first_name = user_info.get("given_name") - user.last_name = user_info.get("family_name") - user.email = user_info.get("email") - user.save() - - except get_user_model().DoesNotExist: - user = get_user_model().objects.create_from_token(token) - request.user = user - - return request - - @staticmethod - def is_auth_header_missing(request): - """Check if exists an authentication header in the HTTP request""" - return "HTTP_AUTHORIZATION" not in request.META - - @staticmethod - def get_token(request): - """Get the token from the HTTP request""" - auth_header = request.META.get("HTTP_AUTHORIZATION").split() - if len(auth_header) == 2: - return auth_header[1] - return None - - -class KeycloakGrapheneMiddleware(KeycloakMiddlewareMixin): - """ - Middleware to validate Keycloak access based on Graphql validations - """ - - def __init__(self): - logging.warning( - "All functionality is provided by KeycloakMiddleware", DeprecationWarning, 2 - ) - self.keycloak = Connect() - - def resolve(self, next, root, info, **kwargs): - """ - Graphene Middleware to validate keycloak access - """ - request = info.context - - if self.is_auth_header_missing(request): - """Append anonymous user and continue""" - return next(root, info, **kwargs) - - token = self.get_token(request) - if token is None: - raise Exception("Invalid token structure. Must be 'Bearer '") - - if not self.keycloak.is_token_active(token): - raise Exception("Invalid or expired token.") - - info.context = self.append_user_info_to_request(request, token) - - return next(root, info, **kwargs) - - -class KeycloakMiddleware(KeycloakMiddlewareMixin, MiddlewareMixin): - """ - Middleware to validate Keycloak access based on REST validations - """ - - sync_capable = True - async_capable = False - - def __init__(self, get_response): - self.keycloak = Connect() - - # Django response - self.get_response = get_response - - def __call__(self, request): - """ - To be executed before the view each request - """ - # Skip auth for gql endpoint (it is done in KeycloakGrapheneMiddleware) - if self.is_graphql_endpoint(request): - return self.get_response(request) - - if not self.is_auth_header_missing(request): - token = self.get_token(request) - if token is None: - return JsonResponse( - {"detail": "Invalid token structure. Must be 'Bearer '"}, - status=401, - ) - - if not self.keycloak.is_token_active(token): - return JsonResponse( - {"detail": "Invalid or expired token."}, - status=401, - ) - request = self.append_user_info_to_request(request, token) - return self.get_response(request) - - def pass_auth(self, request): - """ - Check if the current URI path needs to skip authorization - """ - path = request.path_info.lstrip("/") - return any(re.match(m, path) for m in self.keycloak.exempt_uris) - - def is_graphql_endpoint(self, request): - """ - Check if the request path belongs to a graphql endpoint - """ - if self.keycloak.graphql_endpoint is None: - return False - - path = request.path_info.lstrip("/") - is_graphql_endpoint = re.match(self.keycloak.graphql_endpoint, path) - if is_graphql_endpoint and request.method != "GET": - return True - - return False diff --git a/django_keycloak/mixins.py b/django_keycloak/mixins.py deleted file mode 100644 index 28b6ce9..0000000 --- a/django_keycloak/mixins.py +++ /dev/null @@ -1,43 +0,0 @@ -import logging -from django_keycloak.keycloak import Connect - - -class KeycloakTestMixin: - """ - Cleans up the users created on the Keycloak server as test side-effects. - - Stores all Keycloak users at the start of a test and compares them to those at the - end. Removes all new users. - - Usage: In the test class, derive from this mixin and call keycloak_init/teardown in - the setUp and tearDown functions. - - class LoginTests(KeycloakTestMixin, TestCase): - def setUp(self): - keycloak_init() - ... - - def tearDown(self): - keycloak_teardown() - ... - """ - - def setUp(self): # pylint: disable=invalid-name - logging.warning("Please call keycloak_init() manually", DeprecationWarning, 2) - self.keycloak_init() - - def tearDown(self): # pylint: disable=invalid-name - logging.warning( - "Please call keycloak_cleanup() manually", DeprecationWarning, 2 - ) - self.keycloak_cleanup() - - def keycloak_init(self): - self.keycloak = Connect() - self._start_users = {user.get("id") for user in self.keycloak.get_users()} - - def keycloak_cleanup(self): - new_users = {user.get("id") for user in self.keycloak.get_users()} - users_to_remove = new_users.difference(self._start_users) - for user_id in users_to_remove: - self.keycloak.delete_user(user_id) diff --git a/django_keycloak/tests.py b/django_keycloak/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/django_keycloak/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/django_keycloak/urls.py b/django_keycloak/urls.py deleted file mode 100644 index 253a3c7..0000000 --- a/django_keycloak/urls.py +++ /dev/null @@ -1,23 +0,0 @@ -# KEYCLOAK SERVER -BASE_PATH = "/auth" - -# REST API -KEYCLOAK_INTROSPECT_TOKEN = "{}/realms/{}/protocol/openid-connect/token/introspect" -KEYCLOAK_USER_INFO = "{}/realms/{}/protocol/openid-connect/userinfo" -KEYCLOAK_GET_USERS = "{}/admin/realms/{}/users" -KEYCLOAK_GET_TOKEN = "{}/realms/{}/protocol/openid-connect/token" -KEYCLOAK_GET_USER_BY_ID = "{}/admin/realms/{}/users/{}" -KEYCLOAK_GET_USER_CLIENT_ROLES_BY_ID = ( - "{}/admin/realms/{}/users/{}/role-mappings/clients/{}" -) -KEYCLOAK_UPDATE_USER = "{}/admin/realms/{}/users/{}" -KEYCLOAK_CREATE_USER = "{}/admin/realms/{}/users" -KEYCLOAK_SEND_ACTIONS_EMAIL = "{}/admin/realms/{}/users/{}/execute-actions-email" -KEYCLOAK_DELETE_USER = "{}/admin/realms/{}/users/{}" -KEYCLOAK_OPENID_CONFIG = "{}/realms/{}/.well-known/openid-configuration" - - -# ADMIN CONSOLE -KEYCLOAK_ADMIN_USER_PAGE = ( - "{host}/auth/admin/master/console/#/realms/{realm}/users/{id}" -) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..2cec8e6 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,841 @@ +[[package]] +name = "appnope" +version = "0.1.3" +description = "Disable App Nap on macOS >= 10.9" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "asgiref" +version = "3.5.2" +description = "ASGI specs, helper code, and adapters" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +typing-extensions = {version = "*", markers = "python_version < \"3.8\""} + +[package.extras] +tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] + +[[package]] +name = "backcall" +version = "0.2.0" +description = "Specifications for callback functions passed in to an API" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "black" +version = "22.10.0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_full_version < \"3.11.0a7\""} +typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\" and implementation_name == \"cpython\""} +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "cachetools" +version = "5.2.0" +description = "Extensible memoizing collections and decorators" +category = "main" +optional = false +python-versions = "~=3.7" + +[[package]] +name = "certifi" +version = "2022.9.24" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "charset-normalizer" +version = "2.1.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" + +[[package]] +name = "coverage" +version = "6.5.0" +description = "Code coverage measurement for Python" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "Django" +version = "3.2.16" +description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +asgiref = ">=3.3.2,<4" +pytz = "*" +sqlparse = ">=0.2.2" + +[package.extras] +argon2 = ["argon2-cffi (>=19.1.0)"] +bcrypt = ["bcrypt"] + +[[package]] +name = "djangorestframework" +version = "3.14.0" +description = "Web APIs for Django, made easy." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +django = ">=3.0" +pytz = "*" + +[[package]] +name = "dry-rest-permissions" +version = "0.1.10" +description = "Rules based permissions for the Django Rest Framework" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "ecdsa" +version = "0.18.0" +description = "ECDSA cryptographic signature library (pure python)" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[package.dependencies] +six = ">=1.9.0" + +[package.extras] +gmpy = ["gmpy"] +gmpy2 = ["gmpy2"] + +[[package]] +name = "idna" +version = "3.4" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "importlib-metadata" +version = "5.0.0" +description = "Read metadata from Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} +zipp = ">=0.5" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +perf = ["ipython"] +testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] + +[[package]] +name = "ipdb" +version = "0.13.9" +description = "IPython-enabled pdb" +category = "dev" +optional = false +python-versions = ">=2.7" + +[package.dependencies] +decorator = {version = "*", markers = "python_version > \"3.6\""} +ipython = {version = ">=7.17.0", markers = "python_version > \"3.6\""} +setuptools = "*" +toml = {version = ">=0.10.2", markers = "python_version > \"3.6\""} + +[[package]] +name = "ipython" +version = "7.34.0" +description = "IPython: Productive Interactive Computing" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +appnope = {version = "*", markers = "sys_platform == \"darwin\""} +backcall = "*" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} +pickleshare = "*" +prompt-toolkit = ">=2.0.0,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.1.0" +pygments = "*" +setuptools = ">=18.5" +traitlets = ">=4.2" + +[package.extras] +all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.17)", "pygments", "qtconsole", "requests", "testpath"] +doc = ["Sphinx (>=1.3)"] +kernel = ["ipykernel"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["ipykernel", "nbformat", "nose (>=0.10.1)", "numpy (>=1.17)", "pygments", "requests", "testpath"] + +[[package]] +name = "jedi" +version = "0.18.1" +description = "An autocompletion tool for Python that can be used for text editors." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +parso = ">=0.8.0,<0.9.0" + +[package.extras] +qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] +testing = ["Django (<3.1)", "colorama", "docopt", "pytest (<7.0.0)"] + +[[package]] +name = "matplotlib-inline" +version = "0.1.6" +description = "Inline Matplotlib backend for Jupyter" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +traitlets = "*" + +[[package]] +name = "mypy-extensions" +version = "0.4.3" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "parso" +version = "0.8.3" +description = "A Python Parser" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] +testing = ["docopt", "pytest (<6.0.0)"] + +[[package]] +name = "pathspec" +version = "0.10.1" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "pexpect" +version = "4.8.0" +description = "Pexpect allows easy control of interactive console applications." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "pickleshare" +version = "0.7.5" +description = "Tiny 'shelve'-like database with concurrency support" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "platformdirs" +version = "2.5.2" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx (>=4)", "sphinx-autodoc-typehints (>=1.12)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] + +[[package]] +name = "prompt-toolkit" +version = "3.0.31" +description = "Library for building powerful interactive command lines in Python" +category = "dev" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "pyasn1" +version = "0.4.8" +description = "ASN.1 types and codecs" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "Pygments" +version = "2.13.0" +description = "Pygments is a syntax highlighting package written in Python." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +plugins = ["importlib-metadata"] + +[[package]] +name = "python-jose" +version = "3.3.0" +description = "JOSE implementation in Python" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +ecdsa = "!=0.15" +pyasn1 = "*" +rsa = "*" + +[package.extras] +cryptography = ["cryptography (>=3.4.0)"] +pycrypto = ["pyasn1", "pycrypto (>=2.6.0,<2.7.0)"] +pycryptodome = ["pyasn1", "pycryptodome (>=3.3.1,<4.0.0)"] + +[[package]] +name = "python-keycloak" +version = "2.6.0" +description = "python-keycloak is a Python package providing access to the Keycloak API." +category = "main" +optional = false +python-versions = ">=3.7,<4.0" + +[package.dependencies] +python-jose = ">=3.3.0,<4.0.0" +requests = ">=2.20.0,<3.0.0" +requests-toolbelt = ">=0.9.1,<0.10.0" +urllib3 = ">=1.26.0,<2.0.0" + +[package.extras] +docs = ["Sphinx (>=5.0.2,<6.0.0)", "alabaster (>=0.7.12,<0.8.0)", "commonmark (>=0.9.1,<0.10.0)", "m2r2 (>=0.3.2,<0.4.0)", "mock (>=4.0.3,<5.0.0)", "readthedocs-sphinx-ext (>=2.1.8,<3.0.0)", "recommonmark (>=0.7.1,<0.8.0)", "sphinx-autoapi (>=1.8.4,<2.0.0)", "sphinx-rtd-theme (>=1.0.0,<2.0.0)"] + +[[package]] +name = "pytz" +version = "2022.5" +description = "World timezone definitions, modern and historical" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "requests" +version = "2.28.1" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=3.7, <4" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<3" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "requests-toolbelt" +version = "0.9.1" +description = "A utility belt for advanced users of python-requests" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + +[[package]] +name = "rsa" +version = "4.9" +description = "Pure-Python RSA implementation" +category = "main" +optional = false +python-versions = ">=3.6,<4" + +[package.dependencies] +pyasn1 = ">=0.1.3" + +[[package]] +name = "setuptools" +version = "65.5.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mock", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "sqlparse" +version = "0.4.3" +description = "A non-validating SQL parser." +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +category = "dev" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "traitlets" +version = "5.5.0" +description = "" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["pre-commit", "pytest"] + +[[package]] +name = "typed-ast" +version = "1.5.4" +description = "a fork of Python 2 and 3 ast modules with type comment support" +category = "dev" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "typing-extensions" +version = "4.4.0" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "main" +optional = false +python-versions = ">=3.7" + +[[package]] +name = "urllib3" +version = "1.26.12" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "zipp" +version = "3.10.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] + +[metadata] +lock-version = "1.1" +python-versions = ">=3.7,<4" +content-hash = "87685917c8dd02e85e7c15934ea2dc45e090f5d2088d9aaee805ebd6dce17652" + +[metadata.files] +appnope = [ + {file = "appnope-0.1.3-py2.py3-none-any.whl", hash = "sha256:265a455292d0bd8a72453494fa24df5a11eb18373a60c7c0430889f22548605e"}, + {file = "appnope-0.1.3.tar.gz", hash = "sha256:02bd91c4de869fbb1e1c50aafc4098827a7a54ab2f39d9dcba6c9547ed920e24"}, +] +asgiref = [ + {file = "asgiref-3.5.2-py3-none-any.whl", hash = "sha256:1d2880b792ae8757289136f1db2b7b99100ce959b2aa57fd69dab783d05afac4"}, + {file = "asgiref-3.5.2.tar.gz", hash = "sha256:4a29362a6acebe09bf1d6640db38c1dc3d9217c68e6f9f6204d72667fc19a424"}, +] +backcall = [ + {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, + {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, +] +black = [ + {file = "black-22.10.0-1fixedarch-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:5cc42ca67989e9c3cf859e84c2bf014f6633db63d1cbdf8fdb666dcd9e77e3fa"}, + {file = "black-22.10.0-1fixedarch-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:5d8f74030e67087b219b032aa33a919fae8806d49c867846bfacde57f43972ef"}, + {file = "black-22.10.0-1fixedarch-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:197df8509263b0b8614e1df1756b1dd41be6738eed2ba9e9769f3880c2b9d7b6"}, + {file = "black-22.10.0-1fixedarch-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:2644b5d63633702bc2c5f3754b1b475378fbbfb481f62319388235d0cd104c2d"}, + {file = "black-22.10.0-1fixedarch-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:e41a86c6c650bcecc6633ee3180d80a025db041a8e2398dcc059b3afa8382cd4"}, + {file = "black-22.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2039230db3c6c639bd84efe3292ec7b06e9214a2992cd9beb293d639c6402edb"}, + {file = "black-22.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14ff67aec0a47c424bc99b71005202045dc09270da44a27848d534600ac64fc7"}, + {file = "black-22.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:819dc789f4498ecc91438a7de64427c73b45035e2e3680c92e18795a839ebb66"}, + {file = "black-22.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5b9b29da4f564ba8787c119f37d174f2b69cdfdf9015b7d8c5c16121ddc054ae"}, + {file = "black-22.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8b49776299fece66bffaafe357d929ca9451450f5466e997a7285ab0fe28e3b"}, + {file = "black-22.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:21199526696b8f09c3997e2b4db8d0b108d801a348414264d2eb8eb2532e540d"}, + {file = "black-22.10.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e464456d24e23d11fced2bc8c47ef66d471f845c7b7a42f3bd77bf3d1789650"}, + {file = "black-22.10.0-cp37-cp37m-win_amd64.whl", hash = "sha256:9311e99228ae10023300ecac05be5a296f60d2fd10fff31cf5c1fa4ca4b1988d"}, + {file = "black-22.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fba8a281e570adafb79f7755ac8721b6cf1bbf691186a287e990c7929c7692ff"}, + {file = "black-22.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:915ace4ff03fdfff953962fa672d44be269deb2eaf88499a0f8805221bc68c87"}, + {file = "black-22.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:444ebfb4e441254e87bad00c661fe32df9969b2bf224373a448d8aca2132b395"}, + {file = "black-22.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:974308c58d057a651d182208a484ce80a26dac0caef2895836a92dd6ebd725e0"}, + {file = "black-22.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72ef3925f30e12a184889aac03d77d031056860ccae8a1e519f6cbb742736383"}, + {file = "black-22.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:432247333090c8c5366e69627ccb363bc58514ae3e63f7fc75c54b1ea80fa7de"}, + {file = "black-22.10.0-py3-none-any.whl", hash = "sha256:c957b2b4ea88587b46cf49d1dc17681c1e672864fd7af32fc1e9664d572b3458"}, + {file = "black-22.10.0.tar.gz", hash = "sha256:f513588da599943e0cde4e32cc9879e825d58720d6557062d1098c5ad80080e1"}, +] +cachetools = [ + {file = "cachetools-5.2.0-py3-none-any.whl", hash = "sha256:f9f17d2aec496a9aa6b76f53e3b614c965223c061982d434d160f930c698a9db"}, + {file = "cachetools-5.2.0.tar.gz", hash = "sha256:6a94c6402995a99c3970cc7e4884bb60b4a8639938157eeed436098bf9831757"}, +] +certifi = [ + {file = "certifi-2022.9.24-py3-none-any.whl", hash = "sha256:90c1a32f1d68f940488354e36370f6cca89f0f106db09518524c88d6ed83f382"}, + {file = "certifi-2022.9.24.tar.gz", hash = "sha256:0d9c601124e5a6ba9712dbc60d9c53c21e34f5f641fe83002317394311bdce14"}, +] +charset-normalizer = [ + {file = "charset-normalizer-2.1.1.tar.gz", hash = "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845"}, + {file = "charset_normalizer-2.1.1-py3-none-any.whl", hash = "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f"}, +] +click = [ + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] +colorama = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +coverage = [ + {file = "coverage-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef8674b0ee8cc11e2d574e3e2998aea5df5ab242e012286824ea3c6970580e53"}, + {file = "coverage-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:784f53ebc9f3fd0e2a3f6a78b2be1bd1f5575d7863e10c6e12504f240fd06660"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4a5be1748d538a710f87542f22c2cad22f80545a847ad91ce45e77417293eb4"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83516205e254a0cb77d2d7bb3632ee019d93d9f4005de31dca0a8c3667d5bc04"}, + {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af4fffaffc4067232253715065e30c5a7ec6faac36f8fc8d6f64263b15f74db0"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:97117225cdd992a9c2a5515db1f66b59db634f59d0679ca1fa3fe8da32749cae"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a1170fa54185845505fbfa672f1c1ab175446c887cce8212c44149581cf2d466"}, + {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:11b990d520ea75e7ee8dcab5bc908072aaada194a794db9f6d7d5cfd19661e5a"}, + {file = "coverage-6.5.0-cp310-cp310-win32.whl", hash = "sha256:5dbec3b9095749390c09ab7c89d314727f18800060d8d24e87f01fb9cfb40b32"}, + {file = "coverage-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:59f53f1dc5b656cafb1badd0feb428c1e7bc19b867479ff72f7a9dd9b479f10e"}, + {file = "coverage-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5375e28c5191ac38cca59b38edd33ef4cc914732c916f2929029b4bfb50795"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ed2820d919351f4167e52425e096af41bfabacb1857186c1ea32ff9983ed75"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33a7da4376d5977fbf0a8ed91c4dffaaa8dbf0ddbf4c8eea500a2486d8bc4d7b"}, + {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fb6cf131ac4070c9c5a3e21de0f7dc5a0fbe8bc77c9456ced896c12fcdad91"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a6b7d95969b8845250586f269e81e5dfdd8ff828ddeb8567a4a2eaa7313460c4"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1ef221513e6f68b69ee9e159506d583d31aa3567e0ae84eaad9d6ec1107dddaa"}, + {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cca4435eebea7962a52bdb216dec27215d0df64cf27fc1dd538415f5d2b9da6b"}, + {file = "coverage-6.5.0-cp311-cp311-win32.whl", hash = "sha256:98e8a10b7a314f454d9eff4216a9a94d143a7ee65018dd12442e898ee2310578"}, + {file = "coverage-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:bc8ef5e043a2af066fa8cbfc6e708d58017024dc4345a1f9757b329a249f041b"}, + {file = "coverage-6.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4433b90fae13f86fafff0b326453dd42fc9a639a0d9e4eec4d366436d1a41b6d"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4f05d88d9a80ad3cac6244d36dd89a3c00abc16371769f1340101d3cb899fc3"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94e2565443291bd778421856bc975d351738963071e9b8839ca1fc08b42d4bef"}, + {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:027018943386e7b942fa832372ebc120155fd970837489896099f5cfa2890f79"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:255758a1e3b61db372ec2736c8e2a1fdfaf563977eedbdf131de003ca5779b7d"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:851cf4ff24062c6aec510a454b2584f6e998cada52d4cb58c5e233d07172e50c"}, + {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12adf310e4aafddc58afdb04d686795f33f4d7a6fa67a7a9d4ce7d6ae24d949f"}, + {file = "coverage-6.5.0-cp37-cp37m-win32.whl", hash = "sha256:b5604380f3415ba69de87a289a2b56687faa4fe04dbee0754bfcae433489316b"}, + {file = "coverage-6.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4a8dbc1f0fbb2ae3de73eb0bdbb914180c7abfbf258e90b311dcd4f585d44bd2"}, + {file = "coverage-6.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d900bb429fdfd7f511f868cedd03a6bbb142f3f9118c09b99ef8dc9bf9643c3c"}, + {file = "coverage-6.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2198ea6fc548de52adc826f62cb18554caedfb1d26548c1b7c88d8f7faa8f6ba"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c4459b3de97b75e3bd6b7d4b7f0db13f17f504f3d13e2a7c623786289dd670e"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20c8ac5386253717e5ccc827caad43ed66fea0efe255727b1053a8154d952398"}, + {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b07130585d54fe8dff3d97b93b0e20290de974dc8177c320aeaf23459219c0b"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dbdb91cd8c048c2b09eb17713b0c12a54fbd587d79adcebad543bc0cd9a3410b"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:de3001a203182842a4630e7b8d1a2c7c07ec1b45d3084a83d5d227a3806f530f"}, + {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e07f4a4a9b41583d6eabec04f8b68076ab3cd44c20bd29332c6572dda36f372e"}, + {file = "coverage-6.5.0-cp38-cp38-win32.whl", hash = "sha256:6d4817234349a80dbf03640cec6109cd90cba068330703fa65ddf56b60223a6d"}, + {file = "coverage-6.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:7ccf362abd726b0410bf8911c31fbf97f09f8f1061f8c1cf03dfc4b6372848f6"}, + {file = "coverage-6.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:633713d70ad6bfc49b34ead4060531658dc6dfc9b3eb7d8a716d5873377ab745"}, + {file = "coverage-6.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:95203854f974e07af96358c0b261f1048d8e1083f2de9b1c565e1be4a3a48cfc"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9023e237f4c02ff739581ef35969c3739445fb059b060ca51771e69101efffe"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:265de0fa6778d07de30bcf4d9dc471c3dc4314a23a3c6603d356a3c9abc2dfcf"}, + {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f830ed581b45b82451a40faabb89c84e1a998124ee4212d440e9c6cf70083e5"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7b6be138d61e458e18d8e6ddcddd36dd96215edfe5f1168de0b1b32635839b62"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:42eafe6778551cf006a7c43153af1211c3aaab658d4d66fa5fcc021613d02518"}, + {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:723e8130d4ecc8f56e9a611e73b31219595baa3bb252d539206f7bbbab6ffc1f"}, + {file = "coverage-6.5.0-cp39-cp39-win32.whl", hash = "sha256:d9ecf0829c6a62b9b573c7bb6d4dcd6ba8b6f80be9ba4fc7ed50bf4ac9aecd72"}, + {file = "coverage-6.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc2af30ed0d5ae0b1abdb4ebdce598eafd5b35397d4d75deb341a614d333d987"}, + {file = "coverage-6.5.0-pp36.pp37.pp38-none-any.whl", hash = "sha256:1431986dac3923c5945271f169f59c45b8802a114c8f548d611f2015133df77a"}, + {file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"}, +] +decorator = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] +Django = [ + {file = "Django-3.2.16-py3-none-any.whl", hash = "sha256:18ba8efa36b69cfcd4b670d0fa187c6fe7506596f0ababe580e16909bcdec121"}, + {file = "Django-3.2.16.tar.gz", hash = "sha256:3adc285124244724a394fa9b9839cc8cd116faf7d159554c43ecdaa8cdf0b94d"}, +] +djangorestframework = [ + {file = "djangorestframework-3.14.0-py3-none-any.whl", hash = "sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08"}, + {file = "djangorestframework-3.14.0.tar.gz", hash = "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8"}, +] +dry-rest-permissions = [ + {file = "dry-rest-permissions-0.1.10.tar.gz", hash = "sha256:1f40461184063390e5b24e9c5602eb8cc8c3c2433c796f39a5332065bfbddd2b"}, + {file = "dry_rest_permissions-0.1.10-py2.py3-none-any.whl", hash = "sha256:f3fe685760004ce182801602819b43ebfa922e587036f1f5a5c10ffcfa646039"}, +] +ecdsa = [ + {file = "ecdsa-0.18.0-py2.py3-none-any.whl", hash = "sha256:80600258e7ed2f16b9aa1d7c295bd70194109ad5a30fdee0eaeefef1d4c559dd"}, + {file = "ecdsa-0.18.0.tar.gz", hash = "sha256:190348041559e21b22a1d65cee485282ca11a6f81d503fddb84d5017e9ed1e49"}, +] +idna = [ + {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, + {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, +] +importlib-metadata = [ + {file = "importlib_metadata-5.0.0-py3-none-any.whl", hash = "sha256:ddb0e35065e8938f867ed4928d0ae5bf2a53b7773871bfe6bcc7e4fcdc7dea43"}, + {file = "importlib_metadata-5.0.0.tar.gz", hash = "sha256:da31db32b304314d044d3c12c79bd59e307889b287ad12ff387b3500835fc2ab"}, +] +ipdb = [ + {file = "ipdb-0.13.9.tar.gz", hash = "sha256:951bd9a64731c444fd907a5ce268543020086a697f6be08f7cc2c9a752a278c5"}, +] +ipython = [ + {file = "ipython-7.34.0-py3-none-any.whl", hash = "sha256:c175d2440a1caff76116eb719d40538fbb316e214eda85c5515c303aacbfb23e"}, + {file = "ipython-7.34.0.tar.gz", hash = "sha256:af3bdb46aa292bce5615b1b2ebc76c2080c5f77f54bda2ec72461317273e7cd6"}, +] +jedi = [ + {file = "jedi-0.18.1-py2.py3-none-any.whl", hash = "sha256:637c9635fcf47945ceb91cd7f320234a7be540ded6f3e99a50cb6febdfd1ba8d"}, + {file = "jedi-0.18.1.tar.gz", hash = "sha256:74137626a64a99c8eb6ae5832d99b3bdd7d29a3850fe2aa80a4126b2a7d949ab"}, +] +matplotlib-inline = [ + {file = "matplotlib-inline-0.1.6.tar.gz", hash = "sha256:f887e5f10ba98e8d2b150ddcf4702c1e5f8b3a20005eb0f74bfdbd360ee6f304"}, + {file = "matplotlib_inline-0.1.6-py3-none-any.whl", hash = "sha256:f1f41aab5328aa5aaea9b16d083b128102f8712542f819fe7e6a420ff581b311"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +parso = [ + {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, + {file = "parso-0.8.3.tar.gz", hash = "sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0"}, +] +pathspec = [ + {file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"}, + {file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"}, +] +pexpect = [ + {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, + {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, +] +pickleshare = [ + {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, + {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, +] +platformdirs = [ + {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, + {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, +] +prompt-toolkit = [ + {file = "prompt_toolkit-3.0.31-py3-none-any.whl", hash = "sha256:9696f386133df0fc8ca5af4895afe5d78f5fcfe5258111c2a79a1c3e41ffa96d"}, + {file = "prompt_toolkit-3.0.31.tar.gz", hash = "sha256:9ada952c9d1787f52ff6d5f3484d0b4df8952787c087edf6a1f7c2cb1ea88148"}, +] +ptyprocess = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] +pyasn1 = [ + {file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"}, + {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"}, +] +Pygments = [ + {file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"}, + {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"}, +] +python-jose = [ + {file = "python-jose-3.3.0.tar.gz", hash = "sha256:55779b5e6ad599c6336191246e95eb2293a9ddebd555f796a65f838f07e5d78a"}, + {file = "python_jose-3.3.0-py2.py3-none-any.whl", hash = "sha256:9b1376b023f8b298536eedd47ae1089bcdb848f1535ab30555cd92002d78923a"}, +] +python-keycloak = [ + {file = "python-keycloak-2.6.0.tar.gz", hash = "sha256:08c530ff86f631faccb8033d9d9345cc3148cb2cf132ff7564f025292e4dbd96"}, + {file = "python_keycloak-2.6.0-py3-none-any.whl", hash = "sha256:a1ce102b978beb56d385319b3ca20992b915c2c12d15a2d0c23f1104882f3fb6"}, +] +pytz = [ + {file = "pytz-2022.5-py2.py3-none-any.whl", hash = "sha256:335ab46900b1465e714b4fda4963d87363264eb662aab5e65da039c25f1f5b22"}, + {file = "pytz-2022.5.tar.gz", hash = "sha256:c4d88f472f54d615e9cd582a5004d1e5f624854a6a27a6211591c251f22a6914"}, +] +requests = [ + {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, + {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, +] +requests-toolbelt = [ + {file = "requests-toolbelt-0.9.1.tar.gz", hash = "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"}, + {file = "requests_toolbelt-0.9.1-py2.py3-none-any.whl", hash = "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f"}, +] +rsa = [ + {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, + {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, +] +setuptools = [ + {file = "setuptools-65.5.0-py3-none-any.whl", hash = "sha256:f62ea9da9ed6289bfe868cd6845968a2c854d1427f8548d52cae02a42b4f0356"}, + {file = "setuptools-65.5.0.tar.gz", hash = "sha256:512e5536220e38146176efb833d4a62aa726b7bbff82cfbc8ba9eaa3996e0b17"}, +] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +sqlparse = [ + {file = "sqlparse-0.4.3-py3-none-any.whl", hash = "sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34"}, + {file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"}, +] +toml = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] +tomli = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] +traitlets = [ + {file = "traitlets-5.5.0-py3-none-any.whl", hash = "sha256:1201b2c9f76097195989cdf7f65db9897593b0dfd69e4ac96016661bb6f0d30f"}, + {file = "traitlets-5.5.0.tar.gz", hash = "sha256:b122f9ff2f2f6c1709dab289a05555be011c87828e911c0cf4074b85cb780a79"}, +] +typed-ast = [ + {file = "typed_ast-1.5.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4"}, + {file = "typed_ast-1.5.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62"}, + {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac"}, + {file = "typed_ast-1.5.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe"}, + {file = "typed_ast-1.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72"}, + {file = "typed_ast-1.5.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec"}, + {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47"}, + {file = "typed_ast-1.5.4-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6"}, + {file = "typed_ast-1.5.4-cp36-cp36m-win_amd64.whl", hash = "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1"}, + {file = "typed_ast-1.5.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6"}, + {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66"}, + {file = "typed_ast-1.5.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c"}, + {file = "typed_ast-1.5.4-cp37-cp37m-win_amd64.whl", hash = "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2"}, + {file = "typed_ast-1.5.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d"}, + {file = "typed_ast-1.5.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f"}, + {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc"}, + {file = "typed_ast-1.5.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6"}, + {file = "typed_ast-1.5.4-cp38-cp38-win_amd64.whl", hash = "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e"}, + {file = "typed_ast-1.5.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35"}, + {file = "typed_ast-1.5.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97"}, + {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3"}, + {file = "typed_ast-1.5.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72"}, + {file = "typed_ast-1.5.4-cp39-cp39-win_amd64.whl", hash = "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1"}, + {file = "typed_ast-1.5.4.tar.gz", hash = "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2"}, +] +typing-extensions = [ + {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, + {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, +] +urllib3 = [ + {file = "urllib3-1.26.12-py2.py3-none-any.whl", hash = "sha256:b930dd878d5a8afb066a637fbb35144fe7901e3b209d1cd4f524bd0e9deee997"}, + {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"}, +] +wcwidth = [ + {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, + {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, +] +zipp = [ + {file = "zipp-3.10.0-py3-none-any.whl", hash = "sha256:4fcb6f278987a6605757302a6e40e896257570d11c51628968ccb2a47e80c6c1"}, + {file = "zipp-3.10.0.tar.gz", hash = "sha256:7a7262fd930bd3e36c50b9a64897aec3fafff3dfdeec9623ae22b40e93f99bb8"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fe094ab --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +name = "django_uw_keycloak" +version = "2.0.0" +description = "Middleware to allow authorization using Keycloak and Django" +authors = [ + "Ubiwhere ", + "Moritz Ulmer ", +] +license = "MIT" +repository = "https://github.com/urbanplatform/django-keycloak-auth" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Environment :: Web Environment", + "Framework :: Django", + "Intended Audience :: Developers", + "Topic :: Security", +] +keywords = ["keycloak", "django", "authorization"] +readme = "README.md" +packages = [ + { include = "django_keycloak", from = "src" } +] + +[tool.poetry.dependencies] +python = ">=3.7,<4" +django = ">=2.2" +djangorestframework = ">=3.0" +dry-rest-permissions = ">=0.1" +python-keycloak = ">=2.6.0" +cachetools = ">=5.0.0" + +[tool.poetry.dev-dependencies] +black = "~=22.6" +coverage = "~=6.4" +ipdb = "*" \ No newline at end of file diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 7a389e4..0000000 --- a/setup.cfg +++ /dev/null @@ -1,27 +0,0 @@ -[metadata] -name = django_uw_keycloak -version = 1.2.1 -description = Middleware to allow authorization using Keycloak and Django -long_description = file: README.md -long_description_content_type = text/markdown; charset=UTF-8; variant=GFM -url = https://github.com/urbanplatform/django-keycloak-auth -author = Ubiwhere -author_email = urbanplatform@ubiwhere.com -license = MIT -classifiers = - Environment :: Web Environment - Framework :: Django - Intended Audience :: Developers - Topic :: Security - -[options] -include_package_data = true -packages = find: -install_requires = - Django >= "2.2" - djangorestframework >= "3.0" - dry-rest-permissions >= "0.1" - PyJWT >= "2.3" - requests >= "2.0" - cachetools >= "5.0" - diff --git a/setup.py b/setup.py index d04f632..6068493 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,3 @@ from setuptools import setup -import os -if os.environ.get("CI_COMMIT_TAG"): - version = os.environ["CI_COMMIT_TAG"] - setup(version=version) -else: - setup() +setup() diff --git a/django_keycloak/__init__.py b/src/django_keycloak/__init__.py similarity index 71% rename from django_keycloak/__init__.py rename to src/django_keycloak/__init__.py index b1829aa..05d385f 100644 --- a/django_keycloak/__init__.py +++ b/src/django_keycloak/__init__.py @@ -1 +1,3 @@ +from .token import Token + default_app_config = "django_keycloak.apps.DjangoKeycloakConfig" diff --git a/django_keycloak/admin.py b/src/django_keycloak/admin.py similarity index 64% rename from django_keycloak/admin.py rename to src/django_keycloak/admin.py index 5de869e..7ff640f 100644 --- a/django_keycloak/admin.py +++ b/src/django_keycloak/admin.py @@ -1,10 +1,12 @@ -from django.conf import settings +""" +Module to register models into Django admin dashboard. +""" from django.contrib import admin from django.contrib.auth import get_user_model from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ -from django_keycloak.urls import BASE_PATH, KEYCLOAK_ADMIN_USER_PAGE +from django_keycloak.config import settings User = get_user_model() @@ -31,15 +33,19 @@ class UserAdmin(admin.ModelAdmin): search_fields = ["username", "email"] def keycloak_link(self, obj): - config = settings.KEYCLOAK_CONFIG - label = obj.id - base_path = config.get("BASE_PATH", BASE_PATH) - host = f"{config.get('SERVER_URL')}{base_path}" - link = KEYCLOAK_ADMIN_USER_PAGE.format( - host=host, realm=config.get("REALM"), id=label - ) + """ + Adds an hyperlink to django-admin, which open the Keycloak's user profiles on Keycloak's Admin Console. + """ + base_path = settings.BASE_PATH + server = settings.SERVER_URL + realm = settings.REALM + + link = f"{server}{base_path}/admin/master/console/#/{realm}/users/{obj.keycloak_identifier}/settings" + return format_html( - '{label}', link=link, label=label + '{label}', + link=link, + label=obj.keycloak_identifier, ) keycloak_link.short_description = _("keycloak link") diff --git a/django_keycloak/api/__init__.py b/src/django_keycloak/api/__init__.py similarity index 100% rename from django_keycloak/api/__init__.py rename to src/django_keycloak/api/__init__.py diff --git a/django_keycloak/api/filters.py b/src/django_keycloak/api/filters.py similarity index 100% rename from django_keycloak/api/filters.py rename to src/django_keycloak/api/filters.py diff --git a/src/django_keycloak/api/serializers.py b/src/django_keycloak/api/serializers.py new file mode 100644 index 0000000..b0191fb --- /dev/null +++ b/src/django_keycloak/api/serializers.py @@ -0,0 +1,55 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers +from rest_framework.exceptions import AuthenticationFailed, ValidationError +from django_keycloak import Token + + +class GetTokenSerializer(serializers.Serializer): + username = serializers.CharField(required=True, write_only=True) + password = serializers.CharField(required=True, write_only=True) + + def to_representation(self, instance): + """ + Return an "access" and "refresh" token if the given credentials are correct. + Otherwise return an authentication error + """ + token = Token.from_credentials(instance["username"], instance["password"]) + if not token: + raise AuthenticationFailed + + return { + "access": token.access_token, + "refresh": token.refresh_token, + } + + +class RefreshTokenSerializer(serializers.Serializer): + refresh_token = serializers.CharField(required=True, write_only=True) + + def to_representation(self, instance): + """ + Return an "access" token from the "refresh" token. + If the refresh token is not valid return a validation error. + """ + token = Token.from_refresh_token(instance["refresh_token"]) + if not token: + raise ValidationError + return { + "access": token.access_token, + } + + +class KeycloakUserAutoIdSerializer(serializers.ModelSerializer): + """ + Serializer for the user endpoint + """ + + class Meta: + model = get_user_model() + fields = ( + "id", + "username", + "first_name", + "last_name", + "email", + ) diff --git a/django_keycloak/api/urls.py b/src/django_keycloak/api/urls.py similarity index 100% rename from django_keycloak/api/urls.py rename to src/django_keycloak/api/urls.py diff --git a/django_keycloak/api/views.py b/src/django_keycloak/api/views.py similarity index 50% rename from django_keycloak/api/views.py rename to src/django_keycloak/api/views.py index 294bfe1..bd599b8 100644 --- a/django_keycloak/api/views.py +++ b/src/django_keycloak/api/views.py @@ -1,9 +1,8 @@ from django.contrib.auth import get_user_model -from rest_framework import mixins +from rest_framework import mixins, permissions from rest_framework import status -from rest_framework import viewsets +from rest_framework import viewsets, generics from rest_framework.decorators import action -from rest_framework.generics import GenericAPIView from rest_framework.response import Response from django_keycloak.api.filters import DRYPermissionFilter @@ -12,41 +11,26 @@ RefreshTokenSerializer, KeycloakUserAutoIdSerializer, ) -from django_keycloak.keycloak import Connect -class GetTokenAPIView(GenericAPIView): - serializer_class = GetTokenSerializer - permission_classes = () +class BaseTokenAPIView(generics.GenericAPIView): + serializer_class = None + # Allow anonymous users + permission_classes = [permissions.AllowAny] def post(self, request, *args, **kwargs): serializer_class = self.get_serializer_class() - serializer = serializer_class(data=request.data, context={"request": request}) + serializer = serializer_class(data=request.data) serializer.is_valid(raise_exception=True) + return Response(serializer.data) - keycloak = Connect() - data = keycloak.get_token_from_credentials( - request.data.get("username"), request.data.get("password") - ) - return Response(data) +class GetTokenAPIView(BaseTokenAPIView): + serializer_class = GetTokenSerializer -class RefreshTokenAPIView(GenericAPIView): +class RefreshTokenAPIView(BaseTokenAPIView): serializer_class = RefreshTokenSerializer - permission_classes = () - - def post(self, request, *args, **kwargs): - serializer_class = self.get_serializer_class() - serializer = serializer_class(data=request.data, context={"request": request}) - serializer.is_valid(raise_exception=True) - - keycloak = Connect() - data = keycloak.refresh_token_from_credentials( - request.data.get("refresh_token") - ) - - return Response(data) class UserProfileAPIView(viewsets.GenericViewSet, mixins.RetrieveModelMixin): @@ -55,7 +39,9 @@ class UserProfileAPIView(viewsets.GenericViewSet, mixins.RetrieveModelMixin): filter_backends = (DRYPermissionFilter,) @action( - detail=False, methods=["GET"], serializer_class=KeycloakUserAutoIdSerializer + detail=False, + methods=["GET"], + serializer_class=KeycloakUserAutoIdSerializer, ) def me(self, request): """ diff --git a/django_keycloak/apps.py b/src/django_keycloak/apps.py similarity index 100% rename from django_keycloak/apps.py rename to src/django_keycloak/apps.py diff --git a/src/django_keycloak/authentication.py b/src/django_keycloak/authentication.py new file mode 100644 index 0000000..7e31fcf --- /dev/null +++ b/src/django_keycloak/authentication.py @@ -0,0 +1,38 @@ +""" +Custom authentication class for Django Rest Framework. +""" +from typing import Union +from django.contrib.auth import get_user_model +from rest_framework.authentication import TokenAuthentication +from rest_framework.exceptions import AuthenticationFailed +from django_keycloak import Token + + +class KeycloakAuthentication(TokenAuthentication): + """ + A custom token authentication class for Keycloak. + """ + + # `keyword` refeers to expected prefix in HTTP + # Authentication header. We use `Bearer` because it + # is commonly used in authorization protocols, such + # as OAuth2 + keyword = "Bearer" + + def authenticate_credentials(self, access_token: str): + """ + Overrides `authenticate_credentials` to provide custom + Keycloak authentication for a given Bearer token in a request. + """ + # Try to build a Token instance from the provided access token in request + token: Union[Token, None] = Token.from_access_token(access_token) + + # Check for valid Token instance + if not token: + raise AuthenticationFailed + + # Get the associated user by keycloak id + user = get_user_model().objects.get_by_keycloak_id(token.user_id) + + # Return the user and the associated access token + return (user, token.access_token) diff --git a/src/django_keycloak/backends.py b/src/django_keycloak/backends.py new file mode 100644 index 0000000..f38bccb --- /dev/null +++ b/src/django_keycloak/backends.py @@ -0,0 +1,78 @@ +""" +Module containing custom Django authentication backends. +""" +from typing import Optional, Union +from django.contrib.auth.backends import RemoteUserBackend +from django.contrib.auth import get_user_model +from django_keycloak.models import KeycloakUserAutoId, KeycloakUser +from django_keycloak import Token + + +class KeycloakAuthenticationBackend(RemoteUserBackend): + """ + Custom remote backend for Keycloak + """ + + def authenticate( + self, + request, + remote_user: Optional[str] = None, + password: Optional[str] = None, + ): + """ + Authenticates a user by credentials, and + updates their information (first name, last name, email). + If user does not exist it is created with appropriate permissions. + + Parameters + ---------- + remote_user: str + The Keycloak's username. + password: str + The Keycloak's password. + """ + + # Create token from the provided credentials and check if + # credentials were valid + token = Token.from_credentials(remote_user, password) # type: ignore + + # Check for non-existing or inactive token + if not token: + # credentials were not valid + return + + # Get the user model + User: Union[KeycloakUser, KeycloakUserAutoId] = get_user_model() # type: ignore + + # try to get user from database + try: + user = User.objects.get(username=remote_user) + if isinstance(user, KeycloakUserAutoId): + # Get user information from token + user_info = token.user_info + + # Update local user information based on Keycloak + # information from token + user.first_name = user_info.get("given_name") + user.last_name = user_info.get("family_name") + user.email = user_info.get("email") + + except User.DoesNotExist: + # If user does not exist create in database + # `create_from_token` takes cares of password hashing + user = User.objects.create_from_token(token) + + user.is_staff = user.is_superuser = bool(token.is_superuser) + + user.save() + return user + + def get_user(self, user_id: str): + User: Union[KeycloakUser, KeycloakUserAutoId] = get_user_model() + try: + return User.objects.get(username=user_id) + except User.DoesNotExist: + try: + return User.objects.get(id=user_id) + except User.DoesNotExist: + return None diff --git a/src/django_keycloak/config.py b/src/django_keycloak/config.py new file mode 100644 index 0000000..8d99b48 --- /dev/null +++ b/src/django_keycloak/config.py @@ -0,0 +1,74 @@ +""" +Module to interact with django settings +""" +import re +from dataclasses import dataclass, field +from typing import List, Optional + +from django.conf import settings as django_settings + + +@dataclass +class Settings: + """ + Django Keycloak settings container + """ + + SERVER_URL: str + # The Keycloak realm in which this client is registered + REALM: str + # The ID of this client in the above Keycloak realm + CLIENT_ID: str + # The secret for this confidential client + CLIENT_SECRET_KEY: str + # The name of the admin role for the client + CLIENT_ADMIN_ROLE: str + # The name of the admin role for the realm + REALM_ADMIN_ROLE: str + # Regex formatted URLs to skip authentication for (uses re.match()) + EXEMPT_URIS: Optional[List] = field(default_factory=list) + # Overrides SERVER_URL for Keycloak admin calls + INTERNAL_URL: Optional[str] = None + # Override default Keycloak base path (/auth/) + BASE_PATH: Optional[str] = "/auth/" + # Flag if the token should be introspected or decoded + DECODE_TOKEN: Optional[bool] = False + # Flag if the audience in the token should be verified + VERIFY_AUDIENCE: Optional[bool] = True + # Flag if the user info has been included in the token + USER_INFO_IN_TOKEN: Optional[bool] = True + # Flag to show the traceback of debug logs + TRACE_DEBUG_LOGS: Optional[bool] = False + + # Derived setting of the SERVER/INTERNAL_URL and BASE_PATH + KEYCLOAK_URL: str = field(init=False) + + def __post_init__(self) -> None: + # Decide URL (internal url overrides serverl url) + URL = self.INTERNAL_URL if self.INTERNAL_URL else self.SERVER_URL + self.KEYCLOAK_URL = f"{URL}{self.BASE_PATH}" + + +# Get keycloak configs from django +__configs = django_settings.KEYCLOAK_CONFIG +# Filter out configs with `None` as values +__configs = { + k: v + for k, v in __configs.items() + if v is not None and k in Settings.__annotations__.keys() +} +try: + # The exported settings object + settings = Settings(**__configs) + +except TypeError as e: + import django_keycloak.errors as errors + + if "required positional argument" in str(e): + # Get missing variables with regex + missing_required_vars = re.findall("'([^']*)'", str(e)) + raise errors.KeycloakMissingSettingError( + " / ".join(missing_required_vars) + ) from e + else: + raise e diff --git a/src/django_keycloak/connector.py b/src/django_keycloak/connector.py new file mode 100644 index 0000000..d83e417 --- /dev/null +++ b/src/django_keycloak/connector.py @@ -0,0 +1,79 @@ +""" +Module to interact with Keycloak Admin API +""" +from typing import Dict, List + +from keycloak.exceptions import KeycloakAuthenticationError, KeycloakGetError +from keycloak.keycloak_admin import KeycloakAdmin + +from django_keycloak.config import settings +from django_keycloak.errors import ( + KeycloakMissingServiceAccountRolesError, + KeycloakNoServiceAccountRolesError, +) + +_args: List +_kwargs: Dict +_initialized: bool = False + + +class LazyKeycloakAdmin(KeycloakAdmin): + """ + Overrides `KeycloakAdmin` from `python-keycloak`, + to provide a mechanism to lazy load the connection to + Keycloak's server and re-use the same connection on + subsequent requests, thus eliminating the connection overhead. + """ + + def __init__(self, *args, **kwargs): + global _args, _kwargs + _args, _kwargs = args, kwargs + + def __getattribute__(self, item): + """ + Intercepts a method call for `KeycloakAdmin`. + If no instance has been previously initialized, we + call the parent constructor to create a new one, and + save a flag to re-use the same instance on following requests. + """ + global _initialized, _args, _kwargs + if not _initialized: + _initialized = True + self.handle_keycloak_init(_args, _kwargs) + # Calling the super class to avoid recursion + return super().__getattribute__(item) + + def handle_keycloak_init(self, args, kwargs): + """ + Calls the parent constructor to initialize a + Keycloak connection. + """ + try: + super().__init__(*args, **kwargs) + # Try to call a users method + # if error occurs a required role is missing + # https://github.com/marcospereirampj/python-keycloak/issues/87 + try: + self.users_count() + except KeycloakGetError as error: + if "unknown_error" in str(error): + raise KeycloakMissingServiceAccountRolesError from error + else: + raise error + except KeycloakAuthenticationError as error: + # Check if the error is due to service account not being enabled + if "Client not enabled to retrieve service account" in str(error): + raise KeycloakNoServiceAccountRolesError from error + + # Otherwise re-throw the original error + else: + raise error + + +# The exported module variable +lazy_keycloak_admin = LazyKeycloakAdmin( + server_url=settings.KEYCLOAK_URL, + client_id=settings.CLIENT_ID, + realm_name=settings.REALM, + client_secret_key=settings.CLIENT_SECRET_KEY, +) diff --git a/src/django_keycloak/errors.py b/src/django_keycloak/errors.py new file mode 100644 index 0000000..f5af706 --- /dev/null +++ b/src/django_keycloak/errors.py @@ -0,0 +1,60 @@ +""" +Module containing custom errors. +""" +import django_keycloak.config as config + + +class KeycloakAPIError(Exception): + """ + This should be raised on KeycloakAPIErrors + """ + + def __init__(self, status, message): + self.status = status + self.message = message + + +class KeycloakMissingSettingError(Exception): + """ + Raised when a given Django Keycloak setting(s) is missing. + """ + + def __init__(self, setting: str): + super().__init__( + ( + f"The following settings are missing: '{setting}' " + "Please add them in 'KEYCLOAK_CONFIG' inside Django settings" + ) + ) + + +class KeycloakNoServiceAccountRolesError(Exception): + """ + Raised when the Keycloak server is not configured for + "Service account roles" for a particular client. + """ + + def __init__(self): + super().__init__( + ( + "'Service account roles' setting not enabled. " + f"Please enable this authentication workflow for client '{config.settings.CLIENT_ID}'." + ) + ) + + +class KeycloakMissingServiceAccountRolesError(Exception): + """ + Raised when the Keycloak server has service account roles enabled, + but a necessary role is missing. + """ + + def __init__(self): + super().__init__( + ( + "'Service account roles' setting is enabled, " + "but role 'manage-users' is missing. " + f"To enable it go to realm '{config.settings.REALM}' --> '{config.settings.CLIENT_ID}' client --> Service accounts roles " + " --> Assign role --> Filter by clients --> and add 'manage-users'." + ) + ) diff --git a/django_keycloak/management/__init__.py b/src/django_keycloak/management/__init__.py similarity index 100% rename from django_keycloak/management/__init__.py rename to src/django_keycloak/management/__init__.py diff --git a/django_keycloak/management/commands/__init__.py b/src/django_keycloak/management/commands/__init__.py similarity index 100% rename from django_keycloak/management/commands/__init__.py rename to src/django_keycloak/management/commands/__init__.py diff --git a/django_keycloak/management/commands/sync_keycloak_users.py b/src/django_keycloak/management/commands/sync_keycloak_users.py similarity index 83% rename from django_keycloak/management/commands/sync_keycloak_users.py rename to src/django_keycloak/management/commands/sync_keycloak_users.py index 1a39e9d..f140a1f 100644 --- a/django_keycloak/management/commands/sync_keycloak_users.py +++ b/src/django_keycloak/management/commands/sync_keycloak_users.py @@ -1,19 +1,19 @@ import logging as log -from django.core.management.base import BaseCommand from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand -from django_keycloak.keycloak import Connect +from django_keycloak.connector import lazy_keycloak_admin class Command(BaseCommand): help = "Synchronize users with keycloak" def handle(self, *args, **options): - keycloak = Connect() + User = get_user_model() - remote_users = set([user.get("id") for user in keycloak.get_users()]) + remote_users = set([user.get("id") for user in lazy_keycloak_admin.get_users()]) local_users = set(str(_u.id) for _u in User.objects.all()) users_to_remove = local_users.difference(remote_users) diff --git a/src/django_keycloak/managers.py b/src/django_keycloak/managers.py new file mode 100644 index 0000000..c254b71 --- /dev/null +++ b/src/django_keycloak/managers.py @@ -0,0 +1,70 @@ +""" +Module containing custom object managers +""" +from django.contrib.auth.models import UserManager +from django_keycloak import Token + + +class KeycloakUserManager(UserManager): + def create_from_token(self, token: Token, **kwargs): + """ + Create a new local database user from a valid token. + """ + + # Get user info from token + user_info = token.user_info + + # set admin permissions if user is admin + if token.is_superuser: + is_staff = is_superuser = True + else: + is_staff = is_superuser = False + + # Create the django user from the token information + user = self.model( + id=user_info.get("sub"), + username=user_info.get("preferred_username"), + is_staff=is_staff, + is_superuser=is_superuser, + **kwargs + ) + user.save(using=self._db) + return user + + def get_by_keycloak_id(self, keycloak_id): + return self.get(id=keycloak_id) + + +class KeycloakUserManagerAutoId(KeycloakUserManager): + def create_from_token(self, token: Token, **kwargs): + """ + Create a local new user from a valid token + """ + + user_info = token.user_info + + # set admin permissions if user is admin + if token.is_superuser: + is_staff = is_superuser = True + else: + is_staff = is_superuser = False + + # Create the django user from the token information + user = self.model( + keycloak_id=user_info.get("sub"), + username=user_info.get("preferred_username"), + first_name=user_info.get("given_name"), + last_name=user_info.get("family_name"), + email=user_info.get("email"), + is_staff=is_staff, + is_superuser=is_superuser, + **kwargs + ) + user.save(using=self._db) + return user + + def get_by_keycloak_id(self, keycloak_id): + """ + Returns a local user by keycloak id + """ + return self.get(keycloak_id=keycloak_id) diff --git a/src/django_keycloak/middleware.py b/src/django_keycloak/middleware.py new file mode 100644 index 0000000..2f98a8e --- /dev/null +++ b/src/django_keycloak/middleware.py @@ -0,0 +1,125 @@ +""" +Module containing custom middleware to authenticate, create and +sync user information between keycloak and local database. +""" +import base64 +import re +from typing import Optional, Union + +from django.contrib.auth import get_user_model +from django.utils.deprecation import MiddlewareMixin + +from django_keycloak import Token +from django_keycloak.config import settings +from django_keycloak.models import KeycloakUser, KeycloakUserAutoId + + +class KeycloakMiddleware(MiddlewareMixin): + """ + Middleware to validate Keycloak access based on REST validations + """ + + def get_token_from_request(self, request) -> Optional[Token]: + """ + Get the value of "HTTTP_AUTHORIZATION" request header. + If the authorization is "Bearer" it tries to produce a "Token" + instance from it. + If the authorization is "Basic" (username+password) it tries + to authenticate the user + """ + auth_type, value, *_ = request.META.get("HTTP_AUTHORIZATION").split() + + if auth_type == "Basic": + decoded_username, decoded_password = ( + base64.b64decode(value).decode("utf-8").split(":") + ) + # Try to build a Token instance from decoded credentials + token = Token.from_credentials(decoded_username, decoded_password) + if token: + # Convert the request "Basic" auth to "Bearer" with access token + request.META["HTTP_AUTHORIZATION"] = f"Bearer {token.access_token}" + else: + # Setup an invalid dummy bearer token + request.META["HTTP_AUTHORIZATION"] = "Bearer not-valid-token" + + elif auth_type == "Bearer": + token = Token.from_access_token(value) + + return token + + def append_user_info_to_request(self, request, token: Token): + """ + Appends user info to the request + """ + # Check if already appended in a previous request + if hasattr(request, "remote_user"): + return request + + user_info = token.user_info + + # add the remote user to request + request.remote_user = { + "client_roles": token.client_roles, + "realm_roles": token.realm_roles, + "client_scope": token.client_scopes, + "name": user_info.get("name"), + "given_name": user_info.get("given_name"), + "family_name": user_info.get("family_name"), + "username": user_info.get("preferred_username"), + "email": user_info.get("email"), + "email_verified": user_info.get("email_verified"), + } + + # Get the user model + User: Union[KeycloakUser, KeycloakUserAutoId] = get_user_model() # type: ignore + + # Create or update user info + try: + user = User.objects.get_by_keycloak_id(token.user_id) + + # Only KeycloakUserAutoId stores the user details locally + if isinstance(user, KeycloakUserAutoId): + user.first_name = user_info.get("given_name") + user.last_name = user_info.get("family_name") + user.email = user_info.get("email") + user.save() + + except User.DoesNotExist: + user = User.objects.create_from_token(token) + + # Add the local user to request + request.user = user + + return request + + @staticmethod + def has_auth_header(request) -> bool: + """Check if exists an authentication header in the HTTP request""" + return "HTTP_AUTHORIZATION" in request.META + + def process_request(self, request): + """ + To be executed before the view each request. + """ + # Skip auth in the following cases: + # 1. It is a URL in "EXEMPT_URIS" + # 2. Request does not contain authorization header + # Also skip auth for "EXEMPT_URIS" defined in configs + if self.pass_auth(request) or not self.has_auth_header(request): + return + + token: Union[Token, None] = self.get_token_from_request(request) + + # If token is None, access token was not valid + if token: + # Add user info to request for a valid token + self.append_user_info_to_request(request, token) + + def pass_auth(self, request): + """ + Check if the current URI path needs to skip authorization + """ + path = request.path_info.lstrip("/") + exempt_uris = settings.EXEMPT_URIS + + return any(re.match(m, path) for m in exempt_uris) diff --git a/django_keycloak/migrations/0001_initial.py b/src/django_keycloak/migrations/0001_initial.py similarity index 100% rename from django_keycloak/migrations/0001_initial.py rename to src/django_keycloak/migrations/0001_initial.py diff --git a/django_keycloak/migrations/0001_redo_migrations_0001to0005.py b/src/django_keycloak/migrations/0001_redo_migrations_0001to0005.py similarity index 100% rename from django_keycloak/migrations/0001_redo_migrations_0001to0005.py rename to src/django_keycloak/migrations/0001_redo_migrations_0001to0005.py diff --git a/django_keycloak/migrations/0002_auto_20210209_1503.py b/src/django_keycloak/migrations/0002_auto_20210209_1503.py similarity index 100% rename from django_keycloak/migrations/0002_auto_20210209_1503.py rename to src/django_keycloak/migrations/0002_auto_20210209_1503.py diff --git a/django_keycloak/migrations/0003_auto_20210406_1426.py b/src/django_keycloak/migrations/0003_auto_20210406_1426.py similarity index 100% rename from django_keycloak/migrations/0003_auto_20210406_1426.py rename to src/django_keycloak/migrations/0003_auto_20210406_1426.py diff --git a/django_keycloak/migrations/0004_keycloakuserautoid.py b/src/django_keycloak/migrations/0004_keycloakuserautoid.py similarity index 100% rename from django_keycloak/migrations/0004_keycloakuserautoid.py rename to src/django_keycloak/migrations/0004_keycloakuserautoid.py diff --git a/django_keycloak/migrations/0005_auto_20211231_1702.py b/src/django_keycloak/migrations/0005_auto_20211231_1702.py similarity index 100% rename from django_keycloak/migrations/0005_auto_20211231_1702.py rename to src/django_keycloak/migrations/0005_auto_20211231_1702.py diff --git a/django_keycloak/migrations/__init__.py b/src/django_keycloak/migrations/__init__.py similarity index 100% rename from django_keycloak/migrations/__init__.py rename to src/django_keycloak/migrations/__init__.py diff --git a/src/django_keycloak/mixins.py b/src/django_keycloak/mixins.py new file mode 100644 index 0000000..da0bc6f --- /dev/null +++ b/src/django_keycloak/mixins.py @@ -0,0 +1,53 @@ +from typing import Optional + +from django_keycloak.connector import lazy_keycloak_admin + + +class KeycloakTestMixin: + """ + Cleans up the users created on the Keycloak server as test side-effects. + + Stores all Keycloak users at the start of a test and compares them to those at the + end. Removes all new users. + + Usage: In the test class, derive from this mixin and call keycloak_init/teardown in + the setUp and tearDown functions. + """ + + def keycloak_init(self): + self._start_users = {user.get("id") for user in lazy_keycloak_admin.get_users()} + + def keycloak_cleanup(self): + new_users = {user.get("id") for user in lazy_keycloak_admin.get_users()} + users_to_remove = new_users.difference(self._start_users) + for user_id in users_to_remove: + lazy_keycloak_admin.delete_user(user_id) + + def create_user_on_keycloak( + self, + username: str, + email: str, + password: Optional[str] = None, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + enabled: bool = True, + actions: Optional[str] = None, + ) -> dict: + """ + Creates user on Keycloak's server. + No state is changed on local database. + """ + values = {"username": username, "email": email, "enabled": enabled} + if password is not None: + values["credentials"] = [ + {"type": "password", "value": password, "temporary": False} + ] + if first_name is not None: + values["firstName"] = first_name + if last_name is not None: + values["lastName"] = last_name + if actions is not None: + values["requiredActions"] = actions + + user_id = lazy_keycloak_admin.create_user(payload=values) + return lazy_keycloak_admin.get_user(user_id) diff --git a/django_keycloak/models.py b/src/django_keycloak/models.py similarity index 87% rename from django_keycloak/models.py rename to src/django_keycloak/models.py index e568e85..4e200be 100644 --- a/django_keycloak/models.py +++ b/src/django_keycloak/models.py @@ -4,11 +4,15 @@ from django.utils.translation import gettext_lazy as _ from dry_rest_permissions.generics import authenticated_users -from django_keycloak.keycloak import Connect +from .connector import lazy_keycloak_admin from .managers import KeycloakUserManager, KeycloakUserManagerAutoId class AbstractKeycloakUser(AbstractBaseUser, PermissionsMixin): + """ + Abstract Keycloak user. + """ + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._cached_user_info = None @@ -36,7 +40,6 @@ class Meta(AbstractBaseUser.Meta): abstract = True def update_keycloak(self, email=None, first_name=None, last_name=None): - keycloak = Connect() values = {} if email is not None: values["email"] = email @@ -44,11 +47,10 @@ def update_keycloak(self, email=None, first_name=None, last_name=None): values["firstName"] = first_name if last_name is not None: values["lastName"] = last_name - return keycloak.update_user(self.keycloak_identifier, **values) + return lazy_keycloak_admin.update_user(self.keycloak_identifier, **values) def delete_keycloak(self): - keycloak = Connect() - keycloak.delete_user(self.keycloak_identifier) + lazy_keycloak_admin.delete_user(self.keycloak_identifier) class KeycloakUser(AbstractKeycloakUser): @@ -74,8 +76,7 @@ def last_name(self): def _confirm_cache(self): if not self._cached_user_info: - keycloak = Connect() - self._cached_user_info = keycloak.get_user_info_by_id(self.id) + self._cached_user_info = lazy_keycloak_admin.get_user(self.id) class AbstractKeycloakUserAutoId(AbstractKeycloakUser): @@ -101,11 +102,6 @@ class AbstractKeycloakUserAutoId(AbstractKeycloakUser): def keycloak_identifier(self): return self.keycloak_id - def _confirm_cache(self): - if not self._cached_user_info: - keycloak = Connect() - self._cached_user_info = keycloak.get_user_info_by_id(self.keycloak_id) - class Meta: abstract = True diff --git a/django_keycloak/tasks.py b/src/django_keycloak/tasks.py similarity index 100% rename from django_keycloak/tasks.py rename to src/django_keycloak/tasks.py diff --git a/src/django_keycloak/token.py b/src/django_keycloak/token.py new file mode 100644 index 0000000..b24a456 --- /dev/null +++ b/src/django_keycloak/token.py @@ -0,0 +1,254 @@ +""" +Module to interact with the Keycloak token API +""" +from __future__ import annotations + +import logging +from typing import Optional + +from cachetools.func import ttl_cache +from jose.exceptions import JOSEError +from keycloak.exceptions import ( + KeycloakAuthenticationError, + KeycloakError, + KeycloakPostError, +) +from keycloak.keycloak_openid import KeycloakOpenID + +from django_keycloak.config import settings + +# Define keycloak openid instance +KEYCLOAK = KeycloakOpenID( + server_url=settings.KEYCLOAK_URL, + client_id=settings.CLIENT_ID, + realm_name=settings.REALM, + client_secret_key=settings.CLIENT_SECRET_KEY, +) + +logger = logging.getLogger(__name__) + + +class Token: + def __init__( + self, + access_token: Optional[str] = None, + refresh_token: Optional[str] = None, + ): + self.access_token = access_token + self.refresh_token = refresh_token + + @property + @ttl_cache(maxsize=1, ttl=60) + def public_key(self): + """ + Obtains the Keycloak's Public key, used for token + decodings. + + Raises: + KeycloakError: On Keycloak API errors + """ + + return f"-----BEGIN PUBLIC KEY-----\n{KEYCLOAK.public_key()}\n-----END PUBLIC KEY-----" + + @ttl_cache(maxsize=1, ttl=60) + def get_access_token_info(self) -> dict: + """ + Gets the information from a token either using token decode + or introspect, depending on `DECODE_TOKEN` setting. + + Raises: + JOSEError: On expired or invalid tokens + KeycloakError: On expired / invalid tokens or Keycloak API errors + """ + if not self.access_token: + return {} + # If user enabled `DECODE_TOKEN` using local decoding + if settings.DECODE_TOKEN: + return KEYCLOAK.decode_token( + self.access_token, + key=self.public_key, + options={"verify_aud": settings.VERIFY_AUDIENCE}, + ) + # Otherwise hit the Keycloak API for info + return KEYCLOAK.introspect(self.access_token) + + @ttl_cache(maxsize=1, ttl=60) + def get_refresh_token_info(self) -> dict: + """ + Gets the information from a token either using token decode + or introspect, depending on `DECODE_TOKEN` setting. + + Raises: + JOSEError: On expired or invalid tokens + KeycloakError: On expired / invalid tokens or Keycloak API errors + """ + if not self.refresh_token: + return {} + # If user enabled `DECODE_TOKEN` using local decoding + if settings.DECODE_TOKEN: + return KEYCLOAK.decode_token( + self.refresh_token, + key=self.public_key, + options={"verify_aud": settings.VERIFY_AUDIENCE}, + ) + # Otherwise hit the Keycloak API for info + return KEYCLOAK.introspect(self.refresh_token) + + @staticmethod + def _parse_keycloak_response(keycloak_response: dict) -> dict: + """ + Builds a dictionary mapping internal values to keycloak API + values + """ + return { + "access_token": keycloak_response.get("access_token"), + "refresh_token": keycloak_response.get("refresh_token"), + } + + # Properties + @property + def is_active(self) -> bool: + """ + Returns a boolean indicating if the current access token is active or not. + """ + try: + info = self.get_access_token_info() + except (JOSEError, KeycloakError) as err: + logger.debug( + "%s: %s", + type(err).__name__, + err.args, + exc_info=settings.TRACE_DEBUG_LOGS, + ) + return False + # Keycloak introspections return {"active": bool} + return info["active"] if "active" in info else True + + @property + def user_info(self) -> dict: + """ + Returns the user information contained on the provided access token. + + When DECODE_TOKEN and USER_INFO_IN_TOKEN are enabled the entire token is returned + + Raises: + JOSEError: On expired or invalid tokens + KeycloakError: On expired / invalid tokens or Keycloak API errors + """ + if settings.DECODE_TOKEN and settings.USER_INFO_IN_TOKEN: + return self.get_access_token_info() + return KEYCLOAK.userinfo(self.access_token) + + @property + def user_id(self) -> str: + """ + Returns the Keycloak user id + + Raises: + JOSEError: On expired or invalid tokens + KeycloakError: On expired / invalid tokens or Keycloak API errors + """ + return self.user_info.get("sub") # type: ignore + + @property + def is_superuser(self) -> bool: + """ + Check if token belongs to a user with superuser permissions + + Raises: + JOSEError: On expired or invalid tokens + KeycloakError: On expired / invalid tokens or Keycloak API errors + """ + if (settings.CLIENT_ADMIN_ROLE in self.client_roles) or ( # type: ignore + settings.REALM_ADMIN_ROLE in self.realm_roles + ): # type: ignore + return True + + return False + + @property + def client_roles(self) -> list: + """ + Returns the client roles based on the provided access token. + + Raises: + JOSEError: On expired or invalid tokens + KeycloakError: On expired / invalid tokens or Keycloak API errors + """ + return ( + self.get_access_token_info() + .get("resource_access", {}) + .get(settings.CLIENT_ID, {}) + .get("roles", []) + ) + + @property + def realm_roles(self) -> list: + """ + Returns the realm roles based on the access token. + + Raises: + JOSEError: On expired or invalid tokens + KeycloakError: On expired / invalid tokens or Keycloak API errors + """ + return self.get_access_token_info().get("realm_access", {}).get("roles", []) + + @property + def client_scopes(self) -> list: + """ + Returns the client scope based on the access token. + + Raises: + JOSEError: On expired or invalid tokens + KeycloakError: On expired / invalid tokens or Keycloak API errors + """ + return self.get_access_token_info().get("scope", "").split(" ") + + @classmethod + def from_credentials(cls, username: str, password: str) -> Optional[Token]: # type: ignore + """ + Creates a `Token` object from a set of user credentials. + Returns `None` if authentication fails. + """ + try: + keycloak_response = KEYCLOAK.token(username, password) + return cls(**cls._parse_keycloak_response(keycloak_response)) + # Catch authentication error (invalid credentials), + # and post error (account not completed.) + except (KeycloakAuthenticationError, KeycloakPostError) as err: + logger.debug( + f"{type(err).__name__}: {err.args}", exc_info=settings.TRACE_DEBUG_LOGS + ) + return None + + @classmethod + def from_access_token(cls, access_token: str) -> Optional[Token]: + """ + Creates a `Token` object from an existing access token. + Returns `None` if token is not active. + """ + instance = cls(access_token=access_token) + return instance if instance.is_active else None + + @classmethod + def from_refresh_token(cls, refresh_token: str) -> Optional[Token]: + """ + Creates a `Token` from the provided refresh token. + """ + instance = cls(refresh_token=refresh_token) + instance.refresh() + return instance if instance.is_active else None + + def refresh(self) -> None: + """ + Refreshes the `access_token` with `refresh_token`. + + Raises: + KeycloakError: On Keycloak API errors + """ + if self.refresh_token: + mapping = self._parse_keycloak_response( + KEYCLOAK.refresh_token(self.refresh_token) + ) + for key, value in mapping.items(): + setattr(self, key, value) diff --git a/tests/realm-export-13-14.json b/tests/realm-export-13-14.json new file mode 100644 index 0000000..98c2a23 --- /dev/null +++ b/tests/realm-export-13-14.json @@ -0,0 +1,2169 @@ +{ + "id": "test-realm", + "realm": "test-realm", + "displayName": "test-realm", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "305fdfc7-5e49-4797-8cb7-db3c483a678b", + "name": "default-roles-test-realm", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": [ + "offline_access", + "uma_authorization" + ] + }, + "clientRole": false, + "containerId": "test-realm", + "attributes": {} + }, + { + "id": "291b1491-fb37-4b78-9c88-01595e814efe", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "test-realm", + "attributes": {} + }, + { + "id": "8504ebf9-516d-4175-87e5-b6a4481201e5", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "test-realm", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "9f5ea67a-dd42-4d6d-bbac-fbb39bf392f2", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "ac20b228-484b-43c1-b136-f975597c71b9", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "d0b13bca-7007-49b1-adff-1a67903e2d78", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "dbaa223d-01d8-40c2-ba6a-b659b592b981", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "be3f4f7f-0ef4-4a5c-9d0b-c64ef96cc92d", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-groups", + "query-users" + ] + } + }, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "3c5420f3-edc4-4f41-969b-cc871a04ee4f", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "d40c4da9-9feb-4fc3-bd92-e9c0152944cb", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "93b16ea5-db53-4709-8b27-0deb18d74c5a", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "5c3eae4e-1d14-4f68-ae9a-0e4d279db58b", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "e7b9ddba-9f0a-44c8-9428-efbd027fc484", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "8a693097-dea2-49b1-a26f-d524a38a89f2", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "6c2c51e9-ef0c-4fb2-ba99-1baf8882637e", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "fe3f05a4-a35b-4af1-bc2c-9b5d68fe1b61", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "70cd1697-4693-428e-a556-727d554bab55", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "create-client", + "view-clients", + "view-authorization", + "view-users", + "manage-authorization", + "manage-users", + "view-realm", + "query-clients", + "impersonation", + "query-users", + "view-events", + "manage-realm", + "manage-identity-providers", + "manage-clients", + "view-identity-providers", + "query-groups", + "manage-events", + "query-realms" + ] + } + }, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "b9fd96e0-24f2-4e68-9476-19dc51badd6e", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "fc6d2ab2-4801-49ba-a3ad-e28b921ad887", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "ed42ccd8-bf88-4bf7-9799-ac65a6e168f9", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "6861be93-094d-4da7-975b-cc7f7834227a", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "f828a3ae-1989-4d4f-a1ba-c9ac70cf3cca", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + } + ], + "security-admin-console": [], + "admin-cli": [], + "test-client": [ + { + "id": "f18bdd68-1922-4131-8e36-9a28dbbd1a20", + "name": "admin", + "composite": false, + "clientRole": true, + "containerId": "42f4e729-0845-47fa-b810-73effdac174d", + "attributes": {} + } + ], + "account-console": [], + "broker": [], + "account": [ + { + "id": "44fd5143-9ce6-448f-91c0-27c503c2ac31", + "name": "manage-account", + "composite": false, + "clientRole": true, + "containerId": "b94a34e4-07ed-430a-937a-524ec283f133", + "attributes": {} + }, + { + "id": "7071637d-b154-4ad9-b85b-28c5a2c38ab9", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "b94a34e4-07ed-430a-937a-524ec283f133", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRole": { + "id": "305fdfc7-5e49-4797-8cb7-db3c483a678b", + "name": "default-roles-test-realm", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "test-realm" + }, + "requiredCredentials": [ + "password" + ], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpSupportedApplications": [ + "FreeOTP", + "Google Authenticator" + ], + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "users": [ + { + "id": "55c9883f-5f3e-42ce-91b8-6077fa1aedcc", + "createdTimestamp": 1665408867130, + "username": "service-account-test-client", + "enabled": true, + "totp": false, + "emailVerified": false, + "serviceAccountClientId": "test-client", + "disableableCredentialTypes": [], + "requiredActions": [], + "clientRoles": { + "realm-management": [ + "view-users", + "manage-users" + ] + }, + "notBefore": 0, + "groups": [] + } + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account" + ] + } + ] + }, + "clients": [ + { + "id": "b94a34e4-07ed-430a-937a-524ec283f133", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/test-realm/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/test-realm/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "0b32a005-e2dd-4a9b-bd47-bd489dc0acb8", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/test-realm/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/test-realm/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "9f74ca03-cd65-4335-8755-d1735ba19e3e", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "55b4d2a3-f1be-4b7d-9944-bcd5b60447db", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "e2d1436d-b71e-4184-a234-348beeb7447e", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "68488b0b-5f97-487c-a819-5611c4fd5eec", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/test-realm/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/admin/test-realm/console/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "c62260f4-4039-4132-b38b-8af5eaa10017", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "42f4e729-0845-47fa-b810-73effdac174d", + "clientId": "test-client", + "name": "Test client", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "f6974574-c773-4554-826d-06946cd55e98", + "redirectUris": [ + "https://www.keycloak.org/app/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "id.token.as.detached.signature": "false", + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false", + "oidc.ciba.grant.enabled": "false", + "saml.artifact.binding": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "require.pushed.authorization.requests": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "e9e07797-737f-4dad-a797-8123627de266", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "d9e50279-8961-4352-b748-1dafeb707d25", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientId", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientId", + "jsonType.label": "String" + } + }, + { + "id": "ed88c706-fc65-4b50-bf05-f849b4c0532a", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "b5497d53-fb7e-4cc3-81ff-68f2061d079e", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "cf829ad2-4347-4436-a016-18da66e54c79", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "2847ef05-5d1e-4211-b16a-5d474834c528", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "0617dc30-f043-491c-b2fb-67ecc64e9bce", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "5ffaa1a0-56f3-4395-a81a-160f58edc37c", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "6eeb72f3-ca3d-4fa3-be75-4aa8c69ea6ab", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "b98f31b5-eb47-4429-a74a-3b6d6ca9c5d6", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "5b6a6b2d-f599-41b7-b0c8-bbecfd13280e", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "dd5a0d47-8d1b-49c8-9c47-679a705cb59a", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "05a1cfcd-ac93-4157-9a9c-3a83bb8c94aa", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "2d468904-10b1-4f15-a6fe-b9318eb7af1a", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "53b02a27-6b63-4f10-9b87-428e3c453a1a", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "d5a02ddc-02d1-451f-9c7e-97b33fda2109", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "c7b33cb5-dc05-489f-bf8e-b582d24a89c8", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "ae072dfb-ea99-4104-be08-1e9c43461fb9", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + }, + { + "id": "cd54095f-dd0a-493a-be31-ca7755041668", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "3bb08518-f6c5-43fc-b564-13252ee346eb", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "438070db-06a2-448b-a06b-7b7d5d5c9187", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "af3d04d0-55cd-4b9d-b7ff-86f9ea2abcbb", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "b6f75950-e949-4754-aa61-3b8a4058d1da", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "ea344f21-4abb-4b61-872c-96bf22bdaf3d", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "db9793b8-1e8b-477a-8309-a686834e408f", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "2a975880-75ac-4512-9358-66fd806bbd1a", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "String" + } + }, + { + "id": "7c382312-410c-4c96-b553-e45fc7d6e188", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "d08fd728-cb3d-4fa3-bb02-1d1405700825", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "603eacd7-f079-4a8c-85dd-5ac65028489f", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "0df553b5-5545-4f2a-adc3-dfe4eb67c5c0", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "0ecba014-1e7e-42dc-9298-04ddd1ecdab8", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "be082211-af0e-40b8-823b-0c21f1002b4f", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "31adf350-6c29-4660-8f53-cfd01a5efc19", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "8b52ec84-2e3a-404d-861a-80f21b1f23c6", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "04dc1aa9-7843-4f40-a6af-f0e8c0b0ceeb", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "f7ede40e-84cd-4910-a643-7fb2a7c9e1f8", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "ecb17418-c4c6-4e20-aab6-73995a07e3e3", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "6d60697d-d03e-44ce-a551-30fbc82e992b", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "email", + "profile", + "roles", + "web-origins", + "role_list" + ], + "defaultOptionalClientScopes": [ + "phone", + "offline_access", + "microprofile-jwt", + "address" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "2988f987-fd17-4c9f-9772-29ebf1b11f9f", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "cb241f81-6b2a-42a4-b87a-c434af0eb504", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "60e96658-7cb0-4a85-9410-fc2f578a7eb9", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "61e8844b-a17d-4b9b-b2f3-661204457955", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-user-property-mapper", + "saml-role-list-mapper", + "oidc-address-mapper", + "oidc-usermodel-property-mapper", + "oidc-usermodel-attribute-mapper", + "saml-user-attribute-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-full-name-mapper" + ] + } + }, + { + "id": "92d52e91-38d3-4b8f-9fcf-ef945a7f4ba6", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "id": "aaea42fb-5359-43ef-9329-c3e1f9270f57", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-user-property-mapper", + "saml-role-list-mapper", + "oidc-usermodel-property-mapper", + "oidc-address-mapper", + "oidc-full-name-mapper", + "oidc-usermodel-attribute-mapper", + "saml-user-attribute-mapper", + "oidc-sha256-pairwise-sub-mapper" + ] + } + }, + { + "id": "c94c9225-c457-4007-be98-8ec05412f847", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "bc0a5284-7510-4502-8b9e-bd7f6b8b080d", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "75990713-490d-41fd-b509-43e6f06c718b", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "keyUse": [ + "sig" + ], + "priority": [ + "100" + ] + } + }, + { + "id": "39138abc-1e8b-4836-87b0-93e418b7ab45", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "bfa7d911-0d07-48f4-a70f-e7b0cbd509c5", + "name": "rsa-enc-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "keyUse": [ + "enc" + ], + "priority": [ + "100" + ] + } + }, + { + "id": "fcc694db-3503-48d0-a070-7592e5960c83", + "name": "hmac-generated", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS256" + ] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "ca5c4bd5-d1fe-42f4-9dd8-c85166fb34c3", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "45d64d38-3480-452e-85cc-88ae9469de3d", + "alias": "Authentication Options", + "description": "Authentication options.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "basic-auth", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "basic-auth-otp", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "9e27e34b-488c-49a0-bc9e-116ee63d374b", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "0859f079-dbba-4233-bd4b-b78a0b366563", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "9b8fe625-9fa0-4e12-b6bf-66e374f4d2a0", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "5bb82ccb-ff18-4c18-9f1f-93c089ebb604", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Account verification options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "3a0dddca-15c2-423f-8869-5f917b72e057", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "49c1227a-7b96-48bd-ba3f-80b4eaef570b", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "ffb6e551-7cfa-4aa9-abe2-92c44bcc3b91", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "feafea9a-a4ff-47db-88a0-175ee2e4923d", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "flowAlias": "forms", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "93085ce4-a863-4164-8f8d-8dfac4a779d1", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "a57140a1-f727-4c79-b15c-10568864b947", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "1967775a-815b-476a-b085-f884eb63be77", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "e8b4c7aa-eecb-4505-a860-8983db1ad7ce", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "User creation or linking", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "d471d051-e717-44cf-a687-4ca8fc8abb63", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "4264d059-b004-4cad-a3c0-2acaf8ce7877", + "alias": "http challenge", + "description": "An authentication flow based on challenge-response HTTP Authentication Schemes", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "no-cookie-redirect", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Authentication Options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "5e130404-b690-40da-81b1-3b08ab719853", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "flowAlias": "registration form", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "8d295e45-bf68-4dc0-bc6e-9796c8e891a6", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-profile-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "f6066e87-aabd-40f7-bd63-c592663b3aa2", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "990640bb-bf5d-46b9-a586-f6c90a62cabe", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "5b19c33b-0b5d-44dc-aff4-14afb731f1be", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "3303300b-ea00-4ff1-b50c-6a51728815a8", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "terms_and_conditions", + "name": "Terms and Conditions", + "providerId": "terms_and_conditions", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "clientOfflineSessionMaxLifespan": "0", + "oauth2DevicePollingInterval": "5", + "clientSessionIdleTimeout": "0", + "userProfileEnabled": "false", + "clientSessionMaxLifespan": "0", + "parRequestUriLifespan": "60", + "clientOfflineSessionIdleTimeout": "0", + "cibaInterval": "5" + }, + "keycloakVersion": "15.0.2", + "userManagedAccessAllowed": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} \ No newline at end of file diff --git a/tests/realm-export.json b/tests/realm-export.json new file mode 100644 index 0000000..ea9c097 --- /dev/null +++ b/tests/realm-export.json @@ -0,0 +1,2177 @@ +{ + "id": "test-realm", + "realm": "test-realm", + "displayName": "test-realm", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "roles": { + "realm": [ + { + "id": "305fdfc7-5e49-4797-8cb7-db3c483a678b", + "name": "default-roles-test-realm", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": [ + "offline_access", + "uma_authorization" + ] + }, + "clientRole": false, + "containerId": "test-realm", + "attributes": {} + }, + { + "id": "291b1491-fb37-4b78-9c88-01595e814efe", + "name": "uma_authorization", + "description": "${role_uma_authorization}", + "composite": false, + "clientRole": false, + "containerId": "test-realm", + "attributes": {} + }, + { + "id": "8504ebf9-516d-4175-87e5-b6a4481201e5", + "name": "offline_access", + "description": "${role_offline-access}", + "composite": false, + "clientRole": false, + "containerId": "test-realm", + "attributes": {} + } + ], + "client": { + "realm-management": [ + { + "id": "9f5ea67a-dd42-4d6d-bbac-fbb39bf392f2", + "name": "create-client", + "description": "${role_create-client}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "ac20b228-484b-43c1-b136-f975597c71b9", + "name": "view-clients", + "description": "${role_view-clients}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-clients" + ] + } + }, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "d0b13bca-7007-49b1-adff-1a67903e2d78", + "name": "view-authorization", + "description": "${role_view-authorization}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "dbaa223d-01d8-40c2-ba6a-b659b592b981", + "name": "manage-authorization", + "description": "${role_manage-authorization}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "be3f4f7f-0ef4-4a5c-9d0b-c64ef96cc92d", + "name": "view-users", + "description": "${role_view-users}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "query-groups", + "query-users" + ] + } + }, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "3c5420f3-edc4-4f41-969b-cc871a04ee4f", + "name": "manage-users", + "description": "${role_manage-users}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "d40c4da9-9feb-4fc3-bd92-e9c0152944cb", + "name": "query-clients", + "description": "${role_query-clients}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "93b16ea5-db53-4709-8b27-0deb18d74c5a", + "name": "view-realm", + "description": "${role_view-realm}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "5c3eae4e-1d14-4f68-ae9a-0e4d279db58b", + "name": "impersonation", + "description": "${role_impersonation}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "e7b9ddba-9f0a-44c8-9428-efbd027fc484", + "name": "query-users", + "description": "${role_query-users}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "8a693097-dea2-49b1-a26f-d524a38a89f2", + "name": "view-events", + "description": "${role_view-events}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "6c2c51e9-ef0c-4fb2-ba99-1baf8882637e", + "name": "manage-realm", + "description": "${role_manage-realm}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "fe3f05a4-a35b-4af1-bc2c-9b5d68fe1b61", + "name": "manage-identity-providers", + "description": "${role_manage-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "70cd1697-4693-428e-a556-727d554bab55", + "name": "realm-admin", + "description": "${role_realm-admin}", + "composite": true, + "composites": { + "client": { + "realm-management": [ + "create-client", + "view-clients", + "view-authorization", + "view-users", + "manage-authorization", + "manage-users", + "view-realm", + "query-clients", + "impersonation", + "query-users", + "view-events", + "manage-realm", + "manage-identity-providers", + "manage-clients", + "view-identity-providers", + "query-groups", + "manage-events", + "query-realms" + ] + } + }, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "b9fd96e0-24f2-4e68-9476-19dc51badd6e", + "name": "manage-clients", + "description": "${role_manage-clients}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "fc6d2ab2-4801-49ba-a3ad-e28b921ad887", + "name": "view-identity-providers", + "description": "${role_view-identity-providers}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "ed42ccd8-bf88-4bf7-9799-ac65a6e168f9", + "name": "manage-events", + "description": "${role_manage-events}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "6861be93-094d-4da7-975b-cc7f7834227a", + "name": "query-groups", + "description": "${role_query-groups}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + }, + { + "id": "f828a3ae-1989-4d4f-a1ba-c9ac70cf3cca", + "name": "query-realms", + "description": "${role_query-realms}", + "composite": false, + "clientRole": true, + "containerId": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "attributes": {} + } + ], + "security-admin-console": [], + "admin-cli": [], + "test-client": [ + { + "id": "f18bdd68-1922-4131-8e36-9a28dbbd1a20", + "name": "admin", + "composite": false, + "clientRole": true, + "containerId": "42f4e729-0845-47fa-b810-73effdac174d", + "attributes": {} + } + ], + "account-console": [], + "broker": [], + "account": [ + { + "id": "44fd5143-9ce6-448f-91c0-27c503c2ac31", + "name": "manage-account", + "composite": false, + "clientRole": true, + "containerId": "b94a34e4-07ed-430a-937a-524ec283f133", + "attributes": {} + }, + { + "id": "7071637d-b154-4ad9-b85b-28c5a2c38ab9", + "name": "delete-account", + "description": "${role_delete-account}", + "composite": false, + "clientRole": true, + "containerId": "b94a34e4-07ed-430a-937a-524ec283f133", + "attributes": {} + } + ] + } + }, + "groups": [], + "defaultRole": { + "id": "305fdfc7-5e49-4797-8cb7-db3c483a678b", + "name": "default-roles-test-realm", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "test-realm" + }, + "requiredCredentials": [ + "password" + ], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpSupportedApplications": [ + "FreeOTP", + "Google Authenticator" + ], + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "users": [ + { + "id": "55c9883f-5f3e-42ce-91b8-6077fa1aedcc", + "createdTimestamp": 1665408867130, + "username": "service-account-test-client", + "enabled": true, + "totp": false, + "emailVerified": false, + "serviceAccountClientId": "test-client", + "disableableCredentialTypes": [], + "requiredActions": [], + "clientRoles": { + "realm-management": [ + "view-users", + "manage-users" + ] + }, + "notBefore": 0, + "groups": [] + } + ], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account" + ] + } + ] + }, + "clients": [ + { + "id": "b94a34e4-07ed-430a-937a-524ec283f133", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/test-realm/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/test-realm/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "0b32a005-e2dd-4a9b-bd47-bd489dc0acb8", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/test-realm/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/test-realm/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "9f74ca03-cd65-4335-8755-d1735ba19e3e", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "55b4d2a3-f1be-4b7d-9944-bcd5b60447db", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "e2d1436d-b71e-4184-a234-348beeb7447e", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "59bf406c-0924-44fc-ac9c-0dd66e7cdde5", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "68488b0b-5f97-487c-a819-5611c4fd5eec", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/test-realm/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/admin/test-realm/console/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "c62260f4-4039-4132-b38b-8af5eaa10017", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "42f4e729-0845-47fa-b810-73effdac174d", + "clientId": "test-client", + "name": "Test client", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "f6974574-c773-4554-826d-06946cd55e98", + "redirectUris": [ + "https://www.keycloak.org/app/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": true, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "id.token.as.detached.signature": "false", + "saml.assertion.signature": "false", + "saml.force.post.binding": "false", + "saml.multivalued.roles": "false", + "saml.encrypt": "false", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "saml.server.signature": "false", + "saml.server.signature.keyinfo.ext": "false", + "use.refresh.tokens": "true", + "exclude.session.state.from.auth.response": "false", + "oidc.ciba.grant.enabled": "false", + "saml.artifact.binding": "false", + "backchannel.logout.session.required": "true", + "client_credentials.use_refresh_token": "false", + "saml_force_name_id_format": "false", + "require.pushed.authorization.requests": "false", + "saml.client.signature": "false", + "tls.client.certificate.bound.access.tokens": "false", + "saml.authnstatement": "false", + "display.on.consent.screen": "false", + "saml.onetimeuse.condition": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "protocolMappers": [ + { + "id": "e9e07797-737f-4dad-a797-8123627de266", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "d9e50279-8961-4352-b748-1dafeb707d25", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientId", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientId", + "jsonType.label": "String" + } + }, + { + "id": "ed88c706-fc65-4b50-bf05-f849b4c0532a", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "b5497d53-fb7e-4cc3-81ff-68f2061d079e", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "cf829ad2-4347-4436-a016-18da66e54c79", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "2847ef05-5d1e-4211-b16a-5d474834c528", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "0617dc30-f043-491c-b2fb-67ecc64e9bce", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + }, + { + "id": "5ffaa1a0-56f3-4395-a81a-160f58edc37c", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "6eeb72f3-ca3d-4fa3-be75-4aa8c69ea6ab", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "b98f31b5-eb47-4429-a74a-3b6d6ca9c5d6", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "5b6a6b2d-f599-41b7-b0c8-bbecfd13280e", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "userinfo.token.claim": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "dd5a0d47-8d1b-49c8-9c47-679a705cb59a", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "05a1cfcd-ac93-4157-9a9c-3a83bb8c94aa", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "2d468904-10b1-4f15-a6fe-b9318eb7af1a", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "53b02a27-6b63-4f10-9b87-428e3c453a1a", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "d5a02ddc-02d1-451f-9c7e-97b33fda2109", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "c7b33cb5-dc05-489f-bf8e-b582d24a89c8", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "ae072dfb-ea99-4104-be08-1e9c43461fb9", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + }, + { + "id": "cd54095f-dd0a-493a-be31-ca7755041668", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "3bb08518-f6c5-43fc-b564-13252ee346eb", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + } + ] + }, + { + "id": "438070db-06a2-448b-a06b-7b7d5d5c9187", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "af3d04d0-55cd-4b9d-b7ff-86f9ea2abcbb", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "b6f75950-e949-4754-aa61-3b8a4058d1da", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "ea344f21-4abb-4b61-872c-96bf22bdaf3d", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "db9793b8-1e8b-477a-8309-a686834e408f", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "2a975880-75ac-4512-9358-66fd806bbd1a", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "String" + } + }, + { + "id": "7c382312-410c-4c96-b553-e45fc7d6e188", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "d08fd728-cb3d-4fa3-bb02-1d1405700825", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "603eacd7-f079-4a8c-85dd-5ac65028489f", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "0df553b5-5545-4f2a-adc3-dfe4eb67c5c0", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "0ecba014-1e7e-42dc-9298-04ddd1ecdab8", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "be082211-af0e-40b8-823b-0c21f1002b4f", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "31adf350-6c29-4660-8f53-cfd01a5efc19", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "8b52ec84-2e3a-404d-861a-80f21b1f23c6", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "04dc1aa9-7843-4f40-a6af-f0e8c0b0ceeb", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "f7ede40e-84cd-4910-a643-7fb2a7c9e1f8", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "ecb17418-c4c6-4e20-aab6-73995a07e3e3", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "6d60697d-d03e-44ce-a551-30fbc82e992b", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + "email", + "profile", + "roles", + "web-origins", + "role_list" + ], + "defaultOptionalClientScopes": [ + "phone", + "offline_access", + "microprofile-jwt", + "address" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "2988f987-fd17-4c9f-9772-29ebf1b11f9f", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "cb241f81-6b2a-42a4-b87a-c434af0eb504", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "60e96658-7cb0-4a85-9410-fc2f578a7eb9", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "61e8844b-a17d-4b9b-b2f3-661204457955", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-user-property-mapper", + "saml-role-list-mapper", + "oidc-address-mapper", + "oidc-usermodel-property-mapper", + "oidc-usermodel-attribute-mapper", + "saml-user-attribute-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-full-name-mapper" + ] + } + }, + { + "id": "92d52e91-38d3-4b8f-9fcf-ef945a7f4ba6", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "id": "aaea42fb-5359-43ef-9329-c3e1f9270f57", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "saml-user-property-mapper", + "saml-role-list-mapper", + "oidc-usermodel-property-mapper", + "oidc-address-mapper", + "oidc-full-name-mapper", + "oidc-usermodel-attribute-mapper", + "saml-user-attribute-mapper", + "oidc-sha256-pairwise-sub-mapper" + ] + } + }, + { + "id": "c94c9225-c457-4007-be98-8ec05412f847", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "bc0a5284-7510-4502-8b9e-bd7f6b8b080d", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + } + ], + "org.keycloak.userprofile.UserProfileProvider": [ + { + "id": "b3c90897-d62a-45f5-b29e-ceb0cd9b0dc7", + "providerId": "declarative-user-profile", + "subComponents": {}, + "config": {} + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "75990713-490d-41fd-b509-43e6f06c718b", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "keyUse": [ + "sig" + ], + "priority": [ + "100" + ] + } + }, + { + "id": "39138abc-1e8b-4836-87b0-93e418b7ab45", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "bfa7d911-0d07-48f4-a70f-e7b0cbd509c5", + "name": "rsa-enc-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "keyUse": [ + "enc" + ], + "priority": [ + "100" + ] + } + }, + { + "id": "fcc694db-3503-48d0-a070-7592e5960c83", + "name": "hmac-generated", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS256" + ] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "ca5c4bd5-d1fe-42f4-9dd8-c85166fb34c3", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "45d64d38-3480-452e-85cc-88ae9469de3d", + "alias": "Authentication Options", + "description": "Authentication options.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "basic-auth", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "basic-auth-otp", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "9e27e34b-488c-49a0-bc9e-116ee63d374b", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "0859f079-dbba-4233-bd4b-b78a0b366563", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "9b8fe625-9fa0-4e12-b6bf-66e374f4d2a0", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "5bb82ccb-ff18-4c18-9f1f-93c089ebb604", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Account verification options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "3a0dddca-15c2-423f-8869-5f917b72e057", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "49c1227a-7b96-48bd-ba3f-80b4eaef570b", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "ffb6e551-7cfa-4aa9-abe2-92c44bcc3b91", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "feafea9a-a4ff-47db-88a0-175ee2e4923d", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "flowAlias": "forms", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "93085ce4-a863-4164-8f8d-8dfac4a779d1", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "a57140a1-f727-4c79-b15c-10568864b947", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "1967775a-815b-476a-b085-f884eb63be77", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "e8b4c7aa-eecb-4505-a860-8983db1ad7ce", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "User creation or linking", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "d471d051-e717-44cf-a687-4ca8fc8abb63", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "4264d059-b004-4cad-a3c0-2acaf8ce7877", + "alias": "http challenge", + "description": "An authentication flow based on challenge-response HTTP Authentication Schemes", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "no-cookie-redirect", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "flowAlias": "Authentication Options", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "5e130404-b690-40da-81b1-3b08ab719853", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "flowAlias": "registration form", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "8d295e45-bf68-4dc0-bc6e-9796c8e891a6", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-profile-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 40, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + }, + { + "id": "f6066e87-aabd-40f7-bd63-c592663b3aa2", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "userSetupAllowed": false, + "autheticatorFlow": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false, + "autheticatorFlow": true + } + ] + }, + { + "id": "990640bb-bf5d-46b9-a586-f6c90a62cabe", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "userSetupAllowed": false, + "autheticatorFlow": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "5b19c33b-0b5d-44dc-aff4-14afb731f1be", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "3303300b-ea00-4ff1-b50c-6a51728815a8", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "terms_and_conditions", + "name": "Terms and Conditions", + "providerId": "terms_and_conditions", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "clientOfflineSessionMaxLifespan": "0", + "oauth2DevicePollingInterval": "5", + "clientSessionIdleTimeout": "0", + "userProfileEnabled": "false", + "clientSessionMaxLifespan": "0", + "parRequestUriLifespan": "60", + "clientOfflineSessionIdleTimeout": "0", + "cibaInterval": "5" + }, + "keycloakVersion": "15.0.2", + "userManagedAccessAllowed": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} \ No newline at end of file diff --git a/tests/start.sh b/tests/start.sh new file mode 100755 index 0000000..95061ee --- /dev/null +++ b/tests/start.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# Exit with nonzero exit code if anything fails +set -eo pipefail +# Ensure the script is running in this directory +cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" + +KEYCLOAK_URL=http://$KEYCLOAK_HOST:$KEYCLOAK_PORT +echo "Waiting for Keycloak to launch on $KEYCLOAK_URL..." +# Abort after 10 seconds to avoid blocking (-m --> max time) +while ! curl -s -f -o /dev/null -m 2 "$KEYCLOAK_URL/auth/realms/master"; do + echo "Waiting..." + sleep 2 & + wait +done + +if (($(curl -s -o /dev/null -w "%{http_code}" "$KEYCLOAK_URL/auth/realms/$KEYCLOAK_REALM") != 200)); then + echo "Importing debug Keycloak setup" + # Get an access token + KEYCLOAK_TOKEN_RESPONSE=$(curl -s \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=password" \ + -d "client_id=admin-cli" \ + -d "username=$KEYCLOAK_ADMIN_USER" \ + -d "password=$KEYCLOAK_ADMIN_PASSWORD" \ + "$KEYCLOAK_URL/auth/realms/master/protocol/openid-connect/token") + KEYCLOAK_TOKEN=$(echo "$KEYCLOAK_TOKEN_RESPONSE" | jq -r .access_token) + + # Get Keycloak server version to add the right config file + KEYCLOAK_VERSION="$(curl -s \ + -H "Content-Type: application/json" \ + -H "Authorization: bearer $KEYCLOAK_TOKEN" \ + "$KEYCLOAK_URL/auth/admin/serverinfo" \ + | jq '.systemInfo.version')" + echo "Keycloak version: $KEYCLOAK_VERSION" + VERSION_MAJOR="$(echo "$KEYCLOAK_VERSION" | tr -d \" | cut -d . -f 1)" + if [ "$VERSION_MAJOR" -le 14 ]; then + REALM_FILE="realm-export-13-14.json" + else + REALM_FILE="realm-export.json" + fi + + # Combine the realm and user config and send it to the Keycloak server + HTTP_CODE=$(curl -X POST --data-binary "@$REALM_FILE" \ + -s -o /dev/null -w "%{http_code}" \ + -H "Content-Type: application/json" \ + -H "Authorization: bearer $KEYCLOAK_TOKEN" \ + "$KEYCLOAK_URL/auth/admin/realms") + if ((HTTP_CODE < 200 || HTTP_CODE >= 300)); then + echo "Failed to import the $KEYCLOAK_REALM realm ($HTTP_CODE)" + exit 1 + fi + unset KEYCLOAK_TOKEN +else + echo "Realm '$KEYCLOAK_REALM' already imported into Keycloak" +fi + +poetry run test_site/manage.py test test_app diff --git a/tests/test_site/manage.py b/tests/test_site/manage.py new file mode 100755 index 0000000..7d0b206 --- /dev/null +++ b/tests/test_site/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_site.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/tests/test_site/test_app/__init__.py b/tests/test_site/test_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_site/test_app/apps.py b/tests/test_site/test_app/apps.py new file mode 100644 index 0000000..f168fd5 --- /dev/null +++ b/tests/test_site/test_app/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class TestAppConfig(AppConfig): + name = "test_app" diff --git a/tests/test_site/test_app/tests/__init__.py b/tests/test_site/test_app/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_site/test_app/tests/test_init.py b/tests/test_site/test_app/tests/test_init.py new file mode 100644 index 0000000..083aee4 --- /dev/null +++ b/tests/test_site/test_app/tests/test_init.py @@ -0,0 +1,24 @@ +from django.test import TestCase +from django_keycloak import Token +from django_keycloak.connector import lazy_keycloak_admin +from django_keycloak.mixins import KeycloakTestMixin + + +class TestInit(KeycloakTestMixin, TestCase): + def setUp(self): + self.keycloak_init() + + def tearDown(self): + self.keycloak_cleanup() + + def test_model(self): + user_a = self.create_user_on_keycloak( + username="ownerA", + email="user@example.com", + password="PWowNerA0!", + first_name="Owner", + last_name="AAAA", + ) + lazy_keycloak_admin.update_user(user_a["id"], {"emailVerified": True}) + valid_token = Token.from_credentials(username="ownerA", password="PWowNerA0!") + self.assertTrue(valid_token) diff --git a/tests/test_site/test_app/tests/test_middleware.py b/tests/test_site/test_app/tests/test_middleware.py new file mode 100644 index 0000000..f20c90e --- /dev/null +++ b/tests/test_site/test_app/tests/test_middleware.py @@ -0,0 +1,42 @@ +from django.test import TestCase +from django.urls import reverse +from django_keycloak.connector import lazy_keycloak_admin +from django_keycloak.mixins import KeycloakTestMixin + + +class TestMiddleware(KeycloakTestMixin, TestCase): + def setUp(self): + self.keycloak_init() + self.user_password = "PWowNerA0!" + self.keycloak_user = self.create_user_on_keycloak( + username="ownerA", + email="user@example.com", + password=self.user_password, + first_name="Owner", + last_name="AAAA", + ) + + def tearDown(self): + self.keycloak_cleanup() + + def test_simple_api_call(self): + response = self.client.get(reverse("test_app:simple")) + self.assertEqual(response.json()["status"], "ok") + + def test_user_auth(self): + tokens = lazy_keycloak_admin.keycloak_openid.token( + username=self.keycloak_user["username"], + password=self.user_password, + ) + header = {"HTTP_AUTHORIZATION": f"Bearer {tokens['access_token']}"} + response = self.client.get(reverse("test_app:who_am_i"), **header) + data = response.json() + self.assertFalse(data.pop("isAnonymous")) + self.assertDictContainsSubset(data, self.keycloak_user) + + +class TestErrorHandling(TestCase): + def test_invalid_auth_token(self): + header = {"HTTP_AUTHORIZATION": "Bearer DummyJWT"} + response = self.client.get(reverse("test_app:who_am_i"), **header) + self.assertTrue(response.json()["isAnonymous"]) diff --git a/tests/test_site/test_app/urls.py b/tests/test_site/test_app/urls.py new file mode 100644 index 0000000..20d8360 --- /dev/null +++ b/tests/test_site/test_app/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from test_app.views import Simple, WhoAmI + +app_name = "test_app" + +urlpatterns = [ + path("simple/", Simple.as_view(), name="simple"), + path("who-am-i/", WhoAmI.as_view(), name="who_am_i"), +] diff --git a/tests/test_site/test_app/views.py b/tests/test_site/test_app/views.py new file mode 100644 index 0000000..07a0af4 --- /dev/null +++ b/tests/test_site/test_app/views.py @@ -0,0 +1,21 @@ +from rest_framework.response import Response +from rest_framework.views import APIView + + +class Simple(APIView): + def get(self, request): + return Response({"status": "ok"}) + + +class WhoAmI(APIView): + def get(self, request): + if request.user.is_anonymous: + return Response({"isAnonymous": True}) + user_data = { + "isAnonymous": False, + "username": request.user.username, + "email": request.user.email, + "firstName": request.user.first_name, + "lastName": request.user.last_name, + } + return Response(user_data) diff --git a/tests/test_site/test_site/__init__.py b/tests/test_site/test_site/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_site/test_site/asgi.py b/tests/test_site/test_site/asgi.py new file mode 100644 index 0000000..781494f --- /dev/null +++ b/tests/test_site/test_site/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for test_site project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_site.settings") + +application = get_asgi_application() diff --git a/tests/test_site/test_site/settings.py b/tests/test_site/test_site/settings.py new file mode 100644 index 0000000..f524205 --- /dev/null +++ b/tests/test_site/test_site/settings.py @@ -0,0 +1,177 @@ +""" +Django settings for test_site project. + +Generated by 'django-admin startproject' using Django 4.1.2. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.1/ref/settings/ +""" + +import os +import sys +from pathlib import Path + +LOGGING = { + "version": 1, + "formatters": { + "line_scope": { + "format": f"%(levelname)s | %(name)s.%(funcName)s:%(lineno)s | %(message)s" + }, + }, + "handlers": { + "console_line_scope": { + "class": "logging.StreamHandler", + "formatter": "line_scope", + }, + }, + "loggers": { + "": { + "handlers": ["console_line_scope"], + "level": "INFO", + "propagate": False, + }, + "django_keycloak": { + "handlers": ["console_line_scope"], + "level": os.getenv("DJANGO_LOG_LEVEL", "INFO"), + "propagate": False, + }, + }, +} + + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent +sys.path.append(os.path.abspath(os.path.join(BASE_DIR, "../../src"))) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-ep3)qk(t@*3+-f=p9%1q4o0u*vxoaf3)@t)r29t70wpyh+q(%m" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django_keycloak", + "test_app", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django_keycloak.middleware.KeycloakMiddleware", +] + +ROOT_URLCONF = "test_site.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "test_site.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/4.1/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +# Authentication +AUTH_USER_MODEL = "django_keycloak.KeycloakUser" + +AUTHENTICATION_BACKENDS = ("django_keycloak.backends.KeycloakAuthenticationBackend",) + +# Internationalization +# https://docs.djangoproject.com/en/4.1/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.1/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# KEYCLOAK + +keycloak_server = f"http://{os.getenv('KEYCLOAK_HOST')}:{os.getenv('KEYCLOAK_PORT')}" + +KEYCLOAK_CONFIG = { + "SERVER_URL": keycloak_server, + "INTERNAL_URL": keycloak_server, + "REALM": os.getenv("KEYCLOAK_REALM"), + "CLIENT_ID": os.getenv("KEYCLOAK_CLIENT_ID"), + "CLIENT_SECRET_KEY": os.getenv("KEYCLOAK_CLIENT_SECRET_KEY"), + "CLIENT_ADMIN_ROLE": "admin", + "REALM_ADMIN_ROLE": "admin", + "EXEMPT_URIS": [], + "DECODE_TOKEN": True, + "TRACE_DEBUG_LOGS": True, +} diff --git a/tests/test_site/test_site/urls.py b/tests/test_site/test_site/urls.py new file mode 100644 index 0000000..822af89 --- /dev/null +++ b/tests/test_site/test_site/urls.py @@ -0,0 +1,22 @@ +"""test_site URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("test-app/", include("test_app.urls")), +] diff --git a/tests/test_site/test_site/wsgi.py b/tests/test_site/test_site/wsgi.py new file mode 100644 index 0000000..80ec01f --- /dev/null +++ b/tests/test_site/test_site/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for test_site project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_site.settings") + +application = get_wsgi_application()