diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a969f6..cfb4d12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.3] - 2026-01-01 + +### Fixed + +- Manually extract and send session cookies for Authentik flow auth (fixes cross-domain cookie handling) + ## [1.4.2] - 2026-01-01 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 9e9904f..ac3c400 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "core-api" -version = "1.4.2" +version = "1.4.3" description = "Core Code API - Infrastructure management and tools API" readme = "README.md" requires-python = ">=3.12" diff --git a/src/auth/service.py b/src/auth/service.py index b5846ce..fd3f3c1 100644 --- a/src/auth/service.py +++ b/src/auth/service.py @@ -249,24 +249,30 @@ class AuthService: return items, total - def _get_csrf_token(self, client: httpx.AsyncClient) -> str: - """Extract CSRF token from cookies""" - for cookie in client.cookies.jar: - if cookie.name == "authentik_csrf": - return cookie.value + def _extract_cookie(self, headers: httpx.Headers, cookie_name: str) -> str: + """Extract a specific cookie value from Set-Cookie headers""" + import re + for header in headers.get_list('set-cookie'): + if header.startswith(f'{cookie_name}='): + match = re.match(rf'{cookie_name}=([^;]+)', header) + if match: + return match.group(1) return "" - async def _authentik_session_login(self, client: httpx.AsyncClient) -> None: + async def _authentik_session_login(self, client: httpx.AsyncClient) -> str: """ Authenticate with Authentik using the flow API to establish a session Authentik's flow API requires: - 1. Cookie persistence between requests + 1. Cookie persistence between requests (manually handled due to domain restrictions) 2. X-authentik-CSRF header set to the authentik_csrf cookie value 3. Multi-stage flow handling (identification -> password -> done) Args: - client: httpx client with cookie persistence + client: httpx client + + Returns: + Session cookie value for subsequent API calls Raises: ValueError: If authentication fails @@ -278,55 +284,66 @@ class AuthService: resp.raise_for_status() data = resp.json() - logger.debug(f"Flow initial response: component={data.get('component')}, type={data.get('type')}") + # Extract cookies manually from Set-Cookie headers (bypasses domain restrictions) + session_cookie = self._extract_cookie(resp.headers, "authentik_session") + csrf_cookie = self._extract_cookie(resp.headers, "authentik_csrf") - # Get CSRF token for subsequent requests - csrf_token = self._get_csrf_token(client) - logger.debug(f"CSRF token obtained: {bool(csrf_token)}") + logger.debug(f"Flow initial: component={data.get('component')}, session={bool(session_cookie)}, csrf={bool(csrf_cookie)}") - # Build headers with CSRF token - headers = { - "Accept": "application/json", - "Content-Type": "application/json", - } - if csrf_token: - headers["X-authentik-CSRF"] = csrf_token + # Build headers with manual cookie and CSRF token + def build_headers(): + hdrs = { + "Accept": "application/json", + "Content-Type": "application/json", + "Cookie": f"authentik_session={session_cookie}", + } + if csrf_cookie: + hdrs["Cookie"] += f"; authentik_csrf={csrf_cookie}" + hdrs["X-authentik-CSRF"] = csrf_cookie + return hdrs # Step 2: Handle identification stage - submit username if data.get("component") == "ak-stage-identification": resp = await client.post( flow_url, json={"uid_field": settings.authentik_username}, - headers=headers, + headers=build_headers(), ) resp.raise_for_status() data = resp.json() - logger.debug(f"After username: component={data.get('component')}, type={data.get('type')}") - # Update CSRF token (might change between stages) - csrf_token = self._get_csrf_token(client) - if csrf_token: - headers["X-authentik-CSRF"] = csrf_token + # Update session cookie if new one received + new_session = self._extract_cookie(resp.headers, "authentik_session") + if new_session: + session_cookie = new_session + + logger.debug(f"After username: component={data.get('component')}") # Step 3: Handle password stage if required if data.get("component") == "ak-stage-password": resp = await client.post( flow_url, json={"password": settings.authentik_password}, - headers=headers, + headers=build_headers(), ) resp.raise_for_status() data = resp.json() - logger.debug(f"After password: component={data.get('component')}, type={data.get('type')}") + + # Update session cookie if new one received + new_session = self._extract_cookie(resp.headers, "authentik_session") + if new_session: + session_cookie = new_session + + logger.debug(f"After password: component={data.get('component')}") # Check for access denied if data.get("component") == "ak-stage-access-denied": raise ValueError("Authentik authentication failed: access denied") # Check for redirect (successful auth) - if data.get("type") == "redirect" or data.get("to"): + if data.get("component") == "xak-flow-redirect" or data.get("to"): logger.info("Successfully authenticated with Authentik via flow") - return + return session_cookie # If we're still in identification stage, the username might be wrong if data.get("component") == "ak-stage-identification": @@ -334,6 +351,7 @@ class AuthService: raise ValueError(f"Authentication stuck at identification stage: {response_errors}") logger.info(f"Authentik flow completed with component: {data.get('component')}") + return session_cookie async def bulk_sync_from_authentik(self) -> BulkSyncResultSchema: """ @@ -353,14 +371,17 @@ class AuthService: try: async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: - # Authenticate with Authentik to get session - await self._authentik_session_login(client) + # Authenticate with Authentik to get session cookie + session_cookie = await self._authentik_session_login(client) - # Fetch users from Authentik admin API using session + # Fetch users from Authentik admin API using session cookie response = await client.get( f"{settings.authentik_url}/api/v3/core/users/", params={"page_size": 500}, - headers={"Accept": "application/json"}, + headers={ + "Accept": "application/json", + "Cookie": f"authentik_session={session_cookie}", + }, ) if response.status_code == 401: