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>

97dba399e4791303c5474c8e53563739a9c0066e

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
5 files changed, +73 -14Ignore whitespace
package-lock.json+7 -0
@@ -73,6 +73,7 @@
7373 "ipaddr.js": "^2.2.0",
7474 "is-docker": "^3.0.0",
7575 "isomorphic-git": "^1.36.3",
76+ "js-sha256": "^0.11.1",
7677 "localforage": "^1.10.0",
7778 "lodash": "^4.17.21",
7879 "mime-types": "^3.0.2",
@@ -6366,6 +6367,12 @@
63666367 "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
63676368 "license": "BSD-3-Clause"
63686369 },
6370+ "node_modules/js-sha256": {
6371+ "version": "0.11.1",
6372+ "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz",
6373+ "integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==",
6374+ "license": "MIT"
6375+ },
63696376 "node_modules/js-yaml": {
63706377 "version": "4.1.1",
63716378 "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
package.json+1 -0
@@ -64,6 +64,7 @@
6464 "ipaddr.js": "^2.2.0",
6565 "is-docker": "^3.0.0",
6666 "isomorphic-git": "^1.36.3",
67+ "js-sha256": "^0.11.1",
6768 "localforage": "^1.10.0",
6869 "lodash": "^4.17.21",
6970 "mime-types": "^3.0.2",
public/lib.js+3 -0
@@ -24,6 +24,7 @@ import chalk from 'chalk';
2424import yaml from 'yaml';
2525import * as chevrotain from 'chevrotain';
2626import { gzipSync, gzip } from 'fflate';
27+import { sha256 } from 'js-sha256';
2728
2829/**
2930 * Expose the libraries to the 'window' object.
@@ -105,6 +106,7 @@ export default {
105106 chevrotain,
106107 gzipSync,
107108 gzip,
109+ sha256,
108110};
109111
110112export {
@@ -132,4 +134,5 @@ export {
132134 chevrotain,
133135 gzipSync,
134136 gzip,
137+ sha256,
135138};
public/script.js+2 -1
@@ -213,7 +213,7 @@ import {
213213 tag_import_setting,
214214 applyCharacterTagsToMessageDivs,
215215} from './scripts/tags.js';
216216import { checkOpenRouterAuth, initSecrets, readSecretState } from './scripts/secrets.js';
217217import { markdownExclusionExt } from './scripts/showdown-exclusion.js';
218218import { markdownUnderscoreExt } from './scripts/showdown-underscore.js';
219219import { NOTE_MODULE_NAME, initAuthorsNote, metadata_keys, setFloatingPrompt, shouldWIAddPrompt } from './scripts/authors-note.js';
@@ -748,6 +748,7 @@ async function firstLoadInit() {
748748 await initPresetManager();
749749 await initSystemMessages();
750750 await getSettings(initLoaderHandle);
751+ await checkOpenRouterAuth();
751752 initKeyboard();
752753 initDynamicStyles();
753754 initTags();
public/scripts/secrets.js+60 -13
@@ -1,5 +1,5 @@
11import { DOMPurify, moment, sha256 } from '../lib.js';
22import { event_types, eventSource, getRequestHeaders, saveSettings } from '../script.js';
33import { t } from './i18n.js';
44import { chat_completion_sources } from './openai.js';
55import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
@@ -12,7 +12,9 @@ import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
1212import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';
1313import { renderTemplateAsync } from './templates.js';
1414import { textgen_types } from './textgen-settings.js';
1515import { copyText, isTrueBooleangetCurrentUserHandle } from './utilsuser.js';
16+import { copyText, isTrueBoolean, uuidv4 } from './utils.js';
17+import { accountStorage } from './util/AccountStorage.js';
1618
1719export const SECRET_KEYS = {
1820 HORDE: 'api_key_horde',
@@ -417,7 +419,6 @@ export async function readSecretState() {
417419 secret_state = await response.json();
418420 updateSecretDisplay();
419421 updateInputDataLists();
420- await checkOpenRouterAuth();
421422 }
422423 } catch {
423424 console.error('Could not read secrets file');
@@ -498,6 +499,25 @@ export async function renameSecret(key, id, label) {
498499}
499500
500501/**
502+ * Generates a storage key for the PKCE code verifier for a given source.
503+ * @param {string} source Source for which to generate the storage key (e.g. 'openrouter')
504+ * @returns {string} The storage key for the PKCE code verifier for a given source.
505+ */
506+const getVerifierKey = (source) => `${getCurrentUserHandle()}_${source}_code_verifier`;
507+
508+/**
509+ * Generates a code challenge for PKCE authentication flows.
510+ * @param {string} input Input secret string to generate the code challenge from.
511+ * @returns {string} S256 code challenge generated from the input string, encoded in base64url format.
512+ */
513+const generateChallenge = (input) => {
514+ const encoder = new TextEncoder();
515+ const data = encoder.encode(input);
516+ const hashBytes = sha256.array(data);
517+ return btoa(String.fromCharCode(...hashBytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
518+};
519+
520+/**
501521 * Redirects the user to authorize OpenRouter.
502522 */
503523async function authorizeOpenRouter() {
@@ -508,8 +528,15 @@ async function authorizeOpenRouter() {
508528 }
509529 }
510530
531+ // Generate a PKCE code verifier and code challenge
532+ const codeVerifier = uuidv4() + uuidv4();
533+ const codeChallenge = generateChallenge(codeVerifier);
534+ accountStorage.setItem(getVerifierKey('openrouter'), codeVerifier);
535+ await saveSettings();
536+
537+ // Redirect to OpenRouter authorization URL with the code challenge and callback URL
511538 const redirectUrl = new URL('/callback/openrouter', window.location.origin);
512539 const openRouterUrl = `https://openrouter.ai/auth?callback_url=${encodeURIComponent(redirectUrl.toString())}&code_challenge=${codeChallenge}&code_challenge_method=S256`;
513540 location.href = openRouterUrl;
514541}
515542
@@ -517,16 +544,32 @@ async function authorizeOpenRouter() {
517544 * Checks if the OpenRouter authorization code is present in the URL, and if so, exchanges it for an API key.
518545 * @returns {Promise<void>}
519546 */
520547export async function checkOpenRouterAuth() {
521548 const params = new URLSearchParams(location.search);
522549 const source = params.get('source');
523550 if (source === 'openrouter') {
524551 const query = new URLSearchParams(params.get('query'));
525- const code = query.get('code');
526552 try {
553+ const code = query.get('code');
554+ if (!code) {
555+ throw new Error('OpenRouter authorization code not found in URL');
556+ }
557+
558+ const codeVerifier = accountStorage.getItem(getVerifierKey('openrouter'));
559+ if (!codeVerifier) {
560+ throw new Error('OpenRouter code verifier not found in accountStorage');
561+ }
562+
527563 const response = await fetch('https://openrouter.ai/api/v1/auth/keys', {
528564 method: 'POST',
529- body: JSON.stringify({ code }),
565+ headers: {
566+ 'Content-Type': 'application/json',
567+ },
568+ body: JSON.stringify({
569+ code: code,
570+ code_verifier: codeVerifier,
571+ code_challenge_method: 'S256',
572+ }),
530573 });
531574
532575 if (!response.ok) {
@@ -542,18 +585,22 @@ async function checkOpenRouterAuth() {
542585
543586 if (secret_state[SECRET_KEYS.OPENROUTER]) {
544587 toastr.success('OpenRouter token saved');
545- // Remove the code from the URL
546- const currentUrl = window.location.href;
547- const urlWithoutSearchParams = currentUrl.split('?')[0];
548- window.history.pushState({}, '', urlWithoutSearchParams);
549588 } else {
550589 throw new Error('OpenRouter token not saved');
551590 }
552591 } catch (err) {
553592 toastr.error('Could not verify OpenRouter token. Please try again.');
554- return;
593+ console.error('OpenRouter OAuth error:', err);
594+ } finally {
595+ // Remove the code from the URL
596+ const currentUrl = window.location.href;
597+ const urlWithoutSearchParams = currentUrl.split('?')[0];
598+ window.history.pushState({}, '', urlWithoutSearchParams);
555599 }
556600 }
601+
602+ // Clean-up any code verifiers that might be left in accountStorage from abandoned auth flows
603+ accountStorage.removeItem(getVerifierKey('openrouter'));
557604}
558605
559606/**