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 -14Showing whitespace changes
package-lock.json+7 -0
@@ -73,6 +73,7 @@
73 "ipaddr.js": "^2.2.0",73 "ipaddr.js": "^2.2.0",
74 "is-docker": "^3.0.0",74 "is-docker": "^3.0.0",
75 "isomorphic-git": "^1.36.3",75 "isomorphic-git": "^1.36.3",
76 "js-sha256": "^0.11.1",
76 "localforage": "^1.10.0",77 "localforage": "^1.10.0",
77 "lodash": "^4.17.21",78 "lodash": "^4.17.21",
78 "mime-types": "^3.0.2",79 "mime-types": "^3.0.2",
@@ -6366,6 +6367,12 @@
6366 "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",6367 "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==",
6367 "license": "BSD-3-Clause"6368 "license": "BSD-3-Clause"
6368 },6369 },
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 },
6369 "node_modules/js-yaml": {6376 "node_modules/js-yaml": {
6370 "version": "4.1.1",6377 "version": "4.1.1",
6371 "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",6378 "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
package.json+1 -0
@@ -64,6 +64,7 @@
64 "ipaddr.js": "^2.2.0",64 "ipaddr.js": "^2.2.0",
65 "is-docker": "^3.0.0",65 "is-docker": "^3.0.0",
66 "isomorphic-git": "^1.36.3",66 "isomorphic-git": "^1.36.3",
67 "js-sha256": "^0.11.1",
67 "localforage": "^1.10.0",68 "localforage": "^1.10.0",
68 "lodash": "^4.17.21",69 "lodash": "^4.17.21",
69 "mime-types": "^3.0.2",70 "mime-types": "^3.0.2",
public/lib.js+3 -0
@@ -24,6 +24,7 @@ import chalk from 'chalk';
24import yaml from 'yaml';24import yaml from 'yaml';
25import * as chevrotain from 'chevrotain';25import * as chevrotain from 'chevrotain';
26import { gzipSync, gzip } from 'fflate';26import { gzipSync, gzip } from 'fflate';
27import { sha256 } from 'js-sha256';
2728
28/**29/**
29 * Expose the libraries to the 'window' object.30 * Expose the libraries to the 'window' object.
@@ -105,6 +106,7 @@ export default {
105 chevrotain,106 chevrotain,
106 gzipSync,107 gzipSync,
107 gzip,108 gzip,
109 sha256,
108};110};
109111
110export {112export {
@@ -132,4 +134,5 @@ export {
132 chevrotain,134 chevrotain,
133 gzipSync,135 gzipSync,
134 gzip,136 gzip,
137 sha256,
135};138};
public/script.js+2 -1
@@ -213,7 +213,7 @@ import {
213 tag_import_setting,213 tag_import_setting,
214 applyCharacterTagsToMessageDivs,214 applyCharacterTagsToMessageDivs,
215} from './scripts/tags.js';215} from './scripts/tags.js';
216import { initSecrets, readSecretState } from './scripts/secrets.js';216import { checkOpenRouterAuth, initSecrets, readSecretState } from './scripts/secrets.js';
217import { markdownExclusionExt } from './scripts/showdown-exclusion.js';217import { markdownExclusionExt } from './scripts/showdown-exclusion.js';
218import { markdownUnderscoreExt } from './scripts/showdown-underscore.js';218import { markdownUnderscoreExt } from './scripts/showdown-underscore.js';
219import { NOTE_MODULE_NAME, initAuthorsNote, metadata_keys, setFloatingPrompt, shouldWIAddPrompt } from './scripts/authors-note.js';219import { NOTE_MODULE_NAME, initAuthorsNote, metadata_keys, setFloatingPrompt, shouldWIAddPrompt } from './scripts/authors-note.js';
@@ -748,6 +748,7 @@ async function firstLoadInit() {
748 await initPresetManager();748 await initPresetManager();
749 await initSystemMessages();749 await initSystemMessages();
750 await getSettings(initLoaderHandle);750 await getSettings(initLoaderHandle);
751 await checkOpenRouterAuth();
751 initKeyboard();752 initKeyboard();
752 initDynamicStyles();753 initDynamicStyles();
753 initTags();754 initTags();
public/scripts/secrets.js+60 -13
@@ -1,5 +1,5 @@
1import { DOMPurify, moment } from '../lib.js';1import { DOMPurify, moment, sha256 } from '../lib.js';
2import { event_types, eventSource, getRequestHeaders } from '../script.js';2import { event_types, eventSource, getRequestHeaders, saveSettings } from '../script.js';
3import { t } from './i18n.js';3import { t } from './i18n.js';
4import { chat_completion_sources } from './openai.js';4import { chat_completion_sources } from './openai.js';
5import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';5import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
@@ -12,7 +12,9 @@ import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
12import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';12import { SlashCommandScope } from './slash-commands/SlashCommandScope.js';
13import { renderTemplateAsync } from './templates.js';13import { renderTemplateAsync } from './templates.js';
14import { textgen_types } from './textgen-settings.js';14import { textgen_types } from './textgen-settings.js';
15import { copyText, isTrueBoolean } from './utils.js';15import { getCurrentUserHandle } from './user.js';
16import { copyText, isTrueBoolean, uuidv4 } from './utils.js';
17import { accountStorage } from './util/AccountStorage.js';
1618
17export const SECRET_KEYS = {19export const SECRET_KEYS = {
18 HORDE: 'api_key_horde',20 HORDE: 'api_key_horde',
@@ -417,7 +419,6 @@ export async function readSecretState() {
417 secret_state = await response.json();419 secret_state = await response.json();
418 updateSecretDisplay();420 updateSecretDisplay();
419 updateInputDataLists();421 updateInputDataLists();
420 await checkOpenRouterAuth();
421 }422 }
422 } catch {423 } catch {
423 console.error('Could not read secrets file');424 console.error('Could not read secrets file');
@@ -498,6 +499,25 @@ export async function renameSecret(key, id, label) {
498}499}
499500
500/**501/**
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 */
506const 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 */
513const 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/**
501 * Redirects the user to authorize OpenRouter.521 * Redirects the user to authorize OpenRouter.
502 */522 */
503async function authorizeOpenRouter() {523async function authorizeOpenRouter() {
@@ -508,8 +528,15 @@ async function authorizeOpenRouter() {
508 }528 }
509 }529 }
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
511 const redirectUrl = new URL('/callback/openrouter', window.location.origin);538 const redirectUrl = new URL('/callback/openrouter', window.location.origin);
512 const openRouterUrl = `https://openrouter.ai/auth?callback_url=${encodeURIComponent(redirectUrl.toString())}`;539 const openRouterUrl = `https://openrouter.ai/auth?callback_url=${encodeURIComponent(redirectUrl.toString())}&code_challenge=${codeChallenge}&code_challenge_method=S256`;
513 location.href = openRouterUrl;540 location.href = openRouterUrl;
514}541}
515542
@@ -517,16 +544,32 @@ async function authorizeOpenRouter() {
517 * Checks if the OpenRouter authorization code is present in the URL, and if so, exchanges it for an API key.544 * Checks if the OpenRouter authorization code is present in the URL, and if so, exchanges it for an API key.
518 * @returns {Promise<void>}545 * @returns {Promise<void>}
519 */546 */
520async function checkOpenRouterAuth() {547export async function checkOpenRouterAuth() {
521 const params = new URLSearchParams(location.search);548 const params = new URLSearchParams(location.search);
522 const source = params.get('source');549 const source = params.get('source');
523 if (source === 'openrouter') {550 if (source === 'openrouter') {
524 const query = new URLSearchParams(params.get('query'));551 const query = new URLSearchParams(params.get('query'));
525 const code = query.get('code');
526 try {552 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
527 const response = await fetch('https://openrouter.ai/api/v1/auth/keys', {563 const response = await fetch('https://openrouter.ai/api/v1/auth/keys', {
528 method: 'POST',564 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 }),
530 });573 });
531574
532 if (!response.ok) {575 if (!response.ok) {
@@ -542,18 +585,22 @@ async function checkOpenRouterAuth() {
542585
543 if (secret_state[SECRET_KEYS.OPENROUTER]) {586 if (secret_state[SECRET_KEYS.OPENROUTER]) {
544 toastr.success('OpenRouter token saved');587 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);
549 } else {588 } else {
550 throw new Error('OpenRouter token not saved');589 throw new Error('OpenRouter token not saved');
551 }590 }
552 } catch (err) {591 } catch (err) {
553 toastr.error('Could not verify OpenRouter token. Please try again.');592 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);
555 }599 }
556 }600 }
601
602 // Clean-up any code verifiers that might be left in accountStorage from abandoned auth flows
603 accountStorage.removeItem(getVerifierKey('openrouter'));
557}604}
558605
559/**606/**