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:
Fusion
2026-05-18 22:54:53 +02:00
parent 285d3dace8
commit 2ce7862058
25 changed files with 600 additions and 658 deletions
-1
View File
@@ -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
View File
@@ -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 -22
View File
@@ -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:
+2 -23
View File
@@ -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:
+3 -23
View File
@@ -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())
+2 -23
View File
@@ -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:
+2 -24
View File
@@ -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:
+2 -23
View File
@@ -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:
+3 -5
View File
@@ -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",
]
+49
View File
@@ -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
-27
View File
@@ -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
View File
@@ -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()
-79
View File
@@ -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
+71
View File
@@ -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
+3 -4
View File
@@ -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 -2
View File
@@ -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"]
-24
View File
@@ -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")
-2
View File
@@ -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)
+19 -103
View File
@@ -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
+20 -136
View File
@@ -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