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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 11 additions & 1 deletion components/LightApi.qml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "I18n.js" as I18n

Expand Down Expand Up @@ -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([
Expand Down
13 changes: 13 additions & 0 deletions service/src/daemon.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions service/src/http-server.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) => {
Expand All @@ -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}`;
Expand Down
3 changes: 2 additions & 1 deletion service/test/account-gate.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { authenticatedFetch as fetch } from './helpers/http-client.js';
import assert from 'node:assert/strict';
import test from 'node:test';

Expand All @@ -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 }) },
Expand Down
70 changes: 70 additions & 0 deletions service/test/client-auth.test.js
Original file line number Diff line number Diff line change
@@ -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 }); }
});
7 changes: 4 additions & 3 deletions service/test/compatibility.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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: () => [],
Expand All @@ -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));
});
Expand Down Expand Up @@ -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));
Expand Down
6 changes: 6 additions & 0 deletions service/test/helpers/http-client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function authenticatedFetch(url, options = {}) {
return globalThis.fetch(url, {
...options,
headers: { authorization: 'Bearer test-client', ...options.headers },
});
}
3 changes: 2 additions & 1 deletion service/test/search.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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));
Expand Down
3 changes: 2 additions & 1 deletion service/test/service.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {}; } },
Expand Down
3 changes: 2 additions & 1 deletion service/test/study-data.test.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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}`;
Expand Down
Loading