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>
Signed| @@ -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", |
| @@ -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", |
| @@ -24,6 +24,7 @@ import chalk from 'chalk'; | |||
| 24 | import yaml from 'yaml'; | 24 | import yaml from 'yaml'; |
| 25 | import * as chevrotain from 'chevrotain'; | 25 | import * as chevrotain from 'chevrotain'; |
| 26 | import { gzipSync, gzip } from 'fflate'; | 26 | import { gzipSync, gzip } from 'fflate'; |
| 27 | import { sha256 } from 'js-sha256'; | ||
| 27 | 28 | ||
| 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 | }; |
| 109 | 111 | ||
| 110 | export { | 112 | export { |
| @@ -132,4 +134,5 @@ export { | |||
| 132 | chevrotain, | 134 | chevrotain, |
| 133 | gzipSync, | 135 | gzipSync, |
| 134 | gzip, | 136 | gzip, |
| 137 | sha256, | ||
| 135 | }; | 138 | }; |
| @@ -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'; |
| 216 | import { initSecrets, readSecretState } from './scripts/secrets.js'; | 216 | import { checkOpenRouterAuth, initSecrets, readSecretState } from './scripts/secrets.js'; |
| 217 | import { markdownExclusionExt } from './scripts/showdown-exclusion.js'; | 217 | import { markdownExclusionExt } from './scripts/showdown-exclusion.js'; |
| 218 | import { markdownUnderscoreExt } from './scripts/showdown-underscore.js'; | 218 | import { markdownUnderscoreExt } from './scripts/showdown-underscore.js'; |
| 219 | import { NOTE_MODULE_NAME, initAuthorsNote, metadata_keys, setFloatingPrompt, shouldWIAddPrompt } from './scripts/authors-note.js'; | 219 | import { 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(); |
| @@ -1,5 +1,5 @@ | |||
| 1 | import { DOMPurify, moment } from '../lib.js'; | 1 | import { DOMPurify, moment, sha256 } from '../lib.js'; |
| 2 | import { event_types, eventSource, getRequestHeaders } from '../script.js'; | 2 | import { event_types, eventSource, getRequestHeaders, saveSettings } from '../script.js'; |
| 3 | import { t } from './i18n.js'; | 3 | import { t } from './i18n.js'; |
| 4 | import { chat_completion_sources } from './openai.js'; | 4 | import { chat_completion_sources } from './openai.js'; |
| 5 | import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js'; | 5 | import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js'; |
| @@ -12,7 +12,9 @@ import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; | |||
| 12 | import { SlashCommandScope } from './slash-commands/SlashCommandScope.js'; | 12 | import { SlashCommandScope } from './slash-commands/SlashCommandScope.js'; |
| 13 | import { renderTemplateAsync } from './templates.js'; | 13 | import { renderTemplateAsync } from './templates.js'; |
| 14 | import { textgen_types } from './textgen-settings.js'; | 14 | import { textgen_types } from './textgen-settings.js'; |
| 15 | import { copyText, isTrueBoolean } from './utils.js'; | 15 | import { getCurrentUserHandle } from './user.js'; |
| 16 | import { copyText, isTrueBoolean, uuidv4 } from './utils.js'; | ||
| 17 | import { accountStorage } from './util/AccountStorage.js'; | ||
| 16 | 18 | ||
| 17 | export const SECRET_KEYS = { | 19 | export 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 | } |
| 499 | 500 | ||
| 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 | */ | ||
| 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 | /** | ||
| 501 | * Redirects the user to authorize OpenRouter. | 521 | * Redirects the user to authorize OpenRouter. |
| 502 | */ | 522 | */ |
| 503 | async function authorizeOpenRouter() { | 523 | async function authorizeOpenRouter() { |
| @@ -508,8 +528,15 @@ async function authorizeOpenRouter() { | |||
| 508 | } | 528 | } |
| 509 | } | 529 | } |
| 510 | 530 | ||
| 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 | } |
| 515 | 542 | ||
| @@ -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 | */ |
| 520 | async function checkOpenRouterAuth() { | 547 | export 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 | }); |
| 531 | 574 | ||
| 532 | if (!response.ok) { | 575 | if (!response.ok) { |
| @@ -542,18 +585,22 @@ async function checkOpenRouterAuth() { | |||
| 542 | 585 | ||
| 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 | } |
| 558 | 605 | ||
| 559 | /** | 606 | /** |