| """D2 identity/account API — deterministic, torch-free, dormant unless enabled. |
| |
| Mounted only when ``AMANPAY_D2_ENABLED=1`` (see ``api/main.py``). The runtime (durable store, |
| services) is built during application startup and injected here; nothing in this module opens |
| a database, connects to a bucket, or runs at import time. |
| |
| Security boundaries enforced here: |
| * per-user resources are bound to the SESSION COOKIE subject, never a client-supplied id; |
| * state-changing requests require a matching ``X-CSRF-Token``; |
| * operator routes require ``role == 'operator'`` (deterministic RBAC); |
| * sensitive actions require a recent passkey step-up; |
| * responses are safe projections — no public-key bytes, token/invitation hashes, raw |
| challenges, storage paths or bucket keys ever leave the server; |
| * errors are normalized reason tokens (see :mod:`amanpay.identity.errors`) and never disclose |
| whether an account exists. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import secrets |
| import time |
| from typing import Optional |
|
|
| from fastapi import APIRouter, Body, Request, Response |
| from fastapi.responses import JSONResponse |
|
|
| from amanpay.identity.errors import D2Error, status_for |
| from amanpay.identity.models import Credential, Session, User |
| from amanpay.identity.sessions import SESSION_COOKIE |
|
|
| |
| from api.auth import rate_limit |
|
|
| router = APIRouter(prefix="/identity/v1", tags=["identity"]) |
|
|
| |
| _RUNTIME = None |
|
|
|
|
| def set_runtime(runtime) -> None: |
| global _RUNTIME |
| _RUNTIME = runtime |
|
|
|
|
| def get_runtime(): |
| if _RUNTIME is None: |
| raise D2Error("not_found") |
| return _RUNTIME |
|
|
|
|
| def install_error_handler(app) -> None: |
| """Register the D2Error → normalized-HTTP translator on a FastAPI app.""" |
| @app.exception_handler(D2Error) |
| async def _d2_error_handler(_request: Request, exc: D2Error): |
| token = str(exc) |
| return JSONResponse(status_code=status_for(token), content={"detail": token}) |
|
|
|
|
| |
| def _identity(): |
| return get_runtime().identity |
|
|
|
|
| def _operator_service(): |
| return get_runtime().operator |
|
|
|
|
| def _cookie_token(request: Request) -> Optional[str]: |
| return request.cookies.get(SESSION_COOKIE) |
|
|
|
|
| def _set_session_cookie(response: Response, issued) -> None: |
| attrs = _identity().sessions.cookie_attributes() |
| response.set_cookie(SESSION_COOKIE, issued.token, httponly=attrs["httponly"], |
| secure=attrs["secure"], samesite=attrs["samesite"], |
| path=attrs["path"], max_age=attrs["max_age"]) |
|
|
|
|
| def _clear_session_cookie(response: Response) -> None: |
| response.delete_cookie(SESSION_COOKIE, path="/") |
|
|
|
|
| def _authed(request: Request) -> tuple[Session, User]: |
| return _identity().authenticate_request(_cookie_token(request)) |
|
|
|
|
| def _require_csrf(request: Request, session: Session) -> None: |
| _identity().require_csrf(session, request.headers.get("x-csrf-token")) |
|
|
|
|
| def _account_view(ident, user: User) -> dict: |
| return { |
| "id": user.id, |
| "public_handle": user.public_handle, |
| "display_alias": user.display_alias, |
| "role": user.role, |
| "status": user.status, |
| "preferred_language": user.preferred_language, |
| "profile_generation": user.profile_generation, |
| "passkey_count": ident.repo.count_active_credentials(user.id), |
| } |
|
|
|
|
| def _passkey_view(c: Credential) -> dict: |
| return { |
| "id": c.id, |
| "nickname": c.nickname, |
| "device_type": c.device_type, |
| "backup_eligible": c.backup_eligible, |
| "backup_state": c.backup_state, |
| "transports": [t for t in c.transports.split(",") if t], |
| "created_at": c.created_at, |
| "last_used_at": c.last_used_at, |
| "revoked": c.revoked_at is not None, |
| } |
|
|
|
|
| def _session_view(s: Session, current_id: str) -> dict: |
| return { |
| "id": s.id, |
| "created_at": s.created_at, |
| "last_seen_at": s.last_seen_at, |
| "device_nickname": s.device_nickname, |
| "current": s.id == current_id, |
| } |
|
|
|
|
| |
| @router.get("/status") |
| def status() -> dict: |
| """Safe, secret-free D2 status for the UI/version surface.""" |
| rt = _RUNTIME |
| if rt is None: |
| return {"enabled": False, "ready": False} |
| rr = rt.storage.db.runtime_report() |
| from amanpay.identity.config import is_demo_open_enroll |
| return {"enabled": True, "ready": True, "persistent": rt.persistent, |
| "rp_id": rt.config.rp_id, "origin": rt.config.origin, |
| "journal_mode": rr.get("journal_mode"), "wal_status": rr.get("wal_status"), |
| "schema_version": rt.storage.db.schema_version(), |
| "backup_retention_hours": rt.config.backup_retention_hours, |
| "demo_open_enroll": is_demo_open_enroll()} |
|
|
|
|
| |
| @router.post("/invitations/validate") |
| def validate_invitation(request: Request, body: dict = Body(...)) -> dict: |
| rate_limit(request, "d2_invite_validate", capacity=10, refill_per_sec=0.2) |
| ident = _identity() |
| vi = ident.invites.validate(str(body.get("code", ""))) |
| tenant = ident.repo.get_tenant(vi.tenant_id) |
| return {"valid": True, "role": vi.role, "purpose": vi.purpose, |
| "tenant": {"name_en": tenant.name_en, "name_ar": tenant.name_ar} if tenant else {}} |
|
|
|
|
| @router.post("/enrollment/options") |
| def enrollment_options(request: Request, body: dict = Body(...)) -> Response: |
| rate_limit(request, "d2_enroll", capacity=6, refill_per_sec=0.1) |
| opts = _identity().begin_enrollment( |
| code=str(body.get("code", "")), public_handle=str(body.get("public_handle", "")), |
| display_alias=str(body.get("display_alias", "")), |
| preferred_language=str(body.get("preferred_language", "en")), |
| consent_accepted=bool(body.get("consent_accepted", False))) |
| return Response(content=opts, media_type="application/json") |
|
|
|
|
| @router.post("/demo/enrollment/options") |
| def demo_enrollment_options(request: Request, body: dict = Body(default={})) -> Response: |
| """One-click, code-free enrollment into the DEMO tenant (gated by AMANPAY_DEMO_OPEN_ENROLL). |
| |
| The server mints a single-use invite itself so a visitor can create a passkey without an |
| operator code. Synthetic/demo only — 404 when the flag is off, so it is inert by default. |
| """ |
| from amanpay.identity.config import demo_tenant_slug, is_demo_open_enroll |
| if not is_demo_open_enroll(): |
| raise D2Error("not_found") |
| rate_limit(request, "d2_demo_enroll", capacity=6, refill_per_sec=0.1) |
| ident = _identity() |
| tenant = ident.repo.get_tenant_by_slug(demo_tenant_slug()) |
| if tenant is None: |
| raise D2Error("not_found") |
| role = str(body.get("role", "customer")) |
| if role not in ("customer", "operator"): |
| role = "customer" |
| _inv, code = ident.invites.create(tenant_id=tenant.id, role=role, ttl_seconds=600, |
| created_by="demo") |
| opts = ident.begin_enrollment( |
| code=code, public_handle=str(body.get("public_handle", "")), |
| display_alias=str(body.get("display_alias", "")), |
| preferred_language=str(body.get("preferred_language", "en")), consent_accepted=True) |
| return Response(content=opts, media_type="application/json") |
|
|
|
|
| @router.post("/demo/quick-session") |
| def demo_quick_session(request: Request, response: Response, body: dict = Body(default={})) -> dict: |
| """No-passkey DEMO login (gated by AMANPAY_DEMO_OPEN_ENROLL) — works everywhere, incl. the HF |
| embed iframe and any device where WebAuthn is unavailable. |
| |
| Creates a passkey-LESS demo account in the demo tenant and issues a session directly, then |
| provisions a funded wallet (customer) / claims the demo merchants (operator). This deliberately |
| relaxes the passkey requirement FOR THE DEMO TENANT ONLY; it is 404 when the flag is off and is |
| clearly a synthetic, no-real-money convenience. Passkey enrollment remains available (and is |
| the real security story) on the direct Space origin. |
| """ |
| from amanpay.identity.config import demo_tenant_slug, is_demo_open_enroll |
| from amanpay.identity.handles import normalize_handle |
| if not is_demo_open_enroll(): |
| raise D2Error("not_found") |
| rate_limit(request, "d2_demo_quick", capacity=8, refill_per_sec=0.2) |
| ident = _identity() |
| tenant = ident.repo.get_tenant_by_slug(demo_tenant_slug()) |
| if tenant is None: |
| raise D2Error("not_found") |
| role = str(body.get("role", "customer")) |
| if role not in ("customer", "operator"): |
| role = "customer" |
| suffix = secrets.token_hex(3) |
| handle = ("ops" if role == "operator" else "guest") + suffix |
| created = ident.repo.create_user( |
| tenant_id=tenant.id, webauthn_user_handle=f"demo-nopk-{handle}", |
| public_handle=handle, public_handle_norm=normalize_handle(handle), |
| display_alias=(str(body.get("display_alias", "")) or handle.title()), |
| preferred_language=str(body.get("preferred_language", "en")), role=role, status="active") |
| ident.repo.set_user_status(created.id, "active") |
| user = ident.repo.get_user(created.id) |
| _provision_demo_wallet(user) |
| issued = ident.sessions.create(tenant_id=tenant.id, user_id=user.id, last_auth_at=time.time(), |
| device_nickname="demo (no passkey)", |
| user_agent=request.headers.get("user-agent")) |
| _set_session_cookie(response, issued) |
| return {"account": _account_view(ident, user), "csrf_token": issued.csrf_token, |
| "no_passkey": True} |
|
|
|
|
| @router.post("/enrollment/verify") |
| def enrollment_verify(request: Request, response: Response, body: dict = Body(...)) -> dict: |
| rate_limit(request, "d2_enroll_verify", capacity=6, refill_per_sec=0.1) |
| ident = _identity() |
| res = ident.complete_enrollment(credential=body.get("credential", body), |
| user_agent=request.headers.get("user-agent")) |
| _provision_demo_wallet(res.user) |
| _set_session_cookie(response, res.issued) |
| return {"account": _account_view(ident, res.user), "csrf_token": res.issued.csrf_token} |
|
|
|
|
| def _provision_demo_wallet(user) -> None: |
| """Post-enroll demo provisioning (best-effort, gated by AMANPAY_DEMO_OPEN_ENROLL). |
| |
| Only runs when demo open-enroll is on, D3 is ready, and the new user is in the demo tenant: |
| a customer gets a funded simulated wallet; an operator takes ownership of the demo merchants |
| (so refunds + ATM confirmation work). A failure here never blocks enrollment. |
| """ |
| from amanpay.identity.config import demo_tenant_slug, is_demo_open_enroll |
| if not is_demo_open_enroll(): |
| return |
| try: |
| import api.finance_routes as fr |
| d3 = fr._RUNTIME |
| if d3 is None: |
| return |
| demo_tenant = _identity().repo.get_tenant_by_slug(demo_tenant_slug()) |
| if demo_tenant is None or user.tenant_id != demo_tenant.id: |
| return |
| if user.role == "customer": |
| from amanpay.demo import provision_demo_customer |
| provision_demo_customer(d3, user.tenant_id, user.id) |
| elif user.role == "operator": |
| from amanpay.demo import claim_demo_merchants |
| claim_demo_merchants(d3, user.tenant_id, user.id) |
| except Exception: |
| pass |
|
|
|
|
| |
| @router.post("/authentication/options") |
| def authentication_options(request: Request, body: dict = Body(default={})) -> Response: |
| rate_limit(request, "d2_login", capacity=10, refill_per_sec=0.3) |
| opts = _identity().begin_login(public_handle=(body or {}).get("public_handle")) |
| return Response(content=opts, media_type="application/json") |
|
|
|
|
| @router.post("/authentication/verify") |
| def authentication_verify(request: Request, response: Response, body: dict = Body(...)) -> dict: |
| rate_limit(request, "d2_login_verify", capacity=10, refill_per_sec=0.3) |
| ident = _identity() |
| res = ident.complete_login(credential=body.get("credential", body), |
| user_agent=request.headers.get("user-agent")) |
| _set_session_cookie(response, res.issued) |
| return {"account": _account_view(ident, res.user), "csrf_token": res.issued.csrf_token} |
|
|
|
|
| @router.get("/session") |
| def session_info(request: Request) -> dict: |
| try: |
| _sess, user = _authed(request) |
| except D2Error: |
| return {"authenticated": False} |
| return {"authenticated": True, "account": _account_view(_identity(), user)} |
|
|
|
|
| @router.post("/logout") |
| def logout(request: Request, response: Response) -> dict: |
| ident = _identity() |
| sess, _user = _authed(request) |
| _require_csrf(request, sess) |
| ident.logout(sess) |
| _clear_session_cookie(response) |
| return {"ok": True} |
|
|
|
|
| |
| @router.post("/recovery/options") |
| def recovery_options(request: Request, body: dict = Body(...)) -> Response: |
| rate_limit(request, "d2_recovery", capacity=6, refill_per_sec=0.05) |
| opts = _identity().begin_recovery(code=str(body.get("code", ""))) |
| return Response(content=opts, media_type="application/json") |
|
|
|
|
| @router.post("/recovery/verify") |
| def recovery_verify(request: Request, response: Response, body: dict = Body(...)) -> dict: |
| ident = _identity() |
| res = ident.complete_recovery(credential=body.get("credential", body), |
| user_agent=request.headers.get("user-agent")) |
| _set_session_cookie(response, res.issued) |
| return {"account": _account_view(ident, res.user), "csrf_token": res.issued.csrf_token} |
|
|
|
|
| |
| @router.get("/account") |
| def get_account(request: Request) -> dict: |
| ident = _identity() |
| _sess, user = _authed(request) |
| return {"account": _account_view(ident, user)} |
|
|
|
|
| @router.get("/passkeys") |
| def list_passkeys(request: Request) -> dict: |
| ident = _identity() |
| _sess, user = _authed(request) |
| return {"passkeys": [_passkey_view(c) for c in ident.list_passkeys(user)]} |
|
|
|
|
| @router.post("/passkeys/options") |
| def add_passkey_options(request: Request) -> Response: |
| ident = _identity() |
| sess, user = _authed(request) |
| _require_csrf(request, sess) |
| opts = ident.begin_add_passkey(user, sess) |
| return Response(content=opts, media_type="application/json") |
|
|
|
|
| @router.post("/passkeys/verify") |
| def add_passkey_verify(request: Request, body: dict = Body(...)) -> dict: |
| ident = _identity() |
| sess, user = _authed(request) |
| _require_csrf(request, sess) |
| cred = ident.complete_add_passkey(user, sess, credential=body.get("credential", body), |
| nickname=str(body.get("nickname", ""))) |
| return {"passkey": _passkey_view(cred)} |
|
|
|
|
| @router.patch("/passkeys/{credential_id}") |
| def rename_passkey(request: Request, credential_id: str, body: dict = Body(...)) -> dict: |
| ident = _identity() |
| sess, user = _authed(request) |
| _require_csrf(request, sess) |
| ident.rename_passkey(user, credential_id, str(body.get("nickname", ""))) |
| return {"ok": True} |
|
|
|
|
| @router.delete("/passkeys/{credential_id}") |
| def revoke_passkey(request: Request, credential_id: str) -> dict: |
| ident = _identity() |
| sess, user = _authed(request) |
| _require_csrf(request, sess) |
| ident.revoke_passkey(user, sess, credential_id) |
| return {"ok": True} |
|
|
|
|
| @router.get("/sessions") |
| def list_sessions(request: Request) -> dict: |
| ident = _identity() |
| sess, user = _authed(request) |
| return {"sessions": [_session_view(s, sess.id) for s in ident.list_sessions(user)]} |
|
|
|
|
| @router.delete("/sessions/{session_id}") |
| def revoke_session(request: Request, session_id: str) -> dict: |
| ident = _identity() |
| sess, user = _authed(request) |
| _require_csrf(request, sess) |
| ident.revoke_session(user, sess, session_id) |
| return {"ok": True} |
|
|
|
|
| @router.post("/sessions/revoke-others") |
| def revoke_other_sessions(request: Request) -> dict: |
| ident = _identity() |
| sess, user = _authed(request) |
| _require_csrf(request, sess) |
| ident.revoke_other_sessions(user, sess) |
| return {"ok": True} |
|
|
|
|
| |
| @router.post("/stepup/options") |
| def stepup_options(request: Request) -> Response: |
| ident = _identity() |
| sess, user = _authed(request) |
| _require_csrf(request, sess) |
| opts = ident.begin_stepup(user, sess) |
| return Response(content=opts, media_type="application/json") |
|
|
|
|
| @router.post("/stepup/verify") |
| def stepup_verify(request: Request, response: Response, body: dict = Body(...)) -> dict: |
| ident = _identity() |
| sess, user = _authed(request) |
| _require_csrf(request, sess) |
| issued = ident.complete_stepup(user, sess, credential=body.get("credential", body)) |
| _set_session_cookie(response, issued) |
| return {"ok": True, "csrf_token": issued.csrf_token} |
|
|
|
|
| |
| @router.post("/account/pause") |
| def pause_account(request: Request, response: Response) -> dict: |
| ident = _identity() |
| sess, user = _authed(request) |
| _require_csrf(request, sess) |
| ident.pause_account(user, sess) |
| _clear_session_cookie(response) |
| return {"ok": True, "status": "paused"} |
|
|
|
|
| @router.post("/account/delete/options") |
| def delete_options(request: Request) -> Response: |
| ident = _identity() |
| sess, user = _authed(request) |
| _require_csrf(request, sess) |
| opts = ident.begin_delete(user, sess) |
| return Response(content=opts, media_type="application/json") |
|
|
|
|
| @router.post("/account/delete/confirm") |
| def delete_confirm(request: Request, response: Response, body: dict = Body(...)) -> dict: |
| ident = _identity() |
| sess, user = _authed(request) |
| _require_csrf(request, sess) |
| receipt = ident.complete_delete(user, sess, credential=body.get("credential", body)) |
| _clear_session_cookie(response) |
| return receipt |
|
|
|
|
| |
| def _authed_operator(request: Request) -> tuple[Session, User]: |
| sess, user = _authed(request) |
| _operator_service().require_operator(user) |
| return sess, user |
|
|
|
|
| @router.post("/operator/invitations") |
| def op_create_invitations(request: Request, body: dict = Body(...)) -> dict: |
| op = _operator_service() |
| sess, user = _authed_operator(request) |
| _require_csrf(request, sess) |
| pairs = op.create_invitations(user, role=str(body.get("role", "customer")), |
| count=int(body.get("count", 1)), |
| ttl_seconds=int(body.get("ttl_seconds", 86400))) |
| |
| return {"invitations": [{"id": iid, "code": code} for iid, code in pairs]} |
|
|
|
|
| @router.get("/operator/invitations") |
| def op_list_invitations(request: Request) -> dict: |
| op = _operator_service() |
| _sess, user = _authed_operator(request) |
| return {"invitations": op.list_invitations(user)} |
|
|
|
|
| @router.delete("/operator/invitations/{invitation_id}") |
| def op_revoke_invitation(request: Request, invitation_id: str) -> dict: |
| op = _operator_service() |
| sess, user = _authed_operator(request) |
| _require_csrf(request, sess) |
| op.revoke_invitation(user, invitation_id) |
| return {"ok": True} |
|
|
|
|
| @router.get("/operator/accounts") |
| def op_list_accounts(request: Request) -> dict: |
| op = _operator_service() |
| _sess, user = _authed_operator(request) |
| return {"accounts": op.list_accounts(user)} |
|
|
|
|
| @router.get("/operator/accounts/{user_id}") |
| def op_account_detail(request: Request, user_id: str) -> dict: |
| op = _operator_service() |
| _sess, user = _authed_operator(request) |
| return op.account_detail(user, user_id) |
|
|
|
|
| @router.post("/operator/accounts/{user_id}/pause") |
| def op_pause(request: Request, user_id: str) -> dict: |
| op = _operator_service() |
| sess, user = _authed_operator(request) |
| _require_csrf(request, sess) |
| op.pause_account(user, sess, user_id) |
| return {"ok": True} |
|
|
|
|
| @router.post("/operator/accounts/{user_id}/reactivate") |
| def op_reactivate(request: Request, user_id: str) -> dict: |
| op = _operator_service() |
| sess, user = _authed_operator(request) |
| _require_csrf(request, sess) |
| op.reactivate_account(user, sess, user_id) |
| return {"ok": True} |
|
|
|
|
| @router.post("/operator/accounts/{user_id}/recovery-invitation") |
| def op_recovery_invitation(request: Request, user_id: str) -> dict: |
| op = _operator_service() |
| sess, user = _authed_operator(request) |
| _require_csrf(request, sess) |
| _iid, code = op.issue_recovery_invitation(user, sess, user_id) |
| return {"code": code} |
|
|
|
|
| @router.post("/operator/accounts/{user_id}/delete") |
| def op_delete(request: Request, user_id: str) -> dict: |
| op = _operator_service() |
| sess, user = _authed_operator(request) |
| _require_csrf(request, sess) |
| return op.delete_account(user, sess, user_id) |
|
|