Ohhnews

分类导航

$ cd ..
Jetbrains Blog原文

告别粘贴令牌:JetBrains IDE 插件的 OAuth2 登录

#oauth2#jetbrains 插件#身份验证#pkce#令牌管理

当插件需要访问账户数据时,一个简单的API调用就变成了认证问题。常见的糟糕捷径是:让用户创建个人访问令牌(PAT),再粘贴到设置里,祈祷它永远不会泄露。

对于JetBrains IDE插件,请改用以下流程:用户点击登录按钮,浏览器打开,提供商处理登录,IDE接收回调,插件存储令牌。

从高层次看,插件将执行以下操作:

  1. 在浏览器中打开提供商的授权页面。
  2. 在IDE内部接收OAuth2回调。
  3. 验证返回的state
  4. 使用PKCE交换授权码。
  5. 将访问令牌存储在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...]

  1. 用户点击使用GitHub登录
  2. 插件创建statecode_verifiercode_challenge
  3. 插件在浏览器中打开GitHub的授权URL。
  4. GitHub重定向回IDE,附带state和临时code
  5. 插件验证state
  6. 插件将代码和验证码交换为访问令牌。
  7. 插件将令牌存储在PasswordSafe中,并调用GitHub API。

流程在代码中的位置

示例代码位于code_samples/oauth2。上述流程分布在四个小部分中:

  • plugin.xml 注册设置UI和本地回调处理器。
  • AuthConfigurable 为用户提供登录和注销按钮。
  • AuthRestService 处理GitHub发送回IDE内置HTTP服务器的请求。
  • AuthService 创建OAuth2请求、交换代码、存储令牌以及调用API。

这种分工是主要关注点。当OAuth2被描述为一个庞大的机制时,会显得杂乱无章。在代码中,将每个类负责流程的某一部分,会更容易理解。

注册UI和回调

插件描述符注册两样东西:

  • 设置页面
  • 本地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在浏览器授权后提供了本地重定向目标。

让设置UI保持简单

AuthConfigurable 是设置UI。在示例中,它继承自 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 读取 statecode,找到挂起的登录请求,完成它,并返回一个小的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(),而 AuthRestService 在GitHub返回代码时完成同一个对象。

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)

对于实际插件,请使用稳定的服务名。如果支持多个账户,请为每个提供商账户存储一个凭据。

平台源码:PasswordSafeCredentialStoreCredentialAttributes

调用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论坛 提出。

祝好运!