diff --git a/src/util/awclient.ts b/src/util/awclient.ts index 053ecbd5..666534df 100644 --- a/src/util/awclient.ts +++ b/src/util/awclient.ts @@ -1,9 +1,95 @@ import { AWClient } from 'aw-client'; +import type { AxiosInstance } from 'axios'; 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 || '/'); +} + +// 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; + } + + const urlToken = getApiTokenFromLocation(window.location); + if (urlToken) { + persistApiToken(urlToken); + stripApiTokenFromCurrentUrl(); + return urlToken; + } + + return getStoredApiToken(); +} + +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) { + commonHeaders.Authorization = `Bearer ${token}`; + } else { + delete commonHeaders.Authorization; + } + + headers.common = commonHeaders; + defaults.headers = headers as typeof defaults.headers; +} + export function createClient(force?: boolean): AWClient { let baseURL = ''; @@ -21,6 +107,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..8376fbf2 --- /dev/null +++ b/test/unit/awclient.test.js @@ -0,0 +1,63 @@ +jest.mock('aw-client', () => ({ + AWClient: jest.fn().mockImplementation(() => ({ + req: { + defaults: { + headers: { + common: {}, + }, + }, + }, + })), +})); + +describe('awclient auth bootstrap', () => { + 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({}, '', '/'); + }); + + 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(''); + }); +});