From 85c7be95db223eedb94b5221ee9cb99334a87575 Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 17 Apr 2026 17:46:59 +0000 Subject: [PATCH 1/2] feat(auth): bootstrap API token in aw-webui --- src/util/awclient.ts | 81 ++++++++++++++++++++++++++++++++++++++ test/unit/awclient.test.js | 66 +++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 test/unit/awclient.test.js diff --git a/src/util/awclient.ts b/src/util/awclient.ts index 053ecbd5..2ce184bb 100644 --- a/src/util/awclient.ts +++ b/src/util/awclient.ts @@ -2,8 +2,88 @@ import { AWClient } from 'aw-client'; import { useSettingsStore } from '~/stores/settings'; +const API_TOKEN_QUERY_PARAM = 'token'; +const API_TOKEN_STORAGE_KEY = 'aw-api-token'; + let _client: AWClient | null; +function normalizeToken(token: string | null): string | null { + if (!token) { + return null; + } + + const trimmed = token.trim(); + return trimmed ? trimmed : null; +} + +function getSessionStorage(): Storage | null { + if (typeof sessionStorage === 'undefined') { + return null; + } + + return sessionStorage; +} + +export function getStoredApiToken(): string | null { + return normalizeToken(getSessionStorage()?.getItem(API_TOKEN_STORAGE_KEY) ?? null); +} + +export function getApiTokenFromLocation(currentLocation: Pick): string | null { + if (typeof URLSearchParams === 'undefined') { + return null; + } + + return normalizeToken(new URLSearchParams(currentLocation.search).get(API_TOKEN_QUERY_PARAM)); +} + +function persistApiToken(token: string): void { + getSessionStorage()?.setItem(API_TOKEN_STORAGE_KEY, token); +} + +export function stripApiTokenFromCurrentUrl(): void { + if (typeof window === 'undefined' || typeof history === 'undefined') { + return; + } + + const url = new URL(window.location.href); + if (!url.searchParams.has(API_TOKEN_QUERY_PARAM)) { + return; + } + + url.searchParams.delete(API_TOKEN_QUERY_PARAM); + const nextUrl = `${url.pathname}${url.search}${url.hash}`; + window.history.replaceState(window.history.state, '', nextUrl || '/'); +} + +export function loadApiTokenFromBrowser(): string | null { + if (typeof window === 'undefined') { + return null; + } + + const urlToken = getApiTokenFromLocation(window.location); + if (urlToken) { + persistApiToken(urlToken); + stripApiTokenFromCurrentUrl(); + return urlToken; + } + + return getStoredApiToken(); +} + +export function applyApiToken(client: { req: { defaults: any } }, token: string | null): void { + const headers = client.req.defaults.headers || {}; + const commonHeaders = headers.common || {}; + + if (token) { + commonHeaders.Authorization = `Bearer ${token}`; + } else { + delete commonHeaders.Authorization; + } + + headers.common = commonHeaders; + client.req.defaults.headers = headers; +} + export function createClient(force?: boolean): AWClient { let baseURL = ''; @@ -21,6 +101,7 @@ export function createClient(force?: boolean): AWClient { testing: !production, baseURL, }); + applyApiToken(_client, loadApiTokenFromBrowser()); } else { throw 'Tried to instantiate global AWClient twice!'; } diff --git a/test/unit/awclient.test.js b/test/unit/awclient.test.js new file mode 100644 index 00000000..a3ac9772 --- /dev/null +++ b/test/unit/awclient.test.js @@ -0,0 +1,66 @@ +jest.mock('aw-client', () => ({ + AWClient: jest.fn().mockImplementation(() => ({ + req: { + defaults: { + headers: { + common: {}, + }, + }, + }, + })), +})); + +import { AWClient } from 'aw-client'; + +import { + createClient, + getApiTokenFromLocation, + getStoredApiToken, + loadApiTokenFromBrowser, +} from '~/util/awclient'; + +describe('awclient auth bootstrap', () => { + beforeEach(() => { + AWClient.mockClear(); + sessionStorage.clear(); + window.history.replaceState({}, '', '/'); + }); + + test('reads token from the URL query string', () => { + expect(getApiTokenFromLocation({ search: '?token=secret&foo=bar' })).toBe('secret'); + expect(getApiTokenFromLocation({ search: '?token= ' })).toBeNull(); + expect(getApiTokenFromLocation({ search: '?foo=bar' })).toBeNull(); + }); + + test('loads token from URL, stores it for the tab, and strips it from the address bar', () => { + window.history.replaceState({}, '', '/settings?token=secret&foo=bar#hash'); + + expect(loadApiTokenFromBrowser()).toBe('secret'); + expect(getStoredApiToken()).toBe('secret'); + expect(window.location.pathname).toBe('/settings'); + expect(window.location.search).toBe('?foo=bar'); + expect(window.location.hash).toBe('#hash'); + }); + + test('falls back to the stored token when the URL has none', () => { + sessionStorage.setItem('aw-api-token', 'stored-secret'); + + expect(loadApiTokenFromBrowser()).toBe('stored-secret'); + }); + + test('createClient applies the Bearer token to default request headers', () => { + window.history.replaceState({}, '', '/?token=secret'); + + const client = createClient(true); + + expect(AWClient).toHaveBeenCalledWith( + 'aw-webui', + expect.objectContaining({ + baseURL: 'http://127.0.0.1:5666', + testing: true, + }) + ); + expect(client.req.defaults.headers.common.Authorization).toBe('Bearer secret'); + expect(window.location.search).toBe(''); + }); +}); From c00a46f9ea4721e06ef87688fa93f7bc18d8067d Mon Sep 17 00:00:00 2001 From: Bob Date: Fri, 17 Apr 2026 18:07:13 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(auth):=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20type=20safety,=20security=20doc,=20test=20isolation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace `defaults: any` with `{ req: AxiosInstance }` for proper type checking - Document token URL exposure window with mitigation guidance - Use jest.resetModules() to reset module-level _client between tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/util/awclient.ts | 12 +++++++++--- test/unit/awclient.test.js | 19 ++++++++----------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/util/awclient.ts b/src/util/awclient.ts index 2ce184bb..666534df 100644 --- a/src/util/awclient.ts +++ b/src/util/awclient.ts @@ -1,4 +1,5 @@ import { AWClient } from 'aw-client'; +import type { AxiosInstance } from 'axios'; import { useSettingsStore } from '~/stores/settings'; @@ -55,6 +56,10 @@ export function stripApiTokenFromCurrentUrl(): void { window.history.replaceState(window.history.state, '', nextUrl || '/'); } +// NOTE: The token is visible in window.location.href from page load until this +// function executes. In the intended WebView/launcher environment no third-party +// scripts run, so the exposure window is acceptable. For general browser use, +// consider passing the credential via the URL fragment instead of a query param. export function loadApiTokenFromBrowser(): string | null { if (typeof window === 'undefined') { return null; @@ -70,8 +75,9 @@ export function loadApiTokenFromBrowser(): string | null { return getStoredApiToken(); } -export function applyApiToken(client: { req: { defaults: any } }, token: string | null): void { - const headers = client.req.defaults.headers || {}; +export function applyApiToken(client: { req: AxiosInstance }, token: string | null): void { + const defaults = client.req.defaults; + const headers = (defaults.headers as Record>) || {}; const commonHeaders = headers.common || {}; if (token) { @@ -81,7 +87,7 @@ export function applyApiToken(client: { req: { defaults: any } }, token: string } headers.common = commonHeaders; - client.req.defaults.headers = headers; + defaults.headers = headers as typeof defaults.headers; } export function createClient(force?: boolean): AWClient { diff --git a/test/unit/awclient.test.js b/test/unit/awclient.test.js index a3ac9772..8376fbf2 100644 --- a/test/unit/awclient.test.js +++ b/test/unit/awclient.test.js @@ -10,18 +10,15 @@ jest.mock('aw-client', () => ({ })), })); -import { AWClient } from 'aw-client'; - -import { - createClient, - getApiTokenFromLocation, - getStoredApiToken, - loadApiTokenFromBrowser, -} from '~/util/awclient'; - describe('awclient auth bootstrap', () => { - beforeEach(() => { - AWClient.mockClear(); + let AWClient; + let createClient, getApiTokenFromLocation, getStoredApiToken, loadApiTokenFromBrowser; + + beforeEach(async () => { + jest.resetModules(); + ({ AWClient } = await import('aw-client')); + ({ createClient, getApiTokenFromLocation, getStoredApiToken, loadApiTokenFromBrowser } = + await import('~/util/awclient')); sessionStorage.clear(); window.history.replaceState({}, '', '/'); });