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
This commit is contained in:
26
services/auth/app/config.py
Normal file
26
services/auth/app/config.py
Normal file
@@ -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()
|
||||||
@@ -17,6 +17,8 @@ from shared.db import get_session
|
|||||||
from shared.models import Subscription, User
|
from shared.models import Subscription, User
|
||||||
from shared.schemas import UserProfile
|
from shared.schemas import UserProfile
|
||||||
|
|
||||||
|
from app.config import auth_settings
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||||
|
|
||||||
|
|
||||||
@@ -36,7 +38,7 @@ async def get_current_user(
|
|||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(
|
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")
|
user_id: str | None = payload.get("sub")
|
||||||
email: str | None = payload.get("email")
|
email: str | None = payload.get("email")
|
||||||
|
|||||||
@@ -4,7 +4,16 @@ Standalone FastAPI service extracted from the adiuva-api monolith.
|
|||||||
Owns: users, refresh_tokens, subscriptions (read).
|
Owns: users, refresh_tokens, subscriptions (read).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
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 import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from shared.db import get_session
|
|||||||
from shared.models import RefreshToken, Subscription, User
|
from shared.models import RefreshToken, Subscription, User
|
||||||
from shared.schemas import AuthTokens, UserProfile
|
from shared.schemas import AuthTokens, UserProfile
|
||||||
|
|
||||||
|
from app.config import auth_settings
|
||||||
from app.deps import get_current_user
|
from app.deps import get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
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]:
|
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())
|
now = int(time.time())
|
||||||
exp = now + settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
exp = now + settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||||
payload = {
|
payload = {
|
||||||
@@ -55,7 +56,7 @@ def _make_access_token(user_id: str, email: str, tier: str) -> tuple[str, int]:
|
|||||||
"exp": exp,
|
"exp": exp,
|
||||||
"iat": now,
|
"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
|
return token, exp * 1000 # ms for client
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from shared.config import settings
|
|||||||
from shared.db import async_session
|
from shared.db import async_session
|
||||||
from shared.models import Subscription
|
from shared.models import Subscription
|
||||||
|
|
||||||
|
from app.config import auth_settings
|
||||||
|
|
||||||
router = APIRouter(tags=["auth"])
|
router = APIRouter(tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ async def verify(request: Request) -> Response:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(
|
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")
|
user_id: str | None = payload.get("sub")
|
||||||
email: str | None = payload.get("email")
|
email: str | None = payload.get("email")
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ class Settings(BaseSettings):
|
|||||||
# ── Database ─────────────────────────────────────────────────────
|
# ── Database ─────────────────────────────────────────────────────
|
||||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/adiuva"
|
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/adiuva"
|
||||||
|
|
||||||
# ── JWT (Auth Service owns the secret; others only need it for
|
# ── JWT ────────────────────────────────────────────────────────
|
||||||
# local dev without Traefik ForwardAuth) ───────────────────────
|
# RS256 public key (PEM). Used by any service that needs to verify
|
||||||
JWT_SECRET: str = "change-me-in-production"
|
# JWTs locally (optional — Traefik ForwardAuth handles this in prod).
|
||||||
JWT_ALGORITHM: str = "HS256"
|
# The private key lives ONLY in the Auth Service config.
|
||||||
|
JWT_PUBLIC_KEY: str = ""
|
||||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user