告别粘贴令牌:JetBrains IDE 插件的 OAuth2 登录
当插件需要访问账户数据时,一个简单的API调用就变成了认证问题。常见的糟糕捷径是:让用户创建个人访问令牌(PAT),再粘贴到设置里,祈祷它永远不会泄露。
对于JetBrains IDE插件,请改用以下流程:用户点击登录按钮,浏览器打开,提供商处理登录,IDE接收回调,插件存储令牌。
从高层次看,插件将执行以下操作:
- 在浏览器中打开提供商的授权页面。
- 在IDE内部接收OAuth2回调。
- 验证返回的
state。 - 使用PKCE交换授权码。
- 将访问令牌存储在
PasswordSafe中。
本文以GitHub作为OAuth2提供商,但同样的模式也适用于其他平台。作用域、URL、令牌响应和刷新规则会有所不同。
示例代码: https://github.com/JetBrains/intellij-sdk-docs/tree/main/code_samples/oauth2
思维模型
[LOADING...]
OAuth2可以类比为酒店房卡来理解。
办理入住时,你拿到的不是万能钥匙,而是一张只能开自己房间、可能还有电梯或健身房的卡。退房后,这张卡就失效了。
这就是关键点:允许访问,但有权限且临时。OAuth2访问令牌也是如此。用户通过提供商登录,插件获得一个令牌,用于访问用户已批准的API。插件永远不需要用户的密码。
这种方法比要求用户将长期有效的密钥粘贴到设置中要好得多。用户留在他们已经信任的浏览器登录流程中,而提供商则保持对作用域、过期和撤销的控制。
所以目标很简单:让插件获得一个有限的令牌,而不需要用户手动粘贴。但问题是,桌面插件无法保护传统的客户端密钥。
为什么需要PKCE
在Web应用中,服务器可以在后端保存客户端密钥。桌面插件做不到这一点。任何打包到插件中的内容都可能被检查。
这就是PKCE发挥作用的地方。PKCE代表"Proof Key for Code Exchange"(代码交换证明密钥),它将返回的授权码与创建它的登录请求绑定在一起。
在打开浏览器之前,插件会创建一个随机的code_verifier,并向GitHub发送一个派生出的code_challenge。稍后,当GitHub用临时代码重定向回来时,插件会向令牌端点发送原始的验证码。
GitHub将验证码与之前的挑战进行比较。如果不匹配,则不会颁发令牌。这意味着返回的代码本身是不够的,这恰好是我们在桌面插件中想要的效果。
流程
以下是完整流程:
[LOADING...]
- 用户点击使用GitHub登录。
- 插件创建
state、code_verifier和code_challenge。 - 插件在浏览器中打开GitHub的授权URL。
- GitHub重定向回IDE,附带
state和临时code。 - 插件验证
state。 - 插件将代码和验证码交换为访问令牌。
- 插件将令牌存储在
PasswordSafe中,并调用GitHub API。
流程在代码中的位置
示例代码位于code_samples/oauth2。上述流程分布在四个小部分中:
plugin.xml注册设置UI和本地回调处理器。AuthConfigurable为用户提供登录和注销按钮。AuthRestService处理GitHub发送回IDE内置HTTP服务器的请求。AuthService创建OAuth2请求、交换代码、存储令牌以及调用API。
这种分工是主要关注点。当OAuth2被描述为一个庞大的机制时,会显得杂乱无章。在代码中,将每个类负责流程的某一部分,会更容易理解。
注册UI和回调
插件描述符注册两样东西:
- 设置页面
- 本地HTTP回调处理器
applicationConfigurable 添加设置页面。httpRequestHandler 向IDE内置的HTTP服务器注册一个处理器,因此对 /api/myplugin 的请求可以路由到 AuthRestService。这为GitHub在浏览器授权后提供了本地重定向目标。
让设置UI保持简单
AuthConfigurable 是设置UI。在示例中,它继承自 BoundConfigurable,使用Kotlin UI DSL,其职责很小:
- 如果未连接,则显示使用GitHub登录。
- 如果已连接,则显示用户名和注销。
面板观察 AuthService.state,视图是一个小的状态切换:
接收浏览器重定向
授权后,GitHub重定向回IDE内置的HTTP服务器。回调通过IntelliJ平台的 RestService 处理:
AuthRestService 读取 state 和 code,找到挂起的登录请求,完成它,并返回一个小的HTML响应:
之后,AuthService 继续流程,将代码交换为令牌。
执行流程
AuthService 创建登录请求,等待回调,并交换返回的代码:
CompletableDeferred 是HTTP回调和在 requestToken() 中等待的协程之间的桥梁。requestToken() 等待 callback.await(),而 AuthRestService 在GitHub返回代码时完成同一个对象。
padStart(43, '0') 是因为GitHub要求PKCE验证码符合最小长度。有些提供商不那么严格,可能直接接受UUID,但GitHub需要验证码至少43个字符。
授权URL携带了两项安全检查:state 和PKCE挑战。
挑战是从验证码派生出来的:
实际的令牌交换是向GitHub的令牌端点发送POST请求:
令牌请求将临时代码和原始验证码回传:
示例中包含了一个GitHub客户端密钥,因为GitHub的OAuth应用流程需要它。对于桌面插件,不要将该值视为秘密。PKCE才是这里有用的检查:没有原始验证码,返回的代码是无用的。
将令牌存储在 PasswordSafe 中
一旦提供商返回访问令牌,就将其存储在 PasswordSafe 中。普通的持久化设置适用于偏好设置,但不适用于访问令牌。
示例中使用了一个凭据键:
启动时,服务会恢复之前保存的令牌(如果有的话):
存储和清除都通过同一个辅助函数:
对于实际插件,请使用稳定的服务名。如果支持多个账户,请为每个提供商账户存储一个凭据。
平台源码:PasswordSafe、CredentialStore 和 CredentialAttributes。
调用API
登录后,插件的其他部分不需要关心OAuth2是如何工作的。示例使用了外部的 org.kohsuke:github-api 库,并将令牌传入 GitHubBuilder 中,以获取当前GitHub用户名:
在大型插件中也应该保持这个界限。API代码不应该知道浏览器登录是如何工作的。
总结
在插件中实现OAuth2,主要是将职责放在正确的位置。
让提供商处理登录。让浏览器处理面向用户的登录。让IDE接收回调。让 AuthService 处理令牌。一旦令牌存储在 PasswordSafe 中,插件的其余部分就可以不再关心用户是如何认证的了。
如果你在构建类似的东西,或者遇到了某个提供商的边缘情况,欢迎到 JetBrains Platform论坛 提出。
祝好运!