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 && } +