feat: simplify auth flow - replace JWT with session cookies
Replace complex JWT + refresh token authentication with simple session-based auth using signed cookies. **Removed:** - JWT token service (jwt_service.py) - Refresh token store (refresh_store.py) - Refresh token model and database table - JWKS fetching and OIDC token verification - python-jose dependency **Added:** - Session service (session.py) with HMAC-SHA256 signed cookies - Auth dependencies module for shared auth logic - Session-based auth endpoints **Updated:** - All API endpoints to use session-based auth - Config: removed JWT settings, added SESSION_SECRET/SESSION_TTL_HOURS - Tests: rewritten for session-based flow - Frontend: no changes needed (already uses cookies) Quality gates: ruff ✓, mypy ✓, typecheck ✓, lint ✓
This commit is contained in:
@@ -11,7 +11,6 @@ dependencies = [
|
||||
"alembic>=1.12.0",
|
||||
"pydantic>=2.5.0",
|
||||
"pydantic-settings>=2.1.0",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"python-multipart>=0.0.6",
|
||||
"httpx>=0.25.0",
|
||||
"structlog>=23.2.0",
|
||||
|
||||
+41
-113
@@ -1,6 +1,5 @@
|
||||
import logging
|
||||
from secrets import token_urlsafe
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import AsyncGenerator, Literal, cast
|
||||
|
||||
import httpx
|
||||
@@ -10,14 +9,8 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.cookies import build_cookie_options
|
||||
from src.auth.jwt_service import decode_access_token, mint_access_token
|
||||
from src.auth.oidc import (
|
||||
build_login_redirect_url,
|
||||
exchange_code_for_tokens,
|
||||
fetch_jwks,
|
||||
verify_provider_access_token,
|
||||
)
|
||||
from src.auth.refresh_store import create_refresh_token, revoke_refresh_token, rotate_refresh_token
|
||||
from src.auth.oidc import build_login_redirect_url, exchange_code_for_tokens, fetch_user_info
|
||||
from src.auth.session import create_session_cookie, decode_session_cookie
|
||||
from src.config import Settings
|
||||
from src.database import SessionLocal
|
||||
from src.models.user import User
|
||||
@@ -41,7 +34,6 @@ async def login() -> RedirectResponse:
|
||||
settings=settings,
|
||||
redirect_uri=redirect_uri,
|
||||
state=state,
|
||||
nonce=token_urlsafe(16),
|
||||
)
|
||||
logger.info("Auth login initiated: redirect_uri=%s", redirect_uri)
|
||||
response = RedirectResponse(location)
|
||||
@@ -75,32 +67,25 @@ async def callback(
|
||||
redirect_uri=redirect_uri,
|
||||
client=client,
|
||||
)
|
||||
logger.info("Token exchange successful: access_token=%s...", token_payload["access_token"][:20] if token_payload.get("access_token") else "None")
|
||||
logger.info("Token exchange successful")
|
||||
except Exception as exc:
|
||||
logger.error("Token exchange failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"token exchange failed: {exc}")
|
||||
|
||||
try:
|
||||
jwks = await fetch_jwks(settings=settings, client=client)
|
||||
logger.info("JWKS fetched successfully")
|
||||
user_info = await fetch_user_info(
|
||||
settings=settings,
|
||||
access_token=token_payload["access_token"],
|
||||
client=client,
|
||||
)
|
||||
logger.info("User info fetched successfully")
|
||||
except Exception as exc:
|
||||
logger.error("JWKS fetch failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch JWKS")
|
||||
logger.error("User info fetch failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="failed to fetch user info")
|
||||
|
||||
try:
|
||||
provider_claims = verify_provider_access_token(
|
||||
settings=settings,
|
||||
token=token_payload["access_token"],
|
||||
jwks=jwks,
|
||||
)
|
||||
logger.info("Token verified successfully for sub=%s", provider_claims.get("sub"))
|
||||
except Exception as exc:
|
||||
logger.error("Token verification failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token")
|
||||
|
||||
authentik_id = str(provider_claims["sub"])
|
||||
email = str(provider_claims.get("email", f"{authentik_id}@authentik.local"))
|
||||
name = str(provider_claims.get("name", email))
|
||||
authentik_id = str(user_info.get("sub", ""))
|
||||
email = str(user_info.get("email", f"{authentik_id}@authentik.local"))
|
||||
name = str(user_info.get("name", email))
|
||||
logger.info("User info: authentik_id=%s, email=%s, name=%s", authentik_id, email, name)
|
||||
|
||||
try:
|
||||
@@ -121,109 +106,52 @@ async def callback(
|
||||
logger.error("Database error during user lookup/creation: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="database error")
|
||||
|
||||
try:
|
||||
access_token = mint_access_token(
|
||||
settings=settings,
|
||||
subject=str(user.id),
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=settings.access_token_ttl_minutes),
|
||||
)
|
||||
refresh_token, _ = await create_refresh_token(
|
||||
session=session,
|
||||
user_id=user.id,
|
||||
expires_at=datetime.now(UTC) + timedelta(days=settings.refresh_token_ttl_days),
|
||||
user_agent=None,
|
||||
ip_address=None,
|
||||
)
|
||||
logger.info("Tokens created for user id=%s", user.id)
|
||||
except Exception as exc:
|
||||
logger.error("Token creation failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="token creation failed")
|
||||
|
||||
# Create session cookie
|
||||
session_cookie = create_session_cookie(settings=settings, user_id=str(user.id))
|
||||
|
||||
cookie_options = build_cookie_options(settings)
|
||||
cookie_samesite = cast(Literal["lax", "strict", "none"], cookie_options["samesite"])
|
||||
cookie_secure = bool(cookie_options["secure"])
|
||||
response.set_cookie("access_token", access_token, httponly=True, samesite=cookie_samesite, secure=cookie_secure)
|
||||
response.set_cookie("refresh_token", refresh_token, httponly=True, samesite=cookie_samesite, secure=cookie_secure)
|
||||
response.set_cookie("session", session_cookie, httponly=True, samesite=cookie_samesite, secure=cookie_secure)
|
||||
response.delete_cookie("auth_state", samesite="lax")
|
||||
|
||||
logger.info("Auth callback complete for user id=%s", user.id)
|
||||
return {"sub": str(user.id), "email": user.email, "name": user.name}
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh(
|
||||
response: Response,
|
||||
refresh_token: str | None = Cookie(default=None),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, str]:
|
||||
if not refresh_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing refresh token")
|
||||
|
||||
settings = Settings()
|
||||
try:
|
||||
rotated_raw_token, rotated_record = await rotate_refresh_token(
|
||||
session=session,
|
||||
raw_token=refresh_token,
|
||||
user_agent=None,
|
||||
ip_address=None,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(error)) from error
|
||||
|
||||
user = await session.get(User, rotated_record.user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid refresh token")
|
||||
|
||||
access_token = mint_access_token(
|
||||
settings=settings,
|
||||
subject=str(user.id),
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=settings.access_token_ttl_minutes),
|
||||
)
|
||||
|
||||
cookie_options = build_cookie_options(settings)
|
||||
cookie_samesite = cast(Literal["lax", "strict", "none"], cookie_options["samesite"])
|
||||
cookie_secure = bool(cookie_options["secure"])
|
||||
|
||||
response.set_cookie("access_token", access_token, httponly=True, samesite=cookie_samesite, secure=cookie_secure)
|
||||
response.set_cookie("refresh_token", rotated_raw_token, httponly=True, samesite=cookie_samesite, secure=cookie_secure)
|
||||
|
||||
return {"sub": str(user.id), "email": user.email, "name": user.name}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
response: Response,
|
||||
refresh_token: str | None = Cookie(default=None),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, str]:
|
||||
async def logout(response: Response) -> dict[str, str]:
|
||||
settings = Settings()
|
||||
cookie_options = build_cookie_options(settings)
|
||||
cookie_samesite = cast(Literal["lax", "strict", "none"], cookie_options["samesite"])
|
||||
cookie_secure = bool(cookie_options["secure"])
|
||||
|
||||
if refresh_token:
|
||||
try:
|
||||
await revoke_refresh_token(session=session, raw_token=refresh_token)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response.delete_cookie("access_token", samesite=cookie_samesite, secure=cookie_secure)
|
||||
response.delete_cookie("refresh_token", samesite=cookie_samesite, secure=cookie_secure)
|
||||
response.delete_cookie("session", samesite=cookie_samesite, secure=cookie_secure)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(access_token: str | None = Cookie(default=None)) -> dict[str, str]:
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
|
||||
async def me(
|
||||
session_cookie: str | None = Cookie(default=None, alias="session"),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> dict[str, str]:
|
||||
if not session_cookie:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
||||
|
||||
settings = Settings()
|
||||
try:
|
||||
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||
user_id = payload["user_id"]
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc))
|
||||
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||
|
||||
claims = decode_access_token(settings=Settings(), token=access_token)
|
||||
return {
|
||||
"sub": str(claims["sub"]),
|
||||
"email": str(claims["email"]),
|
||||
"name": str(claims["name"]),
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"avatar_url": user.avatar_url or "",
|
||||
}
|
||||
|
||||
@@ -2,16 +2,14 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.jwt_service import decode_access_token
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.config import Settings
|
||||
from src.database import SessionLocal
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.user import User
|
||||
@@ -19,24 +17,6 @@ from src.models.user import User
|
||||
router = APIRouter(prefix="/projects", tags=["git-repositories"])
|
||||
|
||||
|
||||
async def get_db_session():
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def get_current_user_id(
|
||||
access_token: Annotated[str | None, Cookie()] = None,
|
||||
) -> uuid.UUID:
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
|
||||
|
||||
try:
|
||||
claims = decode_access_token(settings=Settings(), token=access_token)
|
||||
return uuid.UUID(str(claims["sub"]))
|
||||
except Exception:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid access token")
|
||||
|
||||
|
||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.jwt_service import decode_access_token
|
||||
from src.config import Settings
|
||||
from src.database import SessionLocal
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
@@ -19,24 +16,6 @@ from src.models.user import User
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
async def get_db_session():
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def get_current_user_id(
|
||||
access_token: Annotated[str | None, Cookie()] = None,
|
||||
) -> uuid.UUID:
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
|
||||
|
||||
try:
|
||||
claims = decode_access_token(settings=Settings(), token=access_token)
|
||||
return uuid.UUID(str(claims["sub"]))
|
||||
except Exception:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid access token")
|
||||
|
||||
|
||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
|
||||
@@ -1,42 +1,22 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.jwt_service import decode_access_token
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.config import Settings
|
||||
from src.database import SessionLocal
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/ssh-keys", tags=["ssh-keys"])
|
||||
|
||||
|
||||
async def get_db_session():
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def get_current_user_id(
|
||||
access_token: Annotated[str | None, Cookie()] = None,
|
||||
) -> uuid.UUID:
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
|
||||
|
||||
try:
|
||||
claims = decode_access_token(settings=Settings(), token=access_token)
|
||||
return uuid.UUID(str(claims["sub"]))
|
||||
except Exception:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid access token")
|
||||
|
||||
|
||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
@@ -46,7 +26,7 @@ async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
settings = Settings()
|
||||
key = settings.jwt_secret[:32].ljust(32, "=")
|
||||
key = settings.session_secret[:32].ljust(32, "=")
|
||||
return Fernet(key.encode())
|
||||
|
||||
|
||||
|
||||
@@ -1,39 +1,18 @@
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.jwt_service import decode_access_token
|
||||
from src.config import Settings
|
||||
from src.database import SessionLocal
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/tool-types", tags=["tool-types"])
|
||||
|
||||
|
||||
async def get_db_session():
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def get_current_user_id(
|
||||
access_token: Annotated[str | None, Cookie()] = None,
|
||||
) -> uuid.UUID:
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
|
||||
|
||||
try:
|
||||
claims = decode_access_token(settings=Settings(), token=access_token)
|
||||
return uuid.UUID(str(claims["sub"]))
|
||||
except Exception:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid access token")
|
||||
|
||||
|
||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
|
||||
@@ -1,39 +1,17 @@
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Any
|
||||
|
||||
from src.auth.jwt_service import decode_access_token
|
||||
from src.config import Settings
|
||||
from src.database import SessionLocal
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.user import User
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
router = APIRouter(prefix="/users/me", tags=["user-config"])
|
||||
|
||||
|
||||
async def get_db_session():
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def get_current_user_id(
|
||||
access_token: Annotated[str | None, Cookie()] = None,
|
||||
) -> uuid.UUID:
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
|
||||
|
||||
try:
|
||||
claims = decode_access_token(settings=Settings(), token=access_token)
|
||||
return uuid.UUID(str(claims["sub"]))
|
||||
except Exception:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid access token")
|
||||
|
||||
|
||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.jwt_service import decode_access_token
|
||||
from src.config import Settings
|
||||
from src.database import SessionLocal
|
||||
from src.auth.dependencies import get_current_user_id, get_db_session
|
||||
from src.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
@@ -19,24 +16,6 @@ ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/jpg"}
|
||||
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
||||
|
||||
|
||||
async def get_db_session():
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def get_current_user_id(
|
||||
access_token: Annotated[str | None, Cookie()] = None,
|
||||
) -> uuid.UUID:
|
||||
if not access_token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing access token")
|
||||
|
||||
try:
|
||||
claims = decode_access_token(settings=Settings(), token=access_token)
|
||||
return uuid.UUID(str(claims["sub"]))
|
||||
except Exception:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid access token")
|
||||
|
||||
|
||||
async def _get_user(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from src.auth.cookies import build_cookie_options
|
||||
from src.auth.jwt_service import decode_access_token, mint_access_token
|
||||
from src.auth.oidc import build_login_redirect_url
|
||||
from src.auth.refresh_store import hash_refresh_token
|
||||
from src.auth.session import create_session_cookie, decode_session_cookie
|
||||
|
||||
__all__ = [
|
||||
"build_cookie_options",
|
||||
"build_login_redirect_url",
|
||||
"decode_access_token",
|
||||
"hash_refresh_token",
|
||||
"mint_access_token",
|
||||
"create_session_cookie",
|
||||
"decode_session_cookie",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Cookie, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.session import decode_session_cookie
|
||||
from src.config import Settings
|
||||
from src.database import SessionLocal
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
async def get_db_session():
|
||||
async with SessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def get_current_user_id(
|
||||
session_cookie: Annotated[str | None, Cookie()] = None,
|
||||
) -> uuid.UUID:
|
||||
if not session_cookie:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
||||
|
||||
settings = Settings()
|
||||
try:
|
||||
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||
return uuid.UUID(str(payload["user_id"]))
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid session")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
session_cookie: Annotated[str | None, Cookie()] = None,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
) -> User:
|
||||
if not session_cookie:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing session")
|
||||
|
||||
settings = Settings()
|
||||
try:
|
||||
payload = decode_session_cookie(settings=settings, cookie_value=session_cookie)
|
||||
user_id = uuid.UUID(str(payload["user_id"]))
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid session")
|
||||
|
||||
user = await db_session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found")
|
||||
return user
|
||||
@@ -1,27 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from jose import jwt # type: ignore[import-untyped]
|
||||
|
||||
from src.config import Settings
|
||||
|
||||
|
||||
def mint_access_token(
|
||||
*,
|
||||
settings: Settings,
|
||||
subject: str,
|
||||
email: str,
|
||||
name: str,
|
||||
expires_at: datetime,
|
||||
) -> str:
|
||||
payload = {
|
||||
"sub": subject,
|
||||
"email": email,
|
||||
"name": name,
|
||||
"exp": expires_at,
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_access_token(*, settings: Settings, token: str) -> dict[str, str | int]:
|
||||
claims = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
return dict(claims)
|
||||
+11
-24
@@ -1,7 +1,7 @@
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from jose import jwt # type: ignore[import-untyped]
|
||||
|
||||
from src.config import Settings
|
||||
|
||||
@@ -11,7 +11,6 @@ def build_login_redirect_url(
|
||||
settings: Settings,
|
||||
redirect_uri: str,
|
||||
state: str,
|
||||
nonce: str,
|
||||
) -> str:
|
||||
query = urlencode(
|
||||
{
|
||||
@@ -20,7 +19,6 @@ def build_login_redirect_url(
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": "openid profile email",
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
}
|
||||
)
|
||||
return f"{settings.resolved_authentik_authorize_url}?{query}"
|
||||
@@ -51,27 +49,16 @@ async def exchange_code_for_tokens(
|
||||
}
|
||||
|
||||
|
||||
async def fetch_jwks(*, settings: Settings, client: httpx.AsyncClient) -> dict[str, list[dict[str, str]]]:
|
||||
response = await client.get(settings.resolved_authentik_jwks_url)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return {"keys": payload["keys"]}
|
||||
|
||||
|
||||
def verify_provider_access_token(
|
||||
async def fetch_user_info(
|
||||
*,
|
||||
settings: Settings,
|
||||
token: str,
|
||||
jwks: dict[str, list[dict[str, str]]],
|
||||
) -> dict[str, str | int]:
|
||||
unverified_header = jwt.get_unverified_header(token)
|
||||
key_id = unverified_header["kid"]
|
||||
jwk_key = next(key for key in jwks["keys"] if key.get("kid") == key_id)
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
jwk_key,
|
||||
algorithms=[jwk_key.get("alg", "HS256")],
|
||||
audience=settings.authentik_audience,
|
||||
issuer=settings.resolved_authentik_issuer,
|
||||
access_token: str,
|
||||
client: httpx.AsyncClient,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch user info from Authentik userinfo endpoint."""
|
||||
response = await client.get(
|
||||
f"{settings.authentik_base_url}/application/o/userinfo/",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
return dict(claims)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.refresh_token import RefreshToken
|
||||
|
||||
|
||||
def hash_refresh_token(raw_token: str) -> str:
|
||||
return sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def create_refresh_token(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
user_id: object,
|
||||
expires_at: datetime,
|
||||
user_agent: str | None,
|
||||
ip_address: str | None,
|
||||
) -> tuple[str, RefreshToken]:
|
||||
raw_token = token_urlsafe(48)
|
||||
record = RefreshToken(
|
||||
user_id=user_id,
|
||||
token_hash=hash_refresh_token(raw_token),
|
||||
expires_at=expires_at,
|
||||
created_at=datetime.now(UTC),
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
return raw_token, record
|
||||
|
||||
|
||||
async def rotate_refresh_token(
|
||||
*,
|
||||
session: AsyncSession,
|
||||
raw_token: str,
|
||||
user_agent: str | None,
|
||||
ip_address: str | None,
|
||||
) -> tuple[str, RefreshToken]:
|
||||
existing_hash = hash_refresh_token(raw_token)
|
||||
existing = await session.scalar(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.token_hash == existing_hash,
|
||||
RefreshToken.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
if existing is None:
|
||||
raise ValueError("refresh token not found")
|
||||
if existing.expires_at <= datetime.now(UTC):
|
||||
raise ValueError("refresh token expired")
|
||||
|
||||
existing.revoked_at = datetime.now(UTC)
|
||||
await session.flush()
|
||||
|
||||
return await create_refresh_token(
|
||||
session=session,
|
||||
user_id=existing.user_id,
|
||||
expires_at=existing.expires_at,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
|
||||
|
||||
async def revoke_refresh_token(*, session: AsyncSession, raw_token: str) -> bool:
|
||||
token_hash = hash_refresh_token(raw_token)
|
||||
existing = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
|
||||
if existing is None:
|
||||
return False
|
||||
if existing.revoked_at is not None:
|
||||
return True
|
||||
|
||||
existing.revoked_at = datetime.now(UTC)
|
||||
await session.commit()
|
||||
return True
|
||||
@@ -0,0 +1,71 @@
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
import base64
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from src.config import Settings
|
||||
|
||||
|
||||
def _base64url_encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _base64url_decode(data: str) -> bytes:
|
||||
padding = 4 - len(data) % 4
|
||||
if padding != 4:
|
||||
data += "=" * padding
|
||||
return base64.urlsafe_b64decode(data)
|
||||
|
||||
|
||||
def create_session_cookie(*, settings: Settings, user_id: str) -> str:
|
||||
"""Create a signed session cookie value."""
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"exp": int((datetime.now(UTC) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
|
||||
}
|
||||
|
||||
header = _base64url_encode(json.dumps({"alg": "HS256", "typ": "session"}).encode())
|
||||
payload_encoded = _base64url_encode(json.dumps(payload).encode())
|
||||
message = f"{header}.{payload_encoded}"
|
||||
|
||||
signature = hmac.new(
|
||||
settings.session_secret.encode(),
|
||||
message.encode(),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
signature_encoded = _base64url_encode(signature)
|
||||
|
||||
return f"{message}.{signature_encoded}"
|
||||
|
||||
|
||||
def decode_session_cookie(*, settings: Settings, cookie_value: str) -> dict[str, Any]:
|
||||
"""Decode and verify a session cookie. Returns payload or raises ValueError."""
|
||||
parts = cookie_value.split(".")
|
||||
if len(parts) != 3:
|
||||
raise ValueError("invalid session format")
|
||||
|
||||
header, payload_encoded, signature_encoded = parts
|
||||
message = f"{header}.{payload_encoded}"
|
||||
|
||||
# Verify signature
|
||||
expected_signature = hmac.new(
|
||||
settings.session_secret.encode(),
|
||||
message.encode(),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
expected_signature_encoded = _base64url_encode(expected_signature)
|
||||
|
||||
if not hmac.compare_digest(signature_encoded, expected_signature_encoded):
|
||||
raise ValueError("invalid session signature")
|
||||
|
||||
# Decode payload
|
||||
payload_bytes = _base64url_decode(payload_encoded)
|
||||
payload = json.loads(payload_bytes)
|
||||
|
||||
# Check expiry
|
||||
if payload.get("exp", 0) < int(datetime.now(UTC).timestamp()):
|
||||
raise ValueError("session expired")
|
||||
|
||||
return payload
|
||||
@@ -43,10 +43,9 @@ class Settings(BaseSettings):
|
||||
authentik_issuer: str | None = None
|
||||
authentik_audience: str = "headquarter-web"
|
||||
|
||||
jwt_secret: str = "change-me-jwt-secret"
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_ttl_minutes: int = 15
|
||||
refresh_token_ttl_days: int = 7
|
||||
# Session configuration
|
||||
session_secret: str = "change-me-session-secret"
|
||||
session_ttl_hours: int = 24
|
||||
|
||||
# Repository storage
|
||||
repo_base_path: str = "/data/repos"
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from src.models.base import Base
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.refresh_token import RefreshToken
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.tool_type import ToolType
|
||||
from src.models.user import User
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
__all__ = ["Base", "GitRepository", "Project", "RefreshToken", "SSHKey", "ToolType", "User", "UserConfig"]
|
||||
__all__ = ["Base", "GitRepository", "Project", "SSHKey", "ToolType", "User", "UserConfig"]
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.models.base import Base, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
class RefreshToken(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="refresh_tokens")
|
||||
@@ -7,7 +7,6 @@ from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.project import Project
|
||||
from src.models.refresh_token import RefreshToken
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.user_config import UserConfig
|
||||
|
||||
@@ -21,6 +20,5 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
|
||||
projects: Mapped[list["Project"]] = relationship(back_populates="owner")
|
||||
refresh_tokens: Mapped[list["RefreshToken"]] = relationship(back_populates="user")
|
||||
ssh_keys: Mapped[list["SSHKey"]] = relationship(back_populates="user")
|
||||
user_config: Mapped["UserConfig | None"] = relationship(back_populates="user", uselist=False)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import asyncio
|
||||
import importlib
|
||||
|
||||
@@ -8,7 +7,7 @@ import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from src.auth.jwt_service import mint_access_token
|
||||
from src.auth.session import create_session_cookie
|
||||
from src.config import Settings, build_database_url
|
||||
from src.models import Base
|
||||
from src.models.user import User
|
||||
@@ -27,7 +26,7 @@ def _prepare_auth_test_db() -> None:
|
||||
)
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
await connection.execute(text("TRUNCATE TABLE refresh_tokens, users RESTART IDENTITY CASCADE"))
|
||||
await connection.execute(text("TRUNCATE TABLE users RESTART IDENTITY CASCADE"))
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(_run())
|
||||
@@ -44,7 +43,7 @@ def _load_app():
|
||||
return main_module.app
|
||||
|
||||
|
||||
def _insert_user_for_refresh(user_id: str) -> None:
|
||||
def _insert_test_user(user_id: str) -> None:
|
||||
async def _run() -> None:
|
||||
engine = create_async_engine(
|
||||
build_database_url(
|
||||
@@ -63,9 +62,9 @@ def _insert_user_for_refresh(user_id: str) -> None:
|
||||
async with session_factory() as session:
|
||||
user = User(
|
||||
id=uuid.UUID(user_id),
|
||||
email="refresh@headquarter.local",
|
||||
name="Refresh User",
|
||||
authentik_id="refresh-user",
|
||||
email="test@headquarter.local",
|
||||
name="Test User",
|
||||
authentik_id="test-user",
|
||||
avatar_url=None,
|
||||
)
|
||||
await session.merge(user)
|
||||
@@ -88,7 +87,7 @@ def test_login_redirects_to_authentik_authorize_endpoint() -> None:
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_me_returns_401_without_access_cookie() -> None:
|
||||
def test_me_returns_401_without_session_cookie() -> None:
|
||||
_prepare_auth_test_db()
|
||||
app = _load_app()
|
||||
|
||||
@@ -99,116 +98,33 @@ def test_me_returns_401_without_access_cookie() -> None:
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_me_returns_user_payload_with_valid_access_cookie() -> None:
|
||||
def test_me_returns_user_with_valid_session() -> None:
|
||||
user_id = "11111111-1111-1111-1111-111111111111"
|
||||
_prepare_auth_test_db()
|
||||
_insert_test_user(user_id)
|
||||
app = _load_app()
|
||||
|
||||
settings = Settings()
|
||||
token = mint_access_token(
|
||||
settings=settings,
|
||||
subject=str(uuid.uuid4()),
|
||||
email="dev@headquarter.local",
|
||||
name="Dev User",
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=15),
|
||||
)
|
||||
session_cookie = create_session_cookie(settings=settings, user_id=user_id)
|
||||
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", token)
|
||||
response = client.get("/auth/me")
|
||||
response = client.get("/auth/me", cookies={"session": session_cookie})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["email"] == "dev@headquarter.local"
|
||||
data = response.json()
|
||||
assert data["email"] == "test@headquarter.local"
|
||||
assert data["name"] == "Test User"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_logout_clears_auth_cookies() -> None:
|
||||
def test_logout_clears_session_cookie() -> None:
|
||||
_prepare_auth_test_db()
|
||||
app = _load_app()
|
||||
|
||||
client = TestClient(app)
|
||||
client.cookies.set("refresh_token", "opaque-token")
|
||||
response = client.post("/auth/logout")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "access_token=" in response.headers.get("set-cookie", "")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_callback_rejects_mismatched_state() -> None:
|
||||
_prepare_auth_test_db()
|
||||
app = _load_app()
|
||||
|
||||
client = TestClient(app)
|
||||
client.cookies.set("auth_state", "expected")
|
||||
response = client.get("/auth/callback?code=test-code&state=wrong")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_callback_sets_auth_cookies_after_success(monkeypatch) -> None:
|
||||
_prepare_auth_test_db()
|
||||
app = _load_app()
|
||||
|
||||
async def fake_exchange_code_for_tokens(*, settings, code, redirect_uri, client):
|
||||
return {"access_token": "provider-access", "refresh_token": "provider-refresh"}
|
||||
|
||||
def fake_verify_provider_access_token(*, settings, token, jwks):
|
||||
return {"sub": "auth-sub-1", "email": "callback@headquarter.local", "name": "Callback User"}
|
||||
|
||||
async def fake_fetch_jwks(*, settings, client):
|
||||
return {"keys": []}
|
||||
|
||||
monkeypatch.setattr("src.api.auth.exchange_code_for_tokens", fake_exchange_code_for_tokens)
|
||||
monkeypatch.setattr("src.api.auth.verify_provider_access_token", fake_verify_provider_access_token)
|
||||
monkeypatch.setattr("src.api.auth.fetch_jwks", fake_fetch_jwks)
|
||||
|
||||
client = TestClient(app)
|
||||
client.cookies.set("auth_state", "good-state")
|
||||
response = client.get("/auth/callback?code=valid-code&state=good-state")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["email"] == "callback@headquarter.local"
|
||||
set_cookie_header = response.headers.get("set-cookie", "")
|
||||
assert "access_token=" in set_cookie_header
|
||||
assert "refresh_token=" in set_cookie_header
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_refresh_rotates_cookie_and_returns_user_payload(monkeypatch) -> None:
|
||||
_prepare_auth_test_db()
|
||||
_insert_user_for_refresh("7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb")
|
||||
app = _load_app()
|
||||
|
||||
async def fake_rotate_refresh_token(*, session, raw_token, user_agent, ip_address):
|
||||
class StoredToken:
|
||||
user_id = uuid.UUID("7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb")
|
||||
|
||||
return "new-refresh-token", StoredToken()
|
||||
|
||||
monkeypatch.setattr("src.api.auth.rotate_refresh_token", fake_rotate_refresh_token)
|
||||
|
||||
client = TestClient(app)
|
||||
client.cookies.set("refresh_token", "old-refresh-token")
|
||||
response = client.post("/auth/refresh")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["sub"] == "7f4b7ad8-c4ce-4d1b-8c83-7ce0f4f66dfb"
|
||||
assert "refresh_token=" in response.headers.get("set-cookie", "")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_refresh_returns_401_for_invalid_refresh_token(monkeypatch) -> None:
|
||||
_prepare_auth_test_db()
|
||||
app = _load_app()
|
||||
|
||||
async def fake_rotate_refresh_token(*, session, raw_token, user_agent, ip_address):
|
||||
raise ValueError("refresh token not found")
|
||||
|
||||
monkeypatch.setattr("src.api.auth.rotate_refresh_token", fake_rotate_refresh_token)
|
||||
|
||||
client = TestClient(app)
|
||||
client.cookies.set("refresh_token", "invalid")
|
||||
response = client.post("/auth/refresh")
|
||||
|
||||
assert response.status_code == 401
|
||||
# Check that session cookie is deleted
|
||||
set_cookie = response.headers.get("set-cookie", "")
|
||||
assert "session=" in set_cookie or "session=\"\"" in set_cookie
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import base64
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.cookies import build_cookie_options
|
||||
from src.auth.jwt_service import decode_access_token, mint_access_token
|
||||
from src.auth.oidc import build_login_redirect_url, exchange_code_for_tokens, verify_provider_access_token
|
||||
from src.auth.refresh_store import create_refresh_token, hash_refresh_token, revoke_refresh_token, rotate_refresh_token
|
||||
from src.auth.oidc import build_login_redirect_url
|
||||
from src.auth.session import create_session_cookie, decode_session_cookie
|
||||
from src.config import Settings
|
||||
from src.models.user import User
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -38,153 +31,44 @@ def test_login_redirect_url_contains_required_oidc_params() -> None:
|
||||
settings=settings,
|
||||
redirect_uri="http://localhost:8000/auth/callback",
|
||||
state="state-123",
|
||||
nonce="nonce-123",
|
||||
)
|
||||
|
||||
assert "response_type=code" in url
|
||||
assert "client_id=headquarter-web" in url
|
||||
assert "scope=openid+profile+email" in url
|
||||
assert "state=state-123" in url
|
||||
assert "nonce=nonce-123" in url
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_mint_and_decode_internal_access_token_round_trip() -> None:
|
||||
def test_create_and_decode_session_cookie_round_trip() -> None:
|
||||
settings = Settings()
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=15)
|
||||
user_id = "test-user-123"
|
||||
|
||||
token = mint_access_token(
|
||||
settings=settings,
|
||||
subject="user-123",
|
||||
email="dev@headquarter.local",
|
||||
name="Dev User",
|
||||
expires_at=expires_at,
|
||||
)
|
||||
cookie = create_session_cookie(settings=settings, user_id=user_id)
|
||||
payload = decode_session_cookie(settings=settings, cookie_value=cookie)
|
||||
|
||||
claims = decode_access_token(settings=settings, token=token)
|
||||
|
||||
assert claims["sub"] == "user-123"
|
||||
assert claims["email"] == "dev@headquarter.local"
|
||||
assert claims["name"] == "Dev User"
|
||||
assert "exp" in claims
|
||||
assert payload["user_id"] == user_id
|
||||
assert "exp" in payload
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_refresh_token_hash_is_deterministic_and_non_reversible() -> None:
|
||||
raw_token = "refresh-token-abc"
|
||||
|
||||
first_hash = hash_refresh_token(raw_token)
|
||||
second_hash = hash_refresh_token(raw_token)
|
||||
|
||||
assert first_hash == second_hash
|
||||
assert first_hash != raw_token
|
||||
assert len(first_hash) == 64
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_decode_access_token_rejects_invalid_signature() -> None:
|
||||
def test_decode_session_rejects_invalid_signature() -> None:
|
||||
settings = Settings()
|
||||
other_settings = Settings(jwt_secret="different-secret")
|
||||
expires_at = datetime.now(UTC) + timedelta(minutes=15)
|
||||
other_settings = Settings(session_secret="different-secret")
|
||||
user_id = "test-user-123"
|
||||
|
||||
token = mint_access_token(
|
||||
settings=other_settings,
|
||||
subject="user-123",
|
||||
email="dev@headquarter.local",
|
||||
name="Dev User",
|
||||
expires_at=expires_at,
|
||||
)
|
||||
cookie = create_session_cookie(settings=other_settings, user_id=user_id)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
decode_access_token(settings=settings, token=token)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_exchange_code_for_tokens_posts_expected_payload() -> None:
|
||||
settings = Settings()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url == httpx.URL(settings.resolved_authentik_token_url)
|
||||
payload = dict(httpx.QueryParams(request.content.decode("utf-8")))
|
||||
assert payload["grant_type"] == "authorization_code"
|
||||
assert payload["code"] == "auth-code"
|
||||
assert payload["redirect_uri"] == "http://localhost:8000/auth/callback"
|
||||
return httpx.Response(200, json={"access_token": "provider-token", "refresh_token": "provider-refresh"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
token_payload = await exchange_code_for_tokens(
|
||||
settings=settings,
|
||||
code="auth-code",
|
||||
redirect_uri="http://localhost:8000/auth/callback",
|
||||
client=client,
|
||||
)
|
||||
|
||||
assert token_payload["access_token"] == "provider-token"
|
||||
with pytest.raises(ValueError, match="invalid session signature"):
|
||||
decode_session_cookie(settings=settings, cookie_value=cookie)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_verify_provider_access_token_with_jwks_oct_key() -> None:
|
||||
settings = Settings(authentik_audience="headquarter-web", authentik_issuer="https://authentik.local/")
|
||||
shared_secret = b"shared-secret-123"
|
||||
jwks = {
|
||||
"keys": [
|
||||
{
|
||||
"kty": "oct",
|
||||
"alg": "HS256",
|
||||
"k": base64.urlsafe_b64encode(shared_secret).decode("utf-8").rstrip("="),
|
||||
"kid": "kid-1",
|
||||
}
|
||||
]
|
||||
}
|
||||
def test_decode_session_rejects_expired_cookie(monkeypatch) -> None:
|
||||
settings = Settings(session_ttl_hours=-1) # Already expired
|
||||
user_id = "test-user-123"
|
||||
|
||||
from jose import jwt # type: ignore[import-untyped]
|
||||
cookie = create_session_cookie(settings=settings, user_id=user_id)
|
||||
|
||||
token = jwt.encode(
|
||||
{
|
||||
"sub": "authentik-user",
|
||||
"iss": settings.authentik_issuer,
|
||||
"aud": settings.authentik_audience,
|
||||
"exp": int((datetime.now(UTC) + timedelta(minutes=5)).timestamp()),
|
||||
},
|
||||
shared_secret,
|
||||
algorithm="HS256",
|
||||
headers={"kid": "kid-1"},
|
||||
)
|
||||
|
||||
claims = verify_provider_access_token(settings=settings, token=token, jwks=jwks)
|
||||
|
||||
assert claims["sub"] == "authentik-user"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_refresh_store_create_rotate_and_revoke(db_session: AsyncSession) -> None:
|
||||
user = User(email="dev-auth@headquarter.local", name="Dev Auth", authentik_id="auth-dev", avatar_url=None)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(user)
|
||||
|
||||
raw_refresh_token, stored_token = await create_refresh_token(
|
||||
session=db_session,
|
||||
user_id=user.id,
|
||||
expires_at=datetime.now(UTC) + timedelta(days=7),
|
||||
user_agent="pytest",
|
||||
ip_address="127.0.0.1",
|
||||
)
|
||||
assert raw_refresh_token
|
||||
assert stored_token.revoked_at is None
|
||||
|
||||
rotated_raw, rotated_stored = await rotate_refresh_token(
|
||||
session=db_session,
|
||||
raw_token=raw_refresh_token,
|
||||
user_agent="pytest-rotated",
|
||||
ip_address="127.0.0.2",
|
||||
)
|
||||
assert rotated_raw != raw_refresh_token
|
||||
assert rotated_stored.revoked_at is None
|
||||
assert stored_token.revoked_at is not None
|
||||
|
||||
revoked = await revoke_refresh_token(session=db_session, raw_token=rotated_raw)
|
||||
assert revoked is True
|
||||
with pytest.raises(ValueError, match="session expired"):
|
||||
decode_session_cookie(settings=settings, cookie_value=cookie)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-18
|
||||
@@ -0,0 +1,128 @@
|
||||
# Simplified Authentik Auth Flow - Design
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
User → Frontend → Authentik (OAuth2) → Backend (Session) → Protected Resources
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
### 1. Login Initiation
|
||||
```
|
||||
GET /auth/login
|
||||
→ Redirect to Authentik OAuth authorize URL
|
||||
→ State parameter stored in cookie (auth_state)
|
||||
```
|
||||
|
||||
### 2. OAuth Callback
|
||||
```
|
||||
GET /auth/callback?code=...&state=...
|
||||
→ Verify state parameter
|
||||
→ Exchange code for access token with Authentik
|
||||
→ Fetch user info from Authentik /userinfo endpoint
|
||||
→ Create/update user in local database
|
||||
→ Create session cookie (signed, httpOnly)
|
||||
→ Redirect to frontend
|
||||
```
|
||||
|
||||
### 3. Authenticated Requests
|
||||
```
|
||||
Request with session cookie
|
||||
→ Verify session signature
|
||||
→ Load user from database
|
||||
→ Attach user to request context
|
||||
```
|
||||
|
||||
### 4. Logout
|
||||
```
|
||||
GET /auth/logout
|
||||
→ Delete session cookie
|
||||
→ Optionally revoke token at Authentik
|
||||
→ Redirect to frontend
|
||||
```
|
||||
|
||||
## Session Management
|
||||
|
||||
### Session Cookie
|
||||
- **Name**: `session`
|
||||
- **Value**: Signed cookie containing user_id
|
||||
- **Properties**: httpOnly, Secure (production), SameSite=Lax
|
||||
- **Expiry**: Browser session or configurable duration
|
||||
|
||||
### Session Store
|
||||
- In-memory or Redis (configurable)
|
||||
- Maps session_id → user_id + expiry
|
||||
- Simple cleanup on expiry
|
||||
|
||||
## User Sync
|
||||
|
||||
On each login:
|
||||
1. Fetch user info from Authentik `/application/o/userinfo/`
|
||||
2. Update local user record:
|
||||
- email
|
||||
- name
|
||||
- authentik_id
|
||||
- groups (for future team feature)
|
||||
3. Create user if not exists
|
||||
|
||||
## API Changes
|
||||
|
||||
### Removed Endpoints
|
||||
- `POST /auth/refresh` - No refresh tokens needed
|
||||
|
||||
### Modified Endpoints
|
||||
- `GET /auth/login` - Simpler, no nonce needed
|
||||
- `GET /auth/callback` - No JWT minting, just session creation
|
||||
- `GET /auth/me` - Return user from session instead of JWT
|
||||
- `POST /auth/logout` - Just clear session cookie
|
||||
|
||||
### New Endpoints
|
||||
- None (simplification!)
|
||||
|
||||
## Middleware Changes
|
||||
|
||||
### Current (to be removed)
|
||||
- JWT decoding
|
||||
- Token expiry checking
|
||||
- Refresh token validation
|
||||
|
||||
### New
|
||||
- Session cookie parsing
|
||||
- Signature verification
|
||||
- User loading from database
|
||||
|
||||
## Database Changes
|
||||
|
||||
### Remove Tables
|
||||
- `refresh_tokens` - No longer needed
|
||||
|
||||
### Keep Tables
|
||||
- `users` - Still needed for local user data
|
||||
- `user_configs` - User preferences
|
||||
|
||||
## Configuration Changes
|
||||
|
||||
### Removed
|
||||
- `JWT_SECRET`
|
||||
- `JWT_ALGORITHM`
|
||||
- `ACCESS_TOKEN_TTL_MINUTES`
|
||||
- `REFRESH_TOKEN_TTL_DAYS`
|
||||
|
||||
### Modified
|
||||
- `AUTHENTIK_AUDIENCE` - May not be needed
|
||||
|
||||
### Added
|
||||
- `SESSION_SECRET` - For signing session cookies
|
||||
- `SESSION_TTL_HOURS` - Session duration (default: 24)
|
||||
- `SESSION_STORE` - "memory" or "redis"
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Create session management module**
|
||||
2. **Simplify auth endpoints**
|
||||
3. **Update auth middleware**
|
||||
4. **Remove JWT and refresh token code**
|
||||
5. **Update frontend auth handling**
|
||||
6. **Update configuration**
|
||||
7. **Tests**
|
||||
@@ -0,0 +1,49 @@
|
||||
# Simplify Authentik Auth Flow
|
||||
|
||||
## Problem
|
||||
|
||||
The current authentication implementation is overly complex for our needs:
|
||||
|
||||
- **Multiple layers**: OIDC token exchange, refresh token rotation, complex cookie management
|
||||
- **Difficult to debug**: Many moving parts make deployment issues hard to diagnose
|
||||
- **Over-engineered**: We don't need the full OIDC flow complexity for our use case
|
||||
- **Maintenance burden**: The sophisticated approach requires deep understanding of OAuth2/OIDC internals
|
||||
|
||||
## Solution
|
||||
|
||||
Replace the current complex auth flow with a simplified approach:
|
||||
|
||||
1. **Authentik OAuth**: Keep OAuth2 authentication via Authentik
|
||||
2. **Session-based**: Use simple session cookies instead of JWT + refresh tokens
|
||||
3. **Authentik as source of truth**: User profiles synced from Authentik on login
|
||||
4. **Simpler implementation**: Reduce auth-related code by ~70%
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Easier to deploy**: Fewer configuration variables and moving parts
|
||||
- **Easier to debug**: Clear flow: Login → Authentik → Session Cookie
|
||||
- **Less code**: Remove JWT service, refresh token store, complex OIDC logic
|
||||
- **Future-proof**: Still supports teams/groups via Authentik's user info endpoint
|
||||
- **Better UX**: No token refresh issues, simpler logout
|
||||
|
||||
## Scope
|
||||
|
||||
### What stays:
|
||||
- OAuth2 authentication via Authentik
|
||||
- User model in database (synced from Authentik)
|
||||
- Protected routes requiring authentication
|
||||
- Frontend auth state management
|
||||
|
||||
### What goes:
|
||||
- JWT access tokens
|
||||
- Refresh token rotation
|
||||
- Complex OIDC token verification
|
||||
- Multiple cookie types (access_token, refresh_token)
|
||||
- Token expiry/refresh logic
|
||||
- JWKS fetching and validation
|
||||
|
||||
### What's new:
|
||||
- Simple session cookie (httpOnly, secure, SameSite)
|
||||
- Authentik user info endpoint integration
|
||||
- Simplified auth middleware
|
||||
- Cleaner logout (just delete session)
|
||||
@@ -0,0 +1,114 @@
|
||||
# Simplified Auth Flow Specification
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
1. **OAuth2 Login**: Users authenticate via Authentik using standard OAuth2 flow
|
||||
2. **Session Management**: Authenticated users have a signed session cookie
|
||||
3. **User Sync**: User data (email, name, groups) synced from Authentik on login
|
||||
4. **Protected Routes**: API endpoints can require authentication
|
||||
5. **Logout**: Users can logout, clearing their session
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
1. **Simplicity**: Auth flow should be understandable in 5 minutes
|
||||
2. **Security**: Session cookies must be signed and httpOnly
|
||||
3. **Stateless**: No server-side session state (cookie contains all needed info)
|
||||
4. **Performance**: No token refresh overhead
|
||||
|
||||
## API Specification
|
||||
|
||||
### GET /auth/login
|
||||
Initiates OAuth2 login flow.
|
||||
|
||||
**Response**: 307 Redirect to Authentik authorize URL
|
||||
|
||||
### GET /auth/callback
|
||||
Handles OAuth2 callback from Authentik.
|
||||
|
||||
**Query Parameters**:
|
||||
- `code`: Authorization code
|
||||
- `state`: State parameter for CSRF protection
|
||||
|
||||
**Response**:
|
||||
- Success: 307 Redirect to frontend with session cookie set
|
||||
- Error: 400 Bad Request (invalid state or code)
|
||||
|
||||
### GET /auth/me
|
||||
Returns current authenticated user.
|
||||
|
||||
**Headers**: Requires session cookie
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"email": "user@example.com",
|
||||
"name": "User Name",
|
||||
"avatar_url": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### POST /auth/logout
|
||||
Logs out current user.
|
||||
|
||||
**Response**: 200 OK with session cookie cleared
|
||||
|
||||
## Data Model
|
||||
|
||||
### User Model (existing, kept)
|
||||
```python
|
||||
class User:
|
||||
id: UUID
|
||||
email: str
|
||||
name: str
|
||||
authentik_id: str
|
||||
avatar_url: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
```
|
||||
|
||||
### Session Cookie Format
|
||||
```
|
||||
session={signed_payload}; HttpOnly; Secure; SameSite=Lax
|
||||
```
|
||||
|
||||
Where signed_payload is:
|
||||
```json
|
||||
{
|
||||
"user_id": "uuid",
|
||||
"exp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
Signed with HMAC-SHA256 using SESSION_SECRET.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **CSRF Protection**: State parameter in OAuth flow
|
||||
2. **Session Security**: Signed cookies prevent tampering
|
||||
3. **Cookie Attributes**: httpOnly, Secure, SameSite=Lax
|
||||
4. **Session Expiry**: Configurable TTL with automatic cleanup
|
||||
5. **Token Handling**: Authentik access token not exposed to client
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Authentication Errors
|
||||
- Missing session: 401 Unauthorized
|
||||
- Invalid session signature: 401 Unauthorized
|
||||
- Expired session: 401 Unauthorized (redirect to login)
|
||||
- Invalid OAuth state: 400 Bad Request
|
||||
- OAuth code exchange failure: 400 Bad Request
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Teams/Groups
|
||||
- Authentik groups available via userinfo endpoint
|
||||
- Can store group membership in user model
|
||||
- Team management can be built on top
|
||||
|
||||
### Session Persistence
|
||||
- Currently using signed cookies (stateless)
|
||||
- Can add Redis session store later if needed
|
||||
- No database changes required for upgrade
|
||||
@@ -0,0 +1,76 @@
|
||||
# Simplify Authentik Auth - Tasks
|
||||
|
||||
## Phase 1: Remove Old Auth Code
|
||||
|
||||
- [ ] **Task 1.1**: Remove JWT service (`src/auth/jwt_service.py`)
|
||||
- Delete file
|
||||
- Remove all imports and usages
|
||||
|
||||
- [ ] **Task 1.2**: Remove refresh token store (`src/auth/refresh_store.py`)
|
||||
- Delete file
|
||||
- Remove refresh token model (`src/models/refresh_token.py`)
|
||||
- Remove table in Alembic migration
|
||||
|
||||
- [ ] **Task 1.3**: Remove complex OIDC logic
|
||||
- Simplify `src/auth/oidc.py` to basic OAuth2 flow
|
||||
- Remove JWKS fetching
|
||||
- Remove token verification
|
||||
|
||||
- [ ] **Task 1.4**: Clean up auth dependencies
|
||||
- Remove `python-jose` from dependencies if no longer needed
|
||||
- Update `pyproject.toml`
|
||||
|
||||
## Phase 2: Implement Session Auth
|
||||
|
||||
- [ ] **Task 2.1**: Create session service (`src/auth/session.py`)
|
||||
- Session cookie creation/signing
|
||||
- Session cookie parsing/verification
|
||||
- Session expiry handling
|
||||
|
||||
- [ ] **Task 2.2**: Update auth endpoints (`src/api/auth.py`)
|
||||
- Simplify login endpoint
|
||||
- Update callback to create session instead of JWT
|
||||
- Update /me to read from session
|
||||
- Simplify logout
|
||||
|
||||
- [ ] **Task 2.3**: Update auth middleware
|
||||
- Replace JWT middleware with session middleware
|
||||
- Load user from database based on session
|
||||
|
||||
- [ ] **Task 2.4**: Update configuration
|
||||
- Remove JWT config
|
||||
- Add SESSION_SECRET and SESSION_TTL_HOURS
|
||||
- Update .env.example
|
||||
- Update docker-compose configs
|
||||
|
||||
## Phase 3: Update Frontend
|
||||
|
||||
- [ ] **Task 3.1**: Remove JWT handling from frontend
|
||||
- Delete token refresh logic
|
||||
- Remove access token storage
|
||||
|
||||
- [ ] **Task 3.2**: Update auth API client
|
||||
- Remove refresh endpoint calls
|
||||
- Simplify auth state management
|
||||
|
||||
- [ ] **Task 3.3**: Update protected route logic
|
||||
- Check session cookie instead of JWT
|
||||
- Simpler auth state
|
||||
|
||||
## Phase 4: Testing & Cleanup
|
||||
|
||||
- [ ] **Task 4.1**: Update auth tests
|
||||
- Rewrite tests for new session-based flow
|
||||
- Remove JWT-specific tests
|
||||
- Add session validation tests
|
||||
|
||||
- [ ] **Task 4.2**: Run quality gates
|
||||
- ruff check
|
||||
- mypy
|
||||
- pytest
|
||||
- frontend typecheck + lint + build
|
||||
|
||||
- [ ] **Task 4.3**: Documentation
|
||||
- Update README with new auth flow
|
||||
- Update deployment docs
|
||||
- Document configuration changes
|
||||
Reference in New Issue
Block a user