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
+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)