{"id":2639,"date":"2026-06-02T03:37:33","date_gmt":"2026-06-02T03:37:33","guid":{"rendered":"https:\/\/tucumandevelopers.com\/index.php\/2026\/06\/02\/stop-pasting-tokens-oauth2-login-for-jetbrains-ide-plugins\/"},"modified":"2026-06-02T03:37:33","modified_gmt":"2026-06-02T03:37:33","slug":"stop-pasting-tokens-oauth2-login-for-jetbrains-ide-plugins","status":"publish","type":"post","link":"https:\/\/tucumandevelopers.com\/index.php\/2026\/06\/02\/stop-pasting-tokens-oauth2-login-for-jetbrains-ide-plugins\/","title":{"rendered":"Stop Pasting Tokens: OAuth2 Login for JetBrains IDE Plugins"},"content":{"rendered":"<div>\n<div>\n<section data-clarity-region=\"article\">\n<div>\n<p><a href=\"\/platform\/category\/intellij-platform\/\">IntelliJ Platform<\/a> <a href=\"\/platform\/category\/plugins\/\">Plugins<\/a><\/p>\n<h2 id=\"major-updates\">Stop Pasting Tokens: OAuth2 Login for JetBrains IDE Plugins<\/h2>\n<p>The moment a plugin needs account data, a simple API call turns into an authentication problem. The bad shortcut is familiar: ask the user to create a personal access token (PAT), make them paste it into settings, and hope it never leaks.<\/p>\n<p>For a JetBrains IDE plugin, use this flow instead: the user clicks the <strong>Login<\/strong> button, the browser opens, the provider handles sign-in, the IDE receives a callback, and the plugin stores the token.<\/p>\n<p>At a high level, the plugin will:<\/p>\n<ol>\n<li>Open the provider\u2019s authorization page in the browser.<\/li>\n<li>Receive the OAuth2 callback inside the IDE.<\/li>\n<li>Validate the returned <code>state<\/code>.<\/li>\n<li>Exchange the authorization code with PKCE.<\/li>\n<li>Store the access token in <code>PasswordSafe<\/code>.<\/li>\n<\/ol>\n<p>This post uses GitHub as the OAuth2 provider, but the same shape works elsewhere. Scopes, URLs, token responses, and refresh rules will change.<\/p>\n<p><strong>Sample code: <\/strong><a href=\"https:\/\/github.com\/JetBrains\/intellij-sdk-docs\/tree\/main\/code_samples\/oauth2\" target=\"_blank\" rel=\"noreferrer noopener\">https:\/\/github.com\/JetBrains\/intellij-sdk-docs\/tree\/main\/code_samples\/oauth2<\/a><\/p>\n<figure>\n<\/figure>\n<blockquote><\/blockquote>\n<h2>The Mental Model<\/h2>\n<figure><\/figure>\n<p>OAuth2 is easier to reason about as hotel key cards.<br \/>At check-in, you do not get a master key. You get a card for your room, maybe the elevator or gym. When your stay ends, the card stops working.<\/p>\n<p>That is the useful bit: allowed access, but limited and temporary. An OAuth2 access token works the same way. The user signs in with the provider, and the plugin gets a token for the API access the user approved. The plugin never needs the user\u2019s password.<\/p>\n<p>That approach is better than asking people to paste a long-lived secret into settings. Users stay in the browser login flow they already trust, while the provider keeps control of scopes, expiration, and revocation.<\/p>\n<p>So the goal is simple: get the plugin a limited token without making the user paste one manually. The catch is that a desktop plugin cannot protect a traditional client secret.<\/p>\n<h2>Why PKCE Is Part of the Story<\/h2>\n<p>In a web app, the server can keep a client secret on the backend. A desktop plugin cannot do that. Anything bundled into the plugin can be inspected.<\/p>\n<p>That is where PKCE comes in. PKCE stands for Proof Key for Code Exchange, and it ties the returned authorization code to the login request that created it.<\/p>\n<p>Before opening the browser, the plugin creates a random <code>code_verifier<\/code> and sends GitHub a derived <code>code_challenge<\/code>. Later, when GitHub redirects back with a temporary code, the plugin sends the original verifier to the token endpoint.<\/p>\n<p>GitHub compares the verifier with the earlier challenge. If they do not match, no token. That means the returned code is not enough on its own, which is exactly what we want for a desktop plugin.<\/p>\n<h2>The Flow<\/h2>\n<p>Here is the full flow:<\/p>\n<figure><\/figure>\n<ol>\n<li>The user clicks <strong>Login with GitHub<\/strong>.<\/li>\n<li>The plugin creates <code>state<\/code>, <code>code_verifier<\/code>, and <code>code_challenge<\/code>.<\/li>\n<li>The plugin opens GitHub\u2019s authorization URL in the browser.<\/li>\n<li>GitHub redirects back to the IDE with <code>state<\/code> and a temporary <code>code<\/code>.<\/li>\n<li>The plugin validates <code>state<\/code>.<\/li>\n<li>The plugin exchanges the code and verifier for an access token.<\/li>\n<li>The plugin stores the token in <code>PasswordSafe<\/code> and calls the GitHub API.<\/li>\n<\/ol>\n<h2>Where the Flow Lives in Code<\/h2>\n<p>The sample code lives in <a href=\"https:\/\/github.com\/JetBrains\/intellij-sdk-docs\/tree\/main\/code_samples\/oauth2\" target=\"_blank\" rel=\"noreferrer noopener\"><code>code_samples\/oauth2<\/code><\/a>. The flow above is split across four small pieces:<\/p>\n<ul>\n<li><code>plugin.xml<\/code> registers the settings UI and the local callback handler.<\/li>\n<li><code>AuthConfigurable<\/code> gives the user the login and logout buttons.<\/li>\n<li><code>AuthRestService<\/code> handles the request that GitHub sends back to the IDE\u2019s built-in HTTP server.<\/li>\n<li><code>AuthService<\/code> creates the OAuth2 request, exchanges the code, stores the token, and calls the API.<\/li>\n<\/ul>\n<p>That split is the main thing to notice. OAuth2 feels messy when everything is described as one big mechanism. In code, it is much easier to follow when each class owns one part of the trip.<\/p>\n<h3>Register the UI and Callback<\/h3>\n<p>The plugin descriptor registers two things:<\/p>\n<ul>\n<li>the settings page<\/li>\n<li>the local HTTP callback handler<\/li>\n<\/ul>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">&lt;extensions defaultExtensionNs=\"com.intellij\"&gt; &lt;applicationConfigurable instance=\"org.intellij.sdk.oauth2.AuthConfigurable\" id=\"org.intellij.sdk.oauth2.AuthConfigurable\" displayName=\"My Plugin Auth\"\/&gt; &lt;httpRequestHandler implementation=\"org.intellij.sdk.oauth2.AuthRestService\"\/&gt; &lt;\/extensions&gt; <\/pre>\n<p><code>applicationConfigurable<\/code> adds the settings page. <code>httpRequestHandler<\/code> registers a handler with the IDE\u2019s built-in HTTP server, so a request to <code>\/api\/myplugin<\/code> can be routed to <code>AuthRestService<\/code>. That gives GitHub a local redirect target after browser authorization.<\/p>\n<h3>Keep the Settings UI Boring<\/h3>\n<p><code>AuthConfigurable<\/code> is the settings UI. In the sample, it extends <code>BoundConfigurable<\/code>, uses the Kotlin UI DSL, and its job is small:<\/p>\n<ul>\n<li>if disconnected, show <strong>Login with GitHub<\/strong><\/li>\n<li>if connected, show the username and <strong>Logout<\/strong><\/li>\n<\/ul>\n<p>The panel observes <code>AuthService.state<\/code>, and the view is a small state switch:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">private fun createView(state: AuthState) = panel { when (state) { is AuthState.Connected -&gt; row(\"Username\") { label(state.username ?: \"Unknown\") button(\"Logout\") { authService.logout() } } is AuthState.Disconnected -&gt; row { button(\"Login with GitHub\") { authService.login() } } } } <\/pre>\n<h3>Receive the Browser Redirect<\/h3>\n<p>After approval, GitHub redirects back to the IDE\u2019s built-in HTTP server. The callback is handled with the IntelliJ Platform <a href=\"https:\/\/github.com\/JetBrains\/intellij-community\/blob\/master\/platform\/built-in-server\/src\/org\/jetbrains\/ide\/RestService.kt\" target=\"_blank\" rel=\"noreferrer noopener\"><code>RestService<\/code><\/a>:<\/p>\n<pre data-enlighter-language=\"generic\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">http:\/\/localhost:&lt;built-in-server-port&gt;\/api\/myplugin <\/pre>\n<p><code>AuthRestService<\/code> reads <code>state<\/code> and <code>code<\/code>, finds the pending login request, completes it, and returns a small HTML response:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">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&lt;AuthService&gt;().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 <\/pre>\n<p>After that, <code>AuthService<\/code> continues the flow by exchanging the code for a token.<\/p>\n<h3>Run the Flow<\/h3>\n<p><code>AuthService<\/code> creates the login request, waits for the callback, and exchanges the returned code:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">private suspend fun requestToken(): String { val state = UUID.randomUUID().toString() val codeVerifier = UUID.randomUUID().toString().padStart(43, '0') val callback = CompletableDeferred&lt;String&gt;().also { callbacks[state] = it } try { BrowserUtil.browse(authorizationUrl(state, codeVerifier)) return exchangeCodeForToken(callback.await(), codeVerifier) } finally { callbacks.remove(state)?.cancel() } } <\/pre>\n<p><code>CompletableDeferred<\/code> is the bridge between the HTTP callback and the coroutine waiting in <code>requestToken()<\/code>. <code>requestToken()<\/code> waits on <code>callback.await()<\/code>, and <code>AuthRestService<\/code> completes that same object when GitHub redirects back with the code.<\/p>\n<p>The <code>padStart(43, '0')<\/code> is there because GitHub expects the PKCE verifier to meet the minimum verifier length. Some providers are less strict and may accept a UUID as-is, but GitHub needs the verifier to be at least 43 characters long.<\/p>\n<p>The authorization URL carries both safety checks: <code>state<\/code> and the PKCE challenge.<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">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\", ) <\/pre>\n<p>The challenge is derived from the code verifier:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">private fun codeChallenge(codeVerifier: String) = DigestUtil.sha256().digest(codeVerifier.toByteArray()) .let { Base64.getUrlEncoder().withoutPadding().encodeToString(it) } <\/pre>\n<p>The actual token exchange is a POST to GitHub\u2019s token endpoint:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">private suspend fun exchangeCodeForToken(code: String, codeVerifier: String) = withContext(Dispatchers.IO) { parseAccessToken(post(tokenUrl(code, codeVerifier), null).readString()) } <\/pre>\n<p>The token request sends back the temporary code and the original verifier:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">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, ) <\/pre>\n<p>The sample includes a GitHub client secret because GitHub\u2019s OAuth app flow expects one. For a desktop plugin, do not treat that value as secret. PKCE is the useful check here: the returned code is useless without the original verifier.<\/p>\n<h2>Store the Token in <code>PasswordSafe<\/code><\/h2>\n<p>Once the provider returns an access token, store it in <code>PasswordSafe<\/code>. Regular persistent settings are fine for preferences, but not for access tokens.<\/p>\n<p>The sample uses one credential key:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">private val credentials = CredentialAttributes(generateServiceName(\"MyPluginAuth\", \"OAuthToken\")) <\/pre>\n<p>On startup, the service restores an existing token if one was saved earlier:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">init { coroutineScope.launch { val token = PasswordSafe.instance.getPassword(credentials) ?: return@launch _state.value = AuthState.Connected(fetchUserProfile(token)) } } <\/pre>\n<p>Storing and clearing go through the same helper:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">private fun storeToken(token: String?) = PasswordSafe.instance.setPassword(credentials, token) <\/pre>\n<p>For a real plugin, use a stable service name. If you support multiple accounts, store one credential per provider account.<\/p>\n<p>Platform sources: <a href=\"https:\/\/github.com\/JetBrains\/intellij-community\/blob\/master\/platform\/credential-store\/src\/ide\/passwordSafe\/PasswordSafe.kt\" target=\"_blank\" rel=\"noreferrer noopener\"><code>PasswordSafe<\/code><\/a>, <a href=\"https:\/\/github.com\/JetBrains\/intellij-community\/blob\/master\/platform\/credential-store\/src\/credentialStore\/CredentialStore.kt\" target=\"_blank\" rel=\"noreferrer noopener\"><code>CredentialStore<\/code><\/a>, and <a href=\"https:\/\/github.com\/JetBrains\/intellij-community\/blob\/master\/platform\/credential-store\/src\/credentialStore\/CredentialAttributes.kt\" target=\"_blank\" rel=\"noreferrer noopener\"><code>CredentialAttributes<\/code><\/a>.<\/p>\n<h2>Calling the API<\/h2>\n<p>After login, the rest of the plugin should not care how OAuth2 worked. The sample uses the external <code>org.kohsuke:github-api<\/code> library and passes the token into <code>GitHubBuilder<\/code> to fetch the current GitHub username:<\/p>\n<pre data-enlighter-language=\"kotlin\" data-enlighter-theme=\"\" data-enlighter-highlight=\"\" data-enlighter-linenumbers=\"\" data-enlighter-lineoffset=\"\" data-enlighter-title=\"\" data-enlighter-group=\"\">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() } <\/pre>\n<p>Keep that boundary in larger plugins too. API code should not know how browser login works.<\/p>\n<h2>Wrapping Up<\/h2>\n<p>OAuth2 in a plugin is mostly about putting the responsibilities in the right place.<\/p>\n<p>Let the provider handle sign-in. Let the browser handle the user-facing login. Let the IDE receive the callback. Let <code>AuthService<\/code> deal with the token. And once the token is stored in <code>PasswordSafe<\/code>, the rest of your plugin can stop caring how the user authenticated.<\/p>\n<p>If you are building something similar, or if you hit an edge case with a provider, bring it to the <a href=\"https:\/\/platform.jetbrains.com\/c\/intellij-platform\/5\" target=\"_blank\" rel=\"noreferrer noopener\">JetBrains Platform forum<\/a>.<br \/>Good luck!<\/p>\n<\/p><\/div>\n<p> <a href=\"#\"><\/a> <\/section>\n<div>\n<p><h2>Discover more<\/h2>\n<\/p><\/div>\n<\/p><\/div>\n<\/div>\n<\/div>\n<\/div>\n<p>Fuente: <a href=\"https:\/\/blog.jetbrains.com\/platform\/2026\/06\/stop-pasting-tokens-oauth2-login-for-jetbrains-ide-plugins\/\">Art\u00edculo original<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>IntelliJ Platform Plugins Stop Pasting Tokens: OAuth2 Login for JetBrains IDE Plugins The moment a plugin needs account data, a simple API call turns into an authentication problem. The bad shortcut is familiar: ask the user to create a personal access token (PAT), make them paste it into settings, and hope it never leaks. For [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":true,"jetpack_social_options":{"image_generator_settings":{"template":"highway","default_image_id":0,"font":"","enabled":false},"version":2}},"categories":[46],"tags":[],"class_list":["post-2639","post","type-post","status-publish","format-standard","hentry","category-jetbrain"],"jetpack_publicize_connections":[],"_links":{"self":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/2639","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/comments?post=2639"}],"version-history":[{"count":0,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/2639\/revisions"}],"wp:attachment":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/media?parent=2639"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/categories?post=2639"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/tags?post=2639"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}