Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd0997679f | ||
|
|
e2226cd923 | ||
|
|
7bf3c76a1b | ||
|
|
4ae8cfbc0b | ||
|
|
69045a78a7 | ||
|
|
884996fd83 | ||
|
|
8bd13eab09 |
@@ -1,12 +1,25 @@
|
||||
name: Build and Push
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ When changes are ready for deployment:
|
||||
5. **CI/CD triggers automatically**:
|
||||
- Gitea CI builds Docker image on new tag
|
||||
- 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,49 @@ 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/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "core-api"
|
||||
version = "1.7.0"
|
||||
version = "1.9.2"
|
||||
description = "Core Code API - Infrastructure management and tools API"
|
||||
readme = "README.md"
|
||||
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.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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,7 +20,7 @@ from src.domains.auth.schemas import (
|
||||
ApiKeysListResponse,
|
||||
)
|
||||
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__)
|
||||
|
||||
@@ -326,7 +326,7 @@ class AuthController(BaseController):
|
||||
},
|
||||
)
|
||||
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),
|
||||
) -> UserProfileResponse:
|
||||
"""
|
||||
|
||||
@@ -396,6 +396,56 @@ async def get_forward_auth_admin(
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
@@ -36,8 +36,15 @@ class Settings(BaseSettings):
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8083
|
||||
|
||||
# CORS
|
||||
cors_origins: list[str] = ["*"]
|
||||
# CORS - Note: When cors_credentials is True, cannot use "*" for origins
|
||||
# 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_methods: list[str] = ["*"]
|
||||
cors_headers: list[str] = ["*"]
|
||||
|
||||
@@ -812,3 +812,205 @@ class TestPhase4Schemas:
|
||||
response = ApiKeysListResponse(items=[key], total=1)
|
||||
assert len(response.items) == 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