GeminiDesktop GeminiDesktop
가이드

OpenRouter OAuth PKCE 데스크톱 구현 2026: 더 이상 키를 수동으로 붙여넣지 않는다

게시일 · 작성자: GeminiDesktop Team

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 콜백, 토큰 갱신을 전부 처리합니다. 새 앱에 붙이는 건 스킴 설정 한 줄 교체뿐.

BibiGPT AI 영상 대화 및 출처 추적 시연

핵심 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을 열어 키를 한 번도 보지 않고 사용한다면, 그건 이 훅이 뒤에서 돌아가고 있기 때문입니다.