Metadata-Version: 2.4
Name: cp-auth
Version: 2.2.1
Summary: cere.dev 공용 로그인 게이트 — 로그인 서비스(cere.dev) 하나에 앱이 연결한다 (순수 ASGI, 표준 라이브러리만)
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# cp-auth v2.1.0 — cere.dev 공용 로그인

모든 로그인은 이 레포가 맡는다. 로그인 서비스는 **https://cere.dev** 한 곳이고(이 레포 `auth/`), 앱은 `cp_auth` 패키지를
깔고 미들웨어 한 줄로 **연결만** 한다. 허브·위성 구분도, 앱 목록도 없다 — 연결한 앱은 로그인 서비스가 스스로 알아챈다.
동작 계약은 `cp_auth/auth_gate.py` docstring, 변경 이력은 `CHANGELOG.md`.

## 구조

- **로그인 서비스(cere.dev, Railway `auth-app`).** 로그인 화면 `/login`, 접속관리 `/account`(비밀번호 변경·로그인 유지 기간·
  로그인 알림·전체 기기 로그아웃·최근 접속), 설정 저장(전용 저장소 `cp_config`), 정책 `/cp/policy`, 첫 화면 `/`(연결된 앱으로 가는 링크).
  게이트 코드는 이 레포 `cp_auth/` 소스 그대로 돈다(`AuthGate(..., hub=True)` — 로그인 서비스 모드는 `auth/app.py` 에만 있다).
- **앱(그 밖 전부).** 로그인 화면·DB 없음. 미인증이면 `https://cere.dev/login?next=<원래 URL>` 로 보내고, 로그인 뒤 돌아온다.
  쿠키 검증에 필요한 세션 버전·기간은 `/cp/policy?app=<자기 주소>&sig=<공유 비밀 HMAC>` 를 60초마다 읽는다 — 이 조회가 곧 연결 신고다
  (서명이 맞는 앱만 적으므로 아무나 드롭다운에 링크를 끼우지 못한다).
- 쿠키는 `.cere.dev` 공유 서명 토큰 — 한 번 로그인이 모든 앱에서 유효. 비밀번호를 바꾸면 즉시 모든 앱에 적용된다.
- **루트 도메인에는 로그인 서비스 말고 아무것도 올리지 않는다.** 같은 출처의 다른 코드는 로그인 화면을 읽을 수 있다.

## 앱 연결 (새 레포도 이것뿐)

```
# requirements.txt — 레포 안 휠 사본 한 줄 (태그 v2.2.0 으로 지은 휠 · 옆에 .sha256 · git·토큰·GitHub 네트워크 불필요)
./tools/wheels/cp_auth-2.2.0-py3-none-any.whl
```

```python
from cp_auth.auth_gate import AuthGate, is_authenticated
from cp_auth.cp_nav import nav_html
app.add_middleware(AuthGate)                      # 가장 바깥 미들웨어 — 다른 add_middleware 뒤에
```

- Railway 변수 하나: `CP_SESSION_SECRET=${{shared.CP_SESSION_SECRET}}`(engine 환경 공유 변수 참조 — 값은 한 곳, 복사하지 않는다).
  헬스체크가 `/health` 가 아니면 `CP_PUBLIC_PREFIXES`. 빌드 토큰은 없다 — 휠이 레포 안에 있다.
- `/api/*` 토큰 검사에 쿠키 OR: `ok = token == API_TOKEN or is_authenticated(request.headers.get("cookie", ""))`.
- 상단바 좌상단 → `nav_html("<앱 코드>")`. 항목은 로그인 서비스가 모은 연결 앱(최근 14일)이다 — 연결하면 1분 안에 모든 앱의
  드롭다운에 뜨고, 14일 동안 연결이 없으면 빠진다. 이름은 주소의 첫 마디(tt.cere.dev 는 TT).
  순서는 EF·TT·LOL·PB·TEN·BDM·VB·KBL / CTI / 접속관리·로그아웃(2.2.1 — `cp_nav.ORDER`, 순서만 정한다). 작업판(WORK)은 드롭다운에 없고
  같은 한 줄이 화면 우상단에 늘 떠 있는 「작업판」 단추를 낸다(모바일도 — 공용 머리 `.cc-top .cc-hr1` 는 그 자리만큼 오른쪽을 비운다).
- 휠 사본은 cp-auth `work/wheels/` 의 것과 바이트 동일하게 둔다(sha256 대조). 판을 올리면 휠·`.sha256`·requirements 줄을 함께 바꾼다.
  휠은 태그에서 다시 지어도 같은 바이트다(2.2.0~): `git archive v<판> | tar -x -C d && cd d && SOURCE_DATE_EPOCH=$(git log -1 --format=%ct v<판>) python -m pip wheel --no-deps -w out .`
  (2.2.0 sha256 `2ec803ea9035cb74f6caba815ca1b03aa815c9c81c3d7cd101ee51d583549a84`).
- 설치판 확인: `python -c "import cp_auth, importlib.metadata as m; print(cp_auth.__version__, m.version('cp-auth'))"`.
- 의존성 없음(표준 라이브러리만, 순수 ASGI). Python ≥ 3.11. 앱 안에서 `cp_auth` 를 고치거나 복사하지 않는다.
- 운영 API v1(2.2.0~): `REG = api_v1.Registry("<엔진>")` · `api_v1.add_common_ops(REG, ledger=…, sql=…)` · FastAPI 는
  `app.add_middleware(api_v1.ApiV1, registry=REG, ledger=LEDGER)`(AuthGate 보다 먼저), 표준 http.server 는 `if api_v1.handle_http(self, REG, LEDGER): return`.
  Railway 변수 `CP_API_TOKEN=${{shared.CP_API_TOKEN}}`·`CP_API_READ_TOKEN=${{shared.CP_API_READ_TOKEN}}`. 계약 `docs/api_v1.md`.

## 로그인 서비스 (auth/)

- Railway 서비스 `auth-app` — 소스 `cumplete/cp-auth` `main`, Dockerfile `auth/Dockerfile`(변수 `RAILWAY_DOCKERFILE_PATH`,
  빌드 문맥은 레포 루트), 감시 경로 `auth/**`·`cp_auth/**`, 헬스체크 `/health`, 도메인 `cere.dev`(Name.com ANAME `@`).
- 저장소: 볼륨 `/data` 의 SQLite `CP_AUTH_DB=/data/auth.db` — 설정 몇 줄이라 DB 서비스를 따로 두지 않는다(`DATABASE_URL` 을 주면 Postgres).
  표 `cp_config`·`cp_auth_log`·`cp_app`(게이트가 만든다).
- 변수: `CP_AUTH_DB` · `CP_SESSION_SECRET=${{shared.CP_SESSION_SECRET}}` · `CP_PASSWORD_HASH`(초기값 — 기동 때 DB 로 옮겨지면 지운다, 복구 때만 다시) ·
  옮겨 올 때만 `CP_SEED_DATABASE_URL`(그 DB 의 `cp_config` 를 빈 DB 에 한 번 복사 — 비밀번호·세션 버전 그대로, 복사 뒤 지운다).
- 로그인 알림: `CP_TELEGRAM_TOKEN`·`CP_TELEGRAM_CHAT`(환경 공유 변수 참조) — 이상 접속(IP·전역 잠금)·비밀번호 변경·설정 변경·전체 로그아웃, 접속관리에서 켜면 로그인마다.
- **OAuth 2.1 인가 서버(원격 MCP `https://mcp.cere.dev/mcp` 연결용, `auth/oauth.py`)** — 계약 `docs/mcp_auth.md`. 메타데이터
  `/.well-known/oauth-authorization-server` · `/oauth/authorize`(동의 화면 — 비밀번호 = 소유자, 읽기만/읽기·쓰기) · `/oauth/token`(PKCE S256 ·
  접근 토큰 EdDSA JWT 1시간 · 갱신 토큰 회전 30일) · `/oauth/register`(DCR) · `/oauth/jwks.json` · 연결 관리 `/oauth/grants`(첫 화면 링크 「MCP 연결」).
  비밀번호·실패 제한기·출처 검사는 게이트의 것을 쓴다. 서명 키는 처음 쓸 때 만들어 DB(`cp_oauth_key`)에만 — 사람이 만드는 새 비밀 없음.
  전체 기기 로그아웃은 MCP 연결도 끊는다. 표 `cp_oauth_key`·`cp_oauth_client`·`cp_oauth_code`·`cp_oauth_grant`·`cp_oauth_refresh`.
  변수(선택): `CP_OAUTH_ISSUER`·`CP_OAUTH_RESOURCE`·`CP_OAUTH_REDIRECT_HOSTS`(기본 `claude.ai,chatgpt.com`)·`CP_OAUTH_CIMD`(기본 0).
- 복구: 비밀번호를 잊으면 `CP_FORCE_ENV_HASH=1` → 변수의 초기 비밀번호로 로그인 → `/account` 에서 새로 정함 → 변수 삭제.
- 시험: `python auth/test_auth.py`(sqlite 메모리·파일 · OAuth 흐름·CIMD·리다이렉트 규칙) · `DATABASE_URL=<빈 로컬 Postgres> python auth/test_auth.py`.

## 판 규약

- **패치(2.1.x)**: `/cp/policy` JSON·쿠키 서명·DB 스키마(`cp_config`·`cp_auth_log`·`cp_app`)·이벤트 종류 불변. 어느 쪽이 먼저 올려도 안전.
- **마이너(2.x)**: 위 넷 중 하나라도 바뀌거나 공개 계약이 늘면(2.2.0 — `cp_auth.api_v1`). 로그인 서비스는 main 소스로 돌아서 늘 먼저 배포되고, 앱은 그 뒤 핀을 올린다 —
  섞인 판이 공존하는 동안 로그인 서비스가 옛 앱을 받아 줘야 한다(하위 호환 창 1 마이너).
- 태그는 `v<판>`, 한 번 찍으면 옮기지 않는다. 핀은 항상 태그, **`@main` 금지**.
- 각 앱의 검증 스택은 "설치판 == requirements 의 요구판" 을 검사한다(핀 문자열을 파싱 — 숫자를 시험에 박지 않는다).
- 판 올리기 = `pyproject.toml`·`cp_auth/__init__.py`·`auth_gate.py`(`__version__`·docstring) 세 곳 + `CHANGELOG.md` → PR → 머지 → 태그
  (태그는 `.github/workflows/tag.yml` 이 머지 커밋에 자동으로 찍는다 — 사람이 만들지 않는다).
  그 뒤 각 앱의 핀 한 줄 PR.

## 불통 규율

- **공개 접두는 게이트 첫 줄에서 통과.** `CP_PUBLIC_PREFIXES`(기본 `/api/,/static/,/health,/favicon.ico`)에 걸리면 DB·정책 조회 0회.
- **앱**: 정책 조회 실패(타임아웃 3초·비200·JSON 오류)는 30초 동안 재조회하지 않고 마지막 성공 정책으로 동작(fail-open).
  성공 이력이 없으면 미인증 → 로그인 서비스로 302. 조회 중 들어온 요청은 새 조회 없이 처리(single-flight).
- **로그인 서비스**: `cp_config` 읽기 실패도 같은 규율 — 5초 캐시 안에서 마지막 성공값. 성공 이력이 없으면 쿠키 검증 불가 ·
  `/login` 503 · `/cp/policy` 503. 환경변수 해시로 되돌아가지 않는다(그 경로는 `CP_FORCE_ENV_HASH` 뿐).
- 로그인 서비스가 죽으면 새 로그인은 안 된다. 이미 로그인한 기기는 앱에서 그대로 쓴다 — 그 앱 프로세스가 정책을 한 번이라도 읽은 뒤라면.
- 실패 제한기는 로그인 서비스 프로세스 메모리(재배포 시 초기화 — 그래서 로그인 서비스는 로그인 코드가 바뀔 때만 배포된다).
  비밀번호는 15자 이상. 접속관리의 모든 변경은 현재 비밀번호를 다시 묻는다. 전체 로그아웃은 세션 버전 +1 — 앱은 60초 안에 따라온다.
- railway.app 기본 도메인으로는 로그인 불가(쿠키 도메인이 `.cere.dev`).

## 검증

`python -m cp_auth.auth_gate selftest` → `ALL PASS`(sqlite 로 로그인 서비스 저장소까지 흉내 · 운영 API v1 포함. `python cp_auth/auth_gate.py selftest` 도 같다) ·
`python -m cp_auth.cp_nav` · `python auth/test_auth.py`. CI `.github/workflows/selftest.yml` 이 PR·태그마다 돈다.

## 공통 운영 API v1 · MCP

모든 엔진의 운영 API 는 같은 형식 `/api/v1` 하나다(두 키 `CP_API_TOKEN`·`CP_API_READ_TOKEN`). 계약 — 의미 정본 `docs/api_v1.md` ·
자료형 정본 `contracts/api_v1.schema.json` · OAuth·MCP 접점 `docs/mcp_auth.md` · 엔진별 기존 기능 대응표 `docs/api_v1_compat/`.
원격 MCP `https://mcp.cere.dev/mcp`(인가 서버 `https://cere.dev`)가 Claude·GPT Work 공통 도구다. 결정 경위 `notes/orchestrator/261001_1852_api_v1_mcp_final_plan.md`.

### mcp/ — 원격 MCP 서버 (mcp.cere.dev)

- Railway 서비스 `mcp-app` — 소스 `cumplete/cp-auth` `main`, Dockerfile `mcp/Dockerfile`(변수 `RAILWAY_DOCKERFILE_PATH`, 빌드 문맥은 레포 루트),
  감시 경로 `mcp/**`·`cp_auth/**`, 헬스체크 `/health`, 도메인 `mcp.cere.dev`(Name.com CNAME). 저장소 없음(상태 없는 서버).
- Streamable HTTP `POST /mcp`(JSON 응답·세션 없음) · 보호 자원 메타데이터 `/.well-known/oauth-protected-resource[/mcp]` · 토큰은 cere.dev 의
  EdDSA JWT(JWKS 로만 검증 — 공유 비밀 없음) · 도구 `engines`·`capabilities`·`query`(cp:read)·`control`(cp:write — 없으면 403 step-up).
- 엔진 찾기: 로그인 서비스 정책의 연결 앱 중 `/api/v1/capabilities` 가 401 `realm="cp-api"` 로 답하는 곳 — 손으로 고치는 엔진 목록 없음.
- 변수: `CP_API_TOKEN=${{shared.CP_API_TOKEN}}` · `CP_API_READ_TOKEN=${{shared.CP_API_READ_TOKEN}}` · (선택) `CP_MCP_ENGINES`(덮어쓰기)·`CP_MCP_TIMEOUT`.
- 시험: `python mcp/test_mcp.py`(로그인 서비스 OAuth 로 실제 토큰 → MCP → 실제 소켓의 가짜 엔진 v1).

## work/ — 작업판 (work.cere.dev)

이 레포의 `work/` 는 cp_auth 에 연결한 앱 하나다(여러 계정·에이전트의 세션 상태·주간 이용량·알림). `packages = ["cp_auth"]` 라 휠에
들어가지 않고 Railway 서비스 `work-app`(Root Directory `work`)으로 따로 배포된다. 설명 `work/README.md` · 규칙 `work/RULES.md` · CI `.github/workflows/work.yml`.
