diff --git a/.env.example b/.env.example
index 0a17107..8175395 100644
--- a/.env.example
+++ b/.env.example
@@ -4,6 +4,9 @@
GAUTH_CLIENT_SECRET=change-me
GAUTH_REDIRECT_URI=https://tasks.gnexus.space/auth/callback
+# Session cookie
+SESSION_SECRET=change-me-session-secret
+
# Database
DATABASE_URL=postgresql+psycopg://gntodo:gntodo@localhost:5432/gntodo
diff --git a/backend/app/auth/routes.py b/backend/app/auth/routes.py
new file mode 100644
index 0000000..ce23f9b
--- /dev/null
+++ b/backend/app/auth/routes.py
@@ -0,0 +1,66 @@
+"""Маршруты OAuth-флоу через gnexus-gauth.
+
+Поток: /auth/login → redirect на auth.gnexus.space (PKCE) → /auth/callback →
+обмен code на токены → сессия (подписанная cookie через SessionMiddleware) →
+redirect на return_to.
+
+InMemory state/pkce store из gnexus-gauth достаточно для одного пользователя;
+TODO(M0): заменить на постоянные хранилища до прод-деплоя (см. M0-заметки).
+"""
+
+from typing import Any
+
+from fastapi import APIRouter, HTTPException, Request
+from fastapi.responses import RedirectResponse
+
+from app.auth.client import get_gauth_client
+
+router = APIRouter(prefix="/auth", tags=["auth"])
+
+SCOPES = ["openid", "email", "profile"]
+
+
+@router.get("/login")
+async def login(request: Request, return_to: str = "/") -> RedirectResponse:
+ client = get_gauth_client()
+ auth_request = client.build_authorization_request(return_to=return_to, scopes=SCOPES)
+ # return_to запоминаем в сессии и забираем в callback после обмена code.
+ request.session["return_to"] = return_to
+ return RedirectResponse(auth_request.authorization_url)
+
+
+@router.get("/callback")
+async def callback(request: Request, code: str, state: str) -> RedirectResponse:
+ client = get_gauth_client()
+
+ try:
+ token_set = client.exchange_authorization_code(code, state)
+ except Exception as exc: # gnexus_gauth.exceptions.* — общий обработчик на M0
+ raise HTTPException(status_code=400, detail=f"OAuth callback failed: {exc}") from exc
+
+ user = client.fetch_user(token_set.access_token)
+
+ request.session["access_token"] = token_set.access_token
+ request.session["refresh_token"] = token_set.refresh_token
+ request.session["user"] = {
+ "user_id": user.user_id,
+ "email": user.email,
+ "avatar_url": user.avatar_url,
+ }
+
+ return_to = request.session.pop("return_to", "/")
+ return RedirectResponse(return_to)
+
+
+@router.get("/logout")
+async def logout(request: Request) -> dict[str, bool]:
+ request.session.clear()
+ return {"ok": True}
+
+
+@router.get("/me")
+async def me(request: Request) -> dict[str, Any]:
+ user = request.session.get("user")
+ if not user:
+ raise HTTPException(status_code=401, detail="Not authenticated")
+ return {"user": user}
diff --git a/backend/app/config.py b/backend/app/config.py
index 375e1a3..cd45ef3 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -17,6 +17,10 @@
# Database
database_url: str = "postgresql+psycopg://gntodo:gntodo@localhost:5432/gntodo"
+ # Session cookie
+ session_secret: str = "change-me-session-secret"
+ session_cookie_name: str = "gntodo_session"
+
# Ollama (M2)
ollama_base_url: str = "http://localhost:11434"
ollama_model: str = "qwen2.5:3b"
diff --git a/backend/app/main.py b/backend/app/main.py
index 5a054a4..8f3db01 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,8 +1,12 @@
-"""Точка входа FastAPI. MVP: health + каркас авторизации (полный флоу — в M0)."""
+"""Точка входа FastAPI: health + OAuth-флоу gnexus-gauth + защищённый /api."""
-from fastapi import FastAPI
+from typing import Annotated, cast
+
+from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
+from starlette.middleware.sessions import SessionMiddleware
+from app.auth.routes import router as auth_router
from app.config import get_settings
app = FastAPI(title="gntodo API", version="0.1.0")
@@ -16,8 +20,27 @@
allow_methods=["*"],
allow_headers=["*"],
)
+# Сессия — подписанная cookie (HttpOnly); секрет из окружения.
+app.add_middleware(SessionMiddleware, secret_key=settings.session_secret)
+
+app.include_router(auth_router)
+
+
+def require_user(request: Request) -> dict[str, str]:
+ """Зависимость-защита API: пользователь должен быть залогинен."""
+ user = request.session.get("user")
+ if not user:
+ raise HTTPException(status_code=401, detail="Not authenticated")
+ return cast(dict[str, str], user)
@app.get("/api/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
+
+
+@app.get("/api/me")
+async def api_me(
+ user: Annotated[dict[str, str], Depends(require_user)],
+) -> dict[str, dict[str, str]]:
+ return {"user": user}
diff --git a/backend/pyproject.toml b/backend/pyproject.toml
index d997f85..ec32fa3 100644
--- a/backend/pyproject.toml
+++ b/backend/pyproject.toml
@@ -12,6 +12,7 @@
"alembic>=1.14",
"httpx>=0.24",
"gnexus-gauth",
+ "itsdangerous>=2.2.0",
]
[dependency-groups]
@@ -46,4 +47,4 @@
# gnexus-gauth (alpha) пока без py.typed — игнорируем до появления стабов
[[tool.mypy.overrides]]
module = "gnexus_gauth.*"
-ignore_missing_imports = true
\ No newline at end of file
+ignore_missing_imports = true
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
new file mode 100644
index 0000000..449afdb
--- /dev/null
+++ b/backend/tests/conftest.py
@@ -0,0 +1,7 @@
+"""Тестовое окружение: фиктивные креды SSO и сессии до импорта приложения."""
+
+import os
+
+os.environ.setdefault("GAUTH_CLIENT_ID", "test-client")
+os.environ.setdefault("GAUTH_CLIENT_SECRET", "test-secret")
+os.environ.setdefault("SESSION_SECRET", "test-session-secret")
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
new file mode 100644
index 0000000..deecdba
--- /dev/null
+++ b/backend/tests/test_auth.py
@@ -0,0 +1,27 @@
+"""Smoke-тесты M0: health, защита API, redirect OAuth-флоу."""
+
+from fastapi.testclient import TestClient
+
+from app.main import app
+
+client = TestClient(app)
+
+
+def test_health() -> None:
+ assert client.get("/api/health").json() == {"status": "ok"}
+
+
+def test_api_me_requires_auth() -> None:
+ assert client.get("/api/me").status_code == 401
+
+
+def test_auth_me_requires_auth() -> None:
+ assert client.get("/auth/me").status_code == 401
+
+
+def test_login_redirects_to_sso() -> None:
+ resp = client.get("/auth/login", follow_redirects=False)
+ assert resp.status_code in (302, 307)
+ location = resp.headers["location"]
+ assert "auth.gnexus.space" in location
+ assert "state=" in location and "code_challenge=" in location
diff --git a/backend/uv.lock b/backend/uv.lock
index 39219aa..ab38012 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -274,6 +274,7 @@
{ name = "fastapi" },
{ name = "gnexus-gauth" },
{ name = "httpx" },
+ { name = "itsdangerous" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic-settings" },
{ name = "sqlalchemy" },
@@ -294,6 +295,7 @@
{ name = "fastapi", specifier = ">=0.115" },
{ name = "gnexus-gauth", git = "https://git.gnexus.space/git/root/gnexus-auth-client-py.git" },
{ name = "httpx", specifier = ">=0.24" },
+ { name = "itsdangerous", specifier = ">=2.2.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
{ name = "pydantic-settings", specifier = ">=2.6" },
{ name = "sqlalchemy", specifier = ">=2.0" },
@@ -455,6 +457,15 @@
]
[[package]]
+name = "itsdangerous"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
+]
+
+[[package]]
name = "librt"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index e8ae396..3ae9db2 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -1,4 +1,21 @@
@@ -6,6 +23,12 @@
@@ -23,5 +46,9 @@
display: flex;
gap: 1rem;
margin-bottom: 1rem;
+ align-items: center;
+}
+.app-nav .spacer {
+ flex: 1;
}
\ No newline at end of file
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index f69fd94..7cdd5a8 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -10,6 +10,10 @@
target: 'http://localhost:8000',
changeOrigin: true,
},
+ '/auth': {
+ target: 'http://localhost:8000',
+ changeOrigin: true,
+ },
},
},
})
\ No newline at end of file