From 97dba399e4791303c5474c8e53563739a9c0066e Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:32:53 +0300 Subject: [PATCH] Implement S256 challenge in OpenRouter OAuth flow (#5501) * feat: implement S256 challenge in OpenRouter OAuth flow * fix: add error handling for missing OpenRouter authorization code * fix: save verifier to accountStorage Co-authored-by: Copilot * fix: comment on getVerifierKey --------- Co-authored-by: Copilot --- package-lock.json | 7 ++++ package.json | 1 + public/lib.js | 3 ++ public/script.js | 3 +- public/scripts/secrets.js | 73 ++++++++++++++++++++++++++++++++------- 5 files changed, 73 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4c18e660d..9b20c6367 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,7 @@ "ipaddr.js": "^2.2.0", "is-docker": "^3.0.0", "isomorphic-git": "^1.36.3", + "js-sha256": "^0.11.1", "localforage": "^1.10.0", "lodash": "^4.17.21", "mime-types": "^3.0.2", @@ -6366,6 +6367,12 @@ "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", "license": "BSD-3-Clause" }, + "node_modules/js-sha256": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz", + "integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==", + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", diff --git a/package.json b/package.json index 88e295184..29e59cf97 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "ipaddr.js": "^2.2.0", "is-docker": "^3.0.0", "isomorphic-git": "^1.36.3", + "js-sha256": "^0.11.1", "localforage": "^1.10.0", "lodash": "^4.17.21", "mime-types": "^3.0.2", diff --git a/public/lib.js b/public/lib.js index 1282196dc..ea3369be8 100644 --- a/public/lib.js +++ b/public/lib.js @@ -24,6 +24,7 @@ import chalk from 'chalk'; import yaml from 'yaml'; import * as chevrotain from 'chevrotain'; import { gzipSync, gzip } from 'fflate'; +import { sha256 } from 'js-sha256'; /** * Expose the libraries to the 'window' object. @@ -105,6 +106,7 @@ export default { chevrotain, gzipSync, gzip, + sha256, }; export { @@ -132,4 +134,5 @@ export { chevrotain, gzipSync, gzip, + sha256, }; diff --git a/public/script.js b/public/script.js index 2620fe858..772db9055 100644 --- a/public/script.js +++ b/public/script.js @@ -213,7 +213,7 @@ import { tag_import_setting, applyCharacterTagsToMessageDivs, } from './scripts/tags.js'; -import { initSecrets, readSecretState } from './scripts/secrets.js'; +import { checkOpenRouterAuth, initSecrets, readSecretState } from './scripts/secrets.js'; import { markdownExclusionExt } from './scripts/showdown-exclusion.js'; import { markdownUnderscoreExt } from './scripts/showdown-underscore.js'; import { NOTE_MODULE_NAME, initAuthorsNote, metadata_keys, setFloatingPrompt, shouldWIAddPrompt } from './scripts/authors-note.js'; @@ -748,6 +748,7 @@ async function firstLoadInit() { await initPresetManager(); await initSystemMessages(); await getSettings(initLoaderHandle); + await checkOpenRouterAuth(); initKeyboard(); initDynamicStyles(); initTags(); diff --git a/public/scripts/secrets.js b/public/scripts/secrets.js index 57c8fc410..5b2b774bb 100644 --- a/public/scripts/secrets.js +++ b/public/scripts/secrets.js @@ -1,5 +1,5 @@ -import { DOMPurify, moment } from '../lib.js'; -import { event_types, eventSource, getRequestHeaders } from '../script.js'; +import { DOMPurify, moment, sha256 } from '../lib.js'; +import { event_types, eventSource, getRequestHeaders, saveSettings } from '../script.js'; import { t } from './i18n.js'; import { chat_completion_sources } from './openai.js'; import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js'; @@ -12,7 +12,9 @@ import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; import { SlashCommandScope } from './slash-commands/SlashCommandScope.js'; import { renderTemplateAsync } from './templates.js'; import { textgen_types } from './textgen-settings.js'; -import { copyText, isTrueBoolean } from './utils.js'; +import { getCurrentUserHandle } from './user.js'; +import { copyText, isTrueBoolean, uuidv4 } from './utils.js'; +import { accountStorage } from './util/AccountStorage.js'; export const SECRET_KEYS = { HORDE: 'api_key_horde', @@ -417,7 +419,6 @@ export async function readSecretState() { secret_state = await response.json(); updateSecretDisplay(); updateInputDataLists(); - await checkOpenRouterAuth(); } } catch { console.error('Could not read secrets file'); @@ -497,6 +498,25 @@ export async function renameSecret(key, id, label) { } } +/** + * Generates a storage key for the PKCE code verifier for a given source. + * @param {string} source Source for which to generate the storage key (e.g. 'openrouter') + * @returns {string} The storage key for the PKCE code verifier for a given source. + */ +const getVerifierKey = (source) => `${getCurrentUserHandle()}_${source}_code_verifier`; + +/** + * Generates a code challenge for PKCE authentication flows. + * @param {string} input Input secret string to generate the code challenge from. + * @returns {string} S256 code challenge generated from the input string, encoded in base64url format. + */ +const generateChallenge = (input) => { + const encoder = new TextEncoder(); + const data = encoder.encode(input); + const hashBytes = sha256.array(data); + return btoa(String.fromCharCode(...hashBytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + /** * Redirects the user to authorize OpenRouter. */ @@ -508,8 +528,15 @@ async function authorizeOpenRouter() { } } + // Generate a PKCE code verifier and code challenge + const codeVerifier = uuidv4() + uuidv4(); + const codeChallenge = generateChallenge(codeVerifier); + accountStorage.setItem(getVerifierKey('openrouter'), codeVerifier); + await saveSettings(); + + // Redirect to OpenRouter authorization URL with the code challenge and callback URL const redirectUrl = new URL('/callback/openrouter', window.location.origin); - const openRouterUrl = `https://openrouter.ai/auth?callback_url=${encodeURIComponent(redirectUrl.toString())}`; + const openRouterUrl = `https://openrouter.ai/auth?callback_url=${encodeURIComponent(redirectUrl.toString())}&code_challenge=${codeChallenge}&code_challenge_method=S256`; location.href = openRouterUrl; } @@ -517,16 +544,32 @@ async function authorizeOpenRouter() { * Checks if the OpenRouter authorization code is present in the URL, and if so, exchanges it for an API key. * @returns {Promise} */ -async function checkOpenRouterAuth() { +export async function checkOpenRouterAuth() { const params = new URLSearchParams(location.search); const source = params.get('source'); if (source === 'openrouter') { const query = new URLSearchParams(params.get('query')); - const code = query.get('code'); try { + const code = query.get('code'); + if (!code) { + throw new Error('OpenRouter authorization code not found in URL'); + } + + const codeVerifier = accountStorage.getItem(getVerifierKey('openrouter')); + if (!codeVerifier) { + throw new Error('OpenRouter code verifier not found in accountStorage'); + } + const response = await fetch('https://openrouter.ai/api/v1/auth/keys', { method: 'POST', - body: JSON.stringify({ code }), + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + code: code, + code_verifier: codeVerifier, + code_challenge_method: 'S256', + }), }); if (!response.ok) { @@ -542,18 +585,22 @@ async function checkOpenRouterAuth() { if (secret_state[SECRET_KEYS.OPENROUTER]) { toastr.success('OpenRouter token saved'); - // Remove the code from the URL - const currentUrl = window.location.href; - const urlWithoutSearchParams = currentUrl.split('?')[0]; - window.history.pushState({}, '', urlWithoutSearchParams); } else { throw new Error('OpenRouter token not saved'); } } catch (err) { toastr.error('Could not verify OpenRouter token. Please try again.'); - return; + console.error('OpenRouter OAuth error:', err); + } finally { + // Remove the code from the URL + const currentUrl = window.location.href; + const urlWithoutSearchParams = currentUrl.split('?')[0]; + window.history.pushState({}, '', urlWithoutSearchParams); } } + + // Clean-up any code verifiers that might be left in accountStorage from abandoned auth flows + accountStorage.removeItem(getVerifierKey('openrouter')); } /**