cc-dashboard/src/routers/auth.py
Vadym Samoilenko 26127061ec fix: OMG auto-sync, Projects OMG# column, ADO OMG Deliverable Number, session persistence
- Auto-create/update OmgEntry when Project.job_number changes (PATCH /api/projects);
  delete stale entry on clear; sync name/client when those fields change too
- Backfill script: scripts/backfill_omg_from_projects.py
- Projects List-view: add OMG # column with link to /omg?highlight=<job_number>;
  Grid-view badge also made clickable; OmgView supports ?highlight= deep-link with scroll+highlight
- AzureWorkItem: add omg_number column (migration 0009), extracted from
  fields_json[Custom.OMGDeliverableNumber] on sync; DevOps table shows OMG # column
  with CC-project link when matched; toolbar badge shows count of items without OMG #
- Session no longer lost on F5: refresh_token moved to HttpOnly+SameSite=Lax cookie;
  authStore.init() restores session on app start; axios interceptor retries on 401
  via cookie refresh before logging out; POST /api/auth/logout clears cookie

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 12:30:40 +01:00

118 lines
4.1 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.auth import (
CurrentUser, create_access_token, create_refresh_token,
decode_token,
)
from src.config import settings
from src.database import get_db
from src.models import User
from src.schemas import MicrosoftLoginRequest, TokenResponse, UserOut
from src.sso import validate_microsoft_id_token
_REFRESH_COOKIE = "refresh_token"
_REFRESH_MAX_AGE = settings.REFRESH_TOKEN_EXPIRE_DAYS * 86400
def _set_refresh_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=_REFRESH_COOKIE,
value=token,
httponly=True,
secure=not settings.DEBUG,
samesite="lax",
max_age=_REFRESH_MAX_AGE,
path="/api/auth",
)
def _clear_refresh_cookie(response: Response) -> None:
response.delete_cookie(key=_REFRESH_COOKIE, path="/api/auth")
router = APIRouter(prefix="/api/auth", tags=["auth"])
def _admin_set() -> set[str]:
return {e.strip().lower() for e in settings.ADMIN_EMAILS.split(",") if e.strip()}
@router.post("/microsoft", response_model=TokenResponse)
async def microsoft_sso(body: MicrosoftLoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
claims = validate_microsoft_id_token(body.id_token)
raw_email = claims.get("preferred_username") or claims.get("email") or ""
email = raw_email.lower()
oid: str = claims.get("oid", "")
name: str = claims.get("name", "")
if not email or not email.endswith(f"@{settings.ALLOWED_EMAIL_DOMAIN}"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Domain not allowed")
# Find by azure_oid first (most stable), fall back to email match
user: User | None = None
if oid:
result = await db.execute(select(User).where(User.azure_oid == oid))
user = result.scalar_one_or_none()
if user is None:
result = await db.execute(
select(User).where(func.lower(User.email) == email)
)
user = result.scalar_one_or_none()
if user is None:
# First-time SSO login — auto-provision
username = email.split("@")[0]
user = User(
email=email,
username=username,
password_hash=None,
azure_oid=oid or None,
role="admin" if email in _admin_set() else "user",
)
db.add(user)
else:
# Link existing account to Azure OID on first SSO login
if oid and user.azure_oid is None:
user.azure_oid = oid
# Promote to admin if listed
if email in _admin_set() and user.role != "admin":
user.role = "admin"
# Normalize email to lowercase (case-insensitive matching)
if user.email != email:
user.email = email
await db.commit()
await db.refresh(user)
refresh_token = create_refresh_token(user.id)
_set_refresh_cookie(response, refresh_token)
return TokenResponse(access_token=create_access_token(user.id, user.role))
@router.post("/refresh", response_model=TokenResponse)
async def refresh(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
raw = request.cookies.get(_REFRESH_COOKIE)
if not raw:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing refresh token")
payload = decode_token(raw)
if payload.get("type") != "refresh":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
user = await db.get(User, payload["sub"])
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
new_refresh = create_refresh_token(user.id)
_set_refresh_cookie(response, new_refresh)
return TokenResponse(access_token=create_access_token(user.id, user.role))
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(response: Response):
_clear_refresh_cookie(response)
@router.get("/me", response_model=UserOut)
async def me(user: CurrentUser):
return user