Compare commits

...
10 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
Jeroen SchweitzerandClaude Opus 4.5 dd0997679f fix: /auth/users/me now supports NPM forward auth headers
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m11s
Added get_current_user_or_forward_auth() combined dependency that:
- First checks for X-authentik-* headers from NPM forward auth (web)
- Falls back to JWT Bearer token validation (mobile/native)

This fixes web authentication where browsers don't send Bearer tokens
but rely on NPM's forward auth proxy to pass user info via headers.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:24:41 +01:00
Jeroen SchweitzerandClaude Opus 4.5 e2226cd923 fix(build): add production API URLs to Docker build
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m8s
Dockerfile now passes --dart-define flags for CORE_API_URL and
TATLOCK_API_URL pointing to schweitz.net domains. This enables
requiresAuth=true, fixing auth being completely skipped in production.

Also added service port reference table to AGENTS.md.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:18:34 +01:00
Jeroen SchweitzerandClaude Opus 4.5 7bf3c76a1b fix(cors): use explicit origins instead of wildcard
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m11s
When allow_credentials=True, browsers reject wildcard (*) origins.
Added specific allowed origins for Tatlock domains.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:09:47 +01:00
Jeroen SchweitzerandClaude Opus 4.5 4ae8cfbc0b chore: release v1.9.0
Build and Push / release (push) Successful in 3s
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-03 22:24:36 +01:00
Jeroen SchweitzerandClaude Opus 4.5 69045a78a7 test(auth): add tests for GET /auth/me endpoint
Add comprehensive tests for NPM forward auth endpoint:
- Forward auth header parsing
- User lookup methods (get_user_by_email, get_user_by_authentik_id)
- Admin gate authorization logic
- OpenAPI spec validation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:14:13 +01:00
Jeroen SchweitzerandClaude Opus 4.5 884996fd83 feat(auth): implement GET /auth/me for NPM forward auth
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>
2026-01-03 21:54:44 +01:00
Jeroen SchweitzerandClaude Opus 4.5 8bd13eab09 ci: trigger build on version tag push with auto-release
Changed workflow to:
- Trigger on push of v* tags instead of release publish
- Auto-create Gitea release via API
- Then build and push Docker image

This simplifies deployment: just push a version tag.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:40:44 +01:00
11 changed files with 550 additions and 24 deletions
+15 -2
View File
@@ -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
+1 -1
View File
@@ -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`
--- ---
+58
View File
@@ -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
View File
@@ -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
View File
@@ -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
+36
View File
@@ -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
+29 -9
View File
@@ -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),
+50
View File
@@ -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
# ============================================================================= # =============================================================================
+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
+9 -2
View File
@@ -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] = ["*"]
+202
View File
@@ -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