"""인증 라우트 — BFF OAuth 2.1 Auth Code + PKCE(S256) 스텁. 마스터플랜 §5: BFF + OAuth 2.1, 토큰은 서버(Redis)에만, 브라우저엔 __Host- HttpOnly 쿠키. 미성년 사례데이터 + 상담 민감정보 -> XSS 토큰탈취 원천 차단. 1차 = Google OIDC 단독, 한신대 SSO 는 2차(R11, Authlib provider 추상화 뒤). 이 파일은 라우트 시그니처 + 흐름 + TODO. 실제 OAuth 교환/Redis 세션은 Phase 2 트랙 B. """ from __future__ import annotations from typing import Annotated, Optional from fastapi import APIRouter, HTTPException, Query, Response, status from fastapi.responses import RedirectResponse from pydantic import BaseModel from ..config import settings from ..deps import CurrentPrincipal router = APIRouter(prefix="/auth", tags=["auth"]) class MeResponse(BaseModel): user_id: str role: str cohort_ids: list[str] @router.get("/login") async def login( provider: Annotated[str, Query()] = "google", ) -> RedirectResponse: """OAuth Auth Code + PKCE 시작 (BFF). 절차: 1. code_verifier 생성 -> S256 code_challenge 2. state(CSRF) + verifier 를 서버 세션(Redis)에 저장 3. provider authorize URL 로 302 (Google OIDC 1차) TODO: Authlib provider 추상화 + Redis state 저장. 현재 스텁 501. """ if provider != "google": # 한신대 SSO 는 2차 (R11) raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=f"provider {provider} not yet supported") raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail="OAuth login TODO (Phase 2 트랙 B)") @router.get("/callback") async def callback( response: Response, code: Annotated[Optional[str], Query()] = None, state: Annotated[Optional[str], Query()] = None, ) -> RedirectResponse: """OAuth 콜백 — code -> token 교환 후 서버 세션 발급. 절차: 1. state 검증 (Redis 의 저장값과 대조, CSRF) 2. code + code_verifier 로 token 교환 (PKCE) 3. id_token 검증 -> user upsert -> role/cohort 매핑 4. Redis 세션 생성 -> __Host- HttpOnly Secure SameSite=Lax 쿠키 set 5. IRB 동의 미이행 시 동의 게이트로 리다이렉트 (마스터플랜 §7) TODO: 전체 교환 구현. 현재 스텁 501. """ raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail="OAuth callback TODO (Phase 2 트랙 B)") @router.post("/logout") async def logout(response: Response) -> dict[str, bool]: """세션 무효화 (Redis 삭제 + 쿠키 만료). IRB 철회 즉시 무효화 경로 겸용. TODO: Redis 세션 삭제. 현재 쿠키 만료만. """ response.delete_cookie(settings.cookie_name, httponly=True, secure=settings.is_prod, samesite="lax") return {"ok": True} @router.get("/me", response_model=MeResponse) async def me(principal: CurrentPrincipal) -> MeResponse: """현재 세션 주체 (프론트 부트스트랩용). 미인증이면 deps 에서 401.""" return MeResponse( user_id=principal.user_id, role=principal.role.value, cohort_ids=principal.cohort_ids, )