- 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>
118 lines
4.1 KiB
Python
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
|