Metadata-Version: 2.4
Name: axmp-oidc-bff-provider
Version: 0.9.0
Summary: FastAPI + Authlib BFF backend for Keycloak OIDC login
License-File: LICENSE
Requires-Python: >=3.10
Requires-Dist: authlib>=1.3.2
Requires-Dist: cryptography>=42.0.0
Requires-Dist: fastapi>=0.115.0
Requires-Dist: httpx>=0.27.0
Requires-Dist: itsdangerous>=2.2.0
Requires-Dist: pydantic-settings>=2.5.0
Requires-Dist: pymongo>=4.13
Requires-Dist: redis>=5.0.0
Requires-Dist: uvicorn[standard]>=0.32.0
Description-Content-Type: text/markdown

<div align="center">

# axmp-oidc-bff-provider

**FastAPI + [Authlib](https://authlib.org/) 기반 OIDC BFF(Backend-for-Frontend) 라이브러리**

[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.115%2B-009688.svg)](https://fastapi.tiangolo.com/)
[![Authlib](https://img.shields.io/badge/Authlib-1.3.2%2B-orange.svg)](https://authlib.org/)
[![Lint: Ruff](https://img.shields.io/badge/lint-ruff-261230.svg)](https://github.com/astral-sh/ruff)

</div>

소비하는 FastAPI 앱에 한 줄(`install_bff`)로 OIDC 로그인 / 세션 / CSRF / 토큰 자동 갱신 / 인증 리버스 프록시를 붙입니다.

> 요구사항: **Python ≥ 3.10**, FastAPI ≥ 0.115, Authlib ≥ 1.3.2

---

## 목차

- [✨ 주요 기능](#주요-기능)
- [📦 설치](#설치)
- [🚀 빠른 시작](#빠른-시작-install_bff)
  - [내 라우트 보호하기](#내-라우트-보호하기)
- [⚙️ 설정](#설정-bffconfig)
  - [세션 백엔드](#세션-백엔드)
- [🔐 OIDC 프로바이더](#oidc-프로바이더)
- [🧩 제공 엔드포인트](#제공-엔드포인트)
- [🛠️ 고급 사용](#고급-사용)
  - [커스텀 lifespan](#커스텀-lifespan)
  - [미들웨어 추가](#미들웨어-추가)
  - [고급 커스텀 설정](#고급-커스텀-설정)
- [📚 공개 API](#공개-api)
- [▶️ 데모 앱 실행](#데모-앱-실행)
- [🏭 프로덕션 노트](#프로덕션-노트)
- [🧪 개발과 테스트](#개발과-테스트)

---

## 주요 기능

- **BFF 패턴** — 브라우저는 불투명한 `sid` 쿠키만 보유하고, 토큰(access/refresh/id)·userinfo는 서버사이드 세션 스토어에 저장 → XSS로부터 토큰 보호
- **멀티 프로바이더** — Keycloak · Okta · Google · Azure AD(Entra) · GitHub 빌트인 + 표준 OIDC IdP(예: Dream Security)를 **코드 수정 없이** `BFF_OIDC_PROVIDERS`로 추가
- **세션 스토어** — 인메모리(단일 인스턴스) / Redis(멀티 인스턴스·클러스터, per-sid 락 + 슬라이딩 TTL)
- **silent refresh** — 액세스 토큰 만료 임박 시 자동 갱신(동시 요청 single-flight)
- **세션 보안** — 세션 바인딩 CSRF(double-submit) · 슬라이딩 세션 + 절대 수명 상한 · sid/CSRF 쿠키 lockstep
- **BFF 리버스 프록시** — `/api/*` 를 다운스트림 API로 포워딩하며 access token을 Bearer로 주입(쿠키/CSRF/hop-by-hop 헤더 차단)
- **순수 config 주입** — 라이브러리는 `.env`를 몰래 읽지 않음. 명시적 `BFFConfig(...)` 또는 opt-in `BFFConfig.from_env()`

---

## 설치

```bash
# uv
uv add axmp-oidc-bff-provider
# pip
pip install axmp-oidc-bff-provider
```

세션 스토어로 Redis를 쓰려면 Redis 서버가 필요합니다(아래 [세션 백엔드](#세션-백엔드) 참고).

---

## 빠른 시작 (`install_bff`)

```python
from fastapi import FastAPI
from axmp_oidc_bff_provider import install_bff, BFFConfig

config = BFFConfig.from_env()      # 환경변수/.env 에서 로드 (opt-in)
app = FastAPI()
install_bff(app, config)
```

`install_bff(app, config)` 한 번이 다음을 모두 와이어링합니다:

- **미들웨어**: `SessionMiddleware` + `CSRFMiddleware`(항상 짝) + `CORSMiddleware`(opt-in: `cors_enabled` 이고 `frontend_origins` 가 있을 때)
- **라우터**: `/auth/*`(로그인·콜백·로그아웃·me), `/api/*` 프록시(`upstream_api_url` 설정 시에만)
- **lifespan**: 세션 스토어 + 공유 httpx 클라이언트 생성/정리 (앱의 기존 lifespan을 덮지 않고 **감싸서** 합성)

같은 앱에 두 번 호출하면 `RuntimeError`(fail-fast)로 막습니다.

### 내 라우트 보호하기

```python
from fastapi import Depends
from axmp_oidc_bff_provider import get_current_user, get_current_user_optional

@app.get("/me")
def me(user: dict = Depends(get_current_user)):       # 미인증 시 401
    return user

@app.get("/")
def index(user=Depends(get_current_user_optional)):   # 미인증 시 None
    return {"authenticated": user is not None}
```

---

## 설정 (`BFFConfig`)

`BFFConfig`는 순수 pydantic 모델입니다. **명시적 생성은 환경변수를 읽지 않습니다**:

```python
# 명시적 — env 무시
config = BFFConfig(
    default_provider="keycloak",
    keycloak_base_url="https://kc.example.com",
    keycloak_realm="myrealm",
    keycloak_client_id="my-app",
    keycloak_client_secret="...",
    session_secret_key="<랜덤 시크릿>",
    session_backend="redis",
    redis_url="redis://:password@redis:6379/0",
    frontend_origins=["https://app.example.com"],
)

# 또는 env/.env 에서 (opt-in)
config = BFFConfig.from_env()
```

주요 필드 — **모든 env 변수는 `BFF_OIDC_` 프리픽스**를 씁니다(소비 앱의 다른 설정과 충돌 방지). 즉 env 변수명 = `BFF_OIDC_` + 필드명 대문자 (예: `default_provider` → `BFF_OIDC_DEFAULT_PROVIDER`, `keycloak_base_url` → `BFF_OIDC_KEYCLOAK_BASE_URL`):

| 필드 / env | 기본값 | 설명 |
|---|---|---|
| `default_provider` | `keycloak` | `/auth/login`에 `?provider=` 없을 때 기본 IdP |
| `providers` | `{}` | 커스텀 OIDC 프로바이더 (아래 참고). env는 JSON |
| `verify_ssl` | `true` | 모든 OIDC HTTP 호출의 TLS 인증서 검증. 로컬 자체서명 IdP에서만 `false` |
| `app_base_url` | `http://localhost:8000` | 이 백엔드의 공개 URL — `redirect_uri` 조립에 사용 |
| `frontend_url` | `http://localhost:5173` | 로그인/로그아웃 후 브라우저를 보낼 SPA 주소 |
| `frontend_origins` | `["http://localhost:5173"]` | CORS 허용 오리진 + 오픈리다이렉트 허용목록 |
| `cors_enabled` | `true` | CORS 미들웨어 추가 여부(opt-out 가능) |
| `session_secret_key` | (insecure 기본값) | 세션 쿠키 서명 키. **프로덕션 필수 변경** |
| `session_cookie_name` | `bff_session` | 서명 세션 쿠키 이름 |
| `session_https_only` | `false` | 쿠키 `Secure` 플래그 (HTTPS 환경에서 `true`) |
| `session_same_site` | `lax` | 쿠키 SameSite |
| `cookie_domain` | `""` | 쿠키 Domain (분리 호스트 배포용; 비우면 host-only) |
| `session_ttl_seconds` | `28800` (8h) | 서버사이드 세션 수명 |
| `session_sliding` | `true` | 인증 요청마다 TTL 연장(절반 경과 시에만 기록) |
| `session_absolute_max_seconds` | `604800` (7d) | 슬라이딩과 무관한 절대 수명 상한(0=무제한) |
| `session_backend` | `redis` | `memory` 또는 `redis` |
| `redis_url` | `redis://localhost:6379/0` | Redis 접속 URL (인증 시 `redis://:pw@host:6379/0`) |
| `redis_key_prefix` | `bff:sess:` | Redis 키 프리픽스 |
| `refresh_leeway_seconds` | `30` | 액세스 토큰 만료 N초 전 미리 갱신 |
| `refresh_http_timeout_seconds` | `8.0` | refresh HTTP 타임아웃(per-sid 락 TTL 미만이어야 함) |
| `upstream_api_url` | `""` | BFF 프록시 대상. 비우면 `/api` 프록시 비활성 |

생성 시 `session_https_only=True`(또는 `app_base_url`이 https)인데 시크릿이 insecure 기본값이면 **검증 에러로 fail-fast** 합니다.

### 세션 백엔드

`session_backend`로 두 가지 서버사이드 스토어 중 하나를 선택합니다.

- **`memory`** — 프로세스 로컬 인메모리. 단일 인스턴스·로컬 개발용(재시작 시 소실, 노드 간 공유 안 됨).
- **`redis`** (기본) — Redis 백엔드. 멀티 인스턴스·클러스터에서 세션을 공유하고 재시작에도 유지됩니다. per-sid 락으로 silent refresh를 single-flight 하고 슬라이딩 TTL을 지원합니다.

Redis 인증이 필요하면 `redis_url`에 자격증명을 포함하세요(예: `redis://:password@host:6379/0`).

---

## OIDC 프로바이더

### 빌트인 (5종)

각 프로바이더 자격증명을 env/`BFFConfig`로 설정하면 자동 등록됩니다.

> 모든 env 변수는 `BFF_OIDC_` 프리픽스를 씁니다.

| Provider | 핵심 설정 |
|---|---|
| **keycloak** | `BFF_OIDC_KEYCLOAK_BASE_URL`, `BFF_OIDC_KEYCLOAK_REALM`, `BFF_OIDC_KEYCLOAK_CLIENT_ID`, `BFF_OIDC_KEYCLOAK_CLIENT_SECRET` |
| **okta** | `BFF_OIDC_OKTA_ORG_URL`, `BFF_OIDC_OKTA_CLIENT_ID`, `BFF_OIDC_OKTA_CLIENT_SECRET` |
| **google** | `BFF_OIDC_GOOGLE_CLIENT_ID`, `BFF_OIDC_GOOGLE_CLIENT_SECRET` |
| **azure** | `BFF_OIDC_AZURE_TENANT_ID`, `BFF_OIDC_AZURE_CLIENT_ID`, `BFF_OIDC_AZURE_CLIENT_SECRET` |
| **github** | `BFF_OIDC_GITHUB_CLIENT_ID`, `BFF_OIDC_GITHUB_CLIENT_SECRET` (비-OIDC OAuth2; userinfo는 api.github.com에서 조회) |

> Keycloak이 구버전(레거시 `/auth` 컨텍스트 경로)이면 `BFF_OIDC_KEYCLOAK_BASE_URL`에 `/auth`를 포함하세요(예: `https://kc.example.com/auth`).

### 커스텀 (표준 OIDC IdP — 코드 수정 불필요)

`.well-known/openid-configuration` discovery 문서를 노출하는 IdP는 `BFF_OIDC_PROVIDERS`(JSON)로 추가합니다:

```bash
BFF_OIDC_DEFAULT_PROVIDER=dream
BFF_OIDC_PROVIDERS={"dream": {"server_metadata_url": "https://sso.dreamsecurity.example/.well-known/openid-configuration", "client_id": "my-app", "client_secret": "...", "scope": "openid email profile", "client_kwargs": {"code_challenge_method": "S256"}}}
```

런타임에는 `/auth/login?provider=dream` 으로 선택합니다. 키 이름이 빌트인과 겹치면 커스텀이 우선합니다.

---

## 제공 엔드포인트

`install_bff` / `make_auth_router` 가 `/auth` 프리픽스로 추가하는 라우트:

| Method | Path | 설명 |
|---|---|---|
| GET | `/auth/login?provider=<name>&next=<url>` | 선택한 IdP로 302 리다이렉트(PKCE). `provider` 생략 시 기본값. `next`는 `frontend_origins` 내에서만 허용 |
| GET | `/auth/callback` | 인가 코드 교환 → 서버사이드 세션(sid) 생성 → `next`/`frontend_url`로 리다이렉트 |
| GET | `/auth/logout` | 로컬 세션 삭제 + IdP RP-initiated logout |
| GET | `/auth/me` | 현재 사용자 OIDC 클레임(JSON), 인증 필요 |

IdP/discovery 장애 시 `/auth/login`은 traceback 노출 없이 **502**로 graceful 처리됩니다.

### BFF 리버스 프록시 (`upstream_api_url` 설정 시)

| Method | Path | 설명 |
|---|---|---|
| ANY | `/api/{path}` | 인증된 요청을 `upstream_api_url`로 포워딩, 세션의 access token을 `Authorization: Bearer`로 주입. 인입 Cookie·`X-CSRF-Token`·`X-Forwarded-*`·hop-by-hop 헤더 제거, 업스트림 `Set-Cookie` 미전달, 응답 스트리밍 |

SPA가 unsafe 메서드(POST 등)를 호출할 때는 `X-CSRF-Token` 헤더가 필요합니다(콜백이 세팅한 읽기 가능 `csrf_token` 쿠키 값을 echo).

---

## 고급 사용

대부분의 앱은 `install_bff` 한 번으로 충분합니다. 아래는 자체 lifespan·미들웨어를 함께 쓰거나, 와이어링을 직접 제어하고 싶을 때를 위한 가이드입니다.

### 커스텀 lifespan

소비 앱이 자체 `lifespan`(DB 풀, 백그라운드 작업 등)을 쓰고 싶으면, **평소처럼 `FastAPI(lifespan=...)`에 넘긴 뒤 `install_bff`를 호출**하면 됩니다. `install_bff`가 호출 시점에 등록돼 있는 lifespan을 읽어 BFF lifespan으로 **바깥에서 감쌉니다**(소비자 lifespan은 그 안쪽에 중첩).

```python
from contextlib import asynccontextmanager

from fastapi import FastAPI

from axmp_oidc_bff_provider import install_bff, BFFConfig

config = BFFConfig.from_env()


@asynccontextmanager
async def lifespan(app: FastAPI):
    # startup — BFF lifespan이 먼저 진입한 뒤라
    # app.state.bff_session_store / bff_http / bff_auth_service 를 바로 쓸 수 있음
    app.state.my_db = await create_db_pool(...)
    yield
    # shutdown
    await app.state.my_db.aclose()


app = FastAPI(lifespan=lifespan)   # 1) 내 lifespan 을 먼저 등록
install_bff(app, config)           # 2) BFF 가 그 위를 감쌈
```

- **호출 순서가 중요**: lifespan 은 `FastAPI()` 생성 시 넘기고, `install_bff` 는 그 **뒤에** 호출합니다. (`install_bff` 호출 후 `app.router.lifespan_context` 를 덮어쓰면 BFF lifespan 이 사라집니다.)
- **실행 순서**: `BFF setup → 내 startup → (요청 처리) → 내 shutdown → BFF teardown`. BFF 가 바깥이라 내 startup 에서 `app.state.bff_*` 를 바로 쓸 수 있고, 내 startup 이 실패해도 BFF teardown(세션 스토어·httpx 클라이언트 close)은 정상 수행됩니다.

> 커스텀 lifespan 이 없으면 `install_bff` 만 호출하면 됩니다 — BFF lifespan 이 단독으로 동작합니다. 미들웨어·라우터까지 직접 조립하는 경우엔 아래 **고급 커스텀 설정** 에서 `bff_lifespan(config)` 를 직접 사용하세요.

### 미들웨어 추가

`install_bff`는 내부에서 `CSRF → Session → CORS` 순으로 미들웨어를 깝니다(결과 스택은 바깥→안으로 `CORS → Session → CSRF → routes`). 여기에 내 미들웨어를 더할 때는 **`install_bff` 앞/뒤 어디서 `add_middleware`를 부르냐로 위치가 정해집니다** — Starlette의 `add_middleware`는 항상 맨 앞에 prepend하므로 **나중에 추가한 게 더 바깥**입니다.

| 추가 시점 | 위치 | request flow |
|---|---|---|
| `install_bff` **전** | BFF 레이어 **안쪽** (CSRF보다 안, 라우트에 가깝) | `CORS → Session → CSRF → 내것 → routes` |
| `install_bff` **후** | BFF 레이어 **바깥** (CORS보다 밖) | `내것 → CORS → Session → CSRF → routes` |

```python
from fastapi import FastAPI
from starlette.middleware.gzip import GZipMiddleware

from axmp_oidc_bff_provider import install_bff, BFFConfig

config = BFFConfig.from_env()
app = FastAPI(lifespan=lifespan)   # (커스텀 lifespan 을 쓰면)

# (A) request.session 등 세션/인증 컨텍스트가 필요한 미들웨어 → install_bff "전"
#     → SessionMiddleware 안쪽에 위치해 request.session 사용 가능
app.add_middleware(MyTenantContextMiddleware)

install_bff(app, config)

# (B) 전체를 감싸는 cross-cutting(요청ID·로깅·타이밍·GZip·보안헤더) → install_bff "후"
app.add_middleware(GZipMiddleware, minimum_size=1024)
app.add_middleware(RequestIDMiddleware)   # 나중 호출이 더 바깥
# 결과: RequestID → GZip → CORS → Session → CSRF → MyTenantContext → routes
```

- **`request.session`을 읽는 미들웨어는 반드시 `install_bff` 전에** 추가하세요. 바깥(후)에 두면 SessionMiddleware가 아직 안 돌아 `request.session` 접근이 `AssertionError`로 터집니다.
- **CSRF보다 안쪽 = CSRF를 통과한 요청만** 봅니다(unsafe 메서드 기준). CSRF 거부까지 포함해 로깅하려면 `install_bff` 후(바깥)에 두세요.
- **CORS는 가장 바깥이 정석**(프리플라이트 우선 응답). `install_bff` 후에 무거운/조기 응답 미들웨어를 두면 CORS 밖이 되니 유의하세요(로깅·요청ID 정도는 무방).
- **둘 다 앱 시작 전에** 호출해야 합니다 — 시작 후나 lifespan 안에서 `add_middleware`를 부르면 `RuntimeError`(`Cannot add middleware after an application has started`)가 납니다.
- 생성자 `FastAPI(lifespan=..., middleware=[Middleware(X, ...)])`로 넘긴 것도 **"install_bff 전 추가"와 동일하게 innermost**가 됩니다.

순서를 상대적 위치에 의존하지 않고 완전히 명시적으로 짜려면 아래 **고급 커스텀 설정** 처럼 `install_bff` 없이 미들웨어를 직접 원하는 순서로 `add_middleware` 하세요.

### 고급 커스텀 설정

`install_bff`가 내부적으로 쓰는 팩토리를 직접 조립할 수도 있습니다. 미들웨어 순서·소유권을 완전히 제어하고 싶을 때 사용하세요.

```python
from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware
from axmp_oidc_bff_provider import (
    BFFConfig, build_oauth, make_auth_router, make_proxy_router,
    CSRFMiddleware, bff_lifespan,
)

config = BFFConfig.from_env()
app = FastAPI(lifespan=bff_lifespan(config))   # 세션 스토어 + httpx 클라이언트 관리

# 의존성/라우터가 런타임에 읽는 값
app.state.bff_config = config
app.state.bff_oauth = build_oauth(config)

# add_middleware는 나중에 추가한 게 바깥 → CSRF 먼저, Session 다음 (Session이 바깥)
app.add_middleware(CSRFMiddleware, config=config)
app.add_middleware(
    SessionMiddleware,
    secret_key=config.session_secret_key,
    session_cookie=config.session_cookie_name,
    same_site=config.session_same_site,
    https_only=config.session_https_only,
    max_age=config.session_ttl_seconds,
)

app.include_router(make_auth_router(config, app.state.bff_oauth))
proxy = make_proxy_router(config)              # upstream 미설정 시 None
if proxy is not None:
    app.include_router(proxy)
```

> `SessionMiddleware`와 `CSRFMiddleware`는 한 쌍입니다(CSRF가 `request.session`을 읽음). 둘은 항상 함께, Session이 바깥에 오도록 추가하세요.

---

## 공개 API

```python
from axmp_oidc_bff_provider import (
    # 와이어링
    install_bff, bff_lifespan,
    # 설정
    BFFConfig, OIDCProviderConfig, INSECURE_DEFAULT_SECRET,
    # 팩토리 (고급 커스텀 설정용)
    build_oauth, make_auth_router, make_proxy_router, CSRFMiddleware,
    # 세션 스토어
    create_session_store, SessionStore, InMemorySessionStore, RedisSessionStore,
    # 의존성
    get_current_user, get_current_user_optional, get_current_session,
    # 유틸
    SID_KEY, hash_sid,
)
```

패키지를 `import` 해도 부작용이 없습니다(env 읽기·OAuth 등록 없음). 심볼은 최초 접근 시 지연 로드됩니다([PEP 562](https://peps.python.org/pep-0562/)).

---

## 데모 앱 실행

`examples/main.py`가 `install_bff` + 데모 라우트(`/`, `/protected`, `/healthz`)를 보여줍니다.

```bash
# 1) 의존성 + 로컬 스택(Keycloak + Redis)
uv sync
docker compose up -d

# 2) 환경변수
cp .env.example .env   # BFF_OIDC_KEYCLOAK_*, BFF_OIDC_SESSION_SECRET_KEY 등 채우기

# 3) 서버 (examples/ 가 pythonpath)
uv run uvicorn main:app --app-dir examples --port 8000 --reload
```

`BFF_OIDC_SESSION_SECRET_KEY`는 `python -c "import secrets; print(secrets.token_urlsafe(48))"`로 생성하세요.

---

## 프로덕션 노트

- **HTTPS**: `BFF_OIDC_SESSION_HTTPS_ONLY=true` (쿠키 `Secure`). http 환경에서 true면 콜백 state 쿠키가 왕복되지 않아 로그인이 깨집니다.
- **Redis 필수(멀티 인스턴스)**: `BFF_OIDC_SESSION_BACKEND=redis`로 모든 인스턴스가 세션을 공유. 인증이 필요한 Redis는 `BFF_OIDC_REDIS_URL=redis://:<password>@host:6379/0`.
- **시크릿 관리**: `BFF_OIDC_SESSION_SECRET_KEY`, 모든 `BFF_OIDC_*_CLIENT_SECRET`을 시크릿 매니저(Vault/K8s Secret 등)로 주입.
- **분리 호스트 SPA**: SPA와 BFF가 다른 호스트면 `BFF_OIDC_COOKIE_DOMAIN`을 설정하고 `BFF_OIDC_FRONTEND_ORIGINS`에 SPA 오리진을 추가(CORS credentialed).
- **`/healthz`**: Redis 백엔드일 때 ping하여 다운 시 503(degraded) 반환.

---

## 개발과 테스트

```bash
uv sync
uv run ruff check src/ examples/ tests/
uv run pytest -q
```

테스트는 라이브러리 컴포넌트(`install_bff` + 주입 `BFFConfig`)로 앱을 빌드하며, 데모 앱에 의존하지 않습니다.
