CLI 인증, 올바른 방식
(abgeo.dev)- 많은 CLI는 노트북의 로컬 브라우저에서는 빠르게 끝나는 localhost OAuth 리다이렉트를 기본값으로 쓰지만, SSH·컨테이너·WSL 같은 개발 환경에서는 같은 가정이 깨져 로그인 흐름이 멈춤
- 현재 방식은 CLI가
127.0.0.1에 임시 HTTP 서버를 열고 브라우저를 인증 URL로 보낸 뒤, 인증 제공자가 authorization code를 로컬 callback으로 돌려주는 구조임 - 2019년 표준화된 RFC 8628 Device Authorization Grant는 토큰을 요청하는 CLI와 사용자가 인증하는 브라우저 장치를 분리해, 포트 바인딩이나 로컬 브라우저 의존을 없앰
- Device flow는
device_code,user_code,verification_uri,interval을 받아/token을 주기적으로 폴링하고,authorization_pending,slow_down,access_denied,expired_token같은 표준 상태를 처리함 - 새 CLI라면 device flow를 기본값으로 두고
.well-known/openid-configuration으로 엔드포인트를 발견하며, refresh token은~/.config의 JSON 파일이 아니라 OS keychain에 저장해야 함
localhost 리다이렉트가 전제하는 것
- 흔한 CLI 로그인은 로컬 HTTP 서버와 시스템 브라우저가 같은 머신에 있다는 가정 위에서 동작함
- CLI가
127.0.0.1의 특정 포트에 HTTP 서버를 바인딩함 - 시스템 브라우저를 OAuth authorization endpoint로 열고
redirect_uri=http://127.0.0.1:<port>/callback을 포함함 - 사용자가 로그인하면 인증 제공자가 authorization code를 loopback URL로
302리다이렉트함 - CLI의 작은 HTTP 서버가 code를 읽고 token endpoint에서 토큰으로 교환함
- 대부분 PKCE가 붙고, 이후 “이 탭을 닫아도 됩니다” 페이지가 표시됨
- CLI가
gcloud auth login,wrangler login, 이전vercel login과 여러 vendor CLI가 이 방식을 사용함- Wrangler는
8976포트를 사용함 - gcloud는
8085를 사용함 - Claude Code는 실행할 때마다 임시 포트를 잡음
- Wrangler는
- RFC 8252는 native app에서 브라우저가 있는 경우 이 패턴을 권장하지만, 호스트에 브라우저가 없을 때의 처리는 다루지 않음
사용자가 localhost 단계를 잘 못 보는 이유
- localhost callback은 매우 짧게 지나가므로 대부분 사용자가 보지 못함
- CLI가 출력하는 URL은 길고, 그 안에 redirect URI가 query string으로 들어 있음
- 사용자는 인증 제공자의 실제 도메인에서 로그인하고 승인함
- 인증 제공자는 브라우저를 localhost callback으로 보낸 뒤 CLI가 code를 읽게 하고, 다시 polished “signed in” 페이지로 이동시킴
- 겉으로는 “웹사이트에서 로그인했더니 CLI가 인증됐다”처럼 보이지만, 실제로는 로컬 HTTP 서버와 브라우저의 공존이 흐름을 지탱함
SSH·컨테이너·WSL에서 깨지는 지점
- 전체 흐름은 CLI가 실행되는 머신과 브라우저가 실행되는 머신이 같다는 가정에 의존함
- SSH 세션에서는 원격 호스트에 브라우저가 없고,
xdg-open이 실패하거나 X forwarding 환경에서 보이지 않는 원격 브라우저를 열 수 있음- callback 포트를 노트북으로 터널링할 수는 있지만, 인증 제공자에 등록된 redirect URI가 터널을 통과한 포트를 허용해야 함
- 컨테이너에는 브라우저가 없고, 많은 이미지에는
xdg-open이나open도 없음-p로 callback 포트를 노출할 수 있지만 CLI가 어떤 포트를 잡을지 알아야 함- Cloudflare CLI에는 이 문제로 막힌 사용자의 이슈가 이어짐
- WSL에서는 브라우저가 Windows에서 열리고 loopback server는 Linux에서 실행됨
- WSL2의 포트 포워딩은 대부분 동작하지만 항상 그렇지는 않음
- shared box에서는 같은 머신의 다른 프로세스가
/proc/net/tcp로 listening port를 찾거나, 알려진 포트를 먼저 바인딩하려고 경쟁할 수 있음- PKCE는 code exchange를 보호하지만 redirect 자체의 authenticated session을 보호하지 않음
fallback이 이미 드러내는 설계 문제
- loopback 흐름을 기본으로 제공하는 CLI들은 깨질 때를 위한 fallback도 함께 제공함
- gcloud에는
--no-launch-browser가 있음 - Wrangler는 멈추며, 수용된 workaround는 두 번째 터미널에서 localhost URL을 직접 curl하는 방식임
- Anthropic의
claude는 “Paste code here if prompted”를 출력하고 기다림 - 이런 fallback은 사실상 수동 device flow이며, CLI가 실제로 쓰이는 환경에서 기본 흐름이 동작하지 않기 때문에 존재함
RFC 8628 Device Authorization Grant
- RFC 8628은 2019년에 “input-constrained devices”를 위해 나온 OAuth 2.0 Device Authorization Grant임
- TV, 콘솔, CLI가 대상에 포함됨
- 토큰을 요청하는 장치와 사용자가 인증하는 장치를 분리하는 것이 핵심임
- CLI는 인증 제공자의
device_authorization_endpoint에 POST함- 예시 요청은
client_id=my-cli&scope=openid+offline_access를 전송함
- 예시 요청은
- 인증 제공자는 다음 값을 포함한 JSON을 반환함
device_codeuser_codeverification_uriverification_uri_completeexpires_ininterval
- CLI는 URL과 짧은 코드를 출력하고, 가능하면
verification_uri_complete에 대한 QR도 보여줌 - 사용자는 원하는 장치에서 URL을 열고 로그인한 뒤, 요청 scope와 client name을 보고 CLI에 표시된 짧은 코드와 일치하는지 확인한 후 승인함
폴링과 표준 상태 처리
- CLI는 token endpoint를
interval초마다 폴링함 - grant type은
urn:ietf:params:oauth:grant-type:device_code를 사용함 - RFC 8628 section 3.5는 다음 상태를 정의함
authorization_pending: 사용자의 승인을 기다리는 상태slow_down: 인증 제공자가 폴링 간격을 늦추라고 요청한 상태이며, 명세는 interval을 최소 5초 늘리라고 명시함access_denied: 사용자가 거부한 상태expired_token: 너무 오래 기다려 토큰이 만료된 상태
- device flow에서는 CLI가 포트를 바인딩하지 않고, 실행 호스트에 브라우저가 있다고 가정하지 않음
- 같은 로그인 방식이 노트북, 컨테이너, 사람의 승인을 기다리는 CI job에서 동작함
폴링 비용과 엔드포인트 발견
- 기본 폴링 interval은 5초임
- 대부분 인증은 1분 이내에 끝나므로 일반적인 로그인은
/token에 약 10번 정도 폴링하고 멈춤 - 서버는
slow_down으로 interval을 늘릴 수 있고, 잘 작성된 client는 이를 따라야 함 - pending login마다 stateful endpoint에 WebSocket이나 SSE 연결을 유지하는 방식과 비교하면,
/token에 대한 stateless polling이 더 단순하고 저렴함 - 인증 제공자가 OpenID Connect Discovery를 지원하면 CLI는
.well-known/openid-configuration에서device_authorization_endpoint와token_endpoint를 가져와 URL 하드코딩을 없앨 수 있음
device flow의 피싱 위험
- device flow에는 공격자가 실제 인증 제공자의
device_authorization_endpoint를 호출해user_code와device_code를 받은 뒤 피해자에게 입력을 유도하는 공격이 있음 - 피해자는 실제 URL에서 실제 코드로 로그인하고 실제 consent screen을 승인할 수 있음
- 공격자는 자신이 생성한
device_code로/token을 폴링하다가 access token을 받음 - 러시아 threat actor는 2024년 8월 이후 M365 tenant를 상대로 이 캠페인을 수행함
- Microsoft Threat Intelligence는 이를 Storm-2372로 추적함
- Volexity는 APT29/Midnight Blizzard로 attribution함
- 정부, 방위, NGO tenant가 여러 대륙에서 영향을 받음
피싱 방어는 인증 제공자 책임
- 피싱 방어는 CLI가 아니라 인증 제공자 쪽에서 이뤄져야 함
- 필요한 완화책은 다음과 같음
- 짧은
user_code만료 시간 - verification page에서 client name과 요청 위치를 눈에 띄게 표시
- code 입력 시도에 대한 rate limiting
verification_uri_complete를 노출하지 않아 피해자가 링크를 클릭하는 대신 코드를 직접 입력하게 함- 고가치 tenant에서는 known network나 device가 아니면 device code flow를 막는 conditional access policy
- 짧은
- CLI의 역할은 명세를 따르고 shortcut을 만들지 않는 것임
- device flow는 local attack surface를 social attack surface로 바꾸지만, 더 많은 환경에서 동작하는 흐름을 제공하고 인증 제공자의 mitigation을 활용하는 쪽이 더 적절함
Go 구현 예시의 핵심 흐름
- 전체 구현은 Go에서
net/http만으로 약 30줄에 들어감 - 구현 흐름은 다음과 같음
client_id와scope를 담아DeviceAuthorizationEndpoint에http.PostForm호출- 응답 JSON에서
DeviceCode,UserCode,VerificationURIComplete,Interval을 디코딩 - 사용자에게
VerificationURIComplete와UserCode를 출력 TokenEndpoint에device_code,client_id, device grant type을 넣어 반복 POSTauthorization_pending이면 계속 대기slow_down이면 interval을 5초 늘림- error가 없으면
access_token과refresh_token을 반환 - 다른 error는 실패로 처리
- Keycloak realm에서 “OAuth 2.0 Device Authorization Grant” capability를 켜거나, grant를 지원하는 OpenID-certified provider를 쓰면 device-flow login이 동작함
새 CLI의 기본값으로 삼을 방식
- 기본값은 device flow로 설정해야 함
.well-known/openid-configuration에서 엔드포인트를 발견해 URL을 하드코딩하지 않아야 함interval과slow_down을 반드시 지켜야 함- refresh token은
~/.config아래 JSON 파일이 아니라 OS keychain에 저장해야 함 - 빠른 노트북 로그인을 위해 loopback path를 제공하고 싶다면
--webflag 뒤에 두고 기본값으로 만들지 않아야 함
이미 옮겨간 CLI와 남은 도구들
- device flow를 기본값으로 쓰는 CLI들이 있음
gh auth login은 처음부터 device flow를 사용했으며, open source에서 가장 깔끔한 reference implementation으로 평가됨aws sso login은 IAM Identity Center를 상대로 device flow를 end-to-end로 실행함vercel login은 2025년 9월 RFC 8628로 이동하며 email-based login과 이전--oobflag를 대체함- Stripe CLI는 RFC 8628 자체는 아니지만 UX를 잘 구현한 pairing-code flow를 사용함
- 여전히 loopback flow를 기본으로 두고 paste-the-code fallback을 붙인 도구들도 있음
- Google
gcloud - Cloudflare
wrangler - Anthropic
claude
- Google
- CLI가 노트북을 벗어날 때마다 수동 paste-the-code fallback이 필요하다면, 그 fallback을 기본 흐름으로 제공하는 편이 맞음
댓글과 토론
Lobste.rs 의견들
-
표현은 좀 대충이지만 흥미로움. 기기 코드/링크를 1분마다 교체하면 피싱에 악용되는 것도 줄일 수 있을 듯함
한 번 사용된 뒤에는 회전을 멈추고 해당 세션을 IP나 브라우저에 묶어두면 됨- 이 방식이 글에서 말하는 것만큼 크게 도움이 되지는 않음. 사용자가 들어오면 흐름을 시작하고 곧바로 정상 제공자로 리다이렉트하는 피싱 랜딩 페이지를 만드는 건 꽤 쉬움
Microsoft처럼 사용자가 코드를 직접 입력하게 하는 제공자라면, 랜딩 페이지가 안내문을 띄우고 코드를 클립보드에 복사해 피싱에 더 쉽게 걸리게 만들 수도 있음
- 이 방식이 글에서 말하는 것만큼 크게 도움이 되지는 않음. 사용자가 들어오면 흐름을 시작하고 곧바로 정상 제공자로 리다이렉트하는 피싱 랜딩 페이지를 만드는 건 꽤 쉬움
-
좋은 글이고, 모두가 RFC 8628 쪽으로 옮겨가야 한다는 데 동의함
원격 개발 머신에서 CLI OAuth 절차를 너무 자주 겪다 보니,xdg-open을 가로채고 포트를 자동 포워딩해서 나쁜 사용자 경험을 덮어주는 개인 도구를 만들었음: https://github.com/phinze/bankshot -
흥미로움. 최근에 마침 “옛” 인증 방식인 RFC 8252를 구현했는데, “새” 방식인 RFC 8268은 모르고 있었음
내 주된 사용 사례가 Google 서버 인증이었기 때문에 지식 공백이 생긴 듯함. 내가 RFC 8268 흐름이라고 생각하는 문서에는 다음처럼 명시돼 있음Alternatives
If you are writing an app for a platform such as Android, iOS, macOS, Linux, or Windows (including the Universal Windows Platform), that has access to the browser and full input capabilities, use the OAuth 2.0 flow for mobile and desktop applications. (You should use that flow even if your app is a command-line tool without a graphical interface.)
그래서 그대로 RFC 8252 흐름만 읽고 구현했음. 내 도구는 CLI가 맞지만, 사용 사례가 로컬 전용이라 SSH나 컨테이너 환경은 고려하지 않았음
게다가 RFC 8268 흐름에서는 Google이 제한된 OAuth 2.0 범위만 허용하므로, 일부 애플리케이션에는 결정적인 제약이 될 수 있음- 사소한 정정: 원문 번호를 다시 확인해 보니 RFC 8628임
Google의 범위 제약은 OIDC가 복잡하게 고개를 드는 부분임. 이상적으로는 Google이 접근 토큰에 뭉개 넣는 대신 ID 토큰을 돌려줘야 하지만, 그건 Google의 OAuth 설정 문제지 8628 자체의 특성은 아님
OAuth의 끝없는 복잡함은 여기서 옴. 표준은 권한 부여 체계를 어떻게 만들고 전달할지의 틀을 잘 정의하지만, 그것이 무엇이어야 하는지에는 의도적으로 침묵함. “대부분”의 제공자가 동의하는 공통 HTTP 엔드포인트 묶음을 얻는 데도 OIDC 발명과 여러 해가 필요했음
- 사소한 정정: 원문 번호를 다시 확인해 보니 RFC 8628임
-
또 다른 해킹은 서버의 xdg-open 호출을 노트북으로 포워딩하는 것임. 개인 인프라용으로 그걸 해주는 작은 도구를 만들었음: https://github.com/zimbatm/subportal/
-
두 접근을 결합하면 안 되나?
localhostURL로 리다이렉트하고hello를 되돌려 보내게 한 뒤, 클라이언트가hello를 못 받으면 CLI에 URL을 출력하는 방식임
동시에 서버가 보낸hello의 응답을 받지 못하면 브라우저에 코드를 띄우고 “로그인을 시도 중인지 확인하라” 같은 메시지를 보여주면 됨. Google처럼 휴대폰에서 선택할 숫자를 표시하는 식으로 더 쉽게 만들 수도 있음cli -> server/auth?r=localhost&fallback_choices=10,20,30 server -> localhost/hello Case 1: hello request received, go to redirect URI on localhost Case 2: server has not received a hello reply, client has not received a hello request - CLI displays a/the webpage url and prompts for selecting a fallback_choice - Webpage displays a number say `20` from choices - Warn in the webpage not to share this code - User enters/selects it on the CLI - solves the token copy/paste problem if choices장점은 2번 경우에도 사람들이 링크 클릭은 쉽게 하지만 OTP/코드 공유는 상대적으로 덜 한다는 점이고, 공격자가 해킹 중에 계속 사회공학적으로 개입해야 한다는 점임
-
로컬 머신에서 잘 동작할 때는 상호작용이 필요 없으니, 기본값은 브라우저 기반 흐름이었으면 좋겠음
- 이 흐름도 잘 동작하는 경우에는 브라우저 기반으로 작동함. 다만 실패했을 때 더 나은 대체 경로가 있음