From d7c978303031054809122d010137177a31b6ee02 Mon Sep 17 00:00:00 2001 From: Toshimitsu Takahashi Date: Sun, 12 Nov 2023 01:12:35 +0900 Subject: [PATCH 1/7] add optional permission for aesr.dev --- manifest_chrome.json | 1 + manifest_firefox.json | 1 + 2 files changed, 2 insertions(+) diff --git a/manifest_chrome.json b/manifest_chrome.json index 5f459bd..ec2e74e 100644 --- a/manifest_chrome.json +++ b/manifest_chrome.json @@ -1,5 +1,6 @@ { "minimum_chrome_version": "88.0", + "optional_host_permissions": ["https://*.aesr.dev/*"], "background": { "service_worker": "js/background.js", "type": "module" diff --git a/manifest_firefox.json b/manifest_firefox.json index 56160f5..e163dbb 100644 --- a/manifest_firefox.json +++ b/manifest_firefox.json @@ -5,6 +5,7 @@ "strict_min_version": "109.0" } }, + "optional_permissions": ["https://*.aesr.dev/*"], "background": { "scripts": ["js/background.js"] } From 2cacf0531f718a94bd0c3c2fbec9b6f5dbb42810 Mon Sep 17 00:00:00 2001 From: Toshimitsu Takahashi Date: Thu, 23 Nov 2023 15:57:28 +0900 Subject: [PATCH 2/7] wip --- manifest.json | 2 +- package.json | 2 +- src/js/handlers/remote_connect.js | 63 +++++++++++++++++++++++ src/js/options.js | 9 ++++ src/js/popup.js | 14 ++++++ src/js/remote/api-client.js | 16 ++++++ src/js/remote/code-util.js | 40 +++++++++++++++ src/js/remote/oauth-client.js | 84 +++++++++++++++++++++++++++++++ src/options.html | 27 ++++++++-- 9 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 src/js/handlers/remote_connect.js create mode 100644 src/js/remote/api-client.js create mode 100644 src/js/remote/code-util.js create mode 100644 src/js/remote/oauth-client.js diff --git a/manifest.json b/manifest.json index 9865a04..3ae9681 100644 --- a/manifest.json +++ b/manifest.json @@ -1,5 +1,5 @@ { - "version": "4.0.3", + "version": "5.0.0", "name": "AWS Extend Switch Roles", "description": "Extend your AWS IAM switching roles. You can set the configuration like aws config format", "short_name": "Extend SwitchRole", diff --git a/package.json b/package.json index 14d8d03..f5b153d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aws-extend-switch-roles", - "version": "4.0.3", + "version": "5.0.0", "description": "Extend your AWS IAM switching roles by Chrome extension", "main": "index.js", "directories": { diff --git a/src/js/handlers/remote_connect.js b/src/js/handlers/remote_connect.js new file mode 100644 index 0000000..bd0a67d --- /dev/null +++ b/src/js/handlers/remote_connect.js @@ -0,0 +1,63 @@ +import { SessionMemory, StorageProvider } from '../lib/storage_repository.js'; +import { nowEpochSeconds } from '../lib/util.js'; +import { OAuthClient } from '../remote/oauth-client.js'; + +const sessionMemory = new SessionMemory(chrome || browser); + +export async function remoteConnect(subdomain, clientId) { + const permparams = { origins: [`https://*.${subdomain}.aesr.dev/*`] }; + const granted = await chrome.permissions.request(permparams); + if (!granted) return; + + const oauthClient = new OAuthClient(subdomain, clientId); + const { authorizeUrl, codeVerifier } = await oauthClient.startAuthFlow(); + const remoteConnectParams = { subdomain, clientId, codeVerifier }; + await sessionMemory.set({ remoteConnectParams }); + + window.location.href = authorizeUrl; +} + +export async function remoteCallback(uRL) { + const remoteConnectParams = await sessionMemory.get(['remoteConnectParams']); + const { subdomain, clientId, codeVerifier } = remoteConnectParams; + + const oauthClient = new OAuthClient(subdomain, clientId); + const authCode = oauthClient.validateCallbackUrl(uRL); + + const now = nowEpochSeconds(); + const resultToken = await oauthClient.getIdToken(codeVerifier, authCode); + await sessionMemory.set({ + remoteConnectParams: { + idToken: resultToken.id_token, + expiresAt: now + resultToken.expires_in - 15, + apiEndpoint: `https://api.${subdomain}.aesr.dev`, + } + }); + + const localRepo = StorageProvider.getLocalRepository(); + const remoteConnectInfo = { + subdomain, + clientId, + refreshToken: resultToken.refresh_token, + }; + const { profile } = await oauthClient.getUserConfig(resultToken.id_token); + await localRepo.set({ remoteConnectInfo, remoteConfigProfile: profile }); +} + +export async function remoteRefreshIdToken() { + const localRepo = StorageProvider.getLocalRepository(); + const { remoteConnectInfo } = await localRepo.get(['remoteConnectInfo']); + const { subdomain, clientId, refreshToken } = remoteConnectInfo; + + const oauthClient = new OAuthClient(subdomain, clientId); + + const now = nowEpochSeconds(); + const resultToken = await oauthClient.getIdTokenByRefresh(refreshToken); + await sessionMemory.set({ + remoteConnectParams: { + idToken: resultToken.id_token, + expiresAt: now + resultToken.expires_in - 15, + apiEndpoint: `https://api.${subdomain}.aesr.dev`, + } + }); +} diff --git a/src/js/options.js b/src/js/options.js index 060104a..ece44dc 100644 --- a/src/js/options.js +++ b/src/js/options.js @@ -4,6 +4,7 @@ import { loadConfigIni, saveConfigIni } from './lib/config_ini.js'; import { ColorPicker } from './lib/color_picker.js'; import { SessionMemory, StorageProvider } from './lib/storage_repository.js'; import { writeProfileSetToTable } from "./lib/profile_db.js"; +import { remoteConnect } from './handlers/remote_connect.js'; function elById(id) { return document.getElementById(id); @@ -123,6 +124,14 @@ window.onload = function() { } } + elById('remoteButton').onclick = function() { + const subdomain = elById('configHubDomain').value; + const clientId = elById('configHubClientId').value; + remoteConnect(subdomain, clientId).catch(err => { + console.error(err); + }); + } + syncStorageRepo.get(['configSenderId', 'configStorageArea', 'visualMode'].concat(booleanSettings)) .then(data => { elById('configSenderIdText').value = data.configSenderId || ''; diff --git a/src/js/popup.js b/src/js/popup.js index 8b4775f..39bc410 100644 --- a/src/js/popup.js +++ b/src/js/popup.js @@ -2,6 +2,7 @@ import { createRoleListItem } from './lib/create_role_list_item.js'; import { CurrentContext } from './lib/current_context.js'; import { findTargetProfiles } from './lib/target_profiles.js'; import { SessionMemory, SyncStorageRepository } from './lib/storage_repository.js'; +import { remoteCallback } from './handlers/remote_connect.js'; const sessionMemory = new SessionMemory(chrome || browser); @@ -92,6 +93,19 @@ function main() { noMain.style.display = 'block'; } }) + } else if (url.host.endsWith('.aesr.dev') && url.pathname.startsWith('/callback')) { + remoteCallback(url) + .then(() => { + const p = noMain.querySelector('p'); + p.textContent = "Successfully connected to AESR Config Hub!"; + noMain.style.display = 'block'; + }) + .catch(err => { + console.error(err); + const p = noMain.querySelector('p'); + p.textContent = "Failed to connected to AESR Config Hub."; + noMain.style.display = 'block'; + }); } else { const p = noMain.querySelector('p'); p.textContent = "You'll see the role list here when the current tab is AWS Management Console page."; diff --git a/src/js/remote/api-client.js b/src/js/remote/api-client.js new file mode 100644 index 0000000..6f228dd --- /dev/null +++ b/src/js/remote/api-client.js @@ -0,0 +1,16 @@ +export class ApiClient { + constructor(subdomain) { + this.domain = subdomain + '.aesr.dev'; + } + + async fetchUserConfig(idToken) { + const res = await fetch(`https://api.${this.domain}/user/config`, { + method: 'GET', + headers: { + 'Authorization': 'Bearer ' + idToken, + }, + }) + const result = await res.json(); + return result; + } +} diff --git a/src/js/remote/code-util.js b/src/js/remote/code-util.js new file mode 100644 index 0000000..d4d2d41 --- /dev/null +++ b/src/js/remote/code-util.js @@ -0,0 +1,40 @@ + +export function createCodeVerifier() { + const data = randomData(50); + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + let str = '', buf = data[0]; + let i = 1, li = data.length - 1, blen = 8; + while (true) { + str += chars[buf % 64]; + if (i === li) break; + blen -= 6; + buf = buf >> 6; + if (blen < 6) { + buf += (data[++i] << blen); + blen += 8; + } + } + return str; +} + +function randomData(len) { + const arr = new Uint8Array(len); + return window.crypto.getRandomValues(arr); +} + +export async function createCodeChallenge(verifier) { + const hash = await createSha256(verifier); + return encodeBase64URL(new Uint8Array(hash)); +} + +async function createSha256(str) { + const data = new TextEncoder().encode(str); + return window.crypto.subtle.digest('SHA-256', data); +} + +function encodeBase64URL(bytes) { + const buf = window.btoa(String.fromCharCode(...bytes)) + return buf.replace(/([+/=])/g, (_m, p1) => { + return p1 == '+' ? '-' : p1 == '/' ? '_' : ''; + }); +} diff --git a/src/js/remote/oauth-client.js b/src/js/remote/oauth-client.js new file mode 100644 index 0000000..c752c7b --- /dev/null +++ b/src/js/remote/oauth-client.js @@ -0,0 +1,84 @@ +import { createCodeChallenge, createCodeVerifier } from './code-util.js'; + +export class OAuthClient { + constructor(subdomain, clientId) { + this.domain = subdomain + '.aesr.dev'; + this.clientId = clientId; + } + + async startAuthFlow() { + const codeVerifier = createCodeVerifier(); + const codeChallenge = await createCodeChallenge(codeVerifier); + + const authorizeUrl = `https://auth.${this.domain}/oauth2/authorize` + const params = new URLSearchParams({ + response_type: 'code', + client_id: this.clientId, + redirect_uri: `https://api.${this.domain}/callback`, + code_challenge_method: 'S256', + code_challenge: codeChallenge + }) + + return { + authorizeUrl: authorizeUrl + '?' + params.toString(), + codeVerifier, + codeChallenge, + }; + } + + validateCallbackUrl(uRL) { + if (uRL.host === `api.${this.domain}` && uRL.pathname === '/callback') { + const authCode = uRL.searchParams.get('code'); + if (authCode) return authCode; + } + } + + async getIdToken(codeVerifier, authCode) { + const params = { + grant_type: 'authorization_code', + client_id: this.clientId, + redirect_uri: `https://api.${this.domain}/callback`, + code: authCode, + code_verifier: codeVerifier + }; + + const res = await fetch(`https://auth.${this.domain}/oauth2/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(params), + }); + const result = await res.json(); + return result; + } + + async getIdTokenByRefresh(refreshToken) { + const params = { + grant_type: 'refresh_token', + client_id: this.clientId, + refresh_token: refreshToken, + }; + + const res = await fetch(`https://auth.${this.domain}/oauth2/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(params), + }); + const result = await res.json(); + return result; + } + + async getUserConfig(idToken) { + const res = await fetch(`https://api.${this.domain}/user/config`, { + method: 'GET', + headers: { + 'Authorization': 'Bearer ' + idToken, + }, + }) + const result = await res.json(); + return result; + } +} diff --git a/src/options.html b/src/options.html index b33d128..a022050 100644 --- a/src/options.html +++ b/src/options.html @@ -116,6 +116,14 @@

Configuration

+
+ Storage in configuration: + + +
+
+ Switch into fetching from Config Hub +
@@ -129,6 +137,20 @@

Configuration

+
+
+ + .aesr.dev +
+
+ + +
+
+ + +
+

Settings

    @@ -136,11 +158,6 @@

    Settings

  • (Experimental, Supporters only)
  • (Experimental) (temporarily disabled)
  • -
  • - Configuration storage: - - -
  • Visual mode: From 69c5a48da58af01f0d80b32a6d3a807305894f26 Mon Sep 17 00:00:00 2001 From: Toshimitsu Takahashi Date: Thu, 28 Dec 2023 17:07:21 +0900 Subject: [PATCH 3/7] update opt --- package-lock.json | 4 ++-- src/js/options.js | 24 ++++++++++++------- src/options.html | 59 ++++++++++++++++++++++++++++++++--------------- 3 files changed, 58 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index d7ae272..595de35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aws-extend-switch-roles", - "version": "4.0.1", + "version": "5.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "aws-extend-switch-roles", - "version": "4.0.1", + "version": "5.0.0", "license": "MIT", "dependencies": { "aesr-config": "^0.4.1" diff --git a/src/js/options.js b/src/js/options.js index ece44dc..c10a532 100644 --- a/src/js/options.js +++ b/src/js/options.js @@ -17,6 +17,22 @@ window.onload = function() { let configStorageArea = 'sync'; let colorPicker = new ColorPicker(document); + elById('switchConfigHubButton').onclick = function() { + elById('configHubPanel').style.display = 'block'; + elById('standalonePanel').style.display = 'none'; + } + elById('cancelConfigHubButton').onclick = function() { + elById('standalonePanel').style.display = 'block'; + elById('configHubPanel').style.display = 'none'; + } + elById('connectConfigHubButton').onclick = function() { + const subdomain = elById('configHubDomain').value; + const clientId = elById('configHubClientId').value; + remoteConnect(subdomain, clientId).catch(err => { + elById('remoteMsgSpan').textContent = err.message; + }); + } + let selection = []; let textArea = elById('awsConfigTextArea'); textArea.onselect = function() { @@ -124,14 +140,6 @@ window.onload = function() { } } - elById('remoteButton').onclick = function() { - const subdomain = elById('configHubDomain').value; - const clientId = elById('configHubClientId').value; - remoteConnect(subdomain, clientId).catch(err => { - console.error(err); - }); - } - syncStorageRepo.get(['configSenderId', 'configStorageArea', 'visualMode'].concat(booleanSettings)) .then(data => { elById('configSenderIdText').value = data.configSenderId || ''; diff --git a/src/options.html b/src/options.html index a022050..991e83f 100644 --- a/src/options.html +++ b/src/options.html @@ -60,9 +60,11 @@ padding: 0; line-height: 26px; } -#settings input { +input[type="checkbox"], +input[type="radio"] { position: relative; top: 2px; + margin-right: .33ex; } #awsConfigTextArea { box-sizing: border-box; @@ -110,19 +112,36 @@ border-color: #9d9d9d; color: #eee; } +.formItem { + line-height: 32px; +} +.formItem label { + display: inline-block; + min-width: 4em; + padding-right: 1ex; +} +.radioGroup { + display: flex; + flex-direction: row; + line-height: 26px; +} +.radioGroup label { + margin-left: .5ex; + margin-right: 1ex; +}
    -
    -

    Configuration

    -
    - Storage in configuration: +

    Configuration

    +
    +
    +
    + Storage: -
    -
    - Switch into fetching from Config Hub + +
    @@ -137,17 +156,19 @@

    Configuration

    -
    -
    - - .aesr.dev +
    +

    Config Hub settings

    +
    + + .aesr.dev
    -
    - - +
    + +
    - + +
    @@ -158,8 +179,8 @@

    Settings

  • (Experimental, Supporters only)
  • (Experimental) (temporarily disabled)
  • -
  • - Visual mode: +
  • + Visual mode: @@ -168,7 +189,7 @@

    Settings

  • Extension API

    -
    +
    From 1c2c5b93e6e28b913f7472c1ce65ae0ecb3973c5 Mon Sep 17 00:00:00 2001 From: Toshimitsu Takahashi Date: Sun, 17 Mar 2024 20:54:45 +0900 Subject: [PATCH 4/7] update options css --- src/js/options.js | 3 +++ src/options.html | 36 ++++++++++++++++++++---------------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/js/options.js b/src/js/options.js index c10a532..0355b35 100644 --- a/src/js/options.js +++ b/src/js/options.js @@ -89,6 +89,9 @@ window.onload = function() { } } else { signinEndpointInHereCheckBox.disabled = true; + const schb = elById('switchConfigHubButton') + schb.disabled = true; + schb.title = 'Supporters only'; } }); booleanSettings.push('signinEndpointInHere'); diff --git a/src/options.html b/src/options.html index 991e83f..90a9a87 100644 --- a/src/options.html +++ b/src/options.html @@ -15,12 +15,16 @@ padding: 0; margin: 0; } +h1 { font-size: 22px; margin-block: 14px 10px } +h2 { font-size: 17px; margin-block: 14px } +h3 { font-size: 15px; margin-block: 8px } +section { margin-block: 10px 20px } .pane { box-sizing: border-box; height: 100vh; max-width: 660px; width: 100%; - padding: 10px 20px; + padding-inline: 20px; } #settingPane { background-color: #fff; @@ -46,11 +50,6 @@ font-weight: bold; margin: 5px 0; } -#settingPane h1 { - font-size: 20px; - line-height: 1.125; - margin: 1em 0 .68em; -} #settings ul { list-style: none; margin: 0 0; padding: 0; @@ -74,7 +73,8 @@ resize: none; white-space: pre; width: 100%; - height: calc(100vh - 400px); + height: calc(100vh - 456px); + margin: 8px 0; max-height: 800px; } #colorValue { @@ -87,8 +87,12 @@ font-size: 14px; font-family: Consolas, "Courier New", Courier, Monaco, monospace; padding: 0.5ex; + overflow-x: hidden; +} +input, button { + font-size: 14px; + padding-block: 3px; } -input, button { font-size: 16px } code { color: #222; font-weight: bold; @@ -144,7 +148,7 @@

    Configuration

    -
    +
    # @@ -156,7 +160,7 @@

    Configuration

    -
    +
    -

    How to configure

    +

    How to configure

    -

    Simple Configuration

    +

    Simple Configuration

    The simplest configuration is for multiple target roles when you always intend to show the whole list. Target roles can be expressed with a role_arn or with both aws_account_id and role_name.

    -

    Optional parameters

    +

    Optional parameters

    • color - The RGB hex value (without the prefix '#') for the color of the header bottom border and around the current profile.
    • region - Changing the region whenever switching the role if this parameter is specified.
    • @@ -224,7 +228,7 @@

      Optional parameters

    -

    Complex Configuration

    +

    Complex Configuration

    More complex configurations involve multiple AWS accounts and/or organizations.

    • A profile specified by the source_profile of the others is defined as base account
    • @@ -309,7 +313,7 @@

      Complex Configuration

      The 'Show only matching roles' setting is for use with more sophisticated account structures where you're using AWS Organizations with multiple accounts along with AWS Federated Logins via something like Active Directory or Google GSuite. Common practice is to have a role in the master account that is allowed to assume a role of the same name in other member accounts. Checking this box means that if you're logged in to the 'Developer' role in the master account, only member accounts with a role_arn ending in 'role/Developer' will be shown. You won't see roles that your current role can't actually assume.

    -

    Settings

    +

    Settings

    • Hide account id hides the account_id for each profile.
    • Show only matching roles filters to only show profiles with roles that match your role in your master account.
    • @@ -321,7 +325,7 @@

      Settings

    -

    Extension API

    +

    Extension API

    • Config sender extension allowed by the ID can send your switch roles configuration to this extension. 'Configuration storage' forcibly becomes 'Local' when the configuration is received from a config sender. From 02cc7e00d2f33fd24ac45781fbac861c17f4876b Mon Sep 17 00:00:00 2001 From: Toshimitsu Takahashi Date: Sun, 17 Mar 2024 22:32:21 +0900 Subject: [PATCH 5/7] fix remote connect --- src/js/handlers/remote_connect.js | 11 +++++++++-- src/js/options.js | 9 ++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/js/handlers/remote_connect.js b/src/js/handlers/remote_connect.js index bd0a67d..b280b6b 100644 --- a/src/js/handlers/remote_connect.js +++ b/src/js/handlers/remote_connect.js @@ -18,7 +18,7 @@ export async function remoteConnect(subdomain, clientId) { } export async function remoteCallback(uRL) { - const remoteConnectParams = await sessionMemory.get(['remoteConnectParams']); + const { remoteConnectParams } = await sessionMemory.get(['remoteConnectParams']); const { subdomain, clientId, codeVerifier } = remoteConnectParams; const oauthClient = new OAuthClient(subdomain, clientId); @@ -53,7 +53,7 @@ export async function remoteRefreshIdToken() { const now = nowEpochSeconds(); const resultToken = await oauthClient.getIdTokenByRefresh(refreshToken); - await sessionMemory.set({ + await localRepo.set({ remoteConnectParams: { idToken: resultToken.id_token, expiresAt: now + resultToken.expires_in - 15, @@ -61,3 +61,10 @@ export async function remoteRefreshIdToken() { } }); } + +export async function getRemoteConnectInfo() { + const localRepo = StorageProvider.getLocalRepository(); + const { remoteConnectInfo } = await localRepo.get(['remoteConnectInfo']); + const { subdomain, clientId } = remoteConnectInfo; + return { subdomain, clientId }; +} diff --git a/src/js/options.js b/src/js/options.js index 0355b35..e6a3424 100644 --- a/src/js/options.js +++ b/src/js/options.js @@ -2,7 +2,7 @@ import { ConfigParser } from 'aesr-config'; import { nowEpochSeconds } from './lib/util.js'; import { loadConfigIni, saveConfigIni } from './lib/config_ini.js'; import { ColorPicker } from './lib/color_picker.js'; -import { SessionMemory, StorageProvider } from './lib/storage_repository.js'; +import { LocalStorageRepository, SessionMemory, StorageProvider } from './lib/storage_repository.js'; import { writeProfileSetToTable } from "./lib/profile_db.js"; import { remoteConnect } from './handlers/remote_connect.js'; @@ -87,6 +87,13 @@ window.onload = function() { signinEndpointInHereCheckBox.onchange = function() { syncStorageRepo.set({ signinEndpointInHere: this.checked }); } + + getRemoteConnectInfo().then(({ subdomain, clientId }) => { + elById('configHubDomain').value = subdomain; + elById('configHubClientId').value = clientId; + elById('cancelConfigHubButton').textContent = 'Disconnect'; + elById('connectConfigHubButton').textContent = 'Reload'; + }); } else { signinEndpointInHereCheckBox.disabled = true; const schb = elById('switchConfigHubButton') From 35ba1722f0b9aba2da00b42a7598f3e7dfb7eb00 Mon Sep 17 00:00:00 2001 From: Toshimitsu Takahashi Date: Mon, 18 Mar 2024 09:47:47 +0900 Subject: [PATCH 6/7] update remote connect option --- src/js/handlers/remote_connect.js | 31 ++++------ src/js/handlers/update_profiles.js | 20 ++++++- src/js/options.js | 96 +++++++++++++++++++++++------- src/js/remote/api-client.js | 16 ----- src/js/remote/oauth-client.js | 4 +- src/options.html | 8 ++- 6 files changed, 112 insertions(+), 63 deletions(-) delete mode 100644 src/js/remote/api-client.js diff --git a/src/js/handlers/remote_connect.js b/src/js/handlers/remote_connect.js index b280b6b..f2c38ae 100644 --- a/src/js/handlers/remote_connect.js +++ b/src/js/handlers/remote_connect.js @@ -25,7 +25,7 @@ export async function remoteCallback(uRL) { const authCode = oauthClient.validateCallbackUrl(uRL); const now = nowEpochSeconds(); - const resultToken = await oauthClient.getIdToken(codeVerifier, authCode); + const resultToken = await oauthClient.verify(codeVerifier, authCode); await sessionMemory.set({ remoteConnectParams: { idToken: resultToken.id_token, @@ -40,31 +40,26 @@ export async function remoteCallback(uRL) { clientId, refreshToken: resultToken.refresh_token, }; + await localRepo.set({ remoteConnectInfo }); const { profile } = await oauthClient.getUserConfig(resultToken.id_token); - await localRepo.set({ remoteConnectInfo, remoteConfigProfile: profile }); + return { profile }; } -export async function remoteRefreshIdToken() { +export async function getRemoteConnectInfo() { const localRepo = StorageProvider.getLocalRepository(); const { remoteConnectInfo } = await localRepo.get(['remoteConnectInfo']); - const { subdomain, clientId, refreshToken } = remoteConnectInfo; + return remoteConnectInfo; +} - const oauthClient = new OAuthClient(subdomain, clientId); - - const now = nowEpochSeconds(); - const resultToken = await oauthClient.getIdTokenByRefresh(refreshToken); - await localRepo.set({ - remoteConnectParams: { - idToken: resultToken.id_token, - expiresAt: now + resultToken.expires_in - 15, - apiEndpoint: `https://api.${subdomain}.aesr.dev`, - } - }); +export function deleteRemoteConnectInfo() { + const localRepo = StorageProvider.getLocalRepository(); + localRepo.delete(['remoteConnectInfo']); } -export async function getRemoteConnectInfo() { +export async function deleteRefreshTokenFromRemoteConnectInfo() { const localRepo = StorageProvider.getLocalRepository(); + localRepo.delete(['remoteConnectInfo']); const { remoteConnectInfo } = await localRepo.get(['remoteConnectInfo']); - const { subdomain, clientId } = remoteConnectInfo; - return { subdomain, clientId }; + delete remoteConnectInfo.refreshToken; + await localRepo.set({ remoteConnectInfo }); } diff --git a/src/js/handlers/update_profiles.js b/src/js/handlers/update_profiles.js index 571584e..5812f4b 100644 --- a/src/js/handlers/update_profiles.js +++ b/src/js/handlers/update_profiles.js @@ -1,15 +1,31 @@ import { nowEpochSeconds } from "../lib/util.js"; import { DataProfilesSplitter } from "../lib/data_profiles_splitter.js"; -import { writeProfileItemsToTable, refreshDB } from "../lib/profile_db.js"; +import { writeProfileSetToTable, writeProfileItemsToTable, refreshDB } from "../lib/profile_db.js"; import { StorageProvider } from "../lib/storage_repository.js"; import { saveConfigIni } from "../lib/config_ini.js"; +import { OAuthClient } from "../remote/oauth-client.js"; +import { deleteRefreshTokenFromRemoteConnectInfo } from "./remote_connect.js"; export async function updateProfilesTable() { const syncRepo = StorageProvider.getSyncRepository(); const localRepo = StorageProvider.getLocalRepository(); const { configStorageArea = 'sync', profilesLastUpdated = 0 } = await syncRepo.get(['configStorageArea', 'profilesLastUpdated']); - const { profilesTableUpdated = 0 } = await localRepo.get(['profilesTableUpdated']); + const { profilesTableUpdated = 0, remoteConnectInfo = null } = await localRepo.get(['profilesTableUpdated', 'remoteConnectInfo']); + + if (remoteConnectInfo) { + const oaClient = new OAuthClient(remoteConnectInfo.subdomain, remoteConnectInfo.clientId); + try { + const idToken = await oaClient.getIdTokenByRefresh(remoteConnectInfo.refreshToken); + const { profile } = await oaClient.getUserConfig(idToken); + await writeProfileSetToTable(profile); + console.log('Updated profile from Config Hub'); + } catch { + await deleteRefreshTokenFromRemoteConnectInfo(); + console.warn('Failed to profile from Config Hub, so the refresh token was deleted.'); + } + return; + } const now = nowEpochSeconds(); if (profilesTableUpdated === 0) { diff --git a/src/js/options.js b/src/js/options.js index e6a3424..c60c3e3 100644 --- a/src/js/options.js +++ b/src/js/options.js @@ -2,9 +2,10 @@ import { ConfigParser } from 'aesr-config'; import { nowEpochSeconds } from './lib/util.js'; import { loadConfigIni, saveConfigIni } from './lib/config_ini.js'; import { ColorPicker } from './lib/color_picker.js'; -import { LocalStorageRepository, SessionMemory, StorageProvider } from './lib/storage_repository.js'; +import { SessionMemory, StorageProvider } from './lib/storage_repository.js'; import { writeProfileSetToTable } from "./lib/profile_db.js"; -import { remoteConnect } from './handlers/remote_connect.js'; +import { remoteConnect, getRemoteConnectInfo, deleteRemoteConnectInfo } from './handlers/remote_connect.js'; +import { OAuthClient } from './remote/oauth-client.js'; function elById(id) { return document.getElementById(id); @@ -18,18 +19,36 @@ window.onload = function() { let colorPicker = new ColorPicker(document); elById('switchConfigHubButton').onclick = function() { - elById('configHubPanel').style.display = 'block'; - elById('standalonePanel').style.display = 'none'; + updateRemoteFieldsState('disconnected'); } elById('cancelConfigHubButton').onclick = function() { - elById('standalonePanel').style.display = 'block'; - elById('configHubPanel').style.display = 'none'; + updateRemoteFieldsState('not_shown'); } elById('connectConfigHubButton').onclick = function() { const subdomain = elById('configHubDomain').value; const clientId = elById('configHubClientId').value; remoteConnect(subdomain, clientId).catch(err => { - elById('remoteMsgSpan').textContent = err.message; + updateMessage('remoteMsgSpan', err.message, 'warn'); + }); + } + elById('disconnectConfigHubButton').onclick = function() { + updateRemoteFieldsState('disconnected'); + deleteRemoteConnectInfo(); + } + elById('reloadConfigHubButton').onclick = function() { + getRemoteConnectInfo().then(({ subdomain, clientId, refreshToken }) => { + if (subdomain && clientId) { + const oaClient = new OAuthClient(subdomain, clientId); + oaClient.getIdTokenByRefresh(refreshToken).then(idToken => { + return oaClient.getUserConfig(idToken); + }).then(({ profile }) => { + return writeProfileSetToTable(profile); + }).then(() => { + updateMessage('remoteMsgSpan', "Successfully reloaded config from Hub!"); + }).catch(e => { + updateMessage('remoteMsgSpan', `Failed to reload because ${e.message}`, 'warn'); + }); + } }); } @@ -51,26 +70,21 @@ window.onload = function() { } } - let msgSpan = elById('msgSpan'); - let saveButton = elById('saveButton'); - saveButton.onclick = function() { + elById('saveButton').onclick = function() { try { const area = elById('configStorageSyncRadioButton').checked ? 'sync' : 'local'; saveConfiguration(textArea.value, area).then(() => { - updateMessage(msgSpan, 'Configuration has been updated!', 'success'); - setTimeout(() => { - msgSpan.firstChild.remove(); - }, 2500); + updateMessage('msgSpan', 'Configuration has been updated!'); }) .catch(lastError => { let msg = lastError.message if (lastError.message === "A mutation operation was attempted on a database that did not allow mutations.") { msg = "Configuration cannot be saved while using Private Browsing." } - updateMessage(msgSpan, msg, 'warn'); + updateMessage('msgSpan', msg, 'warn'); }); } catch (e) { - updateMessage(msgSpan, `Failed to save because ${e.message}`, 'warn'); + updateMessage('msgSpan', `Failed to save because ${e.message}`, 'warn'); } } @@ -88,11 +102,17 @@ window.onload = function() { syncStorageRepo.set({ signinEndpointInHere: this.checked }); } - getRemoteConnectInfo().then(({ subdomain, clientId }) => { - elById('configHubDomain').value = subdomain; - elById('configHubClientId').value = clientId; - elById('cancelConfigHubButton').textContent = 'Disconnect'; - elById('connectConfigHubButton').textContent = 'Reload'; + getRemoteConnectInfo().then(({ subdomain, clientId, refreshToken }) => { + if (subdomain && clientId) { + elById('configHubDomain').value = subdomain; + elById('configHubClientId').value = clientId; + if (refreshToken) { + updateRemoteFieldsState('connected'); + } else { + updateRemoteFieldsState('disconnected'); + updateMessage('remoteMsgSpan', "Please reconnect because your credentials have expired.", 'warn'); + } + } }); } else { signinEndpointInHereCheckBox.disabled = true; @@ -195,7 +215,8 @@ async function saveConfiguration(text, storageArea) { await localRepo.set({ profilesTableUpdated: now }); } -function updateMessage(el, msg, cls) { +function updateMessage(elId, msg, cls = 'success') { + const el = elById(elId); const span = document.createElement('span'); span.className = cls; span.textContent = msg; @@ -205,4 +226,35 @@ function updateMessage(el, msg, cls) { } else { el.appendChild(span); } + + if (cls === 'success') { + setTimeout(() => { + span.remove(); + }, 2500); + } +} + +function updateRemoteFieldsState(state) { + if (state === 'connected') { + elById('configHubPanel').style.display = 'block'; + elById('standalonePanel').style.display = 'none'; + elById('configHubDomain').disabled = true; + elById('configHubClientId').disabled = true; + elById('cancelConfigHubButton').style.display = 'none'; + elById('connectConfigHubButton').style.display = 'none'; + elById('disconnectConfigHubButton').style.display = 'inline-block'; + elById('reloadConfigHubButton').style.display = 'inline-block'; + } else if (state === 'disconnected') { + elById('configHubPanel').style.display = 'block'; + elById('standalonePanel').style.display = 'none'; + elById('configHubDomain').disabled = false; + elById('configHubClientId').disabled = false; + elById('cancelConfigHubButton').style.display = 'inline-block'; + elById('connectConfigHubButton').style.display = 'inline-block'; + elById('disconnectConfigHubButton').style.display = 'none'; + elById('reloadConfigHubButton').style.display = 'none'; + } else { // not shown + elById('standalonePanel').style.display = 'block'; + elById('configHubPanel').style.display = 'none'; + } } diff --git a/src/js/remote/api-client.js b/src/js/remote/api-client.js deleted file mode 100644 index 6f228dd..0000000 --- a/src/js/remote/api-client.js +++ /dev/null @@ -1,16 +0,0 @@ -export class ApiClient { - constructor(subdomain) { - this.domain = subdomain + '.aesr.dev'; - } - - async fetchUserConfig(idToken) { - const res = await fetch(`https://api.${this.domain}/user/config`, { - method: 'GET', - headers: { - 'Authorization': 'Bearer ' + idToken, - }, - }) - const result = await res.json(); - return result; - } -} diff --git a/src/js/remote/oauth-client.js b/src/js/remote/oauth-client.js index c752c7b..581bab7 100644 --- a/src/js/remote/oauth-client.js +++ b/src/js/remote/oauth-client.js @@ -33,7 +33,7 @@ export class OAuthClient { } } - async getIdToken(codeVerifier, authCode) { + async verify(codeVerifier, authCode) { const params = { grant_type: 'authorization_code', client_id: this.clientId, @@ -68,7 +68,7 @@ export class OAuthClient { body: new URLSearchParams(params), }); const result = await res.json(); - return result; + return result.id_token; } async getUserConfig(idToken) { diff --git a/src/options.html b/src/options.html index 90a9a87..8244641 100644 --- a/src/options.html +++ b/src/options.html @@ -171,13 +171,15 @@

      Config Hub settings

    - - + + + +
-

Settings

+

Settings

connect
  • From b19dd0d3c0d70b6eb5ae2e8379855c9cb4e5765d Mon Sep 17 00:00:00 2001 From: Toshimitsu Takahashi Date: Wed, 3 Apr 2024 02:24:29 +0900 Subject: [PATCH 7/7] fix --- src/js/handlers/remote_connect.js | 4 +--- src/js/handlers/update_profiles.js | 16 +++++---------- src/js/lib/profile_db.js | 6 ++++-- src/js/lib/reload-config.js | 21 ++++++++++++++++++++ src/js/options.js | 32 ++++++++++++++++-------------- src/js/popup.js | 14 ++++++++++--- src/js/remote/oauth-client.js | 21 ++++++++++++++++++++ src/popup.html | 1 + src/updated.html | 7 ++++++- 9 files changed, 87 insertions(+), 35 deletions(-) create mode 100644 src/js/lib/reload-config.js diff --git a/src/js/handlers/remote_connect.js b/src/js/handlers/remote_connect.js index f2c38ae..b627b68 100644 --- a/src/js/handlers/remote_connect.js +++ b/src/js/handlers/remote_connect.js @@ -41,8 +41,7 @@ export async function remoteCallback(uRL) { refreshToken: resultToken.refresh_token, }; await localRepo.set({ remoteConnectInfo }); - const { profile } = await oauthClient.getUserConfig(resultToken.id_token); - return { profile }; + return await oauthClient.getUserConfig(resultToken.id_token); } export async function getRemoteConnectInfo() { @@ -58,7 +57,6 @@ export function deleteRemoteConnectInfo() { export async function deleteRefreshTokenFromRemoteConnectInfo() { const localRepo = StorageProvider.getLocalRepository(); - localRepo.delete(['remoteConnectInfo']); const { remoteConnectInfo } = await localRepo.get(['remoteConnectInfo']); delete remoteConnectInfo.refreshToken; await localRepo.set({ remoteConnectInfo }); diff --git a/src/js/handlers/update_profiles.js b/src/js/handlers/update_profiles.js index 5812f4b..a817931 100644 --- a/src/js/handlers/update_profiles.js +++ b/src/js/handlers/update_profiles.js @@ -1,10 +1,9 @@ import { nowEpochSeconds } from "../lib/util.js"; import { DataProfilesSplitter } from "../lib/data_profiles_splitter.js"; -import { writeProfileSetToTable, writeProfileItemsToTable, refreshDB } from "../lib/profile_db.js"; +import { writeProfileItemsToTable, refreshDB } from "../lib/profile_db.js"; import { StorageProvider } from "../lib/storage_repository.js"; import { saveConfigIni } from "../lib/config_ini.js"; -import { OAuthClient } from "../remote/oauth-client.js"; -import { deleteRefreshTokenFromRemoteConnectInfo } from "./remote_connect.js"; +import { reloadConfig } from "../lib/reload-config.js"; export async function updateProfilesTable() { const syncRepo = StorageProvider.getSyncRepository(); @@ -14,15 +13,10 @@ export async function updateProfilesTable() { const { profilesTableUpdated = 0, remoteConnectInfo = null } = await localRepo.get(['profilesTableUpdated', 'remoteConnectInfo']); if (remoteConnectInfo) { - const oaClient = new OAuthClient(remoteConnectInfo.subdomain, remoteConnectInfo.clientId); try { - const idToken = await oaClient.getIdTokenByRefresh(remoteConnectInfo.refreshToken); - const { profile } = await oaClient.getUserConfig(idToken); - await writeProfileSetToTable(profile); - console.log('Updated profile from Config Hub'); - } catch { - await deleteRefreshTokenFromRemoteConnectInfo(); - console.warn('Failed to profile from Config Hub, so the refresh token was deleted.'); + await reloadConfig(remoteConnectInfo); + } catch (err) { + console.warn('Failed to get profile from Config Hub'); } return; } diff --git a/src/js/lib/profile_db.js b/src/js/lib/profile_db.js index c30b5a1..ea81f5d 100644 --- a/src/js/lib/profile_db.js +++ b/src/js/lib/profile_db.js @@ -11,15 +11,17 @@ export async function writeProfileSetToTable(profileSet) { }); await dbManager.transaction('profiles', async dbTable => { + const { singles = [], complexes = [] } = profileSet; let i = 0; - for (const profile of profileSet.singles) { + + for (const profile of singles) { await dbTable.insert({ profilePath: `[SINGLE];${formatNum(++i)}`, ...profile, }); } - for (const baseProfile of profileSet.complexes) { + for (const baseProfile of complexes) { const { targets, ...props } = baseProfile; await dbTable.insert({ profilePath: `[COMPLEX];${formatNum(++i)}`, diff --git a/src/js/lib/reload-config.js b/src/js/lib/reload-config.js new file mode 100644 index 0000000..f96ea9a --- /dev/null +++ b/src/js/lib/reload-config.js @@ -0,0 +1,21 @@ +import { OAuthClient, RefreshTokenError } from "../remote/oauth-client.js"; +import { writeProfileSetToTable } from "./profile_db.js"; +import { deleteRefreshTokenFromRemoteConnectInfo } from "../handlers/remote_connect.js"; + +export async function reloadConfig(remoteConnectInfo) { + const oaClient = new OAuthClient(remoteConnectInfo.subdomain, remoteConnectInfo.clientId); + try { + const idToken = await oaClient.getIdTokenByRefresh(remoteConnectInfo.refreshToken); + const { profile } = await oaClient.getUserConfig(idToken); + await writeProfileSetToTable(profile); + console.log('Updated profile from Config Hub'); + return true; + } catch (err) { + if (err instanceof RefreshTokenError) { + await deleteRefreshTokenFromRemoteConnectInfo(); + console.log('Refresh token is expired'); + return false; + } + throw err; + } +} diff --git a/src/js/options.js b/src/js/options.js index c60c3e3..9088831 100644 --- a/src/js/options.js +++ b/src/js/options.js @@ -5,7 +5,7 @@ import { ColorPicker } from './lib/color_picker.js'; import { SessionMemory, StorageProvider } from './lib/storage_repository.js'; import { writeProfileSetToTable } from "./lib/profile_db.js"; import { remoteConnect, getRemoteConnectInfo, deleteRemoteConnectInfo } from './handlers/remote_connect.js'; -import { OAuthClient } from './remote/oauth-client.js'; +import { reloadConfig } from './lib/reload-config.js'; function elById(id) { return document.getElementById(id); @@ -36,18 +36,20 @@ window.onload = function() { deleteRemoteConnectInfo(); } elById('reloadConfigHubButton').onclick = function() { - getRemoteConnectInfo().then(({ subdomain, clientId, refreshToken }) => { - if (subdomain && clientId) { - const oaClient = new OAuthClient(subdomain, clientId); - oaClient.getIdTokenByRefresh(refreshToken).then(idToken => { - return oaClient.getUserConfig(idToken); - }).then(({ profile }) => { - return writeProfileSetToTable(profile); - }).then(() => { - updateMessage('remoteMsgSpan', "Successfully reloaded config from Hub!"); + getRemoteConnectInfo().then(rci => { + if (rci && rci.subdomain && rci.clientId) { + reloadConfig(rci).then(result => { + if (result) { + updateMessage('remoteMsgSpan', "Successfully reloaded config from Hub!"); + } else { + updateMessage('remoteMsgSpan', `Failed to reload because the connection expired.`, 'warn'); + updateRemoteFieldsState('disconnected'); + } }).catch(e => { updateMessage('remoteMsgSpan', `Failed to reload because ${e.message}`, 'warn'); }); + } else { + updateMessage('remoteMsgSpan', `Failed to reload because the connection is broken.`, 'warn'); } }); } @@ -102,11 +104,11 @@ window.onload = function() { syncStorageRepo.set({ signinEndpointInHere: this.checked }); } - getRemoteConnectInfo().then(({ subdomain, clientId, refreshToken }) => { - if (subdomain && clientId) { - elById('configHubDomain').value = subdomain; - elById('configHubClientId').value = clientId; - if (refreshToken) { + getRemoteConnectInfo().then(rci => { + if (rci && rci.subdomain && rci.clientId) { + elById('configHubDomain').value = rci.subdomain; + elById('configHubClientId').value = rci.clientId; + if (rci.refreshToken) { updateRemoteFieldsState('connected'); } else { updateRemoteFieldsState('disconnected'); diff --git a/src/js/popup.js b/src/js/popup.js index 39bc410..177cfaf 100644 --- a/src/js/popup.js +++ b/src/js/popup.js @@ -3,6 +3,7 @@ import { CurrentContext } from './lib/current_context.js'; import { findTargetProfiles } from './lib/target_profiles.js'; import { SessionMemory, SyncStorageRepository } from './lib/storage_repository.js'; import { remoteCallback } from './handlers/remote_connect.js'; +import { writeProfileSetToTable } from './lib/profile_db.js'; const sessionMemory = new SessionMemory(chrome || browser); @@ -26,6 +27,12 @@ async function getCurrentTab() { return tab; } +async function moveTabToOption(tabId) { + const brw = chrome || browser; + const url = await brw.runtime.getURL('options.html'); + await brw.tabs.update(tabId, { url }); +} + async function executeAction(tabId, action, data) { return (chrome || browser).tabs.sendMessage(tabId, { action, data }); } @@ -95,15 +102,16 @@ function main() { }) } else if (url.host.endsWith('.aesr.dev') && url.pathname.startsWith('/callback')) { remoteCallback(url) - .then(() => { + .then(userCfg => { const p = noMain.querySelector('p'); p.textContent = "Successfully connected to AESR Config Hub!"; noMain.style.display = 'block'; + return writeProfileSetToTable(userCfg.profile); }) + .then(() => moveTabToOption(tab.id)) .catch(err => { - console.error(err); const p = noMain.querySelector('p'); - p.textContent = "Failed to connected to AESR Config Hub."; + p.textContent = `Failed to connect to AESR Config Hub because.\n${err.message}`; noMain.style.display = 'block'; }); } else { diff --git a/src/js/remote/oauth-client.js b/src/js/remote/oauth-client.js index 581bab7..38c3b8d 100644 --- a/src/js/remote/oauth-client.js +++ b/src/js/remote/oauth-client.js @@ -28,6 +28,13 @@ export class OAuthClient { validateCallbackUrl(uRL) { if (uRL.host === `api.${this.domain}` && uRL.pathname === '/callback') { + const error = uRL.searchParams.get('error'); + if (error) { + let errmsg = error; + const errDesc = uRL.searchParams.get('error_description'); + if (errDesc) errmsg += ': ' + errDesc; + throw new Error(errmsg); + } const authCode = uRL.searchParams.get('code'); if (authCode) return authCode; } @@ -50,6 +57,9 @@ export class OAuthClient { body: new URLSearchParams(params), }); const result = await res.json(); + if (!res.ok) { + throw new Error(result.error); + } return result; } @@ -68,6 +78,12 @@ export class OAuthClient { body: new URLSearchParams(params), }); const result = await res.json(); + if (!res.ok) { + if (result.error === 'invalid_grant') { + throw new RefreshTokenError('refresh token is invalid'); + } + throw new Error(result.error); + } return result.id_token; } @@ -79,6 +95,11 @@ export class OAuthClient { }, }) const result = await res.json(); + if (!res.ok) { + throw new Error(result.message); + } return result; } } + +export class RefreshTokenError extends Error {} diff --git a/src/popup.html b/src/popup.html index c4b295f..b58ca98 100644 --- a/src/popup.html +++ b/src/popup.html @@ -88,6 +88,7 @@ margin: 12px 5px 5px 5px; line-height: 1.66; color: #666; + white-space: pre-wrap; } #supportComment p { line-height: 1.25; diff --git a/src/updated.html b/src/updated.html index de93b47..e95dd29 100644 --- a/src/updated.html +++ b/src/updated.html @@ -72,7 +72,12 @@

    'Sign-in endpoint in current region' setting (Experimental, Supporters only)
    -

    4.0.3 New version!

    +

    5.0.0 New version!

    +
      +
    • Add support for remote retrieval of user configurations through AESR Config Hub, facilitating dynamic configuration management.
    • +
    + +

    4.0.3

    • Implement fallback for displaying the role list in Firefox private browsing mode