From 1c234ce0271d72d1e2fe93b214da8ce373b41203 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:25:42 -0700 Subject: [PATCH] fix(opensky): load OAuth credential files and retire Basic auth (#1229) Read OpenSky OAuth credentials from OPENSKY_CREDENTIALS_FILE and stop sending retired Basic authentication. Update setup diagnostics and guidance. Keep credential-file warnings free of parser excerpts and filesystem error details, cover both failure paths with a regression test, and use a launcher-compatible absolute path example. Original contribution by quanticsoul4772, with credential-warning and documentation follow-up by Sameh Khamis. Co-authored-by: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Co-authored-by: Sameh Khamis --- .env.example | 7 +- changelog.d/1229-opensky-oauth-only.md | 8 ++ docs/CURRENT-STATE.md | 4 + docs/opensky-auth.md | 24 ++-- scripts/setup-doctor.mjs | 25 +++- server/providers/aircraft/opensky.js | 131 ++++++++++-------- .../aircraft/openskyCredentials.test.mjs | 81 +++++++++++ src/setupDoctor.test.mjs | 41 +++++- src/sources/live/standalone.js | 4 +- src/tooling/liveProviders.test.mjs | 63 +++++++++ 10 files changed, 309 insertions(+), 79 deletions(-) create mode 100644 changelog.d/1229-opensky-oauth-only.md create mode 100644 server/providers/aircraft/openskyCredentials.test.mjs diff --git a/.env.example b/.env.example index 55cb367a0..0876262b2 100644 --- a/.env.example +++ b/.env.example @@ -115,7 +115,8 @@ GEMINI_LIVE_MODEL=gemini-3.8-live # Client Token). Client-exposed by design, like GOOGLE_MAPS_API_KEY. MAPILLARY_CLIENT_TOKEN= -# Optional: OpenSky auth mode (oauth | basic | auto | anon) +# Optional: OpenSky auth mode (oauth | auto | anon). OpenSky retired +# username/password (basic) sign-in on 2026-03-18; "basic" now loads anonymously. # Default: oauth — requires client credentials below # Set to "anon" to skip aircraft auth (rate-limited but works) OPENSKY_AUTH_MODE=oauth @@ -128,7 +129,9 @@ OPENSKY_CLIENT_SECRET= # Optional paid Patreon tokens raise request limits; there is no free API key. LL2_API_TOKEN= -# Optional: OpenSky credentials JSON file path (alternative to above) +# Optional: OpenSky credentials JSON file path, used when the two values above +# are empty: the credentials.json your OpenSky account page downloads when you +# create an API client. Use an absolute path for compatibility with launchers. # OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json # Optional: dev server config diff --git a/changelog.d/1229-opensky-oauth-only.md b/changelog.d/1229-opensky-oauth-only.md new file mode 100644 index 000000000..25159830b --- /dev/null +++ b/changelog.d/1229-opensky-oauth-only.md @@ -0,0 +1,8 @@ +- OpenSky sign-in matches what OpenSky accepts since 2026-03-18. The server + now reads `OPENSKY_CREDENTIALS_FILE` (the `credentials.json` OpenSky's + account page downloads) on every platform, not only through the macOS + launcher, and no longer sends retired username/password Basic auth: + `OPENSKY_AUTH_MODE=basic` loads anonymously with one warning saying what to + set instead (quanticsoul4772, #1229). +- Credential-file warnings omit parser excerpts and filesystem error details; + the path example also works with the macOS launchers. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ab0d18b77..dbd591b17 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1916,6 +1916,10 @@ Updated: September 23, 2026 ## Aircraft and vessel server modules +OpenSky credential-file failures emit a fixed warning once per process; neither +JSON parser excerpts nor filesystem error messages are logged. The environment +example uses an absolute path compatible with the macOS launchers. + Standalone aircraft routes now live in `server/providers/aircraft/`: OpenSky state vectors and regional fallback, adsb.lol military positions, ADSBDB enrichment and track backfill. Vessel routes and websocket/watchdog setup live diff --git a/docs/opensky-auth.md b/docs/opensky-auth.md index 67844f912..c5f0a387b 100644 --- a/docs/opensky-auth.md +++ b/docs/opensky-auth.md @@ -3,10 +3,11 @@ God's Eye View uses explicit auth modes for `/api/flights`: - `OPENSKY_AUTH_MODE=oauth` (default, recommended) -- `OPENSKY_AUTH_MODE=auto` (OAuth first, then Basic fallback) -- `OPENSKY_AUTH_MODE=basic` +- `OPENSKY_AUTH_MODE=auto` (OAuth when configured, otherwise anonymous) - `OPENSKY_AUTH_MODE=anon` +`OPENSKY_AUTH_MODE=basic` is retired (see below). + Reference: OpenSky REST API docs recommend OAuth2 Client Credentials flow. ## Quick Start (OAuth Client JSON) @@ -41,13 +42,18 @@ Launchers resolve OAuth creds in this order: 2. `OPENSKY_CREDENTIALS_FILE` 3. Keychain (`opensky-network` / `client_id`, `client_secret`) -## Basic Auth (Legacy) +The server itself reads 1 and 2, so `npm run dev`, `npm run preview` and +Pinokio also work with only `OPENSKY_CREDENTIALS_FILE` set in `.env`, on any +platform. It reads the file on each token refresh. -Basic auth is still supported for now, but OpenSky is moving away from it. +## Basic Auth (Retired) -```bash -OPENSKY_AUTH_MODE=basic ./scripts/dev-fresh.sh -``` +OpenSky stopped accepting username/password (HTTP Basic) sign-in on +2026-03-18; its API now takes only OAuth2 client credentials. With +`OPENSKY_AUTH_MODE=basic` the server no longer sends `OPENSKY_USERNAME` / +`OPENSKY_PASSWORD`: it requests anonymously (reason `basic_retired`) and +logs once what to set instead. Create an API client on your OpenSky +account page and use its `credentials.json` as above. ## Troubleshooting @@ -61,6 +67,6 @@ Useful reasons: - `oauth_invalid_or_missing`: OAuth client not found/usable - `oauth_invalid_credentials`: OAuth client rejected by OpenSky -- `basic_invalid_credentials`: username/password rejected -- `missing_basic_creds`: basic mode missing user/pass +- `missing_oauth_creds`: auto mode found no OAuth client +- `basic_retired`: basic mode requested; answered anonymously - `rate_limited`: OpenSky throttling diff --git a/scripts/setup-doctor.mjs b/scripts/setup-doctor.mjs index 01e048091..7075be2c1 100644 --- a/scripts/setup-doctor.mjs +++ b/scripts/setup-doctor.mjs @@ -124,6 +124,19 @@ export function resolveOpenSkyAuthMode({ return OPENSKY_AUTH_MODES.has(mode) ? mode : OPENSKY_AUTH_MODE_DEFAULT; } +/** Whether OPENSKY_CREDENTIALS_FILE is set, with the same precedence as the auth mode. The server reads that file when the client id and secret are empty. */ +export function resolveOpenSkyCredentialsFile({ + authoritativeEnvironment = false, + environment = process.env, + rootDir = ROOT, +} = {}) { + const environmentDefines = Object.prototype.hasOwnProperty.call(environment, 'OPENSKY_CREDENTIALS_FILE'); + const value = authoritativeEnvironment && environmentDefines + ? String(environment.OPENSKY_CREDENTIALS_FILE || '') + : String(environment.OPENSKY_CREDENTIALS_FILE || '') || readDoctorDotenvValue('OPENSKY_CREDENTIALS_FILE', rootDir); + return isConfiguredValue(value.trim()); +} + function hasKeychainItem(service, account) { if (process.platform !== 'darwin') return false; const result = spawnSync('security', [ @@ -153,10 +166,11 @@ export function resolveCredential(spec, { export function buildCapabilitySummary( credentials, - { openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT } = {}, + { openSkyAuthMode = OPENSKY_AUTH_MODE_DEFAULT, openSkyCredentialsFile = false } = {}, ) { const configured = (name) => credentials[name]?.configured === true; - const hasOAuthCredentials = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET'); + const hasClientPair = configured('OPENSKY_CLIENT_ID') && configured('OPENSKY_CLIENT_SECRET'); + const hasOAuthCredentials = hasClientPair || openSkyCredentialsFile === true; const requestedOpenSkyMode = String(openSkyAuthMode || '').trim().toLowerCase(); const openSkyMode = OPENSKY_AUTH_MODES.has(requestedOpenSkyMode) ? requestedOpenSkyMode @@ -164,11 +178,13 @@ export function buildCapabilitySummary( const flights = openSkyMode === 'anon' ? 'OpenSky keyless anonymous access (rate-limited)' : openSkyMode === 'basic' - ? 'OpenSky Basic mode selected (credential presence and validity not verified)' + ? 'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)' : openSkyMode === 'auto' ? 'OpenSky auto mode selected (runtime credential choice and validity not verified)' - : hasOAuthCredentials + : hasClientPair ? 'OpenSky OAuth credentials present (runtime mode and validity not verified)' + : hasOAuthCredentials + ? 'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)' : 'OpenSky keyless anonymous access (rate-limited)'; const route = selectMapStartupRoute({ googleApiKey: configured('GOOGLE_MAPS_API_KEY') ? 'configured' : '', @@ -215,6 +231,7 @@ export function inspectSetup({ includeKeychain = true, authoritativeEnvironment credentials, capabilities: buildCapabilitySummary(credentials, { openSkyAuthMode: resolveOpenSkyAuthMode({ authoritativeEnvironment, rootDir }), + openSkyCredentialsFile: resolveOpenSkyCredentialsFile({ authoritativeEnvironment, rootDir }), }), }; } diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index a69885e2f..f7019d752 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import os from 'node:os'; import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js'; import { coalesceProxyRequest, @@ -137,6 +139,10 @@ function openskyAdaptiveTtlMs(remaining) { } /** @type {boolean} Guards duplicate auth-failure warnings in logs. */ let _openskyAuthWarned = false; +/** One warning per process when OPENSKY_CREDENTIALS_FILE cannot be read. */ +let _openskyCredentialsFileWarned = false; +/** One warning per process when OPENSKY_AUTH_MODE=basic is requested. */ +let _openskyBasicRetiredWarned = false; /** @type {boolean} Guards duplicate invalid-auth-mode warnings. */ let _openskyAuthModeWarned = false; /** Default auth mode when OPENSKY_AUTH_MODE env is unset. */ @@ -156,6 +162,48 @@ const ADSBLOL_POINT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; // current instead of coasting a stale worldwide frame indefinitely. const OPENSKY_SOURCE_STALE_MS = 120_000; +/** + * The OpenSky API client for OAuth: OPENSKY_CLIENT_ID and + * OPENSKY_CLIENT_SECRET, or else the credentials.json that OpenSky's account + * page downloads, named by OPENSKY_CREDENTIALS_FILE ("~" is the home + * directory). The file is read on each token refresh, so a replaced file is + * picked up without a restart. + * + * @param {Record} [env] + * @param {(path: string) => string} [readFile] + * @returns {{ clientId: string, clientSecret: string } | null} + */ +export function openSkyClientCredentials( + env = process.env, + readFile = (path) => fs.readFileSync(path, 'utf8'), +) { + const clientId = String(env.OPENSKY_CLIENT_ID || '').trim(); + const clientSecret = String(env.OPENSKY_CLIENT_SECRET || '').trim(); + if (clientId && clientSecret) return { clientId, clientSecret }; + const file = String(env.OPENSKY_CREDENTIALS_FILE || '').trim(); + if (!file) return null; + const path = file.replace(/^~(?=$|[\\/])/, os.homedir()); + let raw; + try { + raw = JSON.parse(readFile(path)); + } catch { + if (!_openskyCredentialsFileWarned) { + console.warn( + '[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.', + ); + _openskyCredentialsFileWarned = true; + } + return null; + } + const fileId = String(raw?.clientId ?? raw?.client_id ?? '').trim(); + const fileSecret = String( + raw?.clientSecret ?? raw?.client_secret ?? '', + ).trim(); + return fileId && fileSecret + ? { clientId: fileId, clientSecret: fileSecret } + : null; +} + /** * Obtain a valid OpenSky OAuth2 bearer token, refreshing if needed. * @@ -174,9 +222,9 @@ export async function getOpenSkyToken() { // return the same promise instead of issuing a duplicate token request if (_openskyTokenPromise) return _openskyTokenPromise; - const clientId = process.env.OPENSKY_CLIENT_ID; - const clientSecret = process.env.OPENSKY_CLIENT_SECRET; - if (!clientId || !clientSecret) return null; + const credentials = openSkyClientCredentials(); + if (!credentials) return null; + const { clientId, clientSecret } = credentials; // Wrap the async token fetch in a shared promise stored in _openskyTokenPromise _openskyTokenPromise = (async () => { @@ -417,8 +465,10 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) { * * Supports four auth modes controlled by OPENSKY_AUTH_MODE env: * - 'oauth' (default) — client_credentials bearer token - * - 'basic' — HTTP Basic with OPENSKY_USERNAME / OPENSKY_PASSWORD - * - 'auto' — try OAuth first, fall back to Basic, then anon + * - 'basic' — retired: OpenSky stopped accepting HTTP Basic (username and + * password) on 2026-03-18, so this requests anonymously and + * warns once + * - 'auto' — try OAuth first, then anon * - 'anon' — no credentials * * Successful responses are cached for OPENSKY_CACHE_MS (~9 s). On @@ -524,21 +574,21 @@ export function openSkyProxy() { _openskySnapshotInFlight, 'global', async () => { - const basicUser = process.env.OPENSKY_USERNAME || ''; - const basicPass = process.env.OPENSKY_PASSWORD || ''; - const hasBasicCreds = Boolean(basicUser && basicPass); const headers = { Accept: 'application/json' }; let usedMode = 'anon'; let reason = 'forced_anonymous'; if (requestedMode === 'basic') { - if (hasBasicCreds) { - headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; - usedMode = 'basic'; - reason = 'basic_credentials'; - } else { - reason = 'missing_basic_creds'; + // OpenSky no longer accepts a Basic header, so sending + // OPENSKY_USERNAME / OPENSKY_PASSWORD would only fail; ask + // anonymously and say once what to configure instead. + if (!_openskyBasicRetiredWarned) { + console.warn( + '[OpenSky] OPENSKY_AUTH_MODE=basic: OpenSky retired username/password sign-in on 2026-03-18, so flights load anonymously (rate-limited). Use OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.', + ); + _openskyBasicRetiredWarned = true; } + reason = 'basic_retired'; } else if (requestedMode === 'oauth') { const token = await getOpenSkyToken(); if (token) { @@ -554,31 +604,12 @@ export function openSkyProxy() { headers.Authorization = `Bearer ${token}`; usedMode = 'oauth'; reason = 'oauth_token'; - } else if (hasBasicCreds) { - headers.Authorization = `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`; - usedMode = 'basic'; - reason = 'oauth_unavailable_fallback_basic'; } else { - reason = 'missing_oauth_and_basic_creds'; + reason = 'missing_oauth_creds'; } } - let { upstream, body } = await fetchOpenSkyStates(headers); - // Auto-mode fallback: if OAuth was rejected, retry with Basic credentials - if ( - (upstream.status === 401 || upstream.status === 403) && - requestedMode === 'auto' && - usedMode === 'oauth' && - hasBasicCreds - ) { - const retryHeaders = { - Accept: 'application/json', - Authorization: `Basic ${Buffer.from(`${basicUser}:${basicPass}`).toString('base64')}`, - }; - ({ upstream, body } = await fetchOpenSkyStates(retryHeaders)); - usedMode = 'basic'; - reason = 'oauth_rejected_fallback_basic'; - } + const { upstream, body } = await fetchOpenSkyStates(headers); const sourceEpochMs = upstream.ok ? openSkySourceEpochMs(body) @@ -586,12 +617,6 @@ export function openSkyProxy() { // Refine the reason string to reflect the actual outcome if (upstream.ok && reason === 'forced_anonymous') { reason = 'anonymous_ok'; - } else if ( - upstream.ok && - usedMode === 'basic' && - reason === 'basic_credentials' - ) { - reason = 'basic_ok'; } else if ( upstream.ok && usedMode === 'oauth' && @@ -627,12 +652,11 @@ export function openSkyProxy() { body, usedMode, reason, - hasBasicCreds, sourceEpochMs, }; }, ); - let { upstream, body, usedMode, reason, hasBasicCreds, sourceEpochMs } = + let { upstream, body, usedMode, reason, sourceEpochMs } = await refresh.promise; if ( upstream.ok && @@ -684,35 +708,24 @@ export function openSkyProxy() { } if (upstream.status === 401 || upstream.status === 403) { - if (requestedMode === 'basic' && !hasBasicCreds) { + if (requestedMode === 'oauth' && usedMode !== 'oauth') { body = JSON.stringify({ error: - 'OpenSky auth missing. Basic mode requires OPENSKY_USERNAME and OPENSKY_PASSWORD.', - }); - reason = 'missing_basic_creds'; - } else if (requestedMode === 'oauth' && usedMode !== 'oauth') { - body = JSON.stringify({ - error: - 'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET.', + 'OpenSky auth invalid. OAuth mode requires valid OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.', }); reason = 'oauth_invalid_or_missing'; - } else if (usedMode === 'basic') { - body = JSON.stringify({ - error: 'OpenSky auth invalid. Username/password were rejected.', - }); - reason = 'basic_invalid_credentials'; } else if (usedMode === 'oauth') { body = JSON.stringify({ error: 'OpenSky auth invalid. OAuth client credentials were rejected.', }); reason = 'oauth_invalid_credentials'; - } else if (requestedMode === 'auto' && !hasBasicCreds) { + } else if (requestedMode === 'auto') { body = JSON.stringify({ error: - 'OpenSky auth missing. Provide basic credentials or valid OAuth client credentials.', + 'OpenSky auth missing. Provide OAuth client credentials: OPENSKY_CLIENT_ID and OPENSKY_CLIENT_SECRET, or OPENSKY_CREDENTIALS_FILE.', }); - reason = 'missing_oauth_and_basic_creds'; + reason = 'missing_oauth_creds'; } else { body = JSON.stringify({ error: 'OpenSky auth required.', diff --git a/server/providers/aircraft/openskyCredentials.test.mjs b/server/providers/aircraft/openskyCredentials.test.mjs new file mode 100644 index 000000000..539579517 --- /dev/null +++ b/server/providers/aircraft/openskyCredentials.test.mjs @@ -0,0 +1,81 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import { openSkyClientCredentials } from './opensky.js'; + +const file = (contents) => (filePath) => { + file.read = filePath; + if (contents instanceof Error) throw contents; + return contents; +}; + +test('OpenSky client env vars come first', () => { + const read = () => assert.fail('the file is not read when env vars are set'); + assert.deepEqual( + openSkyClientCredentials( + { + OPENSKY_CLIENT_ID: ' id ', + OPENSKY_CLIENT_SECRET: 'secret', + OPENSKY_CREDENTIALS_FILE: 'credentials.json', + }, + read, + ), + { clientId: 'id', clientSecret: 'secret' }, + ); +}); + +test('OPENSKY_CREDENTIALS_FILE supplies the client when env vars are empty', () => { + const env = { + OPENSKY_CLIENT_ID: '', + OPENSKY_CREDENTIALS_FILE: '~/Downloads/credentials.json', + }; + // The file OpenSky's account page downloads uses camelCase keys. + assert.deepEqual( + openSkyClientCredentials( + env, + file('{"clientId":"cid","clientSecret":"csecret"}'), + ), + { clientId: 'cid', clientSecret: 'csecret' }, + ); + assert.equal( + path.normalize(file.read), + path.normalize(path.join(os.homedir(), 'Downloads/credentials.json')), + ); + assert.deepEqual( + openSkyClientCredentials( + env, + file('{"client_id":"cid","client_secret":"csecret"}'), + ), + { clientId: 'cid', clientSecret: 'csecret' }, + ); +}); + +test('a missing, unreadable or incomplete credentials file means no client', () => { + assert.equal(openSkyClientCredentials({}, file('{}')), null); + const env = { OPENSKY_CREDENTIALS_FILE: 'credentials.json' }; + const missing = Object.assign(new Error('nope'), { code: 'ENOENT' }); + assert.equal(openSkyClientCredentials(env, file(missing)), null); + assert.equal(openSkyClientCredentials(env, file('not json')), null); + assert.equal(openSkyClientCredentials(env, file('{"clientId":"cid"}')), null); +}); + +test('credential file warnings never expose file contents or read errors', async (t) => { + const warnings = []; + t.mock.method(console, 'warn', (message) => warnings.push(message)); + for (const contents of [ + '{"clientId":"id","clientSecret":FAKE_SECRET_123}', + new Error('could not read FAKE_SECRET_123'), + ]) { + const { openSkyClientCredentials: resolve } = await import( + `./opensky.js?warning=${warnings.length}` + ); + const env = { OPENSKY_CREDENTIALS_FILE: '/synthetic/credentials.json' }; + assert.equal(resolve(env, file(contents)), null); + assert.equal(resolve(env, file(contents)), null); + } + assert.deepEqual(warnings, [ + '[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.', + '[OpenSky] OPENSKY_CREDENTIALS_FILE could not be read or parsed as JSON.', + ]); +}); diff --git a/src/setupDoctor.test.mjs b/src/setupDoctor.test.mjs index 26d695ba1..d4a2a7ca7 100644 --- a/src/setupDoctor.test.mjs +++ b/src/setupDoctor.test.mjs @@ -13,6 +13,7 @@ import { npmProcessSpec, readDoctorDotenvValue, resolveOpenSkyAuthMode, + resolveOpenSkyCredentialsFile, resolveCredential, } from '../scripts/setup-doctor.mjs'; @@ -237,7 +238,7 @@ test('doctor retains OAuth capability wording for a complete client pair', () => ); }); -test('doctor reports Basic mode without inferring runtime auth from OAuth credentials', () => { +test('doctor reports retired Basic mode as anonymous, whatever OAuth credentials exist', () => { const oauthCredentials = { OPENSKY_CLIENT_ID: { configured: true }, OPENSKY_CLIENT_SECRET: { configured: true }, @@ -245,7 +246,7 @@ test('doctor reports Basic mode without inferring runtime auth from OAuth creden for (const credentials of [{}, oauthCredentials]) { assert.equal( buildCapabilitySummary(credentials, { openSkyAuthMode: 'basic' }).flights, - 'OpenSky Basic mode selected (credential presence and validity not verified)', + 'OpenSky Basic mode selected: OpenSky retired username/password sign-in, so flights load anonymously (rate-limited)', ); } }); @@ -393,3 +394,39 @@ test('doctor never calls a dependency-missing setup ready', () => { assert.match(output, /Setup needs attention/); assert.doesNotMatch(output, /Ready\. Run/); }); + +test('doctor counts OPENSKY_CREDENTIALS_FILE as OAuth credentials, from the environment or .env', () => { + assert.equal( + buildCapabilitySummary({}, { openSkyCredentialsFile: true }).flights, + 'OpenSky OAuth credentials file set (OPENSKY_CREDENTIALS_FILE; file contents and validity not verified)', + ); + assert.equal( + buildCapabilitySummary({}, { openSkyCredentialsFile: false }).flights, + 'OpenSky keyless anonymous access (rate-limited)', + ); + const root = mkdtempSync(path.join(tmpdir(), 'gev-doctor-opensky-file-')); + try { + assert.equal( + resolveOpenSkyCredentialsFile({ environment: {}, rootDir: root }), + false, + ); + writeFileSync( + path.join(root, '.env'), + 'OPENSKY_CREDENTIALS_FILE=~/Downloads/credentials.json\n', + ); + assert.equal( + resolveOpenSkyCredentialsFile({ environment: {}, rootDir: root }), + true, + ); + assert.equal( + resolveOpenSkyCredentialsFile({ + authoritativeEnvironment: true, + environment: { OPENSKY_CREDENTIALS_FILE: '' }, + rootDir: root, + }), + false, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js index 3f2c73b3b..d76ecf3fa 100644 --- a/src/sources/live/standalone.js +++ b/src/sources/live/standalone.js @@ -31,9 +31,7 @@ function openSkyError(response) { const reasons = { oauth_invalid_or_missing: 'OpenSky OAuth client missing/invalid', oauth_invalid_credentials: 'OpenSky OAuth rejected credentials', - basic_invalid_credentials: 'OpenSky username/password rejected', - missing_basic_creds: 'OpenSky auth missing', - missing_oauth_and_basic_creds: 'OpenSky auth missing', + missing_oauth_creds: 'OpenSky auth missing', auth_required: 'OpenSky auth required', forced_anonymous: 'OpenSky auth required', }; diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index 6b880d186..1f1befc95 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -584,6 +584,69 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches assert.equal(callCount, 1); }); +test('OpenSky Basic mode no longer sends username and password: it asks anonymously and warns once', async (t) => { + environment(t, { + OPENSKY_CLIENT_ID: undefined, + OPENSKY_CLIENT_SECRET: undefined, + OPENSKY_CREDENTIALS_FILE: undefined, + OPENSKY_AUTH_MODE: 'basic', + OPENSKY_USERNAME: 'fixture-user', + OPENSKY_PASSWORD: 'fixture-password', + }); + const warnings = []; + t.mock.method(console, 'warn', (message) => warnings.push(message)); + const authorizations = []; + t.mock.method(globalThis, 'fetch', async (url, options) => { + if (url.includes('/states/')) { + authorizations.push(options.headers.Authorization); + return Response.json({ time: Math.floor(Date.now() / 1000), states: [] }); + } + throw Error(`Unexpected URL: ${url}`); + }); + const fresh = await import( + `../../server/providers/aircraft/opensky.js?basic=${Math.random()}` + ); + const flights = install(fresh.openSkyProxy()); + const first = await flights('/api/flights'); + assert.equal(first.statusCode, 200); + assert.equal(first.headers['x-opensky-auth'], 'anon'); + assert.equal(first.headers['x-opensky-auth-reason'], 'basic_retired'); + assert.deepEqual(authorizations, [undefined]); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /retired username\/password sign-in/); + assert.ok(!warnings[0].includes('fixture-password')); +}); + +test('OpenSky auto mode never falls back to Basic, even when OAuth is rejected', async (t) => { + environment(t, { + OPENSKY_CLIENT_ID: 'fixture-client', + OPENSKY_CLIENT_SECRET: 'fixture-secret', + OPENSKY_CREDENTIALS_FILE: undefined, + OPENSKY_AUTH_MODE: 'auto', + OPENSKY_USERNAME: 'fixture-user', + OPENSKY_PASSWORD: 'fixture-password', + }); + t.mock.method(console, 'warn', () => {}); + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'error', () => {}); + const authorizations = []; + t.mock.method(globalThis, 'fetch', async (url, options) => { + if (url.includes('/token')) + return Response.json({ access_token: 'fixture-token', expires_in: 1800 }); + if (url.includes('/states/')) { + authorizations.push(options.headers.Authorization); + return new Response('', { status: 401 }); + } + if (url.includes('/lat/')) return regionalFallback(); + throw Error(`Unexpected URL: ${url}`); + }); + const fresh = await import( + `../../server/providers/aircraft/opensky.js?auto=${Math.random()}` + ); + await install(fresh.openSkyProxy())('/api/flights', '?lat=30&lon=-97'); + assert.deepEqual(authorizations, ['Bearer fixture-token']); +}); + test('retired military history refuses without upstream I/O while validating identifiers', async (t) => { t.mock.method(globalThis, 'fetch', async () => { assert.fail('retired history must not contact the upstream');