From 9feeaa79c87404c809a2e773ca34b462cc70501f Mon Sep 17 00:00:00 2001 From: Roberto Musso Date: Sun, 22 Mar 2026 00:50:36 +0100 Subject: [PATCH] feat(auth): migrate JWT from HS256 to RS256 - Add services/auth/app/config.py with JWT_PRIVATE_KEY and JWT_PUBLIC_KEY (Auth Service local config - private key never leaves this service) - Update routes.py: sign tokens with RS256 private key - Update deps.py + verify.py: verify tokens with RS256 public key - Update shared/config.py: replace JWT_SECRET/JWT_ALGORITHM with JWT_PUBLIC_KEY (for optional local verification by other services) - Add sys.path fix in main.py for local dev without PYTHONPATH --- services/auth/app/config.py | 26 ++++++++++++++++++++++++++ services/auth/app/deps.py | 4 +++- services/auth/app/main.py | 9 +++++++++ services/auth/app/routes.py | 5 +++-- services/auth/app/verify.py | 4 +++- shared/config.py | 9 +++++---- 6 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 services/auth/app/config.py diff --git a/services/auth/app/config.py b/services/auth/app/config.py new file mode 100644 index 0000000..641f1c1 --- /dev/null +++ b/services/auth/app/config.py @@ -0,0 +1,26 @@ +"""Auth Service — local configuration. + +Contains secrets that ONLY the Auth Service needs (e.g., JWT private key). +These are NOT in shared/config.py to prevent other services from accessing them. +""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class AuthSettings(BaseSettings): + # RS256 private key (PEM format). Used to SIGN JWTs. + # Only the Auth Service has this. Generate with: + # openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048 + # Then set the env var (newlines as \n): + # JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEv..." + JWT_PRIVATE_KEY: str = "" + + # RS256 public key (PEM format). Used to VERIFY JWTs. + # Derived from the private key: + # openssl rsa -in private.pem -pubout -out public.pem + JWT_PUBLIC_KEY: str = "" + + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + + +auth_settings = AuthSettings() diff --git a/services/auth/app/deps.py b/services/auth/app/deps.py index d689c93..562bda8 100644 --- a/services/auth/app/deps.py +++ b/services/auth/app/deps.py @@ -17,6 +17,8 @@ from shared.db import get_session from shared.models import Subscription, User from shared.schemas import UserProfile +from app.config import auth_settings + oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") @@ -36,7 +38,7 @@ async def get_current_user( ) try: payload = jwt.decode( - token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM] + token, auth_settings.JWT_PUBLIC_KEY, algorithms=["RS256"] ) user_id: str | None = payload.get("sub") email: str | None = payload.get("email") diff --git a/services/auth/app/main.py b/services/auth/app/main.py index 981bb56..4c8169d 100644 --- a/services/auth/app/main.py +++ b/services/auth/app/main.py @@ -4,7 +4,16 @@ Standalone FastAPI service extracted from the adiuva-api monolith. Owns: users, refresh_tokens, subscriptions (read). """ +import sys from contextlib import asynccontextmanager +from pathlib import Path + +# Ensure the repo root is on sys.path so "shared" is importable. +# In Docker, COPY shared/ puts it at /app/shared/ (already importable). +# In local dev, we need to add the repo root (two levels up from this file). +_repo_root = str(Path(__file__).resolve().parents[3]) +if _repo_root not in sys.path: + sys.path.insert(0, _repo_root) from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware diff --git a/services/auth/app/routes.py b/services/auth/app/routes.py index 3935d7b..e5b96df 100644 --- a/services/auth/app/routes.py +++ b/services/auth/app/routes.py @@ -23,6 +23,7 @@ from shared.db import get_session from shared.models import RefreshToken, Subscription, User from shared.schemas import AuthTokens, UserProfile +from app.config import auth_settings from app.deps import get_current_user router = APIRouter(prefix="/auth", tags=["auth"]) @@ -45,7 +46,7 @@ def _hash_token(plain_token: str) -> str: def _make_access_token(user_id: str, email: str, tier: str) -> tuple[str, int]: - """Return (signed JWT, expires_at_ms).""" + """Return (RS256-signed JWT, expires_at_ms).""" now = int(time.time()) exp = now + settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60 payload = { @@ -55,7 +56,7 @@ def _make_access_token(user_id: str, email: str, tier: str) -> tuple[str, int]: "exp": exp, "iat": now, } - token = jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) + token = jwt.encode(payload, auth_settings.JWT_PRIVATE_KEY, algorithm="RS256") return token, exp * 1000 # ms for client diff --git a/services/auth/app/verify.py b/services/auth/app/verify.py index 2f50e00..3e642e3 100644 --- a/services/auth/app/verify.py +++ b/services/auth/app/verify.py @@ -19,6 +19,8 @@ from shared.config import settings from shared.db import async_session from shared.models import Subscription +from app.config import auth_settings + router = APIRouter(tags=["auth"]) @@ -37,7 +39,7 @@ async def verify(request: Request) -> Response: try: payload = jwt.decode( - token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM] + token, auth_settings.JWT_PUBLIC_KEY, algorithms=["RS256"] ) user_id: str | None = payload.get("sub") email: str | None = payload.get("email") diff --git a/shared/config.py b/shared/config.py index 39dea37..50df4a7 100644 --- a/shared/config.py +++ b/shared/config.py @@ -13,10 +13,11 @@ class Settings(BaseSettings): # ── Database ───────────────────────────────────────────────────── DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/adiuva" - # ── JWT (Auth Service owns the secret; others only need it for - # local dev without Traefik ForwardAuth) ─────────────────────── - JWT_SECRET: str = "change-me-in-production" - JWT_ALGORITHM: str = "HS256" + # ── JWT ──────────────────────────────────────────────────────── + # RS256 public key (PEM). Used by any service that needs to verify + # JWTs locally (optional — Traefik ForwardAuth handles this in prod). + # The private key lives ONLY in the Auth Service config. + JWT_PUBLIC_KEY: str = "" JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30