Skip to content

[2C-01] Add app-API bearer-token middleware and brain3 token generate CLI - #208

Merged
WilliM233 merged 2 commits into
developfrom
feat/2C-01-bearer-middleware
Apr 27, 2026
Merged

WilliM233 merged 2 commits into
developfrom
feat/2C-01-bearer-middleware

Conversation

@WilliM233

Copy link
Copy Markdown
Owner

Closes #200

Verification

  • ruff check . — ✅ Pass (All checks passed!)
  • pytest -v — ✅ Pass (1351 passed, 0 failed, 0 skipped — 17.21s)
  • Migration applied locally on brain3-dev: ✅ N/A (no schema change in this ticket)
  • Postgres-backed test confirmed: ✅ N/A (auth middleware is DB-independent; SQLite in-memory conftest is sufficient)
  • python -m scripts.generate_token smoke run: ✅ Pass (token printed to stdout, hint to stderr)

Ran locally against develop HEAD at 820312c immediately before opening this PR.

Summary

Adds the bearer-token middleware that gates the new /api/app/* surface (the companion app + scheduler entry points), plus the brain3 token-generation CLI. Mirrors the pattern in brain3-mcp/mcp/auth.py (Starlette BaseHTTPMiddleware, hmac.compare_digest, JSON 401 bodies) but scopes auth by path prefix so existing /api/* routes consumed by MCP and internal clients stay unauthenticated. The token lives in settings.APP_BEARER_TOKEN, sourced from env var BRAIN3_APP_BEARER_TOKEN, and is intentionally distinct from any MCP token — separate secrets, separate rotation. Unblocks [2C-05] (FCM dispatch) and the companion-app pairing flow.

Changes

  • app/auth.py (new): AppBearerAuthMiddleware — pass-through for non-/api/app/* paths; on /api/app/*, validates Authorization: Bearer <token> with constant-time compare, returns {"error": "Missing bearer token"} (no header) or {"error": "Invalid bearer token"} (wrong secret), both 401. Plus install_app_bearer_auth(app, token) helper that mounts the middleware when the token is set or logs a startup warning and skips when it isn't.
  • app/config.py: adds APP_BEARER_TOKEN: str | None field with validation_alias="BRAIN3_APP_BEARER_TOKEN" so the env var stays explicitly prefixed without forcing a global env-prefix on the rest of the settings. populate_by_name=True added to model_config to keep field-name access working alongside the alias.
  • app/main.py: imports install_app_bearer_auth, calls it after CORSMiddleware so the auth gate runs before route dispatch on /api/app/*. Adds the trivial @app.get("/api/app/health") endpoint returning {"ok": True} — the authed probe the companion app hits during URL+token pairing.
  • scripts/generate_token.py (new): python -m scripts.generate_token prints secrets.token_urlsafe(48) to stdout and an operator hint (set BRAIN3_APP_BEARER_TOKEN=<value> in .env, restart) to stderr. AGPL header + __main__ block follow the scripts/migrate_to_artifacts.py pattern.
  • .env.example: documents the optional BRAIN3_APP_BEARER_TOKEN line so devs know it exists and how to generate a value.
  • tests/test_app_auth.py (new): six tests across the four acceptance paths (missing header, wrong token, correct token, env-unset warning) plus the no-regression check that /api/notifications/* answers without auth even with the middleware mounted, plus an empty-Bearer-value 401.

How to Verify

  1. pip install -r requirements.txt -r requirements-dev.txt
  2. pytest -v tests/test_app_auth.py — six tests pass.
  3. ruff check . — clean.
  4. With BRAIN3_APP_BEARER_TOKEN unset: start uvicorn app.main:app --reload and confirm the startup log contains BRAIN3_APP_BEARER_TOKEN is not set …. curl http://localhost:8000/api/app/health → 200 {"ok": true} (middleware skipped). curl http://localhost:8000/api/notifications/queue → 200 (regression intact).
  5. Generate a token: TOKEN=$(python -m scripts.generate_token). Set BRAIN3_APP_BEARER_TOKEN=$TOKEN in .env, restart the API.
  6. Confirm gating: curl -i http://localhost:8000/api/app/health → 401 {"error":"Missing bearer token"}. curl -i -H "Authorization: Bearer wrong" http://localhost:8000/api/app/health → 401 {"error":"Invalid bearer token"}. curl -i -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/app/health → 200 {"ok":true}.
  7. Confirm regression: curl -i http://localhost:8000/api/notifications/queue (or any other existing /api/* route) returns its normal response without auth.

Deviations

Three small calls worth flagging — none change spec intent, all surface for the record.

  1. generate_token.py writes the operator hint to stderr, not stdout. The spec's Scope bullet says the script "prints it to stdout with a one-line instruction to set BRAIN3_APP_BEARER_TOKEN=… in the env file"; the Acceptance Criteria says "prints a token to stdout and nothing else on success." Honoring the acceptance criterion (token-only stdout, so TOKEN=$(python -m scripts.generate_token) captures cleanly) means the hint moves to stderr. Standard Unix CLI convention. Both lines of the spec are honored under this reading; flagging because the Scope wording could also be read as "everything to stdout."
  2. No pyproject.toml [project.scripts] entry. The repo's pyproject.toml has no [project] name/version and no [project.scripts] table — it isn't currently installable as a package. The spec's "if the project uses entry points (verify — otherwise document python -m scripts.generate_token)" branch applies. Sticking with python -m invocation; happy to add a brain3-token entry point in a follow-up if/when the project becomes installable.
  3. No CHANGELOG.md entry in this PR. Recent practice on this branch (Stream F [2F-03] Rule Evaluation Engine #142/[2F-02] Rules — CRUD API Endpoints #141/[2F-04] Default Rules — Seed Data #143, Stream C [2C-26] PR [2C-26] Server: habit + routine completion history endpoints + routine idempotency #207) ships features without per-ticket CHANGELOG updates and batches them into a single docs commit at version cut (the v1.2.0 4ea98b6 commit is the prior pattern). I matched observed convention rather than the once-stated "Update CHANGELOG.md per ticket" line that no longer appears in the BRAIN-canonical org CLAUDE.md v5 or brain3 CLAUDE.md v2. If you'd prefer per-ticket entries restored, easy add — happy to either patch this PR or open a small follow-up that backfills the Stream C tickets shipped so far.

Test Results

$ ruff check .
All checks passed!

$ pytest -v
============================= test session starts =============================
collected 1351 items
...
tests/test_app_auth.py::test_missing_authorization_header_returns_401 PASSED
tests/test_app_auth.py::test_wrong_token_returns_401 PASSED
tests/test_app_auth.py::test_correct_token_returns_200 PASSED
tests/test_app_auth.py::test_unset_token_emits_warning_and_skips_middleware PASSED
tests/test_app_auth.py::test_non_app_paths_pass_through_when_middleware_active PASSED
tests/test_app_auth.py::test_empty_bearer_value_returns_401 PASSED
...
============================ 1351 passed in 17.21s ============================

Acceptance Checklist

  • python -m scripts.generate_token prints a token to stdout and nothing else on success (operator hint goes to stderr).
  • GET /api/app/health with correct Authorization: Bearer <token> → 200.
  • GET /api/app/health without the header → 401 with body {"error": "Missing bearer token"}.
  • GET /api/app/health with the wrong token → 401 with body {"error": "Invalid bearer token"}.
  • Existing /api/notifications/* and other existing routes remain unauthenticated — no regression (test_non_app_paths_pass_through_when_middleware_active).
  • tests/test_app_auth.py covers missing header, wrong token, correct token, env-unset-warning path.

… setting (#200)

Mirrors the brain3-mcp/mcp/auth.py pattern: a Starlette BaseHTTPMiddleware
that validates Authorization: Bearer <token> via hmac.compare_digest and
returns JSON 401s on missing/bad header. Scoped to /api/app/* by path
prefix so MCP and internal clients keep consuming /api/* unauthenticated.

Token sourced from settings.APP_BEARER_TOKEN backed by env var
BRAIN3_APP_BEARER_TOKEN (validation_alias on the pydantic-settings field) —
deliberately distinct from any MCP token per spec v0.9 Q1 + E3.

When the env var is unset, install_app_bearer_auth() emits a startup
WARNING and skips mounting the middleware so dev work is unblocked
without forcing a token in .env. Production deployments must set it
before exposing the app API.

Adds /api/app/health as the trivial authed probe endpoint the companion
app pairing flow calls; closes the [2C-Chunk1] dependency. The existing
unauthenticated /health stays as-is for infra probes.

Tests cover the four acceptance paths (missing header, wrong token,
correct token, env-unset warning) plus a no-regression check that
/api/notifications/* continues to answer without an Authorization header
even when the middleware is mounted.
scripts/generate_token.py — invokable as python -m scripts.generate_token —
prints a fresh secrets.token_urlsafe(48) value to stdout and a one-line
operator hint to stderr. Stdout-only-on-success keeps shell capture clean
(TOKEN=$(python -m scripts.generate_token)); the stderr hint reminds the
operator to set BRAIN3_APP_BEARER_TOKEN in the .env file and restart.

No DB write — the token is the shared secret itself. Per-device tokens
and rotation tooling are Phase 3.

Project does not currently declare [project.scripts] in pyproject.toml
(no name/version, not installable as a package), so the spec's optional
"brain3-token" entry point is documented as the python -m invocation per
the spec's "verify — otherwise document" branch.
@WilliM233
WilliM233 merged commit 5cd3b80 into develop Apr 27, 2026
2 checks passed
@WilliM233
WilliM233 deleted the feat/2C-01-bearer-middleware branch April 27, 2026 17:40
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.

[2C-01] Add app-API bearer-token middleware and brain3 token generate CLI

1 participant