| """API endpoints for enrollment, authentication and verification.""" |
|
|
| from __future__ import annotations |
|
|
| import logging |
|
|
| import base64 |
| import json |
| import os |
| import time |
|
|
| from fastapi import APIRouter, Body, Depends, HTTPException, Request |
|
|
| from amanpay.models.authenticator import AmanPayAuthenticator |
| from api.auth import authorize, mint_token, rate_limit, require_auth |
| from api.observability import record_auth, record_payment |
| from api.dependencies import ( |
| decode_image, |
| get_model, |
| preprocess_face, |
| preprocess_fingerprint, |
| preprocess_voice, |
| state, |
| ) |
| from api.schemas import ( |
| AuthRequest, |
| AuthResponse, |
| ChallengeRequest, |
| ChallengeResponse, |
| EnrollRequest, |
| EnrollResponse, |
| HealthResponse, |
| PasskeyAuthRequest, |
| PasskeyAuthResponse, |
| PasskeyRegisterRequest, |
| UserListResponse, |
| VerifyRequest, |
| VerifyResponse, |
| ) |
|
|
| logger = logging.getLogger(__name__) |
| router = APIRouter() |
|
|
|
|
| @router.get("/health", response_model=HealthResponse) |
| def health() -> HealthResponse: |
| return HealthResponse(status="ok", model_loaded=state.loaded, device=state.device) |
|
|
|
|
| @router.get("/biometrics/status") |
| def biometrics_status() -> dict: |
| """Per-modality capability so the React biometric UI can show available / |
| demo_untrained / unavailable per service. Booleans + labels only — no secrets.""" |
| return state.biometric_status() |
|
|
|
|
| @router.post("/enroll", response_model=EnrollResponse) |
| def enroll(req: EnrollRequest, |
| model: AmanPayAuthenticator = Depends(get_model)) -> EnrollResponse: |
| face = preprocess_face(decode_image(req.face_image)) |
| fp = preprocess_fingerprint(decode_image(req.fingerprint_image)) |
| result = model.enroll(req.user_id, face, fp) |
| return EnrollResponse( |
| success=result["success"], |
| user_id=req.user_id, |
| face_liveness=result.get("face_liveness", 0.0), |
| fp_liveness=result.get("fp_liveness", 0.0), |
| reason=result.get("reason"), |
| ) |
|
|
|
|
| @router.post("/authenticate", response_model=AuthResponse) |
| def authenticate(req: AuthRequest, |
| model: AmanPayAuthenticator = Depends(get_model)) -> AuthResponse: |
| face_img = decode_image(req.face_image) |
| df = state.deepfake_score(face_img) |
| is_df = df is not None and df >= state.deepfake_threshold |
|
|
| face = preprocess_face(face_img) |
| fp = preprocess_fingerprint(decode_image(req.fingerprint_image)) |
| result = model.authenticate(req.user_id, face, fp) |
|
|
| success = result.get("success", False) and not is_df |
| reason = "Deepfake/injection detected" if is_df else result.get("reason") |
| return AuthResponse( |
| success=success, |
| similarity=result.get("similarity", 0.0), |
| threshold=result.get("threshold", model.similarity_threshold), |
| face_liveness=result.get("face_liveness", 0.0), |
| fp_liveness=result.get("fp_liveness", 0.0), |
| confidence=result.get("confidence"), |
| face_quality=result.get("face_quality"), |
| fp_quality=result.get("fp_quality"), |
| protected=result.get("protected"), |
| deepfake_score=df, |
| is_deepfake=is_df, |
| reason=reason, |
| ) |
|
|
|
|
| @router.post("/verify", response_model=VerifyResponse) |
| def verify(req: VerifyRequest, |
| model: AmanPayAuthenticator = Depends(get_model)) -> VerifyResponse: |
| face1 = preprocess_face(decode_image(req.face1)) |
| fp1 = preprocess_fingerprint(decode_image(req.fp1)) |
| face2 = preprocess_face(decode_image(req.face2)) |
| fp2 = preprocess_fingerprint(decode_image(req.fp2)) |
| result = model.verify(face1, fp1, face2, fp2) |
| return VerifyResponse(**result) |
|
|
|
|
| @router.post("/passkey/register") |
| def passkey_register(req: PasskeyRegisterRequest) -> dict: |
| import base64 |
| state.passkeys.register(req.user_id, base64.b64decode(req.public_key)) |
| return {"success": True, "user_id": req.user_id} |
|
|
|
|
| @router.post("/passkey/challenge", response_model=ChallengeResponse) |
| def passkey_challenge(req: ChallengeRequest) -> ChallengeResponse: |
| import base64 |
| import time |
| nonce = state.passkeys.issue_challenge(req.user_id, now=time.time()) |
| if nonce is None: |
| raise HTTPException(status_code=404, detail="device not registered") |
| return ChallengeResponse(user_id=req.user_id, |
| challenge=base64.b64encode(nonce).decode()) |
|
|
|
|
| @router.post("/passkey/authenticate", response_model=PasskeyAuthResponse) |
| def passkey_authenticate(req: PasskeyAuthRequest, |
| model: AmanPayAuthenticator = Depends(get_model)) -> PasskeyAuthResponse: |
| """Two-factor: multimodal biometric (inherence) + device-key signature |
| over a single-use challenge (possession).""" |
| import base64 |
| import time |
| face = preprocess_face(decode_image(req.face_image)) |
| fp = preprocess_fingerprint(decode_image(req.fingerprint_image)) |
| state.ensure_user_loaded(req.user_id) |
| bio = model.authenticate(req.user_id, face, fp) |
| bio_ok = bool(bio.get("success", False)) |
| r = state.passkeys.verify_assertion( |
| req.user_id, base64.b64decode(req.challenge), |
| base64.b64decode(req.signature), bio_ok, now=time.time()) |
| return PasskeyAuthResponse( |
| success=r["success"], biometric_success=bio_ok, |
| similarity=bio.get("similarity", 0.0), |
| factors=r.get("factors"), reason=r.get("reason")) |
|
|
|
|
| def _b64(s: str) -> bytes: |
| if isinstance(s, str) and s.startswith("data:") and "," in s: |
| s = s.split(",", 1)[1] |
| return base64.b64decode(s) |
|
|
|
|
| |
| @router.post("/voice/enroll") |
| def voice_enroll(body: dict = Body(...)) -> dict: |
| return state.voice.enroll(body["user_id"], _b64(body["audio"])) |
|
|
|
|
| @router.post("/voice/verify") |
| def voice_verify(body: dict = Body(...)) -> dict: |
| return state.voice.verify(body["user_id"], _b64(body["audio"])) |
|
|
|
|
| |
| @router.post("/liveness/challenge") |
| def liveness_challenge(body: dict = Body(...)) -> dict: |
| return state.liveness.issue(body.get("user_id", "user"), |
| n=int(body.get("n", 3)), now=time.time()) |
|
|
|
|
| @router.post("/liveness/verify") |
| def liveness_verify(body: dict = Body(...)) -> dict: |
| df_ok = True |
| if body.get("face_image"): |
| s = state.deepfake_score(decode_image(body["face_image"])) |
| df_ok = s is None or s < state.deepfake_threshold |
| return state.liveness.verify(body["session_id"], body.get("completed", []), |
| now=time.time(), deepfake_ok=df_ok) |
|
|
|
|
| |
| def _webauthn(request: Request | None = None): |
| if state.webauthn is None: |
| from amanpay.security.webauthn_server import WebAuthnServer |
| state.webauthn = WebAuthnServer() |
| |
| |
| |
| |
| if request is not None and not os.getenv("AMANPAY_RP_ID"): |
| host = (request.headers.get("host") or "").split(":")[0] |
| allowed = [h.strip() for h in os.getenv("AMANPAY_ALLOWED_HOSTS", "").split(",") if h.strip()] |
| ok = host and (host == "localhost" or not allowed or host in allowed) |
| if ok: |
| state.webauthn.rp_id = host |
| state.webauthn.origin = ("https://localhost:8443" if host == "localhost" |
| else f"https://{host}") |
| return state.webauthn |
|
|
|
|
| @router.post("/webauthn/register/begin") |
| def wa_register_begin(request: Request, body: dict = Body(...)) -> dict: |
| return json.loads(_webauthn(request).register_begin(body["user_id"])) |
|
|
|
|
| @router.post("/webauthn/register/complete") |
| def wa_register_complete(request: Request, body: dict = Body(...)) -> dict: |
| try: |
| r = _webauthn(request).register_complete(body["user_id"], body["credential"]) |
| state.persist() |
| return r |
| except Exception as exc: |
| return {"success": False, "reason": str(exc)} |
|
|
|
|
| @router.post("/webauthn/authenticate/begin") |
| def wa_auth_begin(request: Request, body: dict = Body(...)) -> dict: |
| state.ensure_user_loaded(body["user_id"]) |
| opts = _webauthn(request).authenticate_begin(body["user_id"]) |
| if opts is None: |
| raise HTTPException(status_code=404, detail="no registered device") |
| return json.loads(opts) |
|
|
|
|
| @router.post("/webauthn/authenticate/complete") |
| def wa_auth_complete(request: Request, body: dict = Body(...)) -> dict: |
| state.ensure_user_loaded(body["user_id"]) |
| try: |
| return _webauthn(request).authenticate_complete(body["user_id"], body["credential"]) |
| except Exception as exc: |
| return {"success": False, "reason": str(exc)} |
|
|
|
|
| |
| def _unified_samples(body: dict) -> dict: |
| s = {} |
| if body.get("face_image"): |
| s["face"] = preprocess_face(decode_image(body["face_image"])) |
| if body.get("fingerprint_image"): |
| s["fingerprint"] = preprocess_fingerprint(decode_image(body["fingerprint_image"])) |
| if body.get("voice_audio"): |
| s["voice"] = preprocess_voice(_b64(body["voice_audio"])) |
| return s |
|
|
|
|
| @router.post("/unified/enroll") |
| def unified_enroll(request: Request, body: dict = Body(...)) -> dict: |
| if state.unified is None: |
| raise HTTPException(status_code=503, detail="unified model not loaded") |
| rate_limit(request, "enroll", capacity=6, refill_per_sec=0.2) |
| r = state.unified.enroll(body["user_id"], **_unified_samples(body)) |
| state.persist() |
| state.audit(body["user_id"], "enroll", {"modalities": r.get("modalities")}) |
| |
| |
| r["token"] = mint_token(body["user_id"]) |
| return r |
|
|
|
|
| @router.post("/unified/authenticate") |
| def unified_authenticate(request: Request, body: dict = Body(...)) -> dict: |
| if state.unified is None: |
| raise HTTPException(status_code=503, detail="unified model not loaded") |
| authorize(body["user_id"], request) |
| rate_limit(request, "auth", capacity=8, refill_per_sec=0.3) |
| state.ensure_user_loaded(body["user_id"]) |
| result = state.unified.authenticate(body["user_id"], **_unified_samples(body)) |
| record_auth("success" if result.get("success") else "fail") |
| |
| if body.get("face_image"): |
| df = state.deepfake_score(decode_image(body["face_image"])) |
| if df is not None: |
| result["deepfake_score"] = df |
| if df >= state.deepfake_threshold: |
| result["success"] = False |
| result["reason"] = "Deepfake/injection detected" |
| return result |
|
|
|
|
| |
| @router.post("/pad/challenge") |
| def pad_challenge(body: dict = Body(...)) -> dict: |
| """Issue a one-time random screen-colour sequence. The client flashes each |
| colour from the screen during face capture and reports the reflected colours.""" |
| return state.flash.issue(body.get("user_id", "user"), |
| n=int(body.get("n", 4)), now=time.time()) |
|
|
|
|
| @router.post("/pad/verify") |
| def pad_verify(body: dict = Body(...)) -> dict: |
| """Verify captured face-region reflection against the issued flash sequence.""" |
| return state.flash.verify(body["session_id"], body["baseline"], |
| body["measurements"], now=time.time()) |
|
|
|
|
| |
| |
| |
| |
|
|
|
|
| |
| @router.post("/device/verify") |
| def device_verify(request: Request, body: dict = Body(...)) -> dict: |
| """Verify a WebAuthn platform-authenticator assertion (the device's built-in |
| fingerprint / Face ID) over a single-use challenge — strong possession + |
| on-device inherence that composes with AmanPay's server-side multimodal auth.""" |
| import base64 |
| import time |
| authorize(body["user_id"], request) |
| rate_limit(request, "device", capacity=8, refill_per_sec=0.3) |
| state.ensure_user_loaded(body["user_id"]) |
| r = state.passkeys.verify_assertion( |
| body["user_id"], base64.b64decode(body["challenge"]), |
| base64.b64decode(body["signature"]), True, now=time.time()) |
| return {"success": bool(r.get("success")), "authenticator": "platform", |
| "factors": r.get("factors"), "reason": r.get("reason")} |
|
|
|
|
| |
| @router.post("/wallet/consent") |
| def wallet_consent(request: Request, body: dict = Body(...)) -> dict: |
| authorize(body["user_id"], request) |
| return state.wallet.record_consent( |
| body["user_id"], body.get("purpose", "biometric payment authentication"), |
| body.get("duration", "until account closure"), now=time.time()) |
|
|
|
|
| @router.post("/wallet/link") |
| def wallet_link(request: Request, body: dict = Body(...)) -> dict: |
| authorize(body["user_id"], request) |
| r = state.wallet.link_account( |
| body["user_id"], body.get("bank", "Bank"), body.get("kind", "checking"), |
| body.get("funding_ref", "0000"), now=time.time()) |
| state.persist() |
| return r |
|
|
|
|
| @router.get("/wallet/accounts") |
| def wallet_accounts(user_id: str, request: Request) -> dict: |
| authorize(user_id, request) |
| return {"accounts": state.wallet.accounts(user_id)} |
|
|
|
|
| @router.get("/wallet/transactions") |
| def wallet_transactions(user_id: str, request: Request) -> dict: |
| authorize(user_id, request) |
| return {"transactions": state.wallet.transactions(user_id)} |
|
|
|
|
| @router.post("/wallet/pay") |
| def wallet_pay(request: Request, body: dict = Body(...)) -> dict: |
| """Authorize a payment purely by biometrics: tri-modal verification (inherence) |
| on a device-bound app (possession) = PSD2 SCA, signed to the transaction (SPC). |
| |
| Risk-adaptive (Visa-style): low-risk pays frictionlessly under an SCA exemption; |
| high-risk must clear an active screen-flash liveness challenge first. Pass a |
| prior ``flash_session`` that verified successfully to satisfy the step-up.""" |
| if state.unified is None: |
| raise HTTPException(status_code=503, detail="unified model not loaded") |
| authorize(body["user_id"], request) |
| rate_limit(request, "pay", capacity=8, refill_per_sec=0.3) |
| uid = body["user_id"] |
| state.ensure_user_loaded(uid) |
| amount = float(body.get("amount", 0)) |
| merchant = body.get("merchant", "Merchant") |
| samples = _unified_samples(body) |
| bio = state.unified.authenticate(uid, **samples) |
| biometric_ok = bool(bio.get("success", False)) |
| |
| is_df = False |
| if body.get("face_image"): |
| df = state.deepfake_score(decode_image(body["face_image"])) |
| if df is not None and df >= state.deepfake_threshold: |
| is_df = True; biometric_ok = False; bio["reason"] = "Deepfake/injection detected" |
|
|
| |
| liveness_ok = bool(body.get("liveness_ok", False)) |
| device_verified = bool(body.get("device_verified", False)) |
| oob_confirmed = state.notify.is_approved(body.get("confirmation_id"), now=time.time()) |
| |
| geo = state.geo.assess(uid, body.get("lat"), body.get("lon"), |
| country=body.get("country"), now=time.time()) |
|
|
| recent = len(state.wallet.transactions(uid)) |
| risk = state.risk.assess( |
| amount=amount, biometric_ok=biometric_ok, |
| confidence=float(bio.get("confidence") or bio.get("similarity") or 0.0), |
| modalities=bio.get("modalities"), recent_count=recent, deepfake=is_df, |
| liveness_ok=liveness_ok, device_verified=device_verified, |
| oob_confirmed=oob_confirmed, geo=geo) |
|
|
| record_payment(risk["decision"]) |
| bio_view = {"success": biometric_ok, "similarity": bio.get("similarity"), |
| "modalities": bio.get("modalities"), "reason": bio.get("reason")} |
|
|
| |
| |
| |
| if risk["decision"] == "step_up": |
| conf = state.notify.send_payment_confirmation(uid, amount, merchant, now=time.time()) |
| return {"success": False, "decision": "step_up", "risk": risk, |
| "challenge_required": True, |
| "step_up_options": ["active_liveness", "device_biometric", "out_of_band"], |
| "confirmation": {"confirmation_id": conf["confirmation_id"], |
| "channels": conf["channels"], |
| "dynamic_linked": conf["dynamic_linked"]}, |
| "geo": geo, |
| "reason": "Extra verification required — " + ", ".join(risk["reasons"]), |
| "biometric": bio_view} |
| if risk["decision"] == "decline": |
| return {"success": False, "decision": "decline", "risk": risk, "geo": geo, |
| "reason": risk["reasons"][0] if risk["reasons"] else "declined", |
| "biometric": bio_view} |
|
|
| |
| |
| |
| |
| import os as _os |
| _strict = (_os.getenv("AMANPAY_STRICT_SCA", "0").strip().lower() |
| not in ("", "0", "false", "no")) |
| possession_ok = (state.passkeys.is_registered(uid) or device_verified |
| or oob_confirmed or (bool(samples) and not _strict)) |
| r = state.wallet.authorize_payment( |
| uid, body["account_id"], amount, merchant, biometric_ok, possession_ok, |
| now=time.time()) |
| r["decision"] = risk["decision"] |
| r["risk"] = risk |
| r["geo"] = geo |
| r["biometric"] = bio_view |
| |
| if r.get("success"): |
| r["confirmation"] = state.notify.send_payment_confirmation( |
| uid, amount, merchant, now=time.time()) |
| state.persist() |
| state.audit(uid, "pay", {"amount": amount, "merchant": merchant, |
| "decision": risk["decision"], "risk": risk["risk"], |
| "signature": r.get("transaction", {}).get("signature")}) |
| return r |
|
|
|
|
| |
| _EXAMPLES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "examples") |
|
|
|
|
| def _example_b64(name: str) -> str | None: |
| path = os.path.join(_EXAMPLES_DIR, name) |
| if not os.path.exists(path): |
| return None |
| mt = "audio/wav" if name.endswith(".wav") else \ |
| "image/png" if name.endswith(".png") else "image/jpeg" |
| with open(path, "rb") as fh: |
| return f"data:{mt};base64," + base64.b64encode(fh.read()).decode() |
|
|
|
|
| @router.post("/demo/seed") |
| def demo_seed(body: dict = Body(...)) -> dict: |
| """Seed a ready-to-pay demo: enroll the tri-modal `alice` identity from the |
| bundled example samples, record consent, link a token-backed account, and |
| return the *authenticate* samples so the client can run a payment immediately.""" |
| if state.unified is None: |
| raise HTTPException(status_code=503, detail="unified model not loaded") |
| uid = body.get("user_id", "demo") |
| enroll = {k: _example_b64(v) for k, v in { |
| "face_image": "alice_face_enroll.jpg", |
| "fingerprint_image": "alice_fp_enroll.png", |
| "voice_audio": "alice_voice_enroll.wav"}.items()} |
| if not any(enroll.values()): |
| raise HTTPException(status_code=404, detail="example samples not found") |
| en = state.unified.enroll(uid, **_unified_samples(enroll)) |
| state.wallet.record_consent(uid, "biometric payments (demo)", "1 year", now=time.time()) |
| acc = state.wallet.link_account(uid, "Chase", "checking", "4242424242424242", |
| now=time.time()).get("account") |
| auth = {k: _example_b64(v) for k, v in { |
| "face_image": "alice_face_auth.jpg", |
| "fingerprint_image": "alice_fp_auth.png", |
| "voice_audio": "alice_voice_auth.wav"}.items()} |
| return {"success": bool(en.get("success")), "user_id": uid, |
| "modalities": en.get("modalities"), "account": acc, |
| "token": mint_token(uid), |
| "samples": {k: v for k, v in auth.items() if v}} |
|
|
|
|
| |
| @router.post("/payments") |
| def payments_create(request: Request, body: dict = Body(...)) -> dict: |
| """Initiate a customer-bank -> merchant payment via a licensed provider (mock for |
| now). Non-custodial: AmanPay never holds funds. Requires an Idempotency-Key.""" |
| from amanpay.payments import PaymentError |
| authorize(body["user_id"], request) |
| rate_limit(request, "payments", capacity=8, refill_per_sec=0.3) |
| idem = request.headers.get("idempotency-key") or body.get("idempotency_key") |
| if not idem: |
| raise HTTPException(status_code=400, detail="Idempotency-Key required") |
| try: |
| p = state.payments.create_payment( |
| user_id=body["user_id"], amount_minor=int(body["amount_minor"]), |
| country=body.get("country"), currency=body.get("currency"), |
| merchant_id=body["merchant_id"], payee_iban=body["payee_iban"], |
| consent=body.get("consent") or {}, idempotency_key=idem, |
| correlation_id=request.headers.get("x-correlation-id"), |
| description=body.get("description", ""), now=time.time()) |
| except PaymentError as exc: |
| raise HTTPException(status_code=422, detail=str(exc)) |
| return p.view() |
|
|
|
|
| @router.get("/payments/providers") |
| def payments_providers() -> dict: |
| """Provider routing + capabilities per market, and the Saudi-first defaults.""" |
| from amanpay.payments.config import (DEFAULT_COUNTRY, DEFAULT_CURRENCY, |
| DEFAULT_TIMEZONE, LOCALES) |
| from amanpay.payments.registry import get_provider, provider_name_for |
| routing = {} |
| for cc in ["SA", "AE", "GB", "EU", "US"]: |
| try: |
| routing[cc] = {"provider": provider_name_for(cc), |
| "capabilities": get_provider(cc).capabilities().__dict__} |
| except NotImplementedError as exc: |
| routing[cc] = {"provider": provider_name_for(cc), "status": str(exc)} |
| return {"default": {"country": DEFAULT_COUNTRY, "currency": DEFAULT_CURRENCY, |
| "timezone": DEFAULT_TIMEZONE, "locales": LOCALES}, |
| "non_custodial": True, "routing": routing} |
|
|
|
|
| @router.get("/payments/{payment_id}") |
| def payments_get(payment_id: str, request: Request) -> dict: |
| from amanpay.payments import PaymentError |
| try: |
| p = state.payments.get_status(payment_id, now=time.time()) |
| except PaymentError: |
| raise HTTPException(status_code=404, detail="payment not found") |
| authorize(p.user_id, request) |
| return p.view() |
|
|
|
|
| @router.post("/payments/{payment_id}/cancel") |
| def payments_cancel(payment_id: str, request: Request) -> dict: |
| from amanpay.payments import PaymentError |
| p = state.payments.store.get(payment_id) |
| if p is None: |
| raise HTTPException(status_code=404, detail="payment not found") |
| authorize(p.user_id, request) |
| try: |
| return state.payments.cancel(payment_id, now=time.time()).view() |
| except PaymentError as exc: |
| raise HTTPException(status_code=422, detail=str(exc)) |
|
|
|
|
| @router.post("/payments/{payment_id}/refund") |
| def payments_refund(payment_id: str, request: Request, body: dict = Body(...)) -> dict: |
| from amanpay.payments import PaymentError |
| p = state.payments.store.get(payment_id) |
| if p is None: |
| raise HTTPException(status_code=404, detail="payment not found") |
| authorize(p.user_id, request) |
| idem = (request.headers.get("idempotency-key") or body.get("idempotency_key") |
| or ("rf_" + str(int(time.time() * 1000)))) |
| try: |
| return state.payments.refund(payment_id, int(body["amount_minor"]), idem, |
| body.get("reason", ""), now=time.time()).view() |
| except PaymentError as exc: |
| raise HTTPException(status_code=422, detail=str(exc)) |
|
|
|
|
| @router.post("/payments/{payment_id}/mock-advance") |
| def payments_mock_advance(payment_id: str, request: Request, body: dict = Body(...)) -> dict: |
| """DEV/mock only: simulate the provider advancing this payment by delivering a |
| *signed* webhook internally (so it exercises the real signature/idempotency/ordering |
| path). Rejected for any real provider — never a production settlement path.""" |
| import secrets as _secrets |
| p = state.payments.store.get(payment_id) |
| if p is None: |
| raise HTTPException(status_code=404, detail="payment not found") |
| authorize(p.user_id, request) |
| if p.provider != "mock": |
| raise HTTPException(status_code=403, detail="mock-advance is only for the mock provider") |
| from amanpay.payments.registry import get_provider |
| prov = get_provider(p.country) |
| payload = {"event_id": "mev_" + _secrets.token_hex(6), "provider_ref": p.provider_ref, |
| "raw_status": body.get("raw_status", "SETTLED"), |
| "sequence": int(time.time() * 1000)} |
| headers, raw = prov.sign_webhook(payload, now=time.time()) |
| return state.payments.handle_webhook(p.country, headers, raw, now=time.time()) |
|
|
|
|
| @router.post("/payments/webhooks/{provider}") |
| async def payments_webhook(provider: str, request: Request) -> dict: |
| """Provider webhook receiver — settlement evidence comes ONLY from signed webhooks, |
| never from redirect/callback query params. Unauthenticated by design (verified by |
| the provider's webhook signature inside handle_webhook).""" |
| raw = await request.body() |
| headers = {k.lower(): v for k, v in request.headers.items()} |
| return state.payments.handle_webhook(provider, headers, raw, now=time.time()) |
|
|
|
|
|
|
|
|
| @router.get("/report-card") |
| def report_card(refresh: bool = False, |
| model: AmanPayAuthenticator = Depends(get_model)) -> dict: |
| """Standards & performance conformance summary (recognition, PAD/FIDO, |
| ISO/IEC 24745 template protection, deployment). Cached; ``?refresh=true`` rebuilds.""" |
| if state.report_card is None or refresh: |
| from amanpay.evaluation.report_card import build_report_card |
| cfg = model.config |
| state.report_card = build_report_card( |
| fusion_dim=cfg.fusion.output_dim, protection_bits=cfg.auth.protection_bits) |
| return state.report_card |
|
|
|
|
| @router.get("/users", response_model=UserListResponse) |
| def list_users(request: Request, |
| model: AmanPayAuthenticator = Depends(get_model)) -> UserListResponse: |
| |
| if require_auth(): |
| if request.headers.get("x-admin-token") != os.getenv("AMANPAY_ADMIN_TOKEN", ""): |
| raise HTTPException(status_code=403, detail="admin only") |
| return UserListResponse(users=list(model.enrolled_templates.keys())) |
|
|
|
|
| @router.delete("/users/{user_id}") |
| def delete_user(user_id: str, request: Request) -> dict: |
| """Right-to-erasure: cascade-delete the user across memory + datastore.""" |
| authorize(user_id, request) |
| ok = state.erase(user_id) |
| state.audit(user_id, "erasure", {"cascade": True}) |
| return {"success": True, "user_id": user_id, "erased_in_store": ok, |
| "message": "User and all linked data erased"} |
|
|
|
|
| @router.get("/wallet/spc-key") |
| def wallet_spc_key() -> dict: |
| """Publish the server's Secure-Payment-Confirmation Ed25519 verification key so |
| a merchant/auditor can independently verify transaction signatures.""" |
| from amanpay.banking.wallet import spc_public_key_hex |
| return {"alg": "Ed25519", "public_key_hex": spc_public_key_hex()} |
|
|