create-workframe 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -201
- package/NOTICE +12 -12
- package/README.md +8 -92
- package/SECURITY.md +40 -40
- package/bin/workframe.js +329 -329
- package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +1 -1
- package/docs/workspace-instructions/WORKFRAME_ROUTING.md +8 -8
- package/package.json +3 -6
- package/profiles/architect/AGENTS.md +29 -29
- package/profiles/architect/SOUL.md +2 -2
- package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/designer/AGENTS.md +26 -26
- package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/dev/AGENTS.md +28 -28
- package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/docs/AGENTS.md +27 -27
- package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/research/AGENTS.md +26 -26
- package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/visionary/AGENTS.md +25 -25
- package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/workframe-agent/AGENTS.md +37 -37
- package/profiles/workframe-agent/skills/devops/botfather/SKILL.md +85 -85
- package/profiles/workframe-agent/skills/devops/kanban-handoff-pattern/SKILL.md +58 -58
- package/profiles/workframe-agent/skills/devops/workframe-cohort/SKILL.md +54 -54
- package/rules/workspace-README.md +5 -5
- package/scripts/apply-update-hermes.sh +17 -17
- package/scripts/apply-update-workframe.sh +77 -77
- package/scripts/bootstrap-workspace-link.sh +8 -8
- package/scripts/bundle-workframe-ui.mjs +3 -3
- package/scripts/compose-docker-host.sh +37 -37
- package/scripts/ensure-compose-host-paths.mjs +51 -51
- package/scripts/fix-zk-encryption-key.sh +35 -35
- package/scripts/lib/install-identity.mjs +212 -212
- package/scripts/restart-gateway-hermes.sh +12 -12
- package/scripts/set-compose-public-url.mjs +92 -92
- package/scripts/setup-stack-secrets.sh +50 -50
- package/scripts/sync-canonical-to-package.mjs +8 -7
- package/scripts/verify-public-deploy.sh +105 -105
- package/shared/WORKFRAME_AGENT_LIBRARY.md +17 -17
- package/shared/WORKFRAME_AGENT_OPERATIONS.md +15 -15
- package/shared/WORKFRAME_AGENT_PACKS.json +18 -18
- package/shared/WORKFRAME_AGENT_PACKS.yaml +8 -8
- package/shared/WORKFRAME_SKILL_CURATION.md +4 -4
- package/workframe-api/README.md +28 -28
- package/workframe-api/action_proxy.py +131 -131
- package/workframe-api/auth_rate_limit.py +49 -49
- package/workframe-api/credential_vault.py +445 -445
- package/workframe-api/data/avatar-catalog.json +41 -41
- package/workframe-api/email_sender.py +220 -220
- package/workframe-api/google_auth.py +90 -90
- package/workframe-api/install_api.py +359 -359
- package/workframe-api/internal_proxy_auth.py +150 -150
- package/workframe-api/llm_proxy.py +277 -277
- package/workframe-api/oidc_jwt.py +108 -108
- package/workframe-api/package.json +12 -13
- package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
- package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
- package/workframe-api/requirements.txt +2 -2
- package/workframe-api/site_meta.py +271 -271
- package/workframe-api/stack_config.py +427 -427
- package/workframe-api/time-bind-chat.py +99 -99
- package/workframe-api/turn_credentials.py +226 -226
- package/workframe-api/updates.py +417 -417
- package/workframe-api/vault_kek.py +159 -159
- package/workframe-api/zk_auth.py +633 -633
- package/workframe-supervisor/Dockerfile +11 -11
- package/workframe-supervisor/server.py +787 -787
- package/workframe-ui/docker/nginx.conf +85 -85
- package/workframe-ui/public/assets/{arc-CBDYvkAF.js → arc-COAT3laO.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-DUyH3hWG.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-XnBRKeW0.js → architectureDiagram-3BPJPVTR-BFjWV24l.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-VYHUfVhd.js → blockDiagram-GPEHLZMM-DSQLPfrj.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BTjUcJpm.js → c4Diagram-AAUBKEIU-DKEHv1t2.js} +1 -1
- package/workframe-ui/public/assets/channel-g7r_RGaY.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-w7uu7R-b.js → chunk-2J33WTMH-DHZg-DUi.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-Cb9LtnDX.js → chunk-3OPIFGDE-BB-OYTfp.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-DiQ-qCwH.js → chunk-4BX2VUAB-C93q0YIm.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-C-mLFr7z.js → chunk-55IACEB6-MAYniqik.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-DOesfiCI.js → chunk-5ZQYHXKU-ChgN6YJs.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-BJ3oBZuz.js → chunk-727SXJPM-B_FYwdAv.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CCA6xpGs.js → chunk-AQP2D5EJ-1_Hw_h1A.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-a0cMNFb2.js → chunk-BSJP7CBP-CFiDQ1Rv.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-kuqN8EIY.js → chunk-CSCIHK7Q-DZ9UMTlB.js} +1 -1
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-DyPgYHCg.js → chunk-FMBD7UC4-DlMlyFgw.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-CdUuvR0V.js → chunk-KSCS5N6A-DHXtQ_Hf.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-Dq9NoWmK.js → chunk-L5ZTLDWV-CuQzg-QG.js} +1 -1
- package/workframe-ui/public/assets/{chunk-LZXEDZCA-p74rddlO.js → chunk-LZXEDZCA-BHzjzCGg.js} +2 -2
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-DBD2u1Gz.js → chunk-ND2GUHAM-DHXx05n2.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-BeIeYFnd.js → chunk-NZK2D7GU-CV5pmDM_.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-ClHc56ib.js → chunk-O5CBEL6O-6tkCHxsV.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-C5UQehWY.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-DhWllrI8.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-EFr8R_1p.js → chunk-XPW4576I-BClwIiCp.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BBM_8T8E.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BBM_8T8E.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-C7aPBODd.js → cose-bilkent-S5V4N54A-DOrGV6DQ.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-BdU1Rv-H.js → dagre-BM42HDAG-DXTPvJkX.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-DWowSo85.js → diagram-2AECGRRQ-xX_v-pbf.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-MnxBbceO.js → diagram-5GNKFQAL-Cd2pXbBe.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-DQaLRXFf.js → diagram-KO2AKTUF-Df3XvUtk.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-CQaBud9k.js → diagram-LMA3HP47-CsijIPaD.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-D8bAXbY9.js → diagram-OG6HWLK6-aq5fmfHd.js} +1 -1
- package/workframe-ui/public/assets/{dist-DGpTLHr_.js → dist-D1c0mkbB.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-1E-xSvBK.js → erDiagram-TEJ5UH35-DnFysVRY.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Ci8mdb44.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-CgOVD5hu.js → flowDiagram-I6XJVG4X-C6Ebi3su.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-JFYAIauo.js → ganttDiagram-6RSMTGT7-BQXQtUpa.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-B9REenIl.js → gitGraph-WXDBUCRP-Dt0zIs_M.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-BQ7NcMSn.js → gitGraphDiagram-PVQCEYII-BF8gHzRn.js} +1 -1
- package/workframe-ui/public/assets/index-DpoUZAxh.css +1 -0
- package/workframe-ui/public/assets/{index-Dnw6vjqb.js → index-lRpzpNPT.js} +2 -2
- package/workframe-ui/public/assets/{info-J43DQDTF-CL6-eTjH.js → info-J43DQDTF-CSmszQJT.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-LJTODW4W.js → infoDiagram-5YYISTIA-CVTKGW6p.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-bchrQVuo.js → ishikawaDiagram-YF4QCWOH-Z8pT09Lv.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-DkrvYuxP.js → journeyDiagram-JHISSGLW-r3wD68_T.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-DFRbj0IG.js → kanban-definition-UN3LZRKU-Il8VglqN.js} +1 -1
- package/workframe-ui/public/assets/{line-Vd48P7-O.js → line-oyjpfz2A.js} +1 -1
- package/workframe-ui/public/assets/{linear-Ckizh2G7.js → linear-Cf7p5tVp.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-Bkimsnqj.js → mermaid-parser.core-YmbZ-AfY.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-x0TvVuPo.js → mermaid.core-BFdCAqCo.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-6ykAFPEz.js → mindmap-definition-RKZ34NQL-Cy2iCtEl.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-Dw3xgMDt.js → packet-YPE3B663-DwOBZL6K.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DATysawG.js → pie-LRSECV5Y-04PPhnKK.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-SJKD1S0S.js → pieDiagram-4H26LBE5-LxIpgHqi.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-BrYDZX8q.js → quadrantDiagram-W4KKPZXB-0nBYfYm4.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-BmWYPCds.js → radar-GUYGQ44K-D2-vBqps.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DwL9Mc8e.js → requirementDiagram-4Y6WPE33-DbuU0nlu.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DYIFsL8h.js → sankeyDiagram-5OEKKPKP-B2hQ6B2x.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-0-FPkFk8.js → sequenceDiagram-3UESZ5HK-BBrU30e1.js} +1 -1
- package/workframe-ui/public/assets/{src-B_od6b6h.js → src-BJEDmV70.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BQCiBk6u.js → stateDiagram-AJRCARHV-7FGO4kkH.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DLTSizMg.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DS3tFcXj.js → timeline-definition-PNZ67QCA-ptDm4rCN.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-DSyUCKLY.js → treeView-BLDUP644-CS6Z-0q8.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-CEZaNh5Y.js → treemap-LRROVOQU-DqV4Y2VA.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CD-Vc9NF.js → vennDiagram-CIIHVFJN-C0UrZJYt.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-Drq5w1Mc.js → wardley-L42UT6IY-bNDN3_Sa.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DouXDJoF.js → wardleyDiagram-YWT4CUSO-jWiJsefM.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DDf_Lol5.js → xychartDiagram-2RQKCTM6-Dsh_fLCy.js} +1 -1
- package/workframe-ui/public/favicon.svg +7 -7
- package/workframe-ui/public/index.html +50 -50
- package/workframe-ui/public/workframe-config.json +3 -3
- package/scripts/security_audit.py +0 -156
- package/scripts/test-scaffold.mjs +0 -390
- package/workframe-api/tests/__init__.py +0 -0
- package/workframe-api/tests/db_setup.py +0 -13
- package/workframe-api/tests/test_admin_updates_gated.py +0 -30
- package/workframe-api/tests/test_agent_dm_bootstrap.py +0 -196
- package/workframe-api/tests/test_agent_profile_sync.py +0 -76
- package/workframe-api/tests/test_auth_email.py +0 -222
- package/workframe-api/tests/test_auth_hole_fix_selfcheck.py +0 -99
- package/workframe-api/tests/test_auth_rate_limit.py +0 -19
- package/workframe-api/tests/test_avatar_resolve.py +0 -77
- package/workframe-api/tests/test_child_soul_template.py +0 -71
- package/workframe-api/tests/test_credential_canary.py +0 -135
- package/workframe-api/tests/test_credential_isolation.py +0 -448
- package/workframe-api/tests/test_credential_resolution.py +0 -206
- package/workframe-api/tests/test_device_oauth.py +0 -108
- package/workframe-api/tests/test_doctor_repair.py +0 -103
- package/workframe-api/tests/test_ensure_profile_api.py +0 -77
- package/workframe-api/tests/test_gateway_compose_security.py +0 -136
- package/workframe-api/tests/test_install_secure_host.py +0 -39
- package/workframe-api/tests/test_internal_proxy_auth.py +0 -125
- package/workframe-api/tests/test_invite_runtime_bootstrap.py +0 -72
- package/workframe-api/tests/test_kanban_delegation.py +0 -185
- package/workframe-api/tests/test_llm_proxy.py +0 -155
- package/workframe-api/tests/test_login_access_policy.py +0 -183
- package/workframe-api/tests/test_mvp_model_bootstrap.py +0 -75
- package/workframe-api/tests/test_onboarding_bootstrap.py +0 -248
- package/workframe-api/tests/test_platform_auth.py +0 -47
- package/workframe-api/tests/test_profile_config_path.py +0 -56
- package/workframe-api/tests/test_profile_config_yaml_repair.py +0 -63
- package/workframe-api/tests/test_profile_create.py +0 -72
- package/workframe-api/tests/test_profile_identity_overlay.py +0 -61
- package/workframe-api/tests/test_profile_install_health.py +0 -45
- package/workframe-api/tests/test_profile_secret_policy.py +0 -57
- package/workframe-api/tests/test_profile_workspace_cwd.py +0 -34
- package/workframe-api/tests/test_provider_bootstrap.py +0 -75
- package/workframe-api/tests/test_provider_connect.py +0 -54
- package/workframe-api/tests/test_room_crud.py +0 -192
- package/workframe-api/tests/test_room_tenancy.py +0 -701
- package/workframe-api/tests/test_runtime_identity_backfill.py +0 -34
- package/workframe-api/tests/test_site_meta.py +0 -81
- package/workframe-api/tests/test_soul_stub.py +0 -42
- package/workframe-api/tests/test_space_member_sync.py +0 -99
- package/workframe-api/tests/test_stripe_stack_config.py +0 -37
- package/workframe-api/tests/test_supervisor_lifecycle.py +0 -52
- package/workframe-api/tests/test_turn_credential_vault.py +0 -125
- package/workframe-api/tests/test_updates.py +0 -176
- package/workframe-api/tests/test_user_cohort.py +0 -113
- package/workframe-api/tests/test_vault_envelope.py +0 -110
- package/workframe-api/tests/test_workspace_members.py +0 -183
- package/workframe-api/tests/test_workspace_messaging_sync.py +0 -125
- package/workframe-api/tests/test_workspace_provider_list.py +0 -57
- package/workframe-supervisor/tests/test_exec_guard.py +0 -42
- package/workframe-supervisor/tests/test_server_import.py +0 -21
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CtbQKTuT.js +0 -1
- package/workframe-ui/public/assets/channel-Dy4Z4-jn.js +0 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-CtBEchFK.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-B9pBtriN.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BMAEA8jI.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BMAEA8jI.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-D75cstNT.js +0 -1
- package/workframe-ui/public/assets/index-DpAGxump.css +0 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-B89jAMFF.js +0 -1
package/workframe-api/zk_auth.py
CHANGED
|
@@ -1,633 +1,633 @@
|
|
|
1
|
-
"""
|
|
2
|
-
zk-auth compatible auth module for Workframe API.
|
|
3
|
-
|
|
4
|
-
Ported from /d/ab/projects/zk-auth/ with the same:
|
|
5
|
-
- AES-256-GCM encryption for secrets (emails, tokens)
|
|
6
|
-
- HMAC-SHA256 for email hashing, OTP hashing, token hashing
|
|
7
|
-
- Atomic OTP consume with attempt counting
|
|
8
|
-
- Session management with refresh tokens
|
|
9
|
-
- Profile management (display_name, avatar_url, tagline, bio)
|
|
10
|
-
|
|
11
|
-
Two modes:
|
|
12
|
-
- DEV_LOCAL_UNSAFE: OTP is returned in response (no email needed)
|
|
13
|
-
- SECURE_MODE: OTP is sent via email (requires SMTP config)
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
from __future__ import annotations
|
|
17
|
-
|
|
18
|
-
import base64
|
|
19
|
-
import hmac as _hmac_mod
|
|
20
|
-
import json
|
|
21
|
-
import os
|
|
22
|
-
import re
|
|
23
|
-
import secrets
|
|
24
|
-
import sqlite3
|
|
25
|
-
import uuid
|
|
26
|
-
from datetime import datetime, timedelta, timezone
|
|
27
|
-
|
|
28
|
-
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
# ---------------------------------------------------------------------------
|
|
32
|
-
# Config
|
|
33
|
-
# ---------------------------------------------------------------------------
|
|
34
|
-
|
|
35
|
-
DATA_DIR = os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data")
|
|
36
|
-
AUTH_KEYS_FILE = os.path.join(DATA_DIR, ".auth_keys")
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _load_or_generate_keys() -> tuple[str, str, str]:
|
|
40
|
-
"""Load auth keys from env, disk, or generate on first boot."""
|
|
41
|
-
hmac_env = os.environ.get("ZK_AUTH_HMAC_KEY", "").strip()
|
|
42
|
-
enc_env = os.environ.get("ZK_AUTH_ENCRYPTION_KEY", "").strip()
|
|
43
|
-
session_env = os.environ.get("ZK_AUTH_SESSION_SECRET", "").strip()
|
|
44
|
-
if hmac_env and enc_env and session_env:
|
|
45
|
-
return hmac_env, enc_env, session_env
|
|
46
|
-
|
|
47
|
-
os.makedirs(DATA_DIR, exist_ok=True)
|
|
48
|
-
if os.path.exists(AUTH_KEYS_FILE):
|
|
49
|
-
try:
|
|
50
|
-
with open(AUTH_KEYS_FILE, "r") as f:
|
|
51
|
-
keys = json.load(f)
|
|
52
|
-
return keys["hmac"], keys["enc"], keys["session"]
|
|
53
|
-
except Exception:
|
|
54
|
-
pass
|
|
55
|
-
# First boot — generate and persist
|
|
56
|
-
hmac_key = secrets.token_hex(32)
|
|
57
|
-
enc_key = base64.b64encode(os.urandom(32)).decode()
|
|
58
|
-
session_key = secrets.token_hex(32)
|
|
59
|
-
try:
|
|
60
|
-
with open(AUTH_KEYS_FILE, "w") as f:
|
|
61
|
-
json.dump({"hmac": hmac_key, "enc": enc_key, "session": session_key}, f)
|
|
62
|
-
os.chmod(AUTH_KEYS_FILE, 0o600)
|
|
63
|
-
except Exception:
|
|
64
|
-
pass # container may be read-only; fall back to in-memory
|
|
65
|
-
return hmac_key, enc_key, session_key
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
ZK_AUTH_HMAC_KEY, ZK_AUTH_ENCRYPTION_KEY, ZK_AUTH_SESSION_SECRET = _load_or_generate_keys()
|
|
69
|
-
OTP_TTL_MINUTES = int(os.environ.get("OTP_TTL_MINUTES", "10"))
|
|
70
|
-
SESSION_TTL_DAYS = int(os.environ.get("SESSION_TTL_DAYS", "30"))
|
|
71
|
-
OTP_MAX_ATTEMPTS = 5
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
# ---------------------------------------------------------------------------
|
|
75
|
-
# Crypto (ported from zk-auth/src/crypto/)
|
|
76
|
-
# ---------------------------------------------------------------------------
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def _decode_aes256_key(value: str) -> bytes:
|
|
80
|
-
key = base64.b64decode(value)
|
|
81
|
-
if len(key) != 32:
|
|
82
|
-
raise ValueError("ZK_AUTH_ENCRYPTION_KEY must be a base64-encoded 32-byte key.")
|
|
83
|
-
return key
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def encrypt_string(value: str, secret: str) -> dict:
|
|
87
|
-
"""AES-256-GCM encrypt. Returns {v, alg, iv, tag, ciphertext}."""
|
|
88
|
-
key = _decode_aes256_key(secret)
|
|
89
|
-
iv = os.urandom(12)
|
|
90
|
-
aesgcm = AESGCM(key)
|
|
91
|
-
ct = aesgcm.encrypt(iv, value.encode("utf8"), None)
|
|
92
|
-
return {
|
|
93
|
-
"v": 1,
|
|
94
|
-
"alg": "AES-256-GCM",
|
|
95
|
-
"iv": base64.b64encode(iv).decode("ascii"),
|
|
96
|
-
"tag": base64.b64encode(ct[-16:]).decode("ascii"),
|
|
97
|
-
"ciphertext": base64.b64encode(ct[:-16]).decode("ascii"),
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def decrypt_string(payload: dict, secret: str) -> str:
|
|
102
|
-
"""AES-256-GCM decrypt."""
|
|
103
|
-
if payload.get("v") != 1 or payload.get("alg") != "AES-256-GCM":
|
|
104
|
-
raise ValueError("Unsupported encrypted payload format.")
|
|
105
|
-
key = _decode_aes256_key(secret)
|
|
106
|
-
iv = base64.b64decode(payload["iv"])
|
|
107
|
-
tag = base64.b64decode(payload["tag"])
|
|
108
|
-
ct = base64.b64decode(payload["ciphertext"])
|
|
109
|
-
aesgcm = AESGCM(key)
|
|
110
|
-
pt = aesgcm.decrypt(iv, ct + tag, None)
|
|
111
|
-
return pt.decode("utf8")
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def hmac_sha256(secret: str, value: str) -> str:
|
|
115
|
-
return _hmac_mod.new(secret.encode("utf8"), value.encode("utf8"), "sha256").hexdigest()
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def hash_email(email: str) -> str:
|
|
119
|
-
return hmac_sha256(ZK_AUTH_HMAC_KEY, email.lower().strip())
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def encrypt_email(email: str) -> str:
|
|
123
|
-
return json.dumps(encrypt_string(email.lower().strip(), ZK_AUTH_ENCRYPTION_KEY))
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def decrypt_email(encrypted: str) -> str:
|
|
127
|
-
return decrypt_string(json.loads(encrypted), ZK_AUTH_ENCRYPTION_KEY)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def generate_otp_code(length: int = 6) -> str:
|
|
131
|
-
return "".join(str(secrets.randbelow(10)) for _ in range(length))
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def hash_otp_code(challenge_id: str, code: str) -> str:
|
|
135
|
-
return hmac_sha256(ZK_AUTH_SESSION_SECRET, f"otp:{challenge_id}:{code}")
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def create_opaque_token(byte_length: int = 32) -> str:
|
|
139
|
-
return base64.urlsafe_b64encode(os.urandom(byte_length)).rstrip(b"=").decode("ascii")
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def hash_session_token(token: str) -> str:
|
|
143
|
-
return hmac_sha256(ZK_AUTH_SESSION_SECRET, f"session:{token}")
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def hash_refresh_token(token: str) -> str:
|
|
147
|
-
return hmac_sha256(ZK_AUTH_SESSION_SECRET, f"refresh:{token}")
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def safe_equal(a: str, b: str) -> bool:
|
|
151
|
-
return _hmac_mod.compare_digest(a.encode("utf8"), b.encode("utf8"))
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
# ---------------------------------------------------------------------------
|
|
155
|
-
# Database helpers
|
|
156
|
-
# ---------------------------------------------------------------------------
|
|
157
|
-
|
|
158
|
-
def _zk_db_path() -> str:
|
|
159
|
-
data_dir = os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data")
|
|
160
|
-
return os.path.join(data_dir, "zk_auth.db")
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def _zk_db() -> sqlite3.Connection:
|
|
164
|
-
db_path = _zk_db_path()
|
|
165
|
-
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
|
166
|
-
conn = sqlite3.connect(db_path, timeout=10.0)
|
|
167
|
-
conn.row_factory = sqlite3.Row
|
|
168
|
-
conn.execute("PRAGMA journal_mode=WAL")
|
|
169
|
-
conn.execute("PRAGMA busy_timeout=5000")
|
|
170
|
-
_zk_init_db(conn)
|
|
171
|
-
return conn
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def _zk_init_db(conn: sqlite3.Connection) -> None:
|
|
175
|
-
conn.executescript("""
|
|
176
|
-
CREATE TABLE IF NOT EXISTS users (
|
|
177
|
-
id TEXT PRIMARY KEY,
|
|
178
|
-
status TEXT NOT NULL DEFAULT 'active'
|
|
179
|
-
CHECK (status IN ('active', 'suspended', 'deleted')),
|
|
180
|
-
created_at TEXT NOT NULL,
|
|
181
|
-
updated_at TEXT NOT NULL
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
CREATE TABLE IF NOT EXISTS identities (
|
|
185
|
-
id TEXT PRIMARY KEY,
|
|
186
|
-
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
187
|
-
type TEXT NOT NULL CHECK (type IN ('email', 'passkey', 'oauth')),
|
|
188
|
-
identifier_hash TEXT NOT NULL,
|
|
189
|
-
identifier_encrypted TEXT NOT NULL,
|
|
190
|
-
verified_at TEXT,
|
|
191
|
-
created_at TEXT NOT NULL,
|
|
192
|
-
updated_at TEXT NOT NULL,
|
|
193
|
-
UNIQUE (type, identifier_hash)
|
|
194
|
-
);
|
|
195
|
-
|
|
196
|
-
CREATE TABLE IF NOT EXISTS verification_challenges (
|
|
197
|
-
id TEXT PRIMARY KEY,
|
|
198
|
-
identity_type TEXT NOT NULL CHECK (identity_type IN ('email', 'phone')),
|
|
199
|
-
identifier_hash TEXT NOT NULL,
|
|
200
|
-
code_hash TEXT NOT NULL,
|
|
201
|
-
purpose TEXT NOT NULL CHECK (purpose IN ('login', 'signup', 'recovery')),
|
|
202
|
-
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
203
|
-
max_attempts INTEGER NOT NULL DEFAULT 5,
|
|
204
|
-
expires_at TEXT NOT NULL,
|
|
205
|
-
used_at TEXT,
|
|
206
|
-
created_at TEXT NOT NULL
|
|
207
|
-
);
|
|
208
|
-
|
|
209
|
-
CREATE TABLE IF NOT EXISTS sessions (
|
|
210
|
-
id TEXT PRIMARY KEY,
|
|
211
|
-
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
212
|
-
refresh_token_hash TEXT NOT NULL UNIQUE,
|
|
213
|
-
token_family_id TEXT NOT NULL,
|
|
214
|
-
user_agent_summary TEXT,
|
|
215
|
-
ip_summary TEXT,
|
|
216
|
-
created_at TEXT NOT NULL,
|
|
217
|
-
last_seen_at TEXT,
|
|
218
|
-
expires_at TEXT NOT NULL,
|
|
219
|
-
revoked_at TEXT
|
|
220
|
-
);
|
|
221
|
-
|
|
222
|
-
CREATE TABLE IF NOT EXISTS profiles (
|
|
223
|
-
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
|
224
|
-
display_name TEXT,
|
|
225
|
-
avatar_url TEXT,
|
|
226
|
-
tagline TEXT,
|
|
227
|
-
bio TEXT,
|
|
228
|
-
created_at TEXT NOT NULL,
|
|
229
|
-
updated_at TEXT NOT NULL
|
|
230
|
-
);
|
|
231
|
-
|
|
232
|
-
CREATE INDEX IF NOT EXISTS idx_identities_user_id ON identities(user_id);
|
|
233
|
-
CREATE INDEX IF NOT EXISTS idx_identities_hash ON identities(type, identifier_hash);
|
|
234
|
-
CREATE INDEX IF NOT EXISTS idx_challenges_hash ON verification_challenges(identity_type, identifier_hash, expires_at);
|
|
235
|
-
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
|
236
|
-
CREATE INDEX IF NOT EXISTS idx_sessions_family ON sessions(token_family_id);
|
|
237
|
-
CREATE INDEX IF NOT EXISTS idx_sessions_refresh ON sessions(refresh_token_hash);
|
|
238
|
-
""")
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
# ---------------------------------------------------------------------------
|
|
242
|
-
# Auth service (ported from zk-auth/src/services/)
|
|
243
|
-
# ---------------------------------------------------------------------------
|
|
244
|
-
|
|
245
|
-
def start_email_verification(
|
|
246
|
-
email: str,
|
|
247
|
-
*,
|
|
248
|
-
dev_local_unsafe: bool = False,
|
|
249
|
-
expose_otp: bool = False,
|
|
250
|
-
) -> dict:
|
|
251
|
-
normalized = email.lower().strip()
|
|
252
|
-
identifier_hash = hash_email(normalized)
|
|
253
|
-
challenge_id = str(uuid.uuid4())
|
|
254
|
-
code = generate_otp_code()
|
|
255
|
-
code_hash = hash_otp_code(challenge_id, code)
|
|
256
|
-
now_ts = datetime.now(timezone.utc).isoformat()
|
|
257
|
-
expires = (datetime.now(timezone.utc) + timedelta(minutes=OTP_TTL_MINUTES)).isoformat()
|
|
258
|
-
|
|
259
|
-
conn = _zk_db()
|
|
260
|
-
try:
|
|
261
|
-
conn.execute(
|
|
262
|
-
"UPDATE verification_challenges SET used_at = ? WHERE identifier_hash = ? AND used_at IS NULL AND expires_at > ?",
|
|
263
|
-
(now_ts, identifier_hash, now_ts),
|
|
264
|
-
)
|
|
265
|
-
conn.execute(
|
|
266
|
-
"""INSERT INTO verification_challenges
|
|
267
|
-
(id, identity_type, identifier_hash, code_hash, purpose, max_attempts, expires_at, created_at)
|
|
268
|
-
VALUES (?, 'email', ?, ?, 'login', ?, ?, ?)""",
|
|
269
|
-
(challenge_id, identifier_hash, code_hash, OTP_MAX_ATTEMPTS, expires, now_ts),
|
|
270
|
-
)
|
|
271
|
-
conn.commit()
|
|
272
|
-
finally:
|
|
273
|
-
conn.close()
|
|
274
|
-
|
|
275
|
-
result: dict = {"challenge_id": challenge_id}
|
|
276
|
-
|
|
277
|
-
# Build verification URL
|
|
278
|
-
import email_sender as email_mod
|
|
279
|
-
from urllib.parse import urlencode
|
|
280
|
-
verification_url = f"{email_mod.APP_BASE_URL}/?{urlencode({'email': normalized, 'code': code})}"
|
|
281
|
-
|
|
282
|
-
email_sent = False
|
|
283
|
-
email_error: str | None = None
|
|
284
|
-
try:
|
|
285
|
-
email_mod.send_verification_email(normalized, code, verification_url)
|
|
286
|
-
email_sent = True
|
|
287
|
-
except Exception as exc:
|
|
288
|
-
email_error = str(exc)
|
|
289
|
-
print(f"[zk-auth] Failed to send email: {exc}")
|
|
290
|
-
|
|
291
|
-
result["email_sent"] = email_sent
|
|
292
|
-
if email_error:
|
|
293
|
-
result["email_error"] = email_error
|
|
294
|
-
|
|
295
|
-
# ponytail: OTP in JSON only when DEV_LOCAL_UNSAFE and email did not go out, or explicit E2E install harness
|
|
296
|
-
if expose_otp or (dev_local_unsafe and not email_sent):
|
|
297
|
-
result["otp_code"] = code
|
|
298
|
-
if dev_local_unsafe and not email_sent:
|
|
299
|
-
result["_dev_warning"] = (
|
|
300
|
-
"DEV_LOCAL_UNSAFE: OTP returned because email was not sent"
|
|
301
|
-
)
|
|
302
|
-
elif expose_otp:
|
|
303
|
-
result["_e2e_warning"] = "WORKFRAME_E2E: OTP returned during install window"
|
|
304
|
-
|
|
305
|
-
return result
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
def verify_email_code(email: str, code: str) -> dict:
|
|
309
|
-
normalized = email.lower().strip()
|
|
310
|
-
identifier_hash = hash_email(normalized)
|
|
311
|
-
now_ts = datetime.now(timezone.utc).isoformat()
|
|
312
|
-
|
|
313
|
-
conn = _zk_db()
|
|
314
|
-
try:
|
|
315
|
-
challenge = conn.execute(
|
|
316
|
-
"""SELECT * FROM verification_challenges
|
|
317
|
-
WHERE identifier_hash = ? AND used_at IS NULL AND expires_at > ?
|
|
318
|
-
ORDER BY created_at DESC LIMIT 1""",
|
|
319
|
-
(identifier_hash, now_ts),
|
|
320
|
-
).fetchone()
|
|
321
|
-
|
|
322
|
-
if not challenge:
|
|
323
|
-
raise ValueError("Invalid or expired verification code.")
|
|
324
|
-
|
|
325
|
-
if challenge["attempt_count"] >= challenge["max_attempts"]:
|
|
326
|
-
raise ValueError("Invalid or expired verification code.")
|
|
327
|
-
|
|
328
|
-
expected_hash = hash_otp_code(challenge["id"], code)
|
|
329
|
-
if not safe_equal(expected_hash, challenge["code_hash"]):
|
|
330
|
-
conn.execute(
|
|
331
|
-
"UPDATE verification_challenges SET attempt_count = attempt_count + 1 WHERE id = ?",
|
|
332
|
-
(challenge["id"],),
|
|
333
|
-
)
|
|
334
|
-
conn.commit()
|
|
335
|
-
raise ValueError("Invalid or expired verification code.")
|
|
336
|
-
|
|
337
|
-
conn.execute(
|
|
338
|
-
"UPDATE verification_challenges SET used_at = ? WHERE id = ?",
|
|
339
|
-
(now_ts, challenge["id"]),
|
|
340
|
-
)
|
|
341
|
-
|
|
342
|
-
identity = conn.execute(
|
|
343
|
-
"SELECT * FROM identities WHERE type = 'email' AND identifier_hash = ?",
|
|
344
|
-
(identifier_hash,),
|
|
345
|
-
).fetchone()
|
|
346
|
-
|
|
347
|
-
is_new_user = False
|
|
348
|
-
if not identity:
|
|
349
|
-
user_id = str(uuid.uuid4())
|
|
350
|
-
conn.execute(
|
|
351
|
-
"INSERT INTO users (id, status, created_at, updated_at) VALUES (?, 'active', ?, ?)",
|
|
352
|
-
(user_id, now_ts, now_ts),
|
|
353
|
-
)
|
|
354
|
-
conn.execute(
|
|
355
|
-
"""INSERT INTO identities
|
|
356
|
-
(id, user_id, type, identifier_hash, identifier_encrypted, verified_at, created_at, updated_at)
|
|
357
|
-
VALUES (?, ?, 'email', ?, ?, ?, ?, ?)""",
|
|
358
|
-
(str(uuid.uuid4()), user_id, identifier_hash, encrypt_email(normalized), now_ts, now_ts, now_ts),
|
|
359
|
-
)
|
|
360
|
-
conn.execute(
|
|
361
|
-
"INSERT INTO profiles (user_id, created_at, updated_at) VALUES (?, ?, ?)",
|
|
362
|
-
(user_id, now_ts, now_ts),
|
|
363
|
-
)
|
|
364
|
-
is_new_user = True
|
|
365
|
-
else:
|
|
366
|
-
user_id = identity["user_id"]
|
|
367
|
-
|
|
368
|
-
session_id = str(uuid.uuid4())
|
|
369
|
-
refresh_token = create_opaque_token(32)
|
|
370
|
-
refresh_token_hash = hash_refresh_token(refresh_token)
|
|
371
|
-
token_family_id = str(uuid.uuid4())
|
|
372
|
-
expires_at = (datetime.now(timezone.utc) + timedelta(days=SESSION_TTL_DAYS)).isoformat()
|
|
373
|
-
|
|
374
|
-
conn.execute(
|
|
375
|
-
"""INSERT INTO sessions
|
|
376
|
-
(id, user_id, refresh_token_hash, token_family_id, created_at, expires_at)
|
|
377
|
-
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
378
|
-
(session_id, user_id, refresh_token_hash, token_family_id, now_ts, expires_at),
|
|
379
|
-
)
|
|
380
|
-
conn.commit()
|
|
381
|
-
|
|
382
|
-
return {
|
|
383
|
-
"user_id": user_id,
|
|
384
|
-
"session_id": session_id,
|
|
385
|
-
"refresh_token": refresh_token,
|
|
386
|
-
"expires_at": expires_at,
|
|
387
|
-
"is_new_user": is_new_user,
|
|
388
|
-
}
|
|
389
|
-
except ValueError:
|
|
390
|
-
raise
|
|
391
|
-
except Exception as exc:
|
|
392
|
-
conn.rollback()
|
|
393
|
-
raise RuntimeError(f"Auth failed: {exc}") from exc
|
|
394
|
-
finally:
|
|
395
|
-
conn.close()
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
def create_session_for_email(email: str) -> dict:
|
|
399
|
-
"""Trusted invite path — session without OTP when invite token already proved mailbox."""
|
|
400
|
-
normalized = email.lower().strip()
|
|
401
|
-
identifier_hash = hash_email(normalized)
|
|
402
|
-
now_ts = datetime.now(timezone.utc).isoformat()
|
|
403
|
-
conn = _zk_db()
|
|
404
|
-
try:
|
|
405
|
-
identity = conn.execute(
|
|
406
|
-
"SELECT * FROM identities WHERE type = 'email' AND identifier_hash = ?",
|
|
407
|
-
(identifier_hash,),
|
|
408
|
-
).fetchone()
|
|
409
|
-
is_new_user = False
|
|
410
|
-
if not identity:
|
|
411
|
-
user_id = str(uuid.uuid4())
|
|
412
|
-
conn.execute(
|
|
413
|
-
"INSERT INTO users (id, status, created_at, updated_at) VALUES (?, 'active', ?, ?)",
|
|
414
|
-
(user_id, now_ts, now_ts),
|
|
415
|
-
)
|
|
416
|
-
conn.execute(
|
|
417
|
-
"""INSERT INTO identities
|
|
418
|
-
(id, user_id, type, identifier_hash, identifier_encrypted, verified_at, created_at, updated_at)
|
|
419
|
-
VALUES (?, ?, 'email', ?, ?, ?, ?, ?)""",
|
|
420
|
-
(str(uuid.uuid4()), user_id, identifier_hash, encrypt_email(normalized), now_ts, now_ts, now_ts),
|
|
421
|
-
)
|
|
422
|
-
conn.execute(
|
|
423
|
-
"INSERT INTO profiles (user_id, created_at, updated_at) VALUES (?, ?, ?)",
|
|
424
|
-
(user_id, now_ts, now_ts),
|
|
425
|
-
)
|
|
426
|
-
is_new_user = True
|
|
427
|
-
else:
|
|
428
|
-
user_id = identity["user_id"]
|
|
429
|
-
session_id = str(uuid.uuid4())
|
|
430
|
-
refresh_token = create_opaque_token(32)
|
|
431
|
-
refresh_token_hash = hash_refresh_token(refresh_token)
|
|
432
|
-
token_family_id = str(uuid.uuid4())
|
|
433
|
-
expires_at = (datetime.now(timezone.utc) + timedelta(days=SESSION_TTL_DAYS)).isoformat()
|
|
434
|
-
conn.execute(
|
|
435
|
-
"""INSERT INTO sessions
|
|
436
|
-
(id, user_id, refresh_token_hash, token_family_id, created_at, expires_at)
|
|
437
|
-
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
438
|
-
(session_id, user_id, refresh_token_hash, token_family_id, now_ts, expires_at),
|
|
439
|
-
)
|
|
440
|
-
conn.commit()
|
|
441
|
-
return {
|
|
442
|
-
"user_id": user_id,
|
|
443
|
-
"session_id": session_id,
|
|
444
|
-
"refresh_token": refresh_token,
|
|
445
|
-
"expires_at": expires_at,
|
|
446
|
-
"is_new_user": is_new_user,
|
|
447
|
-
}
|
|
448
|
-
except Exception as exc:
|
|
449
|
-
conn.rollback()
|
|
450
|
-
raise RuntimeError(f"Auth failed: {exc}") from exc
|
|
451
|
-
finally:
|
|
452
|
-
conn.close()
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
def logout_session(session_id: str) -> None:
|
|
456
|
-
now_ts = datetime.now(timezone.utc).isoformat()
|
|
457
|
-
conn = _zk_db()
|
|
458
|
-
try:
|
|
459
|
-
conn.execute("UPDATE sessions SET revoked_at = ? WHERE id = ?", (now_ts, session_id))
|
|
460
|
-
conn.commit()
|
|
461
|
-
finally:
|
|
462
|
-
conn.close()
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
def refresh_session(refresh_token: str) -> dict:
|
|
466
|
-
"""Rotate a refresh token. Returns new session_id + refresh_token.
|
|
467
|
-
|
|
468
|
-
Implements token family revocation: if a previously-used refresh token
|
|
469
|
-
is replayed, the entire family is revoked (reuse detection).
|
|
470
|
-
"""
|
|
471
|
-
token_hash = hash_refresh_token(refresh_token)
|
|
472
|
-
now_ts = datetime.now(timezone.utc).isoformat()
|
|
473
|
-
|
|
474
|
-
conn = _zk_db()
|
|
475
|
-
try:
|
|
476
|
-
session = conn.execute(
|
|
477
|
-
"SELECT * FROM sessions WHERE refresh_token_hash = ?",
|
|
478
|
-
(token_hash,),
|
|
479
|
-
).fetchone()
|
|
480
|
-
|
|
481
|
-
if not session:
|
|
482
|
-
# Possible token reuse — revoke the whole family
|
|
483
|
-
# We can't look up by hash since the token is unknown, but we
|
|
484
|
-
# can check if any session in a family has been revoked.
|
|
485
|
-
# For now, just reject.
|
|
486
|
-
raise ValueError("Invalid refresh token.")
|
|
487
|
-
|
|
488
|
-
if session["revoked_at"] is not None:
|
|
489
|
-
# Token reuse detected — revoke entire family
|
|
490
|
-
conn.execute(
|
|
491
|
-
"UPDATE sessions SET revoked_at = ? WHERE token_family_id = ?",
|
|
492
|
-
(now_ts, session["token_family_id"]),
|
|
493
|
-
)
|
|
494
|
-
conn.commit()
|
|
495
|
-
raise ValueError("Refresh token reuse detected. Session family revoked.")
|
|
496
|
-
|
|
497
|
-
if session["expires_at"] < now_ts:
|
|
498
|
-
raise ValueError("Refresh token expired.")
|
|
499
|
-
|
|
500
|
-
# Rotate: new session ID + new refresh token, same family
|
|
501
|
-
new_session_id = str(uuid.uuid4())
|
|
502
|
-
new_refresh_token = create_opaque_token(32)
|
|
503
|
-
new_refresh_hash = hash_refresh_token(new_refresh_token)
|
|
504
|
-
new_expires = (datetime.now(timezone.utc) + timedelta(days=SESSION_TTL_DAYS)).isoformat()
|
|
505
|
-
|
|
506
|
-
# Revoke old session
|
|
507
|
-
conn.execute(
|
|
508
|
-
"UPDATE sessions SET revoked_at = ? WHERE id = ?",
|
|
509
|
-
(now_ts, session["id"]),
|
|
510
|
-
)
|
|
511
|
-
# Insert new session in same family
|
|
512
|
-
conn.execute(
|
|
513
|
-
"""INSERT INTO sessions
|
|
514
|
-
(id, user_id, refresh_token_hash, token_family_id, user_agent_summary, ip_summary, created_at, expires_at)
|
|
515
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
516
|
-
(new_session_id, session["user_id"], new_refresh_hash,
|
|
517
|
-
session["token_family_id"], session["user_agent_summary"],
|
|
518
|
-
session["ip_summary"], now_ts, new_expires),
|
|
519
|
-
)
|
|
520
|
-
conn.commit()
|
|
521
|
-
|
|
522
|
-
return {
|
|
523
|
-
"user_id": session["user_id"],
|
|
524
|
-
"session_id": new_session_id,
|
|
525
|
-
"refresh_token": new_refresh_token,
|
|
526
|
-
"expires_at": new_expires,
|
|
527
|
-
}
|
|
528
|
-
except ValueError:
|
|
529
|
-
raise
|
|
530
|
-
except Exception as exc:
|
|
531
|
-
conn.rollback()
|
|
532
|
-
raise RuntimeError(f"Refresh failed: {exc}") from exc
|
|
533
|
-
finally:
|
|
534
|
-
conn.close()
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
def validate_session_token(session_id: str) -> dict | None:
|
|
538
|
-
now_ts = datetime.now(timezone.utc).isoformat()
|
|
539
|
-
conn = _zk_db()
|
|
540
|
-
try:
|
|
541
|
-
session = conn.execute(
|
|
542
|
-
"""SELECT s.*, u.status as user_status
|
|
543
|
-
FROM sessions s JOIN users u ON u.id = s.user_id
|
|
544
|
-
WHERE s.id = ? AND s.revoked_at IS NULL AND s.expires_at > ?""",
|
|
545
|
-
(session_id, now_ts),
|
|
546
|
-
).fetchone()
|
|
547
|
-
if not session or session["user_status"] != "active":
|
|
548
|
-
return None
|
|
549
|
-
conn.execute("UPDATE sessions SET last_seen_at = ? WHERE id = ?", (now_ts, session_id))
|
|
550
|
-
conn.commit()
|
|
551
|
-
return {"user_id": session["user_id"], "session_id": session["id"], "expires_at": session["expires_at"]}
|
|
552
|
-
finally:
|
|
553
|
-
conn.close()
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
def get_profile(user_id: str) -> dict | None:
|
|
557
|
-
conn = _zk_db()
|
|
558
|
-
try:
|
|
559
|
-
row = conn.execute("SELECT * FROM profiles WHERE user_id = ?", (user_id,)).fetchone()
|
|
560
|
-
if not row:
|
|
561
|
-
return None
|
|
562
|
-
return {
|
|
563
|
-
"user_id": row["user_id"],
|
|
564
|
-
"display_name": row["display_name"],
|
|
565
|
-
"avatar_url": row["avatar_url"],
|
|
566
|
-
"tagline": row["tagline"],
|
|
567
|
-
"bio": row["bio"],
|
|
568
|
-
}
|
|
569
|
-
finally:
|
|
570
|
-
conn.close()
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
def update_profile(user_id: str, updates: dict) -> dict:
|
|
574
|
-
allowed = {"display_name", "avatar_url", "tagline", "bio"}
|
|
575
|
-
fields = {k: v for k, v in updates.items() if k in allowed and v is not None}
|
|
576
|
-
if not fields:
|
|
577
|
-
return get_profile(user_id)
|
|
578
|
-
|
|
579
|
-
now_ts = datetime.now(timezone.utc).isoformat()
|
|
580
|
-
fields["updated_at"] = now_ts
|
|
581
|
-
|
|
582
|
-
conn = _zk_db()
|
|
583
|
-
try:
|
|
584
|
-
existing = conn.execute("SELECT 1 FROM profiles WHERE user_id = ?", (user_id,)).fetchone()
|
|
585
|
-
if not existing:
|
|
586
|
-
conn.execute(
|
|
587
|
-
"INSERT INTO profiles (user_id, created_at, updated_at) VALUES (?, ?, ?)",
|
|
588
|
-
(user_id, now_ts, now_ts),
|
|
589
|
-
)
|
|
590
|
-
set_clause = ", ".join(f"{k} = ?" for k in fields)
|
|
591
|
-
values = list(fields.values()) + [user_id]
|
|
592
|
-
conn.execute(f"UPDATE profiles SET {set_clause} WHERE user_id = ?", values)
|
|
593
|
-
conn.commit()
|
|
594
|
-
finally:
|
|
595
|
-
conn.close()
|
|
596
|
-
return get_profile(user_id)
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
# ---------------------------------------------------------------------------
|
|
600
|
-
# Cookie helpers
|
|
601
|
-
# ---------------------------------------------------------------------------
|
|
602
|
-
|
|
603
|
-
def session_cookie_name() -> str:
|
|
604
|
-
# ponytail: WORKFRAME_INSTALL_ID scopes cookies per install; auth DB is per install (same email OK across installs).
|
|
605
|
-
install_id = os.environ.get("WORKFRAME_INSTALL_ID", "").strip()
|
|
606
|
-
if install_id:
|
|
607
|
-
safe = re.sub(r"[^a-zA-Z0-9_-]+", "_", install_id).strip("_")
|
|
608
|
-
if safe:
|
|
609
|
-
return f"{safe}_session"
|
|
610
|
-
slug = re.sub(
|
|
611
|
-
r"[^a-z0-9]+",
|
|
612
|
-
"_",
|
|
613
|
-
os.environ.get("WORKFRAME_PROJECT", "workframe").lower(),
|
|
614
|
-
).strip("_")
|
|
615
|
-
return f"wf_{slug or 'workframe'}_session"
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
def session_cookie_value(session_id: str, ttl: int = None, secure: bool = True) -> str:
|
|
619
|
-
if ttl is None:
|
|
620
|
-
ttl = SESSION_TTL_DAYS * 86400
|
|
621
|
-
name = session_cookie_name()
|
|
622
|
-
val = f"{name}={session_id}; HttpOnly; SameSite=Lax; Path=/; Max-Age={ttl}"
|
|
623
|
-
if secure:
|
|
624
|
-
val += "; Secure"
|
|
625
|
-
return val
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
def clear_session_cookie(secure: bool = True) -> str:
|
|
629
|
-
name = session_cookie_name()
|
|
630
|
-
val = f"{name}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0"
|
|
631
|
-
if secure:
|
|
632
|
-
val += "; Secure"
|
|
633
|
-
return val
|
|
1
|
+
"""
|
|
2
|
+
zk-auth compatible auth module for Workframe API.
|
|
3
|
+
|
|
4
|
+
Ported from /d/ab/projects/zk-auth/ with the same:
|
|
5
|
+
- AES-256-GCM encryption for secrets (emails, tokens)
|
|
6
|
+
- HMAC-SHA256 for email hashing, OTP hashing, token hashing
|
|
7
|
+
- Atomic OTP consume with attempt counting
|
|
8
|
+
- Session management with refresh tokens
|
|
9
|
+
- Profile management (display_name, avatar_url, tagline, bio)
|
|
10
|
+
|
|
11
|
+
Two modes:
|
|
12
|
+
- DEV_LOCAL_UNSAFE: OTP is returned in response (no email needed)
|
|
13
|
+
- SECURE_MODE: OTP is sent via email (requires SMTP config)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import hmac as _hmac_mod
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import secrets
|
|
24
|
+
import sqlite3
|
|
25
|
+
import uuid
|
|
26
|
+
from datetime import datetime, timedelta, timezone
|
|
27
|
+
|
|
28
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Config
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
DATA_DIR = os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data")
|
|
36
|
+
AUTH_KEYS_FILE = os.path.join(DATA_DIR, ".auth_keys")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _load_or_generate_keys() -> tuple[str, str, str]:
|
|
40
|
+
"""Load auth keys from env, disk, or generate on first boot."""
|
|
41
|
+
hmac_env = os.environ.get("ZK_AUTH_HMAC_KEY", "").strip()
|
|
42
|
+
enc_env = os.environ.get("ZK_AUTH_ENCRYPTION_KEY", "").strip()
|
|
43
|
+
session_env = os.environ.get("ZK_AUTH_SESSION_SECRET", "").strip()
|
|
44
|
+
if hmac_env and enc_env and session_env:
|
|
45
|
+
return hmac_env, enc_env, session_env
|
|
46
|
+
|
|
47
|
+
os.makedirs(DATA_DIR, exist_ok=True)
|
|
48
|
+
if os.path.exists(AUTH_KEYS_FILE):
|
|
49
|
+
try:
|
|
50
|
+
with open(AUTH_KEYS_FILE, "r") as f:
|
|
51
|
+
keys = json.load(f)
|
|
52
|
+
return keys["hmac"], keys["enc"], keys["session"]
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
# First boot — generate and persist
|
|
56
|
+
hmac_key = secrets.token_hex(32)
|
|
57
|
+
enc_key = base64.b64encode(os.urandom(32)).decode()
|
|
58
|
+
session_key = secrets.token_hex(32)
|
|
59
|
+
try:
|
|
60
|
+
with open(AUTH_KEYS_FILE, "w") as f:
|
|
61
|
+
json.dump({"hmac": hmac_key, "enc": enc_key, "session": session_key}, f)
|
|
62
|
+
os.chmod(AUTH_KEYS_FILE, 0o600)
|
|
63
|
+
except Exception:
|
|
64
|
+
pass # container may be read-only; fall back to in-memory
|
|
65
|
+
return hmac_key, enc_key, session_key
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
ZK_AUTH_HMAC_KEY, ZK_AUTH_ENCRYPTION_KEY, ZK_AUTH_SESSION_SECRET = _load_or_generate_keys()
|
|
69
|
+
OTP_TTL_MINUTES = int(os.environ.get("OTP_TTL_MINUTES", "10"))
|
|
70
|
+
SESSION_TTL_DAYS = int(os.environ.get("SESSION_TTL_DAYS", "30"))
|
|
71
|
+
OTP_MAX_ATTEMPTS = 5
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Crypto (ported from zk-auth/src/crypto/)
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _decode_aes256_key(value: str) -> bytes:
|
|
80
|
+
key = base64.b64decode(value)
|
|
81
|
+
if len(key) != 32:
|
|
82
|
+
raise ValueError("ZK_AUTH_ENCRYPTION_KEY must be a base64-encoded 32-byte key.")
|
|
83
|
+
return key
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def encrypt_string(value: str, secret: str) -> dict:
|
|
87
|
+
"""AES-256-GCM encrypt. Returns {v, alg, iv, tag, ciphertext}."""
|
|
88
|
+
key = _decode_aes256_key(secret)
|
|
89
|
+
iv = os.urandom(12)
|
|
90
|
+
aesgcm = AESGCM(key)
|
|
91
|
+
ct = aesgcm.encrypt(iv, value.encode("utf8"), None)
|
|
92
|
+
return {
|
|
93
|
+
"v": 1,
|
|
94
|
+
"alg": "AES-256-GCM",
|
|
95
|
+
"iv": base64.b64encode(iv).decode("ascii"),
|
|
96
|
+
"tag": base64.b64encode(ct[-16:]).decode("ascii"),
|
|
97
|
+
"ciphertext": base64.b64encode(ct[:-16]).decode("ascii"),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def decrypt_string(payload: dict, secret: str) -> str:
|
|
102
|
+
"""AES-256-GCM decrypt."""
|
|
103
|
+
if payload.get("v") != 1 or payload.get("alg") != "AES-256-GCM":
|
|
104
|
+
raise ValueError("Unsupported encrypted payload format.")
|
|
105
|
+
key = _decode_aes256_key(secret)
|
|
106
|
+
iv = base64.b64decode(payload["iv"])
|
|
107
|
+
tag = base64.b64decode(payload["tag"])
|
|
108
|
+
ct = base64.b64decode(payload["ciphertext"])
|
|
109
|
+
aesgcm = AESGCM(key)
|
|
110
|
+
pt = aesgcm.decrypt(iv, ct + tag, None)
|
|
111
|
+
return pt.decode("utf8")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def hmac_sha256(secret: str, value: str) -> str:
|
|
115
|
+
return _hmac_mod.new(secret.encode("utf8"), value.encode("utf8"), "sha256").hexdigest()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def hash_email(email: str) -> str:
|
|
119
|
+
return hmac_sha256(ZK_AUTH_HMAC_KEY, email.lower().strip())
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def encrypt_email(email: str) -> str:
|
|
123
|
+
return json.dumps(encrypt_string(email.lower().strip(), ZK_AUTH_ENCRYPTION_KEY))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def decrypt_email(encrypted: str) -> str:
|
|
127
|
+
return decrypt_string(json.loads(encrypted), ZK_AUTH_ENCRYPTION_KEY)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def generate_otp_code(length: int = 6) -> str:
|
|
131
|
+
return "".join(str(secrets.randbelow(10)) for _ in range(length))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def hash_otp_code(challenge_id: str, code: str) -> str:
|
|
135
|
+
return hmac_sha256(ZK_AUTH_SESSION_SECRET, f"otp:{challenge_id}:{code}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def create_opaque_token(byte_length: int = 32) -> str:
|
|
139
|
+
return base64.urlsafe_b64encode(os.urandom(byte_length)).rstrip(b"=").decode("ascii")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def hash_session_token(token: str) -> str:
|
|
143
|
+
return hmac_sha256(ZK_AUTH_SESSION_SECRET, f"session:{token}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def hash_refresh_token(token: str) -> str:
|
|
147
|
+
return hmac_sha256(ZK_AUTH_SESSION_SECRET, f"refresh:{token}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def safe_equal(a: str, b: str) -> bool:
|
|
151
|
+
return _hmac_mod.compare_digest(a.encode("utf8"), b.encode("utf8"))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Database helpers
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def _zk_db_path() -> str:
|
|
159
|
+
data_dir = os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data")
|
|
160
|
+
return os.path.join(data_dir, "zk_auth.db")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _zk_db() -> sqlite3.Connection:
|
|
164
|
+
db_path = _zk_db_path()
|
|
165
|
+
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
|
166
|
+
conn = sqlite3.connect(db_path, timeout=10.0)
|
|
167
|
+
conn.row_factory = sqlite3.Row
|
|
168
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
169
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
170
|
+
_zk_init_db(conn)
|
|
171
|
+
return conn
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _zk_init_db(conn: sqlite3.Connection) -> None:
|
|
175
|
+
conn.executescript("""
|
|
176
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
177
|
+
id TEXT PRIMARY KEY,
|
|
178
|
+
status TEXT NOT NULL DEFAULT 'active'
|
|
179
|
+
CHECK (status IN ('active', 'suspended', 'deleted')),
|
|
180
|
+
created_at TEXT NOT NULL,
|
|
181
|
+
updated_at TEXT NOT NULL
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
CREATE TABLE IF NOT EXISTS identities (
|
|
185
|
+
id TEXT PRIMARY KEY,
|
|
186
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
187
|
+
type TEXT NOT NULL CHECK (type IN ('email', 'passkey', 'oauth')),
|
|
188
|
+
identifier_hash TEXT NOT NULL,
|
|
189
|
+
identifier_encrypted TEXT NOT NULL,
|
|
190
|
+
verified_at TEXT,
|
|
191
|
+
created_at TEXT NOT NULL,
|
|
192
|
+
updated_at TEXT NOT NULL,
|
|
193
|
+
UNIQUE (type, identifier_hash)
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
CREATE TABLE IF NOT EXISTS verification_challenges (
|
|
197
|
+
id TEXT PRIMARY KEY,
|
|
198
|
+
identity_type TEXT NOT NULL CHECK (identity_type IN ('email', 'phone')),
|
|
199
|
+
identifier_hash TEXT NOT NULL,
|
|
200
|
+
code_hash TEXT NOT NULL,
|
|
201
|
+
purpose TEXT NOT NULL CHECK (purpose IN ('login', 'signup', 'recovery')),
|
|
202
|
+
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
203
|
+
max_attempts INTEGER NOT NULL DEFAULT 5,
|
|
204
|
+
expires_at TEXT NOT NULL,
|
|
205
|
+
used_at TEXT,
|
|
206
|
+
created_at TEXT NOT NULL
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
210
|
+
id TEXT PRIMARY KEY,
|
|
211
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
212
|
+
refresh_token_hash TEXT NOT NULL UNIQUE,
|
|
213
|
+
token_family_id TEXT NOT NULL,
|
|
214
|
+
user_agent_summary TEXT,
|
|
215
|
+
ip_summary TEXT,
|
|
216
|
+
created_at TEXT NOT NULL,
|
|
217
|
+
last_seen_at TEXT,
|
|
218
|
+
expires_at TEXT NOT NULL,
|
|
219
|
+
revoked_at TEXT
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
CREATE TABLE IF NOT EXISTS profiles (
|
|
223
|
+
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
|
224
|
+
display_name TEXT,
|
|
225
|
+
avatar_url TEXT,
|
|
226
|
+
tagline TEXT,
|
|
227
|
+
bio TEXT,
|
|
228
|
+
created_at TEXT NOT NULL,
|
|
229
|
+
updated_at TEXT NOT NULL
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
CREATE INDEX IF NOT EXISTS idx_identities_user_id ON identities(user_id);
|
|
233
|
+
CREATE INDEX IF NOT EXISTS idx_identities_hash ON identities(type, identifier_hash);
|
|
234
|
+
CREATE INDEX IF NOT EXISTS idx_challenges_hash ON verification_challenges(identity_type, identifier_hash, expires_at);
|
|
235
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
|
236
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_family ON sessions(token_family_id);
|
|
237
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_refresh ON sessions(refresh_token_hash);
|
|
238
|
+
""")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# Auth service (ported from zk-auth/src/services/)
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
def start_email_verification(
|
|
246
|
+
email: str,
|
|
247
|
+
*,
|
|
248
|
+
dev_local_unsafe: bool = False,
|
|
249
|
+
expose_otp: bool = False,
|
|
250
|
+
) -> dict:
|
|
251
|
+
normalized = email.lower().strip()
|
|
252
|
+
identifier_hash = hash_email(normalized)
|
|
253
|
+
challenge_id = str(uuid.uuid4())
|
|
254
|
+
code = generate_otp_code()
|
|
255
|
+
code_hash = hash_otp_code(challenge_id, code)
|
|
256
|
+
now_ts = datetime.now(timezone.utc).isoformat()
|
|
257
|
+
expires = (datetime.now(timezone.utc) + timedelta(minutes=OTP_TTL_MINUTES)).isoformat()
|
|
258
|
+
|
|
259
|
+
conn = _zk_db()
|
|
260
|
+
try:
|
|
261
|
+
conn.execute(
|
|
262
|
+
"UPDATE verification_challenges SET used_at = ? WHERE identifier_hash = ? AND used_at IS NULL AND expires_at > ?",
|
|
263
|
+
(now_ts, identifier_hash, now_ts),
|
|
264
|
+
)
|
|
265
|
+
conn.execute(
|
|
266
|
+
"""INSERT INTO verification_challenges
|
|
267
|
+
(id, identity_type, identifier_hash, code_hash, purpose, max_attempts, expires_at, created_at)
|
|
268
|
+
VALUES (?, 'email', ?, ?, 'login', ?, ?, ?)""",
|
|
269
|
+
(challenge_id, identifier_hash, code_hash, OTP_MAX_ATTEMPTS, expires, now_ts),
|
|
270
|
+
)
|
|
271
|
+
conn.commit()
|
|
272
|
+
finally:
|
|
273
|
+
conn.close()
|
|
274
|
+
|
|
275
|
+
result: dict = {"challenge_id": challenge_id}
|
|
276
|
+
|
|
277
|
+
# Build verification URL
|
|
278
|
+
import email_sender as email_mod
|
|
279
|
+
from urllib.parse import urlencode
|
|
280
|
+
verification_url = f"{email_mod.APP_BASE_URL}/?{urlencode({'email': normalized, 'code': code})}"
|
|
281
|
+
|
|
282
|
+
email_sent = False
|
|
283
|
+
email_error: str | None = None
|
|
284
|
+
try:
|
|
285
|
+
email_mod.send_verification_email(normalized, code, verification_url)
|
|
286
|
+
email_sent = True
|
|
287
|
+
except Exception as exc:
|
|
288
|
+
email_error = str(exc)
|
|
289
|
+
print(f"[zk-auth] Failed to send email: {exc}")
|
|
290
|
+
|
|
291
|
+
result["email_sent"] = email_sent
|
|
292
|
+
if email_error:
|
|
293
|
+
result["email_error"] = email_error
|
|
294
|
+
|
|
295
|
+
# ponytail: OTP in JSON only when DEV_LOCAL_UNSAFE and email did not go out, or explicit E2E install harness
|
|
296
|
+
if expose_otp or (dev_local_unsafe and not email_sent):
|
|
297
|
+
result["otp_code"] = code
|
|
298
|
+
if dev_local_unsafe and not email_sent:
|
|
299
|
+
result["_dev_warning"] = (
|
|
300
|
+
"DEV_LOCAL_UNSAFE: OTP returned because email was not sent"
|
|
301
|
+
)
|
|
302
|
+
elif expose_otp:
|
|
303
|
+
result["_e2e_warning"] = "WORKFRAME_E2E: OTP returned during install window"
|
|
304
|
+
|
|
305
|
+
return result
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def verify_email_code(email: str, code: str) -> dict:
|
|
309
|
+
normalized = email.lower().strip()
|
|
310
|
+
identifier_hash = hash_email(normalized)
|
|
311
|
+
now_ts = datetime.now(timezone.utc).isoformat()
|
|
312
|
+
|
|
313
|
+
conn = _zk_db()
|
|
314
|
+
try:
|
|
315
|
+
challenge = conn.execute(
|
|
316
|
+
"""SELECT * FROM verification_challenges
|
|
317
|
+
WHERE identifier_hash = ? AND used_at IS NULL AND expires_at > ?
|
|
318
|
+
ORDER BY created_at DESC LIMIT 1""",
|
|
319
|
+
(identifier_hash, now_ts),
|
|
320
|
+
).fetchone()
|
|
321
|
+
|
|
322
|
+
if not challenge:
|
|
323
|
+
raise ValueError("Invalid or expired verification code.")
|
|
324
|
+
|
|
325
|
+
if challenge["attempt_count"] >= challenge["max_attempts"]:
|
|
326
|
+
raise ValueError("Invalid or expired verification code.")
|
|
327
|
+
|
|
328
|
+
expected_hash = hash_otp_code(challenge["id"], code)
|
|
329
|
+
if not safe_equal(expected_hash, challenge["code_hash"]):
|
|
330
|
+
conn.execute(
|
|
331
|
+
"UPDATE verification_challenges SET attempt_count = attempt_count + 1 WHERE id = ?",
|
|
332
|
+
(challenge["id"],),
|
|
333
|
+
)
|
|
334
|
+
conn.commit()
|
|
335
|
+
raise ValueError("Invalid or expired verification code.")
|
|
336
|
+
|
|
337
|
+
conn.execute(
|
|
338
|
+
"UPDATE verification_challenges SET used_at = ? WHERE id = ?",
|
|
339
|
+
(now_ts, challenge["id"]),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
identity = conn.execute(
|
|
343
|
+
"SELECT * FROM identities WHERE type = 'email' AND identifier_hash = ?",
|
|
344
|
+
(identifier_hash,),
|
|
345
|
+
).fetchone()
|
|
346
|
+
|
|
347
|
+
is_new_user = False
|
|
348
|
+
if not identity:
|
|
349
|
+
user_id = str(uuid.uuid4())
|
|
350
|
+
conn.execute(
|
|
351
|
+
"INSERT INTO users (id, status, created_at, updated_at) VALUES (?, 'active', ?, ?)",
|
|
352
|
+
(user_id, now_ts, now_ts),
|
|
353
|
+
)
|
|
354
|
+
conn.execute(
|
|
355
|
+
"""INSERT INTO identities
|
|
356
|
+
(id, user_id, type, identifier_hash, identifier_encrypted, verified_at, created_at, updated_at)
|
|
357
|
+
VALUES (?, ?, 'email', ?, ?, ?, ?, ?)""",
|
|
358
|
+
(str(uuid.uuid4()), user_id, identifier_hash, encrypt_email(normalized), now_ts, now_ts, now_ts),
|
|
359
|
+
)
|
|
360
|
+
conn.execute(
|
|
361
|
+
"INSERT INTO profiles (user_id, created_at, updated_at) VALUES (?, ?, ?)",
|
|
362
|
+
(user_id, now_ts, now_ts),
|
|
363
|
+
)
|
|
364
|
+
is_new_user = True
|
|
365
|
+
else:
|
|
366
|
+
user_id = identity["user_id"]
|
|
367
|
+
|
|
368
|
+
session_id = str(uuid.uuid4())
|
|
369
|
+
refresh_token = create_opaque_token(32)
|
|
370
|
+
refresh_token_hash = hash_refresh_token(refresh_token)
|
|
371
|
+
token_family_id = str(uuid.uuid4())
|
|
372
|
+
expires_at = (datetime.now(timezone.utc) + timedelta(days=SESSION_TTL_DAYS)).isoformat()
|
|
373
|
+
|
|
374
|
+
conn.execute(
|
|
375
|
+
"""INSERT INTO sessions
|
|
376
|
+
(id, user_id, refresh_token_hash, token_family_id, created_at, expires_at)
|
|
377
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
378
|
+
(session_id, user_id, refresh_token_hash, token_family_id, now_ts, expires_at),
|
|
379
|
+
)
|
|
380
|
+
conn.commit()
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
"user_id": user_id,
|
|
384
|
+
"session_id": session_id,
|
|
385
|
+
"refresh_token": refresh_token,
|
|
386
|
+
"expires_at": expires_at,
|
|
387
|
+
"is_new_user": is_new_user,
|
|
388
|
+
}
|
|
389
|
+
except ValueError:
|
|
390
|
+
raise
|
|
391
|
+
except Exception as exc:
|
|
392
|
+
conn.rollback()
|
|
393
|
+
raise RuntimeError(f"Auth failed: {exc}") from exc
|
|
394
|
+
finally:
|
|
395
|
+
conn.close()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def create_session_for_email(email: str) -> dict:
|
|
399
|
+
"""Trusted invite path — session without OTP when invite token already proved mailbox."""
|
|
400
|
+
normalized = email.lower().strip()
|
|
401
|
+
identifier_hash = hash_email(normalized)
|
|
402
|
+
now_ts = datetime.now(timezone.utc).isoformat()
|
|
403
|
+
conn = _zk_db()
|
|
404
|
+
try:
|
|
405
|
+
identity = conn.execute(
|
|
406
|
+
"SELECT * FROM identities WHERE type = 'email' AND identifier_hash = ?",
|
|
407
|
+
(identifier_hash,),
|
|
408
|
+
).fetchone()
|
|
409
|
+
is_new_user = False
|
|
410
|
+
if not identity:
|
|
411
|
+
user_id = str(uuid.uuid4())
|
|
412
|
+
conn.execute(
|
|
413
|
+
"INSERT INTO users (id, status, created_at, updated_at) VALUES (?, 'active', ?, ?)",
|
|
414
|
+
(user_id, now_ts, now_ts),
|
|
415
|
+
)
|
|
416
|
+
conn.execute(
|
|
417
|
+
"""INSERT INTO identities
|
|
418
|
+
(id, user_id, type, identifier_hash, identifier_encrypted, verified_at, created_at, updated_at)
|
|
419
|
+
VALUES (?, ?, 'email', ?, ?, ?, ?, ?)""",
|
|
420
|
+
(str(uuid.uuid4()), user_id, identifier_hash, encrypt_email(normalized), now_ts, now_ts, now_ts),
|
|
421
|
+
)
|
|
422
|
+
conn.execute(
|
|
423
|
+
"INSERT INTO profiles (user_id, created_at, updated_at) VALUES (?, ?, ?)",
|
|
424
|
+
(user_id, now_ts, now_ts),
|
|
425
|
+
)
|
|
426
|
+
is_new_user = True
|
|
427
|
+
else:
|
|
428
|
+
user_id = identity["user_id"]
|
|
429
|
+
session_id = str(uuid.uuid4())
|
|
430
|
+
refresh_token = create_opaque_token(32)
|
|
431
|
+
refresh_token_hash = hash_refresh_token(refresh_token)
|
|
432
|
+
token_family_id = str(uuid.uuid4())
|
|
433
|
+
expires_at = (datetime.now(timezone.utc) + timedelta(days=SESSION_TTL_DAYS)).isoformat()
|
|
434
|
+
conn.execute(
|
|
435
|
+
"""INSERT INTO sessions
|
|
436
|
+
(id, user_id, refresh_token_hash, token_family_id, created_at, expires_at)
|
|
437
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
438
|
+
(session_id, user_id, refresh_token_hash, token_family_id, now_ts, expires_at),
|
|
439
|
+
)
|
|
440
|
+
conn.commit()
|
|
441
|
+
return {
|
|
442
|
+
"user_id": user_id,
|
|
443
|
+
"session_id": session_id,
|
|
444
|
+
"refresh_token": refresh_token,
|
|
445
|
+
"expires_at": expires_at,
|
|
446
|
+
"is_new_user": is_new_user,
|
|
447
|
+
}
|
|
448
|
+
except Exception as exc:
|
|
449
|
+
conn.rollback()
|
|
450
|
+
raise RuntimeError(f"Auth failed: {exc}") from exc
|
|
451
|
+
finally:
|
|
452
|
+
conn.close()
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def logout_session(session_id: str) -> None:
|
|
456
|
+
now_ts = datetime.now(timezone.utc).isoformat()
|
|
457
|
+
conn = _zk_db()
|
|
458
|
+
try:
|
|
459
|
+
conn.execute("UPDATE sessions SET revoked_at = ? WHERE id = ?", (now_ts, session_id))
|
|
460
|
+
conn.commit()
|
|
461
|
+
finally:
|
|
462
|
+
conn.close()
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def refresh_session(refresh_token: str) -> dict:
|
|
466
|
+
"""Rotate a refresh token. Returns new session_id + refresh_token.
|
|
467
|
+
|
|
468
|
+
Implements token family revocation: if a previously-used refresh token
|
|
469
|
+
is replayed, the entire family is revoked (reuse detection).
|
|
470
|
+
"""
|
|
471
|
+
token_hash = hash_refresh_token(refresh_token)
|
|
472
|
+
now_ts = datetime.now(timezone.utc).isoformat()
|
|
473
|
+
|
|
474
|
+
conn = _zk_db()
|
|
475
|
+
try:
|
|
476
|
+
session = conn.execute(
|
|
477
|
+
"SELECT * FROM sessions WHERE refresh_token_hash = ?",
|
|
478
|
+
(token_hash,),
|
|
479
|
+
).fetchone()
|
|
480
|
+
|
|
481
|
+
if not session:
|
|
482
|
+
# Possible token reuse — revoke the whole family
|
|
483
|
+
# We can't look up by hash since the token is unknown, but we
|
|
484
|
+
# can check if any session in a family has been revoked.
|
|
485
|
+
# For now, just reject.
|
|
486
|
+
raise ValueError("Invalid refresh token.")
|
|
487
|
+
|
|
488
|
+
if session["revoked_at"] is not None:
|
|
489
|
+
# Token reuse detected — revoke entire family
|
|
490
|
+
conn.execute(
|
|
491
|
+
"UPDATE sessions SET revoked_at = ? WHERE token_family_id = ?",
|
|
492
|
+
(now_ts, session["token_family_id"]),
|
|
493
|
+
)
|
|
494
|
+
conn.commit()
|
|
495
|
+
raise ValueError("Refresh token reuse detected. Session family revoked.")
|
|
496
|
+
|
|
497
|
+
if session["expires_at"] < now_ts:
|
|
498
|
+
raise ValueError("Refresh token expired.")
|
|
499
|
+
|
|
500
|
+
# Rotate: new session ID + new refresh token, same family
|
|
501
|
+
new_session_id = str(uuid.uuid4())
|
|
502
|
+
new_refresh_token = create_opaque_token(32)
|
|
503
|
+
new_refresh_hash = hash_refresh_token(new_refresh_token)
|
|
504
|
+
new_expires = (datetime.now(timezone.utc) + timedelta(days=SESSION_TTL_DAYS)).isoformat()
|
|
505
|
+
|
|
506
|
+
# Revoke old session
|
|
507
|
+
conn.execute(
|
|
508
|
+
"UPDATE sessions SET revoked_at = ? WHERE id = ?",
|
|
509
|
+
(now_ts, session["id"]),
|
|
510
|
+
)
|
|
511
|
+
# Insert new session in same family
|
|
512
|
+
conn.execute(
|
|
513
|
+
"""INSERT INTO sessions
|
|
514
|
+
(id, user_id, refresh_token_hash, token_family_id, user_agent_summary, ip_summary, created_at, expires_at)
|
|
515
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
516
|
+
(new_session_id, session["user_id"], new_refresh_hash,
|
|
517
|
+
session["token_family_id"], session["user_agent_summary"],
|
|
518
|
+
session["ip_summary"], now_ts, new_expires),
|
|
519
|
+
)
|
|
520
|
+
conn.commit()
|
|
521
|
+
|
|
522
|
+
return {
|
|
523
|
+
"user_id": session["user_id"],
|
|
524
|
+
"session_id": new_session_id,
|
|
525
|
+
"refresh_token": new_refresh_token,
|
|
526
|
+
"expires_at": new_expires,
|
|
527
|
+
}
|
|
528
|
+
except ValueError:
|
|
529
|
+
raise
|
|
530
|
+
except Exception as exc:
|
|
531
|
+
conn.rollback()
|
|
532
|
+
raise RuntimeError(f"Refresh failed: {exc}") from exc
|
|
533
|
+
finally:
|
|
534
|
+
conn.close()
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def validate_session_token(session_id: str) -> dict | None:
|
|
538
|
+
now_ts = datetime.now(timezone.utc).isoformat()
|
|
539
|
+
conn = _zk_db()
|
|
540
|
+
try:
|
|
541
|
+
session = conn.execute(
|
|
542
|
+
"""SELECT s.*, u.status as user_status
|
|
543
|
+
FROM sessions s JOIN users u ON u.id = s.user_id
|
|
544
|
+
WHERE s.id = ? AND s.revoked_at IS NULL AND s.expires_at > ?""",
|
|
545
|
+
(session_id, now_ts),
|
|
546
|
+
).fetchone()
|
|
547
|
+
if not session or session["user_status"] != "active":
|
|
548
|
+
return None
|
|
549
|
+
conn.execute("UPDATE sessions SET last_seen_at = ? WHERE id = ?", (now_ts, session_id))
|
|
550
|
+
conn.commit()
|
|
551
|
+
return {"user_id": session["user_id"], "session_id": session["id"], "expires_at": session["expires_at"]}
|
|
552
|
+
finally:
|
|
553
|
+
conn.close()
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def get_profile(user_id: str) -> dict | None:
|
|
557
|
+
conn = _zk_db()
|
|
558
|
+
try:
|
|
559
|
+
row = conn.execute("SELECT * FROM profiles WHERE user_id = ?", (user_id,)).fetchone()
|
|
560
|
+
if not row:
|
|
561
|
+
return None
|
|
562
|
+
return {
|
|
563
|
+
"user_id": row["user_id"],
|
|
564
|
+
"display_name": row["display_name"],
|
|
565
|
+
"avatar_url": row["avatar_url"],
|
|
566
|
+
"tagline": row["tagline"],
|
|
567
|
+
"bio": row["bio"],
|
|
568
|
+
}
|
|
569
|
+
finally:
|
|
570
|
+
conn.close()
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def update_profile(user_id: str, updates: dict) -> dict:
|
|
574
|
+
allowed = {"display_name", "avatar_url", "tagline", "bio"}
|
|
575
|
+
fields = {k: v for k, v in updates.items() if k in allowed and v is not None}
|
|
576
|
+
if not fields:
|
|
577
|
+
return get_profile(user_id)
|
|
578
|
+
|
|
579
|
+
now_ts = datetime.now(timezone.utc).isoformat()
|
|
580
|
+
fields["updated_at"] = now_ts
|
|
581
|
+
|
|
582
|
+
conn = _zk_db()
|
|
583
|
+
try:
|
|
584
|
+
existing = conn.execute("SELECT 1 FROM profiles WHERE user_id = ?", (user_id,)).fetchone()
|
|
585
|
+
if not existing:
|
|
586
|
+
conn.execute(
|
|
587
|
+
"INSERT INTO profiles (user_id, created_at, updated_at) VALUES (?, ?, ?)",
|
|
588
|
+
(user_id, now_ts, now_ts),
|
|
589
|
+
)
|
|
590
|
+
set_clause = ", ".join(f"{k} = ?" for k in fields)
|
|
591
|
+
values = list(fields.values()) + [user_id]
|
|
592
|
+
conn.execute(f"UPDATE profiles SET {set_clause} WHERE user_id = ?", values)
|
|
593
|
+
conn.commit()
|
|
594
|
+
finally:
|
|
595
|
+
conn.close()
|
|
596
|
+
return get_profile(user_id)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# ---------------------------------------------------------------------------
|
|
600
|
+
# Cookie helpers
|
|
601
|
+
# ---------------------------------------------------------------------------
|
|
602
|
+
|
|
603
|
+
def session_cookie_name() -> str:
|
|
604
|
+
# ponytail: WORKFRAME_INSTALL_ID scopes cookies per install; auth DB is per install (same email OK across installs).
|
|
605
|
+
install_id = os.environ.get("WORKFRAME_INSTALL_ID", "").strip()
|
|
606
|
+
if install_id:
|
|
607
|
+
safe = re.sub(r"[^a-zA-Z0-9_-]+", "_", install_id).strip("_")
|
|
608
|
+
if safe:
|
|
609
|
+
return f"{safe}_session"
|
|
610
|
+
slug = re.sub(
|
|
611
|
+
r"[^a-z0-9]+",
|
|
612
|
+
"_",
|
|
613
|
+
os.environ.get("WORKFRAME_PROJECT", "workframe").lower(),
|
|
614
|
+
).strip("_")
|
|
615
|
+
return f"wf_{slug or 'workframe'}_session"
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def session_cookie_value(session_id: str, ttl: int = None, secure: bool = True) -> str:
|
|
619
|
+
if ttl is None:
|
|
620
|
+
ttl = SESSION_TTL_DAYS * 86400
|
|
621
|
+
name = session_cookie_name()
|
|
622
|
+
val = f"{name}={session_id}; HttpOnly; SameSite=Lax; Path=/; Max-Age={ttl}"
|
|
623
|
+
if secure:
|
|
624
|
+
val += "; Secure"
|
|
625
|
+
return val
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def clear_session_cookie(secure: bool = True) -> str:
|
|
629
|
+
name = session_cookie_name()
|
|
630
|
+
val = f"{name}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0"
|
|
631
|
+
if secure:
|
|
632
|
+
val += "; Secure"
|
|
633
|
+
return val
|