1947 lines
97 KiB
Python
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': []}
|