Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ab9f73a1d | ||
|
|
dd5b794de4 | ||
|
|
dd0997679f |
@@ -5,6 +5,22 @@ 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.9.3] - 2026-01-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Handle non-UUID `sub` claim in `/auth/sync` - Authentik JWT may return non-UUID subject identifiers
|
||||||
|
- Now derives deterministic UUID from sub string if direct parsing fails
|
||||||
|
|
||||||
|
## [1.9.2] - 2026-01-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `/auth/users/me` endpoint now supports both NPM forward auth headers AND JWT Bearer tokens
|
||||||
|
- Added `get_current_user_or_forward_auth()` combined auth dependency
|
||||||
|
- Fixes web authentication where NPM passes `X-authentik-*` headers instead of JWT
|
||||||
|
- Mobile/native clients continue to use JWT Bearer tokens as before
|
||||||
|
|
||||||
## [1.9.1] - 2026-01-03
|
## [1.9.1] - 2026-01-03
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "core-api"
|
name = "core-api"
|
||||||
version = "1.9.1"
|
version = "1.9.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"
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from src.domains.auth.schemas import (
|
|||||||
ApiKeysListResponse,
|
ApiKeysListResponse,
|
||||||
)
|
)
|
||||||
from src.domains.auth.service import AuthService
|
from src.domains.auth.service import AuthService
|
||||||
from src.domains.auth.oidc import get_current_user
|
from src.domains.auth.oidc import get_current_user, get_current_user_or_forward_auth
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -322,22 +322,26 @@ class AuthController(BaseController):
|
|||||||
responses={
|
responses={
|
||||||
200: {"description": "User profile with roles and preferences"},
|
200: {"description": "User profile with roles and preferences"},
|
||||||
401: {"description": "Not authenticated"},
|
401: {"description": "Not authenticated"},
|
||||||
404: {"description": "User not found in database"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def get_current_user_profile(
|
async def get_current_user_profile(
|
||||||
user_claims: dict = Depends(get_current_user),
|
user_claims: dict = Depends(get_current_user_or_forward_auth),
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
) -> UserProfileResponse:
|
) -> UserProfileResponse:
|
||||||
"""
|
"""
|
||||||
Get the current authenticated user's profile
|
Get the current authenticated user's profile
|
||||||
|
|
||||||
Returns the user's profile, roles, and preferences.
|
Returns the user's profile, roles, and preferences.
|
||||||
Requires authentication via Bearer token or API key.
|
Supports both:
|
||||||
|
- Bearer token (mobile/native clients)
|
||||||
|
- NPM forward auth headers (web clients via proxy)
|
||||||
|
|
||||||
|
For forward auth users, auto-creates the user in the database
|
||||||
|
if they don't exist yet (first login via web).
|
||||||
"""
|
"""
|
||||||
service = AuthService(session)
|
service = AuthService(session)
|
||||||
|
|
||||||
# Get authentik_id from claims (JWT 'sub' field)
|
# Get authentik_id from claims (JWT 'sub' field or forward auth 'uid')
|
||||||
authentik_id_str = user_claims.get("sub")
|
authentik_id_str = user_claims.get("sub")
|
||||||
if not authentik_id_str or authentik_id_str == "local-user":
|
if not authentik_id_str or authentik_id_str == "local-user":
|
||||||
raise HTTPException(status_code=401, detail="Authentication required")
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
@@ -348,11 +352,27 @@ class AuthController(BaseController):
|
|||||||
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||||
|
|
||||||
user = await service.get_user_by_authentik_id(authentik_id)
|
user = await service.get_user_by_authentik_id(authentik_id)
|
||||||
|
|
||||||
|
# If user not found and using forward auth, auto-create them
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(
|
auth_method = user_claims.get("auth_method")
|
||||||
status_code=404,
|
if auth_method == "forward_auth":
|
||||||
detail="User not found - please sync via /auth/sync first",
|
# Auto-sync user from forward auth headers
|
||||||
)
|
logger.info(f"Auto-creating user from forward auth: {user_claims.get('email')}")
|
||||||
|
user, is_new = await service.sync_user_from_claims(
|
||||||
|
authentik_id=authentik_id,
|
||||||
|
email=user_claims.get("email", ""),
|
||||||
|
name=user_claims.get("name", user_claims.get("preferred_username", "")),
|
||||||
|
groups=user_claims.get("groups", []),
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user, ["preferences", "roles"])
|
||||||
|
else:
|
||||||
|
# JWT auth but user not in DB - they need to sync first
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="User not found - please sync via /auth/sync first",
|
||||||
|
)
|
||||||
|
|
||||||
return UserProfileResponse(
|
return UserProfileResponse(
|
||||||
user=service.user_to_schema(user),
|
user=service.user_to_schema(user),
|
||||||
|
|||||||
@@ -396,6 +396,56 @@ async def get_forward_auth_admin(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user_or_forward_auth(
|
||||||
|
request: Request,
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(security)
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Combined auth: Try forward auth headers first, then JWT Bearer token.
|
||||||
|
|
||||||
|
Supports both:
|
||||||
|
- Web clients via NPM forward auth (X-authentik-* headers from proxy)
|
||||||
|
- Mobile/native clients via OIDC JWT Bearer tokens
|
||||||
|
|
||||||
|
This is the preferred dependency for /auth/users/me and similar endpoints
|
||||||
|
that need to work with both web (cookie-based via NPM) and mobile (token-based).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object containing headers
|
||||||
|
credentials: HTTP Bearer token from Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User claims dictionary with at minimum: sub, email, name, groups, auth_method
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 401: If neither forward auth headers nor valid JWT provided
|
||||||
|
"""
|
||||||
|
# 1. Try forward auth headers first (web via NPM)
|
||||||
|
username = request.headers.get("x-authentik-username")
|
||||||
|
email = request.headers.get("x-authentik-email")
|
||||||
|
|
||||||
|
if username and email:
|
||||||
|
# Forward auth headers present - use them
|
||||||
|
groups = request.headers.get("x-authentik-groups", "")
|
||||||
|
name = request.headers.get("x-authentik-name", username)
|
||||||
|
uid = request.headers.get("x-authentik-uid")
|
||||||
|
|
||||||
|
user_info = {
|
||||||
|
"sub": uid, # Use authentik UID as subject (for user lookup)
|
||||||
|
"email": email,
|
||||||
|
"preferred_username": username,
|
||||||
|
"name": name,
|
||||||
|
"groups": [g.strip() for g in groups.split(",")] if groups else [],
|
||||||
|
"auth_method": "forward_auth"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Authenticated via forward auth: {email}")
|
||||||
|
return user_info
|
||||||
|
|
||||||
|
# 2. Fall back to JWT Bearer token (mobile/native)
|
||||||
|
return await get_current_user(credentials)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Permission-Based Access Control
|
# Permission-Based Access Control
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -102,7 +102,13 @@ class AuthService:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (User, is_new_user)
|
Tuple of (User, is_new_user)
|
||||||
"""
|
"""
|
||||||
authentik_id = uuid.UUID(token_info.sub)
|
# Parse authentik_id - may be UUID or other format
|
||||||
|
try:
|
||||||
|
authentik_id = uuid.UUID(token_info.sub)
|
||||||
|
except ValueError:
|
||||||
|
# If sub is not a valid UUID, derive one deterministically
|
||||||
|
logger.warning(f"sub claim is not a UUID: {token_info.sub}, deriving UUID")
|
||||||
|
authentik_id = uuid.uuid5(uuid.NAMESPACE_OID, token_info.sub)
|
||||||
|
|
||||||
# Try to find existing user
|
# Try to find existing user
|
||||||
stmt = (
|
stmt = (
|
||||||
@@ -144,6 +150,74 @@ class AuthService:
|
|||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
return user, is_new
|
return user, is_new
|
||||||
|
|
||||||
|
async def sync_user_from_claims(
|
||||||
|
self,
|
||||||
|
authentik_id: uuid.UUID,
|
||||||
|
email: str,
|
||||||
|
name: str,
|
||||||
|
groups: list[str],
|
||||||
|
avatar_url: Optional[str] = None,
|
||||||
|
) -> tuple[User, bool]:
|
||||||
|
"""
|
||||||
|
Create or update user from forward auth claims (NPM X-authentik-* headers)
|
||||||
|
|
||||||
|
This is similar to sync_user() but works with raw claims instead of
|
||||||
|
TokenInfoSchema. Used for auto-syncing users on first web login via NPM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
authentik_id: The Authentik user UUID (from X-authentik-uid)
|
||||||
|
email: User email (from X-authentik-email)
|
||||||
|
name: User display name (from X-authentik-name)
|
||||||
|
groups: List of group names (from X-authentik-groups)
|
||||||
|
avatar_url: Optional avatar URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (User, is_new_user)
|
||||||
|
"""
|
||||||
|
# Try to find existing user
|
||||||
|
stmt = (
|
||||||
|
select(User)
|
||||||
|
.options(selectinload(User.roles), selectinload(User.preferences))
|
||||||
|
.where(User.authentik_id == authentik_id)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
is_new = user is None
|
||||||
|
|
||||||
|
if is_new:
|
||||||
|
# Create new user
|
||||||
|
user = User(
|
||||||
|
authentik_id=authentik_id,
|
||||||
|
email=email,
|
||||||
|
name=name or email,
|
||||||
|
avatar_url=avatar_url,
|
||||||
|
last_login=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
self.session.add(user)
|
||||||
|
await self.session.flush() # Get the user ID
|
||||||
|
|
||||||
|
# Create default preferences
|
||||||
|
preferences = UserPreferences(user_id=user.id)
|
||||||
|
self.session.add(preferences)
|
||||||
|
|
||||||
|
logger.info(f"Created new user from forward auth: {email}")
|
||||||
|
else:
|
||||||
|
# Update existing user
|
||||||
|
user.email = email
|
||||||
|
user.name = name or email
|
||||||
|
if avatar_url:
|
||||||
|
user.avatar_url = avatar_url
|
||||||
|
user.last_login = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
logger.info(f"Updated existing user from forward auth: {email}")
|
||||||
|
|
||||||
|
# Sync roles from groups
|
||||||
|
await self.sync_roles(user, groups)
|
||||||
|
|
||||||
|
await self.session.flush()
|
||||||
|
return user, is_new
|
||||||
|
|
||||||
async def sync_roles(self, user: User, group_names: list[str]) -> list[Role]:
|
async def sync_roles(self, user: User, group_names: list[str]) -> list[Role]:
|
||||||
"""
|
"""
|
||||||
Synchronize user roles from Authentik groups via group_roles mapping
|
Synchronize user roles from Authentik groups via group_roles mapping
|
||||||
|
|||||||
Reference in New Issue
Block a user