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