停止粘贴令牌:JetBrains IDE插件的OAuth2登录

TL;DR · AI 摘要
JetBrains官方指南推荐在IDE插件中使用OAuth2登录替代手动粘贴令牌,通过PKCE机制确保安全,提供完整代码示例和流程详解,避免令牌泄露风险。
核心要点
- PKCE机制通过code_verifier和code_challenge防止授权码被窃取,适用于无法保护客户端密钥的桌面插件。
- 代码示例在GitHub提供,包含plugin.xml、AuthConfigurable等组件实现OAuth2流程。
- 令牌存储在PasswordSafe中,利用JetBrains安全存储机制,避免手动粘贴长期令牌风险。
结构提纲
按章节快速跳转。
OAuth2令牌提供有限和临时的访问权限,用户无需共享密码,类似于酒店钥匙卡。
PKCE通过code_verifier和code_challenge确保授权码安全,适用于无法保护客户端密钥的桌面应用。
用户点击登录后,插件打开浏览器,处理回调,验证state,交换代码获取令牌,并存储在PasswordSafe中。
代码分为plugin.xml、AuthConfigurable、AuthRestService和AuthService组件,各负责流程的不同部分。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- JetBrains IDE插件OAuth2登录实现
- 问题背景
- 手动粘贴令牌易泄露
- 无法安全存储客户端密钥
- PKCE机制
- code_verifier和code_challenge
- 防止授权码窃取
- 实现流程
- 浏览器授权页面
- IDE回调处理
- 令牌安全存储
金句 / Highlights
值得收藏与分享的关键句。
PKCE代表Proof Key for Code Exchange,它将返回的授权码与创建它的登录请求绑定。
GitHub将验证器与之前的挑战进行比较。如果它们不匹配,则不返回令牌。
插件将令牌存储在PasswordSafe中并调用GitHub API。
JetBrains 产品的插件与扩展开发。
停止粘贴令牌:JetBrains IDE 插件的 OAuth2 登录

2026年6月1日
当插件需要获取账户数据时,简单的 API 调用便转化为认证问题。常见的错误做法是:要求用户创建个人访问令牌(PAT),手动粘贴到设置中,并祈祷令牌永不泄露。
对于 JetBrains IDE 插件,应采用以下流程:用户点击 登录 按钮 → 浏览器自动打开 → 认证服务提供商处理登录 → IDE 接收回调 → 插件存储令牌。
插件的核心流程如下:
- 在浏览器中打开认证服务提供商的授权页面。
- 在 IDE 内接收 OAuth2 回调。
- 验证返回的
state参数。 - 使用 PKCE 交换授权码。
- 将访问令牌存储至
PasswordSafe。
本文以 GitHub 作为 OAuth2 服务提供商,但该流程同样适用于其他平台。具体作用域(Scopes)、URL、令牌响应及刷新规则会有所不同。
示例代码:https://github.com/JetBrains/intellij-sdk-docs/tree/main/code_samples/oauth2
核心逻辑

OAuth2 可类比酒店房卡理解。
入住时,你不会拿到万能钥匙,而是获得仅限客房、电梯或健身房的房卡。退房后,房卡自动失效。
关键在于:授权访问是受限且有时效的。OAuth2 访问令牌同理——用户通过服务提供商登录后,插件仅获得用户授权范围内的 API 访问权限。插件全程无需获取用户密码。
相比要求用户手动粘贴长期有效的密钥,此方案更安全:用户始终在熟悉的浏览器登录流程中操作,而服务提供商掌控作用域、有效期及撤销权限。
目标很明确:让插件获得受限令牌,同时避免用户手动粘贴。难点在于桌面插件无法安全存储传统客户端密钥。
为何 PKCE 是关键
Web 应用可在后端服务器保护客户端密钥,但桌面插件无法做到——插件内任何内容都可被反编译。
此时 PKCE(Proof Key for Code Exchange,代码交换的证明密钥)发挥作用:它将返回的授权码与初始登录请求绑定。
插件在打开浏览器前生成随机 code_verifier,并向 GitHub 发送派生的 code_challenge。当 GitHub 重定向返回临时授权码时,插件需将原始 code_verifier 发送至令牌端点。
GitHub 会比对 code_verifier 与先前的 code_challenge。若不匹配则拒绝发放令牌。这意味着仅凭返回的授权码无法获取令牌,这正是桌面插件所需的安全机制。
完整流程

- 用户点击 使用 GitHub 登录。
- 插件生成
state、code_verifier和code_challenge。 - 插件在浏览器中打开 GitHub 授权 URL。
- GitHub 将
state和临时code重定向回 IDE。 - 插件验证
state参数。 - 插件用授权码和
code_verifier交换访问令牌。 - 插件将令牌存入
PasswordSafe并调用 GitHub API。
代码中的实现逻辑
示例代码位于 `code_samples/oauth2`。上述流程由四个模块协同完成:
plugin.xml注册设置界面和本地回调处理器。AuthConfigurable提供登录/登出按钮。AuthRestService处理 GitHub 重定向至 IDE 内置 HTTP 服务器的请求。AuthService创建 OAuth2 请求、交换授权码、存储令牌并调用 API。
关键在于模块化设计:OAuth2 流程若作为单一机制描述会显得混乱,但拆分为独立组件后,代码逻辑清晰易读。
注册界面与回调
插件描述文件需注册两项内容:
- 设置页面
- 本地 HTTP 回调处理器
<extensions defaultExtensionNs="com.intellij"> <applicationConfigurable instance="org.intellij.sdk.oauth2.AuthConfigurable" id="org.intellij.sdk.oauth2.AuthConfigurable" displayName="My Plugin Auth"/>
<httpRequestHandler implementation="org.intellij.sdk.oauth2.AuthRestService"/> </extensions>
applicationConfigurable 添加设置页面。httpRequestHandler 向 IDE 内置 HTTP 服务器注册处理器,使 /api/myplugin 请求可路由至 AuthRestService,从而为 GitHub 提供浏览器授权后的本地重定向目标。
简化设置界面
AuthConfigurable 是设置界面的实现。示例中它继承 BoundConfigurable,采用 Kotlin UI DSL,功能简洁:
- 未登录时显示 使用 GitHub 登录 按钮
- 已登录时显示用户名及 登出 按钮
面板通过监听 AuthService.state 实现状态切换,界面逻辑清晰:
private fun createView(state: AuthState) = panel { when (state) { is AuthState.Connected -> row("Username") { label(state.username ?: "Unknown") button("Logout") { authService.logout() } }
is AuthState.Disconnected -> row { button("Login with GitHub") { authService.login() } } } }
接收浏览器重定向
批准后,GitHub 会重定向回 IDE 的内置 HTTP 服务器。回调由 IntelliJ 平台的 `RestService` 处理:
http://localhost:<built-in-server-port>/api/myplugin
AuthRestService 读取 state 和 code,找到待处理的登录请求,完成该请求并返回一个小型 HTML 响应:
val parameters = urlDecoder.parameters() val state = parameters["state"]?.firstOrNull() ?: return "No authorization state found" val code = parameters["code"]?.firstOrNull() ?: return "No authorization code found" val callback = service<AuthService>().callbacks.remove(state) ?: return "No active OAuth request found"
callback.complete(code) sendResponse( request, context, response("text/html", Unpooled.wrappedBuffer(HTML_RESPONSE.toByteArray())) ) return null
此后,AuthService 通过将授权码交换为令牌来继续流程。
执行流程
AuthService 创建登录请求,等待回调,并交换返回的授权码:
private suspend fun requestToken(): String { val state = UUID.randomUUID().toString() val codeVerifier = UUID.randomUUID().toString().padStart(43, '0') val callback = CompletableDeferred<String>().also { callbacks[state] = it }
try { BrowserUtil.browse(authorizationUrl(state, codeVerifier)) return exchangeCodeForToken(callback.await(), codeVerifier) } finally { callbacks.remove(state)?.cancel() } }
CompletableDeferred 是 HTTP 回调与 requestToken() 中等待的协程之间的桥梁。requestToken() 会等待 callback.await(),而当 GitHub 重定向返回授权码时,AuthRestService 会完成该对象。
padStart(43, '0') 的存在是因为 GitHub 要求 PKCE 验证器满足最小长度要求。某些服务提供商可能对长度要求较宽松,可直接接受 UUID,但 GitHub 要求验证器长度至少为 43 个字符。
授权 URL 同时包含两项安全校验:state 和 PKCE 挑战。
private fun authorizationUrl(state: String, codeVerifier: String) = url( AUTHORIZATION_URL, "client_id" to CLIENT_ID, "scope" to SCOPES, "state" to state, "redirect_uri" to redirectUri, "code_challenge" to codeChallenge(codeVerifier), "code_challenge_method" to "S256", )
挑战值由代码验证器生成:
private fun codeChallenge(codeVerifier: String) = DigestUtil.sha256().digest(codeVerifier.toByteArray()) .let { Base64.getUrlEncoder().withoutPadding().encodeToString(it) }
实际令牌交换是向 GitHub 的令牌端点发送 POST 请求:
private suspend fun exchangeCodeForToken(code: String, codeVerifier: String) = withContext(Dispatchers.IO) { parseAccessToken(post(tokenUrl(code, codeVerifier), null).readString()) }
令牌请求会回传临时授权码和原始验证器:
private fun tokenUrl(code: String, codeVerifier: String) = url( ACCESS_TOKEN_URL, "client_id" to CLIENT_ID, "client_secret" to CLIENT_SECRET, "code" to code, "redirect_uri" to redirectUri, "code_verifier" to codeVerifier, )
示例中包含 GitHub 客户端密钥,因为 GitHub 的 OAuth 应用流程需要该密钥。对于桌面插件,不应将该值视为机密。此处 PKCE 是关键校验机制:若没有原始验证器,返回的授权码将无法使用。
将令牌存储在 `PasswordSafe` 中
当服务提供商返回访问令牌后,应将其存储在 PasswordSafe 中。常规持久化设置适用于偏好配置,但不适合存储访问令牌。
示例中使用了一个凭证密钥:
private val credentials = CredentialAttributes(generateServiceName("MyPluginAuth", "OAuthToken"))
启动时,服务会恢复之前保存的令牌(如果存在):
init { coroutineScope.launch { val token = PasswordSafe.instance.getPassword(credentials) ?: return@launch _state.value = AuthState.Connected(fetchUserProfile(token)) } }
存储和清除操作通过同一辅助方法完成:
private fun storeToken(token: String?) = PasswordSafe.instance.setPassword(credentials, token)
对于实际插件,应使用稳定的命名服务。若需支持多账户,应为每个服务提供商账户单独存储凭证。
平台源码参考:`PasswordSafe`、`CredentialStore` 和 `CredentialAttributes`。
调用 API
登录完成后,插件其余部分无需关心 OAuth2 的具体实现。示例使用外部库 org.kohsuke:github-api,并将令牌传入 GitHubBuilder 以获取当前 GitHub 用户名:
private suspend fun fetchUserProfile(token: String): String? = withContext(Dispatchers.IO) { runCatching { GitHubBuilder().withOAuthToken(token).build().myself.login } .onFailure { thisLogger().warn("Failed to fetch user profile", it) } .getOrNull() }
在大型插件中也应保持这种边界划分。API 代码不应了解浏览器登录的具体实现。
总结
插件中的 OAuth2 实现关键在于将职责合理分配到各个组件中。
让提供者处理登录。让浏览器处理面向用户的登录界面。让 IDE 接收回调。让 AuthService 处理令牌。一旦令牌存储在 PasswordSafe 中,插件的其余部分就无需再关心用户的身份验证方式。
如果你正在开发类似功能,或在使用提供者时遇到边界情况,请前往 JetBrains Platform 论坛 讨论。
祝你好运!
[](https://blog.jetbrains.com/platform/2026/06/stop-pasting-tokens-oauth2-login-for-jetbrains-ide-plugins/#)