"""카페24 OAuth 2.0 (Authorization Code) — URL 생성 / 토큰 발급 / 갱신. 토큰 저장은 여기서 하지 않는다(tokens.TokenService 담당). 이 모듈은 순수하게 카페24 인증 엔드포인트와만 대화한다. 카페24 토큰 응답의 만료시각(`expires_at`, `refresh_token_expires_at`)은 타임존 표기가 없는 KST 문자열이므로 KST 를 붙여 aware datetime 으로 만든다. """ from __future__ import annotations import base64 import secrets from dataclasses import dataclass from datetime import datetime, timedelta from urllib.parse import urlencode import httpx from app.timezone import KST, now_kst from .config import Cafe24Config from .errors import Cafe24AuthError, Cafe24ConfigError TOKEN_TIMEOUT = 20.0 @dataclass(frozen=True) class TokenBundle: """카페24가 돌려준 토큰 한 벌 (평문 — 저장 직전에 암호화된다).""" access_token: str refresh_token: str access_token_expires_at: datetime refresh_token_expires_at: datetime | None scopes: str def new_state() -> str: """CSRF 방어용 state. 세션에 넣어두고 콜백에서 대조한다.""" return secrets.token_urlsafe(24) def build_authorize_url(config: Cafe24Config, *, state: str) -> str: if not config.configured: raise Cafe24ConfigError( "카페24 설정이 없습니다. 미설정 항목: " + ", ".join(config.missing) ) query = urlencode( { "response_type": "code", "client_id": config.client_id, "redirect_uri": config.redirect_uri, "scope": config.scope_param, "state": state, } ) return f"{config.api_base}/oauth/authorize?{query}" def _basic_auth_header(config: Cafe24Config) -> str: raw = f"{config.client_id}:{config.client_secret}".encode("utf-8") return "Basic " + base64.b64encode(raw).decode("ascii") def _parse_expiry(value: str | None, *, fallback_seconds: int) -> datetime: """'2026-08-20T14:00:00.000' → KST aware datetime. 실패 시 fallback.""" text = (value or "").strip() if text: try: parsed = datetime.fromisoformat(text) return parsed if parsed.tzinfo else parsed.replace(tzinfo=KST) except ValueError: pass return now_kst() + timedelta(seconds=fallback_seconds) def _to_bundle(payload: dict) -> TokenBundle: access = (payload.get("access_token") or "").strip() refresh = (payload.get("refresh_token") or "").strip() if not access: raise Cafe24AuthError("카페24 응답에 access_token 이 없습니다.", needs_reauth=True) scopes = payload.get("scopes") if isinstance(scopes, list): scope_text = ",".join(str(s) for s in scopes) else: scope_text = str(scopes or "") return TokenBundle( access_token=access, refresh_token=refresh, # access token 은 통상 2시간, refresh token 은 2주. access_token_expires_at=_parse_expiry(payload.get("expires_at"), fallback_seconds=7200), refresh_token_expires_at=( _parse_expiry(payload.get("refresh_token_expires_at"), fallback_seconds=1209600) if refresh else None ), scopes=scope_text, ) def _post_token(config: Cafe24Config, data: dict[str, str]) -> TokenBundle: url = f"{config.api_base}/oauth/token" headers = { "Authorization": _basic_auth_header(config), "Content-Type": "application/x-www-form-urlencoded", } try: with httpx.Client(timeout=TOKEN_TIMEOUT) as client: response = client.post(url, headers=headers, data=data) except httpx.HTTPError as exc: # 예외 문자열에 Authorization 헤더가 들어가지 않도록 타입명만 남긴다. raise Cafe24AuthError(f"카페24 인증 서버에 연결하지 못했습니다. ({type(exc).__name__})") from None if response.status_code != 200: # 400/401 = 코드/리프레시토큰 무효 → 재인증 필요. raise Cafe24AuthError( f"카페24 토큰 요청이 거부되었습니다. (HTTP {response.status_code})", needs_reauth=response.status_code in (400, 401), ) return _to_bundle(response.json()) def exchange_code(config: Cafe24Config, *, code: str) -> TokenBundle: """authorization code → 최초 토큰.""" return _post_token( config, { "grant_type": "authorization_code", "code": code, "redirect_uri": config.redirect_uri, }, ) def refresh_tokens(config: Cafe24Config, *, refresh_token: str) -> TokenBundle: """refresh token → 새 토큰 한 벌 (refresh token 도 함께 회전된다).""" if not (refresh_token or "").strip(): raise Cafe24AuthError("저장된 refresh token 이 없습니다.", needs_reauth=True) return _post_token( config, {"grant_type": "refresh_token", "refresh_token": refresh_token}, )