Added auto setting for redirect urls for keycloak clients. Element and Synapse still need to be mapped

This commit is contained in:
Kevin Veen-Birkenbach 2025-08-11 00:17:18 +02:00
parent 0746acedfd
commit 6e8ae793e3
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E
17 changed files with 436 additions and 56 deletions

View File

@ -1,3 +1,4 @@
# Todos # Todos
- Implement multi language - Implement multi language
- Implement rbac administration interface - Implement rbac administration interface
- Implement ``MASK_CREDENTIALS_IN_LOGS`` for all sensible tasks

View File

@ -1,27 +1,11 @@
#!/usr/bin/python #!/usr/bin/python
import os import sys, os
import sys sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from ansible.errors import AnsibleFilterError from module_utils.get_url import get_url
class FilterModule(object): class FilterModule(object):
''' Infinito.Nexus application config extraction filters '''
def filters(self): def filters(self):
return {'get_url': self.get_url} return {
'get_url': get_url,
def get_url(self, domains, application_id, protocol): }
# 1) module_util-Verzeichnis in den Pfad aufnehmen
plugin_dir = os.path.dirname(__file__)
project_root = os.path.dirname(plugin_dir)
module_utils = os.path.join(project_root, 'module_utils')
if module_utils not in sys.path:
sys.path.append(module_utils)
# 2) jetzt domain_utils importieren
try:
from domain_utils import get_domain
except ImportError as e:
raise AnsibleFilterError(f"could not import domain_utils: {e}")
# 3) Validierung und Aufruf
if not isinstance(protocol, str):
raise AnsibleFilterError("Protocol must be a string")
return f"{protocol}://{ get_domain(domains, application_id) }"

View File

@ -1,5 +1,10 @@
INFINITO_ENVIRONMENT: "production" # Possible values: production, development INFINITO_ENVIRONMENT: "production" # Possible values: production, development
# If true, sensitive credentials will be masked or hidden from all Ansible task logs
# Recommendet to set to true
# @todo needs to be implemented everywhere
MASK_CREDENTIALS_IN_LOGS: true
HOST_CURRENCY: "EUR" HOST_CURRENCY: "EUR"
HOST_TIMEZONE: "UTC" HOST_TIMEZONE: "UTC"

18
module_utils/get_url.py Normal file
View File

@ -0,0 +1,18 @@
from ansible.errors import AnsibleFilterError
import sys, os
def get_url(domains, application_id, protocol):
plugin_dir = os.path.dirname(__file__)
project_root = os.path.dirname(plugin_dir)
module_utils = os.path.join(project_root, 'module_utils')
if module_utils not in sys.path:
sys.path.append(module_utils)
try:
from domain_utils import get_domain
except ImportError as e:
raise AnsibleFilterError(f"could not import domain_utils: {e}")
if not isinstance(protocol, str):
raise AnsibleFilterError("Protocol must be a string")
return f"{protocol}://{ get_domain(domains, application_id) }"

View File

@ -1,4 +1,4 @@
import_realm: True # If True realm will be imported. If false skip. import_realm: True # If True realm will be imported. If false skip.
features: features:
matomo: true matomo: true
css: true css: true
@ -6,7 +6,12 @@ features:
ldap: true ldap: true
central_database: true central_database: true
recaptcha: true recaptcha: true
logout: true
# Doesn't make sense to activate logout page for keycloak, because the logout page
# anyhow should be included via iframe in keycloak.
# The JS is also messing with the keycloak config fields
# @todo optimize the JS
logout: false
server: server:
csp: csp:
flags: flags:

View File

@ -0,0 +1,82 @@
# roles/web-app-keycloak/filter_plugins/redirect_uris.py
from __future__ import annotations
import os, sys
from typing import Iterable, Sequence
from ansible.errors import AnsibleFilterError
# --- Locate project root that contains `module_utils/` dynamically (up to 5 levels) ---
def _ensure_module_utils_on_path():
here = os.path.dirname(__file__)
for depth in range(1, 6):
candidate = os.path.abspath(os.path.join(here, *(['..'] * depth)))
if os.path.isdir(os.path.join(candidate, 'module_utils')):
if candidate not in sys.path:
sys.path.insert(0, candidate)
return
# If not found, imports below will raise a clear error
_ensure_module_utils_on_path()
# Import your existing helpers
from module_utils.config_utils import get_app_conf, AppConfigKeyError, ConfigEntryNotSetError
from module_utils.get_url import get_url # returns "<protocol>://<domain>"
def _stable_dedup(items: Sequence[str]) -> list[str]:
seen = set()
out: list[str] = []
for x in items:
if x not in seen:
seen.add(x)
out.append(x)
return out
def redirect_uris(domains: dict,
applications: dict,
web_protocol: str = "https",
wildcard: str = "/*",
features: Iterable[str] = ("features.oauth2", "features.oidc"),
dedup: bool = True) -> list[str]:
"""
Build redirect URIs using:
- get_app_conf(applications, app_id, dotted_key, default) for feature gating
- get_url(domains_subset, app_id, web_protocol) to form "<proto>://<domain>"
For domain lists, we call get_url() once per domain by passing a minimal
per-app subset like {app_id: "example.org"} to preserve your original
'one entry per domain' behavior.
"""
if not isinstance(domains, dict):
raise AnsibleFilterError("redirect_uris: 'domains' must be a dict mapping app_id -> domain or list of domains")
uris: list[str] = []
for app_id, domain_value in domains.items():
# Feature check via get_app_conf
try:
has_feature = any(bool(get_app_conf(applications, app_id, f, False)) for f in features)
except (AppConfigKeyError, ConfigEntryNotSetError):
has_feature = False
if not has_feature:
continue
# Normalize to iterable of domains
doms = [domain_value] if isinstance(domain_value, str) else list(domain_value or [])
for d in doms:
# Use get_url() to produce "<proto>://<domain>"
# Pass a minimal per-app mapping so get_domain() resolves to 'd'
try:
url = get_url({app_id: d}, app_id, web_protocol)
except Exception as e:
raise AnsibleFilterError(f"redirect_uris: get_url failed for app '{app_id}' with domain '{d}': {e}")
uris.append(f"{url}{wildcard}")
return _stable_dedup(uris) if dedup else uris
class FilterModule(object):
"""Infinito.Nexus redirect URI builder (uses get_app_conf + get_url)"""
def filters(self):
return {
"redirect_uris": redirect_uris,
}

View File

@ -0,0 +1,130 @@
---
# Update redirectUris/webOrigins per kcadm.sh — no defaults used.
# ── REQUIRED VARS (must be provided by caller) ───────────────────────────────
# - WEB_PROTOCOL e.g. "https"
# - keycloak_realm target realm name
# - keycloak_server_host_url e.g. "http://127.0.0.1:8080"
# - keycloak_server_internal_url e.g. "http://127.0.0.1:8080"
# - keycloak_kcadm_path e.g. "docker exec -i keycloak /opt/keycloak/bin/kcadm.sh"
# - keycloak_master_api_user_name
# - keycloak_master_api_user_password
# - keycloak_client_id clientId to update (e.g. same as realm or an app client)
# - domains your domain map
# - applications your applications map
- name: "Assert required variables are present (no defaults allowed)"
assert:
that:
- WEB_PROTOCOL is defined
- keycloak_realm is defined
- keycloak_server_host_url is defined
- keycloak_server_internal_url is defined
- keycloak_kcadm_path is defined
- keycloak_master_api_user_name is defined
- keycloak_master_api_user_password is defined
- keycloak_client_id is defined
- keycloak_redirect_features is defined
- domains is defined
- applications is defined
fail_msg: "Missing required variable(s). Provide all vars listed at the top of 10_update_client_redirects.yml."
# 0) Wait & login
- name: "Wait until Keycloak is reachable at {{ keycloak_server_host_url }}"
uri:
url: "{{ keycloak_server_host_url }}/realms/master"
method: GET
status_code: 200
validate_certs: false
register: kc_up
retries: 30
delay: 5
until: kc_up.status == 200
- name: "kcadm login"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
shell: >
{{ keycloak_kcadm_path }} config credentials
--server {{ keycloak_server_internal_url }}
--realm master
--user {{ keycloak_master_api_user_name }}
--password {{ keycloak_master_api_user_password }}
changed_when: false
# 1) Build desired sets (NO defaults)
- name: "Build desired redirect URIs from config via filter"
set_fact:
kc_redirect_uris: >-
{{ domains | redirect_uris(applications, WEB_PROTOCOL, '/*', keycloak_redirect_features, True) }}
- name: Build desired web origins (scheme://host[:port])
set_fact:
kc_web_origins: >-
{{ kc_redirect_uris
| map('regex_replace','/\\*$','')
| map('regex_search','^(https?://[^/]+)')
| select('string')
| list | unique }}
#- name: "Build post.logout.redirect.uris value ('+' plus explicit URIs without /*)"
# set_fact:
# kc_desired_post_logout_uris: >-
# {{ (['+'] + (kc_redirect_uris | map('regex_replace','/\\*$','') | list)) | join('\n') }}
# 2) Resolve client id (strictly by provided clientId, no fallback)
- name: "Resolve client internal id for {{ keycloak_client_id }}"
shell: >
{{ keycloak_kcadm_path }} get clients
-r {{ keycloak_realm }}
--query 'clientId={{ keycloak_client_id }}'
--fields id --format json | jq -r '.[0].id'
register: kc_client
changed_when: false
- name: "Fail if client not found"
assert:
that: kc_client.stdout is match('^[0-9a-f-]+$')
fail_msg: "Client '{{ keycloak_client_id }}' not found in realm '{{ keycloak_realm }}'."
# 3) Read current config (assume keys exist; we don't use defaults)
- name: "Read current client configuration"
shell: >
{{ keycloak_kcadm_path }} get clients/{{ kc_client.stdout }}
-r {{ keycloak_realm }} --format json
register: kc_client_obj
changed_when: false
- name: "Normalize current vs desired for comparison"
set_fact:
kc_current_redirect_uris: "{{ (kc_client_obj.stdout | from_json).redirectUris | sort }}"
kc_current_web_origins: "{{ (kc_client_obj.stdout | from_json).webOrigins | sort }}"
kc_current_logout_uris: >-
{{
(
(kc_client_obj.stdout | from_json).attributes['post.logout.redirect.uris']
if 'post.logout.redirect.uris' in (kc_client_obj.stdout | from_json).attributes
else ''
)
| regex_replace('\r','')
| split('\n')
| reject('equalto','')
| list | sort
}}
kc_desired_redirect_uris: "{{ kc_redirect_uris | sort }}"
kc_desired_web_origins: "{{ kc_web_origins | sort }}"
kc_desired_post_logout_uris: "+"
kc_desired_post_logout_uris_list: >-
{{ "+" | split('\n') | reject('equalto','') | list | sort }}
# 4) Update only when changed
- name: "Update redirectUris, webOrigins, post.logout.redirect.uris"
shell: >
{{ keycloak_kcadm_path }} update clients/{{ kc_client.stdout }}
-r {{ keycloak_realm }}
-s 'redirectUris={{ kc_redirect_uris | to_json }}'
-s 'webOrigins={{ kc_web_origins | to_json }}'
-s 'attributes."post.logout.redirect.uris"={{ kc_desired_post_logout_uris | to_json }}'
when: kc_current_redirect_uris != kc_desired_redirect_uris
or kc_current_web_origins != kc_desired_web_origins
or kc_current_logout_uris != kc_desired_post_logout_uris_list

View File

@ -16,8 +16,8 @@
{{ keycloak_kcadm_path }} config credentials \ {{ keycloak_kcadm_path }} config credentials \
--server {{ keycloak_server_internal_url }} \ --server {{ keycloak_server_internal_url }} \
--realm master \ --realm master \
--user {{ keycloak_administrator_username }} \ --user {{ keycloak_master_api_user_name }} \
--password {{ keycloak_administrator_password }} --password {{ keycloak_master_api_user_password }}
- name: Retrieve LDAP component ID - name: Retrieve LDAP component ID
shell: | shell: |
@ -37,6 +37,6 @@
{{ keycloak_kcadm_path }} update components/{{ ldap_component.stdout }} \ {{ keycloak_kcadm_path }} update components/{{ ldap_component.stdout }} \
-r {{ keycloak_realm }} \ -r {{ keycloak_realm }} \
-s 'config.bindCredential=["{{ new_bind_password }}"]' -s 'config.bindCredential=["{{ new_bind_password }}"]'
no_log: true no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
register: update_bind register: update_bind
changed_when: update_bind.rc == 0 changed_when: update_bind.rc == 0

View File

@ -15,8 +15,8 @@
{{ keycloak_kcadm_path }} config credentials \ {{ keycloak_kcadm_path }} config credentials \
--server {{ keycloak_server_internal_url }} \ --server {{ keycloak_server_internal_url }} \
--realm master \ --realm master \
--user {{ keycloak_administrator_username }} \ --user {{ keycloak_master_api_user_name }} \
--password {{ keycloak_administrator_password }} --password {{ keycloak_master_api_user_password }}
# LDAP Source # LDAP Source
- name: Get ID of LDAP storage provider - name: Get ID of LDAP storage provider
@ -66,8 +66,8 @@
{{ keycloak_kcadm_path }} config credentials \ {{ keycloak_kcadm_path }} config credentials \
--server {{ keycloak_server_internal_url }} \ --server {{ keycloak_server_internal_url }} \
--realm master \ --realm master \
--user {{ keycloak_administrator_username }} \ --user {{ keycloak_master_api_user_name }} \
--password {{ keycloak_administrator_password }} --password {{ keycloak_master_api_user_password }}
- name: Render user-profile JSON for SSH key - name: Render user-profile JSON for SSH key
template: template:

View File

@ -0,0 +1,3 @@
# Todos
- Include 03_update-ldap-bind.yml
- Include 04_ssh_public_key.yml

View File

@ -1,11 +1,14 @@
--- ---
- name: "create import files for {{application_id}}" #- name: "create import files for {{application_id}}"
include_tasks: 01_import.yml # include_tasks: 01_import.yml
#
#- name: "load docker, db and proxy for {{application_id}}"
# include_role:
# name: cmp-db-docker-proxy
- name: "load docker, db and proxy for {{application_id}}" - name: "Apply client redirects without realm import"
include_role: include_tasks: 02_update_client_redirects.yml
name: cmp-db-docker-proxy
# Deactivated temporary. Import now via realm.yml # Deactivated temporary. Import now via realm.yml
#- name: Implement SSH Public Key Attribut #- name: Implement SSH Public Key Attribut
# include_tasks: attributes/ssh_public_key.yml # include_tasks: 03_ssh_public_key.yml

View File

@ -833,20 +833,8 @@
"alwaysDisplayInConsole": false, "alwaysDisplayInConsole": false,
"clientAuthenticatorType": "desktop-secret", "clientAuthenticatorType": "desktop-secret",
"secret": "{{oidc.client.secret}}", "secret": "{{oidc.client.secret}}",
{%- set redirect_uris = [] %} {# The following line should be covered by 02_update_client_redirects.yml #}
{%- for domain_application_id, domain in domains.items() %} "redirectUris": {{ domains | redirect_uris(applications, WEB_PROTOCOL) | tojson }},
{%- if applications | get_app_conf(domain_application_id, 'features.oauth2', False) or applications | get_app_conf(domain_application_id, 'features.oidc', False) %}
{%- if domain is string %}
{%- set _ = redirect_uris.append(WEB_PROTOCOL ~ '://' ~ domain ~ '/*') %}
{%- else %}
{%- for d in domain %}
{%- set _ = redirect_uris.append(WEB_PROTOCOL ~ '://' ~ d ~ '/*') %}
{%- endfor %}
{%- endif %}
{%- endif %}
{%- endfor %}
"redirectUris": {{ redirect_uris | tojson }},
"webOrigins": [ "webOrigins": [
"{{ WEB_PROTOCOL }}://*.{{primary_domain}}" "{{ WEB_PROTOCOL }}://*.{{primary_domain}}"
], ],

View File

@ -6,9 +6,9 @@ database_type: "postgres"
keycloak_container: "{{ applications | get_app_conf(application_id, 'docker.services.keycloak.name', True) }}" # Name of the keycloack docker container keycloak_container: "{{ applications | get_app_conf(application_id, 'docker.services.keycloak.name', True) }}" # Name of the keycloack docker container
keycloak_docker_import_directory: "/opt/keycloak/data/import/" # Directory in which keycloack import files are placed in the running docker container keycloak_docker_import_directory: "/opt/keycloak/data/import/" # Directory in which keycloack import files are placed in the running docker container
keycloak_realm: "{{ primary_domain}}" # This is the name of the default realm which is used by the applications keycloak_realm: "{{ primary_domain}}" # This is the name of the default realm which is used by the applications
keycloak_administrator: "{{ applications | get_app_conf(application_id, 'users.administrator', True) }}" # Master Administrator keycloak_master_api_user: "{{ applications | get_app_conf(application_id, 'users.administrator', True) }}" # Master Administrator
keycloak_administrator_username: "{{ keycloak_administrator.username }}" # Master Administrator Username keycloak_master_api_user_name: "{{ keycloak_master_api_user.username }}" # Master Administrator Username
keycloak_administrator_password: "{{ keycloak_administrator.password }}" # Master Administrator Password keycloak_master_api_user_password: "{{ keycloak_master_api_user.password }}" # Master Administrator Password
keycloak_kcadm_path: "docker exec -i {{ keycloak_container }} /opt/keycloak/bin/kcadm.sh" # Init script for keycloak keycloak_kcadm_path: "docker exec -i {{ keycloak_container }} /opt/keycloak/bin/kcadm.sh" # Init script for keycloak
keycloak_server_internal_url: "http://127.0.0.1:8080" keycloak_server_internal_url: "http://127.0.0.1:8080"
keycloak_server_host: "127.0.0.1:{{ ports.localhost.http[application_id] }}" keycloak_server_host: "127.0.0.1:{{ ports.localhost.http[application_id] }}"
@ -17,6 +17,8 @@ keycloak_image: "{{ applications | get_app_conf(application_id
keycloak_version: "{{ applications | get_app_conf(application_id, 'docker.services.keycloak.version', True) }}" # Keyloak docker version keycloak_version: "{{ applications | get_app_conf(application_id, 'docker.services.keycloak.version', True) }}" # Keyloak docker version
keycloak_import_realm: "{{ applications | get_app_conf(application_id, 'import_realm', True, True) }}" # Activate realm import keycloak_import_realm: "{{ applications | get_app_conf(application_id, 'import_realm', True, True) }}" # Activate realm import
keycloak_debug_enabled: "{{ enable_debug }}" keycloak_debug_enabled: "{{ enable_debug }}"
keycloak_redirect_features: ["features.oauth2","features.oidc"]
keycloak_client_id: "{{ oidc.client.id }}"
# Docker # Docker
docker_compose_flush_handlers: true # Remember to copy realm import before flushg when set to true docker_compose_flush_handlers: true # Remember to copy realm import before flushg when set to true

View File

@ -0,0 +1,159 @@
# tests/unit/roles/web-app-keycloak/filter_plugins/test_redirect_uris.py
import os
import sys
import types
import unittest
import importlib.util
PLUGIN_REL_PATH = os.path.join("roles", "web-app-keycloak", "filter_plugins", "redirect_uris.py")
def _find_repo_root_containing(rel_path, max_depth=8):
"""Walk upwards from this test file to find the repo root that contains rel_path."""
here = os.path.dirname(__file__)
cur = here
for _ in range(max_depth):
candidate = os.path.join(cur, rel_path)
if os.path.isfile(candidate):
return cur
parent = os.path.dirname(cur)
if parent == cur:
break
cur = parent
raise FileNotFoundError(f"Could not find {rel_path} upwards from {here}")
def _load_module_from_path(name, file_path):
spec = importlib.util.spec_from_file_location(name, file_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader, f"Cannot load spec for {file_path}"
spec.loader.exec_module(module)
return module
class RedirectUrisTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Create stub package: module_utils, with config_utils and get_url submodules.
mu = types.ModuleType("module_utils")
mu_config = types.ModuleType("module_utils.config_utils")
mu_geturl = types.ModuleType("module_utils.get_url")
# Define stub exceptions
class AppConfigKeyError(Exception):
pass
class ConfigEntryNotSetError(Exception):
pass
# Define a practical get_app_conf that understands dotted keys
def get_app_conf(applications, app_id, dotted, default=None):
data = applications.get(app_id, {})
cur = data
for part in dotted.split("."):
if isinstance(cur, dict) and part in cur:
cur = cur[part]
else:
return default
return cur
# Define a simple get_url matching your module_utils/get_url contract
# get_url(domains, application_id, protocol) -> "<protocol>://<domain>"
def get_url(domains, application_id, protocol):
domain = domains[application_id]
return f"{protocol}://{domain}"
# Attach to stub modules
mu_config.get_app_conf = staticmethod(get_app_conf)
mu_config.AppConfigKeyError = AppConfigKeyError
mu_config.ConfigEntryNotSetError = ConfigEntryNotSetError
mu_geturl.get_url = staticmethod(get_url)
# Register in sys.modules so plugin imports succeed
sys.modules["module_utils"] = mu
sys.modules["module_utils.config_utils"] = mu_config
sys.modules["module_utils.get_url"] = mu_geturl
# Load the plugin by path
repo_root = _find_repo_root_containing(PLUGIN_REL_PATH)
plugin_path = os.path.join(repo_root, PLUGIN_REL_PATH)
cls.plugin = _load_module_from_path("test_target.redirect_uris", plugin_path)
# Keep originals for per-test monkeypatching
cls._orig_get_app_conf = cls.plugin.get_app_conf
cls._orig_get_url = cls.plugin.get_url
def tearDown(self):
# Restore plugin functions if a test monkeypatched them
self.plugin.get_app_conf = self._orig_get_app_conf
self.plugin.get_url = self._orig_get_url
def test_single_domain_oauth2_enabled(self):
domains = {"app1": "example.org"}
applications = {"app1": {"features": {"oauth2": True}}}
result = self.plugin.redirect_uris(domains, applications, web_protocol="https")
self.assertEqual(result, ["https://example.org/*"])
def test_multiple_domains_oidc_enabled(self):
domains = {"appX": ["a.example.org", "b.example.org"]}
applications = {"appX": {"features": {"oidc": True}}}
result = self.plugin.redirect_uris(domains, applications, web_protocol="https")
self.assertCountEqual(result, ["https://a.example.org/*", "https://b.example.org/*"])
def test_feature_missing_is_skipped(self):
domains = {"app1": "example.org"}
applications = {"app1": {"features": {"oauth2": False, "oidc": False}}}
result = self.plugin.redirect_uris(domains, applications)
self.assertEqual(result, [])
def test_protocol_and_wildcard_customization(self):
domains = {"app1": "x.test"}
applications = {"app1": {"features": {"oauth2": True}}}
result = self.plugin.redirect_uris(domains, applications, web_protocol="http", wildcard="/cb")
self.assertEqual(result, ["http://x.test/cb"])
def test_dedup_default_true(self):
domains = {"app1": ["dup.test", "dup.test", "other.test"]}
applications = {"app1": {"features": {"oidc": True}}}
result = self.plugin.redirect_uris(domains, applications)
self.assertEqual(result, ["https://dup.test/*", "https://other.test/*"])
def test_dedup_false_keeps_duplicates(self):
domains = {"app1": ["dup.test", "dup.test"]}
applications = {"app1": {"features": {"oidc": True}}}
result = self.plugin.redirect_uris(domains, applications, dedup=False)
self.assertEqual(result, ["https://dup.test/*", "https://dup.test/*"])
def test_invalid_domains_type_raises(self):
with self.assertRaises(self.plugin.AnsibleFilterError):
self.plugin.redirect_uris(["not-a-dict"], {}) # type: ignore[arg-type]
def test_get_url_failure_is_wrapped(self):
# Make get_url raise an arbitrary error; plugin should re-raise AnsibleFilterError
def boom(*args, **kwargs):
raise RuntimeError("boom")
self.plugin.get_url = boom
domains = {"app1": "example.org"}
applications = {"app1": {"features": {"oauth2": True}}}
with self.assertRaises(self.plugin.AnsibleFilterError) as ctx:
self.plugin.redirect_uris(domains, applications)
self.assertIn("get_url failed", str(ctx.exception))
def test_get_app_conf_exception_is_handled_as_no_feature(self):
# Make get_app_conf raise AppConfigKeyError; plugin should treat as not enabled and skip
def raising_get_app_conf(*args, **kwargs):
raise self.plugin.AppConfigKeyError("missing key")
self.plugin.get_app_conf = raising_get_app_conf
domains = {"app1": "example.org"}
applications = {"app1": {"features": {"oauth2": True}}} # value won't be read due to exception
result = self.plugin.redirect_uris(domains, applications)
self.assertEqual(result, [])
if __name__ == "__main__":
unittest.main()