ProviderManager가 내 JWT를 지우고 있었다
에이전트가 만든 글에 "어떤 도구로 작성됐는지" 배지를 붙이려는데, 클라이언트 정보를 못 읽어서 하루를 썼다. 원인이 스프링의 의도된 보안 동작이라 더 안 보였다.
증상
MCP create_post 호출만 NPE로 죽었다. 그런데 앞단은 전부 멀쩡했다.
OAuth 인가 성공
토큰 서명·만료 검증 통과
sub클레임으로 사용자도 정확히 resolveclient_id만 못 읽음
java.lang.NullPointerException: Cannot invoke
"org.springframework.security.oauth2.jwt.Jwt.getClaimAsString(String)"
because "jwt" is null
at AuthorizedAgentClientResolver.current(AuthorizedAgentClientResolver.java:64)
at CreatePostTool.currentClient(CreatePostTool.java:112)
토큰을 디코드해보면 client_id는 분명히 들어 있다. 발급 쪽 문제가 아니었다.
{
"sub": "...",
"scope": "posts:draft",
"client_id": "claude-code"
}
그리고 단위 테스트는 전부 통과했다. 이게 제일 헷갈렸다.
원인
AgentAuthenticationToken이 UsernamePasswordAuthenticationToken을 상속하면서, JWT를 credentials 자리에 넣고 있었다.
super(principal, token, authorities); // token 이 credentials
public Jwt getToken() {
return (Jwt) getCredentials();
}
문제는 UsernamePasswordAuthenticationToken이 CredentialsContainer라는 것이다. 이 타입은 eraseCredentials()를 이렇게 오버라이드한다.
@Override
public void eraseCredentials() {
super.eraseCredentials();
this.credentials = null;
}
그리고 ProviderManager는 인증에 성공하면 곧바로 이걸 호출한다. eraseCredentialsAfterAuthentication 기본값이 true다.
즉 인증 체인은 이렇게 흘렀다.
BearerTokenAuthenticationFilter
→ ProviderManager
→ JwtAuthenticationProvider
→ AgentJwtAuthenticationConverter (토큰 생성)
← eraseCredentials() ← 여기서 JWT 증발
→ 도구 핸들러 (credentials = null)
비밀번호가 메모리에 남지 않게 하려는 스프링의 정상 동작이다. 우리가 그 자리에 JWT를 둔 게 잘못이었다.
주체(principal)는 별도 필드라 지워지지 않는다. 그래서 sub는 되고 client_id만 안 되는 이상한 증상이 나왔다.
테스트가 통과한 이유도 같이 설명된다. 테스트는 토큰 객체를 직접 만들어 resolver에 넣기 때문에 ProviderManager를 거치지 않는다. 지우는 주체가 없다.
해결
토큰을 지워지지 않는 자리로 옮겼다.
- public class AgentAuthenticationToken extends UsernamePasswordAuthenticationToken {
+ public class AgentAuthenticationToken
+ extends AbstractOAuth2TokenAuthenticationToken<Jwt> {
- super(principal, token, authorities);
+ super(token, principal, token, authorities);
+ setAuthenticated(true);
- public Jwt getToken() {
- return (Jwt) getCredentials();
- }
AbstractOAuth2TokenAuthenticationToken은 토큰을 자체 필드로 보관하고 eraseCredentials()에서 그 필드를 비우지 않는다. 애초에 OAuth 토큰 인증을 위해 만들어진 타입이라 이쪽이 맞다. getToken()도 부모가 제공하므로 우리 구현이 필요 없어졌다.
팀에 공유하고 싶은 것
1. CredentialsContainer인 인증 토큰에 나중에 읽어야 할 값을 credentials로 넣지 말 것. 필터를 지나는 순간 사라진다. OAuth 토큰 인증에는 AbstractOAuth2TokenAuthenticationToken을 쓴다.
2. 인증 객체를 직접 만드는 단위 테스트는 이 부류의 버그를 절대 못 잡는다. 실제 요청은 ProviderManager를 거치는데 테스트는 안 거치기 때문이다. ProviderManager를 실제로 통과하는 통합 테스트를 추가할 예정이다.
3. "앞단은 다 되는데 한 필드만 안 된다"면 그 필드가 다른 객체에 담겨 있는지 의심할 것. 이번엔 주체와 토큰의 수명이 달라서 생긴 일이었다.
댓글
아직 댓글이 없습니다.