From dd5b794de4cd978798857d6df6ea6191625a6694 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 4 Jan 2026 17:45:05 +0100 Subject: [PATCH] fix: handle non-UUID sub claim in auth sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/domains/auth/controller.py | 34 +++++++++++---- src/domains/auth/service.py | 76 +++++++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/src/domains/auth/controller.py b/src/domains/auth/controller.py index 2e15a82..fab3c8a 100644 --- a/src/domains/auth/controller.py +++ b/src/domains/auth/controller.py @@ -322,7 +322,6 @@ class AuthController(BaseController): responses={ 200: {"description": "User profile with roles and preferences"}, 401: {"description": "Not authenticated"}, - 404: {"description": "User not found in database"}, }, ) async def get_current_user_profile( @@ -333,11 +332,16 @@ class AuthController(BaseController): Get the current authenticated user's profile 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) - # 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") if not authentik_id_str or authentik_id_str == "local-user": raise HTTPException(status_code=401, detail="Authentication required") @@ -348,11 +352,27 @@ class AuthController(BaseController): 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: - raise HTTPException( - status_code=404, - detail="User not found - please sync via /auth/sync first", - ) + 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), diff --git a/src/domains/auth/service.py b/src/domains/auth/service.py index fd25465..f6fe454 100644 --- a/src/domains/auth/service.py +++ b/src/domains/auth/service.py @@ -102,7 +102,13 @@ class AuthService: Returns: 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 stmt = ( @@ -144,6 +150,74 @@ class AuthService: await self.session.flush() 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]: """ Synchronize user roles from Authentik groups via group_roles mapping