From 7dd05dfc79627029e9bd6986de1ee0c307a0510b Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Thu, 6 Aug 2026 12:59:18 +0200 Subject: [PATCH 01/18] feat: add "Connect with Tolgee" OAuth login Adds a browser-redirect OAuth 2.1 login alongside the existing API-key path: - oauth/: PKCE (S256) via Web Crypto, authorization-code flow through chrome.identity.launchWebAuthFlow, token exchange + refresh, and a per-backend token store in the service worker. - background: OAUTH_LOGIN/OAUTH_GET_TOKEN/OAUTH_LOGOUT handlers plus a chrome.alarms routine that proactively refreshes rotating tokens and pushes the new access token into matching tabs without reloading. - content: injects the access token as __tolgee_authToken into page sessionStorage (the refresh token never leaves the service worker) and updates it in place on refresh. - popup: a "Connect with Tolgee" button; OAuth sessions persist only a marker + backend url and re-fetch a fresh token on open, so a short-lived token is never stored stale. - manifest: adds the "identity" and "alarms" permissions. --- manifest.json | 2 +- package-lock.json | 17 +++++ src/background/background.ts | 62 ++++++++++++++++++ src/constants.ts | 19 ++++++ src/content/contentScript.ts | 25 +++++++ src/oauth/oauthClient.ts | 95 +++++++++++++++++++++++++++ src/oauth/pkce.ts | 23 +++++++ src/oauth/tokenStore.ts | 57 ++++++++++++++++ src/popup/TolgeeDetector.tsx | 43 +++++++++++- src/popup/sendToBackground.ts | 6 ++ src/popup/storage.ts | 14 +++- src/popup/tools.ts | 8 ++- src/popup/useDetectorForm.tsx | 119 ++++++++++++++++++++++++++-------- 13 files changed, 457 insertions(+), 33 deletions(-) create mode 100644 src/oauth/oauthClient.ts create mode 100644 src/oauth/pkce.ts create mode 100644 src/oauth/tokenStore.ts create mode 100644 src/popup/sendToBackground.ts diff --git a/manifest.json b/manifest.json index 3797d7b..0f312d2 100644 --- a/manifest.json +++ b/manifest.json @@ -14,7 +14,7 @@ "service_worker": "src/background/background.ts", "type": "module" }, - "permissions": ["storage"], + "permissions": ["storage", "identity", "alarms"], "host_permissions": [""], "icons": { "128": "icons/present.png" diff --git a/package-lock.json b/package-lock.json index 9d3b532..b5523fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,6 +87,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.6.tgz", "integrity": "sha512-FxpRyGjrMJXh7X3wGLGhNDCRiwpWEF74sKjTLDJSG5Kyvow3QZaG0Adbqzi9ZrVjTWpsX+2cxWXD71NMg93kdw==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -587,6 +588,7 @@ "version": "11.11.3", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.3.tgz", "integrity": "sha512-Cnn0kuq4DoONOMcnoVsTOR8E+AdnKFf//6kUWc4LCdnxj31pZWn7rIULd6Y7/Js1PiPHzn7SKCM9vB/jBni8eA==", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", @@ -627,6 +629,7 @@ "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.0.tgz", "integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", @@ -1199,6 +1202,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.6.0.tgz", "integrity": "sha512-7RKRKuA4xTjMhY+eG3jthb3hlZCsOwg3rztWh75Xc+ShDWOfDDATWbeZpAHBNRpm4Tv9WgBMOy1zEJYXG6NJ7Q==", "dev": true, + "peer": true, "dependencies": { "@octokit/auth-token": "^2.4.4", "@octokit/graphql": "^4.5.8", @@ -4583,6 +4587,7 @@ "version": "18.2.45", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.45.tgz", "integrity": "sha512-TtAxCNrlrBp8GoeEp1npd5g+d/OejJHFxS3OWmrPBMFaVQMSN0OFySozJio5BHxTuTeug00AVXVAjfDSfk+lUg==", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -4670,6 +4675,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.16.0.tgz", "integrity": "sha512-H2GM3eUo12HpKZU9njig3DF5zJ58ja6ahj1GoHEHOgQvYxzoFJJEvC1MQ7T2l9Ha+69ZSOn7RTxOdpC/y3ikMw==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.16.0", "@typescript-eslint/types": "6.16.0", @@ -4867,6 +4873,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5109,6 +5116,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001565", "electron-to-chromium": "^1.4.601", @@ -6214,6 +6222,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6269,6 +6278,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7707,6 +7717,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-2.1.3.tgz", "integrity": "sha512-/Q+7MGzaETqifOMWYEA7HVMaZb4XbcRfaOzcSsHZEith83KGlvaSG33u0SKu89Mj5h+T8V2hM+8O45Qc5XTgwA==", "dev": true, + "peer": true, "bin": { "marked": "bin/marked" }, @@ -8462,6 +8473,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz", "integrity": "sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==", "dev": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -8591,6 +8603,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8602,6 +8615,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -8964,6 +8978,7 @@ "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-17.4.7.tgz", "integrity": "sha512-3Ghu8mKCJgCG3QzE5xphkYWM19lGE3XjFdOXQIKBM2PBpBvgFQ/lXv31oX0+fuN/UjNFO/dqhNs8ATLBhg6zBg==", "dev": true, + "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^8.0.0", "@semantic-release/error": "^2.2.0", @@ -9636,6 +9651,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9759,6 +9775,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz", "integrity": "sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.15.9", "postcss": "^8.4.18", diff --git a/src/background/background.ts b/src/background/background.ts index f46884f..599b717 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -1,9 +1,19 @@ import browser from 'webextension-polyfill'; import { ScreenshotMaker } from './ScreenshotMaker'; import { RuntimeMessage } from '../content/Messages'; +import { login } from '../oauth/oauthClient'; +import { + clearSession, + getValidAccessToken, + loadAllSessions, + saveSession, +} from '../oauth/tokenStore'; +import { OAUTH_REFRESH_SKEW_MS } from '../constants'; type State = 'present' | 'active' | 'inactive'; +const REFRESH_ALARM = 'tolgee-oauth-refresh'; + browser.runtime.onMessage.addListener((message, sender, sendResponse) => { const { type, data } = message as RuntimeMessage; switch (type) { @@ -16,6 +26,22 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => { setStateIcon(data, sender.tab!.id!); sendResponse({}); break; + case 'OAUTH_LOGIN': + login(data.apiUrl, data.projectId) + .then(async (tokens) => { + await saveSession(data.apiUrl, tokens); + sendResponse({ accessToken: tokens.accessToken }); + }) + .catch((e) => sendResponse({ error: String(e) })); + return true; + case 'OAUTH_GET_TOKEN': + getValidAccessToken(data.apiUrl).then((accessToken) => + sendResponse({ accessToken }) + ); + return true; + case 'OAUTH_LOGOUT': + clearSession(data.apiUrl).then(() => sendResponse({})); + return true; default: sendResponse({}); } @@ -27,3 +53,39 @@ const setStateIcon = (state: State, tabId: number) => { tabId, }); }; + +// Keep stored sessions fresh so the popup and the injected page token don't expire mid-use. Rotation means each +// refresh mints a new access + refresh token; getValidAccessToken persists them and pushes the access token to tabs. +browser.alarms.create(REFRESH_ALARM, { periodInMinutes: 10 }); +browser.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name !== REFRESH_ALARM) { + return; + } + const sessions = await loadAllSessions(); + for (const session of sessions) { + if (session.expiresAt - OAUTH_REFRESH_SKEW_MS > Date.now()) { + continue; + } + const accessToken = await getValidAccessToken(session.apiUrl); + if (accessToken) { + await pushTokenToTabs(session.apiUrl, accessToken); + } + } +}); + +// Update the injected access token in every tab whose applied backend matches, without reloading the page. +const pushTokenToTabs = async (apiUrl: string, accessToken: string) => { + const tabs = await browser.tabs.query({}); + await Promise.all( + tabs.map((tab) => + tab.id == null + ? undefined + : browser.tabs + .sendMessage(tab.id, { + type: 'UPDATE_AUTH_TOKEN', + data: { apiUrl, authToken: accessToken }, + }) + .catch(() => undefined) + ) + ); +}; diff --git a/src/constants.ts b/src/constants.ts index f630aac..07c8b7b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,22 @@ export const API_KEY_LOCAL_STORAGE = '__tolgee_apiKey'; export const API_URL_LOCAL_STORAGE = '__tolgee_apiUrl'; export const BRANCH_LOCAL_STORAGE = '__tolgee_branch'; +// OAuth access token injected into the page alongside the api key; the SDK (tolgee-js) reads it as a Bearer token. +export const AUTH_TOKEN_LOCAL_STORAGE = '__tolgee_authToken'; + +// Fixed public client id pre-registered on every Tolgee backend (see PreRegisteredClients on the platform). +export const OAUTH_CLIENT_ID = 'tolgee-browser-extension'; +// Access tokens are short-lived; refresh this many milliseconds before expiry. +export const OAUTH_REFRESH_SKEW_MS = 60_000; +// Scopes the extension requests for in-context editing. The backend intersects them with the user's live +// permissions, so requesting a broad set never grants more than the user actually holds. +export const OAUTH_SCOPES = [ + 'translations.view', + 'translations.edit', + 'translations.state-edit', + 'keys.view', + 'keys.edit', + 'screenshots.view', + 'screenshots.upload', + 'screenshots.delete', +].join(' '); diff --git a/src/content/contentScript.ts b/src/content/contentScript.ts index d9f560a..bccd026 100644 --- a/src/content/contentScript.ts +++ b/src/content/contentScript.ts @@ -1,6 +1,7 @@ import { API_KEY_LOCAL_STORAGE, API_URL_LOCAL_STORAGE, + AUTH_TOKEN_LOCAL_STORAGE, BRANCH_LOCAL_STORAGE, } from '../constants'; import { LibConfig } from '../types'; @@ -18,9 +19,21 @@ const getAppliedCredenials = () => { apiKey: sessionStorage.getItem(API_KEY_LOCAL_STORAGE), apiUrl: sessionStorage.getItem(API_URL_LOCAL_STORAGE), branch: sessionStorage.getItem(BRANCH_LOCAL_STORAGE), + authToken: sessionStorage.getItem(AUTH_TOKEN_LOCAL_STORAGE), }; }; +const sameOrigin = (a: string | null, b: string | null) => { + if (!a || !b) { + return false; + } + try { + return new URL(a).origin === new URL(b).origin; + } catch (e) { + return false; + } +}; + // handshake with library messages.listenWindow('TOLGEE_READY', (c: LibConfig) => { const firstHandshake = !configuration; @@ -82,6 +95,18 @@ messages.listenRuntime('SET_CREDENTIALS', async (data) => { } else { sessionStorage.removeItem(BRANCH_LOCAL_STORAGE); } + if (data.authToken) { + sessionStorage.setItem(AUTH_TOKEN_LOCAL_STORAGE, data.authToken); + } else { + sessionStorage.removeItem(AUTH_TOKEN_LOCAL_STORAGE); + } location.reload(); updateState(configuration, messages); }); + +// Background pushes a rotated access token here on refresh; update it in place so the SDK picks it up without a reload. +messages.listenRuntime('UPDATE_AUTH_TOKEN', async (data) => { + if (sameOrigin(sessionStorage.getItem(API_URL_LOCAL_STORAGE), data.apiUrl)) { + sessionStorage.setItem(AUTH_TOKEN_LOCAL_STORAGE, data.authToken); + } +}); diff --git a/src/oauth/oauthClient.ts b/src/oauth/oauthClient.ts new file mode 100644 index 0000000..f391dc1 --- /dev/null +++ b/src/oauth/oauthClient.ts @@ -0,0 +1,95 @@ +import browser from 'webextension-polyfill'; +import { OAUTH_CLIENT_ID, OAUTH_SCOPES } from '../constants'; +import { challengeFromVerifier, randomUrlSafe } from './pkce'; + +export type OAuthTokens = { + accessToken: string; + refreshToken?: string; + // epoch milliseconds at which the access token expires + expiresAt: number; +}; + +const normalizeUrl = (url: string) => url.replace(/\/$/, ''); + +export const getRedirectUri = () => browser.identity.getRedirectURL(); + +const parseTokenResponse = ( + data: Record, + previousRefreshToken?: string +): OAuthTokens => ({ + accessToken: data.access_token, + // rotation returns a fresh refresh token; if a response omits it, keep the previous one + refreshToken: data.refresh_token ?? previousRefreshToken, + expiresAt: Date.now() + (data.expires_in ?? 0) * 1000, +}); + +const postToken = async ( + base: string, + params: Record, + previousRefreshToken?: string +): Promise => { + const res = await fetch(`${base}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(params), + }); + if (!res.ok) { + throw new Error(`Tolgee token endpoint returned ${res.status}`); + } + return parseTokenResponse(await res.json(), previousRefreshToken); +}; + +export const login = async ( + apiUrl: string, + projectId?: number +): Promise => { + const base = normalizeUrl(apiUrl); + const verifier = randomUrlSafe(); + const redirectUri = getRedirectUri(); + + const authorizeUrl = new URL(`${base}/oauth2/authorize`); + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('client_id', OAUTH_CLIENT_ID); + authorizeUrl.searchParams.set('redirect_uri', redirectUri); + authorizeUrl.searchParams.set('scope', OAUTH_SCOPES); + authorizeUrl.searchParams.set( + 'code_challenge', + await challengeFromVerifier(verifier) + ); + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); + authorizeUrl.searchParams.set('state', randomUrlSafe()); + if (projectId != null) { + authorizeUrl.searchParams.set('project', String(projectId)); + } + + const redirectResponse = await browser.identity.launchWebAuthFlow({ + url: authorizeUrl.toString(), + interactive: true, + }); + const code = new URL(redirectResponse).searchParams.get('code'); + if (!code) { + throw new Error('Tolgee authorization did not return a code'); + } + + return postToken(base, { + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: OAUTH_CLIENT_ID, + code_verifier: verifier, + }); +}; + +export const refresh = ( + apiUrl: string, + refreshToken: string +): Promise => + postToken( + normalizeUrl(apiUrl), + { + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: OAUTH_CLIENT_ID, + }, + refreshToken + ); diff --git a/src/oauth/pkce.ts b/src/oauth/pkce.ts new file mode 100644 index 0000000..81d0894 --- /dev/null +++ b/src/oauth/pkce.ts @@ -0,0 +1,23 @@ +// PKCE helpers (RFC 7636), using Web Crypto — available in both the service worker and the popup. + +const base64UrlEncode = (bytes: Uint8Array): string => { + let str = ''; + bytes.forEach((b) => (str += String.fromCharCode(b))); + return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +export const randomUrlSafe = (byteLength = 32): string => { + const bytes = new Uint8Array(byteLength); + crypto.getRandomValues(bytes); + return base64UrlEncode(bytes); +}; + +export const challengeFromVerifier = async ( + verifier: string +): Promise => { + const digest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(verifier) + ); + return base64UrlEncode(new Uint8Array(digest)); +}; diff --git a/src/oauth/tokenStore.ts b/src/oauth/tokenStore.ts new file mode 100644 index 0000000..d868901 --- /dev/null +++ b/src/oauth/tokenStore.ts @@ -0,0 +1,57 @@ +import browser from 'webextension-polyfill'; +import { OAUTH_REFRESH_SKEW_MS } from '../constants'; +import { OAuthTokens, refresh } from './oauthClient'; + +export type StoredSession = OAuthTokens & { apiUrl: string }; + +// Sessions are keyed by the Tolgee backend origin, so one login is reused across every page that targets it. +const keyFor = (apiUrl: string) => `oauth:${new URL(apiUrl).origin}`; + +export const saveSession = async (apiUrl: string, tokens: OAuthTokens) => { + await browser.storage.local.set({ [keyFor(apiUrl)]: { ...tokens, apiUrl } }); +}; + +export const loadSession = async ( + apiUrl: string +): Promise => { + const key = keyFor(apiUrl); + const stored = await browser.storage.local.get(key); + return (stored[key] as StoredSession) ?? null; +}; + +export const clearSession = async (apiUrl: string) => { + await browser.storage.local.remove(keyFor(apiUrl)); +}; + +export const loadAllSessions = async (): Promise => { + const all = await browser.storage.local.get(null); + return Object.entries(all) + .filter(([key]) => key.startsWith('oauth:')) + .map(([, value]) => value as StoredSession); +}; + +// Returns a valid access token, refreshing (and persisting) if it is expired or near expiry. +// Returns null (and clears the session) when there is nothing valid to fall back on — the caller must re-login. +export const getValidAccessToken = async ( + apiUrl: string +): Promise => { + const session = await loadSession(apiUrl); + if (!session) { + return null; + } + if (session.expiresAt - OAUTH_REFRESH_SKEW_MS > Date.now()) { + return session.accessToken; + } + if (!session.refreshToken) { + await clearSession(apiUrl); + return null; + } + try { + const refreshed = await refresh(apiUrl, session.refreshToken); + await saveSession(apiUrl, refreshed); + return refreshed.accessToken; + } catch (e) { + await clearSession(apiUrl); + return null; + } +}; diff --git a/src/popup/TolgeeDetector.tsx b/src/popup/TolgeeDetector.tsx index 310d005..ffa0b3f 100644 --- a/src/popup/TolgeeDetector.tsx +++ b/src/popup/TolgeeDetector.tsx @@ -13,11 +13,13 @@ import { import { useDetectorForm } from './useDetectorForm'; import { validateValues } from './tools'; +import { sendToBackground } from './sendToBackground'; const POPUP_WIDTH = 400; export const TolgeeDetector = () => { const [state, dispatch] = useDetectorForm(); + const [connecting, setConnecting] = useState(false); const { error, @@ -46,6 +48,28 @@ export const TolgeeDetector = () => { } }; + const handleConnect = async () => { + const apiUrl = values?.apiUrl; + if (!apiUrl) { + return; + } + setConnecting(true); + try { + const res = (await sendToBackground('OAUTH_LOGIN', { apiUrl })) as { + accessToken?: string; + error?: string; + }; + if (res?.accessToken) { + dispatch({ + type: 'OAUTH_APPLY', + payload: { apiUrl, authToken: res.accessToken }, + }); + } + } finally { + setConnecting(false); + } + }; + const dataPresent = storedValues || appliedValues; if (error) { return ( @@ -123,6 +147,10 @@ export const TolgeeDetector = () => { '...' ) : credentialsCheck === 'invalid' ? ( 'Invalid' + ) : 'oauth' in credentialsCheck ? ( + + Connected as {credentialsCheck.userFullName} + ) : ( {credentialsCheck.projectName} @@ -130,8 +158,19 @@ export const TolgeeDetector = () => { )} - {typeof credentialsCheck === 'object' && - credentialsCheck?.branchingEnabled && ( + + {credentialsCheck !== null && + typeof credentialsCheck === 'object' && + 'branchingEnabled' in credentialsCheck && + credentialsCheck.branchingEnabled && ( { + return browser.runtime.sendMessage({ type, data }); +}; diff --git a/src/popup/storage.ts b/src/popup/storage.ts index 19b1abb..e33a7db 100644 --- a/src/popup/storage.ts +++ b/src/popup/storage.ts @@ -4,6 +4,9 @@ type Values = { apiUrl?: string; apiKey?: string; branch?: string; + // OAuth sessions persist only a marker + backend url here; the token itself lives in the service worker's + // tokenStore (kept fresh via refresh) and is re-fetched on load, so a short-lived token is never stored stale. + oauth?: boolean; }; const getCurrentTab = async () => { @@ -17,11 +20,17 @@ const getCurrentTabOrigin = async () => { return url.origin; }; -export const storeValues = async (values: Values | null) => { +export const storeValues = async ( + values: (Values & { authToken?: string }) | null +) => { try { const origin = await getCurrentTabOrigin(); - if (values?.apiKey && values?.apiUrl) { + if (values?.authToken && values?.apiUrl) { + browser.storage.local.set({ + [origin]: { apiUrl: values.apiUrl, oauth: true }, + }); + } else if (values?.apiKey && values?.apiUrl) { browser.storage.local.set({ [origin]: { apiUrl: values.apiUrl, @@ -48,6 +57,7 @@ export const loadValues = async () => { apiKey: data?.apiKey, apiUrl: data?.apiUrl, branch: data?.branch, + oauth: data?.oauth, }; } catch (e) { console.error(e); diff --git a/src/popup/tools.ts b/src/popup/tools.ts index 5f331e0..a8ed249 100644 --- a/src/popup/tools.ts +++ b/src/popup/tools.ts @@ -2,15 +2,20 @@ export type Values = { apiUrl?: string; apiKey?: string; branch?: string; + // OAuth access token (from "Connect with Tolgee"); an alternative to apiKey + authToken?: string; }; export const validateValues = (values?: Values | null) => { - if (values?.apiKey && values?.apiUrl) { + if ((values?.apiKey || values?.authToken) && values?.apiUrl) { return values; } return null; }; +export const isOAuth = (values?: Values | null) => + Boolean(values?.authToken && !values?.apiKey); + export const compareValues = ( values1?: Values | null, values2?: Values | null @@ -18,6 +23,7 @@ export const compareValues = ( return ( values1?.apiKey === values2?.apiKey && values1?.apiUrl === values2?.apiUrl && + values1?.authToken === values2?.authToken && (values1?.branch || '') === (values2?.branch || '') ); }; diff --git a/src/popup/useDetectorForm.tsx b/src/popup/useDetectorForm.tsx index 735f5a6..3bbdd22 100644 --- a/src/popup/useDetectorForm.tsx +++ b/src/popup/useDetectorForm.tsx @@ -4,8 +4,15 @@ import { useEffect, useReducer } from 'react'; import { LibConfig } from '../types'; import { loadAppliedValues } from './loadConfig'; import { sendMessage } from './sendMessage'; +import { sendToBackground } from './sendToBackground'; import { loadValues, storeValues } from './storage'; -import { compareValues, normalizeUrl, validateValues, Values } from './tools'; +import { + compareValues, + isOAuth, + normalizeUrl, + validateValues, + Values, +} from './tools'; import { useApplier } from './useApplier'; import { RuntimeMessage } from '../content/Messages'; @@ -17,7 +24,12 @@ type ProjectInfo = { branchingEnabled: boolean; }; -type CredentialsCheck = null | 'loading' | 'invalid' | ProjectInfo; +type OAuthUser = { + oauth: true; + userFullName: string; +}; + +type CredentialsCheck = null | 'loading' | 'invalid' | ProjectInfo | OAuthUser; type TolgeePresent = 'loading' | 'present' | 'not_present' | 'legacy'; type BranchOption = { @@ -52,6 +64,7 @@ type Action = | { type: 'CLEAR_ALL' } | { type: 'STORE_VALUES' } | { type: 'LOAD_VALUES' } + | { type: 'OAUTH_APPLY'; payload: { apiUrl: string; authToken: string } } | { type: 'SET_BRANCHES'; payload: BranchOption[] | null }; export const useDetectorForm = () => { @@ -112,8 +125,10 @@ export const useDetectorForm = () => { // sync values with storage/localStorage apply(); const branchEnabled = + state.credentialsCheck !== null && typeof state.credentialsCheck === 'object' && - state.credentialsCheck?.branchingEnabled; + 'branchingEnabled' in state.credentialsCheck && + state.credentialsCheck.branchingEnabled; const effectiveBranch = branchEnabled ? state.values?.branch : undefined; @@ -141,6 +156,19 @@ export const useDetectorForm = () => { libConfig: null, }; } + case 'OAUTH_APPLY': { + apply(); + const oauthValues = { + apiUrl: action.payload.apiUrl, + authToken: action.payload.authToken, + }; + return { + ...state, + values: oauthValues, + appliedValues: oauthValues, + storedValues: oauthValues, + }; + } case 'STORE_VALUES': apply(); return { @@ -218,7 +246,18 @@ export const useDetectorForm = () => { } const storedData = await loadValues(); - if (validateValues(storedData)) { + if (storedData.oauth && storedData.apiUrl) { + // OAuth sessions store no token; ask the service worker for a fresh (auto-refreshed) one. + const res = (await sendToBackground('OAUTH_GET_TOKEN', { + apiUrl: storedData.apiUrl, + })) as { accessToken?: string }; + if (res?.accessToken) { + dispatch({ + type: 'LOAD_STORED_VALUES', + payload: { apiUrl: storedData.apiUrl, authToken: res.accessToken }, + }); + } + } else if (validateValues(storedData)) { dispatch({ type: 'LOAD_STORED_VALUES', payload: storedData }); } }; @@ -268,43 +307,69 @@ export const useDetectorForm = () => { const url = normalizeUrl(checkableValues!.apiUrl); - fetch(`${url}/v2/api-keys/current?ak=${checkableValues!.apiKey}`) - .then((r) => { - if (r.ok) { - return r.json(); - } else { - throw r.json(); - } - }) - .catch(() => { - !cancelled && setCredentialsCheck('invalid'); + if (isOAuth(checkableValues)) { + // OAuth tokens are not tied to a single project; confirm the token and show the connected user instead. + fetch(`${url}/v2/user`, { + headers: { Authorization: `Bearer ${checkableValues!.authToken}` }, }) - .then((data) => { - !cancelled && - data && - setCredentialsCheck({ - projectName: data.projectName, - projectId: data.projectId, - scopes: data.scopes, - userFullName: data.userFullName, - branchingEnabled: data.branchingEnabled ?? false, - }); - }); + .then((r) => { + if (r.ok) { + return r.json(); + } + throw new Error('Invalid token'); + }) + .then((data) => { + !cancelled && + setCredentialsCheck({ oauth: true, userFullName: data.name }); + }) + .catch(() => { + !cancelled && setCredentialsCheck('invalid'); + }); + } else { + fetch(`${url}/v2/api-keys/current?ak=${checkableValues!.apiKey}`) + .then((r) => { + if (r.ok) { + return r.json(); + } else { + throw r.json(); + } + }) + .catch(() => { + !cancelled && setCredentialsCheck('invalid'); + }) + .then((data) => { + !cancelled && + data && + setCredentialsCheck({ + projectName: data.projectName, + projectId: data.projectId, + scopes: data.scopes, + userFullName: data.userFullName, + branchingEnabled: data.branchingEnabled ?? false, + }); + }); + } } else { setCredentialsCheck(null); } return () => { cancelled = true; }; - }, [checkableValues?.apiUrl, checkableValues?.apiKey]); + }, [ + checkableValues?.apiUrl, + checkableValues?.apiKey, + checkableValues?.authToken, + ]); // fetch branches when credentials are valid and branching is enabled useEffect(() => { let cancelled = false; const check = state.credentialsCheck; if ( + check !== null && typeof check === 'object' && - check?.branchingEnabled && + 'branchingEnabled' in check && + check.branchingEnabled && validateValues(checkableValues) ) { const url = normalizeUrl(checkableValues!.apiUrl); From 8af4a7af803984544e50e6c4143174eaa1d14463 Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Fri, 7 Aug 2026 00:27:14 +0200 Subject: [PATCH 02/18] feat: restructure popup around "Connect with Tolgee" Make OAuth the primary sign-in: the "Connect with Tolgee" button sits directly under the API url, and the API-key input + Apply are tucked into a collapsible "API key sign in" block (collapsed by default). Also surface OAuth login failures via console.error instead of swallowing them. --- src/background/background.ts | 5 +- src/oauth/oauthClient.ts | 3 +- src/popup/TolgeeDetector.tsx | 309 +++++++++++++++++++---------------- 3 files changed, 171 insertions(+), 146 deletions(-) diff --git a/src/background/background.ts b/src/background/background.ts index 599b717..5534bf6 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -32,7 +32,10 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => { await saveSession(data.apiUrl, tokens); sendResponse({ accessToken: tokens.accessToken }); }) - .catch((e) => sendResponse({ error: String(e) })); + .catch((e) => { + console.error('[tolgee-oauth] login failed', e); + sendResponse({ error: String(e) }); + }); return true; case 'OAUTH_GET_TOKEN': getValidAccessToken(data.apiUrl).then((accessToken) => diff --git a/src/oauth/oauthClient.ts b/src/oauth/oauthClient.ts index f391dc1..763b8d7 100644 --- a/src/oauth/oauthClient.ts +++ b/src/oauth/oauthClient.ts @@ -34,7 +34,8 @@ const postToken = async ( body: new URLSearchParams(params), }); if (!res.ok) { - throw new Error(`Tolgee token endpoint returned ${res.status}`); + const body = await res.text().catch(() => ''); + throw new Error(`Tolgee token endpoint returned ${res.status}: ${body}`); } return parseTokenResponse(await res.json(), previousRefreshToken); }; diff --git a/src/popup/TolgeeDetector.tsx b/src/popup/TolgeeDetector.tsx index ffa0b3f..67d5c7a 100644 --- a/src/popup/TolgeeDetector.tsx +++ b/src/popup/TolgeeDetector.tsx @@ -4,6 +4,7 @@ import { Box, Button, CircularProgress, + Collapse, FormControl, FormHelperText, Switch, @@ -20,6 +21,7 @@ const POPUP_WIDTH = 400; export const TolgeeDetector = () => { const [state, dispatch] = useDetectorForm(); const [connecting, setConnecting] = useState(false); + const [keySigninOpen, setKeySigninOpen] = useState(false); const { error, @@ -122,167 +124,186 @@ export const TolgeeDetector = () => { onKeyDown={handleKeyDown} size="small" /> - - - dispatch({ - type: 'CHANGE_VALUES', - payload: { apiKey: e.target.value }, - }) - } - onKeyDown={handleKeyDown} - size="small" - /> - - {credentialsCheck === null ? ( - '' - ) : credentialsCheck === 'loading' ? ( - '...' - ) : credentialsCheck === 'invalid' ? ( - 'Invalid' - ) : 'oauth' in credentialsCheck ? ( - - Connected as {credentialsCheck.userFullName} - - ) : ( - - {credentialsCheck.projectName} - - )} - - - {credentialsCheck !== null && - typeof credentialsCheck === 'object' && - 'branchingEnabled' in credentialsCheck && - credentialsCheck.branchingEnabled && ( - setBranchOpen(true)} - onClose={() => setBranchOpen(false)} - freeSolo - size="small" - disablePortal - slotProps={{ - popper: { - placement: 'bottom', - modifiers: [{ name: 'flip', enabled: false }], - }, - }} - ListboxProps={{ style: { maxHeight: 150 } }} - options={branches ?? []} - getOptionLabel={(option) => - typeof option === 'string' ? option : option.name - } - value={ - branches?.find((b) => b.name === values?.branch) ?? - values?.branch ?? - null - } - onChange={(_e: any, newValue: any) => { - dispatch({ - type: 'CHANGE_VALUES', - payload: { - branch: - typeof newValue === 'string' - ? newValue - : newValue?.name ?? '', - }, - }); - }} - onInputChange={(_e: any, newInput: string, reason: string) => { - if (reason === 'input') { + + + + + dispatch({ type: 'CHANGE_VALUES', - payload: { branch: newInput }, - }); + payload: { apiKey: e.target.value }, + }) } - }} - renderOption={(props, option) => ( -
  • - {option.name} - {option.isDefault && ( - - default - + onKeyDown={handleKeyDown} + size="small" + /> + + {credentialsCheck === null ? ( + '' + ) : credentialsCheck === 'loading' ? ( + '...' + ) : credentialsCheck === 'invalid' ? ( + 'Invalid' + ) : 'oauth' in credentialsCheck ? ( + + Connected as {credentialsCheck.userFullName} + + ) : ( + + {credentialsCheck.projectName} + + )} + + + {credentialsCheck !== null && + typeof credentialsCheck === 'object' && + 'branchingEnabled' in credentialsCheck && + credentialsCheck.branchingEnabled && ( + setBranchOpen(true)} + onClose={() => setBranchOpen(false)} + freeSolo + size="small" + disablePortal + slotProps={{ + popper: { + placement: 'bottom', + modifiers: [{ name: 'flip', enabled: false }], + }, + }} + ListboxProps={{ style: { maxHeight: 150 } }} + options={branches ?? []} + getOptionLabel={(option) => + typeof option === 'string' ? option : option.name + } + value={ + branches?.find((b) => b.name === values?.branch) ?? + values?.branch ?? + null + } + onChange={(_e: any, newValue: any) => { + dispatch({ + type: 'CHANGE_VALUES', + payload: { + branch: + typeof newValue === 'string' + ? newValue + : newValue?.name ?? '', + }, + }); + }} + onInputChange={( + _e: any, + newInput: string, + reason: string + ) => { + if (reason === 'input') { + dispatch({ + type: 'CHANGE_VALUES', + payload: { branch: newInput }, + }); + } + }} + renderOption={(props, option) => ( +
  • + {option.name} + {option.isDefault && ( + + default + + )} +
  • + )} + renderInput={(params) => ( + )} - - )} - renderInput={(params) => ( - )} - /> - )} - - - {dataPresent ? ( - <> - + + {dataPresent ? ( + <> + + Applied + + ) : isInDevelopmentMode ? ( + + Api key is included directly in Tolgee configuration.
    {' '} + Use this setup only in development environment. +
    + ) : ( + '' + )} +
    + + {dataPresent && ( + + )} + - )} - + disabled={!validateValues(values) || valuesNotChanged} + > + Apply + + +
    -
    +
    ); } else if (tolgeePresent === 'legacy') { From 8a9f3652c2607320ae2eb3899a8bd332e6631861 Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Fri, 7 Aug 2026 13:06:21 +0200 Subject: [PATCH 03/18] feat: label the API-key apply button 'Connect with API key' --- src/popup/TolgeeDetector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/popup/TolgeeDetector.tsx b/src/popup/TolgeeDetector.tsx index 67d5c7a..1dbc62f 100644 --- a/src/popup/TolgeeDetector.tsx +++ b/src/popup/TolgeeDetector.tsx @@ -298,7 +298,7 @@ export const TolgeeDetector = () => { color="primary" disabled={!validateValues(values) || valuesNotChanged} > - Apply + Connect with API key From 5314c12f4eb5182182094b22bdafcddf5a93906b Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Sun, 9 Aug 2026 12:14:38 +0200 Subject: [PATCH 04/18] feat: select and inject the OAuth project for in-context editing OAuth access tokens carry no embedded project (unlike a PAK), so the popup now resolves one: it hints the page's configured project on connect, reads the consented project back from the token's tg.prj and injects it into the page as __tolgee_projectId, and shows a manual project picker only when the token is bound to all projects. --- src/constants.ts | 3 + src/content/contentScript.ts | 7 +++ src/popup/TolgeeDetector.tsx | 58 ++++++++++++++++++- src/popup/storage.ts | 13 ++++- src/popup/tools.ts | 28 +++++++++ src/popup/useDetectorForm.tsx | 106 +++++++++++++++++++++++++++++++++- 6 files changed, 208 insertions(+), 7 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 07c8b7b..d15add1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,6 +3,9 @@ export const API_URL_LOCAL_STORAGE = '__tolgee_apiUrl'; export const BRANCH_LOCAL_STORAGE = '__tolgee_branch'; // OAuth access token injected into the page alongside the api key; the SDK (tolgee-js) reads it as a Bearer token. export const AUTH_TOKEN_LOCAL_STORAGE = '__tolgee_authToken'; +// Project id injected for the OAuth path: OAuth tokens carry no embedded project (unlike a PAK), so the user +// picks one in the popup and the SDK reads it here to know which project to fetch/edit. +export const PROJECT_ID_LOCAL_STORAGE = '__tolgee_projectId'; // Fixed public client id pre-registered on every Tolgee backend (see PreRegisteredClients on the platform). export const OAUTH_CLIENT_ID = 'tolgee-browser-extension'; diff --git a/src/content/contentScript.ts b/src/content/contentScript.ts index bccd026..50b8672 100644 --- a/src/content/contentScript.ts +++ b/src/content/contentScript.ts @@ -3,6 +3,7 @@ import { API_URL_LOCAL_STORAGE, AUTH_TOKEN_LOCAL_STORAGE, BRANCH_LOCAL_STORAGE, + PROJECT_ID_LOCAL_STORAGE, } from '../constants'; import { LibConfig } from '../types'; import { injectUiLib } from './injectUiLib'; @@ -20,6 +21,7 @@ const getAppliedCredenials = () => { apiUrl: sessionStorage.getItem(API_URL_LOCAL_STORAGE), branch: sessionStorage.getItem(BRANCH_LOCAL_STORAGE), authToken: sessionStorage.getItem(AUTH_TOKEN_LOCAL_STORAGE), + projectId: sessionStorage.getItem(PROJECT_ID_LOCAL_STORAGE), }; }; @@ -100,6 +102,11 @@ messages.listenRuntime('SET_CREDENTIALS', async (data) => { } else { sessionStorage.removeItem(AUTH_TOKEN_LOCAL_STORAGE); } + if (data.projectId) { + sessionStorage.setItem(PROJECT_ID_LOCAL_STORAGE, String(data.projectId)); + } else { + sessionStorage.removeItem(PROJECT_ID_LOCAL_STORAGE); + } location.reload(); updateState(configuration, messages); }); diff --git a/src/popup/TolgeeDetector.tsx b/src/popup/TolgeeDetector.tsx index 1dbc62f..7af1e30 100644 --- a/src/popup/TolgeeDetector.tsx +++ b/src/popup/TolgeeDetector.tsx @@ -13,7 +13,7 @@ import { } from '@mui/material'; import { useDetectorForm } from './useDetectorForm'; -import { validateValues } from './tools'; +import { decodeTokenProjectSet, validateValues } from './tools'; import { sendToBackground } from './sendToBackground'; const POPUP_WIDTH = 400; @@ -32,9 +32,21 @@ export const TolgeeDetector = () => { tolgeePresent, credentialsCheck, branches, + projects, } = state; const [branchOpen, setBranchOpen] = useState(false); + const oauthUser = + credentialsCheck !== null && + typeof credentialsCheck === 'object' && + 'oauth' in credentialsCheck + ? credentialsCheck + : null; + + // A single-project token auto-selects its project (done in the reducer); only an "all projects" token needs the + // manual picker below. + const allProjectsToken = decodeTokenProjectSet(values?.authToken) === '*'; + const handleApplyChange = async () => { if (appliedValues) { dispatch({ type: 'STORE_VALUES' }); @@ -57,7 +69,16 @@ export const TolgeeDetector = () => { } setConnecting(true); try { - const res = (await sendToBackground('OAUTH_LOGIN', { apiUrl })) as { + // Hint the project the page is configured for (exposed via the handshake), so the consent screen pre-selects it + // and the minted token is scoped to it. On a public project the hint resolves via the community floor. + const hinted = (libConfig?.config as { projectId?: number | string }) + ?.projectId; + const projectId = + hinted !== undefined && hinted !== '' ? Number(hinted) : undefined; + const res = (await sendToBackground('OAUTH_LOGIN', { + apiUrl, + projectId, + })) as { accessToken?: string; error?: string; }; @@ -133,6 +154,39 @@ export const TolgeeDetector = () => { > {connecting ? 'Connecting…' : 'Connect with Tolgee'} + {oauthUser && ( + <> + + Connected as {oauthUser.userFullName} + + {allProjectsToken && ( + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={ + projects?.find((p) => p.id === values?.projectId) ?? null + } + onChange={(_e, newValue) => { + dispatch({ + type: 'OAUTH_SET_PROJECT', + payload: { projectId: newValue?.id }, + }); + }} + renderInput={(params) => ( + + )} + /> + )} + + )} + )} + + + ); + if (error) { return ( @@ -127,86 +266,164 @@ export const TolgeeDetector = () => { libConfig?.config.apiUrl === values?.apiUrl && (libConfig?.config.branch || '') === (values?.branch || ''); + const detectedProjectId = ( + libConfig?.config as { projectId?: number | string } + )?.projectId; + const projectDetected = + detectedProjectId !== undefined && detectedProjectId !== ''; + + let serverHost = values?.apiUrl || DEFAULT_SERVER; + try { + serverHost = new URL(serverHost).host; + } catch { + // keep the raw value if it's not a full URL yet + } + return ( - - Tolgee settings - - - dispatch({ - type: 'CHANGE_VALUES', - payload: { apiUrl: e.target.value }, - }) - } - onKeyDown={handleKeyDown} - size="small" - /> - - {oauthUser && ( - <> - - Connected as {oauthUser.userFullName} - - {allProjectsToken && ( - option.name} - isOptionEqualToValue={(option, value) => option.id === value.id} - value={ - projects?.find((p) => p.id === values?.projectId) ?? null - } - onChange={(_e, newValue) => { - dispatch({ - type: 'OAUTH_SET_PROJECT', - payload: { projectId: newValue?.id }, - }); - }} - renderInput={(params) => ( - - )} - /> - )} - - )} - - + + + + + {tab === 'login' && + (oauthUser ? ( + + + Connected as {oauthUser.userFullName} + + {declaredProjectInaccessible ? ( + + This site requests a project you can’t edit on {serverHost}. + Check the projectId in the site’s Tolgee configuration, or ask + for access. + + ) : declaredProject ? ( + option.name} + isOptionEqualToValue={(option, value) => + option.id === value.id + } + value={ + values?.projectId != null + ? declaredProject + : ALL_PROJECTS_OPTION + } + onChange={(_e, newValue) => { + dispatch({ + type: 'OAUTH_SET_PROJECT', + payload: { + projectId: + newValue && newValue.id !== ALL_PROJECTS_OPTION.id + ? newValue.id + : undefined, + }, + }); + }} + renderInput={(params) => ( + + )} + /> + ) : ( + + + + )} + {appliedControls(isInDevelopmentMode)} + + ) : projectDetected ? ( + + {serverOpen ? ( + serverField + ) : ( + + Connect to your account on{' '} + + {serverHost} + {' '} + and start translating. + + )} + + {serverOpen ? ( + + Change if you have your own instance of Tolgee. + + ) : ( + + setServerOpen(true)} + > + Change server + + + )} + + ) : ( + + + Project not detected + + + Ask the website administrator to add projectId to the Tolgee + configuration.{' '} + + Learn more + + + + + ))} + + {tab === 'apiKey' && ( - + {serverField} + { /> {credentialsCheck === null ? ( @@ -242,130 +459,30 @@ export const TolgeeDetector = () => { )} - {credentialsCheck !== null && - typeof credentialsCheck === 'object' && - 'branchingEnabled' in credentialsCheck && - credentialsCheck.branchingEnabled && ( - setBranchOpen(true)} - onClose={() => setBranchOpen(false)} - freeSolo - size="small" - disablePortal - slotProps={{ - popper: { - placement: 'bottom', - modifiers: [{ name: 'flip', enabled: false }], - }, - }} - ListboxProps={{ style: { maxHeight: 150 } }} - options={branches ?? []} - getOptionLabel={(option) => - typeof option === 'string' ? option : option.name - } - value={ - branches?.find((b) => b.name === values?.branch) ?? - values?.branch ?? - null - } - onChange={(_e: any, newValue: any) => { - dispatch({ - type: 'CHANGE_VALUES', - payload: { - branch: - typeof newValue === 'string' - ? newValue - : newValue?.name ?? '', - }, - }); - }} - onInputChange={( - _e: any, - newInput: string, - reason: string - ) => { - if (reason === 'input') { - dispatch({ - type: 'CHANGE_VALUES', - payload: { branch: newInput }, - }); - } - }} - renderOption={(props, option) => ( -
  • - {option.name} - {option.isDefault && ( - - default - - )} -
  • - )} - renderInput={(params) => ( - - )} - /> - )} - + Where can I get an{' '} + + API key + + ? + + {branchField} + - )} - - -
    + Connect with API key + + {appliedControls(isInDevelopmentMode)}
    - + )}
    ); } else if (tolgeePresent === 'legacy') { diff --git a/src/popup/reducer.test.ts b/src/popup/reducer.test.ts new file mode 100644 index 0000000..9d79c22 --- /dev/null +++ b/src/popup/reducer.test.ts @@ -0,0 +1,207 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LibConfig } from '../types'; +import { + Action, + createReducer, + initialState, + ProjectInfo, + State, +} from './reducer'; + +const lib = (overrides: Partial): LibConfig => + ({ + uiPresent: true, + mode: 'production', + config: { apiUrl: 'https://app.tolgee.io', apiKey: '' }, + ...overrides, + }) as unknown as LibConfig; + +describe('detector reducer', () => { + let apply: ReturnType; + let reduce: (state: State, action: Action) => State; + + beforeEach(() => { + apply = vi.fn(); + reduce = createReducer(apply); + }); + + describe('CHANGE_LIB_CONFIG', () => { + it('marks Tolgee present and seeds values from the page config', () => { + const next = reduce(initialState, { + type: 'CHANGE_LIB_CONFIG', + payload: { + libData: lib({ + config: { apiUrl: 'https://x.io', apiKey: '' } as any, + }), + frameId: 0, + }, + }); + expect(next.tolgeePresent).toBe('present'); + expect(next.values?.apiUrl).toBe('https://x.io'); + }); + + it('reports legacy when uiPresent is missing', () => { + const next = reduce(initialState, { + type: 'CHANGE_LIB_CONFIG', + payload: { libData: lib({ uiPresent: undefined as any }), frameId: 0 }, + }); + expect(next.tolgeePresent).toBe('legacy'); + }); + + it('reports not_present when there is no lib data', () => { + const next = reduce(initialState, { + type: 'CHANGE_LIB_CONFIG', + payload: { libData: null, frameId: 0 }, + }); + expect(next.tolgeePresent).toBe('not_present'); + }); + + it('errors when a second instance is detected in another frame', () => { + const first = reduce(initialState, { + type: 'CHANGE_LIB_CONFIG', + payload: { libData: lib({}), frameId: 0 }, + }); + const second = reduce(first, { + type: 'CHANGE_LIB_CONFIG', + payload: { libData: lib({}), frameId: 1 }, + }); + expect(second.error).toBe('Detected multiple Tolgee instances'); + }); + }); + + describe('OAUTH_APPLY', () => { + it('applies the token and preserves a previously picked project', () => { + const restored: State = { + ...initialState, + values: { apiUrl: 'https://app.tolgee.io', projectId: 42 }, + }; + const next = reduce(restored, { + type: 'OAUTH_APPLY', + payload: { apiUrl: 'https://app.tolgee.io', authToken: 'jwt' }, + }); + expect(next.values).toEqual({ + apiUrl: 'https://app.tolgee.io', + authToken: 'jwt', + projectId: 42, + }); + expect(next.appliedValues).toEqual(next.values); + expect(next.storedValues).toEqual(next.values); + expect(apply).toHaveBeenCalledOnce(); + }); + }); + + describe('OAUTH_SET_PROJECT', () => { + it('sets the project across values/applied/stored', () => { + const connected: State = { + ...initialState, + values: { apiUrl: 'https://app.tolgee.io', authToken: 'jwt' }, + }; + const next = reduce(connected, { + type: 'OAUTH_SET_PROJECT', + payload: { projectId: 7 }, + }); + expect(next.values?.projectId).toBe(7); + expect(next.appliedValues?.projectId).toBe(7); + expect(next.storedValues?.projectId).toBe(7); + expect(apply).toHaveBeenCalledOnce(); + }); + }); + + describe('APPLY_VALUES', () => { + const withBranch: State = { + ...initialState, + values: { + apiUrl: 'https://app.tolgee.io', + apiKey: 'tgpak_x', + branch: 'feature', + }, + }; + + it('drops the branch when branching is disabled', () => { + const next = reduce(withBranch, { type: 'APPLY_VALUES' }); + expect(next.appliedValues?.branch).toBeUndefined(); + expect(apply).toHaveBeenCalledOnce(); + }); + + it('keeps the branch when branching is enabled', () => { + const enabled: State = { + ...withBranch, + credentialsCheck: { + projectName: 'Demo', + projectId: 2, + scopes: [], + userFullName: 'Jo', + branchingEnabled: true, + } as ProjectInfo, + }; + const next = reduce(enabled, { type: 'APPLY_VALUES' }); + expect(next.appliedValues?.branch).toBe('feature'); + }); + }); + + describe('CLEAR_ALL', () => { + it('wipes credentials and lib config', () => { + const dirty: State = { + ...initialState, + values: { apiUrl: 'https://app.tolgee.io', apiKey: 'tgpak_x' }, + appliedValues: { apiUrl: 'https://app.tolgee.io', apiKey: 'tgpak_x' }, + storedValues: { apiUrl: 'https://app.tolgee.io', apiKey: 'tgpak_x' }, + libConfig: lib({}), + }; + const next = reduce(dirty, { type: 'CLEAR_ALL' }); + expect(next.values).toBeNull(); + expect(next.storedValues).toBeNull(); + expect(next.appliedValues).toBeUndefined(); + expect(next.libConfig).toBeNull(); + expect(apply).toHaveBeenCalledOnce(); + }); + }); + + it('CHANGE_VALUES merges a partial patch', () => { + const next = reduce( + { ...initialState, values: { apiUrl: 'https://app.tolgee.io' } }, + { type: 'CHANGE_VALUES', payload: { apiKey: 'tgpak_x' } } + ); + expect(next.values).toEqual({ + apiUrl: 'https://app.tolgee.io', + apiKey: 'tgpak_x', + }); + expect(apply).not.toHaveBeenCalled(); + }); + + describe('RESOLVE_PROJECT', () => { + const connected: State = { + ...initialState, + values: { apiUrl: 'https://app.tolgee.io', authToken: 'jwt' }, + }; + + it('binds the resolved declared project and injects its id', () => { + const next = reduce(connected, { + type: 'RESOLVE_PROJECT', + payload: { project: { id: 7, name: 'Demo' }, inaccessible: false }, + }); + expect(next.declaredProject).toEqual({ id: 7, name: 'Demo' }); + expect(next.declaredProjectInaccessible).toBe(false); + expect(next.values?.projectId).toBe(7); + expect(next.appliedValues?.projectId).toBe(7); + expect(apply).toHaveBeenCalledOnce(); + }); + + it('flags an inaccessible declared project without injecting', () => { + const next = reduce(connected, { + type: 'RESOLVE_PROJECT', + payload: { project: null, inaccessible: true }, + }); + expect(next.declaredProjectInaccessible).toBe(true); + expect(next.declaredProject).toBeNull(); + expect(next.values?.projectId).toBeUndefined(); + expect(apply).not.toHaveBeenCalled(); + }); + }); + + it('throws on an unknown action', () => { + expect(() => + reduce(initialState, { type: 'NOPE' } as unknown as Action) + ).toThrow(); + }); +}); diff --git a/src/popup/reducer.ts b/src/popup/reducer.ts new file mode 100644 index 0000000..5fb5e10 --- /dev/null +++ b/src/popup/reducer.ts @@ -0,0 +1,244 @@ +import { LibConfig } from '../types'; +import { validateValues, Values } from './tools'; + +export type ProjectInfo = { + projectName: string; + projectId: number; + scopes: string[]; + userFullName: string; + branchingEnabled: boolean; +}; + +export type OAuthUser = { + oauth: true; + userFullName: string; +}; + +export type CredentialsCheck = + | null + | 'loading' + | 'invalid' + | ProjectInfo + | OAuthUser; +export type TolgeePresent = 'loading' | 'present' | 'not_present' | 'legacy'; + +export type BranchOption = { + name: string; + isDefault: boolean; +}; + +export type ProjectOption = { + id: number; + name: string; +}; + +export const initialState = { + values: null as Values | null, + storedValues: null as Values | null, + appliedValues: null as Values | null | undefined, + tolgeePresent: 'loading' as TolgeePresent, + credentialsCheck: null as CredentialsCheck, + libConfig: null as LibConfig | null, + error: null as string | null, + frameId: null as number | null, + branches: null as BranchOption[] | null, + // The project the page declared (via its Tolgee config), resolved against the connected server: the project when the + // user can edit it there, or `declaredProjectInaccessible` when they can't (wrong id / no access). + declaredProject: null as ProjectOption | null, + declaredProjectInaccessible: false, +}; + +export type State = typeof initialState; +export type Action = + | { type: 'CHANGE_VALUES'; payload: Partial } + | { + type: 'CHANGE_LIB_CONFIG'; + payload: { libData: LibConfig | null; frameId: number | null }; + } + | { type: 'SET_ERROR'; payload: string } + | { type: 'SET_APPLIED_VALUES'; payload: Values | null } + | { type: 'SET_CREDENTIALS_CHECK'; payload: CredentialsCheck } + | { type: 'LOAD_STORED_VALUES'; payload: Values | null } + | { type: 'APPLY_VALUES' } + | { type: 'CLEAR_ALL' } + | { type: 'STORE_VALUES' } + | { type: 'LOAD_VALUES' } + | { type: 'OAUTH_APPLY'; payload: { apiUrl: string; authToken: string } } + | { type: 'OAUTH_SET_PROJECT'; payload: { projectId: number | undefined } } + | { type: 'SET_BRANCHES'; payload: BranchOption[] | null } + | { + type: 'RESOLVE_PROJECT'; + payload: { project: ProjectOption | null; inaccessible: boolean }; + }; + +/** + * The reducer is a pure state transition, but a few actions also need to flag that the new state must be synced out to + * storage/sessionStorage. That side effect is injected as `apply` so the reducer stays testable without React. + */ +export const createReducer = + (apply: () => void) => + (state: State, action: Action): State => { + switch (action.type) { + case 'CHANGE_VALUES': + return { ...state, values: { ...state.values, ...action.payload } }; + case 'CHANGE_LIB_CONFIG': { + const { libData, frameId } = action.payload; + const newValues = { + apiKey: libData?.config?.apiKey, + apiUrl: libData?.config?.apiUrl, + branch: libData?.config?.branch, + }; + if (state.libConfig !== null && state.frameId !== frameId) { + return { + ...state, + error: 'Detected multiple Tolgee instances', + }; + } + return { + ...state, + libConfig: libData, + frameId, + values: validateValues(state.values) || newValues, + tolgeePresent: !libData + ? 'not_present' + : libData.uiPresent === undefined + ? 'legacy' + : 'present', + }; + } + case 'SET_ERROR': + return { + ...state, + tolgeePresent: 'not_present', + error: action.payload, + }; + case 'SET_APPLIED_VALUES': + return { + ...state, + appliedValues: action.payload, + }; + case 'SET_CREDENTIALS_CHECK': + return { + ...state, + credentialsCheck: action.payload, + }; + case 'LOAD_STORED_VALUES': + return { + ...state, + storedValues: action.payload, + values: action.payload, + }; + case 'APPLY_VALUES': { + // sync values with storage/localStorage + apply(); + const branchEnabled = + state.credentialsCheck !== null && + typeof state.credentialsCheck === 'object' && + 'branchingEnabled' in state.credentialsCheck && + state.credentialsCheck.branchingEnabled; + const effectiveBranch = branchEnabled + ? state.values?.branch + : undefined; + return { + ...state, + appliedValues: { + apiKey: state.values?.apiKey, + apiUrl: state.values?.apiUrl, + branch: effectiveBranch, + }, + storedValues: { + apiKey: state.values?.apiKey, + apiUrl: state.values?.apiUrl, + branch: effectiveBranch, + }, + }; + } + case 'CLEAR_ALL': { + apply(); + return { + ...state, + appliedValues: undefined, + storedValues: null, + values: null, + libConfig: null, + declaredProject: null, + declaredProjectInaccessible: false, + }; + } + case 'OAUTH_APPLY': { + apply(); + // Keep any project the user already picked for this backend (restored from storage) across a re-connect. + const oauthValues = { + apiUrl: action.payload.apiUrl, + authToken: action.payload.authToken, + projectId: state.values?.projectId, + }; + return { + ...state, + values: oauthValues, + appliedValues: oauthValues, + storedValues: oauthValues, + declaredProject: null, + declaredProjectInaccessible: false, + }; + } + case 'OAUTH_SET_PROJECT': { + apply(); + const oauthValues = { + ...state.values, + projectId: action.payload.projectId, + }; + return { + ...state, + values: oauthValues, + appliedValues: oauthValues, + storedValues: oauthValues, + }; + } + case 'RESOLVE_PROJECT': { + const { project, inaccessible } = action.payload; + if (!project) { + return { + ...state, + declaredProject: null, + declaredProjectInaccessible: inaccessible, + }; + } + // Bind the declared project so in-context editing has a target: an all-projects token carries none, and even a + // single-project token needs the id sent explicitly on every request. + apply(); + const oauthValues = { ...state.values, projectId: project.id }; + return { + ...state, + declaredProject: project, + declaredProjectInaccessible: false, + values: oauthValues, + appliedValues: oauthValues, + storedValues: oauthValues, + }; + } + case 'STORE_VALUES': + apply(); + return { + ...state, + storedValues: state.appliedValues || null, + values: state.appliedValues || null, + appliedValues: null, + }; + case 'LOAD_VALUES': + apply(); + return { + ...state, + appliedValues: state.storedValues, + values: state.storedValues, + }; + case 'SET_BRANCHES': + return { + ...state, + branches: action.payload, + }; + default: + // @ts-expect-error action type is type uknown + throw new Error(`Unknown action ${action.type}`); + } + }; diff --git a/src/popup/tools.test.ts b/src/popup/tools.test.ts new file mode 100644 index 0000000..8e632bd --- /dev/null +++ b/src/popup/tools.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { + compareValues, + decodeTokenProjectSet, + isOAuth, + normalizeUrl, + validateValues, +} from './tools'; + +// Builds a JWT-shaped string (header.payload.signature) whose payload base64url-encodes the given claims, so we can +// exercise the token parsing without a real signature. +const tokenWith = (claims: Record) => { + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url'); + return `eyJhbGciOiJSUzI1NiJ9.${payload}.signature`; +}; + +describe('decodeTokenProjectSet', () => { + it('returns "*" for an all-projects token', () => { + expect(decodeTokenProjectSet(tokenWith({ 'tg.prj': '*' }))).toBe('*'); + }); + + it('returns the ids for a project-scoped token', () => { + expect(decodeTokenProjectSet(tokenWith({ 'tg.prj': [2] }))).toEqual([2]); + expect(decodeTokenProjectSet(tokenWith({ 'tg.prj': [2, 3] }))).toEqual([ + 2, 3, + ]); + }); + + it('coerces string ids to numbers and drops non-numeric entries', () => { + expect( + decodeTokenProjectSet(tokenWith({ 'tg.prj': ['2', 'x', 3] })) + ).toEqual([2, 3]); + }); + + it('returns undefined when the claim is absent', () => { + expect(decodeTokenProjectSet(tokenWith({ sub: '1' }))).toBeUndefined(); + }); + + it('returns undefined for an empty or malformed token', () => { + expect(decodeTokenProjectSet(undefined)).toBeUndefined(); + expect(decodeTokenProjectSet('not-a-jwt')).toBeUndefined(); + }); +}); + +describe('validateValues', () => { + it('accepts an api key with a url', () => { + const v = { apiKey: 'tgpak_x', apiUrl: 'https://app.tolgee.io' }; + expect(validateValues(v)).toBe(v); + }); + + it('accepts an oauth token with a url', () => { + const v = { authToken: 'jwt', apiUrl: 'https://app.tolgee.io' }; + expect(validateValues(v)).toBe(v); + }); + + it('rejects a credential without a url', () => { + expect(validateValues({ apiKey: 'tgpak_x' })).toBeNull(); + }); + + it('rejects a url without any credential', () => { + expect(validateValues({ apiUrl: 'https://app.tolgee.io' })).toBeNull(); + }); + + it('rejects null/undefined', () => { + expect(validateValues(null)).toBeNull(); + expect(validateValues(undefined)).toBeNull(); + }); +}); + +describe('isOAuth', () => { + it('is true only for a bare auth token', () => { + expect(isOAuth({ authToken: 'jwt' })).toBe(true); + }); + + it('is false when an api key is also present', () => { + expect(isOAuth({ authToken: 'jwt', apiKey: 'tgpak_x' })).toBe(false); + }); + + it('is false for an api key alone or nothing', () => { + expect(isOAuth({ apiKey: 'tgpak_x' })).toBe(false); + expect(isOAuth(undefined)).toBe(false); + }); +}); + +describe('normalizeUrl', () => { + it('strips a single trailing slash', () => { + expect(normalizeUrl('https://app.tolgee.io/')).toBe( + 'https://app.tolgee.io' + ); + }); + + it('leaves a url without a trailing slash untouched', () => { + expect(normalizeUrl('https://app.tolgee.io')).toBe('https://app.tolgee.io'); + }); + + it('passes through undefined', () => { + expect(normalizeUrl(undefined)).toBeUndefined(); + }); +}); + +describe('compareValues', () => { + const base = { + apiUrl: 'https://app.tolgee.io', + apiKey: 'tgpak_x', + branch: 'main', + projectId: 2, + }; + + it('treats identical values as equal', () => { + expect(compareValues(base, { ...base })).toBe(true); + }); + + it('treats an empty branch and undefined branch as equal', () => { + expect( + compareValues({ ...base, branch: '' }, { ...base, branch: undefined }) + ).toBe(true); + }); + + it('detects a differing field', () => { + expect(compareValues(base, { ...base, projectId: 3 })).toBe(false); + expect(compareValues(base, { ...base, authToken: 'jwt' })).toBe(false); + }); +}); diff --git a/src/popup/useDetectorForm.tsx b/src/popup/useDetectorForm.tsx index 077efd3..d1aee5a 100644 --- a/src/popup/useDetectorForm.tsx +++ b/src/popup/useDetectorForm.tsx @@ -1,14 +1,12 @@ /* eslint-disable react-hooks/exhaustive-deps */ import browser, { type Runtime } from 'webextension-polyfill'; import { useEffect, useReducer } from 'react'; -import { LibConfig } from '../types'; import { loadAppliedValues } from './loadConfig'; import { sendMessage } from './sendMessage'; import { sendToBackground } from './sendToBackground'; import { loadValues, storeValues } from './storage'; import { compareValues, - decodeTokenProjectSet, isOAuth, normalizeUrl, validateValues, @@ -16,213 +14,12 @@ import { } from './tools'; import { useApplier } from './useApplier'; import { RuntimeMessage } from '../content/Messages'; - -type ProjectInfo = { - projectName: string; - projectId: number; - scopes: string[]; - userFullName: string; - branchingEnabled: boolean; -}; - -type OAuthUser = { - oauth: true; - userFullName: string; -}; - -type CredentialsCheck = null | 'loading' | 'invalid' | ProjectInfo | OAuthUser; -type TolgeePresent = 'loading' | 'present' | 'not_present' | 'legacy'; - -type BranchOption = { - name: string; - isDefault: boolean; -}; - -type ProjectOption = { - id: number; - name: string; -}; - -const initialState = { - values: null as Values | null, - storedValues: null as Values | null, - appliedValues: null as Values | null | undefined, - tolgeePresent: 'loading' as TolgeePresent, - credentialsCheck: null as CredentialsCheck, - libConfig: null as LibConfig | null, - error: null as string | null, - frameId: null as number | null, - branches: null as BranchOption[] | null, - projects: null as ProjectOption[] | null, -}; - -type State = typeof initialState; -type Action = - | { type: 'CHANGE_VALUES'; payload: Partial } - | { - type: 'CHANGE_LIB_CONFIG'; - payload: { libData: LibConfig | null; frameId: number | null }; - } - | { type: 'SET_ERROR'; payload: string } - | { type: 'SET_APPLIED_VALUES'; payload: Values | null } - | { type: 'SET_CREDENTIALS_CHECK'; payload: CredentialsCheck } - | { type: 'LOAD_STORED_VALUES'; payload: Values | null } - | { type: 'APPLY_VALUES' } - | { type: 'CLEAR_ALL' } - | { type: 'STORE_VALUES' } - | { type: 'LOAD_VALUES' } - | { type: 'OAUTH_APPLY'; payload: { apiUrl: string; authToken: string } } - | { type: 'OAUTH_SET_PROJECT'; payload: { projectId: number | undefined } } - | { type: 'SET_BRANCHES'; payload: BranchOption[] | null } - | { type: 'SET_PROJECTS'; payload: ProjectOption[] | null }; +import { CredentialsCheck, createReducer, initialState } from './reducer'; export const useDetectorForm = () => { const { applyRequired, apply } = useApplier(); - const reducer = (state: State, action: Action): State => { - switch (action.type) { - case 'CHANGE_VALUES': - return { ...state, values: { ...state.values, ...action.payload } }; - case 'CHANGE_LIB_CONFIG': { - const { libData, frameId } = action.payload; - const newValues = { - apiKey: libData?.config?.apiKey, - apiUrl: libData?.config?.apiUrl, - branch: libData?.config?.branch, - }; - if (state.libConfig !== null && state.frameId !== frameId) { - return { - ...state, - error: 'Detected multiple Tolgee instances', - }; - } - return { - ...state, - libConfig: libData, - frameId, - values: validateValues(state.values) || newValues, - tolgeePresent: !libData - ? 'not_present' - : libData.uiPresent === undefined - ? 'legacy' - : 'present', - }; - } - case 'SET_ERROR': - return { - ...state, - tolgeePresent: 'not_present', - error: action.payload, - }; - case 'SET_APPLIED_VALUES': - return { - ...state, - appliedValues: action.payload, - }; - case 'SET_CREDENTIALS_CHECK': - return { - ...state, - credentialsCheck: action.payload, - }; - case 'LOAD_STORED_VALUES': - return { - ...state, - storedValues: action.payload, - values: action.payload, - }; - case 'APPLY_VALUES': { - // sync values with storage/localStorage - apply(); - const branchEnabled = - state.credentialsCheck !== null && - typeof state.credentialsCheck === 'object' && - 'branchingEnabled' in state.credentialsCheck && - state.credentialsCheck.branchingEnabled; - const effectiveBranch = branchEnabled - ? state.values?.branch - : undefined; - return { - ...state, - appliedValues: { - apiKey: state.values?.apiKey, - apiUrl: state.values?.apiUrl, - branch: effectiveBranch, - }, - storedValues: { - apiKey: state.values?.apiKey, - apiUrl: state.values?.apiUrl, - branch: effectiveBranch, - }, - }; - } - case 'CLEAR_ALL': { - apply(); - return { - ...state, - appliedValues: undefined, - storedValues: null, - values: null, - libConfig: null, - }; - } - case 'OAUTH_APPLY': { - apply(); - // Keep any project the user already picked for this backend (restored from storage) across a re-connect. - const oauthValues = { - apiUrl: action.payload.apiUrl, - authToken: action.payload.authToken, - projectId: state.values?.projectId, - }; - return { - ...state, - values: oauthValues, - appliedValues: oauthValues, - storedValues: oauthValues, - }; - } - case 'OAUTH_SET_PROJECT': { - apply(); - const oauthValues = { - ...state.values, - projectId: action.payload.projectId, - }; - return { - ...state, - values: oauthValues, - appliedValues: oauthValues, - storedValues: oauthValues, - }; - } - case 'SET_PROJECTS': - return { - ...state, - projects: action.payload, - }; - case 'STORE_VALUES': - apply(); - return { - ...state, - storedValues: state.appliedValues || null, - values: state.appliedValues || null, - appliedValues: null, - }; - case 'LOAD_VALUES': - apply(); - return { - ...state, - appliedValues: state.storedValues, - values: state.storedValues, - }; - case 'SET_BRANCHES': - return { - ...state, - branches: action.payload, - }; - default: - // @ts-expect-error action type is type uknown - throw new Error(`Unknown action ${action.type}`); - } - }; + const reducer = createReducer(apply); const [state, dispatch] = useReducer(reducer, initialState); const { storedValues, appliedValues, libConfig } = state; @@ -442,74 +239,61 @@ export const useDetectorForm = () => { }; }, [state.credentialsCheck]); - // OAuth tokens carry no project, so once the token is confirmed, load the user's accessible projects to pick from. + // The page declares which project it edits (required by the extension), but an OAuth token isn't inherently bound to + // it. Resolve that declared id against the connected server: bind it when the user can edit it there, or flag it + // inaccessible — otherwise the token stays unscoped and in-context editing fails with "project not selected". useEffect(() => { let cancelled = false; const check = state.credentialsCheck; - if ( + const isOauthCheck = check !== null && typeof check === 'object' && 'oauth' in check && - isOAuth(checkableValues) - ) { - const url = normalizeUrl(checkableValues!.apiUrl); - fetch(`${url}/v2/projects?size=1000`, { - headers: { Authorization: `Bearer ${checkableValues!.authToken}` }, - }) - .then((r) => { - if (!r.ok) { - throw new Error('Failed to load projects'); - } - return r.json(); - }) - .then((data) => { - if (!cancelled) { - dispatch({ - type: 'SET_PROJECTS', - payload: - data?._embedded?.projects?.map((p: any) => ({ - id: p.id, - name: p.name, - })) ?? [], - }); - } - }) - .catch(() => { - if (!cancelled) { - dispatch({ type: 'SET_PROJECTS', payload: null }); - } - }); - } else { - dispatch({ type: 'SET_PROJECTS', payload: null }); + isOAuth(checkableValues); + const declaredId = Number( + (libConfig?.config as { projectId?: number | string } | undefined) + ?.projectId + ); + if (!isOauthCheck || !declaredId) { + dispatch({ + type: 'RESOLVE_PROJECT', + payload: { project: null, inaccessible: false }, + }); + return; } + const url = normalizeUrl(checkableValues!.apiUrl); + fetch(`${url}/v2/projects/${declaredId}`, { + headers: { Authorization: `Bearer ${checkableValues!.authToken}` }, + }) + .then((r) => { + if (!r.ok) { + throw new Error('inaccessible'); + } + return r.json(); + }) + .then((data) => { + if (!cancelled) { + dispatch({ + type: 'RESOLVE_PROJECT', + payload: { + project: { id: data.id, name: data.name }, + inaccessible: false, + }, + }); + } + }) + .catch(() => { + if (!cancelled) { + dispatch({ + type: 'RESOLVE_PROJECT', + payload: { project: null, inaccessible: true }, + }); + } + }); return () => { cancelled = true; }; }, [state.credentialsCheck]); - // A token bound to a single project (chosen on the consent screen) auto-selects it — no popup pick needed. Only the - // "all projects" case falls back to the dropdown above. - useEffect(() => { - const check = state.credentialsCheck; - if ( - check !== null && - typeof check === 'object' && - 'oauth' in check && - isOAuth(checkableValues) - ) { - const projectSet = decodeTokenProjectSet(checkableValues!.authToken); - if ( - Array.isArray(projectSet) && - projectSet.length === 1 && - state.values?.projectId !== projectSet[0] - ) { - dispatch({ - type: 'OAUTH_SET_PROJECT', - payload: { projectId: projectSet[0] }, - }); - } - } - }, [state.credentialsCheck]); - return [state, dispatch] as const; }; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fc0cd24 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +// Standalone from the crxjs build configs on purpose: these are pure unit tests (popup logic + helpers) that need +// neither the extension bundler plugins nor a DOM. +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + environment: 'node', + }, +}); From b3d9f424213b7ddb78a63e5b55014a35608cfe11 Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Thu, 13 Aug 2026 14:23:47 +0200 Subject: [PATCH 08/18] feat: unify the connected popup into a single Disconnect view --- src/popup/TolgeeDetector.tsx | 250 ++++++++++++++++++++--------------- 1 file changed, 142 insertions(+), 108 deletions(-) diff --git a/src/popup/TolgeeDetector.tsx b/src/popup/TolgeeDetector.tsx index 564e5a3..2e543a2 100644 --- a/src/popup/TolgeeDetector.tsx +++ b/src/popup/TolgeeDetector.tsx @@ -17,7 +17,7 @@ import { } from '@mui/material'; import { useDetectorForm } from './useDetectorForm'; -import { decodeTokenProjectSet, validateValues } from './tools'; +import { decodeTokenProjectSet, isOAuth, validateValues } from './tools'; import { sendToBackground } from './sendToBackground'; const POPUP_WIDTH = 400; @@ -119,6 +119,21 @@ export const TolgeeDetector = () => { const dataPresent = storedValues || appliedValues; + // Which credentials the session is actually built on, regardless of the Applied toggle: applied when live, otherwise + // the stored ones (an OAuth session's token is re-fetched into storedValues on load). + const activeValues = appliedValues || storedValues || values; + const isOauthSession = isOAuth(activeValues); + + // OAuth Disconnect drops the local token (service worker + storage) but keeps the server-side consent, so reconnecting + // is silent. API-key Disconnect is just the old Clear. Either way the local session is wiped. + const handleDisconnect = async () => { + const apiUrl = activeValues?.apiUrl; + if (isOauthSession && apiUrl) { + await sendToBackground('OAUTH_LOGOUT', { apiUrl }); + } + dispatch({ type: 'CLEAR_ALL' }); + }; + const serverField = ( { /> ); - const appliedControls = (isInDevelopmentMode: boolean) => ( - - - {dataPresent ? ( - <> - - Applied - - ) : isInDevelopmentMode ? ( - - Api key is included directly in Tolgee configuration.
    Use - this setup only in development environment. -
    - ) : ( - '' - )} -
    - - {dataPresent && ( - - )} + const projectPicker = declaredProject && ( + option.name} + isOptionEqualToValue={(option, value) => option.id === value.id} + value={values?.projectId != null ? declaredProject : ALL_PROJECTS_OPTION} + onChange={(_e, newValue) => { + dispatch({ + type: 'OAUTH_SET_PROJECT', + payload: { + projectId: + newValue && newValue.id !== ALL_PROJECTS_OPTION.id + ? newValue.id + : undefined, + }, + }); + }} + renderInput={(params) => ( + + )} + /> + ); + + const footer = ( + + + + Applied + ); @@ -279,6 +313,66 @@ export const TolgeeDetector = () => { // keep the raw value if it's not a full URL yet } + // Once a session exists (either auth method) the popup is a single status view — no tabs, no auth-key field. + if (dataPresent) { + return ( + + Tolgee plugin + + + + + + {isOauthSession ? ( + <> + + {oauthUser + ? `Connected as ${oauthUser.userFullName}` + : 'Connected'} + + {declaredProjectInaccessible ? ( + + This site requests a project you can’t edit on {serverHost}. + Check the projectId in the site’s Tolgee configuration, or ask + for access. + + ) : ( + projectPicker + )} + + ) : ( + <> + {credentialsCheck !== null && + typeof credentialsCheck === 'object' && + 'projectName' in credentialsCheck ? ( + + {credentialsCheck.projectName} + + ) : credentialsCheck === 'invalid' ? ( + + Invalid API key + + ) : null} + {branchField} + + )} + + {footer} + + ); + } + + // No session yet — let the user pick how to connect. return ( { {tab === 'login' && - (oauthUser ? ( - - - Connected as {oauthUser.userFullName} - - {declaredProjectInaccessible ? ( - - This site requests a project you can’t edit on {serverHost}. - Check the projectId in the site’s Tolgee configuration, or ask - for access. - - ) : declaredProject ? ( - option.name} - isOptionEqualToValue={(option, value) => - option.id === value.id - } - value={ - values?.projectId != null - ? declaredProject - : ALL_PROJECTS_OPTION - } - onChange={(_e, newValue) => { - dispatch({ - type: 'OAUTH_SET_PROJECT', - payload: { - projectId: - newValue && newValue.id !== ALL_PROJECTS_OPTION.id - ? newValue.id - : undefined, - }, - }); - }} - renderInput={(params) => ( - - )} - /> - ) : ( - - - - )} - {appliedControls(isInDevelopmentMode)} - - ) : projectDetected ? ( + (projectDetected ? ( {serverOpen ? ( serverField @@ -442,21 +480,13 @@ export const TolgeeDetector = () => { style={{ minHeight: 15 }} sx={{ marginLeft: 0 }} > - {credentialsCheck === null ? ( - '' - ) : credentialsCheck === 'loading' ? ( - '...' - ) : credentialsCheck === 'invalid' ? ( - 'Invalid' - ) : 'oauth' in credentialsCheck ? ( - - Connected as {credentialsCheck.userFullName} - - ) : ( - - {credentialsCheck.projectName} - - )} + {credentialsCheck === null + ? '' + : credentialsCheck === 'loading' + ? '...' + : credentialsCheck === 'invalid' + ? 'Invalid' + : ''}
    @@ -471,7 +501,6 @@ export const TolgeeDetector = () => { ? - {branchField} - {appliedControls(isInDevelopmentMode)} + {isInDevelopmentMode && ( + + Api key is included directly in Tolgee configuration.
    Use + this setup only in development environment. +
    + )} )} From 5a8bd87bda768cdfd0b03923421dafac110a498a Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Thu, 13 Aug 2026 15:48:42 +0200 Subject: [PATCH 09/18] fix: recognize a restored OAuth session so the popup shows the connected user --- src/popup/tools.test.ts | 13 +++++++++++++ src/popup/tools.ts | 14 ++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/popup/tools.test.ts b/src/popup/tools.test.ts index 8e632bd..cf2e129 100644 --- a/src/popup/tools.test.ts +++ b/src/popup/tools.test.ts @@ -120,4 +120,17 @@ describe('compareValues', () => { expect(compareValues(base, { ...base, projectId: 3 })).toBe(false); expect(compareValues(base, { ...base, authToken: 'jwt' })).toBe(false); }); + + it('treats a string projectId and a numeric projectId as equal', () => { + expect( + compareValues({ ...base, projectId: '2' as any }, { ...base }) + ).toBe(true); + }); + + it('treats a null apiKey and an undefined apiKey as equal', () => { + const oauth = { apiUrl: base.apiUrl, authToken: 'jwt', projectId: 1 }; + expect( + compareValues({ ...oauth, apiKey: null as any }, { ...oauth }) + ).toBe(true); + }); }); diff --git a/src/popup/tools.ts b/src/popup/tools.ts index eb5809a..365d17a 100644 --- a/src/popup/tools.ts +++ b/src/popup/tools.ts @@ -22,11 +22,17 @@ export const compareValues = ( values1?: Values | null, values2?: Values | null ) => { + // A restored session and the page's applied values describe the same credentials but not identically: sessionStorage + // hands back projectId as a string and apiKey as null, while the stored copy holds a number and undefined. Normalize + // so a healthy OAuth session isn't seen as "changed" (which would skip the connected-user lookup). + const str = (v?: string | null) => v || undefined; + const num = (v?: number | string | null) => + v === undefined || v === null || v === '' ? undefined : Number(v); return ( - values1?.apiKey === values2?.apiKey && - values1?.apiUrl === values2?.apiUrl && - values1?.authToken === values2?.authToken && - values1?.projectId === values2?.projectId && + str(values1?.apiKey) === str(values2?.apiKey) && + str(values1?.apiUrl) === str(values2?.apiUrl) && + str(values1?.authToken) === str(values2?.authToken) && + num(values1?.projectId) === num(values2?.projectId) && (values1?.branch || '') === (values2?.branch || '') ); }; From e36af312bcc41c4dce3fc4177949bd291740c84e Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Thu, 13 Aug 2026 16:08:03 +0200 Subject: [PATCH 10/18] feat: validate the API key before it can be connected --- src/popup/TolgeeDetector.tsx | 48 +++++++++++++++++++++++++------- src/popup/tools.test.ts | 12 ++++---- src/popup/useApiKeyCheck.ts | 53 ++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 16 deletions(-) create mode 100644 src/popup/useApiKeyCheck.ts diff --git a/src/popup/TolgeeDetector.tsx b/src/popup/TolgeeDetector.tsx index 2e543a2..0ab2f19 100644 --- a/src/popup/TolgeeDetector.tsx +++ b/src/popup/TolgeeDetector.tsx @@ -19,6 +19,7 @@ import { import { useDetectorForm } from './useDetectorForm'; import { decodeTokenProjectSet, isOAuth, validateValues } from './tools'; import { sendToBackground } from './sendToBackground'; +import { useApiKeyCheck } from './useApiKeyCheck'; const POPUP_WIDTH = 400; const DEFAULT_SERVER = 'https://app.tolgee.io'; @@ -56,6 +57,19 @@ export const TolgeeDetector = () => { ? credentialsCheck : null; + // Live-validate the key being typed on the API KEY tab (before it's applied), so a key that's invalid for the target + // server can't be silently connected. + const notConnected = !storedValues && !appliedValues; + const apiKeyCheck = useApiKeyCheck( + values?.apiUrl, + values?.apiKey, + tab === 'apiKey' && notConnected + ); + const apiKeyValid = + apiKeyCheck !== null && + typeof apiKeyCheck === 'object' && + 'projectName' in apiKeyCheck; + // A single-project token auto-selects its project (done in the reducer); only an "all projects" token needs the // manual picker below. const allProjectsToken = decodeTokenProjectSet(values?.authToken) === '*'; @@ -472,21 +486,33 @@ export const TolgeeDetector = () => { payload: { apiKey: e.target.value }, }) } - onKeyDown={handleKeyDown} + onKeyDown={(e) => { + if ( + e.keyCode === 13 && + validateValues(values) && + apiKeyValid + ) { + dispatch({ type: 'APPLY_VALUES' }); + } + }} size="small" /> - {credentialsCheck === null - ? '' - : credentialsCheck === 'loading' - ? '...' - : credentialsCheck === 'invalid' - ? 'Invalid' - : ''} + {apiKeyCheck === null ? ( + '' + ) : apiKeyCheck === 'loading' ? ( + '...' + ) : apiKeyCheck === 'invalid' ? ( + 'Invalid API key for this server' + ) : ( + + {apiKeyCheck.projectName} + + )} @@ -505,7 +531,9 @@ export const TolgeeDetector = () => { variant="contained" color="primary" onClick={() => dispatch({ type: 'APPLY_VALUES' })} - disabled={!validateValues(values) || valuesNotChanged} + disabled={ + !validateValues(values) || valuesNotChanged || !apiKeyValid + } > Connect with API key diff --git a/src/popup/tools.test.ts b/src/popup/tools.test.ts index cf2e129..701fc5a 100644 --- a/src/popup/tools.test.ts +++ b/src/popup/tools.test.ts @@ -122,15 +122,15 @@ describe('compareValues', () => { }); it('treats a string projectId and a numeric projectId as equal', () => { - expect( - compareValues({ ...base, projectId: '2' as any }, { ...base }) - ).toBe(true); + expect(compareValues({ ...base, projectId: '2' as any }, { ...base })).toBe( + true + ); }); it('treats a null apiKey and an undefined apiKey as equal', () => { const oauth = { apiUrl: base.apiUrl, authToken: 'jwt', projectId: 1 }; - expect( - compareValues({ ...oauth, apiKey: null as any }, { ...oauth }) - ).toBe(true); + expect(compareValues({ ...oauth, apiKey: null as any }, { ...oauth })).toBe( + true + ); }); }); diff --git a/src/popup/useApiKeyCheck.ts b/src/popup/useApiKeyCheck.ts new file mode 100644 index 0000000..9c49fbc --- /dev/null +++ b/src/popup/useApiKeyCheck.ts @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react'; +import { normalizeUrl } from './tools'; + +// Result of live-validating a typed API key against its server, before it's applied. +export type ApiKeyCheck = + | null + | 'loading' + | 'invalid' + | { projectName: string }; + +/** + * Validates the API key the user is typing against `/v2/api-keys/current` on the target server, so an invalid key + * (e.g. a cloud key pointed at a local backend) can't be silently applied. Debounced; only runs while `enabled`. + */ +export const useApiKeyCheck = ( + apiUrl: string | undefined, + apiKey: string | undefined, + enabled: boolean +): ApiKeyCheck => { + const [check, setCheck] = useState(null); + + useEffect(() => { + const url = normalizeUrl(apiUrl || ''); + if (!enabled || !apiKey || !url) { + setCheck(null); + return; + } + + let cancelled = false; + setCheck('loading'); + const timer = setTimeout(() => { + fetch(`${url}/v2/api-keys/current?ak=${apiKey}`) + .then((r) => (r.ok ? r.json() : Promise.reject())) + .then((data) => { + if (!cancelled) { + setCheck({ projectName: data.projectName }); + } + }) + .catch(() => { + if (!cancelled) { + setCheck('invalid'); + } + }); + }, 400); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [enabled, apiUrl, apiKey]); + + return check; +}; From a6f0c707ffc1a63afc916d2a7ad5e5415f8b4126 Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Thu, 13 Aug 2026 16:14:05 +0200 Subject: [PATCH 11/18] fix: retry the OAuth authorization window on a transient load failure --- src/oauth/oauthClient.ts | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/src/oauth/oauthClient.ts b/src/oauth/oauthClient.ts index 763b8d7..bdcbe18 100644 --- a/src/oauth/oauthClient.ts +++ b/src/oauth/oauthClient.ts @@ -40,6 +40,39 @@ const postToken = async ( return parseTokenResponse(await res.json(), previousRefreshToken); }; +const AUTH_MAX_ATTEMPTS = 3; +const AUTH_RETRY_DELAY_MS = 500; + +const wasCancelledByUser = (message: string) => + /cancel|did not approve|denied|closed by the user/i.test(message); + +// launchWebAuthFlow runs the authorize flow in an isolated window that intermittently fails to load the bootstrap SPA +// ("Authorization page could not be loaded"), even though a retry succeeds. Reopen it a couple of times on such a +// transient failure, but never after the user closes/denies the window (that decision is final). +const launchAuthWithRetry = async (url: string): Promise => { + let lastError: unknown; + for (let attempt = 1; attempt <= AUTH_MAX_ATTEMPTS; attempt++) { + try { + return await browser.identity.launchWebAuthFlow({ + url, + interactive: true, + }); + } catch (e) { + lastError = e; + const message = e instanceof Error ? e.message : String(e); + if (wasCancelledByUser(message) || attempt === AUTH_MAX_ATTEMPTS) { + throw e; + } + console.warn( + `[tolgee-oauth] authorization attempt ${attempt} failed, retrying`, + message + ); + await new Promise((resolve) => setTimeout(resolve, AUTH_RETRY_DELAY_MS)); + } + } + throw lastError; +}; + export const login = async ( apiUrl: string, projectId?: number @@ -63,10 +96,7 @@ export const login = async ( authorizeUrl.searchParams.set('project', String(projectId)); } - const redirectResponse = await browser.identity.launchWebAuthFlow({ - url: authorizeUrl.toString(), - interactive: true, - }); + const redirectResponse = await launchAuthWithRetry(authorizeUrl.toString()); const code = new URL(redirectResponse).searchParams.get('code'); if (!code) { throw new Error('Tolgee authorization did not return a code'); From abe76f5c6727e253ded33b0e3f8b49601dceed5e Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Fri, 14 Aug 2026 15:03:25 +0200 Subject: [PATCH 12/18] fix: harden OAuth token and redirect handling in the service worker - Deduplicate concurrent refreshes per backend: the alarm handler and an OAUTH_GET_TOKEN message can both refresh at once, and with refresh-token rotation the second call spent the same single-use token and cleared the just-refreshed session, logging the user out silently. Share one in-flight refresh per origin, and clear the session only on a terminal 4xx (not on a transient network error). - Validate the token response before building OAuthTokens: reject a missing access_token, and use a default lifetime when expires_in is absent so a token isn't read as immediately expired (which triggered a refresh per read). - Validate the OAuth redirect: reject a state mismatch (OAuth 2.1) and surface an error/error_description the redirect carries instead of a code. - Attach rejection handlers to OAUTH_GET_TOKEN and OAUTH_LOGOUT so a malformed apiUrl (new URL throw) or a storage error can't leave the caller hanging. - Create the refresh alarm only when absent: re-creating it on every MV3 worker wake reset the schedule, so a frequently-woken worker never refreshed. - Skip writing an empty UPDATE_AUTH_TOKEN so 'Bearer undefined' can't reach the SDK. --- src/background/background.ts | 33 ++++++++++++++++++++++----- src/content/contentScript.ts | 11 ++++++++- src/oauth/oauthClient.ts | 42 ++++++++++++++++++++++++++++------- src/oauth/tokenStore.ts | 43 ++++++++++++++++++++++++++++++------ 4 files changed, 108 insertions(+), 21 deletions(-) diff --git a/src/background/background.ts b/src/background/background.ts index 03fc092..c9c09c6 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -26,6 +26,14 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => { setStateIcon(data, sender.tab!.id!); sendResponse({}); break; + case 'OPEN_POPUP': + // Best effort: chrome.action.openPopup() is only available on newer Chrome; if it's missing or the call is + // refused, the in-context alert still tells the user to click the extension icon. + (browser.action as { openPopup?: () => Promise }) + .openPopup?.() + .catch(() => undefined); + sendResponse({}); + break; case 'OAUTH_LOGIN': login(data.apiUrl, data.projectId) .then(async (tokens) => { @@ -47,12 +55,20 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => { }); return true; case 'OAUTH_GET_TOKEN': - getValidAccessToken(data.apiUrl).then((accessToken) => - sendResponse({ accessToken }) - ); + getValidAccessToken(data.apiUrl) + .then((accessToken) => sendResponse({ accessToken })) + .catch((e) => { + console.error('[tolgee-oauth] token lookup failed', e); + sendResponse({ accessToken: null, error: String(e) }); + }); return true; case 'OAUTH_LOGOUT': - clearSession(data.apiUrl).then(() => sendResponse({})); + clearSession(data.apiUrl) + .then(() => sendResponse({})) + .catch((e) => { + console.error('[tolgee-oauth] logout failed', e); + sendResponse({ error: String(e) }); + }); return true; default: sendResponse({}); @@ -79,7 +95,14 @@ const injectCredentials = async ( // Keep stored sessions fresh so the popup and the injected page token don't expire mid-use. Rotation means each // refresh mints a new access + refresh token; getValidAccessToken persists them and pushes the access token to tabs. -browser.alarms.create(REFRESH_ALARM, { periodInMinutes: 10 }); +// This top-level code re-runs every time the MV3 worker wakes; re-creating an existing alarm resets its schedule, so a +// worker that wakes more often than the period would never let the alarm fire — only create it when it's absent. +const ensureRefreshAlarm = async () => { + if (!(await browser.alarms.get(REFRESH_ALARM))) { + await browser.alarms.create(REFRESH_ALARM, { periodInMinutes: 10 }); + } +}; +ensureRefreshAlarm(); browser.alarms.onAlarm.addListener(async (alarm) => { if (alarm.name !== REFRESH_ALARM) { return; diff --git a/src/content/contentScript.ts b/src/content/contentScript.ts index 344282d..2369aa5 100644 --- a/src/content/contentScript.ts +++ b/src/content/contentScript.ts @@ -70,6 +70,11 @@ messages.listenWindow('TOLGEE_TAKE_SCREENSHOT', () => { }); }); +// in-context editor asks to open the popup (e.g. so the user can re-connect after their OAuth session expired) +messages.listenWindow('TOLGEE_OPEN_PLUGIN', () => { + messages.sendToPlugin('OPEN_POPUP'); +}); + messages.startRuntimeListening(); // popup will ask if tolgee is present on the page @@ -111,7 +116,11 @@ messages.listenRuntime('SET_CREDENTIALS', async (data) => { // Background pushes a rotated access token here on refresh; update it in place so the SDK picks it up without a reload. messages.listenRuntime('UPDATE_AUTH_TOKEN', async (data) => { - if (sameOrigin(sessionStorage.getItem(API_URL_LOCAL_STORAGE), data.apiUrl)) { + // Skip an empty token: setItem would store the literal string "undefined" and the SDK would send `Bearer undefined`. + if ( + data.authToken && + sameOrigin(sessionStorage.getItem(API_URL_LOCAL_STORAGE), data.apiUrl) + ) { sessionStorage.setItem(AUTH_TOKEN_LOCAL_STORAGE, data.authToken); } }); diff --git a/src/oauth/oauthClient.ts b/src/oauth/oauthClient.ts index bdcbe18..6448e72 100644 --- a/src/oauth/oauthClient.ts +++ b/src/oauth/oauthClient.ts @@ -11,17 +11,30 @@ export type OAuthTokens = { const normalizeUrl = (url: string) => url.replace(/\/$/, ''); +// Fallback lifetime when the token endpoint omits expires_in, so getValidAccessToken doesn't read the token as already +// expired (expiresAt === now) and trigger a refresh — and, with rotation, a refresh — on every single read. +const DEFAULT_TOKEN_LIFETIME_SECONDS = 5 * 60; + export const getRedirectUri = () => browser.identity.getRedirectURL(); const parseTokenResponse = ( data: Record, previousRefreshToken?: string -): OAuthTokens => ({ - accessToken: data.access_token, - // rotation returns a fresh refresh token; if a response omits it, keep the previous one - refreshToken: data.refresh_token ?? previousRefreshToken, - expiresAt: Date.now() + (data.expires_in ?? 0) * 1000, -}); +): OAuthTokens => { + if (typeof data.access_token !== 'string' || !data.access_token) { + throw new Error('Tolgee token endpoint returned no access_token'); + } + const expiresIn = + typeof data.expires_in === 'number' && data.expires_in > 0 + ? data.expires_in + : DEFAULT_TOKEN_LIFETIME_SECONDS; + return { + accessToken: data.access_token, + // rotation returns a fresh refresh token; if a response omits it, keep the previous one + refreshToken: data.refresh_token ?? previousRefreshToken, + expiresAt: Date.now() + expiresIn * 1000, + }; +}; const postToken = async ( base: string, @@ -91,13 +104,26 @@ export const login = async ( await challengeFromVerifier(verifier) ); authorizeUrl.searchParams.set('code_challenge_method', 'S256'); - authorizeUrl.searchParams.set('state', randomUrlSafe()); + const state = randomUrlSafe(); + authorizeUrl.searchParams.set('state', state); if (projectId != null) { authorizeUrl.searchParams.set('project', String(projectId)); } const redirectResponse = await launchAuthWithRetry(authorizeUrl.toString()); - const code = new URL(redirectResponse).searchParams.get('code'); + const redirectParams = new URL(redirectResponse).searchParams; + if (redirectParams.get('state') !== state) { + throw new Error('Tolgee authorization returned an unexpected state'); + } + const error = redirectParams.get('error'); + if (error) { + throw new Error( + `Tolgee authorization failed: ${ + redirectParams.get('error_description') || error + }` + ); + } + const code = redirectParams.get('code'); if (!code) { throw new Error('Tolgee authorization did not return a code'); } diff --git a/src/oauth/tokenStore.ts b/src/oauth/tokenStore.ts index d868901..7645e00 100644 --- a/src/oauth/tokenStore.ts +++ b/src/oauth/tokenStore.ts @@ -30,6 +30,33 @@ export const loadAllSessions = async (): Promise => { .map(([, value]) => value as StoredSession); }; +// A token-endpoint 4xx means the refresh token is dead (rotated away or revoked) — terminal, clear the session. A +// network failure (fetch rejects, no status) is transient — keep the session so a later call can retry instead of +// logging the user out on a blip. postToken throws `... returned : ...` for non-ok responses. +const isTerminalRefreshFailure = (e: unknown) => + e instanceof Error && /returned 4\d\d/.test(e.message); + +// A refresh rotates the refresh token, so two concurrent refreshes for the same backend would both spend the same +// (single-use) token: the first wins, the second gets invalid_grant and clears the just-refreshed session. The alarm +// handler and an OAUTH_GET_TOKEN message can land in the same worker at once, so share one in-flight refresh per origin. +const inFlightRefresh = new Map>(); + +const refreshSession = async ( + apiUrl: string, + refreshToken: string +): Promise => { + try { + const refreshed = await refresh(apiUrl, refreshToken); + await saveSession(apiUrl, refreshed); + return refreshed.accessToken; + } catch (e) { + if (isTerminalRefreshFailure(e)) { + await clearSession(apiUrl); + } + return null; + } +}; + // Returns a valid access token, refreshing (and persisting) if it is expired or near expiry. // Returns null (and clears the session) when there is nothing valid to fall back on — the caller must re-login. export const getValidAccessToken = async ( @@ -46,12 +73,14 @@ export const getValidAccessToken = async ( await clearSession(apiUrl); return null; } - try { - const refreshed = await refresh(apiUrl, session.refreshToken); - await saveSession(apiUrl, refreshed); - return refreshed.accessToken; - } catch (e) { - await clearSession(apiUrl); - return null; + const key = keyFor(apiUrl); + const existing = inFlightRefresh.get(key); + if (existing) { + return existing; } + const pending = refreshSession(apiUrl, session.refreshToken).finally(() => + inFlightRefresh.delete(key) + ); + inFlightRefresh.set(key, pending); + return pending; }; From deeaf6fd4bc59d2caada01ff3874dc6341a97273 Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Fri, 14 Aug 2026 15:03:25 +0200 Subject: [PATCH 13/18] fix: preserve the OAuth session in the popup and surface auth failures - APPLY_VALUES rebuilt the applied/stored values from apiKey/apiUrl/branch only, dropping authToken and projectId. This action also fires on the Login tab (Enter in the Server field), so it wiped a live OAuth token and removed the stored session. Carry the OAuth fields through; add a regression test. - Show the connect error in the Login tab: login failures (closed consent window, token-exchange error) previously left the button idle with no reason. - Restrict the Server link href to http(s): the editable field could otherwise produce a javascript: link running with extension privileges. --- src/popup/TolgeeDetector.tsx | 16 ++++++++++++++-- src/popup/reducer.test.ts | 16 ++++++++++++++++ src/popup/reducer.ts | 21 +++++++++++---------- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/popup/TolgeeDetector.tsx b/src/popup/TolgeeDetector.tsx index 0ab2f19..43dc9c8 100644 --- a/src/popup/TolgeeDetector.tsx +++ b/src/popup/TolgeeDetector.tsx @@ -33,6 +33,7 @@ const ALL_PROJECTS_OPTION = { id: -1, name: 'All projects' }; export const TolgeeDetector = () => { const [state, dispatch] = useDetectorForm(); const [connecting, setConnecting] = useState(false); + const [connectError, setConnectError] = useState(null); const [tab, setTab] = useState<'login' | 'apiKey'>('login'); const [serverOpen, setServerOpen] = useState(false); const [branchOpen, setBranchOpen] = useState(false); @@ -99,6 +100,7 @@ export const TolgeeDetector = () => { const handleConnect = async () => { const apiUrl = values?.apiUrl || DEFAULT_SERVER; setConnecting(true); + setConnectError(null); try { // Hint the project the page is configured for (exposed via the handshake), so the consent screen pre-selects it // and the minted token is scoped to it. On a public project the hint resolves via the community floor. @@ -125,6 +127,8 @@ export const TolgeeDetector = () => { type: 'OAUTH_APPLY', payload: { apiUrl, authToken: res.accessToken }, }); + } else { + setConnectError(res?.error || 'Connection failed'); } } finally { setConnecting(false); @@ -321,8 +325,15 @@ export const TolgeeDetector = () => { detectedProjectId !== undefined && detectedProjectId !== ''; let serverHost = values?.apiUrl || DEFAULT_SERVER; + // Restrict the link target to http(s): the Server field is editable, and a value like `javascript:...` would become + // an executable link running with extension privileges. Fall back to the default when it isn't a valid web URL yet. + let serverLink = DEFAULT_SERVER; try { - serverHost = new URL(serverHost).host; + const parsed = new URL(values?.apiUrl || DEFAULT_SERVER); + serverHost = parsed.host; + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + serverLink = parsed.toString(); + } } catch { // keep the raw value if it's not a full URL yet } @@ -414,7 +425,7 @@ export const TolgeeDetector = () => { Connect to your account on{' '} { > {connecting ? 'Connecting…' : 'Connect to Tolgee'} + {connectError && {connectError}} {serverOpen ? ( Change if you have your own instance of Tolgee. diff --git a/src/popup/reducer.test.ts b/src/popup/reducer.test.ts index 9d79c22..dc805f6 100644 --- a/src/popup/reducer.test.ts +++ b/src/popup/reducer.test.ts @@ -137,6 +137,22 @@ describe('detector reducer', () => { const next = reduce(enabled, { type: 'APPLY_VALUES' }); expect(next.appliedValues?.branch).toBe('feature'); }); + + it('preserves the OAuth token and project (Enter in the Server field must not drop them)', () => { + const oauth: State = { + ...initialState, + values: { + apiUrl: 'https://app.tolgee.io', + authToken: 'access-token', + projectId: 7, + }, + }; + const next = reduce(oauth, { type: 'APPLY_VALUES' }); + expect(next.appliedValues?.authToken).toBe('access-token'); + expect(next.appliedValues?.projectId).toBe(7); + expect(next.storedValues?.authToken).toBe('access-token'); + expect(next.storedValues?.projectId).toBe(7); + }); }); describe('CLEAR_ALL', () => { diff --git a/src/popup/reducer.ts b/src/popup/reducer.ts index 5fb5e10..ed97557 100644 --- a/src/popup/reducer.ts +++ b/src/popup/reducer.ts @@ -139,18 +139,19 @@ export const createReducer = const effectiveBranch = branchEnabled ? state.values?.branch : undefined; + // Carry the OAuth fields through: this action also fires on the Login tab (Enter in the Server field), and + // dropping authToken/projectId there would wipe the token from state and remove the stored OAuth session. + const nextValues = { + apiKey: state.values?.apiKey, + apiUrl: state.values?.apiUrl, + branch: effectiveBranch, + authToken: state.values?.authToken, + projectId: state.values?.projectId, + }; return { ...state, - appliedValues: { - apiKey: state.values?.apiKey, - apiUrl: state.values?.apiUrl, - branch: effectiveBranch, - }, - storedValues: { - apiKey: state.values?.apiKey, - apiUrl: state.values?.apiUrl, - branch: effectiveBranch, - }, + appliedValues: nextValues, + storedValues: nextValues, }; } case 'CLEAR_ALL': { From 1e159cb408cd1bed21fe302c28b7725eae013dbd Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Fri, 14 Aug 2026 15:58:21 +0200 Subject: [PATCH 14/18] fix: harden the popup's API-key validation request - Send the API key in the X-API-Key header instead of the ?ak= query string, in both useApiKeyCheck and useDetectorForm. A query string can leak the key via URLs, browser history, and request logs, and an unencoded & or # corrupts it. - Distinguish an unreachable server from an invalid key. The catch mapped every failure to 'invalid', so pointing a valid cloud key at a stopped local backend (a DNS/CORS/5xx failure) wrongly reported the key as invalid. Only 400/401/403 now mean invalid; other failures show 'Could not reach the server'. --- src/popup/TolgeeDetector.tsx | 6 +++++- src/popup/useApiKeyCheck.ts | 24 +++++++++++++++++------- src/popup/useDetectorForm.tsx | 5 ++++- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/popup/TolgeeDetector.tsx b/src/popup/TolgeeDetector.tsx index 43dc9c8..5ee5472 100644 --- a/src/popup/TolgeeDetector.tsx +++ b/src/popup/TolgeeDetector.tsx @@ -510,7 +510,9 @@ export const TolgeeDetector = () => { size="small" /> @@ -520,6 +522,8 @@ export const TolgeeDetector = () => { '...' ) : apiKeyCheck === 'invalid' ? ( 'Invalid API key for this server' + ) : apiKeyCheck === 'unreachable' ? ( + 'Could not reach the server' ) : ( {apiKeyCheck.projectName} diff --git a/src/popup/useApiKeyCheck.ts b/src/popup/useApiKeyCheck.ts index 9c49fbc..36ab49e 100644 --- a/src/popup/useApiKeyCheck.ts +++ b/src/popup/useApiKeyCheck.ts @@ -6,6 +6,7 @@ export type ApiKeyCheck = | null | 'loading' | 'invalid' + | 'unreachable' | { projectName: string }; /** @@ -29,17 +30,26 @@ export const useApiKeyCheck = ( let cancelled = false; setCheck('loading'); const timer = setTimeout(() => { - fetch(`${url}/v2/api-keys/current?ak=${apiKey}`) - .then((r) => (r.ok ? r.json() : Promise.reject())) - .then((data) => { + // Send the key in the header, not the query string, so it can't leak via URLs/history/logs and an `&`/`#` in it + // can't corrupt the request. + fetch(`${url}/v2/api-keys/current`, { headers: { 'X-API-Key': apiKey } }) + .then((r) => { + if (r.ok) { + return r.json().then((data) => { + if (!cancelled) setCheck({ projectName: data.projectName }); + }); + } + // Only an auth/permission rejection means the key itself is wrong; any other status is a server problem, so + // don't tell the user a valid key is invalid just because the backend is down or misconfigured. if (!cancelled) { - setCheck({ projectName: data.projectName }); + setCheck( + [400, 401, 403].includes(r.status) ? 'invalid' : 'unreachable' + ); } }) .catch(() => { - if (!cancelled) { - setCheck('invalid'); - } + // Network failure, DNS, or a CORS block — the server couldn't be reached, which is not an invalid key. + if (!cancelled) setCheck('unreachable'); }); }, 400); diff --git a/src/popup/useDetectorForm.tsx b/src/popup/useDetectorForm.tsx index d1aee5a..b44a84d 100644 --- a/src/popup/useDetectorForm.tsx +++ b/src/popup/useDetectorForm.tsx @@ -156,7 +156,10 @@ export const useDetectorForm = () => { !cancelled && setCredentialsCheck('invalid'); }); } else { - fetch(`${url}/v2/api-keys/current?ak=${checkableValues!.apiKey}`) + // Send the key in the header, not the query string, so it can't leak via URLs/history/logs. + fetch(`${url}/v2/api-keys/current`, { + headers: { 'X-API-Key': checkableValues!.apiKey! }, + }) .then((r) => { if (r.ok) { return r.json(); From 6bdf711ccce35692c4e6046126e3d0e5d527ba6f Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Sat, 15 Aug 2026 20:39:58 +0200 Subject: [PATCH 15/18] feat: key OAuth sessions per project so multiple projects coexist Sessions were keyed by backend origin alone, so connecting a second site on the same backend overwrote the first site's token. Key them by (origin, project scope) instead: two concrete-project logins coexist, and an all-projects ('*') token is reused for any project on that backend. Connect now reuses a usable session (exact project or all-projects) before launching the OAuth flow, so a second site connects with no extra round trip. Token refresh keeps the original scope key and only pushes a rotated token to pages the session actually serves. --- src/background/background.ts | 66 +++++++++++++------ src/content/contentScript.ts | 9 +++ src/oauth/tokenScope.ts | 44 +++++++++++++ src/oauth/tokenStore.test.ts | 120 ++++++++++++++++++++++++++++++++++ src/oauth/tokenStore.ts | 104 ++++++++++++++++++++++------- src/popup/TolgeeDetector.tsx | 9 ++- src/popup/tools.ts | 27 +------- src/popup/useDetectorForm.tsx | 4 +- 8 files changed, 310 insertions(+), 73 deletions(-) create mode 100644 src/oauth/tokenScope.ts create mode 100644 src/oauth/tokenStore.test.ts diff --git a/src/background/background.ts b/src/background/background.ts index c9c09c6..d69c8c0 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -35,27 +35,15 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => { sendResponse({}); break; case 'OAUTH_LOGIN': - login(data.apiUrl, data.projectId) - .then(async (tokens) => { - await saveSession(data.apiUrl, tokens); - // launchWebAuthFlow steals focus, which closes the popup before it can push credentials to the page, so - // inject from here. data.tabId is the tab the popup was acting on, captured before the auth window opened. - if (data.tabId != null) { - await injectCredentials(data.tabId, { - apiUrl: data.apiUrl, - authToken: tokens.accessToken, - projectId: data.projectId, - }); - } - sendResponse({ accessToken: tokens.accessToken }); - }) + connect(data) + .then((accessToken) => sendResponse({ accessToken })) .catch((e) => { console.error('[tolgee-oauth] login failed', e); sendResponse({ error: String(e) }); }); return true; case 'OAUTH_GET_TOKEN': - getValidAccessToken(data.apiUrl) + getValidAccessToken(data.apiUrl, data.projectId) .then((accessToken) => sendResponse({ accessToken })) .catch((e) => { console.error('[tolgee-oauth] token lookup failed', e); @@ -63,7 +51,7 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => { }); return true; case 'OAUTH_LOGOUT': - clearSession(data.apiUrl) + clearSession(data.apiUrl, data.projectId) .then(() => sendResponse({})) .catch((e) => { console.error('[tolgee-oauth] logout failed', e); @@ -82,6 +70,32 @@ const setStateIcon = (state: State, tabId: number) => { }); }; +// Reuse an existing usable session before launching the OAuth flow: a matching concrete-project session, or an +// all-projects one, connects a second site on the same backend with no extra round trip — this is what makes an +// all-projects login "just work" everywhere. Only when nothing serves the requested project do we run the flow. +const connect = async (data: { + apiUrl: string; + projectId?: number; + tabId?: number; +}): Promise => { + let accessToken = await getValidAccessToken(data.apiUrl, data.projectId); + if (!accessToken) { + const tokens = await login(data.apiUrl, data.projectId); + await saveSession(data.apiUrl, tokens); + accessToken = tokens.accessToken; + } + // launchWebAuthFlow steals focus, which closes the popup before it can push credentials to the page, so inject from + // here. data.tabId is the tab the popup was acting on, captured before the auth window opened. + if (data.tabId != null) { + await injectCredentials(data.tabId, { + apiUrl: data.apiUrl, + authToken: accessToken, + projectId: data.projectId, + }); + } + return accessToken; +}; + // Inject the full credential set into a page on connect (the content script writes them to sessionStorage and reloads // so the SDK picks them up). Runs from the service worker because the popup is already gone by the time login resolves. const injectCredentials = async ( @@ -112,15 +126,25 @@ browser.alarms.onAlarm.addListener(async (alarm) => { if (session.expiresAt - OAUTH_REFRESH_SKEW_MS > Date.now()) { continue; } - const accessToken = await getValidAccessToken(session.apiUrl); + // Passing the session's own project scope refreshes exactly this session (a concrete id finds it, '*' the + // all-projects one), so rotating one project's token never disturbs another's. + const accessToken = await getValidAccessToken( + session.apiUrl, + session.projectKey + ); if (accessToken) { - await pushTokenToTabs(session.apiUrl, accessToken); + await pushTokenToTabs(session.apiUrl, session.projectKey, accessToken); } } }); -// Update the injected access token in every tab whose applied backend matches, without reloading the page. -const pushTokenToTabs = async (apiUrl: string, accessToken: string) => { +// Update the injected access token in every tab whose applied backend and project the refreshed session serves, without +// reloading the page. An all-projects ('*') session serves any project; a concrete session only its own. +const pushTokenToTabs = async ( + apiUrl: string, + projectKey: string, + accessToken: string +) => { const tabs = await browser.tabs.query({}); await Promise.all( tabs.map((tab) => @@ -129,7 +153,7 @@ const pushTokenToTabs = async (apiUrl: string, accessToken: string) => { : browser.tabs .sendMessage(tab.id, { type: 'UPDATE_AUTH_TOKEN', - data: { apiUrl, authToken: accessToken }, + data: { apiUrl, projectKey, authToken: accessToken }, }) .catch(() => undefined) ) diff --git a/src/content/contentScript.ts b/src/content/contentScript.ts index 2369aa5..06e0eca 100644 --- a/src/content/contentScript.ts +++ b/src/content/contentScript.ts @@ -116,9 +116,18 @@ messages.listenRuntime('SET_CREDENTIALS', async (data) => { // Background pushes a rotated access token here on refresh; update it in place so the SDK picks it up without a reload. messages.listenRuntime('UPDATE_AUTH_TOKEN', async (data) => { + // Only take a push whose session serves this page's project: '*' (all projects) serves any, a concrete key only its + // own. Without this, a project-2 refresh would clobber a project-3 page's token on the same backend. An absent + // projectKey (older background) matches anything, preserving the previous single-session behaviour. + const pageProjectId = sessionStorage.getItem(PROJECT_ID_LOCAL_STORAGE); + const scopeServesPage = + data.projectKey === undefined || + data.projectKey === '*' || + data.projectKey === pageProjectId; // Skip an empty token: setItem would store the literal string "undefined" and the SDK would send `Bearer undefined`. if ( data.authToken && + scopeServesPage && sameOrigin(sessionStorage.getItem(API_URL_LOCAL_STORAGE), data.apiUrl) ) { sessionStorage.setItem(AUTH_TOKEN_LOCAL_STORAGE, data.authToken); diff --git a/src/oauth/tokenScope.ts b/src/oauth/tokenScope.ts new file mode 100644 index 0000000..8dbaa65 --- /dev/null +++ b/src/oauth/tokenScope.ts @@ -0,0 +1,44 @@ +// Reads the `tg.prj` (project set) claim the backend stamped into the OAuth access token. '*' means all projects +// (the user must then pick one to edit); a single id means the token is bound to that project and we can auto-select it. +export function decodeTokenProjectSet( + token: string | undefined +): '*' | number[] | undefined { + if (!token) { + return undefined; + } + try { + const payload = JSON.parse( + atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')) + ); + const prj = payload['tg.prj']; + if (prj === '*') { + return '*'; + } + if (Array.isArray(prj)) { + return prj.map((x) => Number(x)).filter((n) => !Number.isNaN(n)); + } + return undefined; + } catch (e) { + return undefined; + } +} + +export const ALL_PROJECTS_KEY = '*'; + +// The store key for a token's project scope: the single bound project id, or '*' for an all-projects token. Keying a +// concrete-project token by its id lets two projects on the same backend coexist instead of overwriting each other; +// an all-projects token keys as '*' so it's reused for any project. A multi-project set (the extension never mints one) +// keys by its sorted ids, which no single-project lookup matches — so it is simply never reused, never mis-served. +export function projectKeyForToken(token: string): string { + const scope = decodeTokenProjectSet(token); + if (scope === '*') { + return ALL_PROJECTS_KEY; + } + if (Array.isArray(scope) && scope.length === 1) { + return String(scope[0]); + } + if (Array.isArray(scope) && scope.length > 1) { + return [...scope].sort((a, b) => a - b).join(','); + } + return ALL_PROJECTS_KEY; +} diff --git a/src/oauth/tokenStore.test.ts b/src/oauth/tokenStore.test.ts new file mode 100644 index 0000000..973de06 --- /dev/null +++ b/src/oauth/tokenStore.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// In-memory chrome.storage.local. +const store = new Map(); +vi.mock('webextension-polyfill', () => ({ + default: { + storage: { + local: { + get: async (key: string | null) => { + if (key === null) { + return Object.fromEntries(store); + } + return store.has(key) ? { [key]: store.get(key) } : {}; + }, + set: async (obj: Record) => { + Object.entries(obj).forEach(([k, v]) => store.set(k, v)); + }, + remove: async (key: string) => { + store.delete(key); + }, + }, + }, + }, +})); + +// The scope key is taken verbatim from the token string ('token-3' -> '3', 'token-*' -> '*'), so tests control it. +vi.mock('./tokenScope', () => ({ + ALL_PROJECTS_KEY: '*', + projectKeyForToken: (token: string) => token.replace(/^token-/, ''), +})); + +const refresh = vi.fn(); +vi.mock('./oauthClient', () => ({ + refresh: (apiUrl: string, refreshToken: string) => + refresh(apiUrl, refreshToken), +})); + +import { + clearSession, + getValidAccessToken, + loadSession, + saveSession, +} from './tokenStore'; + +const URL_A = 'https://app.tolgee.io'; +const future = () => Date.now() + 60 * 60 * 1000; + +const tokens = ( + scope: string, + overrides: Partial<{ refreshToken: string; expiresAt: number }> = {} +) => ({ + accessToken: `token-${scope}`, + refreshToken: 'refresh', + expiresAt: future(), + ...overrides, +}); + +describe('tokenStore per-project keying', () => { + beforeEach(() => { + store.clear(); + refresh.mockReset(); + }); + + it('keeps two concrete-project sessions on the same backend from colliding', async () => { + await saveSession(URL_A, tokens('2')); + await saveSession(URL_A, tokens('3')); + + expect((await loadSession(URL_A, 2))?.accessToken).toBe('token-2'); + expect((await loadSession(URL_A, 3))?.accessToken).toBe('token-3'); + }); + + it('serves any project from an all-projects session when no concrete one exists', async () => { + await saveSession(URL_A, tokens('*')); + + expect((await loadSession(URL_A, 7))?.projectKey).toBe('*'); + expect((await loadSession(URL_A, 99))?.accessToken).toBe('token-*'); + }); + + it('prefers a concrete session over an all-projects one for its own project', async () => { + await saveSession(URL_A, tokens('*')); + await saveSession(URL_A, tokens('3')); + + expect((await loadSession(URL_A, 3))?.projectKey).toBe('3'); + expect((await loadSession(URL_A, 5))?.projectKey).toBe('*'); + }); + + it('clears only the session serving the given project', async () => { + await saveSession(URL_A, tokens('2')); + await saveSession(URL_A, tokens('3')); + + await clearSession(URL_A, 2); + + expect(await loadSession(URL_A, 2)).toBeNull(); + expect((await loadSession(URL_A, 3))?.accessToken).toBe('token-3'); + }); + + it('refreshes under the original scope key, not the refreshed token’s', async () => { + await saveSession(URL_A, tokens('3', { expiresAt: Date.now() - 1 })); + // The backend never widens scope on refresh, but even if the refreshed token decoded differently the session must + // stay keyed by '3' so the concrete lookup keeps finding it. + refresh.mockResolvedValue({ + accessToken: 'token-*', + refreshToken: 'r2', + expiresAt: future(), + }); + + const token = await getValidAccessToken(URL_A, 3); + + expect(token).toBe('token-*'); + expect((await loadSession(URL_A, 3))?.projectKey).toBe('3'); + expect((await loadSession(URL_A, 3))?.refreshToken).toBe('r2'); + }); + + it('does not refresh a still-valid token', async () => { + await saveSession(URL_A, tokens('3')); + + expect(await getValidAccessToken(URL_A, 3)).toBe('token-3'); + expect(refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/src/oauth/tokenStore.ts b/src/oauth/tokenStore.ts index 7645e00..a90eb39 100644 --- a/src/oauth/tokenStore.ts +++ b/src/oauth/tokenStore.ts @@ -1,26 +1,79 @@ import browser from 'webextension-polyfill'; import { OAUTH_REFRESH_SKEW_MS } from '../constants'; import { OAuthTokens, refresh } from './oauthClient'; +import { ALL_PROJECTS_KEY, projectKeyForToken } from './tokenScope'; -export type StoredSession = OAuthTokens & { apiUrl: string }; +export type StoredSession = OAuthTokens & { + apiUrl: string; + projectKey: string; +}; + +const originOf = (apiUrl: string) => new URL(apiUrl).origin; + +// Sessions are keyed by (backend origin, project scope): two concrete-project logins on the same backend coexist +// instead of overwriting each other, and an all-projects ('*') session is reused for any project on that origin. +const keyFor = (apiUrl: string, projectKey: string) => + `oauth:${originOf(apiUrl)}:${projectKey}`; -// Sessions are keyed by the Tolgee backend origin, so one login is reused across every page that targets it. -const keyFor = (apiUrl: string) => `oauth:${new URL(apiUrl).origin}`; +const normalizeProjectId = (projectId?: number | string) => + projectId === undefined || projectId === null || projectId === '' + ? undefined + : String(projectId); -export const saveSession = async (apiUrl: string, tokens: OAuthTokens) => { - await browser.storage.local.set({ [keyFor(apiUrl)]: { ...tokens, apiUrl } }); +const persist = async ( + apiUrl: string, + tokens: OAuthTokens, + projectKey: string +) => { + await browser.storage.local.set({ + [keyFor(apiUrl, projectKey)]: { ...tokens, apiUrl, projectKey }, + }); }; -export const loadSession = async ( - apiUrl: string +// Stores a freshly minted token under its own project scope, returning the key so callers can log it. A refresh keeps +// the original key (see refreshSession) so a rotated token never lands under a different scope. +export const saveSession = async ( + apiUrl: string, + tokens: OAuthTokens +): Promise => { + const projectKey = projectKeyForToken(tokens.accessToken); + await persist(apiUrl, tokens, projectKey); + return projectKey; +}; + +const loadByKey = async ( + apiUrl: string, + projectKey: string ): Promise => { - const key = keyFor(apiUrl); + const key = keyFor(apiUrl, projectKey); const stored = await browser.storage.local.get(key); return (stored[key] as StoredSession) ?? null; }; -export const clearSession = async (apiUrl: string) => { - await browser.storage.local.remove(keyFor(apiUrl)); +// The session serving a page on `projectId`: its own concrete-project session when one exists, otherwise an +// all-projects session (whose token covers every project on the backend). +export const loadSession = async ( + apiUrl: string, + projectId?: number | string +): Promise => { + const id = normalizeProjectId(projectId); + if (id !== undefined) { + const exact = await loadByKey(apiUrl, id); + if (exact) { + return exact; + } + } + return loadByKey(apiUrl, ALL_PROJECTS_KEY); +}; + +export const clearSession = async ( + apiUrl: string, + projectId?: number | string +) => { + const session = await loadSession(apiUrl, projectId); + if (session) { + await browser.storage.local.remove(keyFor(apiUrl, session.projectKey)); + } }; export const loadAllSessions = async (): Promise => { @@ -36,33 +89,36 @@ export const loadAllSessions = async (): Promise => { const isTerminalRefreshFailure = (e: unknown) => e instanceof Error && /returned 4\d\d/.test(e.message); -// A refresh rotates the refresh token, so two concurrent refreshes for the same backend would both spend the same +// A refresh rotates the refresh token, so two concurrent refreshes for the same session would both spend the same // (single-use) token: the first wins, the second gets invalid_grant and clears the just-refreshed session. The alarm -// handler and an OAUTH_GET_TOKEN message can land in the same worker at once, so share one in-flight refresh per origin. +// handler and an OAUTH_GET_TOKEN message can land in the same worker at once, so share one in-flight refresh per key. const inFlightRefresh = new Map>(); const refreshSession = async ( - apiUrl: string, - refreshToken: string + session: StoredSession ): Promise => { try { - const refreshed = await refresh(apiUrl, refreshToken); - await saveSession(apiUrl, refreshed); + const refreshed = await refresh(session.apiUrl, session.refreshToken!); + // Persist under the session's original key: a refresh must keep the same project scope, never re-key the session. + await persist(session.apiUrl, refreshed, session.projectKey); return refreshed.accessToken; } catch (e) { if (isTerminalRefreshFailure(e)) { - await clearSession(apiUrl); + await browser.storage.local.remove( + keyFor(session.apiUrl, session.projectKey) + ); } return null; } }; -// Returns a valid access token, refreshing (and persisting) if it is expired or near expiry. -// Returns null (and clears the session) when there is nothing valid to fall back on — the caller must re-login. +// Returns a valid access token for the given project, refreshing (and persisting) if it is expired or near expiry. +// Falls back to an all-projects session; returns null (clearing the session) when there is nothing valid to serve it. export const getValidAccessToken = async ( - apiUrl: string + apiUrl: string, + projectId?: number | string ): Promise => { - const session = await loadSession(apiUrl); + const session = await loadSession(apiUrl, projectId); if (!session) { return null; } @@ -70,15 +126,15 @@ export const getValidAccessToken = async ( return session.accessToken; } if (!session.refreshToken) { - await clearSession(apiUrl); + await browser.storage.local.remove(keyFor(apiUrl, session.projectKey)); return null; } - const key = keyFor(apiUrl); + const key = keyFor(session.apiUrl, session.projectKey); const existing = inFlightRefresh.get(key); if (existing) { return existing; } - const pending = refreshSession(apiUrl, session.refreshToken).finally(() => + const pending = refreshSession(session).finally(() => inFlightRefresh.delete(key) ); inFlightRefresh.set(key, pending); diff --git a/src/popup/TolgeeDetector.tsx b/src/popup/TolgeeDetector.tsx index 5ee5472..305fccf 100644 --- a/src/popup/TolgeeDetector.tsx +++ b/src/popup/TolgeeDetector.tsx @@ -142,12 +142,15 @@ export const TolgeeDetector = () => { const activeValues = appliedValues || storedValues || values; const isOauthSession = isOAuth(activeValues); - // OAuth Disconnect drops the local token (service worker + storage) but keeps the server-side consent, so reconnecting - // is silent. API-key Disconnect is just the old Clear. Either way the local session is wiped. + // OAuth Disconnect drops this project's local token (service worker + storage); the server keeps the consent, so + // reconnecting the same account skips the consent screen (by design). API-key Disconnect is just the old Clear. const handleDisconnect = async () => { const apiUrl = activeValues?.apiUrl; if (isOauthSession && apiUrl) { - await sendToBackground('OAUTH_LOGOUT', { apiUrl }); + await sendToBackground('OAUTH_LOGOUT', { + apiUrl, + projectId: activeValues?.projectId, + }); } dispatch({ type: 'CLEAR_ALL' }); }; diff --git a/src/popup/tools.ts b/src/popup/tools.ts index 365d17a..769e05d 100644 --- a/src/popup/tools.ts +++ b/src/popup/tools.ts @@ -41,27 +41,6 @@ export function normalizeUrl(url: string | undefined) { return url?.replace(/\/$/, ''); } -// Reads the `tg.prj` (project set) claim the backend stamped into the OAuth access token. '*' means all projects -// (the user must then pick one to edit); a single id means the token is bound to that project and we can auto-select it. -export function decodeTokenProjectSet( - token: string | undefined -): '*' | number[] | undefined { - if (!token) { - return undefined; - } - try { - const payload = JSON.parse( - atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')) - ); - const prj = payload['tg.prj']; - if (prj === '*') { - return '*'; - } - if (Array.isArray(prj)) { - return prj.map((x) => Number(x)).filter((n) => !Number.isNaN(n)); - } - return undefined; - } catch (e) { - return undefined; - } -} +// The project-set decoder lives with the OAuth token store so the service worker can key sessions by scope; re-exported +// here for the popup, which reads it to drive the project picker. +export { decodeTokenProjectSet } from '../oauth/tokenScope'; diff --git a/src/popup/useDetectorForm.tsx b/src/popup/useDetectorForm.tsx index b44a84d..602c654 100644 --- a/src/popup/useDetectorForm.tsx +++ b/src/popup/useDetectorForm.tsx @@ -73,9 +73,11 @@ export const useDetectorForm = () => { const storedData = await loadValues(); if (storedData.oauth && storedData.apiUrl) { - // OAuth sessions store no token; ask the service worker for a fresh (auto-refreshed) one. + // OAuth sessions store no token; ask the service worker for a fresh (auto-refreshed) one for this project (its + // own session if concrete, else the all-projects one). const res = (await sendToBackground('OAUTH_GET_TOKEN', { apiUrl: storedData.apiUrl, + projectId: storedData.projectId, })) as { accessToken?: string }; if (res?.accessToken) { dispatch({ From 8a3d045e8b6b00adf4a6029b112ebc6918c38830 Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Sat, 15 Aug 2026 22:23:42 +0200 Subject: [PATCH 16/18] fix: resolve the sole OAuth session when the popup reopens without a project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-project lookup returned null when the popup reopened and asked for a token before it had re-resolved the page's project (passing an undefined or stale projectId). getValidAccessToken then reported "not connected", which made the popup rebuild its applied values without the token — wiping the page token and reloading the tab (closing the popup), so the next open looked disconnected even though the session was still stored. Fall back to the origin's sole session on the read path when no concrete or all-projects session matches. loadSession stays strict so disconnect never clears a session the caller didn't ask for. --- src/oauth/tokenStore.test.ts | 18 ++++++++++++++++++ src/oauth/tokenStore.ts | 21 ++++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/oauth/tokenStore.test.ts b/src/oauth/tokenStore.test.ts index 973de06..ea2c391 100644 --- a/src/oauth/tokenStore.test.ts +++ b/src/oauth/tokenStore.test.ts @@ -84,6 +84,24 @@ describe('tokenStore per-project keying', () => { expect((await loadSession(URL_A, 5))?.projectKey).toBe('*'); }); + it('serves the sole origin session when the requested project is unknown or mismatched', async () => { + // The popup reopens and asks with no (or a stale) projectId before it re-resolves the page's project. With a single + // session for the backend, getValidAccessToken must still resolve it — otherwise the popup treats it as + // disconnected and wipes it. (loadSession itself stays strict, so disconnect never clears the wrong session.) + await saveSession(URL_A, tokens('2')); + + expect(await getValidAccessToken(URL_A, undefined)).toBe('token-2'); + expect(await getValidAccessToken(URL_A, 999)).toBe('token-2'); + expect(await loadSession(URL_A, 999)).toBeNull(); + }); + + it('does not guess when multiple sessions exist and no project matches', async () => { + await saveSession(URL_A, tokens('2')); + await saveSession(URL_A, tokens('3')); + + expect(await getValidAccessToken(URL_A, undefined)).toBeNull(); + }); + it('clears only the session serving the given project', async () => { await saveSession(URL_A, tokens('2')); await saveSession(URL_A, tokens('3')); diff --git a/src/oauth/tokenStore.ts b/src/oauth/tokenStore.ts index a90eb39..708988c 100644 --- a/src/oauth/tokenStore.ts +++ b/src/oauth/tokenStore.ts @@ -51,7 +51,8 @@ const loadByKey = async ( }; // The session serving a page on `projectId`: its own concrete-project session when one exists, otherwise an -// all-projects session (whose token covers every project on the backend). +// all-projects session (whose token covers every project on the backend). Strict — no guessing — so disconnect +// (clearSession) never removes a session the caller didn't ask for. export const loadSession = async ( apiUrl: string, projectId?: number | string @@ -66,6 +67,18 @@ export const loadSession = async ( return loadByKey(apiUrl, ALL_PROJECTS_KEY); }; +// The sole session for an origin, or null when there are zero or several. The read path falls back to this so a caller +// that doesn't know the project yet (e.g. the popup reopening before it re-resolves the page's project) still resolves +// its one session instead of being told "not connected". +const soleOriginSession = async ( + apiUrl: string +): Promise => { + const originSessions = (await loadAllSessions()).filter( + (s) => originOf(s.apiUrl) === originOf(apiUrl) + ); + return originSessions.length === 1 ? originSessions[0] : null; +}; + export const clearSession = async ( apiUrl: string, projectId?: number | string @@ -113,12 +126,14 @@ const refreshSession = async ( }; // Returns a valid access token for the given project, refreshing (and persisting) if it is expired or near expiry. -// Falls back to an all-projects session; returns null (clearing the session) when there is nothing valid to serve it. +// Falls back to an all-projects session, then to the origin's sole session; returns null when there is nothing valid +// to serve it. export const getValidAccessToken = async ( apiUrl: string, projectId?: number | string ): Promise => { - const session = await loadSession(apiUrl, projectId); + const session = + (await loadSession(apiUrl, projectId)) ?? (await soleOriginSession(apiUrl)); if (!session) { return null; } From 66a2a6d9ecaf5318119991fcc3907aa67df7aabf Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Sun, 16 Aug 2026 08:36:38 +0200 Subject: [PATCH 17/18] refactor: harden the OAuth per-project token store and credential injection Accumulated hardening from a multi-round review of the browser-extension OAuth work: - Push a rotated token only to tabs the extension actually injected into (registry gated on an unforgeable per-origin marker), and deliver credentials only to the connected page's own frame, so a cross-origin iframe can't harvest the token. - Refresh only sessions a live tab resolves to (1:1), classify a dead refresh token by HTTP status (400/401), and dedup concurrent single-use refreshes. - Disconnect deletes exactly the session the token owns (by its scope), keep the reopen sole-origin fallback scope-checked, and decode a scalar string tg.prj claim. - Share url/marker/scope helpers, and add unit tests across the store, scope keying, PKCE, the token-push and credential-delivery gates, and the OAuth marker. --- src/background/background.ts | 250 +++++++++++--- src/constants.ts | 8 +- src/content/acceptsCredentialDelivery.test.ts | 37 +++ src/content/acceptsCredentialDelivery.ts | 8 + src/content/contentScript.ts | 74 ++--- src/content/credentialSink.test.ts | 108 +++++++ src/content/credentialSink.ts | 64 ++++ src/content/shouldAcceptTokenPush.test.ts | 39 +++ src/content/shouldAcceptTokenPush.ts | 13 + src/oauth/marker.test.ts | 43 +++ src/oauth/marker.ts | 44 +++ src/oauth/oauthClient.test.ts | 227 +++++++++++++ src/oauth/oauthClient.ts | 153 ++++----- src/oauth/pkce.test.ts | 20 ++ src/oauth/pkce.ts | 14 +- src/oauth/tokenScope.test.ts | 71 ++++ src/oauth/tokenScope.ts | 56 ++-- src/oauth/tokenStore.test.ts | 305 +++++++++++++++++- src/oauth/tokenStore.ts | 277 +++++++++++----- src/oauth/url.test.ts | 50 +++ src/oauth/url.ts | 23 ++ src/popup/TolgeeDetector.tsx | 104 +++--- src/popup/activeTab.ts | 4 + src/popup/reducer.test.ts | 60 ++++ src/popup/reducer.ts | 30 +- src/popup/sendMessage.ts | 11 +- src/popup/sendToBackground.ts | 1 - src/popup/storage.test.ts | 90 ++++++ src/popup/storage.ts | 49 ++- src/popup/tools.test.ts | 19 ++ src/popup/tools.ts | 22 +- src/popup/useApiKeyCheck.ts | 16 +- src/popup/useDetectorForm.tsx | 68 ++-- src/types.ts | 1 + vitest.config.ts | 2 +- 35 files changed, 1878 insertions(+), 483 deletions(-) create mode 100644 src/content/acceptsCredentialDelivery.test.ts create mode 100644 src/content/acceptsCredentialDelivery.ts create mode 100644 src/content/credentialSink.test.ts create mode 100644 src/content/credentialSink.ts create mode 100644 src/content/shouldAcceptTokenPush.test.ts create mode 100644 src/content/shouldAcceptTokenPush.ts create mode 100644 src/oauth/marker.test.ts create mode 100644 src/oauth/marker.ts create mode 100644 src/oauth/oauthClient.test.ts create mode 100644 src/oauth/pkce.test.ts create mode 100644 src/oauth/tokenScope.test.ts create mode 100644 src/oauth/url.test.ts create mode 100644 src/oauth/url.ts create mode 100644 src/popup/activeTab.ts create mode 100644 src/popup/storage.test.ts diff --git a/src/background/background.ts b/src/background/background.ts index d69c8c0..cb83fab 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -3,12 +3,20 @@ import { ScreenshotMaker } from './ScreenshotMaker'; import { RuntimeMessage } from '../content/Messages'; import { login } from '../oauth/oauthClient'; import { - clearSession, + clearSessionByKey, + clearSessionIfUnreferenced, + ensureFreshToken, getValidAccessToken, - loadAllSessions, + isTokenFresh, + loadSession, + resolveSessionForTab, saveSession, + sessionKey, + StoredSession, } from '../oauth/tokenStore'; -import { OAUTH_REFRESH_SKEW_MS } from '../constants'; +import { safeOrigin, sameOrigin } from '../oauth/url'; +import { projectKeyForToken } from '../oauth/tokenScope'; +import { loadOAuthMarker, storeOAuthMarker } from '../oauth/marker'; type State = 'present' | 'active' | 'inactive'; @@ -38,26 +46,39 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => { connect(data) .then((accessToken) => sendResponse({ accessToken })) .catch((e) => { - console.error('[tolgee-oauth] login failed', e); + console.error('[tolgee] login failed', e); sendResponse({ error: String(e) }); }); return true; case 'OAUTH_GET_TOKEN': - getValidAccessToken(data.apiUrl, data.projectId) + getValidAccessToken(data.apiUrl, data.projectId, { + soleOriginFallback: true, + }) .then((accessToken) => sendResponse({ accessToken })) .catch((e) => { - console.error('[tolgee-oauth] token lookup failed', e); + console.error('[tolgee] token lookup failed', e); sendResponse({ accessToken: null, error: String(e) }); }); return true; case 'OAUTH_LOGOUT': - clearSession(data.apiUrl, data.projectId) + disconnect(data) .then(() => sendResponse({})) .catch((e) => { - console.error('[tolgee-oauth] logout failed', e); + console.error('[tolgee] logout failed', e); sendResponse({ error: String(e) }); }); return true; + case 'OAUTH_TAB_CONNECTED': + // Respond only after the registration resolves: returning true keeps the MV3 worker alive for the async work, so a + // torn-down worker can't drop the registration and leave the tab without token refreshes. + registerConnectedTab( + sender.tab?.id, + safeOrigin(sender.tab?.url), + data.apiUrl + ) + .catch((e) => console.error('[tolgee] tab register failed', e)) + .finally(() => sendResponse({})); + return true; default: sendResponse({}); } @@ -70,9 +91,128 @@ const setStateIcon = (state: State, tabId: number) => { }); }; -// Reuse an existing usable session before launching the OAuth flow: a matching concrete-project session, or an -// all-projects one, connects a second site on the same backend with no extra round trip — this is what makes an -// all-projects login "just work" everywhere. Only when nothing serves the requested project do we run the flow. +const INJECTED_TABS_KEY = 'injectedTabs'; +type InjectedTab = { + apiUrl: string; + pageOrigin: string; + projectKey: string; +}; +type InjectedTabs = Record; + +// Register the tab from the unforgeable marker, never the page's message: the marker's projectKey pins WHICH keyed +// session serves this tab, so a connected origin can't claim another project's projectId to harvest its token. +const registerConnectedTab = async ( + tabId: number | undefined, + pageOrigin: string | undefined, + apiUrl: string +) => { + if (tabId == null || !pageOrigin) { + return; + } + const marker = await loadOAuthMarker(pageOrigin); + if (marker?.projectKey && sameOrigin(marker.apiUrl, apiUrl)) { + await recordInjectedTab( + tabId, + marker.apiUrl, + pageOrigin, + marker.projectKey + ); + } +}; + +const recordInjectedTab = ( + tabId: number, + apiUrl: string, + pageOrigin: string, + projectKey: string +) => + withRegistry(async (tabs) => { + tabs[tabId] = { apiUrl, pageOrigin, projectKey }; + await saveInjectedTabs(tabs); + }); + +const forgetInjectedTab = (tabId: number) => + withRegistry(async (tabs) => { + if (tabs[tabId]) { + await dropTab(tabs, tabId); + } + }); + +// Drop every registered tab on a given page origin, so a disconnected site stops receiving refreshed-token pushes. +const forgetInjectedTabsForOrigin = (pageOrigin: string) => + withRegistry(async (tabs) => { + const survivors = Object.entries(tabs).filter( + ([, tab]) => tab.pageOrigin !== pageOrigin + ); + if (survivors.length !== Object.keys(tabs).length) { + await saveInjectedTabs(Object.fromEntries(survivors)); + } + }); + +const dropTab = (tabs: InjectedTabs, tabId: number) => { + delete tabs[tabId]; + return saveInjectedTabs(tabs); +}; + +const loadInjectedTabs = async (): Promise => + ((await browser.storage.local.get(INJECTED_TABS_KEY))[ + INJECTED_TABS_KEY + ] as InjectedTabs) ?? {}; + +const saveInjectedTabs = (tabs: InjectedTabs) => + browser.storage.local.set({ [INJECTED_TABS_KEY]: tabs }); + +// Serialize load→mutate→store so concurrent tab events can't read the same snapshot and clobber each other's write. +let registryQueue: Promise = Promise.resolve(); +const withRegistry = ( + fn: (tabs: InjectedTabs) => Promise | T +): Promise => { + const run = registryQueue.then(async () => fn(await loadInjectedTabs())); + registryQueue = run.catch(() => undefined); + return run; +}; + +browser.tabs.onRemoved.addListener((tabId) => { + forgetInjectedTab(tabId); +}); +browser.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (!changeInfo.url) { + return; + } + withRegistry(async (tabs) => { + const tab = tabs[tabId]; + if (tab && tab.pageOrigin !== safeOrigin(changeInfo.url)) { + await dropTab(tabs, tabId); + } + }); +}); +// Tab ids are reissued after a restart, so a persisted entry could misroute a token to an unrelated new tab. +browser.runtime.onStartup.addListener(() => { + browser.storage.local.remove(INJECTED_TABS_KEY); +}); + +const disconnect = async (data: { + apiUrl: string; + authToken?: string; + projectId?: number; + pageOrigin?: string; +}) => { + const projectKey = data.authToken + ? projectKeyForToken(data.authToken) + : (await loadSession(data.apiUrl, data.projectId))?.projectKey; + if (data.pageOrigin) { + await forgetInjectedTabsForOrigin(data.pageOrigin); + } + if (projectKey === undefined) { + return; + } + if (data.pageOrigin) { + await clearSessionIfUnreferenced(data.apiUrl, projectKey, data.pageOrigin); + } else { + await clearSessionByKey(data.apiUrl, projectKey); + } +}; + const connect = async (data: { apiUrl: string; projectId?: number; @@ -84,33 +224,42 @@ const connect = async (data: { await saveSession(data.apiUrl, tokens); accessToken = tokens.accessToken; } - // launchWebAuthFlow steals focus, which closes the popup before it can push credentials to the page, so inject from - // here. data.tabId is the tab the popup was acting on, captured before the auth window opened. - if (data.tabId != null) { + const pageOrigin = safeOrigin( + data.tabId != null + ? (await browser.tabs.get(data.tabId).catch(() => undefined))?.url + : undefined + ); + if (data.tabId != null && pageOrigin) { await injectCredentials(data.tabId, { apiUrl: data.apiUrl, authToken: accessToken, projectId: data.projectId, + pageOrigin, + }); + await storeOAuthMarker(pageOrigin, { + apiUrl: data.apiUrl, + projectId: data.projectId, + projectKey: projectKeyForToken(accessToken), }); } return accessToken; }; -// Inject the full credential set into a page on connect (the content script writes them to sessionStorage and reloads -// so the SDK picks them up). Runs from the service worker because the popup is already gone by the time login resolves. const injectCredentials = async ( tabId: number, - data: { apiUrl: string; authToken: string; projectId?: number } + data: { + apiUrl: string; + authToken: string; + projectId?: number; + pageOrigin: string; + } ) => { await browser.tabs .sendMessage(tabId, { type: 'SET_CREDENTIALS', data }) .catch(() => undefined); }; -// Keep stored sessions fresh so the popup and the injected page token don't expire mid-use. Rotation means each -// refresh mints a new access + refresh token; getValidAccessToken persists them and pushes the access token to tabs. -// This top-level code re-runs every time the MV3 worker wakes; re-creating an existing alarm resets its schedule, so a -// worker that wakes more often than the period would never let the alarm fire — only create it when it's absent. +// Create the alarm only when absent: re-creating it resets the schedule, and MV3 re-runs this on every worker wake. const ensureRefreshAlarm = async () => { if (!(await browser.alarms.get(REFRESH_ALARM))) { await browser.alarms.create(REFRESH_ALARM, { periodInMinutes: 10 }); @@ -121,41 +270,48 @@ browser.alarms.onAlarm.addListener(async (alarm) => { if (alarm.name !== REFRESH_ALARM) { return; } - const sessions = await loadAllSessions(); - for (const session of sessions) { - if (session.expiresAt - OAUTH_REFRESH_SKEW_MS > Date.now()) { + // Refresh only sessions a live tab resolves to (1:1), so an abandoned session with no owning tab isn't kept alive. + const owning = new Map(); + for (const tab of Object.values(await loadInjectedTabs())) { + const session = await resolveSessionForTab(tab); + if (session) { + owning.set(sessionKey(session), session); + } + } + for (const session of owning.values()) { + if (isTokenFresh(session)) { continue; } - // Passing the session's own project scope refreshes exactly this session (a concrete id finds it, '*' the - // all-projects one), so rotating one project's token never disturbs another's. - const accessToken = await getValidAccessToken( - session.apiUrl, - session.projectKey - ); + const accessToken = await ensureFreshToken(session); if (accessToken) { - await pushTokenToTabs(session.apiUrl, session.projectKey, accessToken); + await pushTokenToSession(session, accessToken); } } }); -// Update the injected access token in every tab whose applied backend and project the refreshed session serves, without -// reloading the page. An all-projects ('*') session serves any project; a concrete session only its own. -const pushTokenToTabs = async ( - apiUrl: string, - projectKey: string, +const pushTokenToSession = async ( + session: StoredSession, accessToken: string ) => { - const tabs = await browser.tabs.query({}); + const key = sessionKey(session); + const injected = Object.entries(await loadInjectedTabs()); await Promise.all( - tabs.map((tab) => - tab.id == null - ? undefined - : browser.tabs - .sendMessage(tab.id, { - type: 'UPDATE_AUTH_TOKEN', - data: { apiUrl, projectKey, authToken: accessToken }, - }) - .catch(() => undefined) - ) + injected.map(async ([tabId, tab]) => { + const owning = await resolveSessionForTab(tab); + if (!owning || sessionKey(owning) !== key) { + return; + } + await browser.tabs + .sendMessage(Number(tabId), { + type: 'UPDATE_AUTH_TOKEN', + data: { + apiUrl: session.apiUrl, + projectKey: session.projectKey, + authToken: accessToken, + pageOrigin: tab.pageOrigin, + }, + }) + .catch(() => undefined); + }) ); }; diff --git a/src/constants.ts b/src/constants.ts index d15add1..389ab4c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,18 +1,12 @@ export const API_KEY_LOCAL_STORAGE = '__tolgee_apiKey'; export const API_URL_LOCAL_STORAGE = '__tolgee_apiUrl'; export const BRANCH_LOCAL_STORAGE = '__tolgee_branch'; -// OAuth access token injected into the page alongside the api key; the SDK (tolgee-js) reads it as a Bearer token. export const AUTH_TOKEN_LOCAL_STORAGE = '__tolgee_authToken'; -// Project id injected for the OAuth path: OAuth tokens carry no embedded project (unlike a PAK), so the user -// picks one in the popup and the SDK reads it here to know which project to fetch/edit. export const PROJECT_ID_LOCAL_STORAGE = '__tolgee_projectId'; -// Fixed public client id pre-registered on every Tolgee backend (see PreRegisteredClients on the platform). +// Public client id pre-registered on every Tolgee backend (see PreRegisteredClients on the platform). export const OAUTH_CLIENT_ID = 'tolgee-browser-extension'; -// Access tokens are short-lived; refresh this many milliseconds before expiry. export const OAUTH_REFRESH_SKEW_MS = 60_000; -// Scopes the extension requests for in-context editing. The backend intersects them with the user's live -// permissions, so requesting a broad set never grants more than the user actually holds. export const OAUTH_SCOPES = [ 'translations.view', 'translations.edit', diff --git a/src/content/acceptsCredentialDelivery.test.ts b/src/content/acceptsCredentialDelivery.test.ts new file mode 100644 index 0000000..429e9c3 --- /dev/null +++ b/src/content/acceptsCredentialDelivery.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { acceptsCredentialDelivery } from './acceptsCredentialDelivery'; + +describe('acceptsCredentialDelivery', () => { + it('with a supplied pageOrigin, accepts only a matching frame origin', () => { + expect( + acceptsCredentialDelivery({ + currentOrigin: 'https://a.io', + isTopFrame: false, + pageOrigin: 'https://a.io', + }) + ).toBe(true); + // a cross-origin frame is rejected even if it is (somehow) the top frame + expect( + acceptsCredentialDelivery({ + currentOrigin: 'https://evil.io', + isTopFrame: true, + pageOrigin: 'https://a.io', + }) + ).toBe(false); + }); + + it('without a pageOrigin, falls back to the top frame only', () => { + expect( + acceptsCredentialDelivery({ + currentOrigin: 'https://a.io', + isTopFrame: true, + }) + ).toBe(true); + expect( + acceptsCredentialDelivery({ + currentOrigin: 'https://a.io', + isTopFrame: false, + }) + ).toBe(false); + }); +}); diff --git a/src/content/acceptsCredentialDelivery.ts b/src/content/acceptsCredentialDelivery.ts new file mode 100644 index 0000000..37d1c28 --- /dev/null +++ b/src/content/acceptsCredentialDelivery.ts @@ -0,0 +1,8 @@ +// Only the connected page's own frame may receive OAuth credentials — a cross-origin iframe must not harvest the token. +// Origin-matched when the sender supplies the connected origin (background paths), else restricted to the top frame. +export const acceptsCredentialDelivery = (args: { + currentOrigin: string; + isTopFrame: boolean; + pageOrigin?: string; +}): boolean => + args.pageOrigin ? args.currentOrigin === args.pageOrigin : args.isTopFrame; diff --git a/src/content/contentScript.ts b/src/content/contentScript.ts index 06e0eca..ee111d1 100644 --- a/src/content/contentScript.ts +++ b/src/content/contentScript.ts @@ -6,6 +6,8 @@ import { PROJECT_ID_LOCAL_STORAGE, } from '../constants'; import { LibConfig } from '../types'; +import { acceptsCredentialDelivery } from './acceptsCredentialDelivery'; +import { acceptTokenPush, writeCredentials } from './credentialSink'; import { injectUiLib } from './injectUiLib'; import { Messages } from './Messages'; import { updateState } from './updateState'; @@ -15,7 +17,7 @@ let configuration: LibConfig | undefined = undefined; const messages = new Messages(); messages.startWindowListening(); -const getAppliedCredenials = () => { +const getAppliedCredentials = () => { return { apiKey: sessionStorage.getItem(API_KEY_LOCAL_STORAGE), apiUrl: sessionStorage.getItem(API_URL_LOCAL_STORAGE), @@ -25,22 +27,11 @@ const getAppliedCredenials = () => { }; }; -const sameOrigin = (a: string | null, b: string | null) => { - if (!a || !b) { - return false; - } - try { - return new URL(a).origin === new URL(b).origin; - } catch (e) { - return false; - } -}; - // handshake with library messages.listenWindow('TOLGEE_READY', (c: LibConfig) => { const firstHandshake = !configuration; configuration = c; - const appliedCredentials = getAppliedCredenials(); + const appliedCredentials = getAppliedCredentials(); if ( appliedCredentials.apiKey && c.uiPresent === false && @@ -48,6 +39,11 @@ messages.listenWindow('TOLGEE_READY', (c: LibConfig) => { ) { injectUiLib(c.uiVersion); } + if (appliedCredentials.authToken && appliedCredentials.apiUrl) { + messages.sendToPlugin('OAUTH_TAB_CONNECTED', { + apiUrl: appliedCredentials.apiUrl, + }); + } updateState(configuration, messages); if (firstHandshake) { messages.sendToLib('TOLGEE_PLUGIN_READY'); @@ -70,7 +66,6 @@ messages.listenWindow('TOLGEE_TAKE_SCREENSHOT', () => { }); }); -// in-context editor asks to open the popup (e.g. so the user can re-connect after their OAuth session expired) messages.listenWindow('TOLGEE_OPEN_PLUGIN', () => { messages.sendToPlugin('OPEN_POPUP'); }); @@ -84,52 +79,27 @@ messages.listenRuntime('DETECT_TOLGEE', async () => { } }); -messages.listenRuntime('GET_CREDENTIALS', async () => getAppliedCredenials()); +messages.listenRuntime('GET_CREDENTIALS', async () => getAppliedCredentials()); -const setOrRemove = (key: string, value: string | undefined | null) => { - const next = value ? String(value) : null; - if (sessionStorage.getItem(key) === next) { - return false; - } - if (next === null) { - sessionStorage.removeItem(key); - } else { - sessionStorage.setItem(key, next); - } - return true; -}; +const acceptsDelivery = (pageOrigin?: string) => + acceptsCredentialDelivery({ + currentOrigin: window.location.origin, + isTopFrame: window.top === window.self, + pageOrigin, + }); messages.listenRuntime('SET_CREDENTIALS', async (data) => { - // Bitwise-or so every key is written before we decide; reload only when something actually changed, so a redundant - // push (e.g. both the popup and the background inject on connect) doesn't reload the page twice. - const changed = - Number(setOrRemove(API_KEY_LOCAL_STORAGE, data.apiKey)) | - Number(setOrRemove(API_URL_LOCAL_STORAGE, data.apiUrl)) | - Number(setOrRemove(BRANCH_LOCAL_STORAGE, data.branch)) | - Number(setOrRemove(AUTH_TOKEN_LOCAL_STORAGE, data.authToken)) | - Number(setOrRemove(PROJECT_ID_LOCAL_STORAGE, data.projectId)); - if (changed) { + if (!acceptsDelivery(data.pageOrigin)) { + return; + } + if (writeCredentials(sessionStorage, data)) { location.reload(); } updateState(configuration, messages); }); -// Background pushes a rotated access token here on refresh; update it in place so the SDK picks it up without a reload. messages.listenRuntime('UPDATE_AUTH_TOKEN', async (data) => { - // Only take a push whose session serves this page's project: '*' (all projects) serves any, a concrete key only its - // own. Without this, a project-2 refresh would clobber a project-3 page's token on the same backend. An absent - // projectKey (older background) matches anything, preserving the previous single-session behaviour. - const pageProjectId = sessionStorage.getItem(PROJECT_ID_LOCAL_STORAGE); - const scopeServesPage = - data.projectKey === undefined || - data.projectKey === '*' || - data.projectKey === pageProjectId; - // Skip an empty token: setItem would store the literal string "undefined" and the SDK would send `Bearer undefined`. - if ( - data.authToken && - scopeServesPage && - sameOrigin(sessionStorage.getItem(API_URL_LOCAL_STORAGE), data.apiUrl) - ) { - sessionStorage.setItem(AUTH_TOKEN_LOCAL_STORAGE, data.authToken); + if (acceptsDelivery(data.pageOrigin)) { + acceptTokenPush(sessionStorage, data); } }); diff --git a/src/content/credentialSink.test.ts b/src/content/credentialSink.test.ts new file mode 100644 index 0000000..8dea82b --- /dev/null +++ b/src/content/credentialSink.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { + acceptTokenPush, + writeCredentials, + SessionStore, +} from './credentialSink'; +import { + API_KEY_LOCAL_STORAGE, + API_URL_LOCAL_STORAGE, + AUTH_TOKEN_LOCAL_STORAGE, + PROJECT_ID_LOCAL_STORAGE, +} from '../constants'; + +const fakeStore = ( + init: Record = {} +): SessionStore & { map: Map } => { + const map = new Map(Object.entries(init)); + return { + map, + getItem: (k) => (map.has(k) ? map.get(k)! : null), + setItem: (k, v) => { + map.set(k, v); + }, + removeItem: (k) => { + map.delete(k); + }, + }; +}; + +describe('writeCredentials', () => { + it('maps each field to its session-storage key and reports a change', () => { + const store = fakeStore(); + const changed = writeCredentials(store, { + apiUrl: 'https://app.tolgee.io', + authToken: 'jwt', + projectId: 7, + }); + expect(changed).toBe(true); + expect(store.map.get(API_URL_LOCAL_STORAGE)).toBe('https://app.tolgee.io'); + expect(store.map.get(AUTH_TOKEN_LOCAL_STORAGE)).toBe('jwt'); + expect(store.map.get(PROJECT_ID_LOCAL_STORAGE)).toBe('7'); + }); + + it('reports no change when the delivered values already match (no needless reload)', () => { + const store = fakeStore({ + [API_URL_LOCAL_STORAGE]: 'https://app.tolgee.io', + [AUTH_TOKEN_LOCAL_STORAGE]: 'jwt', + }); + expect( + writeCredentials(store, { + apiUrl: 'https://app.tolgee.io', + authToken: 'jwt', + }) + ).toBe(false); + }); + + it('removes a key whose value is now absent', () => { + const store = fakeStore({ [API_KEY_LOCAL_STORAGE]: 'tgpak_x' }); + const changed = writeCredentials(store, { + apiUrl: 'https://app.tolgee.io', + }); + expect(changed).toBe(true); + expect(store.map.has(API_KEY_LOCAL_STORAGE)).toBe(false); + }); +}); + +describe('acceptTokenPush', () => { + const page = { + [PROJECT_ID_LOCAL_STORAGE]: '7', + [API_URL_LOCAL_STORAGE]: 'https://app.tolgee.io', + }; + + it('stores a token whose scope serves this page and backend', () => { + const store = fakeStore(page); + expect( + acceptTokenPush(store, { + authToken: 'new-jwt', + projectKey: '7', + apiUrl: 'https://app.tolgee.io', + }) + ).toBe(true); + expect(store.map.get(AUTH_TOKEN_LOCAL_STORAGE)).toBe('new-jwt'); + }); + + it('drops a token scoped to a different project (per-project isolation)', () => { + const store = fakeStore(page); + expect( + acceptTokenPush(store, { + authToken: 'other-jwt', + projectKey: '5', + apiUrl: 'https://app.tolgee.io', + }) + ).toBe(false); + expect(store.map.has(AUTH_TOKEN_LOCAL_STORAGE)).toBe(false); + }); + + it('drops a token pushed for a different backend', () => { + const store = fakeStore(page); + expect( + acceptTokenPush(store, { + authToken: 'new-jwt', + projectKey: '7', + apiUrl: 'https://other.tolgee.io', + }) + ).toBe(false); + expect(store.map.has(AUTH_TOKEN_LOCAL_STORAGE)).toBe(false); + }); +}); diff --git a/src/content/credentialSink.ts b/src/content/credentialSink.ts new file mode 100644 index 0000000..6c5e331 --- /dev/null +++ b/src/content/credentialSink.ts @@ -0,0 +1,64 @@ +import { + API_KEY_LOCAL_STORAGE, + API_URL_LOCAL_STORAGE, + AUTH_TOKEN_LOCAL_STORAGE, + BRANCH_LOCAL_STORAGE, + PROJECT_ID_LOCAL_STORAGE, +} from '../constants'; +import { shouldAcceptTokenPush } from './shouldAcceptTokenPush'; + +export type SessionStore = Pick; + +export const writeCredentials = ( + store: SessionStore, + data: { + apiKey?: string | null; + apiUrl?: string | null; + branch?: string | null; + authToken?: string | null; + projectId?: string | number | null; + } +): boolean => + [ + setOrRemove(store, API_KEY_LOCAL_STORAGE, data.apiKey), + setOrRemove(store, API_URL_LOCAL_STORAGE, data.apiUrl), + setOrRemove(store, BRANCH_LOCAL_STORAGE, data.branch), + setOrRemove(store, AUTH_TOKEN_LOCAL_STORAGE, data.authToken), + setOrRemove(store, PROJECT_ID_LOCAL_STORAGE, data.projectId), + ].some(Boolean); + +// A rotated token is stored only if it serves THIS page — its declared project and backend, read from session storage — +// so a push meant for another keyed session on the same origin is dropped. +export const acceptTokenPush = ( + store: SessionStore, + data: { authToken: string; projectKey?: string; apiUrl?: string } +): boolean => { + const accept = shouldAcceptTokenPush({ + authToken: data.authToken, + projectKey: data.projectKey, + pageProjectId: store.getItem(PROJECT_ID_LOCAL_STORAGE), + pageApiUrl: store.getItem(API_URL_LOCAL_STORAGE), + pushApiUrl: data.apiUrl, + }); + if (accept) { + store.setItem(AUTH_TOKEN_LOCAL_STORAGE, data.authToken); + } + return accept; +}; + +const setOrRemove = ( + store: SessionStore, + key: string, + value: string | number | undefined | null +): boolean => { + const next = value ? String(value) : null; + if (store.getItem(key) === next) { + return false; + } + if (next === null) { + store.removeItem(key); + } else { + store.setItem(key, next); + } + return true; +}; diff --git a/src/content/shouldAcceptTokenPush.test.ts b/src/content/shouldAcceptTokenPush.test.ts new file mode 100644 index 0000000..c614306 --- /dev/null +++ b/src/content/shouldAcceptTokenPush.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { shouldAcceptTokenPush } from './shouldAcceptTokenPush'; + +const base = { + authToken: 'jwt', + projectKey: '2', + pageProjectId: '2', + pageApiUrl: 'https://app.tolgee.io', + pushApiUrl: 'https://app.tolgee.io', +}; + +describe('shouldAcceptTokenPush', () => { + it('accepts a non-empty, same-project, same-backend push', () => { + expect(shouldAcceptTokenPush(base)).toBe(true); + }); + + it('accepts an all-projects (*) push for any page project', () => { + expect( + shouldAcceptTokenPush({ ...base, projectKey: '*', pageProjectId: '9' }) + ).toBe(true); + }); + + it('rejects an empty token', () => { + expect(shouldAcceptTokenPush({ ...base, authToken: '' })).toBe(false); + expect(shouldAcceptTokenPush({ ...base, authToken: null })).toBe(false); + }); + + it('rejects a push whose concrete scope does not serve the page project', () => { + expect( + shouldAcceptTokenPush({ ...base, projectKey: '3', pageProjectId: '2' }) + ).toBe(false); + }); + + it('rejects a push from a different backend origin', () => { + expect( + shouldAcceptTokenPush({ ...base, pushApiUrl: 'https://evil.example' }) + ).toBe(false); + }); +}); diff --git a/src/content/shouldAcceptTokenPush.ts b/src/content/shouldAcceptTokenPush.ts new file mode 100644 index 0000000..6a4dda0 --- /dev/null +++ b/src/content/shouldAcceptTokenPush.ts @@ -0,0 +1,13 @@ +import { scopeServesProject } from '../oauth/tokenScope'; +import { sameOrigin } from '../oauth/url'; + +export const shouldAcceptTokenPush = (args: { + authToken?: string | null; + projectKey?: string; + pageProjectId: string | null; + pageApiUrl: string | null; + pushApiUrl?: string | null; +}): boolean => + Boolean(args.authToken) && + scopeServesProject(args.projectKey, args.pageProjectId) && + sameOrigin(args.pageApiUrl, args.pushApiUrl); diff --git a/src/oauth/marker.test.ts b/src/oauth/marker.test.ts new file mode 100644 index 0000000..e9d3e88 --- /dev/null +++ b/src/oauth/marker.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const store = new Map(); +vi.mock('webextension-polyfill', () => ({ + default: { + storage: { + local: { + get: async (key: string) => + store.has(key) ? { [key]: store.get(key) } : {}, + set: async (obj: Record) => + Object.entries(obj).forEach(([k, v]) => store.set(k, v)), + }, + }, + }, +})); + +import { loadOAuthMarker, storeOAuthMarker } from './marker'; + +describe('OAuth marker', () => { + beforeEach(() => store.clear()); + + it('round-trips apiUrl, projectId and the authoritative projectKey', async () => { + await storeOAuthMarker('https://site.example', { + apiUrl: 'https://api', + projectId: 2, + projectKey: '2', + }); + expect(await loadOAuthMarker('https://site.example')).toEqual({ + apiUrl: 'https://api', + projectId: 2, + projectKey: '2', + }); + }); + + it('returns null when no marker exists for the origin', async () => { + expect(await loadOAuthMarker('https://none.example')).toBeNull(); + }); + + it('returns null for a non-oauth record (e.g. an api-key entry)', async () => { + store.set('https://k.example', { apiUrl: 'https://api', apiKey: 'x' }); + expect(await loadOAuthMarker('https://k.example')).toBeNull(); + }); +}); diff --git a/src/oauth/marker.ts b/src/oauth/marker.ts new file mode 100644 index 0000000..d549a95 --- /dev/null +++ b/src/oauth/marker.ts @@ -0,0 +1,44 @@ +import browser from 'webextension-polyfill'; + +// The write helper for the per-origin "connected via OAuth here" marker, called by both the popup and the worker. +// `projectKey` is the scope of the token actually delivered here — the authoritative binding a page can't forge, so +// token delivery keys off it rather than the page-supplied projectId. `projectId` is only the popup's UX restore hint. +export const storeOAuthMarker = async ( + origin: string, + marker: { apiUrl: string; projectId?: number; projectKey: string } +) => { + await browser.storage.local.set({ + [origin]: { + apiUrl: marker.apiUrl, + oauth: true, + projectId: marker.projectId, + projectKey: marker.projectKey, + }, + }); +}; + +// The page origin's marker (null if none). Extension-written, so unforgeable — the trust anchor for "was connected". +export const loadOAuthMarker = async ( + origin: string +): Promise<{ + apiUrl: string; + projectId?: number; + projectKey?: string; +} | null> => { + const stored = (await browser.storage.local.get(origin))[origin] as + | { + apiUrl?: string; + oauth?: boolean; + projectId?: number; + projectKey?: string; + } + | undefined; + if (!stored?.oauth || !stored.apiUrl) { + return null; + } + return { + apiUrl: stored.apiUrl, + projectId: stored.projectId, + projectKey: stored.projectKey, + }; +}; diff --git a/src/oauth/oauthClient.test.ts b/src/oauth/oauthClient.test.ts new file mode 100644 index 0000000..6f8879b --- /dev/null +++ b/src/oauth/oauthClient.test.ts @@ -0,0 +1,227 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const launchWebAuthFlow = vi.fn(); +vi.mock('webextension-polyfill', () => ({ + default: { + identity: { + getRedirectURL: () => 'https://ext.chromiumapp.org/', + launchWebAuthFlow: (...args: unknown[]) => launchWebAuthFlow(...args), + }, + }, +})); + +// Deterministic PKCE so login()'s generated state is predictable ('fixed') and we can craft matching/mismatching +// redirects. The real pkce is covered in pkce.test.ts. +vi.mock('./pkce', () => ({ + randomUrlSafe: () => 'fixed', + challengeFromVerifier: async () => 'challenge', +})); + +import { + login, + OAuthTokenEndpointError, + parseTokenResponse, + refresh, + wasCancelledByUser, +} from './oauthClient'; + +const redirect = (query: string) => `https://ext.chromiumapp.org/?${query}`; + +afterEach(() => { + launchWebAuthFlow.mockReset(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('login happy path', () => { + it('sends S256 PKCE + project hint on authorize and exchanges the code at the token endpoint', async () => { + launchWebAuthFlow.mockResolvedValue(redirect('state=fixed&code=abc')); + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ + access_token: 'at', + refresh_token: 'rt', + expires_in: 300, + }), + })); + vi.stubGlobal('fetch', fetchMock); + + const tokens = await login('https://api', 7); + expect(tokens.accessToken).toBe('at'); + expect(tokens.refreshToken).toBe('rt'); + + const authorizeUrl = new URL( + (launchWebAuthFlow.mock.calls[0][0] as { url: string }).url + ); + expect(authorizeUrl.pathname).toBe('/oauth2/authorize'); + expect(authorizeUrl.searchParams.get('response_type')).toBe('code'); + expect(authorizeUrl.searchParams.get('code_challenge_method')).toBe('S256'); + expect(authorizeUrl.searchParams.get('code_challenge')).toBe('challenge'); + expect(authorizeUrl.searchParams.get('project')).toBe('7'); + + const [tokenUrl, init] = fetchMock.mock.calls[0] as unknown as [ + string, + { body: URLSearchParams }, + ]; + expect(tokenUrl).toBe('https://api/oauth2/token'); + expect(init.body.get('grant_type')).toBe('authorization_code'); + expect(init.body.get('code')).toBe('abc'); + expect(init.body.get('code_verifier')).toBe('fixed'); + expect(init.body.get('redirect_uri')).toBe('https://ext.chromiumapp.org/'); + }); +}); + +describe('login security gates', () => { + it('rejects when the returned state does not match the sent one (CSRF)', async () => { + launchWebAuthFlow.mockResolvedValue(redirect('state=wrong&code=abc')); + await expect(login('https://api', 1)).rejects.toThrow('unexpected state'); + }); + + it('surfaces an error/error_description from the redirect', async () => { + launchWebAuthFlow.mockResolvedValue( + redirect( + 'state=fixed&error=access_denied&error_description=User%20denied' + ) + ); + await expect(login('https://api')).rejects.toThrow('User denied'); + }); + + it('rejects a matching-state redirect that carries no code', async () => { + launchWebAuthFlow.mockResolvedValue(redirect('state=fixed')); + await expect(login('https://api')).rejects.toThrow('did not return a code'); + }); +}); + +describe('launchAuthWithRetry (via login)', () => { + it('stops immediately when the user cancels', async () => { + launchWebAuthFlow.mockRejectedValue(new Error('The user cancelled')); + await expect(login('https://api')).rejects.toThrow('cancelled'); + expect(launchWebAuthFlow).toHaveBeenCalledTimes(1); + }); + + it('retries a transient load failure before proceeding', async () => { + vi.useFakeTimers(); + launchWebAuthFlow + .mockRejectedValueOnce( + new Error('Authorization page could not be loaded') + ) + .mockRejectedValueOnce( + new Error('Authorization page could not be loaded') + ) + .mockResolvedValueOnce(redirect('state=wrong&code=abc')); + const p = login('https://api'); + const assertion = expect(p).rejects.toThrow('unexpected state'); + await vi.runAllTimersAsync(); + await assertion; + expect(launchWebAuthFlow).toHaveBeenCalledTimes(3); + }); + + it('gives up after the max attempts of consecutive transient failures', async () => { + vi.useFakeTimers(); + launchWebAuthFlow.mockRejectedValue( + new Error('Authorization page could not be loaded') + ); + const p = login('https://api'); + const assertion = expect(p).rejects.toThrow('could not be loaded'); + await vi.runAllTimersAsync(); + await assertion; + expect(launchWebAuthFlow).toHaveBeenCalledTimes(3); + }); +}); + +describe('postToken error contract', () => { + it('rejects with OAuthTokenEndpointError carrying the status on a non-OK response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: false, + status: 400, + text: async () => 'invalid_grant', + })) + ); + const err = await refresh('https://api', 'rt').catch((e) => e); + expect(err).toBeInstanceOf(OAuthTokenEndpointError); + expect(err.status).toBe(400); + }); +}); + +describe('refresh', () => { + it('builds the refresh_token grant request with the token and client_id', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ + access_token: 'a2', + refresh_token: 'r2', + expires_in: 300, + }), + })); + vi.stubGlobal('fetch', fetchMock); + + const tokens = await refresh('https://api', 'r1'); + expect(tokens.accessToken).toBe('a2'); + expect(tokens.refreshToken).toBe('r2'); + + const [tokenUrl, init] = fetchMock.mock.calls[0] as unknown as [ + string, + { body: URLSearchParams }, + ]; + expect(tokenUrl).toBe('https://api/oauth2/token'); + expect(init.body.get('grant_type')).toBe('refresh_token'); + expect(init.body.get('refresh_token')).toBe('r1'); + expect(init.body.get('client_id')).toBeTruthy(); + }); +}); + +describe('parseTokenResponse', () => { + it('rotates the refresh token when the response carries a new one', () => { + const t = parseTokenResponse( + { access_token: 'a2', refresh_token: 'r2', expires_in: 300 }, + 'r1' + ); + expect(t.accessToken).toBe('a2'); + expect(t.refreshToken).toBe('r2'); + }); + + it('keeps the previous refresh token when the response omits one', () => { + const t = parseTokenResponse({ access_token: 'a2', expires_in: 300 }, 'r1'); + expect(t.refreshToken).toBe('r1'); + }); + + it('falls back to a 5-minute lifetime when expires_in is missing or non-positive', () => { + const before = Date.now(); + const t = parseTokenResponse({ access_token: 'a2' }, 'r1'); + expect(t.expiresAt).toBeGreaterThanOrEqual(before + 5 * 60 * 1000); + expect(t.expiresAt).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000 + 1000); + + const zero = parseTokenResponse( + { access_token: 'a2', expires_in: 0 }, + 'r1' + ); + expect(zero.expiresAt).toBeGreaterThan(Date.now() + 4 * 60 * 1000); + }); + + it('throws when the response has no access token', () => { + expect(() => parseTokenResponse({ refresh_token: 'r2' }, 'r1')).toThrow(); + expect(() => parseTokenResponse({ access_token: '' }, 'r1')).toThrow(); + }); +}); + +describe('wasCancelledByUser', () => { + it('matches user-cancellation messages', () => { + for (const m of [ + 'The user cancelled the flow', + 'User did not approve access', + 'Authorization was denied', + 'Window closed by the user', + ]) { + expect(wasCancelledByUser(m)).toBe(true); + } + }); + + it('does not match transient load failures', () => { + expect(wasCancelledByUser('Authorization page could not be loaded')).toBe( + false + ); + expect(wasCancelledByUser('network error')).toBe(false); + }); +}); diff --git a/src/oauth/oauthClient.ts b/src/oauth/oauthClient.ts index 6448e72..6b9d54f 100644 --- a/src/oauth/oauthClient.ts +++ b/src/oauth/oauthClient.ts @@ -1,90 +1,25 @@ import browser from 'webextension-polyfill'; import { OAUTH_CLIENT_ID, OAUTH_SCOPES } from '../constants'; import { challengeFromVerifier, randomUrlSafe } from './pkce'; +import { normalizeUrl } from './url'; export type OAuthTokens = { accessToken: string; refreshToken?: string; - // epoch milliseconds at which the access token expires expiresAt: number; }; -const normalizeUrl = (url: string) => url.replace(/\/$/, ''); - -// Fallback lifetime when the token endpoint omits expires_in, so getValidAccessToken doesn't read the token as already -// expired (expiresAt === now) and trigger a refresh — and, with rotation, a refresh — on every single read. -const DEFAULT_TOKEN_LIFETIME_SECONDS = 5 * 60; - -export const getRedirectUri = () => browser.identity.getRedirectURL(); - -const parseTokenResponse = ( - data: Record, - previousRefreshToken?: string -): OAuthTokens => { - if (typeof data.access_token !== 'string' || !data.access_token) { - throw new Error('Tolgee token endpoint returned no access_token'); - } - const expiresIn = - typeof data.expires_in === 'number' && data.expires_in > 0 - ? data.expires_in - : DEFAULT_TOKEN_LIFETIME_SECONDS; - return { - accessToken: data.access_token, - // rotation returns a fresh refresh token; if a response omits it, keep the previous one - refreshToken: data.refresh_token ?? previousRefreshToken, - expiresAt: Date.now() + expiresIn * 1000, - }; -}; - -const postToken = async ( - base: string, - params: Record, - previousRefreshToken?: string -): Promise => { - const res = await fetch(`${base}/oauth2/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams(params), - }); - if (!res.ok) { - const body = await res.text().catch(() => ''); - throw new Error(`Tolgee token endpoint returned ${res.status}: ${body}`); +export class OAuthTokenEndpointError extends Error { + constructor( + readonly status: number, + body: string + ) { + super(`Tolgee token endpoint returned ${status}: ${body}`); + this.name = 'OAuthTokenEndpointError'; } - return parseTokenResponse(await res.json(), previousRefreshToken); -}; - -const AUTH_MAX_ATTEMPTS = 3; -const AUTH_RETRY_DELAY_MS = 500; - -const wasCancelledByUser = (message: string) => - /cancel|did not approve|denied|closed by the user/i.test(message); +} -// launchWebAuthFlow runs the authorize flow in an isolated window that intermittently fails to load the bootstrap SPA -// ("Authorization page could not be loaded"), even though a retry succeeds. Reopen it a couple of times on such a -// transient failure, but never after the user closes/denies the window (that decision is final). -const launchAuthWithRetry = async (url: string): Promise => { - let lastError: unknown; - for (let attempt = 1; attempt <= AUTH_MAX_ATTEMPTS; attempt++) { - try { - return await browser.identity.launchWebAuthFlow({ - url, - interactive: true, - }); - } catch (e) { - lastError = e; - const message = e instanceof Error ? e.message : String(e); - if (wasCancelledByUser(message) || attempt === AUTH_MAX_ATTEMPTS) { - throw e; - } - console.warn( - `[tolgee-oauth] authorization attempt ${attempt} failed, retrying`, - message - ); - await new Promise((resolve) => setTimeout(resolve, AUTH_RETRY_DELAY_MS)); - } - } - throw lastError; -}; +export const getRedirectUri = () => browser.identity.getRedirectURL(); export const login = async ( apiUrl: string, @@ -150,3 +85,71 @@ export const refresh = ( }, refreshToken ); + +export const parseTokenResponse = ( + data: Record, + previousRefreshToken?: string +): OAuthTokens => { + if (typeof data.access_token !== 'string' || !data.access_token) { + throw new Error('Tolgee token endpoint returned no access_token'); + } + const expiresIn = + typeof data.expires_in === 'number' && data.expires_in > 0 + ? data.expires_in + : DEFAULT_TOKEN_LIFETIME_SECONDS; + return { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? previousRefreshToken, + expiresAt: Date.now() + expiresIn * 1000, + }; +}; + +export const wasCancelledByUser = (message: string) => + /cancel|did not approve|denied|closed by the user/i.test(message); + +const DEFAULT_TOKEN_LIFETIME_SECONDS = 5 * 60; + +const postToken = async ( + base: string, + params: Record, + previousRefreshToken?: string +): Promise => { + const res = await fetch(`${base}/oauth2/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(params), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new OAuthTokenEndpointError(res.status, body); + } + return parseTokenResponse(await res.json(), previousRefreshToken); +}; + +const AUTH_MAX_ATTEMPTS = 3; +const AUTH_RETRY_DELAY_MS = 500; + +// launchWebAuthFlow intermittently fails to load the bootstrap SPA; retry any launch failure but never a user cancel. +const launchAuthWithRetry = async (url: string): Promise => { + let lastError: unknown; + for (let attempt = 1; attempt <= AUTH_MAX_ATTEMPTS; attempt++) { + try { + return await browser.identity.launchWebAuthFlow({ + url, + interactive: true, + }); + } catch (e) { + lastError = e; + const message = e instanceof Error ? e.message : String(e); + if (wasCancelledByUser(message) || attempt === AUTH_MAX_ATTEMPTS) { + throw e; + } + console.warn( + `[tolgee] authorization attempt ${attempt} failed, retrying`, + message + ); + await new Promise((resolve) => setTimeout(resolve, AUTH_RETRY_DELAY_MS)); + } + } + throw lastError; +}; diff --git a/src/oauth/pkce.test.ts b/src/oauth/pkce.test.ts new file mode 100644 index 0000000..f1bec59 --- /dev/null +++ b/src/oauth/pkce.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { challengeFromVerifier, randomUrlSafe } from './pkce'; + +describe('pkce', () => { + it('derives the S256 challenge from the RFC 7636 Appendix B vector', async () => { + const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; + const challenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; + expect(await challengeFromVerifier(verifier)).toBe(challenge); + }); + + it('emits base64url with no +, / or = padding', () => { + const value = randomUrlSafe(); + expect(value).not.toMatch(/[+/=]/); + expect(value.length).toBeGreaterThan(0); + }); + + it('produces a different verifier each call', () => { + expect(randomUrlSafe()).not.toBe(randomUrlSafe()); + }); +}); diff --git a/src/oauth/pkce.ts b/src/oauth/pkce.ts index 81d0894..bbcc85a 100644 --- a/src/oauth/pkce.ts +++ b/src/oauth/pkce.ts @@ -1,10 +1,4 @@ -// PKCE helpers (RFC 7636), using Web Crypto — available in both the service worker and the popup. - -const base64UrlEncode = (bytes: Uint8Array): string => { - let str = ''; - bytes.forEach((b) => (str += String.fromCharCode(b))); - return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -}; +// PKCE (RFC 7636). export const randomUrlSafe = (byteLength = 32): string => { const bytes = new Uint8Array(byteLength); @@ -21,3 +15,9 @@ export const challengeFromVerifier = async ( ); return base64UrlEncode(new Uint8Array(digest)); }; + +const base64UrlEncode = (bytes: Uint8Array): string => { + let str = ''; + bytes.forEach((b) => (str += String.fromCharCode(b))); + return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; diff --git a/src/oauth/tokenScope.test.ts b/src/oauth/tokenScope.test.ts new file mode 100644 index 0000000..15b2825 --- /dev/null +++ b/src/oauth/tokenScope.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { + ALL_PROJECTS_KEY, + projectKeyForToken, + scopeServesProject, +} from './tokenScope'; + +// JWT-shaped string whose payload base64url-encodes the given claims. +const tokenWith = (claims: Record) => { + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url'); + return `header.${payload}.signature`; +}; + +describe('projectKeyForToken', () => { + it('keys an all-projects token as *', () => { + expect(projectKeyForToken(tokenWith({ 'tg.prj': '*' }))).toBe('*'); + }); + + it('keys a single-project token by its id', () => { + expect(projectKeyForToken(tokenWith({ 'tg.prj': [2] }))).toBe('2'); + }); + + it('keys a numeric (non-array) single-project claim by its id', () => { + expect(projectKeyForToken(tokenWith({ 'tg.prj': 5 }))).toBe('5'); + }); + + it('keys a scalar-string single-project claim by its id', () => { + expect(projectKeyForToken(tokenWith({ 'tg.prj': '5' }))).toBe('5'); + }); + + it('keys a multi-project set by its sorted ids (not folded into *)', () => { + expect(projectKeyForToken(tokenWith({ 'tg.prj': [3, 2] }))).toBe('2,3'); + }); + + it('keys an undecodable / claimless token as *', () => { + expect(projectKeyForToken(tokenWith({ sub: '1' }))).toBe(ALL_PROJECTS_KEY); + expect(projectKeyForToken('not-a-jwt')).toBe(ALL_PROJECTS_KEY); + }); + + it('keys an empty or all-non-numeric project claim as * (no concrete set to key by)', () => { + expect(projectKeyForToken(tokenWith({ 'tg.prj': [] }))).toBe( + ALL_PROJECTS_KEY + ); + expect(projectKeyForToken(tokenWith({ 'tg.prj': ['x', 'y'] }))).toBe( + ALL_PROJECTS_KEY + ); + }); +}); + +describe('scopeServesProject', () => { + it('an all-projects key serves any page', () => { + expect(scopeServesProject('*', '7')).toBe(true); + expect(scopeServesProject('*', null)).toBe(true); + }); + + it('a concrete key serves only its own project', () => { + expect(scopeServesProject('3', '3')).toBe(true); + expect(scopeServesProject('3', '2')).toBe(false); + expect(scopeServesProject('3', null)).toBe(false); + }); + + it('a multi-project key serves only its member projects', () => { + expect(scopeServesProject('5,7', '5')).toBe(true); + expect(scopeServesProject('5,7', '7')).toBe(true); + expect(scopeServesProject('5,7', '9')).toBe(false); + }); + + it('an absent key (older background push) serves any page', () => { + expect(scopeServesProject(undefined, '3')).toBe(true); + }); +}); diff --git a/src/oauth/tokenScope.ts b/src/oauth/tokenScope.ts index 8dbaa65..1c9c308 100644 --- a/src/oauth/tokenScope.ts +++ b/src/oauth/tokenScope.ts @@ -1,8 +1,28 @@ -// Reads the `tg.prj` (project set) claim the backend stamped into the OAuth access token. '*' means all projects -// (the user must then pick one to edit); a single id means the token is bound to that project and we can auto-select it. -export function decodeTokenProjectSet( +export const ALL_PROJECTS_KEY = '*'; + +export const projectKeyForToken = (token: string): string => { + const scope = decodeTokenProjectSet(token); + if (Array.isArray(scope) && scope.length >= 1) { + return [...scope].sort((a, b) => a - b).join(','); + } + return ALL_PROJECTS_KEY; +}; + +export const scopeServesProject = ( + projectKey: string | undefined, + pageProjectId: string | null +): boolean => { + if (projectKey === undefined || projectKey === ALL_PROJECTS_KEY) { + return true; + } + return ( + pageProjectId !== null && projectKey.split(',').includes(pageProjectId) + ); +}; + +export const decodeTokenProjectSet = ( token: string | undefined -): '*' | number[] | undefined { +): '*' | number[] | undefined => { if (!token) { return undefined; } @@ -14,31 +34,15 @@ export function decodeTokenProjectSet( if (prj === '*') { return '*'; } + if (typeof prj === 'number' || typeof prj === 'string') { + const n = Number(prj); + return Number.isNaN(n) ? undefined : [n]; + } if (Array.isArray(prj)) { return prj.map((x) => Number(x)).filter((n) => !Number.isNaN(n)); } return undefined; - } catch (e) { + } catch { return undefined; } -} - -export const ALL_PROJECTS_KEY = '*'; - -// The store key for a token's project scope: the single bound project id, or '*' for an all-projects token. Keying a -// concrete-project token by its id lets two projects on the same backend coexist instead of overwriting each other; -// an all-projects token keys as '*' so it's reused for any project. A multi-project set (the extension never mints one) -// keys by its sorted ids, which no single-project lookup matches — so it is simply never reused, never mis-served. -export function projectKeyForToken(token: string): string { - const scope = decodeTokenProjectSet(token); - if (scope === '*') { - return ALL_PROJECTS_KEY; - } - if (Array.isArray(scope) && scope.length === 1) { - return String(scope[0]); - } - if (Array.isArray(scope) && scope.length > 1) { - return [...scope].sort((a, b) => a - b).join(','); - } - return ALL_PROJECTS_KEY; -} +}; diff --git a/src/oauth/tokenStore.test.ts b/src/oauth/tokenStore.test.ts index ea2c391..343041c 100644 --- a/src/oauth/tokenStore.test.ts +++ b/src/oauth/tokenStore.test.ts @@ -1,6 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -// In-memory chrome.storage.local. const store = new Map(); vi.mock('webextension-polyfill', () => ({ default: { @@ -27,16 +26,37 @@ vi.mock('webextension-polyfill', () => ({ vi.mock('./tokenScope', () => ({ ALL_PROJECTS_KEY: '*', projectKeyForToken: (token: string) => token.replace(/^token-/, ''), + scopeServesProject: ( + projectKey: string | undefined, + pageProjectId: string | null + ) => + projectKey === undefined || + projectKey === '*' || + (pageProjectId !== null && projectKey.split(',').includes(pageProjectId)), })); -const refresh = vi.fn(); +const { refresh, OAuthTokenEndpointError } = vi.hoisted(() => { + class OAuthTokenEndpointError extends Error { + constructor(readonly status: number) { + super(`Tolgee token endpoint returned ${status}`); + } + } + return { refresh: vi.fn(), OAuthTokenEndpointError }; +}); vi.mock('./oauthClient', () => ({ + OAuthTokenEndpointError, refresh: (apiUrl: string, refreshToken: string) => refresh(apiUrl, refreshToken), })); +// A token-endpoint error, matching how isTerminalRefreshFailure classifies (instanceof + status). +const endpointError = (status: number) => new OAuthTokenEndpointError(status); + import { clearSession, + clearSessionForToken, + clearSessionIfUnreferenced, + ensureFreshToken, getValidAccessToken, loadSession, saveSession, @@ -84,22 +104,63 @@ describe('tokenStore per-project keying', () => { expect((await loadSession(URL_A, 5))?.projectKey).toBe('*'); }); - it('serves the sole origin session when the requested project is unknown or mismatched', async () => { - // The popup reopens and asks with no (or a stale) projectId before it re-resolves the page's project. With a single - // session for the backend, getValidAccessToken must still resolve it — otherwise the popup treats it as - // disconnected and wipes it. (loadSession itself stays strict, so disconnect never clears the wrong session.) + it('with the reopen fallback, serves the sole origin session when the project is unknown', async () => { + // The popup reopens and asks with no projectId before it re-resolves the page's project. With a single session for + // the backend, the opt-in fallback must still resolve it — otherwise the popup treats it as disconnected and wipes it. await saveSession(URL_A, tokens('2')); + const opts = { soleOriginFallback: true }; - expect(await getValidAccessToken(URL_A, undefined)).toBe('token-2'); - expect(await getValidAccessToken(URL_A, 999)).toBe('token-2'); - expect(await loadSession(URL_A, 999)).toBeNull(); + expect(await getValidAccessToken(URL_A, undefined, opts)).toBe('token-2'); + // A concrete projectId the sole session serves still resolves (via loadSession's exact match). + expect(await getValidAccessToken(URL_A, 2, opts)).toBe('token-2'); + }); + + it('does not serve the sole session for a concrete project its scope does not cover', async () => { + // Guards against injecting a project-2 token into a page bound to project 999 just because it is the only session. + await saveSession(URL_A, tokens('2')); + + expect( + await getValidAccessToken(URL_A, 999, { soleOriginFallback: true }) + ).toBeNull(); + }); + + it('is strict by default: a mismatched concrete project does not reuse the sole session', async () => { + // connect()/OAUTH_LOGIN use the default (strict) so a page declaring a different project runs a fresh login for it + // instead of silently reusing a wrong-scoped token. + await saveSession(URL_A, tokens('2')); + + expect(await getValidAccessToken(URL_A, 999)).toBeNull(); }); it('does not guess when multiple sessions exist and no project matches', async () => { await saveSession(URL_A, tokens('2')); await saveSession(URL_A, tokens('3')); - expect(await getValidAccessToken(URL_A, undefined)).toBeNull(); + expect( + await getValidAccessToken(URL_A, undefined, { soleOriginFallback: true }) + ).toBeNull(); + }); + + it('shares one in-flight refresh across concurrent reads (no single-use double-spend)', async () => { + await saveSession(URL_A, tokens('3', { expiresAt: Date.now() - 1 })); + let resolveRefresh!: (v: unknown) => void; + const deferred = new Promise((res) => (resolveRefresh = res)); + refresh.mockReturnValue(deferred); + + const p1 = getValidAccessToken(URL_A, 3); + const p2 = getValidAccessToken(URL_A, 3); + // Let both reads get past their (async) session load and dedup onto the one shared refresh before it resolves. + await new Promise((r) => setTimeout(r, 0)); + resolveRefresh({ + accessToken: 'token-3b', + refreshToken: 'r2', + expiresAt: future(), + }); + const [a, b] = await Promise.all([p1, p2]); + + expect(refresh).toHaveBeenCalledTimes(1); + expect(a).toBe('token-3b'); + expect(b).toBe('token-3b'); }); it('clears only the session serving the given project', async () => { @@ -112,6 +173,37 @@ describe('tokenStore per-project keying', () => { expect((await loadSession(URL_A, 3))?.accessToken).toBe('token-3'); }); + it('clearSession is strict: disconnecting a non-served concrete project is a no-op', async () => { + // The disconnect half of 8a3d045: clearSession must not delete the wrong session when the project doesn't match. + await saveSession(URL_A, tokens('2')); + + await clearSession(URL_A, 999); + + expect((await loadSession(URL_A, 2))?.accessToken).toBe('token-2'); + expect(await loadSession(URL_A, 999)).toBeNull(); + }); + + it('a multi-project-keyed session is reachable by a member project via membership match', async () => { + // Multi-project tokens are never minted by the extension; this pins the store behavior if one ever appeared: the + // read path matches by membership (like scopeServesProject), so the session auto-refreshes for its member projects. + await saveSession(URL_A, tokens('5,7')); + + expect((await loadSession(URL_A, 5))?.accessToken).toBe('token-5,7'); + expect((await loadSession(URL_A, 7))?.accessToken).toBe('token-5,7'); + expect(await loadSession(URL_A, 9)).toBeNull(); + expect(await getValidAccessToken(URL_A, 5)).toBe('token-5,7'); + }); + + it('disconnecting one project drops the shared all-projects session (documented)', async () => { + // With only an all-projects session, clearSession for any project resolves to and removes that shared '*' session — + // so every other project relying on it is disconnected too. Intended: an all-projects login is one shared session. + await saveSession(URL_A, tokens('*')); + + await clearSession(URL_A, 5); + + expect(await loadSession(URL_A, 7)).toBeNull(); + }); + it('refreshes under the original scope key, not the refreshed token’s', async () => { await saveSession(URL_A, tokens('3', { expiresAt: Date.now() - 1 })); // The backend never widens scope on refresh, but even if the refreshed token decoded differently the session must @@ -135,4 +227,197 @@ describe('tokenStore per-project keying', () => { expect(await getValidAccessToken(URL_A, 3)).toBe('token-3'); expect(refresh).not.toHaveBeenCalled(); }); + + it('refreshes a token inside the refresh-skew window (proactive refresh)', async () => { + // 30s to expiry is inside the 60s skew, so it must refresh proactively rather than serve the near-dead token. + await saveSession(URL_A, tokens('3', { expiresAt: Date.now() + 30_000 })); + refresh.mockResolvedValueOnce({ + accessToken: 'token-3b', + refreshToken: 'r2', + expiresAt: future(), + }); + + expect(await getValidAccessToken(URL_A, 3)).toBe('token-3b'); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it('ensureFreshToken re-reads current state, so a stale expired snapshot never double-spends the refresh token', async () => { + // The alarm captures a snapshot then acts on it later; if a concurrent refresh rotated the token in between, the + // snapshot is stale. ensureFreshToken must re-read and serve the current fresh token, not refresh the stale one. + await saveSession(URL_A, tokens('3')); + const staleSnapshot = { + accessToken: 'token-3-old', + refreshToken: 'r0', + expiresAt: Date.now() - 1, + apiUrl: URL_A, + projectKey: '3', + }; + + expect(await ensureFreshToken(staleSnapshot)).toBe('token-3'); + expect(refresh).not.toHaveBeenCalled(); + }); + + it('a refresh completing after a concurrent Disconnect does not resurrect the removed session', async () => { + await saveSession(URL_A, tokens('3', { expiresAt: Date.now() - 1 })); + let resolveRefresh: (v: unknown) => void = () => {}; + refresh.mockReturnValue( + new Promise((resolve) => { + resolveRefresh = resolve; + }) + ); + + const pending = getValidAccessToken(URL_A, 3); + await new Promise((r) => setTimeout(r, 0)); + await clearSessionForToken(URL_A, 'token-3'); + resolveRefresh({ + accessToken: 'token-3-new', + refreshToken: 'r2', + expiresAt: future(), + }); + await pending; + + expect(await loadSession(URL_A, 3)).toBeNull(); + }); + + it('a refresh completing after a concurrent reconnect does not clobber the reconnected session', async () => { + await saveSession( + URL_A, + tokens('3', { expiresAt: Date.now() - 1, refreshToken: 'old-refresh' }) + ); + let resolveRefresh: (v: unknown) => void = () => {}; + refresh.mockReturnValue( + new Promise((resolve) => { + resolveRefresh = resolve; + }) + ); + + const pending = getValidAccessToken(URL_A, 3); + await new Promise((r) => setTimeout(r, 0)); + await saveSession(URL_A, tokens('3', { refreshToken: 'new-refresh' })); + resolveRefresh({ + accessToken: 'token-3-stale', + refreshToken: 'rotated', + expiresAt: future(), + }); + + // Serves the reconnected token, never the orphan it declined to store (else the alarm would push a stale token). + expect(await pending).toBe('token-3'); + const current = await loadSession(URL_A, 3); + expect(current?.refreshToken).toBe('new-refresh'); + expect(current?.accessToken).toBe('token-3'); + }); + + it('clearSessionForToken deletes exactly the session the token owns (multi-project key)', async () => { + await saveSession(URL_A, tokens('5,7')); + + await clearSessionForToken(URL_A, 'token-5,7'); + + expect( + await getValidAccessToken(URL_A, undefined, { soleOriginFallback: true }) + ).toBeNull(); + }); + + it('clearSessionIfUnreferenced keeps a shared session that another site still marks connected', async () => { + await saveSession(URL_A, tokens('*')); + const marker = { oauth: true, apiUrl: URL_A, projectKey: '*' }; + store.set('https://site-a.example', marker); + store.set('https://site-b.example', marker); + + await clearSessionIfUnreferenced(URL_A, '*', 'https://site-a.example'); + + expect((await loadSession(URL_A, 7))?.accessToken).toBe('token-*'); + }); + + it('clearSessionIfUnreferenced deletes the session when the disconnecting site was the last reference', async () => { + await saveSession(URL_A, tokens('*')); + store.set('https://site-a.example', { + oauth: true, + apiUrl: URL_A, + projectKey: '*', + }); + + await clearSessionIfUnreferenced(URL_A, '*', 'https://site-a.example'); + + expect(await loadSession(URL_A, 7)).toBeNull(); + }); + + it('clears an expired session that has no refresh token, without calling refresh', async () => { + await saveSession( + URL_A, + tokens('3', { refreshToken: undefined, expiresAt: Date.now() - 1 }) + ); + + expect(await getValidAccessToken(URL_A, 3)).toBeNull(); + expect(await loadSession(URL_A, 3)).toBeNull(); + expect(refresh).not.toHaveBeenCalled(); + }); + + it('clears the session when refresh fails with a terminal 400/401 (dead refresh token)', async () => { + for (const status of [400, 401]) { + store.clear(); + await saveSession(URL_A, tokens('3', { expiresAt: Date.now() - 1 })); + refresh.mockReset(); + refresh.mockRejectedValue(endpointError(status)); + + expect(await getValidAccessToken(URL_A, 3)).toBeNull(); + expect(await loadSession(URL_A, 3)).toBeNull(); + } + }); + + it('keeps the session when refresh fails transiently (403/404/429/503/network)', async () => { + for (const err of [ + endpointError(403), + endpointError(404), + endpointError(429), + endpointError(503), + new Error('network error'), + ]) { + store.clear(); + await saveSession(URL_A, tokens('3', { expiresAt: Date.now() - 1 })); + refresh.mockReset(); + refresh.mockRejectedValue(err); + + expect(await getValidAccessToken(URL_A, 3)).toBeNull(); + // The token is kept so a later call can retry rather than logging the user out on a blip. + expect((await loadSession(URL_A, 3))?.accessToken).toBe('token-3'); + } + }); + + it('clears the in-flight entry after settling so a later expiry refreshes again', async () => { + await saveSession(URL_A, tokens('3', { expiresAt: Date.now() - 1 })); + refresh.mockResolvedValueOnce({ + accessToken: 'token-3b', + refreshToken: 'r2', + expiresAt: future(), + }); + expect(await getValidAccessToken(URL_A, 3)).toBe('token-3b'); + + // Force the just-refreshed session expired again; a second read must run a fresh refresh (not serve a stale + // cached in-flight promise). + const s = store.get('oauth:https://app.tolgee.io:3') as { + expiresAt: number; + }; + s.expiresAt = Date.now() - 1; + refresh.mockResolvedValueOnce({ + accessToken: 'token-3c', + refreshToken: 'r3', + expiresAt: future(), + }); + expect(await getValidAccessToken(URL_A, 3)).toBe('token-3c'); + expect(refresh).toHaveBeenCalledTimes(2); + }); + + it('resolves the right sole session per origin when connected to two backends', async () => { + const URL_B = 'https://other.example'; + const fallback = { soleOriginFallback: true }; + await saveSession(URL_A, tokens('2')); + await saveSession(URL_B, tokens('9')); + + expect(await getValidAccessToken(URL_A, undefined, fallback)).toBe( + 'token-2' + ); + expect(await getValidAccessToken(URL_B, undefined, fallback)).toBe( + 'token-9' + ); + }); }); diff --git a/src/oauth/tokenStore.ts b/src/oauth/tokenStore.ts index 708988c..2e01ee9 100644 --- a/src/oauth/tokenStore.ts +++ b/src/oauth/tokenStore.ts @@ -1,37 +1,21 @@ import browser from 'webextension-polyfill'; import { OAUTH_REFRESH_SKEW_MS } from '../constants'; -import { OAuthTokens, refresh } from './oauthClient'; -import { ALL_PROJECTS_KEY, projectKeyForToken } from './tokenScope'; +import { OAuthTokenEndpointError, OAuthTokens, refresh } from './oauthClient'; +import { + ALL_PROJECTS_KEY, + projectKeyForToken, + scopeServesProject, +} from './tokenScope'; +import { originOf, sameOrigin } from './url'; export type StoredSession = OAuthTokens & { apiUrl: string; projectKey: string; }; -const originOf = (apiUrl: string) => new URL(apiUrl).origin; +export const isTokenFresh = (session: Pick) => + session.expiresAt - OAUTH_REFRESH_SKEW_MS > Date.now(); -// Sessions are keyed by (backend origin, project scope): two concrete-project logins on the same backend coexist -// instead of overwriting each other, and an all-projects ('*') session is reused for any project on that origin. -const keyFor = (apiUrl: string, projectKey: string) => - `oauth:${originOf(apiUrl)}:${projectKey}`; - -const normalizeProjectId = (projectId?: number | string) => - projectId === undefined || projectId === null || projectId === '' - ? undefined - : String(projectId); - -const persist = async ( - apiUrl: string, - tokens: OAuthTokens, - projectKey: string -) => { - await browser.storage.local.set({ - [keyFor(apiUrl, projectKey)]: { ...tokens, apiUrl, projectKey }, - }); -}; - -// Stores a freshly minted token under its own project scope, returning the key so callers can log it. A refresh keeps -// the original key (see refreshSession) so a rotated token never lands under a different scope. export const saveSession = async ( apiUrl: string, tokens: OAuthTokens @@ -41,18 +25,6 @@ export const saveSession = async ( return projectKey; }; -const loadByKey = async ( - apiUrl: string, - projectKey: string -): Promise => { - const key = keyFor(apiUrl, projectKey); - const stored = await browser.storage.local.get(key); - return (stored[key] as StoredSession) ?? null; -}; - -// The session serving a page on `projectId`: its own concrete-project session when one exists, otherwise an -// all-projects session (whose token covers every project on the backend). Strict — no guessing — so disconnect -// (clearSession) never removes a session the caller didn't ask for. export const loadSession = async ( apiUrl: string, projectId?: number | string @@ -63,21 +35,24 @@ export const loadSession = async ( if (exact) { return exact; } + // A composite-key ("5,7") session is matched by membership, sorted so the pick is deterministic if several match. + const member = (await loadAllSessions()) + .filter( + (s) => + s.projectKey !== ALL_PROJECTS_KEY && + sameOrigin(s.apiUrl, apiUrl) && + scopeServesProject(s.projectKey, id) + ) + .sort((a, b) => a.projectKey.localeCompare(b.projectKey))[0]; + if (member) { + return member; + } } return loadByKey(apiUrl, ALL_PROJECTS_KEY); }; -// The sole session for an origin, or null when there are zero or several. The read path falls back to this so a caller -// that doesn't know the project yet (e.g. the popup reopening before it re-resolves the page's project) still resolves -// its one session instead of being told "not connected". -const soleOriginSession = async ( - apiUrl: string -): Promise => { - const originSessions = (await loadAllSessions()).filter( - (s) => originOf(s.apiUrl) === originOf(apiUrl) - ); - return originSessions.length === 1 ? originSessions[0] : null; -}; +export const clearSessionByKey = (apiUrl: string, projectKey: string) => + browser.storage.local.remove(keyFor(apiUrl, projectKey)); export const clearSession = async ( apiUrl: string, @@ -85,73 +60,197 @@ export const clearSession = async ( ) => { const session = await loadSession(apiUrl, projectId); if (session) { - await browser.storage.local.remove(keyFor(apiUrl, session.projectKey)); + await clearSessionByKey(apiUrl, session.projectKey); + } +}; + +// Delete exactly the session a token belongs to, keyed by the token's own scope (handles all-projects and multi-set). +export const clearSessionForToken = (apiUrl: string, accessToken: string) => + clearSessionByKey(apiUrl, projectKeyForToken(accessToken)); + +// A '*'/shared session is reused across sites on the same backend, so a per-site Disconnect deletes it only when no +// origin other than the disconnecting one still marks it connected. +export const clearSessionIfUnreferenced = async ( + apiUrl: string, + projectKey: string, + excludeOrigin: string +) => { + const all = await browser.storage.local.get(null); + const referenced = Object.entries(all).some(([key, value]) => { + if (key === excludeOrigin || key.startsWith(OAUTH_KEY_PREFIX)) { + return false; + } + const marker = value as { + oauth?: boolean; + apiUrl?: string; + projectKey?: string; + }; + return Boolean( + marker?.oauth && + marker.apiUrl && + sameOrigin(marker.apiUrl, apiUrl) && + marker.projectKey === projectKey + ); + }); + if (!referenced) { + await clearSessionByKey(apiUrl, projectKey); } }; export const loadAllSessions = async (): Promise => { const all = await browser.storage.local.get(null); return Object.entries(all) - .filter(([key]) => key.startsWith('oauth:')) + .filter(([key]) => key.startsWith(OAUTH_KEY_PREFIX)) .map(([, value]) => value as StoredSession); }; -// A token-endpoint 4xx means the refresh token is dead (rotated away or revoked) — terminal, clear the session. A -// network failure (fetch rejects, no status) is transient — keep the session so a later call can retry instead of -// logging the user out on a blip. postToken throws `... returned : ...` for non-ok responses. -const isTerminalRefreshFailure = (e: unknown) => - e instanceof Error && /returned 4\d\d/.test(e.message); - -// A refresh rotates the refresh token, so two concurrent refreshes for the same session would both spend the same -// (single-use) token: the first wins, the second gets invalid_grant and clears the just-refreshed session. The alarm -// handler and an OAUTH_GET_TOKEN message can land in the same worker at once, so share one in-flight refresh per key. -const inFlightRefresh = new Map>(); +export const sessionKey = (session: StoredSession) => + keyFor(session.apiUrl, session.projectKey); -const refreshSession = async ( - session: StoredSession -): Promise => { - try { - const refreshed = await refresh(session.apiUrl, session.refreshToken!); - // Persist under the session's original key: a refresh must keep the same project scope, never re-key the session. - await persist(session.apiUrl, refreshed, session.projectKey); - return refreshed.accessToken; - } catch (e) { - if (isTerminalRefreshFailure(e)) { - await browser.storage.local.remove( - keyFor(session.apiUrl, session.projectKey) - ); - } - return null; - } -}; +// Resolve by the tab's recorded scope key — never re-derive from a page-supplied projectId (which could point the +// refresh at a different keyed session). +export const resolveSessionForTab = (tab: { + apiUrl: string; + projectKey: string; +}) => loadByKey(tab.apiUrl, tab.projectKey); -// Returns a valid access token for the given project, refreshing (and persisting) if it is expired or near expiry. -// Falls back to an all-projects session, then to the origin's sole session; returns null when there is nothing valid -// to serve it. export const getValidAccessToken = async ( apiUrl: string, - projectId?: number | string + projectId?: number | string, + { soleOriginFallback = false }: { soleOriginFallback?: boolean } = {} ): Promise => { const session = - (await loadSession(apiUrl, projectId)) ?? (await soleOriginSession(apiUrl)); + (await loadSession(apiUrl, projectId)) ?? + (soleOriginFallback + ? await soleOriginFallbackSession(apiUrl, projectId) + : null); if (!session) { return null; } - if (session.expiresAt - OAUTH_REFRESH_SKEW_MS > Date.now()) { + return ensureFreshToken(session); +}; + +export const ensureFreshToken = async ( + session: StoredSession +): Promise => { + if (isTokenFresh(session)) { return session.accessToken; } - if (!session.refreshToken) { - await browser.storage.local.remove(keyFor(apiUrl, session.projectKey)); - return null; - } - const key = keyFor(session.apiUrl, session.projectKey); + const key = sessionKey(session); const existing = inFlightRefresh.get(key); if (existing) { return existing; } - const pending = refreshSession(session).finally(() => + const pending = refreshCurrent(session, key).finally(() => inFlightRefresh.delete(key) ); inFlightRefresh.set(key, pending); return pending; }; + +// Re-read at refresh time: the caller may hold a stale snapshot, so a rotated single-use refresh token isn't double-spent. +const refreshCurrent = async ( + session: StoredSession, + key: string +): Promise => { + const current = await loadByKey(session.apiUrl, session.projectKey); + if (!current) { + return null; + } + if (isTokenFresh(current)) { + return current.accessToken; + } + if (!current.refreshToken) { + await browser.storage.local.remove(key); + return null; + } + return refreshSession(current); +}; + +const soleOriginFallbackSession = async ( + apiUrl: string, + projectId?: number | string +): Promise => { + const sole = await soleOriginSession(apiUrl); + if (!sole) { + return null; + } + const id = normalizeProjectId(projectId); + if (id !== undefined && !scopeServesProject(sole.projectKey, id)) { + return null; + } + return sole; +}; + +const soleOriginSession = async ( + apiUrl: string +): Promise => { + const originSessions = (await loadAllSessions()).filter((s) => + sameOrigin(s.apiUrl, apiUrl) + ); + return originSessions.length === 1 ? originSessions[0] : null; +}; + +const persist = async ( + apiUrl: string, + tokens: OAuthTokens, + projectKey: string +) => { + await browser.storage.local.set({ + [keyFor(apiUrl, projectKey)]: { ...tokens, apiUrl, projectKey }, + }); +}; + +const loadByKey = async ( + apiUrl: string, + projectKey: string +): Promise => { + const key = keyFor(apiUrl, projectKey); + const stored = await browser.storage.local.get(key); + return (stored[key] as StoredSession) ?? null; +}; + +// One in-flight refresh per key: refresh tokens are single-use, so a concurrent second refresh gets invalid_grant. +const inFlightRefresh = new Map>(); + +const refreshSession = async ( + session: StoredSession +): Promise => { + const spentRefreshToken = session.refreshToken!; + try { + const refreshed = await refresh(session.apiUrl, spentRefreshToken); + // A Disconnect or reconnect can land during the network round-trip. Write back (and hand out) our result only if this + // is still the session we refreshed; otherwise serve whatever replaced it, never the orphan we just declined to store. + const current = await loadByKey(session.apiUrl, session.projectKey); + if (current?.refreshToken === spentRefreshToken) { + await persist(session.apiUrl, refreshed, session.projectKey); + return refreshed.accessToken; + } + return current?.accessToken ?? null; + } catch (e) { + console.warn('[tolgee] session refresh failed', e); + const current = await loadByKey(session.apiUrl, session.projectKey); + if ( + isTerminalRefreshFailure(e) && + current?.refreshToken === spentRefreshToken + ) { + await browser.storage.local.remove(sessionKey(session)); + } + return null; + } +}; + +// Only 400/401 (invalid_grant) means a dead refresh token; other statuses and network failures are transient. +const isTerminalRefreshFailure = (e: unknown) => + e instanceof OAuthTokenEndpointError && + (e.status === 400 || e.status === 401); + +const OAUTH_KEY_PREFIX = 'oauth:'; + +const keyFor = (apiUrl: string, projectKey: string) => + `${OAUTH_KEY_PREFIX}${originOf(apiUrl)}:${projectKey}`; + +const normalizeProjectId = (projectId?: number | string) => + projectId === undefined || projectId === null || projectId === '' + ? undefined + : String(projectId); diff --git a/src/oauth/url.test.ts b/src/oauth/url.test.ts new file mode 100644 index 0000000..c329297 --- /dev/null +++ b/src/oauth/url.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeUrl, originOf, safeOrigin, sameOrigin } from './url'; + +describe('normalizeUrl', () => { + it('trims a single trailing slash and passes undefined through', () => { + expect(normalizeUrl('https://app.tolgee.io/')).toBe( + 'https://app.tolgee.io' + ); + expect(normalizeUrl('https://app.tolgee.io')).toBe('https://app.tolgee.io'); + expect(normalizeUrl(undefined)).toBeUndefined(); + }); +}); + +describe('originOf', () => { + it('returns the origin of a valid url', () => { + expect(originOf('https://app.tolgee.io/foo?x=1')).toBe( + 'https://app.tolgee.io' + ); + }); + + it('throws on a malformed url', () => { + expect(() => originOf('not a url')).toThrow(); + }); +}); + +describe('safeOrigin', () => { + it('returns the origin of a valid url', () => { + expect(safeOrigin('https://app.tolgee.io/x')).toBe('https://app.tolgee.io'); + }); + + it('returns undefined for undefined, null, or a malformed url', () => { + expect(safeOrigin(undefined)).toBeUndefined(); + expect(safeOrigin(null)).toBeUndefined(); + expect(safeOrigin('')).toBeUndefined(); + expect(safeOrigin('not a url')).toBeUndefined(); + }); +}); + +describe('sameOrigin', () => { + it('is true only for two parseable, equal origins', () => { + expect(sameOrigin('https://a.io/x', 'https://a.io/y')).toBe(true); + expect(sameOrigin('https://a.io', 'https://b.io')).toBe(false); + }); + + it('is false when either side is unparseable or absent (including both undefined)', () => { + expect(sameOrigin('https://a.io', 'nope')).toBe(false); + expect(sameOrigin(undefined, 'https://a.io')).toBe(false); + expect(sameOrigin(undefined, undefined)).toBe(false); + }); +}); diff --git a/src/oauth/url.ts b/src/oauth/url.ts new file mode 100644 index 0000000..f72f735 --- /dev/null +++ b/src/oauth/url.ts @@ -0,0 +1,23 @@ +export function normalizeUrl(url: string): string; +export function normalizeUrl(url: string | undefined): string | undefined; +export function normalizeUrl(url: string | undefined) { + return url?.replace(/\/$/, ''); +} + +export const originOf = (url: string): string => new URL(url).origin; + +export const safeOrigin = (url?: string | null): string | undefined => { + if (!url) { + return undefined; + } + try { + return new URL(url).origin; + } catch { + return undefined; + } +}; + +export const sameOrigin = (a?: string | null, b?: string | null): boolean => { + const oa = safeOrigin(a); + return oa !== undefined && oa === safeOrigin(b); +}; diff --git a/src/popup/TolgeeDetector.tsx b/src/popup/TolgeeDetector.tsx index 305fccf..1255a97 100644 --- a/src/popup/TolgeeDetector.tsx +++ b/src/popup/TolgeeDetector.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useState } from 'react'; -import browser from 'webextension-polyfill'; import { Alert, Autocomplete, @@ -17,9 +16,17 @@ import { } from '@mui/material'; import { useDetectorForm } from './useDetectorForm'; -import { decodeTokenProjectSet, isOAuth, validateValues } from './tools'; +import { + declaredProjectId, + decodeTokenProjectSet, + isOAuth, + validateValues, +} from './tools'; import { sendToBackground } from './sendToBackground'; -import { useApiKeyCheck } from './useApiKeyCheck'; +import { getActiveTab } from './activeTab'; +import { safeOrigin } from '../oauth/url'; +import { isApiKeyValid, useApiKeyCheck } from './useApiKeyCheck'; +import { isOAuthUser, isProjectInfo } from './reducer'; const POPUP_WIDTH = 400; const DEFAULT_SERVER = 'https://app.tolgee.io'; @@ -51,38 +58,25 @@ export const TolgeeDetector = () => { declaredProjectInaccessible, } = state; - const oauthUser = - credentialsCheck !== null && - typeof credentialsCheck === 'object' && - 'oauth' in credentialsCheck - ? credentialsCheck - : null; + const oauthUser = isOAuthUser(credentialsCheck) ? credentialsCheck : null; - // Live-validate the key being typed on the API KEY tab (before it's applied), so a key that's invalid for the target - // server can't be silently connected. - const notConnected = !storedValues && !appliedValues; + const hasSession = Boolean(storedValues || appliedValues); + const notConnected = !hasSession; const apiKeyCheck = useApiKeyCheck( values?.apiUrl, values?.apiKey, tab === 'apiKey' && notConnected ); - const apiKeyValid = - apiKeyCheck !== null && - typeof apiKeyCheck === 'object' && - 'projectName' in apiKeyCheck; - - // A single-project token auto-selects its project (done in the reducer); only an "all projects" token needs the - // manual picker below. + const apiKeyValid = isApiKeyValid(apiKeyCheck); const allProjectsToken = decodeTokenProjectSet(values?.authToken) === '*'; - // A restored API-key session should reopen on the API KEY tab; an OAuth session stays on LOGIN. useEffect(() => { if (values?.apiKey && !values?.authToken) { setTab('apiKey'); } }, [values?.apiKey, values?.authToken]); - const handleApplyChange = async () => { + const handleApplyChange = () => { if (appliedValues) { dispatch({ type: 'STORE_VALUES' }); } else { @@ -91,8 +85,12 @@ export const TolgeeDetector = () => { }; const handleKeyDown = (e: React.KeyboardEvent) => { - // on enter - if (e.keyCode === 13 && validateValues(values)) { + // On the API-key tab, Enter must also respect the live key check (like the field's own handler and the button). + if ( + e.key === 'Enter' && + validateValues(values) && + (tab !== 'apiKey' || apiKeyValid) + ) { dispatch({ type: 'APPLY_VALUES' }); } }; @@ -102,18 +100,10 @@ export const TolgeeDetector = () => { setConnecting(true); setConnectError(null); try { - // Hint the project the page is configured for (exposed via the handshake), so the consent screen pre-selects it - // and the minted token is scoped to it. On a public project the hint resolves via the community floor. - const hinted = (libConfig?.config as { projectId?: number | string }) - ?.projectId; - const projectId = - hinted !== undefined && hinted !== '' ? Number(hinted) : undefined; + const projectId = declaredProjectId(libConfig); // Capture the target tab now: launchWebAuthFlow closes the popup, so the background does the injection and needs // the tab id up front. - const [activeTab] = await browser.tabs.query({ - active: true, - currentWindow: true, - }); + const activeTab = await getActiveTab(); const res = (await sendToBackground('OAUTH_LOGIN', { apiUrl, projectId, @@ -125,7 +115,7 @@ export const TolgeeDetector = () => { if (res?.accessToken) { dispatch({ type: 'OAUTH_APPLY', - payload: { apiUrl, authToken: res.accessToken }, + payload: { apiUrl, authToken: res.accessToken, projectId }, }); } else { setConnectError(res?.error || 'Connection failed'); @@ -135,21 +125,18 @@ export const TolgeeDetector = () => { } }; - const dataPresent = storedValues || appliedValues; - - // Which credentials the session is actually built on, regardless of the Applied toggle: applied when live, otherwise - // the stored ones (an OAuth session's token is re-fetched into storedValues on load). const activeValues = appliedValues || storedValues || values; const isOauthSession = isOAuth(activeValues); - // OAuth Disconnect drops this project's local token (service worker + storage); the server keeps the consent, so - // reconnecting the same account skips the consent screen (by design). API-key Disconnect is just the old Clear. const handleDisconnect = async () => { const apiUrl = activeValues?.apiUrl; if (isOauthSession && apiUrl) { + const tab = await getActiveTab(); await sendToBackground('OAUTH_LOGOUT', { apiUrl, + authToken: activeValues?.authToken, projectId: activeValues?.projectId, + pageOrigin: safeOrigin(tab?.url), }); } dispatch({ type: 'CLEAR_ALL' }); @@ -174,9 +161,7 @@ export const TolgeeDetector = () => { ); - const branchField = credentialsCheck !== null && - typeof credentialsCheck === 'object' && - 'branchingEnabled' in credentialsCheck && + const branchField = isProjectInfo(credentialsCheck) && credentialsCheck.branchingEnabled && ( { libConfig?.config.apiUrl === values?.apiUrl && (libConfig?.config.branch || '') === (values?.branch || ''); - const detectedProjectId = ( - libConfig?.config as { projectId?: number | string } - )?.projectId; - const projectDetected = - detectedProjectId !== undefined && detectedProjectId !== ''; + const projectDetected = declaredProjectId(libConfig) !== undefined; - let serverHost = values?.apiUrl || DEFAULT_SERVER; + const rawServer = values?.apiUrl || DEFAULT_SERVER; + let serverHost = rawServer; // Restrict the link target to http(s): the Server field is editable, and a value like `javascript:...` would become // an executable link running with extension privileges. Fall back to the default when it isn't a valid web URL yet. let serverLink = DEFAULT_SERVER; try { - const parsed = new URL(values?.apiUrl || DEFAULT_SERVER); + const parsed = new URL(rawServer); serverHost = parsed.host; if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { serverLink = parsed.toString(); } } catch { - // keep the raw value if it's not a full URL yet + serverLink = DEFAULT_SERVER; } - - // Once a session exists (either auth method) the popup is a single status view — no tabs, no auth-key field. - if (dataPresent) { + if (hasSession) { return ( { ) : ( <> - {credentialsCheck !== null && - typeof credentialsCheck === 'object' && - 'projectName' in credentialsCheck ? ( + {isProjectInfo(credentialsCheck) ? ( {credentialsCheck.projectName} @@ -399,8 +377,6 @@ export const TolgeeDetector = () => { ); } - - // No session yet — let the user pick how to connect. return ( { payload: { apiKey: e.target.value }, }) } - onKeyDown={(e) => { - if ( - e.keyCode === 13 && - validateValues(values) && - apiKeyValid - ) { - dispatch({ type: 'APPLY_VALUES' }); - } - }} + onKeyDown={handleKeyDown} size="small" /> + (await browser.tabs.query({ active: true, currentWindow: true }))[0]; diff --git a/src/popup/reducer.test.ts b/src/popup/reducer.test.ts index dc805f6..17dded4 100644 --- a/src/popup/reducer.test.ts +++ b/src/popup/reducer.test.ts @@ -56,6 +56,24 @@ describe('detector reducer', () => { expect(next.tolgeePresent).toBe('not_present'); }); + it('preserves a restored OAuth session instead of overwriting it with the page config', () => { + const restored: State = { + ...initialState, + values: { apiUrl: 'https://app.tolgee.io', authToken: 'jwt' }, + }; + const next = reduce(restored, { + type: 'CHANGE_LIB_CONFIG', + payload: { + libData: lib({ + config: { apiUrl: 'https://x.io', apiKey: 'tgpak_x' } as any, + }), + frameId: 0, + }, + }); + expect(next.values?.authToken).toBe('jwt'); + expect(next.values?.apiKey).toBeUndefined(); + }); + it('errors when a second instance is detected in another frame', () => { const first = reduce(initialState, { type: 'CHANGE_LIB_CONFIG', @@ -88,6 +106,22 @@ describe('detector reducer', () => { expect(next.storedValues).toEqual(next.values); expect(apply).toHaveBeenCalledOnce(); }); + + it('prefers the projectId from the connect-flow payload over a previously picked one', () => { + const restored: State = { + ...initialState, + values: { apiUrl: 'https://app.tolgee.io', projectId: 42 }, + }; + const next = reduce(restored, { + type: 'OAUTH_APPLY', + payload: { + apiUrl: 'https://app.tolgee.io', + authToken: 'jwt', + projectId: 7, + }, + }); + expect(next.values?.projectId).toBe(7); + }); }); describe('OAUTH_SET_PROJECT', () => { @@ -173,6 +207,32 @@ describe('detector reducer', () => { }); }); + describe('STORE_VALUES / LOAD_VALUES restore roundtrip', () => { + const applied = { apiUrl: 'https://app.tolgee.io', apiKey: 'tgpak_x' }; + + it('STORE_VALUES promotes appliedValues to stored and clears applied', () => { + const next = reduce( + { ...initialState, appliedValues: applied }, + { type: 'STORE_VALUES' } + ); + expect(next.storedValues).toEqual(applied); + expect(next.values).toEqual(applied); + expect(next.appliedValues).toBeNull(); + expect(apply).toHaveBeenCalledOnce(); + }); + + it('LOAD_VALUES restores the stored session into values and applied', () => { + const next = reduce( + { ...initialState, storedValues: applied }, + { type: 'LOAD_VALUES' } + ); + expect(next.appliedValues).toEqual(applied); + expect(next.values).toEqual(applied); + expect(next.storedValues).toEqual(applied); + expect(apply).toHaveBeenCalledOnce(); + }); + }); + it('CHANGE_VALUES merges a partial patch', () => { const next = reduce( { ...initialState, values: { apiUrl: 'https://app.tolgee.io' } }, diff --git a/src/popup/reducer.ts b/src/popup/reducer.ts index ed97557..5274afe 100644 --- a/src/popup/reducer.ts +++ b/src/popup/reducer.ts @@ -20,6 +20,13 @@ export type CredentialsCheck = | 'invalid' | ProjectInfo | OAuthUser; + +export const isProjectInfo = (c: CredentialsCheck): c is ProjectInfo => + c !== null && typeof c === 'object' && 'projectName' in c; + +export const isOAuthUser = (c: CredentialsCheck): c is OAuthUser => + c !== null && typeof c === 'object' && 'oauth' in c; + export type TolgeePresent = 'loading' | 'present' | 'not_present' | 'legacy'; export type BranchOption = { @@ -63,7 +70,10 @@ export type Action = | { type: 'CLEAR_ALL' } | { type: 'STORE_VALUES' } | { type: 'LOAD_VALUES' } - | { type: 'OAUTH_APPLY'; payload: { apiUrl: string; authToken: string } } + | { + type: 'OAUTH_APPLY'; + payload: { apiUrl: string; authToken: string; projectId?: number }; + } | { type: 'OAUTH_SET_PROJECT'; payload: { projectId: number | undefined } } | { type: 'SET_BRANCHES'; payload: BranchOption[] | null } | { @@ -71,10 +81,6 @@ export type Action = payload: { project: ProjectOption | null; inaccessible: boolean }; }; -/** - * The reducer is a pure state transition, but a few actions also need to flag that the new state must be synced out to - * storage/sessionStorage. That side effect is injected as `apply` so the reducer stays testable without React. - */ export const createReducer = (apply: () => void) => (state: State, action: Action): State => { @@ -129,18 +135,13 @@ export const createReducer = values: action.payload, }; case 'APPLY_VALUES': { - // sync values with storage/localStorage apply(); const branchEnabled = - state.credentialsCheck !== null && - typeof state.credentialsCheck === 'object' && - 'branchingEnabled' in state.credentialsCheck && + isProjectInfo(state.credentialsCheck) && state.credentialsCheck.branchingEnabled; const effectiveBranch = branchEnabled ? state.values?.branch : undefined; - // Carry the OAuth fields through: this action also fires on the Login tab (Enter in the Server field), and - // dropping authToken/projectId there would wipe the token from state and remove the stored OAuth session. const nextValues = { apiKey: state.values?.apiKey, apiUrl: state.values?.apiUrl, @@ -168,11 +169,10 @@ export const createReducer = } case 'OAUTH_APPLY': { apply(); - // Keep any project the user already picked for this backend (restored from storage) across a re-connect. const oauthValues = { apiUrl: action.payload.apiUrl, authToken: action.payload.authToken, - projectId: state.values?.projectId, + projectId: action.payload.projectId ?? state.values?.projectId, }; return { ...state, @@ -205,8 +205,6 @@ export const createReducer = declaredProjectInaccessible: inaccessible, }; } - // Bind the declared project so in-context editing has a target: an all-projects token carries none, and even a - // single-project token needs the id sent explicitly on every request. apply(); const oauthValues = { ...state.values, projectId: project.id }; return { @@ -239,7 +237,7 @@ export const createReducer = branches: action.payload, }; default: - // @ts-expect-error action type is type uknown + // @ts-expect-error action type is unknown throw new Error(`Unknown action ${action.type}`); } }; diff --git a/src/popup/sendMessage.ts b/src/popup/sendMessage.ts index 7447a99..c66dfd4 100644 --- a/src/popup/sendMessage.ts +++ b/src/popup/sendMessage.ts @@ -1,11 +1,12 @@ import browser from 'webextension-polyfill'; +import { getActiveTab } from './activeTab'; export const sendMessage = async (type: string, data?: any) => { - const tabs = await browser.tabs.query({ active: true, currentWindow: true }); - const response = await browser.tabs.sendMessage(tabs[0].id as number, { - type, - data, - }); + const tab = await getActiveTab(); + if (tab?.id == null) { + throw new Error('No active tab to message'); + } + const response = await browser.tabs.sendMessage(tab.id, { type, data }); if (browser.runtime.lastError) { throw browser.runtime.lastError; diff --git a/src/popup/sendToBackground.ts b/src/popup/sendToBackground.ts index 55d9eb9..7ace3f1 100644 --- a/src/popup/sendToBackground.ts +++ b/src/popup/sendToBackground.ts @@ -1,6 +1,5 @@ import browser from 'webextension-polyfill'; -// Messages the service worker (OAuth login/refresh/logout), unlike sendMessage which targets the page content script. export const sendToBackground = async (type: string, data?: any) => { return browser.runtime.sendMessage({ type, data }); }; diff --git a/src/popup/storage.test.ts b/src/popup/storage.test.ts new file mode 100644 index 0000000..74bceec --- /dev/null +++ b/src/popup/storage.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const store = new Map(); +vi.mock('webextension-polyfill', () => ({ + default: { + tabs: { + query: async () => [{ url: 'https://site.example/page' }], + }, + storage: { + local: { + get: async (key: string) => + store.has(key) ? { [key]: store.get(key) } : {}, + set: async (obj: Record) => + Object.entries(obj).forEach(([k, v]) => store.set(k, v)), + remove: async (key: string) => { + store.delete(key); + }, + }, + }, + }, +})); + +vi.mock('../oauth/tokenScope', () => ({ + projectKeyForToken: () => '7', +})); + +import { loadValues, storeValues } from './storage'; + +const ORIGIN = 'https://site.example'; + +describe('popup storage routing', () => { + beforeEach(() => store.clear()); + + it('persists an OAuth session as a marker and never writes the access token', async () => { + await storeValues({ + apiUrl: 'https://app.tolgee.io', + authToken: 'jwt', + projectId: 7, + }); + const record = store.get(ORIGIN) as Record; + expect(record).toEqual({ + apiUrl: 'https://app.tolgee.io', + oauth: true, + projectId: 7, + projectKey: '7', + }); + expect(record.authToken).toBeUndefined(); + expect(record.apiKey).toBeUndefined(); + }); + + it('persists an api-key session as a plain record without the oauth flag', async () => { + await storeValues({ + apiUrl: 'https://app.tolgee.io', + apiKey: 'tgpak_x', + branch: 'feat', + }); + expect(store.get(ORIGIN)).toEqual({ + apiUrl: 'https://app.tolgee.io', + apiKey: 'tgpak_x', + branch: 'feat', + }); + }); + + it('removes the origin record when neither credential is present', async () => { + store.set(ORIGIN, { apiUrl: 'https://app.tolgee.io', apiKey: 'tgpak_x' }); + await storeValues(null); + expect(store.has(ORIGIN)).toBe(false); + }); + + it('an incomplete value (token without apiUrl) clears the record rather than half-persisting', async () => { + store.set(ORIGIN, { apiUrl: 'https://app.tolgee.io', apiKey: 'tgpak_x' }); + await storeValues({ authToken: 'jwt' }); + expect(store.has(ORIGIN)).toBe(false); + }); + + it('loadValues surfaces the stored fields (oauth flag included)', async () => { + store.set(ORIGIN, { + apiUrl: 'https://app.tolgee.io', + oauth: true, + projectId: 7, + }); + expect(await loadValues()).toEqual({ + apiKey: undefined, + apiUrl: 'https://app.tolgee.io', + branch: undefined, + oauth: true, + projectId: 7, + }); + }); +}); diff --git a/src/popup/storage.ts b/src/popup/storage.ts index 7c0b848..3255e29 100644 --- a/src/popup/storage.ts +++ b/src/popup/storage.ts @@ -1,43 +1,32 @@ import browser from 'webextension-polyfill'; +import { storeOAuthMarker } from '../oauth/marker'; +import { projectKeyForToken } from '../oauth/tokenScope'; +import { originOf } from '../oauth/url'; +import { getActiveTab } from './activeTab'; +import { Values } from './tools'; -type Values = { +// The per-origin record persisted in browser.storage.local — distinct from the live form `Values`: it carries the +// OAuth `oauth` marker flag and never the short-lived `authToken` (the token lives in the service worker's tokenStore). +type PersistedValues = { apiUrl?: string; apiKey?: string; branch?: string; - // OAuth sessions persist only a marker + backend url (+ the picked project) here; the token itself lives in the - // service worker's tokenStore (kept fresh via refresh) and is re-fetched on load, so a short-lived token is never - // stored stale. oauth?: boolean; projectId?: number; }; -const getCurrentTab = async () => { - const tabs = await browser.tabs.query({ active: true, currentWindow: true }); - - return tabs[0]; -}; - -const getCurrentTabOrigin = async () => { - const url = new URL((await getCurrentTab()).url!); - return url.origin; -}; - -export const storeValues = async ( - values: (Values & { authToken?: string }) | null -) => { +export const storeValues = async (values: Values | null) => { try { const origin = await getCurrentTabOrigin(); if (values?.authToken && values?.apiUrl) { - browser.storage.local.set({ - [origin]: { - apiUrl: values.apiUrl, - oauth: true, - projectId: values.projectId, - }, + await storeOAuthMarker(origin, { + apiUrl: values.apiUrl, + projectId: values.projectId, + projectKey: projectKeyForToken(values.authToken), }); } else if (values?.apiKey && values?.apiUrl) { - browser.storage.local.set({ + await browser.storage.local.set({ [origin]: { apiUrl: values.apiUrl, apiKey: values.apiKey, @@ -45,10 +34,10 @@ export const storeValues = async ( }, }); } else { - browser.storage.local.remove(origin); + await browser.storage.local.remove(origin); } } catch (e) { - console.error(e); + console.error('[tolgee] storage error', e); return; } }; @@ -57,7 +46,7 @@ export const loadValues = async () => { try { const origin = await getCurrentTabOrigin(); const keys = await browser.storage.local.get(origin); - const data = keys[origin] as Values; + const data = keys[origin] as PersistedValues; return { apiKey: data?.apiKey, @@ -67,7 +56,9 @@ export const loadValues = async () => { projectId: data?.projectId, }; } catch (e) { - console.error(e); + console.error('[tolgee] storage error', e); return {}; } }; + +const getCurrentTabOrigin = async () => originOf((await getActiveTab()).url!); diff --git a/src/popup/tools.test.ts b/src/popup/tools.test.ts index 701fc5a..489da52 100644 --- a/src/popup/tools.test.ts +++ b/src/popup/tools.test.ts @@ -1,11 +1,30 @@ import { describe, expect, it } from 'vitest'; import { compareValues, + declaredProjectId, decodeTokenProjectSet, isOAuth, normalizeUrl, validateValues, } from './tools'; +import { LibConfig } from '../types'; + +const withProjectId = (projectId: unknown) => + ({ config: { projectId } }) as unknown as LibConfig; + +describe('declaredProjectId', () => { + it('returns the numeric project id and coerces a string id', () => { + expect(declaredProjectId(withProjectId(7))).toBe(7); + expect(declaredProjectId(withProjectId('7'))).toBe(7); + }); + + it('is undefined for blank, absent, or non-numeric', () => { + expect(declaredProjectId(withProjectId(''))).toBeUndefined(); + expect(declaredProjectId(withProjectId(undefined))).toBeUndefined(); + expect(declaredProjectId(undefined)).toBeUndefined(); + expect(declaredProjectId(withProjectId('abc'))).toBeUndefined(); + }); +}); // Builds a JWT-shaped string (header.payload.signature) whose payload base64url-encodes the given claims, so we can // exercise the token parsing without a real signature. diff --git a/src/popup/tools.ts b/src/popup/tools.ts index 769e05d..c1d6835 100644 --- a/src/popup/tools.ts +++ b/src/popup/tools.ts @@ -1,13 +1,24 @@ +import { LibConfig } from '../types'; + export type Values = { apiUrl?: string; apiKey?: string; branch?: string; - // OAuth access token (from "Connect with Tolgee"); an alternative to apiKey authToken?: string; - // Project selected in the OAuth path; PAKs embed the project id, OAuth tokens don't, so the user picks one. projectId?: number; }; +export const declaredProjectId = ( + libConfig?: LibConfig | null +): number | undefined => { + const raw = libConfig?.config?.projectId; + if (raw === undefined || raw === '') { + return undefined; + } + const n = Number(raw); + return Number.isNaN(n) ? undefined : n; +}; + export const validateValues = (values?: Values | null) => { if ((values?.apiKey || values?.authToken) && values?.apiUrl) { return values; @@ -37,10 +48,5 @@ export const compareValues = ( ); }; -export function normalizeUrl(url: string | undefined) { - return url?.replace(/\/$/, ''); -} - -// The project-set decoder lives with the OAuth token store so the service worker can key sessions by scope; re-exported -// here for the popup, which reads it to drive the project picker. +export { normalizeUrl } from '../oauth/url'; export { decodeTokenProjectSet } from '../oauth/tokenScope'; diff --git a/src/popup/useApiKeyCheck.ts b/src/popup/useApiKeyCheck.ts index 36ab49e..d736e46 100644 --- a/src/popup/useApiKeyCheck.ts +++ b/src/popup/useApiKeyCheck.ts @@ -1,7 +1,6 @@ import { useEffect, useState } from 'react'; import { normalizeUrl } from './tools'; -// Result of live-validating a typed API key against its server, before it's applied. export type ApiKeyCheck = | null | 'loading' @@ -9,10 +8,11 @@ export type ApiKeyCheck = | 'unreachable' | { projectName: string }; -/** - * Validates the API key the user is typing against `/v2/api-keys/current` on the target server, so an invalid key - * (e.g. a cloud key pointed at a local backend) can't be silently applied. Debounced; only runs while `enabled`. - */ +export const isApiKeyValid = ( + check: ApiKeyCheck +): check is { projectName: string } => + check !== null && typeof check === 'object' && 'projectName' in check; + export const useApiKeyCheck = ( apiUrl: string | undefined, apiKey: string | undefined, @@ -30,8 +30,7 @@ export const useApiKeyCheck = ( let cancelled = false; setCheck('loading'); const timer = setTimeout(() => { - // Send the key in the header, not the query string, so it can't leak via URLs/history/logs and an `&`/`#` in it - // can't corrupt the request. + // Key goes in the header, never the query string, so it can't leak via URLs/history/logs. fetch(`${url}/v2/api-keys/current`, { headers: { 'X-API-Key': apiKey } }) .then((r) => { if (r.ok) { @@ -39,8 +38,6 @@ export const useApiKeyCheck = ( if (!cancelled) setCheck({ projectName: data.projectName }); }); } - // Only an auth/permission rejection means the key itself is wrong; any other status is a server problem, so - // don't tell the user a valid key is invalid just because the backend is down or misconfigured. if (!cancelled) { setCheck( [400, 401, 403].includes(r.status) ? 'invalid' : 'unreachable' @@ -48,7 +45,6 @@ export const useApiKeyCheck = ( } }) .catch(() => { - // Network failure, DNS, or a CORS block — the server couldn't be reached, which is not an invalid key. if (!cancelled) setCheck('unreachable'); }); }, 400); diff --git a/src/popup/useDetectorForm.tsx b/src/popup/useDetectorForm.tsx index 602c654..17940b7 100644 --- a/src/popup/useDetectorForm.tsx +++ b/src/popup/useDetectorForm.tsx @@ -1,12 +1,15 @@ /* eslint-disable react-hooks/exhaustive-deps */ import browser, { type Runtime } from 'webextension-polyfill'; import { useEffect, useReducer } from 'react'; +import { getActiveTab } from './activeTab'; import { loadAppliedValues } from './loadConfig'; import { sendMessage } from './sendMessage'; import { sendToBackground } from './sendToBackground'; import { loadValues, storeValues } from './storage'; +import { safeOrigin } from '../oauth/url'; import { compareValues, + declaredProjectId, isOAuth, normalizeUrl, validateValues, @@ -14,7 +17,13 @@ import { } from './tools'; import { useApplier } from './useApplier'; import { RuntimeMessage } from '../content/Messages'; -import { CredentialsCheck, createReducer, initialState } from './reducer'; +import { + CredentialsCheck, + createReducer, + initialState, + isOAuthUser, + isProjectInfo, +} from './reducer'; export const useDetectorForm = () => { const { applyRequired, apply } = useApplier(); @@ -34,7 +43,16 @@ export const useDetectorForm = () => { useEffect(() => { // sync applied values if (applyRequired) { - sendMessage('SET_CREDENTIALS', { ...appliedValues }); + // Stamp the connected page's origin so the content script delivers only to that frame, not a cross-origin iframe. + getActiveTab() + .then((tab) => + sendMessage('SET_CREDENTIALS', { + ...appliedValues, + pageOrigin: safeOrigin(tab?.url), + }) + ) + // The tab may be mid-reload (no content script to receive) right after connect; a failed delivery is harmless. + .catch(() => undefined); } }, [appliedValues]); @@ -66,15 +84,14 @@ export const useDetectorForm = () => { // after tolgee config is loaded // get applied values and stored values const onLibConfigChange = async () => { - const appliedValues = await loadAppliedValues(); - if (validateValues(appliedValues)) { - dispatch({ type: 'SET_APPLIED_VALUES', payload: appliedValues }); + const pageApplied = await loadAppliedValues(); + if (validateValues(pageApplied)) { + dispatch({ type: 'SET_APPLIED_VALUES', payload: pageApplied }); } const storedData = await loadValues(); if (storedData.oauth && storedData.apiUrl) { - // OAuth sessions store no token; ask the service worker for a fresh (auto-refreshed) one for this project (its - // own session if concrete, else the all-projects one). + // OAuth sessions store no token; ask the service worker for a fresh (auto-refreshed) one for this project. const res = (await sendToBackground('OAUTH_GET_TOKEN', { apiUrl: storedData.apiUrl, projectId: storedData.projectId, @@ -116,7 +133,7 @@ export const useDetectorForm = () => { return undefined; }; browser.runtime.onMessage.addListener(listener); - () => browser.runtime.onMessage.removeListener(listener); + return () => browser.runtime.onMessage.removeListener(listener); }, []); const setCredentialsCheck = (val: CredentialsCheck) => { @@ -126,7 +143,7 @@ export const useDetectorForm = () => { let checkableValues: Values | undefined | null; // we want to check validity of values, that are displayed and applied - const valuesToCompare = appliedValues || libConfig?.config; + const valuesToCompare = appliedValues || (libConfig?.config as Values); if (!storedValues || compareValues(valuesToCompare, storedValues)) { checkableValues = validateValues(valuesToCompare); } @@ -158,7 +175,6 @@ export const useDetectorForm = () => { !cancelled && setCredentialsCheck('invalid'); }); } else { - // Send the key in the header, not the query string, so it can't leak via URLs/history/logs. fetch(`${url}/v2/api-keys/current`, { headers: { 'X-API-Key': checkableValues!.apiKey! }, }) @@ -201,18 +217,14 @@ export const useDetectorForm = () => { let cancelled = false; const check = state.credentialsCheck; if ( - check !== null && - typeof check === 'object' && - 'branchingEnabled' in check && + isProjectInfo(check) && check.branchingEnabled && validateValues(checkableValues) ) { const url = normalizeUrl(checkableValues!.apiUrl); - fetch( - `${url}/v2/projects/${check.projectId}/branches?ak=${ - checkableValues!.apiKey - }&size=100` - ) + fetch(`${url}/v2/projects/${check.projectId}/branches?size=100`, { + headers: { 'X-API-Key': checkableValues!.apiKey! }, + }) .then((r) => { if (!r.ok) { throw new Error('Failed to load branches'); @@ -244,22 +256,14 @@ export const useDetectorForm = () => { }; }, [state.credentialsCheck]); - // The page declares which project it edits (required by the extension), but an OAuth token isn't inherently bound to - // it. Resolve that declared id against the connected server: bind it when the user can edit it there, or flag it - // inaccessible — otherwise the token stays unscoped and in-context editing fails with "project not selected". + // Bind the page's declared project to the OAuth token by resolving it against the connected server, or flag it + // inaccessible — else the token stays unscoped and in-context editing fails with "project not selected". + const declaredId = declaredProjectId(libConfig); useEffect(() => { let cancelled = false; const check = state.credentialsCheck; - const isOauthCheck = - check !== null && - typeof check === 'object' && - 'oauth' in check && - isOAuth(checkableValues); - const declaredId = Number( - (libConfig?.config as { projectId?: number | string } | undefined) - ?.projectId - ); - if (!isOauthCheck || !declaredId) { + const isOauthCheck = isOAuthUser(check) && isOAuth(checkableValues); + if (!isOauthCheck || declaredId === undefined) { dispatch({ type: 'RESOLVE_PROJECT', payload: { project: null, inaccessible: false }, @@ -298,7 +302,7 @@ export const useDetectorForm = () => { return () => { cancelled = true; }; - }, [state.credentialsCheck]); + }, [state.credentialsCheck, declaredId]); return [state, dispatch] as const; }; diff --git a/src/types.ts b/src/types.ts index 8cbfac0..14b0df0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,6 +6,7 @@ export type LibConfig = { apiUrl: ''; apiKey: ''; branch?: string; + projectId?: number | string; // @deprecated older versions mode?: 'production' | 'development'; }; diff --git a/vitest.config.ts b/vitest.config.ts index fc0cd24..8642f8e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,7 +4,7 @@ import { defineConfig } from 'vitest/config'; // neither the extension bundler plugins nor a DOM. export default defineConfig({ test: { - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.{ts,tsx}'], environment: 'node', }, }); From 5f8d9211c5e107ec389a0e59c80af0028ea261dd Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Tue, 18 Aug 2026 00:17:56 +0200 Subject: [PATCH 18/18] fix: stop OAuth "Applied" toggle from flickering and blocking the popup Switching the Applied toggle off on an OAuth session re-applied itself in a loop and could leave the popup stuck on "No access to this page": - RESOLVE_PROJECT re-applied the session unconditionally, overriding an explicit toggle-off; it now only writes appliedValues when the session is already applied, binding the project into the stored session otherwise. - Recognize a connected session from the stored session, not just the page's SDK config, so the connected state stays visible while Applied is off. - Retry DETECT_TOLGEE across the apply/un-apply page reload instead of latching onto the inaccessible-page error on the first miss. - Show the project as a read-only field for both API-key and OAuth sessions instead of an editable picker, dropping the now-dead OAUTH_SET_PROJECT action. --- src/popup/TolgeeDetector.tsx | 59 +++++++++-------------------------- src/popup/reducer.test.ts | 35 ++++++++++----------- src/popup/reducer.ts | 19 +++-------- src/popup/useDetectorForm.tsx | 31 ++++++++++++++---- 4 files changed, 60 insertions(+), 84 deletions(-) diff --git a/src/popup/TolgeeDetector.tsx b/src/popup/TolgeeDetector.tsx index 1255a97..d7da293 100644 --- a/src/popup/TolgeeDetector.tsx +++ b/src/popup/TolgeeDetector.tsx @@ -16,12 +16,7 @@ import { } from '@mui/material'; import { useDetectorForm } from './useDetectorForm'; -import { - declaredProjectId, - decodeTokenProjectSet, - isOAuth, - validateValues, -} from './tools'; +import { declaredProjectId, isOAuth, validateValues } from './tools'; import { sendToBackground } from './sendToBackground'; import { getActiveTab } from './activeTab'; import { safeOrigin } from '../oauth/url'; @@ -34,8 +29,6 @@ const LEARN_MORE_PROJECT_ID = 'https://docs.tolgee.io/js-sdk/api/core_package/options#projectid'; const API_KEY_HELP = 'https://docs.tolgee.io/platform/account_settings/api_keys_and_pat_tokens'; -// Sentinel option for an unscoped ("all projects") token; a negative id can't collide with a real project id. -const ALL_PROJECTS_OPTION = { id: -1, name: 'All projects' }; export const TolgeeDetector = () => { const [state, dispatch] = useDetectorForm(); @@ -68,7 +61,6 @@ export const TolgeeDetector = () => { tab === 'apiKey' && notConnected ); const apiKeyValid = isApiKeyValid(apiKeyCheck); - const allProjectsToken = decodeTokenProjectSet(values?.authToken) === '*'; useEffect(() => { if (values?.apiKey && !values?.authToken) { @@ -225,38 +217,17 @@ export const TolgeeDetector = () => { /> ); - const projectPicker = declaredProject && ( - option.name} - isOptionEqualToValue={(option, value) => option.id === value.id} - value={values?.projectId != null ? declaredProject : ALL_PROJECTS_OPTION} - onChange={(_e, newValue) => { - dispatch({ - type: 'OAUTH_SET_PROJECT', - payload: { - projectId: - newValue && newValue.id !== ALL_PROJECTS_OPTION.id - ? newValue.id - : undefined, - }, - }); - }} - renderInput={(params) => ( - - )} - /> + // The project is fixed by the credentials, so show it as a read-only field (same for API-key and OAuth sessions). + const projectField = (name: string) => ( + + + ); const footer = ( @@ -355,15 +326,13 @@ export const TolgeeDetector = () => { for access. ) : ( - projectPicker + declaredProject && projectField(declaredProject.name) )} ) : ( <> {isProjectInfo(credentialsCheck) ? ( - - {credentialsCheck.projectName} - + projectField(credentialsCheck.projectName) ) : credentialsCheck === 'invalid' ? ( Invalid API key diff --git a/src/popup/reducer.test.ts b/src/popup/reducer.test.ts index 17dded4..8e1a505 100644 --- a/src/popup/reducer.test.ts +++ b/src/popup/reducer.test.ts @@ -124,23 +124,6 @@ describe('detector reducer', () => { }); }); - describe('OAUTH_SET_PROJECT', () => { - it('sets the project across values/applied/stored', () => { - const connected: State = { - ...initialState, - values: { apiUrl: 'https://app.tolgee.io', authToken: 'jwt' }, - }; - const next = reduce(connected, { - type: 'OAUTH_SET_PROJECT', - payload: { projectId: 7 }, - }); - expect(next.values?.projectId).toBe(7); - expect(next.appliedValues?.projectId).toBe(7); - expect(next.storedValues?.projectId).toBe(7); - expect(apply).toHaveBeenCalledOnce(); - }); - }); - describe('APPLY_VALUES', () => { const withBranch: State = { ...initialState, @@ -251,7 +234,7 @@ describe('detector reducer', () => { values: { apiUrl: 'https://app.tolgee.io', authToken: 'jwt' }, }; - it('binds the resolved declared project and injects its id', () => { + it('binds the resolved declared project into the stored session without applying it while switched off', () => { const next = reduce(connected, { type: 'RESOLVE_PROJECT', payload: { project: { id: 7, name: 'Demo' }, inaccessible: false }, @@ -259,7 +242,23 @@ describe('detector reducer', () => { expect(next.declaredProject).toEqual({ id: 7, name: 'Demo' }); expect(next.declaredProjectInaccessible).toBe(false); expect(next.values?.projectId).toBe(7); + expect(next.storedValues?.projectId).toBe(7); + // Applied toggle is off (no appliedValues): binding must not silently re-apply the session. + expect(next.appliedValues).toBeNull(); + expect(apply).toHaveBeenCalledOnce(); + }); + + it('re-applies the bound project when the session is currently applied', () => { + const applied: State = { + ...connected, + appliedValues: { apiUrl: 'https://app.tolgee.io', authToken: 'jwt' }, + }; + const next = reduce(applied, { + type: 'RESOLVE_PROJECT', + payload: { project: { id: 7, name: 'Demo' }, inaccessible: false }, + }); expect(next.appliedValues?.projectId).toBe(7); + expect(next.storedValues?.projectId).toBe(7); expect(apply).toHaveBeenCalledOnce(); }); diff --git a/src/popup/reducer.ts b/src/popup/reducer.ts index 5274afe..63b4450 100644 --- a/src/popup/reducer.ts +++ b/src/popup/reducer.ts @@ -74,7 +74,6 @@ export type Action = type: 'OAUTH_APPLY'; payload: { apiUrl: string; authToken: string; projectId?: number }; } - | { type: 'OAUTH_SET_PROJECT'; payload: { projectId: number | undefined } } | { type: 'SET_BRANCHES'; payload: BranchOption[] | null } | { type: 'RESOLVE_PROJECT'; @@ -183,19 +182,6 @@ export const createReducer = declaredProjectInaccessible: false, }; } - case 'OAUTH_SET_PROJECT': { - apply(); - const oauthValues = { - ...state.values, - projectId: action.payload.projectId, - }; - return { - ...state, - values: oauthValues, - appliedValues: oauthValues, - storedValues: oauthValues, - }; - } case 'RESOLVE_PROJECT': { const { project, inaccessible } = action.payload; if (!project) { @@ -207,12 +193,15 @@ export const createReducer = } apply(); const oauthValues = { ...state.values, projectId: project.id }; + // Respect the Applied toggle: binding the page's project must update the (stored) session, but must NOT + // re-apply a session the user switched off — otherwise the auto-resolve fights the toggle and the popup flickers. + const isApplied = state.appliedValues != null; return { ...state, declaredProject: project, declaredProjectInaccessible: false, values: oauthValues, - appliedValues: oauthValues, + appliedValues: isApplied ? oauthValues : state.appliedValues, storedValues: oauthValues, }; } diff --git a/src/popup/useDetectorForm.tsx b/src/popup/useDetectorForm.tsx index 17940b7..e4ca433 100644 --- a/src/popup/useDetectorForm.tsx +++ b/src/popup/useDetectorForm.tsx @@ -57,12 +57,28 @@ export const useDetectorForm = () => { }, [appliedValues]); useEffect(() => { - sendMessage('DETECT_TOLGEE').catch(() => { - dispatch({ - type: 'SET_ERROR', - payload: 'No access to this page, try to refresh', + let cancelled = false; + // Applying/un-applying reloads the page, so the content script is briefly gone. Retry before declaring the page + // inaccessible, otherwise opening the popup mid-reload sticks on the error screen with no recovery. + const detect = (attemptsLeft: number) => { + sendMessage('DETECT_TOLGEE').catch(() => { + if (cancelled) { + return; + } + if (attemptsLeft > 0) { + setTimeout(() => detect(attemptsLeft - 1), 250); + return; + } + dispatch({ + type: 'SET_ERROR', + payload: 'No access to this page, try to refresh', + }); }); - }); + }; + detect(16); + return () => { + cancelled = true; + }; }, []); // timeout when Tolgee is not detected @@ -143,7 +159,10 @@ export const useDetectorForm = () => { let checkableValues: Values | undefined | null; // we want to check validity of values, that are displayed and applied - const valuesToCompare = appliedValues || (libConfig?.config as Values); + // Fall back to the stored session (not just the page's SDK config) so a connected session stays recognized while the + // Applied toggle is off — the page config carries no OAuth token, which would otherwise blank the connected state. + const valuesToCompare = + appliedValues || storedValues || (libConfig?.config as Values); if (!storedValues || compareValues(valuesToCompare, storedValues)) { checkableValues = validateValues(valuesToCompare); }