외부 에이전트 OAuth 인가 서버는 Spring Authorization Server 로 열고, 저장과 위임 판정은 HeyMoa 가 쥔다
- 상태: 채택됨
- 작성일: 2026-10-05
- 관련 문서: ADR-0024 · PRO-54 · APP-885 · APP-886 · APP-887
- 고친 날: 2026-10-06 (APP-886 — 동의·토큰 발급에서 바꾼 자리와 인가 기록 잠금을 더함 · APP-887 — 공개 클라이언트 refresh 와 재사용 감지)
맥락
PRO-54 2단계는 외부 에이전트가 토큰을 복사하지 않고 OAuth 2.1 로 붙게 한다. claude.ai·ChatGPT 는 고정 헤더를 보내지 못해 OAuth 로만 붙는다(PRD 「연결 방식」). 그래서 HeyMoa 가 처음으로 토큰을 발급하는 쪽이 된다. 다음이 이미 정해져 있었다.
- 위임과 연결 자격은 따로다(ADR-0024, APP-801). 읽기·권한 판정·요청 상한·회수는 위임만 보고 동작한다. OAuth 토큰도 위임 판정을 지나야 하고, 회수의 길이 자격 종류마다 따로 생기면 안 된다.
- server 는 Spring Boot 4.1 · Spring Security 7.1 · Jackson 3 · Java 25 이고, ECS 태스크가 여럿이며 고정 세션이 없다. 인가 흐름이 어느 태스크에 닿든 이어져야 한다.
- 로그인은 쿠키 JWT 다. 서버 세션을 쓰지 않는다.
- MCP 사양이 요구하는 것. 보호 리소스 메타데이터(RFC 9728)와 그 안의
authorization_servers, PKCE S256, 공개 클라이언트의 refresh 토큰 교체. - 2026-10-05 실측(같은 DB 를 쓰는 인스턴스 둘, Codex CLI 0.160)에서 Spring Authorization Server(SAS)로 Codex 로그인이 끝까지 됐다. 다만 기본값 그대로는 막히는 자리가 있었다.
결정
- 인가 서버는 SAS(Spring Security 7.1 에 합쳐진 것)를 쓴다. 인가 코드·PKCE·redirect 검증·메타데이터·자체 등록 골격이라는 보안 핵심부를 검증된 코드에 맡긴다.
- 저장은 SAS 의 JDBC 저장소와 표 모양을 쓰되 HeyMoa 테이블로 둔다. Flyway 로 표를 만들고(PostgreSQL 에 맞춰
text·timestamptz), 토큰 원문을 digest 로만 두는 일과 위임 연결은 토큰이 처음 생기는 APP-886 이 저장소를 감싸서 한다. - 사람 확인은 서버 세션 없이 쿠키 JWT 로 한다. 쿠키 인증 필터를 SAS 인가 필터 앞에 두고, 주체는 표준
UsernamePasswordAuthenticationToken으로 세운다. 진행 중인 인가 요청은 DB 의 인가 기록(state)에 있어 어느 태스크든 이어 받는다. - 설정
agent-oauth.enabled로 켜고, 기본은 꺼짐이다. 꺼져 있으면 인가 서버 체인·저장소·정리 스케줄러를 등록하지 않고, Boot 의 인가 서버 자동 구성도 뺀다. 운영은 동의 화면·토큰 발급·갱신이 나간 뒤 켠다. - SAS 기본값을 바꾼 자리(이 결정이 기대는 SAS 7.1.1 의 성질 — 바뀌면 다시 본다)
- 공개 등록의 scope 검증기: 기본은 scope 가 있으면 등록을 전부 거절해, scope 를 넣어 등록하는 Codex 가 붙지 못한다.
mcp:read만 받도록 바꿨다 - 인가 요청의 redirect 검증: 기본은
127.0.0.1만 포트를 무시한다. Claude Code 가 쓰는localhost도 포트를 무시한다 - 인가 오류 응답: 기본은 redirect 를 믿을 수 없으면
sendError(400)을 부르는데,/error재디스패치에서 쿠키 인증 필터가 돌지 않아 쿠키 체인이 401 로 덮는다. 400 OAuth 오류를 직접 쓴다 - 보호 리소스 메타데이터: 기본은
resource를 요청 주소로 만들고authorization_servers를 비운다. 설정의 공개 주소로 채운다 - 동의 기억(APP-886): 기본은 한 번 동의한 클라이언트의 다음 인가 요청을 동의 없이 통과시켜, 워크스페이스를 고르지 않은 채 코드가 나간다. 「동의 필요」 판정을 항상 참으로 두고 동의 저장소는 아무것도 저장하지 않는다
- 동의 결과에 실을 값(APP-886): 기본 동의 처리는 scope 만 받는다. 동의는 쿠키 체인의 HeyMoa 동의 API 가 받아 위임을
만들고 SAS 동의 처리(
OAuth2AuthorizationConsentAuthenticationProvider)를 그대로 부른다. 그 처리는 인가 서버 문맥을 요구하므로 호출 동안 issuer·설정으로 세운다. SAS 의 동의 제출 엔드포인트는 닫는다 - access 토큰(APP-886): 형식·수명이 등록 클라이언트 설정(자체 등록 기본 JWT·5분)에서 오는데 서명 키를 두지 않는다.
토큰 생성기를 바꿔 등록 설정과 상관없이 불투명
hmo_토큰(1시간)만 만든다 resource(APP-886): SAS 는 RFC 8707 을 보지 않는다. 인가 요청 검증기와 토큰 요청 변환기에 검사를 더한다- 저장(APP-886): JDBC 저장소는 코드·토큰 원문을 열에 쓰고 원문으로 찾는다. 저장소를 감싸 digest 로 쓰고 찾는다
- refresh(APP-887): 기본 생성기는 공개 클라이언트에 refresh 를 주지 않고, 공개 클라이언트 인증은
code_verifier가 있을 때만 받아client_id만 실린 갱신 요청이 401 이며, 교체 여부는 등록 설정reuseRefreshTokens(기본 참 — 교체 안 함)를 따른다. 토큰 생성기가 refresh(hmr_, 90일)도 만들고,none으로 등록된 클라이언트의client_id만 갱신을 인증하는 변환기·처리기를 더하고(SAS 가 PKCE 검증에 쓰는none인증은 건드리지 않는다), 등록 클라이언트를 읽는 자리에서reuseRefreshTokens=false로 덮는다 - 재사용 감지(APP-887): SAS 는 교체하면 옛 refresh 를 덮어써, 옛 값이 다시 와도 「없는 토큰」일 뿐이다. 바뀐 refresh 의 digest 를 따로 남기고, 다시 오면 그 위임을 회수한다(교체 직후 10초는 재시도로 보고 거절만)
- 공개 등록의 scope 검증기: 기본은 scope 가 있으면 등록을 전부 거절해, scope 를 넣어 등록하는 Codex 가 붙지 못한다.
- SAS 는 인가 기록을 잠그지 않는다(APP-886). 읽고 판단하고 통째로 다시 쓰므로, 같은 기록을 두 요청이 동시에 다루면
둘 다 옛 상태로 판단한다 — 같은 코드의 이중 교환은 토큰 둘을 내주고 재사용 감지가 돌지 않는다. 같은 기록을 다루는 길
(코드 교환, 같은 동의의 허락·거절, 교환과 정리)은 트랜잭션 안에서 인가 기록 행을
FOR UPDATE로 먼저 잠그고 SAS 를 부른다. SAS JDBC 저장소는 그 트랜잭션에 함께 든다(실측). 재사용 감지의 무효화는 SAS 가 오류를 던지기 전에 저장하므로, 코드 교환은 그 오류로 롤백하지 않고 커밋한 뒤 다시 던진다. refresh 갱신(APP-887)도 같은 잠금 아래에서 돌고, 위임 판정을 SAS 갱신보다 먼저 한다 - SAS 인가 엔드포인트 필터는 권한 검사보다 먼저 돈다. 아직 열지 않는 엔드포인트(동의 제출·토큰 발급)는 권한 규칙이 아니라 그 앞의 필터로 막는다.
- SAS JDBC 저장소는 redirect 목록을 쉼표로 이어 저장한다. 쉼표가 든 주소를 받으면 저장 뒤 주소가 늘어 허용 목록을 우회하므로, 등록에서 거절한다.
대안
대안 1 — 인가 서버를 직접 구현. 인가 코드 + PKCE + refresh 정도라 범위가 작아 보이고, 위임·digest 저장과 처음부터
맞출 수 있다.
기각한 이유: redirect 검증·코드 1회성·PKCE·메타데이터·등록 검증 같은 보안 핵심부를 전부 우리가 책임진다. 실측으로 SAS
기본값을 바꿀 자리의 목록이 나왔고, 직접 채워야 하는 MCP 전용 부분(위임 연결·refresh 재사용 감지·resource 검사)은
어느 쪽이든 같다.
대안 2 — spring-ai-community mcp-security 모듈. MCP 용으로 공개 등록·CIMD·resource→aud 를 더해 준다.
기각한 이유: 실험용(0.1.x)이고, 공개 클라이언트 refresh·불투명 토큰·재사용 감지가 없어 결국 직접 채울 범위가 같다.
대안 3 — 서버 세션으로 사람 확인. SAS 기본 흐름(폼 로그인·세션)을 그대로 쓴다. 기각한 이유: ECS 태스크가 여럿이라 세션 공유가 필요하고, 지금 server 는 세션을 쓰지 않는다.
대안 4 — SAS 의 Boot 자동 구성을 그대로 두기. 기각한 이유: 의존만 있어도 서명 키를 만들고 빈을 올려, OAuth 를 끈 운영이 지금과 달라진다.
결과
- 프로토콜 핵심부는 SAS 가 맡고, HeyMoa 는 위임·저장·사람 확인·MCP 전용 규칙을 쥔다. 개인 토큰과 OAuth 토큰은 같은 위임 판정을 지난다.
- 꺼진 채 배포할 수 있어 동의 화면·토큰 발급이 나오기 전에도 server 를 운영에 낼 수 있다.
- 대가: SAS 내부 동작(필터 순서·기본 검증기·JDBC 저장 형식)에 기대는 자리가 생겼다. SAS 패치가 이를 바꾸면 시험
(
AgentOAuthAuthorizationServerIT)이 잡아야 한다. - 대가(APP-886): 동의 처리의 state·주체·클라이언트 검사와 코드 발급, 코드 교환의 재사용 무효화(무효로 저장한 뒤 오류),
저장소를 감쌀 수 있는 모양(
OAuth2Authorization을 통째로 저장·조회)에도 기댄다. 시험은AgentOAuthTokenFlowIT·ApproveAgentOAuthConsentServiceIT·AgentOAuthStorageIT다. - 대가(APP-887): 갱신 처리가 등록 설정의
reuseRefreshTokens를 읽고 같은 인가 기록의 refresh·access 를 덮어쓴다는 점에 기댄다. 이것이 바뀌면 교체·재사용 감지가 함께 흔들린다. 시험은AgentOAuthTokenFlowIT의 갱신 경우들이다. - 대가: SAS 표 이름(
oauth2_registered_client·oauth2_authorization)은 JDBC 저장소가 고정해 우리 표 이름 관례의 예외다. - CIMD 는 SAS 에 없다(spring-security#18375). 필요해지면 등록 저장소를 직접 구현해 더한다(PRO-54-I13).