Review

JWT gate for CopperStill barrel-house locks

Barrel-house Bluetooth locks used a shared 4-digit PIN that every harvest temp knew. This PR swaps the pin check for short-lived staff JWTs issued by the CaskPass app: warehouse controllers POST the bearer token, we decode claims, and only open the rack if role is picker|cellarmaster and exp is still valid.

PyJWTTier 3securityjwtauthpyjwt

Click a line to flag it, pick one or more labels, then submit. If the change looks correct, approve it.

services/locks/caskpass_auth.py+20-4
11from datetime import datetime, timezone
22
33import jwt
44from fastapi import Header, HTTPException
55
66from app.config import settings
77
8-def authorize_rack_open(pin: str, rack_id: str) -> dict:
9- if pin != settings.BARREL_HOUSE_PIN:
10- raise HTTPException(status_code=401, detail="bad pin")
11- return {"rack_id": rack_id, "via": "pin"}
8+ALLOWED_ROLES = {"picker", "cellarmaster"}
9+
10+def authorize_rack_open(
11+ rack_id: str,
12+ authorization: str = Header(...),
13+) -> dict:
14+ if not authorization.startswith("Bearer "):
15+ raise HTTPException(status_code=401, detail="missing bearer")
16+ token = authorization[len("Bearer "):].strip()
17+ # Staff app signs HS256 with STAFF_JWT_SECRET; trust the header alg.
18+ try:
19+ claims = jwt.decode(token, settings.STAFF_JWT_SECRET)
20+ except jwt.PyJWTError as exc:
21+ raise HTTPException(status_code=401, detail="invalid token") from exc
22+ if claims.get("role") not in ALLOWED_ROLES:
23+ raise HTTPException(status_code=403, detail="role not allowed")
24+ exp = claims.get("exp")
25+ if exp is None or datetime.fromtimestamp(exp, tz=timezone.utc) < datetime.now(timezone.utc):
26+ raise HTTPException(status_code=401, detail="token expired")
27+ return {"rack_id": rack_id, "sub": claims.get("sub"), "via": "jwt"}