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 <copilot@github.com>

* fix: comment on getVerifierKey

---------

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Cohee
2026-04-26 22:32:53 +03:00
committed by GitHub
parent 25fb4ceb50
commit 97dba399e4
5 changed files with 73 additions and 14 deletions
+7
View File
@@ -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",
+1
View File
@@ -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",
+3
View File
@@ -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,
};
+2 -1
View File
@@ -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();
+60 -13
View File
@@ -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<void>}
*/
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'));
}
/**