Add endpoint to get current user profile from NPM forward auth headers. Enables web authentication flow where NPM handles Authentik login. - Read X-authentik-* headers set by NPM forward auth - Auto-create user if not in database (first login via web) - Sync roles from current Authentik groups - Add get_user_by_email and get_user_by_authentik_id helpers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
310 lines
12 KiB
Python
310 lines
12 KiB
Python
"""
|
|
Authentication Controller
|
|
|
|
Provides authentication endpoints for OIDC token sync and user management.
|
|
"""
|
|
from typing import Optional
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from fastapi.responses import JSONResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.controllers.base import BaseController
|
|
from src.logging_config import get_logger
|
|
from src.db import get_async_session
|
|
from src.auth.schemas import AuthSyncRequest, AuthSyncResponse, UsersListResponse, BulkSyncResultSchema, GroupsListResponse
|
|
from src.auth.service import AuthService
|
|
from src.auth.oidc import get_forward_auth_user
|
|
|
|
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(
|
|
"/me",
|
|
summary="Get current user profile",
|
|
response_model=AuthSyncResponse,
|
|
responses={
|
|
200: {"description": "User profile"},
|
|
401: {"description": "Not authenticated"},
|
|
404: {"description": "User not found in database"},
|
|
},
|
|
)
|
|
async def get_me(
|
|
forward_auth_user: Optional[dict] = Depends(get_forward_auth_user),
|
|
session: AsyncSession = Depends(get_async_session),
|
|
) -> AuthSyncResponse:
|
|
"""
|
|
Get the current authenticated user's profile
|
|
|
|
Authentication is handled by NPM forward auth with Authentik.
|
|
The proxy sets X-authentik-* headers which this endpoint reads.
|
|
|
|
For internal/LAN access (no forward auth headers), returns 401.
|
|
Use POST /auth/sync with an OIDC token for mobile app authentication.
|
|
"""
|
|
# Require forward auth for this endpoint
|
|
if forward_auth_user is None:
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail="Authentication required - access via authenticated proxy or use /auth/sync",
|
|
)
|
|
|
|
service = AuthService(session)
|
|
|
|
# Try to find user by Authentik UID first, then by email
|
|
user = None
|
|
uid = forward_auth_user.get("uid")
|
|
if uid:
|
|
try:
|
|
import uuid
|
|
authentik_id = uuid.UUID(uid)
|
|
user = await service.get_user_by_authentik_id(authentik_id)
|
|
except (ValueError, TypeError):
|
|
pass # Invalid UUID, try email
|
|
|
|
if user is None:
|
|
email = forward_auth_user.get("email")
|
|
if email:
|
|
user = await service.get_user_by_email(email)
|
|
|
|
if user is None:
|
|
# User authenticated with Authentik but not synced to database yet
|
|
# This can happen on first login via web
|
|
logger.info(f"User {forward_auth_user.get('email')} not found, creating from forward auth")
|
|
|
|
# Create user from forward auth headers
|
|
from src.auth.schemas import TokenInfoSchema
|
|
token_info = TokenInfoSchema(
|
|
sub=forward_auth_user.get("uid", ""),
|
|
email=forward_auth_user.get("email", ""),
|
|
name=forward_auth_user.get("name"),
|
|
groups=forward_auth_user.get("groups", []),
|
|
)
|
|
|
|
try:
|
|
user, _ = await service.sync_user(token_info)
|
|
await service.sync_roles(user, token_info.groups)
|
|
await session.commit()
|
|
await session.refresh(user, ["preferences", "roles"])
|
|
except Exception as e:
|
|
logger.error(f"Failed to create user from forward auth: {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Failed to create user profile",
|
|
)
|
|
|
|
# Sync roles from current groups (in case they changed)
|
|
groups = forward_auth_user.get("groups", [])
|
|
roles = await service.sync_roles(user, groups)
|
|
await session.commit()
|
|
|
|
return AuthSyncResponse(
|
|
user=service.user_to_schema(user),
|
|
roles=service.roles_to_schema(roles),
|
|
preferences=service.preferences_to_schema(user.preferences),
|
|
is_new_user=False,
|
|
)
|
|
|
|
return router
|
|
|
|
|
|
# Create controller instance
|
|
auth_controller = AuthController()
|