Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions src/util/awclient.ts
Original file line number Diff line number Diff line change
@@ -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<Location, 'search'>): 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();
}
Comment thread
TimeToBuildBob marked this conversation as resolved.

export function applyApiToken(client: { req: AxiosInstance }, token: string | null): void {
const defaults = client.req.defaults;
const headers = (defaults.headers as Record<string, Record<string, unknown>>) || {};
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 = '';

Expand All @@ -21,6 +107,7 @@ export function createClient(force?: boolean): AWClient {
testing: !production,
baseURL,
});
applyApiToken(_client, loadApiTokenFromBrowser());
} else {
throw 'Tried to instantiate global AWClient twice!';
}
Expand Down
63 changes: 63 additions & 0 deletions test/unit/awclient.test.js
Original file line number Diff line number Diff line change
@@ -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({}, '', '/');
});
Comment thread
TimeToBuildBob marked this conversation as resolved.

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('');
});
});
Loading