Compare commits

...
4 Commits
Author SHA1 Message Date
Jeroen SchweitzerandClaude Opus 4.5 3516376d92 chore: cleanup auth code after debugging session
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:41:10 +01:00
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 144 additions and 95 deletions
+25
View File
@@ -5,6 +5,31 @@ 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.6] - 2026-01-01
### Changed
- Code cleanup: move inline `re` import to top of auth/service.py
## [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.6"
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
@@ -3,6 +3,7 @@ Authentication Service
Business logic for user synchronization from Authentik. Business logic for user synchronization from Authentik.
""" """
import re
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
@@ -249,24 +250,29 @@ 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: for header in headers.get_list('set-cookie'):
if cookie.name == "authentik_csrf": if header.startswith(f'{cookie_name}='):
return cookie.value 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,