feat(auth): auth_service_account WASM primitive + remove TS JWT signer

- registry/components/auth_service_account: TinyGo impl for Google
  Service Account (JWT-bearer → token exchange) and base structure
  for AWS SigV4.
- .component-builds/auth_service_account: independent Worker at
  auth-service-account.arcrun.dev, extends wasi-shim with an
  http_request host function for the token exchange step.
- Delete cypher-executor/src/lib/wasm-executor.ts (legacy, replaced
  by component-loader WASM HTTP runner path).
- credential-injector.ts service_account branch now throws — all
  service_account recipes must route through auth-dispatcher.

Per .agents/specs/arcrun/credential-primitives-wasm Phase 2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 17:34:42 +08:00
parent 18f04448ce
commit 8c14562a2f
10 changed files with 1771 additions and 207 deletions
@@ -0,0 +1,14 @@
{
"name": "arcrun-auth-service-account",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"hono": "^4.7.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250408.0",
"typescript": "^5.4.0",
"wrangler": "^4.0.0"
}
}
+898
View File
@@ -0,0 +1,898 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
hono:
specifier: ^4.7.0
version: 4.12.14
devDependencies:
'@cloudflare/workers-types':
specifier: ^4.20250408.0
version: 4.20260420.1
typescript:
specifier: ^5.4.0
version: 5.9.3
wrangler:
specifier: ^4.0.0
version: 4.83.0(@cloudflare/workers-types@4.20260420.1)
packages:
'@cloudflare/kv-asset-handler@0.4.2':
resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==}
engines: {node: '>=18.0.0'}
'@cloudflare/unenv-preset@2.16.0':
resolution: {integrity: sha512-8ovsRpwzPoEqPUzoErAYVv8l3FMZNeBVQfJTvtzP4AgLSRGZISRfuChFxHWUQd3n6cnrwkuTGxT+2cGo8EsyYg==}
peerDependencies:
unenv: 2.0.0-rc.24
workerd: 1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0
peerDependenciesMeta:
workerd:
optional: true
'@cloudflare/workerd-darwin-64@1.20260415.1':
resolution: {integrity: sha512-dsxaKsQm3LnPGNPEdsRv09QN3Y4DqCw7kX5j6noKqbAtro2jTr95sVlYM1jUxZ5FkOl1f7SXgaKKB9t5H5Nkbg==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20260415.1':
resolution: {integrity: sha512-+JgSgVA49KyKteHRA1SnonE4Zn5Ei5zdAp5FQMxFmXI8qulZw4Hl7safXxRyK4i9sTO8gl7TFOKO5Q64VPvSDQ==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@cloudflare/workerd-linux-64@1.20260415.1':
resolution: {integrity: sha512-tU+9pwsqCy8afOVlGtiWrWQc/fedQK4SRm4KPIAt+zOiQWDxWASm6YGBUJis5c648WN80yz47qnmdDi8DQNOcA==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@cloudflare/workerd-linux-arm64@1.20260415.1':
resolution: {integrity: sha512-bR9uITnV19r5NQ14xnypi2xHXu2iQvfYV8cVgx0JouFUmWwTEEAwFVojDdssGq93VHX9hr/pi2IRUZeegbYBog==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@cloudflare/workerd-windows-64@1.20260415.1':
resolution: {integrity: sha512-4NuMLlerI0Ijua3Ir8HXQ+qyNvCUDEG5gDco5Om+sAiK6rnWiz+aGoSlbB8W16yW9QAgzCstbmXLiVknUBflfQ==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
'@cloudflare/workers-types@4.20260420.1':
resolution: {integrity: sha512-DHT9JnSn9cIiCSdL76OxW+Xvc1+ml1CWzWvgVwreoHQ+E604aeFxPPHp9X7nE+XRWm2NH4l0OgtxUI5T/nuI3g==}
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@esbuild/aix-ppc64@0.27.3':
resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.3':
resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.3':
resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.3':
resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.3':
resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.3':
resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.3':
resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.3':
resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.3':
resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.3':
resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.3':
resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.3':
resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.3':
resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.3':
resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.3':
resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.3':
resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.3':
resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.3':
resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.3':
resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.3':
resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.3':
resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.3':
resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.3':
resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.3':
resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.3':
resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.3':
resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@poppinss/colors@4.1.6':
resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==}
'@poppinss/dumper@0.6.5':
resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==}
'@poppinss/exception@1.2.3':
resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==}
'@sindresorhus/is@7.2.0':
resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==}
engines: {node: '>=18'}
'@speed-highlight/core@1.2.15':
resolution: {integrity: sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw==}
blake3-wasm@2.1.5:
resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
error-stack-parser-es@1.0.5:
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
hasBin: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
hono@4.12.14:
resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==}
engines: {node: '>=16.9.0'}
kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
miniflare@4.20260415.0:
resolution: {integrity: sha512-JoExRWN4YBI2luA5BoSMFEgi8rQWXUGzo3mtE+58VXCLV3jj/Xnk5Yeqs/IXWz8Es5GJIaq6BtsixDvAxXSIng==}
engines: {node: '>=18.0.0'}
hasBin: true
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
hasBin: true
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici@7.24.8:
resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==}
engines: {node: '>=20.18.1'}
unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
workerd@1.20260415.1:
resolution: {integrity: sha512-phyPjRnx+mQDfkhN9ENPioL1L0SdhYs4S0YmJK/xF9Oga+ykNfdSy1MHnsOj8yqnOV96zcVQMx32dJ0r3pq0jQ==}
engines: {node: '>=16'}
hasBin: true
wrangler@4.83.0:
resolution: {integrity: sha512-gw5g3LCiuAqVWxaoKY6+quE0HzAUEFb/FV3oAlNkE1ttd4XP3FiV91XDkkzUCcdqxS4WjhQvPhIDBNdhEi8P0A==}
engines: {node: '>=20.3.0'}
hasBin: true
peerDependencies:
'@cloudflare/workers-types': ^4.20260415.1
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
ws@8.18.0:
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
youch-core@0.3.3:
resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==}
youch@4.1.0-beta.10:
resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
snapshots:
'@cloudflare/kv-asset-handler@0.4.2': {}
'@cloudflare/unenv-preset@2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260415.1)':
dependencies:
unenv: 2.0.0-rc.24
optionalDependencies:
workerd: 1.20260415.1
'@cloudflare/workerd-darwin-64@1.20260415.1':
optional: true
'@cloudflare/workerd-darwin-arm64@1.20260415.1':
optional: true
'@cloudflare/workerd-linux-64@1.20260415.1':
optional: true
'@cloudflare/workerd-linux-arm64@1.20260415.1':
optional: true
'@cloudflare/workerd-windows-64@1.20260415.1':
optional: true
'@cloudflare/workers-types@4.20260420.1': {}
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@emnapi/runtime@1.10.0':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.27.3':
optional: true
'@esbuild/android-arm64@0.27.3':
optional: true
'@esbuild/android-arm@0.27.3':
optional: true
'@esbuild/android-x64@0.27.3':
optional: true
'@esbuild/darwin-arm64@0.27.3':
optional: true
'@esbuild/darwin-x64@0.27.3':
optional: true
'@esbuild/freebsd-arm64@0.27.3':
optional: true
'@esbuild/freebsd-x64@0.27.3':
optional: true
'@esbuild/linux-arm64@0.27.3':
optional: true
'@esbuild/linux-arm@0.27.3':
optional: true
'@esbuild/linux-ia32@0.27.3':
optional: true
'@esbuild/linux-loong64@0.27.3':
optional: true
'@esbuild/linux-mips64el@0.27.3':
optional: true
'@esbuild/linux-ppc64@0.27.3':
optional: true
'@esbuild/linux-riscv64@0.27.3':
optional: true
'@esbuild/linux-s390x@0.27.3':
optional: true
'@esbuild/linux-x64@0.27.3':
optional: true
'@esbuild/netbsd-arm64@0.27.3':
optional: true
'@esbuild/netbsd-x64@0.27.3':
optional: true
'@esbuild/openbsd-arm64@0.27.3':
optional: true
'@esbuild/openbsd-x64@0.27.3':
optional: true
'@esbuild/openharmony-arm64@0.27.3':
optional: true
'@esbuild/sunos-x64@0.27.3':
optional: true
'@esbuild/win32-arm64@0.27.3':
optional: true
'@esbuild/win32-ia32@0.27.3':
optional: true
'@esbuild/win32-x64@0.27.3':
optional: true
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.10.0
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.9':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@poppinss/colors@4.1.6':
dependencies:
kleur: 4.1.5
'@poppinss/dumper@0.6.5':
dependencies:
'@poppinss/colors': 4.1.6
'@sindresorhus/is': 7.2.0
supports-color: 10.2.2
'@poppinss/exception@1.2.3': {}
'@sindresorhus/is@7.2.0': {}
'@speed-highlight/core@1.2.15': {}
blake3-wasm@2.1.5: {}
cookie@1.1.1: {}
detect-libc@2.1.2: {}
error-stack-parser-es@1.0.5: {}
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
'@esbuild/android-arm': 0.27.3
'@esbuild/android-arm64': 0.27.3
'@esbuild/android-x64': 0.27.3
'@esbuild/darwin-arm64': 0.27.3
'@esbuild/darwin-x64': 0.27.3
'@esbuild/freebsd-arm64': 0.27.3
'@esbuild/freebsd-x64': 0.27.3
'@esbuild/linux-arm': 0.27.3
'@esbuild/linux-arm64': 0.27.3
'@esbuild/linux-ia32': 0.27.3
'@esbuild/linux-loong64': 0.27.3
'@esbuild/linux-mips64el': 0.27.3
'@esbuild/linux-ppc64': 0.27.3
'@esbuild/linux-riscv64': 0.27.3
'@esbuild/linux-s390x': 0.27.3
'@esbuild/linux-x64': 0.27.3
'@esbuild/netbsd-arm64': 0.27.3
'@esbuild/netbsd-x64': 0.27.3
'@esbuild/openbsd-arm64': 0.27.3
'@esbuild/openbsd-x64': 0.27.3
'@esbuild/openharmony-arm64': 0.27.3
'@esbuild/sunos-x64': 0.27.3
'@esbuild/win32-arm64': 0.27.3
'@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3
fsevents@2.3.3:
optional: true
hono@4.12.14: {}
kleur@4.1.5: {}
miniflare@4.20260415.0:
dependencies:
'@cspotcode/source-map-support': 0.8.1
sharp: 0.34.5
undici: 7.24.8
workerd: 1.20260415.1
ws: 8.18.0
youch: 4.1.0-beta.10
transitivePeerDependencies:
- bufferutil
- utf-8-validate
path-to-regexp@6.3.0: {}
pathe@2.0.3: {}
semver@7.7.4: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.7.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
supports-color@10.2.2: {}
tslib@2.8.1:
optional: true
typescript@5.9.3: {}
undici@7.24.8: {}
unenv@2.0.0-rc.24:
dependencies:
pathe: 2.0.3
workerd@1.20260415.1:
optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20260415.1
'@cloudflare/workerd-darwin-arm64': 1.20260415.1
'@cloudflare/workerd-linux-64': 1.20260415.1
'@cloudflare/workerd-linux-arm64': 1.20260415.1
'@cloudflare/workerd-windows-64': 1.20260415.1
wrangler@4.83.0(@cloudflare/workers-types@4.20260420.1):
dependencies:
'@cloudflare/kv-asset-handler': 0.4.2
'@cloudflare/unenv-preset': 2.16.0(unenv@2.0.0-rc.24)(workerd@1.20260415.1)
blake3-wasm: 2.1.5
esbuild: 0.27.3
miniflare: 4.20260415.0
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.24
workerd: 1.20260415.1
optionalDependencies:
'@cloudflare/workers-types': 4.20260420.1
fsevents: 2.3.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
ws@8.18.0: {}
youch-core@0.3.3:
dependencies:
'@poppinss/exception': 1.2.3
error-stack-parser-es: 1.0.5
youch@4.1.0-beta.10:
dependencies:
'@poppinss/colors': 4.1.6
'@poppinss/dumper': 0.6.5
'@speed-highlight/core': 1.2.15
cookie: 1.1.1
youch-core: 0.3.3
@@ -0,0 +1,115 @@
/**
* arcrun auth_service_account Worker
*
* POST / → JSON input {action, api_key, service} → WASM (WASI preview1 stdin/stdout) → JSON output
*
* 方案 A:直接 import cypher-executor/src/lib/wasi-shim.ts 的 shim + host function factory,
* 確保 AES-GCM 解密 / RS256 sign 邏輯只存在於一個檔案(rule 02 §2.2)。
*
* 這個 Worker 比 auth_static_key 多一個 host function:http_request(token exchange 用)。
* http_request 不是 crypto,不受 rule 02 §2.2 約束;在此檔內聚合提供即可。
*
* 安全邊界:
* - api_key 經 stdin 傳進 WASM,同時綁到 host function 的 kv_get 做越權檢查
* - ENCRYPTION_KEY 只存在於 host function 的 closure 中,不會進入 WASM 記憶體
* - private key 只以 PKCS8 bytes 傳給 crypto_sign_rs256 host function,decrypt 後 plaintext 不離開 WASM
*/
import componentWasm from '../component.wasm' assert { type: 'webassembly' };
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import {
createWasiShim,
createArcrunHostFunctions,
type ArcrunHostEnv,
type WasiHostFunctions,
} from '../../../cypher-executor/src/lib/wasi-shim';
type Env = ArcrunHostEnv;
const app = new Hono<{ Bindings: Env }>();
app.use('*', cors());
app.get('/', (c) => c.json({ ok: true, component: 'auth_service_account' }));
app.post('/', async (c) => {
let input: Record<string, unknown>;
try {
input = await c.req.json();
} catch {
return c.json({ success: false, error: 'request body must be JSON' }, 400);
}
const apiKey = typeof input.api_key === 'string' ? input.api_key : '';
if (!apiKey) {
return c.json({ success: false, error: 'api_key 必填' }, 400);
}
try {
const result = await runWasm(c.env, apiKey, input);
return c.json(result);
} catch (e) {
return c.json(
{ success: false, error: e instanceof Error ? e.message : String(e) },
500,
);
}
});
export default app;
// ── WASM runner ──────────────────────────────────────────────────────────────
async function runWasm(env: Env, apiKey: string, input: unknown): Promise<unknown> {
const stdinData = JSON.stringify(input);
const baseHost = createArcrunHostFunctions(env, apiKey);
const hostFunctions: WasiHostFunctions = {
...baseHost,
http_request: async (url, method, headersJson, body) => {
const headers: Record<string, string> = {};
if (headersJson) {
try {
const parsed = JSON.parse(headersJson);
if (parsed && typeof parsed === 'object') {
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === 'string') headers[k] = v;
}
}
} catch {
// 忽略 header parse 錯誤,當作沒 header
}
}
const init: RequestInit = { method, headers };
if (body && method.toUpperCase() !== 'GET' && method.toUpperCase() !== 'HEAD') {
init.body = body;
}
const res = await fetch(url, init);
// WASM 端(main.go)直接 json.Unmarshal 回傳內容找 access_token,
// 因此只回傳 response body 原文。非 2xx 也回原文,讓 WASM 從 {error, error_description} 判斷
return await res.text();
},
};
const shim = createWasiShim(stdinData, hostFunctions);
const instance = await WebAssembly.instantiate(
componentWasm as WebAssembly.Module,
shim.imports,
);
shim.setMemory(instance.exports.memory as WebAssembly.Memory);
const start = (instance.exports._start ?? instance.exports.main) as () => void;
if (typeof start !== 'function') {
throw new Error('WASM missing _start or main export');
}
try {
start();
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
}
const stdout = shim.getStdout().trim();
if (!stdout) throw new Error('WASM component produced no output');
return JSON.parse(stdout);
}
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
}
}
@@ -0,0 +1,23 @@
name = "arcrun-auth-service-account"
main = "src/index.ts"
compatibility_date = "2025-02-19"
compatibility_flags = ["nodejs_compat"]
[vars]
COMPONENT_ID = "auth_service_account"
[[routes]]
pattern = "auth-service-account.arcrun.dev/*"
zone_name = "arcrun.dev"
# 與 cypher-executor/wrangler.toml 同一組 KV namespace
[[kv_namespaces]]
binding = "CREDENTIALS_KV"
id = "e7f4320f88d343f187e35e3543dd74c9"
[[kv_namespaces]]
binding = "RECIPES"
id = "9cf9db905c6241f78503199e58b2ffe0"
# ENCRYPTION_KEY 透過 wrangler secret set 設定
# wrangler secret put ENCRYPTION_KEY
@@ -1,93 +1,53 @@
/**
* Credential Injector
*
* 在 WASM 零件執行前,從 CREDENTIALS_KV 讀取加密 credential
* AES-GCM 解密後注入到 input 的對應欄位(inject_as)。
* 執行順序:
* 1. 檢查是否有對應的 auth recipeauth_recipe:{componentId} in RECIPES KV
* → 有:走 auth recipe 路徑(支援 static_key, service_account
* → 無:走舊有 flat injection 路徑(向後相容)
*
* 用戶的 workflow.yaml config 中不需要也不應該包含明文 token。
* Auth Recipe 路徑:
* - static_key:展開 inject.header/query/body 的 {{secret.KEY}} 模板
* - service_accountJWT signing → token exchange → 展開 {{runtime.access_token}}
* - 注入結果以 _auth_headers / _auth_query / _auth_body 攜帶,不污染業務欄位
*
* 設計原則
* - contract.yaml 的 credentials_required 宣告需要哪個 credential
* - CREDENTIALS_KV 存放 AES-GCM 加密後的 credentialkey = cred:{name}
* - 注入發生在 WASM 執行前,不修改 WEBHOOKS KV 中儲存的 workflow 定義
* 舊有路徑(向後相容)
* - 從 RECIPES KV 讀取 credentials_required(動態 recipe
* - 或從 BUILTIN_CREDENTIALS_MAP(內建清單
* - 解密後以 inject_as 欄位名稱直接注入 context
*/
import type { Bindings } from '../types';
import { resolveRecipe, resolveAuthRecipe } from '../routes/recipes';
import type { AuthRecipeDefinition } from '../routes/recipes';
export interface CredentialRequirement {
key: string; // CREDENTIALS_KV 的 key(如 gmail_token
type: string; // token 類型(如 google_oauth
description: string; // 說明
key: string; // CREDENTIALS_KV 的 credential 名稱(如 gmail_token
inject_as: string; // 注入到 input 的欄位名稱(如 access_token
}
/**
* 讀取並解析零件的 contract.yaml(從 WASM_BUCKET
* 回傳 credentials_required 陣列,若不存在則回傳空陣列
*/
async function loadCredentialsRequired(
componentId: string,
wasmBucket: R2Bucket,
): Promise<CredentialRequirement[]> {
const contractKey = `${componentId}/component.contract.yaml`;
const obj = await wasmBucket.get(contractKey);
if (!obj) return [];
/** 內建 API recipe 的 credentials_required(對應 component-loader 的 BUILTIN_API_RECIPES*/
const BUILTIN_CREDENTIALS_MAP: Record<string, CredentialRequirement[]> = {
gmail: [{ key: 'gmail_token', inject_as: 'access_token' }],
google_sheets: [{ key: 'google_oauth', inject_as: 'access_token' }],
telegram: [{ key: 'telegram_bot_token', inject_as: 'bot_token' }],
line_notify: [{ key: 'line_token', inject_as: 'token' }],
};
const yamlText = await obj.text();
return parseCredentialsRequired(yamlText);
}
// ── AES-GCM 解密 ──────────────────────────────────────────────────────────────
/**
* 從 YAML 文字解析 credentials_required 欄位
* 使用簡單的正規表達式解析(避免引入 js-yaml 依賴)
*/
function parseCredentialsRequired(yaml: string): CredentialRequirement[] {
const credsSection = yaml.match(/credentials_required:\s*([\s\S]*?)(?=\n\w|\n#|$)/);
if (!credsSection) return [];
const items: CredentialRequirement[] = [];
const blockText = credsSection[1];
// 解析 " - key: xxx" 開頭的項目
const itemMatches = blockText.split(/\n - /).slice(1);
for (const item of itemMatches) {
const key = item.match(/key:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
const type = item.match(/type:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
const description = item.match(/description:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim() ?? '';
const inject_as = item.match(/inject_as:\s*["']?([^"'\n]+)["']?/)?.[1]?.trim();
if (key && type && inject_as) {
items.push({ key, type, description, inject_as });
}
}
return items;
}
/**
* AES-GCM 解密(與 credentials Worker 的加密邏輯對應)
* CREDENTIALS_KV 儲存格式:{ encrypted: base64, iv: base64 }
*/
async function decryptCredential(encryptedJson: string, encryptionKey: string): Promise<string> {
const { encrypted, iv } = JSON.parse(encryptedJson) as { encrypted: string; iv: string };
// 將 hex-encoded 256-bit key 轉為 CryptoKey
const keyBytes = hexToUint8Array(encryptionKey);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBytes,
{ name: 'AES-GCM' },
false,
['decrypt'],
'raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt'],
);
const ivBytes = base64ToUint8Array(iv);
const cipherBytes = base64ToUint8Array(encrypted);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: ivBytes },
{ name: 'AES-GCM', iv: base64ToUint8Array(iv) },
cryptoKey,
cipherBytes,
base64ToUint8Array(encrypted),
);
return new TextDecoder().decode(decrypted);
@@ -95,53 +55,168 @@ async function decryptCredential(encryptedJson: string, encryptionKey: string):
function hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
}
for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
return bytes;
}
function base64ToUint8Array(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
// ── 解密所有 required_secrets → { key: decryptedValue } ──────────────────────
async function decryptSecrets(
recipe: AuthRecipeDefinition,
apiKey: string,
env: Bindings,
): Promise<Record<string, string>> {
const result: Record<string, string> = {};
for (const req of recipe.required_secrets) {
if (req.optional) continue;
const kvKey = `${apiKey}:cred:${req.key}`;
const record = await env.CREDENTIALS_KV.get(kvKey);
if (!record) {
throw new Error(
`缺少 credential${req.key}${req.label}\n` +
`修復步驟:\n` +
` 1. 在 credentials.yaml 加入 ${req.key}: "your-value"\n` +
` 2. 執行:acr creds push`,
);
}
result[req.key] = await decryptCredential(record, env.ENCRYPTION_KEY);
}
return result;
}
// ── Template 展開:{{secret.KEY}} 和 {{runtime.KEY}} ─────────────────────────
function interpolateTemplate(
template: string,
secrets: Record<string, string>,
runtime: Record<string, string>,
): string {
return template.replace(/\{\{(secret|runtime)\.(\w+)\}\}/g, (_, ns, key) => {
if (ns === 'secret') return secrets[key] ?? '';
if (ns === 'runtime') return runtime[key] ?? '';
return '';
});
}
function interpolateRecord(
record: Record<string, string>,
secrets: Record<string, string>,
runtime: Record<string, string>,
): Record<string, string> {
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(record)) {
result[k] = interpolateTemplate(v, secrets, runtime);
}
return result;
}
// ── Auth Recipe 注入(新路徑)────────────────────────────────────────────────
async function injectFromAuthRecipe(
recipe: AuthRecipeDefinition,
input: Record<string, unknown>,
env: Bindings,
apiKey: string,
): Promise<Record<string, unknown>> {
// 解密所有 required_secrets
const secrets = await decryptSecrets(recipe, apiKey, env);
// runtime tokenservice_account 路徑已改走 auth-dispatcher → auth_service_account WASM;
// 這條 TS fallback 只處理 static_key (runtime 為空即可),service_account 永遠不會走到這裡
const runtime: Record<string, string> = {};
if (recipe.primitive === 'service_account') {
throw new Error(
`service_account primitive 應由 auth-dispatcher → auth_service_account WASM 處理,` +
`不應進到 credential-injector TS fallback (service=${recipe.service})`,
);
}
// 展開 inject 模板
const authHeaders = recipe.inject.header
? interpolateRecord(recipe.inject.header, secrets, runtime)
: {};
const authQuery = recipe.inject.query
? interpolateRecord(recipe.inject.query, secrets, runtime)
: {};
const authBody = recipe.inject.body
? interpolateRecord(recipe.inject.body, secrets, runtime)
: {};
return {
...input,
_auth_headers: authHeaders,
_auth_query: authQuery,
_auth_body: authBody,
};
}
// ── 舊有路徑:flat injection(向後相容)──────────────────────────────────────
async function loadCredentialsRequired(
componentId: string,
env: Bindings,
): Promise<CredentialRequirement[]> {
const recipe = await resolveRecipe(componentId, env.RECIPES);
if (recipe?.credentials_required?.length) {
return recipe.credentials_required;
}
return BUILTIN_CREDENTIALS_MAP[componentId] ?? [];
}
// ── 主入口 ────────────────────────────────────────────────────────────────────
/**
* 執行 credential 注入
* 執行 credential 注入
*
* @param componentId - 零件 canonical_id
* @param input - 節點的原始 input(來自 workflow config
* @param componentId - 零件 canonical_id 或 hash
* @param input - 節點的 merged context
* @param env - Cloudflare Worker Bindings
* @returns 注入 credential 後的 input
*
* @throws 若 credential 不存在,拋出結構化錯誤(含 key 名稱與修復步驟)
* @param apiKey - 用戶的 API Keyak_前綴),作為 KV namespace
*/
export async function injectCredentials(
componentId: string,
input: Record<string, unknown>,
env: Bindings,
apiKey?: string,
): Promise<Record<string, unknown>> {
// 讀取 contract.yaml 中的 credentials_required
const required = await loadCredentialsRequired(componentId, env.WASM_BUCKET);
// 沒有 api_key → local 模式,略過
if (!apiKey) return input;
// ── 新路徑:auth recipe ──
const authRecipe = await resolveAuthRecipe(componentId, env.RECIPES);
if (authRecipe) {
return injectFromAuthRecipe(authRecipe, input, env, apiKey);
}
// ── 舊路徑:flat injection(向後相容)──
const required = await loadCredentialsRequired(componentId, env);
if (required.length === 0) return input;
const enriched = { ...input };
for (const cred of required) {
const kvKey = `cred:${cred.key}`;
const kvKey = `${apiKey}:cred:${cred.key}`;
const record = await env.CREDENTIALS_KV.get(kvKey);
if (!record) {
throw new Error(
`缺少 credential${cred.key}${cred.description}\n` +
`缺少 credential${cred.key}\n` +
`修復步驟:\n` +
` 1. 在 credentials.yaml 中加入\n` +
` ${cred.key}: "your-${cred.type}-token"\n` +
` 2. 執行:acr creds push`
` 1. 在 credentials.yaml 中加入 ${cred.key}: "your-token"\n` +
` 2. 執行:acr creds push`,
);
}
@@ -151,7 +226,7 @@ export async function injectCredentials(
} catch (e) {
throw new Error(
`credential "${cred.key}" 解密失敗:${e instanceof Error ? e.message : String(e)}\n` +
`修復步驟:重新執行 acr creds push 上傳正確的 credential`
`修復步驟:重新執行 acr creds push。`,
);
}
}
-119
View File
@@ -1,119 +0,0 @@
/**
* Tier 1 WASM 執行器
* 從 R2 載入 .wasm,透過 WASI preview1 shim 執行,stdin/stdout JSON I/O。
*
* 快取策略:WebAssembly.Module 快取於 Worker 記憶體(跨請求共享),
* 避免重複編譯。每次執行只重新 instantiate。
*
* Requirements: 3.1, 3.3, 6.6
*/
import { createWasiShim, type WasiHostFunctions } from './wasi-shim';
// Worker 記憶體快取:r2Key → WebAssembly.Module
const moduleCache = new Map<string, WebAssembly.Module>();
export interface WasmExecutorOptions {
/** R2 Bucket binding */
bucket: R2Bucket;
/** R2 物件鍵(例:components/validate_json/v1.wasm */
r2Key: string;
/** 逾時上限(ms),對應 contract.constraints.max_cold_start_ms */
timeoutMs?: number;
/** 可選的 host function 注入(讓 .wasm 呼叫外部服務) */
hostFunctions?: WasiHostFunctions;
}
export interface WasmExecuteResult {
output: unknown;
stdout: string;
stderr: string;
duration_ms: number;
}
/**
* 執行 WASM 零件
* @param input - 傳入零件的 JSON 物件(寫入 stdin
* @param options - 執行選項
*/
export async function executeWasm(
input: unknown,
options: WasmExecutorOptions,
): Promise<WasmExecuteResult> {
const { bucket, r2Key, timeoutMs = 50, hostFunctions } = options;
// ...(其餘不變)
const start = Date.now();
// 1. 取得或編譯 WebAssembly.Module(快取)
let wasmModule = moduleCache.get(r2Key);
if (!wasmModule) {
const obj = await bucket.get(r2Key);
if (!obj) throw new Error(`WASM 零件不存在於 R2${r2Key}`);
const arrayBuffer = await obj.arrayBuffer();
wasmModule = await WebAssembly.compile(arrayBuffer);
moduleCache.set(r2Key, wasmModule);
}
// 2. 建立 WASI shim,注入 stdin 與可選的 host functions
const stdinJson = JSON.stringify(input);
const shim = createWasiShim(stdinJson, hostFunctions);
// 3. instantiate(每次執行都重新 instantiate,shim 狀態是獨立的)
const instance = await WebAssembly.instantiate(wasmModule, shim.imports);
// 4. 注入 memoryWASI fd_read/fd_write 需要存取 memory
const memory = instance.exports.memory as WebAssembly.Memory | undefined;
if (memory) shim.setMemory(memory);
// 5. 執行(帶逾時)
const exports = instance.exports as Record<string, unknown>;
const entryFn = (exports._start ?? exports.main) as (() => void) | undefined;
if (typeof entryFn !== 'function') {
throw new Error(`WASM 零件缺少 _start 或 main exportr2Key: ${r2Key}`);
}
const runWithTimeout = new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`WASM 執行逾時(>${timeoutMs}ms):${r2Key}`));
}, timeoutMs);
try {
entryFn();
clearTimeout(timer);
resolve();
} catch (e) {
clearTimeout(timer);
// proc_exit(0) 拋出 "wasm exit: 0",視為正常結束
if (e instanceof Error && e.message === 'wasm exit: 0') {
resolve();
} else {
reject(e);
}
}
});
await runWithTimeout;
// 6. 讀取 stdoutJSON.parse
const stdout = shim.getStdout().trim();
const stderr = shim.getStderr().trim();
const duration_ms = Date.now() - start;
if (!stdout) {
throw new Error(`WASM 零件沒有輸出(stdout 為空):${r2Key}`);
}
let output: unknown;
try {
output = JSON.parse(stdout);
} catch {
throw new Error(`WASM 零件輸出不是合法 JSON${stdout.slice(0, 200)}`);
}
return { output, stdout, stderr, duration_ms };
}
/** 清除 Module 快取(測試用) */
export function clearModuleCache(): void {
moduleCache.clear();
}
@@ -0,0 +1,70 @@
canonical_id: "auth_service_account"
display_name: "Auth Primitive — Service Account (Google JWT)"
category: "auth"
version: "v1"
wasi_target: "preview1"
stability: "floating"
runtime_compat:
- "cf-workers"
- "workerd"
- "wazero"
constraints:
max_size_kb: 2048
max_cold_start_ms: 100
no_network_syscall: false
no_filesystem_syscall: true
io_model: "stdin_stdout_json"
input_schema:
type: object
required: [action, api_key, service]
properties:
action:
type: string
enum: [authenticate]
description: 目前僅支援 authenticate
api_key:
type: string
description: 租戶識別(ak_ 前綴),用來組 {api_key}:cred:{name} KV key
service:
type: string
description: auth recipe 名稱,對應 auth_recipe:{service} 的 KV 記錄
request:
type: object
description: (保留)下游零件的 HTTP request 上下文
output_schema:
type: object
properties:
success:
type: boolean
auth_headers:
type: object
additionalProperties:
type: string
auth_query:
type: object
additionalProperties:
type: string
auth_body:
type: object
additionalProperties:
type: string
runtime:
type: object
description: 包含 access_token(token exchange 後取得)
properties:
access_token:
type: string
gherkin_tests:
- scenario: "缺少 api_key"
given: '{"action":"authenticate","service":"google_sheets_sa"}'
then_contains: '{"success":false'
- scenario: "找不到 auth recipe"
given: '{"action":"authenticate","api_key":"ak_nonexistent","service":"nonexistent"}'
then_contains: '{"success":false'
tags: [auth, credential, primitive, service_account, google]
description: "Service Account auth primitive (Google JWT 方案)。讀取 auth_recipe + 解密 service_account_json → 解析 PEM private key → 組 JWT → crypto_sign_rs256 (host function) → token exchange endpoint → 取 access_token → 展開 {{runtime.access_token}} 模板。透過 host function crypto_sign_rs256,private key 僅以 PKCS8 bytes 傳給 host,解密後 plaintext 不離開 WASM。"
config_example: |
auth_step:
component: "auth_service_account"
action: "authenticate"
service: "google_sheets_sa"
@@ -0,0 +1,3 @@
module component
go 1.21
@@ -0,0 +1,474 @@
// auth_service_account — Google Service Account JWT auth primitive
//
// 讀取 auth_recipe:{service} + 解密 service_account_json + 組 JWT + RS256 簽章(透過 host)
// + token exchange → access_token + 展開 {{runtime.access_token}}。
//
// Host imports:
// - u6u.kv_get — 讀 RECIPES + CREDENTIALS_KV
// - u6u.crypto_decrypt — AES-GCM 解密 service account JSON
// - u6u.crypto_sign_rs256 — RSASSA-PKCS1-v1_5 + SHA-256 (PKCS8 private key)
// - u6u.http_request — POST token exchange endpoint
//
//go:build tinygo
package main
import (
"encoding/base64"
"encoding/json"
"io"
"net/url"
"os"
"strings"
"time"
"unsafe"
)
// ── host function 宣告 ───────────────────────────────────────────────────────
//go:wasmimport u6u kv_get
func hostKvGet(
keyPtr uintptr, keyLen uint32,
outPtr uintptr, outLenPtr uintptr,
) uint32
//go:wasmimport u6u crypto_decrypt
func hostCryptoDecrypt(
encPtr uintptr, encLen uint32,
ivPtr uintptr, ivLen uint32,
outPtr uintptr, outLenPtr uintptr,
) uint32
//go:wasmimport u6u crypto_sign_rs256
func hostCryptoSignRS256(
dataPtr uintptr, dataLen uint32,
pkcs8Ptr uintptr, pkcs8Len uint32,
outPtr uintptr, outLenPtr uintptr,
) uint32
//go:wasmimport u6u http_request
func hostHttpRequest(
urlPtr uintptr, urlLen uint32,
methodPtr uintptr, methodLen uint32,
headersPtr uintptr, headersLen uint32,
bodyPtr uintptr, bodyLen uint32,
outPtr uintptr, outLenPtr uintptr,
) uint32
// ── 型別 ─────────────────────────────────────────────────────────────────────
type Input struct {
Action string `json:"action"`
APIKey string `json:"api_key"`
Service string `json:"service"`
Request json.RawMessage `json:"request,omitempty"`
}
type SecretRequirement struct {
Key string `json:"key"`
Label string `json:"label"`
Optional bool `json:"optional,omitempty"`
}
type AuthInjectSpec struct {
Header map[string]string `json:"header,omitempty"`
Query map[string]string `json:"query,omitempty"`
Body map[string]string `json:"body,omitempty"`
}
type TokenExchange struct {
Endpoint string `json:"endpoint"`
Scopes []string `json:"scopes"`
}
type AuthRecipe struct {
Kind string `json:"kind"`
Service string `json:"service"`
Primitive string `json:"primitive"`
ServiceAccountKind string `json:"service_account_kind,omitempty"`
TokenExchange *TokenExchange `json:"token_exchange,omitempty"`
RequiredSecrets []SecretRequirement `json:"required_secrets"`
Inject AuthInjectSpec `json:"inject"`
}
type EncryptedRecord struct {
Encrypted string `json:"encrypted"`
IV string `json:"iv"`
}
type ServiceAccountJSON struct {
ClientEmail string `json:"client_email"`
PrivateKey string `json:"private_key"`
}
type JWTHeader struct {
Alg string `json:"alg"`
Typ string `json:"typ"`
}
type JWTPayload struct {
Iss string `json:"iss"`
Sub string `json:"sub"`
Aud string `json:"aud"`
Scope string `json:"scope"`
Iat int64 `json:"iat"`
Exp int64 `json:"exp"`
}
// ── main ─────────────────────────────────────────────────────────────────────
func main() {
raw, err := io.ReadAll(os.Stdin)
if err != nil {
writeError("failed to read stdin: " + err.Error())
return
}
var input Input
if err := json.Unmarshal(raw, &input); err != nil {
writeError("invalid input JSON: " + err.Error())
return
}
if input.APIKey == "" {
writeError("api_key 必填")
return
}
if input.Service == "" {
writeError("service 必填")
return
}
if input.Action != "" && input.Action != "authenticate" {
writeError("auth_service_account 僅支援 action=authenticate")
return
}
// 1. 讀 auth recipe
recipeJSON, status := kvGet("auth_recipe:" + input.Service)
if status == 2 {
writeError("找不到 auth recipe: " + input.Service)
return
}
if status != 0 {
writeError("kv_get 失敗(auth_recipe)")
return
}
var recipe AuthRecipe
if err := json.Unmarshal([]byte(recipeJSON), &recipe); err != nil {
writeError("auth recipe JSON 解析失敗: " + err.Error())
return
}
if recipe.Primitive != "service_account" {
writeError("auth recipe " + input.Service + " 的 primitive 不是 service_account (是 " + recipe.Primitive + ")")
return
}
if recipe.ServiceAccountKind != "google_jwt" {
writeError("auth recipe " + input.Service + " 的 service_account_kind 必須是 google_jwt,實際: " + recipe.ServiceAccountKind)
return
}
if recipe.TokenExchange == nil || recipe.TokenExchange.Endpoint == "" {
writeError("auth recipe " + input.Service + " 缺少 token_exchange.endpoint")
return
}
if len(recipe.RequiredSecrets) == 0 {
writeError("auth recipe " + input.Service + " 缺少 required_secrets[0](SA JSON)")
return
}
// 2. 解密 service account JSON (慣例:required_secrets[0] 是 SA JSON)
saReq := recipe.RequiredSecrets[0]
kvKey := input.APIKey + ":cred:" + saReq.Key
encJSON, s := kvGet(kvKey)
if s == 2 {
writeError("缺少 credential: " + saReq.Key + " (" + saReq.Label + ")。修復: 編輯 credentials.yaml 後執行 acr creds push")
return
}
if s != 0 {
writeError("kv_get 失敗(credential " + saReq.Key + ")")
return
}
var rec EncryptedRecord
if err := json.Unmarshal([]byte(encJSON), &rec); err != nil {
writeError("credential " + saReq.Key + " 格式錯誤: " + err.Error())
return
}
saJSONStr, ok := cryptoDecrypt(rec.Encrypted, rec.IV)
if !ok {
writeError("credential " + saReq.Key + " 解密失敗")
return
}
// 3. 解析 service account JSON
var sa ServiceAccountJSON
if err := json.Unmarshal([]byte(saJSONStr), &sa); err != nil {
writeError("service account JSON 格式錯誤: " + err.Error())
return
}
if sa.ClientEmail == "" || sa.PrivateKey == "" {
writeError("service account JSON 缺少 client_email 或 private_key")
return
}
// 4. PEM → PKCS8 bytes (去 header/footer + base64 decode)
pkcs8, err := pemToPkcs8(sa.PrivateKey)
if err != nil {
writeError("解析 service account private key 失敗: " + err.Error())
return
}
// 5. 組 JWT header + payload (base64url-encoded)
now := time.Now().Unix()
header := JWTHeader{Alg: "RS256", Typ: "JWT"}
payload := JWTPayload{
Iss: sa.ClientEmail,
Sub: sa.ClientEmail,
Aud: recipe.TokenExchange.Endpoint,
Scope: strings.Join(recipe.TokenExchange.Scopes, " "),
Iat: now,
Exp: now + 3600,
}
headerBytes, _ := json.Marshal(header)
payloadBytes, _ := json.Marshal(payload)
signingInput := base64.RawURLEncoding.EncodeToString(headerBytes) + "." +
base64.RawURLEncoding.EncodeToString(payloadBytes)
// 6. 呼叫 host 簽章 (RSASSA-PKCS1-v1_5 + SHA-256)
signature, ok := cryptoSignRS256([]byte(signingInput), pkcs8)
if !ok {
writeError("JWT 簽章失敗(host function crypto_sign_rs256 回傳錯誤)")
return
}
jwt := signingInput + "." + base64.RawURLEncoding.EncodeToString(signature)
// 7. token exchange:POST form-urlencoded 到 token_exchange.endpoint
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
form.Set("assertion", jwt)
formBody := form.Encode()
headersJSON := `{"Content-Type":"application/x-www-form-urlencoded"}`
respStr, ok := httpRequest(recipe.TokenExchange.Endpoint, "POST", headersJSON, formBody)
if !ok {
writeError("token exchange HTTP 失敗")
return
}
var tokenResp struct {
AccessToken string `json:"access_token"`
Error string `json:"error"`
ErrorDesc string `json:"error_description"`
}
if err := json.Unmarshal([]byte(respStr), &tokenResp); err != nil {
writeError("token exchange 回應解析失敗: " + err.Error() + " (raw: " + respStr + ")")
return
}
if tokenResp.AccessToken == "" {
errMsg := tokenResp.Error
if tokenResp.ErrorDesc != "" {
errMsg += ": " + tokenResp.ErrorDesc
}
if errMsg == "" {
errMsg = "access_token 為空 (raw: " + respStr + ")"
}
writeError("token exchange 失敗: " + errMsg)
return
}
// 8. 展開模板 (service_account 不用 secret.*,只用 runtime.access_token)
secrets := map[string]string{}
runtime := map[string]string{"access_token": tokenResp.AccessToken}
authHeaders := interpolateRecord(recipe.Inject.Header, secrets, runtime)
authQuery := interpolateRecord(recipe.Inject.Query, secrets, runtime)
authBody := interpolateRecord(recipe.Inject.Body, secrets, runtime)
out, _ := json.Marshal(map[string]interface{}{
"success": true,
"auth_headers": authHeaders,
"auth_query": authQuery,
"auth_body": authBody,
"runtime": runtime,
})
os.Stdout.Write(out)
}
// ── helpers ──────────────────────────────────────────────────────────────────
func writeError(msg string) {
out, _ := json.Marshal(map[string]interface{}{
"success": false,
"error": msg,
"auth_headers": map[string]string{},
"auth_query": map[string]string{},
"auth_body": map[string]string{},
})
os.Stdout.Write(out)
}
// pemToPkcs8 從 PEM 取出 base64 body 再 decode 成 bytes。
// 支援 "BEGIN PRIVATE KEY" / "BEGIN RSA PRIVATE KEY"(SA JSON 幾乎都是前者)。
func pemToPkcs8(pem string) ([]byte, error) {
// 移除所有 BEGIN/END 行與空白
lines := strings.Split(pem, "\n")
var b strings.Builder
for _, line := range lines {
l := strings.TrimSpace(line)
if l == "" {
continue
}
if strings.HasPrefix(l, "-----BEGIN") || strings.HasPrefix(l, "-----END") {
continue
}
b.WriteString(l)
}
cleaned := strings.ReplaceAll(b.String(), "\\n", "") // 防呆:JSON-escaped newline
return base64.StdEncoding.DecodeString(cleaned)
}
// kvGet 呼叫 host function,回傳 (value, status)。status: 0=成功 1=錯誤 2=找不到
func kvGet(key string) (string, uint32) {
keyBytes := []byte(key)
outBuf := make([]byte, 65536)
var outLen uint32
status := hostKvGet(
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
if status != 0 {
return "", status
}
return string(outBuf[:outLen]), 0
}
func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
encBytes := []byte(encB64)
ivBytes := []byte(ivB64)
outBuf := make([]byte, 65536)
var outLen uint32
if len(encBytes) == 0 || len(ivBytes) == 0 {
return "", false
}
status := hostCryptoDecrypt(
uintptr(unsafe.Pointer(&encBytes[0])), uint32(len(encBytes)),
uintptr(unsafe.Pointer(&ivBytes[0])), uint32(len(ivBytes)),
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
if status != 0 {
return "", false
}
return string(outBuf[:outLen]), true
}
// cryptoSignRS256 呼叫 host,回傳簽章 bytes
func cryptoSignRS256(data, pkcs8 []byte) ([]byte, bool) {
if len(data) == 0 || len(pkcs8) == 0 {
return nil, false
}
outBuf := make([]byte, 1024) // RSA-2048 簽章 = 256 bytes,1KB 綽綽有餘
var outLen uint32
status := hostCryptoSignRS256(
uintptr(unsafe.Pointer(&data[0])), uint32(len(data)),
uintptr(unsafe.Pointer(&pkcs8[0])), uint32(len(pkcs8)),
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
if status != 0 {
return nil, false
}
return outBuf[:outLen], true
}
// httpRequest 呼叫 host,回傳 response body 字串(host 側把 status + body 串好)
func httpRequest(url, method, headersJSON, body string) (string, bool) {
urlBytes := []byte(url)
methodBytes := []byte(method)
headersBytes := []byte(headersJSON)
bodyBytes := []byte(body)
if len(urlBytes) == 0 {
return "", false
}
outBuf := make([]byte, 65536)
var outLen uint32
// bodyBytes 可能為空(GET),host function 允許 len=0
var bodyPtr uintptr
if len(bodyBytes) > 0 {
bodyPtr = uintptr(unsafe.Pointer(&bodyBytes[0]))
}
var headersPtr uintptr
if len(headersBytes) > 0 {
headersPtr = uintptr(unsafe.Pointer(&headersBytes[0]))
}
status := hostHttpRequest(
uintptr(unsafe.Pointer(&urlBytes[0])), uint32(len(urlBytes)),
uintptr(unsafe.Pointer(&methodBytes[0])), uint32(len(methodBytes)),
headersPtr, uint32(len(headersBytes)),
bodyPtr, uint32(len(bodyBytes)),
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
if status != 0 {
return "", false
}
return string(outBuf[:outLen]), true
}
// interpolateTemplate 展開 {{secret.X}} 與 {{runtime.X}}。未知 key 展開為空字串。
// 其他 namespace 的 {{...}} 原樣保留。
func interpolateTemplate(template string, secrets, runtime map[string]string) string {
var b strings.Builder
b.Grow(len(template))
i := 0
for i < len(template) {
start := strings.Index(template[i:], "{{")
if start < 0 {
b.WriteString(template[i:])
break
}
b.WriteString(template[i : i+start])
openIdx := i + start
closeRel := strings.Index(template[openIdx+2:], "}}")
if closeRel < 0 {
b.WriteString(template[openIdx:])
break
}
inner := template[openIdx+2 : openIdx+2+closeRel]
advance := openIdx + 2 + closeRel + 2
switch {
case strings.HasPrefix(inner, "secret."):
key := inner[len("secret."):]
b.WriteString(secrets[key])
case strings.HasPrefix(inner, "runtime."):
key := inner[len("runtime."):]
b.WriteString(runtime[key])
default:
b.WriteString(template[openIdx:advance])
}
i = advance
}
return b.String()
}
func interpolateRecord(
record map[string]string,
secrets, runtime map[string]string,
) map[string]string {
if record == nil {
return map[string]string{}
}
result := make(map[string]string, len(record))
for k, v := range record {
result[k] = interpolateTemplate(v, secrets, runtime)
}
return result
}