Case study

ERP 통합 인증 서비스

backend·21 commits·2025–2026

Architecture

pinch to zoom · drag to pan

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)는 보안 요구가 다르고 — 같은 토큰 모양으로 강제하면 어느 한 쪽이 반드시 깨진다.

pinch to zoom · drag to pan

목표는 세 가지로 좁혔다.

  1. 단일 인증 코어, 다중 페르소나 — 같은 FastAPI 앱이 사람·서비스·AI 세 종류 클라이언트를 각각의 표준 흐름으로 처리한다.
  2. 표준에 충실 — 자체 발명하지 않고 OAuth 2.1(MCP Spec 2025-06-18 기준) + DCR + introspection + OIDC를 그대로 구현한다.
  3. 운영 가능한 토큰 수명주기 — 모든 토큰(refresh / svc_ / OAuth access)은 DB에 살고 revocation·로테이션·만료가 추적 가능해야 한다.

구현

3페르소나 인증 표면

같은 FastAPI 앱이 세 진입점을 노출한다. 코어(JWT 서명·토큰 저장소·rate limit)는 공유하지만 정체성은 분리된다.

페르소나진입점인증 출처토큰
사람POST /auth/loginERPClient(httpx → ERP API)HS256 JWT access + refresh
사내 서비스 (M2M)/auth/service-tokens CRUD사전 발급, svc_ 접두어장수명 service token (DB 추적)
AI 에이전트 / 외부OAuth 2.1 endpoints + Google Workspace OIDCDCR + PKCEOAuth access token (introspect 지원)

ERPClient는 사용자 id/pw를 ERP에 그대로 던지고 결과로 받은 사용자 프로파일에 HS256 JWT를 서명해 돌려준다 — auth-service는 자격증명을 저장하지 않는다. 패스워드 책임은 끝까지 ERP에 있고, 우리는 그 검증 결과를 토큰화할 뿐이다.

pinch to zoom · drag to pan

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/authorizePKCE code 발급Jinja 템플릿으로 동의 페이지 ("Institutional Precision" 리디자인)
/oauth/tokencode → access_token 교환code_verifier 검증, refresh 회전
/oauth/registerDCRrate limit 10/min (스팸 등록 차단)
/oauth/introspectRS용 토큰 유효성 조회인증된 RS만 호출 가능 (Basic auth)
/oidc/googleGoogle 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-10ERP 인증 공통 백엔드 서비스 초안 추가코어(JWT · refresh · ERPClient · M2M) 완성
04-13Cloud Run prod 배포 설정 / Dockerfile → Containerfile배포 표준화 (Podman/Docker 양립)
04-16OAuth 2.1 AS + MCP RS + Google Workspace OIDCAI 에이전트 페르소나 추가 (코어 무수정)
04-16DCR 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)
사내 IdPGoogle Workspace OIDC (hd=tidesquare.com 강제)
배포Cloud Run · Supabase Pooler 6543 · Sentry on_error

기술적·운영적으로 가져간 학습은 네 가지다.

  1. 표준 스펙은 일정의 친구다. OAuth 2.1과 MCP Spec 2025-06-18을 그대로 구현했기에 설계 토론을 건너뛰고 6일 안에 AS·DCR·introspect·OIDC를 한 번에 묶을 수 있었다. 자체 프로토콜이면 6주짜리 일이었다.
  2. 페르소나는 코어 위에 얹는 것이지 코어에 섞는 것이 아니다. ERP/JWT 코어를 먼저 안정시키고 OAuth 표면을 추가하는 순서로 갔기 때문에, OAuth 작업이 기존 사내 클라이언트에 회귀 사고를 내지 않았다. 거꾸로였다면 충돌이 났다.
  3. Refresh family rotation은 단발 로테이션보다 운영 가치가 크다. "도난된 토큰이 다시 들어왔다"는 신호를 코드 한 줄(if used:)로 잡고 그 순간 패밀리 전체를 끊는 단순함은, 사후 사고 대응 절차를 통째로 없앤다.
  4. 토큰은 DB에 살아야 한다. stateless JWT의 매력은 알지만, revocation·감사·last_used 추적이 필요한 운영에서는 refresh와 svc_ 모두 영속 모델로 두는 게 정답이었다. 액세스 토큰만 stateless로 두고 나머지는 DB로 — 이 분리가 핵심.
pinch to zoom · drag to pan

Next

  • JWKS 공개 + RS256 전환 옵션 — 현재 HS256 단일 키. 외부 Resource Server가 늘면 공개키 검증을 위한 JWKS endpoint와 비대칭 서명으로 옮길 준비를 한다.
  • 세션 관리 화면 — refresh family 단위 활성 세션을 사용자가 직접 확인·종료할 수 있는 UI. 현재는 DB 직접 조회로만 가능.
  • MCP 클라이언트 카탈로그 — DCR로 등록된 클라이언트의 메타데이터(이름·발급일·last_used)를 관리자 화면에서 일람·취소. 표준 흐름의 출구를 가시화하는 것이 보안 운영의 다음 단계.