From 97a73655b831112df2af765f667fe27f97b15f86 Mon Sep 17 00:00:00 2001 From: WilliM233 <30969699+WilliM233@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:53:19 -0500 Subject: [PATCH 1/2] feat(auth): add bearer middleware on /api/app/* with APP_BEARER_TOKEN setting (#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the brain3-mcp/mcp/auth.py pattern: a Starlette BaseHTTPMiddleware that validates Authorization: Bearer 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. --- .env.example | 5 ++ app/auth.py | 92 ++++++++++++++++++++++++++ app/config.py | 12 +++- app/main.py | 16 +++++ tests/test_app_auth.py | 146 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 app/auth.py create mode 100644 tests/test_app_auth.py diff --git a/.env.example b/.env.example index 36f86f3..f42c4eb 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,8 @@ API_PORT=8000 # CORS — comma-separated list of allowed origins CORS_ORIGINS=http://localhost:3000,http://localhost:8000 + +# App API bearer token — gates /api/app/* (companion app + scheduler). +# Distinct from any MCP token. Generate with: python -m scripts.generate_token +# Leave unset in dev to skip auth (a startup warning will be logged). +# BRAIN3_APP_BEARER_TOKEN= diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..849cea6 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,92 @@ +# BRAIN 3.0 — AI-powered personal operating system for ADHD +# Copyright (C) 2026 L (WilliM233) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Bearer token authentication for the App API surface (``/api/app/*``). + +Mirrors the pattern in ``brain3-mcp/mcp/auth.py``: a Starlette +``BaseHTTPMiddleware`` that validates ``Authorization: Bearer `` using +``hmac.compare_digest`` for constant-time comparison. + +Scoped by path prefix so the existing ``/api/*`` surface (consumed by +``brain3-mcp`` and other internal clients) remains unauthenticated. Only +requests under ``/api/app/`` are gated. The token is sourced from +``settings.APP_BEARER_TOKEN`` (env var ``BRAIN3_APP_BEARER_TOKEN``) and is +intentionally distinct from any MCP token — separate secrets, separate +rotation. +""" + +from __future__ import annotations + +import hmac +import logging + +from fastapi import FastAPI +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +logger = logging.getLogger(__name__) + +APP_PATH_PREFIX = "/api/app/" + + +class AppBearerAuthMiddleware(BaseHTTPMiddleware): + """Bearer auth on ``/api/app/*``. Pass-through for all other paths. + + Token comparison uses ``hmac.compare_digest`` to prevent timing attacks + against the shared secret. + """ + + def __init__(self, app, token: str) -> None: + super().__init__(app) + self.token = token + + async def dispatch(self, request: Request, call_next) -> Response: + if not request.url.path.startswith(APP_PATH_PREFIX): + return await call_next(request) + + auth_header = request.headers.get("authorization", "") + if not auth_header.startswith("Bearer "): + return JSONResponse( + {"error": "Missing bearer token"}, status_code=401, + ) + + provided = auth_header[len("Bearer ") :] + if not hmac.compare_digest(provided, self.token): + return JSONResponse( + {"error": "Invalid bearer token"}, status_code=401, + ) + + return await call_next(request) + + +def install_app_bearer_auth(app: FastAPI, token: str | None) -> None: + """Mount :class:`AppBearerAuthMiddleware` if ``token`` is set. + + When ``token`` is ``None`` or empty, the middleware is **not** mounted + and a startup warning is logged. This matches the existing graceful- + degradation pattern used elsewhere in the app and lets local dev work + without forcing a token in ``.env``. Production deployments must set + ``BRAIN3_APP_BEARER_TOKEN`` before exposing the app API. + """ + if not token: + logger.warning( + "BRAIN3_APP_BEARER_TOKEN is not set — /api/app/* endpoints are " + "UNAUTHENTICATED. Set the env var to enable bearer auth before " + "exposing the app API.", + ) + return + app.add_middleware(AppBearerAuthMiddleware, token=token) diff --git a/app/config.py b/app/config.py index 44b22e9..ef241a6 100644 --- a/app/config.py +++ b/app/config.py @@ -16,6 +16,7 @@ """Application configuration via Pydantic Settings.""" +from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -33,7 +34,16 @@ class Settings(BaseSettings): CORS_ORIGINS: str = "http://localhost:3000,http://localhost:8000" - model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + # Shared-secret bearer token gating /api/app/* (companion app + scheduler). + # Distinct from any MCP token. Unset in dev → middleware skipped with a + # startup warning. Required in production. + APP_BEARER_TOKEN: str | None = Field( + default=None, validation_alias="BRAIN3_APP_BEARER_TOKEN", + ) + + model_config = SettingsConfigDict( + env_file=".env", env_file_encoding="utf-8", populate_by_name=True, + ) @property def database_url(self) -> str: diff --git a/app/main.py b/app/main.py index f531284..bc4f357 100644 --- a/app/main.py +++ b/app/main.py @@ -21,6 +21,7 @@ from fastapi.responses import JSONResponse from sqlalchemy import text +from app.auth import install_app_bearer_auth from app.config import settings from app.database import SessionLocal from app.routers import ( @@ -57,6 +58,10 @@ allow_headers=["*"], ) +# Bearer auth on /api/app/*. Skipped with a warning when the token is unset +# (dev convenience) — production must set BRAIN3_APP_BEARER_TOKEN. +install_app_bearer_auth(app, settings.APP_BEARER_TOKEN) + @app.get("/health") def health_check() -> dict: @@ -75,6 +80,17 @@ def health_check() -> dict: ) +@app.get("/api/app/health") +def app_health_check() -> dict: + """Authed probe endpoint for the companion app's validation ping. + + Sits behind ``AppBearerAuthMiddleware``. The companion app calls this + during URL+token pairing to confirm both that the server is reachable + and that the bearer token is valid. + """ + return {"ok": True} + + app.include_router(domains.router, prefix="/api/domains", tags=["Domains"]) app.include_router(goals.router, prefix="/api/goals", tags=["Goals"]) app.include_router(projects.router, prefix="/api/projects", tags=["Projects"]) diff --git a/tests/test_app_auth.py b/tests/test_app_auth.py new file mode 100644 index 0000000..3b7a63d --- /dev/null +++ b/tests/test_app_auth.py @@ -0,0 +1,146 @@ +# BRAIN 3.0 — AI-powered personal operating system for ADHD +# Copyright (C) 2026 L (WilliM233) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Tests for the App API bearer-token middleware (``app/auth.py``). + +Covers the four acceptance paths from issue #200 — missing header, wrong +token, correct token, env-unset warning — plus the no-regression check that +non-``/api/app/*`` routes stay unauthenticated when the middleware is on. + +Each test builds its own ``FastAPI`` app rather than reusing the global +``app.main.app``: that app's middleware is wired up at import time from the +real env, so per-test scenarios (no-token, known-token) need a clean +instance. +""" + +from __future__ import annotations + +import logging + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.auth import AppBearerAuthMiddleware, install_app_bearer_auth + +TEST_TOKEN = "test-bearer-token-abc123" # noqa: S105 — fixture, not a real secret + + +def _app_with_token(token: str) -> FastAPI: + """Build a fresh app with the middleware mounted to ``token``.""" + fresh = FastAPI() + fresh.add_middleware(AppBearerAuthMiddleware, token=token) + + @fresh.get("/api/app/health") + def app_health() -> dict: + return {"ok": True} + + @fresh.get("/api/notifications/ping") + def notif_ping() -> dict: + return {"pong": True} + + return fresh + + +@pytest.fixture +def authed_client() -> TestClient: + """TestClient against a fresh app with the bearer middleware enabled.""" + return TestClient(_app_with_token(TEST_TOKEN)) + + +# --------------------------------------------------------------------------- +# Acceptance paths — issue #200 +# --------------------------------------------------------------------------- + +def test_missing_authorization_header_returns_401(authed_client: TestClient) -> None: + """No Authorization header → 401 with the missing-token error body.""" + resp = authed_client.get("/api/app/health") + assert resp.status_code == 401 + assert resp.json() == {"error": "Missing bearer token"} + + +def test_wrong_token_returns_401(authed_client: TestClient) -> None: + """Authorization header with the wrong secret → 401 invalid-token.""" + resp = authed_client.get( + "/api/app/health", + headers={"Authorization": "Bearer not-the-real-token"}, + ) + assert resp.status_code == 401 + assert resp.json() == {"error": "Invalid bearer token"} + + +def test_correct_token_returns_200(authed_client: TestClient) -> None: + """Authorization header with the right secret → endpoint runs.""" + resp = authed_client.get( + "/api/app/health", + headers={"Authorization": f"Bearer {TEST_TOKEN}"}, + ) + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + + +def test_unset_token_emits_warning_and_skips_middleware( + caplog: pytest.LogCaptureFixture, +) -> None: + """``install_app_bearer_auth`` warns and stays a no-op when token is unset.""" + fresh = FastAPI() + + @fresh.get("/api/app/health") + def app_health() -> dict: + return {"ok": True} + + with caplog.at_level(logging.WARNING, logger="app.auth"): + install_app_bearer_auth(fresh, None) + + assert any( + "BRAIN3_APP_BEARER_TOKEN is not set" in record.message + and record.levelno == logging.WARNING + for record in caplog.records + ), f"Expected unset-token warning; got: {[r.message for r in caplog.records]}" + + # Middleware was not mounted: the endpoint serves without an auth header. + with TestClient(fresh) as client: + resp = client.get("/api/app/health") + assert resp.status_code == 200 + assert resp.json() == {"ok": True} + + +# --------------------------------------------------------------------------- +# Regression — issue #200: existing /api/* routes stay unauthenticated +# --------------------------------------------------------------------------- + +def test_non_app_paths_pass_through_when_middleware_active( + authed_client: TestClient, +) -> None: + """Routes outside ``/api/app/*`` are not gated even with the middleware on. + + Guards the no-regression criterion: ``/api/notifications/*`` (and every + other existing route consumed by MCP / internal clients) must still + answer without an Authorization header. + """ + resp = authed_client.get("/api/notifications/ping") + assert resp.status_code == 200 + assert resp.json() == {"pong": True} + + +def test_empty_bearer_value_returns_401(authed_client: TestClient) -> None: + """Header present but value empty → still missing-token (no Bearer prefix).""" + resp = authed_client.get( + "/api/app/health", + headers={"Authorization": ""}, + ) + assert resp.status_code == 401 + assert resp.json() == {"error": "Missing bearer token"} From fdca304985fcebd05afd941b582e8f899b30fe29 Mon Sep 17 00:00:00 2001 From: WilliM233 <30969699+WilliM233@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:53:28 -0500 Subject: [PATCH 2/2] feat(scripts): add brain3 token generate CLI (#200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/generate_token.py | 59 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 scripts/generate_token.py diff --git a/scripts/generate_token.py b/scripts/generate_token.py new file mode 100644 index 0000000..a1d4fe8 --- /dev/null +++ b/scripts/generate_token.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# BRAIN 3.0 — AI-powered personal operating system for ADHD +# Copyright (C) 2026 L (WilliM233) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Generate a BRAIN 3.0 app-API bearer token. + +Prints a URL-safe 48-byte random token to stdout. The companion app and the +scheduler authenticate to ``/api/app/*`` with this token, so it should be +treated as a shared secret. After generating, set:: + + BRAIN3_APP_BEARER_TOKEN= + +in the ``.env`` file (or your deployment secret store), then restart the API. + +The token is the shared secret itself — there is no DB write, no user +record, no per-device state. Rotation = generate a new token, update the +env, restart the API. (Per-device tokens and rotation tooling are Phase 3.) + +Usage: + python -m scripts.generate_token +""" + +from __future__ import annotations + +import secrets +import sys + +TOKEN_BYTES = 48 + + +def main() -> None: + """Print a fresh URL-safe token to stdout, with a hint to stderr.""" + token = secrets.token_urlsafe(TOKEN_BYTES) + # Token-only on stdout so it captures cleanly: + # TOKEN=$(python -m scripts.generate_token) + print(token) + # Operator hint goes to stderr so it doesn't pollute the captured value. + print( + f"Set BRAIN3_APP_BEARER_TOKEN={token} in your .env file " + "(or secret store) and restart the API to enable /api/app/* auth.", + file=sys.stderr, + ) + + +if __name__ == "__main__": + main()