Compare commits

...
3 Commits
Author SHA1 Message Date
Jeroen SchweitzerandClaude Opus 4.5 e3a49c800a fix: SQLAlchemy async lazy loading for new users
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m11s
Initialize user.roles=[] and user.preferences on new user creation
to avoid MissingGreenlet error in async context.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 18:05:34 +01:00
Jeroen SchweitzerandClaude Opus 4.5 7ab9f73a1d chore: release v1.9.3
Build and Push / release (push) Successful in 2s
Build and Push / build (push) Successful in 1m10s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:49:26 +01:00
Jeroen SchweitzerandClaude Opus 4.5 dd5b794de4 fix: handle non-UUID sub claim in auth sync
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>
2026-01-04 17:45:05 +01:00
4 changed files with 122 additions and 10 deletions
+15
View File
@@ -5,6 +5,21 @@ 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.4] - 2026-01-04
### Fixed
- Fix SQLAlchemy async lazy loading error for new users in `/auth/sync`
- Initialize `user.roles = []` and `user.preferences` to avoid greenlet error
- Was causing "MissingGreenlet: greenlet_spawn has not been called" on new user creation
## [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 ## [1.9.2] - 2026-01-04
### Fixed ### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "core-api" name = "core-api"
version = "1.9.2" version = "1.9.4"
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"
+27 -7
View File
@@ -322,7 +322,6 @@ 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(
@@ -333,11 +332,16 @@ class AuthController(BaseController):
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),
+79 -2
View File
@@ -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 = (
@@ -124,11 +130,14 @@ class AuthService:
avatar_url=token_info.picture, avatar_url=token_info.picture,
last_login=datetime.now(timezone.utc), last_login=datetime.now(timezone.utc),
) )
# Initialize relationships to avoid lazy loading issues in async
user.roles = []
self.session.add(user) self.session.add(user)
await self.session.flush() # Get the user ID await self.session.flush() # Get the user ID
# Create default preferences # Create default preferences and attach to user
preferences = UserPreferences(user_id=user.id) preferences = UserPreferences(user_id=user.id)
user.preferences = preferences
self.session.add(preferences) self.session.add(preferences)
logger.info(f"Created new user: {token_info.email}") logger.info(f"Created new user: {token_info.email}")
@@ -144,6 +153,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