AI 동반자를 위한 키리스 OAuth 로그인 구현: PKCE, 단일 사용 루프백 콜백 및 디스크에 저장되지 않는 토큰
요약
본 글은 AI 동반자 애플리케이션을 위한 키리스(keyless) OAuth 로그인 구현 방법을 상세히 다룹니다. 클라이언트 시크릿이나 사용자 개입 없이, PKCE와 단일 사용 루프백 콜백을 활용하여 안전하고 간편한 인증 흐름을 구축하는 것이 핵심입니다. 이 방식은 앱 자체가 클라이언트로 등록되고, 브라우저를 통해 로그인하며, 어떤 비밀 정보도 디스크에 저장되지 않아 보안성이 높습니다.
핵심 포인트
- 키리스(Keyless) OAuth 구현으로 사용자 편의성 극대화
- PKCE와 단일 사용 루프백 콜백을 활용한 강력한 인증 메커니즘
- 클라이언트 시크릿 없이 앱이 클라이언트로 등록 가능 (Dynamic client registration)
- 로그인 성공 후 리스너를 종료하여 보안 강화
나의 AI 동반자를 위한 키리스(keyless) OAuth 로그인 구현 방법: PKCE, 단일 사용 루프백 콜백, 그리고 디스크에 저장되지 않는 토큰
Ankita는 제가 개발한 오픈 소스 데스크톱 AI 동반자입니다. a little companion, a lot more possible. 이 동반자가 진정으로 유용하려면 제 애플리케이션 내에서 작동해야 합니다: 스레드를 읽거나, 이메일을 보내거나, 일정을 잡는 식입니다. 이 모든 과정은 Composio의 MCP 서버를 거치며, 해당 서버는 인증에 대한 의견을 가지고 있습니다. 즉, API 키 자체를 거부합니다. 프로젝트 키(ak_), CLI 키(uak_), 그리고 x-consumer-api-key 헤더 모두 "유효하지 않은 AuthKit JWT"라는 메시지와 함께 401 에러를 반환합니다. 이 서버가 수락하는 유일한 자격 증명은 표준 MCP OAuth 흐름을 통해 발행된 Bearer 토큰입니다.
이로 인해 저는 16세 청소년 사용자들이 해결하고 싶어 하지 않는 문제를 안게 되었습니다: 아무도 개발자 콘솔에 접속하여 OAuth 클라이언트를 등록하고 그곳에서 얻은 클라이언트 시크릿(client secret)을 제 앱에 붙여넣으려 하지 않을 것이기 때문입니다. 로그인은 키리스(keyless)여야 했습니다. 즉, 앱 자체가 클라이언트로 자신을 등록하고, 사용자가 자신의 브라우저를 통해 로그인하며, 어떤 비밀 정보도 디스크에 저장되지 않아야 합니다. 이 모든 과정은 단 하나의 파일, src/integrations/composio-oauth.mjs (352줄)로 구현되었습니다.
흐름: 발견(discovery)부터 볼트(vault)까지 5단계
discovery → dynamic client registration → browser authorize
→ single-use loopback callback → PKCE code exchange → vault grant
호스팅할 필요가 없습니다. 이것이 전체 구현의 핵심 규칙입니다. 리디렉션 URI는 127.0.0.1에 위치한 루프백 리스너(loopback listener)이며, 단 한 번의 로그인 기간 동안만 존재합니다 (RFC 8252). 공개 콜백 주소도, 도메인도, 인증서도, 브로커도 없습니다. 이 리스너는 선호 포트(5757)에 바인딩하며, 만약 해당 포트를 사용할 수 없다면 OS가 할당한 다른 포트로 대체합니다. 모든 종료 경로—성공, 상태 불일치(state mismatch), 사용자 거부, 5분간의 "사람이 직접 로그인하는" 시간 초과—에서는 서버를 종료하여 로그인이 끝난 후에는 아무것도 계속 리스닝하지 않도록 합니다. 하나의 코드, 한 번 사용, 그리고 침묵입니다.
클라이언트는 공개적입니다. 동적 클라이언트 등록(Dynamic client registration)은 token_endpoint_auth_method: "none"을 전송하며, 클라이언트 시크릿(client secret)은 발행되거나 저장되지 않습니다. PKCE S256 챌린지 자체가 코드를 위한 전체 방어 수단입니다. 검증자(verifier)는 crypto.randomBytes에서 나온 32바이트이며, 챌린지는 그 SHA-256 값이고, 콜백 리스너(callback listener)는 다른 작업을 수행하기 전에 반드시 state 매개변수를 확인합니다. 상태가 일치하지 않으면 일반 텍스트의 400 에러를 받고 어떠한 권한 부여(grant)도 작성되지 않습니다. 이는 직접적으로 테스트됩니다. 테스트 중 하나는 문자 그대로 "잘못된 상태로 호출되면 거부되고 권한 부여가 작성되지 않음"입니다.
토큰은 정확한 서버에 바인딩됩니다. MCP 인증 사양(authorization spec)은 토큰이 사용될 대상 MCP 서버 URL, 즉 RFC 8707의 resource 지표를 승인 요청과 토큰 요청 모두에 요구합니다. 따라서 인증 서버는 토큰의 오디언스(audience)를 해당 단일 서버에 바인딩하며, 이 없이 발행된 토큰은 서버에서 거부됩니다. 코드 주석에는 명확하게 나와 있습니다: "이를 생략하는 것이 승인된 로그인과 인증 서버로부터의 400 에러 사이의 차이점입니다." 제가 이것을 알게 된 것은 처음에 잘못 구현했기 때문입니다.
제가 하드코딩한 보안 경계
네트워크 호출이 발생하기 전에, 흐름 내의 모든 URL은 assertTrustedEndpoint를 통과합니다. 프로덕션 환경에서는 반드시 *.composio.dev의 https여야 하며, 사용자 정보(userinfo)에 자격 증명(credentials)을 포함해서는 안 됩니다. 또한 루프백 URL은 테스트용 또는 사용자가 승인한 자체 호스팅 인증 서버에만 허용되며, 기본적으로 비활성화되어 있습니다. 헤더 주석은 의도를 명시합니다: 툴 출력이나 리디렉션 체인 내부로 도착하는 URL은 절대로 라이브 연결이 될 수 없습니다. 웹을 탐색하는 컴패니언(companion)은 결국 웹페이지로부터 악성 URL을 보여주게 되지만, 이 허용 목록(allowlist) 덕분에 해당 URL이 OAuth 핸드셰이크로 전환되는 일은 결코 없을 것입니다.
토큰은 절대로 금고를 벗어나지 않습니다. 디스크에 있는 그랜트 파일에는 비밀이 담겨 있지 않습니다. 단지 grantId, 엔드포인트, 연결된 앱 목록, 그리고 타임스탬프만 있습니다. 실제 토큰은 주입된 시크릿 스토어를 통해 OS 키체인으로 전달됩니다. withToken은 평문(plaintext) 토큰을 콜백에 넘겨주고 절대 반환하지 않으며, 이는 기존의 SecureStore 계약과 일치합니다. 토큰 취소는 하나의 동작입니다: 금고에 보관된 토큰을 버리고 메타데이터를 잊어버리는 것입니다. 이 스토어는 모든 변경(mutation) 전에 디스크에서 다시 읽어옵니다 (_fresh()). 이는 코드베이스의 다른 모든 스토어가 사용하는 패턴이며, 따라서 데스크톱 엔진과 채팅 도구가 동일한 로그인에 대해 두 가지 버전을 갖게 되는 것을 방지합니다.
제가 좋아하는 실용적인 세부 사항이 있습니다: openUrl은 주입됩니다. 데스크톱은 Electron의 shell.openExternal을 전달하고, 테스트는 캡처 함수를 전달합니다. 전체 연결 플로우는 테스트 스위트에서 주입된 fetch와 URL 열기를 대상으로 실행됩니다 —
이 레포지토리는 오픈 소스(MIT)이며 https://github.com/akyourowngames/A.N.K.I.T.A에서 확인할 수 있습니다. 상태 처리나 루프백 라이프사이클을 직접 확인하고 싶다면 src/integrations/composio-oauth.mjs를 처음부터 끝까지 읽어볼 수 있습니다. 어디까지가 한계라고 생각하시나요?
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기