from __future__ import annotations import hashlib import hmac import json import re from collections.abc import Callable from dataclasses import dataclass from typing import Any from urllib.parse import quote, urljoin, urlparse, urlsplit from uuid import RFC_4122, UUID import httpx from ..core.config import settings from ..core.redaction import redact_message from ..schemas.forge_oidc import ( FORGE_OIDC_APPLICATION_NAME, FORGE_OIDC_CALLBACK_URL, FORGE_OIDC_CLIENT_ID, FORGE_OIDC_DISCOVERY_URL, FORGE_OIDC_EMAIL_MAPPING_EXPRESSION_SHA256, FORGE_OIDC_EMAIL_MAPPING_MANAGED, FORGE_OIDC_GROUPS, FORGE_OIDC_ISSUER, FORGE_OIDC_LAUNCH_URL, FORGE_OIDC_MANAGED_DESCRIPTION, FORGE_OIDC_OPENID_MAPPING_EXPRESSION_SHA256, FORGE_OIDC_OPENID_MAPPING_MANAGED, FORGE_OIDC_PROFILE_MAPPING_EXPRESSION, FORGE_OIDC_PROFILE_MAPPING_EXPRESSION_SHA256, FORGE_OIDC_PROFILE_MAPPING_NAME, FORGE_OIDC_PROVIDER_NAME, ForgeOidcScopeMappingIdsV1, ) from .base import BaseConnector _FORGE_OIDC_AUTHENTIK_URL = 'https://auth.example-host.com' DEPLOYMENT_OIDC_PROFILE_MAPPING_EXPRESSION = '''return delete_none_values({ "name": request.user.name, "preferred_username": request.user.username, })''' _FORGE_OIDC_SCOPE_MAPPING_IDS = ( 'AUTHENTIK_FORGE_OIDC_OPENID_CLAIM_MAPPING', 'AUTHENTIK_FORGE_OIDC_EMAIL_CLAIM_MAPPING', 'AUTHENTIK_FORGE_OIDC_PROFILE_CLAIM_MAPPING', ) _FORGE_OIDC_GROUP_READ_PARAMS = { 'include_users': 'false', 'include_children': 'true', 'include_parents': 'true', 'include_inherited_roles': 'false', } def _authentik_id(value: Any) -> str: if isinstance(value, dict): value = value.get('pk') or value.get('id') return str(value or '') def _authentik_enum(value: Any) -> str: if isinstance(value, dict): value = value.get('value') or value.get('type') or value.get('name') or value.get('slug') return str(value or '').strip().lower() def _forge_oidc_redirect_is_exact(value: Any) -> bool: if not isinstance(value, list) or len(value) != 1 or not isinstance(value[0], dict): return False redirect = value[0] return ( str(redirect.get('url') or '') == FORGE_OIDC_CALLBACK_URL and _authentik_enum(redirect.get('matching_mode')) == 'strict' and set(redirect) == {'matching_mode', 'url'} ) def _normalized_expression_sha256(value: Any) -> str: if not isinstance(value, str): return '' normalized = value.replace('\r\n', '\n').strip() return hashlib.sha256(normalized.encode('utf-8')).hexdigest() def _forge_oidc_scope_mapping_matches( mapping: Any, *, mapping_id: str, scope: str, ) -> bool: if not isinstance(mapping, dict) or _authentik_id(mapping) != mapping_id: return False if str(mapping.get('scope_name') or '') != scope: return False if scope == 'openid': return ( mapping.get('managed') == FORGE_OIDC_OPENID_MAPPING_MANAGED and _normalized_expression_sha256(mapping.get('expression')) == FORGE_OIDC_OPENID_MAPPING_EXPRESSION_SHA256 ) if scope == 'email': return ( mapping.get('managed') == FORGE_OIDC_EMAIL_MAPPING_MANAGED and _normalized_expression_sha256(mapping.get('expression')) == FORGE_OIDC_EMAIL_MAPPING_EXPRESSION_SHA256 ) return ( scope == 'profile' and str(mapping.get('name') or '') == FORGE_OIDC_PROFILE_MAPPING_NAME and not mapping.get('managed') and _normalized_expression_sha256(mapping.get('expression')) == FORGE_OIDC_PROFILE_MAPPING_EXPRESSION_SHA256 and str(mapping.get('expression') or '').replace('\r\n', '\n').strip() == FORGE_OIDC_PROFILE_MAPPING_EXPRESSION ) def _forge_oidc_provider_id(value: Any) -> str | None: candidate = str(value or '') return candidate if re.fullmatch(r'[1-9][0-9]{0,9}', candidate) else None def _forge_oidc_application_id(value: Any) -> str | None: candidate = str(value or '') try: parsed = UUID(candidate) except (TypeError, ValueError, AttributeError): return None if str(parsed) != candidate or parsed.variant != RFC_4122 or parsed.version not in range(1, 9): return None return candidate def _forge_oidc_group_contract_matches( group: Any, *, require_empty_users: bool = False, require_users: bool = False, ) -> bool: if not isinstance(group, dict): return False valid = ( isinstance(group.get('name'), str) and group.get('is_superuser') is False and group.get('attributes') == {} and group.get('parents') == [] and group.get('children') == [] and group.get('roles') == [] ) if require_users: valid = valid and isinstance(group.get('users'), list) if require_empty_users: valid = valid and group.get('users') == [] return bool(valid) @dataclass class _AuthentikConfig: url: str token: str verify_tls: bool timeout_seconds: float outpost_url: str | None expect_embedded_outpost: bool def _as_bool(value: Any, default: bool = False) -> bool: if isinstance(value, bool): return value if value is None: return default return str(value).strip().lower() in {'1', 'true', 'yes', 'on'} def _as_float(value: Any, default: float) -> float: try: return float(value) except (TypeError, ValueError): return default def _as_int(value: Any, default: int = 0) -> int: try: return int(float(value)) except (TypeError, ValueError): return default def _token_value(value: Any) -> str: token = str(value or '').strip() if token.lower().startswith('bearer '): return token[7:].strip() return token def _as_list(value: Any) -> list[Any]: if isinstance(value, list): return value if value in {None, ''}: return [] return [value] def _safe_url(value: Any = None) -> str: raw = str(value or settings.apis.get('AUTHENTIK_URL') or '').strip().rstrip('/') if not raw: return '' parsed = urlparse(raw) if not parsed.username and not parsed.password: return raw host = parsed.hostname or '' port = f':{parsed.port}' if parsed.port else '' return f'{parsed.scheme}://{host}{port}{parsed.path}'.rstrip('/') def _domain(value: Any) -> str: text = str(value or '').strip() if not text: return '' parsed = urlparse(text if '://' in text else f'https://{text}') return (parsed.hostname or text).lower().strip() def _response_items(payload: Any) -> list[dict[str, Any]]: if isinstance(payload, list): return [item for item in payload if isinstance(item, dict)] if isinstance(payload, dict): data = payload.get('data') if isinstance(data, list): return [item for item in data if isinstance(item, dict)] if isinstance(payload.get('results'), list): return [item for item in payload['results'] if isinstance(item, dict)] if isinstance(payload.get('items'), list): return [item for item in payload['items'] if isinstance(item, dict)] return [] def _pagination_next(payload: Any) -> str | None: if not isinstance(payload, dict): return None pagination = payload.get('pagination') if isinstance(pagination, dict) and pagination.get('next'): return str(pagination['next']) if payload.get('next'): return str(payload['next']) return None class AuthentikConnector(BaseConnector): name = 'Authentik' def __init__(self): super().__init__() self._read_cache: dict[str, dict[str, Any]] = {} self._deployment_template_config: dict[str, str] | None = None self._deployment_template_issuer: str | None = None def configure_deployment_oidc_template(self, config: dict[str, str] | None, *, issuer_origin: str | None = None) -> None: self._deployment_template_config = dict(config) if config is not None else None self._deployment_template_issuer = issuer_origin self._read_cache.clear() @staticmethod def deployment_oidc_origin_matches(issuer_origin: str) -> bool: """Root templates use the established tenant over verified HTTPS.""" apis = dict(settings.apis) return AuthentikConnector._deployment_oidc_endpoint_matches(issuer_origin, apis) @staticmethod def _deployment_oidc_endpoint_matches(issuer_origin: str, apis: dict[str, Any]) -> bool: try: endpoint = urlsplit(str(apis.get('AUTHENTIK_URL') or '')) origin = f'{endpoint.scheme}://{endpoint.netloc.lower()}' return bool( endpoint.scheme == 'https' and endpoint.hostname and not endpoint.username and not endpoint.password and endpoint.path in {'', '/'} and not endpoint.query and not endpoint.fragment and origin == issuer_origin and _as_bool(apis.get('AUTHENTIK_VERIFY_TLS'), False) ) except ValueError: return False def _deployment_oidc_config(self) -> dict[str, Any]: if self._deployment_template_issuer and not self.deployment_oidc_origin_matches(self._deployment_template_issuer): return {} return self._deployment_template_config if self._deployment_template_config is not None else settings.apis def _memo(self, key: str, factory: Any) -> dict[str, Any]: if key not in self._read_cache: self._read_cache[key] = factory() return self._read_cache[key] def is_configured(self) -> bool: return bool(settings.apis.get('AUTHENTIK_URL') and settings.apis.get('AUTHENTIK_TOKEN')) def _config(self) -> _AuthentikConfig | None: apis = dict(settings.apis) if not (apis.get('AUTHENTIK_URL') and apis.get('AUTHENTIK_TOKEN')): return None if self._deployment_template_issuer and not self._deployment_oidc_endpoint_matches(self._deployment_template_issuer, apis): return None return _AuthentikConfig( url=str(apis['AUTHENTIK_URL']).rstrip('/'), token=_token_value(apis['AUTHENTIK_TOKEN']), verify_tls=_as_bool(apis.get('AUTHENTIK_VERIFY_TLS'), False), timeout_seconds=_as_float(apis.get('AUTHENTIK_TIMEOUT_SECONDS'), 10.0), outpost_url=str(apis.get('AUTHENTIK_OUTPOST_URL') or '').strip().rstrip('/') or None, expect_embedded_outpost=_as_bool(apis.get('AUTHENTIK_EXPECT_EMBEDDED_OUTPOST'), False), ) def _request(self, path: str, params: dict[str, Any] | None = None) -> Any: config = self._config() if not config: raise RuntimeError('missing_config') url = path if path.startswith(('http://', 'https://')) else f'{config.url}/api/v3{path}' try: self.clear_error() with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client: response = client.get( url, params=params, headers={'Accept': 'application/json', 'Authorization': f'Bearer {config.token}'}, ) except httpx.RequestError as exc: message = redact_message(str(exc)) self.set_error(message) raise RuntimeError(message) if response.status_code in {401, 403}: self.set_error('auth_failed') raise RuntimeError('auth_failed') if response.status_code >= 400: message = redact_message(f'Authentik API error: HTTP {response.status_code} {(response.text or "")[:256]}') self.set_error(message) raise RuntimeError(message) try: return response.json() except ValueError as exc: message = redact_message(str(exc)) self.set_error(message) raise RuntimeError(message) def _write_request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any: """Use the existing Authentik API identity for one typed mutation. Callers in this repository pass only a DeploymentPlan-derived payload; this method is private so routes cannot turn it into a generic Authentik proxy. """ config = self._config() if not config: raise RuntimeError('missing_config') try: self.clear_error() with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client: request_kwargs: dict[str, Any] = { 'headers': {'Accept': 'application/json', 'Authorization': f'Bearer {config.token}'}, } if payload is not None: request_kwargs['json'] = payload response = client.request(method, f'{config.url}/api/v3{path}', **request_kwargs) except httpx.RequestError as exc: message = redact_message(str(exc)) self.set_error(message) raise RuntimeError(message) if response.status_code in {401, 403}: self.set_error('auth_failed') raise RuntimeError('auth_failed') if response.status_code >= 400: message = redact_message(f'Authentik API error: HTTP {response.status_code} {(response.text or "")[:256]}') self.set_error(message) raise RuntimeError(message) if not response.content: return {} try: return response.json() except ValueError as exc: message = redact_message(str(exc)) self.set_error(message) raise RuntimeError(message) def _paged(self, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]: config = self._config() if not config: raise RuntimeError('missing_config') items: list[dict[str, Any]] = [] base_path = path base_params = dict(params or {}) next_path: str | None = path next_params = params for _ in range(20): if not next_path: break payload = self._request(next_path, next_params) items.extend(_response_items(payload)) next_value = _pagination_next(payload) if not next_value: break if next_value.isdigit(): next_path = base_path next_params = {**base_params, 'page': int(next_value)} continue if next_value.startswith(('http://', 'https://')): parsed_base = urlparse(config.url) parsed_next = urlparse(next_value) if parsed_next.netloc and parsed_next.netloc != parsed_base.netloc: break next_path = next_value else: next_path = urljoin('/api/v3/', next_value).replace('/api/v3', '', 1) next_params = None return items def test_connection(self) -> dict[str, Any]: if not self.is_configured(): return {'status': 'missing_config', 'reason': 'missing_config'} try: root = self._request('/root/config/') except RuntimeError as exc: reason = redact_message(str(exc)) if reason.startswith('auth_failed'): return {'status': 'auth_failed', 'reason': reason} if reason == 'missing_config': return {'status': 'missing_config', 'reason': reason} return {'status': 'unreachable', 'reason': reason} return {'status': 'connected', 'version': self._version(root)} def health(self) -> dict[str, Any]: if not self.is_configured(): return self._missing(applicationCount=0, outpostAvailable=False) try: result = self.test_connection() if result.get('status') != 'connected': return self._error_payload(RuntimeError(str(result.get('reason', result.get('status')))), applicationCount=0, outpostAvailable=False) apps = self.applications() outpost = self.outpost_health() except RuntimeError as exc: return self._error_payload(exc, applicationCount=0, outpostAvailable=False) return { 'success': True, 'status': 'connected', 'message': 'Authentik connector ready.', 'url': _safe_url(), 'version': str(result.get('version') or 'unknown'), 'applicationCount': len(apps.get('items', [])), 'outpostAvailable': bool(outpost.get('available')), 'data': {}, } def system_status(self) -> dict[str, Any]: if not self.is_configured(): return self._missing() try: root = self._request('/root/config/') except RuntimeError as exc: return self._error_payload(exc) data = { 'version': self._version(root), 'brand': root.get('brand') if isinstance(root, dict) else None, 'url': _safe_url(), } return {'success': True, 'status': 'ok', 'message': 'system_status_ok', **data, 'data': data} def applications(self) -> dict[str, Any]: return self._memo('applications', lambda: self._collection('/core/applications/', self._map_applications, 'applications_ok')) def providers(self) -> dict[str, Any]: return self._memo('providers', lambda: self._collection('/core/providers/', self._map_providers, 'providers_ok')) def proxy_providers(self) -> dict[str, Any]: return self._memo('proxy_providers', lambda: self._collection('/providers/proxy/', self._map_proxy_providers, 'proxy_providers_ok')) def oauth2_providers(self) -> dict[str, Any]: return self._memo('oauth2_providers', lambda: self._collection('/providers/oauth2/', self._map_oauth2_providers, 'oauth2_providers_ok')) def groups(self) -> dict[str, Any]: """Read role-group identities for enrollment takeover-safe preflight.""" return self._memo( 'groups', lambda: self._collection( '/core/groups/', lambda items: [ {'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or '')} for item in items ], 'groups_ok', ), ) @staticmethod def deployment_oidc_template_status(*, role_claim: str = 'roles', template_config: dict[str, Any] | None = None) -> dict[str, Any]: """Report only whether the server-owned OIDC template is configured.""" config = settings.apis if template_config is None else template_config required = ( 'AUTHENTIK_OIDC_AUTHORIZATION_FLOW', 'AUTHENTIK_OIDC_INVALIDATION_FLOW', 'AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING', 'AUTHENTIK_OIDC_OPENID_CLAIM_MAPPING', 'AUTHENTIK_OIDC_EMAIL_CLAIM_MAPPING', 'AUTHENTIK_OIDC_PROFILE_CLAIM_MAPPING', 'AUTHENTIK_OIDC_SIGNING_KEY_ID', ) missing = [name for name in required if not str(config.get(name) or '').strip()] configured_claim = str(config.get('AUTHENTIK_OIDC_ROLE_CLAIM') or '').strip() if configured_claim != role_claim: missing.append('AUTHENTIK_OIDC_ROLE_CLAIM') return { 'status': 'ready' if not missing else 'missing_config', 'missing': missing, } def validate_deployment_oidc_template( self, *, role_claim: str, role_groups: tuple[str, ...], ) -> dict[str, Any]: """Read the selected template; never return remote bodies or expressions.""" config = self._deployment_oidc_config() configured = self.deployment_oidc_template_status(role_claim=role_claim, template_config=config) if configured['status'] != 'ready': return configured if role_claim != 'roles' or len(set(role_groups)) != 3 or any(not group for group in role_groups): return {'status': 'unavailable', 'reason': 'oidc_role_model_invalid'} authorization_id = str(config['AUTHENTIK_OIDC_AUTHORIZATION_FLOW']) invalidation_id = str(config['AUTHENTIK_OIDC_INVALIDATION_FLOW']) mapping_id = str(config['AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING']) expression = ( 'return {"roles": sorted({group.name for group in request.user.ak_groups.all() ' 'if group.name in ' + json.dumps(sorted(role_groups)) + '})}' ) try: # Flow detail is keyed by slug; OAuth providers reference UUIDs. flows = self._paged('/flows/instances/') for identifier, designation in ( (authorization_id, 'authorization'), (invalidation_id, 'invalidation'), ): matching_flows = [flow for flow in flows if isinstance(flow, dict) and _authentik_id(flow) == identifier] if (len(matching_flows) != 1 or _authentik_enum(matching_flows[0].get('designation')) != designation): return {'status': 'unavailable', 'reason': 'oidc_flow_template_invalid'} mapping = self._request(f'/propertymappings/provider/scope/{quote(mapping_id, safe="")}/') if (not isinstance(mapping, dict) or str(mapping.get('pk')) != mapping_id or mapping.get('scope_name') != role_claim or str(mapping.get('expression') or '').strip() != expression): return {'status': 'unavailable', 'reason': 'oidc_role_mapping_invalid'} for scope in ('openid', 'email', 'profile'): identifier = str(config[f'AUTHENTIK_OIDC_{scope.upper()}_CLAIM_MAPPING']) mapping = self._request(f'/propertymappings/provider/scope/{quote(identifier, safe="")}/') if (not isinstance(mapping, dict) or _authentik_id(mapping) != identifier or mapping.get('scope_name') != scope): return {'status': 'unavailable', 'reason': 'oidc_identity_scope_invalid'} # Every attached mapping can contribute token claims. Scope # names alone cannot prevent an alternate administrator claim. if scope in {'openid', 'email'}: identity_matches = _forge_oidc_scope_mapping_matches( mapping, mapping_id=identifier, scope=scope, ) else: identity_matches = ( not mapping.get('managed') and str(mapping.get('expression') or '').replace('\r\n', '\n').strip() == DEPLOYMENT_OIDC_PROFILE_MAPPING_EXPRESSION ) if not identity_matches: return {'status': 'unavailable', 'reason': 'oidc_identity_scope_invalid'} key_id = str(config['AUTHENTIK_OIDC_SIGNING_KEY_ID']) keys = self._paged('/crypto/certificatekeypairs/', {'has_key': True}) matching = [key for key in keys if _authentik_id(key) == key_id] if (len(matching) != 1 or _authentik_enum(matching[0].get('key_type')) != 'rsa' or matching[0].get('private_key_available') is not True): return {'status': 'unavailable', 'reason': 'oidc_signing_key_unavailable'} except RuntimeError: return {'status': 'unavailable', 'reason': 'oidc_template_unreachable'} return {'status': 'ready', 'missing': []} def ensure_deployment_oidc_application( self, *, application_slug: str, launch_url: str, redirect_uri: str, client_secret: str, administrator_group: str, support_agent_group: str, read_only_group: str, ) -> dict[str, Any]: """Provision one Authentik OIDC app from the reviewed deployment template. The method refuses tenant-wide group/flow creation and never returns the supplied/generated client secret. Existing applications are accepted only when their slug and launch URL already identify this deployment. """ if not self.is_configured(): return self._missing(reason='missing_config') if not re.fullmatch(r'[a-z][a-z0-9-]{1,62}', application_slug): return {'success': False, 'status': 'invalid', 'message': 'invalid_application_slug'} if not re.fullmatch(r'https://[a-z0-9.-]+(?:/[^?#]*)?', launch_url): return {'success': False, 'status': 'invalid', 'message': 'invalid_launch_url'} if not redirect_uri.startswith(f'{launch_url}/') or '?' in redirect_uri or '#' in redirect_uri: return {'success': False, 'status': 'invalid', 'message': 'invalid_redirect_uri'} config = self._deployment_oidc_config() authorization_flow = str(config.get('AUTHENTIK_OIDC_AUTHORIZATION_FLOW') or '').strip() invalidation_flow = str(config.get('AUTHENTIK_OIDC_INVALIDATION_FLOW') or '').strip() group_claim_mapping = str(config.get('AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING') or '').strip() identity_mappings = [str(config.get(f'AUTHENTIK_OIDC_{scope}_CLAIM_MAPPING') or '').strip() for scope in ('OPENID', 'EMAIL', 'PROFILE')] signing_key = str(config.get('AUTHENTIK_OIDC_SIGNING_KEY_ID') or '').strip() if not authorization_flow or not invalidation_flow or not group_claim_mapping or not all(identity_mappings) or not signing_key: return {'success': False, 'status': 'blocked', 'message': 'oidc_template_not_configured'} expected_mappings = [*identity_mappings, group_claim_mapping] expected_redirects = [{'matching_mode': 'strict', 'url': redirect_uri}] try: groups = self._paged('/core/groups/') group_names = {str(item.get('name') or '') for item in groups} required_groups = {administrator_group, support_agent_group, read_only_group} if not required_groups.issubset(group_names): return {'success': False, 'status': 'blocked', 'message': 'required_role_groups_are_missing'} applications = self._paged('/core/applications/') matches = [item for item in applications if str(item.get('slug') or '') == application_slug] if len(matches) > 1: return {'success': False, 'status': 'conflict', 'message': 'multiple_authentik_applications_with_slug'} if matches: existing = matches[0] existing_url = str(existing.get('meta_launch_url') or existing.get('launch_url') or '') if existing_url.rstrip('/') != launch_url.rstrip('/'): return {'success': False, 'status': 'conflict', 'message': 'application_slug_owned_by_different_launch_url'} provider_id = existing.get('provider') or existing.get('provider_id') providers = self._paged('/providers/oauth2/') provider = next( (item for item in providers if str(item.get('pk') or item.get('id')) == str(provider_id)), None, ) provider_redirects = provider.get('redirect_uris') if isinstance(provider, dict) else None provider_mappings = provider.get('property_mappings') if isinstance(provider, dict) else None if ( not isinstance(provider, dict) or str(provider.get('client_id') or '') != application_slug or str(provider.get('client_type') or '') != 'confidential' or provider_redirects != expected_redirects or not isinstance(provider_mappings, list) or not all(isinstance(item, str) for item in provider_mappings) or set(provider_mappings) != set(expected_mappings) or _authentik_id(provider.get('signing_key')) != signing_key or provider.get('issuer_mode') != 'per_provider' or str(provider.get('authorization_flow') or '') != authorization_flow or str(provider.get('invalidation_flow') or '') != invalidation_flow ): return {'success': False, 'status': 'conflict', 'message': 'existing_oidc_provider_does_not_match_managed_contract'} return { 'success': True, 'status': 'already_managed', 'message': 'oidc_application_already_matches_typed_plan', 'applicationId': existing.get('pk') or existing.get('id'), 'clientSecretCreated': False, } provider = self._write_request( 'POST', '/providers/oauth2/', { 'name': f'{application_slug} OIDC', 'authorization_flow': authorization_flow, 'invalidation_flow': invalidation_flow, 'client_type': 'confidential', 'client_id': application_slug, 'client_secret': client_secret, 'redirect_uris': expected_redirects, 'property_mappings': expected_mappings, 'signing_key': signing_key, 'include_claims_in_id_token': True, 'sub_mode': 'hashed_user_id', 'issuer_mode': 'per_provider', }, ) provider_id = provider.get('pk') or provider.get('id') if isinstance(provider, dict) else None if not provider_id: return {'success': False, 'status': 'failed', 'message': 'oidc_provider_response_missing_identifier'} try: application = self._write_request( 'POST', '/core/applications/', { 'name': application_slug, 'slug': application_slug, 'provider': provider_id, 'meta_launch_url': launch_url, }, ) except RuntimeError: # A generated client secret must never appear in an API error, # audit record or browser response. Compensate only the provider # this method just created, and fail closed if that cleanup is # not acknowledged by Authentik. try: self._write_request('DELETE', f'/providers/oauth2/{provider_id}/') except RuntimeError: return {'success': False, 'status': 'partial_failure', 'message': 'oidc_application_failed_provider_cleanup_failed'} return {'success': False, 'status': 'failed', 'message': 'oidc_application_creation_failed_provider_removed'} except RuntimeError: # The provider request carries the confidential client secret. A # remote error body is not a safe channel for returning it. return {'success': False, 'status': 'failed', 'message': 'oidc_provider_creation_failed'} application_id = application.get('pk') or application.get('id') if isinstance(application, dict) else None if not application_id: try: self._write_request('DELETE', f'/providers/oauth2/{provider_id}/') except RuntimeError: return {'success': False, 'status': 'partial_failure', 'message': 'oidc_application_response_missing_identifier_provider_cleanup_failed'} return {'success': False, 'status': 'failed', 'message': 'oidc_application_response_missing_identifier_provider_removed'} return { 'success': True, 'status': 'created', 'message': 'oidc_application_created_from_typed_template', 'applicationId': application_id, 'providerId': provider_id, 'clientSecretCreated': True, } def _forge_oidc_static_preflight(self) -> dict[str, Any]: if not self.is_configured(): return {'success': False, 'status': 'blocked', 'message': 'authentik_missing_config'} config = self._config() if not config or config.url.rstrip('/') != _FORGE_OIDC_AUTHENTIK_URL: return {'success': False, 'status': 'blocked', 'message': 'forge_oidc_authentik_url_mismatch'} if config.verify_tls is not True: return {'success': False, 'status': 'blocked', 'message': 'forge_oidc_tls_verification_required'} required_settings = ( 'AUTHENTIK_OIDC_AUTHORIZATION_FLOW', 'AUTHENTIK_OIDC_INVALIDATION_FLOW', 'AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID', ) values = {key: str(settings.apis.get(key) or '').strip() for key in required_settings} missing = [key for key, value in values.items() if not value] if missing: return { 'success': False, 'status': 'blocked', 'message': 'forge_oidc_template_not_configured', 'missing': missing, } try: flows = self._paged('/flows/instances/') signing_keys = self._paged('/crypto/certificatekeypairs/', {'has_key': True}) except RuntimeError: return {'success': False, 'status': 'failed', 'message': 'forge_oidc_preflight_failed'} for field in ('AUTHENTIK_OIDC_AUTHORIZATION_FLOW', 'AUTHENTIK_OIDC_INVALIDATION_FLOW'): if sum(1 for item in flows if _authentik_id(item) == values[field]) != 1: return { 'success': False, 'status': 'blocked', 'message': 'forge_oidc_flow_unavailable_or_ambiguous', 'missing': [field], } signing_key_id = values['AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID'] matching_keys = [item for item in signing_keys if _authentik_id(item) == signing_key_id] if ( len(matching_keys) != 1 or _authentik_enum(matching_keys[0].get('key_type')) != 'rsa' or matching_keys[0].get('private_key_available') is not True ): return { 'success': False, 'status': 'blocked', 'message': 'forge_oidc_rsa_signing_key_unavailable', 'missing': ['AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID'], } return {'success': True, 'status': 'ready', 'message': 'forge_oidc_static_prerequisites_ready'} def _forge_oidc_read_groups(self) -> list[dict[str, Any]]: return self._paged('/core/groups/', _FORGE_OIDC_GROUP_READ_PARAMS) def _forge_oidc_read_group(self, group_id: str) -> Any: return self._request( f'/core/groups/{quote(group_id, safe="")}/', {**_FORGE_OIDC_GROUP_READ_PARAMS, 'include_users': 'true'}, ) def _forge_oidc_read_scope_mappings(self) -> list[dict[str, Any]]: return self._paged('/propertymappings/provider/scope/', {'page_size': 100}) @staticmethod def _forge_oidc_bound_mapping_ids() -> list[str] | None: if settings.apis.get('AUTHENTIK_FORGE_OIDC_BINDING_VALID') is not True: return None mapping_ids = [str(settings.apis.get(key) or '').strip() for key in _FORGE_OIDC_SCOPE_MAPPING_IDS] try: ForgeOidcScopeMappingIdsV1.model_validate(dict(zip(('openid', 'email', 'profile'), mapping_ids, strict=True))) except Exception: # noqa: BLE001 - invalid protected binding always blocks provider mutation. return [] return mapping_ids @staticmethod def _forge_oidc_mapping_state(mappings: list[dict[str, Any]]) -> tuple[dict[str, str], dict[str, Any] | None, str | None]: builtins: dict[str, str] = {} for scope, managed, digest in ( ('openid', FORGE_OIDC_OPENID_MAPPING_MANAGED, FORGE_OIDC_OPENID_MAPPING_EXPRESSION_SHA256), ('email', FORGE_OIDC_EMAIL_MAPPING_MANAGED, FORGE_OIDC_EMAIL_MAPPING_EXPRESSION_SHA256), ): matches = [item for item in mappings if item.get('managed') == managed] if len(matches) != 1: return {}, None, 'forge_oidc_standard_scope_mapping_ambiguous' mapping = matches[0] mapping_id = _authentik_id(mapping) if ( not _forge_oidc_application_id(mapping_id) or str(mapping.get('scope_name') or '') != scope or _normalized_expression_sha256(mapping.get('expression')) != digest ): return {}, None, 'forge_oidc_standard_scope_mapping_mismatch' builtins[scope] = mapping_id profiles = [item for item in mappings if str(item.get('name') or '') == FORGE_OIDC_PROFILE_MAPPING_NAME] if len(profiles) > 1: return {}, None, 'forge_oidc_profile_mapping_ambiguous' profile = profiles[0] if profiles else None if profile is not None and not _forge_oidc_scope_mapping_matches( profile, mapping_id=_authentik_id(profile), scope='profile', ): return {}, None, 'forge_oidc_profile_mapping_mismatch' return builtins, profile, None @staticmethod def _forge_oidc_group_state(groups: list[dict[str, Any]]) -> tuple[dict[str, dict[str, Any]], str | None]: resolved: dict[str, dict[str, Any]] = {} for name in FORGE_OIDC_GROUPS: matches = [item for item in groups if str(item.get('name') or '') == name] if len(matches) > 1: return {}, 'forge_oidc_group_ambiguous' if matches: group = matches[0] if not _forge_oidc_group_contract_matches(group): return {}, 'forge_oidc_group_conflict' resolved[name] = group return resolved, None def ensure_forge_oidc_prerequisites( self, *, expected_client_secret: str | None = None, ) -> dict[str, Any]: """Create only the fixed empty Forge groups and exact profile scope map.""" static = self._forge_oidc_static_preflight() if static.get('success') is not True: return static bound_ids = self._forge_oidc_bound_mapping_ids() if bound_ids == []: return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_binding_invalid'} bound_provider_id = settings.apis.get('AUTHENTIK_FORGE_OIDC_PROVIDER_ID') if bound_provider_id is not None and _forge_oidc_provider_id(bound_provider_id) is None: return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_binding_invalid'} allow_existing_group_users = False if bound_provider_id is not None: if not expected_client_secret: return { 'success': False, 'status': 'conflict', 'message': 'forge_oidc_existing_client_secret_missing', } existing = self.inspect_forge_oidc_application( expected_client_secret=expected_client_secret, ) if existing.get('success') is not True or existing.get('status') not in { 'already_configured', 'provider_orphan', }: return existing if existing.get('providerId') != str(bound_provider_id) or existing.get('secretVerified') is not True: return { 'success': False, 'status': 'conflict', 'message': 'forge_oidc_bound_provider_mismatch', } allow_existing_group_users = existing.get('status') == 'already_configured' try: groups = self._forge_oidc_read_groups() mappings = self._forge_oidc_read_scope_mappings() except RuntimeError: return {'success': False, 'status': 'failed', 'message': 'forge_oidc_resource_preflight_failed'} group_state, group_error = self._forge_oidc_group_state(groups) if group_error: return {'success': False, 'status': 'conflict', 'message': group_error} for name, group in group_state.items(): group_id = _authentik_id(group) if _forge_oidc_application_id(group_id) is None: return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_group_id_invalid'} try: observed = self._forge_oidc_read_group(group_id) except RuntimeError: return {'success': False, 'status': 'failed', 'message': 'forge_oidc_group_read_failed'} if ( not isinstance(observed, dict) or _authentik_id(observed) != group_id or str(observed.get('name') or '') != name or not _forge_oidc_group_contract_matches( observed, require_empty_users=not allow_existing_group_users, require_users=True, ) ): return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_group_conflict'} group_state[name] = observed builtin_ids, profile_mapping, mapping_error = self._forge_oidc_mapping_state(mappings) if mapping_error: return {'success': False, 'status': 'conflict', 'message': mapping_error} profile_id = _authentik_id(profile_mapping) if profile_mapping else '' discovered_ids = [builtin_ids.get('openid', ''), builtin_ids.get('email', ''), profile_id] if bound_ids is not None and ( discovered_ids[0] != bound_ids[0] or discovered_ids[1] != bound_ids[1] or (profile_mapping is not None and discovered_ids[2] != bound_ids[2]) or (profile_mapping is None and bound_ids[2]) ): return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_bound_scope_mapping_mismatch'} # Create only absent fixed groups. A failed/unknown POST is reconciled by # exact name and contract before any later request can retry it. for name in FORGE_OIDC_GROUPS: if name in group_state: continue try: created = self._forge_oidc_write_request( 'POST', '/core/groups/', { 'name': name, 'is_superuser': False, 'attributes': {}, 'parents': [], 'users': [], 'roles': [], 'children': [], }, ) except RuntimeError: try: after_unknown = self._forge_oidc_read_groups() except RuntimeError: return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_group_creation_unknown'} matches = [item for item in after_unknown if str(item.get('name') or '') == name] if len(matches) != 1: return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_group_creation_unknown'} recovered_id = _authentik_id(matches[0]) if _forge_oidc_application_id(recovered_id) is None: return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_group_creation_unknown'} try: recovered = self._forge_oidc_read_group(recovered_id) except RuntimeError: return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_group_creation_unknown'} if ( not isinstance(recovered, dict) or _authentik_id(recovered) != recovered_id or str(recovered.get('name') or '') != name or not _forge_oidc_group_contract_matches(recovered, require_empty_users=True) ): return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_group_creation_unknown'} group_state[name] = recovered continue group_id = str(created.get('pk') or created.get('id') or '') if isinstance(created, dict) else '' if _forge_oidc_application_id(group_id) is None: return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_group_create_response_invalid'} try: observed = self._forge_oidc_read_group(group_id) except RuntimeError: return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_group_readback_failed'} if ( not isinstance(observed, dict) or _authentik_id(observed) != group_id or str(observed.get('name') or '') != name or not _forge_oidc_group_contract_matches(observed, require_empty_users=True) ): return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_group_readback_mismatch'} group_state[name] = observed if profile_mapping is None: if bound_ids is not None: return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_bound_profile_mapping_missing'} try: created_mapping = self._forge_oidc_write_request( 'POST', '/propertymappings/provider/scope/', { 'name': FORGE_OIDC_PROFILE_MAPPING_NAME, 'scope_name': 'profile', 'expression': FORGE_OIDC_PROFILE_MAPPING_EXPRESSION, }, ) except RuntimeError: try: after_unknown = self._forge_oidc_read_scope_mappings() except RuntimeError: return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_profile_mapping_creation_unknown'} matches = [item for item in after_unknown if str(item.get('name') or '') == FORGE_OIDC_PROFILE_MAPPING_NAME] if len(matches) != 1 or not _forge_oidc_scope_mapping_matches( matches[0], mapping_id=_authentik_id(matches[0]), scope='profile' ): return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_profile_mapping_creation_unknown'} profile_mapping = matches[0] else: created_mapping_id = _authentik_id(created_mapping) if not _forge_oidc_application_id(created_mapping_id): return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_profile_mapping_create_response_invalid'} try: observed_mapping = self._request( f'/propertymappings/provider/scope/{quote(created_mapping_id, safe="")}/' ) mapping_list = self._forge_oidc_read_scope_mappings() except RuntimeError: return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_profile_mapping_readback_failed'} matching_names = [item for item in mapping_list if str(item.get('name') or '') == FORGE_OIDC_PROFILE_MAPPING_NAME] if ( len(matching_names) != 1 or _authentik_id(matching_names[0]) != created_mapping_id or not _forge_oidc_scope_mapping_matches( observed_mapping, mapping_id=created_mapping_id, scope='profile', ) ): return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_profile_mapping_readback_mismatch'} profile_mapping = observed_mapping profile_id = _authentik_id(profile_mapping) if profile_mapping else '' mapping_ids = { 'openid': builtin_ids['openid'], 'email': builtin_ids['email'], 'profile': profile_id, } if bound_ids is not None and mapping_ids != dict(zip(('openid', 'email', 'profile'), bound_ids, strict=True)): return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_bound_scope_mapping_mismatch'} try: ForgeOidcScopeMappingIdsV1.model_validate(mapping_ids) except Exception: # noqa: BLE001 - return only a fixed safe reason. return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_scope_mapping_id_invalid'} return {'success': True, 'status': 'ready', 'scopeMappingIds': mapping_ids} def inspect_forge_oidc_application( self, *, expected_client_secret: str | None = None, ) -> dict[str, Any]: """Read-only preflight for the one fixed Forge OIDC application.""" static = self._forge_oidc_static_preflight() if static.get('success') is not True: return static mapping_ids = self._forge_oidc_bound_mapping_ids() if mapping_ids is None or mapping_ids == []: return {'success': False, 'status': 'blocked', 'message': 'forge_oidc_binding_required'} signing_key_id = str(settings.apis.get('AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID') or '').strip() bound_provider_id = settings.apis.get('AUTHENTIK_FORGE_OIDC_PROVIDER_ID') if bound_provider_id is not None and _forge_oidc_provider_id(bound_provider_id) is None: return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_binding_invalid'} groups_have_users = False try: groups = self._forge_oidc_read_groups() mappings = self._forge_oidc_read_scope_mappings() group_state, group_error = self._forge_oidc_group_state(groups) if group_error: return {'success': False, 'status': 'conflict', 'message': group_error} if len(group_state) != len(FORGE_OIDC_GROUPS): return {'success': False, 'status': 'blocked', 'message': 'forge_oidc_required_groups_missing'} for name, group in group_state.items(): group_id = _authentik_id(group) if _forge_oidc_application_id(group_id) is None: return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_group_id_invalid'} observed_group = self._forge_oidc_read_group(group_id) if ( not isinstance(observed_group, dict) or _authentik_id(observed_group) != group_id or str(observed_group.get('name') or '') != name or not _forge_oidc_group_contract_matches( observed_group, require_users=True, ) ): return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_group_conflict'} if observed_group.get('users'): groups_have_users = True builtin_ids, profile_mapping, mapping_error = self._forge_oidc_mapping_state(mappings) if mapping_error: return {'success': False, 'status': 'conflict', 'message': mapping_error} if profile_mapping is None: return {'success': False, 'status': 'blocked', 'message': 'forge_oidc_profile_mapping_missing'} actual_mapping_ids = [builtin_ids['openid'], builtin_ids['email'], _authentik_id(profile_mapping)] if actual_mapping_ids != mapping_ids: return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_bound_scope_mapping_mismatch'} for scope, mapping_id in zip(('openid', 'email', 'profile'), mapping_ids, strict=True): mapping = self._request(f'/propertymappings/provider/scope/{mapping_id}/') if not _forge_oidc_scope_mapping_matches( mapping, mapping_id=mapping_id, scope=scope, ): return {'success': False, 'status': 'blocked', 'message': 'forge_oidc_scope_mapping_mismatch'} applications = self._paged('/core/applications/') raw_providers = self._paged('/providers/oauth2/') providers = [ {key: value for key, value in item.items() if key != 'client_secret'} for item in raw_providers if isinstance(item, dict) ] raw_providers = [] except RuntimeError: return {'success': False, 'status': 'failed', 'message': 'forge_oidc_preflight_failed'} application_matches = [ item for item in applications if str(item.get('slug') or '') == FORGE_OIDC_CLIENT_ID or str(item.get('name') or '') == FORGE_OIDC_APPLICATION_NAME ] provider_matches = [ item for item in providers if str(item.get('client_id') or '') == FORGE_OIDC_CLIENT_ID or str(item.get('name') or '') == FORGE_OIDC_PROVIDER_NAME ] if len(application_matches) > 1 or len(provider_matches) > 1: return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_duplicate_resources'} if not application_matches and not provider_matches: if groups_have_users: return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_group_conflict'} if bound_provider_id is not None: return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_bound_provider_missing'} return {'success': True, 'status': 'ready', 'message': 'forge_oidc_preflight_ready'} provider = provider_matches[0] if provider_matches else None provider_id = _authentik_id(provider) if provider else '' if provider and ( _forge_oidc_provider_id(provider_id) is None or not self._forge_oidc_provider_matches(provider, signing_key_id=signing_key_id) or (bound_provider_id is not None and provider_id != str(bound_provider_id)) ): return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_existing_contract_mismatch'} if not application_matches: if not provider: return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_application_provider_mismatch'} if groups_have_users: return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_group_conflict'} secret_verified = expected_client_secret is not None and self._forge_oidc_provider_secret_matches( provider_id, expected_client_secret, ) if expected_client_secret is not None and not secret_verified: return { 'success': False, 'status': 'conflict', 'message': 'forge_oidc_existing_client_secret_mismatch', } return { 'success': True, 'status': 'provider_orphan', 'message': 'forge_oidc_provider_recovery_available', 'providerId': provider_id, 'secretVerified': secret_verified, } application = application_matches[0] application_id = _authentik_id(application) linked_provider_id = _authentik_id(application.get('provider') or application.get('provider_id')) if ( not _forge_oidc_application_id(application_id) or str(application.get('name') or '') != FORGE_OIDC_APPLICATION_NAME or str(application.get('slug') or '') != FORGE_OIDC_CLIENT_ID or str(application.get('meta_launch_url') or application.get('launch_url') or '').rstrip('/') != FORGE_OIDC_LAUNCH_URL or str(application.get('meta_description') or '') != FORGE_OIDC_MANAGED_DESCRIPTION or not provider or linked_provider_id != provider_id ): return { 'success': False, 'status': 'conflict', 'message': 'forge_oidc_existing_contract_mismatch', } secret_verified = expected_client_secret is not None and self._forge_oidc_provider_secret_matches( provider_id, expected_client_secret, ) if expected_client_secret is not None and not secret_verified: return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_existing_client_secret_mismatch'} if groups_have_users and ( bound_provider_id is None or provider_id != str(bound_provider_id) or not secret_verified ): return {'success': False, 'status': 'conflict', 'message': 'forge_oidc_group_conflict'} return { 'success': True, 'status': 'already_configured', 'message': 'forge_oidc_already_configured', 'applicationId': application_id, 'providerId': provider_id, 'secretVerified': secret_verified, } def _forge_oidc_provider_secret_matches(self, provider_id: str, expected: str) -> bool: """Compare provider detail secret without retaining, logging or returning it.""" if not provider_id or not expected: return False config = self._config() if not config: return False url = f'{config.url}/api/v3/providers/oauth2/{quote(provider_id, safe="")}/' try: with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client: response = client.get( url, headers={ 'Accept': 'application/json', 'Authorization': f'Bearer {config.token}', }, ) if response.status_code != 200: self.set_error('forge_oidc_provider_secret_read_failed') return False try: payload = response.json() except ValueError: self.set_error('forge_oidc_provider_secret_read_failed') return False actual = payload.get('client_secret') if isinstance(payload, dict) else None matches = ( isinstance(actual, str) and actual.isascii() and expected.isascii() and hmac.compare_digest(actual, expected) ) # The response body is transient and never enters the connector # cache, an error message, the audit stream, or an API result. actual = None payload = None response = None if not matches: self.set_error('forge_oidc_existing_client_secret_mismatch') return matches except httpx.RequestError: self.set_error('forge_oidc_provider_secret_read_failed') return False @staticmethod def _forge_oidc_provider_matches(provider: dict[str, Any], *, signing_key_id: str) -> bool: expected_authorization_flow = str(settings.apis.get('AUTHENTIK_OIDC_AUTHORIZATION_FLOW') or '').strip() expected_invalidation_flow = str(settings.apis.get('AUTHENTIK_OIDC_INVALIDATION_FLOW') or '').strip() expected_mapping_ids = [str(settings.apis.get(key) or '').strip() for key in _FORGE_OIDC_SCOPE_MAPPING_IDS] expected_provider_id = settings.apis.get('AUTHENTIK_FORGE_OIDC_PROVIDER_ID') actual_mapping_values = provider.get('property_mappings') actual_mapping_ids = ( [_authentik_id(item) for item in actual_mapping_values] if isinstance(actual_mapping_values, list) else [] ) mappings_match = ( len(expected_mapping_ids) == 3 and len(set(expected_mapping_ids)) == 3 and len(actual_mapping_ids) == 3 and len(set(actual_mapping_ids)) == 3 and set(actual_mapping_ids) == set(expected_mapping_ids) ) provider_id_valid = expected_provider_id is None or bool( re.fullmatch(r'[1-9][0-9]{0,9}', str(expected_provider_id)) ) return ( provider_id_valid and (expected_provider_id is None or _authentik_id(provider) == str(expected_provider_id)) and str(provider.get('name') or '') == FORGE_OIDC_PROVIDER_NAME and str(provider.get('client_id') or '') == FORGE_OIDC_CLIENT_ID and str(provider.get('client_type') or '') == 'confidential' and _forge_oidc_redirect_is_exact(provider.get('redirect_uris')) and str(provider.get('authorization_flow') or '') == expected_authorization_flow and str(provider.get('invalidation_flow') or '') == expected_invalidation_flow and mappings_match and 'grant_types' not in provider and 'redirect_uri_type' not in provider and provider.get('include_claims_in_id_token') is True and str(provider.get('sub_mode') or '') == 'hashed_user_id' and str(provider.get('issuer_mode') or '') == 'per_provider' and _authentik_id(provider.get('signing_key')) == signing_key_id ) def _forge_oidc_discovery_matches(self) -> bool: config = self._config() if not config or config.url.rstrip('/') != _FORGE_OIDC_AUTHENTIK_URL or config.verify_tls is not True: return False try: with httpx.Client(timeout=config.timeout_seconds, verify=True) as client: response = client.get(FORGE_OIDC_DISCOVERY_URL, headers={'Accept': 'application/json'}) if response.status_code != 200: return False metadata = response.json() if not isinstance(metadata, dict): return False valid = ( metadata.get('issuer') == FORGE_OIDC_ISSUER and 'code' in metadata.get('response_types_supported', []) and 'authorization_code' in metadata.get('grant_types_supported', []) and {'openid', 'profile', 'email'}.issubset(set(metadata.get('scopes_supported', []))) and 'RS256' in metadata.get('id_token_signing_alg_values_supported', []) ) metadata = None response = None return valid except (httpx.RequestError, ValueError, TypeError): return False @staticmethod def _forge_oidc_result_from_observation( observation: dict[str, Any], *, accepted_statuses: set[str], response_provider_id: str | None = None, response_application_id: str | None = None, ) -> dict[str, Any] | None: if ( observation.get('success') is not True or observation.get('secretVerified') is not True or observation.get('status') not in accepted_statuses ): return None provider_id = str(observation.get('providerId') or '') if response_provider_id is not None and provider_id != response_provider_id: return None application_id = str(observation.get('applicationId') or '') if response_application_id is not None and application_id != response_application_id: return None if _forge_oidc_provider_id(provider_id) is None: return None if observation.get('status') == 'already_configured' and not _forge_oidc_application_id(application_id): return None return { 'success': True, 'status': observation.get('status'), 'message': 'forge_oidc_already_configured' if observation.get('status') == 'already_configured' else 'forge_oidc_provider_recovery_available', 'applicationId': application_id or None, 'providerId': provider_id, } def ensure_forge_oidc_application( self, *, client_secret: str, persist_provider_id: Callable[[str], bool], ) -> dict[str, Any]: """Create or recover the fixed provider/application only after observed proof.""" secret_valid = ( isinstance(client_secret, str) and 32 <= len(client_secret) <= 255 and client_secret.isascii() and client_secret.isprintable() ) if not secret_valid or not callable(persist_provider_id): return {'success': False, 'status': 'blocked', 'message': 'forge_oidc_client_secret_invalid'} preflight = self.inspect_forge_oidc_application(expected_client_secret=client_secret) if preflight.get('success') is not True: return preflight if preflight.get('status') == 'already_configured': provider_id = str(preflight.get('providerId') or '') application_id = str(preflight.get('applicationId') or '') if persist_provider_id(provider_id) is not True: return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_binding_persist_failed'} if not self._forge_oidc_discovery_matches(): return {'success': False, 'status': 'failed', 'message': 'forge_oidc_discovery_contract_mismatch'} return { 'success': True, 'status': 'already_configured', 'message': 'forge_oidc_already_configured', 'applicationId': application_id, 'providerId': provider_id, } if preflight.get('status') not in {'ready', 'provider_orphan'}: return {'success': False, 'status': 'failed', 'message': 'forge_oidc_preflight_failed'} authorization_flow = str(settings.apis.get('AUTHENTIK_OIDC_AUTHORIZATION_FLOW') or '').strip() invalidation_flow = str(settings.apis.get('AUTHENTIK_OIDC_INVALIDATION_FLOW') or '').strip() scope_mapping_ids = [str(settings.apis.get(key) or '').strip() for key in _FORGE_OIDC_SCOPE_MAPPING_IDS] signing_key_id = str(settings.apis.get('AUTHENTIK_FORGE_OIDC_SIGNING_KEY_ID') or '').strip() provider_id = str(preflight.get('providerId') or '') if preflight.get('status') == 'provider_orphan' else '' provider_response_id: str | None = None application_missing = preflight.get('status') in {'ready', 'provider_orphan'} if preflight.get('status') == 'ready': try: provider_response = self._forge_oidc_write_request( 'POST', '/providers/oauth2/', { 'name': FORGE_OIDC_PROVIDER_NAME, 'authorization_flow': authorization_flow, 'invalidation_flow': invalidation_flow, 'client_type': 'confidential', 'client_id': FORGE_OIDC_CLIENT_ID, 'client_secret': client_secret, 'signing_key': signing_key_id, 'redirect_uris': [{'matching_mode': 'strict', 'url': FORGE_OIDC_CALLBACK_URL}], 'property_mappings': scope_mapping_ids, 'include_claims_in_id_token': True, 'sub_mode': 'hashed_user_id', 'issuer_mode': 'per_provider', }, ) provider_response_id = _authentik_id(provider_response) except RuntimeError: # The request may have committed remotely. Reconcile before any # retry; a retry path can only continue with one exact provider # whose confidential secret matches this AppOps-held secret. try: observed = self.inspect_forge_oidc_application(expected_client_secret=client_secret) except Exception: # noqa: BLE001 - connector failures remain a safe partial result. observed = {'success': False} reconciled = self._forge_oidc_result_from_observation( observed, accepted_statuses={'provider_orphan', 'already_configured'}, ) if isinstance(observed, dict) else None if not reconciled: return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_provider_creation_unknown'} if observed.get('status') == 'already_configured': provider_id = str(observed.get('providerId') or '') if persist_provider_id(provider_id) is not True: return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_binding_persist_failed'} if not self._forge_oidc_discovery_matches(): return {'success': False, 'status': 'failed', 'message': 'forge_oidc_discovery_contract_mismatch'} return { 'success': True, 'status': 'already_configured', 'message': 'forge_oidc_already_configured', 'applicationId': observed.get('applicationId'), 'providerId': provider_id, } provider_id = str(reconciled['providerId']) if not provider_id: provider_id = provider_response_id or '' if _forge_oidc_provider_id(provider_id) is None: return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_provider_id_missing'} if provider_response_id is not None: if _forge_oidc_provider_id(provider_response_id) is None: return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_provider_response_invalid'} try: observed_provider = self.inspect_forge_oidc_application(expected_client_secret=client_secret) except Exception: # noqa: BLE001 - no remote body or secret crosses the result boundary. observed_provider = {'success': False} reconciled = self._forge_oidc_result_from_observation( observed_provider, accepted_statuses={'provider_orphan', 'already_configured'}, response_provider_id=provider_response_id, ) if isinstance(observed_provider, dict) else None if not reconciled: return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_provider_readback_mismatch'} provider_id = str(reconciled['providerId']) if reconciled.get('status') == 'already_configured': if persist_provider_id(provider_id) is not True: return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_binding_persist_failed'} if not self._forge_oidc_discovery_matches(): return {'success': False, 'status': 'failed', 'message': 'forge_oidc_discovery_contract_mismatch'} return { 'success': True, 'status': 'already_configured', 'message': 'forge_oidc_already_configured', 'applicationId': reconciled.get('applicationId'), 'providerId': provider_id, } if persist_provider_id(provider_id) is not True: return {'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_binding_persist_failed'} app_request_id: str | None = None if application_missing: try: application = self._forge_oidc_write_request( 'POST', '/core/applications/', { 'name': FORGE_OIDC_APPLICATION_NAME, 'slug': FORGE_OIDC_CLIENT_ID, 'provider': provider_id, 'meta_launch_url': FORGE_OIDC_LAUNCH_URL, 'meta_description': FORGE_OIDC_MANAGED_DESCRIPTION, }, ) app_request_id = _authentik_id(application) except RuntimeError: try: observed = self.inspect_forge_oidc_application(expected_client_secret=client_secret) except Exception: # noqa: BLE001 - unknown outcome requires a read-only reconcile. observed = {'success': False} if isinstance(observed, dict) and observed.get('status') == 'already_configured': reconciled = self._forge_oidc_result_from_observation( observed, accepted_statuses={'already_configured'}, response_provider_id=provider_id, ) if reconciled and self._forge_oidc_discovery_matches(): return { 'success': True, 'status': 'already_configured', 'message': 'forge_oidc_already_configured', 'applicationId': reconciled['applicationId'], 'providerId': provider_id, } return { 'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_application_creation_unknown', 'providerId': provider_id, } if _forge_oidc_application_id(app_request_id) is None: return { 'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_application_response_invalid', 'providerId': provider_id, } try: observed_pair = self.inspect_forge_oidc_application(expected_client_secret=client_secret) except Exception: # noqa: BLE001 - successful writes are never trusted without observed readback. observed_pair = {'success': False} reconciled_pair = self._forge_oidc_result_from_observation( observed_pair, accepted_statuses={'already_configured'}, response_provider_id=provider_id, response_application_id=app_request_id, ) if isinstance(observed_pair, dict) else None if not reconciled_pair: return { 'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_pair_readback_mismatch', 'providerId': provider_id, } if not self._forge_oidc_discovery_matches(): return { 'success': False, 'status': 'partial_failure', 'message': 'forge_oidc_discovery_contract_mismatch', 'providerId': provider_id, } created_now = app_request_id is not None return { 'success': True, 'status': 'created' if created_now else 'already_configured', 'message': 'forge_oidc_created' if created_now else 'forge_oidc_already_configured', 'applicationId': reconciled_pair['applicationId'], 'providerId': provider_id, } def _forge_oidc_write_request( self, method: str, path: str, payload: dict[str, Any] | None = None, ) -> Any: """Use the connector identity without retaining remote error bodies.""" config = self._config() if not config: raise RuntimeError('missing_config') try: with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client: request_kwargs: dict[str, Any] = { 'headers': {'Accept': 'application/json', 'Authorization': f'Bearer {config.token}'}, } if payload is not None: request_kwargs['json'] = payload response = client.request(method, f'{config.url}/api/v3{path}', **request_kwargs) except httpx.RequestError: self.set_error('forge_oidc_request_failed') raise RuntimeError('forge_oidc_request_failed') from None if response.status_code in {401, 403}: self.set_error('auth_failed') raise RuntimeError('auth_failed') if response.status_code >= 400: self.set_error('forge_oidc_api_error') raise RuntimeError('forge_oidc_api_error') if not response.content: return {} try: return response.json() except ValueError: self.set_error('forge_oidc_invalid_response') raise RuntimeError('forge_oidc_invalid_response') from None def remove_deployment_oidc_application( self, *, application_id: str | int, provider_id: str | int, application_slug: str, launch_url: str, ) -> dict[str, Any]: """Compensate only an OIDC pair created during this enrollment call. This is intentionally private to the enrollment orchestrator. It first re-reads both resources and refuses to delete if the identity is no longer exactly the bounded application/provider pair it created. """ if not self.is_configured(): return self._missing(reason='missing_config') if not re.fullmatch(r'[a-z][a-z0-9-]{1,62}', application_slug): return {'success': False, 'status': 'invalid', 'message': 'invalid_application_slug'} if not re.fullmatch(r'https://[a-z0-9.-]+(?:/[^?#]*)?', launch_url): return {'success': False, 'status': 'invalid', 'message': 'invalid_launch_url'} try: applications = self._paged('/core/applications/') matches = [ item for item in applications if str(item.get('pk') or item.get('id')) == str(application_id) ] if len(matches) != 1: return {'success': False, 'status': 'not_found', 'message': 'managed_oidc_application_not_found'} application = matches[0] if ( application.get('slug') != application_slug or str(application.get('meta_launch_url') or application.get('launch_url') or '').rstrip('/') != launch_url.rstrip('/') or str(application.get('provider') or application.get('provider_id') or '') != str(provider_id) ): return {'success': False, 'status': 'conflict', 'message': 'oidc_application_is_not_the_created_managed_pair'} provider = self._write_request('GET', f'/providers/oauth2/{provider_id}/') if not isinstance(provider, dict) or str(provider.get('client_id') or '') != application_slug: return {'success': False, 'status': 'conflict', 'message': 'oidc_provider_is_not_the_created_managed_pair'} self._write_request('DELETE', f'/core/applications/{application_id}/') self._write_request('DELETE', f'/providers/oauth2/{provider_id}/') except RuntimeError as exc: return self._error_payload(exc) return { 'success': True, 'status': 'removed', 'applicationId': application_id, 'providerId': provider_id, } def outposts(self) -> dict[str, Any]: return self._memo('outposts', lambda: self._collection('/outposts/instances/', self._map_outposts, 'outposts_ok')) def outpost_health(self) -> dict[str, Any]: return self._memo('outpost_health', self._outpost_health_uncached) def _outpost_health_uncached(self) -> dict[str, Any]: if not self.is_configured(): return self._missing(items=[], total=0, available=False, embeddedExpected=False) outposts = self.outposts() if not outposts.get('success'): return {**outposts, 'available': False} items = outposts.get('items', []) available = any(str(item.get('health') or '').lower() in {'healthy', 'ok', 'connected'} for item in items) config = self._config() embedded_expected = bool(config and config.expect_embedded_outpost) reachability = {'status': 'reported_healthy', 'reachable': True, 'statusCode': None} if available else self._probe_outpost(config) if config and config.outpost_url else { 'status': 'not_configured', 'reachable': None, 'statusCode': None, } reachable = reachability.get('reachable') status = 'ok' if available else ('reachable_unverified' if reachable else ('warning' if embedded_expected else 'unknown')) if available: message = 'outpost_health_ok' elif reachable: message = 'outpost_reachable_health_unverified' else: message = 'outpost_health_unknown' data = { 'items': items, 'available': available, 'embeddedExpected': embedded_expected, 'outpostUrl': _safe_url(config.outpost_url) if config and config.outpost_url else None, 'reachable': reachable, 'reachabilityStatus': reachability.get('status'), 'reachabilityStatusCode': reachability.get('statusCode'), 'telemetryStatus': 'reported' if available else 'unverified', } return {'success': True, 'status': status, 'message': message, 'items': items, 'total': len(items), **data, 'data': data} @staticmethod def _probe_outpost(config: _AuthentikConfig) -> dict[str, Any]: if not config.outpost_url: return {'status': 'not_configured', 'reachable': None, 'statusCode': None} try: # Never forward the Authentik API bearer token to a separately # configured outpost origin. This probe verifies reachability only. with httpx.Client( timeout=min(config.timeout_seconds, 8.0), verify=config.verify_tls, follow_redirects=False, ) as client: response = client.get( config.outpost_url, headers={'Accept': 'text/html,application/json;q=0.9,*/*;q=0.8'}, ) except httpx.RequestError: return {'status': 'unreachable', 'reachable': False, 'statusCode': None} reachable = response.status_code < 500 return { 'status': 'reachable' if reachable else 'degraded', 'reachable': reachable, 'statusCode': response.status_code, } def flows_summary(self) -> dict[str, Any]: if not self.is_configured(): return self._missing(items=[], total=0, byDesignation={}) try: items = self._paged('/flows/instances/') except RuntimeError as exc: return self._error_payload(exc, items=[], total=0, byDesignation={}) mapped = [{'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or item.get('slug') or ''), 'designation': str(item.get('designation') or 'unknown')} for item in items] by_designation: dict[str, int] = {} for item in mapped: by_designation[item['designation']] = by_designation.get(item['designation'], 0) + 1 return {'success': True, 'status': 'ok', 'message': 'flows_summary_ok', 'total': len(mapped), 'items': mapped, 'byDesignation': by_designation, 'data': {'items': mapped, 'byDesignation': by_designation}} def policies_summary(self) -> dict[str, Any]: if not self.is_configured(): return self._missing(items=[], total=0, enabled=0, disabled=0) try: items = self._paged('/policies/all/') except RuntimeError as exc: return self._error_payload(exc, items=[], total=0, enabled=0, disabled=0) mapped = [{'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or ''), 'enabled': bool(item.get('enabled', True))} for item in items] enabled = sum(1 for item in mapped if item['enabled']) data = {'items': mapped, 'enabled': enabled, 'disabled': len(mapped) - enabled} return {'success': True, 'status': 'ok', 'message': 'policies_summary_ok', 'total': len(mapped), **data, 'data': data} def events(self, limit: int = 25) -> dict[str, Any]: cache_key = f'events:{max(1, min(limit, 100))}' return self._memo(cache_key, lambda: self._events_uncached(limit)) def _events_uncached(self, limit: int = 25) -> dict[str, Any]: if not self.is_configured(): return self._missing(items=[], total=0) try: raw = self._paged('/events/events/', {'page_size': max(1, min(limit, 100))}) except RuntimeError as exc: return self._error_payload(exc, items=[], total=0) items = [self._map_event(item) for item in raw[:limit]] return {'success': True, 'status': 'ok', 'message': 'events_ok', 'total': len(items), 'items': items, 'data': {'items': items}} def auth_coverage(self) -> dict[str, Any]: return self._memo('auth_coverage', self._auth_coverage_uncached) def _auth_coverage_uncached(self) -> dict[str, Any]: apps = self.applications() providers = self.providers() proxy = self.proxy_providers() if not apps.get('success'): return {**apps, 'summary': self._empty_coverage_summary(), 'publicHosts': [], 'data': {'publicHosts': [], 'summary': self._empty_coverage_summary()}} npm_hosts = self._npm_hosts() hosts = npm_hosts.get('items', []) if npm_hosts.get('success') else [] proxy_domains = {_domain(item.get('externalHost')): item for item in proxy.get('items', []) if _domain(item.get('externalHost'))} if proxy.get('success') else {} app_domains = {_domain(item.get('launchUrl')): item for item in apps.get('items', []) if _domain(item.get('launchUrl'))} public_hosts: list[dict[str, Any]] = [] for host in hosts: if not host.get('enabled', True): continue domains = [str(item) for item in _as_list(host.get('domainNames'))] matches = [domain for domain in domains if _domain(domain) in proxy_domains or _domain(domain) in app_domains] status = 'protected' if matches else ('likely_protected' if host.get('accessListId') else 'unprotected') public_hosts.append({ 'hostId': host.get('id'), 'domains': domains, 'target': f"{host.get('forwardScheme', 'http')}://{host.get('forwardHost', '')}:{host.get('forwardPort', '')}", 'coverageStatus': status, 'authentikMatches': matches, 'accessListId': host.get('accessListId'), }) summary = { 'totalApplications': len(apps.get('items', [])), 'totalProviders': len(providers.get('items', [])) if providers.get('success') else 0, 'totalProxyProviders': len(proxy.get('items', [])) if proxy.get('success') else 0, 'totalPublicHosts': len(public_hosts), 'protectedHosts': sum(1 for item in public_hosts if item['coverageStatus'] == 'protected'), 'likelyProtectedHosts': sum(1 for item in public_hosts if item['coverageStatus'] == 'likely_protected'), 'unprotectedHosts': sum(1 for item in public_hosts if item['coverageStatus'] == 'unprotected'), 'npmStatus': npm_hosts.get('status', 'unknown'), } data = {'summary': summary, 'publicHosts': public_hosts} return {'success': True, 'status': 'ok', 'message': 'auth_coverage_ok', 'summary': summary, 'publicHosts': public_hosts, 'data': data} def security_warnings(self) -> dict[str, Any]: return self._memo('security_warnings', self._security_warnings_uncached) def _security_warnings_uncached(self) -> dict[str, Any]: warnings: list[dict[str, Any]] = [] if not self.is_configured(): npm_hosts = self._npm_hosts() if npm_hosts.get('success') and npm_hosts.get('items'): warnings.append({'type': 'authentik_missing_config_with_public_hosts', 'severity': 'high', 'title': 'Authentik connector is missing while NPM has public hosts', 'evidence': [str(len(npm_hosts.get('items', [])))], 'suggestedAction': 'configure_authentik_connector'}) return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'items': warnings, 'total': len(warnings), 'data': {'items': warnings}} coverage = self.auth_coverage() outpost = self.outpost_health() apps = self.applications() proxy = self.proxy_providers() oauth = self.oauth2_providers() events = self.events() if not outpost.get('items'): warnings.append({'type': 'no_outposts', 'severity': 'high', 'title': 'No Authentik outposts reported', 'evidence': [], 'suggestedAction': 'review_outpost_health'}) if outpost.get('embeddedExpected') and not outpost.get('available'): outpost_status = str(outpost.get('status') or 'unknown') if outpost.get('reachable'): warnings.append({ 'type': 'embedded_outpost_health_unverified', 'severity': 'info', 'title': 'Embedded outpost is reachable but health telemetry is not reported', 'evidence': [outpost_status, str(outpost.get('reachabilityStatusCode') or '')], 'suggestedAction': 'review_embedded_outpost_telemetry', }) else: warnings.append({'type': 'embedded_outpost_unavailable', 'severity': 'warning', 'title': 'Expected embedded outpost is unavailable or unknown', 'evidence': [outpost_status], 'suggestedAction': 'review_embedded_outpost'}) for host in coverage.get('publicHosts', []): if host.get('coverageStatus') == 'unprotected': warnings.append({'type': 'public_host_without_authentik', 'severity': 'high', 'title': f"{', '.join(host.get('domains') or [])} has no Authentik mapping", 'evidence': host.get('domains') or [], 'suggestedAction': 'map_host_to_authentik'}) app_slugs = {str(item.get('slug')) for item in apps.get('items', []) if item.get('slug')} provider_app_slugs = {str(item.get('assignedApplicationSlug')) for item in proxy.get('items', []) if item.get('assignedApplicationSlug')} for provider in proxy.get('items', []): if not provider.get('assignedApplicationSlug'): warnings.append({'type': 'proxy_provider_without_app', 'severity': 'warning', 'title': f"{provider.get('name')} is not assigned to an application", 'evidence': [str(provider.get('externalHost') or provider.get('id'))], 'suggestedAction': 'assign_proxy_provider'}) for app in apps.get('items', []): slug = str(app.get('slug') or '') if slug and slug not in provider_app_slugs and not app.get('providerId'): warnings.append({'type': 'application_without_provider', 'severity': 'warning', 'title': f"{app.get('name')} has no provider mapping", 'evidence': [slug], 'suggestedAction': 'review_application_provider'}) for provider in oauth.get('items', []): if _as_int(provider.get('redirectUriCount')) >= 4: warnings.append({'type': 'oauth_many_redirect_uris', 'severity': 'warning', 'title': f"{provider.get('name')} has many redirect URIs", 'evidence': [str(provider.get('redirectUriCount'))], 'suggestedAction': 'review_oauth_redirect_uris'}) if not events.get('items'): warnings.append({'type': 'no_recent_events', 'severity': 'info', 'title': 'No recent Authentik events returned', 'evidence': [], 'suggestedAction': 'review_event_retention_or_permissions'}) warnings = [item for item in warnings if item.get('evidence') is not app_slugs] severity_order = {'critical': 3, 'high': 2, 'warning': 1, 'info': 0} items = sorted(warnings, key=lambda item: severity_order.get(str(item.get('severity')), 0), reverse=True) status = 'warning' if items else 'ok' return {'success': True, 'status': status, 'message': 'security_warnings_detected' if items else 'security_warnings_ok', 'items': items, 'total': len(items), 'data': {'items': items}} def _collection(self, path: str, mapper: Any, message: str) -> dict[str, Any]: if not self.is_configured(): return self._missing(items=[], total=0) try: items = mapper(self._paged(path)) except RuntimeError as exc: return self._error_payload(exc, items=[], total=0) return {'success': True, 'status': 'ok', 'message': message, 'total': len(items), 'items': items, 'data': {'items': items}} @staticmethod def _version(payload: Any) -> str: if not isinstance(payload, dict): return 'unknown' return str(payload.get('version') or payload.get('build') or payload.get('authentik_version') or 'unknown') @staticmethod def _map_applications(raw: list[dict[str, Any]]) -> list[dict[str, Any]]: return [{ 'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or ''), 'slug': str(item.get('slug') or ''), 'launchUrl': str(item.get('launch_url') or item.get('launchUrl') or item.get('meta_launch_url') or ''), 'providerId': item.get('provider') or item.get('providerId'), } for item in raw] @staticmethod def _map_providers(raw: list[dict[str, Any]]) -> list[dict[str, Any]]: result = [] for item in raw: type_text = str(item.get('type') or item.get('component') or '').lower() provider_type = 'proxy' if 'proxy' in type_text else ('oauth2' if 'oauth' in type_text or 'oidc' in type_text else 'unknown') result.append({'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or ''), 'providerType': provider_type, 'assignedApplicationSlug': item.get('assigned_application_slug') or item.get('assignedApplicationSlug')}) return result @staticmethod def _map_proxy_providers(raw: list[dict[str, Any]]) -> list[dict[str, Any]]: return [{ 'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or ''), 'externalHost': str(item.get('external_host') or item.get('externalHost') or ''), 'assignedApplicationSlug': item.get('assigned_application_slug') or item.get('assignedApplicationSlug'), 'mode': str(item.get('mode') or ''), } for item in raw] @staticmethod def _map_oauth2_providers(raw: list[dict[str, Any]]) -> list[dict[str, Any]]: mapped = [] for item in raw: redirect_uris = item.get('redirect_uris') or item.get('redirectUris') or [] if isinstance(redirect_uris, str): uris = [line for line in redirect_uris.splitlines() if line.strip()] else: uris = [str(uri) for uri in _as_list(redirect_uris)] mapped.append({'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or ''), 'assignedApplicationSlug': item.get('assigned_application_slug') or item.get('assignedApplicationSlug'), 'redirectUriCount': len(uris), 'redirectUris': uris[:10]}) return mapped @staticmethod def _map_outposts(raw: list[dict[str, Any]]) -> list[dict[str, Any]]: return [{ 'id': item.get('pk') or item.get('id'), 'name': str(item.get('name') or ''), 'type': str(item.get('type') or item.get('outpost_type') or 'unknown'), 'health': str(item.get('health') or item.get('health_status') or item.get('status') or 'unknown'), 'providerCount': len(_as_list(item.get('providers'))), } for item in raw] @staticmethod def _map_event(raw: dict[str, Any]) -> dict[str, Any]: user = raw.get('user') if isinstance(raw.get('user'), dict) else {} return { 'id': raw.get('pk') or raw.get('id'), 'action': str(raw.get('action') or raw.get('event') or ''), 'created': raw.get('created') or raw.get('timestamp'), 'username': str(user.get('username') or raw.get('username') or ''), 'clientIp': AuthentikConnector._mask_ip(raw.get('client_ip') or raw.get('clientIp')), } @staticmethod def _mask_ip(value: Any) -> str: text = str(value or '') parts = text.split('.') if len(parts) == 4: return '.'.join(parts[:3] + ['x']) return 'masked' if text else '' @staticmethod def _empty_coverage_summary() -> dict[str, Any]: return {'totalApplications': 0, 'totalProviders': 0, 'totalProxyProviders': 0, 'totalPublicHosts': 0, 'protectedHosts': 0, 'likelyProtectedHosts': 0, 'unprotectedHosts': 0, 'npmStatus': 'unknown'} @staticmethod def _missing(**extra: Any) -> dict[str, Any]: return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'url': _safe_url(), 'required_env_vars': ['AUTHENTIK_URL', 'AUTHENTIK_TOKEN'], 'data': {}, **extra} @staticmethod def _error_payload(exc: Exception, **extra: Any) -> dict[str, Any]: reason = redact_message(str(exc)) status = 'auth_failed' if reason.startswith('auth_failed') else ('missing_config' if reason == 'missing_config' else 'unreachable') return {'success': False, 'status': status, 'message': reason, 'data': {}, **extra} @staticmethod def _npm_hosts() -> dict[str, Any]: try: from .nginx_proxy_manager import NginxProxyManagerConnector return NginxProxyManagerConnector().proxy_hosts() except Exception as exc: return {'success': False, 'status': 'unavailable', 'message': redact_message(str(exc)), 'items': []}