From 17ba02a3a81065080a753c2bc6bcbb912b29f34b Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Tue, 30 Jun 2026 16:37:59 +0100 Subject: [PATCH 1/2] added edge-sso template --- .github/build-rust/action.yaml | 32 + .github/setup-harbor-pull/action.yaml | 23 + .github/setup-node/action.yaml | 47 + .../workflows/publish-edge-sso-templates.yml | 263 + .github/workflows/publish-html2md.yml | 72 + .gitignore | 5 + AGENTS.md | 42 + CLAUDE.md | 63 + Cargo.toml | 14 - README.md | 31 +- {.cargo => edge-sso/.cargo}/config.toml | 0 edge-sso/.gitignore | 45 + edge-sso/AGENTS.md | 38 + edge-sso/CLAUDE.md | 69 + edge-sso/Cargo.lock | 889 +++ edge-sso/Cargo.toml | 18 + edge-sso/LICENSE | 201 + edge-sso/README.md | 74 + edge-sso/context/INDEX.md | 46 + edge-sso/context/architecture/auth-modes.md | 44 + edge-sso/context/architecture/overview.md | 125 + .../architecture/runtime-constraints.md | 144 + edge-sso/context/architecture/saml-flow.md | 143 + edge-sso/context/architecture/security.md | 128 + edge-sso/context/design/integration.md | 121 + edge-sso/context/development/testing.md | 79 + edge-sso/core/federation/app.tsx | 148 + edge-sso/core/federation/chooser.tsx | 237 + edge-sso/core/federation/config.ts | 108 + edge-sso/core/federation/error.tsx | 24 + edge-sso/core/federation/index.ts | 13 + edge-sso/core/federation/providers/common.ts | 117 + .../core/federation/providers/facebook.ts | 243 + edge-sso/core/federation/providers/github.ts | 268 + .../core/federation/providers/google-oidc.ts | 26 + edge-sso/core/federation/providers/google.ts | 235 + .../federation/providers/microsoft-oidc.ts | 69 + .../core/federation/providers/microsoft.ts | 251 + .../core/federation/providers/registry.ts | 92 + edge-sso/core/federation/saml/claims.ts | 102 + edge-sso/core/federation/saml/config.ts | 21 + edge-sso/core/federation/saml/handlers.ts | 138 + edge-sso/core/federation/saml/request.ts | 96 + edge-sso/core/federation/saml/response.ts | 330 ++ edge-sso/core/federation/util/redirect.ts | 48 + edge-sso/core/filter/Cargo.toml | 30 + edge-sso/core/filter/src/lib.rs | 401 ++ edge-sso/core/package.json | 34 + edge-sso/core/session/key.ts | 20 + edge-sso/core/session/token.ts | 172 + edge-sso/core/tsconfig.json | 9 + edge-sso/core/util/bytes.ts | 39 + edge-sso/core/util/env.ts | 16 + edge-sso/package.json | 11 + edge-sso/pnpm-lock.yaml | 4942 +++++++++++++++++ edge-sso/pnpm-workspace.yaml | 21 + edge-sso/scripts/gen-ec-keypair.mjs | 51 + .../templates/cookie/auth-app/.env.example | 171 + edge-sso/templates/cookie/auth-app/.env.test | 37 + .../templates/cookie/auth-app/package.json | 35 + .../templates/cookie/auth-app/registry.json | 329 ++ edge-sso/templates/cookie/auth-app/server.tsx | 7 + .../cookie/auth-app/tests/_cookies.ts | 11 + .../cookie/auth-app/tests/app.test.ts | 788 +++ .../auth-app/tests/e2e/facebook.test.ts | 289 + .../cookie/auth-app/tests/e2e/github.test.ts | 297 + .../cookie/auth-app/tests/e2e/google.test.ts | 336 ++ .../auth-app/tests/e2e/microsoft.test.ts | 314 ++ .../cookie/auth-app/tests/non-cookie-smoke.ts | 101 + .../cookie/auth-app/tests/tsconfig.json | 15 + .../tests/unit/default-claims.test.ts | 38 + .../auth-app/tests/unit/google-oidc.test.ts | 138 + .../tests/unit/microsoft-oidc.test.ts | 228 + .../auth-app/tests/unit/oauth-common.test.ts | 117 + .../tests/unit/providers-registry.test.ts | 94 + .../auth-app/tests/unit/redirect.test.ts | 83 + .../auth-app/tests/unit/saml-claims.test.ts | 120 + .../auth-app/tests/unit/saml-response.test.ts | 382 ++ .../auth-app/tests/unit/session-token.test.ts | 207 + .../templates/cookie/auth-app/tsconfig.json | 9 + .../templates/cookie/cdn-filter/.env.example | 61 + .../templates/cookie/cdn-filter/Cargo.toml | 12 + .../templates/cookie/cdn-filter/package.json | 15 + .../templates/cookie/cdn-filter/registry.json | 49 + .../templates/cookie/cdn-filter/src/lib.rs | 10 + .../cookie/cdn-filter/tests/filter-suite.ts | 866 +++ .../cookie/cdn-filter/tests/filter.test.ts | 118 + .../templates/gate-only/auth-app/.env.example | 168 + .../templates/gate-only/auth-app/package.json | 26 + .../gate-only/auth-app/registry.json | 315 ++ .../templates/gate-only/auth-app/server.tsx | 9 + .../gate-only/auth-app/tests/app.test.ts | 6 + .../gate-only/auth-app/tsconfig.json | 9 + .../gate-only/cdn-filter/.env.example | 61 + .../templates/gate-only/cdn-filter/Cargo.toml | 13 + .../gate-only/cdn-filter/package.json | 9 + .../gate-only/cdn-filter/registry.json | 49 + .../templates/gate-only/cdn-filter/src/lib.rs | 10 + .../templates/header/auth-app/.env.example | 169 + .../templates/header/auth-app/package.json | 26 + .../templates/header/auth-app/registry.json | 315 ++ edge-sso/templates/header/auth-app/server.tsx | 16 + .../header/auth-app/tests/app.test.ts | 8 + .../templates/header/auth-app/tsconfig.json | 9 + .../templates/header/cdn-filter/.env.example | 66 + .../templates/header/cdn-filter/Cargo.toml | 12 + .../templates/header/cdn-filter/package.json | 15 + .../templates/header/cdn-filter/registry.json | 49 + .../templates/header/cdn-filter/src/lib.rs | 10 + .../header/cdn-filter/tests/filter.test.ts | 301 + edge-sso/tsconfig.base.json | 13 + html2md/.cargo/config.toml | 2 + html2md/Cargo.lock | 432 ++ html2md/Cargo.toml | 2 +- html2md/LICENSE | 201 + html2md/registry.json | 6 + 116 files changed, 18647 insertions(+), 25 deletions(-) create mode 100644 .github/build-rust/action.yaml create mode 100644 .github/setup-harbor-pull/action.yaml create mode 100644 .github/setup-node/action.yaml create mode 100644 .github/workflows/publish-edge-sso-templates.yml create mode 100644 .github/workflows/publish-html2md.yml create mode 100644 AGENTS.md create mode 100644 CLAUDE.md delete mode 100644 Cargo.toml rename {.cargo => edge-sso/.cargo}/config.toml (100%) mode change 100644 => 100755 create mode 100644 edge-sso/.gitignore create mode 100644 edge-sso/AGENTS.md create mode 100644 edge-sso/CLAUDE.md create mode 100644 edge-sso/Cargo.lock create mode 100644 edge-sso/Cargo.toml create mode 100644 edge-sso/LICENSE create mode 100644 edge-sso/README.md create mode 100644 edge-sso/context/INDEX.md create mode 100644 edge-sso/context/architecture/auth-modes.md create mode 100644 edge-sso/context/architecture/overview.md create mode 100644 edge-sso/context/architecture/runtime-constraints.md create mode 100644 edge-sso/context/architecture/saml-flow.md create mode 100644 edge-sso/context/architecture/security.md create mode 100644 edge-sso/context/design/integration.md create mode 100644 edge-sso/context/development/testing.md create mode 100644 edge-sso/core/federation/app.tsx create mode 100644 edge-sso/core/federation/chooser.tsx create mode 100644 edge-sso/core/federation/config.ts create mode 100644 edge-sso/core/federation/error.tsx create mode 100644 edge-sso/core/federation/index.ts create mode 100644 edge-sso/core/federation/providers/common.ts create mode 100644 edge-sso/core/federation/providers/facebook.ts create mode 100644 edge-sso/core/federation/providers/github.ts create mode 100644 edge-sso/core/federation/providers/google-oidc.ts create mode 100644 edge-sso/core/federation/providers/google.ts create mode 100644 edge-sso/core/federation/providers/microsoft-oidc.ts create mode 100644 edge-sso/core/federation/providers/microsoft.ts create mode 100644 edge-sso/core/federation/providers/registry.ts create mode 100644 edge-sso/core/federation/saml/claims.ts create mode 100644 edge-sso/core/federation/saml/config.ts create mode 100644 edge-sso/core/federation/saml/handlers.ts create mode 100644 edge-sso/core/federation/saml/request.ts create mode 100644 edge-sso/core/federation/saml/response.ts create mode 100644 edge-sso/core/federation/util/redirect.ts create mode 100644 edge-sso/core/filter/Cargo.toml create mode 100644 edge-sso/core/filter/src/lib.rs create mode 100644 edge-sso/core/package.json create mode 100644 edge-sso/core/session/key.ts create mode 100644 edge-sso/core/session/token.ts create mode 100644 edge-sso/core/tsconfig.json create mode 100644 edge-sso/core/util/bytes.ts create mode 100644 edge-sso/core/util/env.ts create mode 100644 edge-sso/package.json create mode 100644 edge-sso/pnpm-lock.yaml create mode 100644 edge-sso/pnpm-workspace.yaml create mode 100644 edge-sso/scripts/gen-ec-keypair.mjs create mode 100644 edge-sso/templates/cookie/auth-app/.env.example create mode 100644 edge-sso/templates/cookie/auth-app/.env.test create mode 100644 edge-sso/templates/cookie/auth-app/package.json create mode 100644 edge-sso/templates/cookie/auth-app/registry.json create mode 100644 edge-sso/templates/cookie/auth-app/server.tsx create mode 100644 edge-sso/templates/cookie/auth-app/tests/_cookies.ts create mode 100644 edge-sso/templates/cookie/auth-app/tests/app.test.ts create mode 100644 edge-sso/templates/cookie/auth-app/tests/e2e/facebook.test.ts create mode 100644 edge-sso/templates/cookie/auth-app/tests/e2e/github.test.ts create mode 100644 edge-sso/templates/cookie/auth-app/tests/e2e/google.test.ts create mode 100644 edge-sso/templates/cookie/auth-app/tests/e2e/microsoft.test.ts create mode 100644 edge-sso/templates/cookie/auth-app/tests/non-cookie-smoke.ts create mode 100644 edge-sso/templates/cookie/auth-app/tests/tsconfig.json create mode 100644 edge-sso/templates/cookie/auth-app/tests/unit/default-claims.test.ts create mode 100644 edge-sso/templates/cookie/auth-app/tests/unit/google-oidc.test.ts create mode 100644 edge-sso/templates/cookie/auth-app/tests/unit/microsoft-oidc.test.ts create mode 100644 edge-sso/templates/cookie/auth-app/tests/unit/oauth-common.test.ts create mode 100644 edge-sso/templates/cookie/auth-app/tests/unit/providers-registry.test.ts create mode 100644 edge-sso/templates/cookie/auth-app/tests/unit/redirect.test.ts create mode 100644 edge-sso/templates/cookie/auth-app/tests/unit/saml-claims.test.ts create mode 100644 edge-sso/templates/cookie/auth-app/tests/unit/saml-response.test.ts create mode 100644 edge-sso/templates/cookie/auth-app/tests/unit/session-token.test.ts create mode 100644 edge-sso/templates/cookie/auth-app/tsconfig.json create mode 100644 edge-sso/templates/cookie/cdn-filter/.env.example create mode 100644 edge-sso/templates/cookie/cdn-filter/Cargo.toml create mode 100644 edge-sso/templates/cookie/cdn-filter/package.json create mode 100644 edge-sso/templates/cookie/cdn-filter/registry.json create mode 100644 edge-sso/templates/cookie/cdn-filter/src/lib.rs create mode 100644 edge-sso/templates/cookie/cdn-filter/tests/filter-suite.ts create mode 100644 edge-sso/templates/cookie/cdn-filter/tests/filter.test.ts create mode 100644 edge-sso/templates/gate-only/auth-app/.env.example create mode 100644 edge-sso/templates/gate-only/auth-app/package.json create mode 100644 edge-sso/templates/gate-only/auth-app/registry.json create mode 100644 edge-sso/templates/gate-only/auth-app/server.tsx create mode 100644 edge-sso/templates/gate-only/auth-app/tests/app.test.ts create mode 100644 edge-sso/templates/gate-only/auth-app/tsconfig.json create mode 100644 edge-sso/templates/gate-only/cdn-filter/.env.example create mode 100644 edge-sso/templates/gate-only/cdn-filter/Cargo.toml create mode 100644 edge-sso/templates/gate-only/cdn-filter/package.json create mode 100644 edge-sso/templates/gate-only/cdn-filter/registry.json create mode 100644 edge-sso/templates/gate-only/cdn-filter/src/lib.rs create mode 100644 edge-sso/templates/header/auth-app/.env.example create mode 100644 edge-sso/templates/header/auth-app/package.json create mode 100644 edge-sso/templates/header/auth-app/registry.json create mode 100644 edge-sso/templates/header/auth-app/server.tsx create mode 100644 edge-sso/templates/header/auth-app/tests/app.test.ts create mode 100644 edge-sso/templates/header/auth-app/tsconfig.json create mode 100644 edge-sso/templates/header/cdn-filter/.env.example create mode 100644 edge-sso/templates/header/cdn-filter/Cargo.toml create mode 100644 edge-sso/templates/header/cdn-filter/package.json create mode 100644 edge-sso/templates/header/cdn-filter/registry.json create mode 100644 edge-sso/templates/header/cdn-filter/src/lib.rs create mode 100644 edge-sso/templates/header/cdn-filter/tests/filter.test.ts create mode 100644 edge-sso/tsconfig.base.json create mode 100644 html2md/.cargo/config.toml create mode 100644 html2md/Cargo.lock create mode 100644 html2md/LICENSE create mode 100644 html2md/registry.json diff --git a/.github/build-rust/action.yaml b/.github/build-rust/action.yaml new file mode 100644 index 0000000..0e233da --- /dev/null +++ b/.github/build-rust/action.yaml @@ -0,0 +1,32 @@ +name: 'Build Rust WASM' +description: 'Builds a Rust cdylib package to wasm32-wasip1 using the FastEdge compiler image.' +inputs: + rust_package: + description: 'Cargo package name (hyphens allowed; output filename uses underscores)' + required: true + working_directory: + description: 'Directory containing the Cargo workspace (relative to repo root)' + required: true + output_path: + description: 'Destination path for the built .wasm file (relative to repo root)' + required: true + +runs: + using: 'composite' + steps: + - name: Build ${{ inputs.rust_package }} + shell: bash + run: | + docker run --rm \ + -v ${{ github.workspace }}:/usr/src/myapp \ + -w /usr/src/myapp/${{ inputs.working_directory }} \ + harbor.p.gc.onl/fastedge/fastedge-apps-compiler:0.0.1 \ + cargo build --release --target wasm32-wasip1 --package ${{ inputs.rust_package }} + + - name: Copy wasm to output path + shell: bash + run: | + WASM_FILE=$(echo "${{ inputs.rust_package }}" | tr '-' '_').wasm + mkdir -p $(dirname "${{ inputs.output_path }}") + cp "${{ inputs.working_directory }}/target/wasm32-wasip1/release/$WASM_FILE" \ + "${{ inputs.output_path }}" diff --git a/.github/setup-harbor-pull/action.yaml b/.github/setup-harbor-pull/action.yaml new file mode 100644 index 0000000..6f867ae --- /dev/null +++ b/.github/setup-harbor-pull/action.yaml @@ -0,0 +1,23 @@ +name: 'Login to Harbor and Pull Compiler' +description: 'Logs in to the Harbor registry and pulls the FastEdge compiler image.' +inputs: + harbor_login: + description: 'Harbor registry username' + required: true + harbor_password: + description: 'Harbor registry password' + required: true + +runs: + using: 'composite' + steps: + - name: Login to Harbor + uses: docker/login-action@v3 + with: + registry: harbor.p.gc.onl + username: ${{ inputs.harbor_login }} + password: ${{ inputs.harbor_password }} + + - name: Pull FastEdge compiler image + shell: bash + run: docker pull harbor.p.gc.onl/fastedge/fastedge-apps-compiler:0.0.1 diff --git a/.github/setup-node/action.yaml b/.github/setup-node/action.yaml new file mode 100644 index 0000000..17cc92e --- /dev/null +++ b/.github/setup-node/action.yaml @@ -0,0 +1,47 @@ +name: 'Setup Node.js' +description: 'Sets up Node.js environment and installs dependencies.' +inputs: + node_version: + description: 'Node.js version to use, e.g. 20.x' + required: false + default: '24.12.0' + pnpm_version: + description: 'pnpm version to use, e.g. 10.x' + required: false + default: '10.x' + working_directory: + description: 'Directory to run pnpm install in (relative to repo root)' + required: false + default: '.' + +runs: + using: 'composite' + steps: + - name: Use Node.js ${{ inputs.node_version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node_version }} + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: ${{ inputs.pnpm_version }} + + - name: Get pnpm store location + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies (pnpm) + shell: bash + working-directory: ${{ inputs.working_directory }} + run: | + pnpm install --frozen-lockfile diff --git a/.github/workflows/publish-edge-sso-templates.yml b/.github/workflows/publish-edge-sso-templates.yml new file mode 100644 index 0000000..fd7c9a6 --- /dev/null +++ b/.github/workflows/publish-edge-sso-templates.yml @@ -0,0 +1,263 @@ +name: Publish edge-sso Templates + +on: + workflow_dispatch: + push: + branches: + - feature/saml-app + paths: + - 'edge-sso/**' + +jobs: + build_and_publish: + name: Build and publish edge-sso templates + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Import credentials from Vault + uses: hashicorp/vault-action@v3 + id: creds + with: + url: https://puppet-vault.gc.onl + token: ${{ secrets.VAULT_TOKEN }} + secrets: | + secret/project_fastedge/harbor-robot-account username | HARBOR_LOGIN ; + secret/project_fastedge/harbor-robot-account password | HARBOR_PASSWORD ; + secret/project_fastedge/gcore_api/preprod/token token | PREPROD_API_KEY ; + secret/project_fastedge/gcore_api/preprod/hostname url | PREPROD_API_URL ; + secret/project_fastedge/gcore_api/prod/token token | PROD_API_KEY ; + secret/project_fastedge/gcore_api/prod/hostname url | PROD_API_URL ; + + - name: Login to Harbor and pull compiler + uses: ./.github/setup-harbor-pull + with: + harbor_login: ${{ steps.creds.outputs.HARBOR_LOGIN }} + harbor_password: ${{ steps.creds.outputs.HARBOR_PASSWORD }} + + - name: Setup Node.js and install dependencies + uses: ./.github/setup-node + with: + working_directory: edge-sso + + # --- Unit tests (no wasm needed — fast gate before any build) --- + + - name: Unit tests + working-directory: edge-sso + run: pnpm -C templates/cookie/auth-app test:unit + + # --- TypeScript auth-apps --- + + - name: Build TS WASM + working-directory: edge-sso + run: pnpm -r --filter '!*cdn-filter' run --if-present build + + # --- Rust CDN filters --- + + - name: Build gate-only CDN filter + uses: ./.github/build-rust + with: + rust_package: sso-guard-gate-only + working_directory: edge-sso + output_path: edge-sso/templates/gate-only/cdn-filter/wasm/gate_sso_guard.wasm + + - name: Build cookie CDN filter + uses: ./.github/build-rust + with: + rust_package: sso-guard-cookie + working_directory: edge-sso + output_path: edge-sso/templates/cookie/cdn-filter/wasm/cookie_sso_guard.wasm + + - name: Build header CDN filter + uses: ./.github/build-rust + with: + rust_package: sso-guard-header + working_directory: edge-sso + output_path: edge-sso/templates/header/cdn-filter/wasm/headers_sso_guard.wasm + + # --- Integration, filter, and e2e tests (require wasm) --- + + - name: Integration tests + working-directory: edge-sso + run: | + pnpm -C templates/cookie/auth-app test + pnpm -C templates/gate-only/auth-app test + pnpm -C templates/header/auth-app test + + - name: Filter tests + working-directory: edge-sso + run: | + pnpm -C templates/cookie/cdn-filter test + pnpm -C templates/header/cdn-filter test + + - name: E2E tests + working-directory: edge-sso + run: pnpm -C templates/cookie/auth-app test:e2e + + # --- Deploy --- + # preprod: always (workflow_dispatch or push to main) + # prod: push to main only + + - name: Read sso-gate-only-auth registry + id: gate-only-auth + run: | + echo "descr=$(jq -r '.description' edge-sso/templates/gate-only/auth-app/registry.json)" >> "$GITHUB_OUTPUT" + echo "params=$(jq -c '.params' edge-sso/templates/gate-only/auth-app/registry.json)" >> "$GITHUB_OUTPUT" + + - name: Deploy sso-gate-only-auth to preprod + uses: gcore-github-actions/fastedge/deploy-template@v1 + with: + api_key: ${{ steps.creds.outputs.PREPROD_API_KEY }} + api_url: ${{ steps.creds.outputs.PREPROD_API_URL }} + wasm_file: edge-sso/templates/gate-only/auth-app/wasm/gate-auth-app.wasm + template_name: sso-gate-only-auth + short_descr: ${{ steps.gate-only-auth.outputs.descr }} + params: ${{ steps.gate-only-auth.outputs.params }} + + - name: Deploy sso-gate-only-auth to prod + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: gcore-github-actions/fastedge/deploy-template@v1 + with: + api_key: ${{ steps.creds.outputs.PROD_API_KEY }} + api_url: ${{ steps.creds.outputs.PROD_API_URL }} + wasm_file: edge-sso/templates/gate-only/auth-app/wasm/gate-auth-app.wasm + template_name: sso-gate-only-auth + short_descr: ${{ steps.gate-only-auth.outputs.descr }} + params: ${{ steps.gate-only-auth.outputs.params }} + + - name: Read sso-gate-only-filter registry + id: gate-only-filter + run: | + echo "descr=$(jq -r '.description' edge-sso/templates/gate-only/cdn-filter/registry.json)" >> "$GITHUB_OUTPUT" + echo "params=$(jq -c '.params' edge-sso/templates/gate-only/cdn-filter/registry.json)" >> "$GITHUB_OUTPUT" + + - name: Deploy sso-gate-only-filter to preprod + uses: gcore-github-actions/fastedge/deploy-template@v1 + with: + api_key: ${{ steps.creds.outputs.PREPROD_API_KEY }} + api_url: ${{ steps.creds.outputs.PREPROD_API_URL }} + wasm_file: edge-sso/templates/gate-only/cdn-filter/wasm/gate_sso_guard.wasm + template_name: sso-gate-only-filter + short_descr: ${{ steps.gate-only-filter.outputs.descr }} + params: ${{ steps.gate-only-filter.outputs.params }} + + - name: Deploy sso-gate-only-filter to prod + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: gcore-github-actions/fastedge/deploy-template@v1 + with: + api_key: ${{ steps.creds.outputs.PROD_API_KEY }} + api_url: ${{ steps.creds.outputs.PROD_API_URL }} + wasm_file: edge-sso/templates/gate-only/cdn-filter/wasm/gate_sso_guard.wasm + template_name: sso-gate-only-filter + short_descr: ${{ steps.gate-only-filter.outputs.descr }} + params: ${{ steps.gate-only-filter.outputs.params }} + + - name: Read sso-cookie-auth registry + id: cookie-auth + run: | + echo "descr=$(jq -r '.description' edge-sso/templates/cookie/auth-app/registry.json)" >> "$GITHUB_OUTPUT" + echo "params=$(jq -c '.params' edge-sso/templates/cookie/auth-app/registry.json)" >> "$GITHUB_OUTPUT" + + - name: Deploy sso-cookie-auth to preprod + uses: gcore-github-actions/fastedge/deploy-template@v1 + with: + api_key: ${{ steps.creds.outputs.PREPROD_API_KEY }} + api_url: ${{ steps.creds.outputs.PREPROD_API_URL }} + wasm_file: edge-sso/templates/cookie/auth-app/wasm/cookie-auth-app.wasm + template_name: sso-cookie-auth + short_descr: ${{ steps.cookie-auth.outputs.descr }} + params: ${{ steps.cookie-auth.outputs.params }} + + - name: Deploy sso-cookie-auth to prod + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: gcore-github-actions/fastedge/deploy-template@v1 + with: + api_key: ${{ steps.creds.outputs.PROD_API_KEY }} + api_url: ${{ steps.creds.outputs.PROD_API_URL }} + wasm_file: edge-sso/templates/cookie/auth-app/wasm/cookie-auth-app.wasm + template_name: sso-cookie-auth + short_descr: ${{ steps.cookie-auth.outputs.descr }} + params: ${{ steps.cookie-auth.outputs.params }} + + - name: Read sso-cookie-filter registry + id: cookie-filter + run: | + echo "descr=$(jq -r '.description' edge-sso/templates/cookie/cdn-filter/registry.json)" >> "$GITHUB_OUTPUT" + echo "params=$(jq -c '.params' edge-sso/templates/cookie/cdn-filter/registry.json)" >> "$GITHUB_OUTPUT" + + - name: Deploy sso-cookie-filter to preprod + uses: gcore-github-actions/fastedge/deploy-template@v1 + with: + api_key: ${{ steps.creds.outputs.PREPROD_API_KEY }} + api_url: ${{ steps.creds.outputs.PREPROD_API_URL }} + wasm_file: edge-sso/templates/cookie/cdn-filter/wasm/cookie_sso_guard.wasm + template_name: sso-cookie-filter + short_descr: ${{ steps.cookie-filter.outputs.descr }} + params: ${{ steps.cookie-filter.outputs.params }} + + - name: Deploy sso-cookie-filter to prod + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: gcore-github-actions/fastedge/deploy-template@v1 + with: + api_key: ${{ steps.creds.outputs.PROD_API_KEY }} + api_url: ${{ steps.creds.outputs.PROD_API_URL }} + wasm_file: edge-sso/templates/cookie/cdn-filter/wasm/cookie_sso_guard.wasm + template_name: sso-cookie-filter + short_descr: ${{ steps.cookie-filter.outputs.descr }} + params: ${{ steps.cookie-filter.outputs.params }} + + - name: Read sso-header-auth registry + id: header-auth + run: | + echo "descr=$(jq -r '.description' edge-sso/templates/header/auth-app/registry.json)" >> "$GITHUB_OUTPUT" + echo "params=$(jq -c '.params' edge-sso/templates/header/auth-app/registry.json)" >> "$GITHUB_OUTPUT" + + - name: Deploy sso-header-auth to preprod + uses: gcore-github-actions/fastedge/deploy-template@v1 + with: + api_key: ${{ steps.creds.outputs.PREPROD_API_KEY }} + api_url: ${{ steps.creds.outputs.PREPROD_API_URL }} + wasm_file: edge-sso/templates/header/auth-app/wasm/headers-auth-app.wasm + template_name: sso-header-auth + short_descr: ${{ steps.header-auth.outputs.descr }} + params: ${{ steps.header-auth.outputs.params }} + + - name: Deploy sso-header-auth to prod + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: gcore-github-actions/fastedge/deploy-template@v1 + with: + api_key: ${{ steps.creds.outputs.PROD_API_KEY }} + api_url: ${{ steps.creds.outputs.PROD_API_URL }} + wasm_file: edge-sso/templates/header/auth-app/wasm/headers-auth-app.wasm + template_name: sso-header-auth + short_descr: ${{ steps.header-auth.outputs.descr }} + params: ${{ steps.header-auth.outputs.params }} + + - name: Read sso-header-filter registry + id: header-filter + run: | + echo "descr=$(jq -r '.description' edge-sso/templates/header/cdn-filter/registry.json)" >> "$GITHUB_OUTPUT" + echo "params=$(jq -c '.params' edge-sso/templates/header/cdn-filter/registry.json)" >> "$GITHUB_OUTPUT" + + - name: Deploy sso-header-filter to preprod + uses: gcore-github-actions/fastedge/deploy-template@v1 + with: + api_key: ${{ steps.creds.outputs.PREPROD_API_KEY }} + api_url: ${{ steps.creds.outputs.PREPROD_API_URL }} + wasm_file: edge-sso/templates/header/cdn-filter/wasm/headers_sso_guard.wasm + template_name: sso-header-filter + short_descr: ${{ steps.header-filter.outputs.descr }} + params: ${{ steps.header-filter.outputs.params }} + + - name: Deploy sso-header-filter to prod + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: gcore-github-actions/fastedge/deploy-template@v1 + with: + api_key: ${{ steps.creds.outputs.PROD_API_KEY }} + api_url: ${{ steps.creds.outputs.PROD_API_URL }} + wasm_file: edge-sso/templates/header/cdn-filter/wasm/headers_sso_guard.wasm + template_name: sso-header-filter + short_descr: ${{ steps.header-filter.outputs.descr }} + params: ${{ steps.header-filter.outputs.params }} diff --git a/.github/workflows/publish-html2md.yml b/.github/workflows/publish-html2md.yml new file mode 100644 index 0000000..c5ccfab --- /dev/null +++ b/.github/workflows/publish-html2md.yml @@ -0,0 +1,72 @@ +name: Publish html2md + +on: + workflow_dispatch: + push: + branches: + - feature/saml-app + paths: + - 'html2md/**' + +jobs: + build_and_publish: + name: Build and publish html2md + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Import credentials from Vault + uses: hashicorp/vault-action@v3 + id: creds + with: + url: https://puppet-vault.gc.onl + token: ${{ secrets.VAULT_TOKEN }} + secrets: | + secret/project_fastedge/harbor-robot-account username | HARBOR_LOGIN ; + secret/project_fastedge/harbor-robot-account password | HARBOR_PASSWORD ; + secret/project_fastedge/gcore_api/preprod/token token | PREPROD_API_KEY ; + secret/project_fastedge/gcore_api/preprod/hostname url | PREPROD_API_URL ; + secret/project_fastedge/gcore_api/prod/token token | PROD_API_KEY ; + secret/project_fastedge/gcore_api/prod/hostname url | PROD_API_URL ; + + - name: Login to Harbor and pull compiler + uses: ./.github/setup-harbor-pull + with: + harbor_login: ${{ steps.creds.outputs.HARBOR_LOGIN }} + harbor_password: ${{ steps.creds.outputs.HARBOR_PASSWORD }} + + - name: Build html2md + uses: ./.github/build-rust + with: + rust_package: html2md + working_directory: html2md + output_path: html2md/wasm/html2md.wasm + + - name: Read html2md registry + id: html2md + run: | + echo "descr=$(jq -r '.description' html2md/registry.json)" >> "$GITHUB_OUTPUT" + echo "params=$(jq -c '.params' html2md/registry.json)" >> "$GITHUB_OUTPUT" + + - name: Deploy html2md to preprod + uses: gcore-github-actions/fastedge/deploy-template@v1 + with: + api_key: ${{ steps.creds.outputs.PREPROD_API_KEY }} + api_url: ${{ steps.creds.outputs.PREPROD_API_URL }} + wasm_file: html2md/wasm/html2md.wasm + template_name: html2md + short_descr: ${{ steps.html2md.outputs.descr }} + params: ${{ steps.html2md.outputs.params }} + + - name: Deploy html2md to prod + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: gcore-github-actions/fastedge/deploy-template@v1 + with: + api_key: ${{ steps.creds.outputs.PROD_API_KEY }} + api_url: ${{ steps.creds.outputs.PROD_API_URL }} + wasm_file: html2md/wasm/html2md.wasm + template_name: html2md + short_descr: ${{ steps.html2md.outputs.descr }} + params: ${{ steps.html2md.outputs.params }} diff --git a/.gitignore b/.gitignore index ad67955..45fccc8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,11 @@ # will have compiled files and executables debug target +**/target/ + +# Wasm build artifacts +**/*.wasm +**/wasm/ # These are backup files generated by rustfmt **/*.rs.bk diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9ae1e67 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,42 @@ +--- +doc_type: policy +audience: bot +lang: en +tags: ["ai-agents", "rules", "critical", "codex"] +last_modified: 2026-06-30T00:00:00Z +copyright: "© 2026 gcore.com" +--- + +# RULES FOR AI AGENTS + +TL;DR: Keep command output short. Do not take actions unless asked. +Do not waste tokens on experiments. Do only what is asked. + +# COMMUNICATION STYLE + +- Use English by default; if the user writes in another language, use that language +- Use an informal tone, avoid formal business language +- Question ideas and suggest alternatives — do not just agree with everything +- Think for yourself instead of agreeing to be polite + +# INVARIANTS + +- NEVER do anything beyond the assigned task +- NEVER change code that was not asked to change +- NEVER "improve" or "optimize" without a clear request +- NEVER use scripts for mass code replacements +- NEVER make architecture decisions on your own +- ALWAYS keep command output short — every extra line = wasted tokens +- ALWAYS think before acting — do not repeat checks, remember context +- ALWAYS ask an expert when the solution is not clear +- ALWAYS tell apart an observation from an action request: + observation ("works oddly") → discuss, DO NOT fix + request ("fix this") → act + +# REPO CONTEXT + +See `CLAUDE.md` for repo structure and how to navigate to the right template. + +When working inside a specific template directory, read that template's own +`AGENTS.md` and `CLAUDE.md` first — they take precedence over this file for +all template-specific decisions. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7e409c5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,63 @@ +# FastEdge Templates + +## What This Repo Is + +Build and publish pipeline for Gcore FastEdge bolt-on application templates. Each +template is a standalone project that a developer can copy out and work with +independently — no shared dependencies, no workspace coupling between templates. + +The repo is public. Consumers can read what each template does or copy a specific +template to adapt it to their needs. + +## Templates + +| Directory | What it is | +|---|---| +| `html2md/` | Proxy-WASM filter: converts HTML origin responses to Markdown on `Accept: text/markdown` | +| `edge-sso/` | Multi-provider SSO bolt-on (Google, GitHub, Microsoft, Facebook, SAML) — three delivery variants: gate-only, cookie, header | + +## Repo Structure + +``` +FastEdge-templates/ +├── AGENTS.md ← company-wide agent rules (this file's sibling) +├── CLAUDE.md ← this file +├── README.md ← public-facing overview +├── LICENSE +├── assets/ ← shared marketing assets (deploy buttons, etc.) +├── .github/workflows/ ← CI/CD: builds and publishes all templates to Gcore portal +├── html2md/ ← standalone Rust/WASM template +└── edge-sso/ ← standalone mixed Rust+TypeScript SSO template +``` + +## Standalone Principle + +Each template directory is fully self-contained: + +- Its own build toolchain config (`.cargo/config.toml`, `package.json`, etc.) +- Its own lockfiles +- Its own `README.md` for public consumers +- Its own `CLAUDE.md` and `AGENTS.md` for developers and AI agents +- Its own `context/` folder with architecture and design docs + +**Do not** add cross-template dependencies or shared root-level build config. + +## Working in This Repo + +**Determine scope first:** + +| Task | Where to work | What to read first | +|---|---|---| +| CI/CD pipeline, root docs | Repo root | This file | +| Working on `html2md` | `html2md/` | `html2md/README.md` | +| Working on `edge-sso` | `edge-sso/` | `edge-sso/CLAUDE.md`, then `edge-sso/context/INDEX.md` | + +When working inside a template, treat it as a standalone project. Read its own +`CLAUDE.md` (if present) before doing anything. The root context does not apply +to template internals. + +## CI/CD + +`.github/workflows/` at the repo root builds and publishes all templates to the +Gcore portal on `workflow_dispatch`. Each template's build steps are independent +within the single workflow. diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index f40eda1..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[workspace.package] -version = "0.2.1" -edition = "2021" - -[workspace.dependencies] -fastedge = "0.3" -proxy-wasm = "0.2.1" - -[workspace] -members = [ - "html2md", -] -resolver = "2" - diff --git a/README.md b/README.md index 5144865..595d04d 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,33 @@ # FastEdge Templates -FastEdge application templates. +FastEdge application templates for [Gcore FastEdge](https://gcore.com/fastedge). -## Included Template +Each template is a standalone project you can deploy directly from the Gcore portal +or copy out and modify to suit your needs. Templates have no dependencies on each +other. + +## Templates ### html2md -`html2md` is a `cdylib` Proxy-Wasm module for content negotiation from HTML to Markdown. +Proxy-WASM filter that converts HTML origin responses to Markdown when the client +sends `Accept: text/markdown`. Zero configuration — drop it in front of any +HTML-serving origin. + +See [`html2md/README.md`](html2md/README.md) for details and the deploy button. + +### edge-sso -Behavior summary: +Multi-provider SSO bolt-on — an Identity-Aware Proxy that adds login (Google, +GitHub, Microsoft, Facebook, SAML) to any existing site without changing the +backend. Comes in three delivery variants: -- Detects markdown intent from request `Accept: text/markdown`. -- Converts only `Content-Type: text/html` origin responses. -- Rewrites response body to Markdown using `htmd`. -- Updates response headers for transformed content. +- **gate-only** — allow/deny at the edge +- **cookie** — issues a verifiable JWT the origin can inspect +- **header** — injects a signed `X-Forwarded-User` header upstream -See `html2md/README.md` for details. +See [`edge-sso/README.md`](edge-sso/README.md) for details. ## License -Apache-2.0. See `LICENSE`. +Apache-2.0. See [`LICENSE`](LICENSE). diff --git a/.cargo/config.toml b/edge-sso/.cargo/config.toml old mode 100644 new mode 100755 similarity index 100% rename from .cargo/config.toml rename to edge-sso/.cargo/config.toml diff --git a/edge-sso/.gitignore b/edge-sso/.gitignore new file mode 100644 index 0000000..a37618a --- /dev/null +++ b/edge-sso/.gitignore @@ -0,0 +1,45 @@ +# VS Code MCP configuration (contains API keys) +.vscode/mcp.json + + +# Dependencies & build artifacts +**/node_modules/ +**/out/ +**/dist/ +**/build/ +**/*.wasm +**/target/ +*.tsbuildinfo + +# Ignore editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Ignore OS files +.DS_Store +Thumbs.db + +# Ignore logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Ignore environment files +.env +.env.local +.env.*.local + +# Ignore test coverage +coverage/ +.nyc_output/ + +# Ignore extension build artifacts +packages/extension/dist-firefox/ +packages/extension/web-ext-artifacts/ + +client_secret_**.json +client_secret_**.md diff --git a/edge-sso/AGENTS.md b/edge-sso/AGENTS.md new file mode 100644 index 0000000..4369bc2 --- /dev/null +++ b/edge-sso/AGENTS.md @@ -0,0 +1,38 @@ +--- +doc_type: policy +audience: bot +lang: en +tags: ["ai-agents", "rules", "critical", "codex"] +last_modified: 2026-04-02T00:00:00Z +copyright: "© 2026 gcore.com" +--- + +# RULES FOR AI AGENTS + +TL;DR: Keep command output short. Do not take actions unless asked. +Do not waste tokens on experiments. Do only what is asked. + +# COMMUNICATION STYLE + +- Use English by default; if the user writes in another language, use that language +- Use an informal tone, avoid formal business language +- Question ideas and suggest alternatives — do not just agree with everything +- Think for yourself instead of agreeing to be polite + +# INVARIANTS + +- NEVER do anything beyond the assigned task +- NEVER change code that was not asked to change +- NEVER "improve" or "optimize" without a clear request +- NEVER use scripts for mass code replacements +- NEVER make architecture decisions on your own +- ALWAYS keep command output short — every extra line = wasted tokens +- ALWAYS think before acting — do not repeat checks, remember context +- ALWAYS ask an expert when the solution is not clear +- ALWAYS tell apart an observation from an action request: + observation ("works oddly") → discuss, DO NOT fix + request ("fix this") → act + +# PROJECT CONTEXT + +see CLAUDE.md diff --git a/edge-sso/CLAUDE.md b/edge-sso/CLAUDE.md new file mode 100644 index 0000000..0ad9e9a --- /dev/null +++ b/edge-sso/CLAUDE.md @@ -0,0 +1,69 @@ +# Multi-Provider SSO for FastEdge CDN Resources + +## Governance (REQUIRED) + +Read `AGENTS.md` for company-wide agent rules. These are mandatory and override any conflicting behavior. Key rules: never go beyond the assigned task, never change code that was not asked to change, never "improve" or "optimize" without a clear request, always distinguish observations from action requests. + +--- + +## Project Goal + +A **sellable edge SSO bolt-on** for Gcore FastEdge — an Identity-Aware Proxy (forward-auth) in the spirit of Cloudflare Access. A customer puts our apps in front of their existing site to add SSO (Google, GitHub, Microsoft, Facebook, and SAML) without rewriting their backend. Shipped as **three templates** on the identity-delivery axis — **gate-only** (allow/deny), **cookie** (verifiable JWT the origin checks), **header** (signed `X-Forwarded-User` injected upstream) — over a shared `core/`. + +> **Read `context/INDEX.md` first** — it is the discovery hub and current state. Then `context/architecture/overview.md` for the authoritative design (variants, repo structure, config model, signing strategy). + +## Architecture Overview + +> **Structure (as-built):** shared `core/` (`@sso/core` TS federation/session + `@sso/filter` Rust) consumed by thin `templates//{auth-app,cdn-filter}` presets. All three variants (gate-only, cookie, header) are complete (see `context/development/testing.md` for the test suites). See `context/architecture/overview.md` for the authoritative design. The per-deployment view below still holds: each deployment is one auth-app + one filter. + +Two FastEdge apps work together **per deployment**: + +1. **CDN filter** (Proxy-WASM, Rust — `core/filter` → `templates/*/cdn-filter`) — sits in the CDN proxy layer, verifies the session JWT on every request, redirects unauthenticated users +2. **HTTP Auth App** (HTTP App, TypeScript/Hono — `core/federation` → `templates/*/auth-app`) — federates to the IdP (SAML / GitHub / Google), issues the session token + +``` +User → CDN App (check session cookie) + ↓ unauthenticated + HTTP Auth App /auth/login → IdP (Okta / Azure AD / etc.) + ↓ (user authenticates) + HTTP Auth App /auth/callback ← IdP POSTs SAMLResponse + ↓ (validates SAML, issues signed token) + Back to CDN resource (with session cookie set) + ↓ authenticated + CDN App passes through → origin +``` + +See `context/` for detailed documentation on each component and design decisions. + +## Discovery Guide + +**Read when working on:** + +| Task | Read | +|---|---| +| **Start here — current state, file map** | `context/INDEX.md` | +| Product shape, variants, repo structure, token contract, signing strategy | `context/architecture/overview.md` | +| Three delivery templates (gate-only / cookie / header) | `context/architecture/auth-modes.md` | +| SAML-specific flow, XML/crypto, security checklist | `context/architecture/saml-flow.md` | +| Choosing a library for the auth app (WebCrypto vs Node APIs) | `context/architecture/runtime-constraints.md` | +| Security posture and known limitations (CHECK before touching auth/error/token/redirect) | `context/architecture/security.md` | +| How a customer wires login into their origin | `context/design/integration.md` | +| Run or extend the test suite (layout, scripts, shared patterns) | `context/development/testing.md` | +| Every configuration option (env vars + secrets, with cross-app callouts) | each template's `.env.example` | + +## Key Constraints + +- **No shared state**: FastEdge WASM instances are stateless per-request; sessions must be self-contained signed tokens +- **KV store is read-only** from apps — data written via Gcore portal/API only +- **CDN App language**: Rust (Proxy-WASM ABI) +- **HTTP App language**: JavaScript or TypeScript (recommended) +- **Execution limits**: 50ms (Basic plan), 200ms (Pro plan) +- **No Node.js APIs in HTTP App**: The JS runtime (StarlingMonkey) has no `node:crypto`, no `fs`, no Node compat layer. Standard SAML libraries (samlify, node-saml, boxyhq) will not work. Use the WebCrypto-native stack — see `context/architecture/runtime-constraints.md`. + +## FastEdge Platform + +- JS SDK: `@gcoredev/fastedge-sdk-js` +- Framework: Hono with `app.fire()` (not `export default`) +- Secrets: `getSecret("KEY")` from `fastedge::secret` +- KV: `KvStore.open("name")` from `fastedge::kv` (read-only from app) +- Outbound fetch: standard `fetch()` API available diff --git a/edge-sso/Cargo.lock b/edge-sso/Cargo.lock new file mode 100644 index 0000000..f90717d --- /dev/null +++ b/edge-sso/Cargo.lock @@ -0,0 +1,889 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fastedge" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d665bf3e9be79d0be271c1fd4233c752d0c52f0e48c8e4e8cdb6da6ea6915a6a" +dependencies = [ + "bytes", + "fastedge-derive", + "http", + "mime", + "thiserror", + "wit-bindgen", +] + +[[package]] +name = "fastedge-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddaea791529e71281550aa22e30597e23362039f7027fe7f9db5d55a16c338ba" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proxy-wasm" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de8f6564bd52c2f4ff79fa5d1bd3bc10d8f822162af8d527e121e46703496aa0" +dependencies = [ + "hashbrown 0.16.1", + "log", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sso-guard" +version = "0.0.1" +dependencies = [ + "base64", + "fastedge", + "hmac", + "p256", + "proxy-wasm", + "serde_json", + "sha2", + "urlencoding", +] + +[[package]] +name = "sso-guard-cookie" +version = "0.0.1" +dependencies = [ + "proxy-wasm", + "sso-guard", +] + +[[package]] +name = "sso-guard-gate-only" +version = "0.0.1" +dependencies = [ + "proxy-wasm", + "sso-guard", +] + +[[package]] +name = "sso-guard-header" +version = "0.0.1" +dependencies = [ + "proxy-wasm", + "sso-guard", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-encoder" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be00faa2b4950c76fe618c409d2c3ea5a3c9422013e079482d78544bb2d184c" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20b3ec880a9ac69ccd92fbdbcf46ee833071cf09f82bb005b2327c7ae6025ae2" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +dependencies = [ + "bitflags", + "futures", + "once_cell", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabd629f94da277abc739c71353397046401518efb2c707669f805205f0b9890" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a4232e841089fa5f3c4fc732a92e1c74e1a3958db3b12f1de5934da2027f1f4" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0d4698c2913d8d9c2b220d116409c3f51a7aa8d7765151b886918367179ee9" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a866b19dba2c94d706ec58c92a4c62ab63e482b4c935d2a085ac94caecb136" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55c92c939d667b7bf0c6bf2d1f67196529758f99a2a45a3355cc56964fd5315d" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/edge-sso/Cargo.toml b/edge-sso/Cargo.toml new file mode 100644 index 0000000..52a1761 --- /dev/null +++ b/edge-sso/Cargo.toml @@ -0,0 +1,18 @@ +[workspace] +members = [ + "core/filter", + "templates/cookie/cdn-filter", + "templates/gate-only/cdn-filter", + "templates/header/cdn-filter", +] +resolver = "2" + +[workspace.dependencies] +proxy-wasm = "0.2.1" +fastedge = { version = "0.4", default-features = false, features = ["proxywasm"] } +hmac = "0.12" +sha2 = "0.10" +base64 = "0.22" +serde_json = "1.0" +urlencoding = "2.1.2" +p256 = { version = "0.13", features = ["ecdsa"] } diff --git a/edge-sso/LICENSE b/edge-sso/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/edge-sso/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/edge-sso/README.md b/edge-sso/README.md new file mode 100644 index 0000000..15f1343 --- /dev/null +++ b/edge-sso/README.md @@ -0,0 +1,74 @@ +# edge-sso — Multi-Provider SSO for FastEdge + +Bolt-on Identity-Aware Proxy for Gcore FastEdge. Adds SSO (Google, GitHub, Microsoft, Facebook, SAML) to any existing site without changing the backend. Ships as three variants on the identity-delivery axis. + +## How It Works + +Two FastEdge apps work together per deployment: + +1. **CDN filter** (Proxy-WASM, Rust) — sits in the CDN proxy layer, verifies the session token on every request, redirects unauthenticated users to the auth app +2. **Auth app** (HTTP app, TypeScript/Hono) — federates to the identity provider, issues a signed session token, sets it on the client + +``` +User → CDN resource (filter checks session token) + ↓ no valid token + Auth app /auth/login → Identity Provider + ↓ (user authenticates) + Auth app /auth/callback ← IdP response + ↓ (validates, issues signed token) + Back to CDN resource (token set) → origin +``` + +## Variants + +Choose one variant per deployment based on how your origin needs to consume identity: + +| Variant | Session delivery | Use when | +|---|---|---| +| **gate-only** | Allow/deny only — no identity forwarded | Origin needs no user context, just access control | +| **cookie** | Signed JWT in a cookie the origin can verify | Origin reads user identity from a verifiable token | +| **header** | Signed `X-Forwarded-User` header injected upstream | Origin trusts a header from the CDN layer | + +Each variant is in `templates//` and contains two deployable apps: + +``` +templates/ +├── gate-only/ +│ ├── auth-app/ ← deploy as HTTP App +│ └── cdn-filter/ ← deploy as CDN App (Proxy-WASM) +├── cookie/ +│ ├── auth-app/ +│ └── cdn-filter/ +└── header/ + ├── auth-app/ + └── cdn-filter/ +``` + +## Shared Core + +`core/` contains the shared TypeScript federation and session logic consumed by all three auth-app variants. The Rust CDN filter is in `core/filter/` and compiled into each `cdn-filter` variant. + +## Configuration + +Each template's `.env.example` lists all supported environment variables and secrets. Key shared requirements across both apps in a deployment: + +- `SESSION_SECRET` — shared signing secret (gate-only and header variants) +- `SESSION_PUBLIC_KEY` / `SESSION_PRIVATE_KEY` — EC key pair (cookie variant) +- `SSO_AUDIENCE` — must match on both apps; the filter rejects tokens whose `aud` doesn't match +- `AUTH_PREFIX` — the path prefix reserved for auth routes (default: `/auth`) + +See each template's `.env.example` for the full list including per-provider OAuth credentials and SAML IdP settings. + +## Providers + +| Provider | Type | Auth-app env vars | +|---|---|---| +| Google | OAuth 2.0 | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` | +| GitHub | OAuth 2.0 | `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GITHUB_REDIRECT_URI` | +| Microsoft | OAuth 2.0 / OIDC | `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_REDIRECT_URI` | +| Facebook | OAuth 2.0 | `FACEBOOK_CLIENT_ID`, `FACEBOOK_CLIENT_SECRET`, `FACEBOOK_REDIRECT_URI` | +| SAML | SAML 2.0 | `SAML_IDP_SSO_URL`, `SAML_IDP_ENTITY_ID`, `SAML_IDP_CERT`, `SAML_SP_ENTITY_ID`, `SAML_ACS_URL` | + +## License + +Apache-2.0. See `LICENSE`. diff --git a/edge-sso/context/INDEX.md b/edge-sso/context/INDEX.md new file mode 100644 index 0000000..6df7804 --- /dev/null +++ b/edge-sso/context/INDEX.md @@ -0,0 +1,46 @@ +# Edge SSO Bolt-On — Context Index + +**Product:** Identity-Aware Proxy (forward-auth) for Gcore FastEdge. A customer +deploys two FastEdge apps in front of their existing site to add SSO without +rewriting their backend. Providers: Google, GitHub, Microsoft, Facebook, and SAML. +Shipped as three delivery templates: **gate-only**, **cookie**, **header**. + +**Current state:** All three templates are built and tested — unit, filter, and +integration suites green (see `development/testing.md` for the inventory and how to +run them). The CDN filter is Rust (proxy-WASM); the auth-app is TypeScript/Hono on +StarlingMonkey. Session-token audience binding is required and fail-closed (see +`architecture/security.md`). + +--- + +## What to read + +| Task | Read | +|---|---| +| Understand the product, repo structure, token contract, config model | `architecture/overview.md` | +| Three delivery templates (gate-only / cookie / header) | `architecture/auth-modes.md` | +| SAML protocol, XMLDSig, library stack | `architecture/saml-flow.md` | +| FastEdge JS runtime limits (why standard SAML libs don't work) | `architecture/runtime-constraints.md` | +| Security posture and known limitations | `architecture/security.md` | +| How a customer wires login into their origin | `design/integration.md` | +| Run or extend the test suite (layout, scripts, shared patterns) | `development/testing.md` | +| All configuration options (env vars + secrets) | each template's `.env.example` | + +--- + +## File map + +``` +context/ +├── INDEX.md ← this file +├── architecture/ +│ ├── overview.md ← what it is, two-app split, token, signing, config, deployment +│ ├── auth-modes.md ← three templates: gate-only / cookie / header +│ ├── saml-flow.md ← SAML protocol, XMLDSig, library stack +│ ├── runtime-constraints.md ← StarlingMonkey limits, WebCrypto availability table +│ └── security.md ← security posture and known limitations +├── design/ +│ └── integration.md ← customer guide: routes, login page, /auth/providers API +└── development/ + └── testing.md ← test layout, how to run, shared-suite patterns +``` diff --git a/edge-sso/context/architecture/auth-modes.md b/edge-sso/context/architecture/auth-modes.md new file mode 100644 index 0000000..a22b3e2 --- /dev/null +++ b/edge-sso/context/architecture/auth-modes.md @@ -0,0 +1,44 @@ +# Delivery Templates — Three Auth Modes + +## Three templates on one axis + +The axis that distinguishes the templates is **how the edge hands identity to the origin** — which maps directly to what the customer already runs. + +| Template | Edge delivers to origin | Fits an origin that… | +|---|---|---| +| **gate-only** | nothing — just allow/deny | only needs "is this user authed?" (static sites, downloads, internal tools) | +| **cookie** | a verifiable JWT cookie the origin verifies itself | already verifies stateless JWTs | +| **header** | injects identity request headers (`x-sso-user` + per-claim `x-sso-*`); origin trusts the edge | has server-side sessions, or won't verify tokens | + +> **Axis discipline:** templates split on identity-delivery ONLY. +> - **Provider** (Google / GitHub / SAML / future Microsoft, Facebook) is **runtime config**, not a template axis. +> - **Signing tier** (HS256 vs ES256+JWKS) is a config/hardening option inside the cookie/header variants, not a template. +> Letting either become a template axis causes a combinatorial explosion. + +## How thin the differences actually are + +- **auth-app** is the same across all three. It varies only by **token claim richness** (gate: `sub` only; cookie/header: `+email`, `+name`) and whether it exposes a JWKS route — both runtime config. +- **cdn-filter**: gate-only and cookie use the **identical** filter (no cookie → redirect; valid → allow). Only **header** adds one behavior: inject the `x-sso-*` identity headers upstream. Injection uses `add` for a new header and `set` to replace a client-supplied one (the CDN origin-fetch drops a `set` of a header that wasn't already present, so the API is chosen by the header's original presence — see the `put_user_header` comment in `core/filter/src/lib.rs`). + +Three deployable artifacts, one shared core, one optional filter behavior (header injection), one config dimension (claims + JWKS). Adding a provider or upgrading the signing tier touches `core/` once and propagates to all three. + +## Templates are thin presets + +A `templates//` directory is glue, not a reimplementation: + +```tsx +// templates/cookie/auth-app/server.tsx — the variant is the only knob; JWKS +// route + ES256-vs-HS256 signing follow from it inside @sso/core. +import { createAuthApp } from "@sso/core"; +const app = createAuthApp({ variant: "cookie" }); +addEventListener("fetch", (e) => e.respondWith(app.fetch(e.request))); +``` + +```toml +# templates/header/cdn-filter/Cargo.toml — the filter is Rust; behaviour is +# selected by compile-time Cargo features on the shared core/filter crate. +# header → strip-session-cookie + inject-user-header + alg-hs256 +# gate → strip-session-cookie + alg-hs256 +# cookie → alg-es256 +sso-guard = { path = "../../../core/filter", default-features = false, features = ["strip-session-cookie", "inject-user-header", "alg-hs256"] } +``` diff --git a/edge-sso/context/architecture/overview.md b/edge-sso/context/architecture/overview.md new file mode 100644 index 0000000..355b6db --- /dev/null +++ b/edge-sso/context/architecture/overview.md @@ -0,0 +1,125 @@ +# Architecture Overview — Edge SSO Bolt-On + +## What this is + +An **Identity-Aware Proxy (IAP) / forward-auth gateway** for the Gcore CDN edge, sold as a **bolt-on**: a customer puts our FastEdge apps in front of their existing site to add SSO / OAuth / SAML login **without rewriting their backend**. + +The category benchmark is **Cloudflare Access** (Zero Trust): edge gates every request, federates unauthenticated users to an IdP, issues a signed cookie, and verifies it at the edge on subsequent requests. Open-source analogues: oauth2-proxy, Pomerium, Authelia, Google IAP, Ory Oathkeeper. The honest pitch is *"Cloudflare Access, native to Gcore FastEdge."* + +## Two cooperating apps per deployment + +Every variant is built from this pair: + +| App | Runtime / language | Role | +|---|---|---| +| **auth-app** | HTTP-WASM, StarlingMonkey, TypeScript / Hono | Federation: provider login/callback (OAuth/OIDC/SAML), issues the signed session token | +| **cdn-filter** | proxy-WASM, Rust | Enforcement: verifies the token on every request, redirects unauthenticated users | + +### Why two apps and not one + +Recurring question: could the OAuth/redirect logic fold into the filter so customers deploy **one** app instead of two? **No — keep the split.** + +**The platform forbids the collapse you'd actually want.** Federation (PKCE, signed state, code exchange, SAML XML/sig validation, `jose` token minting) needs WebCrypto + JS libraries, which only exist in the **HTTP-WASM / StarlingMonkey** runtime. The **proxy-WASM** filter (Rust) cannot run the federation dance. So you *cannot* move auth into the filter. The only real "single app" option is the inverse: delete the filter and make the **JS app an in-path reverse proxy** — wrong direction for a CDN product. It puts a heavyweight JS runtime + `fetch()`-to-origin in the data path of **every** request, burning the 50/200ms budget on the hot path and forfeiting edge-caching positioning. The proxy-WASM filter verifies inline, cheaply, on every request; JS runs only on the cold login path. + +**The split is what the category does.** Cloudflare Access, Pomerium, oauth2-proxy, Authelia, Envoy ext_authz all separate *enforcement* from *federation*. Our filter runs **inline in the CDN proxy** (proxy-WASM), avoiding the per-request subrequest hop that draws the main latency complaint against forward-auth tools. + +**The split also gives "just OAuth, enforce at origin" for free** — a customer who wants only federation deploys the auth-app alone and enforces on their own backend. Merging would destroy that composability. + +## Repo structure — monorepo, `core/` + `templates/` + +``` +saml-app/ +├── core/ +│ ├── federation/ # TypeScript package (@sso/core) — runs in the auth-app +│ │ ├── providers/ # google, github, microsoft, facebook, saml/ (+ common.ts: PKCE, signed state) +│ │ ├── app.tsx # createAuthApp — mounts the /auth/** Hono router +│ │ ├── chooser.tsx # provider chooser (renders enabled providers dynamically) +│ │ └── config.ts # runtime config resolution from env/secrets +│ ├── session/ +│ │ └── token.ts # mint/verify; HS256 (gate/header) and ES256 (cookie) +│ └── filter/ # Rust filter lib +├── templates/ +│ ├── gate-only/ { auth-app/ , cdn-filter/ } +│ ├── cookie/ { auth-app/ , cdn-filter/ } +│ └── header/ { auth-app/ , cdn-filter/ } +└── context/ +``` + +### `core/` is two libraries, one per runtime + +The auth-app (TS) and the filter (Rust) run in different runtimes and **cannot share source**: + +1. **`core/federation/` + `core/session/`** — TypeScript package consumed by every template's auth-app. Mints the token. Future providers land here only — the filter is provider-agnostic. +2. **`core/filter/`** — Rust library consumed by every template's cdn-filter. Verifies the token, redirects, (header variant) injects. + +They share only the **token contract** (below), implemented once on each side: TS mints, Rust verifies. + +## Token contract + +Standard JWT. **Algorithm is per-variant:** gate-only / header sign **HS256** with `SESSION_SECRET`; the **cookie** variant signs **ES256** with `SESSION_SIGNING_KEY` (a PKCS#8 EC private key) and publishes the public half via JWKS (see signing strategy below). Default cookie `sso_session` (configurable via `SESSION_COOKIE`). + +``` +header = { alg: "HS256" | "ES256", typ: "JWT", kid? } +payload = { sub, iat, exp, aud, iss?, email?, name?, picture?, given_name?, family_name? } +``` + +- `aud` scopes a token to its deployment. It is **required and fail-closed**: the auth-app refuses to sign without `SSO_AUDIENCE`, and the filter rejects any token whose `aud` doesn't match — and refuses all sessions when `SSO_AUDIENCE` is unset. Distinct value per deployment = isolated sessions; the same value on two apps = sessions deliberately shared. `iss` is validated only when configured. +- Cookie: `sso_session`; `HttpOnly; Secure; SameSite=Lax`. Under single-domain routing **no `Domain=` is needed** — same-origin. +- The filter verifies signature + `exp` + `aud` (+ `iss` when set). It is **provider-agnostic** — it never learns which provider authenticated. + +## Signing strategy & roadmap + +**HS256 (shared secret) — gate-only / header.** Each customer deploys their **own** app pair per CDN resource — issuer + all verifiers belong to **one trust domain**, so a shared secret is the zero-friction choice. The filter verifies HS256 against `SESSION_SECRET`. + +**ES256 + JWKS — cookie variant.** The cookie variant signs ES256 so the customer's public origin can verify via a published JWKS endpoint and never holds a forge-capable secret. The auth-app signs ES256 with `SESSION_SIGNING_KEY` (`core/session/token.ts` + `key.ts`) and serves the public JWK at `GET /auth/.well-known/jwks.json` (`core/federation/app.tsx`, cookie variant only, gated on `SESSION_PUBLIC_JWK`, public members only); the Rust filter is ES256-only and verifies against `SESSION_PUBLIC_JWK` (algorithm pinned at compile time). An origin verifies the cookie itself via `createRemoteJWKSet` against the JWKS URL. + +**StarlingMonkey constraint (the reason for the offline-key design):** `crypto.subtle` supports ECDSA `sign`/`verify` but **not** `generateKey`/`exportKey` — so the keypair is generated offline (`scripts/gen-ec-keypair.mjs`), the private key stored as a PKCS#8 secret (`SESSION_SIGNING_KEY`), and the pre-computed public JWK served from `SESSION_PUBLIC_JWK`. `jose` handles the signing. + +## Configuration model — env + secrets only (no KV) + +**KV is explicitly NOT used for config** — too expensive per read. Config comes from: + +- **Secrets** (`getSecret`) — credentials and signing keys: `*_CLIENT_SECRET`, `SESSION_SECRET`, `SESSION_SIGNING_KEY` (cookie), `IDP_CERT`. +- **Env vars** (`getEnv` / `fastedge::env`) — non-sensitive selectors: `SSO_PROVIDERS`, `SSO_CLAIMS`, `SSO_ISSUER`, `SSO_AUDIENCE`, `CANONICAL_HOST`, `SSO_ALLOWED_ORIGINS`, `SESSION_COOKIE`, `SESSION_PUBLIC_JWK` (cookie), per-provider `*_CLIENT_ID`/`*_REDIRECT_URI`, `MICROSOFT_TENANT`/`MICROSOFT_ALLOWED_TENANTS`, SAML `IDP_*`/`SP_*`, and `LOGIN_PAGE_*` branding. + +> Each template's `.env.example` is the authoritative, exhaustive list of every option with inline guidance — including which keys must match between an auth-app and its CDN filter. + +### Build-time vs runtime split + +- **Build-time** (defines which template you are): variant (gate/cookie/header), header-injection on/off, JWKS on/off. Fixed per artifact. +- **Runtime** (set in the Gcore portal, no rebuild): providers, claims, credentials, redirect URL, cookie name, allowed redirect origins. The **same built WASM serves every consumer.** + +### Provider enablement + +A provider self-activates only if its secrets exist (`no GOOGLE_CLIENT_ID` → Google off). Override with an explicit `SSO_PROVIDERS="google,github"` allowlist. The chooser page and `/auth/providers` both derive from the same resolved set. + +## Cross-domain — Option 3 (single domain via CDN-as-origin) + +The CDN supports a **FastEdge HTTP app as an origin**. The CDN resource rule-set routes `/auth/**` to the auth-app as an origin — everything is on one domain. + +``` +cdn.example.com/auth/** → CDN rule → auth-app (HTTP app) as origin +cdn.example.com/ → cdn-filter checks cookie → customer origin +``` + +Consequences: +- Session cookie is plain same-origin — no `Domain=` juggling. +- **The filter MUST bypass `/auth/**`** — without the bypass the login flow redirect-loops. +- IdP callback/ACS URLs register against the single CDN domain. + +### CANONICAL_HOST + +The auth-app enforces a `CANONICAL_HOST` env var via middleware — any request arriving on the bare FastEdge origin URL (`*.fastedge.cdn.gc.onl`) is 301-redirected to the canonical domain. This prevents sessions being established on the wrong host and keeps IdP callback URLs pointing at a stable, customer-visible domain. + +## Deployment model + +A **per-CDN-resource template**, not multi-tenant SaaS. A consumer assigns an app pair to their own CDN resource and sets their own secrets. One customer may reuse the same app across several of their resources — still one trust domain. HS256 is acceptable today because there is no cross-tenant blast radius. + +## Current state + +All three templates are built and tested (unit, filter, and integration suites +green — see `development/testing.md`). The CDN filter is Rust (proxy-WASM); the +auth-app is TypeScript/Hono. ES256/JWKS for the cookie variant and required, +fail-closed audience binding are both in place. Known limitations (no token +revocation, SAML full-POST replay, no IdP Single Logout) are tracked in +`architecture/security.md`. diff --git a/edge-sso/context/architecture/runtime-constraints.md b/edge-sso/context/architecture/runtime-constraints.md new file mode 100644 index 0000000..58509ba --- /dev/null +++ b/edge-sso/context/architecture/runtime-constraints.md @@ -0,0 +1,144 @@ +# FastEdge JS Runtime Constraints + +## Runtime: StarlingMonkey + +The FastEdge HTTP App JS SDK (`@gcoredev/fastedge-sdk-js`) is built on +[@bytecodealliance/StarlingMonkey](https://github.com/bytecodealliance/StarlingMonkey) — +a SpiderMonkey-based JS runtime targeting the WASI 0.2 Component Model. + +It is a **strict WinterCG-style runtime**. It is NOT Node.js and has NO Node.js compatibility layer. + +### What is available + +- `fetch` (standard) +- `crypto.subtle` (Web Crypto API — partial, see below) +- `crypto.getRandomValues()` +- `TextEncoder` / `TextDecoder` +- `CompressionStream` / `DecompressionStream` (including `deflate-raw`) +- `URL`, `URLSearchParams`, `FormData` +- `WHATWG Streams`, `Blob`, structured clone, `btoa` / `atob`, `console` +- `@gcoredev/fastedge-sdk-js`: `getSecret()`, `KvStore`, outbound `fetch` + +### What is NOT available + +- `node:crypto` — not implemented, not polyfillable (see below) +- `node:fs`, `node:path`, `node:buffer`, `process`, `require` +- No Node.js compatibility flag (unlike Cloudflare Workers' `nodejs_compat`) + +### `crypto.subtle` supported operations + +| Operation | Algorithms | +|---|---| +| `digest()` | SHA-1, SHA-256, SHA-384, SHA-512, MD5 | +| `sign()` / `verify()` | RSASSA-PKCS1-v1_5, ECDSA, HMAC | +| `importKey()` | JWK, PKCS#8, SPKI, raw (HMAC) | +| `getRandomValues()` | ✓ | +| `encrypt()` / `decrypt()` | **Not implemented** | +| `generateKey()`, `deriveKey()`, `deriveBits()` | **Not implemented** | +| `exportKey()` | **Not implemented** | + +The SAML-critical operations (SHA-256 digest, RSASSA-PKCS1-v1_5 verify, SPKI importKey) are all available. + +--- + +## Why Standard SAML Libraries Don't Work + +All mainstream SAML libraries for Node.js are **incompatible** with StarlingMonkey: + +| Library | Blocker | +|---|---| +| `samlify` | Depends on `xml-crypto` (sync Node crypto) and `node-rsa` | +| `@node-saml/node-saml` | Deep Node.js `crypto` dependency | +| `@boxyhq/saml20` | `xml-crypto` + `node-forge`; CVE-2025-29775 affected | +| `passport-saml` | Node.js `crypto` | + +The core issue is `xml-crypto`, which calls the **synchronous** Node.js crypto API (`crypto.createVerify()`, `crypto.createSign()`, `crypto.createHash()`). StarlingMonkey only has the **async** `crypto.subtle` API. This is a fundamental impedance mismatch — no bundler polyfill can bridge synchronous calls to async `Promise`-returning functions without rewriting the library itself. + +### Why polyfilling node:crypto doesn't work + +`esbuild-plugin-polyfill-node` can substitute `node:crypto` with `crypto-browserify`. However: + +1. `crypto-browserify` implements `createSign` / `createVerify` synchronously using its own pure-JS RSA implementation — it does **not** delegate to `crypto.subtle`. +2. Even if it did, `crypto.subtle` is async — the sync/async mismatch remains. +3. No known successful deployment of samlify or xml-crypto in a StarlingMonkey environment exists. + +--- + +## Security Note: CVE-2025-29775 (SAMLStorm) + +All libraries depending on `xml-crypto < 6.0.1` are affected by SAMLStorm, a critical authentication bypass via XML comment injection in `DigestValue`. The implemented stack strips XML comments before processing — see `saml-flow.md` security checklist. + +--- + +## Viable Stack for SAML SP in FastEdge + +| Task | Package | Node deps? | +|---|---|---| +| XML parsing | `@xmldom/xmldom` | None — pure JS | +| XML Digital Signature (XMLDSig) | `xmldsigjs` | None — uses `crypto.subtle` | +| X.509 cert → CryptoKey | `@peculiar/x509` | None — uses `crypto.subtle` | +| Deflate (SAMLRequest encoding) | Native `CompressionStream("deflate-raw")` | None | + +### Caveats and Known Workarounds + +**`exportKey` not implemented — polyfill required** + +`crypto.subtle.exportKey` is not implemented in StarlingMonkey. `xmldsigjs.Verify()` calls `reimportKey()` internally which calls `exportKey("spki", key)`. Without a polyfill this throws `Application.crypto.subtle.exportKey is not a function`. + +**Fix in `core/federation/saml/response.ts`:** +- Import the IdP public key through the **global `crypto.subtle.importKey("spki", ...)`** (not via `cert.publicKey.export()` which uses `@peculiar/webcrypto`'s engine) +- At module load, install a polyfill for `exportKey` that stores SPKI bytes in a WeakMap keyed by the CryptoKey, then returns them when `exportKey("spki", key)` is called +- Covered by `saml-response.test.ts` "StarlingMonkey polyfill" test case + +**`@peculiar/x509` crypto engine mismatch** + +`cert.publicKey.export()` uses `@peculiar/webcrypto` as its crypto provider, not the global `crypto`. Keys imported via `@peculiar/webcrypto` are not usable by `xmldsigjs`, which uses the global `crypto`. Fix: use `cert.publicKey.rawData` (DER-encoded SPKI `ArrayBuffer`) and import directly via `crypto.subtle.importKey`. + +- `xmldsigjs` is battle-tested in this stack — verified against Okta SAML 2.0. +- `@peculiar/x509` documents `node >= 20` as a requirement but uses Web Crypto internally — it bundles fine. +- Bundle size limit: 10 MB WASM binary. Check with `fastedge-build` after adding dependencies. + +### XMLDSig Verification Steps (manual reference) + +If implementing manually instead of using `xmldsigjs`: + +1. Parse SAMLResponse XML with `@xmldom/xmldom` +2. Locate `` inside the `` +3. Extract `` and apply **Exclusive C14N** with enveloped-signature transform (remove the `` element before canonicalizing) +4. `crypto.subtle.digest("SHA-256", c14nBytes)` and compare to `` +5. `crypto.subtle.verify({ name: "RSASSA-PKCS1-v1_5" }, publicKey, sigBytes, c14nSignedInfoBytes)` + +Exclusive C14N is the hardest part to implement from scratch — prefer `xmldsigjs` to avoid this. + +--- + +## SAMLRequest Encoding + +Use native `CompressionStream("deflate-raw")` — available in StarlingMonkey: + +```js +async function deflateRaw(str) { + const encoded = new TextEncoder().encode(str); + const cs = new CompressionStream("deflate-raw"); + const writer = cs.writable.getWriter(); + writer.write(encoded); + writer.close(); + const chunks = []; + const reader = cs.readable.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + const total = chunks.reduce((n, c) => n + c.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const c of chunks) { + out.set(c, off); + off += c.length; + } + return btoa(String.fromCharCode(...out)); +} +``` + +Or use `fflate` (`deflateRawSync`) as a synchronous alternative. diff --git a/edge-sso/context/architecture/saml-flow.md b/edge-sso/context/architecture/saml-flow.md new file mode 100644 index 0000000..c63c880 --- /dev/null +++ b/edge-sso/context/architecture/saml-flow.md @@ -0,0 +1,143 @@ +# SAML Flow + +## What is SAML? + +SAML (Security Assertion Markup Language) is an XML-based standard for exchanging authentication data between two parties: + +- **Identity Provider (IdP)**: The authority that authenticates users. Examples: Okta, Azure Active Directory, Google Workspace, Keycloak. +- **Service Provider (SP)**: Your application. It trusts the IdP to vouch for users. + +## SP-Initiated Flow (used here) + +The user starts at the SP (your CDN resource), not the IdP. + +``` +SP User Browser IdP +| | | +|<-- GET /protected/file ------------| | +| | | +|--> 302 /auth/login?redirect=... -->| | +| | | +|<-- GET /auth/login ----------------| | +| - build SAMLRequest XML | | +| - base64 + deflate encode | | +|--> 302 /sso?SAMLRequest=... ------>| | +| |-- GET /sso?SAMLRequest=... -->| +| | | +| |<-- login page ----------------| +| |-- submit credentials -------->| +| | | +| |<-- 200 (auto-submit form) ----| +|<-- POST /auth/callback (SAMLResponse) | +| - parse XML | | +| - verify signature | | +| - extract claims | | +| - issue session token | | +|--> 302 /protected/file ----------->| | +|<-- GET /protected/file (+ cookie)--| | +``` + +## SAMLRequest + +Generated by the SP (`/auth/login`) and sent to the IdP: + +- XML document identifying the SP, the ACS URL, and the request ID +- Deflate-compressed and base64-encoded +- Sent as a query parameter in the redirect URL +- Optionally signed (required by some IdPs) + +Key fields: +- `AssertionConsumerServiceURL`: where the IdP POSTs the response (your `/auth/callback`) +- `Issuer`: your SP's entity ID (a URI identifying your app, registered with the IdP) +- `ID` + `IssueInstant`: unique request ID and timestamp + +## SAMLResponse + +POSTed by the IdP to your ACS URL (`/auth/callback`): + +- XML document containing an **Assertion** with user identity and attributes +- The Assertion (or entire response) is signed with the IdP's private key +- Base64-encoded and sent as a form field: `SAMLResponse=` +- Must be processed within a short time window (typically 5 minutes) + +Key fields to validate: +- `Status/StatusCode`: must be `urn:oasis:names:tc:SAML:2.0:status:Success` +- `Conditions/NotBefore` + `NotOnOrAfter`: time validity window +- `SubjectConfirmationData/NotOnOrAfter`: recipient and expiry +- `Issuer`: must match the expected IdP entity ID +- `Signature`: XML Digital Signature — must be verified against IdP's X.509 certificate + +## XML Signature Verification (the hard part) + +SAML signatures use **XML Digital Signatures (XMLDSig)**, which is complex: + +1. **Canonicalization** (C14N): normalise the XML element before hashing (whitespace, namespace handling) +2. **Digest**: SHA-256 hash of the canonicalised element +3. **Signature**: RSA-SHA256 (or RSA-SHA1) over the digest, using the IdP's private key +4. **Verification**: use the IdP's public X.509 certificate to verify the signature + +`crypto.subtle` in FastEdge supports RSA-SHA256 verification (`crypto.subtle.verify`), but **canonicalization must be done manually or via a library**. This is why using an existing SAML library is strongly preferred. + +## JS Libraries for the HTTP Auth App + +**None of the standard Node.js SAML libraries work in StarlingMonkey.** `samlify`, `node-saml`, `boxyhq`, and `passport-saml` all depend on the synchronous Node.js `crypto` API which does not exist in StarlingMonkey. See `runtime-constraints.md` for full details. + +### Implemented stack + +| Task | Package | Reason | +|---|---|---| +| XML parsing | `@xmldom/xmldom` | Pure JS, no Node deps | +| XMLDSig verification | `xmldsigjs` | Uses `crypto.subtle` (WebCrypto-native) | +| X.509 cert → CryptoKey | `@peculiar/x509` | Uses Web Crypto internally | +| SAMLRequest deflate | Native `CompressionStream("deflate-raw")` | Built into StarlingMonkey | +| Session tokens | `crypto.subtle` HMAC-SHA256 | Built-in, no deps | + +### xmldsigjs usage pattern + +```ts +import { SignedXml } from "xmldsigjs"; +import { X509Certificate } from "@peculiar/x509"; + +const cert = new X509Certificate(pemString); +const publicKey = await cert.publicKey.export(); + +const signedXml = new SignedXml(doc); +signedXml.LoadXml(sigElements[0]); // Load the element +const valid = await signedXml.Verify(publicKey); +``` + +Note: `Verify` is capital V. The `signature` property is protected — find the signature element via `doc.getElementsByTagNameNS("http://www.w3.org/2000/09/xmldsig#", "Signature")` instead. + +## IdP Configuration + +Register your SP with the IdP before testing. You will need to provide: + +| Field | Value | +|---|---| +| Entity ID / Issuer | A URI for your SP, e.g. `https://auth.example.com/saml` | +| ACS URL | `https://auth.example.com/auth/callback` (or CDN domain if proxying) | +| Name ID Format | Usually `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress` | +| SP Certificate | Only needed if signing AuthnRequests | + +The IdP will provide: + +| Field | Where used | +|---|---| +| IdP SSO URL | Redirect target for SAMLRequest | +| IdP Entity ID | Validate `Issuer` in SAMLResponse | +| IdP X.509 Certificate | Verify SAMLResponse signature | + +## Security Checklist + +- [x] Validate `Issuer` matches expected IdP entity ID — **implemented** +- [x] Validate `AudienceRestriction` contains your SP entity ID — **implemented** +- [x] Validate `NotBefore` / `NotOnOrAfter` time windows (±30s clock skew) — **implemented** +- [x] Validate `SubjectConfirmationData/NotOnOrAfter` expiry — **implemented** +- [x] Validate XML Digital Signature against IdP's certificate — **implemented** +- [x] Pin signature/digest algorithms to SHA-256 (reject SHA-1 downgrade) — **implemented** +- [x] Require exactly one signature + one assertion, bound to the verified subtree (XML Signature Wrapping defense) — **implemented** +- [x] Strip XML comments before processing (CVE-2025-29775 / SAMLStorm) — **implemented** +- [x] Bind the response to a request we issued (`InResponseTo` matches a signed `RelayState` request id) — **implemented** +- [ ] One-time-use / replay of a captured full POST — **known gap** (the request binding stops substitution but not replay of a captured `SAMLResponse`+`RelayState`; needs writable state — see `security.md`) +- [ ] Use HTTPS for all endpoints +- [ ] Keep IdP certificate pinned / updated on rotation diff --git a/edge-sso/context/architecture/security.md b/edge-sso/context/architecture/security.md new file mode 100644 index 0000000..8dcfe79 --- /dev/null +++ b/edge-sso/context/architecture/security.md @@ -0,0 +1,128 @@ +# Security Posture & Known Limitations + +What the edge-SSO bolt-on defends against, how, and the limitations that remain by +design or platform constraint. Read this before changing the auth flow, token +handling, error pages, or redirect handling. + +## Trust model + +The signing key is the root of trust. The auth-app mints a signed session token; +the CDN filter verifies it on every request. Only a holder of the signing +key/secret can mint a token the filter accepts. `aud` scopes a token to its +deployment on top of that; `iss` is an optional secondary check. + +Each deployment should use **its own** signing key/secret **and** its own +`SSO_AUDIENCE`. Sharing either across deployments is only for deliberately shared +sessions (see "Audience binding"). + +--- + +## Protections in place + +### Session token +- **Algorithm pinned per variant at compile time.** cookie ⇒ ES256 only; gate-only + / header ⇒ HS256 only. The non-selected verification path is not compiled into + the filter, so a token cannot choose its own algorithm (no HS256↔ES256 confusion, + even if a stray `SESSION_SECRET` is present in a cookie deployment). +- **Audience binding is required and fail-closed.** The filter rejects every + session unless `SSO_AUDIENCE` is configured and equals the token's `aud`; the + auth-app refuses to mint a token without `SSO_AUDIENCE`. This blocks + cross-deployment token replay. `aud` matching is RFC 7519 membership-aware + (string equal, or array containing the value). To share sessions across apps, + set the **same** `SSO_AUDIENCE` on each; to isolate (default), give each its own. +- **Issuer validated when configured.** When `SSO_ISSUER` is set on both sides the + filter requires a matching `iss`. Optional because the signing key already + establishes issuer trust in this single-key model. +- **Expiry enforced** on both sign and verify. + +### SAML +- **XML signature verified** against the IdP's X.509 certificate (RSASSA-PKCS1-v1_5 + / SHA-256). +- **Signature algorithm pinned to SHA-256.** A SHA-1 (or other legacy) + `SignatureMethod`/`DigestMethod` is rejected before verification — no downgrade. +- **XML Signature Wrapping defended.** Exactly one `Signature` and exactly one + `Assertion` are required; the signature's `Reference` is resolved to its target + element and the `Assertion` must live inside that verified subtree; every claim + is read from within that assertion (never globally). +- **XML comments stripped before processing** (CVE-2025-29775 / SAMLStorm). +- **Issuer, AudienceRestriction, and time windows validated** (`Conditions` and + `SubjectConfirmationData`, ±30s skew). Time-window checks are enforced **only + when the IdP supplies the bound** — a `NotBefore`/`NotOnOrAfter` that is absent + is treated as "no bound", not as failure (see the limitation below). +- **Request binding.** Login mints an `AuthnRequest` ID carried in a signed + `RelayState` (`requestId.tag`, HMAC, ≤80 bytes); the callback requires the + assertion's signed `InResponseTo` to equal it. Rejects responses to requests we + never issued and forged/altered `RelayState`. +- **Post-login redirect** is carried in the `saml_relay` cookie (`SameSite=None` + so it survives the IdP's cross-site POST callback; HttpOnly, Secure, signed, + 300s) and re-validated against the redirect allowlist on read. + +### OAuth / OIDC +- **PKCE (S256)** on every OAuth/OIDC provider. +- **CSRF protection** via a signed, short-lived state cookie plus a `state` + round-trip checked at callback. +- **OIDC nonce binding** (Google, Microsoft): a nonce is sent in the auth request + and required to match in the `id_token`. +- **GitHub email is verified** — the verified primary address from `/user/emails` + is used, never the public profile field. +- **Microsoft tenant guardrail.** With a wildcard `MICROSOFT_TENANT` + (`common`/`organizations`/`consumers`) any Microsoft tenant can sign in; set + `MICROSOFT_ALLOWED_TENANTS` to restrict by the verified `tid`. A wildcard tenant + with no allowlist logs a warning. + +### Edge / app surface +- **Open-redirect allowlist.** `?redirect=` is validated against + `SSO_ALLOWED_ORIGINS`; relative paths are allowed, off-origin absolutes are + dropped to `/`. Protocol-relative and backslash bypasses (`/\evil.com`) are + rejected. +- **Canonical host.** `CANONICAL_HOST` 301-redirects requests on other hosts, + keeping sessions and IdP callbacks on one domain. +- **Generic error page.** Failures redirect to a static `/auth/error` that reflects + no caller input; the real reason is logged server-side only. +- **Session cookie hygiene.** `HttpOnly; Secure; SameSite=Lax`. gate-only and header + variants strip the session cookie before proxying upstream so the origin never + sees the raw token. +- **Header variant anti-spoofing.** The filter clears any client-supplied `x-sso-*` + header and injects only verified values. (Origin contract: the platform blanks a + cleared header to empty rather than removing it, so the origin must treat an empty + `x-sso-*` as absent.) +- **JWKS endpoint** serves only public JWK members — private key material pasted + into `SESSION_PUBLIC_JWK` is stripped before it is published. + +--- + +## Known limitations + +- **No token revocation.** Sessions are stateless JWTs and KV is read-only, so there + is no live denylist — a token is valid until `exp` regardless of logout or account + suspension. Mitigation: keep `Max-Age` short (currently 86400). Instant revocation + would require writable KV. +- **No IdP Single Logout.** `GET /auth/logout` clears the edge session cookie, but + the IdP session survives — a user can silently re-authenticate while the IdP + session is active. Full SLO would require a SAML `LogoutRequest` / OIDC + `end_session_endpoint` call. +- **SAML replay of a captured full POST.** The request binding stops + response/request substitution and forged `RelayState`, but `RelayState` travels in + the same POST as the `SAMLResponse`, so capturing and re-submitting both still + validates. True one-time-use requires recording consumed assertion/request IDs + (a write; KV is read-only). +- **SAML assertions with no time bound never expire.** The `Conditions` and + `SubjectConfirmationData` time windows are validated only when the IdP includes + them; an assertion that omits both carries no upper time bound and is accepted + indefinitely (until the request binding / signature checks reject it on other + grounds). In practice every mainstream IdP (Okta, Azure AD, etc.) always emits + `Conditions/NotOnOrAfter`, so this affects only non-conformant or misconfigured + IdPs — but the SP does not currently *require* a bound to be present. +- **SAML: no AuthnRequest signing**, single IdP per deployment, manual IdP cert + rotation (the cert is a static secret). + +--- + +## Operational notes + +- Use a distinct `SESSION_SECRET` / signing key **and** a distinct `SSO_AUDIENCE` + per deployment unless you intend to share sessions. +- Set `MICROSOFT_TENANT` to your tenant (or `MICROSOFT_ALLOWED_TENANTS`) — do not + ship a wildcard tenant unrestricted. +- Rotate any secret that was ever committed to or generated in the working tree + before it goes to production. diff --git a/edge-sso/context/design/integration.md b/edge-sso/context/design/integration.md new file mode 100644 index 0000000..7c1819f --- /dev/null +++ b/edge-sso/context/design/integration.md @@ -0,0 +1,121 @@ +# Integration Guide — how a customer wires login into their origin + +> How a consumer of the bolt-on connects their existing site to the auth-app's login surface. See `architecture/overview.md` for the product shape and single-domain routing model. + +## The surface the auth-app exposes + +Under single-domain routing (Option 3), the CDN routes `/auth/**` to the auth-app as an origin, so everything below is on the **customer's own domain** (e.g. `https://shop.example.com/auth/...`). The filter bypasses `/auth/**`; everything else is gated. + +| Route | Method | Purpose | +|---|---|---| +| `/auth/` (and `/auth`) | GET | **Hosted login page** — server-rendered, branded, provider buttons. Honours `?redirect=`. | +| `/auth/providers` | GET | **Provider data (JSON)** — the enabled provider set, for customer-built login UIs. Honours `?redirect=`. | +| `/auth/branding` | GET | **Branding config (JSON)** — current `LOGIN_PAGE_*` values, for custom pages that want consistent branding. | +| `/auth/login/google` | GET | Start Google OIDC. Honours `?redirect=`. | +| `/auth/login/github` | GET | Start GitHub OAuth. Honours `?redirect=`. | +| `/auth/login/microsoft` | GET | Start Microsoft OIDC. Honours `?redirect=`. | +| `/auth/login/facebook` | GET | Start Facebook OAuth. Honours `?redirect=`. | +| `/auth/login` | GET | Start SAML SSO. Honours `?redirect=`. | +| `/auth/logout` | GET | **Sign out** — clears `sso_session` (`Max-Age=0`), redirects to the validated `?redirect=` (defaults to `/`). Not gated by the filter. | + +Each provider's OAuth/SAML callback lands on `/auth/callback/` (or `/auth/callback` for SAML) — these are used by the IdP, not called directly. + +`?redirect=` is the post-login destination. After successful federation the auth-app sets the `sso_session` cookie and 302s to that URL. + +The **enabled provider set** is resolved at runtime from `SSO_PROVIDERS` ∩ providers-whose-creds-are-present. The hosted page and `/auth/providers` are driven by the same resolution (`selectProviders`) so they never disagree. + +--- + +## Login page customization — three tiers + +### Tier 1 — env var branding (recommended default) + +The built-in hosted login page reads these env vars per-request. No code changes, no custom CSS required for basic branding. + +| Env var | Default | Effect | +|---|---|---| +| `LOGIN_PAGE_TITLE` | `"Sign in"` | `` and `<h1>` | +| `LOGIN_PAGE_SUBTITLE` | `"Choose a sign-in method"` | Subheading below the title | +| `LOGIN_PAGE_LOGO_URL` | — | Logo image above the title | +| `LOGIN_PAGE_FAVICON_URL` | — | Tab favicon | +| `LOGIN_PAGE_ACCENT_COLOR` | `#0066cc` | Button/focus-ring color (CSS `--lp-accent`) | +| `LOGIN_PAGE_BACKGROUND_COLOR` | `#f0f2f5` | Page background (CSS `--lp-bg`) | +| `LOGIN_PAGE_CSS_URL` | — | Customer stylesheet linked last — overrides any built-in style | +| `IDP_LABEL` | `"SSO"` | Display name for the SAML provider button | +| `IDP_ICON_URL` | — | Icon URL for the SAML provider button | + +`LOGIN_PAGE_CSS_URL` is the deep-customization escape hatch — a `<link rel="stylesheet">` injected after built-in styles. The CSS variables `--lp-accent` and `--lp-bg` are intentional override points. + +### Tier 2 — fully custom login page (`LOGIN_PAGE_URL`) + +Set `LOGIN_PAGE_URL` on the **CDN filter** to redirect unauthenticated users to a page you own instead of the built-in one. That page calls `GET /auth/providers` for login URLs and, optionally, `GET /auth/branding` for consistent branding tokens. + +``` +LOGIN_PAGE_URL=https://shop.example.com/my-login +``` + +Your custom page handles the full UI; clicking a provider's button navigates to its `loginUrl` (relative, same-origin) which kicks off the standard federation flow. The default value of `LOGIN_PAGE_URL` is `/auth/` — set it only to opt out. + +### Tier 3 — embed sign-in buttons on an existing page + +**Static links (simplest):** hard-code the provider routes. +```html +<a href="/auth/login/google?redirect=/account">Sign in with Google</a> +<a href="/auth/login/github?redirect=/account">Sign in with GitHub</a> +<a href="/auth/login?redirect=/account">Single Sign-On</a> +``` + +**Dynamic widget:** fetch `/auth/providers` and render whatever is enabled. +```js +const { providers } = await fetch("/auth/providers?redirect=/account").then(r => r.json()); +for (const p of providers) { + const a = document.createElement("a"); + a.href = p.loginUrl; // relative, same-origin, redirect already encoded + a.textContent = `Sign in with ${p.label}`; + loginContainer.append(a); +} +``` + +Adding or removing a provider (a secret or `SSO_PROVIDERS` change in the portal) updates the widget with no code change on the customer's side. + +--- + +## `GET /auth/providers` contract + +```jsonc +// GET /auth/providers?redirect=/cart +{ + "providers": [ + { "id": "google", "label": "Google", "loginUrl": "/auth/login/google?redirect=%2Fcart" }, + { "id": "github", "label": "GitHub", "loginUrl": "/auth/login/github?redirect=%2Fcart" }, + { "id": "saml", "label": "SSO", "loginUrl": "/auth/login?redirect=%2Fcart" } + ] +} +``` + +- `id` — stable identifier (also the value used in `SSO_PROVIDERS`). +- `label` — human label; SAML label is overridden by `IDP_LABEL`. +- `loginUrl` — relative path including the encoded `redirect`. +- Order is stable (registry order), not allowlist order. + +## `GET /auth/branding` contract + +```jsonc +{ + "title": "Sign in", + "subtitle": "Choose a sign-in method", + "logoUrl": "https://cdn.example.com/logo.png", + "faviconUrl": null, + "accentColor": "#e00", + "backgroundColor": "#f0f2f5", + "cssUrl": null +} +``` + +Returns the current `LOGIN_PAGE_*` env var values as a JSON object. Custom login pages (Tier 2) can `fetch("/auth/branding")` to auto-style themselves consistently without duplicating the env var set. + +--- + +## Security — `?redirect=` validation + +The `redirect` parameter is validated against `SSO_ALLOWED_ORIGINS`. Relative URLs (starting with `/`) are always permitted. Off-origin absolute URLs are silently dropped — the post-login redirect falls back to `/`. Set `SSO_ALLOWED_ORIGINS` to a comma-separated list of allowed origins (e.g. `https://shop.example.com`) to permit absolute redirects. diff --git a/edge-sso/context/development/testing.md b/edge-sso/context/development/testing.md new file mode 100644 index 0000000..87c156e --- /dev/null +++ b/edge-sso/context/development/testing.md @@ -0,0 +1,79 @@ +# Testing — layout, how to run, patterns + +There is **no root test runner** — each package has its own scripts. Unit tests are +pure TypeScript (run under Node, no build). Integration, filter, and e2e tests run a +built WASM artifact, so the wasm must exist first. + +## Inventory + +| Package | Script | Covers | Count | +|---|---|---|---| +| `templates/cookie/auth-app` | `test:unit` | shared `@sso/core` — token (HS256+ES256), SAML response/XSW + weak-alg rejection, OAuth common, OIDC (Google/Microsoft incl. tenant allowlist), claims, redirect | **120** | +| `templates/cookie/auth-app` | `test` | integration (`app.test.ts`): SAML login/callback, redirect sanitization, logout, JWKS endpoint | — | +| `templates/cookie/auth-app` | `test:e2e` | e2e per OAuth provider (github, google, microsoft, facebook) against a local stub IdP | 4 | +| `templates/cookie/cdn-filter` | `test` | gate + ES256 + aud/iss + fail-closed audience + alg-pin | **21** | +| `templates/header/cdn-filter` | `test` | gate + header inject + cookie strip + aud/iss + fail-closed + per-claim + spoof-strip | **22** | +| `templates/{gate-only,header}/auth-app` | `test` | variant smoke (no JWKS route, boots, serves federation routes) | 3 each | + +`gate-only/cdn-filter` is **build-only** (no test suite) — its filter logic is identical +to cookie's gate behaviour, exercised via the shared filter suite under both signing tiers. + +## Running + +Unit tests need no build: + +```bash +pnpm -C templates/cookie/auth-app test:unit +``` + +Integration / filter / e2e need the wasm built first. Each `build` script emits the +variant-prefixed artifact name the matching test loads (e.g. `cookie-auth-app.wasm`, +`cookie_sso_guard.wasm`) — and the filter build copies the Cargo output into place — so +`build` then `test` works with no manual renaming: + +```bash +# auth-app (TS): build emits ./wasm/<variant>-auth-app.wasm, which the test loads +pnpm -C templates/cookie/auth-app build && pnpm -C templates/cookie/auth-app test + +# filter (Rust): build compiles + copies to ./wasm/<variant>_sso_guard.wasm +pnpm -C templates/cookie/cdn-filter build && pnpm -C templates/cookie/cdn-filter test +pnpm -C templates/header/cdn-filter build && pnpm -C templates/header/cdn-filter test +``` + +## Shared-suite patterns (DRY across variants) + +- **Filter suite** — `templates/cookie/cdn-filter/tests/filter-suite.ts` is imported by the + header filter test via relative path. Exports `runGateSuites` / `runEs256Suites` / + `runStripSuites` / `runAudIssSuites` / `runFailClosedSuites`, the `GateSigningTier` + abstraction (HS256 vs ES256), `FILTER_AUDIENCE` / `AUDIENCE_ENV`, and one `signJwt` helper + every minter funnels through (don't reintroduce per-tier copies). Because audience is + fail-closed, every suite that expects a token to be accepted sets `SSO_AUDIENCE` and mints + tokens carrying that `aud`. +- **Variant smoke** — `templates/cookie/auth-app/tests/non-cookie-smoke.ts` is imported by the + `gate-only` and `header` auth-app tests. Those two are **identical** at the auth-app level + (HS256, no JWKS route), so they share one smoke suite. The only variant branch in the whole + codebase is in `core/federation/app.tsx` (ES256 vs HS256 signing key; JWKS route mounted only + for `cookie`). +- **e2e** — deterministic, **no live credentials**. Each spins up a local Node HTTP stub IdP + (mocking the token endpoint, userinfo/JWKS, and minting a stub `id_token` where applicable). + +## CI + +Tests are wired into `.github/workflows/publish-edge-sso-templates.yml` and gate +deployment — a push to `edge-sso/**` will not reach the deploy steps unless all +test stages pass. Run order: + +1. **Unit tests** — no wasm needed, runs immediately after install +2. **Build** — TS WASM + Rust CDN filters compiled +3. **Integration tests** — all three auth-app variants +4. **Filter tests** — cookie and header CDN filters +5. **E2E tests** — cookie auth-app against a local stub IdP +6. **Deploy** — only reached if all above pass + +`pnpm -r test` is not used; the workflow calls per-package scripts explicitly +(see the workflow for the exact commands). + +## Known gaps + +- e2e covers the happy path per provider; failure-path e2e is not present (unit/integration + cover much of it). diff --git a/edge-sso/core/federation/app.tsx b/edge-sso/core/federation/app.tsx new file mode 100644 index 0000000..aef3637 --- /dev/null +++ b/edge-sso/core/federation/app.tsx @@ -0,0 +1,148 @@ +import { Hono } from "hono"; +import type { Context } from "hono"; +import { html } from "hono/html"; +import { getEnv } from "fastedge::env"; +import { Chooser } from "./chooser"; +import { resolveRuntimeConfig, resolveBranding } from "./config"; +import { buildLoginUrl } from "./providers/registry"; +import { ErrorPage } from "./error"; +import { validateRedirect } from "./util/redirect"; +import { handleGitHubLogin, handleGitHubCallback } from "./providers/github"; +import { handleGoogleLogin, handleGoogleCallback } from "./providers/google"; +import { handleMicrosoftLogin, handleMicrosoftCallback } from "./providers/microsoft"; +import { handleFacebookLogin, handleFacebookCallback } from "./providers/facebook"; +import { handleSamlLogin, handleSamlCallback } from "./saml/handlers"; +import { requireEs256SigningKey, requireHs256Secret } from "../session/key.js"; + +export interface AuthAppOptions { + /** Identity-delivery variant this template represents. */ + variant: "gate-only" | "cookie" | "header"; +} + +export function createAuthApp(options: AuthAppOptions): Hono { + const app = new Hono(); + const auth = new Hono(); + + const signingKeyResolver = + options.variant === "cookie" ? requireEs256SigningKey : requireHs256Secret; + + // Redirect requests on non-canonical hosts to the canonical host. + // Runs before every route — protects the entire /auth surface. + app.use("*", async (c, next) => { + const canonical = getEnv("CANONICAL_HOST"); + if (!canonical) return next(); + // Strip port (host: "example.com:8080" → "example.com") — present in test + // runners and local dev; production FastEdge sends bare host. + const host = (c.req.header("host") ?? "").split(":")[0]; + if (host && host !== canonical) { + const url = new URL(c.req.url); + return c.redirect(`https://${canonical}${url.pathname}${url.search}`, 301); + } + return next(); + }); + + const chooserHandler = (c: Context) => { + const { providers, allowedOrigins } = resolveRuntimeConfig(); + const branding = resolveBranding(); + // Validate redirect before weaving it into login links. + const raw = c.req.query("redirect"); + const redirect = raw !== undefined ? validateRedirect(raw, allowedOrigins) : undefined; + const effectiveRedirect = redirect === "/" ? undefined : redirect; + return c.html( + html`<!doctype html>${( + <Chooser providers={providers} redirect={effectiveRedirect} branding={branding} /> + )}`, + ); + }; + // A Hono sub-app mounted at /auth matches `get("/")` for `/auth` but not for + // `/auth/`. The filter's default CHOOSER_URL is `/auth/`, so register the + // trailing-slash form on the parent too — both must render the chooser. + auth.get("/", chooserHandler); + app.get("/auth/", chooserHandler); + + // Same enabled-provider set as data — the integration surface for embedded + // login UIs (mode C). See context/integration.md. + auth.get("/providers", (c) => { + const { providers, allowedOrigins } = resolveRuntimeConfig(); + // Validate redirect before embedding it in login URLs. + const raw = c.req.query("redirect"); + const redirect = raw !== undefined ? validateRedirect(raw, allowedOrigins) : undefined; + const effectiveRedirect = redirect === "/" ? undefined : redirect; + return c.json({ + providers: providers.map((p) => ({ + id: p.id, + label: p.label, + loginUrl: buildLoginUrl(p.loginPath, effectiveRedirect), + })), + }); + }); + + // Branding config as JSON — lets custom login pages (Tier 2) auto-style + // themselves to match the operator's LOGIN_PAGE_* env vars without re-reading + // them from the server side. + auth.get("/branding", (c) => c.json(resolveBranding())); + + // Clear the SSO session cookie and redirect to a safe destination. The filter + // already bypasses /auth/**, so this route is never gated. + auth.get("/logout", (c) => { + const { allowedOrigins } = resolveRuntimeConfig(); + const raw = c.req.query("redirect"); + const dest = raw !== undefined ? validateRedirect(raw, allowedOrigins) : "/"; + const sessionCookieName = getEnv("SESSION_COOKIE") || "sso_session"; + c.header( + "Set-Cookie", + `${sessionCookieName}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`, + ); + return c.redirect(dest, 302); + }); + + // The error route ignores ?message= — the page shows a generic message. + // The real reason was logged server-side before the redirect. + auth.get("/error", (c) => + c.html(html`<!doctype html>${(<ErrorPage />)}`), + ); + + if (options.variant === "cookie") { + auth.get("/.well-known/jwks.json", (c) => { + const jwk = getEnv("SESSION_PUBLIC_JWK"); + if (!jwk) { + return c.json({ error: "JWKS not configured" }, 503); + } + let parsed: unknown; + try { + parsed = JSON.parse(jwk); + } catch { + return c.json({ error: "Invalid SESSION_PUBLIC_JWK" }, 500); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return c.json({ error: "Invalid SESSION_PUBLIC_JWK" }, 500); + } + // Defence in depth: publish only public JWK members. If an operator pastes + // a JWK that still carries private key material (RSA/EC `d`, CRT factors, + // symmetric `k`), strip it so the public endpoint can never leak the key. + const PUBLIC_JWK_FIELDS = new Set([ + "kty", "crv", "x", "y", "n", "e", "kid", "use", "alg", "key_ops", + ]); + const publicJwk: Record<string, unknown> = {}; + for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) { + if (PUBLIC_JWK_FIELDS.has(k)) publicJwk[k] = v; + } + return c.json({ keys: [publicJwk] }); + }); + } + + auth.get("/login/github", handleGitHubLogin); + auth.get("/callback/github", (c) => handleGitHubCallback(c, signingKeyResolver)); + auth.get("/login/google", handleGoogleLogin); + auth.get("/callback/google", (c) => handleGoogleCallback(c, signingKeyResolver)); + auth.get("/login/microsoft", handleMicrosoftLogin); + auth.get("/callback/microsoft", (c) => handleMicrosoftCallback(c, signingKeyResolver)); + auth.get("/login/facebook", handleFacebookLogin); + auth.get("/callback/facebook", (c) => handleFacebookCallback(c, signingKeyResolver)); + + auth.get("/login", handleSamlLogin); + auth.post("/callback", (c) => handleSamlCallback(c, signingKeyResolver)); + + app.route("/auth", auth); + return app; +} diff --git a/edge-sso/core/federation/chooser.tsx b/edge-sso/core/federation/chooser.tsx new file mode 100644 index 0000000..efa6b26 --- /dev/null +++ b/edge-sso/core/federation/chooser.tsx @@ -0,0 +1,237 @@ +import type { FC } from "hono/jsx"; +import { buildLoginUrl, type ResolvedProvider } from "./providers/registry"; +import type { LoginPageBranding } from "./config"; + +/** + * Server-rendered login page (hono/jsx — no React, no client bundle). + * + * Three-tier customization: + * Tier 1 — LOGIN_PAGE_* env vars (branding prop) style the built-in page. + * Tier 2 — LOGIN_PAGE_CSS_URL links a customer stylesheet (loaded last, full + * override). CSS variables --lp-accent and --lp-bg are entry points. + * Tier 3 — Set LOGIN_PAGE_URL (filter env) to redirect unauthenticated users + * to a fully custom page; that page uses GET /auth/providers for data. + * + * Provider buttons follow each IdP's branding guidelines: Google uses the + * official "G" mark and button style; GitHub uses the Octocat mark. + * The SAML button label is configurable via IDP_LABEL; its icon via IDP_ICON_URL. + */ + +// Static CSS — uses CSS variables so LOGIN_PAGE_CSS_URL can trivially restyle. +// Dynamic brand values (accent, bg) are injected via the <html style> attribute +// rather than directly in this string, so no escaping concerns here. +const PAGE_CSS = ` +*{box-sizing:border-box;margin:0;padding:0} +body{ + min-height:100vh; + display:flex; + align-items:center; + justify-content:center; + background:var(--lp-bg,#f0f2f5); + font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; + padding:1rem; + -webkit-font-smoothing:antialiased; +} +.card{ + background:#fff; + border-radius:8px; + box-shadow:0 2px 16px rgba(0,0,0,.10); + padding:2.5rem 2rem; + width:100%; + max-width:400px; + text-align:center; +} +.logo{max-height:48px;width:auto;margin-bottom:1.5rem} +.card h1{font-size:1.375rem;color:#1a1a1a;font-weight:600;margin-bottom:.375rem} +.subtitle{color:#666;font-size:.875rem;margin-bottom:2rem;line-height:1.4} +.providers{display:flex;flex-direction:column;gap:.625rem} +.btn{ + display:flex; + align-items:center; + justify-content:center; + gap:.625rem; + width:100%; + height:44px; + padding:0 1rem; + border:none; + border-radius:4px; + font-size:.9375rem; + font-weight:500; + text-decoration:none; + white-space:nowrap; + transition:opacity .15s ease; +} +.btn:hover,.btn:focus-visible{opacity:.85} +.btn:focus-visible{outline:2px solid var(--lp-accent,#0066cc);outline-offset:2px} +.btn-google{background:#fff;border:1px solid #dadce0;color:#3c4043} +.btn-github{background:#24292e;color:#fff} +.btn-microsoft{background:#fff;border:1px solid #8c8c8c;color:#5e5e5e} +.btn-facebook{background:#1877f2;color:#fff} +.btn-sso{background:var(--lp-accent,#0066cc);color:#fff} +.btn-icon{flex-shrink:0;display:block;width:18px;height:18px} +.btn-icon-initial{ + border-radius:50%; + background:rgba(255,255,255,.25); + display:flex; + align-items:center; + justify-content:center; + font-size:11px; + font-weight:700; + line-height:1; +} +.no-providers{color:#888;font-size:.875rem;line-height:1.5} +`; + +const GoogleIcon: FC = () => ( + <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"> + <path + d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" + fill="#4285F4" + /> + <path + d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" + fill="#34A853" + /> + <path + d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" + fill="#FBBC05" + /> + <path + d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" + fill="#EA4335" + /> + </svg> +); + +const GitHubIcon: FC = () => ( + <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"> + <path + fill="currentColor" + d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" + /> + </svg> +); + +const MicrosoftIcon: FC = () => ( + <svg viewBox="0 0 21 21" width="18" height="18" aria-hidden="true"> + <rect x="1" y="1" width="9" height="9" fill="#f25022" /> + <rect x="11" y="1" width="9" height="9" fill="#7fba00" /> + <rect x="1" y="11" width="9" height="9" fill="#00a4ef" /> + <rect x="11" y="11" width="9" height="9" fill="#ffb900" /> + </svg> +); + +const FacebookIcon: FC = () => ( + <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"> + <path + fill="currentColor" + d="M13.397 20.997v-8.196h2.765l.411-3.209h-3.176V7.548c0-.926.258-1.56 1.587-1.56h1.684V3.127A22.336 22.336 0 0 0 14.201 3c-2.444 0-4.122 1.492-4.122 4.231v2.355H7.332v3.209h2.753v8.202h3.312z" + /> + </svg> +); + +const ProviderButton: FC<{ provider: ResolvedProvider; redirect?: string }> = ({ + provider, + redirect, +}) => { + const href = buildLoginUrl(provider.loginPath, redirect); + + if (provider.id === "google") { + return ( + <a href={href} class="btn btn-google"> + <GoogleIcon /> + Sign in with Google + </a> + ); + } + + if (provider.id === "github") { + return ( + <a href={href} class="btn btn-github"> + <GitHubIcon /> + Sign in with GitHub + </a> + ); + } + + if (provider.id === "microsoft") { + return ( + <a href={href} class="btn btn-microsoft"> + <MicrosoftIcon /> + Sign in with Microsoft + </a> + ); + } + + if (provider.id === "facebook") { + return ( + <a href={href} class="btn btn-facebook"> + <FacebookIcon /> + Sign in with Facebook + </a> + ); + } + + // SAML / future providers — configurable label and optional icon + return ( + <a href={href} class="btn btn-sso"> + {provider.iconUrl ? ( + <img + src={provider.iconUrl} + alt="" + width="18" + height="18" + class="btn-icon" + /> + ) : ( + <span class="btn-icon btn-icon-initial" aria-hidden="true"> + {provider.label.charAt(0).toUpperCase()} + </span> + )} + Sign in with {provider.label} + </a> + ); +}; + +export const Chooser: FC<{ + providers: ResolvedProvider[]; + redirect?: string; + branding: LoginPageBranding; +}> = ({ providers, redirect, branding }) => { + // CSS variables are set on <html> as a style attribute — hono/jsx auto-escapes + // attribute values, so no injection risk from the color strings. + const cssVars = `--lp-accent:${branding.accentColor};--lp-bg:${branding.backgroundColor}`; + + return ( + <html lang="en" style={cssVars}> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>{branding.title} + {branding.faviconUrl && } + + + + ${logoHtml} +

Two-factor authentication

+

Enter the ${digits}-digit code from your authenticator app.

+ ${errorHtml} +
+ + + +
+ +`; +} diff --git a/edge-totp/otp-app/src/config.ts b/edge-totp/otp-app/src/config.ts new file mode 100644 index 0000000..755945b --- /dev/null +++ b/edge-totp/otp-app/src/config.ts @@ -0,0 +1,97 @@ +import { getEnv } from "fastedge::env"; +import { getSecret } from "fastedge::secret"; +import { DEFAULT_KEY_PREFIX } from "./seed/kv.js"; +import { validateInt, parseBool } from "./lib/validate.js"; + +export interface Config { + authPrefix: string; + issuer: string; + digits: number; + period: number; + algorithm: string; + drift: number; + ticketTtl: number; + proofTtl: number; + mfaSessionTtl: number; + enrollTtl: number; + maxAttempts: number; + allowSelfEnrollment: boolean; + mfaSessionCookie: string; + mfaProofCookie: string; + mfaAudience: string | null; + mfaIssuer: string | null; + kvStoreName: string | null; + kvStoreId: string | null; + kvKeyPrefix: string; + gcoreApiUrl: string; + brandName: string | null; + brandLogoUrl: string | null; + brandFaviconUrl: string | null; + brandButtonColor: string | null; + brandButtonHoverColor: string | null; +} + +export interface Secrets { + handoffKey: string | null; + mfaSessionKey: string | null; + mfaProofSigningKey: string | null; + enrollApiKey: string | null; + gcoreApiToken: string | null; +} + +/** + * Parse an integer env var, applying `fallback` when unset/empty and rejecting + * anything that is not a whole number within [min, max]. We fail loudly rather + * than let `parseInt` silently coerce: `parseInt("6abc")` is 6 (hides a typo) + * and `parseInt("abc")` is NaN (turns into a confusing run-time misbehaviour — + * e.g. drift=NaN makes the verify loop never run and every code "fails"). A + * misconfigured edge app should surface the bad value, not degrade quietly. + */ +function intEnv( + name: string, + fallback: number, + bounds: { min?: number; max?: number } = {}, +): number { + return validateInt(name, getEnv(name), fallback, bounds); +} + +export function loadConfig(): Config { + const authPrefix = (getEnv("AUTH_PREFIX") ?? "/auth/totp").replace(/\/$/, ""); + return { + authPrefix, + issuer: getEnv("TOTP_ISSUER") ?? "TOTP", + digits: intEnv("TOTP_DIGITS", 6, { min: 6, max: 8 }), + period: intEnv("TOTP_PERIOD", 30, { min: 1, max: 300 }), + algorithm: getEnv("TOTP_ALGORITHM") ?? "SHA1", + drift: intEnv("TOTP_DRIFT", 1, { min: 0, max: 10 }), + ticketTtl: intEnv("TICKET_TTL", 90, { min: 1 }), + proofTtl: intEnv("PROOF_TTL", 90, { min: 1 }), + mfaSessionTtl: intEnv("MFA_SESSION_TTL", 28800, { min: 1 }), + enrollTtl: intEnv("ENROLL_TTL", 600, { min: 1 }), + maxAttempts: intEnv("MAX_ATTEMPTS", 5, { min: 1 }), + allowSelfEnrollment: parseBool(getEnv("ALLOW_SELF_ENROLLMENT"), true), + mfaSessionCookie: getEnv("MFA_SESSION_COOKIE") ?? "mfa_session", + mfaProofCookie: getEnv("MFA_PROOF_COOKIE") ?? "mfa_proof", + mfaAudience: getEnv("MFA_AUDIENCE") ?? null, + mfaIssuer: getEnv("MFA_ISSUER") ?? null, + kvStoreName: getEnv("KV_STORE_NAME"), + kvStoreId: getEnv("KV_STORE_ID"), + kvKeyPrefix: getEnv("KV_KEY_PREFIX") ?? DEFAULT_KEY_PREFIX, + gcoreApiUrl: getEnv("GCORE_API_URL") ?? "https://api.gcore.com", + brandName: getEnv("TOTP_BRAND_NAME") ?? null, + brandLogoUrl: getEnv("TOTP_BRAND_LOGO_URL") ?? null, + brandFaviconUrl: getEnv("TOTP_BRAND_FAVICON_URL") ?? null, + brandButtonColor: getEnv("TOTP_BRAND_BUTTON_COLOR") ?? null, + brandButtonHoverColor: getEnv("TOTP_BRAND_BUTTON_HOVER_COLOR") ?? null, + }; +} + +export function loadSecrets(): Secrets { + return { + handoffKey: getSecret("HANDOFF_KEY"), + mfaSessionKey: getSecret("MFA_SESSION_KEY"), + mfaProofSigningKey: getSecret("MFA_PROOF_SIGNING_KEY"), + enrollApiKey: getSecret("ENROLL_API_KEY"), + gcoreApiToken: getSecret("GCORE_API_TOKEN"), + }; +} diff --git a/edge-totp/otp-app/src/enroll.ts b/edge-totp/otp-app/src/enroll.ts new file mode 100644 index 0000000..4779a01 --- /dev/null +++ b/edge-totp/otp-app/src/enroll.ts @@ -0,0 +1,131 @@ +import { escapeHtml, brandingChrome } from "./lib/html.js"; + +function errorMessage(error: string): string { + switch (error) { + case "invalid": return "Incorrect code — please scan the QR code again and try once more."; + case "kv": return "Enrollment could not be saved. Please try again."; + default: return "Something went wrong. Please try again."; + } +} + +export function renderEnrollPage(opts: { + formAction: string; + svgQr: string; + error?: string; + digits?: number; + brandName?: string | null; + brandLogoUrl?: string | null; + brandFaviconUrl?: string | null; + brandButtonColor?: string | null; + brandButtonHoverColor?: string | null; +}): string { + const { formAction, svgQr, error } = opts; + const digits = opts.digits ?? 6; + const errorHtml = error + ? `

${escapeHtml(errorMessage(error))}

` + : ""; + const { title, logoHtml, faviconHtml, btnColor, btnHoverCss } = + brandingChrome(opts, "Activate two-factor authentication"); + + return ` + + + + + + ${title} + ${faviconHtml} + + + + ${logoHtml} +

Set up two-factor authentication

+

Protect your account with a one-time code from your phone.

+
    +
  1. Install an authenticator app — Google Authenticator, Authy, or similar.
  2. +
  3. Tap + or Add account and scan the QR code below.
  4. +
  5. Enter the ${digits}-digit code shown in the app to confirm.
  6. +
+
${svgQr}
+ ${errorHtml} + +
+ + + +
+ + +`; +} diff --git a/edge-totp/otp-app/src/index.ts b/edge-totp/otp-app/src/index.ts new file mode 100644 index 0000000..ef68489 --- /dev/null +++ b/edge-totp/otp-app/src/index.ts @@ -0,0 +1,545 @@ +import { Hono } from "hono"; +import type { Context } from "hono"; +import { getCookie } from "hono/cookie"; +import { getEnv } from "fastedge::env"; +import { Cache } from "fastedge::cache"; +import { loadConfig, loadSecrets } from "./config.js"; +import type { Config, Secrets } from "./config.js"; +import { + verifyHandoffTicket, + signMfaSession, + signProof, + signEnrollCookie, + verifyEnrollCookie, + buildJwks, + importEs256PrivateKey, + ticketFingerprint, +} from "./lib/jwt.js"; +import { findMatchingStep, generateSecret, otpauthUri } from "./lib/totp.js"; +import { timingSafeEqual } from "./lib/safeEqual.js"; +import { otpauthToSvg } from "./lib/qr.js"; +import { appendCookie } from "./lib/cookies.js"; +import { readSeed, writeSeed, isEnrolled } from "./seed/kv.js"; +import { validateRedirect } from "./lib/validate.js"; +import { renderChallengePage } from "./challenge.js"; +import { renderEnrollPage } from "./enroll.js"; + +// Issue the edge MFA cookies after a successful OTP: the HS256 mfa_session the +// Rust filter checks, plus (Profile B) a one-time ES256 proof for origins that +// verify via JWKS. Shared by /verify and /activate so the two success paths +// can't drift. Caller must ensure secrets.mfaSessionKey is set. +async function setMfaCookies( + c: Context, + cfg: Config, + secrets: Secrets, + userId: string, + logTag: string, +): Promise { + const claimOpts = { + ...(cfg.mfaAudience ? { aud: cfg.mfaAudience } : {}), + ...(cfg.mfaIssuer ? { iss: cfg.mfaIssuer } : {}), + }; + const sessionToken = await signMfaSession( + userId, + secrets.mfaSessionKey as string, + cfg.mfaSessionTtl, + claimOpts, + ); + appendCookie(c, cfg.mfaSessionCookie, sessionToken, { + maxAge: cfg.mfaSessionTtl, + }); + + // Profile B: mint a one-time ES256 proof if a signing key is configured. + // Delivered as a short-lived cookie — never in a URL. A signing failure is + // non-fatal: the Profile A session cookie above still stands. + if (secrets.mfaProofSigningKey) { + try { + const privateKey = await importEs256PrivateKey(secrets.mfaProofSigningKey); + const jti = crypto.randomUUID(); + const proof = await signProof(userId, privateKey, cfg.proofTtl, jti, claimOpts); + appendCookie(c, cfg.mfaProofCookie, proof, { maxAge: cfg.proofTtl }); + } catch (err) { + console.error(`[${logTag}] Profile B proof signing failed: ${err}`); + } + } +} + +function buildApp(authPrefix: string): Hono { + const app = new Hono(); + + // --- Health --- + app.get("/health", (c) => c.json({ ok: true })); + + // --- Enroll: POST {prefix}/enroll --- + app.post(`${authPrefix}/enroll`, async (c) => { + const cfg = loadConfig(); + const secrets = loadSecrets(); + + const authHeader = c.req.header("authorization") ?? ""; + const key = authHeader.startsWith("Bearer ") + ? authHeader.slice(7) + : authHeader; + // Constant-time compare so a timing oracle can't be used to recover the + // enroll API key character by character. + if (!secrets.enrollApiKey || !timingSafeEqual(key, secrets.enrollApiKey)) { + return c.json({ error: "Unauthorized" }, 401); + } + + const body = (await c.req.json().catch(() => null)) as Record< + string, + unknown + > | null; + if (!body || typeof body.userId !== "string" || !body.userId) { + return c.json({ error: "userId required" }, 400); + } + const userId = body.userId as string; + const account = typeof body.account === "string" ? body.account : userId; + const force = body.force === true; + + if (!cfg.kvStoreName) + return c.json({ error: "KV_STORE_NAME not configured" }, 503); + if (!cfg.kvStoreId) + return c.json({ error: "KV_STORE_ID not configured" }, 503); + if (!secrets.gcoreApiToken) + return c.json({ error: "GCORE_API_TOKEN not configured" }, 503); + + // Guard against silent re-enrollment + if (!force && isEnrolled(cfg.kvStoreName, userId, cfg.kvKeyPrefix)) { + return c.json( + { error: "Already enrolled. Set force:true to re-enroll." }, + 409, + ); + } + + const seed = await generateSecret(); + const uri = otpauthUri(seed, account, cfg.issuer, { + digits: cfg.digits, + period: cfg.period, + algorithm: cfg.algorithm, + }); + const svgQr = otpauthToSvg(uri); + + try { + await writeSeed( + cfg.gcoreApiUrl, + secrets.gcoreApiToken, + cfg.kvStoreId, + userId, + seed, + cfg.kvKeyPrefix, + ); + } catch (err) { + return c.json({ error: `KV write failed: ${err}` }, 502); + } + + return c.json({ userId, otpauthUri: uri, svgQr }); + }); + + // --- Challenge page: GET {prefix}/challenge?t= --- + app.get(`${authPrefix}/challenge`, async (c) => { + const cfg = loadConfig(); + const secrets = loadSecrets(); + const ticket = c.req.query("t"); + const error = c.req.query("error"); + + if (!ticket) return c.text("Missing ticket", 400); + if (!secrets.handoffKey) return c.text("HANDOFF_KEY not configured", 503); + + const claims = await verifyHandoffTicket(ticket, secrets.handoffKey); + if (!claims) return c.text("Invalid or expired ticket", 400); + + // Not enrolled: send to self-service activation, or refuse when self-service + // enrollment is disabled (admin-provisioned deployments). + if (cfg.kvStoreName && !isEnrolled(cfg.kvStoreName, claims.sub, cfg.kvKeyPrefix)) { + if (!cfg.allowSelfEnrollment) { + return c.text("Not enrolled. Please contact your administrator.", 403); + } + return c.redirect( + `${authPrefix}/activate?t=${encodeURIComponent(ticket)}`, + 303, + ); + } + + return c.html( + renderChallengePage({ + formAction: `${authPrefix}/verify`, + ticket, + error: error ?? undefined, + digits: cfg.digits, + brandName: cfg.brandName, + brandLogoUrl: cfg.brandLogoUrl, + brandFaviconUrl: cfg.brandFaviconUrl, + brandButtonColor: cfg.brandButtonColor, + brandButtonHoverColor: cfg.brandButtonHoverColor, + }), + ); + }); + + // --- Self-service enrollment: GET {prefix}/activate?t= --- + // Generates a new TOTP seed, shows QR code, stores seed in a signed + // HttpOnly cookie (not written to KV until the user confirms the code). + app.get(`${authPrefix}/activate`, async (c) => { + const cfg = loadConfig(); + const secrets = loadSecrets(); + if (!cfg.allowSelfEnrollment) + return c.text("Self-service enrollment is disabled.", 403); + const ticket = c.req.query("t"); + + if (!ticket) return c.text("Missing ticket", 400); + if (!secrets.handoffKey) return c.text("HANDOFF_KEY not configured", 503); + + const claims = await verifyHandoffTicket(ticket, secrets.handoffKey); + if (!claims) return c.text("Invalid or expired ticket", 400); + + // Already enrolled → skip to challenge + if (cfg.kvStoreName && isEnrolled(cfg.kvStoreName, claims.sub, cfg.kvKeyPrefix)) { + return c.redirect( + `${authPrefix}/challenge?t=${encodeURIComponent(ticket)}`, + 303, + ); + } + + const seed = await generateSecret(); + const uri = otpauthUri(seed, claims.sub, cfg.issuer, { + digits: cfg.digits, + period: cfg.period, + algorithm: cfg.algorithm, + }); + const svgQr = otpauthToSvg(uri); + + // Store seed in a signed cookie — written to KV only after confirmation + const enrollToken = await signEnrollCookie( + claims.sub, + seed, + claims.next, + secrets.handoffKey, + cfg.enrollTtl, + ); + appendCookie(c, "totp_enroll", enrollToken, { maxAge: cfg.enrollTtl }); + + return c.html( + renderEnrollPage({ + formAction: `${authPrefix}/activate`, + svgQr, + digits: cfg.digits, + brandName: cfg.brandName, + brandLogoUrl: cfg.brandLogoUrl, + brandFaviconUrl: cfg.brandFaviconUrl, + brandButtonColor: cfg.brandButtonColor, + brandButtonHoverColor: cfg.brandButtonHoverColor, + }), + ); + }); + + // --- Self-service enrollment confirm: POST {prefix}/activate --- + // Verifies the confirmation code, writes seed to KV, issues mfa_session. + app.post(`${authPrefix}/activate`, async (c) => { + const cfg = loadConfig(); + const secrets = loadSecrets(); + + if (!cfg.allowSelfEnrollment) + return c.text("Self-service enrollment is disabled.", 403); + if (!secrets.handoffKey) return c.text("HANDOFF_KEY not configured", 503); + + const enrollToken = getCookie(c, "totp_enroll"); + if (!enrollToken) + return c.text( + "Enrollment session missing or expired — please log in again", + 400, + ); + + const enrollClaims = await verifyEnrollCookie( + enrollToken, + secrets.handoffKey, + ); + if (!enrollClaims) + return c.text( + "Enrollment session missing or expired — please log in again", + 400, + ); + + const { sub: userId, seed, next } = enrollClaims; + + // The enroll page submits the code as JSON via fetch(); a urlencoded + // fallback covers no-JS / direct posts. + const contentType = c.req.header("content-type") ?? ""; + const wantsJson = contentType.includes("application/json"); + let code = ""; + if (wantsJson) { + const parsed = (await c.req.json().catch(() => ({}))) as { + code?: unknown; + }; + code = + typeof parsed.code === "string" ? parsed.code.replace(/\s/g, "") : ""; + } else { + const params = new URLSearchParams(await c.req.text().catch(() => "")); + code = (params.get("code") ?? "").replace(/\s/g, ""); + } + + // Verify the confirmation code against the pending seed + const matchedStep = await findMatchingStep(code, seed, { + digits: cfg.digits, + period: cfg.period, + algorithm: cfg.algorithm, + drift: cfg.drift, + }); + + if (matchedStep === null) { + if (wantsJson) { + return c.json({ message: "Incorrect code — please try again." }, 400); + } + const uri = otpauthUri(seed, userId, cfg.issuer, { + digits: cfg.digits, + period: cfg.period, + algorithm: cfg.algorithm, + }); + return c.html( + renderEnrollPage({ + formAction: `${authPrefix}/activate`, + svgQr: otpauthToSvg(uri), + error: "invalid", + digits: cfg.digits, + brandName: cfg.brandName, + brandLogoUrl: cfg.brandLogoUrl, + brandFaviconUrl: cfg.brandFaviconUrl, + brandButtonColor: cfg.brandButtonColor, + brandButtonHoverColor: cfg.brandButtonHoverColor, + }), + ); + } + + // Code confirmed — persist seed to KV + if (!cfg.kvStoreName) return c.text("KV_STORE_NAME not configured", 503); + if (!cfg.kvStoreId) return c.text("KV_STORE_ID not configured", 503); + if (!secrets.gcoreApiToken) + return c.text("GCORE_API_TOKEN not configured", 503); + + try { + await writeSeed( + cfg.gcoreApiUrl, + secrets.gcoreApiToken, + cfg.kvStoreId, + userId, + seed, + cfg.kvKeyPrefix, + ); + } catch (err) { + console.error(`[activate] KV write failed: ${err}`); + if (wantsJson) { + return c.json( + { message: "Enrollment could not be saved. Please try again." }, + 503, + ); + } + const uri = otpauthUri(seed, userId, cfg.issuer, { + digits: cfg.digits, + period: cfg.period, + algorithm: cfg.algorithm, + }); + return c.html( + renderEnrollPage({ + formAction: `${authPrefix}/activate`, + svgQr: otpauthToSvg(uri), + error: "kv", + digits: cfg.digits, + brandName: cfg.brandName, + brandLogoUrl: cfg.brandLogoUrl, + brandFaviconUrl: cfg.brandFaviconUrl, + brandButtonColor: cfg.brandButtonColor, + brandButtonHoverColor: cfg.brandButtonHoverColor, + }), + 503, + ); + } + + // Clear enrollment cookie + appendCookie(c, "totp_enroll", "", { maxAge: 0 }); + + // Issue mfa_session + optional Profile B proof (same as /verify) + if (!secrets.mfaSessionKey) + return c.text("MFA_SESSION_KEY not configured", 503); + await setMfaCookies(c, cfg, secrets, userId, "activate"); + + const target = validateRedirect(next); + // JSON path: the page's fetch() can't navigate the top window via a 303, + // so hand it the destination to navigate to. The mfa_session cookie set + // above rides along on this response. Fallback form posts get the 303. + if (wantsJson) { + return c.json({ next: target }); + } + return c.redirect(target, 303); + }); + + // --- Verify: POST {prefix}/verify --- + app.post(`${authPrefix}/verify`, async (c) => { + const cfg = loadConfig(); + const secrets = loadSecrets(); + const branding = { + digits: cfg.digits, + brandName: cfg.brandName, + brandLogoUrl: cfg.brandLogoUrl, + brandFaviconUrl: cfg.brandFaviconUrl, + brandButtonColor: cfg.brandButtonColor, + brandButtonHoverColor: cfg.brandButtonHoverColor, + }; + + // Accept both form and JSON + let ticket: string; + let code: string; + const ct = c.req.header("content-type") ?? ""; + if (ct.includes("application/json")) { + const body = (await c.req.json().catch(() => ({}))) as Record< + string, + unknown + >; + ticket = String(body.t ?? ""); + code = String(body.code ?? ""); + } else { + const params = new URLSearchParams(await c.req.text().catch(() => "")); + ticket = params.get("t") ?? ""; + code = params.get("code") ?? ""; + } + + if (!ticket || !code) return c.text("Missing t or code", 400); + if (!secrets.handoffKey) return c.text("HANDOFF_KEY not configured", 503); + + const claims = await verifyHandoffTicket(ticket, secrets.handoffKey); + if (!claims) { + return c.html( + renderChallengePage({ + formAction: `${authPrefix}/verify`, + ticket, + error: "expired", + ...branding, + }), + 400, + ); + } + const { sub: userId, next } = claims; + + // SEC: single-use handoff ticket. The ticket is consumed on the first + // successful verify (marked below); presenting it again — a replay of the + // URL after a completed login — is refused here. POP-local best-effort, + // like the replay/brute-force guards (see R1/R2). + const ticketKey = `ticket:${await ticketFingerprint(ticket)}`; + if (await Cache.exists(ticketKey)) { + return c.html( + renderChallengePage({ + formAction: `${authPrefix}/verify`, + ticket, + error: "expired", + ...branding, + }), + 400, + ); + } + + // Brute-force guard (POP-local; see R2) + const failKey = `fail:${userId}`; + const attempts = await Cache.incr(failKey); + if (attempts === 1) await Cache.expire(failKey, { ttl: 300 }); // 5-min window + if (attempts > cfg.maxAttempts) { + return c.html( + renderChallengePage({ + formAction: `${authPrefix}/verify`, + ticket, + error: "locked", + ...branding, + }), + 429, + ); + } + + if (!cfg.kvStoreName) return c.text("KV_STORE_NAME not configured", 503); + const seed = readSeed(cfg.kvStoreName, userId, cfg.kvKeyPrefix); + if (!seed) return c.json({ error: "User not enrolled" }, 403); + + // Find which time-step matched, then check POP-local replay guard + const matchedStep = await findMatchingStep(code, seed, { + digits: cfg.digits, + period: cfg.period, + algorithm: cfg.algorithm, + drift: cfg.drift, + }); + + if (matchedStep === null) { + return c.html( + renderChallengePage({ + formAction: `${authPrefix}/verify`, + ticket, + error: "invalid", + ...branding, + }), + 200, + ); + } + + // Mark step used atomically (TTL covers the full drift acceptance window). + // incr is atomic, so two concurrent requests presenting the same valid code + // race here and exactly one wins (count === 1) — a plain exists()-then-set() + // check has a TOCTOU window that lets both through. + const replayKey = `used:${userId}:${matchedStep}`; + const replayTtl = cfg.period * (cfg.drift * 2 + 2); + const replayCount = await Cache.incr(replayKey); + if (replayCount === 1) { + await Cache.expire(replayKey, { ttl: replayTtl }); + } else { + return c.html( + renderChallengePage({ + formAction: `${authPrefix}/verify`, + ticket, + error: "invalid", + ...branding, + }), + 200, + ); + } + + // A valid, non-replayed code clears the failure counter so a legitimate + // user's earlier mistakes don't accrue toward a later lockout. + await Cache.delete(failKey); + + if (!secrets.mfaSessionKey) + return c.text("MFA_SESSION_KEY not configured", 503); + + // Consume the ticket so it cannot be replayed after this success. TTL + // covers the handoff ticket's absolute max age (verifyHandoffTicket caps + // maxTokenAge at 10 min). POP-local best-effort like the guards above. + await Cache.set(ticketKey, "1", { ttl: 600 }); + + await setMfaCookies(c, cfg, secrets, userId, "verify"); + + return c.redirect(validateRedirect(next), 303); + }); + + // --- Logout: GET {prefix}/logout --- + // Clears edge cookies and redirects. The origin links here before (or instead + // of) its own logout so edge cookies are always cleared as part of the + // sign-out flow. + app.get(`${authPrefix}/logout`, (c) => { + const cfg = loadConfig(); + const dest = validateRedirect(c.req.query("redirect") ?? "/"); + appendCookie(c, cfg.mfaSessionCookie, "", { maxAge: 0 }); + appendCookie(c, cfg.mfaProofCookie, "", { maxAge: 0 }); + return c.redirect(dest, 302); + }); + + // --- JWKS: GET {prefix}/.well-known/jwks.json (Profile B) --- + app.get(`${authPrefix}/.well-known/jwks.json`, (c) => { + const publicJwk = getEnv("MFA_PROOF_PUBLIC_JWK"); + if (!publicJwk) return c.json({ error: "JWKS not configured" }, 503); + try { + return c.json(buildJwks(publicJwk)); + } catch { + return c.json({ error: "Invalid MFA_PROOF_PUBLIC_JWK" }, 500); + } + }); + + return app; +} + +addEventListener("fetch", (event: FetchEvent) => { + const cfg = loadConfig(); + const app = buildApp(cfg.authPrefix); + event.respondWith(app.fetch(event.request)); +}); diff --git a/edge-totp/otp-app/src/lib/base32.ts b/edge-totp/otp-app/src/lib/base32.ts new file mode 100644 index 0000000..957cbbe --- /dev/null +++ b/edge-totp/otp-app/src/lib/base32.ts @@ -0,0 +1,43 @@ +const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +export function base32Decode(input: string): Uint8Array { + const s = input.toUpperCase().replace(/=+$/, "").replace(/\s/g, ""); + const bytes = new Uint8Array(Math.floor((s.length * 5) / 8)); + let buf = 0; + let bits = 0; + let idx = 0; + + for (let i = 0; i < s.length; i++) { + const val = ALPHABET.indexOf(s[i]); + if (val === -1) throw new Error(`Invalid base32 character: ${s[i]}`); + buf = (buf << 5) | val; + bits += 5; + if (bits >= 8) { + bits -= 8; + bytes[idx++] = (buf >> bits) & 0xff; + } + } + + return bytes.slice(0, idx); +} + +export function base32Encode(input: Uint8Array): string { + let out = ""; + let buf = 0; + let bits = 0; + + for (let i = 0; i < input.length; i++) { + buf = (buf << 8) | input[i]; + bits += 8; + while (bits >= 5) { + bits -= 5; + out += ALPHABET[(buf >> bits) & 31]; + } + } + + if (bits > 0) { + out += ALPHABET[(buf << (5 - bits)) & 31]; + } + + return out; +} diff --git a/edge-totp/otp-app/src/lib/cookies.ts b/edge-totp/otp-app/src/lib/cookies.ts new file mode 100644 index 0000000..ad0ffba --- /dev/null +++ b/edge-totp/otp-app/src/lib/cookies.ts @@ -0,0 +1,26 @@ +import type { Context } from "hono"; + +/** + * Append a Set-Cookie header to a Hono context. + * Uses c.header(..., { append: true }) so multiple cookies can be set in one + * response. + */ +export function appendCookie( + c: Context, + name: string, + value: string, + opts: { + maxAge: number; + path?: string; + httpOnly?: boolean; + secure?: boolean; + sameSite?: "Lax" | "Strict" | "None"; + }, +): void { + const path = opts.path ?? "/"; + const parts = [`${name}=${value}`, `Path=${path}`, `Max-Age=${opts.maxAge}`]; + if (opts.httpOnly !== false) parts.push("HttpOnly"); + if (opts.secure !== false) parts.push("Secure"); + parts.push(`SameSite=${opts.sameSite ?? "Lax"}`); + c.header("Set-Cookie", parts.join("; "), { append: true }); +} diff --git a/edge-totp/otp-app/src/lib/html.ts b/edge-totp/otp-app/src/lib/html.ts new file mode 100644 index 0000000..b792513 --- /dev/null +++ b/edge-totp/otp-app/src/lib/html.ts @@ -0,0 +1,51 @@ +// Shared HTML helpers for the hosted challenge/enroll pages. Kept in one place +// so the escaping used on every reflected value (branding, error text) has a +// single definition to audit, and the branding chrome can't drift between the +// two pages. + +export function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +export interface Branding { + brandName?: string | null; + brandLogoUrl?: string | null; + brandFaviconUrl?: string | null; + brandButtonColor?: string | null; + brandButtonHoverColor?: string | null; +} + +export interface Chrome { + title: string; + logoHtml: string; + faviconHtml: string; + btnColor: string; + btnHoverCss: string; +} + +/** + * Derive the escaped, ready-to-interpolate branding fragments shared by both + * pages. `baseTitle` is the page-specific heading (e.g. "Two-factor + * authentication"); the brand name, when set, is appended after a separator. + */ +export function brandingChrome(b: Branding, baseTitle: string): Chrome { + return { + title: b.brandName + ? `${baseTitle} · ${escapeHtml(b.brandName)}` + : baseTitle, + logoHtml: b.brandLogoUrl + ? `` + : "", + faviconHtml: b.brandFaviconUrl + ? `` + : "", + btnColor: escapeHtml(b.brandButtonColor ?? "#0066cc"), + btnHoverCss: b.brandButtonHoverColor + ? `background: ${escapeHtml(b.brandButtonHoverColor)};` + : "filter: brightness(0.88);", + }; +} diff --git a/edge-totp/otp-app/src/lib/jwt.ts b/edge-totp/otp-app/src/lib/jwt.ts new file mode 100644 index 0000000..e360d34 --- /dev/null +++ b/edge-totp/otp-app/src/lib/jwt.ts @@ -0,0 +1,187 @@ +import { SignJWT, jwtVerify } from "jose"; + +function encodeUtf8(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +export async function importEs256PrivateKey(pem: string): Promise { + const b64 = pem + .replace(/-----BEGIN PRIVATE KEY-----/, "") + .replace(/-----END PRIVATE KEY-----/, "") + .replace(/\s+/g, ""); + const der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + return crypto.subtle.importKey( + "pkcs8", + der, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign"], + ); +} + +// --- Handoff ticket (HS256, signed by origin, verified by edge) --- + +export interface HandoffClaims { + sub: string; + next: string; +} + +export async function verifyHandoffTicket( + token: string, + secret: string, + opts: { iss?: string; aud?: string } = {}, +): Promise { + try { + const { payload } = await jwtVerify(token, encodeUtf8(secret), { + algorithms: ["HS256"], + // A handoff ticket MUST carry exp. jose validates exp only when present, + // so without this an origin that forgets to set exp would mint a ticket + // that never expires and can be replayed indefinitely (it rides in the + // URL). Require it and cap the absolute age as defence-in-depth. + requiredClaims: ["exp"], + maxTokenAge: "10 minutes", + ...(opts.iss ? { issuer: opts.iss } : {}), + ...(opts.aud ? { audience: opts.aud } : {}), + }); + // A handoff ticket is minted by the origin and never carries a seed. Reject + // any token that does — it would be an enroll cookie (same key, overlapping + // claims) being replayed through the handoff path. + if (typeof payload.sub !== "string" || typeof payload["next"] !== "string") { + return null; + } + if ("seed" in payload) return null; + return { sub: payload.sub, next: payload["next"] as string }; + } catch { + return null; + } +} + +/** + * Stable, unguessable fingerprint of a handoff ticket, derived from its + * signature segment. Used as a Cache key to enforce single-use: the signature + * is unique per minted ticket, so two distinct tickets never collide and the + * same ticket always maps to the same key. We hash it (rather than use the raw + * signature) to keep the cache key short and bounded. + */ +export async function ticketFingerprint(token: string): Promise { + const sig = token.slice(token.lastIndexOf(".") + 1); + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(sig), + ); + const bytes = new Uint8Array(digest); + let hex = ""; + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i].toString(16).padStart(2, "0"); + } + return hex.slice(0, 32); +} + +// --- mfa_session (HS256, edge-internal: HTTP app ↔ Rust filter) --- + +export async function signMfaSession( + sub: string, + secret: string, + ttlSeconds: number, + opts: { iss?: string; aud?: string } = {}, +): Promise { + const now = Math.floor(Date.now() / 1000); + let builder = new SignJWT({ sub, amr: ["otp"] }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setIssuedAt(now) + .setExpirationTime(now + ttlSeconds); + if (opts.iss) builder = builder.setIssuer(opts.iss); + if (opts.aud) builder = builder.setAudience(opts.aud); + return builder.sign(encodeUtf8(secret)); +} + +// mfa_session is verified in production by the Rust filter (otp-filter), not +// here — the app only signs it. No TS verifier is kept to avoid a second, +// untested verification path drifting from the filter's. + +// --- ES256 one-time proof + JWKS (Profile B) --- + +export async function signProof( + sub: string, + privateKey: CryptoKey, + ttlSeconds: number, + jti: string, + opts: { iss?: string; aud?: string } = {}, +): Promise { + const now = Math.floor(Date.now() / 1000); + let builder = new SignJWT({ sub, amr: ["otp"], jti }) + .setProtectedHeader({ alg: "ES256", typ: "JWT" }) + .setIssuedAt(now) + .setExpirationTime(now + ttlSeconds); + if (opts.iss) builder = builder.setIssuer(opts.iss); + if (opts.aud) builder = builder.setAudience(opts.aud); + return builder.sign(privateKey); +} + +// --- Enrollment cookie (HS256, short-lived, carries pending seed + next) --- +// Written during GET /activate (before KV write); verified during POST /activate. +// Avoids persisting an unconfirmed seed to KV — the seed is only written after +// the user proves they scanned correctly. + +export interface EnrollClaims { + sub: string; + seed: string; + next: string; +} + +// The enroll cookie shares HANDOFF_KEY with the handoff ticket and has +// overlapping claims. A `purpose` marker (asserted on verify, and rejected on +// the handoff path) makes the two token types non-interchangeable. +const ENROLL_PURPOSE = "totp-enroll"; + +export async function signEnrollCookie( + sub: string, + seed: string, + next: string, + secret: string, + ttlSeconds: number, +): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ sub, seed, next, purpose: ENROLL_PURPOSE }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setIssuedAt(now) + .setExpirationTime(now + ttlSeconds) + .sign(encodeUtf8(secret)); +} + +export async function verifyEnrollCookie( + token: string, + secret: string, +): Promise { + try { + const { payload } = await jwtVerify(token, encodeUtf8(secret), { + algorithms: ["HS256"], + }); + if (payload["purpose"] !== ENROLL_PURPOSE) return null; + if ( + typeof payload.sub !== "string" || + typeof payload["seed"] !== "string" || + typeof payload["next"] !== "string" + ) return null; + return { + sub: payload.sub, + seed: payload["seed"] as string, + next: payload["next"] as string, + }; + } catch { + return null; + } +} + +// --- JWKS --- + +/** + * Build the JWKS response body from the pre-computed public JWK env var. + * exportKey is unavailable in the FastEdge runtime — the keypair is generated + * offline (scripts/gen-ec-keypair.mjs) and the public JWK stored in + * MFA_PROOF_PUBLIC_JWK (see storage-and-secrets.md). + */ +export function buildJwks(publicJwkJson: string): { keys: unknown[] } { + const parsed = JSON.parse(publicJwkJson); + return { keys: [parsed] }; +} diff --git a/edge-totp/otp-app/src/lib/qr.ts b/edge-totp/otp-app/src/lib/qr.ts new file mode 100644 index 0000000..addcec6 --- /dev/null +++ b/edge-totp/otp-app/src/lib/qr.ts @@ -0,0 +1,11 @@ +import { renderSVG } from "uqr"; + +/** + * Render an otpauth:// URI to an inline SVG string. + * uqr is pure-JS / SVG-string — no canvas or Node built-ins. + * Security: the otpauth URI embeds the seed — never send it to an + * external QR service. Render locally only. + */ +export function otpauthToSvg(uri: string): string { + return renderSVG(uri); +} diff --git a/edge-totp/otp-app/src/lib/safeEqual.ts b/edge-totp/otp-app/src/lib/safeEqual.ts new file mode 100644 index 0000000..3b4e778 --- /dev/null +++ b/edge-totp/otp-app/src/lib/safeEqual.ts @@ -0,0 +1,14 @@ +/** + * Constant-time string comparison. + * + * Returns early only on a length mismatch (string length is not secret here); + * otherwise compares every character so the time taken does not reveal how + * many leading characters matched. Used for comparing secrets — TOTP codes + * and the enroll API key — where a timing oracle would otherwise leak data. + */ +export function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} diff --git a/edge-totp/otp-app/src/lib/totp.ts b/edge-totp/otp-app/src/lib/totp.ts new file mode 100644 index 0000000..9ee5af3 --- /dev/null +++ b/edge-totp/otp-app/src/lib/totp.ts @@ -0,0 +1,108 @@ +import { base32Decode, base32Encode } from "./base32.js"; +import { timingSafeEqual } from "./safeEqual.js"; + +export interface TotpOptions { + digits?: number; + period?: number; + algorithm?: string; + drift?: number; +} + +function webCryptoHash(algorithm: string): string { + switch (algorithm.toUpperCase()) { + case "SHA256": return "SHA-256"; + case "SHA512": return "SHA-512"; + default: return "SHA-1"; // SHA1 is the RFC 6238 / authenticator-app default + } +} + +async function importSeed(seedBytes: Uint8Array, algorithm: string): Promise { + return crypto.subtle.importKey( + "raw", + seedBytes as unknown as ArrayBuffer, + { name: "HMAC", hash: webCryptoHash(algorithm) }, + false, + ["sign"], + ); +} + +/** + * Generate a TOTP code for a given seed and counter value. + * Exported for testing against known RFC 6238 vectors. + */ +export async function generateCode( + seed: string, + counter: number, + digits = 6, + algorithm = "SHA1", +): Promise { + const key = await importSeed(base32Decode(seed), algorithm); + + // 8-byte big-endian counter (RFC 4226 §5.3) + const buf = new ArrayBuffer(8); + const view = new DataView(buf); + view.setUint32(0, Math.floor(counter / 0x100000000), false); + view.setUint32(4, counter >>> 0, false); + + const hmac = new Uint8Array(await crypto.subtle.sign("HMAC", key, buf)); + + // RFC 4226 dynamic truncation + const offset = hmac[hmac.length - 1] & 0x0f; + const code = + ((hmac[offset] & 0x7f) << 24) | + ((hmac[offset + 1] & 0xff) << 16) | + ((hmac[offset + 2] & 0xff) << 8) | + (hmac[offset + 3] & 0xff); + + return String(code % 10 ** digits).padStart(digits, "0"); +} + +/** + * Find the absolute step counter that matches code (within the drift window). + * Returns the matching step, or null. Used by the verify handler so it can + * record the exact step for POP-local replay guarding. + */ +export async function findMatchingStep( + code: string, + seed: string, + opts: TotpOptions = {}, +): Promise { + const digits = opts.digits ?? 6; + const period = opts.period ?? 30; + const algorithm = opts.algorithm ?? "SHA1"; + const drift = opts.drift ?? 1; + + const normalised = code.replace(/\s/g, ""); + if (normalised.length !== digits) return null; + + const counter = Math.floor(Date.now() / 1000 / period); + + for (let step = -drift; step <= drift; step++) { + const expected = await generateCode(seed, counter + step, digits, algorithm); + if (timingSafeEqual(normalised, expected)) return counter + step; + } + + return null; +} + +export async function generateSecret(): Promise { + const bytes = new Uint8Array(20); + crypto.getRandomValues(bytes); + return base32Encode(bytes); +} + +export function otpauthUri( + secret: string, + account: string, + issuer: string, + opts: { digits?: number; period?: number; algorithm?: string } = {}, +): string { + const params = new URLSearchParams({ + secret, + issuer, + digits: String(opts.digits ?? 6), + period: String(opts.period ?? 30), + algorithm: opts.algorithm ?? "SHA1", + }); + return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(account)}?${params}`; +} diff --git a/edge-totp/otp-app/src/lib/validate.ts b/edge-totp/otp-app/src/lib/validate.ts new file mode 100644 index 0000000..0296cf3 --- /dev/null +++ b/edge-totp/otp-app/src/lib/validate.ts @@ -0,0 +1,65 @@ +/** + * Only relative paths (starting with `/` but not `//` or `/\`) are allowed as + * redirect targets. Absolute URLs are rejected — same-host is required and the + * handoff ticket already came from the trusted origin. Anything else collapses + * to `/`, so a tampered/odd `next` can't become an open redirect. + */ +export function validateRedirect(next: string): string { + if ( + typeof next === "string" && + next.startsWith("/") && + !/^\/[\\/]/.test(next) + ) { + return next; + } + return "/"; +} + +/** + * Parse a boolean config value. Unset/empty returns `fallback`; `true/1/yes` + * and `false/0/no` (case-insensitive) map as expected; any other value falls + * back rather than guessing. Kept here (dependency-free) so it is unit-testable + * under plain Node alongside validateInt. + */ +export function parseBool( + raw: string | null | undefined, + fallback: boolean, +): boolean { + if (raw === undefined || raw === null || raw === "") return fallback; + const v = raw.trim().toLowerCase(); + if (v === "true" || v === "1" || v === "yes") return true; + if (v === "false" || v === "0" || v === "no") return false; + return fallback; +} + +/** + * Parse an integer config value, applying `fallback` when unset/empty and + * rejecting anything that is not a whole number within [min, max]. We fail + * loudly rather than let `parseInt` silently coerce: `parseInt("6abc")` is 6 + * (hides a typo) and `parseInt("abc")` is NaN (turns into a confusing run-time + * misbehaviour — e.g. drift=NaN makes the verify loop never run so every code + * "fails"). A misconfigured edge app should surface the bad value, not degrade + * quietly. + * + * Kept dependency-free (no `fastedge::*` imports) so it is unit-testable under + * plain Node. + */ +export function validateInt( + name: string, + raw: string | null | undefined, + fallback: number, + bounds: { min?: number; max?: number } = {}, +): number { + if (raw === undefined || raw === null || raw === "") return fallback; + const n = Number(raw); + if (!Number.isInteger(n)) { + throw new Error(`Config error: ${name}="${raw}" must be an integer`); + } + if (bounds.min !== undefined && n < bounds.min) { + throw new Error(`Config error: ${name}=${n} must be >= ${bounds.min}`); + } + if (bounds.max !== undefined && n > bounds.max) { + throw new Error(`Config error: ${name}=${n} must be <= ${bounds.max}`); + } + return n; +} diff --git a/edge-totp/otp-app/src/seed/kv.ts b/edge-totp/otp-app/src/seed/kv.ts new file mode 100644 index 0000000..6d1ae79 --- /dev/null +++ b/edge-totp/otp-app/src/seed/kv.ts @@ -0,0 +1,51 @@ +import { KvStore } from "fastedge::kv"; + +export const DEFAULT_KEY_PREFIX = "totp:"; + +/** + * Read a TOTP seed for a given userId from KV. + * Returns the base32-encoded seed string, or null if not enrolled. + */ +export function readSeed(storeName: string, userId: string, keyPrefix = DEFAULT_KEY_PREFIX): string | null { + const store = KvStore.open(storeName); + const buf = store.get(keyPrefix + userId); + if (!buf) return null; + return new TextDecoder().decode(buf); +} + +/** + * Write a TOTP seed for a given userId via the Gcore KV REST API. + * The fastedge::kv SDK is read-only — writes go through the management API. + * + * API: PUT /fastedge/v1/kv/{storeId}/data + * Body: array of { key, datatype, op, payload: { value, encoding } } entries. + * The seed is stored with encoding "plain" — see context/security/threat-model.md + * (R4) for the at-rest implications. + */ +export async function writeSeed( + apiUrl: string, + apiToken: string, + storeId: string, + userId: string, + seed: string, + keyPrefix = DEFAULT_KEY_PREFIX, +): Promise { + const url = `${apiUrl}/fastedge/v1/kv/${storeId}/data`; + const headers = { "Content-Type": "application/json", Authorization: `APIKey ${apiToken}` }; + const body = JSON.stringify([ + { key: keyPrefix + userId, datatype: "kv", op: "add", payload: { value: seed, encoding: "plain" } }, + ]); + const res = await fetch(url, { method: "PUT", headers, body }); + if (!res.ok) { + const errBody = await res.text().catch(() => ""); + throw new Error(`KV write failed: ${res.status} ${errBody}`); + } +} + +/** + * Check whether a user already has a seed enrolled. + * Used to guard against silent re-enrollment. + */ +export function isEnrolled(storeName: string, userId: string, keyPrefix = DEFAULT_KEY_PREFIX): boolean { + return readSeed(storeName, userId, keyPrefix) !== null; +} diff --git a/edge-totp/otp-app/tests/unit/base32.test.ts b/edge-totp/otp-app/tests/unit/base32.test.ts new file mode 100644 index 0000000..ac159d7 --- /dev/null +++ b/edge-totp/otp-app/tests/unit/base32.test.ts @@ -0,0 +1,58 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { base32Decode, base32Encode } from "../../src/lib/base32.js"; + +// RFC 4648 §10 test vectors +test("base32Decode: empty string", () => { + assert.deepEqual(base32Decode(""), new Uint8Array(0)); +}); + +test("base32Decode: 'MY======' → 'f'", () => { + assert.deepEqual(base32Decode("MY======"), new Uint8Array([0x66])); +}); + +test("base32Decode: 'MZXQ====' → 'fo'", () => { + assert.deepEqual(base32Decode("MZXQ===="), new Uint8Array([0x66, 0x6f])); +}); + +test("base32Decode: 'MZXW6===' → 'foo'", () => { + assert.deepEqual(base32Decode("MZXW6==="), new Uint8Array([0x66, 0x6f, 0x6f])); +}); + +test("base32Decode: 'MZXW6YQ=' → 'foob'", () => { + assert.deepEqual(base32Decode("MZXW6YQ="), new Uint8Array([0x66, 0x6f, 0x6f, 0x62])); +}); + +test("base32Decode: 'MZXW6YTB' → 'fooba'", () => { + assert.deepEqual( + base32Decode("MZXW6YTB"), + new Uint8Array([0x66, 0x6f, 0x6f, 0x62, 0x61]), + ); +}); + +test("base32Decode: RFC 6238 SHA1 seed (ASCII '12345678901234567890')", () => { + const expected = new TextEncoder().encode("12345678901234567890"); + assert.deepEqual(base32Decode("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"), expected); +}); + +test("base32Decode: case-insensitive", () => { + assert.deepEqual(base32Decode("mfrgg==="), base32Decode("MFRGG===")); +}); + +test("base32Decode: strips whitespace", () => { + assert.deepEqual(base32Decode("MF RG G==="), base32Decode("MFRGG===")); +}); + +test("base32Decode: throws on invalid character", () => { + assert.throws(() => base32Decode("MFRG1==="), /Invalid base32 character/); +}); + +test("base32Encode + base32Decode: roundtrip", () => { + const input = new Uint8Array([0x00, 0xff, 0x10, 0xab, 0xcd, 0xef]); + assert.deepEqual(base32Decode(base32Encode(input)), input); +}); + +test("base32Encode: all-zero bytes", () => { + // 5 zero bytes → 8 'A' chars (5 * 8 bits = 40 bits = 8 * 5-bit groups, all zero) + assert.equal(base32Encode(new Uint8Array(5)), "AAAAAAAA"); +}); diff --git a/edge-totp/otp-app/tests/unit/jwt.test.ts b/edge-totp/otp-app/tests/unit/jwt.test.ts new file mode 100644 index 0000000..9ae3d82 --- /dev/null +++ b/edge-totp/otp-app/tests/unit/jwt.test.ts @@ -0,0 +1,210 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + verifyHandoffTicket, + signMfaSession, + signProof, + buildJwks, + importEs256PrivateKey, + signEnrollCookie, + verifyEnrollCookie, + ticketFingerprint, +} from "../../src/lib/jwt.js"; +import { SignJWT } from "jose"; + +const SECRET = "test-handoff-secret-32-bytes-long!"; + +function encodeUtf8(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +async function mintHandoffTicket( + sub: string, + next: string, + secret: string, + ttl = 90, +): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ sub, next }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(now) + .setExpirationTime(now + ttl) + .sign(encodeUtf8(secret)); +} + +// --- Handoff ticket --- + +test("verifyHandoffTicket: valid token returns claims", async () => { + const token = await mintHandoffTicket("user1", "/dashboard", SECRET); + const claims = await verifyHandoffTicket(token, SECRET); + assert.equal(claims?.sub, "user1"); + assert.equal(claims?.next, "/dashboard"); +}); + +test("verifyHandoffTicket: wrong secret returns null", async () => { + const token = await mintHandoffTicket("user1", "/dashboard", SECRET); + const claims = await verifyHandoffTicket(token, "wrong-secret"); + assert.equal(claims, null); +}); + +test("verifyHandoffTicket: expired token returns null", async () => { + const token = await mintHandoffTicket("user1", "/dashboard", SECRET, -10); + const claims = await verifyHandoffTicket(token, SECRET); + assert.equal(claims, null); +}); + +test("verifyHandoffTicket: missing next claim returns null", async () => { + const now = Math.floor(Date.now() / 1000); + const token = await new SignJWT({ sub: "user1" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(now) + .setExpirationTime(now + 90) + .sign(encodeUtf8(SECRET)); + const claims = await verifyHandoffTicket(token, SECRET); + assert.equal(claims, null); +}); + +test("verifyHandoffTicket: ticket without exp returns null", async () => { + const token = await new SignJWT({ sub: "user1", next: "/dashboard" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(Math.floor(Date.now() / 1000)) + .sign(encodeUtf8(SECRET)); + assert.equal(await verifyHandoffTicket(token, SECRET), null); +}); + +test("verifyHandoffTicket: ticket older than maxTokenAge returns null", async () => { + // iat 20 min ago but exp still in the future — only the maxTokenAge cap rejects it. + const old = Math.floor(Date.now() / 1000) - 20 * 60; + const token = await new SignJWT({ sub: "user1", next: "/dashboard" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(old) + .setExpirationTime(old + 60 * 60) + .sign(encodeUtf8(SECRET)); + assert.equal(await verifyHandoffTicket(token, SECRET), null); +}); + +test("verifyHandoffTicket: token carrying a seed claim returns null", async () => { + const now = Math.floor(Date.now() / 1000); + const token = await new SignJWT({ sub: "user1", next: "/", seed: "JBSWY3DP" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(now) + .setExpirationTime(now + 90) + .sign(encodeUtf8(SECRET)); + assert.equal(await verifyHandoffTicket(token, SECRET), null); +}); + +// --- enroll cookie (purpose binding) --- + +test("signEnrollCookie + verifyEnrollCookie: roundtrip", async () => { + const token = await signEnrollCookie("user1", "JBSWY3DP", "/dash", SECRET, 600); + const claims = await verifyEnrollCookie(token, SECRET); + assert.equal(claims?.sub, "user1"); + assert.equal(claims?.seed, "JBSWY3DP"); + assert.equal(claims?.next, "/dash"); +}); + +test("verifyEnrollCookie: token without enroll purpose returns null", async () => { + // A handoff-shaped token that happens to carry a seed must not pass as an enroll cookie. + const now = Math.floor(Date.now() / 1000); + const token = await new SignJWT({ sub: "user1", next: "/", seed: "JBSWY3DP" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(now) + .setExpirationTime(now + 600) + .sign(encodeUtf8(SECRET)); + assert.equal(await verifyEnrollCookie(token, SECRET), null); +}); + +// --- mfa_session (verified in production by the Rust filter, not in TS) --- + +test("signMfaSession: emits HS256 token with sub/amr/exp/aud/iss", async () => { + const token = await signMfaSession("alice", SECRET, 3600, { + iss: "totp-app", + aud: "myapp", + }); + const [headerB64, payloadB64] = token.split("."); + const header = JSON.parse(Buffer.from(headerB64, "base64url").toString()); + const payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString()); + assert.equal(header.alg, "HS256"); + assert.equal(payload.sub, "alice"); + assert.deepEqual(payload.amr, ["otp"]); + assert.equal(payload.iss, "totp-app"); + assert.equal(payload.aud, "myapp"); + assert.ok(payload.exp > payload.iat); +}); + +// --- ticketFingerprint (single-use ticket key derivation) --- + +test("ticketFingerprint: stable for the same token", async () => { + const token = await mintHandoffTicket("user1", "/dashboard", SECRET); + assert.equal(await ticketFingerprint(token), await ticketFingerprint(token)); +}); + +test("ticketFingerprint: differs for distinct tokens", async () => { + const a = await mintHandoffTicket("user1", "/dashboard", SECRET); + const b = await mintHandoffTicket("user2", "/dashboard", SECRET); + assert.notEqual(await ticketFingerprint(a), await ticketFingerprint(b)); +}); + +test("ticketFingerprint: bounded-length lowercase hex", async () => { + const token = await mintHandoffTicket("user1", "/dashboard", SECRET); + const fp = await ticketFingerprint(token); + assert.equal(fp.length, 32); + assert.match(fp, /^[0-9a-f]+$/); +}); + +// --- buildJwks --- + +test("buildJwks: wraps parsed JWK in keys array", () => { + const jwk = { kty: "EC", crv: "P-256", x: "abc", y: "def", use: "sig" }; + const result = buildJwks(JSON.stringify(jwk)); + assert.deepEqual(result, { keys: [jwk] }); +}); + +test("buildJwks: throws on invalid JSON", () => { + assert.throws(() => buildJwks("not json"), /SyntaxError|Unexpected/); +}); + +// --- ES256 proof (requires an actual keypair) --- + +test("signProof: produces a JWT signed with ES256", async () => { + // Generate a test keypair using the Web Crypto API (Node has this) + const keyPair = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + + const token = await signProof("bob", keyPair.privateKey, 90, "jti-abc", { + iss: "totp-app", + aud: "myapp", + }); + + // Decode header to confirm ES256 alg + const [headerB64] = token.split("."); + const header = JSON.parse(Buffer.from(headerB64, "base64url").toString()); + assert.equal(header.alg, "ES256"); + + // Decode payload to confirm claims + const [, payloadB64] = token.split("."); + const payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString()); + assert.equal(payload.sub, "bob"); + assert.equal(payload.jti, "jti-abc"); + assert.deepEqual(payload.amr, ["otp"]); +}); + +test("importEs256PrivateKey: accepts PKCS8 PEM from gen-ec-keypair.mjs format", async () => { + // Generate + export a key pair to produce a real PKCS8 PEM for testing + const keyPair = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + const pkcs8Der = await crypto.subtle.exportKey("pkcs8", keyPair.privateKey); + const b64 = Buffer.from(pkcs8Der).toString("base64"); + const lines = b64.match(/.{1,64}/g)!.join("\n"); + const pem = `-----BEGIN PRIVATE KEY-----\n${lines}\n-----END PRIVATE KEY-----`; + + // importEs256PrivateKey must accept this PEM without throwing + const imported = await importEs256PrivateKey(pem); + assert.equal(typeof imported, "object"); +}); diff --git a/edge-totp/otp-app/tests/unit/totp.test.ts b/edge-totp/otp-app/tests/unit/totp.test.ts new file mode 100644 index 0000000..1f01ec7 --- /dev/null +++ b/edge-totp/otp-app/tests/unit/totp.test.ts @@ -0,0 +1,74 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { generateCode, otpauthUri } from "../../src/lib/totp.js"; + +// RFC 6238 Appendix B test vectors. +// Seed: ASCII "12345678901234567890" = base32 GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ +// Algorithm: SHA1 (authenticator-app default), 8-digit, period=30, T0=0 +const RFC_SEED = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; + +function counter(unixTime: number, period = 30): number { + return Math.floor(unixTime / period); +} + +test("RFC 6238 vector T=59 → 8-digit 94287082", async () => { + const code = await generateCode(RFC_SEED, counter(59), 8); + assert.equal(code, "94287082"); +}); + +test("RFC 6238 vector T=1111111109 → 8-digit 07081804", async () => { + const code = await generateCode(RFC_SEED, counter(1111111109), 8); + assert.equal(code, "07081804"); +}); + +test("RFC 6238 vector T=1234567890 → 8-digit 89005924", async () => { + const code = await generateCode(RFC_SEED, counter(1234567890), 8); + assert.equal(code, "89005924"); +}); + +test("RFC 6238 vector T=2000000000 → 8-digit 69279037", async () => { + const code = await generateCode(RFC_SEED, counter(2000000000), 8); + assert.equal(code, "69279037"); +}); + +// 6-digit = rightmost 6 of the 8-digit code +test("RFC 6238 vector T=59 → 6-digit 287082", async () => { + const code = await generateCode(RFC_SEED, counter(59), 6); + assert.equal(code, "287082"); +}); + +test("RFC 6238 vector T=1111111109 → 6-digit 081804", async () => { + const code = await generateCode(RFC_SEED, counter(1111111109), 6); + assert.equal(code, "081804"); +}); + +test("RFC 6238 vector T=1234567890 → 6-digit 005924", async () => { + const code = await generateCode(RFC_SEED, counter(1234567890), 6); + assert.equal(code, "005924"); +}); + +test("RFC 6238 vector T=2000000000 → 6-digit 279037", async () => { + const code = await generateCode(RFC_SEED, counter(2000000000), 6); + assert.equal(code, "279037"); +}); + +// Drift: same counter at adjacent step +test("adjacent counter produces a different code", async () => { + const c = counter(1111111109); + const a = await generateCode(RFC_SEED, c, 6); + const b = await generateCode(RFC_SEED, c + 1, 6); + assert.notEqual(a, b); +}); + +test("otpauthUri: produces correct scheme and query params", () => { + const uri = otpauthUri("JBSWY3DPEHPK3PXP", "alice@example.com", "Acme", { + digits: 6, + period: 30, + algorithm: "SHA1", + }); + assert.ok(uri.startsWith("otpauth://totp/"), `unexpected scheme: ${uri}`); + assert.ok(uri.includes("secret=JBSWY3DPEHPK3PXP"), `missing secret: ${uri}`); + assert.ok(uri.includes("issuer=Acme"), `missing issuer: ${uri}`); + assert.ok(uri.includes("digits=6"), `missing digits: ${uri}`); + assert.ok(uri.includes("period=30"), `missing period: ${uri}`); +}); diff --git a/edge-totp/otp-app/tests/unit/validate.test.ts b/edge-totp/otp-app/tests/unit/validate.test.ts new file mode 100644 index 0000000..d2b9ded --- /dev/null +++ b/edge-totp/otp-app/tests/unit/validate.test.ts @@ -0,0 +1,66 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { validateInt, validateRedirect, parseBool } from "../../src/lib/validate.js"; + +test("validateRedirect: keeps a same-host relative path", () => { + assert.equal(validateRedirect("/dashboard"), "/dashboard"); + assert.equal(validateRedirect("/"), "/"); + assert.equal(validateRedirect("/path?x=1#h"), "/path?x=1#h"); +}); + +test("validateRedirect: rejects protocol-relative and backslash tricks", () => { + assert.equal(validateRedirect("//evil.com"), "/"); + assert.equal(validateRedirect("/\\evil.com"), "/"); +}); + +test("validateRedirect: rejects absolute URLs and non-paths", () => { + assert.equal(validateRedirect("https://evil.com"), "/"); + assert.equal(validateRedirect("javascript:alert(1)"), "/"); + assert.equal(validateRedirect(""), "/"); +}); + +test("validateInt: unset/empty falls back to default", () => { + assert.equal(validateInt("X", undefined, 30), 30); + assert.equal(validateInt("X", null, 30), 30); + assert.equal(validateInt("X", "", 30), 30); +}); + +test("validateInt: parses a valid integer", () => { + assert.equal(validateInt("X", "45", 30), 45); +}); + +test("validateInt: rejects non-integer (no silent parseInt coercion)", () => { + // parseInt("6abc") would be 6 — we must reject it instead of hiding the typo. + assert.throws(() => validateInt("TOTP_DIGITS", "6abc", 6), /must be an integer/); + assert.throws(() => validateInt("TOTP_DRIFT", "abc", 1), /must be an integer/); + assert.throws(() => validateInt("TOTP_PERIOD", "1.5", 30), /must be an integer/); +}); + +test("validateInt: enforces min bound", () => { + assert.throws(() => validateInt("TOTP_DIGITS", "4", 6, { min: 6 }), /must be >= 6/); +}); + +test("validateInt: enforces max bound", () => { + assert.throws(() => validateInt("TOTP_DIGITS", "9", 6, { min: 6, max: 8 }), /must be <= 8/); +}); + +test("validateInt: accepts values on the boundary", () => { + assert.equal(validateInt("TOTP_DIGITS", "6", 6, { min: 6, max: 8 }), 6); + assert.equal(validateInt("TOTP_DIGITS", "8", 6, { min: 6, max: 8 }), 8); +}); + +test("parseBool: unset/empty falls back to default", () => { + assert.equal(parseBool(undefined, true), true); + assert.equal(parseBool(null, true), true); + assert.equal(parseBool("", false), false); +}); + +test("parseBool: recognises truthy and falsy spellings (case-insensitive)", () => { + for (const v of ["true", "TRUE", "1", "yes", " Yes "]) assert.equal(parseBool(v, false), true); + for (const v of ["false", "FALSE", "0", "no", " No "]) assert.equal(parseBool(v, true), false); +}); + +test("parseBool: unrecognised value falls back rather than guessing", () => { + assert.equal(parseBool("maybe", true), true); + assert.equal(parseBool("maybe", false), false); +}); diff --git a/edge-totp/otp-app/tsconfig.json b/edge-totp/otp-app/tsconfig.json new file mode 100644 index 0000000..c678d62 --- /dev/null +++ b/edge-totp/otp-app/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "lib": ["ES2023"], + "types": ["@gcoredev/fastedge-sdk-js"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/edge-totp/otp-filter/.cargo/config.toml b/edge-totp/otp-filter/.cargo/config.toml new file mode 100755 index 0000000..dc0be73 --- /dev/null +++ b/edge-totp/otp-filter/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "wasm32-wasip1" \ No newline at end of file diff --git a/edge-totp/otp-filter/.env.example b/edge-totp/otp-filter/.env.example new file mode 100644 index 0000000..a6bba48 --- /dev/null +++ b/edge-totp/otp-filter/.env.example @@ -0,0 +1,61 @@ +# ============================================================================ +# otp-filter — TOTP CDN filter configuration +# ============================================================================ +# Copy this file to .env and fill in real values. .env is git-ignored — never +# commit real secrets. +# +# Two prefixes map to two places in the Gcore portal: +# FASTEDGE_VAR_ENV_ -> Environment Variables (non-sensitive config) +# FASTEDGE_VAR_SECRET_ -> Secret Manager (sensitive values) +# +# This filter runs in the CDN proxy in front of the protected resource. On every +# request it verifies the mfa_session cookie (HS256) and either passes the +# request through or redirects/denies. It pairs with otp-app. +# +# ---------------------------------------------------------------------------- +# Keys shared with otp-app — these MUST match otp-app's .env +# ---------------------------------------------------------------------------- +# MFA_SESSION_KEY otp-app signs the mfa_session token with this key +# MFA_SESSION_COOKIE must equal otp-app's MFA_SESSION_COOKIE +# AUTH_PREFIX must equal otp-app's AUTH_PREFIX +# MFA_AUDIENCE must equal otp-app's MFA_AUDIENCE +# MFA_ISSUER (optional) only checked when set on BOTH sides +# ============================================================================ + + +# --- Token verification (HS256) -------------------------------------------- + +# REQUIRED. HS256 key used to verify the mfa_session JWT signed by otp-app. +# Must be the same value as MFA_SESSION_KEY in otp-app. +FASTEDGE_VAR_SECRET_MFA_SESSION_KEY=change-me-to-a-long-random-string + + +# --- Token binding (audience / issuer) ------------------------------------- + +# REQUIRED and fail-closed. The filter refuses EVERY session unless this is +# set and equals the token's `aud`. Must match MFA_AUDIENCE in otp-app. +# (otp-app must also be configured to embed this value — see its MFA_AUDIENCE.) +FASTEDGE_VAR_ENV_MFA_AUDIENCE=https://app.example.com + +# OPTIONAL. When set, the token's `iss` must match. Only meaningful if +# otp-app also sets MFA_ISSUER. Leave unset to skip the issuer check. +# FASTEDGE_VAR_ENV_MFA_ISSUER=https://auth.example.com + + +# --- Request handling ------------------------------------------------------- + +# Cookie name to read the mfa_session token from. Must match otp-app. +# Default: mfa_session +FASTEDGE_VAR_ENV_MFA_SESSION_COOKIE=mfa_session + +# Where to bypass — the totp-app path prefix (challenge, verify, enroll, JWKS). +# Must match otp-app's AUTH_PREFIX so the login flow can complete. +# Default: /auth/totp +# FASTEDGE_VAR_ENV_AUTH_PREFIX=/auth/totp + +# Where to send unauthenticated users. This should be a URL on the customer +# origin that initiates the MFA flow: verifies the password session, signs a +# handoff ticket, and 303-redirects to /auth/totp/challenge?t=. +# The original URL is appended as ?redirect= for post-MFA return. +# If unset, denied requests get a 401 instead of a redirect (useful for APIs). +FASTEDGE_VAR_ENV_MFA_LOGIN_URL=https://app.example.com/login/mfa diff --git a/edge-totp/otp-filter/Cargo.lock b/edge-totp/otp-filter/Cargo.lock new file mode 100644 index 0000000..dc8e446 --- /dev/null +++ b/edge-totp/otp-filter/Cargo.lock @@ -0,0 +1,650 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fastedge" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d665bf3e9be79d0be271c1fd4233c752d0c52f0e48c8e4e8cdb6da6ea6915a6a" +dependencies = [ + "bytes", + "fastedge-derive", + "http", + "mime", + "thiserror", + "wit-bindgen", +] + +[[package]] +name = "fastedge-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddaea791529e71281550aa22e30597e23362039f7027fe7f9db5d55a16c338ba" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proxy-wasm" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de8f6564bd52c2f4ff79fa5d1bd3bc10d8f822162af8d527e121e46703496aa0" +dependencies = [ + "hashbrown 0.16.1", + "log", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "totp-filter" +version = "0.1.0" +dependencies = [ + "base64", + "fastedge", + "hmac", + "proxy-wasm", + "serde_json", + "sha2", + "urlencoding", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-encoder" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be00faa2b4950c76fe618c409d2c3ea5a3c9422013e079482d78544bb2d184c" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20b3ec880a9ac69ccd92fbdbcf46ee833071cf09f82bb005b2327c7ae6025ae2" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +dependencies = [ + "bitflags", + "futures", + "once_cell", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabd629f94da277abc739c71353397046401518efb2c707669f805205f0b9890" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a4232e841089fa5f3c4fc732a92e1c74e1a3958db3b12f1de5934da2027f1f4" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0d4698c2913d8d9c2b220d116409c3f51a7aa8d7765151b886918367179ee9" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a866b19dba2c94d706ec58c92a4c62ab63e482b4c935d2a085ac94caecb136" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55c92c939d667b7bf0c6bf2d1f67196529758f99a2a45a3355cc56964fd5315d" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/edge-totp/otp-filter/Cargo.toml b/edge-totp/otp-filter/Cargo.toml new file mode 100644 index 0000000..65f8d4e --- /dev/null +++ b/edge-totp/otp-filter/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "totp-filter" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +proxy-wasm = "0.2.1" +fastedge = { version = "0.4", default-features = false, features = ["proxywasm"] } +hmac = "0.12" +sha2 = "0.10" +base64 = "0.22" +serde_json = "1.0" +urlencoding = "2.1.2" + +[profile.release] +opt-level = "s" +lto = true diff --git a/edge-totp/otp-filter/package.json b/edge-totp/otp-filter/package.json new file mode 100644 index 0000000..c1721fc --- /dev/null +++ b/edge-totp/otp-filter/package.json @@ -0,0 +1,16 @@ +{ + "name": "otp-filter", + "description": "Edge MFA enforcement filter (Rust proxy-wasm) — black-box wasm tests", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "cargo build --release", + "test": "FASTEDGE_RUN_PATH=./node_modules/@gcoredev/fastedge-test/dist/fastedge-cli/fastedge-run-linux-x64 node --import=tsx/esm tests/filter.test.ts" + }, + "devDependencies": { + "@gcoredev/fastedge-test": "catalog:", + "jose": "catalog:", + "tsx": "catalog:" + } +} diff --git a/edge-totp/otp-filter/registry.json b/edge-totp/otp-filter/registry.json new file mode 100644 index 0000000..160f0f7 --- /dev/null +++ b/edge-totp/otp-filter/registry.json @@ -0,0 +1,49 @@ +{ + "name": "totp-filter", + "api_type": "proxy-wasm", + "description": "Edge TOTP MFA enforcement filter. Verifies the mfa_session cookie (HS256) on every CDN request and redirects or denies unauthenticated users. Pairs with totp-app which issues the session after a successful OTP challenge.", + "params": [ + { + "name": "MFA_SESSION_KEY", + "data_type": "secret", + "mandatory": true, + "descr": "HS256 key used to verify the mfa_session JWT signed by totp-app. Must be set on both totp-app (signs) and totp-filter (verifies) with the same value.", + "metadata": "{}" + }, + { + "name": "MFA_AUDIENCE", + "data_type": "string", + "mandatory": true, + "descr": "Required and fail-closed. The filter refuses every session unless this is set and equals the token's aud claim. Must match MFA_AUDIENCE on totp-app (e.g. https://app.example.com).", + "metadata": "{}" + }, + { + "name": "MFA_ISSUER", + "data_type": "string", + "mandatory": false, + "descr": "When set, the token's iss claim must match. Only meaningful if totp-app also sets MFA_ISSUER. Leave unset to skip the issuer check.", + "metadata": "{}" + }, + { + "name": "MFA_SESSION_COOKIE", + "data_type": "string", + "mandatory": false, + "descr": "Cookie name to read the mfa_session token from. Must match totp-app. Default: mfa_session.", + "metadata": "{\"default_value\":\"mfa_session\"}" + }, + { + "name": "AUTH_PREFIX", + "data_type": "string", + "mandatory": false, + "descr": "Path prefix the filter bypasses so the login flow can complete. Must match totp-app's AUTH_PREFIX and the CDN path rule routing to totp-app. Default: /auth/totp", + "metadata": "{\"default_value\":\"/auth/totp\"}" + }, + { + "name": "MFA_LOGIN_URL", + "data_type": "string", + "mandatory": false, + "descr": "Where to redirect unauthenticated users. Should be a URL on the customer origin that validates the password session and redirects to /auth/totp/challenge?t=. If unset, denied requests get a 401 instead of a redirect.", + "metadata": "{}" + } + ] +} diff --git a/edge-totp/otp-filter/src/lib.rs b/edge-totp/otp-filter/src/lib.rs new file mode 100644 index 0000000..79da52f --- /dev/null +++ b/edge-totp/otp-filter/src/lib.rs @@ -0,0 +1,258 @@ +use std::env; +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use fastedge::proxywasm::secret; +use hmac::{Hmac, Mac}; +use proxy_wasm::traits::*; +use proxy_wasm::types::*; +use sha2::Sha256; + +proxy_wasm::main! {{ + proxy_wasm::set_log_level(LogLevel::Info); + proxy_wasm::set_root_context(|_| -> Box { Box::new(OtpGuardRoot) }); +}} + +pub struct OtpGuardRoot; + +impl Context for OtpGuardRoot {} + +impl RootContext for OtpGuardRoot { + fn get_type(&self) -> Option { + Some(ContextType::HttpContext) + } + + fn create_http_context(&self, _: u32) -> Option> { + Some(Box::new(OtpGuard)) + } +} + +struct OtpGuard; + +impl Context for OtpGuard {} + +const DEFAULT_COOKIE: &str = "mfa_session"; +const DEFAULT_AUTH_PREFIX: &str = "/auth/totp"; +const EXPECTED_ALG: &str = "HS256"; + +impl HttpContext for OtpGuard { + fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { + let full_path = self + .get_property(vec!["request.path"]) + .and_then(|v| String::from_utf8(v).ok()) + .unwrap_or_default(); + + let auth_prefix = env::var("AUTH_PREFIX") + .unwrap_or_else(|_| DEFAULT_AUTH_PREFIX.to_string()); + + // Bypass the totp-app paths (challenge, verify, enroll, JWKS) and /health. + // Gating these would redirect-loop the TOTP flow. + if is_bypass_path(&full_path, &auth_prefix) { + return Action::Continue; + } + + let cookie_name = env::var("MFA_SESSION_COOKIE") + .unwrap_or_else(|_| DEFAULT_COOKIE.to_string()); + let login_url = env::var("MFA_LOGIN_URL").unwrap_or_default(); + + let cookie_header = self.get_http_request_header("cookie").unwrap_or_default(); + let token = match extract_cookie(&cookie_header, &cookie_name) { + Some(t) => t, + None => return self.deny(&full_path, &login_url, "no mfa_session cookie"), + }; + + let claims = match verify_jwt(&token) { + Err(reason) => return self.deny(&full_path, &login_url, reason), + Ok(c) => c, + }; + + // MFA_AUDIENCE is required. Fail-closed if not configured — a filter + // with no audience set cannot know which tokens belong to it, so it refuses + // every session rather than trusting any signed token indiscriminately. + let expected_aud = env::var("MFA_AUDIENCE").unwrap_or_default(); + if expected_aud.is_empty() { + return self.deny( + &full_path, + &login_url, + "MFA_AUDIENCE not configured — refusing all sessions (set MFA_AUDIENCE to match otp-app's MFA_AUDIENCE)", + ); + } + if !aud_matches(&claims.aud, &expected_aud) { + return self.deny(&full_path, &login_url, "aud mismatch"); + } + + // MFA_ISSUER is optional — validated only when set. + if let Ok(expected) = env::var("MFA_ISSUER") { + if !expected.is_empty() && claims.iss.as_deref() != Some(expected.as_str()) { + return self.deny(&full_path, &login_url, "iss mismatch"); + } + } + + println!( + "otp_guard: authorized — valid mfa_session (sub={}) for {full_path}", + claims.sub.as_deref().unwrap_or("") + ); + + Action::Continue + } +} + +impl OtpGuard { + fn deny(&mut self, full_path: &str, login_url: &str, reason: &str) -> Action { + println!("otp_guard: denied — {reason}"); + if login_url.is_empty() { + // No redirect configured: fail-closed with 401. + self.send_http_response( + 401, + vec![("content-type", "text/plain")], + Some(b"MFA required"), + ); + return Action::Pause; + } + // Encode the return target as the *relative* request path only. + // Building it from request.scheme/request.host would fold an + // attacker-controllable Host header into the redirect= value, which a + // login page that bounces back to `redirect` could turn into an open + // redirect. A relative path is inherently same-origin and matches the + // app's own same-host redirect policy (validateRedirect). + let encoded = urlencoding::encode(full_path); + let sep = if login_url.contains('?') { "&" } else { "?" }; + let location = format!("{login_url}{sep}redirect={encoded}"); + self.send_http_response(302, vec![("location", &location)], Some(b"")); + Action::Pause + } +} + +fn is_bypass_path(full_path: &str, auth_prefix: &str) -> bool { + let path = full_path.split('?').next().unwrap_or(full_path); + // Never bypass a path that carries traversal or encoded-separator + // sequences. `/auth/totp/../admin` matches the prefix below but the origin + // may normalise it back to `/admin`, slipping a protected resource past the + // gate. If the path looks abnormal, fall through to the cookie check so it + // is gated rather than waved through. + if has_suspicious_sequence(path) { + return false; + } + path == "/health" + || path == auth_prefix + || path.starts_with(&format!("{auth_prefix}/")) +} + +/// Detect path segments that could be normalised by the origin into a +/// different resource: `..`, `//`, backslashes, or percent-encoded dots and +/// slashes (`%2e`, `%2f`, including double-encoding via `%25`). +fn has_suspicious_sequence(path: &str) -> bool { + if path.contains("..") || path.contains("//") || path.contains('\\') { + return true; + } + let lower = path.to_ascii_lowercase(); + lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") || lower.contains("%25") +} + +fn extract_cookie(header: &str, name: &str) -> Option { + let needle = format!("{name}="); + for pair in header.split(';') { + let trimmed = pair.trim(); + if trimmed.starts_with(needle.as_str()) { + return Some(trimmed[needle.len()..].to_string()); + } + } + None +} + +struct JwtClaims { + sub: Option, + iss: Option, + aud: Option, +} + +fn verify_jwt(token: &str) -> Result { + let dot1 = token.find('.').ok_or("malformed token")?; + let after_header = &token[dot1 + 1..]; + let dot2 = after_header.find('.').ok_or("malformed token")?; + let header_b64 = &token[..dot1]; + let payload_b64 = &after_header[..dot2]; + let sig_b64 = &after_header[dot2 + 1..]; + let message = &token[..dot1 + 1 + dot2]; + + let header_bytes = + URL_SAFE_NO_PAD.decode(header_b64).map_err(|_| "bad header encoding")?; + let header: serde_json::Value = + serde_json::from_slice(&header_bytes).map_err(|_| "bad header JSON")?; + let alg = header["alg"].as_str().unwrap_or_default(); + + // Alg is pinned to HS256 at compile time — token cannot select its own + // verification path. + if alg != EXPECTED_ALG { + println!( + "otp_guard: token alg '{alg}' rejected — filter accepts only {EXPECTED_ALG}" + ); + return Err("unexpected alg"); + } + + let sig_bytes = + URL_SAFE_NO_PAD.decode(sig_b64).map_err(|_| "bad signature encoding")?; + + let key_bytes = secret::get("MFA_SESSION_KEY") + .ok() + .flatten() + .unwrap_or_default(); + let key = String::from_utf8(key_bytes).unwrap_or_default(); + if key.is_empty() { + println!("otp_guard: MFA_SESSION_KEY not configured"); + return Err("missing session key"); + } + verify_hs256(message, &sig_bytes, &key)?; + + let payload_bytes = + URL_SAFE_NO_PAD.decode(payload_b64).map_err(|_| "bad payload encoding")?; + let payload: serde_json::Value = + serde_json::from_slice(&payload_bytes).map_err(|_| "bad payload JSON")?; + + let exp = payload["exp"] + .as_u64() + .or_else(|| payload["exp"].as_f64().map(|f| f as u64)) + .ok_or("missing exp claim")?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + if exp <= now { + return Err("token expired"); + } + + // Honour nbf when present — a token must not be accepted before it becomes + // valid. Absent nbf is fine (the claim is optional). + if let Some(nbf) = payload["nbf"] + .as_u64() + .or_else(|| payload["nbf"].as_f64().map(|f| f as u64)) + { + if now < nbf { + return Err("token not yet valid"); + } + } + + Ok(JwtClaims { + sub: payload["sub"].as_str().map(str::to_owned), + iss: payload["iss"].as_str().map(str::to_owned), + aud: payload.get("aud").cloned(), + }) +} + +fn verify_hs256(message: &str, sig_bytes: &[u8], secret: &str) -> Result<(), &'static str> { + let mut mac = + Hmac::::new_from_slice(secret.as_bytes()).map_err(|_| "bad key length")?; + mac.update(message.as_bytes()); + mac.verify_slice(sig_bytes).map_err(|_| "invalid signature") +} + +fn aud_matches(aud: &Option, expected: &str) -> bool { + match aud { + None => false, + Some(serde_json::Value::String(s)) => s == expected, + Some(serde_json::Value::Array(arr)) => { + arr.iter().any(|v| v.as_str() == Some(expected)) + } + _ => false, + } +} diff --git a/edge-totp/otp-filter/tests/filter.test.ts b/edge-totp/otp-filter/tests/filter.test.ts new file mode 100644 index 0000000..879beaf --- /dev/null +++ b/edge-totp/otp-filter/tests/filter.test.ts @@ -0,0 +1,256 @@ +/** + * Black-box acceptance suite for the Rust enforcement filter (otp-filter). + * + * It drives the *compiled wasm* through + * @gcoredev/fastedge-test rather than unit-testing Rust functions, so it + * exercises the artifact that actually ships — including the request lifecycle, + * env/secret wiring, the deny()/redirect path, and the 401 fail-closed path + * that pure-function tests can't reach. + * + * The filter is HS256-only (the edge-internal mfa_session). Tokens are minted + * with jose to match what otp-app's signMfaSession produces. + */ +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { SignJWT } from "jose"; +import { + defineTestSuite, + runTestSuite, + runFlow, +} from "@gcoredev/fastedge-test/test"; + +const WASM = "target/wasm32-wasip1/release/totp_filter.wasm"; + +const SESSION_KEY = "test-mfa-session-secret-abcdefghijklmnop0123456789"; +const AUDIENCE = "https://app.example.com"; +const OTHER_AUDIENCE = "https://other.example.com"; +const ISSUER = "https://totp.example.com"; +const LOGIN_URL = "https://cdn.example.com/auth/totp/challenge"; +const DEFAULT_COOKIE = "mfa_session"; +// A protected resource (not under the auth prefix), with a query string so we +// can assert the redirect target is the *relative path* only. +const PROTECTED_URL = "https://cdn.example.com/private/resource?foo=bar"; + +function bytes(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +async function mint( + ttlSeconds: number, + claims: Record = {}, + sub = "user-123", +): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ sub, amr: ["otp"], ...claims }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setIssuedAt(now) + .setExpirationTime(now + ttlSeconds) + .sign(bytes(SESSION_KEY)); +} + +/** + * Hand-craft a token whose header advertises a non-HS256 alg (alg-confusion / + * "alg:none" probe). jose refuses to *mint* these, but an attacker would just + * assemble the bytes — which is what we do here. The signature segment is + * arbitrary; the filter must reject on the header alg before ever checking it. + */ +function craftWithAlg(alg: string): string { + const b64 = (o: object) => Buffer.from(JSON.stringify(o)).toString("base64url"); + const header = b64({ alg, typ: "JWT" }); + const payload = b64({ sub: "user-123", aud: AUDIENCE, exp: Math.floor(Date.now() / 1000) + 3600 }); + return `${header}.${payload}.`; +} + +function tamper(jwt: string): string { + const [h, p, sig] = jwt.split("."); + const mid = Math.floor(sig.length / 2); + const flipped = sig.slice(0, mid) + (sig[mid] === "A" ? "B" : "A") + sig.slice(mid + 1); + return [h, p, flipped].join("."); +} + +function headerValue(v: string | string[] | undefined): string { + return Array.isArray(v) ? (v[0] ?? "") : (v ?? ""); +} + +interface SuiteResult { + passed: number; + total: number; + failed: number; + durationMs: number; + results: { name: string; passed: boolean; error?: string; durationMs: number }[]; +} + +function report(label: string, result: SuiteResult): void { + console.log(`\n${label}`); + for (const r of result.results) { + const mark = r.passed ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m"; + console.log(` ${mark} ${r.name} (${r.durationMs.toFixed(1)}ms)`); + if (!r.passed && r.error) console.log(` ${r.error}`); + } + console.log(` ${result.passed}/${result.total} passed in ${result.durationMs.toFixed(1)}ms`); +} + +// Assertion helpers: the filter signals authorize via Action::Continue +// (returnCode 0) and deny via a synthesized response (302 redirect, or 401 when +// no MFA_LOGIN_URL is configured). +function assertContinue(r: Awaited>): void { + if (r.hookResults.onRequestHeaders.returnCode !== 0) { + throw new Error(`expected Continue (returnCode=0), got ${r.hookResults.onRequestHeaders.returnCode}`); + } + if (r.finalResponse.status === 302 || r.finalResponse.status === 401) { + throw new Error(`expected no deny, got ${r.finalResponse.status}`); + } +} +function assertStatus(r: Awaited>, status: number): void { + if (r.finalResponse.status !== status) { + throw new Error(`expected ${status}, got ${r.finalResponse.status}`); + } +} + +async function envDir(prefix: string, lines: string[]): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)); + await writeFile(join(dir, ".env"), [...lines, ""].join("\n")); + return dir; +} + +const SECRET_KEY_ENV = `FASTEDGE_VAR_SECRET_MFA_SESSION_KEY=${SESSION_KEY}`; +const cookie = (jwt: string) => ({ cookie: `${DEFAULT_COOKIE}=${jwt}` }); + +const tempDirs: string[] = []; +let totalFailed = 0; + +async function suite( + label: string, + env: string[], + tests: { name: string; run: (runner: Parameters[0]["tests"][number]["run"]>[0]) => Promise }[], +): Promise { + const dir = await envDir("otp-filter-", env); + tempDirs.push(dir); + const result = (await runTestSuite( + defineTestSuite({ + wasmPath: WASM, + runnerConfig: { dotenv: { enabled: true, path: dir } }, + tests, + }), + )) as SuiteResult; + report(label, result); + totalFailed += result.failed; +} + +// Pre-mint tokens. +const validJwt = await mint(3600, { aud: AUDIENCE }); +const expiredJwt = await mint(-3600, { aud: AUDIENCE }); +const tamperedJwt = tamper(validJwt); +const wrongKeyJwt = await new SignJWT({ sub: "user-123", aud: AUDIENCE }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setIssuedAt(Math.floor(Date.now() / 1000)) + .setExpirationTime(Math.floor(Date.now() / 1000) + 3600) + .sign(bytes("a-different-secret-key-that-should-not-verify")); +const nowSec = Math.floor(Date.now() / 1000); +const notYetValidJwt = await mint(7200, { aud: AUDIENCE, nbf: nowSec + 3600 }); + +// --- Gate behaviour (audience configured, redirect login configured) -------- +await suite( + "Gate — default config (MFA_AUDIENCE + MFA_LOGIN_URL set):", + [SECRET_KEY_ENV, `FASTEDGE_VAR_ENV_MFA_AUDIENCE=${AUDIENCE}`, `FASTEDGE_VAR_ENV_MFA_LOGIN_URL=${LOGIN_URL}`], + [ + { + name: "valid mfa_session → Continue", + run: async (runner) => assertContinue(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(validJwt) })), + }, + { + name: "no cookie → 302 to MFA_LOGIN_URL with relative-path redirect", + run: async (runner) => { + const r = await runFlow(runner, { url: PROTECTED_URL, requestHeaders: {} }); + assertStatus(r, 302); + const loc = headerValue(r.finalResponse.headers["location"]); + if (!loc.startsWith(`${LOGIN_URL}?redirect=`)) { + throw new Error(`expected redirect to login, got '${loc}'`); + } + const target = decodeURIComponent(loc.split("redirect=")[1] ?? ""); + // must be the relative path only — never an absolute URL with host + if (!target.startsWith("/private/resource")) { + throw new Error(`expected relative path target, got '${target}'`); + } + if (target.includes("https://") || target.includes("cdn.example.com")) { + throw new Error(`redirect target leaked absolute URL/host: '${target}'`); + } + }, + }, + { name: "expired token → 302", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(expiredJwt) }), 302) }, + { name: "tampered signature → 302", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(tamperedJwt) }), 302) }, + { name: "wrong signing key → 302", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(wrongKeyJwt) }), 302) }, + { name: "not-yet-valid (nbf in future) → 302", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(notYetValidJwt) }), 302) }, + { name: "malformed token (no dots) → 302", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: { cookie: `${DEFAULT_COOKIE}=not-a-jwt` } }), 302) }, + { + name: "alg=none token → 302 (alg pinned to HS256)", + run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(craftWithAlg("none")) }), 302), + }, + { + name: "alg=RS256 token → 302 (alg pinned, no alg-confusion)", + run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(craftWithAlg("RS256")) }), 302), + }, + // bypass paths must pass through even with no cookie + { name: "/health bypassed → Continue", run: async (runner) => assertContinue(await runFlow(runner, { url: "https://cdn.example.com/health", requestHeaders: {} })) }, + { name: "/auth/totp/challenge bypassed → Continue", run: async (runner) => assertContinue(await runFlow(runner, { url: "https://cdn.example.com/auth/totp/challenge?t=abc", requestHeaders: {} })) }, + { + name: "traversal /auth/totp/../admin is GATED despite prefix match → 302", + run: async (runner) => assertStatus(await runFlow(runner, { url: "https://cdn.example.com/auth/totp/../admin", requestHeaders: {} }), 302), + }, + ], +); + +// --- 401 fail-closed when no MFA_LOGIN_URL is configured --------------------- +await suite( + "Fail-closed — no MFA_LOGIN_URL (deny must be a hard 401, not a redirect):", + [SECRET_KEY_ENV, `FASTEDGE_VAR_ENV_MFA_AUDIENCE=${AUDIENCE}`], + [ + { name: "no cookie → 401 MFA required", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: {} }), 401) }, + { name: "valid token still → Continue", run: async (runner) => assertContinue(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(validJwt) })) }, + ], +); + +// --- Fail-closed audience: MFA_AUDIENCE unset refuses every session ---------- +await suite( + "Fail-closed audience — MFA_AUDIENCE unset refuses even a valid token:", + [SECRET_KEY_ENV, `FASTEDGE_VAR_ENV_MFA_LOGIN_URL=${LOGIN_URL}`], + [ + { name: "valid token but MFA_AUDIENCE unset → 302 (refuse)", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(validJwt) }), 302) }, + ], +); + +// --- aud / iss enforcement --------------------------------------------------- +await suite( + "aud/iss enforcement (MFA_AUDIENCE + MFA_ISSUER set):", + [SECRET_KEY_ENV, `FASTEDGE_VAR_ENV_MFA_AUDIENCE=${AUDIENCE}`, `FASTEDGE_VAR_ENV_MFA_ISSUER=${ISSUER}`, `FASTEDGE_VAR_ENV_MFA_LOGIN_URL=${LOGIN_URL}`], + [ + { name: "matching aud + iss → Continue", run: async (runner) => assertContinue(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(await mint(3600, { aud: AUDIENCE, iss: ISSUER })) })) }, + { name: "aud mismatch → 302 (cross-resource replay blocked)", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(await mint(3600, { aud: OTHER_AUDIENCE, iss: ISSUER })) }), 302) }, + { name: "iss mismatch → 302", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(await mint(3600, { aud: AUDIENCE, iss: "https://evil.example.com" })) }), 302) }, + { name: "aud array containing configured value → Continue", run: async (runner) => assertContinue(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(await mint(3600, { aud: [OTHER_AUDIENCE, AUDIENCE], iss: ISSUER })) })) }, + { name: "no aud claim while MFA_AUDIENCE required → 302", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(await mint(3600, { iss: ISSUER })) }), 302) }, + ], +); + +// --- Custom AUTH_PREFIX + custom cookie name -------------------------------- +await suite( + "Custom AUTH_PREFIX (/mfa) + custom MFA_SESSION_COOKIE:", + [ + SECRET_KEY_ENV, + `FASTEDGE_VAR_ENV_MFA_AUDIENCE=${AUDIENCE}`, + `FASTEDGE_VAR_ENV_MFA_LOGIN_URL=${LOGIN_URL}`, + `FASTEDGE_VAR_ENV_AUTH_PREFIX=/mfa`, + `FASTEDGE_VAR_ENV_MFA_SESSION_COOKIE=my_mfa`, + ], + [ + { name: "custom /mfa/ path bypassed (Continue) with no cookie", run: async (runner) => assertContinue(await runFlow(runner, { url: "https://cdn.example.com/mfa/verify", requestHeaders: {} })) }, + { name: "old default /auth/totp/ NOT bypassed when AUTH_PREFIX overridden → 302", run: async (runner) => assertStatus(await runFlow(runner, { url: "https://cdn.example.com/auth/totp/challenge", requestHeaders: {} }), 302) }, + { name: "valid token in custom cookie name → Continue", run: async (runner) => assertContinue(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: { cookie: `my_mfa=${validJwt}` } })) }, + { name: "token in default cookie name NOT honoured when overridden → 302", run: async (runner) => assertStatus(await runFlow(runner, { url: PROTECTED_URL, requestHeaders: cookie(validJwt) }), 302) }, + ], +); + +for (const dir of tempDirs) await rm(dir, { recursive: true, force: true }).catch(() => {}); +console.log(""); +process.exit(totalFailed > 0 ? 1 : 0); diff --git a/edge-totp/package.json b/edge-totp/package.json new file mode 100644 index 0000000..8b2f407 --- /dev/null +++ b/edge-totp/package.json @@ -0,0 +1,16 @@ +{ + "name": "totp", + "private": true, + "scripts": { + "build": "npm-run-all -s build:app build:filter copy:wasm", + "build:app": "pnpm --filter otp-app build", + "build:filter": "pnpm --filter otp-filter build", + "test": "npm-run-all -p test:app test:filter", + "test:app": "pnpm --filter otp-app test", + "test:filter": "pnpm build:filter && pnpm --filter otp-filter test", + "copy:wasm": "mkdir -p wasm && mv otp-app/dist/totp-app.wasm wasm/totp-app.wasm && cp otp-filter/target/wasm32-wasip1/release/totp_filter.wasm wasm/totp-filter.wasm" + }, + "devDependencies": { + "npm-run-all2": "^9.0.2" + } +} diff --git a/edge-totp/pnpm-lock.yaml b/edge-totp/pnpm-lock.yaml new file mode 100644 index 0000000..a3a2f45 --- /dev/null +++ b/edge-totp/pnpm-lock.yaml @@ -0,0 +1,2783 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +catalogs: + default: + '@gcoredev/fastedge-sdk-js': + specifier: ^2.5.1 + version: 2.5.1 + '@gcoredev/fastedge-test': + specifier: ^0.2.4 + version: 0.2.4 + jose: + specifier: ^6.2.2 + version: 6.2.3 + tsx: + specifier: ^4.20.6 + version: 4.22.4 + +importers: + + .: + devDependencies: + npm-run-all2: + specifier: ^9.0.2 + version: 9.0.2 + + otp-app: + dependencies: + hono: + specifier: ^4.9.8 + version: 4.12.27 + jose: + specifier: 'catalog:' + version: 6.2.3 + uqr: + specifier: ^0.1.3 + version: 0.1.3 + devDependencies: + '@gcoredev/fastedge-sdk-js': + specifier: 'catalog:' + version: 2.5.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@gcoredev/fastedge-test': + specifier: 'catalog:' + version: 0.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + tsx: + specifier: 'catalog:' + version: 4.22.4 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + + otp-filter: + devDependencies: + '@gcoredev/fastedge-test': + specifier: 'catalog:' + version: 0.2.4 + jose: + specifier: 'catalog:' + version: 6.2.3 + tsx: + specifier: 'catalog:' + version: 4.22.4 + +packages: + + '@assemblyscript/wasi-shim@0.1.0': + resolution: {integrity: sha512-fSLH7MdJHf2uDW5llA5VCF/CG62Jp2WkYGui9/3vIWs3jDhViGeQF7nMYLUjpigluM5fnq61I6obtCETy39FZw==} + + '@bytecodealliance/componentize-js@0.19.3': + resolution: {integrity: sha512-ju7Y4WeF0B9uMkSPHJgmT6ouEfSwbe9M1uR/YOnYZjBpxJjH9qzxIkJg/kf8NycVDyFJ2/lscmJ1E1uPiDQVRQ==} + hasBin: true + + '@bytecodealliance/componentize-js@0.21.0': + resolution: {integrity: sha512-Av/XS3bJXE812P/bff3qtrJmehpgD0niK2A0fF3YIdM+jOD6E+mFrxOmuAgaIR0GNkpnm03+GE3ttt/+muqyCQ==} + hasBin: true + + '@bytecodealliance/jco-transpile@0.3.3': + resolution: {integrity: sha512-twkIb3oz8hGEjAnLQNBmQYCHI4YEkaMsSQ0k+iWFYfFefPcm4kFSkQUrjNe0DwMl00KM5kAvG/0r15AYHWs6aw==} + + '@bytecodealliance/jco@1.24.3': + resolution: {integrity: sha512-2NGY31zFdnMqusmmBMrQ9Rlxw89P/BHyQ5BAcrzCMqRRoc95SzuuCtOy8DwWgZ3WeXlWpG9ozAIEW4YPwspgQw==} + hasBin: true + + '@bytecodealliance/preview2-shim@0.17.9': + resolution: {integrity: sha512-i0R3eQBe6PA/o/1EFE3Owe4In2rcccb6QxnjpntM/lPe3/duJ0bRQTVZM2Ufpo99X4eofGeltQUkape1C91FFA==} + + '@bytecodealliance/preview3-shim@0.1.2': + resolution: {integrity: sha512-5twfO5h6zVkJ2QLxrLR21qyLS3jY35XvY0f+jZV4e9FkI6qa2RdPfNDgC50HYhGCtWmbH4TlN1puIyHfEemSNg==} + + '@bytecodealliance/weval@0.4.1': + resolution: {integrity: sha512-vJegSAkNjENhJcMUod76KUGAgQLdACDDCwB3JwyR14zDhyHVPAvArvtDDYEEi+c+ELzls62H6wxTvzRmaYTaqg==} + engines: {node: '>=16'} + + '@bytecodealliance/wizer-darwin-arm64@10.0.0': + resolution: {integrity: sha512-dhZTWel+xccGTKSJtI9A7oM4yyP20FWflsT+AoqkOqkCY7kCNrj4tmMtZ6GXZFRDkrPY5+EnOh62sfShEibAMA==} + cpu: [arm64] + os: [darwin] + hasBin: true + + '@bytecodealliance/wizer-darwin-arm64@11.0.3': + resolution: {integrity: sha512-QKU5sLLYMyz+XON5yIqPoffQb8qLUH/qWfnXuVz9GfM9K3sHSnDenkPUmLbqz6L+q+zvXnjhGv+u0wr+DVdR8A==} + cpu: [arm64] + os: [darwin] + hasBin: true + + '@bytecodealliance/wizer-darwin-x64@10.0.0': + resolution: {integrity: sha512-r/LUIZw6Q3Hf4htd46mD+EBxfwjBkxVIrTM1r+B2pTCddoBYQnKVdVsI4UFyy7NoBxzEg8F8BwmTNoSLmFRjpw==} + cpu: [x64] + os: [darwin] + hasBin: true + + '@bytecodealliance/wizer-darwin-x64@11.0.3': + resolution: {integrity: sha512-3pAcAtBkpTHDTJL39G9xJMZjBsJrCBS1vo5JXV5URlz8purksv72lsYm9zp4vwFzjwESQPve7ZoH4sZo9QFKGg==} + cpu: [x64] + os: [darwin] + hasBin: true + + '@bytecodealliance/wizer-linux-arm64@10.0.0': + resolution: {integrity: sha512-pGSfFWXzeTqHm6z1PtVaEn+7Fm3QGC8YnHrzBV4sQDVS3N1NwmuHZAc8kslmlFPNdu61ycEvdOsSgCny8JPQvg==} + cpu: [arm64] + os: [linux] + hasBin: true + + '@bytecodealliance/wizer-linux-arm64@11.0.3': + resolution: {integrity: sha512-NHO9cceJ7F4b7sNyHT/GRby+fpm85gk57c7fOernZlRSgpq6/AUae1b48P0UrJXwjob7M3oDnCW/uRu6a6r1Vw==} + cpu: [arm64] + os: [linux] + hasBin: true + + '@bytecodealliance/wizer-linux-s390x@10.0.0': + resolution: {integrity: sha512-O8vHxRTAdb1lUnVXMIMTcp/9q4pq1D4iIKigJCipg2JN15taV9uFAWh0fO88wylXwuSlO7dOE1AwQl54fMKXQg==} + cpu: [s390x] + os: [linux] + hasBin: true + + '@bytecodealliance/wizer-linux-s390x@11.0.3': + resolution: {integrity: sha512-G7VScGT8K/H2jvTaQzXTRdfe82jikDxCVR1j4j4jCekmZF0jzV390WauZF/QaDB0FXZY0aAOob7gt76HGbS1aw==} + cpu: [s390x] + os: [linux] + hasBin: true + + '@bytecodealliance/wizer-linux-x64@10.0.0': + resolution: {integrity: sha512-fJtM1sy43FBMnp+xpapFX6U1YdTBKA/1T4CYfG/qeE8jn0SXk2EuiYoY/EnC2uyNy9hjTrvfdYO5n4MXW0EIdQ==} + cpu: [x64] + os: [linux] + hasBin: true + + '@bytecodealliance/wizer-linux-x64@11.0.3': + resolution: {integrity: sha512-HvqiIcnSdaNeQ/D/Q613OXgyW4bWt3bXrl62kv+MvcCeiMskYLclRjvvDH8Mvrjvtjtx0GwuyhyP4gYFaiIQxg==} + cpu: [x64] + os: [linux] + hasBin: true + + '@bytecodealliance/wizer-win32-x64@10.0.0': + resolution: {integrity: sha512-55BPLfGT7iT7gH5M69NpTM16QknJZ7OxJ0z73VOEoeGA9CT8QPKMRzFKsPIvLs+W8G28fdudFA94nElrdkp3Kg==} + cpu: [x64] + os: [win32] + hasBin: true + + '@bytecodealliance/wizer-win32-x64@11.0.3': + resolution: {integrity: sha512-5MMQwTfpEAHhQabFT7YZtD+XLB9qp1lSliwDqFCiLJkMkxus+6hHIJaMQOiFb6Lb2rP2MA6w57p7T7SYZl9ucg==} + cpu: [x64] + os: [win32] + hasBin: true + + '@bytecodealliance/wizer@10.0.0': + resolution: {integrity: sha512-ziWmovyu1jQl9TsKlfC2bwuUZwxVPFHlX4fOqTzxhgS76jITIo45nzODEwPgU+jjmOr8F3YX2V2wAChC5NKujg==} + engines: {node: '>=16'} + hasBin: true + + '@bytecodealliance/wizer@11.0.3': + resolution: {integrity: sha512-OVKpXne02S5O0TXgfhf5RishlU4YrjA6N76vGGNZWsZZJMAsjy3T35scRVPqtC/k9SDDgzKFGhvXhwma6V8uKg==} + engines: {node: '>=16'} + hasBin: true + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@gcoredev/fastedge-sdk-js@2.5.1': + resolution: {integrity: sha512-kECzIG6//HmJJCKmMppsOgnJrLJsfH4yClgwL2RlgdKEAE9vm38+mSTNOmZjJ+YuqK9dkgotsYQHZnpaB7/VgA==} + engines: {node: '>=22', pnpm: '>=10'} + hasBin: true + + '@gcoredev/fastedge-test@0.2.4': + resolution: {integrity: sha512-1ABCIfQS0Cm8NjWL0ffPwB2wiOrMpXTuOf/s1ztINw9W0gmYLo/uDG40TYnIY+s7XZCfrrFznGhqumklF9Supg==} + engines: {node: '>=22.12'} + hasBin: true + + '@gcoredev/proxy-wasm-sdk-as@1.2.3': + resolution: {integrity: sha512-aBuyNAXnPzgFFUPWNGcxkmcCBvMvyq32D9oBhc8ksQinj80ASNBiLQzF4UDOwProO7+f6te41OZI+JKsP5thmQ==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/lzma-android-arm-eabi@1.4.5': + resolution: {integrity: sha512-Up4gpyw2SacmyKWWEib06GhiDdF+H+CCU0LAV8pnM4aJIDqKKd5LHSlBht83Jut6frkB0vwEPmAkv4NjQ5u//Q==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@napi-rs/lzma-android-arm64@1.4.5': + resolution: {integrity: sha512-uwa8sLlWEzkAM0MWyoZJg0JTD3BkPknvejAFG2acUA1raXM8jLrqujWCdOStisXhqQjZ2nDMp3FV6cs//zjfuQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/lzma-darwin-arm64@1.4.5': + resolution: {integrity: sha512-0Y0TQLQ2xAjVabrMDem1NhIssOZzF/y/dqetc6OT8mD3xMTDtF8u5BqZoX3MyPc9FzpsZw4ksol+w7DsxHrpMA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/lzma-darwin-x64@1.4.5': + resolution: {integrity: sha512-vR2IUyJY3En+V1wJkwmbGWcYiT8pHloTAWdW4pG24+51GIq+intst6Uf6D/r46citObGZrlX0QvMarOkQeHWpw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/lzma-freebsd-x64@1.4.5': + resolution: {integrity: sha512-XpnYQC5SVovO35tF0xGkbHYjsS6kqyNCjuaLQ2dbEblFRr5cAZVvsJ/9h7zj/5FluJPJRDojVNxGyRhTp4z2lw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/lzma-linux-arm-gnueabihf@1.4.5': + resolution: {integrity: sha512-ic1ZZMoRfRMwtSwxkyw4zIlbDZGC6davC9r+2oX6x9QiF247BRqqT94qGeL5ZP4Vtz0Hyy7TEViWhx5j6Bpzvw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/lzma-linux-arm64-gnu@1.4.5': + resolution: {integrity: sha512-asEp7FPd7C1Yi6DQb45a3KPHKOFBSfGuJWXcAd4/bL2Fjetb2n/KK2z14yfW8YC/Fv6x3rBM0VAZKmJuz4tysg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/lzma-linux-arm64-musl@1.4.5': + resolution: {integrity: sha512-yWjcPDgJ2nIL3KNvi4536dlT/CcCWO0DUyEOlBs/SacG7BeD6IjGh6yYzd3/X1Y3JItCbZoDoLUH8iB1lTXo3w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/lzma-linux-ppc64-gnu@1.4.5': + resolution: {integrity: sha512-0XRhKuIU/9ZjT4WDIG/qnX7Xz7mSQHYZo9Gb3MP2gcvBgr6BA4zywQ9k3gmQaPn9ECE+CZg2V7DV7kT+x2pUMQ==} + engines: {node: '>= 10'} + cpu: [ppc64] + os: [linux] + + '@napi-rs/lzma-linux-riscv64-gnu@1.4.5': + resolution: {integrity: sha512-QrqDIPEUUB23GCpyQj/QFyMlr8SGxxyExeZz9OWFnHfb70kXdTLWrHS/hEI1Ru+lSbQ/6xRqeoGyQ4Aqdg+/RA==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/lzma-linux-s390x-gnu@1.4.5': + resolution: {integrity: sha512-k8RVM5aMhW86E9H0QXdquwojew4H3SwPxbRVbl49/COJQWCUjGi79X6mYruMnMPEznZinUiT1jgKbFo2A00NdA==} + engines: {node: '>= 10'} + cpu: [s390x] + os: [linux] + + '@napi-rs/lzma-linux-x64-gnu@1.4.5': + resolution: {integrity: sha512-6rMtBgnIq2Wcl1rQdZsnM+rtCcVCbws1nF8S2NzaUsVaZv8bjrPiAa0lwg4Eqnn1d9lgwqT+cZgm5m+//K08Kw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/lzma-linux-x64-musl@1.4.5': + resolution: {integrity: sha512-eiadGBKi7Vd0bCArBUOO/qqRYPHt/VQVvGyYvDFt6C2ZSIjlD+HuOl+2oS1sjf4CFjK4eDIog6EdXnL0NE6iyQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/lzma-wasm32-wasi@1.4.5': + resolution: {integrity: sha512-+VyHHlr68dvey6fXc2hehw9gHVFIW3TtGF1XkcbAu65qVXsA9D/T+uuoRVqhE+JCyFHFrO0ixRbZDRK1XJt1sA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@napi-rs/lzma-win32-arm64-msvc@1.4.5': + resolution: {integrity: sha512-eewnqvIyyhHi3KaZtBOJXohLvwwN27gfS2G/YDWdfHlbz1jrmfeHAmzMsP5qv8vGB+T80TMHNkro4kYjeh6Deg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/lzma-win32-ia32-msvc@1.4.5': + resolution: {integrity: sha512-OeacFVRCJOKNU/a0ephUfYZ2Yt+NvaHze/4TgOwJ0J0P4P7X1mHzN+ig9Iyd74aQDXYqc7kaCXA2dpAOcH87Cg==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/lzma-win32-x64-msvc@1.4.5': + resolution: {integrity: sha512-T4I1SamdSmtyZgDXGAGP+y5LEK5vxHUFwe8mz6D4R7Sa5/WCxTcCIgPJ9BD7RkpO17lzhlaM2vmVvMy96Lvk9Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/lzma@1.4.5': + resolution: {integrity: sha512-zS5LuN1OBPAyZpda2ZZgYOEDC+xecUdAGnrvbYzjnLXkrq/OBC3B9qcRvlxbDR3k5H/gVfvef1/jyUqPknqjbg==} + engines: {node: '>= 10'} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-parser/binding-android-arm64@0.76.0': + resolution: {integrity: sha512-1XJW/16CDmF5bHE7LAyPPmEEVnxSadDgdJz+xiLqBrmC4lfAeuAfRw3HlOygcPGr+AJsbD4Z5sFJMkwjbSZlQg==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.76.0': + resolution: {integrity: sha512-yoQwSom8xsB+JdGsPUU0xxmxLKiF2kdlrK7I56WtGKZilixuBf/TmOwNYJYLRWkBoW5l2/pDZOhBm2luwmLiLw==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.76.0': + resolution: {integrity: sha512-uRIopPLvr3pf2Xj7f5LKyCuqzIU6zOS+zEIR8UDYhcgJyZHnvBkfrYnfcztyIcrGdQehrFUi3uplmI09E7RdiQ==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.76.0': + resolution: {integrity: sha512-a0EOFvnOd2FqmDSvH6uWLROSlU6KV/JDKbsYDA/zRLyKcG6HCsmFnPsp8iV7/xr9WMbNgyJi6R5IMpePQlUq7Q==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.76.0': + resolution: {integrity: sha512-ikRYDHL3fOdZwfJKmcdqjlLgkeNZ3Ez0qM8wAev5zlHZ+lY/Ig7qG5SCqPlvuTu+nNQ6zrFFaKvvt69EBKXU/g==} + engines: {node: '>=20.0.0'} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.76.0': + resolution: {integrity: sha512-dtRv5J5MRCLR7x39K8ufIIW4svIc7gYFUaI0YFXmmeOBhK/K2t/CkguPnDroKtsmXIPHDRtmJ1JJYzNcgJl6Wg==} + engines: {node: '>=20.0.0'} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.76.0': + resolution: {integrity: sha512-IE4iiiggFH2snagQxHrY5bv6dDpRMMat+vdlMN/ibonA65eOmRLp8VLTXnDiNrcla/itJ1L9qGABHNKU+SnE8g==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-arm64-musl@0.76.0': + resolution: {integrity: sha512-wi9zQPMDHrBuRuT7Iurfidc9qlZh7cKa5vfYzOWNBCaqJdgxmNOFzvYen02wVUxSWGKhpiPHxrPX0jdRyJ8Npg==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + + '@oxc-parser/binding-linux-riscv64-gnu@0.76.0': + resolution: {integrity: sha512-0tqqu1pqPee2lLGY8vtYlX1L415fFn89e0a3yp4q5N9f03j1rRs0R31qesTm3bt/UK8HYjECZ+56FCVPs2MEMQ==} + engines: {node: '>=20.0.0'} + cpu: [riscv64] + os: [linux] + + '@oxc-parser/binding-linux-s390x-gnu@0.76.0': + resolution: {integrity: sha512-y36Hh1a5TA+oIGtlc8lT7N9vdHXBlhBetQJW0p457KbiVQ7jF7AZkaPWhESkjHWAsTVKD2OjCa9ZqfaqhSI0FQ==} + engines: {node: '>=20.0.0'} + cpu: [s390x] + os: [linux] + + '@oxc-parser/binding-linux-x64-gnu@0.76.0': + resolution: {integrity: sha512-7/acaG9htovp3gp/J0kHgbItQTuHctl+rbqPPqZ9DRBYTz8iV8kv3QN8t8Or8i/hOmOjfZp9McDoSU1duoR4/A==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-linux-x64-musl@0.76.0': + resolution: {integrity: sha512-AxFt0reY6Q2rfudABmMTFGR8tFFr58NlH2rRBQgcj+F+iEwgJ+jMwAPhXd2y1I2zaI8GspuahedUYQinqxWqjA==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + + '@oxc-parser/binding-wasm32-wasi@0.76.0': + resolution: {integrity: sha512-wHdkHdhf6AWBoO8vs5cpoR6zEFY1rB+fXWtq6j/xb9j/lu1evlujRVMkh8IM/M/pOUIrNkna3nzST/mRImiveQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.76.0': + resolution: {integrity: sha512-G7ZlEWcb2hNwCK3qalzqJoyB6HaTigQ/GEa7CU8sAJ/WwMdG/NnPqiC9IqpEAEy1ARSo4XMALfKbKNuqbSs5mg==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.76.0': + resolution: {integrity: sha512-0jLzzmnu8/mqNhKBnNS2lFUbPEzRdj5ReiZwHGHpjma0+ullmmwP2AqSEqx3ssHDK9CpcEMdKOK2LsbCfhHKIA==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.76.0': + resolution: {integrity: sha512-CH3THIrSViKal8yV/Wh3FK0pFhp40nzW1MUDCik9fNuid2D/7JJXKJnfFOAvMxInGXDlvmgT6ACAzrl47TqzkQ==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/node@26.0.0': + resolution: {integrity: sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + binaryen@130.0.0: + resolution: {integrity: sha512-XDrb+zql0RbFPKgj7MuH9zOc78R3Fa/P/VSGnnpdwYsvNZPWjcMYMdAkKCOQEL2A7yqgjSMTDRFp6gfSDW+/QQ==} + hasBin: true + + bl@1.2.3: + resolution: {integrity: sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==} + + body-parser@1.20.5: + resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + buffer-alloc-unsafe@1.1.0: + resolution: {integrity: sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==} + + buffer-alloc@1.2.0: + resolution: {integrity: sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-fill@1.0.0: + resolution: {integrity: sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-tar@4.1.1: + resolution: {integrity: sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==} + engines: {node: '>=4'} + + decompress-tarbz2@4.1.1: + resolution: {integrity: sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==} + engines: {node: '>=4'} + + decompress-targz@4.1.1: + resolution: {integrity: sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==} + engines: {node: '>=4'} + + decompress-unzip@4.0.1: + resolution: {integrity: sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==} + engines: {node: '>=4'} + + decompress@4.2.1: + resolution: {integrity: sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==} + engines: {node: '>=4'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-polyfill@0.0.4: + resolution: {integrity: sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==} + + express@4.22.2: + resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} + engines: {node: '>= 0.10.0'} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + file-type@3.9.0: + resolution: {integrity: sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==} + engines: {node: '>=0.10.0'} + + file-type@5.2.0: + resolution: {integrity: sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==} + engines: {node: '>=4'} + + file-type@6.2.0: + resolution: {integrity: sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==} + engines: {node: '>=4'} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@2.3.1: + resolution: {integrity: sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==} + engines: {node: '>=0.10.0'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.12.27: + resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + immer@11.1.8: + resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-natural-number@4.0.1: + resolution: {integrity: sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==} + + is-stream@1.1.0: + resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} + engines: {node: '>=0.10.0'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@6.0.0: + resolution: {integrity: sha512-2/8adwnK1/+Fdjyts4r6wSpfANWw8zdNhU9U/Llk59c6O+DjSisPWPykwoL8gZmocP9Dy64S7oie2g+Mia123A==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + make-dir@1.3.0: + resolution: {integrity: sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==} + engines: {node: '>=4'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + memorystream@0.3.1: + resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} + engines: {node: '>= 0.10.0'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + npm-normalize-package-bin@6.0.0: + resolution: {integrity: sha512-tdt4aFn9QamlhdN3HV2D2ccpBwO5/fyjjbXUxYA6uBjyekMZcZvDq0aSj9t5Jo+tih6AYFnt/cuIRn9013e0Uw==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + npm-run-all2@9.0.2: + resolution: {integrity: sha512-+dd4SO2jAlLE06OzmJKzIe6QvvjXezcbmobnh8usR0a8BzQCABTdqTXqVPji0ICOhSQpIIrkGd7IzNl5iDaRSA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0, npm: '>= 10'} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + + oxc-parser@0.76.0: + resolution: {integrity: sha512-l98B2e9evuhES7zN99rb1QGhbzx25829TJFaKi2j0ib3/K/G5z1FdGYz6HZkrU3U8jdH7v2FC8mX1j2l9JrOUg==} + engines: {node: '>=20.0.0'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pidtree@1.0.0: + resolution: {integrity: sha512-avfAvjB9Dd0wdj3rjJX//yS+G79OO0KrS5pJHFJENjYGX6N4SMgEDBBI/yFy0lloOYSaC6XQxzpOAMPfSYFV/Q==} + engines: {node: '>=18'} + hasBin: true + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pify@3.0.0: + resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} + engines: {node: '>=4'} + + pinkie-promise@2.0.1: + resolution: {integrity: sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==} + engines: {node: '>=0.10.0'} + + pinkie@2.0.4: + resolution: {integrity: sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==} + engines: {node: '>=0.10.0'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + read-package-json-fast@6.0.0: + resolution: {integrity: sha512-PNaGjoCnw9DBA2Kl8D+8po957z778q/HOPuY2u3Bkw/JO3eC8MDx7jn/PgMtSgpcBbs+6UOjDbwReGpXmRvs0g==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.2: + resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} + hasBin: true + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + seek-bzip@1.0.6: + resolution: {integrity: sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.4: + resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-dirs@2.1.0: + resolution: {integrity: sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==} + + tar-stream@1.6.2: + resolution: {integrity: sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==} + engines: {node: '>= 0.8.0'} + + terser@5.48.0: + resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} + engines: {node: '>=10'} + hasBin: true + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unbzip2-stream@1.4.3: + resolution: {integrity: sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==} + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@6.27.0: + resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} + engines: {node: '>=18.17'} + + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + uqr@0.1.3: + resolution: {integrity: sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@7.0.0: + resolution: {integrity: sha512-RancgH2dmbLdHl6LRhEqvklWMgl/Hdnun0Y90KhBOLkMefg8Qa7/Zel8Sm+8HEcP6DEjzsWzpkuBQEZok58isA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + 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 + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zustand-debounce@2.3.1: + resolution: {integrity: sha512-QhCM/eXU8tGKaQk5GRvWMeSivj19hcU00SFvo7fUSq1aJK2vYwYpNUvJl+YuKXneuMGIXHjvoou2irpX4OTu2w==} + peerDependencies: + zustand: '>=4.0.0' + + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@assemblyscript/wasi-shim@0.1.0': {} + + '@bytecodealliance/componentize-js@0.19.3': + dependencies: + '@bytecodealliance/jco': 1.24.3 + '@bytecodealliance/wizer': 10.0.0 + es-module-lexer: 1.7.0 + oxc-parser: 0.76.0 + + '@bytecodealliance/componentize-js@0.19.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@bytecodealliance/jco': 1.24.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@bytecodealliance/wizer': 10.0.0 + es-module-lexer: 1.7.0 + oxc-parser: 0.76.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@bytecodealliance/componentize-js@0.21.0': + dependencies: + '@bytecodealliance/jco': 1.24.3 + '@bytecodealliance/weval': 0.4.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@bytecodealliance/wizer': 10.0.0 + es-module-lexer: 1.7.0 + oxc-parser: 0.76.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@bytecodealliance/componentize-js@0.21.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@bytecodealliance/jco': 1.24.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@bytecodealliance/weval': 0.4.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@bytecodealliance/wizer': 10.0.0 + es-module-lexer: 1.7.0 + oxc-parser: 0.76.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@bytecodealliance/jco-transpile@0.3.3': + dependencies: + '@bytecodealliance/preview2-shim': 0.17.9 + binaryen: 130.0.0 + terser: 5.48.0 + + '@bytecodealliance/jco@1.24.3': + dependencies: + '@bytecodealliance/componentize-js': 0.21.0 + '@bytecodealliance/componentize-js-0-19-3': '@bytecodealliance/componentize-js@0.19.3' + '@bytecodealliance/jco-transpile': 0.3.3 + '@bytecodealliance/preview2-shim': 0.17.9 + '@bytecodealliance/preview3-shim': 0.1.2 + binaryen: 130.0.0 + commander: 14.0.3 + mkdirp: 3.0.1 + ora: 8.2.0 + terser: 5.48.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@bytecodealliance/jco@1.24.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@bytecodealliance/componentize-js': 0.21.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@bytecodealliance/componentize-js-0-19-3': '@bytecodealliance/componentize-js@0.19.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)' + '@bytecodealliance/jco-transpile': 0.3.3 + '@bytecodealliance/preview2-shim': 0.17.9 + '@bytecodealliance/preview3-shim': 0.1.2 + binaryen: 130.0.0 + commander: 14.0.3 + mkdirp: 3.0.1 + ora: 8.2.0 + terser: 5.48.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@bytecodealliance/preview2-shim@0.17.9': {} + + '@bytecodealliance/preview3-shim@0.1.2': + dependencies: + '@bytecodealliance/preview2-shim': 0.17.9 + + '@bytecodealliance/weval@0.4.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@napi-rs/lzma': 1.4.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + decompress: 4.2.1 + decompress-tar: 4.1.1 + decompress-unzip: 4.0.1 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@bytecodealliance/wizer-darwin-arm64@10.0.0': + optional: true + + '@bytecodealliance/wizer-darwin-arm64@11.0.3': + optional: true + + '@bytecodealliance/wizer-darwin-x64@10.0.0': + optional: true + + '@bytecodealliance/wizer-darwin-x64@11.0.3': + optional: true + + '@bytecodealliance/wizer-linux-arm64@10.0.0': + optional: true + + '@bytecodealliance/wizer-linux-arm64@11.0.3': + optional: true + + '@bytecodealliance/wizer-linux-s390x@10.0.0': + optional: true + + '@bytecodealliance/wizer-linux-s390x@11.0.3': + optional: true + + '@bytecodealliance/wizer-linux-x64@10.0.0': + optional: true + + '@bytecodealliance/wizer-linux-x64@11.0.3': + optional: true + + '@bytecodealliance/wizer-win32-x64@10.0.0': + optional: true + + '@bytecodealliance/wizer-win32-x64@11.0.3': + optional: true + + '@bytecodealliance/wizer@10.0.0': + optionalDependencies: + '@bytecodealliance/wizer-darwin-arm64': 10.0.0 + '@bytecodealliance/wizer-darwin-x64': 10.0.0 + '@bytecodealliance/wizer-linux-arm64': 10.0.0 + '@bytecodealliance/wizer-linux-s390x': 10.0.0 + '@bytecodealliance/wizer-linux-x64': 10.0.0 + '@bytecodealliance/wizer-win32-x64': 10.0.0 + + '@bytecodealliance/wizer@11.0.3': + optionalDependencies: + '@bytecodealliance/wizer-darwin-arm64': 11.0.3 + '@bytecodealliance/wizer-darwin-x64': 11.0.3 + '@bytecodealliance/wizer-linux-arm64': 11.0.3 + '@bytecodealliance/wizer-linux-s390x': 11.0.3 + '@bytecodealliance/wizer-linux-x64': 11.0.3 + '@bytecodealliance/wizer-win32-x64': 11.0.3 + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@gcoredev/fastedge-sdk-js@2.5.1': + dependencies: + '@bytecodealliance/jco': 1.24.3 + '@bytecodealliance/wizer': 11.0.3 + acorn: 8.17.0 + acorn-walk: 8.3.5 + arg: 5.0.2 + enquirer: 2.4.1 + esbuild: 0.28.1 + event-target-polyfill: 0.0.4 + magic-string: 0.30.21 + npm-run-all2: 9.0.2 + prompts: 2.4.2 + regexpu-core: 6.4.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@gcoredev/fastedge-sdk-js@2.5.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@bytecodealliance/jco': 1.24.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@bytecodealliance/wizer': 11.0.3 + acorn: 8.17.0 + acorn-walk: 8.3.5 + arg: 5.0.2 + enquirer: 2.4.1 + esbuild: 0.28.1 + event-target-polyfill: 0.0.4 + magic-string: 0.30.21 + npm-run-all2: 9.0.2 + prompts: 2.4.2 + regexpu-core: 6.4.0 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@gcoredev/fastedge-test@0.2.4': + dependencies: + '@assemblyscript/wasi-shim': 0.1.0 + '@gcoredev/fastedge-sdk-js': 2.5.1 + '@gcoredev/proxy-wasm-sdk-as': 1.2.3 + '@types/ws': 8.18.1 + express: 4.22.2 + immer: 11.1.8 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + undici: 6.27.0 + ws: 8.21.0 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + zustand: 5.0.14(immer@11.1.8)(react@19.2.7) + zustand-debounce: 2.3.1(zustand@5.0.14(immer@11.1.8)(react@19.2.7)) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - '@types/react' + - bufferutil + - supports-color + - use-sync-external-store + - utf-8-validate + + '@gcoredev/fastedge-test@0.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@assemblyscript/wasi-shim': 0.1.0 + '@gcoredev/fastedge-sdk-js': 2.5.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@gcoredev/proxy-wasm-sdk-as': 1.2.3 + '@types/ws': 8.18.1 + express: 4.22.2 + immer: 11.1.8 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + undici: 6.27.0 + ws: 8.21.0 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + zustand: 5.0.14(immer@11.1.8)(react@19.2.7) + zustand-debounce: 2.3.1(zustand@5.0.14(immer@11.1.8)(react@19.2.7)) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + - '@types/react' + - bufferutil + - supports-color + - use-sync-external-store + - utf-8-validate + + '@gcoredev/proxy-wasm-sdk-as@1.2.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/lzma-android-arm-eabi@1.4.5': + optional: true + + '@napi-rs/lzma-android-arm64@1.4.5': + optional: true + + '@napi-rs/lzma-darwin-arm64@1.4.5': + optional: true + + '@napi-rs/lzma-darwin-x64@1.4.5': + optional: true + + '@napi-rs/lzma-freebsd-x64@1.4.5': + optional: true + + '@napi-rs/lzma-linux-arm-gnueabihf@1.4.5': + optional: true + + '@napi-rs/lzma-linux-arm64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-arm64-musl@1.4.5': + optional: true + + '@napi-rs/lzma-linux-ppc64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-riscv64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-s390x-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-x64-gnu@1.4.5': + optional: true + + '@napi-rs/lzma-linux-x64-musl@1.4.5': + optional: true + + '@napi-rs/lzma-wasm32-wasi@1.4.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@napi-rs/lzma-win32-arm64-msvc@1.4.5': + optional: true + + '@napi-rs/lzma-win32-ia32-msvc@1.4.5': + optional: true + + '@napi-rs/lzma-win32-x64-msvc@1.4.5': + optional: true + + '@napi-rs/lzma@1.4.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + optionalDependencies: + '@napi-rs/lzma-android-arm-eabi': 1.4.5 + '@napi-rs/lzma-android-arm64': 1.4.5 + '@napi-rs/lzma-darwin-arm64': 1.4.5 + '@napi-rs/lzma-darwin-x64': 1.4.5 + '@napi-rs/lzma-freebsd-x64': 1.4.5 + '@napi-rs/lzma-linux-arm-gnueabihf': 1.4.5 + '@napi-rs/lzma-linux-arm64-gnu': 1.4.5 + '@napi-rs/lzma-linux-arm64-musl': 1.4.5 + '@napi-rs/lzma-linux-ppc64-gnu': 1.4.5 + '@napi-rs/lzma-linux-riscv64-gnu': 1.4.5 + '@napi-rs/lzma-linux-s390x-gnu': 1.4.5 + '@napi-rs/lzma-linux-x64-gnu': 1.4.5 + '@napi-rs/lzma-linux-x64-musl': 1.4.5 + '@napi-rs/lzma-wasm32-wasi': 1.4.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/lzma-win32-arm64-msvc': 1.4.5 + '@napi-rs/lzma-win32-ia32-msvc': 1.4.5 + '@napi-rs/lzma-win32-x64-msvc': 1.4.5 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-parser/binding-android-arm64@0.76.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.76.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.76.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.76.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.76.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.76.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.76.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.76.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.76.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.76.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.76.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.76.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.76.0': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.76.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.76.0': + optional: true + + '@oxc-project/types@0.76.0': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/node@26.0.0': + dependencies: + undici-types: 8.3.0 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.0.0 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@6.2.3: {} + + arg@5.0.2: {} + + array-flatten@1.1.1: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + base64-js@1.5.1: {} + + binaryen@130.0.0: {} + + bl@1.2.3: + dependencies: + readable-stream: 2.3.8 + safe-buffer: 5.2.1 + + body-parser@1.20.5: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + buffer-alloc-unsafe@1.1.0: {} + + buffer-alloc@1.2.0: + dependencies: + buffer-alloc-unsafe: 1.1.0 + buffer-fill: 1.0.0 + + buffer-crc32@0.2.13: {} + + buffer-fill@1.0.0: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chalk@5.6.2: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + commander@14.0.3: {} + + commander@2.20.3: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + core-util-is@1.0.3: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + decompress-tar@4.1.1: + dependencies: + file-type: 5.2.0 + is-stream: 1.1.0 + tar-stream: 1.6.2 + + decompress-tarbz2@4.1.1: + dependencies: + decompress-tar: 4.1.1 + file-type: 6.2.0 + is-stream: 1.1.0 + seek-bzip: 1.0.6 + unbzip2-stream: 1.4.3 + + decompress-targz@4.1.1: + dependencies: + decompress-tar: 4.1.1 + file-type: 5.2.0 + is-stream: 1.1.0 + + decompress-unzip@4.0.1: + dependencies: + file-type: 3.9.0 + get-stream: 2.3.1 + pify: 2.3.0 + yauzl: 2.10.0 + + decompress@4.2.1: + dependencies: + decompress-tar: 4.1.1 + decompress-tarbz2: 4.1.1 + decompress-targz: 4.1.1 + decompress-unzip: 4.0.1 + graceful-fs: 4.2.11 + make-dir: 1.3.0 + pify: 2.3.0 + strip-dirs: 2.1.0 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + depd@2.0.0: {} + + destroy@1.2.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + emoji-regex@10.6.0: {} + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + event-target-polyfill@0.0.4: {} + + express@4.22.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.5 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + file-type@3.9.0: {} + + file-type@5.2.0: {} + + file-type@6.2.0: {} + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fs-constants@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@2.3.1: + dependencies: + object-assign: 4.1.1 + pinkie-promise: 2.0.1 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.12.27: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + immer@11.1.8: {} + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-callable@1.2.7: {} + + is-interactive@2.0.0: {} + + is-natural-number@4.0.1: {} + + is-stream@1.1.0: {} + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + isarray@1.0.0: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + isexe@4.0.0: {} + + jose@6.2.3: {} + + jsesc@3.1.0: {} + + json-parse-even-better-errors@6.0.0: {} + + kleur@3.0.3: {} + + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-dir@1.3.0: + dependencies: + pify: 3.0.0 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + memorystream@0.3.1: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + mimic-function@5.0.1: {} + + mkdirp@3.0.1: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + negotiator@0.6.3: {} + + npm-normalize-package-bin@6.0.0: {} + + npm-run-all2@9.0.2: + dependencies: + ansi-styles: 6.2.3 + cross-spawn: 7.0.6 + memorystream: 0.3.1 + picomatch: 4.0.4 + pidtree: 1.0.0 + read-package-json-fast: 6.0.0 + shell-quote: 1.8.4 + which: 7.0.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + oxc-parser@0.76.0: + dependencies: + '@oxc-project/types': 0.76.0 + optionalDependencies: + '@oxc-parser/binding-android-arm64': 0.76.0 + '@oxc-parser/binding-darwin-arm64': 0.76.0 + '@oxc-parser/binding-darwin-x64': 0.76.0 + '@oxc-parser/binding-freebsd-x64': 0.76.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.76.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.76.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.76.0 + '@oxc-parser/binding-linux-arm64-musl': 0.76.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.76.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.76.0 + '@oxc-parser/binding-linux-x64-gnu': 0.76.0 + '@oxc-parser/binding-linux-x64-musl': 0.76.0 + '@oxc-parser/binding-wasm32-wasi': 0.76.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.76.0 + '@oxc-parser/binding-win32-x64-msvc': 0.76.0 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@0.1.13: {} + + pend@1.2.0: {} + + picomatch@4.0.4: {} + + pidtree@1.0.0: {} + + pify@2.3.0: {} + + pify@3.0.0: {} + + pinkie-promise@2.0.1: + dependencies: + pinkie: 2.0.4 + + pinkie@2.0.4: {} + + possible-typed-array-names@1.1.0: {} + + process-nextick-args@2.0.1: {} + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.2: + dependencies: + side-channel: 1.1.1 + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react@19.2.7: {} + + read-package-json-fast@6.0.0: + dependencies: + json-parse-even-better-errors: 6.0.0 + npm-normalize-package-bin: 6.0.0 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.2 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.2: + dependencies: + jsesc: 3.1.0 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + seek-bzip@1.0.6: + dependencies: + commander: 2.20.3 + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.4: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + statuses@2.0.2: {} + + stdin-discarder@0.2.2: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-dirs@2.1.0: + dependencies: + is-natural-number: 4.0.1 + + tar-stream@1.6.2: + dependencies: + bl: 1.2.3 + buffer-alloc: 1.2.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + readable-stream: 2.3.8 + to-buffer: 1.2.2 + xtend: 4.0.2 + + terser@5.48.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.17.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + through@2.3.8: {} + + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + + toidentifier@1.0.1: {} + + tslib@2.8.1: + optional: true + + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typescript@5.9.3: {} + + unbzip2-stream@1.4.3: + dependencies: + buffer: 5.7.1 + through: 2.3.8 + + undici-types@8.3.0: {} + + undici@6.27.0: {} + + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + + unpipe@1.0.0: {} + + uqr@0.1.3: {} + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + vary@1.1.2: {} + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@7.0.0: + dependencies: + isexe: 4.0.0 + + wrappy@1.0.2: {} + + ws@8.21.0: {} + + xtend@4.0.2: {} + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} + + zustand-debounce@2.3.1(zustand@5.0.14(immer@11.1.8)(react@19.2.7)): + dependencies: + zustand: 5.0.14(immer@11.1.8)(react@19.2.7) + + zustand@5.0.14(immer@11.1.8)(react@19.2.7): + optionalDependencies: + immer: 11.1.8 + react: 19.2.7 diff --git a/edge-totp/pnpm-workspace.yaml b/edge-totp/pnpm-workspace.yaml new file mode 100644 index 0000000..000c4a0 --- /dev/null +++ b/edge-totp/pnpm-workspace.yaml @@ -0,0 +1,15 @@ +packages: + - otp-app + - otp-filter + +# Single source of truth for shared dependency versions. Packages reference these +# with the "catalog:" specifier instead of pinning their own version, so the +# HTTP app (otp-app) and the filter's test harness (otp-filter) can't drift. +catalog: + "@gcoredev/fastedge-test": ^0.2.4 + "@gcoredev/fastedge-sdk-js": ^2.5.1 + jose: ^6.2.2 + tsx: ^4.20.6 + +onlyBuiltDependencies: + - esbuild