用户登录态和仓库访问凭据,是两条不能互相替代的认证链路。让 App 以”用户身份”登录没问题,可一旦要自动提交 PR、读取私有仓库,就得拿到一个能代表”App 对某仓库”的凭据。一开始我把所有请求都塞给 User Access Token,结果用户撤销授权后整个自动化停摆,限流也经常把翻译任务卡死。后来把两类 Token 的边界理清楚——User Access Token 代表用户,Installation Access Token 代表 App 在某安装上的访问能力——权限边界才算真正画对。GitHub App 涉及两类 Token:User Access Token(代表用户身份)与 Installation Access Token(代表应用对仓库的访问)。理解两者生成方式、生命周期与适用场景,是把权限边界设计清楚的前提。
一、两类 Token 的对比
先给一个整体印象。把两类 Token 并排看,差别集中在身份、生成方式和失效条件上,下面从 6 个维度对比:
| 维度 | User Access Token | Installation Access Token |
|---|---|---|
| 身份 | 用户 | App 在某安装 |
| 生成方式 | OAuth 授权 | 用私钥签 JWT 换 |
| 有效期 | 默认较短,可刷新 | 默认 1 小时 |
| 权限 | 用户授权范围 | App 安装时授予 |
| 用途 | 读用户信息、安装列表 | 读仓库、提交 PR |
| 失效条件 | 用户撤销授权 | 安装被卸载 / 过期 |
一句话总结:登录、读用户信息走 User Access Token,凡是”代表 App 去操作仓库”的动作都走 Installation Access Token,两者各管一段,互不掺和。这个划分我在项目里沿用至今,接口权限表也是照着它画的。
二、User Access Token
User Access Token 由 OAuth 流程产生,常见场景是”登录”。为什么用户侧要用它,而不是让 App 直接拿仓库权限?因为授权范围是用户自己勾选的,用户能明确知道”这个 App 能读我的哪些信息”,撤销时也立刻生效,这对一个处理用户私人仓库的工具很关键。我一开始想省事,让用户装好 App 就完事,结果连”他是谁”都不知道,管理后台根本没法做。补上 OAuth 登录后,身份、安装列表、仓库列表这些信息才齐全。
拿到 token 的完整链路是四步:
- 用户点登录,跳转 GitHub OAuth;
- 用户同意授权范围(如
read:user、user:email); - GitHub 返回 code,App 用 code + client secret 换 access token;
- 拿到 access token 后,可调
GET /user获取用户信息。
这里踩过一个坑:最开始把 code 换 token 的请求放在前端做,client secret 差点暴露,后来全部挪到服务端,前端只拿最终的登录态。User Access Token 不适合直接读仓库:它的权限继承自用户,且速率限制受用户其它行为影响。翻译任务是定时批量跑的,用户个人的限流配额根本扛不住。
三、Installation Access Token
Installation Access Token 是”App 访问某仓库”的凭据,必须服务端生成。为什么必须服务端?因为要用 App 私钥签名,私钥绝不能出现在客户端代码里,一旦泄露等于把整个 App 的仓库访问权限都交出去了。
3.1 生成步骤
生成流程看起来只有几步,但每步都有讲究:
1. 用 App 私钥签一个 JWT(10 分钟内有效)
2. 用 JWT 调 POST /app/installations/{id}/access_tokens
3. 拿到 token 和过期时间
4. token 接近过期前重新生成
JWT 有效期卡在 10 分钟内,是因为它只承担”换取 token”这一个使命,生命周期越短越安全。我最初把 JWT 的有效期设成 1 小时,换来换去没问题,但一旦 JWT 泄露,攻击者能在一个小时内随便换 token,后来收紧到 10 分钟,风险窗口小了很多。
3.2 JWT 构造
JWT 本身用 RS256 签名,关键是把时间戳和 App ID 填对:
header: { "alg": "RS256", "typ": "JWT" }
payload:
iat: 当前时间 - 60s
exp: 当前时间 + 600s
iss: App ID
私钥用 RS256 签名。生成 JWT 后用 Authorization: Bearer <jwt> 调换 token 接口。iat 往前拨 60 秒是为了容忍服务器与 GitHub 之间的时钟偏差,这一点不处理,认证会偶发报错,排查起来很费时间。
3.3 缓存
由于 1 小时过期,服务端要缓存。每次翻译任务要处理几十个仓库,每个仓库都现场换 token,不仅慢,还容易撞上速率限制,所以缓存是必选项:
- Redis key:
gh_install_token:{installation_id}; - TTL: 50 分钟(留 10 分钟余量);
- 命中即返回,未命中则重新生成。
把 TTL 定在 50 分钟而不是 60,是我踩过坑后调出来的:卡在整点过期,缓存和真实过期时间贴得太近,一旦 Redis 与 GitHub 时钟有偏差,就会偶发 401。留 10 分钟余量后,这类问题再没出现过。
四、本项目的使用划分
光理解两类 Token 还不够,落地时得有一张明确的”场景到 Token”对照表,避免新同事把 token 用串:
| 场景 | 用哪个 Token |
|---|---|
| 读取用户信息 | User Access Token |
| 列出用户已安装的 App | User Access Token |
| 读取仓库元数据 | Installation Access Token |
| 提交分支/PR | Installation Access Token |
| 接收 push 事件 | Webhook(无需 Token) |
这张表定下来之后,权限审计变得很轻松:凡是仓库写入动作,代码里只允许出现 Installation Token;凡是身份相关,只允许 User Access Token。团队做代码 review 时直接按表对号入座,越界的基本一眼能看出来。
五、Token 的安全存储
Token 的安全存储是上线前必须过的一道关,我按”最小暴露”原则逐条落实:
- User Access Token 存服务端 session,HttpOnly cookie 携带 session id;
- App 私钥存放在环境变量或密钥管理服务(KMS/SSM);
- Installation Token 缓存在 Redis,不入数据库;
- 任何 token 不写日志、不暴露前端。
一条一条说原因:User Access Token 不落前端,浏览器拿不到明文;私钥进 KMS 后可以随时 rotate,配合定期轮换,泄露窗口可控;Installation Token 放 Redis 是因为它本来就是短期凭据,写数据库反而留了陈旧的敏感数据。日志里打 token 是我早期犯过的错,后来加了脱敏检查,凡是命中 gh_ 开头的字段一律打码。
六、Webhook 与 Token 的配合
App 装载后,GitHub 会向配置的 URL 推送事件(如 push、pull_request)。Webhook 与 Token 是两套独立体系:Webhook 只负责”告诉我发生了什么”,真正执行任务还得靠 Installation Token。处理流程:
- 校验
X-Hub-Signature-256与 webhook secret; - 解析事件,得到
installation.id、repository.full_name; - 调缓存或重新生成 Installation Token;
- 执行业务逻辑(增量翻译、PR 状态同步等)。
这四步里最容易忽略的是第一步签名校验。不校验,任何人都能伪造 push 事件触发任务,等于给别人留了个免费的调用入口。我一开始为了省事跳过校验,被扫描工具提示后才补上,之后所有 webhook 入口都强制校验签名。
七、典型错误与处理
上线后线上报错集中在几个固定类型,我把它们整理成排查表,运维同学照着处理就行:
| 错误 | 原因 | 处理 |
|---|---|---|
| 401 Bad credentials | Token 过期或撤销 | 重新生成 Installation Token |
| 403 Resource not accessible | 安装权限不足 | 引导用户重新安装并勾选仓库 |
| 404 Not Found | 安装被卸载 | 标记安装失效,提示重装 |
| 429 Too Many Requests | 限流 | 退避 + 复用请求 |
几个错误里,403 容易被误判为代码 bug,其实多半是用户安装 App 时没勾选目标仓库。处理方式是从错误响应里解析出仓库名,直接提示用户去安装页面补勾选,省去来回排查。
常见问题(FAQ)
Q1:User Access Token 能不能读仓库?
可以,但权限继承自用户本人,不推荐用于应用自动化。
Q2:Installation Token 为什么要缓存?
因为生成需要私钥签名,且调用有速率限制。
Q3:私钥泄露怎么办?
立刻在 GitHub 开发者设置里”Rotate”私钥并重新部署。