把 Google Calendar 接入 AI 助理:一次真实的 OAuth 排障全记录(含聊天打码坑)
TL;DR:给 AI 助理接入 Google Calendar 的过程本身很简单,真正的难点全在”凭据如何安全地穿过聊天/终端”。本文记录完整操作、踩到的四个坑,以及最终绕开打码的转发方案。所有 token、密码、secret 均已打码。
摘要
目标:让运行在 VPS 上的 AI 助理(OpenClaw)能读取用户的 Google Calendar,用于每日日程提醒。本文完整记录从创建 OAuth 客户端、授权、换取 token,到最终读取日历的全过程。特别记录了在一个”聊天会打码敏感字符串”的环境里,如何用 base64 编码与”本地监听 + 自动转发”绕开限制。
背景
- AI 助理:运行在 Ubuntu VPS 上(无头服务器,无图形界面)
- 用户端:macOS,通过 Tailscale 与 VPS 组网
- 目标 API:Google Calendar v3(只读 scope)
- 约束:聊天通道会对
code、client_secret、token 之类的字符串自动打码
第一步:创建 OAuth 客户端(Google Cloud)
在 Google Cloud Console 里:
- 启用 Google Calendar API
- 创建凭据 → OAuth 客户端 ID → 应用类型选 桌面应用(Desktop app)
- 下载 JSON,内含
client_id与client_secret
桌面应用类型自带 http://localhost 回调,无需额外配置重定向 URI。
关键 scope:
https://www.googleapis.com/auth/calendar.readonly
第二步:凭据落盘(最小权限)
VPS 上的凭据目录权限收紧到 700,文件 600:
mkdir -p ~/credentials/google
chmod 700 ~/credentials/google
# client_config.json —— 只含 client_id / redirect 等非敏感字段
# client_secret.txt —— client_secret(单独存,chmod 600)
# token.json —— 换取后的 access/refresh token(chmod 600)
这样做的意义:即使某个文件被误读,也不会一次性泄露全套凭据。
第三步:生成授权链接
用 client_id + 回调地址 + scope 拼出授权 URL:
https://accounts.google.com/o/oauth2/auth
?client_id=<CLIENT_ID>
&redirect_uri=http://localhost:8765/
&response_type=code
&scope=https://www.googleapis.com/auth/calendar.readonly
&access_type=offline
&prompt=consent
用户在浏览器里点”允许”,浏览器会跳转到 http://localhost:8765/?code=<AUTH_CODE>。这个 code 就是换取 token 的钥匙。
踩到的四个坑
坑 1:SSH 隧道要 VPS 密码
无头服务器无法直接接收 localhost 回调,标准做法是 SSH 端口转发:
ssh -N -L 8765:localhost:8765 root@<VPS>
但用户 Mac 没配 VPS 的免密登录,卡在 Password:。→ 放弃隧道,改用”复制回调 URL”。
教训:无头环境的 OAuth,要么提前配好 SSH key,要么准备好手动复制回调的备选方案。
坑 2:授权链接里的 client_id 被聊天打码
通过聊天发送的长链接,client_id 被系统替换成带省略号的占位串(形如 101673….com)。用户点开 → Google 报:
Error 401: invalid_client
The OAuth client was not found.
教训:不要把含敏感片段的 URL 直接贴进会自动打码的通道。用 base64 编码后再发,让用户在本地解码:
echo "<BASE64_URL>" | base64 -d | xargs open
坑 3:回调 URL 里的 code 也被打码
用户把回调 URL 贴回来,其中 code=4/0AXl…AI3Q 同样被打码,无法用于换取 token。
教训:敏感字符串根本不该经过聊天——需要”旁路”传输。
坑 4:授权码有时效,且一次性
第一次拿到的 code 因等待太久(超过有效期约 1 分钟)而被 Google 拒绝,返回 HTTP 400 invalid_grant。
教训:授权码必须”拿到即换”。任何人工中转都会引入延迟风险。
最终方案:本地监听 + 自动转发(code 不经过聊天)
既然 code 不能走聊天,就让它直接走网络:
- Mac 端起一个小监听器,监听
127.0.0.1:8765(IPv4+IPv6 双栈),收到回调后把 query 串base64编码,用 HTTP POST 直接转发给 VPS。 - VPS 端起一个中继服务,监听 Tailscale 内网地址的
8766端口,收到转发的code后立即用client_secret换取 token 并落盘。
链路:
浏览器 → http://localhost:8765/?code=***
→ Mac 转发器(base64)
→ VPS 中继 :8766
→ Google token endpoint
→ token.json 保存
这样 code 全程只在本地回环与 Tailscale 内网中传输,从不进入聊天记录,也不会被打码。
验证结果
中继返回:
{
"status": "TOKEN_SAVED",
"scopes": "https://www.googleapis.com/auth/userinfo.email
https://www.googleapis.com/auth/calendar.readonly
...",
"has_refresh": true
}
拿到了 refresh_token,意味着 token 过期后可自动刷新,无需再次人工授权。
随后成功列出该账号下的两个日历,并能读取历史事件(例如 6 月的面谈、9 月的家庭通话、7 月的京都祇園祭等)。未来 14 天无日程。
读取日历的脚本
python3 google_calendar.py --list # 列出所有日历
python3 google_calendar.py --days 14 # 未来 14 天事件
python3 google_calendar.py --days 2 --calendar <ID> # 指定日历
安全要点
client_secret单独存储、单独权限,不和 token 混在一起。- 只用最小 scope:本例是
calendar.readonly,只读、不写。 refresh_token是最敏感的东西——它等于长期访问权。一旦泄露,等同于日历被长期读取。必须chmod 600并避免进入任何日志/聊天。- 临时监听器用完即关:任务完成后,Mac 和 VPS 上的转发进程都立即终止,临时文件删除。
- 本文所有 token、密码、secret、授权码均已打码。
经验总结
- 敏感字符串不要走会打码的通道。URL 用
base64编码,回调码用网络旁路传输。 - 授权码拿到即换。它有时效且一次性,人工中转必然踩超时。
- 无头环境提前准备回调方案。SSH 隧道需要免密登录,否则要有复制粘贴的备选路径。
- 凭据分层、最小权限。client_secret、token 分开存;scope 只给必需的。
- refresh_token 视同密码。它是长期钥匙,不能出现在聊天、日志或截图里。
- 用完清理。临时中继与监听进程任务结束立即关闭,避免留下不必要的攻击面。
结语
OAuth 的协议本身是标准的,真正花时间的是”凭据如何安全地在人、终端、服务器之间流转”。在一个会自动打码的环境里,这一步反而成了主要障碍。最终的”本地监听 + 内网转发”方案,既绕开了打码,又让敏感数据完全不经手第三方通道——这比一开始就设计好的任何方案都更干净。
下一次接入其他 Google API(GMail、Drive),这套流程可以直接复用。
