ERP 통합 인증 서비스
Architecture
Stack
- Python 3.12
- FastAPI
- SQLAlchemy 2.0 async
- asyncpg
- PyJWT (HS256)
- Supabase
- slowapi
External integrations
- ERP API (tidesquare)
- Supabase PostgreSQL
- Sentry
- Cloud Run
Highlights
- Refresh token family rotation
- Service tokens (M2M, svc_ prefix)
- ERP proxy + rate limiting
문제정의
사내 도구·내부 API·AI 에이전트가 늘면서 인증 표면이 빠르게 파편화됐다. 각 서비스가 자체적으로 ERP 자격증명을 받아 처리하거나, 서비스 간 호출에 long-lived API key가 코드에 박혀 돌았고, 새로 만들 MCP 기반 AI 에이전트는 표준 OAuth 흐름이 필요한데 사내에는 그걸 발급해 줄 IdP가 없었다.
| 한계 | 영향 |
|---|---|
| 서비스마다 ERP 자격증명 직접 처리 | 비밀번호가 N개 코드베이스에 분산 — 회수·로테이션 사실상 불가 |
| M2M 호출은 환경변수 API key | 만료·revocation 없음, 누가 언제 만들었는지 추적 불가 |
| MCP 에이전트용 IdP 부재 | RFC 표준 흐름(DCR · PKCE · introspection) 없이는 외부 클라이언트 연동 불가 |
이 세 가지를 하나의 인증 백엔드로 수렴시키되, 각 클라이언트의 정체성을 가짜로 통일하지 않는 게 목표였다. 사람(브라우저·SSO), 사내 서비스(M2M), AI 에이전트(MCP/OAuth)는 보안 요구가 다르고 — 같은 토큰 모양으로 강제하면 어느 한 쪽이 반드시 깨진다.
목표는 세 가지로 좁혔다.
- 단일 인증 코어, 다중 페르소나 — 같은 FastAPI 앱이 사람·서비스·AI 세 종류 클라이언트를 각각의 표준 흐름으로 처리한다.
- 표준에 충실 — 자체 발명하지 않고 OAuth 2.1(MCP Spec 2025-06-18 기준) + DCR + introspection + OIDC를 그대로 구현한다.
- 운영 가능한 토큰 수명주기 — 모든 토큰(refresh / svc_ / OAuth access)은 DB에 살고 revocation·로테이션·만료가 추적 가능해야 한다.
구현
3페르소나 인증 표면
같은 FastAPI 앱이 세 진입점을 노출한다. 코어(JWT 서명·토큰 저장소·rate limit)는 공유하지만 정체성은 분리된다.
| 페르소나 | 진입점 | 인증 출처 | 토큰 |
|---|---|---|---|
| 사람 | POST /auth/login | ERPClient(httpx → ERP API) | HS256 JWT access + refresh |
| 사내 서비스 (M2M) | /auth/service-tokens CRUD | 사전 발급, svc_ 접두어 | 장수명 service token (DB 추적) |
| AI 에이전트 / 외부 | OAuth 2.1 endpoints + Google Workspace OIDC | DCR + PKCE | OAuth access token (introspect 지원) |
ERPClient는 사용자 id/pw를 ERP에 그대로 던지고 결과로 받은 사용자 프로파일에 HS256 JWT를 서명해 돌려준다 — auth-service는 자격증명을 저장하지 않는다. 패스워드 책임은 끝까지 ERP에 있고, 우리는 그 검증 결과를 토큰화할 뿐이다.
Refresh token family rotation
리프레시 토큰을 한 번 쓰고 버리는 단발 로테이션이 아니라, 패밀리(family) 단위로 묶어 도난 탐지를 내장했다.
- 로그인 시 새 family_id 발급, refresh 토큰에
family_id + jti포함. /auth/refresh호출 시 사용된 토큰을used마크하고 같은 family로 새 토큰 발급.- 이미
used표시된 토큰이 다시 들어오면 패밀리 전체 revoke — 탈취된 토큰이 한 번이라도 재사용되면 그 사용자의 모든 세션이 끊긴다.
이 패턴 덕에 "현재 활성 세션 N개"를 사용자별로 조회·강제 종료할 수 있고, 보안 사건이 나면 family_id 한 줄 update로 즉시 차단된다.
svc_ 접두어 M2M 토큰
사내 서비스 간 호출은 OAuth로 가기엔 무겁고, 평문 API key는 추적이 안 된다. 절충안으로 svc_<random> 접두어 토큰을 도입했다.
- 접두어로 즉시 분류 —
/auth/verify가 토큰 첫 4자만 봐도 JWT vs service token 분기. JWT 디코딩 시도 → 실패 → svc 조회의 fallback 비용 없음. - DB에 살아 있음 — name·description·created_by·last_used_at 모두 추적. 누가 언제 만들었고 마지막 사용이 언제인지 운영 화면에서 확인 가능.
- rate limiting (slowapi) — 서비스 토큰별 호출 한도 적용. 노출된 토큰이 폭주해도 영향 격리.
OAuth 2.1 AS · 표준 구현으로 우회 비용 회피
MCP Spec 2025-06-18은 AI 클라이언트 인증을 OAuth 2.1 + DCR(Dynamic Client Registration)로 명확히 정의한다. 자체 프로토콜을 발명하는 대신 그대로 따랐다 — 6일 안에 끝낸 가장 큰 이유.
| 엔드포인트 | 역할 | 주의점 |
|---|---|---|
/oauth/authorize | PKCE code 발급 | Jinja 템플릿으로 동의 페이지 ("Institutional Precision" 리디자인) |
/oauth/token | code → access_token 교환 | code_verifier 검증, refresh 회전 |
/oauth/register | DCR | rate limit 10/min (스팸 등록 차단) |
/oauth/introspect | RS용 토큰 유효성 조회 | 인증된 RS만 호출 가능 (Basic auth) |
/oidc/google | Google Workspace 로그인 | hd=tidesquare.com 강제로 외부 도메인 차단 |
Google Workspace OIDC를 IdP로 묶었기 때문에 사내 직원은 OAuth 동의 페이지에서 워크스페이스 계정으로 로그인하면 그대로 통과한다 — 별도 패스워드 입력 불필요.
표준에 충실한 운영 인프라
- Supabase Pooler 6543 — SQLAlchemy 2.0 async + asyncpg로 트랜잭션 풀러 모드 사용. Cloud Run의 인스턴스 변동성에 맞춘 선택.
- slowapi rate limit — 로그인·DCR·introspect 각각 별도 한도. 무차별 대입과 클라이언트 등록 스팸을 동시에 차단.
- Jinja 템플릿 git 추적 — Docker 이미지에 템플릿을 포함하도록 .dockerignore 조정. 누락된 동의 페이지가 prod에 올라가 흰 화면 사고가 일어나지 않도록 git이 source of truth.
- Sentry on_error —
/auth/login실패 경로에만 묶어 정상 인증 거부(잘못된 비밀번호)는 노이즈로 잡지 않는다.
출시
전 구간을 일주일 안에 단계적으로 풀었다. 각 단계는 이전 단계의 표면을 깨지 않도록 추가 전용(additive) 으로 묶였다.
| 시기 | 변경 | 의미 |
|---|---|---|
| 04-10 | ERP 인증 공통 백엔드 서비스 초안 추가 | 코어(JWT · refresh · ERPClient · M2M) 완성 |
| 04-13 | Cloud Run prod 배포 설정 / Dockerfile → Containerfile | 배포 표준화 (Podman/Docker 양립) |
| 04-16 | OAuth 2.1 AS + MCP RS + Google Workspace OIDC | AI 에이전트 페르소나 추가 (코어 무수정) |
| 04-16 | DCR rate limit 10/min · Jinja 템플릿 git 추적 · 로그인 페이지 리디자인 | 운영 안정화 |
| 04-17 | "MCP × OAuth 2.1 구축기" 가이드 추가 | 사내 공유 — 다른 팀이 같은 패턴으로 IdP 묶을 수 있게 |
.env.example에 누락됐던 OAUTH_SESSION_SECRET·introspect 자격 3개를 추가 커밋한 것은 작은 일이지만 중요했다 — Cloud Run 환경에서 시작 직후 500이 떠야 알게 되는 종류의 실수를, 새 배포자가 같은 함정에 빠지지 않도록 막아두는 작업이다.
결과학습
| 항목 | 결과 |
|---|---|
| 인증 표면 통합 | 3페르소나 (사람·M2M·OAuth) 단일 서비스 수렴 |
| 집중 개발 | 6일 (04-10 ~ 04-17, 코어 + OAuth AS + OIDC + 가이드) |
| 준수 스펙 | OAuth 2.1 / RFC 7591 DCR / MCP Spec 2025-06-18 |
| 자격증명 저장 | 0 — ERP 패스워드는 통과만 시킴, JWT만 서명 |
| Refresh 도난 탐지 | family rotation으로 사용 가능 (재사용 시 family revoke) |
| 사내 IdP | Google Workspace OIDC (hd=tidesquare.com 강제) |
| 배포 | Cloud Run · Supabase Pooler 6543 · Sentry on_error |
기술적·운영적으로 가져간 학습은 네 가지다.
- 표준 스펙은 일정의 친구다. OAuth 2.1과 MCP Spec 2025-06-18을 그대로 구현했기에 설계 토론을 건너뛰고 6일 안에 AS·DCR·introspect·OIDC를 한 번에 묶을 수 있었다. 자체 프로토콜이면 6주짜리 일이었다.
- 페르소나는 코어 위에 얹는 것이지 코어에 섞는 것이 아니다. ERP/JWT 코어를 먼저 안정시키고 OAuth 표면을 추가하는 순서로 갔기 때문에, OAuth 작업이 기존 사내 클라이언트에 회귀 사고를 내지 않았다. 거꾸로였다면 충돌이 났다.
- Refresh family rotation은 단발 로테이션보다 운영 가치가 크다. "도난된 토큰이 다시 들어왔다"는 신호를 코드 한 줄(
if used:)로 잡고 그 순간 패밀리 전체를 끊는 단순함은, 사후 사고 대응 절차를 통째로 없앤다. - 토큰은 DB에 살아야 한다. stateless JWT의 매력은 알지만, revocation·감사·last_used 추적이 필요한 운영에서는 refresh와 svc_ 모두 영속 모델로 두는 게 정답이었다. 액세스 토큰만 stateless로 두고 나머지는 DB로 — 이 분리가 핵심.
Next
- JWKS 공개 + RS256 전환 옵션 — 현재 HS256 단일 키. 외부 Resource Server가 늘면 공개키 검증을 위한 JWKS endpoint와 비대칭 서명으로 옮길 준비를 한다.
- 세션 관리 화면 — refresh family 단위 활성 세션을 사용자가 직접 확인·종료할 수 있는 UI. 현재는 DB 직접 조회로만 가능.
- MCP 클라이언트 카탈로그 — DCR로 등록된 클라이언트의 메타데이터(이름·발급일·last_used)를 관리자 화면에서 일람·취소. 표준 흐름의 출구를 가시화하는 것이 보안 운영의 다음 단계.