From 97d8136b58cd1b8b4203366954cd9b584fb3f89b Mon Sep 17 00:00:00 2001 From: sheyaln Date: Fri, 12 Dec 2025 20:24:47 -0500 Subject: [PATCH 1/2] Add group support and improved configuration options for Authentik OIDC --- Dockerfile | 50 ++++++++++++++ docker-compose.yml | 19 ++++++ src/auth/auth_authentik_openid.py | 105 +++++++++++++++++++++++------- 3 files changed, 150 insertions(+), 24 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..20e3609d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# Multi-stage build for script-server + +# Stage 1: Build frontend +# Using Node 16 for compatibility with older webpack +FROM node:16-alpine AS frontend-builder + +WORKDIR /app/web-src + +# Copy package files first for better caching +COPY web-src/package*.json ./ +RUN npm ci + +# Copy frontend source and build +COPY web-src/ ./ +RUN npm run build + +# Stage 2: Python runtime +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies for pty support +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install Python dependencies +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application source +COPY src/ ./src/ +COPY launcher.py ./ +COPY conf/ ./conf/ + +# Copy built frontend from builder stage +COPY --from=frontend-builder /app/web /app/web + +# Create directories for configs and logs +RUN mkdir -p /app/conf/runners /app/conf/scripts /app/logs + +# Default port +EXPOSE 5000 + +# Environment variables +ENV PYTHONUNBUFFERED=1 + +# Run the application +CMD ["python3", "launcher.py"] + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..727b7b3b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + script-server: + build: + context: . + dockerfile: Dockerfile + ports: + - "5001:5000" + volumes: + - ./conf:/app/conf + - ./samples/scripts:/app/samples/scripts:ro + - script-server-logs:/app/logs + environment: + # - AUTHENTIK_CLIENT_SECRET=your-secret-here + - PYTHONUNBUFFERED=1 + restart: unless-stopped + +volumes: + script-server-logs: + diff --git a/src/auth/auth_authentik_openid.py b/src/auth/auth_authentik_openid.py index 75256293..28ae4c92 100644 --- a/src/auth/auth_authentik_openid.py +++ b/src/auth/auth_authentik_openid.py @@ -1,52 +1,109 @@ import logging +from typing import Optional, List, Dict from tornado import escape from auth.auth_abstract_oauth import AbstractOauthAuthenticator, _OauthUserInfo from model import model_helper -LOGGER = logging.getLogger('script_server.GoogleOauthAuthorizer') +LOGGER = logging.getLogger('script_server.AuthentikOpenidAuthenticator') + + +def _map_groups(groups: List[str], group_mapping: Optional[Dict[str, str]]) -> List[str]: + """ + Map Authentik groups to internal groups using the provided mapping. + Groups not in the mapping are passed through unchanged. + """ + if not group_mapping: + return groups + + result = [] + for group in groups: + if group in group_mapping: + mapped = group_mapping[group] + if isinstance(mapped, list): + result.extend(mapped) + else: + result.append(mapped) + else: + result.append(group) + + return list(set(result)) # noinspection PyProtectedMember class AuthentikOpenidAuthenticator(AbstractOauthAuthenticator): def __init__(self, params_dict): - authenitk_url = model_helper.read_obligatory( - params_dict, - 'authenitk_url', - ': should contain openid url, e.g. http://localhost:9001/') - if not authenitk_url.endswith('/'): - authenitk_url = authenitk_url + '/' - self._authenitk_url = authenitk_url - - super().__init__(authenitk_url + 'application/o/authorize/', - authenitk_url + 'application/o/token/', - 'email openid profile', - params_dict) + # Support both spellings for backwards compatibility + authentik_url = params_dict.get('authentik_url') or params_dict.get('authenitk_url') + if not authentik_url: + raise Exception('authentik_url is required: should contain Authentik URL, e.g. https://authentik.example.com/') + + if not authentik_url.endswith('/'): + authentik_url = authentik_url + '/' + self._authentik_url = authentik_url + + application_slug = params_dict.get('application_slug') + if application_slug: + auth_base = f'{authentik_url}application/o/{application_slug}/' + else: + auth_base = f'{authentik_url}application/o/' + + # Read group mapping configuration + self._group_mapping = model_helper.read_dict(params_dict, 'group_mapping') + + # Read username claim preference + self._username_claim = params_dict.get('username_claim', 'preferred_username') + + scope = params_dict.get('scope', 'openid email profile groups') + + super().__init__( + auth_base + 'authorize/', + auth_base + 'token/', + scope, + params_dict) + + # Set userinfo URL + self._userinfo_url = auth_base + 'userinfo/' async def fetch_user_info(self, access_token) -> _OauthUserInfo: - user_future = self.http_client.fetch( - self._authenitk_url + 'application/o/userinfo/', + user_response = await self.http_client.fetch( + self._userinfo_url, headers={'Authorization': 'Bearer ' + access_token}) - user_response = await user_future - if not user_response: - raise Exception('No response during loading userinfo') + raise Exception('No response from Authentik userinfo endpoint') response_values = {} if user_response.body: response_values = escape.json_decode(user_response.body) + username = response_values.get(self._username_claim) + if not username: + for fallback in ['preferred_username', 'email', 'sub']: + username = response_values.get(fallback) + if username: + if fallback != self._username_claim: + LOGGER.warning( + f'Username claim "{self._username_claim}" not found, ' + f'falling back to "{fallback}"') + break + + # Extract and map groups eager_groups = None if self.group_support: - eager_groups = response_values.get('groups') - if eager_groups is None: + raw_groups = response_values.get('groups') + if raw_groups is not None: + eager_groups = _map_groups(raw_groups, self._group_mapping) + LOGGER.debug(f'Loaded groups for {username}: {eager_groups}') + else: eager_groups = [] - LOGGER.warning('Failed to load user groups. Most probably groups mapping is not enabled. ' - 'Check the corresponding wiki section') + LOGGER.warning( + 'Groups not found in Authentik response. ' + 'Make sure the Authentik provider is configured to include groups scope ' + 'and the application has a groups scope mapping.') - return _OauthUserInfo(response_values.get('preferred_username'), True, response_values, eager_groups) + return _OauthUserInfo(username, True, response_values, eager_groups) async def fetch_user_groups(self, access_token): - raise Exception('This shouldn\'t be used, all the groups should be fetched with user info.') + raise Exception('Groups are fetched with userinfo, this method should not be called') From 2448bda3f7bd3ccbef9a8dbb77ca77bef72060b7 Mon Sep 17 00:00:00 2001 From: sheyaln Date: Fri, 12 Dec 2025 21:33:05 -0500 Subject: [PATCH 2/2] fix typo --- src/auth/auth_authentik_openid.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth/auth_authentik_openid.py b/src/auth/auth_authentik_openid.py index 28ae4c92..6fb04297 100644 --- a/src/auth/auth_authentik_openid.py +++ b/src/auth/auth_authentik_openid.py @@ -35,7 +35,7 @@ def _map_groups(groups: List[str], group_mapping: Optional[Dict[str, str]]) -> L class AuthentikOpenidAuthenticator(AbstractOauthAuthenticator): def __init__(self, params_dict): # Support both spellings for backwards compatibility - authentik_url = params_dict.get('authentik_url') or params_dict.get('authenitk_url') + authentik_url = params_dict.get('authentik_url') or params_dict.get('authentik_url') if not authentik_url: raise Exception('authentik_url is required: should contain Authentik URL, e.g. https://authentik.example.com/')