OpenRouter OAuth PKCE 데스크톱 구현 2026: 더 이상 키를 수동으로 붙여넣지 않는다
OpenRouter OAuth PKCE 데스크톱 구현 2026: 더 이상 키를 수동으로 붙여넣지 않는다
결론부터 말하면: 데스크톱 AI 앱의 가장 못생긴 온보딩은 “OpenRouter 사이트에서 키 만들기 → sk-or-… 복사 → 앱에 붙여넣기”입니다. OpenRouter는 2024년부터 PKCE OAuth 플로우를 제공하고 있지만, 공식 문서는 주로 웹 케이스를 다룹니다. geminidesktop.app과 bibigpt-desktop에 붙이면서 세 가지 함정을 밟은 뒤, 전체 로직을 하나의 공유 훅으로 추출해 사용자가 클릭 한 번으로 인증을 끝낼 수 있게 만들었습니다. 이제는 키를 볼 일이 없습니다.
한 줄 요약: PKCE 플로우 = Tauri 커스텀 deep-link 스킴 + localStorage에 code_verifier 저장 + 콜백에서 code → access_token 교환. 사용자는 “키”라는 개념을 몰라도 됩니다.
OpenRouter가 OAuth를 미는 이유
예전에는 openrouter.ai/keys에서 API 키를 만들어 클라이언트에 붙여넣는 플로우였습니다. 문제는 세 가지.
- UX가 나쁨: 개발자가 아닌 사용자의 90%는 “sk-or-…“를 보는 순간 이탈
- 보안 리스크: 키가 클립보드에 실려 다른 앱에 읽히거나 스크린샷으로 유출될 수 있음
- 어트리뷰션 부재: OpenRouter는 “어느 앱을 통해 결제했는지” 알고 싶어 함 (레베뉴 셰어에 영향)
OAuth PKCE가 세 가지를 모두 해결합니다. 앱이 브라우저를 열고, 사용자가 OpenRouter에 로그인하면 브라우저가 일회용 코드를 돌려주고, 앱이 code + verifier로 access_token을 교환합니다. 클릭 한 번이면 끝이고 키는 화면에 노출되지 않습니다.
geminidesktop.app 클로즈 베타에서 키 붙여넣기를 PKCE로 바꾼 뒤 7일 리텐션이 38%에서 71%로 올랐습니다.
세 가지 함정
함정 1: 콜백 URI 설계
웹에서는 HTTPS 콜백만 되면 되지만, 데스크톱 앱에는 공개 URL이 없습니다. 선택지는 두 가지.
| 방식 | 장점 | 단점 |
|---|---|---|
| localhost loopback (http://127.0.0.1:임의포트) | OAuth 표준 권장 | 로컬 HTTP 서버 필요, 방화벽이 종종 차단, Windows Defender가 간혹 오탐 |
| 커스텀 스킴 (geminidesktop://callback) | 서버 불필요 | OS에 스킴 등록 필요, OpenRouter 측에서 리다이렉트 URI 화이트리스트에 등록 |
최종적으로 커스텀 스킴을 선택했습니다. Tauri의 tauri-plugin-deep-link을 한 번 등록하면 macOS/Windows/Linux에서 geminidesktop://callback?code=...를 받을 수 있습니다.
함정 2: code_verifier를 어디에 저장할 것인가
PKCE는 “브라우저로 점프 → 돌아오기” 사이에 verifier를 유지해야 합니다. 시도한 것들:
- 메모리 변수: 앱이 2분 정도 백그라운드에 있으면 날아감
- Tauri store plugin: 영속화는 되지만
Rust <-> JS동기화가 번거로움 - localStorage: 최종 채택. 단순하고 영속적이며 웹뷰 창 간에도 공유됨
함정 3: 토큰 만료 처리
OpenRouter의 access_token 수명은 1시간, refresh_token은 30일입니다. 데스크톱에서는 호출 전에 능동적으로 갱신하지 않으면 61분째 호출한 사용자가 401을 맞습니다. fetch 인터셉터로 자동 갱신하고, refresh 자체가 실패할 때만 “재인증이 필요합니다” 모달을 띄웁니다.
Tauri + 커스텀 스킴 구현
Tauri 2.x는 거의 허무할 정도로 간단합니다. src-tauri/tauri.conf.json에 등록:
{
"plugins": {
"deep-link": {
"schemes": ["geminidesktop"]
}
}
}
Rust에서 리슨하고 웹뷰로 전달:
tauri::Builder::default()
.plugin(tauri_plugin_deep_link::init())
.setup(|app| {
app.listen("deep-link://new-url", |event| {
window.emit("oauth-callback", event.payload());
});
Ok(())
})
bibigpt-desktop, geminidesktop, aigtd 모두 스킴은 <product>://callback 패턴입니다. OAuth 로직 본체는 공유이고 스킴 이름만 바뀝니다.
에러 처리의 세 가지 UX
케이스 A: 사용자가 인증 거부
브라우저가 error=access_denied를 반환합니다. 앱은 부드러운 카드를 보여줍니다: “인증이 취소되었어요. sk-or-… 키를 직접 붙여넣어도 됩니다.” 강제로 OAuth를 다시 시도시키지 말 것.
케이스 B: 네트워크 순간 장애
code → token 교환은 불안정한 네트워크에서 실패합니다. 1s / 3s / 9s 지수 백오프로 3회 재시도. 최종 실패 시 “네트워크가 불안정합니다. 나중에 다시 시도하거나 AI Studio 키를 사용하세요”로 안내.
케이스 C: 사용 중 토큰 만료
fetch 인터셉터가 401을 받으면 조용히 refresh. refresh 자체가 실패한 경우 (refresh_token 만료 또는 취소)에만 재인증 모달. 타이핑 도중에 전체 화면 로그인 창이 뜨는 일은 없어야 합니다.
세 제품에서 공유하는 useOpenRouterOAuth
이 로직은 하나의 훅으로 수렴되어 세 제품에서 공유됩니다.
bibigpt-desktop: 채팅과 영상 요약의 모델 전환- geminidesktop: Menubar Quick Chat과 메인 채팅 윈도우
- aigtd: AI 기반 태스크 분해
훅이 외부에 노출하는 필드는 네 개뿐: authorize() / signOut() / token / status. 내부에서 PKCE 챌린지 생성, state 검증, deep-link 콜백, 토큰 갱신을 전부 처리합니다. 새 앱에 붙이는 건 스킴 설정 한 줄 교체뿐.

핵심 10 라인
PKCE의 본체는 verifier와 SHA-256 challenge 생성이 전부이고, 실제로 10 줄 정도입니다:
const verifier = btoa(crypto.getRandomValues(new Uint8Array(32))
.reduce((s, b) => s + String.fromCharCode(b), ''))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
const challenge = await crypto.subtle.digest('SHA-256',
new TextEncoder().encode(verifier))
.then(buf => btoa(String.fromCharCode(...new Uint8Array(buf))))
.then(s => s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''))
localStorage.setItem('or_pkce_verifier', verifier)
localStorage.setItem('or_pkce_state', crypto.randomUUID())
window.open(`https://openrouter.ai/auth?code_challenge=${challenge}&...`)
콜백 측에서는 state를 검증해 CSRF를 막고 verifier로 코드를 토큰과 교환합니다. 표준 OAuth라 생략.
왜 OpenRouter 웹 OAuth를 쓰지 않는가
OpenRouter에는 openrouter.ai/chat이라는 웹 버전이 있어 이론상 사용자를 그쪽으로 보낼 수도 있습니다. 하지만 실제 UX는 deep-link에 명백히 밀립니다 — 웹 버전은 매번 로그인, refresh_token 획득 불가, 리다이렉트는 항상 새 탭. Deep-link는 “설치한 앱에 로그인”하는 감각이고 세션도 30일 유지됩니다.
다음 단계
- 백그라운드 토큰 갱신: 현재는 호출 시 갱신. 정기 갱신으로 첫 바이트 지연을 더 줄이기
- AI Studio 키로의 폴백: OpenRouter가 502를 반환하면 미리 설정한 AI Studio 키로 조용히 전환해 대화가 끊기지 않게
데스크톱 AI 기능 설계 노트는 Gemini Mac 앱에 빠진 6가지 기능에서도 다룹니다.
자주 묻는 질문
Q1: bibigpt-desktop의 OAuth 코드를 그대로 가져다 써도 되나요?
A: 네. 스킴 이름을 내 앱에 맞게 바꾸고 OpenRouter 대시보드에 리다이렉트 URI를 추가하면 됩니다. PKCE 로직은 수정 불필요.
Q2: 커스텀 스킴이 다른 앱에 탈취될 수 있나요?
A: macOS는 bundle ID에 바인딩되어 충돌이 없습니다. Windows는 레지스트리 last-writer-wins로 이론상 리스크는 있지만 프로덕션에서 겪은 적은 없습니다.
Q3: Tauri 없이도 같은 플로우를 만들 수 있나요?
A: Electron에는 app.setAsDefaultProtocolClient가 있어 원리가 같습니다. 네이티브 WKWebView/WebView2 앱은 프로토콜 핸들러를 수동으로 등록해야 하지만 역시 가능합니다.
마무리
OpenRouter OAuth PKCE는 새로운 기술이 아니지만, 데스크톱 사용자에게 “클릭 한 번이면 끝”이라는 경험을 주려면 deep-link, verifier 저장, 토큰 갱신 세 가지를 제대로 설계해야 합니다. 한 번 공유 훅으로 묶어두면 새 앱에 붙이는 건 20분짜리 작업입니다. 다음번 geminidesktop.app을 열어 키를 한 번도 보지 않고 사용한다면, 그건 이 훅이 뒤에서 돌아가고 있기 때문입니다.