Authentik JWT sub claim may not be a valid UUID. Now derives a deterministic UUID from the sub string if parsing fails. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
613 lines
24 KiB
Python
613 lines
24 KiB
Python
"""
|
|
Authentication Controller
|
|
|
|
Provides authentication endpoints for OIDC token sync and user management.
|
|
"""
|
|
import uuid
|
|
from typing import Optional
|
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
|
from fastapi.responses import JSONResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.shared.base import BaseController
|
|
from src.shared.logging import get_logger
|
|
from src.shared.database import get_async_session
|
|
from src.domains.auth.schemas import (
|
|
AuthSyncRequest, AuthSyncResponse, UsersListResponse,
|
|
BulkSyncResultSchema, GroupsListResponse, RolesListResponse,
|
|
GroupRoleAssignmentResponse, UserProfileResponse, PreferencesUpdateRequest,
|
|
UserPreferencesSchema, ApiKeyCreateRequest, ApiKeyCreateResponse,
|
|
ApiKeysListResponse,
|
|
)
|
|
from src.domains.auth.service import AuthService
|
|
from src.domains.auth.oidc import get_current_user, get_current_user_or_forward_auth
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
class AuthController(BaseController):
|
|
"""
|
|
Controller for authentication operations
|
|
|
|
Provides endpoints for:
|
|
- Token synchronization (login)
|
|
- User profile retrieval
|
|
"""
|
|
|
|
def __init__(self):
|
|
super().__init__(prefix="/auth", tags=["Authentication"])
|
|
|
|
def create_router(self) -> APIRouter:
|
|
"""Create and configure the router"""
|
|
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
|
|
|
@router.post(
|
|
"/sync",
|
|
summary="Sync user from OIDC token",
|
|
response_model=AuthSyncResponse,
|
|
responses={
|
|
200: {"description": "User synced successfully"},
|
|
401: {"description": "Invalid or expired token"},
|
|
503: {"description": "Authentication service unavailable"},
|
|
},
|
|
)
|
|
async def sync_user(
|
|
request: AuthSyncRequest,
|
|
session: AsyncSession = Depends(get_async_session),
|
|
) -> AuthSyncResponse:
|
|
"""
|
|
Synchronize user from OIDC access token
|
|
|
|
This endpoint should be called after the client obtains an access token
|
|
from Authentik. It:
|
|
1. Validates the token via Authentik's userinfo endpoint
|
|
2. Creates or updates the user in the database
|
|
3. Syncs roles from Authentik groups
|
|
4. Returns the user profile with roles and preferences
|
|
|
|
The client should store the returned user info for local use.
|
|
"""
|
|
service = AuthService(session)
|
|
|
|
try:
|
|
# Validate token with Authentik
|
|
token_info = await service.validate_token(request.access_token)
|
|
except ValueError as e:
|
|
logger.warning(f"Token validation failed: {e}")
|
|
raise HTTPException(status_code=401, detail=str(e))
|
|
|
|
# Sync user to database
|
|
user, is_new = await service.sync_user(token_info)
|
|
|
|
# Sync roles from groups
|
|
roles = await service.sync_roles(user, token_info.groups)
|
|
|
|
# Commit the transaction
|
|
await session.commit()
|
|
|
|
# Refresh to get relationships
|
|
await session.refresh(user, ["preferences"])
|
|
|
|
# Build response
|
|
return AuthSyncResponse(
|
|
user=service.user_to_schema(user),
|
|
roles=service.roles_to_schema(roles),
|
|
preferences=service.preferences_to_schema(user.preferences),
|
|
is_new_user=is_new,
|
|
)
|
|
|
|
@router.get(
|
|
"/users",
|
|
summary="List all users",
|
|
response_model=UsersListResponse,
|
|
responses={
|
|
200: {"description": "List of users"},
|
|
},
|
|
)
|
|
async def list_users(
|
|
search: Optional[str] = Query(None, description="Search by name or email"),
|
|
offset: int = Query(0, ge=0, description="Number of records to skip"),
|
|
limit: int = Query(50, ge=1, le=100, description="Maximum records to return"),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
) -> UsersListResponse:
|
|
"""
|
|
List all users who have logged in via Authentik
|
|
|
|
Returns paginated list of users with their roles.
|
|
Supports search filtering by name or email.
|
|
"""
|
|
service = AuthService(session)
|
|
items, total = await service.list_users(
|
|
search=search,
|
|
offset=offset,
|
|
limit=limit,
|
|
)
|
|
return UsersListResponse(items=items, total=total)
|
|
|
|
@router.post(
|
|
"/users/sync-from-authentik",
|
|
summary="Bulk sync users from Authentik",
|
|
response_model=BulkSyncResultSchema,
|
|
responses={
|
|
200: {"description": "Sync completed"},
|
|
401: {"description": "Authentik API token invalid"},
|
|
503: {"description": "Authentik service unavailable"},
|
|
},
|
|
)
|
|
async def sync_users_from_authentik(
|
|
session: AsyncSession = Depends(get_async_session),
|
|
) -> BulkSyncResultSchema:
|
|
"""
|
|
Fetch all users from Authentik and sync to local database
|
|
|
|
This endpoint uses the Authentik admin API to fetch all users
|
|
and create/update them in the local database. Requires
|
|
AUTHENTIK_CORE_API_TOKEN to be configured.
|
|
|
|
Use this to initially populate users or to re-sync after
|
|
changes in Authentik.
|
|
"""
|
|
service = AuthService(session)
|
|
|
|
try:
|
|
result = await service.bulk_sync_from_authentik()
|
|
logger.info(
|
|
f"Bulk sync completed: {result.created} created, "
|
|
f"{result.updated} updated, {result.failed} failed"
|
|
)
|
|
return result
|
|
except ValueError as e:
|
|
logger.error(f"Bulk sync failed: {e}")
|
|
raise HTTPException(status_code=401, detail=str(e))
|
|
|
|
@router.get(
|
|
"/groups",
|
|
summary="List all groups",
|
|
response_model=GroupsListResponse,
|
|
responses={
|
|
200: {"description": "List of groups"},
|
|
},
|
|
)
|
|
async def list_groups(
|
|
search: Optional[str] = Query(None, description="Search by group name"),
|
|
offset: int = Query(0, ge=0, description="Number of records to skip"),
|
|
limit: int = Query(50, ge=1, le=100, description="Maximum records to return"),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
) -> GroupsListResponse:
|
|
"""
|
|
List all groups synced from Authentik
|
|
|
|
Returns paginated list of groups with their details.
|
|
Supports search filtering by name.
|
|
"""
|
|
service = AuthService(session)
|
|
items, total = await service.list_groups(
|
|
search=search,
|
|
offset=offset,
|
|
limit=limit,
|
|
)
|
|
return GroupsListResponse(items=items, total=total)
|
|
|
|
@router.post(
|
|
"/groups/sync-from-authentik",
|
|
summary="Bulk sync groups from Authentik",
|
|
response_model=BulkSyncResultSchema,
|
|
responses={
|
|
200: {"description": "Sync completed"},
|
|
401: {"description": "Authentik API credentials invalid"},
|
|
503: {"description": "Authentik service unavailable"},
|
|
},
|
|
)
|
|
async def sync_groups_from_authentik(
|
|
session: AsyncSession = Depends(get_async_session),
|
|
) -> BulkSyncResultSchema:
|
|
"""
|
|
Fetch all groups from Authentik and sync to local database
|
|
|
|
This endpoint uses the Authentik admin API to fetch all groups
|
|
and create/update them in the local database. Requires
|
|
AUTHENTIK_USERNAME and AUTHENTIK_PASSWORD to be configured.
|
|
|
|
Use this to populate groups or to re-sync after changes in Authentik.
|
|
"""
|
|
service = AuthService(session)
|
|
|
|
try:
|
|
result = await service.bulk_sync_groups_from_authentik()
|
|
logger.info(
|
|
f"Groups bulk sync completed: {result.created} created, "
|
|
f"{result.updated} updated, {result.failed} failed"
|
|
)
|
|
return result
|
|
except ValueError as e:
|
|
logger.error(f"Groups bulk sync failed: {e}")
|
|
raise HTTPException(status_code=401, detail=str(e))
|
|
|
|
@router.get(
|
|
"/roles",
|
|
summary="List all roles",
|
|
response_model=RolesListResponse,
|
|
responses={
|
|
200: {"description": "List of all available roles"},
|
|
},
|
|
)
|
|
async def list_roles(
|
|
session: AsyncSession = Depends(get_async_session),
|
|
) -> RolesListResponse:
|
|
"""
|
|
List all available roles in the system
|
|
|
|
Returns all domain.category:action role combinations.
|
|
Use these when assigning roles to groups.
|
|
"""
|
|
service = AuthService(session)
|
|
roles = await service.list_roles()
|
|
return RolesListResponse(
|
|
items=service.roles_to_schema(roles),
|
|
total=len(roles),
|
|
)
|
|
|
|
@router.post(
|
|
"/groups/{group_id}/roles/{role_id}",
|
|
summary="Assign role to group",
|
|
response_model=GroupRoleAssignmentResponse,
|
|
responses={
|
|
200: {"description": "Role assigned successfully"},
|
|
404: {"description": "Group or role not found"},
|
|
},
|
|
)
|
|
async def assign_role_to_group(
|
|
group_id: uuid.UUID = Path(..., description="Group ID"),
|
|
role_id: uuid.UUID = Path(..., description="Role ID to assign"),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
) -> GroupRoleAssignmentResponse:
|
|
"""
|
|
Assign a role to a group
|
|
|
|
All users in this group will inherit this role's permissions.
|
|
"""
|
|
service = AuthService(session)
|
|
|
|
try:
|
|
group = await service.assign_role_to_group(group_id, role_id)
|
|
await session.commit()
|
|
return GroupRoleAssignmentResponse(
|
|
group_id=group.id,
|
|
group_name=group.name,
|
|
roles=[role.name for role in group.roles],
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
@router.delete(
|
|
"/groups/{group_id}/roles/{role_id}",
|
|
summary="Remove role from group",
|
|
response_model=GroupRoleAssignmentResponse,
|
|
responses={
|
|
200: {"description": "Role removed successfully"},
|
|
404: {"description": "Group or role not found"},
|
|
},
|
|
)
|
|
async def remove_role_from_group(
|
|
group_id: uuid.UUID = Path(..., description="Group ID"),
|
|
role_id: uuid.UUID = Path(..., description="Role ID to remove"),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
) -> GroupRoleAssignmentResponse:
|
|
"""
|
|
Remove a role from a group
|
|
|
|
Users in this group will no longer inherit this role's permissions.
|
|
"""
|
|
service = AuthService(session)
|
|
|
|
try:
|
|
group = await service.remove_role_from_group(group_id, role_id)
|
|
await session.commit()
|
|
return GroupRoleAssignmentResponse(
|
|
group_id=group.id,
|
|
group_name=group.name,
|
|
roles=[role.name for role in group.roles],
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
# =====================================================================
|
|
# Phase 4: User Profile & Settings
|
|
# =====================================================================
|
|
|
|
@router.get(
|
|
"/users/me",
|
|
summary="Get current user profile",
|
|
response_model=UserProfileResponse,
|
|
responses={
|
|
200: {"description": "User profile with roles and preferences"},
|
|
401: {"description": "Not authenticated"},
|
|
},
|
|
)
|
|
async def get_current_user_profile(
|
|
user_claims: dict = Depends(get_current_user_or_forward_auth),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
) -> UserProfileResponse:
|
|
"""
|
|
Get the current authenticated user's profile
|
|
|
|
Returns the user's profile, roles, and preferences.
|
|
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)
|
|
|
|
# Get authentik_id from claims (JWT 'sub' field or forward auth 'uid')
|
|
authentik_id_str = user_claims.get("sub")
|
|
if not authentik_id_str or authentik_id_str == "local-user":
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
try:
|
|
authentik_id = uuid.UUID(authentik_id_str)
|
|
except ValueError:
|
|
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
|
|
|
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:
|
|
auth_method = user_claims.get("auth_method")
|
|
if auth_method == "forward_auth":
|
|
# 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(
|
|
user=service.user_to_schema(user),
|
|
roles=service.roles_to_schema(user.roles),
|
|
preferences=service.preferences_to_schema(user.preferences),
|
|
)
|
|
|
|
@router.get(
|
|
"/users/me/preferences",
|
|
summary="Get user preferences",
|
|
response_model=UserPreferencesSchema,
|
|
responses={
|
|
200: {"description": "User preferences"},
|
|
401: {"description": "Not authenticated"},
|
|
},
|
|
)
|
|
async def get_preferences(
|
|
user_claims: dict = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
) -> UserPreferencesSchema:
|
|
"""
|
|
Get the current user's preferences
|
|
"""
|
|
service = AuthService(session)
|
|
|
|
authentik_id_str = user_claims.get("sub")
|
|
if not authentik_id_str or authentik_id_str == "local-user":
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
try:
|
|
authentik_id = uuid.UUID(authentik_id_str)
|
|
except ValueError:
|
|
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
|
|
|
user = await service.get_user_by_authentik_id(authentik_id)
|
|
if user is None:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
return service.preferences_to_schema(user.preferences)
|
|
|
|
@router.patch(
|
|
"/users/me/preferences",
|
|
summary="Update user preferences",
|
|
response_model=UserPreferencesSchema,
|
|
responses={
|
|
200: {"description": "Updated preferences"},
|
|
401: {"description": "Not authenticated"},
|
|
422: {"description": "Invalid preference value"},
|
|
},
|
|
)
|
|
async def update_preferences(
|
|
request: PreferencesUpdateRequest,
|
|
user_claims: dict = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
) -> UserPreferencesSchema:
|
|
"""
|
|
Update the current user's preferences
|
|
|
|
Only provided fields are updated. preferences_json is merged
|
|
with existing values (not replaced).
|
|
"""
|
|
service = AuthService(session)
|
|
|
|
authentik_id_str = user_claims.get("sub")
|
|
if not authentik_id_str or authentik_id_str == "local-user":
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
try:
|
|
authentik_id = uuid.UUID(authentik_id_str)
|
|
except ValueError:
|
|
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
|
|
|
user = await service.get_user_by_authentik_id(authentik_id)
|
|
if user is None:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
try:
|
|
prefs = await service.update_preferences(
|
|
user_id=user.id,
|
|
theme=request.theme,
|
|
default_room=request.default_room,
|
|
preferences_json=request.preferences_json,
|
|
)
|
|
await session.commit()
|
|
return service.preferences_to_schema(prefs)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=422, detail=str(e))
|
|
|
|
# =====================================================================
|
|
# Phase 4: API Keys
|
|
# =====================================================================
|
|
|
|
@router.get(
|
|
"/users/me/api-keys",
|
|
summary="List user's API keys",
|
|
response_model=ApiKeysListResponse,
|
|
responses={
|
|
200: {"description": "List of API keys"},
|
|
401: {"description": "Not authenticated"},
|
|
},
|
|
)
|
|
async def list_api_keys(
|
|
user_claims: dict = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
) -> ApiKeysListResponse:
|
|
"""
|
|
List all API keys for the current user
|
|
|
|
Returns key metadata only - the actual key values are never
|
|
retrievable after creation.
|
|
"""
|
|
service = AuthService(session)
|
|
|
|
authentik_id_str = user_claims.get("sub")
|
|
if not authentik_id_str or authentik_id_str == "local-user":
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
try:
|
|
authentik_id = uuid.UUID(authentik_id_str)
|
|
except ValueError:
|
|
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
|
|
|
user = await service.get_user_by_authentik_id(authentik_id)
|
|
if user is None:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
keys = await service.list_user_api_keys(user.id)
|
|
return ApiKeysListResponse(
|
|
items=[service.api_key_to_schema(k) for k in keys],
|
|
total=len(keys),
|
|
)
|
|
|
|
@router.post(
|
|
"/users/me/api-keys",
|
|
summary="Create a new API key",
|
|
response_model=ApiKeyCreateResponse,
|
|
responses={
|
|
201: {"description": "API key created"},
|
|
401: {"description": "Not authenticated"},
|
|
403: {"description": "API keys disabled for user"},
|
|
},
|
|
)
|
|
async def create_api_key(
|
|
request: ApiKeyCreateRequest,
|
|
user_claims: dict = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
) -> ApiKeyCreateResponse:
|
|
"""
|
|
Create a new API key for the current user
|
|
|
|
**IMPORTANT**: The full API key is only returned once in this response!
|
|
Store it securely - it cannot be retrieved again.
|
|
"""
|
|
service = AuthService(session)
|
|
|
|
authentik_id_str = user_claims.get("sub")
|
|
if not authentik_id_str or authentik_id_str == "local-user":
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
try:
|
|
authentik_id = uuid.UUID(authentik_id_str)
|
|
except ValueError:
|
|
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
|
|
|
user = await service.get_user_by_authentik_id(authentik_id)
|
|
if user is None:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
try:
|
|
api_key, full_key = await service.create_api_key(
|
|
user_id=user.id,
|
|
name=request.name,
|
|
scopes=request.scopes,
|
|
expires_in_days=request.expires_in_days,
|
|
)
|
|
await session.commit()
|
|
|
|
return ApiKeyCreateResponse(
|
|
id=api_key.id,
|
|
name=api_key.name,
|
|
key=full_key, # Only time this is returned!
|
|
key_prefix=api_key.key_prefix,
|
|
scopes=api_key.scopes,
|
|
expires_at=api_key.expires_at,
|
|
created_at=api_key.created_at,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=403, detail=str(e))
|
|
|
|
@router.delete(
|
|
"/users/me/api-keys/{key_id}",
|
|
summary="Delete an API key",
|
|
responses={
|
|
204: {"description": "API key deleted"},
|
|
401: {"description": "Not authenticated"},
|
|
404: {"description": "API key not found"},
|
|
},
|
|
)
|
|
async def delete_api_key(
|
|
key_id: uuid.UUID = Path(..., description="API key ID to delete"),
|
|
user_claims: dict = Depends(get_current_user),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
) -> JSONResponse:
|
|
"""
|
|
Delete an API key
|
|
|
|
The key will be immediately invalidated.
|
|
"""
|
|
service = AuthService(session)
|
|
|
|
authentik_id_str = user_claims.get("sub")
|
|
if not authentik_id_str or authentik_id_str == "local-user":
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
try:
|
|
authentik_id = uuid.UUID(authentik_id_str)
|
|
except ValueError:
|
|
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
|
|
|
user = await service.get_user_by_authentik_id(authentik_id)
|
|
if user is None:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
try:
|
|
deleted = await service.delete_api_key(user.id, key_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="API key not found")
|
|
await session.commit()
|
|
return JSONResponse(status_code=204, content=None)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=403, detail=str(e))
|
|
|
|
return router
|
|
|
|
|
|
# Create controller instance
|
|
auth_controller = AuthController()
|