Files
Unraid-AppOps-Gateway-Public/backend/app/connectors/authentik.py
T

1947 lines
97 KiB
Python

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': []}