fix: production deployment — Docker, Nomad, Consul KV, SHA tags

- Dockerfile: multi-stage build, non-root user, src/static tracked with .gitkeep
- Nomad job: force_pull=true, Traefik router fixed to tink-demo.i80.dk,
  loadbalancer.server.port=8000, job renamed from moneycapp-tink-demo
- CI/CD: git SHA image tags (deterministic deploys), removed .env.production
  baking — secrets injected at runtime via Consul KV template stanza
- Session security: asyncio lock prevents duplicate code exchange on callback,
  guard for already-stored token, api_log moved server-side (cookie overflow fix)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Henrik Jess Nielsen
2026-05-23 02:08:27 +02:00
parent ab591be464
commit bf61790465
6 changed files with 90 additions and 55 deletions

View File

@@ -40,7 +40,10 @@ def _session(request: Request) -> dict:
# Server-side token store — keeps JWTs OUT of the session cookie
# (cookie limit is 4KB; two JWTs alone are ~1.3KB before base64 overhead)
# ---------------------------------------------------------------------------
import asyncio
_token_store: dict[str, dict] = {} # sid → {"app_token": str, "user_token": str}
_callback_locks: dict[str, asyncio.Lock] = {} # sid → Lock (prevents concurrent code exchange)
def _get_sid(sess: dict) -> str:
@@ -69,16 +72,24 @@ def _ctx(request: Request, extra: dict) -> dict:
def _logger(sess: dict):
"""Returns a callback that appends log entries to sess['api_log']."""
"""Returns a callback that appends log entries to server-side store (not cookie)."""
def cb(entry: dict):
log = sess.setdefault("api_log", [])
sid = sess.get("sid", "")
if not sid:
return
store = _token_store.setdefault(sid, {})
log = store.setdefault("api_log", [])
log.append(entry)
# keep last 50 entries
if len(log) > 50:
sess["api_log"] = log[-50:]
store["api_log"] = log[-50:]
return cb
def _get_api_log(sess: dict) -> list:
sid = sess.get("sid", "")
return _token_store.get(sid, {}).get("api_log", [])
# ---------------------------------------------------------------------------
# Landing
# ---------------------------------------------------------------------------
@@ -108,7 +119,7 @@ async def debug_session(request: Request):
for k, v in sess.items()
if k != "api_log"
}
safe["api_log_count"] = len(sess.get("api_log", []))
safe["api_log_count"] = len(_get_api_log(sess))
safe["cookie_size_bytes"] = len(str(request.session))
return safe
@@ -402,28 +413,35 @@ async def tink_callback(request: Request, code: Optional[str] = None,
print(f"[CALLBACK] Tink returned error: {error}")
return RedirectResponse(f"/demo/step/3?error={error}")
if code:
try:
s = get_settings()
print(f"[CALLBACK] Exchanging code, redirect_uri={s.tink_redirect_uri!r}")
client = _client(log_cb=_logger(sess))
tokens = await client.exchange_code_for_token(
code, redirect_uri=s.tink_redirect_uri
)
print(f"[CALLBACK] Token response keys: {list(tokens.keys())}")
user_token = tokens.get("access_token", "")
if not user_token:
print(f"[CALLBACK] ERROR: access_token missing, got: {tokens}")
return RedirectResponse(
f"/demo/step/3?cb_error=Token+exchange+ok+but+access_token+missing+in+response",
status_code=303,
sid = sess.get("sid", "unknown")
if sid not in _callback_locks:
_callback_locks[sid] = asyncio.Lock()
async with _callback_locks[sid]:
if _load_token(sess, "user_token"):
print(f"[CALLBACK] Already have user_token — skipping duplicate exchange")
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
try:
s = get_settings()
print(f"[CALLBACK] Exchanging code, redirect_uri={s.tink_redirect_uri!r}")
client = _client(log_cb=_logger(sess))
tokens = await client.exchange_code_for_token(
code, redirect_uri=s.tink_redirect_uri
)
_store_token(sess, "user_token", user_token)
print(f"[CALLBACK] SUCCESS — user_token saved ({len(user_token)} chars)")
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
except Exception as e:
import traceback
print(f"[CALLBACK] EXCEPTION: {e}\n{traceback.format_exc()}")
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303)
print(f"[CALLBACK] Token response keys: {list(tokens.keys())}")
user_token = tokens.get("access_token", "")
if not user_token:
print(f"[CALLBACK] ERROR: access_token missing, got: {tokens}")
return RedirectResponse(
f"/demo/step/3?cb_error=Token+exchange+ok+but+access_token+missing+in+response",
status_code=303,
)
_store_token(sess, "user_token", user_token)
print(f"[CALLBACK] SUCCESS — user_token saved ({len(user_token)} chars)")
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
except Exception as e:
import traceback
print(f"[CALLBACK] EXCEPTION: {e}\n{traceback.format_exc()}")
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303)
print(f"[CALLBACK] No code — bare redirect to step 3")
return RedirectResponse("/demo/step/3", status_code=303)
@@ -617,7 +635,7 @@ async def step6(request: Request):
@router.get("/demo/log", response_class=HTMLResponse)
async def api_log(request: Request):
sess = _session(request)
log = sess.get("api_log", [])
log = _get_api_log(sess)
return templates.TemplateResponse("log.html", _ctx(request, {
"log": list(reversed(log)), # newest first
"log_count": len(log),
@@ -627,7 +645,9 @@ async def api_log(request: Request):
@router.post("/demo/log/clear")
async def clear_log(request: Request):
sess = _session(request)
sess["api_log"] = []
sid = sess.get("sid", "")
if sid and sid in _token_store:
_token_store[sid].pop("api_log", None)
return RedirectResponse("/demo/log", status_code=303)

0
src/static/.gitkeep Normal file
View File