From 884996fd83e7216c3b6d351e758109da27729f0e Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 3 Jan 2026 21:54:44 +0100 Subject: [PATCH] feat(auth): implement GET /auth/me for NPM forward auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/auth/controller.py | 77 ++++++++++++++++++++++++++++++++++++++---- src/auth/service.py | 36 ++++++++++++++++++++ 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/src/auth/controller.py b/src/auth/controller.py index 88d34ce..df241f7 100644 --- a/src/auth/controller.py +++ b/src/auth/controller.py @@ -13,6 +13,7 @@ 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__) @@ -222,21 +223,83 @@ class AuthController(BaseController): 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), - ) -> JSONResponse: + ) -> AuthSyncResponse: """ Get the current authenticated user's profile - Note: This endpoint requires a valid session or API key. - For now, returns 501 Not Implemented until session management is added. + 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. """ - # TODO: Implement with get_current_user dependency - raise HTTPException( - status_code=501, - detail="Not implemented - use /auth/sync with access token", + # 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 diff --git a/src/auth/service.py b/src/auth/service.py index c930bf7..1c3428f 100644 --- a/src/auth/service.py +++ b/src/auth/service.py @@ -86,6 +86,42 @@ class AuthService: logger.error(f"Authentik userinfo request error: {e}") raise ValueError("Authentication service unavailable") + async def get_user_by_email(self, email: str) -> Optional[User]: + """ + Get user by email address + + Args: + email: User email address + + Returns: + User if found, None otherwise + """ + stmt = ( + select(User) + .options(selectinload(User.roles), selectinload(User.preferences)) + .where(User.email == email) + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_user_by_authentik_id(self, authentik_id: uuid.UUID) -> Optional[User]: + """ + Get user by Authentik UUID + + Args: + authentik_id: Authentik user UUID + + Returns: + User if found, None otherwise + """ + stmt = ( + select(User) + .options(selectinload(User.roles), selectinload(User.preferences)) + .where(User.authentik_id == authentik_id) + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + async def sync_user(self, token_info: TokenInfoSchema) -> tuple[User, bool]: """ Create or update user from OIDC token info