Publish curated Unraid-AppOps-Gateway source from 68878c3167dacf2b424c16780e60e0d60815e477
This commit is contained in:
1 parent
837c263cd8
commit
551de41e9f
18 files changed
+1183
-58
No files matched your search
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user