Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3a49c800a | ||
|
|
7ab9f73a1d | ||
|
|
dd5b794de4 | ||
|
|
dd0997679f | ||
|
|
e2226cd923 | ||
|
|
7bf3c76a1b | ||
|
|
4ae8cfbc0b | ||
|
|
69045a78a7 | ||
|
|
884996fd83 | ||
|
|
8bd13eab09 |
@@ -1,12 +1,25 @@
|
|||||||
name: Build and Push
|
name: Build and Push
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
push:
|
||||||
types: [published]
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Create Gitea Release
|
||||||
|
run: |
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"tag_name": "${{ github.ref_name }}", "name": "Release ${{ github.ref_name }}", "body": "Automated release for ${{ github.ref_name }}"}' \
|
||||||
|
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||||
|
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: release
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ When changes are ready for deployment:
|
|||||||
5. **CI/CD triggers automatically**:
|
5. **CI/CD triggers automatically**:
|
||||||
- Gitea CI builds Docker image on new tag
|
- Gitea CI builds Docker image on new tag
|
||||||
- Watchtower pulls and deploys to production
|
- Watchtower pulls and deploys to production
|
||||||
- Verify deployment: `curl http://192.168.86.149:8000/health`
|
- Verify deployment: `curl http://192.168.86.149:8083/health`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,64 @@ 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
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `/auth/users/me` endpoint now supports both NPM forward auth headers AND JWT Bearer tokens
|
||||||
|
- Added `get_current_user_or_forward_auth()` combined auth dependency
|
||||||
|
- Fixes web authentication where NPM passes `X-authentik-*` headers instead of JWT
|
||||||
|
- Mobile/native clients continue to use JWT Bearer tokens as before
|
||||||
|
|
||||||
|
## [1.9.1] - 2026-01-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- CORS configuration now uses explicit origins instead of `"*"`
|
||||||
|
- When `allow_credentials=True`, wildcard origins are rejected by browsers
|
||||||
|
- Added `home.schweitz.net`, `tatlock.schweitz.net`, and localhost origins
|
||||||
|
|
||||||
|
## [1.9.0] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **NPM Forward Auth Support** - Web authentication via Nginx Proxy Manager forward auth
|
||||||
|
- `GET /auth/me` - Get current user from NPM forward auth headers (X-authentik-uid, X-authentik-email, etc.)
|
||||||
|
- Auto-creates user on first web login if not in database
|
||||||
|
- Syncs roles from NPM forward auth groups header
|
||||||
|
- `get_user_by_email` and `get_user_by_authentik_id` methods in AuthService
|
||||||
|
- Comprehensive tests for `/auth/me` endpoint
|
||||||
|
|
||||||
|
## [1.8.0] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Group-Role Mapping & Permissions** (Phase 3)
|
||||||
|
- Decoupled group-role architecture (groups from Authentik, roles admin-managed)
|
||||||
|
- Permission format: `domain.category:action` with action hierarchy
|
||||||
|
- `require_permission` and `require_any_permission` dependency factories
|
||||||
|
- Global admin override (`admin.general:admin`)
|
||||||
|
- **User Profile & API Keys** (Phase 4)
|
||||||
|
- `GET /auth/users/me` - Full user profile with roles and preferences
|
||||||
|
- `GET/PATCH /auth/users/me/preferences` - User preferences management
|
||||||
|
- `GET/POST/DELETE /auth/users/me/api-keys` - API key lifecycle
|
||||||
|
- API keys with `tak_` prefix, SHA-256 hashing, shown only once on creation
|
||||||
|
|
||||||
## [1.7.0] - 2026-01-03
|
## [1.7.0] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "core-api"
|
name = "core-api"
|
||||||
version = "1.7.0"
|
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"
|
||||||
|
|||||||
+70
-7
@@ -13,6 +13,7 @@ from src.logging_config import get_logger
|
|||||||
from src.db import get_async_session
|
from src.db import get_async_session
|
||||||
from src.auth.schemas import AuthSyncRequest, AuthSyncResponse, UsersListResponse, BulkSyncResultSchema, GroupsListResponse
|
from src.auth.schemas import AuthSyncRequest, AuthSyncResponse, UsersListResponse, BulkSyncResultSchema, GroupsListResponse
|
||||||
from src.auth.service import AuthService
|
from src.auth.service import AuthService
|
||||||
|
from src.auth.oidc import get_forward_auth_user
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -222,21 +223,83 @@ class AuthController(BaseController):
|
|||||||
responses={
|
responses={
|
||||||
200: {"description": "User profile"},
|
200: {"description": "User profile"},
|
||||||
401: {"description": "Not authenticated"},
|
401: {"description": "Not authenticated"},
|
||||||
|
404: {"description": "User not found in database"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def get_me(
|
async def get_me(
|
||||||
|
forward_auth_user: Optional[dict] = Depends(get_forward_auth_user),
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
) -> JSONResponse:
|
) -> AuthSyncResponse:
|
||||||
"""
|
"""
|
||||||
Get the current authenticated user's profile
|
Get the current authenticated user's profile
|
||||||
|
|
||||||
Note: This endpoint requires a valid session or API key.
|
Authentication is handled by NPM forward auth with Authentik.
|
||||||
For now, returns 501 Not Implemented until session management is added.
|
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
|
# Require forward auth for this endpoint
|
||||||
raise HTTPException(
|
if forward_auth_user is None:
|
||||||
status_code=501,
|
raise HTTPException(
|
||||||
detail="Not implemented - use /auth/sync with access token",
|
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
|
return router
|
||||||
|
|||||||
@@ -86,6 +86,42 @@ class AuthService:
|
|||||||
logger.error(f"Authentik userinfo request error: {e}")
|
logger.error(f"Authentik userinfo request error: {e}")
|
||||||
raise ValueError("Authentication service unavailable")
|
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]:
|
async def sync_user(self, token_info: TokenInfoSchema) -> tuple[User, bool]:
|
||||||
"""
|
"""
|
||||||
Create or update user from OIDC token info
|
Create or update user from OIDC token info
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from src.domains.auth.schemas import (
|
|||||||
ApiKeysListResponse,
|
ApiKeysListResponse,
|
||||||
)
|
)
|
||||||
from src.domains.auth.service import AuthService
|
from src.domains.auth.service import AuthService
|
||||||
from src.domains.auth.oidc import get_current_user
|
from src.domains.auth.oidc import get_current_user, get_current_user_or_forward_auth
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -322,22 +322,26 @@ 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(
|
||||||
user_claims: dict = Depends(get_current_user),
|
user_claims: dict = Depends(get_current_user_or_forward_auth),
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
) -> UserProfileResponse:
|
) -> UserProfileResponse:
|
||||||
"""
|
"""
|
||||||
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),
|
||||||
|
|||||||
@@ -396,6 +396,56 @@ async def get_forward_auth_admin(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user_or_forward_auth(
|
||||||
|
request: Request,
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(security)
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Combined auth: Try forward auth headers first, then JWT Bearer token.
|
||||||
|
|
||||||
|
Supports both:
|
||||||
|
- Web clients via NPM forward auth (X-authentik-* headers from proxy)
|
||||||
|
- Mobile/native clients via OIDC JWT Bearer tokens
|
||||||
|
|
||||||
|
This is the preferred dependency for /auth/users/me and similar endpoints
|
||||||
|
that need to work with both web (cookie-based via NPM) and mobile (token-based).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object containing headers
|
||||||
|
credentials: HTTP Bearer token from Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User claims dictionary with at minimum: sub, email, name, groups, auth_method
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 401: If neither forward auth headers nor valid JWT provided
|
||||||
|
"""
|
||||||
|
# 1. Try forward auth headers first (web via NPM)
|
||||||
|
username = request.headers.get("x-authentik-username")
|
||||||
|
email = request.headers.get("x-authentik-email")
|
||||||
|
|
||||||
|
if username and email:
|
||||||
|
# Forward auth headers present - use them
|
||||||
|
groups = request.headers.get("x-authentik-groups", "")
|
||||||
|
name = request.headers.get("x-authentik-name", username)
|
||||||
|
uid = request.headers.get("x-authentik-uid")
|
||||||
|
|
||||||
|
user_info = {
|
||||||
|
"sub": uid, # Use authentik UID as subject (for user lookup)
|
||||||
|
"email": email,
|
||||||
|
"preferred_username": username,
|
||||||
|
"name": name,
|
||||||
|
"groups": [g.strip() for g in groups.split(",")] if groups else [],
|
||||||
|
"auth_method": "forward_auth"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Authenticated via forward auth: {email}")
|
||||||
|
return user_info
|
||||||
|
|
||||||
|
# 2. Fall back to JWT Bearer token (mobile/native)
|
||||||
|
return await get_current_user(credentials)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Permission-Based Access Control
|
# Permission-Based Access Control
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -36,8 +36,15 @@ class Settings(BaseSettings):
|
|||||||
host: str = "0.0.0.0"
|
host: str = "0.0.0.0"
|
||||||
port: int = 8083
|
port: int = 8083
|
||||||
|
|
||||||
# CORS
|
# CORS - Note: When cors_credentials is True, cannot use "*" for origins
|
||||||
cors_origins: list[str] = ["*"]
|
# Set CORS_ORIGINS env var to override (comma-separated list)
|
||||||
|
cors_origins: list[str] = [
|
||||||
|
"https://home.schweitz.net",
|
||||||
|
"https://tatlock.schweitz.net",
|
||||||
|
"http://localhost:8080",
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://127.0.0.1:8080",
|
||||||
|
]
|
||||||
cors_credentials: bool = True
|
cors_credentials: bool = True
|
||||||
cors_methods: list[str] = ["*"]
|
cors_methods: list[str] = ["*"]
|
||||||
cors_headers: list[str] = ["*"]
|
cors_headers: list[str] = ["*"]
|
||||||
|
|||||||
@@ -812,3 +812,205 @@ class TestPhase4Schemas:
|
|||||||
response = ApiKeysListResponse(items=[key], total=1)
|
response = ApiKeysListResponse(items=[key], total=1)
|
||||||
assert len(response.items) == 1
|
assert len(response.items) == 1
|
||||||
assert response.total == 1
|
assert response.total == 1
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GET /auth/me Endpoint Tests (NPM Forward Auth)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
class TestAuthMeEndpoint:
|
||||||
|
"""Test GET /auth/me endpoint with NPM forward auth."""
|
||||||
|
|
||||||
|
def test_auth_me_in_openapi(self, client):
|
||||||
|
"""Auth me endpoint should be in OpenAPI spec."""
|
||||||
|
response = client.get("/openapi.json")
|
||||||
|
spec = response.json()
|
||||||
|
assert "/auth/me" in spec["paths"]
|
||||||
|
assert "get" in spec["paths"]["/auth/me"]
|
||||||
|
|
||||||
|
def test_auth_me_returns_401_without_forward_auth(self, client):
|
||||||
|
"""Should return 401 when accessed without forward auth headers."""
|
||||||
|
response = client.get("/auth/me")
|
||||||
|
# Without NPM forward auth headers, should return 401
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_auth_me_response_schema(self, client):
|
||||||
|
"""Auth me should return AuthSyncResponse schema."""
|
||||||
|
response = client.get("/openapi.json")
|
||||||
|
spec = response.json()
|
||||||
|
|
||||||
|
# Check response schema references AuthSyncResponse
|
||||||
|
me_endpoint = spec["paths"]["/auth/me"]["get"]
|
||||||
|
assert "responses" in me_endpoint
|
||||||
|
assert "200" in me_endpoint["responses"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestForwardAuthParsing:
|
||||||
|
"""Test NPM forward auth header parsing."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parses_all_headers(self):
|
||||||
|
"""Should parse all X-authentik-* headers."""
|
||||||
|
from src.auth.oidc import get_forward_auth_user
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
headers = {
|
||||||
|
"x-authentik-username": "jdoe",
|
||||||
|
"x-authentik-email": "john.doe@example.com",
|
||||||
|
"x-authentik-groups": "tatlock-admins, tatlock-media-viewers",
|
||||||
|
"x-authentik-name": "John Doe",
|
||||||
|
"x-authentik-uid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
}
|
||||||
|
mock_request.headers.get.side_effect = lambda h: headers.get(h)
|
||||||
|
|
||||||
|
result = await get_forward_auth_user(mock_request)
|
||||||
|
|
||||||
|
assert result["username"] == "jdoe"
|
||||||
|
assert result["email"] == "john.doe@example.com"
|
||||||
|
assert result["name"] == "John Doe"
|
||||||
|
assert result["uid"] == "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
assert "tatlock-admins" in result["groups"]
|
||||||
|
assert "tatlock-media-viewers" in result["groups"]
|
||||||
|
assert result["auth_method"] == "forward_auth"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_none_for_internal_access(self):
|
||||||
|
"""Should return None when no forward auth headers (internal access)."""
|
||||||
|
from src.auth.oidc import get_forward_auth_user
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.headers.get.return_value = None
|
||||||
|
|
||||||
|
result = await get_forward_auth_user(mock_request)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_raises_401_missing_email(self):
|
||||||
|
"""Should raise 401 when username present but email missing."""
|
||||||
|
from src.auth.oidc import get_forward_auth_user
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
headers = {
|
||||||
|
"x-authentik-username": "jdoe",
|
||||||
|
"x-authentik-email": None,
|
||||||
|
}
|
||||||
|
mock_request.headers.get.side_effect = lambda h: headers.get(h)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await get_forward_auth_user(mock_request)
|
||||||
|
|
||||||
|
assert exc.value.status_code == 401
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handles_empty_groups(self):
|
||||||
|
"""Should handle empty groups header."""
|
||||||
|
from src.auth.oidc import get_forward_auth_user
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
headers = {
|
||||||
|
"x-authentik-username": "jdoe",
|
||||||
|
"x-authentik-email": "jdoe@example.com",
|
||||||
|
"x-authentik-groups": "",
|
||||||
|
"x-authentik-name": None,
|
||||||
|
"x-authentik-uid": None,
|
||||||
|
}
|
||||||
|
mock_request.headers.get.side_effect = lambda h: headers.get(h)
|
||||||
|
|
||||||
|
result = await get_forward_auth_user(mock_request)
|
||||||
|
|
||||||
|
assert result["groups"] == [""]
|
||||||
|
assert result["name"] == "jdoe" # Falls back to username
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_strips_whitespace_from_groups(self):
|
||||||
|
"""Should strip whitespace from group names."""
|
||||||
|
from src.auth.oidc import get_forward_auth_user
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
headers = {
|
||||||
|
"x-authentik-username": "jdoe",
|
||||||
|
"x-authentik-email": "jdoe@example.com",
|
||||||
|
"x-authentik-groups": " group1 , group2 ,group3",
|
||||||
|
"x-authentik-name": "John",
|
||||||
|
"x-authentik-uid": None,
|
||||||
|
}
|
||||||
|
mock_request.headers.get.side_effect = lambda h: headers.get(h)
|
||||||
|
|
||||||
|
result = await get_forward_auth_user(mock_request)
|
||||||
|
|
||||||
|
assert result["groups"] == ["group1", "group2", "group3"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthServiceNewMethods:
|
||||||
|
"""Test new AuthService methods for /auth/me."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_by_email(self):
|
||||||
|
"""Should find user by email."""
|
||||||
|
from src.auth.service import AuthService
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_user = MagicMock()
|
||||||
|
mock_user.email = "test@example.com"
|
||||||
|
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = mock_user
|
||||||
|
mock_session.execute.return_value = mock_result
|
||||||
|
|
||||||
|
service = AuthService(mock_session)
|
||||||
|
result = await service.get_user_by_email("test@example.com")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.email == "test@example.com"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_by_email_not_found(self):
|
||||||
|
"""Should return None when user not found."""
|
||||||
|
from src.auth.service import AuthService
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = None
|
||||||
|
mock_session.execute.return_value = mock_result
|
||||||
|
|
||||||
|
service = AuthService(mock_session)
|
||||||
|
result = await service.get_user_by_email("notfound@example.com")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_by_authentik_id(self):
|
||||||
|
"""Should find user by Authentik UUID."""
|
||||||
|
from src.auth.service import AuthService
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
test_id = uuid.uuid4()
|
||||||
|
mock_user = MagicMock()
|
||||||
|
mock_user.authentik_id = test_id
|
||||||
|
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = mock_user
|
||||||
|
mock_session.execute.return_value = mock_result
|
||||||
|
|
||||||
|
service = AuthService(mock_session)
|
||||||
|
result = await service.get_user_by_authentik_id(test_id)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.authentik_id == test_id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_by_authentik_id_not_found(self):
|
||||||
|
"""Should return None when user not found by Authentik ID."""
|
||||||
|
from src.auth.service import AuthService
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = None
|
||||||
|
mock_session.execute.return_value = mock_result
|
||||||
|
|
||||||
|
service = AuthService(mock_session)
|
||||||
|
result = await service.get_user_by_authentik_id(uuid.uuid4())
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|||||||
Reference in New Issue
Block a user