OpenRouter OAuth PKCE 桌面端实现:用户不再手动贴 key
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 配置就能接入。

关键代码片段
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 在背后工作。