Compare commits

...
3 Commits
Author SHA1 Message Date
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
Jeroen SchweitzerandClaude Opus 4.5 892015fa59 fix: use Authentik domain URL for cookie domain compatibility
Build and Push / build (release) Successful in 1m17s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:52:14 +01:00
Jeroen SchweitzerandClaude Opus 4.5 ee93a73160 fix: use AUTHENTIK_USERNAME/PASSWORD env vars to match production
Build and Push / build (release) Successful in 28s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:37:12 +01:00
4 changed files with 81 additions and 41 deletions
+18
View File
@@ -5,6 +5,24 @@ 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.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
- Use Authentik domain URL (auth.schweitz.net) instead of IP to fix cookie domain matching
## [1.4.1] - 2026-01-01
### Fixed
- Authentik config now uses AUTHENTIK_USERNAME/PASSWORD to match production env vars
## [1.4.0] - 2026-01-01 ## [1.4.0] - 2026-01-01
### Added ### Added
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "core-api" name = "core-api"
version = "1.4.0" version = "1.4.3"
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"
+58 -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():
"Accept": "application/json", hdrs = {
"Content-Type": "application/json", "Accept": "application/json",
} "Content-Type": "application/json",
if csrf_token: "Cookie": f"authentik_session={session_cookie}",
headers["X-authentik-CSRF"] = csrf_token }
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 # 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_admin_user}, 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_admin_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:
""" """
@@ -342,8 +360,8 @@ class AuthService:
Returns: Returns:
BulkSyncResultSchema with counts of created/updated/failed users BulkSyncResultSchema with counts of created/updated/failed users
""" """
if not settings.authentik_admin_user or not settings.authentik_admin_password: if not settings.authentik_username or not settings.authentik_password:
raise ValueError("AUTHENTIK_ADMIN_USER and AUTHENTIK_ADMIN_PASSWORD must be configured") raise ValueError("AUTHENTIK_USERNAME and AUTHENTIK_PASSWORD must be configured")
created = 0 created = 0
updated = 0 updated = 0
@@ -353,14 +371,17 @@ class AuthService:
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:
+4 -3
View File
@@ -121,9 +121,10 @@ class Settings(BaseSettings):
oidc_audience: str = "core-api" oidc_audience: str = "core-api"
# Authentik API (for token validation and user management) # Authentik API (for token validation and user management)
authentik_url: str = "http://192.168.86.149:9000" # Authentik base URL # Must use domain name (not IP) when AUTHENTIK_COOKIE_DOMAIN is set
authentik_admin_user: str = "" # Admin username for API access authentik_url: str = "https://auth.schweitz.net" # Authentik base URL
authentik_admin_password: str = "" # Admin password for API access authentik_username: str = "" # Admin username for API access (AUTHENTIK_USERNAME env var)
authentik_password: str = "" # Admin password for API access (AUTHENTIK_PASSWORD env var)
@property @property
def model_aliases(self) -> dict: def model_aliases(self) -> dict: