From fd6c9099b127a7c299eb43a2344c35c20ea90edb Mon Sep 17 00:00:00 2001 From: whiteOvis <267137073+whiteOvis@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:50:17 -0700 Subject: [PATCH] Authenticate local HTTP clients independently of YouVersion sessions --- README.md | 2 +- components/LightApi.qml | 12 ++++- service/src/daemon.js | 13 ++++++ service/src/http-server.js | 9 ++++ service/test/account-gate.test.js | 3 +- service/test/client-auth.test.js | 70 +++++++++++++++++++++++++++++ service/test/compatibility.test.js | 7 +-- service/test/helpers/http-client.js | 6 +++ service/test/search.test.js | 3 +- service/test/service.test.js | 3 +- service/test/study-data.test.js | 3 +- 11 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 service/test/client-auth.test.js create mode 100644 service/test/helpers/http-client.js diff --git a/README.md b/README.md index 99cdd17..9fd4d62 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Bible reading requires a YouVersion account and access to the YouVersion Platfor 2. Open Light from the top bar and sign in with YouVersion. 3. Choose a Bible version and start reading. -The installer enables the widget and starts `omarchy-light-public.service` on port 8788. Light stores its data in `~/.config/omarchy/light-public/` and preserves existing LHT services and shortcuts. If a suggested shortcut is occupied, choose another in Settings. +The installer enables the widget and starts `omarchy-light-public.service` on loopback port 8788. Every HTTP endpoint requires a random local client credential, rotated on daemon startup and stored in the owner-only `~/.config/omarchy/light-public/client-auth-header` file (0600 inside a 0700 directory). The widget reads this file directly; other local users cannot access the service using the daemon owner’s YouVersion session. If you override `LIGHT_CONFIG_DIR`, set the same value for the widget process. Light stores its data in `~/.config/omarchy/light-public/` and preserves existing LHT services and shortcuts. If a suggested shortcut is occupied, choose another in Settings. ### Install from the Omarchy plugin page diff --git a/components/LightApi.qml b/components/LightApi.qml index 6f85e71..186d8cd 100644 --- a/components/LightApi.qml +++ b/components/LightApi.qml @@ -1,4 +1,5 @@ import QtQuick +import Quickshell import Quickshell.Io import "I18n.js" as I18n @@ -52,10 +53,19 @@ Item { responseText = "" errorText = "" + // Never send the local credential to a configurable remote service. + if (!/^http:\/\/(127\.0\.0\.1|localhost|\[::1\])(?::[0-9]+)?\/?$/.test(baseUrl)) { + errorText = "Light requires a loopback service URL." + finish(-1) + return + } + var configDir = Quickshell.env("LIGHT_CONFIG_DIR") + || ((Quickshell.env("XDG_CONFIG_HOME") || (Quickshell.env("HOME") + "/.config")) + "/omarchy/light-public") var command = [ "curl", "-sS", "--max-time", "30", "--fail-with-body", "-w", "\n%{http_code}", "-X", activeRequest.method, - "-H", "Accept: application/json" + "-H", "Accept: application/json", + "-H", "@" + configDir + "/client-auth-header" ] if (activeRequest.body !== null) { command = command.concat([ diff --git a/service/src/daemon.js b/service/src/daemon.js index 102d378..fc87a89 100755 --- a/service/src/daemon.js +++ b/service/src/daemon.js @@ -1,5 +1,9 @@ #!/usr/bin/env node +import { randomBytes } from 'node:crypto'; +import { writeFileSync, renameSync, chmodSync } from 'node:fs'; +import { join } from 'node:path'; + import { stdin, stdout } from 'node:process'; import { AuthenticationManager } from './auth.js'; @@ -250,7 +254,16 @@ async function serve({ }); } const port = portNumber(process.env.LIGHT_PORT || 8788); + // Rotate on each daemon start. The widget reads this owner-only header file + // directly with curl, keeping the credential out of process arguments. + const clientToken = randomBytes(32).toString('hex'); + const headerPath = join(getConfigDir(), 'client-auth-header'); + const temporaryPath = `${headerPath}.${process.pid}.tmp`; + writeFileSync(temporaryPath, `Authorization: Bearer ${clientToken}\n`, { mode: 0o600, flag: 'wx' }); + renameSync(temporaryPath, headerPath); + chmodSync(headerPath, 0o600); const server = createHttpServer({ + clientToken, service, cache, authentication, diff --git a/service/src/http-server.js b/service/src/http-server.js index ad48f95..013936a 100644 --- a/service/src/http-server.js +++ b/service/src/http-server.js @@ -1,4 +1,5 @@ import { createServer } from 'node:http'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; import { StudyData } from './study-data.js'; import { setLightGlobalHotkey, setLightVerseOfTheDayHotkey } from './global-hotkey.js'; @@ -20,6 +21,7 @@ export function createHttpServer({ applyVerseOfTheDayHotkey = setLightVerseOfTheDayHotkey, listSystemBindings = activeSystemBindings, requireAccount = true, + clientToken = randomBytes(32).toString('hex'), }) { const study = userData?.database ? new StudyData(userData) : null; return createServer(async (request, response) => { @@ -37,6 +39,13 @@ export function createHttpServer({ if (request.headers.origin !== undefined || request.headers['sec-fetch-site'] !== undefined) { throw new ServiceError('Browser requests are not allowed.', { code: 'FORBIDDEN', status: 403 }); } + const credential = Buffer.from(request.headers.authorization || ''); + const expected = Buffer.from(`Bearer ${clientToken}`); + if (credential.length !== expected.length || !timingSafeEqual(credential, expected)) { + throw new ServiceError('Local client authentication required.', { + code: 'CLIENT_AUTHENTICATION_REQUIRED', status: 401, + }); + } const url = new URL(request.url, 'http://localhost'); const query = url.searchParams; const route = `${request.method} ${url.pathname}`; diff --git a/service/test/account-gate.test.js b/service/test/account-gate.test.js index 34e7628..c8ede62 100644 --- a/service/test/account-gate.test.js +++ b/service/test/account-gate.test.js @@ -1,3 +1,4 @@ +import { authenticatedFetch as fetch } from './helpers/http-client.js'; import assert from 'node:assert/strict'; import test from 'node:test'; @@ -16,7 +17,7 @@ test('Bible catalog, content, downloads, and study require an active YouVersion return 'test-access-token'; }, }; - const server = createHttpServer({ + const server = createHttpServer({ clientToken: 'test-client', authentication, oauth: { status: () => ({ pending: false }) }, cache: { stats: () => ({ rows: 1 }) }, diff --git a/service/test/client-auth.test.js b/service/test/client-auth.test.js new file mode 100644 index 0000000..200d9d7 --- /dev/null +++ b/service/test/client-auth.test.js @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createServer } from 'node:net'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, readFileSync, statSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createHttpServer } from '../src/http-server.js'; + +test('local client authentication precedes account access on every endpoint', async (context) => { + let accountChecks = 0; + const server = createHttpServer({ + clientToken: 'private-test-client', + authentication: { getAccessToken: async () => { accountChecks++; return 'owner-account'; } }, + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + context.after(() => new Promise(resolve => server.close(resolve))); + const base = `http://127.0.0.1:${server.address().port}`; + for (const path of ['/health', '/v1/auth/status', '/v1/auth/logout', '/v1/study/backup', '/v1/study/restore', '/v1/user-data/notes']) { + for (const authorization of [undefined, 'Bearer incorrect', 'Bearer '+ 'x'.repeat(19)]) { + const response = await fetch(base + path, { + method: path.endsWith('logout') || path.endsWith('restore') ? 'POST' : 'GET', + headers: authorization ? { authorization } : {}, + }); + assert.equal(response.status, 401, path); + assert.equal((await response.json()).error.code, 'CLIENT_AUTHENTICATION_REQUIRED'); + } + } + assert.equal(accountChecks, 0); + const response = await fetch(base + '/v1/unknown', { headers: { authorization: 'Bearer private-test-client' } }); + assert.equal(response.status, 404); + assert.equal(accountChecks, 1); +}); + +test('daemon rotates its client credential and stores it with owner-only permissions', async () => { + const directory = mkdtempSync(join(tmpdir(), 'light-client-auth-')); + async function start() { + const probe = createServer(); + await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve)); + const port = probe.address().port; + await new Promise(resolve => probe.close(resolve)); + const child = spawn(process.execPath, ['src/daemon.js', 'serve'], { + cwd: new URL('..', import.meta.url), + env: { ...process.env, LIGHT_CONFIG_DIR: directory, LIGHT_PORT: String(port) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + try { + await new Promise((resolve, reject) => { + let errors = ''; + child.stderr.on('data', chunk => { errors += chunk; }); + child.once('error', reject); + child.once('exit', code => reject(new Error(`Daemon exited ${code}: ${errors}`))); + child.stdout.on('data', chunk => { if (String(chunk).includes('listening')) resolve(); }); + }); + const header = readFileSync(join(directory, 'client-auth-header'), 'utf8'); + assert.match(header, /^Authorization: Bearer [a-f0-9]{64}\n$/); + assert.equal(statSync(directory).mode & 0o777, 0o700); + assert.equal(statSync(join(directory, 'client-auth-header')).mode & 0o777, 0o600); + return header; + } finally { + if (child.exitCode === null) { + const closed = new Promise(resolve => child.once('close', resolve)); + child.kill('SIGTERM'); + await closed; + } + } + } + try { assert.notEqual(await start(), await start()); } + finally { rmSync(directory, { recursive: true, force: true }); } +}); diff --git a/service/test/compatibility.test.js b/service/test/compatibility.test.js index 5412c2d..f5fe9cb 100644 --- a/service/test/compatibility.test.js +++ b/service/test/compatibility.test.js @@ -1,3 +1,4 @@ +import { authenticatedFetch as fetch } from './helpers/http-client.js'; import assert from 'node:assert/strict'; import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -204,7 +205,7 @@ test('HTTP rejects web origins and rebinding hosts; failed global updates do not let mutations = 0; let globalChanges = 0; let verseOfTheDayChanges = 0; - const server = createHttpServer({ + const server = createHttpServer({ clientToken: 'test-client', requireAccount: false, userData: data, listSystemBindings: () => [], @@ -217,7 +218,7 @@ test('HTTP rejects web origins and rebinding hosts; failed global updates do not const base = `http://127.0.0.1:${server.address().port}`; for (const headers of [{ Origin: 'https://example.com' }, { Origin: 'null' }, { Host: 'rebind.example.com' }, { 'Sec-Fetch-Site': 'cross-site' }]) { const status = await new Promise((resolve, reject) => { - const req = request(`${base}/v1/downloads/111/resume`, { method: 'POST', headers }, (response) => { + const req = request(`${base}/v1/downloads/111/resume`, { method: 'POST', headers: { ...headers, authorization: 'Bearer test-client' } }, (response) => { response.resume(); response.on('end', () => resolve(response.statusCode)); }); @@ -260,7 +261,7 @@ test('HTTP rejects web origins and rebinding hosts; failed global updates do not test('HTTP saves and orders radio collections beyond former count and body limits', async (context) => { const cache = new SQLiteCache({ directory: mkdtempSync(join(tmpdir(), 'light-radio-capacity-')) }); const data = new UserDataManager({ database: cache.database, authentication: {} }); - const server = createHttpServer({ userData: data, requireAccount: false }); + const server = createHttpServer({ clientToken: 'test-client', userData: data, requireAccount: false }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); context.after(async () => { await new Promise((resolve) => server.close(resolve)); diff --git a/service/test/helpers/http-client.js b/service/test/helpers/http-client.js new file mode 100644 index 0000000..cd40691 --- /dev/null +++ b/service/test/helpers/http-client.js @@ -0,0 +1,6 @@ +export function authenticatedFetch(url, options = {}) { + return globalThis.fetch(url, { + ...options, + headers: { authorization: 'Bearer test-client', ...options.headers }, + }); +} diff --git a/service/test/search.test.js b/service/test/search.test.js index be4b80b..090e2bd 100644 --- a/service/test/search.test.js +++ b/service/test/search.test.js @@ -1,3 +1,4 @@ +import { authenticatedFetch as fetch } from './helpers/http-client.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import { DatabaseSync } from 'node:sqlite'; @@ -53,7 +54,7 @@ test('online search uses documented endpoint, selected Bible and opaque paginati test('HTTP search routes phrases and rejects malformed queries', async (context) => { const { createHttpServer } = await import('../src/http-server.js'); - const server = createHttpServer({ service: new LightService({ + const server = createHttpServer({ clientToken: 'test-client', service: new LightService({ bibleClient: { searchVerses: async () => ({ verses: [{ reference: 'JHN.3.16' }] }) }, }), requireAccount: false }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); diff --git a/service/test/service.test.js b/service/test/service.test.js index bbc85aa..63433c1 100644 --- a/service/test/service.test.js +++ b/service/test/service.test.js @@ -1,3 +1,4 @@ +import { authenticatedFetch as fetch } from './helpers/http-client.js'; import assert from 'node:assert/strict'; import { mkdtempSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -942,7 +943,7 @@ test('paused downloads resume with their saved options and retain completed chap test('HTTP resume endpoint queues a paused download', async (context) => { let resumedVersion = null; - const server = createHttpServer({ + const server = createHttpServer({ clientToken: 'test-client', requireAccount: false, service: { onlineConfigured: true }, cache: { stats() { return {}; } }, diff --git a/service/test/study-data.test.js b/service/test/study-data.test.js index f712569..2ba622e 100644 --- a/service/test/study-data.test.js +++ b/service/test/study-data.test.js @@ -1,3 +1,4 @@ +import { authenticatedFetch as fetch } from './helpers/http-client.js'; import assert from 'node:assert/strict'; import { DatabaseSync } from 'node:sqlite'; import test from 'node:test'; @@ -114,7 +115,7 @@ test('invalid or conflicting backup rolls back all library changes', t => { test('study endpoints retain loopback browser protection and validate option updates', async t => { const { userData } = fixture(t); - const server = createHttpServer({ userData, requireAccount: false }); + const server = createHttpServer({ clientToken: 'test-client', userData, requireAccount: false }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); t.after(() => new Promise(resolve => server.close(resolve))); const base = `http://127.0.0.1:${server.address().port}`;