From 2ce7862058d13c9f84af1fa3a7f845211a58d93f Mon Sep 17 00:00:00 2001 From: Fusion Date: Mon, 18 May 2026 22:54:53 +0200 Subject: [PATCH] feat: simplify auth flow - replace JWT with session cookies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ✓ --- apps/api/pyproject.toml | 1 - apps/api/src/api/auth.py | 154 +++++------------ apps/api/src/api/git_repositories.py | 24 +-- apps/api/src/api/projects.py | 25 +-- apps/api/src/api/ssh_keys.py | 26 +-- apps/api/src/api/tool_types.py | 25 +-- apps/api/src/api/user_config.py | 26 +-- apps/api/src/api/users.py | 25 +-- apps/api/src/auth/__init__.py | 8 +- apps/api/src/auth/dependencies.py | 49 ++++++ apps/api/src/auth/jwt_service.py | 27 --- apps/api/src/auth/oidc.py | 35 ++-- apps/api/src/auth/refresh_store.py | 79 --------- apps/api/src/auth/session.py | 71 ++++++++ apps/api/src/config.py | 7 +- apps/api/src/models/__init__.py | 3 +- apps/api/src/models/refresh_token.py | 24 --- apps/api/src/models/user.py | 2 - apps/api/tests/integration/test_auth_api.py | 122 +++----------- .../tests/integration/test_auth_services.py | 156 +++--------------- .../simplify-authentik-auth/.openspec.yaml | 2 + .../changes/simplify-authentik-auth/design.md | 128 ++++++++++++++ .../simplify-authentik-auth/proposal.md | 49 ++++++ .../simplify-authentik-auth/specs/spec.md | 114 +++++++++++++ .../changes/simplify-authentik-auth/tasks.md | 76 +++++++++ 25 files changed, 600 insertions(+), 658 deletions(-) create mode 100644 apps/api/src/auth/dependencies.py delete mode 100644 apps/api/src/auth/jwt_service.py delete mode 100644 apps/api/src/auth/refresh_store.py create mode 100644 apps/api/src/auth/session.py delete mode 100644 apps/api/src/models/refresh_token.py create mode 100644 openspec/changes/simplify-authentik-auth/.openspec.yaml create mode 100644 openspec/changes/simplify-authentik-auth/design.md create mode 100644 openspec/changes/simplify-authentik-auth/proposal.md create mode 100644 openspec/changes/simplify-authentik-auth/specs/spec.md create mode 100644 openspec/changes/simplify-authentik-auth/tasks.md diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 7cfc887..3d49f67 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -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", diff --git a/apps/api/src/api/auth.py b/apps/api/src/api/auth.py index 73a3549..fecfb75 100644 --- a/apps/api/src/api/auth.py +++ b/apps/api/src/api/auth.py @@ -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 "", } diff --git a/apps/api/src/api/git_repositories.py b/apps/api/src/api/git_repositories.py index c73d20f..baed40d 100644 --- a/apps/api/src/api/git_repositories.py +++ b/apps/api/src/api/git_repositories.py @@ -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: diff --git a/apps/api/src/api/projects.py b/apps/api/src/api/projects.py index e5dc21e..72f166b 100644 --- a/apps/api/src/api/projects.py +++ b/apps/api/src/api/projects.py @@ -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: diff --git a/apps/api/src/api/ssh_keys.py b/apps/api/src/api/ssh_keys.py index 870efd5..ff91adb 100644 --- a/apps/api/src/api/ssh_keys.py +++ b/apps/api/src/api/ssh_keys.py @@ -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()) diff --git a/apps/api/src/api/tool_types.py b/apps/api/src/api/tool_types.py index a338a6a..9356a75 100644 --- a/apps/api/src/api/tool_types.py +++ b/apps/api/src/api/tool_types.py @@ -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: diff --git a/apps/api/src/api/user_config.py b/apps/api/src/api/user_config.py index 4d6e22c..95d4308 100644 --- a/apps/api/src/api/user_config.py +++ b/apps/api/src/api/user_config.py @@ -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: diff --git a/apps/api/src/api/users.py b/apps/api/src/api/users.py index 6553bee..8d81319 100644 --- a/apps/api/src/api/users.py +++ b/apps/api/src/api/users.py @@ -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: diff --git a/apps/api/src/auth/__init__.py b/apps/api/src/auth/__init__.py index 1611ea7..6bc22af 100644 --- a/apps/api/src/auth/__init__.py +++ b/apps/api/src/auth/__init__.py @@ -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", ] diff --git a/apps/api/src/auth/dependencies.py b/apps/api/src/auth/dependencies.py new file mode 100644 index 0000000..457d5d1 --- /dev/null +++ b/apps/api/src/auth/dependencies.py @@ -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 diff --git a/apps/api/src/auth/jwt_service.py b/apps/api/src/auth/jwt_service.py deleted file mode 100644 index 8549d5e..0000000 --- a/apps/api/src/auth/jwt_service.py +++ /dev/null @@ -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) diff --git a/apps/api/src/auth/oidc.py b/apps/api/src/auth/oidc.py index 79475f7..a3e72b8 100644 --- a/apps/api/src/auth/oidc.py +++ b/apps/api/src/auth/oidc.py @@ -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() diff --git a/apps/api/src/auth/refresh_store.py b/apps/api/src/auth/refresh_store.py deleted file mode 100644 index bad8adc..0000000 --- a/apps/api/src/auth/refresh_store.py +++ /dev/null @@ -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 diff --git a/apps/api/src/auth/session.py b/apps/api/src/auth/session.py new file mode 100644 index 0000000..67f5bed --- /dev/null +++ b/apps/api/src/auth/session.py @@ -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 diff --git a/apps/api/src/config.py b/apps/api/src/config.py index b2f28dd..e2ebb3e 100644 --- a/apps/api/src/config.py +++ b/apps/api/src/config.py @@ -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" diff --git a/apps/api/src/models/__init__.py b/apps/api/src/models/__init__.py index bcadb22..a2a4130 100644 --- a/apps/api/src/models/__init__.py +++ b/apps/api/src/models/__init__.py @@ -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"] diff --git a/apps/api/src/models/refresh_token.py b/apps/api/src/models/refresh_token.py deleted file mode 100644 index 44a34fb..0000000 --- a/apps/api/src/models/refresh_token.py +++ /dev/null @@ -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") diff --git a/apps/api/src/models/user.py b/apps/api/src/models/user.py index c944884..91b83fd 100644 --- a/apps/api/src/models/user.py +++ b/apps/api/src/models/user.py @@ -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) diff --git a/apps/api/tests/integration/test_auth_api.py b/apps/api/tests/integration/test_auth_api.py index e9c6701..7bd9b82 100644 --- a/apps/api/tests/integration/test_auth_api.py +++ b/apps/api/tests/integration/test_auth_api.py @@ -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 diff --git a/apps/api/tests/integration/test_auth_services.py b/apps/api/tests/integration/test_auth_services.py index dfef016..03e0150 100644 --- a/apps/api/tests/integration/test_auth_services.py +++ b/apps/api/tests/integration/test_auth_services.py @@ -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) diff --git a/openspec/changes/simplify-authentik-auth/.openspec.yaml b/openspec/changes/simplify-authentik-auth/.openspec.yaml new file mode 100644 index 0000000..231e3ab --- /dev/null +++ b/openspec/changes/simplify-authentik-auth/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-18 diff --git a/openspec/changes/simplify-authentik-auth/design.md b/openspec/changes/simplify-authentik-auth/design.md new file mode 100644 index 0000000..52922cc --- /dev/null +++ b/openspec/changes/simplify-authentik-auth/design.md @@ -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** diff --git a/openspec/changes/simplify-authentik-auth/proposal.md b/openspec/changes/simplify-authentik-auth/proposal.md new file mode 100644 index 0000000..3226196 --- /dev/null +++ b/openspec/changes/simplify-authentik-auth/proposal.md @@ -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) diff --git a/openspec/changes/simplify-authentik-auth/specs/spec.md b/openspec/changes/simplify-authentik-auth/specs/spec.md new file mode 100644 index 0000000..99d913e --- /dev/null +++ b/openspec/changes/simplify-authentik-auth/specs/spec.md @@ -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 diff --git a/openspec/changes/simplify-authentik-auth/tasks.md b/openspec/changes/simplify-authentik-auth/tasks.md new file mode 100644 index 0000000..98b0118 --- /dev/null +++ b/openspec/changes/simplify-authentik-auth/tasks.md @@ -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