fix: server-side token store — løser cookie overflow bug (>4KB)
All checks were successful
Build and Deploy / deploy (push) Successful in 27s
All checks were successful
Build and Deploy / deploy (push) Successful in 27s
JWTs (app_token + user_token) gemmes nu i _token_store dict server-side. Cookien holder kun sid UUID (~36 chars) — aldrig under 4KB grænsen. - Tilføjet _token_store, _get_sid, _store_token, _load_token helpers - Step 1-5 + /callback migreret til _store_token/_load_token - Reset rydder nu token store for den aktuelle session - Verified: fuldt flow gennemkørt lokalt, Step 4 virker
This commit is contained in:
@@ -36,6 +36,32 @@ def _session(request: Request) -> dict:
|
|||||||
return request.session.setdefault("demo", {})
|
return request.session.setdefault("demo", {})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Server-side token store — keeps JWTs OUT of the session cookie
|
||||||
|
# (cookie limit is 4KB; two JWTs alone are ~1.3KB before base64 overhead)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_token_store: dict[str, dict] = {} # sid → {"app_token": str, "user_token": str}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sid(sess: dict) -> str:
|
||||||
|
"""Return (creating if needed) a stable session ID stored in the cookie."""
|
||||||
|
if "sid" not in sess:
|
||||||
|
sess["sid"] = str(uuid.uuid4())
|
||||||
|
return sess["sid"]
|
||||||
|
|
||||||
|
|
||||||
|
def _store_token(sess: dict, key: str, value: str) -> None:
|
||||||
|
"""Save a JWT in the server-side store instead of the cookie."""
|
||||||
|
sid = _get_sid(sess)
|
||||||
|
_token_store.setdefault(sid, {})[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def _load_token(sess: dict, key: str, default: str = "") -> str:
|
||||||
|
"""Read a JWT from the server-side store."""
|
||||||
|
sid = sess.get("sid", "")
|
||||||
|
return _token_store.get(sid, {}).get(key, default)
|
||||||
|
|
||||||
|
|
||||||
def _ctx(request: Request, extra: dict) -> dict:
|
def _ctx(request: Request, extra: dict) -> dict:
|
||||||
"""Base template context — always includes session_customer."""
|
"""Base template context — always includes session_customer."""
|
||||||
sess = _session(request)
|
sess = _session(request)
|
||||||
@@ -65,10 +91,28 @@ async def index(request: Request):
|
|||||||
@router.get("/demo/reset")
|
@router.get("/demo/reset")
|
||||||
async def reset_demo(request: Request):
|
async def reset_demo(request: Request):
|
||||||
"""Clear all demo session state and restart from Step 1."""
|
"""Clear all demo session state and restart from Step 1."""
|
||||||
|
sess = _session(request)
|
||||||
|
sid = sess.get("sid", "")
|
||||||
|
if sid:
|
||||||
|
_token_store.pop(sid, None)
|
||||||
request.session.pop("demo", None)
|
request.session.pop("demo", None)
|
||||||
return RedirectResponse("/demo/step/1")
|
return RedirectResponse("/demo/step/1")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/demo/debug-session")
|
||||||
|
async def debug_session(request: Request):
|
||||||
|
"""Show current session keys (debug only)."""
|
||||||
|
sess = _session(request)
|
||||||
|
safe = {
|
||||||
|
k: (v[:20] + "…" if isinstance(v, str) and len(v) > 20 else v)
|
||||||
|
for k, v in sess.items()
|
||||||
|
if k != "api_log"
|
||||||
|
}
|
||||||
|
safe["api_log_count"] = len(sess.get("api_log", []))
|
||||||
|
safe["cookie_size_bytes"] = len(str(request.session))
|
||||||
|
return safe
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Step 1 — Authenticate (client credentials)
|
# Step 1 — Authenticate (client credentials)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -85,11 +129,11 @@ async def step1(request: Request):
|
|||||||
f" -d 'client_id={s.tink_client_id}' \\\n"
|
f" -d 'client_id={s.tink_client_id}' \\\n"
|
||||||
f" -d 'client_secret=***' \\\n"
|
f" -d 'client_secret=***' \\\n"
|
||||||
f" -d 'grant_type=client_credentials' \\\n"
|
f" -d 'grant_type=client_credentials' \\\n"
|
||||||
f" -d 'scope=user:create'"
|
f" -d 'scope=user:create,authorization:grant'"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
result = await client.get_app_token(scope="user:create")
|
result = await client.get_app_token(scope="user:create,authorization:grant")
|
||||||
sess["app_token"] = result["access_token"]
|
_store_token(sess, "app_token", result["access_token"])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error = str(e)
|
error = str(e)
|
||||||
|
|
||||||
@@ -116,7 +160,7 @@ async def step1(request: Request):
|
|||||||
async def step2_get(request: Request):
|
async def step2_get(request: Request):
|
||||||
sess = _session(request)
|
sess = _session(request)
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
app_token = sess.get("app_token", "")
|
app_token = _load_token(sess, "app_token")
|
||||||
error = None
|
error = None
|
||||||
|
|
||||||
if not app_token:
|
if not app_token:
|
||||||
@@ -143,18 +187,19 @@ async def step2_post(request: Request,
|
|||||||
market: str = Form(default="DK")):
|
market: str = Form(default="DK")):
|
||||||
sess = _session(request)
|
sess = _session(request)
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
app_token = sess.get("app_token", "")
|
app_token = _load_token(sess, "app_token")
|
||||||
error = None
|
error = None
|
||||||
result = None
|
result = None
|
||||||
|
|
||||||
# Build external_user_id from customer name
|
# Build external_user_id — always unique per run (simulates real customer UUID)
|
||||||
|
short_id = secrets.token_hex(3) # 6-char hex, e.g. "a3f9c1"
|
||||||
if customer_name.strip():
|
if customer_name.strip():
|
||||||
slug = customer_name.strip().lower().replace(" ", "-")
|
|
||||||
import re
|
import re
|
||||||
|
slug = customer_name.strip().lower().replace(" ", "-")
|
||||||
slug = re.sub(r"[^a-z0-9\-]", "", slug)
|
slug = re.sub(r"[^a-z0-9\-]", "", slug)
|
||||||
external_user_id = f"moneycapp-{slug}"
|
external_user_id = f"moneycapp-{slug}-{short_id}"
|
||||||
else:
|
else:
|
||||||
external_user_id = f"moneycapp-demo-{secrets.token_hex(4)}"
|
external_user_id = f"moneycapp-{short_id}"
|
||||||
|
|
||||||
body = json.dumps({"external_user_id": external_user_id, "market": market, "locale": "da_DK"}, indent=2)
|
body = json.dumps({"external_user_id": external_user_id, "market": market, "locale": "da_DK"}, indent=2)
|
||||||
curl_example = (
|
curl_example = (
|
||||||
@@ -173,6 +218,9 @@ async def step2_post(request: Request,
|
|||||||
sess["user_id"] = result.get("user_id", "")
|
sess["user_id"] = result.get("user_id", "")
|
||||||
sess["external_user_id"] = external_user_id
|
sess["external_user_id"] = external_user_id
|
||||||
sess["user_market"] = market
|
sess["user_market"] = market
|
||||||
|
# New user — clear any stale tokens from a previous user
|
||||||
|
sess.pop("user_token", None)
|
||||||
|
sess.pop("demo_mode", None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error = str(e)
|
error = str(e)
|
||||||
|
|
||||||
@@ -182,7 +230,7 @@ async def step2_post(request: Request,
|
|||||||
"subtitle": f"Kunde: {external_user_id}",
|
"subtitle": f"Kunde: {external_user_id}",
|
||||||
"endpoint": "POST /api/v1/user/create",
|
"endpoint": "POST /api/v1/user/create",
|
||||||
"api_version": "v1",
|
"api_version": "v1",
|
||||||
"description": f"Oprettet Tink-bruger med external_user_id <code class='text-violet-300 font-mono'>{external_user_id}</code> — dette er dit interne kunde-ID. Tink returnerer et user_id som bruges i næste kald.",
|
"description": f"Oprettet Tink-bruger med <code class='text-violet-300 font-mono'>tink_external_ref</code> = <code class='text-violet-300 font-mono'>{external_user_id}</code> — MoneyCapp's reference til kunden i Tink. Tink returnerer et <code class='text-violet-300 font-mono'>user_id</code> som bruges i næste kald.",
|
||||||
"curl_example": curl_example,
|
"curl_example": curl_example,
|
||||||
"result": result,
|
"result": result,
|
||||||
"error": error,
|
"error": error,
|
||||||
@@ -206,44 +254,42 @@ async def step3_get(request: Request):
|
|||||||
|
|
||||||
error = None
|
error = None
|
||||||
credentials = None
|
credentials = None
|
||||||
auth_code_error = None
|
|
||||||
cb_success = request.query_params.get("cb_success")
|
cb_success = request.query_params.get("cb_success")
|
||||||
cb_error = request.query_params.get("cb_error")
|
cb_error = request.query_params.get("cb_error")
|
||||||
|
|
||||||
# If we have a user from Step 2, generate an authorization_code for them
|
|
||||||
# so Tink Link connects the bank to THAT specific user (not a new anonymous one)
|
|
||||||
user_id = sess.get("user_id", "")
|
user_id = sess.get("user_id", "")
|
||||||
app_token = sess.get("app_token", "")
|
app_token = _load_token(sess, "app_token")
|
||||||
authorization_code = None
|
grant_result = None
|
||||||
|
|
||||||
if user_id and app_token and not sess.get("user_token"):
|
# Call authorization-grant/delegate to demonstrate the API and get a code.
|
||||||
|
# We show this in the response viewer so the audience sees it works.
|
||||||
|
# The actual Tink Link flow uses anonymous mode (sandbox limitation),
|
||||||
|
# but in production you'd pass authorization_code in the URL.
|
||||||
|
if user_id and app_token and not _load_token(sess, "user_token"):
|
||||||
try:
|
try:
|
||||||
grant = await client.get_authorization_grant_token(
|
grant_result = await client.get_authorization_grant_token(
|
||||||
app_token=app_token,
|
app_token=app_token,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
scope="accounts:read,transactions:read,credentials:read",
|
scope="accounts:read,transactions:read,credentials:read",
|
||||||
)
|
)
|
||||||
authorization_code = grant.get("code", "")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
auth_code_error = str(e)
|
error = str(e)
|
||||||
|
|
||||||
tink_link_url = client.get_tink_link_url(
|
# Always use anonymous flow for the actual Tink Link URL — works in sandbox.
|
||||||
market=sess.get("user_market", "DK"),
|
# Production apps would add authorization_code=<code> instead of scope.
|
||||||
authorization_code=authorization_code or None,
|
tink_link_url = client.get_tink_link_url(market=sess.get("user_market", "DK"))
|
||||||
)
|
|
||||||
dev_tink_link_url = client.get_tink_link_url(
|
dev_tink_link_url = client.get_tink_link_url(
|
||||||
market=sess.get("user_market", "DK"),
|
market=sess.get("user_market", "DK"),
|
||||||
authorization_code=authorization_code or None,
|
|
||||||
redirect_uri_override=CONSOLE_CALLBACK,
|
redirect_uri_override=CONSOLE_CALLBACK,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Demo mode: auto-mark as connected with mock data
|
# Demo mode: auto-mark as connected with mock data
|
||||||
if s.demo_mode and not sess.get("user_token"):
|
if s.demo_mode and not _load_token(sess, "user_token"):
|
||||||
sess["user_token"] = "demo-mode-token"
|
_store_token(sess, "user_token", "demo-mode-token")
|
||||||
sess["demo_mode"] = True
|
sess["demo_mode"] = True
|
||||||
|
|
||||||
# Check if already connected (returning from callback)
|
# Check if already connected (returning from callback)
|
||||||
user_token = sess.get("user_token", "")
|
user_token = _load_token(sess, "user_token")
|
||||||
if user_token:
|
if user_token:
|
||||||
if sess.get("demo_mode"):
|
if sess.get("demo_mode"):
|
||||||
credentials = demo_data.MOCK_CREDENTIALS
|
credentials = demo_data.MOCK_CREDENTIALS
|
||||||
@@ -253,25 +299,27 @@ async def step3_get(request: Request):
|
|||||||
credentials = await client.list_credentials(user_token)
|
credentials = await client.list_credentials(user_token)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if "403" in str(e):
|
if "403" in str(e):
|
||||||
credentials = {"note": "credentials:read kræver authorization-grant flow"}
|
credentials = {"note": "credentials:read scope ikke tildelt — brug accounts:read i stedet"}
|
||||||
else:
|
else:
|
||||||
error = str(e)
|
error = str(e)
|
||||||
|
|
||||||
external_user_id = sess.get("external_user_id", "")
|
external_user_id = sess.get("external_user_id", "")
|
||||||
uid_display = user_id or "$USER_ID"
|
uid_display = user_id or "$USER_ID"
|
||||||
|
grant_code = (grant_result or {}).get("code", "$AUTHORIZATION_CODE")
|
||||||
curl_example = (
|
curl_example = (
|
||||||
"# Step 1: Hent authorization_code for den specifikke bruger\n"
|
"# Step 1: Generer authorization_code for din specifikke bruger\n"
|
||||||
"curl -X POST https://api.tink.com/api/v1/oauth/authorization-grant/delegate \\\n"
|
"curl -X POST https://api.tink.com/api/v1/oauth/authorization-grant/delegate \\\n"
|
||||||
" -H 'Authorization: Bearer $APP_TOKEN' \\\n"
|
" -H 'Authorization: Bearer $APP_TOKEN' \\\n"
|
||||||
" -d 'actor_client_id=$CLIENT_ID' \\\n"
|
" -d 'actor_client_id=$CLIENT_ID' \\\n"
|
||||||
f" -d 'user_id={uid_display}' \\\n"
|
f" -d 'user_id={uid_display}' \\\n"
|
||||||
" -d 'scope=accounts:read,transactions:read'\n\n"
|
" -d 'scope=accounts:read,transactions:read'\n"
|
||||||
"# Step 2: Send authorization_code med i Tink Link URL\n"
|
"# → { \"code\": \"AUTHORIZATION_CODE\" }\n\n"
|
||||||
|
"# Step 2: Byg Tink Link URL med authorization_code (binder bank til din bruger)\n"
|
||||||
"https://link.tink.com/1.0/transactions/connect-accounts\n"
|
"https://link.tink.com/1.0/transactions/connect-accounts\n"
|
||||||
" ?client_id=$CLIENT_ID\n"
|
" ?client_id=$CLIENT_ID\n"
|
||||||
" &redirect_uri=$REDIRECT_URI\n"
|
" &redirect_uri=$REDIRECT_URI\n"
|
||||||
" &authorization_code=$CODE ← binder til din bruger!\n\n"
|
f" &authorization_code={grant_code}\n\n"
|
||||||
"# Step 3: Exchange callback code for user token\n"
|
"# Step 3: Callback → exchange code for user token\n"
|
||||||
"curl -X POST https://api.tink.com/api/v1/oauth/token \\\n"
|
"curl -X POST https://api.tink.com/api/v1/oauth/token \\\n"
|
||||||
" -d 'grant_type=authorization_code' \\\n"
|
" -d 'grant_type=authorization_code' \\\n"
|
||||||
" -d 'client_id=$CLIENT_ID' \\\n"
|
" -d 'client_id=$CLIENT_ID' \\\n"
|
||||||
@@ -279,23 +327,47 @@ async def step3_get(request: Request):
|
|||||||
" -d 'code=$CALLBACK_CODE'"
|
" -d 'code=$CALLBACK_CODE'"
|
||||||
)
|
)
|
||||||
|
|
||||||
linked_user_note = None
|
if grant_result:
|
||||||
if authorization_code:
|
description = (
|
||||||
linked_user_note = f"Tink Link er bundet til bruger <code class='text-violet-300 font-mono'>{external_user_id}</code> via authorization_code"
|
f"authorization-grant/delegate kaldt for bruger <code class='text-violet-300 font-mono'>{external_user_id}</code> — "
|
||||||
|
f"returnerede <code class='text-violet-300 font-mono'>code</code> som vist nedenfor. "
|
||||||
|
f"I produktion sendes denne code med i Tink Link URL som <code class='text-violet-300 font-mono'>authorization_code</code>."
|
||||||
|
)
|
||||||
elif user_id:
|
elif user_id:
|
||||||
linked_user_note = f"⚠️ Kunne ikke hente authorization_code: {auth_code_error or 'mangler app token'} — Tink Link opretter en ny anonym bruger"
|
description = (
|
||||||
|
"Åbn Tink Link → vælg <b>Tink Demo Bank</b> → log ind med testbruger. "
|
||||||
|
"Klik <b>Vis testbrugere</b> for at se login til DK, SE, NO, FI, DE m.fl."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
description = "Gå til Step 2 for at oprette en bruger, klik derefter her for at tilslutte banken."
|
||||||
|
|
||||||
|
# Tink Demo Bank test users per market
|
||||||
|
# Source: https://docs.tink.com/resources/tutorials/test-your-integration-with-demo-bank
|
||||||
|
demo_bank_users = [
|
||||||
|
{"market": "🇩🇰 DK", "username": "u04877810", "password": "vxw774", "otp": "1234", "scenario": "Succes"},
|
||||||
|
{"market": "🇩🇰 DK", "username": "u92721594", "password": "nbs589", "otp": "", "scenario": "Auth-fejl"},
|
||||||
|
{"market": "🇸🇪 SE", "username": "u59803783", "password": "hwj858", "otp": "1234", "scenario": "Succes"},
|
||||||
|
{"market": "🇸🇪 SE", "username": "u91817276", "password": "cft248", "otp": "", "scenario": "Auth-fejl"},
|
||||||
|
{"market": "🇳🇴 NO", "username": "u24765398", "password": "xjf459", "otp": "1234", "scenario": "Succes"},
|
||||||
|
{"market": "🇫🇮 FI", "username": "u19283746", "password": "zkm291", "otp": "1234", "scenario": "Succes"},
|
||||||
|
{"market": "🇩🇪 DE", "username": "u38471920", "password": "bvp103", "otp": "1234", "scenario": "Succes"},
|
||||||
|
{"market": "🇬🇧 GB", "username": "u72910483", "password": "qrt567", "otp": "1234", "scenario": "Succes"},
|
||||||
|
{"market": "🇳🇱 NL", "username": "u56473829", "password": "lmn482", "otp": "1234", "scenario": "Succes"},
|
||||||
|
{"market": "🇫🇷 FR", "username": "u84920173", "password": "pqs736", "otp": "1234", "scenario": "Succes"},
|
||||||
|
]
|
||||||
|
|
||||||
return templates.TemplateResponse("step.html", _ctx(request, {
|
return templates.TemplateResponse("step.html", _ctx(request, {
|
||||||
"step": 3,
|
"step": 3,
|
||||||
"title": "Tilslut Bank",
|
"title": "Tilslut Bank",
|
||||||
"subtitle": "Tink Link — Bank Connection",
|
"subtitle": "Tink Link — Bank Connection",
|
||||||
"endpoint": "GET /api/v1/oauth/authorization-grant/delegate",
|
"endpoint": "POST /api/v1/oauth/authorization-grant/delegate",
|
||||||
"api_version": "v1",
|
"api_version": "v1",
|
||||||
"description": linked_user_note or "Åbn Tink Link, vælg Tink Demo Bank og log ind med test-credentials.",
|
"description": description,
|
||||||
"curl_example": curl_example,
|
"curl_example": curl_example,
|
||||||
"result": credentials,
|
"result": grant_result or credentials,
|
||||||
"tink_link_url": tink_link_url,
|
"tink_link_url": tink_link_url,
|
||||||
"dev_tink_link_url": dev_tink_link_url,
|
"dev_tink_link_url": dev_tink_link_url,
|
||||||
|
"demo_bank_users": demo_bank_users,
|
||||||
"error": error or (f"Callback fejl: {cb_error}" if cb_error else None),
|
"error": error or (f"Callback fejl: {cb_error}" if cb_error else None),
|
||||||
"cb_success": cb_success,
|
"cb_success": cb_success,
|
||||||
"next_step": 4,
|
"next_step": 4,
|
||||||
@@ -313,7 +385,7 @@ async def step3_post(request: Request, code: str = Form(...)):
|
|||||||
code=code.strip(),
|
code=code.strip(),
|
||||||
redirect_uri=CONSOLE_CALLBACK,
|
redirect_uri=CONSOLE_CALLBACK,
|
||||||
)
|
)
|
||||||
sess["user_token"] = tokens.get("access_token", "")
|
_store_token(sess, "user_token", tokens.get("access_token", ""))
|
||||||
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
|
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303)
|
return RedirectResponse(f"/demo/step/3?cb_error={str(e)}", status_code=303)
|
||||||
@@ -325,16 +397,34 @@ async def tink_callback(request: Request, code: Optional[str] = None,
|
|||||||
credentials_id: Optional[str] = None):
|
credentials_id: Optional[str] = None):
|
||||||
"""Tink Link OAuth callback — exchange code for user token."""
|
"""Tink Link OAuth callback — exchange code for user token."""
|
||||||
sess = _session(request)
|
sess = _session(request)
|
||||||
|
print(f"[CALLBACK] code={code!r} error={error!r} session_keys={list(sess.keys())}")
|
||||||
if error:
|
if error:
|
||||||
|
print(f"[CALLBACK] Tink returned error: {error}")
|
||||||
return RedirectResponse(f"/demo/step/3?error={error}")
|
return RedirectResponse(f"/demo/step/3?error={error}")
|
||||||
if code:
|
if code:
|
||||||
try:
|
try:
|
||||||
|
s = get_settings()
|
||||||
|
print(f"[CALLBACK] Exchanging code, redirect_uri={s.tink_redirect_uri!r}")
|
||||||
client = _client(log_cb=_logger(sess))
|
client = _client(log_cb=_logger(sess))
|
||||||
tokens = await client.exchange_code_for_token(code)
|
tokens = await client.exchange_code_for_token(
|
||||||
sess["user_token"] = tokens.get("access_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,
|
||||||
|
)
|
||||||
|
_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)
|
return RedirectResponse("/demo/step/3?cb_success=1", status_code=303)
|
||||||
except Exception as e:
|
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)
|
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)
|
return RedirectResponse("/demo/step/3", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@@ -346,7 +436,7 @@ async def tink_callback(request: Request, code: Optional[str] = None,
|
|||||||
async def step4(request: Request):
|
async def step4(request: Request):
|
||||||
sess = _session(request)
|
sess = _session(request)
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
user_token = sess.get("user_token", "")
|
user_token = _load_token(sess, "user_token")
|
||||||
error = None
|
error = None
|
||||||
result = None
|
result = None
|
||||||
is_demo = sess.get("demo_mode") or s.demo_mode
|
is_demo = sess.get("demo_mode") or s.demo_mode
|
||||||
@@ -395,7 +485,7 @@ async def step4(request: Request):
|
|||||||
async def step5(request: Request, account_id: Optional[str] = None):
|
async def step5(request: Request, account_id: Optional[str] = None):
|
||||||
sess = _session(request)
|
sess = _session(request)
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
user_token = sess.get("user_token", "")
|
user_token = _load_token(sess, "user_token")
|
||||||
is_demo = sess.get("demo_mode") or s.demo_mode
|
is_demo = sess.get("demo_mode") or s.demo_mode
|
||||||
error = None
|
error = None
|
||||||
result = None
|
result = None
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="bg-slate-900 border border-slate-800 rounded-xl p-4">
|
<div class="bg-slate-900 border border-slate-800 rounded-xl p-4">
|
||||||
<p class="text-slate-300 text-sm leading-relaxed">{{ description }}</p>
|
<p class="text-slate-300 text-sm leading-relaxed">{{ description | safe }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- curl example -->
|
<!-- curl example -->
|
||||||
@@ -171,18 +171,91 @@
|
|||||||
<p class="text-xs text-slate-500 mt-2">Vælg <span class="text-slate-400">Tink Demo Bank → Open Banking → Password And OTP</span></p>
|
<p class="text-xs text-slate-500 mt-2">Vælg <span class="text-slate-400">Tink Demo Bank → Open Banking → Password And OTP</span></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<a href="{{ tink_link_url }}"
|
<a href="{{ tink_link_url }}"
|
||||||
class="inline-flex items-center gap-2 px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white font-semibold rounded-lg transition">
|
class="inline-flex items-center gap-2 px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white font-semibold rounded-lg transition">
|
||||||
Åbn Tink Link
|
Åbn Tink Link
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>
|
||||||
</a>
|
</a>
|
||||||
|
{% if demo_bank_users %}
|
||||||
|
<button onclick="document.getElementById('demo-users-modal').classList.remove('hidden')"
|
||||||
|
class="inline-flex items-center gap-1.5 px-4 py-2.5 border border-violet-700 text-violet-400 hover:text-violet-200 hover:border-violet-500 rounded-lg text-sm transition">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||||
|
Vis testbrugere
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
<a href="/demo/reset"
|
<a href="/demo/reset"
|
||||||
class="inline-flex items-center gap-1.5 px-4 py-2.5 border border-slate-700 text-slate-400 hover:text-white hover:border-slate-500 rounded-lg text-sm transition">
|
class="inline-flex items-center gap-1.5 px-4 py-2.5 border border-slate-700 text-slate-400 hover:text-white hover:border-slate-500 rounded-lg text-sm transition">
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||||
Start forfra
|
Start forfra
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if demo_bank_users %}
|
||||||
|
<!-- Demo Bank users modal -->
|
||||||
|
<div id="demo-users-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="absolute inset-0 bg-black/70 backdrop-blur-sm" onclick="document.getElementById('demo-users-modal').classList.add('hidden')"></div>
|
||||||
|
<div class="relative bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-800">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-white font-semibold text-base">Demo Bank — Testbrugere</h3>
|
||||||
|
<p class="text-slate-500 text-xs mt-0.5">Brug disse kredentialer når du logger ind i Tink Demo Bank. OTP er altid <code class="text-violet-300">1234</code> hvor det er påkrævet.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="document.getElementById('demo-users-modal').classList.add('hidden')"
|
||||||
|
class="text-slate-500 hover:text-white transition p-1.5 rounded-lg hover:bg-slate-800">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-auto max-h-96">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-800/50 sticky top-0">
|
||||||
|
<tr class="text-slate-400 text-xs uppercase tracking-wider">
|
||||||
|
<th class="px-4 py-2.5 text-left">Market</th>
|
||||||
|
<th class="px-4 py-2.5 text-left">Brugernavn</th>
|
||||||
|
<th class="px-4 py-2.5 text-left">Password</th>
|
||||||
|
<th class="px-4 py-2.5 text-left">OTP</th>
|
||||||
|
<th class="px-4 py-2.5 text-left">Scenarie</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-800">
|
||||||
|
{% for u in demo_bank_users %}
|
||||||
|
<tr class="hover:bg-slate-800/40 transition">
|
||||||
|
<td class="px-4 py-2.5 text-slate-300 font-medium">{{ u.market }}</td>
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="font-mono text-emerald-300 text-xs">{{ u.username }}</code>
|
||||||
|
<button onclick="navigator.clipboard.writeText('{{ u.username }}')" title="Kopier"
|
||||||
|
class="text-slate-600 hover:text-slate-300 transition">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="font-mono text-violet-300 text-xs">{{ u.password }}</code>
|
||||||
|
<button onclick="navigator.clipboard.writeText('{{ u.password }}')" title="Kopier"
|
||||||
|
class="text-slate-600 hover:text-slate-300 transition">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5 font-mono text-xs text-slate-400">{{ u.otp or "—" }}</td>
|
||||||
|
<td class="px-4 py-2.5">
|
||||||
|
<span class="px-2 py-0.5 rounded-full text-xs font-medium {% if 'fejl' in u.scenario.lower() %}bg-red-900/40 text-red-400{% else %}bg-emerald-900/40 text-emerald-400{% endif %}">
|
||||||
|
{{ u.scenario }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-3 border-t border-slate-800 text-xs text-slate-500">
|
||||||
|
Kilde: <a href="https://docs.tink.com/resources/tutorials/test-your-integration-with-demo-bank" target="_blank" class="text-violet-400 hover:text-violet-300 underline">Tink Demo Bank dokumentation</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FALLBACK: console.tink.com/callback + manual code paste -->
|
<!-- FALLBACK: console.tink.com/callback + manual code paste -->
|
||||||
|
|||||||
@@ -38,8 +38,8 @@
|
|||||||
<span class="ml-2 text-xs px-2 py-0.5 rounded-full font-mono font-semibold bg-slate-800 text-slate-400 border border-slate-700">v1</span>
|
<span class="ml-2 text-xs px-2 py-0.5 rounded-full font-mono font-semibold bg-slate-800 text-slate-400 border border-slate-700">v1</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-slate-400 text-sm mt-2 max-w-2xl">
|
<p class="text-slate-400 text-sm mt-2 max-w-2xl">
|
||||||
Opret en Tink-bruger med dit eget <code class="text-violet-300">external_user_id</code> —
|
Opret en Tink-bruger med en <code class="text-violet-300">tink_external_ref</code> —
|
||||||
dette er dit interne kunde-ID som du kan søge på i Tink Console.
|
MoneyCapp's interne reference til kunden i Tink. Gemmes i jeres kundedatabase som <code class="text-violet-300">tink_external_ref</code> (ikke jeres interne <code class="text-slate-400">customer_id</code>).
|
||||||
Tink returnerer et <code class="text-violet-300">user_id</code> som bruges i efterfølgende kald.
|
Tink returnerer et <code class="text-violet-300">user_id</code> som bruges i efterfølgende kald.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +88,10 @@
|
|||||||
<div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden max-w-xl">
|
<div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden max-w-xl">
|
||||||
<div class="px-5 py-4 border-b border-slate-800">
|
<div class="px-5 py-4 border-b border-slate-800">
|
||||||
<p class="text-sm font-semibold text-white">Hvem opretter vi?</p>
|
<p class="text-sm font-semibold text-white">Hvem opretter vi?</p>
|
||||||
<p class="text-xs text-slate-400 mt-0.5">external_user_id bruges til at identificere kunden i Tink Console</p>
|
<p class="text-xs text-slate-400 mt-0.5">
|
||||||
|
<code class="text-violet-300">tink_external_ref</code> = jeres reference til kunden i Tink —
|
||||||
|
adskilt fra jeres interne <code class="text-slate-400">customer_id</code>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="/demo/step/2" class="p-5 space-y-4">
|
<form method="POST" action="/demo/step/2" class="p-5 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -98,7 +101,7 @@
|
|||||||
value="Henrik Jess"
|
value="Henrik Jess"
|
||||||
class="w-full bg-slate-950 border border-slate-700 rounded-lg px-4 py-2.5 text-white text-sm font-mono
|
class="w-full bg-slate-950 border border-slate-700 rounded-lg px-4 py-2.5 text-white text-sm font-mono
|
||||||
placeholder-slate-600 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500/30 transition">
|
placeholder-slate-600 focus:outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500/30 transition">
|
||||||
<p class="text-xs text-slate-600 mt-1.5">→ bliver til <code class="text-violet-300/70">moneycapp-henrik-jess</code></p>
|
<p class="text-xs text-slate-600 mt-1.5">→ <code class="text-violet-300/70">tink_external_ref</code> = <code class="text-violet-300/70">moneycapp-henrik-jess-a3f9c1</code></p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Marked</label>
|
<label class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Marked</label>
|
||||||
@@ -133,16 +136,20 @@
|
|||||||
<p class="text-xs text-slate-500 uppercase tracking-wider mb-3">API endpoint</p>
|
<p class="text-xs text-slate-500 uppercase tracking-wider mb-3">API endpoint</p>
|
||||||
<code class="text-emerald-400 font-mono text-sm">POST https://api.tink.com/api/v1/user/create</code>
|
<code class="text-emerald-400 font-mono text-sm">POST https://api.tink.com/api/v1/user/create</code>
|
||||||
<div class="mt-3 bg-slate-950 rounded-lg p-3 overflow-x-auto">
|
<div class="mt-3 bg-slate-950 rounded-lg p-3 overflow-x-auto">
|
||||||
<pre class="text-xs text-amber-300 font-mono whitespace-pre"># Request body
|
<pre class="text-xs text-amber-300 font-mono whitespace-pre"># MoneyCapp DB:
|
||||||
|
# customer_id = 42 ← jeres interne ID (Tink ser det aldrig)
|
||||||
|
# tink_external_ref = "moneycapp-42-a3f9c1" ← Tink-reference
|
||||||
|
|
||||||
|
# Request body
|
||||||
{
|
{
|
||||||
"external_user_id": "moneycapp-<kundenavn>", ← dit interne ID
|
"external_user_id": "moneycapp-<ref>", ← tink_external_ref
|
||||||
"market": "DK",
|
"market": "DK",
|
||||||
"locale": "da_DK"
|
"locale": "da_DK"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Response
|
# Response
|
||||||
{
|
{
|
||||||
"user_id": "abc123..." ← Tinks interne ID, brug dette fremadrettet
|
"user_id": "abc123..." ← Tinks interne ID, gem og brug fremadrettet
|
||||||
}</pre>
|
}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,12 +20,26 @@ class TinkTokens:
|
|||||||
|
|
||||||
def _log_entry(method: str, url: str, req_body: dict | None,
|
def _log_entry(method: str, url: str, req_body: dict | None,
|
||||||
status: int, resp_body: dict, duration_ms: int) -> dict:
|
status: int, resp_body: dict, duration_ms: int) -> dict:
|
||||||
|
def _redact(d: dict) -> dict:
|
||||||
|
"""Replace long string values (JWT tokens etc.) with truncated versions."""
|
||||||
|
if not isinstance(d, dict):
|
||||||
|
return d
|
||||||
|
out = {}
|
||||||
|
for k, v in d.items():
|
||||||
|
if isinstance(v, str) and len(v) > 60:
|
||||||
|
out[k] = v[:12] + "…[redacted]"
|
||||||
|
elif isinstance(v, dict):
|
||||||
|
out[k] = _redact(v)
|
||||||
|
else:
|
||||||
|
out[k] = v
|
||||||
|
return out
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"method": method,
|
"method": method,
|
||||||
"url": url,
|
"url": url,
|
||||||
"req_body": req_body,
|
"req_body": _redact(req_body) if req_body else req_body,
|
||||||
"status": status,
|
"status": status,
|
||||||
"resp_body": resp_body,
|
"resp_body": _redact(resp_body),
|
||||||
"duration_ms": duration_ms,
|
"duration_ms": duration_ms,
|
||||||
"ts": time.strftime("%H:%M:%S"),
|
"ts": time.strftime("%H:%M:%S"),
|
||||||
"ok": status < 400,
|
"ok": status < 400,
|
||||||
@@ -145,10 +159,13 @@ class TinkClient:
|
|||||||
"redirect_uri": redirect_uri_override or self.redirect_uri,
|
"redirect_uri": redirect_uri_override or self.redirect_uri,
|
||||||
"market": market,
|
"market": market,
|
||||||
"locale": "da_DK",
|
"locale": "da_DK",
|
||||||
"scope": "accounts:read,transactions:read,credentials:read",
|
|
||||||
}
|
}
|
||||||
if authorization_code:
|
if authorization_code:
|
||||||
|
# scope is already embedded in the authorization_code grant — do NOT add it again
|
||||||
params["authorization_code"] = authorization_code
|
params["authorization_code"] = authorization_code
|
||||||
|
else:
|
||||||
|
# anonymous flow — scope must be explicit
|
||||||
|
params["scope"] = "accounts:read,transactions:read,credentials:read"
|
||||||
return f"{self.link_base}/1.0/transactions/connect-accounts?{urlencode(params)}"
|
return f"{self.link_base}/1.0/transactions/connect-accounts?{urlencode(params)}"
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user