Skip to content

Commit 7b6bd1d

Browse files
committed
Add optional API-key auth and third-party CORS support
- backend/auth.py: API_KEYS env (comma-separated) enforced per-request on /api routes via router dependency; no-op when unset so the open API is unchanged until keys are configured - Keys accepted as 'Authorization: Bearer <key>' or 'X-API-Key: <key>'; own-site origins (AUTH_EXEMPT_ORIGINS, default vedicpanchanga.com + localhost dev) and the /api/ + /api/health liveness paths stay keyless - CORS: allow Authorization + X-API-Key request headers and cache the preflight verdict for 24h (max_age=86400); preflight OPTIONS is answered by CORSMiddleware before auth runs - Load backend/.env.local (gitignored) over .env so server-side keys survive the .env rewrite done by setup-vps.sh on every deploy - Add httpx (TestClient dep) and backend/tests/test_api_auth.py: 15 cases covering key checks, exemptions, and the CORS preflight contract
1 parent 59c5b65 commit 7b6bd1d

7 files changed

Lines changed: 287 additions & 5 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,5 @@ memory/test_credentials.md
8282
android-sdk/frontend/node_modules/.cache/default-development/0.pack
8383

8484
backend/.env
85+
backend/.env.local
8586
frontend/.env.production

AGENTS.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,30 @@ vedicpanchanga.com/
9494

9595
The frontend calls these directly via same-origin `/api/` (Nginx proxy in prod, Vite proxy in dev).
9696

97+
### Third-party API access (optional API keys)
98+
99+
The API is open by default. To grant external clients (mobile apps, other
100+
domains) access, set `API_KEYS` (comma-separated) in `backend/.env.local`
101+
on the server - that file is gitignored and never rewritten by
102+
`setup-vps.sh`, so keys survive deploys. Clients authenticate with either
103+
header:
104+
105+
```
106+
Authorization: Bearer <key>
107+
X-API-Key: <key>
108+
```
109+
110+
- The site's own frontend needs no key: requests whose `Origin` (or
111+
`Referer`, for same-origin GETs) matches `AUTH_EXEMPT_ORIGINS`
112+
(default: vedicpanchanga.com + localhost dev) skip the check, as do
113+
`/api/` and `/api/health` (monitoring probes).
114+
- Browser-based third-party apps (Flutter Web, React, ...) additionally
115+
need their origin added to `CORS_ORIGINS` (overridable in `.env.local`).
116+
Preflight `OPTIONS` is answered by CORSMiddleware before auth runs and
117+
allows `Content-Type, Authorization, X-API-Key` with `max_age=86400`.
118+
- Generate keys with `python3 -c "import secrets; print(secrets.token_urlsafe(32))"`.
119+
- Logic: `backend/auth.py`. Tests: `backend/tests/test_api_auth.py`.
120+
97121
---
98122

99123
## 5. Coding Standards
@@ -162,7 +186,7 @@ pytest tests/test_muhurta.py -v # single suite
162186
panchang reference data (Kelowna), ayanamsa variants, varjyam/amrit/siddhi yogas,
163187
muhurta unit + HTTP, Gowri panchangam, Hora, Tyajyam, Tamil calendar,
164188
planetary transits, dasha sub-periods, Jaimini karakas, friendships,
165-
Kalsarpa yoga, PDF rendering.
189+
Kalsarpa yoga, PDF rendering, API-key auth + CORS preflight.
166190

167191
### Test Rules
168192
- Never reduce test count. If you refactor, migrate tests - don't delete them.

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ ruff check . --fix # lint + auto-fix
5151
ruff format . # format - write
5252
ruff format --check . # format check (CI)
5353
```
54-
- Reads `CORS_ORIGINS` from `backend/.env` (optional, defaults to `*`). No MongoDB required.
54+
- Reads `CORS_ORIGINS` from `backend/.env` (optional, defaults to `*`). No MongoDB required. `backend/.env.local` (gitignored, loaded on top with override) is where server-side secrets go - `setup-vps.sh` rewrites `.env` on every deploy but never touches `.env.local`.
55+
- Optional third-party API-key auth lives in `auth.py`: `API_KEYS` (comma-separated, unset = open API) + `AUTH_EXEMPT_ORIGINS` (own-site origins that stay keyless, defaults to vedicpanchanga.com + localhost dev), both read per-request. Clients send `Authorization: Bearer <key>` or `X-API-Key: <key>`; browser apps also need their origin in `CORS_ORIGINS`. `/api/` and `/api/health` stay open for monitoring. See `AGENTS.md` §4.
5556
- Swiss Ephemeris data files live in `backend/ephe/` (`*.se1`). Calculations silently fail without them; never delete or move this directory.
5657
- Must bind to `127.0.0.1` only in production. Never expose 8001 publicly (see `AGENTS.md` §8).
5758
- The CPU-bound endpoints (`/calculate`, `/get-panchang`, `/find-muhurta`) are plain `def` handlers so FastAPI runs them in its threadpool - keeps the event loop responsive under concurrent load.

backend/auth.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Optional API-key authentication for the /api routes.
2+
3+
Enabled by setting the ``API_KEYS`` env var (comma-separated list of keys).
4+
When it is empty or unset the API stays fully open - exactly the behavior
5+
before this module existed - so local dev and the current production deploy
6+
are unaffected until keys are configured.
7+
8+
When keys are configured, a request passes if any of these hold:
9+
10+
* it carries a valid key in ``Authorization: Bearer <key>`` or ``X-API-Key``,
11+
* its ``Origin`` (or, for same-origin GETs that omit Origin, its ``Referer``)
12+
matches ``AUTH_EXEMPT_ORIGINS`` - the site's own frontend keeps working
13+
without a key,
14+
* it targets an exempt liveness path (``/api/``, ``/api/health``) so
15+
monitoring probes never need credentials.
16+
17+
CORS preflight (``OPTIONS``) never reaches this dependency: CORSMiddleware
18+
answers it before routing, as the CORS spec requires (preflights carry no
19+
credentials).
20+
21+
Honest scope note: the origin exemption trusts browser-controlled headers,
22+
which non-browser clients can spoof. Since the API was fully public before,
23+
this does not weaken anything - keys exist to grant/track third-party access
24+
(mobile apps, other domains), not to harden the first-party path.
25+
26+
Env vars are read per-request (parsing a short string is negligible) so keys
27+
can be tested with monkeypatch and rotated with a plain service restart.
28+
"""
29+
30+
import hmac
31+
import os
32+
33+
from fastapi import HTTPException, Request
34+
35+
# Liveness endpoints stay open even with keys configured.
36+
EXEMPT_PATHS = {"/api", "/api/", "/api/health"}
37+
38+
_DEFAULT_EXEMPT_ORIGINS = (
39+
"https://vedicpanchanga.com,https://www.vedicpanchanga.com,"
40+
"http://localhost:3121,http://127.0.0.1:3121"
41+
)
42+
43+
44+
def _csv_env(name: str, default: str = "") -> list[str]:
45+
raw = os.environ.get(name, default)
46+
return [item.strip() for item in raw.split(",") if item.strip()]
47+
48+
49+
def configured_keys() -> list[str]:
50+
return _csv_env("API_KEYS")
51+
52+
53+
def exempt_origins() -> list[str]:
54+
return [
55+
o.rstrip("/").lower()
56+
for o in _csv_env("AUTH_EXEMPT_ORIGINS", _DEFAULT_EXEMPT_ORIGINS)
57+
]
58+
59+
60+
def _extract_key(request: Request) -> str:
61+
auth = request.headers.get("authorization", "")
62+
scheme, _, token = auth.partition(" ")
63+
if scheme.lower() == "bearer" and token.strip():
64+
return token.strip()
65+
return request.headers.get("x-api-key", "").strip()
66+
67+
68+
def _is_exempt_origin(request: Request) -> bool:
69+
allowed = exempt_origins()
70+
origin = request.headers.get("origin", "").rstrip("/").lower()
71+
if origin:
72+
return origin in allowed
73+
# Same-origin GET/HEAD fetches omit Origin; fall back to Referer.
74+
referer = request.headers.get("referer", "").lower()
75+
return any(referer == o or referer.startswith(f"{o}/") for o in allowed if referer)
76+
77+
78+
def require_api_key(request: Request) -> None:
79+
"""FastAPI dependency enforcing the policy described in the module docstring."""
80+
keys = configured_keys()
81+
if not keys:
82+
return
83+
if request.url.path in EXEMPT_PATHS:
84+
return
85+
supplied = _extract_key(request)
86+
# compare_digest over every key keeps the comparison timing-safe.
87+
if supplied and any(hmac.compare_digest(supplied, k) for k in keys):
88+
return
89+
if _is_exempt_origin(request):
90+
return
91+
raise HTTPException(
92+
status_code=401,
93+
detail=(
94+
"Missing or invalid API key. Send it as 'Authorization: Bearer "
95+
"<key>' or 'X-API-Key: <key>'."
96+
),
97+
headers={"WWW-Authenticate": "Bearer"},
98+
)

backend/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ timezonefinder==8.2.0
77
python-dotenv==1.2.1
88
pytest==8.4.2
99
requests==2.32.5
10+
httpx==0.28.1
1011
fpdf2==2.8.4
1112
uharfbuzz==0.39.3
1213
orjson==3.11.9

backend/server.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@
88
from typing import Literal, Optional
99

1010
from dotenv import load_dotenv
11-
from fastapi import APIRouter, FastAPI, HTTPException, Request
11+
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request
1212
from fastapi.middleware.gzip import GZipMiddleware
1313
from fastapi.responses import JSONResponse, ORJSONResponse, Response
1414
from prometheus_fastapi_instrumentator import Instrumentator
1515
from pydantic import BaseModel, Field
1616
from starlette.middleware.cors import CORSMiddleware
1717

1818
from advanced_panchang import compute_detailed_panchang
19+
from auth import require_api_key
1920
from ayanamsa import AYANAMSA_OPTIONS
2021
from calculator import compute_chart
2122
from muhurta import find_muhurtas, list_purposes
@@ -24,11 +25,16 @@
2425

2526
ROOT_DIR = Path(__file__).parent
2627
load_dotenv(ROOT_DIR / ".env")
28+
# Server-side overrides (API_KEYS, AUTH_EXEMPT_ORIGINS, custom CORS_ORIGINS)
29+
# live in .env.local: gitignored and never rewritten by setup-vps.sh, so
30+
# secrets survive deploys. Values here win over .env.
31+
load_dotenv(ROOT_DIR / ".env.local", override=True)
2732

2833
# ORJSONResponse serializes the ~100 KB panchang payloads several times
2934
# faster than the stdlib json encoder FastAPI defaults to.
3035
app = FastAPI(title="Vedic Astrology API", default_response_class=ORJSONResponse)
31-
api_router = APIRouter(prefix="/api")
36+
# API-key auth is a no-op until API_KEYS is set in the env - see auth.py.
37+
api_router = APIRouter(prefix="/api", dependencies=[Depends(require_api_key)])
3238

3339

3440
class CalculateRequest(BaseModel):
@@ -498,14 +504,19 @@ def print_pdf(req: PrintPdfRequest):
498504

499505
app.include_router(api_router)
500506

507+
# Third-party browser apps (Flutter Web, React, ...) calling with an API key
508+
# need their origin added to CORS_ORIGINS. Preflight OPTIONS is answered here,
509+
# before routing, so it never hits the auth dependency; max_age lets browsers
510+
# cache the preflight verdict for a day.
501511
app.add_middleware(
502512
CORSMiddleware,
503513
allow_credentials=True,
504514
allow_origins=os.environ.get(
505515
"CORS_ORIGINS", "https://vedicpanchanga.com,http://localhost:3121"
506516
).split(","),
507517
allow_methods=["GET", "POST", "OPTIONS"],
508-
allow_headers=["Content-Type"],
518+
allow_headers=["Content-Type", "Authorization", "X-API-Key"],
519+
max_age=86400,
509520
)
510521

511522
# Nginx gzips in production; this covers the dev server and any path that

backend/tests/test_api_auth.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""In-process tests for the optional API-key layer (auth.py) and the CORS
2+
contract that external browser apps (Flutter Web, React, ...) rely on.
3+
4+
Uses FastAPI's TestClient, so no running server is needed. API_KEYS /
5+
AUTH_EXEMPT_ORIGINS are read per-request by auth.py, which is what makes
6+
monkeypatch.setenv work here without reloading the app.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import pytest
12+
from fastapi.testclient import TestClient
13+
14+
from server import app
15+
16+
KEY = "test-key-abc123"
17+
SECOND_KEY = "second-key-xyz789"
18+
19+
# Cheap authenticated endpoint - static payload, no ephemeris math.
20+
PROBE = "/api/ayanamsa-options"
21+
22+
23+
@pytest.fixture(scope="module")
24+
def client():
25+
return TestClient(app)
26+
27+
28+
@pytest.fixture
29+
def auth_on(monkeypatch):
30+
# Space after the comma on purpose: keys must be stripped when parsed.
31+
monkeypatch.setenv("API_KEYS", f"{KEY}, {SECOND_KEY}")
32+
33+
34+
@pytest.fixture
35+
def auth_off(monkeypatch):
36+
monkeypatch.delenv("API_KEYS", raising=False)
37+
38+
39+
# ── auth disabled (default) ──────────────────────────────────────────────
40+
def test_open_access_when_no_keys_configured(client, auth_off):
41+
assert client.get(PROBE).status_code == 200
42+
43+
44+
def test_empty_api_keys_var_means_open(client, monkeypatch):
45+
monkeypatch.setenv("API_KEYS", " , ")
46+
assert client.get(PROBE).status_code == 200
47+
48+
49+
# ── auth enabled: key checks ─────────────────────────────────────────────
50+
def test_401_without_key(client, auth_on):
51+
r = client.get(PROBE)
52+
assert r.status_code == 401
53+
assert r.headers.get("www-authenticate") == "Bearer"
54+
55+
56+
def test_bearer_key_accepted(client, auth_on):
57+
r = client.get(PROBE, headers={"Authorization": f"Bearer {KEY}"})
58+
assert r.status_code == 200
59+
60+
61+
def test_x_api_key_accepted(client, auth_on):
62+
r = client.get(PROBE, headers={"X-API-Key": KEY})
63+
assert r.status_code == 200
64+
65+
66+
def test_second_key_in_csv_accepted(client, auth_on):
67+
r = client.get(PROBE, headers={"X-API-Key": SECOND_KEY})
68+
assert r.status_code == 200
69+
70+
71+
def test_wrong_key_rejected(client, auth_on):
72+
r = client.get(PROBE, headers={"X-API-Key": "not-a-real-key"})
73+
assert r.status_code == 401
74+
75+
76+
def test_wrong_bearer_scheme_rejected(client, auth_on):
77+
r = client.get(PROBE, headers={"Authorization": f"Basic {KEY}"})
78+
assert r.status_code == 401
79+
80+
81+
# ── auth enabled: exemptions ─────────────────────────────────────────────
82+
def test_liveness_paths_stay_open(client, auth_on):
83+
assert client.get("/api/").status_code == 200
84+
assert client.get("/api/health").status_code in (200, 503)
85+
86+
87+
def test_exempt_origin_needs_no_key(client, auth_on):
88+
# localhost:3121 is in the default AUTH_EXEMPT_ORIGINS (Vite dev server).
89+
r = client.get(PROBE, headers={"Origin": "http://localhost:3121"})
90+
assert r.status_code == 200
91+
92+
93+
def test_referer_fallback_for_same_origin_get(client, auth_on):
94+
# Same-origin GET fetches omit Origin; the Referer prefix must match.
95+
r = client.get(PROBE, headers={"Referer": "https://vedicpanchanga.com/panchang"})
96+
assert r.status_code == 200
97+
98+
99+
def test_unknown_origin_still_needs_key(client, auth_on):
100+
headers = {"Origin": "https://myexampledomain.com"}
101+
assert client.get(PROBE, headers=headers).status_code == 401
102+
headers["X-API-Key"] = KEY
103+
assert client.get(PROBE, headers=headers).status_code == 200
104+
105+
106+
def test_custom_exempt_origins_override(client, auth_on, monkeypatch):
107+
monkeypatch.setenv("AUTH_EXEMPT_ORIGINS", "https://partner.example")
108+
r = client.get(PROBE, headers={"Origin": "https://partner.example"})
109+
assert r.status_code == 200
110+
# The default exemptions are replaced, not extended.
111+
r = client.get(PROBE, headers={"Origin": "http://localhost:3121"})
112+
assert r.status_code == 401
113+
114+
115+
# ── CORS preflight contract ──────────────────────────────────────────────
116+
def test_preflight_succeeds_without_key(client, auth_on):
117+
"""OPTIONS /api/calculate must succeed with the CORS headers browser apps
118+
need, even with auth enabled - preflights never carry credentials."""
119+
r = client.options(
120+
"/api/calculate",
121+
headers={
122+
# localhost:3121 is in the dev CORS_ORIGINS allowlist (.env).
123+
"Origin": "http://localhost:3121",
124+
"Access-Control-Request-Method": "POST",
125+
"Access-Control-Request-Headers": "content-type,x-api-key",
126+
},
127+
)
128+
assert r.status_code == 200
129+
assert r.headers["access-control-allow-origin"] == "http://localhost:3121"
130+
assert "POST" in r.headers["access-control-allow-methods"]
131+
allow_headers = r.headers["access-control-allow-headers"].lower()
132+
assert "authorization" in allow_headers
133+
assert "x-api-key" in allow_headers
134+
assert r.headers["access-control-max-age"] == "86400"
135+
136+
137+
def test_preflight_rejects_origin_outside_cors_allowlist(client, auth_on):
138+
r = client.options(
139+
"/api/calculate",
140+
headers={
141+
"Origin": "https://not-in-allowlist.example",
142+
"Access-Control-Request-Method": "POST",
143+
},
144+
)
145+
assert r.status_code == 400
146+
assert "access-control-allow-origin" not in r.headers

0 commit comments

Comments
 (0)