Skip to content

Commit 1c5e71c

Browse files
feat(auth): add PUBLIC_IPS env var and cookie-based session auth
- Add _parse_public_ips() to populate IPConfig.public_ips from comma-separated PUBLIC_IPS env var - get_current_user falls back to session_token cookie when no Bearer token is present - /token and /auth/login endpoints set httponly session_token cookie on successful authentication - Add 12 unit tests and 4 e2e tests covering IP whitelisting and cookie auth paths Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c125cf4 commit 1c5e71c

File tree

4 files changed

+247
-15
lines changed

4 files changed

+247
-15
lines changed

app/main.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from decouple import config
1515
from fastapi import APIRouter, Depends, FastAPI, Form, HTTPException, Request, status
1616
from fastapi.middleware.cors import CORSMiddleware
17-
from fastapi.responses import HTMLResponse, RedirectResponse
17+
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
1818
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
1919
from fastapi.templating import Jinja2Templates
2020
from icecream import ic
@@ -86,12 +86,17 @@
8686
DISABLE_IP_WHITELIST = config("DISABLE_IP_WHITELIST", default=False, cast=bool)
8787

8888

89+
def _parse_public_ips() -> list[str]:
90+
raw = config("PUBLIC_IPS", default="")
91+
return [ip.strip() for ip in raw.split(",") if ip.strip()]
92+
93+
8994
class IPConfig(BaseModel):
9095
whitelist: list[str] = ["localhost", "127.0.0.1"]
9196
public_ips: list[str] = []
9297

9398

94-
ip_config = IPConfig()
99+
ip_config = IPConfig(public_ips=_parse_public_ips())
95100

96101

97102
def is_ip_allowed(request: Request):
@@ -208,8 +213,10 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None):
208213
return encoded_jwt
209214

210215

211-
async def get_current_user(token: str | None = Depends(oauth2_scheme)):
212-
"""Get current user"""
216+
async def get_current_user(request: Request, token: str | None = Depends(oauth2_scheme)):
217+
"""Get current user from Bearer token or session_token cookie."""
218+
if token is None:
219+
token = request.cookies.get("session_token")
213220
if token is None:
214221
return None
215222
credentials_exception = HTTPException(
@@ -280,7 +287,16 @@ async def login_for_oauth_token(form_data: OAuth2PasswordRequestForm = Depends()
280287
oauth_token_expires = timedelta(minutes=TOKEN_EXPIRE)
281288
oauth_token = create_access_token(data={"sub": user.username}, expires_delta=oauth_token_expires)
282289

283-
return {"access_token": oauth_token, "token_type": "bearer"}
290+
response = JSONResponse(content={"access_token": oauth_token, "token_type": "bearer"})
291+
response.set_cookie(
292+
key="session_token",
293+
value=oauth_token,
294+
httponly=True,
295+
secure=not DEV,
296+
samesite="lax",
297+
max_age=TOKEN_EXPIRE * 60,
298+
)
299+
return response
284300

285301

286302
"""
@@ -318,7 +334,18 @@ def index(request: Request):
318334
def login(request: Request, username: str = Form(...), password: str = Form(...)):
319335
"""Redirect to "/docs" from index page if user successfully logs in with HTML form"""
320336
if load_user(username) and verify_password(password, load_user(username).hashed_password):
321-
return RedirectResponse(url="/docs", status_code=303)
337+
oauth_token_expires = timedelta(minutes=TOKEN_EXPIRE)
338+
oauth_token = create_access_token(data={"sub": username}, expires_delta=oauth_token_expires)
339+
response = RedirectResponse(url="/docs", status_code=303)
340+
response.set_cookie(
341+
key="session_token",
342+
value=oauth_token,
343+
httponly=True,
344+
secure=not DEV,
345+
samesite="lax",
346+
max_age=TOKEN_EXPIRE * 60,
347+
)
348+
return response
322349

323350

324351
@api_router.get("/token")
Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
---
22
id: TASK-013
33
title: Add IP whitelisting and cookie-based session auth
4-
status: To Do
5-
assignee: []
4+
status: Done
5+
assignee:
6+
- Claude
67
created_date: '2026-02-27 00:54'
7-
updated_date: '2026-03-17 02:09'
8+
updated_date: '2026-03-20 23:34'
89
labels:
910
- auth
1011
dependencies: []
1112
references:
1213
- app/main.py
1314
priority: medium
14-
ordinal: 6000
15+
ordinal: 2500
1516
---
1617

1718
## Description
@@ -22,8 +23,38 @@ Two auth improvements: (1) populate public_ips in IPConfig so external IPs can b
2223

2324
## Acceptance Criteria
2425
<!-- AC:BEGIN -->
25-
- [ ] #1 IPConfig.public_ips is configurable (env var or config file) and tested
26-
- [ ] #2 User session is persisted in a secure, httponly cookie
27-
- [ ] #3 Existing bearer token auth still works alongside cookie auth
28-
- [ ] #4 Tests cover both cookie-based and IP-whitelisted auth paths
26+
- [x] #1 IPConfig.public_ips is configurable (env var or config file) and tested
27+
- [x] #2 User session is persisted in a secure, httponly cookie
28+
- [x] #3 Existing bearer token auth still works alongside cookie auth
29+
- [x] #4 Tests cover both cookie-based and IP-whitelisted auth paths
2930
<!-- AC:END -->
31+
32+
## Implementation Plan
33+
34+
<!-- SECTION:PLAN:BEGIN -->
35+
## Implementation Plan
36+
37+
### AC #1: IPConfig.public_ips configurable via env var
38+
- Add `PUBLIC_IPS` env var (comma-separated) read via `decouple.config`
39+
- Pass into `IPConfig(public_ips=...)` at initialization
40+
- Empty/unset means no public IPs (current default)
41+
42+
### AC #2: Cookie-based session persistence
43+
- After successful login at `/token` and `/auth/login`, set `session_token` httponly cookie with the JWT
44+
- Modify `get_current_user` to check cookie fallback when no Bearer token present
45+
- Cookie attrs: httponly, secure (HTTPS), samesite=lax, max_age from TOKEN_EXPIRE
46+
47+
### AC #3: Bearer token still works alongside cookie
48+
- `get_current_user` checks Bearer first, falls back to cookie — no breaking changes
49+
- `ip_whitelist_or_auth` unchanged
50+
51+
### AC #4: Tests
52+
- Unit: IPConfig with PUBLIC_IPS env var, is_ip_allowed with public IPs, get_current_user cookie extraction
53+
- E2E: login sets cookie, subsequent cookied request works, bearer still works
54+
<!-- SECTION:PLAN:END -->
55+
56+
## Implementation Notes
57+
58+
<!-- SECTION:NOTES:BEGIN -->
59+
All 4 ACs implemented and tested. 12 new unit tests + 4 new e2e tests. 88 total tests passing.
60+
<!-- SECTION:NOTES:END -->

tests/test_e2e.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,42 @@ def test_get_events_authenticated(self, auth_session, base_url):
216216
def test_get_events_no_params_uses_defaults(self, auth_session, base_url):
217217
resp = auth_session.get(f"{base_url}/api/events")
218218
assert resp.status_code == 200
219+
220+
221+
@pytest.mark.e2e
222+
class TestCookieAuth:
223+
def test_token_endpoint_sets_session_cookie(self, e2e_server, base_url):
224+
with httpx.Client() as client:
225+
resp = client.post(
226+
f"{base_url}/token",
227+
data={"username": e2e_server["db_user"], "password": e2e_server["db_pass"]},
228+
)
229+
assert resp.status_code == 200
230+
assert "session_token" in resp.cookies
231+
232+
def test_cookie_auth_accesses_protected_endpoint(self, e2e_server, base_url):
233+
with httpx.Client() as client:
234+
resp = client.post(
235+
f"{base_url}/token",
236+
data={"username": e2e_server["db_user"], "password": e2e_server["db_pass"]},
237+
)
238+
assert resp.status_code == 200
239+
cookie_token = resp.cookies["session_token"]
240+
241+
client.cookies.set("session_token", cookie_token)
242+
resp = client.get(f"{base_url}/api/events")
243+
assert resp.status_code == 200
244+
245+
def test_bearer_still_works_without_cookie(self, auth_session, base_url):
246+
resp = auth_session.get(f"{base_url}/api/events")
247+
assert resp.status_code == 200
248+
249+
def test_form_login_sets_session_cookie(self, e2e_server, base_url):
250+
with httpx.Client() as client:
251+
resp = client.post(
252+
f"{base_url}/auth/login",
253+
data={"username": e2e_server["db_user"], "password": e2e_server["db_pass"]},
254+
follow_redirects=False,
255+
)
256+
assert resp.status_code == 303
257+
assert "session_token" in resp.cookies

tests/test_unit.py

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from fastapi import HTTPException
1414
from fastapi.testclient import TestClient
1515
from jose import jwt
16-
from main import UserInDB, app, get_current_user
16+
from main import IPConfig, UserInDB, app, get_current_user, is_ip_allowed
1717
from meetup_query import (
1818
build_batched_group_query,
1919
export_to_file,
@@ -466,6 +466,141 @@ def test_invalid_token(raw_test_client):
466466
assert "detail" in response.json()
467467

468468

469+
# ── IP whitelisting tests ──────────────────────────────────────────
470+
471+
472+
@pytest.mark.unit
473+
class TestIPConfigPublicIps:
474+
"""IPConfig.public_ips should be configurable via PUBLIC_IPS env var."""
475+
476+
def test_default_public_ips_empty(self):
477+
cfg = IPConfig()
478+
assert cfg.public_ips == []
479+
480+
def test_public_ips_from_env_single(self):
481+
with patch.dict(os.environ, {"PUBLIC_IPS": "10.0.0.1"}):
482+
from main import _parse_public_ips
483+
484+
ips = _parse_public_ips()
485+
assert ips == ["10.0.0.1"]
486+
487+
def test_public_ips_from_env_multiple(self):
488+
with patch.dict(os.environ, {"PUBLIC_IPS": "10.0.0.1,192.168.1.1,172.16.0.1"}):
489+
from main import _parse_public_ips
490+
491+
ips = _parse_public_ips()
492+
assert ips == ["10.0.0.1", "192.168.1.1", "172.16.0.1"]
493+
494+
def test_public_ips_from_env_empty(self):
495+
with patch.dict(os.environ, {"PUBLIC_IPS": ""}):
496+
from main import _parse_public_ips
497+
498+
ips = _parse_public_ips()
499+
assert ips == []
500+
501+
def test_public_ips_strips_whitespace(self):
502+
with patch.dict(os.environ, {"PUBLIC_IPS": " 10.0.0.1 , 192.168.1.1 "}):
503+
from main import _parse_public_ips
504+
505+
ips = _parse_public_ips()
506+
assert ips == ["10.0.0.1", "192.168.1.1"]
507+
508+
509+
@pytest.mark.unit
510+
class TestIsIpAllowedWithPublicIps:
511+
"""is_ip_allowed should match against both whitelist and public_ips."""
512+
513+
def test_allowed_via_public_ips(self):
514+
mock_request = MagicMock()
515+
mock_request.client.host = "203.0.113.50"
516+
with patch("main.ip_config", IPConfig(public_ips=["203.0.113.50"])):
517+
assert is_ip_allowed(mock_request) is True
518+
519+
def test_denied_when_not_in_either_list(self):
520+
mock_request = MagicMock()
521+
mock_request.client.host = "203.0.113.99"
522+
with patch("main.ip_config", IPConfig(public_ips=["203.0.113.50"])):
523+
assert is_ip_allowed(mock_request) is False
524+
525+
def test_allowed_via_whitelist(self):
526+
mock_request = MagicMock()
527+
mock_request.client.host = "127.0.0.1"
528+
with patch("main.ip_config", IPConfig()):
529+
assert is_ip_allowed(mock_request) is True
530+
531+
532+
# ── Cookie auth tests ─────────────────────────────────────────────
533+
534+
535+
@pytest.mark.unit
536+
class TestCookieAuth:
537+
"""get_current_user should fall back to session_token cookie when no Bearer token."""
538+
539+
def test_cookie_auth_returns_user(self, raw_test_client):
540+
from main import ALGORITHM, SECRET_KEY, get_password_hash
541+
542+
token = jwt.encode({"sub": "testuser"}, SECRET_KEY, algorithm=ALGORITHM)
543+
with (
544+
patch("main.DEV", False),
545+
patch("main.is_ip_allowed", return_value=False),
546+
patch(
547+
"main.get_user",
548+
return_value=UserInDB(
549+
username="testuser",
550+
email="test@example.com",
551+
hashed_password=get_password_hash("pass"),
552+
),
553+
),
554+
):
555+
raw_test_client.cookies.set("session_token", token)
556+
response = raw_test_client.get("/api/events")
557+
raw_test_client.cookies.clear()
558+
assert response.status_code == 200
559+
560+
def test_bearer_takes_precedence_over_cookie(self, raw_test_client):
561+
from main import ALGORITHM, SECRET_KEY, get_password_hash
562+
563+
good_token = jwt.encode({"sub": "testuser"}, SECRET_KEY, algorithm=ALGORITHM)
564+
bad_cookie = "invalid_cookie_token"
565+
with (
566+
patch("main.DEV", False),
567+
patch("main.is_ip_allowed", return_value=False),
568+
patch(
569+
"main.get_user",
570+
return_value=UserInDB(
571+
username="testuser",
572+
email="test@example.com",
573+
hashed_password=get_password_hash("pass"),
574+
),
575+
),
576+
):
577+
raw_test_client.cookies.set("session_token", bad_cookie)
578+
response = raw_test_client.get(
579+
"/api/events",
580+
headers={"Authorization": f"Bearer {good_token}"},
581+
)
582+
raw_test_client.cookies.clear()
583+
assert response.status_code == 200
584+
585+
def test_invalid_cookie_returns_401(self, raw_test_client):
586+
with (
587+
patch("main.DEV", False),
588+
patch("main.is_ip_allowed", return_value=False),
589+
):
590+
raw_test_client.cookies.set("session_token", "invalid")
591+
response = raw_test_client.get("/api/events")
592+
raw_test_client.cookies.clear()
593+
assert response.status_code == 401
594+
595+
def test_no_token_no_cookie_returns_401(self, raw_test_client):
596+
with (
597+
patch("main.DEV", False),
598+
patch("main.is_ip_allowed", return_value=False),
599+
):
600+
response = raw_test_client.get("/api/events")
601+
assert response.status_code == 401
602+
603+
469604
# ── Deprecation / bcrypt tests ──────────────────────────────────────
470605

471606

0 commit comments

Comments
 (0)