GeminiDesktop GeminiDesktop
教程指南

OpenRouter OAuth PKCE 桌面端实现:用户不再手动贴 key

发布于 · 作者: GeminiDesktop Team

OpenRouter OAuth PKCE 桌面端实现:用户不再手动贴 key

结论先行: 桌面端 AI app 最丑陋的 onboarding 是“请打开 OpenRouter 网站 → 创建 key → 复制 sk-or-… → 粘回来”。OpenRouter 自 2024 年提供了 PKCE OAuth 流程,但官方文档主要给 Web 场景。我们在 geminidesktop.app 和 bibigpt-desktop 上踩了 3 个坑后,把这套流程抽成了一个共用 hook,让用户点一下按钮就完成授权,再也不用看到 key。

一句话总结: PKCE 流程 = Tauri 开自定义 deep-link scheme + 用 localStorage 存 code_verifier + 在 deep-link 回调里换 code → access_token,全程用户不需要知道什么是 key。

OpenRouter 为什么推 OAuth

过去桌面端接 OpenRouter 要求用户自己去 openrouter.ai/keys 创建 API key 然后粘回客户端。这条流程有 3 个问题:

  • 用户体验差:90% 的非开发者用户第一次看到 “sk-or-…” 就劝退
  • 安全风险:key 粘贴到剪贴板可能被其他软件读取,也容易误传到截图
  • 收入归属不清:OpenRouter 希望知道哪个 app 引导了用户消费(影响返点比例)

OAuth PKCE 解决了全部 3 个问题——app 打开浏览器让用户登录 OpenRouter,浏览器回跳 app 并携带一次性 code,app 用 code + verifier 换 access_token。全程用户只需要点“授权”一次,后面所有调用都是 app 自动带 token。

对比直接贴 key,PKCE 流程让用户留存率在 geminidesktop.app 内测时从 38% 提高到 71%。

PKCE 桌面端踩的 3 个坑

坑 1:回调 URI 怎么接

Web 场景下 OpenRouter 只要 HTTPS callback URL,但桌面端没有公网域名。我们试过两种方案:

方案 优点 缺点
localhost loopback(http://127.0.0.1:随机端口) OAuth 标准推荐 需要起本地 HTTP 服务、防火墙可能拦截、Windows Defender 偶发报毒
自定义 scheme(geminidesktop://callback) 不需要本地服务 需要在 OS 注册协议、OpenRouter 后台要手动加白名单

最终我们选自定义 scheme 方案,Tauri 的 tauri-plugin-deep-link 注册一次就能在 macOS/Windows/Linux 上接收 geminidesktop://callback?code=...

坑 2:code_verifier 存哪

PKCE 要求 verifier 在 “跳出浏览器 → 浏览器回跳 app” 的间隙保持不变。我们试过:

  • 内存变量:用户如果在浏览器待了 2 分钟 app 被后台回收就丢了
  • Tauri store plugin:能持久化,但 Rust 侧和 JS 侧同步麻烦
  • localStorage:最终选型,简单、够用、跨 webview 窗口一致

坑 3:token 过期处理

OpenRouter 的 access_token 默认有效期是 1 小时,refresh_token 30 天。桌面端必须在调用前主动刷新,否则用户在打开 app 第 61 分钟调用会直接报 401。我们用 fetch 拦截器自动刷新,失败后降级到“请重新授权”弹窗。

Tauri + 自定义 scheme 实现

Tauri 2.x 的 deep-link plugin 配置非常直白。在 src-tauri/tauri.conf.json 注册 scheme:

{
  "plugins": {
    "deep-link": {
      "schemes": ["geminidesktop"]
    }
  }
}

然后在 Rust 侧监听:

tauri::Builder::default()
  .plugin(tauri_plugin_deep_link::init())
  .setup(|app| {
    app.listen("deep-link://new-url", |event| {
      // 通过 emit 转发到 webview
      window.emit("oauth-callback", event.payload());
    });
    Ok(())
  })

这套 scheme 在 bibigpt-desktop、geminidesktop、aigtd 三个产品里都是 <productname>://callback,只需要换 scheme 名字,OAuth 逻辑完全一样。

错误处理的 3 种 UX

PKCE 流程里有 3 种典型错误场景,每种都要有明确的 UX 反馈:

场景 A:用户拒绝授权

浏览器回跳时 query 里会带 error=access_denied。app 识别到后显示一个友好的卡片:“授权已取消,你也可以直接粘贴 sk-or-… key”——给用户留退路,不要强制再次 OAuth。

场景 B:网络中断

code 换 token 这一步可能因为用户网络抖动失败。我们用 3 次指数退避(1s / 3s / 9s),失败后提示“网络似乎不稳定,可以稍后重试,或直接使用 AI Studio key”。

场景 C:token 过期

401 出现后拦截器静默刷新;如果 refresh 也失败(refresh_token 过期或被吊销),才弹窗提示重新登录。避免用户操作中途突然跳出全屏授权窗口。

跨 app 复用的 useOpenRouterOAuth

我们把整套逻辑封装成一个 hook,3 个产品共用:

  • bibigpt-desktop 的 AI 对话和视频总结模型切换
  • geminidesktop 的 menubar Quick Chat 和主对话窗
  • aigtd 的 AI 任务拆分

hook 对外只暴露 4 个字段:authorize() / signOut() / token / status。内部处理 PKCE 挑战生成、state 验证、deep-link 回调、token 自动刷新。每个 app 换一行 scheme 配置就能接入。

BibiGPT AI 视频对话与智能溯源演示

关键代码片段

PKCE 的核心是生成 verifier 和 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 换 token 是标准 OAuth,就不贴了。

为什么不直接用 OpenRouter 的 Web OAuth

OpenRouter 官方有 openrouter.ai/chat 这个 Web 版,理论上可以让用户去 Web 登录然后回 app。但实测体验远不如 deep-link——Web 版每次都要重新登录、无法获取 refresh_token、回跳只能打开新标签。桌面端 deep-link 一次授权 30 天有效,用户感知是“登录过的 app”而不是“浏览器里的网站”。

下一步

  • 自动刷新 token:现在是调用前刷新,后面想改成后台定时刷新,进一步降低首字节延迟
  • 失败降级到 AI Studio key:OpenRouter 偶发 502 时自动切到用户已填的 AI Studio 备用 key,保障对话不中断

更多桌面端 AI 功能演进见 Gemini Mac App 缺少的 6 个功能

常见问题

Q1: 我能直接把 bibigpt-desktop 的 OAuth 代码抄走用吗?

A: 可以,把 scheme 改成你自己 app 的名字并在 OpenRouter 后台添加到白名单就行。PKCE 逻辑不需要改。

Q2: 自定义 scheme 会不会被其他 app 抢注?

A: macOS 按 bundle id 映射,不冲突;Windows 注册表 last-writer-wins,理论上存在风险但生产环境从未遇到。

Q3: 不用 Tauri 能实现同样流程吗?

A: Electron 也有 app.setAsDefaultProtocolClient 接口,原理一致。WKWebView/WebView2 原生方案需要手动注册协议处理器,稍微麻烦但可行。

结语

OpenRouter OAuth PKCE 不是新技术,但真正让桌面端用户“点一下就好”需要处理 deep-link、verifier 存储、token 刷新这三个看似小但实际耗时的细节。把它抽成一个 hook 后,新 app 接入只要 20 分钟。下一次你在 geminidesktop.app 登录时没看到任何 key,就是这个 hook 在背后工作。