Compare commits

...
3 Commits
Author SHA1 Message Date
Jeroen SchweitzerandClaude Opus 4.5 ffa984e271 fix: separate httpx and SQLAlchemy async contexts in bulk sync
Build and Push / build (release) Successful in 50s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 21:34:34 +01:00
Jeroen SchweitzerandClaude Opus 4.5 7d13be6052 fix: use uuid field instead of pk for Authentik user sync
Build and Push / build (release) Successful in 50s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 21:27:21 +01:00
Jeroen SchweitzerandClaude Opus 4.5 faff45db90 fix: manually handle session cookies for Authentik API authentication
Build and Push / build (release) Successful in 1m14s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 21:01:29 +01:00
3 changed files with 138 additions and 95 deletions
+19
View File
@@ -5,6 +5,25 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.5] - 2026-01-01
### Fixed
- Separate httpx and SQLAlchemy async contexts in bulk sync (fixes greenlet error)
## [1.4.4] - 2026-01-01
### Fixed
- Use `uuid` field instead of `pk` for Authentik user sync (pk is integer, uuid is proper UUID)
- Skip internal_service_account type users during bulk sync
## [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 ## [1.4.2] - 2026-01-01
### Fixed ### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "core-api" name = "core-api"
version = "1.4.2" version = "1.4.5"
description = "Core Code API - Infrastructure management and tools API" description = "Core Code API - Infrastructure management and tools API"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+61 -37
View File
@@ -249,24 +249,30 @@ class AuthService:
return items, total return items, total
def _get_csrf_token(self, client: httpx.AsyncClient) -> str: def _extract_cookie(self, headers: httpx.Headers, cookie_name: str) -> str:
"""Extract CSRF token from cookies""" """Extract a specific cookie value from Set-Cookie headers"""
for cookie in client.cookies.jar: import re
if cookie.name == "authentik_csrf": for header in headers.get_list('set-cookie'):
return cookie.value if header.startswith(f'{cookie_name}='):
match = re.match(rf'{cookie_name}=([^;]+)', header)
if match:
return match.group(1)
return "" 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 Authenticate with Authentik using the flow API to establish a session
Authentik's flow API requires: 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 2. X-authentik-CSRF header set to the authentik_csrf cookie value
3. Multi-stage flow handling (identification -> password -> done) 3. Multi-stage flow handling (identification -> password -> done)
Args: Args:
client: httpx client with cookie persistence client: httpx client
Returns:
Session cookie value for subsequent API calls
Raises: Raises:
ValueError: If authentication fails ValueError: If authentication fails
@@ -278,55 +284,66 @@ class AuthService:
resp.raise_for_status() resp.raise_for_status()
data = resp.json() 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 logger.debug(f"Flow initial: component={data.get('component')}, session={bool(session_cookie)}, csrf={bool(csrf_cookie)}")
csrf_token = self._get_csrf_token(client)
logger.debug(f"CSRF token obtained: {bool(csrf_token)}")
# Build headers with CSRF token # Build headers with manual cookie and CSRF token
headers = { def build_headers():
hdrs = {
"Accept": "application/json", "Accept": "application/json",
"Content-Type": "application/json", "Content-Type": "application/json",
"Cookie": f"authentik_session={session_cookie}",
} }
if csrf_token: if csrf_cookie:
headers["X-authentik-CSRF"] = csrf_token hdrs["Cookie"] += f"; authentik_csrf={csrf_cookie}"
hdrs["X-authentik-CSRF"] = csrf_cookie
return hdrs
# Step 2: Handle identification stage - submit username # Step 2: Handle identification stage - submit username
if data.get("component") == "ak-stage-identification": if data.get("component") == "ak-stage-identification":
resp = await client.post( resp = await client.post(
flow_url, flow_url,
json={"uid_field": settings.authentik_username}, json={"uid_field": settings.authentik_username},
headers=headers, headers=build_headers(),
) )
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
logger.debug(f"After username: component={data.get('component')}, type={data.get('type')}")
# Update CSRF token (might change between stages) # Update session cookie if new one received
csrf_token = self._get_csrf_token(client) new_session = self._extract_cookie(resp.headers, "authentik_session")
if csrf_token: if new_session:
headers["X-authentik-CSRF"] = csrf_token session_cookie = new_session
logger.debug(f"After username: component={data.get('component')}")
# Step 3: Handle password stage if required # Step 3: Handle password stage if required
if data.get("component") == "ak-stage-password": if data.get("component") == "ak-stage-password":
resp = await client.post( resp = await client.post(
flow_url, flow_url,
json={"password": settings.authentik_password}, json={"password": settings.authentik_password},
headers=headers, headers=build_headers(),
) )
resp.raise_for_status() resp.raise_for_status()
data = resp.json() 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 # Check for access denied
if data.get("component") == "ak-stage-access-denied": if data.get("component") == "ak-stage-access-denied":
raise ValueError("Authentik authentication failed: access denied") raise ValueError("Authentik authentication failed: access denied")
# Check for redirect (successful auth) # 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") 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 we're still in identification stage, the username might be wrong
if data.get("component") == "ak-stage-identification": if data.get("component") == "ak-stage-identification":
@@ -334,6 +351,7 @@ class AuthService:
raise ValueError(f"Authentication stuck at identification stage: {response_errors}") raise ValueError(f"Authentication stuck at identification stage: {response_errors}")
logger.info(f"Authentik flow completed with component: {data.get('component')}") logger.info(f"Authentik flow completed with component: {data.get('component')}")
return session_cookie
async def bulk_sync_from_authentik(self) -> BulkSyncResultSchema: async def bulk_sync_from_authentik(self) -> BulkSyncResultSchema:
""" """
@@ -351,16 +369,21 @@ class AuthService:
errors = [] errors = []
total_in_authentik = 0 total_in_authentik = 0
# Step 1: Fetch all user data from Authentik API
authentik_users = []
try: try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
# Authenticate with Authentik to get session # Authenticate with Authentik to get session cookie
await self._authentik_session_login(client) 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( response = await client.get(
f"{settings.authentik_url}/api/v3/core/users/", f"{settings.authentik_url}/api/v3/core/users/",
params={"page_size": 500}, params={"page_size": 500},
headers={"Accept": "application/json"}, headers={
"Accept": "application/json",
"Cookie": f"authentik_session={session_cookie}",
},
) )
if response.status_code == 401: if response.status_code == 401:
@@ -372,16 +395,22 @@ class AuthService:
authentik_users = data.get("results", []) authentik_users = data.get("results", [])
total_in_authentik = data.get("pagination", {}).get("count", len(authentik_users)) total_in_authentik = data.get("pagination", {}).get("count", len(authentik_users))
except httpx.HTTPStatusError as e:
raise ValueError(f"Authentik API error: {e.response.status_code}")
except httpx.RequestError as e:
raise ValueError(f"Failed to connect to Authentik: {str(e)}")
# Step 2: Sync users to database (outside of httpx context to avoid greenlet issues)
for auth_user in authentik_users: for auth_user in authentik_users:
try: try:
# Skip service accounts and inactive users # Skip service accounts and inactive users
if auth_user.get("type") == "service_account": if auth_user.get("type") in ("service_account", "internal_service_account"):
continue continue
if not auth_user.get("is_active", True): if not auth_user.get("is_active", True):
continue continue
# Extract user data from Authentik # Extract user data from Authentik
authentik_id = uuid.UUID(auth_user["pk"]) authentik_id = uuid.UUID(auth_user["uuid"])
email = auth_user.get("email") or f"{auth_user['username']}@local" email = auth_user.get("email") or f"{auth_user['username']}@local"
name = auth_user.get("name") or auth_user.get("username", "Unknown") name = auth_user.get("name") or auth_user.get("username", "Unknown")
avatar_url = auth_user.get("avatar") avatar_url = auth_user.get("avatar")
@@ -433,11 +462,6 @@ class AuthService:
# Commit all changes # Commit all changes
await self.session.commit() await self.session.commit()
except httpx.HTTPStatusError as e:
raise ValueError(f"Authentik API error: {e.response.status_code}")
except httpx.RequestError as e:
raise ValueError(f"Failed to connect to Authentik: {str(e)}")
return BulkSyncResultSchema( return BulkSyncResultSchema(
created=created, created=created,
updated=updated, updated=updated,