create-workframe 0.1.5 → 0.1.7
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/.dockerignore +22 -22
- package/.gitignore +73 -73
- package/LICENSE +201 -201
- package/NOTICE +12 -12
- package/README.md +40 -40
- package/SECURITY.md +38 -38
- package/bin/create-workframe.js +2816 -2814
- package/bin/workframe.js +329 -329
- package/docs/workspace-instructions/WORKFRAME_DISCORD.md +20 -20
- package/docs/workspace-instructions/WORKFRAME_DOCUMENTS_AND_ARTIFACTS.md +20 -20
- package/docs/workspace-instructions/WORKFRAME_KANBAN.md +20 -20
- package/docs/workspace-instructions/WORKFRAME_ONBOARDING.md +21 -21
- package/docs/workspace-instructions/WORKFRAME_ROUTING.md +29 -29
- package/docs/workspace-instructions/WORKFRAME_TELEGRAM.md +19 -19
- package/package.json +64 -64
- package/profiles/README.md +15 -15
- package/profiles/architect/AGENTS.md +29 -29
- package/profiles/architect/SOUL.md +44 -44
- package/profiles/architect/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/designer/AGENTS.md +26 -26
- package/profiles/designer/SOUL.md +31 -31
- package/profiles/designer/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/dev/AGENTS.md +28 -28
- package/profiles/dev/SOUL.md +31 -31
- package/profiles/dev/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/docs/AGENTS.md +27 -27
- package/profiles/docs/SOUL.md +30 -30
- package/profiles/docs/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/research/AGENTS.md +26 -26
- package/profiles/research/SOUL.md +31 -31
- package/profiles/research/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/visionary/AGENTS.md +25 -25
- package/profiles/visionary/SOUL.md +31 -31
- package/profiles/visionary/skills/devops/kanban-worker/SKILL.md +27 -27
- package/profiles/workframe-agent/AGENTS.md +37 -37
- package/profiles/workframe-agent/SETUP.md +185 -185
- package/profiles/workframe-agent/SOUL.md +61 -61
- 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/prompts/WORKFRAME_PROMPT_TEMPLATES.md +16 -16
- package/rules/.hermes.md +11 -11
- package/rules/AGENTS.md +22 -22
- package/rules/workspace-README.md +5 -5
- package/scripts/apply-update-workframe.sh +91 -21
- package/scripts/bundle-workframe-ui.mjs +83 -83
- package/scripts/create_workframe_scaffold.py +648 -648
- package/scripts/ensure-compose-host-paths.mjs +51 -51
- package/scripts/lib/install-identity.mjs +212 -212
- package/scripts/lib/workframe-registry.mjs +290 -290
- package/scripts/new-project.mjs +68 -68
- package/scripts/select_agent_pack.py +31 -31
- package/scripts/set-compose-public-url.mjs +92 -92
- package/scripts/sync-canonical-to-package.mjs +164 -164
- package/shared/WORKFRAME_AGENT_LIBRARY.md +31 -31
- package/shared/WORKFRAME_AGENT_OPERATIONS.md +29 -29
- package/shared/WORKFRAME_AGENT_PACKS.json +64 -64
- package/shared/WORKFRAME_AGENT_PACKS.yaml +20 -20
- package/shared/WORKFRAME_CHAT_PERMISSION_MODEL.md +20 -20
- package/shared/WORKFRAME_HANDOFF_SCHEMA.md +25 -25
- package/shared/WORKFRAME_SKILL_CURATION.md +27 -27
- package/workframe-api/Dockerfile +14 -14
- package/workframe-api/README.md +24 -24
- 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/data/logo-catalog.json +14 -14
- package/workframe-api/data/user-avatar-catalog.json +18 -18
- 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 +280 -280
- package/workframe-api/oidc_jwt.py +108 -108
- package/workframe-api/package.json +12 -12
- package/workframe-api/platform_auth.py +194 -194
- package/workframe-api/profile_secret_policy.py +86 -86
- package/workframe-api/public/assets/index-DPXu_lGn.css +1 -1
- package/workframe-api/public/assets/index-DYnLrCZZ.js +8 -8
- package/workframe-api/public/assets/index-DglUqFB_.js +8 -8
- package/workframe-api/public/index.html +12 -12
- package/workframe-api/requirements.txt +2 -2
- package/workframe-api/server.py +20295 -19866
- package/workframe-api/site_meta.py +271 -271
- package/workframe-api/stack_config.py +435 -427
- package/workframe-api/test_ensure_native_profile.py +29 -0
- package/workframe-api/test_oauth_llm.py +58 -0
- package/workframe-api/test_provider_model_surface.py +31 -0
- package/workframe-api/test_stack_config_install_smtp.py +51 -0
- package/workframe-api/time-bind-chat.py +99 -99
- package/workframe-api/turn_credentials.py +226 -226
- package/workframe-api/updates.py +506 -506
- 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/profile_secret_policy.py +76 -76
- package/workframe-supervisor/server.py +860 -860
- package/workframe-ui/docker/nginx.conf +85 -85
- package/workframe-ui/public/assets/{arc-CnriPN3G.js → arc-Cn-lt9fT.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CCMNXHod.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DYYB1Ic2.js → architectureDiagram-3BPJPVTR-qXoGCqfH.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-Bn-fFOyj.js → blockDiagram-GPEHLZMM-DoopFlWj.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-BPPDtAnD.js → c4Diagram-AAUBKEIU-BRa7hfzE.js} +1 -1
- package/workframe-ui/public/assets/channel-CtFV3sE3.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-Bz2CJiC3.js → chunk-2J33WTMH-Cl33E2hh.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-B8aMeE1B.js → chunk-3OPIFGDE-BRywYZQO.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-bLFHQTuT.js → chunk-4BX2VUAB-CI3xzp-0.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-BRxhx-lG.js → chunk-55IACEB6-Bkf3Ye3F.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BLyOgEJ8.js → chunk-5ZQYHXKU-C5RN3hUT.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-DiuvBl7G.js → chunk-727SXJPM-BaY0yDwt.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-BE1_aBdC.js → chunk-AQP2D5EJ-VMMBaDqd.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-BicGsmBs.js → chunk-BSJP7CBP-DlNZ1UV3.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-C3VTns3d.js → chunk-CSCIHK7Q-tT2HyVy0.js} +1 -1
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-DSiXY3bY.js → chunk-FMBD7UC4-CyACRaUT.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-Cvw5s3fR.js → chunk-KSCS5N6A-seK5WAZs.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-jJifGdIQ.js → chunk-L5ZTLDWV-r5cyG5AH.js} +1 -1
- package/workframe-ui/public/assets/{chunk-LZXEDZCA-Maee1aqo.js → chunk-LZXEDZCA-SnbeVxsJ.js} +2 -2
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-C77N3VAM.js → chunk-ND2GUHAM-zbFgEXSJ.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-BY68lrPB.js → chunk-NZK2D7GU-UhZc8kOk.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-D_c4kWzh.js → chunk-O5CBEL6O-DHvu2iZ-.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-B1g7l1_B.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-B_qfZ6QP.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-DlQgvqBp.js → chunk-XPW4576I-CCPNKbzG.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CJoeCNq6.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CJoeCNq6.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CHCOHDSd.js → cose-bilkent-S5V4N54A-D1CLe6Y0.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-CKPcSYwU.js → dagre-BM42HDAG-DYHcGVs_.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-C6qe0jP-.js → diagram-2AECGRRQ-DVUkJ-II.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-DhQhrieU.js → diagram-5GNKFQAL-C9Y1Acc2.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-Bqf6Iw1r.js → diagram-KO2AKTUF-Db3zez-n.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt1ms2D2.js → diagram-LMA3HP47-BaOR5sIr.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-C0Rrxe1i.js → diagram-OG6HWLK6-fLBzMY5W.js} +1 -1
- package/workframe-ui/public/assets/{dist-BLcLhTn6.js → dist-uZYeD3o1.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-Cy1GbZVu.js → erDiagram-TEJ5UH35-BoCxDSyE.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Bw910jho.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-DL3ALqDG.js → flowDiagram-I6XJVG4X-BnYa3CAj.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-CMpwBhXQ.js → ganttDiagram-6RSMTGT7-Dm_e_Kaa.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-C0mwHUXL.js → gitGraph-WXDBUCRP-CBAj8ntv.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-6gzhWaF0.js → gitGraphDiagram-PVQCEYII-D5auJjJh.js} +1 -1
- package/workframe-ui/public/assets/index-DHLQNAXd.js +129 -0
- package/workframe-ui/public/assets/index-auJ0bLMU.css +1 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-Bb_wZTxU.js → info-J43DQDTF-BNMADxE2.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-601evYFV.js → infoDiagram-5YYISTIA-C8c45T4J.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-BYtF-wHm.js → ishikawaDiagram-YF4QCWOH-CyWuHB4e.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-BMizyHzC.js → journeyDiagram-JHISSGLW-B43pCuDT.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CLLJsdnh.js → kanban-definition-UN3LZRKU-Dyn480BI.js} +1 -1
- package/workframe-ui/public/assets/{line-BFiSvu-q.js → line-CNtIbnmt.js} +1 -1
- package/workframe-ui/public/assets/{linear-D6HYZ18_.js → linear-pmSg_4uV.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-Cid5yDBQ.js → mermaid-parser.core-BX4tnojE.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-DqIiju5Z.js → mermaid.core-BtpBva5o.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-Bg7xJdsz.js → mindmap-definition-RKZ34NQL-DcMcB_69.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-1ebGt86D.js → packet-YPE3B663-KSzK9WlD.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DJ9wOBC_.js → pie-LRSECV5Y-DLOH6Mg8.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-BmK96Aus.js → pieDiagram-4H26LBE5-BhrX2aGk.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-ByUqtWPZ.js → quadrantDiagram-W4KKPZXB-B5dDjbTX.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-DN-Hkt96.js → radar-GUYGQ44K-BPdjqZiu.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-ByU2r8gb.js → requirementDiagram-4Y6WPE33-21Dq5e0N.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-elyWa0GK.js → sankeyDiagram-5OEKKPKP-CiA99dbO.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-CzZwsAiK.js → sequenceDiagram-3UESZ5HK-Bwpui7_x.js} +1 -1
- package/workframe-ui/public/assets/{src-5vgsM_dl.js → src-B3Axsi1U.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-B0O5w5lJ.js → stateDiagram-AJRCARHV-C76qwtE8.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-Dn0CDWJD.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-oyqa5w5y.js → timeline-definition-PNZ67QCA-CufInOyk.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-MCV12-eP.js → treeView-BLDUP644-C4sxh0R-.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-DiA1P9n5.js → treemap-LRROVOQU-BjV72ANV.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CO_4x7OR.js → vennDiagram-CIIHVFJN-DgsdR0Rl.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-CmQNv6Xv.js → wardley-L42UT6IY-DZ279G_t.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-CJxDkbIj.js → wardleyDiagram-YWT4CUSO-Bde25kvz.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-B02MxgzG.js → xychartDiagram-2RQKCTM6-B8VnS2Pb.js} +1 -1
- package/workframe-ui/public/favicon.svg +7 -7
- package/workframe-ui/public/icons.svg +24 -24
- package/workframe-ui/public/index.html +47 -47
- package/workframe-ui/public/manifest.webmanifest +18 -18
- package/workframe-ui/public/workframe-config.json +3 -3
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CFhr7xAe.js +0 -1
- package/workframe-ui/public/assets/channel-Dok_-T0o.js +0 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-C3wwLrTa.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-GFFSg2kf.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DKbGfT5Z.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DKbGfT5Z.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-C_PrceHe.js +0 -1
- package/workframe-ui/public/assets/index-DyO6K0TD.css +0 -1
- package/workframe-ui/public/assets/index-yvpSUs8T.js +0 -133
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-RXZRaSgT.js +0 -1
|
@@ -1,108 +1,108 @@
|
|
|
1
|
-
"""Minimal OIDC id_token verification (RS256 + JWKS) — no extra dependencies."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import base64
|
|
6
|
-
import json
|
|
7
|
-
import time
|
|
8
|
-
import urllib.error
|
|
9
|
-
import urllib.request
|
|
10
|
-
from typing import Any
|
|
11
|
-
|
|
12
|
-
from cryptography.hazmat.primitives import hashes
|
|
13
|
-
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
14
|
-
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
|
15
|
-
|
|
16
|
-
_JWKS_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
|
|
17
|
-
_JWKS_TTL = 3600.0
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _b64url_decode(raw: str) -> bytes:
|
|
21
|
-
padded = raw + "=" * (-len(raw) % 4)
|
|
22
|
-
return base64.urlsafe_b64decode(padded.encode("ascii"))
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _jwk_to_rsa(key: dict[str, Any]) -> RSAPublicKey:
|
|
26
|
-
n = int.from_bytes(_b64url_decode(str(key["n"])), "big")
|
|
27
|
-
e = int.from_bytes(_b64url_decode(str(key["e"])), "big")
|
|
28
|
-
return rsa.RSAPublicNumbers(e, n).public_key()
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _fetch_jwks(jwks_url: str) -> dict[str, Any]:
|
|
32
|
-
now = time.time()
|
|
33
|
-
cached = _JWKS_CACHE.get(jwks_url)
|
|
34
|
-
if cached and cached[0] > now:
|
|
35
|
-
return cached[1]
|
|
36
|
-
req = urllib.request.Request(jwks_url, headers={"Accept": "application/json"})
|
|
37
|
-
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
38
|
-
payload = json.loads(resp.read().decode("utf-8"))
|
|
39
|
-
if not isinstance(payload, dict):
|
|
40
|
-
raise ValueError("invalid jwks")
|
|
41
|
-
_JWKS_CACHE[jwks_url] = (now + _JWKS_TTL, payload)
|
|
42
|
-
return payload
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def verify_rs256_id_token(
|
|
46
|
-
token: str,
|
|
47
|
-
*,
|
|
48
|
-
audience: str,
|
|
49
|
-
issuer: str | tuple[str, ...] = (),
|
|
50
|
-
jwks_url: str,
|
|
51
|
-
clock_skew: int = 60,
|
|
52
|
-
) -> dict[str, Any]:
|
|
53
|
-
parts = str(token or "").split(".")
|
|
54
|
-
if len(parts) != 3:
|
|
55
|
-
raise ValueError("invalid id_token")
|
|
56
|
-
header = json.loads(_b64url_decode(parts[0]))
|
|
57
|
-
payload = json.loads(_b64url_decode(parts[1]))
|
|
58
|
-
if str(header.get("alg") or "") != "RS256":
|
|
59
|
-
raise ValueError("unsupported jwt alg")
|
|
60
|
-
kid = str(header.get("kid") or "")
|
|
61
|
-
jwks = _fetch_jwks(jwks_url)
|
|
62
|
-
keys = jwks.get("keys") if isinstance(jwks.get("keys"), list) else []
|
|
63
|
-
pub: RSAPublicKey | None = None
|
|
64
|
-
for entry in keys:
|
|
65
|
-
if not isinstance(entry, dict):
|
|
66
|
-
continue
|
|
67
|
-
if kid and str(entry.get("kid") or "") != kid:
|
|
68
|
-
continue
|
|
69
|
-
if str(entry.get("kty") or "") != "RSA":
|
|
70
|
-
continue
|
|
71
|
-
pub = _jwk_to_rsa(entry)
|
|
72
|
-
break
|
|
73
|
-
if pub is None:
|
|
74
|
-
raise ValueError("jwks key not found")
|
|
75
|
-
signing_input = f"{parts[0]}.{parts[1]}".encode("ascii")
|
|
76
|
-
signature = _b64url_decode(parts[2])
|
|
77
|
-
pub.verify(signature, signing_input, padding.PKCS1v15(), hashes.SHA256())
|
|
78
|
-
|
|
79
|
-
now = int(time.time())
|
|
80
|
-
exp = int(payload.get("exp") or 0)
|
|
81
|
-
if exp and now > exp + clock_skew:
|
|
82
|
-
raise ValueError("id_token expired")
|
|
83
|
-
iat = int(payload.get("iat") or 0)
|
|
84
|
-
if iat and now + clock_skew < iat:
|
|
85
|
-
raise ValueError("id_token not yet valid")
|
|
86
|
-
|
|
87
|
-
iss = str(payload.get("iss") or "")
|
|
88
|
-
allowed_iss = issuer or ()
|
|
89
|
-
if allowed_iss and iss not in allowed_iss:
|
|
90
|
-
raise ValueError("issuer mismatch")
|
|
91
|
-
|
|
92
|
-
aud = payload.get("aud")
|
|
93
|
-
aud_ok = audience in (aud if isinstance(aud, list) else [aud])
|
|
94
|
-
if not aud_ok:
|
|
95
|
-
raise ValueError("audience mismatch")
|
|
96
|
-
|
|
97
|
-
if not isinstance(payload, dict):
|
|
98
|
-
raise ValueError("invalid claims")
|
|
99
|
-
return payload
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def verify_google_id_token(token: str, client_id: str) -> dict[str, Any]:
|
|
103
|
-
return verify_rs256_id_token(
|
|
104
|
-
token,
|
|
105
|
-
audience=str(client_id or "").strip(),
|
|
106
|
-
issuer=("https://accounts.google.com", "accounts.google.com"),
|
|
107
|
-
jwks_url="https://www.googleapis.com/oauth2/v3/certs",
|
|
108
|
-
)
|
|
1
|
+
"""Minimal OIDC id_token verification (RS256 + JWKS) — no extra dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from cryptography.hazmat.primitives import hashes
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
|
15
|
+
|
|
16
|
+
_JWKS_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
|
|
17
|
+
_JWKS_TTL = 3600.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _b64url_decode(raw: str) -> bytes:
|
|
21
|
+
padded = raw + "=" * (-len(raw) % 4)
|
|
22
|
+
return base64.urlsafe_b64decode(padded.encode("ascii"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _jwk_to_rsa(key: dict[str, Any]) -> RSAPublicKey:
|
|
26
|
+
n = int.from_bytes(_b64url_decode(str(key["n"])), "big")
|
|
27
|
+
e = int.from_bytes(_b64url_decode(str(key["e"])), "big")
|
|
28
|
+
return rsa.RSAPublicNumbers(e, n).public_key()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fetch_jwks(jwks_url: str) -> dict[str, Any]:
|
|
32
|
+
now = time.time()
|
|
33
|
+
cached = _JWKS_CACHE.get(jwks_url)
|
|
34
|
+
if cached and cached[0] > now:
|
|
35
|
+
return cached[1]
|
|
36
|
+
req = urllib.request.Request(jwks_url, headers={"Accept": "application/json"})
|
|
37
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
38
|
+
payload = json.loads(resp.read().decode("utf-8"))
|
|
39
|
+
if not isinstance(payload, dict):
|
|
40
|
+
raise ValueError("invalid jwks")
|
|
41
|
+
_JWKS_CACHE[jwks_url] = (now + _JWKS_TTL, payload)
|
|
42
|
+
return payload
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def verify_rs256_id_token(
|
|
46
|
+
token: str,
|
|
47
|
+
*,
|
|
48
|
+
audience: str,
|
|
49
|
+
issuer: str | tuple[str, ...] = (),
|
|
50
|
+
jwks_url: str,
|
|
51
|
+
clock_skew: int = 60,
|
|
52
|
+
) -> dict[str, Any]:
|
|
53
|
+
parts = str(token or "").split(".")
|
|
54
|
+
if len(parts) != 3:
|
|
55
|
+
raise ValueError("invalid id_token")
|
|
56
|
+
header = json.loads(_b64url_decode(parts[0]))
|
|
57
|
+
payload = json.loads(_b64url_decode(parts[1]))
|
|
58
|
+
if str(header.get("alg") or "") != "RS256":
|
|
59
|
+
raise ValueError("unsupported jwt alg")
|
|
60
|
+
kid = str(header.get("kid") or "")
|
|
61
|
+
jwks = _fetch_jwks(jwks_url)
|
|
62
|
+
keys = jwks.get("keys") if isinstance(jwks.get("keys"), list) else []
|
|
63
|
+
pub: RSAPublicKey | None = None
|
|
64
|
+
for entry in keys:
|
|
65
|
+
if not isinstance(entry, dict):
|
|
66
|
+
continue
|
|
67
|
+
if kid and str(entry.get("kid") or "") != kid:
|
|
68
|
+
continue
|
|
69
|
+
if str(entry.get("kty") or "") != "RSA":
|
|
70
|
+
continue
|
|
71
|
+
pub = _jwk_to_rsa(entry)
|
|
72
|
+
break
|
|
73
|
+
if pub is None:
|
|
74
|
+
raise ValueError("jwks key not found")
|
|
75
|
+
signing_input = f"{parts[0]}.{parts[1]}".encode("ascii")
|
|
76
|
+
signature = _b64url_decode(parts[2])
|
|
77
|
+
pub.verify(signature, signing_input, padding.PKCS1v15(), hashes.SHA256())
|
|
78
|
+
|
|
79
|
+
now = int(time.time())
|
|
80
|
+
exp = int(payload.get("exp") or 0)
|
|
81
|
+
if exp and now > exp + clock_skew:
|
|
82
|
+
raise ValueError("id_token expired")
|
|
83
|
+
iat = int(payload.get("iat") or 0)
|
|
84
|
+
if iat and now + clock_skew < iat:
|
|
85
|
+
raise ValueError("id_token not yet valid")
|
|
86
|
+
|
|
87
|
+
iss = str(payload.get("iss") or "")
|
|
88
|
+
allowed_iss = issuer or ()
|
|
89
|
+
if allowed_iss and iss not in allowed_iss:
|
|
90
|
+
raise ValueError("issuer mismatch")
|
|
91
|
+
|
|
92
|
+
aud = payload.get("aud")
|
|
93
|
+
aud_ok = audience in (aud if isinstance(aud, list) else [aud])
|
|
94
|
+
if not aud_ok:
|
|
95
|
+
raise ValueError("audience mismatch")
|
|
96
|
+
|
|
97
|
+
if not isinstance(payload, dict):
|
|
98
|
+
raise ValueError("invalid claims")
|
|
99
|
+
return payload
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def verify_google_id_token(token: str, client_id: str) -> dict[str, Any]:
|
|
103
|
+
return verify_rs256_id_token(
|
|
104
|
+
token,
|
|
105
|
+
audience=str(client_id or "").strip(),
|
|
106
|
+
issuer=("https://accounts.google.com", "accounts.google.com"),
|
|
107
|
+
jwks_url="https://www.googleapis.com/oauth2/v3/certs",
|
|
108
|
+
)
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@workframe/workframe-api",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"private": true,
|
|
5
|
-
"type": "module",
|
|
6
|
-
"scripts": {
|
|
7
|
-
"dev": "python3 server.py",
|
|
8
|
-
"start": "python3 server.py",
|
|
9
|
-
"typecheck": "python3 -m py_compile server.py zk_auth.py email_sender.py",
|
|
10
|
-
"build": "python3 -m py_compile server.py zk_auth.py email_sender.py"
|
|
11
|
-
}
|
|
12
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@workframe/workframe-api",
|
|
3
|
+
"version": "0.1.7",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "python3 server.py",
|
|
8
|
+
"start": "python3 server.py",
|
|
9
|
+
"typecheck": "python3 -m py_compile server.py zk_auth.py email_sender.py",
|
|
10
|
+
"build": "python3 -m py_compile server.py zk_auth.py email_sender.py"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -1,194 +1,194 @@
|
|
|
1
|
-
"""Stack-level sign-in: Discord OAuth + Telegram Login (workframe admin config)."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import hashlib
|
|
6
|
-
import hmac
|
|
7
|
-
import json
|
|
8
|
-
import os
|
|
9
|
-
import secrets
|
|
10
|
-
import urllib.parse
|
|
11
|
-
import urllib.request
|
|
12
|
-
from typing import Any
|
|
13
|
-
from urllib.parse import urlparse
|
|
14
|
-
|
|
15
|
-
import stack_config
|
|
16
|
-
|
|
17
|
-
APP_BASE_URL = os.environ.get("APP_BASE_URL", "http://127.0.0.1:18644").rstrip("/")
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def _oauth_public(block: dict[str, Any]) -> dict[str, Any]:
|
|
21
|
-
client_id = str(block.get("client_id") or "").strip()
|
|
22
|
-
return {
|
|
23
|
-
"client_id": client_id,
|
|
24
|
-
"has_secret": bool(str(block.get("client_secret") or "").strip()),
|
|
25
|
-
"enabled": bool(client_id),
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def resolved_discord_oauth() -> dict[str, str]:
|
|
30
|
-
block = stack_config._stack_oauth_raw("discord_oauth") # ponytail: stack_config sibling module
|
|
31
|
-
return {
|
|
32
|
-
"client_id": str(block.get("client_id") or "").strip(),
|
|
33
|
-
"client_secret": str(block.get("client_secret") or "").strip(),
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def discord_oauth_configured() -> bool:
|
|
38
|
-
cfg = resolved_discord_oauth()
|
|
39
|
-
return bool(cfg.get("client_id") and cfg.get("client_secret"))
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def resolved_telegram_login() -> dict[str, str]:
|
|
43
|
-
block = stack_config._stack_oauth_raw("telegram_login")
|
|
44
|
-
return {
|
|
45
|
-
"bot_username": str(block.get("bot_username") or "").strip().lstrip("@"),
|
|
46
|
-
"bot_token": str(block.get("bot_token") or "").strip(),
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def telegram_login_configured() -> bool:
|
|
51
|
-
cfg = resolved_telegram_login()
|
|
52
|
-
return bool(cfg.get("bot_username") and cfg.get("bot_token"))
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def telegram_login_domain() -> str:
|
|
56
|
-
host = urlparse(APP_BASE_URL).hostname or ""
|
|
57
|
-
return host.strip()
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def public_discord_oauth() -> dict[str, Any]:
|
|
61
|
-
return _oauth_public(stack_config._stack_oauth_raw("discord_oauth"))
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def public_telegram_login() -> dict[str, Any]:
|
|
65
|
-
cfg = resolved_telegram_login()
|
|
66
|
-
domain = telegram_login_domain()
|
|
67
|
-
return {
|
|
68
|
-
"bot_username": cfg.get("bot_username") or "",
|
|
69
|
-
"has_token": bool(cfg.get("bot_token")),
|
|
70
|
-
"enabled": telegram_login_configured(),
|
|
71
|
-
"domain": domain,
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def discord_redirect_uri() -> str:
|
|
76
|
-
return f"{APP_BASE_URL}/api/oauth/discord/callback"
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def _discord_exchange_code(code: str, client_id: str, client_secret: str, redirect_uri: str) -> dict[str, Any]:
|
|
80
|
-
body = urllib.parse.urlencode(
|
|
81
|
-
{
|
|
82
|
-
"client_id": client_id,
|
|
83
|
-
"client_secret": client_secret,
|
|
84
|
-
"grant_type": "authorization_code",
|
|
85
|
-
"code": code,
|
|
86
|
-
"redirect_uri": redirect_uri,
|
|
87
|
-
},
|
|
88
|
-
).encode("utf-8")
|
|
89
|
-
req = urllib.request.Request(
|
|
90
|
-
"https://discord.com/api/oauth2/token",
|
|
91
|
-
data=body,
|
|
92
|
-
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
93
|
-
method="POST",
|
|
94
|
-
)
|
|
95
|
-
try:
|
|
96
|
-
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
97
|
-
raw = resp.read().decode("utf-8", errors="replace")
|
|
98
|
-
except urllib.error.HTTPError as exc:
|
|
99
|
-
raw = exc.read().decode("utf-8", errors="replace")
|
|
100
|
-
except OSError as exc:
|
|
101
|
-
return {"error": str(exc)}
|
|
102
|
-
try:
|
|
103
|
-
data = json.loads(raw)
|
|
104
|
-
except json.JSONDecodeError:
|
|
105
|
-
return {"error": raw or "discord_token_exchange_failed"}
|
|
106
|
-
return data if isinstance(data, dict) else {"error": "invalid_discord_response"}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def _discord_fetch_user(access_token: str) -> dict[str, Any]:
|
|
110
|
-
req = urllib.request.Request(
|
|
111
|
-
"https://discord.com/api/users/@me",
|
|
112
|
-
headers={"Authorization": f"Bearer {access_token}"},
|
|
113
|
-
)
|
|
114
|
-
try:
|
|
115
|
-
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
116
|
-
data = json.loads(resp.read().decode("utf-8", errors="replace"))
|
|
117
|
-
except (OSError, urllib.error.HTTPError, json.JSONDecodeError) as exc:
|
|
118
|
-
return {"error": str(exc)}
|
|
119
|
-
return data if isinstance(data, dict) else {"error": "invalid_discord_user"}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def start_discord_link(
|
|
123
|
-
*,
|
|
124
|
-
state: str,
|
|
125
|
-
redirect_url: str,
|
|
126
|
-
) -> dict[str, Any]:
|
|
127
|
-
cfg = resolved_discord_oauth()
|
|
128
|
-
client_id = str(cfg.get("client_id") or "").strip()
|
|
129
|
-
if not client_id or not str(cfg.get("client_secret") or "").strip():
|
|
130
|
-
return {
|
|
131
|
-
"ok": False,
|
|
132
|
-
"error": "discord_oauth_not_configured",
|
|
133
|
-
"message": "Workframe admin must register a Discord OAuth app under Integrations.",
|
|
134
|
-
}
|
|
135
|
-
params = urllib.parse.urlencode(
|
|
136
|
-
{
|
|
137
|
-
"client_id": client_id,
|
|
138
|
-
"redirect_uri": discord_redirect_uri(),
|
|
139
|
-
"response_type": "code",
|
|
140
|
-
"scope": "identify",
|
|
141
|
-
"state": state,
|
|
142
|
-
"prompt": "consent",
|
|
143
|
-
},
|
|
144
|
-
)
|
|
145
|
-
return {
|
|
146
|
-
"ok": True,
|
|
147
|
-
"provider": "discord",
|
|
148
|
-
"redirect_url": redirect_url or f"https://discord.com/api/oauth2/authorize?{params}",
|
|
149
|
-
"output": "",
|
|
150
|
-
"error": None,
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def complete_discord_link(code: str) -> dict[str, Any]:
|
|
155
|
-
cfg = resolved_discord_oauth()
|
|
156
|
-
client_id = str(cfg.get("client_id") or "").strip()
|
|
157
|
-
client_secret = str(cfg.get("client_secret") or "").strip()
|
|
158
|
-
if not client_id or not client_secret:
|
|
159
|
-
return {"ok": False, "error": "discord_oauth_not_configured"}
|
|
160
|
-
token_data = _discord_exchange_code(code, client_id, client_secret, discord_redirect_uri())
|
|
161
|
-
if token_data.get("error"):
|
|
162
|
-
return {"ok": False, "error": str(token_data.get("error"))}
|
|
163
|
-
access_token = str(token_data.get("access_token") or "").strip()
|
|
164
|
-
if not access_token:
|
|
165
|
-
return {"ok": False, "error": "discord_missing_access_token"}
|
|
166
|
-
user = _discord_fetch_user(access_token)
|
|
167
|
-
if user.get("error"):
|
|
168
|
-
return {"ok": False, "error": str(user.get("error"))}
|
|
169
|
-
discord_id = str(user.get("id") or "").strip()
|
|
170
|
-
if not discord_id:
|
|
171
|
-
return {"ok": False, "error": "discord_missing_user_id"}
|
|
172
|
-
return {"ok": True, "provider": "discord", "platform_ids": {"discord": discord_id}}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def verify_telegram_login(payload: dict[str, Any]) -> dict[str, Any]:
|
|
176
|
-
"""Validate Telegram Login widget payload; returns user id on success."""
|
|
177
|
-
cfg = resolved_telegram_login()
|
|
178
|
-
bot_token = str(cfg.get("bot_token") or "").strip()
|
|
179
|
-
if not bot_token:
|
|
180
|
-
return {"ok": False, "error": "telegram_login_not_configured"}
|
|
181
|
-
data = {k: str(v) for k, v in payload.items() if k != "hash" and v is not None}
|
|
182
|
-
check_hash = str(payload.get("hash") or "").strip()
|
|
183
|
-
if not check_hash or not data.get("id"):
|
|
184
|
-
return {"ok": False, "error": "telegram_invalid_payload"}
|
|
185
|
-
check_line = "\n".join(f"{k}={data[k]}" for k in sorted(data))
|
|
186
|
-
secret = hashlib.sha256(bot_token.encode("utf-8")).digest()
|
|
187
|
-
computed = hmac.new(secret, check_line.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
188
|
-
if not hmac.compare_digest(computed, check_hash):
|
|
189
|
-
return {"ok": False, "error": "telegram_hash_mismatch"}
|
|
190
|
-
return {"ok": True, "provider": "telegram", "platform_ids": {"telegram": str(data["id"]).strip()}}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
def new_oauth_state() -> str:
|
|
194
|
-
return secrets.token_urlsafe(24)
|
|
1
|
+
"""Stack-level sign-in: Discord OAuth + Telegram Login (workframe admin config)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import secrets
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urlparse
|
|
14
|
+
|
|
15
|
+
import stack_config
|
|
16
|
+
|
|
17
|
+
APP_BASE_URL = os.environ.get("APP_BASE_URL", "http://127.0.0.1:18644").rstrip("/")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _oauth_public(block: dict[str, Any]) -> dict[str, Any]:
|
|
21
|
+
client_id = str(block.get("client_id") or "").strip()
|
|
22
|
+
return {
|
|
23
|
+
"client_id": client_id,
|
|
24
|
+
"has_secret": bool(str(block.get("client_secret") or "").strip()),
|
|
25
|
+
"enabled": bool(client_id),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolved_discord_oauth() -> dict[str, str]:
|
|
30
|
+
block = stack_config._stack_oauth_raw("discord_oauth") # ponytail: stack_config sibling module
|
|
31
|
+
return {
|
|
32
|
+
"client_id": str(block.get("client_id") or "").strip(),
|
|
33
|
+
"client_secret": str(block.get("client_secret") or "").strip(),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def discord_oauth_configured() -> bool:
|
|
38
|
+
cfg = resolved_discord_oauth()
|
|
39
|
+
return bool(cfg.get("client_id") and cfg.get("client_secret"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolved_telegram_login() -> dict[str, str]:
|
|
43
|
+
block = stack_config._stack_oauth_raw("telegram_login")
|
|
44
|
+
return {
|
|
45
|
+
"bot_username": str(block.get("bot_username") or "").strip().lstrip("@"),
|
|
46
|
+
"bot_token": str(block.get("bot_token") or "").strip(),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def telegram_login_configured() -> bool:
|
|
51
|
+
cfg = resolved_telegram_login()
|
|
52
|
+
return bool(cfg.get("bot_username") and cfg.get("bot_token"))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def telegram_login_domain() -> str:
|
|
56
|
+
host = urlparse(APP_BASE_URL).hostname or ""
|
|
57
|
+
return host.strip()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def public_discord_oauth() -> dict[str, Any]:
|
|
61
|
+
return _oauth_public(stack_config._stack_oauth_raw("discord_oauth"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def public_telegram_login() -> dict[str, Any]:
|
|
65
|
+
cfg = resolved_telegram_login()
|
|
66
|
+
domain = telegram_login_domain()
|
|
67
|
+
return {
|
|
68
|
+
"bot_username": cfg.get("bot_username") or "",
|
|
69
|
+
"has_token": bool(cfg.get("bot_token")),
|
|
70
|
+
"enabled": telegram_login_configured(),
|
|
71
|
+
"domain": domain,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def discord_redirect_uri() -> str:
|
|
76
|
+
return f"{APP_BASE_URL}/api/oauth/discord/callback"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _discord_exchange_code(code: str, client_id: str, client_secret: str, redirect_uri: str) -> dict[str, Any]:
|
|
80
|
+
body = urllib.parse.urlencode(
|
|
81
|
+
{
|
|
82
|
+
"client_id": client_id,
|
|
83
|
+
"client_secret": client_secret,
|
|
84
|
+
"grant_type": "authorization_code",
|
|
85
|
+
"code": code,
|
|
86
|
+
"redirect_uri": redirect_uri,
|
|
87
|
+
},
|
|
88
|
+
).encode("utf-8")
|
|
89
|
+
req = urllib.request.Request(
|
|
90
|
+
"https://discord.com/api/oauth2/token",
|
|
91
|
+
data=body,
|
|
92
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
93
|
+
method="POST",
|
|
94
|
+
)
|
|
95
|
+
try:
|
|
96
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
97
|
+
raw = resp.read().decode("utf-8", errors="replace")
|
|
98
|
+
except urllib.error.HTTPError as exc:
|
|
99
|
+
raw = exc.read().decode("utf-8", errors="replace")
|
|
100
|
+
except OSError as exc:
|
|
101
|
+
return {"error": str(exc)}
|
|
102
|
+
try:
|
|
103
|
+
data = json.loads(raw)
|
|
104
|
+
except json.JSONDecodeError:
|
|
105
|
+
return {"error": raw or "discord_token_exchange_failed"}
|
|
106
|
+
return data if isinstance(data, dict) else {"error": "invalid_discord_response"}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _discord_fetch_user(access_token: str) -> dict[str, Any]:
|
|
110
|
+
req = urllib.request.Request(
|
|
111
|
+
"https://discord.com/api/users/@me",
|
|
112
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
113
|
+
)
|
|
114
|
+
try:
|
|
115
|
+
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
116
|
+
data = json.loads(resp.read().decode("utf-8", errors="replace"))
|
|
117
|
+
except (OSError, urllib.error.HTTPError, json.JSONDecodeError) as exc:
|
|
118
|
+
return {"error": str(exc)}
|
|
119
|
+
return data if isinstance(data, dict) else {"error": "invalid_discord_user"}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def start_discord_link(
|
|
123
|
+
*,
|
|
124
|
+
state: str,
|
|
125
|
+
redirect_url: str,
|
|
126
|
+
) -> dict[str, Any]:
|
|
127
|
+
cfg = resolved_discord_oauth()
|
|
128
|
+
client_id = str(cfg.get("client_id") or "").strip()
|
|
129
|
+
if not client_id or not str(cfg.get("client_secret") or "").strip():
|
|
130
|
+
return {
|
|
131
|
+
"ok": False,
|
|
132
|
+
"error": "discord_oauth_not_configured",
|
|
133
|
+
"message": "Workframe admin must register a Discord OAuth app under Integrations.",
|
|
134
|
+
}
|
|
135
|
+
params = urllib.parse.urlencode(
|
|
136
|
+
{
|
|
137
|
+
"client_id": client_id,
|
|
138
|
+
"redirect_uri": discord_redirect_uri(),
|
|
139
|
+
"response_type": "code",
|
|
140
|
+
"scope": "identify",
|
|
141
|
+
"state": state,
|
|
142
|
+
"prompt": "consent",
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
return {
|
|
146
|
+
"ok": True,
|
|
147
|
+
"provider": "discord",
|
|
148
|
+
"redirect_url": redirect_url or f"https://discord.com/api/oauth2/authorize?{params}",
|
|
149
|
+
"output": "",
|
|
150
|
+
"error": None,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def complete_discord_link(code: str) -> dict[str, Any]:
|
|
155
|
+
cfg = resolved_discord_oauth()
|
|
156
|
+
client_id = str(cfg.get("client_id") or "").strip()
|
|
157
|
+
client_secret = str(cfg.get("client_secret") or "").strip()
|
|
158
|
+
if not client_id or not client_secret:
|
|
159
|
+
return {"ok": False, "error": "discord_oauth_not_configured"}
|
|
160
|
+
token_data = _discord_exchange_code(code, client_id, client_secret, discord_redirect_uri())
|
|
161
|
+
if token_data.get("error"):
|
|
162
|
+
return {"ok": False, "error": str(token_data.get("error"))}
|
|
163
|
+
access_token = str(token_data.get("access_token") or "").strip()
|
|
164
|
+
if not access_token:
|
|
165
|
+
return {"ok": False, "error": "discord_missing_access_token"}
|
|
166
|
+
user = _discord_fetch_user(access_token)
|
|
167
|
+
if user.get("error"):
|
|
168
|
+
return {"ok": False, "error": str(user.get("error"))}
|
|
169
|
+
discord_id = str(user.get("id") or "").strip()
|
|
170
|
+
if not discord_id:
|
|
171
|
+
return {"ok": False, "error": "discord_missing_user_id"}
|
|
172
|
+
return {"ok": True, "provider": "discord", "platform_ids": {"discord": discord_id}}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def verify_telegram_login(payload: dict[str, Any]) -> dict[str, Any]:
|
|
176
|
+
"""Validate Telegram Login widget payload; returns user id on success."""
|
|
177
|
+
cfg = resolved_telegram_login()
|
|
178
|
+
bot_token = str(cfg.get("bot_token") or "").strip()
|
|
179
|
+
if not bot_token:
|
|
180
|
+
return {"ok": False, "error": "telegram_login_not_configured"}
|
|
181
|
+
data = {k: str(v) for k, v in payload.items() if k != "hash" and v is not None}
|
|
182
|
+
check_hash = str(payload.get("hash") or "").strip()
|
|
183
|
+
if not check_hash or not data.get("id"):
|
|
184
|
+
return {"ok": False, "error": "telegram_invalid_payload"}
|
|
185
|
+
check_line = "\n".join(f"{k}={data[k]}" for k in sorted(data))
|
|
186
|
+
secret = hashlib.sha256(bot_token.encode("utf-8")).digest()
|
|
187
|
+
computed = hmac.new(secret, check_line.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
188
|
+
if not hmac.compare_digest(computed, check_hash):
|
|
189
|
+
return {"ok": False, "error": "telegram_hash_mismatch"}
|
|
190
|
+
return {"ok": True, "provider": "telegram", "platform_ids": {"telegram": str(data["id"]).strip()}}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def new_oauth_state() -> str:
|
|
194
|
+
return secrets.token_urlsafe(24)
|