Publish curated Unraid-AppOps-Gateway source from 68878c3167dacf2b424c16780e60e0d60815e477

This commit is contained in:
Public Source Publisher committed 2026-10-08 18:33:13 +00:00
1 parent 837c263cd8
commit 551de41e9f
18 files changed
+1183 -58

No files matched your search

+138 -21
View File
@@ -2,11 +2,12 @@ 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
from urllib.parse import quote, urljoin, urlparse, urlsplit
from uuid import RFC_4122, UUID
import httpx
@@ -35,6 +36,10 @@ from ..schemas.forge_oidc import (
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',
@@ -248,6 +253,39 @@ class AuthentikConnector(BaseConnector):
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:
@@ -258,15 +296,18 @@ class AuthentikConnector(BaseConnector):
return bool(settings.apis.get('AUTHENTIK_URL') and settings.apis.get('AUTHENTIK_TOKEN'))
def _config(self) -> _AuthentikConfig | None:
if not self.is_configured():
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(settings.apis['AUTHENTIK_URL']).rstrip('/'),
token=_token_value(settings.apis['AUTHENTIK_TOKEN']),
verify_tls=_as_bool(settings.apis.get('AUTHENTIK_VERIFY_TLS'), False),
timeout_seconds=_as_float(settings.apis.get('AUTHENTIK_TIMEOUT_SECONDS'), 10.0),
outpost_url=str(settings.apis.get('AUTHENTIK_OUTPOST_URL') or '').strip().rstrip('/') or None,
expect_embedded_outpost=_as_bool(settings.apis.get('AUTHENTIK_EXPECT_EMBEDDED_OUTPOST'), False),
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:
@@ -448,15 +489,20 @@ class AuthentikConnector(BaseConnector):
)
@staticmethod
def deployment_oidc_template_status(*, role_claim: str = 'roles') -> dict[str, Any]:
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(settings.apis.get(name) or '').strip()]
configured_claim = str(settings.apis.get('AUTHENTIK_OIDC_ROLE_CLAIM') or '').strip()
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 {
@@ -464,6 +510,68 @@ class AuthentikConnector(BaseConnector):
'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,
*,
@@ -489,11 +597,17 @@ class AuthentikConnector(BaseConnector):
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'}
authorization_flow = str(settings.apis.get('AUTHENTIK_OIDC_AUTHORIZATION_FLOW') or '').strip()
invalidation_flow = str(settings.apis.get('AUTHENTIK_OIDC_INVALIDATION_FLOW') or '').strip()
group_claim_mapping = str(settings.apis.get('AUTHENTIK_OIDC_GROUP_CLAIM_MAPPING') or '').strip()
if not authorization_flow or not invalidation_flow or not group_claim_mapping:
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}
@@ -516,15 +630,17 @@ class AuthentikConnector(BaseConnector):
None,
)
provider_redirects = provider.get('redirect_uris') if isinstance(provider, dict) else None
if isinstance(provider_redirects, str):
provider_redirects = [item for item in provider_redirects.splitlines() if item.strip()]
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 != [redirect_uri]
or group_claim_mapping not in (provider_mappings or [])
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
):
@@ -547,8 +663,9 @@ class AuthentikConnector(BaseConnector):
'client_type': 'confidential',
'client_id': application_slug,
'client_secret': client_secret,
'redirect_uris': [redirect_uri],
'property_mappings': [group_claim_mapping],
'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',