Skip to content

feat(acquisition): OpenSky OAuth2 client-credentials auth - #152

Open
montge wants to merge 1 commit into
developfrom
feature/opensky-oauth2
Open

feat(acquisition): OpenSky OAuth2 client-credentials auth#152
montge wants to merge 1 commit into
developfrom
feature/opensky-oauth2

Conversation

@montge

@montge montge commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Why

OpenSky removed HTTP Basic authentication from its REST API. The acquisition layer's --credentials USER:PASS path could no longer authenticate at all — it was dead code that silently degraded to anonymous access.

This is the prerequisite for flight-data-training-pipeline finding #1: anonymous access delivers ~55 bbox polls/day (nominally 400 credits), which is what produced the cruise-dominated capture — 92.9% cv, 0.06% coord_turn — that task 6.8's maneuvering-scenario failure traces back to. A turn classifier cannot be trained from data containing no turns.

Authenticated accounts get 4,000 credits/day (8,000 for an active feeder ≥30% uptime).

What

  • New acquisition/opensky_auth.py — the client_credentials grant as an httpx.Auth flow:
    • mints a bearer token, caches until 60 s before expiry (tokens live 30 min)
    • re-mints once on a 401 and replays the request; a second 401 propagates rather than looping
    • token requests travel over the caller's transport, so the whole flow is driven by one MockTransport — no network, no real credentials in tests
  • Credential resolution: OPENSKY_CLIENT_ID/OPENSKY_CLIENT_SECRET (matching the existing ADSBX_API_KEY convention) → ~/.config/opensky/credentials.json → anonymous. The file format is exactly what OpenSky's web UI emits, accepted unmodified (clientId/clientSecret and snake_case both work).
  • fetch_state_vectors / fetch_current_states / capture take auth: httpx.Auth | None in place of the dead credentials tuple.
  • --time (historical replay) now fails fast when unauthenticated instead of silently handing back the current snapshot.

Security notes

  • Secrets are never accepted as CLI flagsargv is readable by any user on the box via ps. The old --credentials flag is retained as a hidden argument solely to emit a migration error: it is an unambiguous argparse prefix of --credentials-file, so without this it would silently bind to the new flag and report a confusing "file not found".
  • OpenSkyCredentials.__repr__ redacts the secret so it cannot leak into logs or tracebacks.
  • A warning fires if the credentials file is group/world-readable.
  • .gitignore now covers credentials.json and .env*.

Testing

16 new tests in tests/test_opensky_auth.py covering resolution precedence, both file spellings, half-set env vars, malformed files, secret redaction, token caching, expiry-margin re-mint, 401 re-mint + replay, and both token-endpoint failure modes.

ruff check ✅ · pyright 0 errors ✅ · pytest 136 passed, 3 skipped ✅ (the 3 skips are pre-existing — torch/onnxruntime are not in the CI dev group)

Follow-on

Unblocks the authenticated capture over terminal areas with turning traffic, which then feeds the Track B retrain (6.8). The dt-feature work that pairs with it is on feature/imm-dt-feature.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added OAuth2 authentication for OpenSky data access using environment variables or a credentials file.
    • Added support for authenticated historical data requests.
    • Added --credentials-file to the OpenSky command-line tool.
    • Anonymous access remains available for current snapshots with provider-imposed limits.
  • Documentation

    • Updated OpenSky setup and authentication guidance.
  • Bug Fixes

    • Added automatic token caching, refresh, and recovery from expired authentication responses.
    • Prevented repeated authentication retries from looping indefinitely.
  • Security

    • Added safeguards to ignore credential and environment files while allowing .env.example.

OpenSky removed HTTP Basic authentication from its REST API; the
`--credentials USER:PASS` path in the acquisition layer could no longer
authenticate at all. This blocks flight-data-training-pipeline finding #1:
anonymous access yields ~55 bbox polls/day (nominally 400 credits), which
produced the cruise-dominated capture (0.06% coord_turn labels) that task
6.8's maneuvering-scenario failure traces back to.

Add `acquisition.opensky_auth` implementing the `client_credentials` grant
as an `httpx.Auth` flow: mints a bearer token, caches it until 60 s before
expiry, and re-mints once on a 401 before replaying the request. Token
requests travel over the caller's transport, so the whole flow is testable
through a single `MockTransport` with no network or real credentials.

Credentials resolve from `OPENSKY_CLIENT_ID`/`OPENSKY_CLIENT_SECRET`
(matching the existing `ADSBX_API_KEY` convention), then from a JSON file
defaulting to `~/.config/opensky/credentials.json` — the exact format
OpenSky's web UI emits, accepted unmodified. Secrets are not accepted as
CLI flags: `argv` is readable by other users via `ps`. The removed
`--credentials` flag is retained as a hidden argument purely to emit a
migration error, since it is otherwise an unambiguous argparse prefix of
`--credentials-file` and would silently bind to it.

`--time` (historical replay) now fails fast when unauthenticated instead of
silently returning the current snapshot.

Authenticated budget is 4,000 credits/day (8,000 for an active feeder)
versus ~400 anonymous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 25, 2026 20:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds OAuth2 client-credentials authentication for OpenSky, including credential resolution, token caching, expiry refresh, and 401 retry handling. Acquisition APIs and the CLI now accept auth objects, require authentication for historical windows, and document the new credential workflow.

Changes

OpenSky OAuth2 authentication

Layer / File(s) Summary
Credential resolution and OAuth2 token flow
python/acquisition/opensky_auth.py, python/tests/test_opensky_auth.py, .gitignore
Credential sources, validation, OAuth2 bearer-token management, retry behavior, redaction tests, and secret-file ignore rules are added.
Authenticated acquisition and CLI wiring
python/acquisition/opensky.py, python/acquisition/opensky_cli.py, python/tests/test_opensky_sample.py, TRAINING.md
Acquisition functions and CLI flows use httpx.Auth, historical requests require authentication, legacy credential flags are rejected, and usage documentation is updated.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • Chaffgold/thresh#122: Updates the OpenSky CLI capture flow and tests around credential/auth propagation.

Suggested reviewers: copilot

Sequence Diagram(s)

sequenceDiagram
  participant CLI as opensky_cli.main
  participant Loader as load_credentials
  participant Auth as OAuth2ClientCredentialsAuth
  participant Token as OpenSky token endpoint
  participant API as OpenSky API
  CLI->>Loader: resolve credentials
  CLI->>Auth: build authentication object
  Auth->>Token: request client_credentials token
  Token-->>Auth: return bearer token
  Auth->>API: send authenticated acquisition request
  API-->>Auth: return response or 401
  Auth->>Token: mint replacement token after 401
  Auth->>API: replay request once
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: adding OpenSky OAuth2 client-credentials authentication.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/opensky-oauth2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@sonarqubecloud

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
python/acquisition/opensky_auth.py (1)

143-224: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider thread-safety for the cached token state.

_token/_expires_at are mutated in auth_flow without any synchronization. httpx.Client is explicitly documented as safe to share across threads, and httpx's own authentication docs recommend overriding sync_auth_flow()/async_auth_flow() with a lock specifically for OAuth2-style token caching across threads (the docs' canonical example wraps token minting in a threading.RLock/asyncio.Lock). If this OAuth2ClientCredentialsAuth instance (or the httpx.Client it's attached to) is ever reused from multiple threads — plausible for a general-purpose auth helper exported from opensky_auth.py — two threads could simultaneously detect a stale token and both fire mint requests, or interleave in ways that leave _token/_expires_at inconsistent.

Current call sites (opensky_cli.capture/main) are single-threaded, so this isn't exploitable today, but it's worth hardening given the class is a reusable public API.

🔒 Sketch using httpx's documented `sync_auth_flow` + lock pattern
+import threading
...
         self._token: str | None = None
         self._expires_at: float = 0.0
+        self._lock = threading.Lock()
...
-    def auth_flow(
+    def sync_auth_flow(
         self, request: httpx.Request
     ) -> Generator[httpx.Request, httpx.Response, None]:
-        if not self._is_fresh():
-            self._store_token((yield self._build_token_request()))
+        with self._lock:
+            if not self._is_fresh():
+                self._store_token((yield self._build_token_request()))
         request.headers["Authorization"] = f"Bearer {self._token}"
         response = yield request
         if response.status_code == 401:
-            self._token = None
-            self._store_token((yield self._build_token_request()))
-            request.headers["Authorization"] = f"Bearer {self._token}"
-            yield request
+            with self._lock:
+                self._token = None
+                self._store_token((yield self._build_token_request()))
+            request.headers["Authorization"] = f"Bearer {self._token}"
+            yield request
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/acquisition/opensky_auth.py` around lines 143 - 224, Make
OAuth2ClientCredentialsAuth thread-safe for shared synchronous use by adding a
reentrant lock and synchronizing cached-token reads, refreshes, expiry updates,
and 401 re-mint/replay handling in auth_flow. Prefer the httpx sync_auth_flow
pattern so token minting is serialized without holding the lock across the
yielded application request, while preserving single-refresh behavior and
existing request authentication.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@python/acquisition/opensky_auth.py`:
- Around line 186-206: Update _store_token to validate that the parsed response
payload is a dict immediately after response.json(); raise OpenSkyAuthError with
an appropriate invalid-response message for lists or scalar JSON values, and
only then access payload.get or sort its keys.

---

Nitpick comments:
In `@python/acquisition/opensky_auth.py`:
- Around line 143-224: Make OAuth2ClientCredentialsAuth thread-safe for shared
synchronous use by adding a reentrant lock and synchronizing cached-token reads,
refreshes, expiry updates, and 401 re-mint/replay handling in auth_flow. Prefer
the httpx sync_auth_flow pattern so token minting is serialized without holding
the lock across the yielded application request, while preserving single-refresh
behavior and existing request authentication.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 216399ee-f83d-4495-9c60-6d3d552e66dc

📥 Commits

Reviewing files that changed from the base of the PR and between bcf5da7 and 2261bac.

📒 Files selected for processing (7)
  • .gitignore
  • TRAINING.md
  • python/acquisition/opensky.py
  • python/acquisition/opensky_auth.py
  • python/acquisition/opensky_cli.py
  • python/tests/test_opensky_auth.py
  • python/tests/test_opensky_sample.py

Comment on lines +186 to +206
def _store_token(self, response: httpx.Response) -> None:
if response.status_code != 200:
detail = response.text[:500]
raise OpenSkyAuthError(
f"OpenSky token request failed: HTTP {response.status_code}: {detail}"
)
try:
payload = response.json()
except ValueError as exc:
raise OpenSkyAuthError(f"OpenSky token response was not JSON: {exc}") from exc
token = payload.get("access_token")
if not token:
raise OpenSkyAuthError(
f"OpenSky token response has no access_token (keys: {sorted(payload)})"
)
try:
lifetime_s = float(payload.get("expires_in", DEFAULT_TOKEN_LIFETIME_S))
except (TypeError, ValueError):
lifetime_s = DEFAULT_TOKEN_LIFETIME_S
self._token = str(token)
self._expires_at = self._clock() + max(lifetime_s - self._expiry_margin_s, 0.0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

_store_token assumes the token JSON payload is a dict.

payload = response.json() is never type-checked before payload.get(...) is called at Lines 196/199/202. If the OpenSky token endpoint (or a misconfigured token_url) returns valid JSON that isn't an object (e.g. a list or scalar), this raises an unhandled AttributeError instead of the intended OpenSkyAuthError, unlike _credentials_from_file which does guard with isinstance(payload, dict).

🐛 Proposed fix
         try:
             payload = response.json()
         except ValueError as exc:
             raise OpenSkyAuthError(f"OpenSky token response was not JSON: {exc}") from exc
+        if not isinstance(payload, dict):
+            raise OpenSkyAuthError(
+                f"OpenSky token response must be a JSON object, got {type(payload).__name__}"
+            )
         token = payload.get("access_token")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _store_token(self, response: httpx.Response) -> None:
if response.status_code != 200:
detail = response.text[:500]
raise OpenSkyAuthError(
f"OpenSky token request failed: HTTP {response.status_code}: {detail}"
)
try:
payload = response.json()
except ValueError as exc:
raise OpenSkyAuthError(f"OpenSky token response was not JSON: {exc}") from exc
token = payload.get("access_token")
if not token:
raise OpenSkyAuthError(
f"OpenSky token response has no access_token (keys: {sorted(payload)})"
)
try:
lifetime_s = float(payload.get("expires_in", DEFAULT_TOKEN_LIFETIME_S))
except (TypeError, ValueError):
lifetime_s = DEFAULT_TOKEN_LIFETIME_S
self._token = str(token)
self._expires_at = self._clock() + max(lifetime_s - self._expiry_margin_s, 0.0)
def _store_token(self, response: httpx.Response) -> None:
if response.status_code != 200:
detail = response.text[:500]
raise OpenSkyAuthError(
f"OpenSky token request failed: HTTP {response.status_code}: {detail}"
)
try:
payload = response.json()
except ValueError as exc:
raise OpenSkyAuthError(f"OpenSky token response was not JSON: {exc}") from exc
if not isinstance(payload, dict):
raise OpenSkyAuthError(
f"OpenSky token response must be a JSON object, got {type(payload).__name__}"
)
token = payload.get("access_token")
if not token:
raise OpenSkyAuthError(
f"OpenSky token response has no access_token (keys: {sorted(payload)})"
)
try:
lifetime_s = float(payload.get("expires_in", DEFAULT_TOKEN_LIFETIME_S))
except (TypeError, ValueError):
lifetime_s = DEFAULT_TOKEN_LIFETIME_S
self._token = str(token)
self._expires_at = self._clock() + max(lifetime_s - self._expiry_margin_s, 0.0)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/acquisition/opensky_auth.py` around lines 186 - 206, Update
_store_token to validate that the parsed response payload is a dict immediately
after response.json(); raise OpenSkyAuthError with an appropriate
invalid-response message for lists or scalar JSON values, and only then access
payload.get or sort its keys.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants