Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]

19 changes: 19 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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:

105 changes: 81 additions & 24 deletions src/auth/auth_authentik_openid.py
Original file line number Diff line number Diff line change
@@ -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('authentik_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')