KSvend Claude Opus 4.6 (1M context) commited on
Commit
a7da499
·
1 Parent(s): 43dd6eb

feat: add get_current_user auth dependency and GET /api/jobs list endpoint

Browse files
Files changed (3) hide show
  1. app/api/auth.py +31 -3
  2. app/api/jobs.py +17 -1
  3. tests/test_api_auth.py +57 -0
app/api/auth.py CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
  import hashlib
3
  import hmac
4
  import time
5
- from fastapi import APIRouter, HTTPException
6
  from pydantic import BaseModel
7
 
8
  router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -27,7 +27,35 @@ async def verify_token(req: VerifyRequest):
27
  raise HTTPException(status_code=401, detail="Invalid or expired token")
28
  return {"email": req.email, "verified": True}
29
 
30
- def _generate_token(email: str) -> str:
31
- hour = int(time.time() // 3600)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  payload = f"{email}:{hour}:{SECRET}"
33
  return hashlib.sha256(payload.encode()).hexdigest()[:32]
 
 
 
 
 
2
  import hashlib
3
  import hmac
4
  import time
5
+ from fastapi import APIRouter, Header, HTTPException
6
  from pydantic import BaseModel
7
 
8
  router = APIRouter(prefix="/api/auth", tags=["auth"])
 
27
  raise HTTPException(status_code=401, detail="Invalid or expired token")
28
  return {"email": req.email, "verified": True}
29
 
30
+ async def get_current_user(authorization: str = Header(default=None)) -> str:
31
+ """FastAPI dependency: extract and verify email from Authorization header.
32
+
33
+ Expected format: Bearer <email>:<token>
34
+ Returns the verified email address.
35
+ """
36
+ if not authorization:
37
+ raise HTTPException(status_code=401, detail="Authorization header missing")
38
+ parts = authorization.split(" ", 1)
39
+ if len(parts) != 2 or parts[0] != "Bearer":
40
+ raise HTTPException(status_code=401, detail="Invalid authorization format")
41
+ payload = parts[1]
42
+ if ":" not in payload:
43
+ raise HTTPException(status_code=401, detail="Invalid token format")
44
+ email, token = payload.split(":", 1)
45
+ # Verify against current and previous hour (handle clock edge)
46
+ for offset in (0, -1):
47
+ expected = _generate_token_for_hour(email, offset)
48
+ if hmac.compare_digest(token, expected):
49
+ return email
50
+ raise HTTPException(status_code=401, detail="Invalid or expired token")
51
+
52
+
53
+ def _generate_token_for_hour(email: str, hour_offset: int = 0) -> str:
54
+ """Generate token for a specific hour offset (0 = current, -1 = previous)."""
55
+ hour = int(time.time() // 3600) + hour_offset
56
  payload = f"{email}:{hour}:{SECRET}"
57
  return hashlib.sha256(payload.encode()).hexdigest()[:32]
58
+
59
+
60
+ def _generate_token(email: str) -> str:
61
+ return _generate_token_for_hour(email, 0)
app/api/jobs.py CHANGED
@@ -1,4 +1,5 @@
1
- from fastapi import APIRouter, HTTPException
 
2
  from app.database import Database
3
  from app.models import JobRequest
4
 
@@ -11,6 +12,21 @@ def init_router(db: Database) -> None:
11
  _db = db
12
 
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  @router.post("", status_code=201)
15
  async def submit_job(request: JobRequest):
16
  job_id = await _db.create_job(request)
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from app.api.auth import get_current_user
3
  from app.database import Database
4
  from app.models import JobRequest
5
 
 
12
  _db = db
13
 
14
 
15
+ @router.get("")
16
+ async def list_jobs(email: str = Depends(get_current_user)):
17
+ jobs = await _db.get_jobs_by_email(email)
18
+ return [
19
+ {
20
+ "id": j.id,
21
+ "status": j.status.value,
22
+ "aoi_name": j.request.aoi.name,
23
+ "created_at": j.created_at.isoformat(),
24
+ "indicator_count": len(j.request.indicator_ids),
25
+ }
26
+ for j in jobs
27
+ ]
28
+
29
+
30
  @router.post("", status_code=201)
31
  async def submit_job(request: JobRequest):
32
  job_id = await _db.create_job(request)
tests/test_api_auth.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for auth middleware — get_current_user dependency."""
2
+ import time
3
+ import hashlib
4
+ import pytest
5
+ from httpx import AsyncClient, ASGITransport
6
+ from app.main import create_app
7
+
8
+
9
+ def _make_token(email: str) -> str:
10
+ """Mirror the backend token generation for test fixtures."""
11
+ secret = "aperture-mvp-secret-change-in-production"
12
+ hour = int(time.time() // 3600)
13
+ payload = f"{email}:{hour}:{secret}"
14
+ return hashlib.sha256(payload.encode()).hexdigest()[:32]
15
+
16
+
17
+ @pytest.fixture
18
+ async def client(tmp_path):
19
+ app = create_app(db_path=str(tmp_path / "test.db"), run_worker=False)
20
+ transport = ASGITransport(app=app)
21
+ async with AsyncClient(transport=transport, base_url="http://test") as c:
22
+ yield c
23
+
24
+
25
+ @pytest.mark.asyncio
26
+ async def test_auth_header_valid(client):
27
+ email = "user@example.com"
28
+ token = _make_token(email)
29
+ resp = await client.get(
30
+ "/api/jobs",
31
+ headers={"Authorization": f"Bearer {email}:{token}"},
32
+ )
33
+ assert resp.status_code == 200
34
+
35
+
36
+ @pytest.mark.asyncio
37
+ async def test_auth_header_missing(client):
38
+ resp = await client.get("/api/jobs")
39
+ assert resp.status_code == 401
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_auth_header_bad_token(client):
44
+ resp = await client.get(
45
+ "/api/jobs",
46
+ headers={"Authorization": "Bearer user@example.com:badtoken"},
47
+ )
48
+ assert resp.status_code == 401
49
+
50
+
51
+ @pytest.mark.asyncio
52
+ async def test_auth_header_malformed(client):
53
+ resp = await client.get(
54
+ "/api/jobs",
55
+ headers={"Authorization": "Bearer garbage"},
56
+ )
57
+ assert resp.status_code == 401