Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d13be6052 | ||
|
|
faff45db90 | ||
|
|
892015fa59 | ||
|
|
ee93a73160 |
@@ -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.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
|
||||||
|
|
||||||
|
### 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
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "core-api"
|
name = "core-api"
|
||||||
version = "1.4.0"
|
version = "1.4.4"
|
||||||
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"
|
||||||
|
|||||||
+60
-39
@@ -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:
|
||||||
@@ -375,13 +396,13 @@ class AuthService:
|
|||||||
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")
|
||||||
|
|||||||
+4
-3
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user