Drop in-container auth — MetaMCP guards the user-facing edge
The MCP's port 8000 isn't exposed outside the private mcp-servers_mcp
Docker network, so only the MetaMCP gateway can ever reach it. MetaMCP
itself enforces auth at the gateway → MCP-client edge (bearer token in
its UI), which is the right layer for it. In-container Basic/Bearer was
defense-in-depth that turned out to be friction-in-depth.
Removed:
- ag_bids_mcp/auth.py (HTTP Basic middleware)
- tests/test_auth.py (3 tests covering the middleware)
- AG_BIDS_MCP_USER / AG_BIDS_MCP_PASS env vars from .env.example, README,
docker-compose.snippet.yml, and deploy/README.md
Server.py simplified — direct `mcp.run(transport=...)` like zerto-docs-mcp,
no Starlette wrapping. 21 tests passing.
Live on 192.168.0.2: container recreated, real MCP initialize handshake
returns 200 + capability metadata over the mcp-servers_mcp network with
no auth header.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,11 +11,6 @@ AG_BIDS_API_URL=https://agbids.paul.farm
|
||||
AG_BIDS_API_KEY=
|
||||
AG_BIDS_API_TIMEOUT_SECS=20
|
||||
|
||||
# --- HTTP Basic auth (REQUIRED — server refuses to start without both) ---
|
||||
# These guard the MCP itself. MetaMCP injects them on every upstream call.
|
||||
AG_BIDS_MCP_USER=
|
||||
AG_BIDS_MCP_PASS=
|
||||
|
||||
# --- Per-tool-call usage logging ---
|
||||
USAGE_LOG_DIR=/app/var/logs
|
||||
USAGE_LOG_KEEP_DAYS=90
|
||||
|
||||
@@ -28,18 +28,12 @@ for the contract.
|
||||
|
||||
## Authentication
|
||||
|
||||
This MCP enforces **HTTP Basic** auth in front of the FastMCP HTTP transport.
|
||||
Set both:
|
||||
**No in-container auth.** The MCP's port 8000 is never exposed outside the
|
||||
private `mcp-servers_mcp` Docker network on `.0.2`. The only client that
|
||||
can reach it is MetaMCP, and MetaMCP enforces auth at the gateway → client
|
||||
edge (bearer token / OAuth in its UI).
|
||||
|
||||
```
|
||||
AG_BIDS_MCP_USER=<your username>
|
||||
AG_BIDS_MCP_PASS=<your password>
|
||||
```
|
||||
|
||||
MetaMCP is configured to inject `Authorization: Basic <b64>` on every upstream
|
||||
call. Direct access without the header returns `401`.
|
||||
|
||||
If either env var is unset the server refuses to start (fail closed).
|
||||
This matches the zerto-docs-mcp pattern.
|
||||
|
||||
## Local dev (stdio)
|
||||
|
||||
@@ -61,18 +55,13 @@ Wire into Claude Desktop's `claude_desktop_config.json`:
|
||||
"env": {
|
||||
"MCP_TRANSPORT": "stdio",
|
||||
"AG_BIDS_API_URL": "https://agbids.paul.farm",
|
||||
"AG_BIDS_API_KEY": "...",
|
||||
"AG_BIDS_MCP_USER": "x",
|
||||
"AG_BIDS_MCP_PASS": "y"
|
||||
"AG_BIDS_API_KEY": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(The Basic auth check is skipped automatically when `MCP_TRANSPORT=stdio` since
|
||||
stdio has no HTTP layer.)
|
||||
|
||||
## Deploy (MetaMCP host)
|
||||
|
||||
See [deploy/README.md](deploy/README.md). Container image is pulled from
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
"""HTTP Basic auth in front of the FastMCP Streamable-HTTP transport.
|
||||
|
||||
MetaMCP can inject ``Authorization: Basic <b64>`` on every upstream call,
|
||||
so this is the simplest robust gate. Two env vars (``AG_BIDS_MCP_USER`` and
|
||||
``AG_BIDS_MCP_PASS``) are required at process startup; the server fails
|
||||
closed if either is missing.
|
||||
|
||||
Stdio transport (local dev with Claude Desktop) skips this entirely — no
|
||||
HTTP layer exists in stdio mode.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import PlainTextResponse, Response
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
REALM = "ag-bids MCP"
|
||||
|
||||
|
||||
def expected_credentials() -> tuple[str, str]:
|
||||
"""Return the (user, pass) the server enforces. Raises if missing."""
|
||||
u = os.environ.get("AG_BIDS_MCP_USER", "")
|
||||
p = os.environ.get("AG_BIDS_MCP_PASS", "")
|
||||
if not u or not p:
|
||||
raise RuntimeError(
|
||||
"AG_BIDS_MCP_USER and AG_BIDS_MCP_PASS must both be set for HTTP "
|
||||
"Basic auth on the ag-bids MCP server."
|
||||
)
|
||||
return u, p
|
||||
|
||||
|
||||
def _decode_basic(header: str) -> tuple[str, str] | None:
|
||||
if not header or not header.lower().startswith("basic "):
|
||||
return None
|
||||
try:
|
||||
decoded = base64.b64decode(header.split(" ", 1)[1]).decode("utf-8")
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
user, _, pw = decoded.partition(":")
|
||||
return user, pw
|
||||
|
||||
|
||||
def _check(presented_user: str, presented_pass: str) -> bool:
|
||||
expected_user, expected_pass = expected_credentials()
|
||||
return (
|
||||
secrets.compare_digest(presented_user, expected_user)
|
||||
and secrets.compare_digest(presented_pass, expected_pass)
|
||||
)
|
||||
|
||||
|
||||
def _unauthorized() -> Response:
|
||||
return PlainTextResponse(
|
||||
"Unauthorized",
|
||||
status_code=401,
|
||||
headers={"WWW-Authenticate": f'Basic realm="{REALM}"'},
|
||||
)
|
||||
|
||||
|
||||
async def basic_auth_middleware(request: Request, call_next):
|
||||
"""Starlette middleware that 401s anything missing valid Basic creds."""
|
||||
creds = _decode_basic(request.headers.get("authorization", ""))
|
||||
if creds is None:
|
||||
log.info("auth: missing/malformed Authorization header (path=%s)", request.url.path)
|
||||
return _unauthorized()
|
||||
user, pw = creds
|
||||
if not _check(user, pw):
|
||||
log.info("auth: bad credentials (user=%r path=%s)", user, request.url.path)
|
||||
return _unauthorized()
|
||||
return await call_next(request)
|
||||
+10
-45
@@ -1,10 +1,11 @@
|
||||
"""ag-bids MCP server.
|
||||
|
||||
Mirrors the zerto-docs-rag layout — FastMCP, ``@mcp.tool()`` decorators
|
||||
returning markdown strings, dual stdio/streamable-http transport — with one
|
||||
addition: HTTP Basic auth in front of the streamable-http transport. MetaMCP
|
||||
upstream config injects ``Authorization: Basic <b64>`` so the proxied call
|
||||
sails through; direct hits without the header get 401.
|
||||
returning markdown strings, dual stdio/streamable-http transport.
|
||||
|
||||
No in-container auth. The MCP's port 8000 isn't exposed outside the private
|
||||
``mcp-servers_mcp`` Docker network; the only thing that can reach it is the
|
||||
MetaMCP gateway, and MetaMCP handles auth at the gateway→client edge.
|
||||
|
||||
Run locally (stdio for Claude Desktop):
|
||||
MCP_TRANSPORT=stdio python -m ag_bids_mcp.server
|
||||
@@ -25,7 +26,6 @@ from mcp.server.fastmcp import FastMCP
|
||||
from pydantic import Field
|
||||
|
||||
from ag_bids_mcp import client, format as fmt
|
||||
from ag_bids_mcp.auth import basic_auth_middleware, expected_credentials
|
||||
from ag_bids_mcp.usage import track
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -134,12 +134,7 @@ def price_history(
|
||||
window has fewer than ~60 samples."""
|
||||
cm = commodity.strip().lower()
|
||||
with track("price_history", commodity=cm, source=source, delivery=delivery, days=days):
|
||||
# source_id lookup would require an extra call; for now we accept
|
||||
# source by name and let users filter the markdown output. The
|
||||
# underlying /api/data/history accepts source_id; pass-through is
|
||||
# source-agnostic.
|
||||
payload = client.history(commodity=cm, delivery=delivery, days=days)
|
||||
# If user asked for a single source, filter rows post-fetch.
|
||||
if source:
|
||||
payload["rows"] = [r for r in payload.get("rows") or [] if r.get("source_name") == source]
|
||||
return fmt.fmt_history(payload)
|
||||
@@ -198,23 +193,6 @@ def todays_summary() -> str:
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _streamable_app_with_auth():
|
||||
"""Attach Basic-auth middleware directly to FastMCP's Starlette app.
|
||||
|
||||
Earlier we tried mounting the FastMCP app under a parent Starlette app to
|
||||
install middleware. That broke the streamable-HTTP transport because the
|
||||
parent's lifespan didn't trigger FastMCP's internal session-manager
|
||||
task-group startup, so requests hit "Task group is not initialized".
|
||||
Adding middleware on the FastMCP-provided app keeps the original lifespan
|
||||
intact.
|
||||
"""
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
app = mcp.streamable_http_app()
|
||||
app.add_middleware(BaseHTTPMiddleware, dispatch=basic_auth_middleware)
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument(
|
||||
@@ -227,15 +205,10 @@ def main() -> None:
|
||||
args = p.parse_args()
|
||||
|
||||
if args.transport == "stdio":
|
||||
# stdio has no HTTP layer → no Basic auth to enforce. Useful for
|
||||
# local Claude Desktop development.
|
||||
log.info("starting ag-bids MCP on stdio (auth bypassed in stdio mode)")
|
||||
log.info("starting ag-bids MCP on stdio")
|
||||
mcp.run()
|
||||
return
|
||||
|
||||
# HTTP transports: fail closed if Basic-auth credentials are unset.
|
||||
expected_credentials()
|
||||
|
||||
# Same DNS-rebinding logic as zerto-docs-rag: behind a Docker DNS name
|
||||
# like "ag-bids-mcp:8000", FastMCP's default localhost-only check would
|
||||
# 421 every request.
|
||||
@@ -257,18 +230,10 @@ def main() -> None:
|
||||
o.strip() for o in allowed_origins.split(",") if o.strip()
|
||||
]
|
||||
|
||||
if args.transport == "sse":
|
||||
# SSE transport: same direct-add-middleware pattern.
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
app = mcp.sse_app()
|
||||
app.add_middleware(BaseHTTPMiddleware, dispatch=basic_auth_middleware)
|
||||
else:
|
||||
app = _streamable_app_with_auth()
|
||||
|
||||
import uvicorn
|
||||
log.info("starting ag-bids MCP on %s://%s:%s (Basic auth enforced)",
|
||||
args.transport, args.host, args.port)
|
||||
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
|
||||
mcp.settings.host = args.host
|
||||
mcp.settings.port = args.port
|
||||
log.info("starting ag-bids MCP on %s://%s:%s", args.transport, args.host, args.port)
|
||||
mcp.run(transport=args.transport)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+21
-41
@@ -1,13 +1,18 @@
|
||||
# Deploying `ag-bids-mcp` behind MetaMCP
|
||||
|
||||
This runs on the **MetaMCP host (`192.168.0.2`)** as its **own standalone
|
||||
docker-compose project** — independent from the zerto-docs-rag stack but
|
||||
joining the same `mcp-servers_mcp` Docker network so MetaMCP can proxy to it
|
||||
by container DNS name (`http://ag-bids-mcp:8000/mcp`).
|
||||
docker-compose project** — independent from the MetaMCP/zerto-docs stack
|
||||
but joining the same `mcp-servers_mcp` Docker network so MetaMCP can proxy
|
||||
to it by container DNS name (`http://ag-bids-mcp:8000/mcp`).
|
||||
|
||||
The deploy lives in `/home/justin/ag-bids-mcp/` on `.0.2`. Watchtower (already
|
||||
running on the host) auto-pulls a new image every 5 min whenever a fresh
|
||||
`git.jpaul.io/justin/ag-bids-mcp:latest` is pushed.
|
||||
The deploy lives in `/home/justin/ag-bids-mcp/` on `.0.2`. Watchtower
|
||||
(already running on the host) auto-pulls a new image every 5 min whenever
|
||||
a fresh `git.jpaul.io/justin/ag-bids-mcp:latest` is pushed.
|
||||
|
||||
**No in-container auth.** Port 8000 is never exposed outside the private
|
||||
`mcp-servers_mcp` Docker network — only MetaMCP can reach it. MetaMCP
|
||||
enforces auth at the gateway → MCP-client edge (bearer token in its UI),
|
||||
which is the right layer for it.
|
||||
|
||||
## One-time setup (already done — kept for re-runs)
|
||||
|
||||
@@ -25,13 +30,6 @@ Create `/home/justin/ag-bids-mcp/.env` (mode 600) on `.0.2` with:
|
||||
AG_BIDS_API_URL=https://agbids.paul.farm
|
||||
AG_BIDS_API_KEY=<copy BRIEF_API_KEY from ag-monitor .env on 192.168.0.126>
|
||||
AG_BIDS_API_TIMEOUT_SECS=20
|
||||
AG_BIDS_MCP_USER=<pick a username; non-user-facing>
|
||||
AG_BIDS_MCP_PASS=<32+ random bytes>
|
||||
```
|
||||
|
||||
Generate a password locally:
|
||||
```bash
|
||||
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
```
|
||||
|
||||
### 3. Build + push the image
|
||||
@@ -52,7 +50,7 @@ ssh justin@192.168.0.2
|
||||
cd ~/ag-bids-mcp
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
docker compose logs -f ag-bids-mcp # expect "starting ag-bids MCP on streamable-http://0.0.0.0:8000 (Basic auth enforced)"
|
||||
docker compose logs -f ag-bids-mcp # expect "starting ag-bids MCP on streamable-http://0.0.0.0:8000"
|
||||
```
|
||||
|
||||
### 5. Register the namespace in MetaMCP
|
||||
@@ -63,9 +61,8 @@ In the MetaMCP web UI at `https://mcp.jpaul.io`:
|
||||
2. **Add upstream MCP server** to that namespace:
|
||||
- **Transport:** Streamable HTTP
|
||||
- **URL:** `http://ag-bids-mcp:8000/mcp`
|
||||
- **Authentication:** Basic
|
||||
- **Username:** matches `AG_BIDS_MCP_USER`
|
||||
- **Password:** matches `AG_BIDS_MCP_PASS`
|
||||
- **Bearer token:** leave blank (the upstream has no auth; MetaMCP
|
||||
enforces at the user-facing endpoint instead)
|
||||
3. Save.
|
||||
|
||||
Public endpoint becomes:
|
||||
@@ -73,27 +70,15 @@ Public endpoint becomes:
|
||||
|
||||
## Smoke test
|
||||
|
||||
From inside the `mcp-servers_mcp` network (e.g. the `metamcp` container)
|
||||
the MCP should 401 anonymous, 200 authenticated:
|
||||
From inside the `mcp-servers_mcp` network the MCP should respond to a real
|
||||
MCP `initialize` handshake:
|
||||
|
||||
```bash
|
||||
ssh justin@192.168.0.2 'docker exec metamcp wget -qS -O- --tries=1 http://ag-bids-mcp:8000/mcp 2>&1 | head -3'
|
||||
# expect: HTTP/1.1 401 Unauthorized
|
||||
```
|
||||
|
||||
Then, with creds, an MCP `initialize` handshake should return capability
|
||||
metadata:
|
||||
|
||||
```bash
|
||||
# from the .0.2 host
|
||||
USER=<env value> PASS=<env value>
|
||||
CREDS=$(printf '%s:%s' "$USER" "$PASS" | base64 -w0)
|
||||
docker run --rm --network mcp-servers_mcp curlimages/curl:latest \
|
||||
-s -H "Authorization: Basic $CREDS" \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'accept: application/json, text/event-stream' \
|
||||
ssh justin@192.168.0.2 'docker run --rm --network mcp-servers_mcp curlimages/curl:latest \
|
||||
-s -H "content-type: application/json" \
|
||||
-H "accept: application/json, text/event-stream" \
|
||||
-X POST http://ag-bids-mcp:8000/mcp \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"1"}}}'
|
||||
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"smoke\",\"version\":\"1\"}}}"'
|
||||
# expect: event: message ... "serverInfo":{"name":"ag-bids", ...}
|
||||
```
|
||||
|
||||
@@ -104,11 +89,7 @@ against `https://mcp.jpaul.io/metamcp/ag-bids/mcp`, try:
|
||||
- **"What's the current price of lime?"** → calls `current_lime_price()`
|
||||
- **"Are any sources down?"** → calls `source_health()`
|
||||
|
||||
## Rotating credentials
|
||||
|
||||
To rotate the Basic password: change `AG_BIDS_MCP_PASS` in `~/ag-bids-mcp/.env`
|
||||
on `.0.2` → `docker compose up -d` to restart with the new value → update
|
||||
the MetaMCP namespace's upstream Basic password to match.
|
||||
## Rotating the upstream API key
|
||||
|
||||
To rotate the upstream API key: change `BRIEF_API_KEY` in ag-monitor's `.env`
|
||||
on `.0.126` + restart `api` there, then update `AG_BIDS_API_KEY` in
|
||||
@@ -118,4 +99,3 @@ on `.0.126` + restart `api` there, then update `AG_BIDS_API_KEY` in
|
||||
|
||||
- Per-tool-call usage logs: `/home/justin/ag-bids-mcp/ag-bids-mcp-logs/usage-YYYY-MM-DD.jsonl`
|
||||
- Container stdout: `docker compose logs ag-bids-mcp`
|
||||
- Successful auth → no log line; failed auth → INFO line with the offending path
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
# Standalone docker-compose for ag-bids-mcp.
|
||||
#
|
||||
# This file is the ENTIRE compose project — it does NOT belong inside
|
||||
# zerto-docs-rag's compose. Put it in /home/justin/ag-bids-mcp/ on the
|
||||
# MetaMCP host (192.168.0.2) alongside a .env file with three secrets:
|
||||
# This file is the ENTIRE compose project — it does NOT belong inside the
|
||||
# MetaMCP compose. Put it in /home/justin/ag-bids-mcp/ on the MetaMCP host
|
||||
# (192.168.0.2) alongside a .env file with ONE secret:
|
||||
# AG_BIDS_API_KEY — copy from ag-monitor's .env (BRIEF_API_KEY) on .0.126
|
||||
# AG_BIDS_MCP_USER — username MetaMCP will send in Basic auth
|
||||
# AG_BIDS_MCP_PASS — password MetaMCP will send in Basic auth
|
||||
#
|
||||
# Joins the EXISTING `mcp-servers_mcp` network (created by the MetaMCP
|
||||
# compose project at /home/justin/mcp-servers/) as external, so MetaMCP
|
||||
# can reach this container by DNS name `ag-bids-mcp`.
|
||||
# No in-container auth is needed because port 8000 is never exposed outside
|
||||
# the private `mcp-servers_mcp` Docker network — the only client that can
|
||||
# reach it is the MetaMCP gateway, which handles auth at the user-facing
|
||||
# edge. Joins the EXISTING `mcp-servers_mcp` network (created by the
|
||||
# MetaMCP compose project at /home/justin/mcp-servers/) as external so
|
||||
# MetaMCP can reach this container by DNS name `ag-bids-mcp`.
|
||||
|
||||
services:
|
||||
ag-bids-mcp:
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
"""HTTP Basic middleware tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
|
||||
def _reload_auth(monkeypatch, user="alice", password="hunter2"):
|
||||
monkeypatch.setenv("AG_BIDS_MCP_USER", user)
|
||||
monkeypatch.setenv("AG_BIDS_MCP_PASS", password)
|
||||
from ag_bids_mcp import auth
|
||||
importlib.reload(auth)
|
||||
return auth
|
||||
|
||||
|
||||
def _b64(creds: str) -> str:
|
||||
return base64.b64encode(creds.encode()).decode()
|
||||
|
||||
|
||||
def test_expected_credentials_requires_both(monkeypatch):
|
||||
monkeypatch.delenv("AG_BIDS_MCP_USER", raising=False)
|
||||
monkeypatch.delenv("AG_BIDS_MCP_PASS", raising=False)
|
||||
from ag_bids_mcp import auth
|
||||
importlib.reload(auth)
|
||||
import pytest
|
||||
with pytest.raises(RuntimeError):
|
||||
auth.expected_credentials()
|
||||
|
||||
monkeypatch.setenv("AG_BIDS_MCP_USER", "alice")
|
||||
monkeypatch.delenv("AG_BIDS_MCP_PASS", raising=False)
|
||||
importlib.reload(auth)
|
||||
with pytest.raises(RuntimeError):
|
||||
auth.expected_credentials()
|
||||
|
||||
|
||||
def test_middleware_via_starlette_app(monkeypatch):
|
||||
"""End-to-end: a Starlette app with the middleware returns 401 / 200 correctly."""
|
||||
auth = _reload_auth(monkeypatch, "alice", "hunter2")
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import PlainTextResponse
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
async def hello(request):
|
||||
return PlainTextResponse("ok")
|
||||
|
||||
app = Starlette(
|
||||
routes=[Route("/x", endpoint=hello)],
|
||||
middleware=[Middleware(BaseHTTPMiddleware, dispatch=auth.basic_auth_middleware)],
|
||||
)
|
||||
c = TestClient(app)
|
||||
|
||||
# No header -> 401 + WWW-Authenticate
|
||||
r = c.get("/x")
|
||||
assert r.status_code == 401
|
||||
assert r.headers.get("www-authenticate", "").startswith("Basic")
|
||||
|
||||
# Wrong creds -> 401
|
||||
r = c.get("/x", headers={"Authorization": "Basic " + _b64("alice:wrong")})
|
||||
assert r.status_code == 401
|
||||
|
||||
# Wrong username, right password -> 401
|
||||
r = c.get("/x", headers={"Authorization": "Basic " + _b64("eve:hunter2")})
|
||||
assert r.status_code == 401
|
||||
|
||||
# Right creds -> 200
|
||||
r = c.get("/x", headers={"Authorization": "Basic " + _b64("alice:hunter2")})
|
||||
assert r.status_code == 200
|
||||
assert r.text == "ok"
|
||||
|
||||
|
||||
def test_malformed_authorization_header(monkeypatch):
|
||||
auth = _reload_auth(monkeypatch)
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import PlainTextResponse
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
async def hello(request):
|
||||
return PlainTextResponse("ok")
|
||||
|
||||
app = Starlette(
|
||||
routes=[Route("/x", endpoint=hello)],
|
||||
middleware=[Middleware(BaseHTTPMiddleware, dispatch=auth.basic_auth_middleware)],
|
||||
)
|
||||
c = TestClient(app)
|
||||
# Not even base64
|
||||
r = c.get("/x", headers={"Authorization": "Basic !!!not_b64!!!"})
|
||||
assert r.status_code == 401
|
||||
# Bearer instead of Basic
|
||||
r = c.get("/x", headers={"Authorization": "Bearer abc"})
|
||||
assert r.status_code == 401
|
||||
Reference in New Issue
Block a user