Skip to content

Commit c2760ff

Browse files
HR-codingclaude
andcommitted
feat(api): React-ready auth + profile endpoints + CORS
- auth_api.py: verify the frontend's bearer JWT (Supabase HS256 by default; provider-agnostic verify_token seam), get-or-create the control-plane user, and expose GET /me, GET/POST /profiles (tenant-scoped to the logged-in user). - main.py: CORS middleware (FRONTEND_ORIGINS) + auth router. - requirements: pyjwt (lazy-imported). .env.example: SUPABASE_JWT_SECRET, FRONTEND_ORIGINS. - +4 tests (401 without bearer, user auto-create, profile create/list scoping, CORS header). Suite 172/172. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9637096 commit c2760ff

5 files changed

Lines changed: 153 additions & 0 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ AGENT_REQUIRE_SIGNATURE=1 # 1 in prod (signed-request
2525
CONTROL_ENC_KEY= # 🔑 Fernet key (encrypts OAuth tokens)
2626
POLL_TOKEN= # 🔑 guards /internal/poll (free-tier external cron)
2727

28+
# ── Frontend (React) + auth provider ──
29+
FRONTEND_ORIGINS=http://localhost:5173,https://your-app.vercel.app # CORS allow-list
30+
SUPABASE_JWT_SECRET= # 🔑 verifies the frontend's login JWT
31+
2832
# ── Google OAuth client file (per-user web flow in prod) ──
2933
GOOGLE_CREDENTIALS_FILE=/etc/secrets/credentials.json # Render Secret File path; defaults to ./credentials.json
3034
# GOOGLE_TOKEN_FILE=./token.json # single-user dev fallback only

app/main.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import os
12
from contextlib import asynccontextmanager
23
from fastapi import FastAPI
4+
from fastapi.middleware.cors import CORSMiddleware
35
from app.orchestrator.gateway import router as gateway_router
46
from app.orchestrator.feedback_api import router as feedback_router
7+
from app.orchestrator.auth_api import router as auth_router
58
from app.core.db_initializer import initialize_database
69

710

@@ -12,8 +15,22 @@ async def lifespan(app: FastAPI):
1215

1316

1417
app = FastAPI(title="Tax Agent API", version="1.0.0", lifespan=lifespan)
18+
19+
# CORS for the React frontend (set FRONTEND_ORIGINS to your deployed origins).
20+
_origins = os.getenv(
21+
"FRONTEND_ORIGINS", "http://localhost:5173,http://localhost:3000"
22+
).split(",")
23+
app.add_middleware(
24+
CORSMiddleware,
25+
allow_origins=[o.strip() for o in _origins if o.strip()],
26+
allow_credentials=True,
27+
allow_methods=["*"],
28+
allow_headers=["*"],
29+
)
30+
1531
app.include_router(gateway_router)
1632
app.include_router(feedback_router)
33+
app.include_router(auth_router)
1734

1835

1936
@app.get("/")

app/orchestrator/auth_api.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""
2+
Frontend-facing auth + profile API (React-ready).
3+
4+
The React app authenticates the user with the auth provider (Supabase by default)
5+
and sends its JWT as `Authorization: Bearer <token>`. We verify it, get-or-create
6+
the matching control-plane user, and expose the endpoints the UI needs:
7+
8+
GET /me -> current user + their profiles
9+
GET /profiles -> list profiles
10+
POST /profiles -> create a profile (self / spouse / ...)
11+
12+
Provider-agnostic: swap verify_token() for Firebase/Clerk if you change providers.
13+
"""
14+
import os
15+
from typing import Optional
16+
from fastapi import APIRouter, Depends, Header, HTTPException
17+
from pydantic import BaseModel
18+
19+
router = APIRouter()
20+
21+
22+
def verify_token(token: str) -> dict:
23+
"""Verify a Supabase JWT (HS256) and return {id, email}. Fail closed."""
24+
secret = os.getenv("SUPABASE_JWT_SECRET")
25+
if not secret:
26+
raise HTTPException(status_code=503, detail="Auth is not configured")
27+
import jwt # lazy: only needed when actually verifying
28+
try:
29+
claims = jwt.decode(token, secret, algorithms=["HS256"], audience="authenticated")
30+
except Exception:
31+
raise HTTPException(status_code=401, detail="Invalid or expired token")
32+
return {"id": claims.get("sub"), "email": claims.get("email")}
33+
34+
35+
def get_current_user(authorization: Optional[str] = Header(None)) -> dict:
36+
"""FastAPI dependency: verify the bearer token and resolve our control-plane user."""
37+
if not authorization or not authorization.lower().startswith("bearer "):
38+
raise HTTPException(status_code=401, detail="Missing bearer token")
39+
claims = verify_token(authorization.split(" ", 1)[1])
40+
email = claims.get("email")
41+
if not email:
42+
raise HTTPException(status_code=401, detail="Token has no email")
43+
44+
from app.core import identity
45+
user = identity.get_user_by_email(email)
46+
if not user:
47+
identity.create_user(email)
48+
user = identity.get_user_by_email(email)
49+
return {"user_id": user.id, "email": email}
50+
51+
52+
def _profile_dto(p):
53+
return {"id": p.id, "display_name": p.display_name, "relation": p.relation,
54+
"itr_type": p.itr_type, "drive_folder_id": p.drive_folder_id}
55+
56+
57+
@router.get("/me")
58+
def me(user: dict = Depends(get_current_user)):
59+
from app.core import identity
60+
profiles = identity.list_profiles(user["user_id"])
61+
return {"user": user, "profiles": [_profile_dto(p) for p in profiles]}
62+
63+
64+
@router.get("/profiles")
65+
def list_profiles(user: dict = Depends(get_current_user)):
66+
from app.core import identity
67+
return [_profile_dto(p) for p in identity.list_profiles(user["user_id"])]
68+
69+
70+
class ProfileIn(BaseModel):
71+
display_name: str
72+
relation: str = "self" # self | spouse | dependent | other
73+
itr_type: str = "ITR1"
74+
drive_folder_id: str = ""
75+
76+
77+
@router.post("/profiles")
78+
def create_profile(body: ProfileIn, user: dict = Depends(get_current_user)):
79+
from app.core import identity
80+
pid = identity.create_profile(
81+
user["user_id"], body.display_name, relation=body.relation,
82+
itr_type=body.itr_type, drive_folder_id=body.drive_folder_id)
83+
return {"id": pid}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Frontend auth + profile API (JWT verification mocked)."""
2+
import unittest
3+
from unittest.mock import patch
4+
5+
from fastapi.testclient import TestClient
6+
from app.core.control_db import init_control_db
7+
from app.main import app
8+
9+
10+
class TestAuthApi(unittest.TestCase):
11+
@classmethod
12+
def setUpClass(cls):
13+
init_control_db()
14+
cls.client = TestClient(app)
15+
16+
def test_me_requires_bearer(self):
17+
self.assertEqual(self.client.get("/me").status_code, 401)
18+
19+
def test_me_creates_user_and_returns_profiles(self):
20+
with patch("app.orchestrator.auth_api.verify_token",
21+
return_value={"id": "sub-1", "email": "newuser@example.com"}):
22+
r = self.client.get("/me", headers={"Authorization": "Bearer x"})
23+
self.assertEqual(r.status_code, 200)
24+
self.assertEqual(r.json()["user"]["email"], "newuser@example.com")
25+
self.assertEqual(r.json()["profiles"], [])
26+
27+
def test_create_and_list_profiles_scoped_to_user(self):
28+
hdr = {"Authorization": "Bearer x"}
29+
with patch("app.orchestrator.auth_api.verify_token",
30+
return_value={"id": "sub-2", "email": "filer@example.com"}):
31+
pid = self.client.post("/profiles", json={"display_name": "Self"}, headers=hdr).json()["id"]
32+
spouse = self.client.post(
33+
"/profiles", json={"display_name": "Spouse", "relation": "spouse", "itr_type": "ITR2"},
34+
headers=hdr).json()["id"]
35+
profiles = self.client.get("/profiles", headers=hdr).json()
36+
ids = [p["id"] for p in profiles]
37+
self.assertIn(pid, ids)
38+
self.assertIn(spouse, ids)
39+
self.assertEqual(len(profiles), 2)
40+
41+
def test_cors_headers_present(self):
42+
with patch.dict("os.environ", {"FRONTEND_ORIGINS": "http://localhost:5173"}):
43+
r = self.client.get("/", headers={"Origin": "http://localhost:5173"})
44+
self.assertEqual(r.headers.get("access-control-allow-origin"), "http://localhost:5173")
45+
46+
47+
if __name__ == "__main__":
48+
unittest.main(verbosity=2)

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ mcp
77
sqlalchemy
88
psycopg[binary]
99
cryptography
10+
pyjwt
1011
# Async agent runs (Phase 2)
1112
rq
1213
redis

0 commit comments

Comments
 (0)