엔지니어링 노트보안과 감사

「연결됨」인데 401이 나는 커넥터, 토큰 갱신은 응답 코드로 판단한다

OwlAI에서 커넥터(MCP 서버처럼 회사가 OwlAI에 연결한 외부 서비스)를 OAuth로 붙이면, 사용자는 각자 자기 계정으로 한 번 로그인하고 OwlAI는 그때 받은 액세스 토큰으로 도구를 부릅니다. 액세스 토큰은 곧 만료되므로 리프레시 토큰으로 새로 받아야 합니다. 이 갱신을 저장된 만료 시각에만 맡기면 두 곳에서 깨집니다. 토큰 응답에 만료 시간(expires_in)을 넣지 않는 인증 서버에서는 만료 시각이 비어 갱신이 한 번도 일어나지 않고, 화면에는 「연결됨」이 뜬 채 도구 호출만 401로 실패합니다. 반대로 갱신 실패를 모두 재로그인 사유로 보면, 인증 서버가 500을 한 번 낸 것만으로 사용자가 손으로 다시 연결할 때까지 그 커넥터의 도구가 모두 멈춥니다.

이 글은 두 문제를 푼 규칙을 다룹니다. 언제 갱신할지는 만료 시각과 함께 MCP 서버가 실제로 돌려준 401로 정합니다. 갱신이 실패하면 사용자에게 다시 연결하라고 할지를 인증 서버의 응답 코드로 정합니다. 토큰을 암호화해 저장하는 방법은 「DB 백업에 따라가는 비밀번호, 봉투 암호화로 키를 따로 둔다」에서 다뤘습니다.

만료 시각만 보는 갱신은 만료 시각이 없으면 멈춘다

갱신을 붙이는 흔한 방법은 이렇습니다. 토큰을 받을 때 expires_in으로 만료 시각을 계산해 저장하고, 도구를 부르기 전에 그 시각이 지났으면 리프레시 토큰으로 새 토큰을 받습니다. 갱신이 실패하면 연결에 재인증 필요 표시를 걸고 사용자에게 다시 로그인하게 합니다.

OAuth 2.0 표준에서 expires_in은 권장 항목이라, 넣지 않는 인증 서버가 실제로 있습니다. 그런 서버와 연결하면 저장된 만료 시각이 비고, 만료 시각을 보는 갱신은 영영 돌지 않습니다. 사용자의 커넥터 화면도 같은 저장값으로 상태를 보여 줍니다. 상태는 「미연결」, 「연결됨」, 「만료됨」, 「오류」 넷인데, 만료 시각이 비어 있고 재인증 표시도 없으면 「연결됨」입니다. 서버 쪽에서 토큰이 끝난 뒤에도 화면은 「연결됨」을 보여 주고, 「다시 연결」 버튼도 뜨지 않습니다. 사용자는 고칠 방법을 모른 채 도구 오류만 봅니다.

실패를 한 종류로 다루는 쪽에도 문제가 있습니다. 인증 서버가 잠깐 500을 내거나 사내 프록시가 오류 페이지를 돌려준 경우, 사용자가 다시 로그인해도 결과는 같습니다. 그런데 재인증 표시가 걸리면 그 표시는 사용자가 다시 연결할 때까지 남고, 그동안 그 커넥터의 도구 호출은 인증 서버에 가 보지도 않고 실패합니다. 인증 서버는 몇 초 만에 살아났어도 사용자는 몇 시간 동안 도구를 쓰지 못할 수 있습니다.

만료가 가까우면 미리 갱신하고, 실패해도 지금 토큰을 쓴다

토큰 갱신은 OAuth 커넥터에서만 일어나고, OAuth 커넥터는 모두 사용자별 인증입니다. 관리자가 공용 계정으로 로그인해 토큰을 받는 흐름이 없어서, 등록 화면은 OAuth를 고르면 사용자별 인증만 남기고, 서버 API도 공용 인증과 OAuth를 함께 고른 요청을 거절합니다. 그래서 아래 규칙은 모두 「이 사용자의 이 커넥터 연결」 하나를 단위로 돕니다.

도구를 부를 때마다 OwlAI는 그 사용자의 연결을 읽고 만료 시각을 봅니다.

만료 시각 동작
3분 넘게 남음 저장된 토큰으로 부른다
3분 안쪽 미리 갱신한다. 갱신이 실패하면 아직 유효한 토큰으로 그대로 부른다
이미 지남 갱신한 뒤 새 토큰으로 부른다. 실패하면 아래 「갱신 실패」 규칙을 따른다
비어 있음 저장된 토큰으로 부르고, 401이 오면 갱신한다

미리 갱신하는 이유는 갱신이 실패할 때 기댈 토큰을 남겨 두기 위해서입니다. 만료된 뒤 첫 호출에서야 갱신하면, 그 갱신이 한 번 실패했을 때 쓸 수 있는 토큰이 없어 호출이 그대로 실패합니다. 3분 안쪽에서 미리 갱신하면 갱신이 실패해도 남은 몇 분 동안은 지금 토큰이 통하고, 다음 호출이 다시 미리 갱신을 시도합니다. 그 사이 사용자는 아무것도 겪지 않습니다.

401을 받으면 만료 시각과 상관없이 한 번 갱신하고 다시 부른다

만료 시각이 비어 있으면 MCP 서버의 401이 토큰이 끝났다는 유일한 신호입니다. 그래서 사용자별 OAuth 커넥터의 도구 호출이 401을 받으면 OwlAI는 만료 시각을 보지 않고 갱신을 한 번 합니다. 갱신에 성공하면 새 토큰으로 같은 도구를 한 번 더 부릅니다. 사용자에게는 조금 늦게 온 정상 결과로 보입니다.

다시 부른 호출도 401이면 더 갱신하지 않습니다. 갱신은 성공했는데 새 토큰이 이 서버에 통하지 않는다는 뜻이고, 대개 서비스 쪽에서 앱에 준 권한을 거뒀거나 필요한 권한이 바뀐 경우입니다. 이때는 연결에 재인증 필요 표시를 걸고, 사용자에게 「{커넥터 이름}의 필요 권한이 변경되었습니다. 다시 연결해주세요」라고 알립니다. 갱신과 재호출을 한 번씩으로 묶어 두었기 때문에 401이 계속 오는 서버 앞에서도 호출 하나가 끝없이 돌지 않습니다.

사용자가 자기 API 키를 넣는 사용자별 커넥터에는 리프레시 토큰이 없습니다. 이런 커넥터가 401을 받으면 갱신을 건너뛰고 곧바로 재인증 필요 표시를 겁니다. 사용자는 커넥터 화면의 「토큰 입력」으로 새 키를 넣습니다.

도구 호출 하나가 토큰을 고르는 순서와 갱신 실패의 두 갈래
그림 1. 도구 호출 하나가 토큰을 고르는 순서와 갱신 실패의 두 갈래

갱신 실패는 인증 서버의 응답 코드로 가른다

리프레시 토큰을 새 토큰으로 바꾸는 요청이 실패하면, OwlAI는 실패를 두 가지로 나눕니다. 기준은 「사용자가 다시 로그인하면 결과가 달라지는가」입니다.

실패 판정 처리
인증 서버의 4xx(무효한 리프레시 토큰, 무효한 클라이언트), 저장된 리프레시 토큰 없음 다시 시도해도 결과가 같은 실패 연결에 재인증 필요 표시를 건다. 화면 상태가 「오류」나 「만료됨」으로 바뀌고 「다시 연결」이 뜬다
인증 서버의 5xx, 네트워크 오류·시간 초과, JSON이 아닌 응답 일시적인 실패 이번 도구 호출만 실패로 돌려준다. 표시를 걸지 않으므로 다음 호출이 다시 갱신한다

4xx를 다시 시도해도 결과가 같은 실패로 보는 것은 OAuth 표준을 따른 것입니다. 표준은 토큰 엔드포인트가 리프레시 토큰이나 클라이언트를 받아들이지 않을 때 4xx와 invalid_grant·invalid_client 같은 오류 코드로 답하게 정합니다. 사용자가 서비스에서 앱 연결을 끊었거나 리프레시 토큰의 수명이 다한 경우가 여기에 들고, 사용자가 다시 로그인해야만 풀립니다.

5xx와 네트워크 오류는 인증 서버나 경로의 문제라서 사용자가 다시 로그인해도 같은 오류가 납니다. JSON이 아닌 응답도 같은 쪽에 넣었습니다. 사내 프록시나 게이트웨이가 인증 서버 대신 HTML 오류 페이지를 돌려준 경우인데, 인증 서버의 판단이 아니므로 리프레시 토큰이 무효하다는 근거가 되지 못합니다. 이런 실패에 표시를 걸지 않으니, 인증 서버가 돌아온 뒤의 첫 호출이 스스로 갱신에 성공합니다.

다른 요청이 먼저 갱신한 경우의 4xx는 재인증으로 보지 않는다

4xx인데도 사용자가 다시 로그인할 필요가 없는 경우가 하나 있습니다. 리프레시 토큰을 쓸 때마다 새 리프레시 토큰을 내주는(rotation) 인증 서버에서, 같은 리프레시 토큰으로 두 갱신이 동시에 나가는 경우입니다. 사용자가 커넥터 화면을 여는 순간 화면이 만료된 토큰을 갱신하고, 같은 순간 채팅의 도구 호출도 갱신하면 이렇게 겹칩니다. 먼저 도착한 쪽이 새 토큰을 받고, 늦은 쪽은 이미 쓴 리프레시 토큰을 보냈으므로 4xx를 받습니다. 늦은 쪽이 재인증 필요 표시를 걸면 방금 갱신에 성공한 연결을 끊는 셈입니다.

그래서 4xx를 받으면 표시를 걸기 전에 저장된 연결을 짧은 간격으로 몇 번 다시 읽습니다. 저장된 리프레시 토큰이 내가 보낸 것과 다르고 새 액세스 토큰이 아직 유효하면 다른 요청이 갱신에 성공한 것이므로, 그 토큰으로 도구를 부릅니다.

갱신 결과를 저장할 때도 같은 경합을 봅니다. 갱신은 인증 서버를 오가는 동안 연결 행을 잠그지 않습니다. 저장할 때 행을 잠그고, 갱신을 시작할 때 읽은 토큰과 지금 저장된 토큰이 같은지, 커넥터의 서버 주소와 리소스가 그대로인지 확인한 뒤에만 덮어씁니다. 그 사이 관리자가 서버 주소를 바꿨다면, 옛 주소용으로 받은 연결 정보는 이미 지워졌고 사용자는 각자 다시 연결해야 합니다. 이때 늦게 끝난 갱신이 옛 주소의 토큰을 다시 써넣으면 그 토큰이 새 주소로 나갑니다. 확인이 맞지 않으면 받은 토큰을 버리고, 다른 갱신이 먼저 저장한 토큰이 있으면 그것을 씁니다.

채팅에서는 다시 연결한 뒤 같은 호출을 한 번 더 부른다

재인증이 필요해지면 채팅 화면에 「{커넥터 이름}에 연결」 창이 뜹니다. 창에는 사유가 함께 적힙니다. 인증 서버가 리프레시 토큰을 거절했으면 「{커넥터 이름}의 토큰 갱신에 실패했습니다」, 갱신한 토큰이 서버에 통하지 않았으면 「{커넥터 이름}의 필요 권한이 변경되었습니다」입니다. 사용자가 새 탭에서 로그인을 마치면, OwlAI는 멈춰 있던 도구 호출을 새 토큰으로 한 번 더 부르고 턴(질문 하나를 받아 답을 내기까지의 한 번의 질의응답)을 이어 갑니다. 사용자는 같은 질문을 다시 입력하지 않아도 됩니다.

사용자가 「나중에」를 누르면 그 커넥터의 도구는 이 턴이 끝날 때까지 쓸 수 없고, 다른 도구는 그대로 씁니다. 창은 도구 호출 하나에 한 번만 뜨고, 다시 연결한 뒤에도 인증을 요구하는 서버에는 그 턴에서 창을 다시 띄우지 않습니다.

일시적인 실패에는 창을 띄우지 않습니다. 인증 서버가 500을 내는 동안 사용자가 다시 로그인해도 같은 500을 받기 때문입니다. 그 호출은 실패로 끝나고 모델은 도구 오류를 결과로 받습니다. 같은 커넥터의 다음 호출은 처음부터 갱신을 다시 시도합니다.

설계에서 고른 것

만료 시각이 비어 있으면 기본 수명을 추측해 채우는 방법도 있습니다. 1시간으로 두면 대부분의 인증 서버와 맞고, 만료 전에 미리 갱신하는 규칙도 그대로 돕니다. 그런데 그 값은 인증 서버가 말한 적 없는 값이고, 화면의 「만료까지」와 「곧 만료」가 그 추측을 사실처럼 보여 줍니다. 수명이 더 짧은 서버에서는 추측한 만료 전에 401이 나므로 결국 401 처리가 따로 있어야 합니다. 그래서 저장값에는 인증 서버가 준 값만 두고, 실제 401을 갱신 신호로 골랐습니다. 대가는 두 가지입니다. 만료 뒤 첫 호출은 401, 갱신, 재호출을 한 번 오가는 만큼 늦어집니다. 그리고 그 첫 호출 전까지 화면은 「연결됨」을 보여 줍니다.

갱신 실패를 모두 재인증 필요로 표시하는 쪽은 더 단순하고, 사용자에게 보이는 상태도 하나로 분명합니다. 그 대신 인증 서버의 짧은 장애가 사용자 모두의 손 재연결로 번집니다. 응답 코드로 가르면 판정이 OAuth 표준의 오류 응답에 기대므로, 인증 서버 구현이 제각각이어도 같은 규칙으로 돕니다. 대가는 일시적인 실패가 이어지는 동안 도구 호출마다 인증 서버에 갱신 요청이 한 번씩 나간다는 것입니다. 인증 서버가 오래 죽어 있으면 사용자는 재연결 안내 없이 도구 오류만 보게 됩니다.

401을 받은 뒤의 갱신과 재호출은 한 번씩으로 묶었습니다. 두 번째 401에서 다시 갱신하면 드문 경합 하나를 더 살릴 수는 있지만, 권한을 거둔 서버 앞에서는 호출 하나가 갱신을 되풀이합니다. 두 번째 401은 사용자가 다시 연결해야 풀리는 경우로 보고 바로 알리는 쪽을 골랐습니다.

여러 외부 서비스에 사용자별 OAuth로 붙는 에이전트라면 이 규칙을 그대로 옮길 수 있습니다. 갱신 시점은 저장된 만료 시각과 실제 401을 함께 보고 정하고, 재로그인 안내는 인증 서버의 4xx에만 걸고, 4xx라도 다른 요청이 먼저 갱신했는지 저장값을 다시 읽어 확인합니다.

어떤 문제를 해결하나요?

보안과 감사의 다른 글