diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 595c0d853..9bd2a19d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -221,7 +221,7 @@ jobs: test-cp-integration: name: Test (control-plane integration) runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 needs: [lint-typescript, typecheck-typescript] steps: - name: Checkout diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index ee7a14dc0..99702dcea 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -67,9 +67,6 @@ jobs: - name: Build Project if: steps.check-secrets.outputs.configured == 'true' run: vercel build --prod --token=${{ secrets.VERCEL_API_TOKEN }} - env: - # Required for NextAuth static generation - set via GitHub secret or Vercel will provide - NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL || 'https://localhost:3000' }} - name: Deploy to Vercel if: steps.check-secrets.outputs.configured == 'true' diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 14ae79ef8..2aae97810 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -440,7 +440,7 @@ echo "repo_secrets_encryption_key: $(openssl rand -base64 32)" # Modal API secret (use hex for this one) echo "modal_api_secret: $(openssl rand -hex 32)" -# NextAuth secret +# Browser authentication secret (Terraform retains the legacy input name) echo "nextauth_secret: $(openssl rand -base64 32)" # GitHub webhook secret (only if enabling GitHub bot) @@ -822,7 +822,7 @@ cloudflare_custom_domain = "app.example.com" # bare hostname, no scheme Cloudflare provisions the DNS record and edge certificate automatically. Notes: -- The web app URL — including `NEXTAUTH_URL` and the links the bots send — becomes +- The canonical browser-auth origin and the links the bots send become `https://{your-custom-domain}`, and the workers.dev route for the web Worker is disabled so the app has a single canonical origin. - Update the GitHub App callback URL (and the Google redirect URI, if Google login is enabled) to @@ -918,7 +918,6 @@ Go to your fork's Settings → Secrets and variables → Actions, and add: | `VERCEL_API_TOKEN` | Vercel API token _(only if `web_platform = "vercel"`)_ | | `VERCEL_TEAM_ID` | Vercel team/account ID _(only if `web_platform = "vercel"`)_ | | `VERCEL_PROJECT_ID` | Vercel project ID _(only if `web_platform = "vercel"`)_ | -| `NEXTAUTH_URL` | Your web app URL | | `MODAL_TOKEN_ID` | Modal token ID | | `MODAL_TOKEN_SECRET` | Modal token secret | | `MODAL_WORKSPACE` | Modal workspace name | @@ -955,7 +954,7 @@ Go to your fork's Settings → Secrets and variables → Actions, and add: | `TOKEN_ENCRYPTION_KEY` | Generated encryption key (OAuth tokens) | | `REPO_SECRETS_ENCRYPTION_KEY` | Generated encryption key (repo secrets) | | `MODAL_API_SECRET` | Generated Modal API secret | -| `NEXTAUTH_SECRET` | Generated NextAuth secret | +| `NEXTAUTH_SECRET` | Generated browser-auth secret (legacy Actions secret name) | | `ALLOWED_USERS` | Comma-separated GitHub usernames (or empty for all users) | | `ALLOWED_EMAIL_DOMAINS` | Comma-separated email domains (or empty for all domains) | | `ALLOWED_EMAILS` | Comma-separated exact email addresses (for individual users on shared domains) | diff --git a/docs/SETUP_GUIDE.md b/docs/SETUP_GUIDE.md index 81deaf1d0..dc5343715 100644 --- a/docs/SETUP_GUIDE.md +++ b/docs/SETUP_GUIDE.md @@ -60,8 +60,9 @@ What this does: ## Path A: Run the Web App Locally (Recommended Quick Start) -Use this when you already have a deployed control plane and sandbox backend, and only need local UI -development. +Use this with a dedicated development control plane whose `WEB_APP_URL` is `http://localhost:3000`. +Browser auth is origin-bound, so a production control plane configured for its deployed web origin +cannot authenticate a localhost web process. ### 1. Create local env file @@ -74,23 +75,11 @@ cp packages/web/.env.example packages/web/.env.local Edit `packages/web/.env.local`: ```bash -# GitHub App OAuth -GITHUB_CLIENT_ID=your_github_app_client_id -GITHUB_CLIENT_SECRET=your_github_app_client_secret - -# Google OAuth (optional — enables "Sign in with Google"). Create a Web OAuth -# client at https://console.cloud.google.com/apis/credentials with redirect URI -# http://localhost:3000/api/auth/callback/google. Set NEXT_PUBLIC_GOOGLE_ENABLED=true -# to reveal the button (inlined at build time — restart the dev server after changing). -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= +# Match the providers configured on the development control plane. This value +# is inlined at build time, so restart the dev server after changing it. NEXT_PUBLIC_GOOGLE_ENABLED= -# NextAuth -NEXTAUTH_URL=http://localhost:3000 -NEXTAUTH_SECRET=your_generated_secret - -# Backend endpoints (deployed) +# Development control-plane endpoints CONTROL_PLANE_URL=https://open-inspect-control-plane-..workers.dev NEXT_PUBLIC_WS_URL=wss://open-inspect-control-plane-..workers.dev @@ -99,17 +88,6 @@ NEXT_PUBLIC_WS_URL=wss://open-inspect-control-plane-..workers.d # terraform state or the deployed web app's env). SERVICE_AUTH_SECRET=your_web_service_secret -# Optional access control (a user is admitted if they match ANY allowlist) -ALLOWED_USERS= -ALLOWED_EMAIL_DOMAINS= -# Exact emails (any provider's verified email) — for users on shared domains -ALLOWED_EMAILS= -# GitHub orgs whose active members can sign in. Requests read:org only when set, -# then checks active org membership with the user's OAuth token. Requires GitHub -# App Organization permissions: Members read-only. -ALLOWED_GITHUB_ORGS= -UNSAFE_ALLOW_ALL_USERS=false - # Optional whitelabel branding (defaults shown). NEXT_PUBLIC_* vars are # inlined into the client bundle at build time — restart `npm run dev` # after changing them. @@ -121,16 +99,18 @@ NEXT_PUBLIC_APP_ICON_URL= Do not commit `packages/web/.env.local`. -Generate a secret value for `NEXTAUTH_SECRET` (never for `SERVICE_AUTH_SECRET`, which must be read -from the deployment as described above): - -```bash -openssl rand -base64 32 -``` +OAuth provider credentials are not web environment variables. Better Auth runs in the control plane, +so configure `github_client_id` and `github_client_secret`—and, when enabled, `google_client_id` and +`google_client_secret`—on the development control plane through Terraform. See +[Create GitHub App](GETTING_STARTED.md#step-3-create-github-app) and +[Enable Google Login](GETTING_STARTED.md#enable-google-login-optional) for the complete provider +setup. `NEXT_PUBLIC_GOOGLE_ENABLED` only controls whether the web UI offers Google sign-in and must +match the providers configured on the control plane. If you are using someone else's deployed backend, do not generate your own `SERVICE_AUTH_SECRET`. Use the web service secret configured in that backend deployment (the control plane only accepts -signatures under its own copy). +signatures under its own copy). That backend must also be configured with +`WEB_APP_URL=http://localhost:3000`; otherwise use its deployed web app rather than a local UI. ### 3. Configure GitHub callback URL @@ -235,9 +215,9 @@ Your GitHub callback URL does not exactly match the running app URL. ### Access denied after sign-in -Check `ALLOWED_USERS`, `ALLOWED_EMAIL_DOMAINS`, and `ALLOWED_GITHUB_ORGS` in -`packages/web/.env.local`. If `ALLOWED_GITHUB_ORGS` is set, make sure your GitHub App has -Organization permissions: Members read-only and that the updated permission was republished and +Check `allowed_users`, `allowed_email_domains`, `allowed_emails`, and `allowed_github_orgs` in the +control plane's Terraform configuration. If `allowed_github_orgs` is set, make sure your GitHub App +has Organization permissions: Members read-only and that the updated permission was republished and approved for the installation. ### Web can load, but session APIs return 401 diff --git a/eslint.config.js b/eslint.config.js index e1ed4fa64..902c65617 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -113,15 +113,10 @@ export default tseslint.config( }, }, - // Web BFF routes depend on the server-auth seam, not directly on the - // current authentication framework. The auth endpoints own the framework - // integration and are intentionally excluded. + // Web code depends on app-owned auth and request seams. OAuth and session + // protocol code is owned by the control plane. { - files: [ - "packages/web/src/app/api/**/*.{ts,tsx}", - "packages/web/src/lib/integration-settings-proxy.ts", - ], - ignores: ["packages/web/src/app/api/auth/**"], + files: ["packages/web/src/**/*.{ts,tsx}"], rules: { "no-restricted-imports": [ "error", @@ -129,38 +124,17 @@ export default tseslint.config( paths: [ { name: "next-auth", - message: "Use getServerAuthSession from @/lib/server-auth-session.", + message: "Use the app-owned browser authentication seams.", }, ], patterns: [ { - regex: "(?:^|/)lib/auth$", - message: "Use getServerAuthSession from @/lib/server-auth-session.", + group: ["next-auth/*"], + message: "Use the app-owned browser authentication seams.", }, - ], - }, - ], - }, - }, - - // Web code depends on app-owned auth and request seams so the terminal - // browser-auth implementation can replace NextAuth and add its request - // contract without another consumer migration. - { - files: ["packages/web/src/**/*.{ts,tsx}"], - ignores: [ - "packages/web/src/app/api/**", - "packages/web/src/lib/auth-session.tsx", - "packages/web/src/lib/auth-session.test.tsx", - ], - rules: { - "no-restricted-imports": [ - "error", - { - paths: [ { - name: "next-auth/react", - message: "Use the app-owned boundary from @/lib/auth-session.", + regex: "(?:^|/)lib/auth$", + message: "Use getServerAuthSession from @/lib/server-auth-session.", }, ], }, diff --git a/package-lock.json b/package-lock.json index 1ef2011e6..2a816b15c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1384,6 +1384,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1413,6 +1414,33 @@ "node": ">=18" } }, + "node_modules/@better-auth/utils": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz", + "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/@better-auth/utils/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@better-fetch/fetch": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.1.tgz", + "integrity": "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==", + "license": "MIT" + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -1817,6 +1845,7 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2572,6 +2601,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2594,6 +2624,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2616,6 +2647,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2632,6 +2664,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2648,6 +2681,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2664,6 +2698,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2680,6 +2715,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2696,6 +2732,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2712,6 +2749,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2728,6 +2766,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2744,6 +2783,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2760,6 +2800,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2776,6 +2817,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2798,6 +2840,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2820,6 +2863,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2842,6 +2886,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2864,6 +2909,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2886,6 +2932,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2908,6 +2955,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2930,6 +2978,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -2952,6 +3001,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -2971,6 +3021,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -2990,6 +3041,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3009,6 +3061,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -3163,9 +3216,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3182,9 +3232,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3201,9 +3248,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3220,9 +3264,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3504,25 +3545,25 @@ "node": ">=18" } }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@panva/hkdf": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", - "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/@pierre/diffs": { "version": "1.2.12", "resolved": "https://registry.npmjs.org/@pierre/diffs/-/diffs-1.2.12.tgz", @@ -4830,7 +4871,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@shikijs/core": { @@ -5641,7 +5682,7 @@ "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/deep-eql": "*", @@ -5724,7 +5765,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/estree": { @@ -6120,7 +6161,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", @@ -6138,7 +6179,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/spy": "4.1.9", @@ -6165,7 +6206,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "tinyrainbow": "^3.1.0" @@ -6178,7 +6219,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/utils": "4.1.9", @@ -6192,7 +6233,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "4.1.9", @@ -6208,7 +6249,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://opencollective.com/vitest" @@ -6218,7 +6259,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/pretty-format": "4.1.9", @@ -6600,7 +6641,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -6744,6 +6785,282 @@ "node": ">=6.0.0" } }, + "node_modules/better-auth": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.25.tgz", + "integrity": "sha512-fvoq+oCO+FF5fpP3XfU7znRyGFpHB77UG2EyxsKNy+Cak7Q5pELu+auvvDveQbWQxcoKugZ7jYQQPFQLpUTGOw==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.6.25", + "@better-auth/drizzle-adapter": "1.6.25", + "@better-auth/kysely-adapter": "1.6.25", + "@better-auth/memory-adapter": "1.6.25", + "@better-auth/mongo-adapter": "1.6.25", + "@better-auth/prisma-adapter": "1.6.25", + "@better-auth/telemetry": "1.6.25", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@noble/ciphers": "^2.1.1", + "@noble/hashes": "^2.0.1", + "better-call": "1.3.7", + "defu": "^6.1.4", + "jose": "^6.1.3", + "kysely": "^0.28.17 || ^0.29.0", + "nanostores": "^1.1.1", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "better-sqlite3": "^12.0.0", + "drizzle-kit": ">=0.31.4", + "drizzle-orm": "^0.45.2", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/core": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.25.tgz", + "integrity": "sha512-lMTlhtwyK4NpY9kPF+2rQCRKYpg136d3gM2xl8esxT1PjJx5Nh5YwZvxcYCIjDuO759sx6TCloJTuwcZGG6ZBw==", + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.39.0", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@cloudflare/workers-types": ">=4", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.3.7", + "jose": "^6.1.0", + "kysely": "^0.28.5 || ^0.29.0", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/drizzle-adapter": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.25.tgz", + "integrity": "sha512-ru/DeKjFPQUVeKkxF/ScazmPqIY7lwfkAV5Yt4j24wmn1Y8vFwoiPRnHgXUeZqBs10+nubaRwEqLF39CP6EhRw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.25", + "@better-auth/utils": "0.4.2", + "drizzle-orm": "^0.45.2" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/kysely-adapter": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.25.tgz", + "integrity": "sha512-zxiePhtN1YClS1irKYPVwWfN6kYp+QoYlz1hdQUOj8hXyo2aE/ny4RNAb6v332b0+U6Vu88EhYITRPdmvCo6uA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.25", + "@better-auth/utils": "0.4.2", + "kysely": "^0.28.17 || ^0.29.0" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/memory-adapter": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.25.tgz", + "integrity": "sha512-GhEzTumc8yfTz+OZ6pMg06BA49xob49x1bX+1mEl/FStDJoSF+6mTfI5M2ytFxaiN89336/aUjkW8u+qRyLexw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.25", + "@better-auth/utils": "0.4.2" + } + }, + "node_modules/better-auth/node_modules/@better-auth/mongo-adapter": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.25.tgz", + "integrity": "sha512-ZtMmjcOdXR2Ziqx5y8ptTOaNpe0snNfALbBUPXJsgeyeRkDJDYzyLZ8MpuvNBTNllNeIFDbiXWAK5k+pEBZrUQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.25", + "@better-auth/utils": "0.4.2", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/prisma-adapter": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.25.tgz", + "integrity": "sha512-ym7B6Iqcry+/4aQnYpFwqP/GBIiXvjrm/5B6+0qmx8mkTY/apHFTpHuGzUYYNf4vPTtzF3eYY2+s2GOsomKaRg==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.25", + "@better-auth/utils": "0.4.2", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/telemetry": { + "version": "1.6.25", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.25.tgz", + "integrity": "sha512-2ZfC9lp7tU6Jw/q2Lz/bKfQqGMdMwc/IQDTYdBhvtGi24qInYVnhp2ZCW57hHM9j+fq1ULOtxgg6M3T1LEaihw==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.25", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1" + } + }, + "node_modules/better-auth/node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/better-auth/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/better-auth/node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/better-call": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.7.tgz", + "integrity": "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.4.0", + "@better-fetch/fetch": "^1.1.21", + "rou3": "^0.7.12", + "set-cookie-parser": "^3.0.1" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -6997,7 +7314,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -7362,7 +7679,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cookie": { @@ -7746,6 +8063,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -8130,7 +8453,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/es-object-atoms": { @@ -8509,7 +8832,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" @@ -8601,7 +8924,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=12.0.0" @@ -10329,15 +10652,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/jose": { - "version": "4.15.9", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", - "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -10466,6 +10780,15 @@ "node": ">=6" } }, + "node_modules/kysely": { + "version": "0.29.4", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.4.tgz", + "integrity": "sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -10484,7 +10807,7 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, + "devOptional": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -10952,7 +11275,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -12099,6 +12422,21 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.4.1.tgz", + "integrity": "sha512-PGd3uPojJB9Z07d5NX3Db/SOSBbyy3wLMUGq0GpnEEJfVzY9mq7daPMAZ3jObV5D3Jn+YKND636eI5ULg7F80Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -12169,47 +12507,6 @@ } } }, - "node_modules/next-auth": { - "version": "4.24.15", - "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.15.tgz", - "integrity": "sha512-NnjYtjrSOAx/TIVFGTX4IfI/9yHnNpi4B7FuLUwuV20v2Zxgr2OGP/YN0ynJuI7y8QOnTBPitfOdEXZrVvhIuA==", - "license": "ISC", - "dependencies": { - "@babel/runtime": "^7.20.13", - "@panva/hkdf": "^1.0.2", - "cookie": "^0.7.0", - "jose": "^4.15.5", - "oauth": "^0.9.15", - "openid-client": "^5.4.0", - "preact": "^10.6.3", - "preact-render-to-string": "^5.1.19", - "uuid": "^11.1.1" - }, - "peerDependencies": { - "@auth/core": "0.34.3", - "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", - "nodemailer": "^7.0.7", - "react": "^17.0.2 || ^18 || ^19", - "react-dom": "^17.0.2 || ^18 || ^19" - }, - "peerDependenciesMeta": { - "@auth/core": { - "optional": true - }, - "nodemailer": { - "optional": true - } - } - }, - "node_modules/next-auth/node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -12361,21 +12658,6 @@ "node": ">=8" } }, - "node_modules/oauth": { - "version": "0.9.15", - "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", - "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", - "license": "MIT" - }, - "node_modules/oauth4webapi": { - "version": "3.8.6", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", - "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -12385,15 +12667,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-hash": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", - "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -12513,7 +12786,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", - "dev": true, + "devOptional": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" @@ -12523,15 +12796,6 @@ "node": ">=12.20.0" } }, - "node_modules/oidc-token-hash": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", - "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", - "license": "MIT", - "engines": { - "node": "^10.13.0 || >=12.0.0" - } - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -12588,33 +12852,6 @@ "regex-recursion": "^6.0.2" } }, - "node_modules/openid-client": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", - "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", - "license": "MIT", - "dependencies": { - "jose": "^4.15.9", - "lru-cache": "^6.0.0", - "object-hash": "^2.2.0", - "oidc-token-hash": "^5.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/openid-client/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -12821,7 +13058,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/picocolors": { @@ -13060,34 +13297,6 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, - "node_modules/preact": { - "version": "10.29.2", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", - "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/preact-render-to-string": { - "version": "5.2.6", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", - "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", - "license": "MIT", - "dependencies": { - "pretty-format": "^3.8.0" - }, - "peerDependencies": { - "preact": ">=10" - } - }, - "node_modules/preact-render-to-string/node_modules/pretty-format": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", - "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", - "license": "MIT" - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -13772,7 +13981,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.133.0", @@ -13802,6 +14011,12 @@ "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -13991,6 +14206,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -14227,7 +14448,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/signal-exit": { @@ -14327,7 +14548,7 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/statuses": { @@ -14344,7 +14565,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { @@ -14842,14 +15063,14 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -14875,7 +15096,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=14.0.0" @@ -15425,19 +15646,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/uuid": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", - "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -15502,7 +15710,7 @@ "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", @@ -15580,7 +15788,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vitest/expect": "4.1.9", @@ -15832,7 +16040,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "siginfo": "^2.0.0", @@ -16033,12 +16241,6 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", @@ -16164,7 +16366,7 @@ "dependencies": { "@cloudflare/workers-types": "^4.20241230.0", "@open-inspect/shared": "file:../shared", - "oauth4webapi": "3.8.6", + "better-auth": "1.6.25", "zod": "^4.1.13" }, "devDependencies": { @@ -16272,7 +16474,6 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "next": "^16.2.11", - "next-auth": "^4.24.15", "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/packages/control-plane/README.md b/packages/control-plane/README.md index c184f9d7f..7f9721a06 100644 --- a/packages/control-plane/README.md +++ b/packages/control-plane/README.md @@ -317,6 +317,22 @@ Automations: firing-time repository snapshot (`repo_owner/repo_name/repo_id/base_branch`) and the session linkage. Firing keys live on the invocation, not the run. +## Browser Authentication + +The control plane is the browser authentication authority and runs exact-pinned Better Auth. The +Next.js web app is a BFF: it proxies a small `/api/auth/*` allowlist with its `sig1` service +credential and never stores provider secrets. + +Browser resource requests require both the signed `service:web` channel and Better Auth's opaque +session cookie. The application principal is the canonical `users.id`, and the authentication +context contains only the browser-session and signed-channel evidence. Provider-specific credential +authorities resolve linked accounts on demand for workflows such as GitHub SCM enrichment; linked +accounts do not participate in browser-session authentication. + +Terraform configures `WEB_APP_URL`, provider credentials, admission allowlists, and +`BROWSER_AUTH_SECRET` on this worker. `WEB_APP_URL` must be the exact browser-visible HTTPS origin, +except that an HTTP loopback origin is accepted for local development. + ## Token Encryption GitHub OAuth tokens are encrypted at rest using AES-256-GCM: diff --git a/packages/control-plane/package.json b/packages/control-plane/package.json index e1a10dbda..5d764fd7c 100644 --- a/packages/control-plane/package.json +++ b/packages/control-plane/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "esbuild src/index.ts --bundle --format=esm --outfile=dist/index.js --platform=browser --target=es2022 --external:cloudflare:* --external:node:*", + "build": "esbuild src/index.ts --bundle --format=esm --outfile=dist/index.js --platform=browser --target=es2022 --conditions=workerd,module --external:cloudflare:* --external:node:*", "build:vercel-base-snapshot": "esbuild scripts/build-vercel-base-snapshot.ts --bundle --format=esm --platform=node --target=node22 --outfile=dist/vercel-base-snapshot.js", "test": "vitest run", "test:coverage": "vitest run --coverage", @@ -16,7 +16,7 @@ "dependencies": { "@cloudflare/workers-types": "^4.20241230.0", "@open-inspect/shared": "file:../shared", - "oauth4webapi": "3.8.6", + "better-auth": "1.6.25", "zod": "^4.1.13" }, "devDependencies": { diff --git a/packages/control-plane/src/auth/auth-encryption.test.ts b/packages/control-plane/src/auth/auth-encryption.test.ts deleted file mode 100644 index 083aa4c04..000000000 --- a/packages/control-plane/src/auth/auth-encryption.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { OAuthFlowVerifierIntegrityError } from "./oauth-flow-verifier"; -import { ProviderCredentialIntegrityError } from "./provider-credential-cipher"; -import { - InvalidAuthEncryptionRootError, - ProviderCredentialCipher, - ProviderPkceFlowCipher, - UnsupportedAuthEncryptionVersionError, - deriveAuthEncryptionKeyBytes, -} from "./auth-encryption"; - -const ROOT_KEY_BASE64 = Buffer.from( - "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", - "hex" -).toString("base64"); - -describe("browser auth encryption key derivation", () => { - it("derives stable, purpose-separated v1 keys from the configured root", async () => { - const providerKey = await deriveAuthEncryptionKeyBytes( - ROOT_KEY_BASE64, - "provider_credentials", - 1 - ); - const flowKey = await deriveAuthEncryptionKeyBytes(ROOT_KEY_BASE64, "provider_pkce_flow", 1); - - expect(Buffer.from(providerKey).toString("hex")).toBe( - "435bee8840d77453bcf2a1e08d603cb781b24d71d4b0488fbc475c45b21f4939" - ); - expect(Buffer.from(flowKey).toString("hex")).toBe( - "41a9432e1831f5202c5561c2d31e98d3ab1eff1df5dbd774f8a4c99ce567bdd4" - ); - expect(providerKey).not.toEqual(flowKey); - }); - - it("rejects malformed roots and unsupported key versions", async () => { - await expect( - deriveAuthEncryptionKeyBytes("not-a-32-byte-base64-key", "provider_pkce_flow", 1) - ).rejects.toBeInstanceOf(InvalidAuthEncryptionRootError); - await expect( - deriveAuthEncryptionKeyBytes(ROOT_KEY_BASE64, "provider_pkce_flow", 2) - ).rejects.toBeInstanceOf(UnsupportedAuthEncryptionVersionError); - }); -}); - -describe("ProviderPkceFlowCipher", () => { - it("binds a verifier to its flow, provider, and key version", async () => { - const cipher = new ProviderPkceFlowCipher(ROOT_KEY_BASE64); - const context = { - flowId: "flow-1", - provider: "google" as const, - keyVersion: 1, - }; - - const encrypted = await cipher.encrypt("provider-pkce-verifier", context); - - await expect(cipher.decrypt(encrypted, context)).resolves.toBe("provider-pkce-verifier"); - await expect( - cipher.decrypt(encrypted, { ...context, provider: "github" }) - ).rejects.toBeInstanceOf(OAuthFlowVerifierIntegrityError); - }); - - it("uses an injected initialization vector and preserves round-trip decryption", async () => { - const initializationVector = Uint8Array.from({ length: 12 }, (_, index) => index); - const cipher = new ProviderPkceFlowCipher(ROOT_KEY_BASE64, { - ivGenerator: { generate: () => initializationVector }, - }); - const context = { - flowId: "flow-1", - provider: "google" as const, - keyVersion: 1, - }; - - const encrypted = await cipher.encrypt("provider-pkce-verifier", context); - - expect(encrypted).toBe("AAECAwQFBgcICQoLU7ir/zg3qDSh4hffgBH4d57nSJPZYWPWIpEfJ+mJY7P039F+Idk="); - expect(Buffer.from(encrypted, "base64").subarray(0, initializationVector.byteLength)).toEqual( - Buffer.from(initializationVector) - ); - await expect(cipher.decrypt(encrypted, context)).resolves.toBe("provider-pkce-verifier"); - }); - - it("rejects an invalid initialization-vector length", async () => { - const cipher = new ProviderPkceFlowCipher(ROOT_KEY_BASE64, { - ivGenerator: { generate: () => new Uint8Array(11) }, - }); - - await expect( - cipher.encrypt("provider-pkce-verifier", { - flowId: "flow-1", - provider: "google", - keyVersion: 1, - }) - ).rejects.toThrow("Provider PKCE flow IV generator returned an invalid IV"); - }); -}); - -describe("ProviderCredentialCipher", () => { - it("binds ciphertext to its identity, shape, token role, and row version", async () => { - const initializationVector = Uint8Array.from({ length: 12 }, (_, index) => index); - const cipher = new ProviderCredentialCipher(ROOT_KEY_BASE64, { - ivGenerator: { generate: () => initializationVector }, - }); - const binding = { - providerIdentityId: "identity-1", - credentialKind: "refreshable" as const, - tokenRole: "access" as const, - encryptionKeyVersion: 1, - rowVersion: 3, - }; - const encrypted = await cipher.encrypt("provider-access-token", binding); - - expect(encrypted).toBe("AAECAwQFBgcICQoLiRyTliBzKgjV8xzRi0vqSQPLGq0sVzWGmPuFl+yGTHHBQtlqZQ=="); - await expect(cipher.decrypt(encrypted, binding)).resolves.toBe("provider-access-token"); - await expect( - cipher.decrypt(encrypted, { ...binding, providerIdentityId: "identity-2" }) - ).rejects.toBeInstanceOf(ProviderCredentialIntegrityError); - await expect( - cipher.decrypt(encrypted, { ...binding, credentialKind: "access_only_expiring" }) - ).rejects.toBeInstanceOf(ProviderCredentialIntegrityError); - await expect( - cipher.decrypt(encrypted, { ...binding, tokenRole: "refresh" }) - ).rejects.toBeInstanceOf(ProviderCredentialIntegrityError); - await expect(cipher.decrypt(encrypted, { ...binding, rowVersion: 4 })).rejects.toBeInstanceOf( - ProviderCredentialIntegrityError - ); - }); - - it("rejects an invalid initialization-vector length", async () => { - const cipher = new ProviderCredentialCipher(ROOT_KEY_BASE64, { - ivGenerator: { generate: () => new Uint8Array(11) }, - }); - - await expect( - cipher.encrypt("provider-access-token", { - providerIdentityId: "identity-1", - credentialKind: "access_only_nonexpiring", - tokenRole: "access", - encryptionKeyVersion: 1, - rowVersion: 1, - }) - ).rejects.toThrow("Provider credential IV generator returned an invalid IV"); - }); -}); diff --git a/packages/control-plane/src/auth/auth-encryption.ts b/packages/control-plane/src/auth/auth-encryption.ts deleted file mode 100644 index 00375103c..000000000 --- a/packages/control-plane/src/auth/auth-encryption.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { - OAuthFlowVerifierIntegrityError, - type OAuthFlowVerifierBinding, - type OAuthFlowVerifierCipher, -} from "./oauth-flow-verifier"; -import { - ProviderCredentialIntegrityError, - type ProviderCredentialCipherBinding, - type ProviderCredentialCipherPort, -} from "./provider-credential-cipher"; - -export type AuthEncryptionPurpose = "provider_credentials" | "provider_pkce_flow"; - -const HKDF_SALT = "openinspect/auth-key-derivation/v1"; -const V1_PURPOSE_INFO: Readonly> = { - provider_credentials: "openinspect/provider-credentials/v1", - provider_pkce_flow: "openinspect/provider-pkce-flow/v1", -}; - -export class UnsupportedAuthEncryptionVersionError extends Error { - constructor(readonly version: number) { - super("Unsupported authentication encryption version"); - this.name = "UnsupportedAuthEncryptionVersionError"; - } -} - -export class InvalidAuthEncryptionRootError extends Error { - constructor() { - super("Authentication encryption root must be exactly 32 base64-encoded bytes"); - this.name = "InvalidAuthEncryptionRootError"; - } -} - -function decodeRootKey(rootKeyBase64: string): Uint8Array { - if (!/^[A-Za-z0-9+/]{43}=$/.test(rootKeyBase64)) { - throw new InvalidAuthEncryptionRootError(); - } - - try { - const decoded = Uint8Array.from(atob(rootKeyBase64), (character) => character.charCodeAt(0)); - if (decoded.byteLength === 32) return decoded; - } catch { - // Normalize platform decoding errors into the stable configuration error. - } - throw new InvalidAuthEncryptionRootError(); -} - -export async function deriveAuthEncryptionKeyBytes( - rootKeyBase64: string, - purpose: AuthEncryptionPurpose, - version: number -): Promise { - if (version !== 1) throw new UnsupportedAuthEncryptionVersionError(version); - - const encoder = new TextEncoder(); - const rootKey = await crypto.subtle.importKey( - "raw", - decodeRootKey(rootKeyBase64), - "HKDF", - false, - ["deriveBits"] - ); - const bits = await crypto.subtle.deriveBits( - { - name: "HKDF", - hash: "SHA-256", - salt: encoder.encode(HKDF_SALT), - info: encoder.encode(V1_PURPOSE_INFO[purpose]), - }, - rootKey, - 256 - ); - return new Uint8Array(bits); -} - -export interface InitializationVectorGenerator { - generate(): Uint8Array; -} - -const AES_GCM_IV_BYTES = 12; -const AES_GCM_TAG_BYTES = 16; - -function providerPkceFlowAssociatedData(context: OAuthFlowVerifierBinding): Uint8Array { - return new TextEncoder().encode( - JSON.stringify(["provider_pkce_flow", context.flowId, context.provider, context.keyVersion]) - ); -} - -function encodeCiphertext(iv: Uint8Array, ciphertext: ArrayBuffer): string { - const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength); - combined.set(iv); - combined.set(new Uint8Array(ciphertext), iv.byteLength); - return btoa(String.fromCharCode(...combined)); -} - -function decodeCiphertext( - value: string, - integrityError: () => Error -): { iv: Uint8Array; ciphertext: Uint8Array } { - try { - if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { - throw new Error("Ciphertext is not canonical base64"); - } - const combined = Uint8Array.from(atob(value), (character) => character.charCodeAt(0)); - if (combined.byteLength <= AES_GCM_IV_BYTES + AES_GCM_TAG_BYTES) { - throw new Error("Ciphertext is too short"); - } - return { - iv: combined.slice(0, AES_GCM_IV_BYTES), - ciphertext: combined.slice(AES_GCM_IV_BYTES), - }; - } catch { - throw integrityError(); - } -} - -interface AesGcmPurposeCipherOptions { - readonly rootKeyBase64: string; - readonly purpose: AuthEncryptionPurpose; - readonly associatedData: (binding: Binding) => Uint8Array; - readonly keyVersion: (binding: Binding) => number; - readonly integrityError: () => Error; - readonly invalidIvError: () => Error; - readonly ivGenerator?: InitializationVectorGenerator; -} - -class AesGcmPurposeCipher { - private readonly keys = new Map>(); - private readonly ivGenerator: InitializationVectorGenerator; - - constructor(private readonly options: AesGcmPurposeCipherOptions) { - this.ivGenerator = options.ivGenerator ?? { - generate: () => crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES)), - }; - } - - async encrypt(plaintext: string, binding: Binding): Promise { - const iv = this.ivGenerator.generate(); - if (iv.byteLength !== AES_GCM_IV_BYTES) { - throw this.options.invalidIvError(); - } - const ciphertext = await crypto.subtle.encrypt( - { - name: "AES-GCM", - iv, - additionalData: this.options.associatedData(binding), - }, - await this.getKey(this.options.keyVersion(binding)), - new TextEncoder().encode(plaintext) - ); - return encodeCiphertext(iv, ciphertext); - } - - async decrypt(encrypted: string, binding: Binding): Promise { - const { iv, ciphertext } = decodeCiphertext(encrypted, this.options.integrityError); - try { - const plaintext = await crypto.subtle.decrypt( - { - name: "AES-GCM", - iv, - additionalData: this.options.associatedData(binding), - }, - await this.getKey(this.options.keyVersion(binding)), - ciphertext - ); - return new TextDecoder().decode(plaintext); - } catch (error) { - if (error instanceof UnsupportedAuthEncryptionVersionError) throw error; - if (error instanceof InvalidAuthEncryptionRootError) throw error; - throw this.options.integrityError(); - } - } - - private getKey(version: number): Promise { - const existing = this.keys.get(version); - if (existing) return existing; - - const derived = deriveAuthEncryptionKeyBytes( - this.options.rootKeyBase64, - this.options.purpose, - version - ).then((bytes) => - crypto.subtle.importKey("raw", bytes, { name: "AES-GCM", length: 256 }, false, [ - "encrypt", - "decrypt", - ]) - ); - this.keys.set(version, derived); - return derived; - } -} - -export class ProviderPkceFlowCipher implements OAuthFlowVerifierCipher { - private readonly cipher: AesGcmPurposeCipher; - - constructor( - rootKeyBase64: string, - dependencies: { readonly ivGenerator?: InitializationVectorGenerator } = {} - ) { - this.cipher = new AesGcmPurposeCipher({ - rootKeyBase64, - purpose: "provider_pkce_flow", - associatedData: providerPkceFlowAssociatedData, - keyVersion: (binding) => binding.keyVersion, - integrityError: () => new OAuthFlowVerifierIntegrityError(), - invalidIvError: () => new Error("Provider PKCE flow IV generator returned an invalid IV"), - ivGenerator: dependencies.ivGenerator, - }); - } - - encrypt(plaintext: string, context: OAuthFlowVerifierBinding): Promise { - return this.cipher.encrypt(plaintext, context); - } - - decrypt(encrypted: string, context: OAuthFlowVerifierBinding): Promise { - return this.cipher.decrypt(encrypted, context); - } -} - -function providerCredentialAssociatedData(context: ProviderCredentialCipherBinding): Uint8Array { - return new TextEncoder().encode( - JSON.stringify([ - "provider_credentials", - context.providerIdentityId, - context.credentialKind, - context.tokenRole, - context.encryptionKeyVersion, - context.rowVersion, - ]) - ); -} - -export class ProviderCredentialCipher implements ProviderCredentialCipherPort { - private readonly cipher: AesGcmPurposeCipher; - - constructor( - rootKeyBase64: string, - dependencies: { readonly ivGenerator?: InitializationVectorGenerator } = {} - ) { - this.cipher = new AesGcmPurposeCipher({ - rootKeyBase64, - purpose: "provider_credentials", - associatedData: providerCredentialAssociatedData, - keyVersion: (binding) => binding.encryptionKeyVersion, - integrityError: () => new ProviderCredentialIntegrityError(), - invalidIvError: () => new Error("Provider credential IV generator returned an invalid IV"), - ivGenerator: dependencies.ivGenerator, - }); - } - - encrypt(plaintext: string, context: ProviderCredentialCipherBinding): Promise { - return this.cipher.encrypt(plaintext, context); - } - - decrypt(encrypted: string, context: ProviderCredentialCipherBinding): Promise { - return this.cipher.decrypt(encrypted, context); - } -} diff --git a/packages/control-plane/src/auth/authenticate.test.ts b/packages/control-plane/src/auth/authenticate.test.ts index b56dbd422..5ec0e0975 100644 --- a/packages/control-plane/src/auth/authenticate.test.ts +++ b/packages/control-plane/src/auth/authenticate.test.ts @@ -309,6 +309,70 @@ describe("authenticate — service credentials", () => { }); }); +describe("authenticate — compound browser credentials", () => { + function createUserAuthContext( + session: { + session: { id: string; userId: string }; + user: { id: string }; + } | null + ): RequestContext { + const ctx = createCtx(); + ctx.getUserAuth = () => + ({ + api: { + getSession: vi.fn(async () => session), + }, + }) as never; + return ctx; + } + + it("requires the web sig1 channel and Better Auth session for a browser resource", async () => { + const request = await signedRequest({ + service: "web", + method: "GET", + url: "https://cp.test.local/sessions", + mutate: (headers) => { + headers.Cookie = "__Secure-openinspect.session_token=signed-session-token"; + }, + }); + const ctx = createUserAuthContext({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1" }, + }); + + const result = await authenticate(request, createEnv(), ctx, { + webService: "user", + }); + + expect(isAuthError(result)).toBe(false); + if (isAuthError(result)) return; + expect(result.principal).toEqual({ kind: "user", userId: "user-1" }); + expect(result.authentication).toEqual({ + mechanism: "browser_session", + credentialId: "session-1", + channel: { kind: "sig1", service: "web" }, + }); + }); + + it("does not let a valid web channel fall back when its browser session is absent", async () => { + const request = await signedRequest({ + service: "web", + method: "GET", + url: "https://cp.test.local/sessions", + }); + + const result = await authenticate(request, createEnv(), createUserAuthContext(null), { + webService: "user", + }); + + expect(result).toEqual({ + reason: "Unauthorized", + status: 401, + failedScheme: "browser-session", + }); + }); +}); + describe("authenticate — nonce replay logging", () => { it("warns on nonce reuse inside the validity window but still authenticates", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -340,10 +404,8 @@ describe("authenticate — nonce replay logging", () => { }); }); -describe("authenticate — web session token dispatch", () => { - it("dispatches oi_at_ bearers to token verification, never the shared bearer", async () => { - // An unknown token must fail as a user-token attempt (terminal), even - // though the same header would otherwise reach the shared-bearer arm. +describe("authenticate — retired web session tokens", () => { + it("treats oi_at_ bearers as unrecognized after the browser-session cutover", async () => { const request = new Request("https://cp.test.local/sessions", { headers: { Authorization: "Bearer oi_at_unknown-token-value" }, }); @@ -351,7 +413,7 @@ describe("authenticate — web session token dispatch", () => { expect(result).toEqual({ reason: "Unauthorized", status: 401, - failedScheme: "user-token", + failedScheme: "none", }); }); }); diff --git a/packages/control-plane/src/auth/authenticate.ts b/packages/control-plane/src/auth/authenticate.ts index d1b4c5d64..8d06a789e 100644 --- a/packages/control-plane/src/auth/authenticate.ts +++ b/packages/control-plane/src/auth/authenticate.ts @@ -2,325 +2,92 @@ * Edge authentication: resolve every non-public, non-sandbox request to a * typed `Principal` before any handler runs. * - * Dispatch order: a `sig1` service signature (verified against - * that service's own secret — a failed attempt is terminal), then an - * `oi_at_` web session token. Anything else is not a recognized credential. + * A `sig1` service signature is verified against that service's own secret. + * User requests additionally require a Better Auth session. Anything else + * is not a recognized credential. * * Sandbox tokens stay router-verified (they need the session id from the * path and a DO round-trip), so they are not dispatched here. */ -import { - ACTOR_HEADER, - SERVICE_HEADER, - SERVICE_SIGNATURE_HEADER, - TOKEN_VALIDITY_MS, - isServiceName, - parseServiceSignatureHeader, - readBodyCapped, - sha256Hex, - verifyServiceSignature, - type ServiceName, -} from "@open-inspect/shared"; - -import { - ASSERTION_RIGHTS, - isActorNamespace, - type ActorNamespace, - type Principal, -} from "./principal"; -import { ACCESS_TOKEN_PREFIX, WebSessionTokenService } from "./web-session-tokens"; -import { ApiTokenStore } from "../db/api-tokens"; -import { UserStore } from "../db/user-store"; +import { SERVICE_SIGNATURE_HEADER } from "@open-inspect/shared"; +import { authenticateSession, SessionIntegrityError } from "./user/session-authenticator"; +import { isAuthError, type AuthResult } from "./result"; +import { authenticateServiceRequest } from "./service/request-authenticator"; import { createLogger } from "../logger"; import type { RequestContext } from "../routes/shared"; import type { Env } from "../types"; const logger = createLogger("auth"); -export interface AuthError { - /** Response body message (also the log detail). Never carries token material. */ - reason: string; - status: 401 | 413 | 500; +export { isAuthError, type AuthError, type AuthResult } from "./result"; +export { SERVICE_REQUEST_MAX_BODY_BYTES } from "./service/request-authenticator"; + +export interface AuthenticationRequirement { /** - * Which scheme was attempted and failed. A per-service or user-token - * attempt is terminal; "none" means no recognized credential was presented - * at all, and the router may still try sandbox auth on sandbox routes. + * Whether a verified service:web request is acting as the service itself or + * as a user through a Better Auth session. */ - failedScheme: "per-service" | "user-token" | "none"; -} - -/** - * Hard cap on a service-signed request body. The signature covers the body - * hash, so the body must be buffered and hashed before verification can - * finish — this cap bounds what an unauthenticated sender can make the edge - * buffer. The largest legitimate signed body is a session-attachment - * multipart upload (see SESSION_ATTACHMENT_MAX_REQUEST_BYTES, ~10MB); sandbox - * media uploads authenticate with sandbox tokens and never pass through here. - */ -export const SERVICE_REQUEST_MAX_BODY_BYTES = 16 * 1024 * 1024; - -export type AuthResult = { principal: Principal; request: Request } | AuthError; - -export function isAuthError(result: AuthResult): result is AuthError { - return !("principal" in result); -} - -/** The per-service verification keys the CP holds. */ -export interface ServiceKeyEnv { - SERVICE_AUTH_SECRET_WEB?: string; - SERVICE_AUTH_SECRET_SLACK_BOT?: string; - SERVICE_AUTH_SECRET_GITHUB_BOT?: string; - SERVICE_AUTH_SECRET_LINEAR_BOT?: string; - SERVICE_AUTH_SECRET_MODAL?: string; -} - -/** The verification key the CP holds for each service (also the signing key for CP→bot callbacks). */ -export function serviceAuthSecret(env: ServiceKeyEnv, service: ServiceName): string | undefined { - switch (service) { - case "web": - return env.SERVICE_AUTH_SECRET_WEB; - case "slack-bot": - return env.SERVICE_AUTH_SECRET_SLACK_BOT; - case "github-bot": - return env.SERVICE_AUTH_SECRET_GITHUB_BOT; - case "linear-bot": - return env.SERVICE_AUTH_SECRET_LINEAR_BOT; - case "modal": - return env.SERVICE_AUTH_SECRET_MODAL; - } -} - -/** Parse `:` into a typed actor reference; null when malformed. */ -function parseActor(actor: string): { provider: ActorNamespace; providerUserId: string } | null { - const separator = actor.indexOf(":"); - if (separator <= 0) return null; - const namespace = actor.slice(0, separator); - const providerUserId = actor.slice(separator + 1); - if (providerUserId === "" || !isActorNamespace(namespace)) return null; - return { provider: namespace, providerUserId }; + readonly webService?: "service" | "user"; } -/** - * Best-effort nonce-reuse detection (log-only for now; a future change may - * reject). In-isolate only — a replay against a different isolate is not - * observed. Entries expire with the signature validity window. - */ -const seenNonces = new Map(); -const SEEN_NONCE_LIMIT = 5000; - -function recordNonce(service: ServiceName, nonce: string, ctx: RequestContext): void { - const now = Date.now(); - const key = `${service}:${nonce}`; - const expiresAt = seenNonces.get(key); - if (expiresAt !== undefined && expiresAt > now) { - logger.warn("Service auth nonce reused", { - event: "auth.nonce_reuse", - service, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - return; - } - if (seenNonces.size >= SEEN_NONCE_LIMIT) { - for (const [candidate, expiry] of seenNonces) { - if (expiry <= now) seenNonces.delete(candidate); - } - // Still over cap: shed the oldest entries (Map iteration is insertion - // order, and entries are inserted with monotonically increasing expiry). - // A flood must degrade detection gradually, never erase all memory. - let excess = seenNonces.size - SEEN_NONCE_LIMIT + 1; - for (const candidate of seenNonces.keys()) { - if (excess-- <= 0) break; - seenNonces.delete(candidate); - } - } - seenNonces.set(key, now + TOKEN_VALIDITY_MS); -} - -async function authenticateServiceCredential( +export async function authenticate( request: Request, env: Env, ctx: RequestContext, - signatureHeader: string + requirement: AuthenticationRequirement = {} ): Promise { - const serviceHeader = request.headers.get(SERVICE_HEADER) ?? ""; - if (!isServiceName(serviceHeader)) { - logger.warn("Service auth failed: unknown service", { - event: "auth.service_failed", - failure: "unknown_service", - service: serviceHeader, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - return { reason: "Unauthorized", status: 401, failedScheme: "per-service" }; - } - const service = serviceHeader; - - const secret = serviceAuthSecret(env, service); - if (!secret) { - logger.error("Service auth secret not configured - rejecting request", { - event: "auth.misconfigured", - service, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - return { - reason: "Service authentication not configured", - status: 500, - failedScheme: "per-service", - }; - } - - // Reject everything rejectable before paying for the body: a malformed or - // stale header must not cost a body buffer + hash. - const parsedSignature = parseServiceSignatureHeader(signatureHeader); - if (!parsedSignature.ok) { - logger.warn("Service auth failed: signature rejected", { - event: "auth.service_failed", - failure: parsedSignature.reason, - service, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - return { reason: "Unauthorized", status: 401, failedScheme: "per-service" }; - } - - let bodyBuffer: Uint8Array | null = null; - if (request.body !== null) { - bodyBuffer = await readBodyCapped(request.body, SERVICE_REQUEST_MAX_BODY_BYTES); - if (bodyBuffer === null) { - logger.warn("Service auth failed: body over size cap", { - event: "auth.service_failed", - failure: "body_too_large", - service, + const signatureHeader = request.headers.get(SERVICE_SIGNATURE_HEADER); + if (signatureHeader !== null) { + const channel = await authenticateServiceRequest(request, env, ctx, signatureHeader); + if ( + isAuthError(channel) || + channel.principal.kind !== "service" || + channel.principal.service !== "web" || + requirement.webService !== "user" + ) { + return channel; + } + if (!ctx.getUserAuth) { + logger.error("User authentication runtime unavailable", { + event: "auth.browser.misconfigured", request_id: ctx.request_id, trace_id: ctx.trace_id, }); - return { reason: "Request body too large", status: 413, failedScheme: "per-service" }; + return { + reason: "User authentication is not configured", + status: 500, + failedScheme: "browser-session", + }; } - } - const bodySha256Hex = await sha256Hex(bodyBuffer ?? ""); - const actor = request.headers.get(ACTOR_HEADER) ?? ""; - - const verification = await verifyServiceSignature({ - signatureHeader, - service, - secret, - method: request.method, - url: request.url, - bodySha256Hex, - actor, - }); - if (!verification.ok) { - logger.warn("Service auth failed: signature rejected", { - event: "auth.service_failed", - failure: verification.reason, - service, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - return { reason: "Unauthorized", status: 401, failedScheme: "per-service" }; - } - - recordNonce(service, verification.nonce, ctx); - - let resolvedActor = null; - if (actor !== "") { - const parsed = parseActor(actor); - if (!parsed || ASSERTION_RIGHTS[service] !== parsed.provider) { - logger.warn("Actor assertion denied", { - event: "auth.assertion_denied", - service, - actor, + try { + const userSession = await authenticateSession(ctx.getUserAuth().api, channel.request.headers); + if (!userSession) { + return { + reason: "Unauthorized", + status: 401, + failedScheme: "browser-session", + }; + } + return { + principal: { kind: "user", userId: userSession.userId }, + authentication: userSession.authentication, + request: channel.request, + }; + } catch (cause) { + logger.error("User session validation failed", { + event: "auth.browser.failed", + failure: cause instanceof SessionIntegrityError ? "integrity" : "runtime", + error: cause, request_id: ctx.request_id, trace_id: ctx.trace_id, }); - return { reason: "Unauthorized", status: 401, failedScheme: "per-service" }; + return { + reason: "User authentication failed", + status: 500, + failedScheme: "browser-session", + }; } - const identity = await new UserStore(ctx.db).getIdentity( - parsed.provider, - parsed.providerUserId - ); - resolvedActor = { - provider: parsed.provider, - providerUserId: parsed.providerUserId, - canonicalUserId: identity?.userId ?? null, - participantUserId: actor, - }; - } - - // The body was consumed to hash it; hand the handler a request that can - // still be read. Bodyless requests pass through untouched. Built from - // parts deliberately: the `new Request(request, { body })` copy-constructor - // throws in workerd once the source request's body has been disturbed. - const handlerRequest = - bodyBuffer === null - ? request - : new Request(request.url, { - method: request.method, - headers: request.headers, - body: bodyBuffer, - }); - - return { - principal: { kind: "service", service, actor: resolvedActor }, - request: handlerRequest, - }; -} - -async function authenticateWebSessionToken( - token: string, - request: Request, - ctx: RequestContext -): Promise { - const store = new ApiTokenStore(ctx.db); - const verification = await new WebSessionTokenService(store).verifyAccessToken(token); - if (!verification.ok) { - logger.warn("Web session token rejected", { - event: "auth.user_token_failed", - failure: verification.failure, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - return { reason: "Unauthorized", status: 401, failedScheme: "user-token" }; - } - - ctx.executionCtx?.waitUntil( - store.touchLastUsed(verification.tokenId).catch(() => { - // Best-effort usage stamp; never block or fail the request for it. - }) - ); - - return { - principal: { - kind: "user", - user: { - provider: verification.provider, - providerUserId: verification.providerUserId, - canonicalUserId: verification.userId, - // Web users participate under their bare canonical id. - participantUserId: verification.userId, - }, - tokenId: verification.tokenId, - }, - request, - }; -} - -export async function authenticate( - request: Request, - env: Env, - ctx: RequestContext -): Promise { - const signatureHeader = request.headers.get(SERVICE_SIGNATURE_HEADER); - if (signatureHeader !== null) { - return authenticateServiceCredential(request, env, ctx, signatureHeader); - } - - const authHeader = request.headers.get("Authorization"); - if (authHeader?.startsWith(`Bearer ${ACCESS_TOKEN_PREFIX}`)) { - return authenticateWebSessionToken(authHeader.slice("Bearer ".length), request, ctx); } // No recognized credential. The shared bearer is retired — a diff --git a/packages/control-plane/src/auth/identity-enforcement.test.ts b/packages/control-plane/src/auth/identity-enforcement.test.ts index 5d0807f85..8d51c05d8 100644 --- a/packages/control-plane/src/auth/identity-enforcement.test.ts +++ b/packages/control-plane/src/auth/identity-enforcement.test.ts @@ -3,7 +3,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { applyIdentityEnforcement, deriveIdentity, - authorizeProviderIdentityRequest, mayAttachCallbackContext, requireEventPoster, resolveCanonicalUserId, @@ -14,13 +13,7 @@ import type { RequestContext } from "../routes/shared"; const USER_PRINCIPAL: Principal = { kind: "user", - user: { - provider: "github", - providerUserId: "583231", - canonicalUserId: "canon-1", - participantUserId: "canon-1", - }, - tokenId: "token-1", + userId: "canon-1", }; const SLACK_ACTOR: ResolvedIdentity = { @@ -345,65 +338,3 @@ describe("requireEventPoster", () => { expect(requireEventPoster(createCtx(GITHUB_BOT), "sentry")).toBeNull(); }); }); - -describe("authorizeProviderIdentityRequest", () => { - it("logs and denies a user principal upserting another identity", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - const authz = authorizeProviderIdentityRequest(createCtx(USER_PRINCIPAL), "github", "999999"); - expect(authz.action).toBe("deny"); - expect(authz.action === "deny" && authz.response.status).toBe(403); - const mismatch = loggedEvents(warn).find((e) => e.event === "identity.mismatch_rejected"); - expect(mismatch).toMatchObject({ - expected: "github:583231", - actual: "github:999999", - }); - }); - - it("resolves a matching user to its token-fixed canonical id, never an upsert", () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - // The takeover guard: a matching user resolves to the id its token already - // carries — it must NEVER return `upsert`, which would let the request - // body's providerEmail re-link the identity to another user. - const authz = authorizeProviderIdentityRequest(createCtx(USER_PRINCIPAL), "github", "583231"); - expect(authz).toEqual({ action: "resolve", canonicalUserId: "canon-1" }); - expect(warn).not.toHaveBeenCalled(); - }); - - it("denies the web service now that provider identity linking requires a user token", () => { - vi.spyOn(console, "warn").mockImplementation(() => undefined); - const authz = authorizeProviderIdentityRequest( - createCtx({ kind: "service", service: "web", actor: null }), - "github", - "999999" - ); - expect(authz.action === "deny" && authz.response.status).toBe(403); - }); - - it("fails closed if a user principal ever lacks a canonical id", () => { - vi.spyOn(console, "warn").mockImplementation(() => undefined); - const principal: Principal = { - kind: "user", - user: { - provider: "github", - providerUserId: "583231", - canonicalUserId: null, - participantUserId: "583231", - }, - tokenId: "token-1", - }; - const authz = authorizeProviderIdentityRequest(createCtx(principal), "github", "583231"); - expect(authz.action === "deny" && authz.response.status).toBe(500); - }); - - it("denies every other service principal", () => { - vi.spyOn(console, "warn").mockImplementation(() => undefined); - const slack = authorizeProviderIdentityRequest(createCtx(SLACK_BOT_PRINCIPAL), "slack", "UANY"); - expect(slack.action === "deny" && slack.response.status).toBe(403); - const modal = authorizeProviderIdentityRequest( - createCtx({ kind: "service", service: "modal", actor: null }), - "github", - "999999" - ); - expect(modal.action === "deny" && modal.response.status).toBe(403); - }); -}); diff --git a/packages/control-plane/src/auth/identity-enforcement.ts b/packages/control-plane/src/auth/identity-enforcement.ts index f50c59c34..256b706bb 100644 --- a/packages/control-plane/src/auth/identity-enforcement.ts +++ b/packages/control-plane/src/auth/identity-enforcement.ts @@ -12,7 +12,7 @@ import type { AutomationEventSource, ServiceName, SpawnSource } from "@open-inspect/shared"; import { createLogger } from "./../logger"; -import { CALLBACK_DESTINATIONS } from "./callback-signing"; +import { CALLBACK_DESTINATIONS } from "./service/callback-signing"; import type { Principal, ResolvedIdentity } from "./principal"; import type { UserStore } from "../db/user-store"; import { error, type RequestContext } from "../routes/shared"; @@ -108,8 +108,8 @@ export function deriveIdentity(principal: Principal | undefined): DerivedIdentit switch (principal.kind) { case "user": return { - participantUserId: principal.user.participantUserId, - canonicalUserId: principal.user.canonicalUserId, + participantUserId: principal.userId, + canonicalUserId: principal.userId, actor: null, spawnSource: "user", }; @@ -297,62 +297,3 @@ export function requireEventPoster( logMismatchRejected(`internal-${source}-event`, "service", expected, principal.service, ctx); return error("Unauthorized", 401); } - -/** - * The action a caller is authorized to take on - * `PUT /provider-identities/:provider/:id`. - * - * - `resolve`: a user principal matching the path identity. Its canonical - * user is already fixed by the token it presented, so the route returns - * that id verbatim and never touches identity linkage. Crucially, the - * request body (and any `providerEmail` in it) is IGNORED — otherwise an - * `oi_at_` holder could assert an arbitrary email and have - * `resolveOrCreateUser` re-link its provider identity onto another user's - * canonical account. Linking stays provider-verified, in the exchange flow. - * - `deny`: everyone else. - */ -type ProviderIdentityAuthorization = - | { action: "resolve"; canonicalUserId: string } - | { action: "deny"; response: Response }; - -export function authorizeProviderIdentityRequest( - ctx: RequestContext, - provider: string, - providerUserId: string -): ProviderIdentityAuthorization { - const principal = ctx.principal; - if (principal?.kind === "user") { - if (provider === principal.user.provider && providerUserId === principal.user.providerUserId) { - // canonicalUserId is always set for user principals (minted from the - // token row); the body is deliberately never consulted here. Fail - // closed on the impossible null rather than emitting an invalid id. - const canonicalUserId = principal.user.canonicalUserId; - if (!canonicalUserId) { - return { action: "deny", response: error("User principal has no canonical id", 500) }; - } - return { action: "resolve", canonicalUserId }; - } - logMismatchRejected( - "provider-identities", - "provider-identity-path", - `${principal.user.provider}:${principal.user.providerUserId}`, - `${provider}:${providerUserId}`, - ctx - ); - return { - action: "deny", - response: error("Path identity does not match the authenticated user", 403), - }; - } - logMismatchRejected( - "provider-identities", - "principal", - "matching user", - principal?.kind === "service" ? principal.service : (principal?.kind ?? "none"), - ctx - ); - return { - action: "deny", - response: error("Only the matching user may resolve a provider identity", 403), - }; -} diff --git a/packages/control-plane/src/auth/index.ts b/packages/control-plane/src/auth/index.ts deleted file mode 100644 index d8c6ac556..000000000 --- a/packages/control-plane/src/auth/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Auth module exports. - */ - -export { encryptToken, decryptToken, generateEncryptionKey, generateId } from "./crypto"; - -export { isGitHubAppConfigured, getGitHubAppConfig, type GitHubAppConfig } from "./github-app"; diff --git a/packages/control-plane/src/auth/oauth-authorization-service.test.ts b/packages/control-plane/src/auth/oauth-authorization-service.test.ts deleted file mode 100644 index 3d442bd85..000000000 --- a/packages/control-plane/src/auth/oauth-authorization-service.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - OAuthAuthorizationRequestError, - OAuthAuthorizationService, - StaticOAuthClientRegistry, - WebCryptoOpaqueValueGenerator, -} from "./oauth-authorization-service"; -import type { CreateOAuthFlowStateInput } from "./oauth-flow-state"; -import type { - OAuthSignInProvider, - ProviderAuthorizationRequest, - ProviderCodeExchangeRequest, - ProviderCodeExchangeResult, -} from "./providers/types"; -import type { SignInProvider } from "./sign-in-provider"; - -class FakeProvider

implements OAuthSignInProvider

{ - readonly createAuthorizationUrl = vi.fn( - async (request: ProviderAuthorizationRequest

): Promise => { - const url = new URL(`https://${this.provider}.example/authorize`); - url.searchParams.set("state", request.state); - return url; - } - ); - - constructor(readonly provider: P) {} - - exchangeAuthorizationCode( - _request: ProviderCodeExchangeRequest

- ): Promise> { - throw new Error("not used"); - } -} - -describe("OAuthAuthorizationService", () => { - it("generates independent 32-byte base64url values for upstream secrets", () => { - const generator = new WebCryptoOpaqueValueGenerator(); - - const first = generator.generate(); - const second = generator.generate(); - - expect(first).toMatch(/^[A-Za-z0-9_-]{43}$/); - expect(second).toMatch(/^[A-Za-z0-9_-]{43}$/); - expect(second).not.toBe(first); - }); - - it("validates the web client and persists a GitHub flow before returning its redirect", async () => { - const github = new FakeProvider("github"); - const google = new FakeProvider("google"); - const create = vi.fn(async (_input: CreateOAuthFlowStateInput) => ({ flowId: "flow-1" })); - const verifier = "v".repeat(43); - const service = new OAuthAuthorizationService({ - clients: new StaticOAuthClientRegistry(["https://web.example.com/api/auth/callback"]), - providers: { github, google }, - flowStateStore: { create }, - opaqueValueGenerator: { generate: () => verifier }, - }); - - const redirect = await service.authorize({ - responseType: "code", - clientId: "web", - redirectUri: "https://web.example.com/api/auth/callback", - state: "s".repeat(43), - codeChallenge: "c".repeat(43), - codeChallengeMethod: "S256", - provider: "github", - }); - - expect(redirect.toString()).toBe(`https://github.example/authorize?state=${"s".repeat(43)}`); - expect(create).toHaveBeenCalledWith({ - state: "s".repeat(43), - provider: "github", - clientId: "web", - redirectUri: "https://web.example.com/api/auth/callback", - clientCodeChallenge: "c".repeat(43), - providerPkceVerifier: verifier, - }); - expect(github.createAuthorizationUrl).toHaveBeenCalledWith({ - state: "s".repeat(43), - codeChallenge: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), - }); - expect(google.createAuthorizationUrl).not.toHaveBeenCalled(); - }); - - it("rejects an unregistered redirect before provider or storage work", async () => { - const github = new FakeProvider("github"); - const google = new FakeProvider("google"); - const create = vi.fn(); - const generate = vi.fn(() => "v".repeat(43)); - const service = new OAuthAuthorizationService({ - clients: new StaticOAuthClientRegistry(["https://web.example.com/api/auth/callback"]), - providers: { github, google }, - flowStateStore: { create }, - opaqueValueGenerator: { generate }, - }); - - const rejection = service.authorize({ - responseType: "code", - clientId: "web", - redirectUri: "https://attacker.example/callback", - state: "s".repeat(43), - codeChallenge: "c".repeat(43), - codeChallengeMethod: "S256", - provider: "github", - }); - const error = await rejection.catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(OAuthAuthorizationRequestError); - expect(error).toMatchObject({ - name: "OAuthAuthorizationRequestError", - code: "invalid_request", - }); - expect(generate).not.toHaveBeenCalled(); - expect(create).not.toHaveBeenCalled(); - expect(github.createAuthorizationUrl).not.toHaveBeenCalled(); - }); - - it.each([ - ["unsupported response type", { responseType: "token" }, "unsupported_response_type"], - ["unknown client", { clientId: "cli" }, "invalid_client"], - ["short state", { state: "short" }, "invalid_request"], - ["plain PKCE", { codeChallengeMethod: "plain" }, "invalid_request"], - ["malformed challenge", { codeChallenge: "short" }, "invalid_request"], - ["unknown provider", { provider: "okta" }, "invalid_request"], - ] as const)("rejects %s before generating or persisting flow state", async (_, patch, code) => { - const github = new FakeProvider("github"); - const google = new FakeProvider("google"); - const create = vi.fn(); - const generate = vi.fn(() => "v".repeat(43)); - const service = new OAuthAuthorizationService({ - clients: new StaticOAuthClientRegistry(["https://web.example.com/api/auth/callback"]), - providers: { github, google }, - flowStateStore: { create }, - opaqueValueGenerator: { generate }, - }); - - await expect( - service.authorize({ - responseType: "code", - clientId: "web", - redirectUri: "https://web.example.com/api/auth/callback", - state: "s".repeat(43), - codeChallenge: "c".repeat(43), - codeChallengeMethod: "S256", - provider: "github", - ...patch, - }) - ).rejects.toMatchObject({ code }); - expect(generate).not.toHaveBeenCalled(); - expect(create).not.toHaveBeenCalled(); - expect(github.createAuthorizationUrl).not.toHaveBeenCalled(); - expect(google.createAuthorizationUrl).not.toHaveBeenCalled(); - }); - - it("persists the generated Google nonce for hash-only storage", async () => { - const github = new FakeProvider("github"); - const google = new FakeProvider("google"); - const create = vi.fn(async (_input: CreateOAuthFlowStateInput) => ({ flowId: "flow-1" })); - const verifier = "v".repeat(43); - const nonce = "n".repeat(43); - const generate = vi.fn<() => string>().mockReturnValueOnce(verifier).mockReturnValueOnce(nonce); - const service = new OAuthAuthorizationService({ - clients: new StaticOAuthClientRegistry(["https://web.example.com/api/auth/callback"]), - providers: { github, google }, - flowStateStore: { create }, - opaqueValueGenerator: { generate }, - }); - - await service.authorize({ - responseType: "code", - clientId: "web", - redirectUri: "https://web.example.com/api/auth/callback", - state: "s".repeat(43), - codeChallenge: "c".repeat(43), - codeChallengeMethod: "S256", - provider: "google", - }); - - expect(google.createAuthorizationUrl).toHaveBeenCalledWith({ - state: "s".repeat(43), - codeChallenge: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), - oidcNonce: nonce, - }); - expect(create).toHaveBeenCalledWith({ - state: "s".repeat(43), - provider: "google", - clientId: "web", - redirectUri: "https://web.example.com/api/auth/callback", - clientCodeChallenge: "c".repeat(43), - providerPkceVerifier: verifier, - oidcNonce: nonce, - }); - }); -}); diff --git a/packages/control-plane/src/auth/oauth-authorization-service.ts b/packages/control-plane/src/auth/oauth-authorization-service.ts deleted file mode 100644 index 4b238da79..000000000 --- a/packages/control-plane/src/auth/oauth-authorization-service.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { base64UrlEncode } from "./encoding"; -import type { OAuthFlowStateWriter } from "./oauth-flow-state"; -import { createPkceS256Challenge, isPkceS256Challenge, isPkceVerifier } from "./pkce"; -import type { OAuthSignInProviderRegistry } from "./providers/types"; -import { isSignInProvider, type SignInProvider } from "./sign-in-provider"; - -const OPAQUE_STATE_PATTERN = /^[A-Za-z0-9_-]{32,128}$/; - -export class StaticOAuthClientRegistry { - private readonly redirectUris: ReadonlySet; - - constructor(redirectUris: readonly string[]) { - if (redirectUris.length === 0 || redirectUris.some((uri) => uri.length === 0)) { - throw new Error("Web OAuth client requires at least one redirect URI"); - } - this.redirectUris = new Set(redirectUris); - } - - accepts(clientId: string, redirectUri: string): boolean { - return clientId === "web" && this.redirectUris.has(redirectUri); - } -} - -export type OAuthAuthorizationRequestErrorCode = - | "invalid_request" - | "invalid_client" - | "unsupported_response_type"; - -export class OAuthAuthorizationRequestError extends Error { - constructor(readonly code: OAuthAuthorizationRequestErrorCode) { - super("OAuth authorization request is invalid"); - this.name = "OAuthAuthorizationRequestError"; - } -} - -export interface OAuthAuthorizationRequest { - readonly responseType: string; - readonly clientId: string; - readonly redirectUri: string; - readonly state: string; - readonly codeChallenge: string; - readonly codeChallengeMethod: string; - readonly provider: string; -} - -export interface OpaqueValueGenerator { - generate(): string; -} - -export class WebCryptoOpaqueValueGenerator implements OpaqueValueGenerator { - generate(): string { - return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32))); - } -} - -export interface OAuthAuthorizationServiceDependencies { - readonly clients: StaticOAuthClientRegistry; - readonly providers: OAuthSignInProviderRegistry; - readonly flowStateStore: OAuthFlowStateWriter; - readonly opaqueValueGenerator: OpaqueValueGenerator; -} - -export class OAuthAuthorizationService { - constructor(private readonly dependencies: OAuthAuthorizationServiceDependencies) {} - - async authorize(request: OAuthAuthorizationRequest): Promise { - const provider = this.validateRequest(request); - const providerPkceVerifier = this.dependencies.opaqueValueGenerator.generate(); - if (!isPkceVerifier(providerPkceVerifier)) { - throw new Error("OAuth opaque-value generator returned an invalid PKCE verifier"); - } - const providerCodeChallenge = await createPkceS256Challenge(providerPkceVerifier); - - if (provider === "google") { - const oidcNonce = this.dependencies.opaqueValueGenerator.generate(); - if (!OPAQUE_STATE_PATTERN.test(oidcNonce)) { - throw new Error("OAuth opaque-value generator returned an invalid OIDC nonce"); - } - const redirect = await this.dependencies.providers.google.createAuthorizationUrl({ - state: request.state, - codeChallenge: providerCodeChallenge, - oidcNonce, - }); - await this.dependencies.flowStateStore.create({ - state: request.state, - provider, - clientId: "web", - redirectUri: request.redirectUri, - clientCodeChallenge: request.codeChallenge, - providerPkceVerifier, - oidcNonce, - }); - return redirect; - } - - const redirect = await this.dependencies.providers.github.createAuthorizationUrl({ - state: request.state, - codeChallenge: providerCodeChallenge, - }); - await this.dependencies.flowStateStore.create({ - state: request.state, - provider, - clientId: "web", - redirectUri: request.redirectUri, - clientCodeChallenge: request.codeChallenge, - providerPkceVerifier, - }); - return redirect; - } - - private validateRequest(request: OAuthAuthorizationRequest): SignInProvider { - if (request.responseType !== "code") { - throw new OAuthAuthorizationRequestError("unsupported_response_type"); - } - if (!this.dependencies.clients.accepts(request.clientId, request.redirectUri)) { - throw new OAuthAuthorizationRequestError( - request.clientId === "web" ? "invalid_request" : "invalid_client" - ); - } - if ( - !OPAQUE_STATE_PATTERN.test(request.state) || - !isPkceS256Challenge(request.codeChallenge) || - request.codeChallengeMethod !== "S256" || - !isSignInProvider(request.provider) - ) { - throw new OAuthAuthorizationRequestError("invalid_request"); - } - return request.provider; - } -} diff --git a/packages/control-plane/src/auth/oauth-flow-state.ts b/packages/control-plane/src/auth/oauth-flow-state.ts deleted file mode 100644 index 62abd6aef..000000000 --- a/packages/control-plane/src/auth/oauth-flow-state.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { SignInProvider } from "./sign-in-provider"; - -interface OAuthFlowStateBinding { - readonly state: string; - readonly clientId: "web"; - readonly redirectUri: string; - readonly clientCodeChallenge: string; - readonly providerPkceVerifier: string; -} - -export type CreateOAuthFlowStateInput = - | (OAuthFlowStateBinding & { - readonly provider: "github"; - readonly oidcNonce?: never; - }) - | (OAuthFlowStateBinding & { - readonly provider: "google"; - readonly oidcNonce: string; - }); - -export interface OAuthFlowStateWriter { - create(input: CreateOAuthFlowStateInput): Promise<{ flowId: string }>; -} - -export interface ConsumedOAuthFlowStateBinding { - readonly flowId: string; - readonly clientId: "web"; - readonly redirectUri: string; - readonly clientCodeChallenge: string; - readonly providerPkceVerifier: string; -} - -export type ConsumedOAuthFlowState = - | (ConsumedOAuthFlowStateBinding & { - readonly provider: "github"; - readonly oidcNonceHash: null; - }) - | (ConsumedOAuthFlowStateBinding & { - readonly provider: "google"; - readonly oidcNonceHash: string; - }); - -export type ConsumedOAuthFlowStateFor

= Extract< - ConsumedOAuthFlowState, - { provider: P } ->; - -export interface OAuthFlowStateReader { - consume

( - state: string, - expectedProvider: P - ): Promise>; -} diff --git a/packages/control-plane/src/auth/oauth-flow-verifier.ts b/packages/control-plane/src/auth/oauth-flow-verifier.ts deleted file mode 100644 index f3c420217..000000000 --- a/packages/control-plane/src/auth/oauth-flow-verifier.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { SignInProvider } from "./sign-in-provider"; - -export interface OAuthFlowVerifierBinding { - flowId: string; - provider: SignInProvider; - keyVersion: number; -} - -/** - * Stable integrity failure exposed by the verifier-cipher port. Implementations - * must throw this when ciphertext cannot be authenticated or decoded. - */ -export class OAuthFlowVerifierIntegrityError extends Error { - constructor() { - super("OAuth flow verifier ciphertext could not be verified"); - this.name = "OAuthFlowVerifierIntegrityError"; - } -} - -export interface OAuthFlowVerifierCipher { - encrypt(plaintext: string, binding: OAuthFlowVerifierBinding): Promise; - decrypt(ciphertext: string, binding: OAuthFlowVerifierBinding): Promise; -} diff --git a/packages/control-plane/src/auth/pkce.test.ts b/packages/control-plane/src/auth/pkce.test.ts deleted file mode 100644 index ca5c04c48..000000000 --- a/packages/control-plane/src/auth/pkce.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { InvalidPkceVerifierError, createPkceS256Challenge } from "./pkce"; - -describe("createPkceS256Challenge", () => { - it("matches the RFC 7636 S256 test vector", async () => { - await expect( - createPkceS256Challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") - ).resolves.toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); - }); - - it("rejects verifiers outside the RFC 7636 syntax and length bounds", async () => { - await expect(createPkceS256Challenge("too-short")).rejects.toBeInstanceOf( - InvalidPkceVerifierError - ); - await expect(createPkceS256Challenge("*".repeat(43))).rejects.toBeInstanceOf( - InvalidPkceVerifierError - ); - }); -}); diff --git a/packages/control-plane/src/auth/pkce.ts b/packages/control-plane/src/auth/pkce.ts deleted file mode 100644 index 098af41f3..000000000 --- a/packages/control-plane/src/auth/pkce.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { base64UrlEncode } from "./encoding"; - -const PKCE_VERIFIER_PATTERN = /^[A-Za-z0-9\-._~]{43,128}$/; -const PKCE_S256_CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/; - -export class InvalidPkceVerifierError extends Error { - constructor() { - super("PKCE verifier is malformed"); - this.name = "InvalidPkceVerifierError"; - } -} - -export function isPkceS256Challenge(value: unknown): value is string { - return typeof value === "string" && PKCE_S256_CHALLENGE_PATTERN.test(value); -} - -export function isPkceVerifier(value: unknown): value is string { - return typeof value === "string" && PKCE_VERIFIER_PATTERN.test(value); -} - -export async function createPkceS256Challenge(verifier: string): Promise { - if (!isPkceVerifier(verifier)) { - throw new InvalidPkceVerifierError(); - } - const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); - return base64UrlEncode(new Uint8Array(digest)); -} diff --git a/packages/control-plane/src/auth/principal.ts b/packages/control-plane/src/auth/principal.ts index d299ce95f..0a27f46e7 100644 --- a/packages/control-plane/src/auth/principal.ts +++ b/packages/control-plane/src/auth/principal.ts @@ -26,8 +26,18 @@ export interface ResolvedIdentity { participantUserId: string; } +/** Provider-independent evidence used to authenticate a browser request. */ +export interface AuthenticationContext { + mechanism: "browser_session"; + credentialId: string; + channel: { + kind: "sig1"; + service: "web"; + }; +} + export type Principal = - | { kind: "user"; user: ResolvedIdentity; tokenId: string } + | { kind: "user"; userId: string } | { kind: "service"; service: ServiceName; actor: ResolvedIdentity | null } | { kind: "sandbox"; sessionId: string }; diff --git a/packages/control-plane/src/auth/provider-credential-cipher.ts b/packages/control-plane/src/auth/provider-credential-cipher.ts deleted file mode 100644 index 3f8f56bb8..000000000 --- a/packages/control-plane/src/auth/provider-credential-cipher.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { ProviderCredentialKind } from "./provider-credential"; - -export interface ProviderCredentialCipherBinding { - providerIdentityId: string; - credentialKind: ProviderCredentialKind; - tokenRole: "access" | "refresh"; - encryptionKeyVersion: number; - rowVersion: number; -} - -/** - * Stable integrity failure exposed by the provider-credential cipher port. - * Implementations must throw this when ciphertext cannot be authenticated or - * decoded. - */ -export class ProviderCredentialIntegrityError extends Error { - constructor() { - super("Provider credential ciphertext could not be verified"); - this.name = "ProviderCredentialIntegrityError"; - } -} - -/** Encrypts provider tokens while binding them to their exact persisted row. */ -export interface ProviderCredentialCipherPort { - encrypt(plaintext: string, binding: ProviderCredentialCipherBinding): Promise; - decrypt(ciphertext: string, binding: ProviderCredentialCipherBinding): Promise; -} diff --git a/packages/control-plane/src/auth/providers/github.test.ts b/packages/control-plane/src/auth/providers/github.test.ts deleted file mode 100644 index 4058ed143..000000000 --- a/packages/control-plane/src/auth/providers/github.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { GitHubOAuthProvider } from "./github"; - -describe("GitHubOAuthProvider", () => { - const config = { - clientId: "github-client", - clientSecret: "github-secret", - callbackUri: "https://cp.example.com/oauth/callback/github", - issuer: "https://github.com", - userAgent: "Open Inspect Test", - }; - - it("rejects a non-canonical GitHub issuer", () => { - expect( - () => - new GitHubOAuthProvider({ - ...config, - issuer: "https://github.com.attacker.example", - }) - ).toThrow(expect.objectContaining({ failure: "invalid_configuration" })); - }); - - it("creates a GitHub App authorization URL bound to state and PKCE without classic scopes", async () => { - const provider = new GitHubOAuthProvider(config); - - const authorizationUrl = await provider.createAuthorizationUrl({ - state: "state-value", - codeChallenge: "a".repeat(43), - }); - - expect(authorizationUrl.origin + authorizationUrl.pathname).toBe( - "https://github.com/login/oauth/authorize" - ); - expect(Object.fromEntries(authorizationUrl.searchParams)).toEqual({ - client_id: "github-client", - redirect_uri: "https://cp.example.com/oauth/callback/github", - state: "state-value", - code_challenge: "a".repeat(43), - code_challenge_method: "S256", - }); - expect(authorizationUrl.searchParams.has("scope")).toBe(false); - }); - - it("exchanges the code with PKCE and returns validated identity and credential evidence", async () => { - const fetch = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - access_token: "ghu-access", - token_type: "bearer", - expires_in: 28_800, - refresh_token: "ghr-refresh", - refresh_token_expires_in: 15_552_000, - }) - ) - .mockResolvedValueOnce( - Response.json({ - id: 583_231, - login: "octocat", - name: "The Octocat", - avatar_url: "https://avatars.example/octocat", - }) - ) - .mockResolvedValueOnce( - Response.json([ - { - email: "Secondary@Example.com", - primary: false, - verified: true, - visibility: null, - }, - { - email: "Primary@Example.com", - primary: true, - verified: true, - visibility: "private", - }, - { - email: "PRIMARY@example.com", - primary: false, - verified: true, - visibility: null, - }, - { - email: "unverified@example.com", - primary: false, - verified: false, - visibility: null, - }, - ]) - ); - const now = 1_752_000_000_000; - const provider = new GitHubOAuthProvider(config, { fetch, clock: { now: () => now } }); - - await expect( - provider.exchangeAuthorizationCode({ - code: "github-code", - codeVerifier: "v".repeat(43), - }) - ).resolves.toEqual({ - identity: { - provider: "github", - issuer: "https://github.com", - subject: "583231", - login: "octocat", - displayName: "The Octocat", - avatarUrl: "https://avatars.example/octocat", - verifiedEmails: ["secondary@example.com", "primary@example.com"], - primaryEmail: "primary@example.com", - }, - credential: { - kind: "refreshable", - accessToken: "ghu-access", - accessExpiresAt: now + 28_800_000, - refreshToken: "ghr-refresh", - refreshExpiresAt: now + 15_552_000_000, - }, - }); - - const tokenRequest = fetch.mock.calls[0]; - expect(String(tokenRequest[0])).toBe("https://github.com/login/oauth/access_token"); - expect(tokenRequest[1]).toMatchObject({ - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - }); - expect(Object.fromEntries(new URLSearchParams(String(tokenRequest[1]?.body)))).toEqual({ - client_id: "github-client", - client_secret: "github-secret", - code: "github-code", - redirect_uri: "https://cp.example.com/oauth/callback/github", - code_verifier: "v".repeat(43), - }); - }); - - it("paginates GitHub emails to exhaustion so later verified evidence is not missed", async () => { - const firstPage = Array.from({ length: 100 }, (_, index) => ({ - email: `unverified-${index}@example.com`, - primary: false, - verified: false, - visibility: null, - })); - const fetch = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - access_token: "ghu-access", - token_type: "bearer", - }) - ) - .mockResolvedValueOnce( - Response.json({ - id: 583_231, - login: "octocat", - name: null, - avatar_url: null, - }) - ) - .mockResolvedValueOnce( - Response.json(firstPage, { - headers: { - Link: '; rel="next"', - }, - }) - ) - .mockResolvedValueOnce( - Response.json([ - { - email: "later-page@example.com", - primary: true, - verified: true, - visibility: null, - }, - ]) - ); - const provider = new GitHubOAuthProvider(config, { fetch }); - - const result = await provider.exchangeAuthorizationCode({ - code: "github-code", - codeVerifier: "v".repeat(43), - }); - - expect(result.identity.verifiedEmails).toEqual(["later-page@example.com"]); - expect(result.identity.primaryEmail).toBe("later-page@example.com"); - expect(String(fetch.mock.calls[3][0])).toBe( - "https://api.github.com/user/emails?per_page=100&page=2" - ); - }); - - it("fails closed when GitHub email pagination metadata is malformed", async () => { - const fetch = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - access_token: "ghu-access", - token_type: "bearer", - }) - ) - .mockResolvedValueOnce( - Response.json({ - id: 583_231, - login: "octocat", - }) - ) - .mockResolvedValueOnce( - Response.json([], { - headers: { Link: "this is not a valid Link header" }, - }) - ); - const provider = new GitHubOAuthProvider(config, { fetch }); - - await expect( - provider.exchangeAuthorizationCode({ - code: "github-code", - codeVerifier: "v".repeat(43), - }) - ).rejects.toMatchObject({ - name: "OAuthProviderError", - failure: "malformed_response", - }); - }); - - it("maps a malformed GitHub email pagination URL to a bounded provider error", async () => { - const fetch = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - access_token: "ghu-access", - token_type: "bearer", - }) - ) - .mockResolvedValueOnce( - Response.json({ - id: 583_231, - login: "octocat", - }) - ) - .mockResolvedValueOnce( - Response.json([], { - headers: { Link: '; rel="next"' }, - }) - ); - const provider = new GitHubOAuthProvider(config, { fetch }); - - await expect( - provider.exchangeAuthorizationCode({ - code: "github-code", - codeVerifier: "v".repeat(43), - }) - ).rejects.toMatchObject({ - name: "OAuthProviderError", - failure: "malformed_response", - }); - }); - - it("fails closed when GitHub repeats an email page", async () => { - const repeatedPage = "https://api.github.com/user/emails?per_page=100&page=1"; - const fetch = vi - .fn() - .mockResolvedValueOnce( - Response.json({ - access_token: "ghu-access", - token_type: "bearer", - }) - ) - .mockResolvedValueOnce( - Response.json({ - id: 583_231, - login: "octocat", - }) - ) - .mockResolvedValueOnce( - Response.json([], { - headers: { Link: `<${repeatedPage}>; rel="next"` }, - }) - ); - const provider = new GitHubOAuthProvider(config, { fetch }); - - await expect( - provider.exchangeAuthorizationCode({ - code: "github-code", - codeVerifier: "v".repeat(43), - }) - ).rejects.toMatchObject({ - name: "OAuthProviderError", - failure: "malformed_response", - }); - expect(fetch).toHaveBeenCalledTimes(3); - }); -}); diff --git a/packages/control-plane/src/auth/providers/github.ts b/packages/control-plane/src/auth/providers/github.ts deleted file mode 100644 index fd8c08fe2..000000000 --- a/packages/control-plane/src/auth/providers/github.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { z } from "zod"; -import type { ProviderCredentialInput } from "../provider-credential"; -import { DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS } from "./constants"; -import { - assertCanonicalIssuer, - OAuthProviderError, - type OAuthSignInProvider, - type ProviderAuthorizationRequest, - type ProviderCodeExchangeRequest, - type ProviderCodeExchangeResult, -} from "./types"; - -const GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize"; -const GITHUB_ISSUER = "https://github.com"; -const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"; -const GITHUB_API_URL = "https://api.github.com"; -const GITHUB_API_VERSION = "2022-11-28"; -const GITHUB_EMAILS_PER_PAGE = 100; -const GITHUB_EMAILS_MAX_PAGES = 10; - -const githubTokenResponseSchema = z - .object({ - access_token: z.string().min(1), - token_type: z.string().transform((value, ctx) => { - if (value.toLowerCase() !== "bearer") { - ctx.addIssue({ code: "custom", message: "token_type must be bearer" }); - return z.NEVER; - } - return "bearer" as const; - }), - expires_in: z.number().int().positive().optional(), - refresh_token: z.string().min(1).optional(), - refresh_token_expires_in: z.number().int().positive().optional(), - }) - .superRefine((value, ctx) => { - if (value.refresh_token && value.expires_in === undefined) { - ctx.addIssue({ - code: "custom", - path: ["expires_in"], - message: "refreshable credentials require access expiry", - }); - } - if (value.refresh_token_expires_in !== undefined && !value.refresh_token) { - ctx.addIssue({ - code: "custom", - path: ["refresh_token_expires_in"], - message: "refresh expiry requires a refresh token", - }); - } - }); - -const githubOAuthErrorSchema = z.object({ - error: z.string().min(1), - error_description: z.string().optional(), -}); - -const githubUserSchema = z.object({ - id: z.number().int().positive(), - login: z.string().min(1), - name: z.string().nullable().optional(), - avatar_url: z.url().nullable().optional(), -}); - -const githubEmailSchema = z.object({ - email: z.email(), - primary: z.boolean(), - verified: z.boolean(), - visibility: z.string().nullable(), -}); - -const githubEmailPageSchema = z.array(githubEmailSchema); - -export interface GitHubOAuthProviderConfig { - readonly clientId: string; - readonly clientSecret: string; - readonly callbackUri: string; - readonly issuer: string; - readonly userAgent: string; -} - -export interface GitHubOAuthProviderDependencies { - readonly fetch?: typeof globalThis.fetch; - readonly clock?: { now(): number }; - readonly requestTimeoutMs?: number; -} - -export class GitHubOAuthProvider implements OAuthSignInProvider<"github"> { - readonly provider = "github" as const; - private readonly fetchImpl: typeof globalThis.fetch; - private readonly clock: { now(): number }; - private readonly requestTimeoutMs: number; - - constructor( - private readonly config: GitHubOAuthProviderConfig, - dependencies: GitHubOAuthProviderDependencies = {} - ) { - assertCanonicalIssuer(config.issuer, GITHUB_ISSUER); - this.fetchImpl = dependencies.fetch ?? globalThis.fetch; - this.clock = dependencies.clock ?? { now: () => Date.now() }; - this.requestTimeoutMs = dependencies.requestTimeoutMs ?? DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS; - } - - async createAuthorizationUrl(request: ProviderAuthorizationRequest<"github">): Promise { - const url = new URL(GITHUB_AUTHORIZE_URL); - url.searchParams.set("client_id", this.config.clientId); - url.searchParams.set("redirect_uri", this.config.callbackUri); - url.searchParams.set("state", request.state); - url.searchParams.set("code_challenge", request.codeChallenge); - url.searchParams.set("code_challenge_method", "S256"); - return url; - } - - async exchangeAuthorizationCode( - request: ProviderCodeExchangeRequest<"github"> - ): Promise> { - const token = await this.exchangeCode(request); - const [user, emailEntries] = await Promise.all([ - this.fetchGitHubUser(token.access_token), - this.fetchVerifiedEmails(token.access_token), - ]); - const verifiedEmailEntries = emailEntries.filter((entry) => entry.verified); - const verifiedEmails = [ - ...new Set(verifiedEmailEntries.map((entry) => entry.email.toLowerCase())), - ]; - return { - identity: { - provider: this.provider, - issuer: GITHUB_ISSUER, - subject: String(user.id), - login: user.login, - displayName: user.name ?? user.login, - ...(user.avatar_url ? { avatarUrl: user.avatar_url } : {}), - verifiedEmails, - primaryEmail: - verifiedEmailEntries.find((entry) => entry.primary)?.email.toLowerCase() ?? null, - }, - credential: this.toCredential(token), - }; - } - - private async exchangeCode( - request: ProviderCodeExchangeRequest<"github"> - ): Promise> { - const body = new URLSearchParams({ - client_id: this.config.clientId, - client_secret: this.config.clientSecret, - code: request.code, - redirect_uri: this.config.callbackUri, - code_verifier: request.codeVerifier, - }); - const response = await this.fetchWithTimeout(GITHUB_TOKEN_URL, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body, - }); - const raw = await this.parseJson(response, "GitHub token"); - const providerError = githubOAuthErrorSchema.safeParse(raw); - if (!response.ok || providerError.success) { - throw new OAuthProviderError("provider_rejected", "GitHub rejected the authorization code"); - } - const parsed = githubTokenResponseSchema.safeParse(raw); - if (!parsed.success) { - throw new OAuthProviderError("malformed_response", "GitHub returned an invalid token"); - } - return parsed.data; - } - - private async fetchGitHubUser(accessToken: string): Promise> { - const response = await this.fetchWithTimeout(`${GITHUB_API_URL}/user`, { - headers: this.apiHeaders(accessToken), - }); - if (!response.ok) { - throw new OAuthProviderError("provider_unavailable", "GitHub user lookup was not successful"); - } - const parsed = githubUserSchema.safeParse(await this.parseJson(response, "GitHub user")); - if (!parsed.success) { - throw new OAuthProviderError("malformed_response", "GitHub returned an invalid user"); - } - return parsed.data; - } - - private async fetchVerifiedEmails( - accessToken: string - ): Promise>> { - let nextUrl: URL | null = new URL(`${GITHUB_API_URL}/user/emails`); - nextUrl.searchParams.set("per_page", String(GITHUB_EMAILS_PER_PAGE)); - nextUrl.searchParams.set("page", "1"); - const seenUrls = new Set(); - const entries: Array> = []; - - for (let page = 1; nextUrl !== null && page <= GITHUB_EMAILS_MAX_PAGES; page += 1) { - const currentUrl: URL = nextUrl; - const serializedUrl = currentUrl.toString(); - if (seenUrls.has(serializedUrl)) { - throw new OAuthProviderError( - "malformed_response", - "GitHub repeated an email pagination page" - ); - } - seenUrls.add(serializedUrl); - - const response: Response = await this.fetchWithTimeout(currentUrl, { - headers: this.apiHeaders(accessToken), - }); - if (!response.ok) { - throw new OAuthProviderError( - "provider_unavailable", - "GitHub email lookup was not successful" - ); - } - const parsed = githubEmailPageSchema.safeParse( - await this.parseJson(response, "GitHub emails") - ); - if (!parsed.success) { - throw new OAuthProviderError("malformed_response", "GitHub returned invalid emails"); - } - entries.push(...parsed.data); - - nextUrl = this.parseEmailNextPage(response.headers.get("Link")); - if (nextUrl !== null && page === GITHUB_EMAILS_MAX_PAGES) { - throw new OAuthProviderError( - "malformed_response", - "GitHub email pagination exceeded its limit" - ); - } - } - return entries; - } - - private parseEmailNextPage(linkHeader: string | null): URL | null { - if (!linkHeader) return null; - const links = linkHeader - .split(",") - .map((value) => value.trim().match(/^<([^>]+)>;\s*rel="([^"]+)"$/)); - if (links.some((match) => match === null)) { - throw new OAuthProviderError( - "malformed_response", - "GitHub returned malformed email pagination" - ); - } - const nextLinks = links - .filter((match): match is RegExpMatchArray => match !== null) - .filter((match) => match[2].split(/\s+/).includes("next")); - if (nextLinks.length === 0) return null; - if (nextLinks.length !== 1) { - throw new OAuthProviderError( - "malformed_response", - "GitHub returned ambiguous email pagination" - ); - } - - let url: URL; - try { - url = new URL(nextLinks[0][1]); - } catch { - throw new OAuthProviderError( - "malformed_response", - "GitHub returned invalid email pagination" - ); - } - if ( - url.origin !== GITHUB_API_URL || - url.pathname !== "/user/emails" || - url.searchParams.get("per_page") !== String(GITHUB_EMAILS_PER_PAGE) || - !/^[1-9]\d*$/.test(url.searchParams.get("page") ?? "") - ) { - throw new OAuthProviderError( - "malformed_response", - "GitHub returned invalid email pagination" - ); - } - return url; - } - - private apiHeaders(accessToken: string): HeadersInit { - return { - Authorization: `Bearer ${accessToken}`, - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": GITHUB_API_VERSION, - "User-Agent": this.config.userAgent, - }; - } - - private toCredential(token: z.infer): ProviderCredentialInput { - const now = this.clock.now(); - if (token.refresh_token && token.expires_in !== undefined) { - return { - kind: "refreshable", - accessToken: token.access_token, - accessExpiresAt: now + token.expires_in * 1000, - refreshToken: token.refresh_token, - refreshExpiresAt: - token.refresh_token_expires_in === undefined - ? null - : now + token.refresh_token_expires_in * 1000, - }; - } - if (token.expires_in !== undefined) { - return { - kind: "access_only_expiring", - accessToken: token.access_token, - accessExpiresAt: now + token.expires_in * 1000, - }; - } - return { kind: "access_only_nonexpiring", accessToken: token.access_token }; - } - - private async parseJson(response: Response, context: string): Promise { - try { - return await response.json(); - } catch { - throw new OAuthProviderError("malformed_response", `${context} response was not JSON`); - } - } - - private async fetchWithTimeout(input: RequestInfo | URL, init: RequestInit): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs); - try { - return await this.fetchImpl(input, { ...init, signal: controller.signal }); - } catch { - throw new OAuthProviderError("provider_unavailable", "GitHub request failed"); - } finally { - clearTimeout(timeout); - } - } -} diff --git a/packages/control-plane/src/auth/providers/google.test.ts b/packages/control-plane/src/auth/providers/google.test.ts deleted file mode 100644 index 3dd6f0ed5..000000000 --- a/packages/control-plane/src/auth/providers/google.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { hashToken } from "../crypto"; -import { base64UrlEncode } from "../encoding"; -import { GoogleOidcProvider } from "./google"; - -const discoveryDocument = { - issuer: "https://accounts.google.com", - authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth", - token_endpoint: "https://oauth2.googleapis.com/token", - jwks_uri: "https://www.googleapis.com/oauth2/v3/certs", - response_types_supported: ["code"], - subject_types_supported: ["public"], - id_token_signing_alg_values_supported: ["RS256"], - token_endpoint_auth_methods_supported: ["client_secret_post"], - code_challenge_methods_supported: ["S256"], -}; - -const config = { - clientId: "google-client", - clientSecret: "google-secret", - callbackUri: "https://cp.example.com/oauth/callback/google", - issuer: "https://accounts.google.com", -}; - -async function createSignedIdToken( - claims: Record -): Promise<{ idToken: string; publicJwk: Record }> { - const keyPair = (await crypto.subtle.generateKey( - { - name: "RSASSA-PKCS1-v1_5", - modulusLength: 2048, - publicExponent: new Uint8Array([1, 0, 1]), - hash: "SHA-256", - }, - true, - ["sign", "verify"] - )) as CryptoKeyPair; - const header = base64UrlEncode(JSON.stringify({ alg: "RS256", kid: "test-key", typ: "JWT" })); - const payload = base64UrlEncode(JSON.stringify(claims)); - const signingInput = `${header}.${payload}`; - const signature = await crypto.subtle.sign( - "RSASSA-PKCS1-v1_5", - keyPair.privateKey, - new TextEncoder().encode(signingInput) - ); - const publicJwk = (await crypto.subtle.exportKey("jwk", keyPair.publicKey)) as unknown as Record< - string, - unknown - >; - return { - idToken: `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`, - publicJwk: { ...publicJwk, kid: "test-key", alg: "RS256", use: "sig" }, - }; -} - -async function expectSignedTokenRejection( - claims: Record, - failure = "malformed_response" -): Promise { - const { idToken, publicJwk } = await createSignedIdToken(claims); - const fetch = vi - .fn() - .mockResolvedValueOnce(Response.json(discoveryDocument)) - .mockResolvedValueOnce( - Response.json({ - access_token: "google-access", - token_type: "Bearer", - id_token: idToken, - }) - ) - .mockResolvedValueOnce(Response.json({ keys: [publicJwk] })); - const provider = new GoogleOidcProvider(config, { fetch }); - - await expect( - provider.exchangeAuthorizationCode({ - code: "google-code", - codeVerifier: "v".repeat(43), - oidcNonceHash: await hashToken("nonce-value"), - }) - ).rejects.toMatchObject({ - name: "OAuthProviderError", - failure, - }); -} - -describe("GoogleOidcProvider", () => { - it("rejects a non-canonical Google issuer before discovery", () => { - const fetch = vi.fn(); - - expect( - () => - new GoogleOidcProvider( - { - ...config, - issuer: "https://accounts.google.com.attacker.example", - }, - { fetch } - ) - ).toThrow(expect.objectContaining({ failure: "invalid_configuration" })); - expect(fetch).not.toHaveBeenCalled(); - }); - - it("discovers Google and creates an OIDC authorization URL with PKCE and nonce", async () => { - const fetch = vi - .fn() - .mockResolvedValue(Response.json(discoveryDocument)); - const provider = new GoogleOidcProvider(config, { fetch }); - - const authorizationUrl = await provider.createAuthorizationUrl({ - state: "state-value", - codeChallenge: "a".repeat(43), - oidcNonce: "nonce-value", - }); - - expect(authorizationUrl.origin + authorizationUrl.pathname).toBe( - "https://accounts.google.com/o/oauth2/v2/auth" - ); - expect(Object.fromEntries(authorizationUrl.searchParams)).toEqual({ - client_id: "google-client", - redirect_uri: "https://cp.example.com/oauth/callback/google", - response_type: "code", - scope: "openid email profile", - state: "state-value", - code_challenge: "a".repeat(43), - code_challenge_method: "S256", - nonce: "nonce-value", - }); - expect(String(fetch.mock.calls[0][0])).toBe( - "https://accounts.google.com/.well-known/openid-configuration" - ); - }); - - it("validates the signed ID token and binds its nonce through the persisted hash", async () => { - const nowEpochSeconds = Math.floor(Date.now() / 1000); - const nonce = "nonce-value"; - const { idToken, publicJwk } = await createSignedIdToken({ - iss: "https://accounts.google.com", - sub: "google-subject", - aud: "google-client", - azp: "google-client", - exp: nowEpochSeconds + 300, - iat: nowEpochSeconds, - nonce, - email: "person@example.com", - email_verified: true, - name: "A Person", - picture: "https://images.example/person", - }); - const fetch = vi - .fn() - .mockResolvedValueOnce(Response.json(discoveryDocument)) - .mockResolvedValueOnce( - Response.json({ - access_token: "google-access", - token_type: "Bearer", - expires_in: 3_600, - id_token: idToken, - }) - ) - .mockResolvedValueOnce(Response.json({ keys: [publicJwk] })); - const provider = new GoogleOidcProvider(config, { fetch }); - - await expect( - provider.exchangeAuthorizationCode({ - code: "google-code", - codeVerifier: "v".repeat(43), - oidcNonceHash: await hashToken(nonce), - }) - ).resolves.toEqual({ - identity: { - provider: "google", - issuer: "https://accounts.google.com", - subject: "google-subject", - displayName: "A Person", - avatarUrl: "https://images.example/person", - verifiedEmails: ["person@example.com"], - primaryEmail: "person@example.com", - }, - credential: null, - }); - - expect(String(fetch.mock.calls[1][0])).toBe("https://oauth2.googleapis.com/token"); - const tokenBody = new URLSearchParams(String(fetch.mock.calls[1][1]?.body)); - expect(Object.fromEntries(tokenBody)).toEqual({ - redirect_uri: "https://cp.example.com/oauth/callback/google", - code: "google-code", - code_verifier: "v".repeat(43), - grant_type: "authorization_code", - client_id: "google-client", - client_secret: "google-secret", - }); - expect(String(fetch.mock.calls[2][0])).toBe("https://www.googleapis.com/oauth2/v3/certs"); - }); - - it("rejects an ID token that does not verify against the discovered JWKS", async () => { - const nowEpochSeconds = Math.floor(Date.now() / 1000); - const { idToken, publicJwk } = await createSignedIdToken({ - iss: "https://accounts.google.com", - sub: "google-subject", - aud: "google-client", - exp: nowEpochSeconds + 300, - iat: nowEpochSeconds, - nonce: "nonce-value", - }); - const [header, payload, signature] = idToken.split("."); - const forgedIdToken = `${header}.${payload}.${signature.startsWith("A") ? "B" : "A"}${signature.slice(1)}`; - const fetch = vi - .fn() - .mockResolvedValueOnce(Response.json(discoveryDocument)) - .mockResolvedValueOnce( - Response.json({ - access_token: "google-access", - token_type: "Bearer", - id_token: forgedIdToken, - }) - ) - .mockResolvedValueOnce(Response.json({ keys: [publicJwk] })); - const provider = new GoogleOidcProvider(config, { fetch }); - - await expect( - provider.exchangeAuthorizationCode({ - code: "google-code", - codeVerifier: "v".repeat(43), - oidcNonceHash: await hashToken("nonce-value"), - }) - ).rejects.toMatchObject({ - name: "OAuthProviderError", - failure: "malformed_response", - }); - }); - - it("rejects a valid signed token whose nonce does not match the consumed flow", async () => { - const nowEpochSeconds = Math.floor(Date.now() / 1000); - const { idToken, publicJwk } = await createSignedIdToken({ - iss: "https://accounts.google.com", - sub: "google-subject", - aud: "google-client", - exp: nowEpochSeconds + 300, - iat: nowEpochSeconds, - nonce: "attacker-nonce", - }); - const fetch = vi - .fn() - .mockResolvedValueOnce(Response.json(discoveryDocument)) - .mockResolvedValueOnce( - Response.json({ - access_token: "google-access", - token_type: "Bearer", - id_token: idToken, - }) - ) - .mockResolvedValueOnce(Response.json({ keys: [publicJwk] })); - const provider = new GoogleOidcProvider(config, { fetch }); - - await expect( - provider.exchangeAuthorizationCode({ - code: "google-code", - codeVerifier: "v".repeat(43), - oidcNonceHash: await hashToken("expected-nonce"), - }) - ).rejects.toMatchObject({ - name: "OAuthProviderError", - failure: "provider_rejected", - }); - }); - - it("rejects a signed token with an unexpected authorized party", async () => { - const nowEpochSeconds = Math.floor(Date.now() / 1000); - await expectSignedTokenRejection({ - iss: "https://accounts.google.com", - sub: "google-subject", - aud: "google-client", - azp: "another-client", - exp: nowEpochSeconds + 300, - iat: nowEpochSeconds, - nonce: "nonce-value", - }); - }); - - it("rejects a signed token issued for a different audience", async () => { - const nowEpochSeconds = Math.floor(Date.now() / 1000); - await expectSignedTokenRejection({ - iss: "https://accounts.google.com", - sub: "google-subject", - aud: "another-client", - exp: nowEpochSeconds + 300, - iat: nowEpochSeconds, - nonce: "nonce-value", - }); - }); - - it("rejects an expired signed token", async () => { - const nowEpochSeconds = Math.floor(Date.now() / 1000); - await expectSignedTokenRejection({ - iss: "https://accounts.google.com", - sub: "google-subject", - aud: "google-client", - exp: nowEpochSeconds - 300, - iat: nowEpochSeconds - 600, - nonce: "nonce-value", - }); - }); -}); diff --git a/packages/control-plane/src/auth/providers/google.ts b/packages/control-plane/src/auth/providers/google.ts deleted file mode 100644 index 695da0326..000000000 --- a/packages/control-plane/src/auth/providers/google.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { z } from "zod"; -import { - OidcAuthorizationCodeClient, - type OidcAuthorizationCodeClientDependencies, -} from "./oidc-authorization-code-client"; -import { - assertCanonicalIssuer, - OAuthProviderError, - type OAuthSignInProvider, - type ProviderAuthorizationRequest, - type ProviderCodeExchangeRequest, - type ProviderCodeExchangeResult, -} from "./types"; - -const GOOGLE_ISSUER = "https://accounts.google.com"; -const GOOGLE_SCOPES = ["openid", "email", "profile"] as const; - -const googleIdentityClaimsSchema = z - .object({ - iss: z.literal(GOOGLE_ISSUER), - sub: z.string().min(1), - email: z.email().optional(), - email_verified: z.boolean().optional(), - name: z.string().min(1).optional(), - picture: z.url().optional(), - }) - .superRefine((claims, ctx) => { - if (claims.email_verified === true && claims.email === undefined) { - ctx.addIssue({ - code: "custom", - path: ["email"], - message: "verified email claim requires an email", - }); - } - }); - -export interface GoogleOidcProviderConfig { - readonly clientId: string; - readonly clientSecret: string; - readonly callbackUri: string; - readonly issuer: string; -} - -export type GoogleOidcProviderDependencies = OidcAuthorizationCodeClientDependencies; - -export class GoogleOidcProvider implements OAuthSignInProvider<"google"> { - readonly provider = "google" as const; - private readonly oidc: OidcAuthorizationCodeClient; - - constructor(config: GoogleOidcProviderConfig, dependencies: GoogleOidcProviderDependencies = {}) { - assertCanonicalIssuer(config.issuer, GOOGLE_ISSUER); - this.oidc = new OidcAuthorizationCodeClient( - { - issuer: GOOGLE_ISSUER, - clientId: config.clientId, - clientSecret: config.clientSecret, - callbackUri: config.callbackUri, - scopes: GOOGLE_SCOPES, - }, - dependencies - ); - } - - async createAuthorizationUrl(request: ProviderAuthorizationRequest<"google">): Promise { - return this.oidc.createAuthorizationUrl({ - state: request.state, - codeChallenge: request.codeChallenge, - nonce: request.oidcNonce, - }); - } - - async exchangeAuthorizationCode( - request: ProviderCodeExchangeRequest<"google"> - ): Promise> { - const rawClaims = await this.oidc.exchangeAuthorizationCode({ - code: request.code, - codeVerifier: request.codeVerifier, - nonceHash: request.oidcNonceHash, - }); - const claims = googleIdentityClaimsSchema.safeParse(rawClaims); - if (!claims.success) { - throw new OAuthProviderError("malformed_response", "Google returned invalid identity claims"); - } - - const verifiedEmail = claims.data.email_verified === true ? (claims.data.email ?? null) : null; - return { - identity: { - provider: this.provider, - issuer: claims.data.iss, - subject: claims.data.sub, - ...(claims.data.name ? { displayName: claims.data.name } : {}), - ...(claims.data.picture ? { avatarUrl: claims.data.picture } : {}), - verifiedEmails: verifiedEmail ? [verifiedEmail] : [], - primaryEmail: verifiedEmail, - }, - credential: null, - }; - } -} diff --git a/packages/control-plane/src/auth/providers/oidc-authorization-code-client.ts b/packages/control-plane/src/auth/providers/oidc-authorization-code-client.ts deleted file mode 100644 index fa05b39b7..000000000 --- a/packages/control-plane/src/auth/providers/oidc-authorization-code-client.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { timingSafeEqual } from "@open-inspect/shared"; -import * as oauth from "oauth4webapi"; -import { hashToken } from "../crypto"; -import { DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS } from "./constants"; -import { OAuthProviderError } from "./types"; - -const SHA_256_HEX_PATTERN = /^[0-9a-f]{64}$/; - -export interface OidcAuthorizationCodeClientConfig { - readonly issuer: string; - readonly clientId: string; - readonly clientSecret: string; - readonly callbackUri: string; - readonly scopes: readonly string[]; -} - -export interface OidcAuthorizationCodeClientDependencies { - readonly fetch?: typeof globalThis.fetch; - readonly requestTimeoutMs?: number; - readonly tokenHasher?: { hash(value: string): Promise }; -} - -export interface OidcAuthorizationRequest { - readonly state: string; - readonly codeChallenge: string; - readonly nonce: string; -} - -export interface OidcCodeExchangeRequest { - readonly code: string; - readonly codeVerifier: string; - readonly nonceHash: string; -} - -/** - * Maintained OIDC protocol boundary shared by executable OIDC provider - * adapters. Provider-specific code owns issuer allowlisting and claim policy; - * this client owns discovery, PKCE exchange, ID-token validation, JWKS - * signature verification, and hash-only nonce binding. - */ -export class OidcAuthorizationCodeClient { - private readonly issuer: URL; - private readonly fetchImpl: typeof globalThis.fetch; - private readonly requestTimeoutMs: number; - private readonly tokenHasher: { hash(value: string): Promise }; - private readonly client: oauth.Client; - private readonly jwksCache: oauth.JWKSCacheInput = {}; - private discovery: Promise | null = null; - - constructor( - private readonly config: OidcAuthorizationCodeClientConfig, - dependencies: OidcAuthorizationCodeClientDependencies = {} - ) { - this.issuer = new URL(config.issuer); - this.fetchImpl = dependencies.fetch ?? globalThis.fetch; - this.requestTimeoutMs = dependencies.requestTimeoutMs ?? DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS; - this.tokenHasher = dependencies.tokenHasher ?? { hash: hashToken }; - this.client = { client_id: config.clientId }; - } - - async createAuthorizationUrl(request: OidcAuthorizationRequest): Promise { - const authorizationServer = await this.getAuthorizationServer(); - if (!authorizationServer.authorization_endpoint) { - throw new OAuthProviderError( - "invalid_configuration", - "OIDC discovery omitted the authorization endpoint" - ); - } - if (!authorizationServer.code_challenge_methods_supported?.includes("S256")) { - throw new OAuthProviderError( - "invalid_configuration", - "OIDC discovery does not advertise PKCE S256" - ); - } - - const url = new URL(authorizationServer.authorization_endpoint); - url.searchParams.set("client_id", this.config.clientId); - url.searchParams.set("redirect_uri", this.config.callbackUri); - url.searchParams.set("response_type", "code"); - url.searchParams.set("scope", this.config.scopes.join(" ")); - url.searchParams.set("state", request.state); - url.searchParams.set("code_challenge", request.codeChallenge); - url.searchParams.set("code_challenge_method", "S256"); - url.searchParams.set("nonce", request.nonce); - return url; - } - - async exchangeAuthorizationCode(request: OidcCodeExchangeRequest): Promise { - if (!SHA_256_HEX_PATTERN.test(request.nonceHash)) { - throw new OAuthProviderError( - "invalid_request", - "OIDC authorization-code exchange requires a nonce hash" - ); - } - - try { - const authorizationServer = await this.getAuthorizationServer(); - const callbackParameters = oauth.validateAuthResponse( - authorizationServer, - this.client, - new URLSearchParams({ code: request.code }), - oauth.expectNoState - ); - const tokenResponse = await oauth.authorizationCodeGrantRequest( - authorizationServer, - this.client, - oauth.ClientSecretPost(this.config.clientSecret), - callbackParameters, - this.config.callbackUri, - request.codeVerifier, - { [oauth.customFetch]: this.fetchWithTimeout } - ); - - // The raw nonce is deliberately not persisted. oauth4webapi validates - // issuer, audience, expiry, algorithm, and ID-token claim structure; the - // application then verifies the signed nonce claim against the stored - // SHA-256 value. - const tokenResult = await oauth.processGenericTokenEndpointResponse( - authorizationServer, - this.client, - tokenResponse - ); - await oauth.validateApplicationLevelSignature(authorizationServer, tokenResponse, { - [oauth.customFetch]: this.fetchWithTimeout, - [oauth.jwksCache]: this.jwksCache, - }); - const claims = oauth.getValidatedIdTokenClaims(tokenResult); - if (!claims || typeof claims.nonce !== "string" || claims.nonce.length === 0) { - throw new OAuthProviderError("malformed_response", "OIDC identity claims are invalid"); - } - if (claims.azp !== undefined && claims.azp !== this.config.clientId) { - throw new OAuthProviderError( - "malformed_response", - "OIDC returned an unexpected authorized party" - ); - } - const actualNonceHash = await this.tokenHasher.hash(claims.nonce); - if (!timingSafeEqual(actualNonceHash, request.nonceHash)) { - throw new OAuthProviderError("provider_rejected", "OIDC nonce validation failed"); - } - return claims; - } catch (error) { - if (error instanceof OAuthProviderError) throw error; - if ( - error instanceof oauth.ResponseBodyError || - error instanceof oauth.AuthorizationResponseError - ) { - throw new OAuthProviderError("provider_rejected", "OIDC provider rejected the code"); - } - throw new OAuthProviderError("malformed_response", "OIDC validation failed"); - } - } - - private getAuthorizationServer(): Promise { - if (!this.discovery) { - this.discovery = this.discover().catch((error) => { - this.discovery = null; - if (error instanceof OAuthProviderError) throw error; - throw new OAuthProviderError("provider_unavailable", "OIDC discovery failed"); - }); - } - return this.discovery; - } - - private async discover(): Promise { - const response = await oauth.discoveryRequest(this.issuer, { - algorithm: "oidc", - [oauth.customFetch]: this.fetchWithTimeout, - }); - return oauth.processDiscoveryResponse(this.issuer, response); - } - - private readonly fetchWithTimeout = async ( - input: string | URL | Request, - init?: RequestInit - ): Promise => { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs); - try { - return await this.fetchImpl(input, { ...init, signal: controller.signal }); - } catch (error) { - if (error instanceof OAuthProviderError) throw error; - throw new OAuthProviderError("provider_unavailable", "OIDC request failed"); - } finally { - clearTimeout(timeout); - } - }; -} diff --git a/packages/control-plane/src/auth/providers/types.test.ts b/packages/control-plane/src/auth/providers/types.test.ts deleted file mode 100644 index ada4784c6..000000000 --- a/packages/control-plane/src/auth/providers/types.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { - OAuthSignInProvider, - OAuthSignInProviderRegistry, - ProviderAuthorizationRequest, - ProviderCodeExchangeRequest, -} from "./types"; - -describe("OAuth provider contracts", () => { - it("requires Google nonce bindings and excludes them from GitHub requests", () => { - const googleAuthorization: ProviderAuthorizationRequest<"google"> = { - state: "state", - codeChallenge: "challenge", - oidcNonce: "nonce", - }; - const githubAuthorization: ProviderAuthorizationRequest<"github"> = { - state: "state", - codeChallenge: "challenge", - }; - const googleExchange: ProviderCodeExchangeRequest<"google"> = { - code: "code", - codeVerifier: "verifier", - oidcNonceHash: "nonce-hash", - }; - const githubExchange: ProviderCodeExchangeRequest<"github"> = { - code: "code", - codeVerifier: "verifier", - }; - - // @ts-expect-error Google authorization requires an OIDC nonce. - const googleWithoutNonce: ProviderAuthorizationRequest<"google"> = { - state: "state", - codeChallenge: "challenge", - }; - const githubWithNonce: ProviderAuthorizationRequest<"github"> = { - state: "state", - codeChallenge: "challenge", - // @ts-expect-error GitHub authorization cannot carry an OIDC nonce. - oidcNonce: "nonce", - }; - // @ts-expect-error Google exchange requires the persisted OIDC nonce hash. - const googleExchangeWithoutNonce: ProviderCodeExchangeRequest<"google"> = { - code: "code", - codeVerifier: "verifier", - }; - const githubExchangeWithNonce: ProviderCodeExchangeRequest<"github"> = { - code: "code", - codeVerifier: "verifier", - // @ts-expect-error GitHub exchange cannot carry an OIDC nonce hash. - oidcNonceHash: "nonce-hash", - }; - - expect(googleAuthorization.oidcNonce).toBe("nonce"); - expect(githubAuthorization).not.toHaveProperty("oidcNonce"); - expect(googleExchange.oidcNonceHash).toBe("nonce-hash"); - expect(githubExchange).not.toHaveProperty("oidcNonceHash"); - void googleWithoutNonce; - void githubWithNonce; - void googleExchangeWithoutNonce; - void githubExchangeWithNonce; - }); - - it("prevents provider adapters from being registered under another provider key", () => { - const googleProvider: OAuthSignInProvider<"google"> = { - provider: "google", - async createAuthorizationUrl() { - return new URL("https://google.example/authorize"); - }, - async exchangeAuthorizationCode() { - throw new Error("not used"); - }, - }; - const registry: OAuthSignInProviderRegistry = { - // @ts-expect-error The GitHub key cannot hold a Google adapter. - github: googleProvider, - google: googleProvider, - }; - - void registry; - }); -}); diff --git a/packages/control-plane/src/auth/providers/types.ts b/packages/control-plane/src/auth/providers/types.ts deleted file mode 100644 index dd556f8b8..000000000 --- a/packages/control-plane/src/auth/providers/types.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { SignInProvider } from "../sign-in-provider"; -import type { ProviderCredentialInput } from "../provider-credential"; - -interface ProviderAuthorizationRequestBinding { - readonly state: string; - readonly codeChallenge: string; -} - -interface ProviderAuthorizationRequestByProvider { - readonly github: ProviderAuthorizationRequestBinding & { - readonly oidcNonce?: never; - }; - readonly google: ProviderAuthorizationRequestBinding & { - readonly oidcNonce: string; - }; -} - -export type ProviderAuthorizationRequest

= - ProviderAuthorizationRequestByProvider[P]; - -interface ProviderCodeExchangeRequestBinding { - readonly code: string; - readonly codeVerifier: string; -} - -interface ProviderCodeExchangeRequestByProvider { - readonly github: ProviderCodeExchangeRequestBinding & { - readonly oidcNonceHash?: never; - }; - readonly google: ProviderCodeExchangeRequestBinding & { - readonly oidcNonceHash: string; - }; -} - -export type ProviderCodeExchangeRequest

= - ProviderCodeExchangeRequestByProvider[P]; - -export interface VerifiedProviderIdentity

{ - readonly provider: P; - readonly issuer: string; - readonly subject: string; - readonly login?: string; - readonly displayName?: string; - readonly avatarUrl?: string; - readonly verifiedEmails: readonly string[]; - readonly primaryEmail: string | null; -} - -interface ProviderCodeExchangeResultByProvider { - readonly github: { - readonly identity: VerifiedProviderIdentity<"github">; - readonly credential: ProviderCredentialInput; - }; - readonly google: { - readonly identity: VerifiedProviderIdentity<"google">; - readonly credential: null; - }; -} - -export type ProviderCodeExchangeResult

= - ProviderCodeExchangeResultByProvider[P]; - -export type OAuthProviderFailure = - | "invalid_configuration" - | "invalid_request" - | "provider_rejected" - | "provider_unavailable" - | "malformed_response"; - -export class OAuthProviderError extends Error { - constructor( - readonly failure: OAuthProviderFailure, - message: string - ) { - super(message); - this.name = "OAuthProviderError"; - } -} - -export function assertCanonicalIssuer(configuredIssuer: string, expectedIssuer: string): void { - let configured: URL; - try { - configured = new URL(configuredIssuer); - } catch { - throw new OAuthProviderError("invalid_configuration", "Provider issuer is invalid"); - } - const expected = new URL(expectedIssuer); - if (configured.href !== expected.href) { - throw new OAuthProviderError("invalid_configuration", "Provider issuer is not canonical"); - } -} - -export interface OAuthSignInProvider

{ - readonly provider: P; - createAuthorizationUrl(request: ProviderAuthorizationRequest

): Promise; - exchangeAuthorizationCode( - request: ProviderCodeExchangeRequest

- ): Promise>; -} - -export type OAuthSignInProviderRegistry = { - readonly [P in SignInProvider]: OAuthSignInProvider

; -}; diff --git a/packages/control-plane/src/auth/result.ts b/packages/control-plane/src/auth/result.ts new file mode 100644 index 000000000..957db7cfb --- /dev/null +++ b/packages/control-plane/src/auth/result.ts @@ -0,0 +1,25 @@ +import type { AuthenticationContext, Principal } from "./principal"; + +export interface AuthError { + /** Response body message (also the log detail). Never carries token material. */ + reason: string; + status: 401 | 413 | 500; + /** + * Which scheme was attempted and failed. A per-service attempt is terminal; + * "none" means no recognized credential was presented at all, and the + * router may still try sandbox auth on sandbox routes. + */ + failedScheme: "per-service" | "browser-session" | "none"; +} + +export type AuthResult = + | { + principal: Principal; + request: Request; + authentication?: AuthenticationContext; + } + | AuthError; + +export function isAuthError(result: AuthResult): result is AuthError { + return !("principal" in result); +} diff --git a/packages/control-plane/src/auth/callback-signing.ts b/packages/control-plane/src/auth/service/callback-signing.ts similarity index 90% rename from packages/control-plane/src/auth/callback-signing.ts rename to packages/control-plane/src/auth/service/callback-signing.ts index dc4e3f5c6..5412e6fe3 100644 --- a/packages/control-plane/src/auth/callback-signing.ts +++ b/packages/control-plane/src/auth/service/callback-signing.ts @@ -6,7 +6,7 @@ * with its own. */ -import { serviceAuthSecret, type ServiceKeyEnv } from "./authenticate"; +import { serviceAuthSecret, type ServiceKeyEnv } from "./config"; /** The bots the CP delivers callbacks to — also the only services that may attach a `callbackContext`. */ export const CALLBACK_DESTINATIONS = ["slack-bot", "linear-bot"] as const; diff --git a/packages/control-plane/src/auth/service/config.ts b/packages/control-plane/src/auth/service/config.ts new file mode 100644 index 000000000..614686c5b --- /dev/null +++ b/packages/control-plane/src/auth/service/config.ts @@ -0,0 +1,26 @@ +import type { ServiceName } from "@open-inspect/shared"; + +/** The per-service verification keys held by the control plane. */ +export interface ServiceKeyEnv { + SERVICE_AUTH_SECRET_WEB?: string; + SERVICE_AUTH_SECRET_SLACK_BOT?: string; + SERVICE_AUTH_SECRET_GITHUB_BOT?: string; + SERVICE_AUTH_SECRET_LINEAR_BOT?: string; + SERVICE_AUTH_SECRET_MODAL?: string; +} + +/** Resolve the verification key for one authenticated service. */ +export function serviceAuthSecret(env: ServiceKeyEnv, service: ServiceName): string | undefined { + switch (service) { + case "web": + return env.SERVICE_AUTH_SECRET_WEB; + case "slack-bot": + return env.SERVICE_AUTH_SECRET_SLACK_BOT; + case "github-bot": + return env.SERVICE_AUTH_SECRET_GITHUB_BOT; + case "linear-bot": + return env.SERVICE_AUTH_SECRET_LINEAR_BOT; + case "modal": + return env.SERVICE_AUTH_SECRET_MODAL; + } +} diff --git a/packages/control-plane/src/auth/service/request-authenticator.ts b/packages/control-plane/src/auth/service/request-authenticator.ts new file mode 100644 index 000000000..ee5287964 --- /dev/null +++ b/packages/control-plane/src/auth/service/request-authenticator.ts @@ -0,0 +1,198 @@ +import { + ACTOR_HEADER, + SERVICE_HEADER, + TOKEN_VALIDITY_MS, + isServiceName, + parseServiceSignatureHeader, + readBodyCapped, + sha256Hex, + verifyServiceSignature, + type ServiceName, +} from "@open-inspect/shared"; +import { UserStore } from "../../db/user-store"; +import { createLogger } from "../../logger"; +import type { RequestContext } from "../../routes/shared"; +import type { Env } from "../../types"; +import { ASSERTION_RIGHTS, isActorNamespace, type ActorNamespace } from "../principal"; +import type { AuthResult } from "../result"; +import { serviceAuthSecret } from "./config"; + +const logger = createLogger("auth"); + +/** + * Hard cap on a service-signed request body. The signature covers the body + * hash, so the body must be buffered and hashed before verification can + * finish. The largest legitimate signed body is a session attachment upload. + */ +export const SERVICE_REQUEST_MAX_BODY_BYTES = 16 * 1024 * 1024; + +/** Parse `:` into a typed actor reference; null when malformed. */ +function parseActor(actor: string): { provider: ActorNamespace; providerUserId: string } | null { + const separator = actor.indexOf(":"); + if (separator <= 0) return null; + const namespace = actor.slice(0, separator); + const providerUserId = actor.slice(separator + 1); + if (providerUserId === "" || !isActorNamespace(namespace)) return null; + return { provider: namespace, providerUserId }; +} + +/** + * Best-effort nonce-reuse detection (log-only for now). This is in-isolate + * state; entries expire with the signature validity window. + */ +const seenNonces = new Map(); +const SEEN_NONCE_LIMIT = 5000; + +function recordNonce(service: ServiceName, nonce: string, ctx: RequestContext): void { + const now = Date.now(); + const key = `${service}:${nonce}`; + const expiresAt = seenNonces.get(key); + if (expiresAt !== undefined && expiresAt > now) { + logger.warn("Service auth nonce reused", { + event: "auth.nonce_reuse", + service, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return; + } + if (seenNonces.size >= SEEN_NONCE_LIMIT) { + for (const [candidate, expiry] of seenNonces) { + if (expiry <= now) seenNonces.delete(candidate); + } + // Map iteration follows insertion order, which also follows expiry order. + let excess = seenNonces.size - SEEN_NONCE_LIMIT + 1; + for (const candidate of seenNonces.keys()) { + if (excess-- <= 0) break; + seenNonces.delete(candidate); + } + } + seenNonces.set(key, now + TOKEN_VALIDITY_MS); +} + +export async function authenticateServiceRequest( + request: Request, + env: Env, + ctx: RequestContext, + signatureHeader: string +): Promise { + const serviceHeader = request.headers.get(SERVICE_HEADER) ?? ""; + if (!isServiceName(serviceHeader)) { + logger.warn("Service auth failed: unknown service", { + event: "auth.service_failed", + failure: "unknown_service", + service: serviceHeader, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return { reason: "Unauthorized", status: 401, failedScheme: "per-service" }; + } + const service = serviceHeader; + + const secret = serviceAuthSecret(env, service); + if (!secret) { + logger.error("Service auth secret not configured - rejecting request", { + event: "auth.misconfigured", + service, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return { + reason: "Service authentication not configured", + status: 500, + failedScheme: "per-service", + }; + } + + // Reject malformed or stale signatures before buffering the request body. + const parsedSignature = parseServiceSignatureHeader(signatureHeader); + if (!parsedSignature.ok) { + logger.warn("Service auth failed: signature rejected", { + event: "auth.service_failed", + failure: parsedSignature.reason, + service, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return { reason: "Unauthorized", status: 401, failedScheme: "per-service" }; + } + + let bodyBuffer: Uint8Array | null = null; + if (request.body !== null) { + bodyBuffer = await readBodyCapped(request.body, SERVICE_REQUEST_MAX_BODY_BYTES); + if (bodyBuffer === null) { + logger.warn("Service auth failed: body over size cap", { + event: "auth.service_failed", + failure: "body_too_large", + service, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return { reason: "Request body too large", status: 413, failedScheme: "per-service" }; + } + } + const bodySha256Hex = await sha256Hex(bodyBuffer ?? ""); + const actor = request.headers.get(ACTOR_HEADER) ?? ""; + + const verification = await verifyServiceSignature({ + signatureHeader, + service, + secret, + method: request.method, + url: request.url, + bodySha256Hex, + actor, + }); + if (!verification.ok) { + logger.warn("Service auth failed: signature rejected", { + event: "auth.service_failed", + failure: verification.reason, + service, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return { reason: "Unauthorized", status: 401, failedScheme: "per-service" }; + } + + recordNonce(service, verification.nonce, ctx); + + let resolvedActor = null; + if (actor !== "") { + const parsed = parseActor(actor); + if (!parsed || ASSERTION_RIGHTS[service] !== parsed.provider) { + logger.warn("Actor assertion denied", { + event: "auth.assertion_denied", + service, + actor, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return { reason: "Unauthorized", status: 401, failedScheme: "per-service" }; + } + const identity = await new UserStore(ctx.db).getIdentity( + parsed.provider, + parsed.providerUserId + ); + resolvedActor = { + provider: parsed.provider, + providerUserId: parsed.providerUserId, + canonicalUserId: identity?.userId ?? null, + participantUserId: actor, + }; + } + + // Rebuild requests whose body was consumed for signature verification. + const handlerRequest = + bodyBuffer === null + ? request + : new Request(request.url, { + method: request.method, + headers: request.headers, + body: bodyBuffer, + }); + + return { + principal: { kind: "service", service, actor: resolvedActor }, + request: handlerRequest, + }; +} diff --git a/packages/control-plane/src/auth/subject-verification.test.ts b/packages/control-plane/src/auth/subject-verification.test.ts deleted file mode 100644 index 953a5624e..000000000 --- a/packages/control-plane/src/auth/subject-verification.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { verifySubjectToken } from "./subject-verification"; - -const originalFetch = globalThis.fetch; - -beforeEach(() => { - globalThis.fetch = vi.fn(); -}); - -afterEach(() => { - globalThis.fetch = originalFetch; - vi.restoreAllMocks(); -}); - -function mockFetchResponse(status: number, body: unknown): void { - vi.mocked(globalThis.fetch).mockResolvedValue( - new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }) - ); -} - -const OCTOCAT = { - id: 583231, - login: "octocat", - name: "The Octocat", - email: null, - avatar_url: "https://avatars.example/octocat", -}; - -/** URL-aware GitHub mock: /user and /user/emails answer independently. */ -function mockGitHubFetch(opts: { - user?: { status?: number; body?: unknown }; - emails?: { status?: number; body?: unknown; reject?: boolean }; -}): void { - vi.mocked(globalThis.fetch).mockImplementation(async (input) => { - const url = String(input); - if (url === "https://api.github.com/user") { - return new Response(JSON.stringify(opts.user?.body ?? OCTOCAT), { - status: opts.user?.status ?? 200, - headers: { "Content-Type": "application/json" }, - }); - } - if (url === "https://api.github.com/user/emails") { - if (opts.emails?.reject) throw new TypeError("network down"); - return new Response(JSON.stringify(opts.emails?.body ?? []), { - status: opts.emails?.status ?? 200, - headers: { "Content-Type": "application/json" }, - }); - } - throw new Error(`unexpected url ${url}`); - }); -} - -describe("github-access-token", () => { - it("resolves the verified primary email even when the public profile email is null", async () => { - mockGitHubFetch({ - emails: { - body: [ - { email: "secondary@example.com", primary: false, verified: true }, - { email: "primary@example.com", primary: true, verified: true }, - ], - }, - }); - const result = await verifySubjectToken("github-access-token", "gho_token"); - expect(result).toEqual({ - ok: true, - subject: { - provider: "github", - providerUserId: "583231", - providerLogin: "octocat", - // The verified primary from /user/emails, NOT /user.email (null) — so - // email-based cross-provider linking mints the family on the right user. - providerEmail: "primary@example.com", - displayName: "The Octocat", - avatarUrl: "https://avatars.example/octocat", - }, - }); - const urls = vi.mocked(globalThis.fetch).mock.calls.map((c) => String(c[0])); - expect(urls).toEqual(["https://api.github.com/user", "https://api.github.com/user/emails"]); - }); - - it("degrades to no email when /user/emails access is deterministically unsupported", async () => { - // 403 = GitHub App missing the "Email addresses" permission; 404 = OAuth - // token without the email scope. Retrying cannot produce an email in - // these deployments, so the exchange proceeds with id-based resolution. - for (const status of [403, 404]) { - mockGitHubFetch({ emails: { status, body: { message: "nope" } } }); - expect(await verifySubjectToken("github-access-token", "gho_token")).toMatchObject({ - ok: true, - subject: { providerUserId: "583231", providerEmail: undefined }, - }); - } - }); - - it("fails closed when the /user/emails request fails transiently", async () => { - // A transient failure must NOT read as "no email": on a first exchange it - // would mint the family on a fresh duplicate canonical user and fix the - // provider identity to it permanently. Fail retryable instead. - mockGitHubFetch({ emails: { reject: true } }); - expect(await verifySubjectToken("github-access-token", "gho_token")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - mockGitHubFetch({ emails: { status: 500, body: { message: "boom" } } }); - expect(await verifySubjectToken("github-access-token", "gho_token")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - mockGitHubFetch({ emails: { status: 429, body: { message: "rate limited" } } }); - expect(await verifySubjectToken("github-access-token", "gho_token")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - }); - - it("fails closed on a malformed /user/emails body instead of reading it as no email", async () => { - mockGitHubFetch({ emails: { body: [{ email: "x@example.com" }] } }); - expect(await verifySubjectToken("github-access-token", "gho_token")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - }); - - it("never treats an unverified or non-primary email as the verified identity", async () => { - mockGitHubFetch({ - emails: { - body: [ - { email: "unverified-primary@example.com", primary: true, verified: false }, - { email: "verified-secondary@example.com", primary: false, verified: true }, - ], - }, - }); - expect(await verifySubjectToken("github-access-token", "gho_token")).toMatchObject({ - ok: true, - subject: { providerEmail: undefined }, - }); - }); - - it("resolves an identity from id+login even when the display fields are absent", async () => { - // Validation is fail-closed on the identity keys only; a valid-but-partial - // body (no name/avatar) must still resolve, not be rejected. - mockGitHubFetch({ user: { body: { id: 583231, login: "octocat" } } }); - expect(await verifySubjectToken("github-access-token", "gho_token")).toMatchObject({ - ok: true, - subject: { providerUserId: "583231", providerLogin: "octocat", displayName: "octocat" }, - }); - }); - - it("fails closed on a malformed 200 /user body instead of minting a 'undefined' subject", async () => { - // A 200 whose body is missing `id` must not collapse to providerUserId - // "undefined" — treat it as the provider failing, fail closed, retryable. - mockGitHubFetch({ user: { body: { login: "octocat", avatar_url: "https://a/x" } } }); - expect(await verifySubjectToken("github-access-token", "gho_token")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - }); - - it("maps provider 401 to subject_rejected", async () => { - mockFetchResponse(401, { message: "Bad credentials" }); - expect(await verifySubjectToken("github-access-token", "bad")).toEqual({ - ok: false, - failure: "subject_rejected", - }); - }); - - it("maps provider 5xx and network failures to provider_unavailable", async () => { - mockFetchResponse(502, {}); - expect(await verifySubjectToken("github-access-token", "t")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - vi.mocked(globalThis.fetch).mockRejectedValue(new TypeError("network down")); - expect(await verifySubjectToken("github-access-token", "t")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - }); - - it("maps 429 throttling to provider_unavailable, not subject_rejected", async () => { - mockFetchResponse(429, { message: "rate limited" }); - expect(await verifySubjectToken("github-access-token", "t")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - }); -}); - -describe("google-access-token", () => { - it("returns the provider-verified identity from userinfo", async () => { - mockFetchResponse(200, { - sub: "1078462347", - email: "person@example.com", - email_verified: true, - name: "A Person", - picture: "https://lh3.example/photo", - }); - const result = await verifySubjectToken("google-access-token", "ya29.token"); - expect(result).toEqual({ - ok: true, - subject: { - provider: "google", - providerUserId: "1078462347", - providerEmail: "person@example.com", - displayName: "A Person", - avatarUrl: "https://lh3.example/photo", - }, - }); - expect(String(vi.mocked(globalThis.fetch).mock.calls[0][0])).toBe( - "https://openidconnect.googleapis.com/v1/userinfo" - ); - }); - - it("maps provider 401 to subject_rejected", async () => { - mockFetchResponse(401, { error: "invalid_token" }); - expect(await verifySubjectToken("google-access-token", "bad")).toEqual({ - ok: false, - failure: "subject_rejected", - }); - }); - - it("never exposes an unverified email as the subject's providerEmail", async () => { - for (const email_verified of [false, undefined]) { - mockFetchResponse(200, { - sub: "1078462347", - email: "victim@example.com", - ...(email_verified === undefined ? {} : { email_verified }), - name: "A Person", - }); - const result = await verifySubjectToken("google-access-token", "ya29.token"); - expect(result).toMatchObject({ - ok: true, - subject: { providerUserId: "1078462347", providerEmail: undefined }, - }); - } - }); - - it("treats malformed userinfo responses as provider_unavailable", async () => { - mockFetchResponse(200, { not_sub: "x" }); - expect(await verifySubjectToken("google-access-token", "t")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - }); - - it("maps network failures to provider_unavailable", async () => { - vi.mocked(globalThis.fetch).mockRejectedValue(new TypeError("network down")); - expect(await verifySubjectToken("google-access-token", "t")).toEqual({ - ok: false, - failure: "provider_unavailable", - }); - }); -}); diff --git a/packages/control-plane/src/auth/subject-verification.ts b/packages/control-plane/src/auth/subject-verification.ts deleted file mode 100644 index c65ee6a72..000000000 --- a/packages/control-plane/src/auth/subject-verification.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Subject-token verification for the exchange. - * - * The CP verifies the presented provider credential WITH the provider — - * never trusting asserted claims — and returns the provider's own account - * identity as the verified subject. - */ - -import { z } from "zod"; - -import { getGitHubUser, getGitHubUserEmails, GitHubUserApiError } from "./github"; -import type { SignInProvider } from "./sign-in-provider"; -import { createLogger } from "../logger"; - -const logger = createLogger("subject-verification"); - -export const SUBJECT_TOKEN_TYPES = ["github-access-token", "google-access-token"] as const; -export type SubjectTokenType = (typeof SUBJECT_TOKEN_TYPES)[number]; - -export interface VerifiedSubject { - provider: SignInProvider; - providerUserId: string; - providerLogin?: string; - providerEmail?: string; - displayName?: string; - avatarUrl?: string; -} - -export type SubjectVerificationResult = - | { ok: true; subject: VerifiedSubject } - /** - * `subject_rejected`: the provider says the token is invalid (401/403) — - * the caller's assertion failed verification. `provider_unavailable`: the - * provider itself failed (429/other 4xx/5xx/timeout/network) — fail - * closed, retryable. - */ - | { ok: false; failure: "subject_rejected" | "provider_unavailable" }; - -const GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo"; -const PROVIDER_FETCH_TIMEOUT_MS = 10_000; - -const googleUserinfoSchema = z.object({ - sub: z.string().min(1), - email: z.string().optional(), - email_verified: z.boolean().optional(), - name: z.string().optional(), - picture: z.string().optional(), -}); - -/** Non-2xx from a provider identity endpoint, carrying the status for classification. */ -class ProviderStatusError extends Error { - constructor(readonly status: number) { - super(`Provider identity endpoint returned ${status}`); - this.name = "ProviderStatusError"; - } -} - -/** - * Run one provider identity fetch under the shared contract: a - * PROVIDER_FETCH_TIMEOUT_MS abort, status classification (401/403 means the - * subject was rejected, everything else — 429/other 4xx/5xx/timeout/network/ - * malformed — means the provider failed), and the single failure log site. - * Per-provider code contributes only the URL/schema/shape mapping. - */ -async function fetchProviderIdentity( - provider: SignInProvider, - fetchSubject: (signal: AbortSignal) => Promise -): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), PROVIDER_FETCH_TIMEOUT_MS); - try { - return { ok: true, subject: await fetchSubject(controller.signal) }; - } catch (error) { - const status = - error instanceof GitHubUserApiError || error instanceof ProviderStatusError - ? error.status - : undefined; - // 401/403 are the provider judging the credential. Everything else — - // 429 throttling, other 4xx (our request shape), 5xx/timeout/network — - // is the provider failing, so fail closed and retryable. - const failure = status === 401 || status === 403 ? "subject_rejected" : "provider_unavailable"; - logger.warn("Subject verification failed", { - event: "auth.subject_verification_failed", - provider, - provider_status: status, - failure, - }); - return { ok: false, failure }; - } finally { - clearTimeout(timer); - } -} - -function verifyGitHubSubject(accessToken: string): Promise { - return fetchProviderIdentity("github", async (signal) => { - const user = await getGitHubUser(accessToken, undefined, signal); - return { - provider: "github", - providerUserId: String(user.id), - providerLogin: user.login, - // Resolve the VERIFIED PRIMARY email, exactly as the web sign-in flow - // does — NOT the public profile email. `/user.email` is null when the - // user has no public email, which would skip email-based cross-provider - // linking in resolveOrCreateUser and mint the 90-day token family on a - // fresh, orphaned canonical user instead of the existing one. Using the - // same source as sign-in keeps the exchange and the provider-identity - // upsert resolving to the same canonical user. - providerEmail: await resolveVerifiedGitHubEmail(accessToken, signal), - displayName: user.name ?? user.login, - avatarUrl: user.avatar_url, - }; - }); -} - -/** - * `/user/emails` statuses that deterministically mean the deployment cannot - * grant email access: 403 (GitHub App missing the "Email addresses" - * permission) and 404 (OAuth token without the email scope). Retrying cannot - * produce an email in these deployments — the web session carries no email - * there either — so account linking correctly degrades to id-based - * resolution. - */ -const GITHUB_EMAILS_UNSUPPORTED_STATUSES = new Set([403, 404]); - -/** - * The verified primary email GitHub reports for the token's user, or - * undefined ONLY when the deployment deterministically has no email access. - * Every other failure — 5xx, throttling, network/timeout, malformed body — - * propagates and fails the exchange closed (`provider_unavailable`, - * retryable): the email is an identity-linking key, and treating a transient - * failure as "no email" would mint the token family on a fresh duplicate - * canonical user, permanently fixing the provider identity to it. - */ -async function resolveVerifiedGitHubEmail( - accessToken: string, - signal: AbortSignal -): Promise { - try { - const emails = await getGitHubUserEmails(accessToken, undefined, signal); - return emails.find((entry) => entry.primary && entry.verified)?.email; - } catch (error) { - if ( - error instanceof GitHubUserApiError && - GITHUB_EMAILS_UNSUPPORTED_STATUSES.has(error.status) - ) { - return undefined; - } - throw error; - } -} - -function verifyGoogleSubject(accessToken: string): Promise { - return fetchProviderIdentity("google", async (signal) => { - const response = await fetch(GOOGLE_USERINFO_URL, { - headers: { Authorization: `Bearer ${accessToken}` }, - signal, - }); - if (!response.ok) { - throw new ProviderStatusError(response.status); - } - const parsed = googleUserinfoSchema.safeParse(await response.json().catch(() => null)); - if (!parsed.success) { - throw new Error("Malformed Google userinfo response"); - } - return { - provider: "google", - providerUserId: parsed.data.sub, - // Email-keyed account linking downstream means an unverified email - // must never leave this boundary — the subject stays valid by `sub`. - providerEmail: parsed.data.email_verified === true ? parsed.data.email : undefined, - displayName: parsed.data.name, - avatarUrl: parsed.data.picture, - }; - }); -} - -export async function verifySubjectToken( - subjectTokenType: SubjectTokenType, - subjectToken: string -): Promise { - switch (subjectTokenType) { - case "github-access-token": - return verifyGitHubSubject(subjectToken); - case "google-access-token": - return verifyGoogleSubject(subjectToken); - } -} diff --git a/packages/control-plane/src/auth/token-exchange.ts b/packages/control-plane/src/auth/token-exchange.ts deleted file mode 100644 index ae0fb0679..000000000 --- a/packages/control-plane/src/auth/token-exchange.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * The provider-verified token exchange: verify the presented - * subject with its provider, resolve the canonical user, capture SCM - * credentials, and mint a web session token pair. Lives beside - * WebSessionTokenService so P2's public OAuth surface can reuse the same - * sequence without going through the internal route. - */ - -import type { SignInProvider } from "./sign-in-provider"; -import { verifySubjectToken, type SubjectTokenType } from "./subject-verification"; -import type { WebSessionTokenService, WebSessionTokenPair } from "./web-session-tokens"; -import type { SqlDatabase } from "../db/sql-database"; -import { UserStore } from "../db/user-store"; -import { UserScmTokenStore, DEFAULT_TOKEN_LIFETIME_MS } from "../db/user-scm-tokens"; - -export interface ExchangeRequest { - subjectTokenType: SubjectTokenType; - subjectToken: string; - scmRefreshToken?: string; - scmTokenExpiresAt?: number; -} - -export type ExchangeResult = - | { ok: true; userId: string; provider: SignInProvider; pair: WebSessionTokenPair } - | { ok: false; failure: "subject_rejected" | "provider_unavailable" }; - -/** - * Run the exchange. SCM capture is awaited — a failure fails the exchange - * (fail closed) rather than minting tokens for a user whose credentials were - * silently dropped. - */ -export async function performExchange( - request: ExchangeRequest, - db: SqlDatabase, - tokenService: WebSessionTokenService, - tokenEncryptionKey: string | undefined -): Promise { - const verification = await verifySubjectToken(request.subjectTokenType, request.subjectToken); - if (!verification.ok) { - return { ok: false, failure: verification.failure }; - } - const subject = verification.subject; - - // Resolve the canonical user from the VERIFIED identity — this is the - // identity-creating path for web, replacing trust in body fields. - const user = await new UserStore(db).resolveOrCreateUser({ - provider: subject.provider, - providerUserId: subject.providerUserId, - providerLogin: subject.providerLogin, - providerEmail: subject.providerEmail, - displayName: subject.displayName, - avatarUrl: subject.avatarUrl, - }); - - // Capture SCM credentials once, keyed by the provider-verified id — the - // same store session-create feeds today, now from a verified source. - if (subject.provider === "github" && request.scmRefreshToken && tokenEncryptionKey) { - await new UserScmTokenStore(db, tokenEncryptionKey).upsertTokens( - subject.providerUserId, - request.subjectToken, - request.scmRefreshToken, - request.scmTokenExpiresAt ?? Date.now() + DEFAULT_TOKEN_LIFETIME_MS, - user.id - ); - } - - const pair = await tokenService.mintPair(user.id, { - provider: subject.provider, - providerUserId: subject.providerUserId, - }); - - return { ok: true, userId: user.id, provider: subject.provider, pair }; -} diff --git a/packages/control-plane/src/auth/user/admission-policy.test.ts b/packages/control-plane/src/auth/user/admission-policy.test.ts new file mode 100644 index 000000000..75cc9bb1d --- /dev/null +++ b/packages/control-plane/src/auth/user/admission-policy.test.ts @@ -0,0 +1,182 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + AdmissionDeniedError, + AdmissionPolicy, + AdmissionUnavailableError, + parseAdmissionAllowlist, + parseAdmissionBoolean, + type AdmissionPolicyConfig, +} from "./admission-policy"; +import type { ProviderSignInResult } from "./providers/types"; + +const BASE_CONFIG: AdmissionPolicyConfig = { + allowedGitHubUsers: [], + allowedEmails: [], + allowedEmailDomains: [], + allowedGitHubOrganizations: [], + unsafeAllowAllUsers: false, +}; + +const GOOGLE_SIGN_IN: ProviderSignInResult<"google"> = { + identity: { + provider: "google", + issuer: "https://accounts.google.com", + subject: "google-subject", + verifiedEmails: ["first@example.net", "allowed@corp.example"], + primaryEmail: "first@example.net", + }, + credential: null, +}; + +const GITHUB_SIGN_IN: ProviderSignInResult<"github"> = { + identity: { + provider: "github", + issuer: "https://github.com", + subject: "123", + login: "octocat", + verifiedEmails: [], + primaryEmail: null, + }, + credential: { + kind: "access_only_nonexpiring", + accessToken: "ghu_token", + }, +}; + +describe("AdmissionPolicy", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("evaluates the complete verified email set with OR semantics", async () => { + const policy = new AdmissionPolicy({ + ...BASE_CONFIG, + allowedEmailDomains: ["corp.example"], + }); + + await expect(policy.requireAdmission(GOOGLE_SIGN_IN)).resolves.toEqual({ + reason: "email_domain_allowlist", + }); + }); + + it("admits an active GitHub organization member with the current flow credential", async () => { + const fetcher = vi.fn().mockResolvedValue(Response.json({ state: "active" })); + const policy = new AdmissionPolicy( + { + ...BASE_CONFIG, + allowedGitHubOrganizations: ["open-inspect"], + }, + { fetcher } + ); + + await expect(policy.requireAdmission(GITHUB_SIGN_IN)).resolves.toEqual({ + reason: "github_organization", + organization: "open-inspect", + }); + expect(fetcher).toHaveBeenCalledWith( + "https://api.github.com/user/memberships/orgs/open-inspect", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer ghu_token" }), + signal: expect.any(AbortSignal), + }) + ); + }); + + it("preserves the Worker receiver when using the default global fetch", async () => { + const runtimeFetch = vi.fn(function (this: unknown) { + if (this !== globalThis) { + throw new TypeError("Illegal invocation"); + } + return Promise.resolve(Response.json({ state: "active" })); + }); + vi.stubGlobal("fetch", runtimeFetch); + const policy = new AdmissionPolicy({ + ...BASE_CONFIG, + allowedGitHubOrganizations: ["open-inspect"], + }); + + await expect(policy.requireAdmission(GITHUB_SIGN_IN)).resolves.toEqual({ + reason: "github_organization", + organization: "open-inspect", + }); + expect(runtimeFetch).toHaveBeenCalledOnce(); + }); + + it("parses deployment admission settings conservatively", () => { + expect(parseAdmissionAllowlist(" Alice,alice, BOB ,, ")).toEqual(["alice", "bob"]); + expect(parseAdmissionBoolean(" TRUE ")).toBe(true); + expect(parseAdmissionBoolean("1")).toBe(false); + expect(parseAdmissionBoolean(undefined)).toBe(false); + }); + + it("keeps unsafe allow-all limited to an otherwise empty policy", async () => { + const emptyPolicy = new AdmissionPolicy({ + ...BASE_CONFIG, + unsafeAllowAllUsers: true, + }); + await expect(emptyPolicy.requireAdmission(GOOGLE_SIGN_IN)).resolves.toEqual({ + reason: "unsafe_allow_all", + }); + + const configuredPolicy = new AdmissionPolicy({ + ...BASE_CONFIG, + allowedEmails: ["someone@example.com"], + unsafeAllowAllUsers: true, + }); + await expect(configuredPolicy.requireAdmission(GOOGLE_SIGN_IN)).rejects.toBeInstanceOf( + AdmissionDeniedError + ); + }); + + it("does not apply the GitHub username allowlist to another provider", async () => { + const policy = new AdmissionPolicy({ + ...BASE_CONFIG, + allowedGitHubUsers: ["google-subject"], + }); + + await expect(policy.requireAdmission(GOOGLE_SIGN_IN)).rejects.toBeInstanceOf( + AdmissionDeniedError + ); + }); + + it("distinguishes definitive non-membership from an unavailable organization check", async () => { + const unavailable = new AdmissionPolicy( + { + ...BASE_CONFIG, + allowedGitHubOrganizations: ["open-inspect"], + }, + { + fetcher: vi.fn().mockResolvedValue(new Response(null, { status: 503 })), + } + ); + await expect(unavailable.requireAdmission(GITHUB_SIGN_IN)).rejects.toBeInstanceOf( + AdmissionUnavailableError + ); + + const denied = new AdmissionPolicy( + { + ...BASE_CONFIG, + allowedGitHubOrganizations: ["open-inspect"], + }, + { + fetcher: vi.fn().mockResolvedValue(new Response(null, { status: 404 })), + } + ); + await expect(denied.requireAdmission(GITHUB_SIGN_IN)).rejects.toBeInstanceOf( + AdmissionDeniedError + ); + + const pending = new AdmissionPolicy( + { + ...BASE_CONFIG, + allowedGitHubOrganizations: ["open-inspect"], + }, + { + fetcher: vi.fn().mockResolvedValue(Response.json({ state: "pending" })), + } + ); + await expect(pending.requireAdmission(GITHUB_SIGN_IN)).rejects.toBeInstanceOf( + AdmissionDeniedError + ); + }); +}); diff --git a/packages/control-plane/src/auth/user/admission-policy.ts b/packages/control-plane/src/auth/user/admission-policy.ts new file mode 100644 index 000000000..328e8e0ac --- /dev/null +++ b/packages/control-plane/src/auth/user/admission-policy.ts @@ -0,0 +1,165 @@ +import { z } from "zod"; +import { DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS } from "./providers/constants"; +import type { ProviderSignInResult } from "./providers/types"; + +export type VerifiedProviderSignIn = + | ProviderSignInResult<"github"> + | ProviderSignInResult<"google">; + +export interface AdmissionPolicyConfig { + readonly allowedGitHubUsers: readonly string[]; + readonly allowedEmails: readonly string[]; + readonly allowedEmailDomains: readonly string[]; + readonly allowedGitHubOrganizations: readonly string[]; + readonly unsafeAllowAllUsers: boolean; +} + +export type AdmissionDecision = + | { readonly reason: "unsafe_allow_all" } + | { readonly reason: "github_user_allowlist" } + | { readonly reason: "email_allowlist" } + | { readonly reason: "email_domain_allowlist" } + | { readonly reason: "github_organization"; readonly organization: string }; + +export interface AdmissionPolicyDependencies { + readonly fetcher?: typeof fetch; +} + +export class AdmissionDeniedError extends Error { + constructor() { + super("User is not admitted by this deployment"); + this.name = "AdmissionDeniedError"; + } +} + +export class AdmissionUnavailableError extends Error { + constructor() { + super("Admission policy could not be evaluated"); + this.name = "AdmissionUnavailableError"; + } +} + +const membershipSchema = z.object({ + state: z.enum(["active", "pending"]), +}); + +function normalize(values: readonly string[]): string[] { + return [...new Set(values.map((value) => value.trim().toLowerCase()).filter(Boolean))]; +} + +export function parseAdmissionAllowlist(value: string | undefined): string[] { + return normalize(value?.split(",") ?? []); +} + +export function parseAdmissionBoolean(value: string | undefined): boolean { + return value?.trim().toLowerCase() === "true"; +} + +function emailDomain(email: string): string | null { + const separator = email.lastIndexOf("@"); + if (separator <= 0 || separator === email.length - 1) return null; + return email.slice(separator + 1).toLowerCase(); +} + +function isGitHubSignIn(signIn: VerifiedProviderSignIn): signIn is ProviderSignInResult<"github"> { + return signIn.identity.provider === "github"; +} + +export class AdmissionPolicy { + private readonly config: AdmissionPolicyConfig; + private readonly fetcher: typeof fetch; + + constructor(config: AdmissionPolicyConfig, dependencies: AdmissionPolicyDependencies = {}) { + this.config = { + allowedGitHubUsers: normalize(config.allowedGitHubUsers), + allowedEmails: normalize(config.allowedEmails), + allowedEmailDomains: normalize(config.allowedEmailDomains), + allowedGitHubOrganizations: normalize(config.allowedGitHubOrganizations), + unsafeAllowAllUsers: config.unsafeAllowAllUsers, + }; + this.fetcher = dependencies.fetcher ?? globalThis.fetch.bind(globalThis); + } + + async requireAdmission(signIn: VerifiedProviderSignIn): Promise { + const hasConfiguredAllowlist = + this.config.allowedGitHubUsers.length > 0 || + this.config.allowedEmails.length > 0 || + this.config.allowedEmailDomains.length > 0 || + this.config.allowedGitHubOrganizations.length > 0; + if (!hasConfiguredAllowlist && this.config.unsafeAllowAllUsers) { + return { reason: "unsafe_allow_all" }; + } + + if ( + isGitHubSignIn(signIn) && + signIn.identity.login && + this.config.allowedGitHubUsers.includes(signIn.identity.login.toLowerCase()) + ) { + return { reason: "github_user_allowlist" }; + } + + const emails = normalize(signIn.identity.verifiedEmails); + if (emails.some((email) => this.config.allowedEmails.includes(email))) { + return { reason: "email_allowlist" }; + } + if ( + emails.some((email) => { + const domain = emailDomain(email); + return domain !== null && this.config.allowedEmailDomains.includes(domain); + }) + ) { + return { reason: "email_domain_allowlist" }; + } + + if (isGitHubSignIn(signIn) && this.config.allowedGitHubOrganizations.length > 0) { + return this.requireGitHubOrganization(signIn); + } + throw new AdmissionDeniedError(); + } + + private async requireGitHubOrganization( + signIn: ProviderSignInResult<"github"> + ): Promise { + const accessToken = signIn.credential.accessToken; + let unavailable = false; + + for (const organization of this.config.allowedGitHubOrganizations) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS); + try { + const response = await this.fetcher( + `https://api.github.com/user/memberships/orgs/${encodeURIComponent(organization)}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "Open-Inspect-Control-Plane", + }, + signal: controller.signal, + } + ); + if (response.status === 404) continue; + if (!response.ok) { + unavailable = true; + continue; + } + const parsed = membershipSchema.safeParse(await response.json().catch(() => null)); + if (!parsed.success) { + unavailable = true; + continue; + } + if (parsed.data.state === "active") { + return { reason: "github_organization", organization }; + } + } catch { + unavailable = true; + } finally { + clearTimeout(timer); + } + } + + if (unavailable) throw new AdmissionUnavailableError(); + throw new AdmissionDeniedError(); + } +} diff --git a/packages/control-plane/src/auth/user/better-auth.ts b/packages/control-plane/src/auth/user/better-auth.ts new file mode 100644 index 000000000..893dcfda5 --- /dev/null +++ b/packages/control-plane/src/auth/user/better-auth.ts @@ -0,0 +1,107 @@ +import { BROWSER_AUTH_CLIENT_IP_HEADER } from "@open-inspect/shared"; +import { betterAuth } from "better-auth"; +import { generateId } from "../crypto"; +import type { CanonicalUserProjection } from "./canonical-user-projection"; +import type { ProviderProfileResolver } from "./provider-profile"; + +const MS_PER_SECOND = 1000; + +export const SESSION_EXPIRES_IN_MS = 7 * 24 * 60 * 60 * MS_PER_SECOND; +export const SESSION_UPDATE_AGE_MS = 24 * 60 * 60 * MS_PER_SECOND; + +export interface UserAuthConfig { + readonly database: D1Database; + readonly publicWebOrigin: string; + readonly secret: string; + readonly userProjection: CanonicalUserProjection; + readonly github?: { + readonly clientId: string; + readonly clientSecret: string; + readonly getUserInfo: ProviderProfileResolver; + }; + readonly google?: { + readonly clientId: string; + readonly clientSecret: string; + readonly getUserInfo: ProviderProfileResolver; + }; +} + +/** + * Creates the control plane's user-authentication authority. + * + * `publicWebOrigin` is deliberately the browser-visible web origin rather than + * the control-plane origin. The web transparently proxies this handler, so all + * redirects and host-only cookies remain scoped to the web application. + */ +export function createUserAuth(config: UserAuthConfig) { + return betterAuth({ + baseURL: config.publicWebOrigin, + database: config.database, + secret: config.secret, + trustedOrigins: [config.publicWebOrigin], + telemetry: { enabled: false }, + // Workers do not expose NODE_ENV through process.env under every supported + // compatibility date. Keep the production security behavior explicit. + rateLimit: { + enabled: true, + window: 60, + max: 100, + storage: "memory", + }, + advanced: { + cookiePrefix: "openinspect", + ipAddress: { + ipAddressHeaders: [BROWSER_AUTH_CLIENT_IP_HEADER], + }, + // Production is HTTPS-only. Loopback HTTP remains available for local + // development, where browsers reject Secure cookies by design. + useSecureCookies: new URL(config.publicWebOrigin).protocol === "https:", + // Application authorization names users by the existing canonical + // 32-character lowercase-hex ID. Keep Better Auth authoritative for ID + // creation while preserving that stable cross-service contract. + database: { + generateId: () => generateId(), + }, + }, + socialProviders: { + ...(config.github + ? { + github: { + ...config.github, + disableDefaultScope: true, + }, + } + : {}), + ...(config.google ? { google: config.google } : {}), + }, + user: { + modelName: "auth_users", + }, + session: { + modelName: "auth_sessions", + expiresIn: SESSION_EXPIRES_IN_MS / MS_PER_SECOND, + updateAge: SESSION_UPDATE_AGE_MS / MS_PER_SECOND, + }, + account: { + modelName: "auth_accounts", + accountLinking: { + disableImplicitLinking: true, + }, + encryptOAuthTokens: true, + }, + verification: { + modelName: "auth_verifications", + storeIdentifier: "hashed", + }, + databaseHooks: { + user: { + create: { + after: (user) => config.userProjection.project(user), + }, + update: { + after: (user) => config.userProjection.project(user), + }, + }, + }, + }); +} diff --git a/packages/control-plane/src/auth/user/canonical-user-projection.ts b/packages/control-plane/src/auth/user/canonical-user-projection.ts new file mode 100644 index 000000000..56e80a611 --- /dev/null +++ b/packages/control-plane/src/auth/user/canonical-user-projection.ts @@ -0,0 +1,18 @@ +export interface UserProjectionInput { + readonly id: string; + readonly name: string; + readonly email: string; + readonly image?: string | null; + readonly createdAt: Date; + readonly updatedAt: Date; +} + +/** + * Projects Better Auth's user into the application's actor model. + * + * The ids must remain identical: authorization always names `users.id`, while + * Better Auth remains authoritative for authentication state. + */ +export interface CanonicalUserProjection { + project(user: UserProjectionInput): Promise; +} diff --git a/packages/control-plane/src/auth/provider-credential.ts b/packages/control-plane/src/auth/user/provider-credential.ts similarity index 100% rename from packages/control-plane/src/auth/provider-credential.ts rename to packages/control-plane/src/auth/user/provider-credential.ts diff --git a/packages/control-plane/src/auth/user/provider-profile.ts b/packages/control-plane/src/auth/user/provider-profile.ts new file mode 100644 index 000000000..04fdc7e6f --- /dev/null +++ b/packages/control-plane/src/auth/user/provider-profile.ts @@ -0,0 +1,21 @@ +export interface ProviderTokens { + readonly accessToken?: string; + readonly refreshToken?: string; + readonly accessTokenExpiresAt?: Date; + readonly refreshTokenExpiresAt?: Date; + readonly idToken?: string; + readonly scopes?: readonly string[]; +} + +export interface ProviderProfile { + readonly user: { + readonly id: string; + readonly name?: string; + readonly email?: string | null; + readonly image?: string; + readonly emailVerified: boolean; + }; + readonly data: unknown; +} + +export type ProviderProfileResolver = (tokens: ProviderTokens) => Promise; diff --git a/packages/control-plane/src/auth/providers/constants.ts b/packages/control-plane/src/auth/user/providers/constants.ts similarity index 100% rename from packages/control-plane/src/auth/providers/constants.ts rename to packages/control-plane/src/auth/user/providers/constants.ts diff --git a/packages/control-plane/src/auth/user/providers/github-identity.test.ts b/packages/control-plane/src/auth/user/providers/github-identity.test.ts new file mode 100644 index 000000000..415099d6b --- /dev/null +++ b/packages/control-plane/src/auth/user/providers/github-identity.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it, vi } from "vitest"; +import { GitHubProviderIdentityResolver } from "./github-identity"; + +describe("GitHubProviderIdentityResolver", () => { + const config = { + issuer: "https://github.com", + userAgent: "Open Inspect Test", + }; + + it("rejects a non-canonical GitHub issuer", () => { + expect( + () => + new GitHubProviderIdentityResolver({ + ...config, + issuer: "https://github.com.attacker.example", + }) + ).toThrow(expect.objectContaining({ failure: "invalid_configuration" })); + }); + + it("resolves a verified provider identity from an existing access token", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + id: 583_231, + login: "octocat", + name: "The Octocat", + avatar_url: "https://avatars.example/octocat", + }) + ) + .mockResolvedValueOnce( + Response.json([ + { + email: "Primary@Example.com", + primary: true, + verified: true, + visibility: "private", + }, + { + email: "PRIMARY@example.com", + primary: false, + verified: true, + visibility: null, + }, + { + email: "unverified@example.com", + primary: false, + verified: false, + visibility: null, + }, + ]) + ); + const resolver = new GitHubProviderIdentityResolver(config, { fetch }); + + await expect(resolver.resolveIdentity("ghu-access")).resolves.toEqual({ + provider: "github", + issuer: "https://github.com", + subject: "583231", + login: "octocat", + displayName: "The Octocat", + avatarUrl: "https://avatars.example/octocat", + verifiedEmails: ["primary@example.com"], + primaryEmail: "primary@example.com", + }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("preserves the Workers receiver when using the default global fetch", async () => { + const runtimeFetch = vi.fn(async function ( + this: unknown, + input: RequestInfo | URL + ): Promise { + if (this !== globalThis) { + throw new TypeError("Illegal invocation"); + } + if (String(input) === "https://api.github.com/user") { + return Response.json({ + id: 583_231, + login: "octocat", + name: "The Octocat", + avatar_url: null, + }); + } + return Response.json([ + { + email: "octocat@example.com", + primary: true, + verified: true, + visibility: null, + }, + ]); + }); + vi.stubGlobal("fetch", runtimeFetch); + + try { + const resolver = new GitHubProviderIdentityResolver(config); + + await expect(resolver.resolveIdentity("ghu-access")).resolves.toMatchObject({ + subject: "583231", + primaryEmail: "octocat@example.com", + }); + expect(runtimeFetch).toHaveBeenCalledTimes(2); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("retries one transient GitHub network failure", async () => { + let userAttempts = 0; + const fetch = vi.fn().mockImplementation(async (input) => { + const url = String(input); + if (url === "https://api.github.com/user") { + userAttempts += 1; + if (userAttempts === 1) { + throw new TypeError("temporary network failure"); + } + return Response.json({ + id: 583_231, + login: "octocat", + name: "The Octocat", + avatar_url: null, + }); + } + return Response.json([ + { + email: "octocat@example.com", + primary: true, + verified: true, + visibility: null, + }, + ]); + }); + const resolver = new GitHubProviderIdentityResolver(config, { fetch }); + + await expect(resolver.resolveIdentity("ghu-access")).resolves.toMatchObject({ + subject: "583231", + primaryEmail: "octocat@example.com", + }); + expect(userAttempts).toBe(2); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it("preserves the final network failure for provider diagnostics", async () => { + const cause = new TypeError("persistent network failure"); + const fetch = vi.fn().mockRejectedValue(cause); + const resolver = new GitHubProviderIdentityResolver(config, { fetch }); + + await expect(resolver.resolveIdentity("ghu-access")).rejects.toMatchObject({ + name: "OAuthProviderError", + failure: "provider_unavailable", + cause, + }); + }); + + it("logs a sanitized diagnostic after the network retry is exhausted", async () => { + const cause = new TypeError("persistent network failure"); + const fetch = vi.fn().mockRejectedValue(cause); + const logger = { error: vi.fn() }; + const resolver = new GitHubProviderIdentityResolver(config, { fetch, logger }); + + await expect(resolver.resolveIdentity("ghu-access")).rejects.toMatchObject({ + failure: "provider_unavailable", + }); + expect(logger.error).toHaveBeenCalledWith("GitHub provider request failed", { + event: "auth.github_provider_request_failed", + attempts: 2, + error: cause, + }); + }); + + it("constructs subsequent GitHub email page URLs locally", async () => { + const firstPage = Array.from({ length: 100 }, (_, index) => ({ + email: `unverified-${index}@example.com`, + primary: false, + verified: false, + visibility: null, + })); + const fetch = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + id: 583_231, + login: "octocat", + name: null, + avatar_url: null, + }) + ) + .mockResolvedValueOnce( + Response.json(firstPage, { + headers: { + Link: '; rel="next"', + }, + }) + ) + .mockResolvedValueOnce( + Response.json([ + { + email: "later-page@example.com", + primary: true, + verified: true, + visibility: null, + }, + ]) + ); + const resolver = new GitHubProviderIdentityResolver(config, { fetch }); + + const result = await resolver.resolveIdentity("ghu-access"); + + expect(result.verifiedEmails).toEqual(["later-page@example.com"]); + expect(result.primaryEmail).toBe("later-page@example.com"); + expect(String(fetch.mock.calls[2][0])).toBe( + "https://api.github.com/user/emails?per_page=100&page=2" + ); + }); + + it("bounds GitHub email pagination", async () => { + const fullPage = Array.from({ length: 100 }, (_, index) => ({ + email: `email-${index}@example.com`, + primary: false, + verified: true, + visibility: null, + })); + const fetch = vi.fn().mockImplementation(async (input) => { + if (String(input) === "https://api.github.com/user") { + return Response.json({ id: 583_231, login: "octocat" }); + } + return Response.json(fullPage, { + headers: { + Link: '; rel="next"', + }, + }); + }); + const resolver = new GitHubProviderIdentityResolver(config, { fetch }); + + await expect(resolver.resolveIdentity("ghu-access")).rejects.toMatchObject({ + name: "OAuthProviderError", + failure: "malformed_response", + message: "GitHub email pagination exceeded its limit", + }); + expect(fetch).toHaveBeenCalledTimes(11); + expect(String(fetch.mock.calls[10][0])).toBe( + "https://api.github.com/user/emails?per_page=100&page=10" + ); + }); +}); diff --git a/packages/control-plane/src/auth/user/providers/github-identity.ts b/packages/control-plane/src/auth/user/providers/github-identity.ts new file mode 100644 index 000000000..18e424e77 --- /dev/null +++ b/packages/control-plane/src/auth/user/providers/github-identity.ts @@ -0,0 +1,172 @@ +import { z } from "zod"; +import { createLogger, type Logger } from "../../../logger"; +import { DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS } from "./constants"; +import { assertCanonicalIssuer, OAuthProviderError, type VerifiedProviderIdentity } from "./types"; + +const GITHUB_ISSUER = "https://github.com"; +const GITHUB_API_URL = "https://api.github.com"; +const GITHUB_API_VERSION = "2022-11-28"; +const GITHUB_EMAILS_PER_PAGE = 100; +const GITHUB_EMAILS_MAX_PAGES = 10; +const GITHUB_NETWORK_REQUEST_ATTEMPTS = 2; + +const githubUserSchema = z.object({ + id: z.number().int().positive(), + login: z.string().min(1), + name: z.string().nullable().optional(), + avatar_url: z.url().nullable().optional(), +}); + +const githubEmailSchema = z.object({ + email: z.email(), + primary: z.boolean(), + verified: z.boolean(), + visibility: z.string().nullable(), +}); + +const githubEmailPageSchema = z.array(githubEmailSchema); + +export interface GitHubProviderIdentityResolverConfig { + readonly issuer: string; + readonly userAgent: string; +} + +export interface GitHubProviderIdentityResolverDependencies { + readonly fetch?: typeof globalThis.fetch; + readonly requestTimeoutMs?: number; + readonly logger?: Pick; +} + +/** + * Resolves GitHub identity evidence from an access token exchanged and owned + * by Better Auth. This boundary deliberately implements no OAuth protocol. + */ +export class GitHubProviderIdentityResolver { + private readonly fetchImpl: typeof globalThis.fetch; + private readonly requestTimeoutMs: number; + private readonly logger: Pick; + + constructor( + private readonly config: GitHubProviderIdentityResolverConfig, + dependencies: GitHubProviderIdentityResolverDependencies = {} + ) { + assertCanonicalIssuer(config.issuer, GITHUB_ISSUER); + this.fetchImpl = dependencies.fetch ?? globalThis.fetch.bind(globalThis); + this.requestTimeoutMs = dependencies.requestTimeoutMs ?? DEFAULT_PROVIDER_REQUEST_TIMEOUT_MS; + this.logger = dependencies.logger ?? createLogger("github-provider-identity"); + } + + async resolveIdentity(accessToken: string): Promise> { + const [user, emailEntries] = await Promise.all([ + this.fetchGitHubUser(accessToken), + this.fetchVerifiedEmails(accessToken), + ]); + const verifiedEmailEntries = emailEntries.filter((entry) => entry.verified); + const verifiedEmails = [ + ...new Set(verifiedEmailEntries.map((entry) => entry.email.toLowerCase())), + ]; + return { + provider: "github", + issuer: GITHUB_ISSUER, + subject: String(user.id), + login: user.login, + displayName: user.name ?? user.login, + ...(user.avatar_url ? { avatarUrl: user.avatar_url } : {}), + verifiedEmails, + primaryEmail: + verifiedEmailEntries.find((entry) => entry.primary)?.email.toLowerCase() ?? null, + }; + } + + private async fetchGitHubUser(accessToken: string): Promise> { + const response = await this.fetchWithTimeout(`${GITHUB_API_URL}/user`, { + headers: this.apiHeaders(accessToken), + }); + if (!response.ok) { + throw new OAuthProviderError("provider_unavailable", "GitHub user lookup was not successful"); + } + const parsed = githubUserSchema.safeParse(await this.parseJson(response, "GitHub user")); + if (!parsed.success) { + throw new OAuthProviderError("malformed_response", "GitHub returned an invalid user"); + } + return parsed.data; + } + + private async fetchVerifiedEmails( + accessToken: string + ): Promise>> { + const entries: Array> = []; + + for (let page = 1; page <= GITHUB_EMAILS_MAX_PAGES; page += 1) { + const pageUrl = new URL(`${GITHUB_API_URL}/user/emails`); + pageUrl.searchParams.set("per_page", String(GITHUB_EMAILS_PER_PAGE)); + pageUrl.searchParams.set("page", String(page)); + + const response = await this.fetchWithTimeout(pageUrl, { + headers: this.apiHeaders(accessToken), + }); + if (!response.ok) { + throw new OAuthProviderError( + "provider_unavailable", + "GitHub email lookup was not successful" + ); + } + const parsed = githubEmailPageSchema.safeParse( + await this.parseJson(response, "GitHub emails") + ); + if (!parsed.success) { + throw new OAuthProviderError("malformed_response", "GitHub returned invalid emails"); + } + entries.push(...parsed.data); + + const hasNextPage = response.headers.get("Link")?.includes('rel="next"') ?? false; + if (!hasNextPage) { + return entries; + } + } + throw new OAuthProviderError( + "malformed_response", + "GitHub email pagination exceeded its limit" + ); + } + + private apiHeaders(accessToken: string): HeadersInit { + return { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": GITHUB_API_VERSION, + "User-Agent": this.config.userAgent, + }; + } + + private async parseJson(response: Response, context: string): Promise { + try { + return await response.json(); + } catch { + throw new OAuthProviderError("malformed_response", `${context} response was not JSON`); + } + } + + private async fetchWithTimeout(input: RequestInfo | URL, init: RequestInit): Promise { + let lastCause: unknown; + for (let attempt = 1; attempt <= GITHUB_NETWORK_REQUEST_ATTEMPTS; attempt += 1) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs); + try { + return await this.fetchImpl(input, { ...init, signal: controller.signal }); + } catch (cause) { + lastCause = cause; + } finally { + clearTimeout(timeout); + } + } + this.logger.error("GitHub provider request failed", { + event: "auth.github_provider_request_failed", + attempts: GITHUB_NETWORK_REQUEST_ATTEMPTS, + error: lastCause instanceof Error ? lastCause : new Error(String(lastCause)), + }); + throw new OAuthProviderError("provider_unavailable", "GitHub request failed", { + cause: lastCause, + }); + } +} diff --git a/packages/control-plane/src/auth/user/providers/github-profile.test.ts b/packages/control-plane/src/auth/user/providers/github-profile.test.ts new file mode 100644 index 000000000..92242b3d1 --- /dev/null +++ b/packages/control-plane/src/auth/user/providers/github-profile.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; +import { GitHubSignInProfileResolver } from "./github-profile"; + +describe("GitHubSignInProfileResolver", () => { + it("resolves verified GitHub evidence before admission and profile mapping", async () => { + const resolveIdentity = vi.fn(async () => ({ + provider: "github" as const, + issuer: "https://github.com", + subject: "583231", + login: "octocat", + displayName: "The Octocat", + avatarUrl: "https://github.com/images/error/octocat_happy.gif", + verifiedEmails: ["secondary@example.com", "primary@example.com"], + primaryEmail: "primary@example.com", + })); + const requireAdmission = vi.fn(async () => ({ + reason: "github_user_allowlist" as const, + })); + const resolver = new GitHubSignInProfileResolver({ + identityResolver: { resolveIdentity }, + admissionPolicy: { requireAdmission }, + }); + const accessTokenExpiresAt = new Date("2026-07-27T00:00:00.000Z"); + const refreshTokenExpiresAt = new Date("2026-08-27T00:00:00.000Z"); + + const result = await resolver.getUserInfo({ + accessToken: "github-access-token", + accessTokenExpiresAt, + refreshToken: "github-refresh-token", + refreshTokenExpiresAt, + }); + + expect(resolveIdentity).toHaveBeenCalledWith("github-access-token"); + expect(requireAdmission).toHaveBeenCalledWith({ + identity: { + provider: "github", + issuer: "https://github.com", + subject: "583231", + login: "octocat", + displayName: "The Octocat", + avatarUrl: "https://github.com/images/error/octocat_happy.gif", + verifiedEmails: ["secondary@example.com", "primary@example.com"], + primaryEmail: "primary@example.com", + }, + credential: { + kind: "refreshable", + accessToken: "github-access-token", + accessExpiresAt: accessTokenExpiresAt.getTime(), + refreshToken: "github-refresh-token", + refreshExpiresAt: refreshTokenExpiresAt.getTime(), + }, + }); + expect(result.user).toEqual({ + id: "583231", + name: "The Octocat", + email: "primary@example.com", + image: "https://github.com/images/error/octocat_happy.gif", + emailVerified: true, + }); + }); + + it("rejects missing access-token evidence before lookup or admission", async () => { + const resolveIdentity = vi.fn(); + const requireAdmission = vi.fn(); + const resolver = new GitHubSignInProfileResolver({ + identityResolver: { resolveIdentity }, + admissionPolicy: { requireAdmission }, + }); + + await expect(resolver.getUserInfo({})).rejects.toMatchObject({ + name: "OAuthProviderError", + failure: "malformed_response", + }); + expect(resolveIdentity).not.toHaveBeenCalled(); + expect(requireAdmission).not.toHaveBeenCalled(); + }); + + it("rejects an identity without a verified email before admission", async () => { + const requireAdmission = vi.fn(); + const resolver = new GitHubSignInProfileResolver({ + identityResolver: { + resolveIdentity: vi.fn(async () => ({ + provider: "github" as const, + issuer: "https://github.com", + subject: "583231", + login: "octocat", + verifiedEmails: [], + primaryEmail: null, + })), + }, + admissionPolicy: { requireAdmission }, + }); + + await expect( + resolver.getUserInfo({ accessToken: "github-access-token" }) + ).rejects.toMatchObject({ + name: "OAuthProviderError", + failure: "malformed_response", + }); + expect(requireAdmission).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/auth/user/providers/github-profile.ts b/packages/control-plane/src/auth/user/providers/github-profile.ts new file mode 100644 index 000000000..fc81a3902 --- /dev/null +++ b/packages/control-plane/src/auth/user/providers/github-profile.ts @@ -0,0 +1,89 @@ +import type { AdmissionPolicy } from "../admission-policy"; +import type { ProviderProfile, ProviderTokens } from "../provider-profile"; +import type { ProviderCredentialInput } from "../provider-credential"; +import { + OAuthProviderError, + type ProviderSignInResult, + type VerifiedProviderIdentity, +} from "./types"; + +export interface GitHubIdentityResolver { + resolveIdentity(accessToken: string): Promise>; +} + +export interface GitHubSignInProfileResolverConfig { + readonly identityResolver: GitHubIdentityResolver; + readonly admissionPolicy: Pick; +} + +export class GitHubSignInProfileResolver { + constructor(private readonly config: GitHubSignInProfileResolverConfig) {} + + readonly getUserInfo = async (tokens: ProviderTokens): Promise => { + if (!tokens.accessToken) { + throw new OAuthProviderError("malformed_response", "GitHub did not return an access token"); + } + + const identity = await this.config.identityResolver.resolveIdentity(tokens.accessToken); + const email = identity.primaryEmail ?? identity.verifiedEmails[0]; + if (!email) { + throw new OAuthProviderError("malformed_response", "GitHub did not return a verified email"); + } + + const signIn: ProviderSignInResult<"github"> = { + identity, + credential: toProviderCredential(tokens), + }; + await this.config.admissionPolicy.requireAdmission(signIn); + + return { + user: { + id: identity.subject, + name: identity.displayName ?? identity.login ?? email, + email, + ...(identity.avatarUrl ? { image: identity.avatarUrl } : {}), + emailVerified: true, + }, + data: identity, + }; + }; +} + +function toProviderCredential(tokens: ProviderTokens): ProviderCredentialInput { + const accessToken = tokens.accessToken; + if (!accessToken) { + throw new OAuthProviderError("malformed_response", "GitHub did not return an access token"); + } + if (tokens.refreshToken && !tokens.accessTokenExpiresAt) { + throw new OAuthProviderError( + "malformed_response", + "GitHub returned a refresh token without access expiry" + ); + } + if (tokens.refreshTokenExpiresAt && !tokens.refreshToken) { + throw new OAuthProviderError( + "malformed_response", + "GitHub returned refresh expiry without a refresh token" + ); + } + if (tokens.refreshToken && tokens.accessTokenExpiresAt) { + return { + kind: "refreshable", + accessToken, + accessExpiresAt: tokens.accessTokenExpiresAt.getTime(), + refreshToken: tokens.refreshToken, + refreshExpiresAt: tokens.refreshTokenExpiresAt?.getTime() ?? null, + }; + } + if (tokens.accessTokenExpiresAt) { + return { + kind: "access_only_expiring", + accessToken, + accessExpiresAt: tokens.accessTokenExpiresAt.getTime(), + }; + } + return { + kind: "access_only_nonexpiring", + accessToken, + }; +} diff --git a/packages/control-plane/src/auth/user/providers/google-profile.test.ts b/packages/control-plane/src/auth/user/providers/google-profile.test.ts new file mode 100644 index 000000000..a00f04d3a --- /dev/null +++ b/packages/control-plane/src/auth/user/providers/google-profile.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import { GoogleSignInProfileResolver } from "./google-profile"; + +describe("GoogleSignInProfileResolver", () => { + it("verifies Google claims before admission and profile mapping", async () => { + const verifyIdToken = vi.fn(async () => ({ + iss: "https://accounts.google.com", + sub: "google-subject", + email: "Person@Example.com", + email_verified: true, + name: "Person Example", + picture: "https://example.com/avatar.png", + })); + const requireAdmission = vi.fn(async () => ({ reason: "email_allowlist" as const })); + const resolver = new GoogleSignInProfileResolver( + { + clientId: "google-client-id", + admissionPolicy: { requireAdmission }, + }, + { verifyIdToken } + ); + + const result = await resolver.getUserInfo({ idToken: "signed-google-id-token" }); + + expect(verifyIdToken).toHaveBeenCalledWith({ + token: "signed-google-id-token", + audience: "google-client-id", + }); + expect(requireAdmission).toHaveBeenCalledWith({ + identity: { + provider: "google", + issuer: "https://accounts.google.com", + subject: "google-subject", + displayName: "Person Example", + avatarUrl: "https://example.com/avatar.png", + verifiedEmails: ["person@example.com"], + primaryEmail: "person@example.com", + }, + credential: null, + }); + expect(result.user).toEqual({ + id: "google-subject", + name: "Person Example", + email: "person@example.com", + image: "https://example.com/avatar.png", + emailVerified: true, + }); + }); + + it("rejects an unverifiable ID token before admission", async () => { + const requireAdmission = vi.fn(); + const resolver = new GoogleSignInProfileResolver( + { + clientId: "google-client-id", + admissionPolicy: { requireAdmission }, + }, + { verifyIdToken: vi.fn(async () => null) } + ); + + await expect(resolver.getUserInfo({ idToken: "invalid-id-token" })).rejects.toMatchObject({ + name: "OAuthProviderError", + failure: "malformed_response", + }); + expect(requireAdmission).not.toHaveBeenCalled(); + }); + + it("rejects an unverified provider email before admission", async () => { + const requireAdmission = vi.fn(); + const resolver = new GoogleSignInProfileResolver( + { + clientId: "google-client-id", + admissionPolicy: { requireAdmission }, + }, + { + verifyIdToken: vi.fn(async () => ({ + iss: "https://accounts.google.com", + sub: "google-subject", + email: "person@example.com", + email_verified: false, + })), + } + ); + + await expect(resolver.getUserInfo({ idToken: "signed-id-token" })).rejects.toMatchObject({ + name: "OAuthProviderError", + failure: "malformed_response", + }); + expect(requireAdmission).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/auth/user/providers/google-profile.ts b/packages/control-plane/src/auth/user/providers/google-profile.ts new file mode 100644 index 000000000..ce3a6b89f --- /dev/null +++ b/packages/control-plane/src/auth/user/providers/google-profile.ts @@ -0,0 +1,79 @@ +import { verifyGoogleIdToken } from "better-auth/social-providers"; +import { z } from "zod"; +import type { AdmissionPolicy } from "../admission-policy"; +import type { ProviderProfile, ProviderTokens } from "../provider-profile"; +import { OAuthProviderError, type ProviderSignInResult } from "./types"; + +const GOOGLE_ISSUER = "https://accounts.google.com"; + +const googleClaimsSchema = z.object({ + iss: z.union([z.literal(GOOGLE_ISSUER), z.literal("accounts.google.com")]), + sub: z.string().min(1), + email: z.email(), + email_verified: z.literal(true), + name: z.string().min(1).optional(), + picture: z.url().optional(), +}); + +type VerifyGoogleIdToken = typeof verifyGoogleIdToken; + +export interface GoogleSignInProfileResolverConfig { + readonly clientId: string; + readonly admissionPolicy: Pick; +} + +export interface GoogleSignInProfileResolverDependencies { + readonly verifyIdToken?: VerifyGoogleIdToken; +} + +export class GoogleSignInProfileResolver { + private readonly verifyIdToken: VerifyGoogleIdToken; + + constructor( + private readonly config: GoogleSignInProfileResolverConfig, + dependencies: GoogleSignInProfileResolverDependencies = {} + ) { + this.verifyIdToken = dependencies.verifyIdToken ?? verifyGoogleIdToken; + } + + readonly getUserInfo = async (tokens: ProviderTokens): Promise => { + if (!tokens.idToken) { + throw new OAuthProviderError("malformed_response", "Google did not return an ID token"); + } + + const rawClaims = await this.verifyIdToken({ + token: tokens.idToken, + audience: this.config.clientId, + }); + const parsedClaims = googleClaimsSchema.safeParse(rawClaims); + if (!parsedClaims.success) { + throw new OAuthProviderError("malformed_response", "Google returned an invalid ID token"); + } + + const email = parsedClaims.data.email.toLowerCase(); + const signIn: ProviderSignInResult<"google"> = { + identity: { + provider: "google", + issuer: GOOGLE_ISSUER, + subject: parsedClaims.data.sub, + ...(parsedClaims.data.name ? { displayName: parsedClaims.data.name } : {}), + ...(parsedClaims.data.picture ? { avatarUrl: parsedClaims.data.picture } : {}), + verifiedEmails: [email], + primaryEmail: email, + }, + credential: null, + }; + await this.config.admissionPolicy.requireAdmission(signIn); + + return { + user: { + id: signIn.identity.subject, + name: signIn.identity.displayName ?? email, + email, + ...(signIn.identity.avatarUrl ? { image: signIn.identity.avatarUrl } : {}), + emailVerified: true, + }, + data: parsedClaims.data, + }; + }; +} diff --git a/packages/control-plane/src/auth/user/providers/types.ts b/packages/control-plane/src/auth/user/providers/types.ts new file mode 100644 index 000000000..b40dc3833 --- /dev/null +++ b/packages/control-plane/src/auth/user/providers/types.ts @@ -0,0 +1,57 @@ +import type { SignInProvider } from "../sign-in-provider"; +import type { ProviderCredentialInput } from "../provider-credential"; + +export interface VerifiedProviderIdentity

{ + readonly provider: P; + readonly issuer: string; + readonly subject: string; + readonly login?: string; + readonly displayName?: string; + readonly avatarUrl?: string; + readonly verifiedEmails: readonly string[]; + readonly primaryEmail: string | null; +} + +interface ProviderSignInResultByProvider { + readonly github: { + readonly identity: VerifiedProviderIdentity<"github">; + readonly credential: ProviderCredentialInput; + }; + readonly google: { + readonly identity: VerifiedProviderIdentity<"google">; + readonly credential: null; + }; +} + +export type ProviderSignInResult

= ProviderSignInResultByProvider[P]; + +export type OAuthProviderFailure = + | "invalid_configuration" + | "invalid_request" + | "provider_rejected" + | "provider_unavailable" + | "malformed_response"; + +export class OAuthProviderError extends Error { + constructor( + readonly failure: OAuthProviderFailure, + message: string, + options?: ErrorOptions + ) { + super(message, options); + this.name = "OAuthProviderError"; + } +} + +export function assertCanonicalIssuer(configuredIssuer: string, expectedIssuer: string): void { + let configured: URL; + try { + configured = new URL(configuredIssuer); + } catch { + throw new OAuthProviderError("invalid_configuration", "Provider issuer is invalid"); + } + const expected = new URL(expectedIssuer); + if (configured.href !== expected.href) { + throw new OAuthProviderError("invalid_configuration", "Provider issuer is not canonical"); + } +} diff --git a/packages/control-plane/src/auth/user/runtime.test.ts b/packages/control-plane/src/auth/user/runtime.test.ts new file mode 100644 index 000000000..65f878627 --- /dev/null +++ b/packages/control-plane/src/auth/user/runtime.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { parsePublicWebOrigin } from "./runtime"; + +describe("parsePublicWebOrigin", () => { + it.each([ + ["https://open-inspect.example", "https://open-inspect.example"], + ["https://open-inspect.example/", "https://open-inspect.example"], + ["http://localhost:3000", "http://localhost:3000"], + ["http://127.0.0.1:3000", "http://127.0.0.1:3000"], + ["http://[::1]:3000", "http://[::1]:3000"], + ])("accepts a browser-reachable web origin: %s", (configured, expected) => { + expect(parsePublicWebOrigin(configured)).toBe(expected); + }); + + it.each([ + undefined, + "", + "not-a-url", + "http://open-inspect.example", + "http://localhost.evil.example:3000", + "https://open-inspect.example/path", + "https://open-inspect.example?query=1", + ])("rejects an unsafe or non-origin WEB_APP_URL: %s", (configured) => { + expect(() => parsePublicWebOrigin(configured)).toThrow(); + }); +}); diff --git a/packages/control-plane/src/auth/user/runtime.ts b/packages/control-plane/src/auth/user/runtime.ts new file mode 100644 index 000000000..dcf30628b --- /dev/null +++ b/packages/control-plane/src/auth/user/runtime.ts @@ -0,0 +1,165 @@ +import { + AdmissionPolicy, + parseAdmissionAllowlist, + parseAdmissionBoolean, +} from "./admission-policy"; +import { createUserAuth } from "./better-auth"; +import { GitHubProviderIdentityResolver } from "./providers/github-identity"; +import { GitHubSignInProfileResolver } from "./providers/github-profile"; +import { GoogleSignInProfileResolver } from "./providers/google-profile"; +import { D1CanonicalUserProjection } from "../../db/canonical-user-projection"; +import type { Env } from "../../types"; + +const GITHUB_ISSUER = "https://github.com"; +const MINIMUM_SECRET_LENGTH = 32; + +export class UserAuthConfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = "UserAuthConfigurationError"; + } +} + +function requireConfig(value: string | undefined, name: string): string { + const normalized = value?.trim(); + if (!normalized) { + throw new UserAuthConfigurationError(`${name} is not configured`); + } + return normalized; +} + +function isLoopbackHost(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"; +} + +export function parsePublicWebOrigin(value: string | undefined): string { + const configured = requireConfig(value, "WEB_APP_URL"); + let url: URL; + try { + url = new URL(configured); + } catch { + throw new UserAuthConfigurationError("WEB_APP_URL is invalid"); + } + + const isOriginOnly = + url.username === "" && + url.password === "" && + url.pathname === "/" && + url.search === "" && + url.hash === ""; + const isSecure = url.protocol === "https:"; + const isLocalDevelopment = url.protocol === "http:" && isLoopbackHost(url.hostname); + if (!isOriginOnly || (!isSecure && !isLocalDevelopment)) { + throw new UserAuthConfigurationError( + "WEB_APP_URL must be an HTTPS origin or an HTTP loopback origin" + ); + } + return url.origin; +} + +function createAdmissionPolicy(env: Env): AdmissionPolicy { + return new AdmissionPolicy({ + allowedGitHubUsers: parseAdmissionAllowlist(env.ALLOWED_USERS), + allowedEmails: parseAdmissionAllowlist(env.ALLOWED_EMAILS), + allowedEmailDomains: parseAdmissionAllowlist(env.ALLOWED_EMAIL_DOMAINS), + allowedGitHubOrganizations: parseAdmissionAllowlist(env.ALLOWED_GITHUB_ORGS), + unsafeAllowAllUsers: parseAdmissionBoolean(env.UNSAFE_ALLOW_ALL_USERS), + }); +} + +export function createUserAuthFromEnv(env: Env, database: D1Database) { + const publicWebOrigin = parsePublicWebOrigin(env.WEB_APP_URL); + const secret = requireConfig(env.BROWSER_AUTH_SECRET, "BROWSER_AUTH_SECRET"); + if (secret.length < MINIMUM_SECRET_LENGTH) { + throw new UserAuthConfigurationError( + `BROWSER_AUTH_SECRET must be at least ${MINIMUM_SECRET_LENGTH} characters` + ); + } + + const githubClientId = requireConfig(env.GITHUB_CLIENT_ID, "GITHUB_CLIENT_ID"); + const githubClientSecret = requireConfig(env.GITHUB_CLIENT_SECRET, "GITHUB_CLIENT_SECRET"); + const admissionPolicy = createAdmissionPolicy(env); + const githubIdentityResolver = new GitHubProviderIdentityResolver({ + issuer: GITHUB_ISSUER, + userAgent: `${env.APP_NAME?.trim() || "Open-Inspect"} Control Plane`, + }); + const githubProfile = new GitHubSignInProfileResolver({ + identityResolver: githubIdentityResolver, + admissionPolicy, + }); + + const googleClientId = env.GOOGLE_CLIENT_ID?.trim(); + const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim(); + if (Boolean(googleClientId) !== Boolean(googleClientSecret)) { + throw new UserAuthConfigurationError( + "GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be configured together" + ); + } + + const googleProfile = + googleClientId && googleClientSecret + ? new GoogleSignInProfileResolver({ + clientId: googleClientId, + admissionPolicy, + }) + : null; + + return createUserAuth({ + database, + publicWebOrigin, + secret, + userProjection: new D1CanonicalUserProjection(database), + github: { + clientId: githubClientId, + clientSecret: githubClientSecret, + getUserInfo: githubProfile.getUserInfo, + }, + ...(googleProfile && googleClientId && googleClientSecret + ? { + google: { + clientId: googleClientId, + clientSecret: googleClientSecret, + getUserInfo: googleProfile.getUserInfo, + }, + } + : {}), + }); +} + +type BetterAuthInstance = ReturnType; + +interface CachedUserAuth { + readonly fingerprint: string; + readonly auth: BetterAuthInstance; +} + +const userAuthByDatabase = new WeakMap(); + +function configurationFingerprint(env: Env): string { + return [ + env.WEB_APP_URL, + env.BROWSER_AUTH_SECRET, + env.GITHUB_CLIENT_ID, + env.GITHUB_CLIENT_SECRET, + env.GOOGLE_CLIENT_ID, + env.GOOGLE_CLIENT_SECRET, + env.ALLOWED_USERS, + env.ALLOWED_EMAILS, + env.ALLOWED_EMAIL_DOMAINS, + env.ALLOWED_GITHUB_ORGS, + env.UNSAFE_ALLOW_ALL_USERS, + ].join("\u0000"); +} + +export function getUserAuth(env: Env, database: D1Database): BetterAuthInstance { + const fingerprint = configurationFingerprint(env); + const cached = userAuthByDatabase.get(database); + if (cached?.fingerprint === fingerprint) { + return cached.auth; + } + const auth = createUserAuthFromEnv(env, database); + userAuthByDatabase.set(database, { fingerprint, auth }); + return auth; +} + +export type BetterAuthRuntime = BetterAuthInstance; diff --git a/packages/control-plane/src/auth/user/session-authenticator.test.ts b/packages/control-plane/src/auth/user/session-authenticator.test.ts new file mode 100644 index 000000000..911a28b8b --- /dev/null +++ b/packages/control-plane/src/auth/user/session-authenticator.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; +import { authenticateSession, type SessionReader } from "./session-authenticator"; + +describe("authenticateSession", () => { + it("authenticates a browser session without enumerating provider accounts", async () => { + const sessionReader: SessionReader = { + getSession: vi.fn(async () => ({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1" }, + })), + }; + const headers = new Headers({ Cookie: "openinspect.session_token=session.signature" }); + + await expect(authenticateSession(sessionReader, headers)).resolves.toEqual({ + userId: "user-1", + authentication: { + mechanism: "browser_session", + credentialId: "session-1", + channel: { kind: "sig1", service: "web" }, + }, + }); + expect(sessionReader.getSession).toHaveBeenCalledWith({ + headers, + query: { disableRefresh: true }, + }); + }); + + it("returns null when Better Auth does not resolve a session", async () => { + const sessionReader: SessionReader = { + getSession: vi.fn(async () => null), + }; + + await expect(authenticateSession(sessionReader, new Headers())).resolves.toBeNull(); + }); + + it("rejects a session whose user does not match", async () => { + const sessionReader: SessionReader = { + getSession: vi.fn(async () => ({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "different-user" }, + })), + }; + + await expect(authenticateSession(sessionReader, new Headers())).rejects.toThrow( + "Better Auth returned a cross-user session" + ); + }); +}); diff --git a/packages/control-plane/src/auth/user/session-authenticator.ts b/packages/control-plane/src/auth/user/session-authenticator.ts new file mode 100644 index 000000000..8ff6b8314 --- /dev/null +++ b/packages/control-plane/src/auth/user/session-authenticator.ts @@ -0,0 +1,79 @@ +/** + * Adapts a Better Auth session into the control plane's authentication model. + * + * `authenticate()` calls this module only after validating the `service:web` + * sig1 channel. This adapter delegates opaque session-cookie verification and + * session lookup to Better Auth, verifies that the returned session and user + * agree, and emits provider-independent authentication evidence for the + * control-plane router. It does not perform authorization or resolve the + * provider account originally used to sign in. + * + * Protected resource requests use a non-refreshing session read so validation + * does not extend session expiry or write D1 as a side effect. Browser-facing + * Better Auth endpoints remain responsible for session refresh. + */ + +import { z } from "zod"; +import type { AuthenticationContext } from "../principal"; + +const sessionSchema = z.object({ + session: z.object({ + id: z.string().min(1), + userId: z.string().min(1), + }), + user: z.object({ + id: z.string().min(1), + }), +}); + +export interface AuthenticatedUserSession { + readonly userId: string; + readonly authentication: AuthenticationContext; +} + +export interface SessionReader { + getSession(input: { + readonly headers: Headers; + readonly query: { readonly disableRefresh: true }; + }): Promise; +} + +export class SessionIntegrityError extends Error { + constructor(message: string) { + super(message); + this.name = "SessionIntegrityError"; + } +} + +export async function authenticateSession( + sessionReader: SessionReader, + headers: Headers +): Promise { + // Resource authentication is a read-only hot path, not a session-lifecycle endpoint. + const candidate = await sessionReader.getSession({ + headers, + query: { disableRefresh: true }, + }); + if (candidate === null) return null; + + const parsedSession = sessionSchema.safeParse(candidate); + if (!parsedSession.success) { + throw new SessionIntegrityError("Better Auth returned a malformed session"); + } + const { session, user } = parsedSession.data; + if (session.userId !== user.id) { + throw new SessionIntegrityError("Better Auth returned a cross-user session"); + } + + return { + userId: user.id, + authentication: { + mechanism: "browser_session", + credentialId: session.id, + channel: { + kind: "sig1", + service: "web", + }, + }, + }; +} diff --git a/packages/control-plane/src/auth/sign-in-provider.test.ts b/packages/control-plane/src/auth/user/sign-in-provider.test.ts similarity index 100% rename from packages/control-plane/src/auth/sign-in-provider.test.ts rename to packages/control-plane/src/auth/user/sign-in-provider.test.ts diff --git a/packages/control-plane/src/auth/sign-in-provider.ts b/packages/control-plane/src/auth/user/sign-in-provider.ts similarity index 77% rename from packages/control-plane/src/auth/sign-in-provider.ts rename to packages/control-plane/src/auth/user/sign-in-provider.ts index c1263b783..31c3bbb2a 100644 --- a/packages/control-plane/src/auth/sign-in-provider.ts +++ b/packages/control-plane/src/auth/user/sign-in-provider.ts @@ -1,4 +1,4 @@ -/** Browser sign-in providers with executable authentication adapters. */ +/** Sign-in providers with executable authentication adapters. */ export const SIGN_IN_PROVIDERS = ["github", "google"] as const; export type SignInProvider = (typeof SIGN_IN_PROVIDERS)[number]; diff --git a/packages/control-plane/src/auth/web-session-tokens.test.ts b/packages/control-plane/src/auth/web-session-tokens.test.ts deleted file mode 100644 index 76a695471..000000000 --- a/packages/control-plane/src/auth/web-session-tokens.test.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; - -import type { ApiTokenRow, NewApiToken, WebSessionTokenStore } from "../db/api-tokens"; -import { - ACCESS_TOKEN_PREFIX, - REFRESH_REUSE_GRACE_MS, - REFRESH_TOKEN_PREFIX, - WEB_SESSION_FAMILY_TTL_MS, - WEB_SESSION_REFRESH_TTL_MS, - WEB_SESSION_TOKEN_TTL_MS, - WebSessionTokenService, -} from "./web-session-tokens"; - -/** In-memory ApiTokenStore double with the same semantics as the D1 store. */ -class FakeApiTokenStore implements WebSessionTokenStore { - rows = new Map(); - private nextId = 0; - - async createPair(tokens: [NewApiToken, NewApiToken]): Promise<[string, string]> { - const ids = tokens.map((token) => { - const id = `token-${this.nextId++}`; - this.rows.set(id, { - id, - tokenHash: token.tokenHash, - kind: token.kind, - userId: token.userId, - provider: token.provider, - providerUserId: token.providerUserId, - familyId: token.familyId, - rotatedTo: null, - createdAt: Date.now(), - expiresAt: token.expiresAt, - familyExpiresAt: token.familyExpiresAt, - revokedAt: null, - lastUsedAt: null, - }); - return id; - }); - return ids as [string, string]; - } - - async getByHash(tokenHash: string): Promise { - for (const row of this.rows.values()) { - if (row.tokenHash === tokenHash) return { ...row }; - } - return null; - } - - async getById(id: string): Promise { - const row = this.rows.get(id); - return row ? { ...row } : null; - } - - async consumeRefreshToken(id: string, successorId: string): Promise { - const row = this.rows.get(id); - if (!row || row.rotatedTo !== null || row.revokedAt !== null) return false; - row.rotatedTo = successorId; - return true; - } - - async revokeFamily(familyId: string): Promise { - for (const row of this.rows.values()) { - if (row.familyId === familyId && row.revokedAt === null) { - row.revokedAt = Date.now(); - } - } - } - - async revokeToken(id: string): Promise { - const row = this.rows.get(id); - if (row && row.revokedAt === null) row.revokedAt = Date.now(); - } -} - -const SUBJECT = { provider: "github" as const, providerUserId: "424242" }; - -function createService(): { service: WebSessionTokenService; store: FakeApiTokenStore } { - const store = new FakeApiTokenStore(); - return { service: new WebSessionTokenService(store), store }; -} - -afterEach(() => { - vi.useRealTimers(); -}); - -describe("mintPair", () => { - it("mints prefixed opaque tokens with hash-at-rest storage", async () => { - const { service, store } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - - expect(pair.accessToken).toMatch(new RegExp(`^${ACCESS_TOKEN_PREFIX}[A-Za-z0-9_-]{43}$`)); - expect(pair.refreshToken).toMatch(new RegExp(`^${REFRESH_TOKEN_PREFIX}[A-Za-z0-9_-]{43}$`)); - - const rows = [...store.rows.values()]; - expect(rows).toHaveLength(2); - for (const row of rows) { - expect(row.tokenHash).toMatch(/^[0-9a-f]{64}$/); - expect(row.tokenHash).not.toContain(pair.accessToken); - expect(row.userId).toBe("user-1"); - expect(row.provider).toBe("github"); - expect(row.providerUserId).toBe("424242"); - } - const [access, refresh] = rows; - expect(access.kind).toBe("web_session"); - expect(refresh.kind).toBe("web_session_refresh"); - expect(access.familyId).toBe(refresh.familyId); - }); - - it("applies the access, refresh, and family TTLs", async () => { - vi.useFakeTimers({ now: 1_000_000 }); - const { service } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - expect(pair.accessTokenExpiresAtEpochMs).toBe(1_000_000 + WEB_SESSION_TOKEN_TTL_MS); - expect(pair.refreshTokenExpiresAtEpochMs).toBe(1_000_000 + WEB_SESSION_REFRESH_TTL_MS); - }); -}); - -describe("verifyAccessToken", () => { - it("verifies a freshly minted token", async () => { - const { service } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - const result = await service.verifyAccessToken(pair.accessToken); - expect(result).toMatchObject({ - ok: true, - userId: "user-1", - provider: "github", - providerUserId: "424242", - }); - }); - - it("rejects unknown tokens and refresh tokens presented as access tokens", async () => { - const { service } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - expect(await service.verifyAccessToken("oi_at_nonexistent")).toEqual({ - ok: false, - failure: "unknown", - }); - expect(await service.verifyAccessToken(pair.refreshToken)).toEqual({ - ok: false, - failure: "unknown", - }); - }); - - it("rejects expired tokens", async () => { - vi.useFakeTimers({ now: 1_000_000 }); - const { service } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - vi.setSystemTime(1_000_000 + WEB_SESSION_TOKEN_TTL_MS + 1); - expect(await service.verifyAccessToken(pair.accessToken)).toEqual({ - ok: false, - failure: "expired", - }); - }); - - it("rejects revoked tokens", async () => { - const { service, store } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - const accessRow = [...store.rows.values()].find((r) => r.kind === "web_session")!; - await store.revokeToken(accessRow.id); - expect(await service.verifyAccessToken(pair.accessToken)).toEqual({ - ok: false, - failure: "revoked", - }); - }); - - it("fails closed on rows missing the minted subject/family shape", async () => { - const { service, store } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - const accessRow = [...store.rows.values()].find((r) => r.kind === "web_session")!; - accessRow.familyId = null; - expect(await service.verifyAccessToken(pair.accessToken)).toEqual({ - ok: false, - failure: "unknown", - }); - }); -}); - -describe("redeemRefreshToken", () => { - it("rotates: mints a new pair in the same family and consumes the old token", async () => { - const { service, store } = createService(); - const first = await service.mintPair("user-1", SUBJECT); - const result = await service.redeemRefreshToken(first.refreshToken); - - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.pair.accessToken).not.toBe(first.accessToken); - expect((await service.verifyAccessToken(result.pair.accessToken)).ok).toBe(true); - - const families = new Set([...store.rows.values()].map((r) => r.familyId)); - expect(families.size).toBe(1); - const oldRefresh = [...store.rows.values()].find( - (r) => r.kind === "web_session_refresh" && r.rotatedTo !== null - ); - expect(oldRefresh).toBeDefined(); - }); - - it("caps the rotated leaf's expiry at the family cap", async () => { - vi.useFakeTimers({ now: 1_000_000 }); - const { service } = createService(); - const first = await service.mintPair("user-1", SUBJECT); - // Advance close to the family cap: a fresh 30d leaf would overshoot it. - vi.setSystemTime(1_000_000 + WEB_SESSION_FAMILY_TTL_MS - 1000); - const result = await service.redeemRefreshToken(first.refreshToken); - expect(result.ok).toBe(false); - if (result.ok) return; - // The original leaf itself expired (30d < 90d elapsed) — invalid, not reuse. - expect(result.failure).toBe("invalid_refresh_token"); - }); - - it("caps a mid-family rotation at family_expires_at", async () => { - vi.useFakeTimers({ now: 1_000_000 }); - const DAY_MS = 24 * 60 * 60 * 1000; - const { service } = createService(); - let latest = await service.mintPair("user-1", SUBJECT); - // Rotate at 29d and 58d (each leaf still valid), then at 61d, where a - // fresh 30d leaf would outlive the 90d family cap and must be truncated. - for (const day of [29, 58, 61]) { - vi.setSystemTime(1_000_000 + day * DAY_MS); - const result = await service.redeemRefreshToken(latest.refreshToken); - expect(result.ok, `rotation at day ${day}`).toBe(true); - if (!result.ok) return; - latest = result.pair; - } - expect(latest.refreshTokenExpiresAtEpochMs).toBe(1_000_000 + WEB_SESSION_FAMILY_TTL_MS); - }); - - it("clamps the rotated access token to the family cap, not just the refresh leaf", async () => { - vi.useFakeTimers({ now: 1_000_000 }); - const DAY_MS = 24 * 60 * 60 * 1000; - const { service } = createService(); - let latest = await service.mintPair("user-1", SUBJECT); - const familyCap = 1_000_000 + WEB_SESSION_FAMILY_TTL_MS; - // Keep the family alive up to the last few hours, then rotate 4h before the - // 90d cap — inside the 8h access window, so a fresh access leaf would - // otherwise outlive the family's absolute deadline by ~4h. - const rotations = [ - 1_000_000 + 29 * DAY_MS, - 1_000_000 + 58 * DAY_MS, - 1_000_000 + 87 * DAY_MS, - familyCap - 4 * 60 * 60 * 1000, - ]; - for (const at of rotations) { - vi.setSystemTime(at); - const result = await service.redeemRefreshToken(latest.refreshToken); - expect(result.ok, `rotation at ${at}`).toBe(true); - if (!result.ok) return; - latest = result.pair; - } - // Both leaves stop at the cap — the access token never survives the family. - expect(latest.accessTokenExpiresAtEpochMs).toBe(familyCap); - expect(latest.refreshTokenExpiresAtEpochMs).toBe(familyCap); - // And that cap is sooner than a naive now+8h would have landed. - expect(latest.accessTokenExpiresAtEpochMs).toBeLessThan( - familyCap - 4 * 60 * 60 * 1000 + WEB_SESSION_TOKEN_TTL_MS - ); - }); - - it("tolerates replay within the grace window without revoking the family", async () => { - vi.useFakeTimers({ now: 1_000_000 }); - const { service } = createService(); - const first = await service.mintPair("user-1", SUBJECT); - const rotated = await service.redeemRefreshToken(first.refreshToken); - expect(rotated.ok).toBe(true); - if (!rotated.ok) return; - - // A concurrent renewal that lost the race replays the consumed token - // almost immediately: superseded (NOT a dead grant), rotated pair live. - vi.setSystemTime(1_000_000 + 5_000); - const replay = await service.redeemRefreshToken(first.refreshToken); - expect(replay).toEqual({ - ok: false, - failure: "refresh_superseded", - familyId: expect.any(String), - }); - expect((await service.verifyAccessToken(rotated.pair.accessToken)).ok).toBe(true); - }); - - it("detects reuse after the grace window and revokes the whole family", async () => { - vi.useFakeTimers({ now: 1_000_000 }); - const { service } = createService(); - const first = await service.mintPair("user-1", SUBJECT); - const rotated = await service.redeemRefreshToken(first.refreshToken); - expect(rotated.ok).toBe(true); - if (!rotated.ok) return; - - vi.setSystemTime(1_000_000 + REFRESH_REUSE_GRACE_MS + 1000); - const replay = await service.redeemRefreshToken(first.refreshToken); - expect(replay).toEqual({ - ok: false, - failure: "refresh_reuse_detected", - familyId: expect.any(String), - }); - - // Family revocation kills the live pair minted by the legitimate rotation. - expect(await service.verifyAccessToken(rotated.pair.accessToken)).toEqual({ - ok: false, - failure: "revoked", - }); - expect(await service.redeemRefreshToken(rotated.pair.refreshToken)).toEqual({ - ok: false, - failure: "invalid_refresh_token", - familyId: expect.any(String), - }); - }); - - it("treats losing the consume race as benign and revokes only the orphaned pair", async () => { - const { service, store } = createService(); - const first = await service.mintPair("user-1", SUBJECT); - const originalConsume = store.consumeRefreshToken.bind(store); - store.consumeRefreshToken = async () => false; - const result = await service.redeemRefreshToken(first.refreshToken); - store.consumeRefreshToken = originalConsume; - expect(result).toEqual({ - ok: false, - failure: "refresh_superseded", - familyId: expect.any(String), - }); - - // Only the loser's freshly minted pair is revoked; the original refresh - // token row (the presumed race winner's input) is untouched. - const revoked = [...store.rows.values()].filter((r) => r.revokedAt !== null); - expect(revoked).toHaveLength(2); - const firstRefreshHashRow = [...store.rows.values()].find( - (r) => r.kind === "web_session_refresh" && r.revokedAt === null && r.rotatedTo === null - ); - expect(firstRefreshHashRow).toBeDefined(); - }); - - it("rejects unknown, revoked, and access tokens", async () => { - const { service, store } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - expect(await service.redeemRefreshToken("oi_rt_nonexistent")).toEqual({ - ok: false, - failure: "invalid_refresh_token", - familyId: null, - }); - expect(await service.redeemRefreshToken(pair.accessToken)).toEqual({ - ok: false, - failure: "invalid_refresh_token", - familyId: null, - }); - const refreshRow = [...store.rows.values()].find((r) => r.kind === "web_session_refresh")!; - await store.revokeToken(refreshRow.id); - expect(await service.redeemRefreshToken(pair.refreshToken)).toEqual({ - ok: false, - failure: "invalid_refresh_token", - familyId: expect.any(String), - }); - }); - - it("fails closed on refresh rows missing the minted subject/family shape", async () => { - const { service, store } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - const refreshRow = [...store.rows.values()].find((r) => r.kind === "web_session_refresh")!; - refreshRow.provider = "not-a-web-provider"; - expect(await service.redeemRefreshToken(pair.refreshToken)).toEqual({ - ok: false, - failure: "invalid_refresh_token", - familyId: null, - }); - }); - - it("fails closed on refresh rows missing the family cap", async () => { - const { service, store } = createService(); - const pair = await service.mintPair("user-1", SUBJECT); - const refreshRow = [...store.rows.values()].find((r) => r.kind === "web_session_refresh")!; - refreshRow.familyExpiresAt = null; - expect(await service.redeemRefreshToken(pair.refreshToken)).toEqual({ - ok: false, - failure: "invalid_refresh_token", - familyId: expect.any(String), - }); - }); -}); diff --git a/packages/control-plane/src/auth/web-session-tokens.ts b/packages/control-plane/src/auth/web-session-tokens.ts deleted file mode 100644 index 95283c33b..000000000 --- a/packages/control-plane/src/auth/web-session-tokens.ts +++ /dev/null @@ -1,249 +0,0 @@ -/** - * Web session tokens (`oi_at_`) and their rotating refresh tokens (`oi_rt_`). - * - * Minted only by the provider-verified exchange; opaque, - * hash-at-rest, individually revocable. Renewal is a refresh grant with - * rotation — redeeming a refresh token mints a new pair and consumes the old - * one, and reuse of a consumed token revokes its whole family. - * - * Reuse within REFRESH_REUSE_GRACE_MS of the original rotation is treated as - * a benign concurrent renewal (NextAuth's jwt callback runs in contexts that - * cannot all persist the rotated cookie), rejected without family revocation. - * Reuse after the grace window is the attack signal and revokes the family. - */ - -import { generateId, hashToken } from "./crypto"; -import { base64UrlEncode } from "./encoding"; -import { isSignInProvider, type SignInProvider } from "./sign-in-provider"; -import type { ApiTokenRow, WebSessionTokenStore } from "../db/api-tokens"; - -export const WEB_SESSION_TOKEN_TTL_MS = 8 * 60 * 60 * 1000; -export const WEB_SESSION_REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000; -export const WEB_SESSION_FAMILY_TTL_MS = 90 * 24 * 60 * 60 * 1000; -export const REFRESH_REUSE_GRACE_MS = 60 * 1000; - -export const ACCESS_TOKEN_PREFIX = "oi_at_"; -export const REFRESH_TOKEN_PREFIX = "oi_rt_"; - -/** The provider-verified subject a token pair was minted for. */ -export interface TokenSubject { - provider: SignInProvider; - providerUserId: string; -} - -export interface WebSessionTokenPair { - accessToken: string; - accessTokenExpiresAtEpochMs: number; - refreshToken: string; - refreshTokenExpiresAtEpochMs: number; -} - -export type AccessTokenVerification = - | { - ok: true; - tokenId: string; - userId: string; - provider: SignInProvider; - providerUserId: string; - } - | { ok: false; failure: "unknown" | "expired" | "revoked" }; - -export type RefreshRedemption = - | { ok: true; pair: WebSessionTokenPair; userId: string; familyId: string } - | { - ok: false; - /** - * `refresh_superseded`: a benign concurrent renewal already rotated - * this token (grace-window replay or a lost consume race) — the - * winner's pair is live and the caller must NOT treat the grant as - * dead. `invalid_refresh_token`: the grant is genuinely dead (unknown, - * revoked, expired, or family-expired). `refresh_reuse_detected`: - * replay outside the grace window — the theft signal; the family has - * been revoked. The distinction is made HERE, where the row state is - * known — callers must never infer it from access-token freshness. - */ - failure: "invalid_refresh_token" | "refresh_superseded" | "refresh_reuse_detected"; - /** The rotation family when the presented token resolved to a row. */ - familyId: string | null; - }; - -/** 32 random bytes as unpadded base64url — the opaque token body. */ -function randomTokenBody(): string { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return base64UrlEncode(bytes); -} - -/** - * An api_tokens row as this service mints it: subject and family columns are - * always populated (they are nullable in the schema only for future P2 token - * kinds). - */ -interface WebSessionRow extends ApiTokenRow { - provider: SignInProvider; - providerUserId: string; - familyId: string; -} - -/** - * Narrow a raw row to the shape this service mints — fail closed: a row - * missing the verified subject or its rotation family was not minted by this - * service in this shape, so the presented token is not a valid web session - * token. - */ -function isWebSessionRow(row: ApiTokenRow): row is WebSessionRow { - return isSignInProvider(row.provider) && row.providerUserId !== null && row.familyId !== null; -} - -export class WebSessionTokenService { - constructor(private readonly store: WebSessionTokenStore) {} - - /** Mint a fresh pair in a new rotation family (exchange path). */ - async mintPair(userId: string, subject: TokenSubject): Promise { - const familyId = generateId(); - const familyExpiresAt = Date.now() + WEB_SESSION_FAMILY_TTL_MS; - const minted = await this.mintPairInFamily(userId, subject, familyId, familyExpiresAt); - return minted.pair; - } - - private async mintPairInFamily( - userId: string, - subject: TokenSubject, - familyId: string, - familyExpiresAt: number - ): Promise<{ pair: WebSessionTokenPair; accessTokenId: string; refreshTokenId: string }> { - const now = Date.now(); - const accessToken = `${ACCESS_TOKEN_PREFIX}${randomTokenBody()}`; - const refreshToken = `${REFRESH_TOKEN_PREFIX}${randomTokenBody()}`; - // Both leaves are clamped to the family cap: the family's absolute lifetime - // is the ceiling for everything it mints, so a rotation near the deadline - // must not hand out an access token that outlives the family it belongs to. - const accessTokenExpiresAtEpochMs = Math.min(now + WEB_SESSION_TOKEN_TTL_MS, familyExpiresAt); - const refreshTokenExpiresAtEpochMs = Math.min( - now + WEB_SESSION_REFRESH_TTL_MS, - familyExpiresAt - ); - - const [accessHash, refreshHash] = await Promise.all([ - hashToken(accessToken), - hashToken(refreshToken), - ]); - const [accessTokenId, refreshTokenId] = await this.store.createPair([ - { - tokenHash: accessHash, - kind: "web_session", - userId, - provider: subject.provider, - providerUserId: subject.providerUserId, - familyId, - expiresAt: accessTokenExpiresAtEpochMs, - familyExpiresAt: null, - }, - { - tokenHash: refreshHash, - kind: "web_session_refresh", - userId, - provider: subject.provider, - providerUserId: subject.providerUserId, - familyId, - expiresAt: refreshTokenExpiresAtEpochMs, - familyExpiresAt, - }, - ]); - - return { - pair: { - accessToken, - accessTokenExpiresAtEpochMs, - refreshToken, - refreshTokenExpiresAtEpochMs, - }, - accessTokenId, - refreshTokenId, - }; - } - - async verifyAccessToken(token: string): Promise { - const row = await this.store.getByHash(await hashToken(token)); - if (!row || row.kind !== "web_session") { - return { ok: false, failure: "unknown" }; - } - if (!isWebSessionRow(row)) { - return { ok: false, failure: "unknown" }; - } - if (row.revokedAt !== null) { - return { ok: false, failure: "revoked" }; - } - if (row.expiresAt <= Date.now()) { - return { ok: false, failure: "expired" }; - } - return { - ok: true, - tokenId: row.id, - userId: row.userId, - provider: row.provider, - providerUserId: row.providerUserId, - }; - } - - /** - * Redeem a refresh token for a new pair, consuming it. Reuse of a consumed - * token — or losing the consume race — revokes the whole family. - */ - async redeemRefreshToken(token: string): Promise { - const row = await this.store.getByHash(await hashToken(token)); - if (!row || row.kind !== "web_session_refresh") { - return { ok: false, failure: "invalid_refresh_token", familyId: null }; - } - if (!isWebSessionRow(row)) { - return { ok: false, failure: "invalid_refresh_token", familyId: null }; - } - // Ordering is load-bearing: the replay (rotatedTo) check runs BEFORE the - // revoked/expired checks so that reuse of a consumed-and-since-expired - // token still counts as the attack signal and revokes the family. - if (row.rotatedTo !== null) { - // Replay of an already-consumed token. Within the grace window this is - // a benign concurrent renewal — reject without a new pair, but leave - // the family alive. Beyond it, assume the family is compromised. - const successor = await this.store.getById(row.rotatedTo); - if (successor !== null && Date.now() - successor.createdAt <= REFRESH_REUSE_GRACE_MS) { - return { ok: false, failure: "refresh_superseded", familyId: row.familyId }; - } - await this.store.revokeFamily(row.familyId); - return { ok: false, failure: "refresh_reuse_detected", familyId: row.familyId }; - } - const now = Date.now(); - // A null familyExpiresAt is rejected fail-closed: this service always - // stamps the family cap on refresh rows it mints, so a row without one - // was not minted here and must not seed a fresh rotation family. - if ( - row.revokedAt !== null || - row.expiresAt <= now || - row.familyExpiresAt === null || - row.familyExpiresAt <= now - ) { - return { ok: false, failure: "invalid_refresh_token", familyId: row.familyId }; - } - - const minted = await this.mintPairInFamily( - row.userId, - { provider: row.provider, providerUserId: row.providerUserId }, - row.familyId, - row.familyExpiresAt - ); - - const consumed = await this.store.consumeRefreshToken(row.id, minted.refreshTokenId); - if (!consumed) { - // Lost a concurrent redeem race — by definition within the grace - // window. Revoke only the orphaned pair this call minted; the race - // winner's pair stays live. - await Promise.all([ - this.store.revokeToken(minted.accessTokenId), - this.store.revokeToken(minted.refreshTokenId), - ]); - return { ok: false, failure: "refresh_superseded", familyId: row.familyId }; - } - - return { ok: true, pair: minted.pair, userId: row.userId, familyId: row.familyId }; - } -} diff --git a/packages/control-plane/src/db/api-tokens.test.ts b/packages/control-plane/src/db/api-tokens.test.ts deleted file mode 100644 index c2bad6b42..000000000 --- a/packages/control-plane/src/db/api-tokens.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { ApiTokenStore, EXPIRED_TOKEN_RETENTION_MS } from "./api-tokens"; -import type { SqlDatabase, SqlResult, SqlStatement } from "./sql-database"; - -class RecordingStatement implements SqlStatement { - boundValues: unknown[] = []; - - constructor( - readonly query: string, - private readonly changes: number - ) {} - - bind(...values: unknown[]): SqlStatement { - this.boundValues = values; - return this; - } - - async first>(): Promise { - return null; - } - - async run>(): Promise> { - return { results: [], meta: { changes: this.changes } }; - } - - async all>(): Promise> { - return { results: [], meta: { changes: 0 } }; - } -} - -class RecordingDatabase implements SqlDatabase { - statements: RecordingStatement[] = []; - - constructor(private readonly changes: number) {} - - prepare(query: string): SqlStatement { - const statement = new RecordingStatement(query, this.changes); - this.statements.push(statement); - return statement; - } - - async batch(statements: SqlStatement[]): Promise[]> { - return Promise.all(statements.map((statement) => statement.run())); - } -} - -describe("deleteExpired", () => { - it("sweeps access rows by expires_at and family rows by family_expires_at", async () => { - const db = new RecordingDatabase(3); - const store = new ApiTokenStore(db); - const now = 1_750_000_000_000; - - const deleted = await store.deleteExpired(now); - - expect(deleted).toBe(6); - // Bare-column predicates on purpose: anything fancier skips the plain - // indexes (migrations 0044/0045). - expect(db.statements.map((statement) => statement.query)).toEqual([ - "DELETE FROM api_tokens WHERE family_expires_at IS NULL AND expires_at <= ?", - "DELETE FROM api_tokens WHERE family_expires_at <= ?", - ]); - for (const statement of db.statements) { - expect(statement.boundValues).toEqual([now - EXPIRED_TOKEN_RETENTION_MS]); - } - }); - - it("returns 0 when nothing is past the retention window", async () => { - const store = new ApiTokenStore(new RecordingDatabase(0)); - expect(await store.deleteExpired(Date.now())).toBe(0); - }); -}); diff --git a/packages/control-plane/src/db/api-tokens.ts b/packages/control-plane/src/db/api-tokens.ts deleted file mode 100644 index a102aea7c..000000000 --- a/packages/control-plane/src/db/api-tokens.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Store for CP-issued opaque credentials (`api_tokens`): web session tokens - * and their rotating refresh tokens. Rows hold SHA-256 hashes — plaintext - * tokens never reach storage or logs. - */ - -import { generateId } from "../auth/crypto"; -import type { SqlDatabase } from "./sql-database"; - -export type ApiTokenKind = "web_session" | "web_session_refresh"; - -export interface ApiTokenRow { - id: string; - tokenHash: string; - kind: ApiTokenKind; - userId: string; - provider: string | null; - providerUserId: string | null; - familyId: string | null; - rotatedTo: string | null; - createdAt: number; - expiresAt: number; - familyExpiresAt: number | null; - revokedAt: number | null; - lastUsedAt: number | null; -} - -export interface NewApiToken { - tokenHash: string; - kind: ApiTokenKind; - userId: string; - provider: string; - providerUserId: string; - familyId: string; - expiresAt: number; - familyExpiresAt: number | null; -} - -/** - * The store surface WebSessionTokenService consumes (engine-neutral, like - * SqlDatabase). ApiTokenStore is the D1 implementation; test doubles declare - * this interface so their conformance is compiler-checked. - */ -export interface WebSessionTokenStore { - createPair(tokens: [NewApiToken, NewApiToken]): Promise<[string, string]>; - getByHash(tokenHash: string): Promise; - getById(id: string): Promise; - consumeRefreshToken(id: string, successorId: string): Promise; - revokeFamily(familyId: string): Promise; - revokeToken(id: string): Promise; -} - -/** - * How long past a row's retention anchor — `expires_at` for access tokens, - * `family_expires_at` for family-scoped refresh rows — the sweep waits before - * deleting it. Generous compared to REFRESH_REUSE_GRACE_MS: the grace check - * resolves a rotated token's successor by id, so both rows must outlive the - * window — a day past the anchor, nothing can still legitimately reference - * the row. - */ -export const EXPIRED_TOKEN_RETENTION_MS = 24 * 60 * 60 * 1000; - -interface ApiTokenDbRow { - id: string; - token_hash: string; - kind: ApiTokenKind; - user_id: string; - provider: string | null; - provider_user_id: string | null; - family_id: string | null; - rotated_to: string | null; - created_at: number; - expires_at: number; - family_expires_at: number | null; - revoked_at: number | null; - last_used_at: number | null; -} - -function toApiTokenRow(row: ApiTokenDbRow): ApiTokenRow { - return { - id: row.id, - tokenHash: row.token_hash, - kind: row.kind, - userId: row.user_id, - provider: row.provider, - providerUserId: row.provider_user_id, - familyId: row.family_id, - rotatedTo: row.rotated_to, - createdAt: row.created_at, - expiresAt: row.expires_at, - familyExpiresAt: row.family_expires_at, - revokedAt: row.revoked_at, - lastUsedAt: row.last_used_at, - }; -} - -const INSERT_TOKEN_SQL = `INSERT INTO api_tokens - (id, token_hash, kind, user_id, provider, provider_user_id, family_id, created_at, expires_at, family_expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; - -export class ApiTokenStore implements WebSessionTokenStore { - constructor(private readonly db: SqlDatabase) {} - - /** - * Insert an access/refresh token pair atomically. Returns the generated row - * ids in input order. - */ - async createPair(tokens: [NewApiToken, NewApiToken]): Promise<[string, string]> { - const now = Date.now(); - const ids: [string, string] = [generateId(), generateId()]; - await this.db.batch( - tokens.map((token, i) => - this.db - .prepare(INSERT_TOKEN_SQL) - .bind( - ids[i], - token.tokenHash, - token.kind, - token.userId, - token.provider, - token.providerUserId, - token.familyId, - now, - token.expiresAt, - token.familyExpiresAt - ) - ) - ); - return ids; - } - - async getByHash(tokenHash: string): Promise { - const row = await this.db - .prepare("SELECT * FROM api_tokens WHERE token_hash = ?") - .bind(tokenHash) - .first(); - return row ? toApiTokenRow(row) : null; - } - - async getById(id: string): Promise { - const row = await this.db - .prepare("SELECT * FROM api_tokens WHERE id = ?") - .bind(id) - .first(); - return row ? toApiTokenRow(row) : null; - } - - /** - * Mark a refresh token consumed by its successor. Compare-and-set: returns - * false when the token was already consumed or revoked (a concurrent redeem - * or a replay), in which case the caller must treat the redeem as reuse. - */ - async consumeRefreshToken(id: string, successorId: string): Promise { - const result = await this.db - .prepare( - "UPDATE api_tokens SET rotated_to = ? WHERE id = ? AND rotated_to IS NULL AND revoked_at IS NULL" - ) - .bind(successorId, id) - .run(); - return (result.meta?.changes ?? 0) > 0; - } - - /** Revoke every token in a rotation family (refresh-reuse response). */ - async revokeFamily(familyId: string): Promise { - await this.db - .prepare("UPDATE api_tokens SET revoked_at = ? WHERE family_id = ? AND revoked_at IS NULL") - .bind(Date.now(), familyId) - .run(); - } - - async revokeToken(id: string): Promise { - await this.db - .prepare("UPDATE api_tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL") - .bind(Date.now(), id) - .run(); - } - - /** Best-effort usage stamp; callers run it via waitUntil, never awaited inline. */ - async touchLastUsed(id: string): Promise { - await this.db - .prepare("UPDATE api_tokens SET last_used_at = ? WHERE id = ?") - .bind(Date.now(), id) - .run(); - } - - /** - * Retention sweep. Rows without family scope (access tokens) go - * EXPIRED_TOKEN_RETENTION_MS past their own expiry. Family-scoped refresh - * rows are kept until the same margin past `family_expires_at`: a consumed - * ancestor must stay resolvable for the family's whole lifetime so a late - * replay still reads as reuse (revoking the family) rather than as an - * unknown token. Bare-column comparisons on purpose — anything fancier - * skips the plain indexes (migrations 0044/0045; see the 0024 lesson). - * NULL family_expires_at never satisfies `<=`, so the second delete only - * touches family-scoped rows. Returns the number of rows deleted. - */ - async deleteExpired(now: number): Promise { - const cutoff = now - EXPIRED_TOKEN_RETENTION_MS; - const results = await this.db.batch([ - this.db - .prepare("DELETE FROM api_tokens WHERE family_expires_at IS NULL AND expires_at <= ?") - .bind(cutoff), - this.db.prepare("DELETE FROM api_tokens WHERE family_expires_at <= ?").bind(cutoff), - ]); - return results.reduce((sum, result) => sum + (result.meta?.changes ?? 0), 0); - } -} diff --git a/packages/control-plane/src/db/browser-auth-legacy-migration.test.ts b/packages/control-plane/src/db/browser-auth-legacy-migration.test.ts new file mode 100644 index 000000000..9f515cf7e --- /dev/null +++ b/packages/control-plane/src/db/browser-auth-legacy-migration.test.ts @@ -0,0 +1,179 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { DatabaseSync } from "node:sqlite"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const MIGRATIONS_DIRECTORY = fileURLToPath( + new URL("../../../../terraform/d1/migrations/", import.meta.url) +); +const BACKFILL_MIGRATION = "0049_backfill_better_auth_accounts.sql"; + +function applyMigrationsBeforeBackfill(db: DatabaseSync): void { + const migrationFiles = readdirSync(MIGRATIONS_DIRECTORY) + .filter((file) => /^\d{4}_.+\.sql$/.test(file) && file < BACKFILL_MIGRATION) + .sort(); + + for (const migrationFile of migrationFiles) { + db.exec(readFileSync(`${MIGRATIONS_DIRECTORY}/${migrationFile}`, "utf8")); + } +} + +describe("Better Auth legacy account backfill migration", () => { + it("preserves the canonical user for an existing immutable provider identity", () => { + const db = new DatabaseSync(":memory:"); + try { + db.exec("PRAGMA foreign_keys = ON"); + applyMigrationsBeforeBackfill(db); + db.exec(` + INSERT INTO users ( + id, display_name, email, avatar_url, created_at, updated_at + ) VALUES ( + '11111111111111111111111111111111', + 'Legacy User', + 'legacy@example.com', + 'https://avatars.example/legacy', + 1785000000000, + 1785000001000 + ); + + INSERT INTO user_identities ( + id, user_id, provider, provider_user_id, provider_login, + provider_email, created_at, provider_issuer + ) VALUES ( + '22222222222222222222222222222222', + '11111111111111111111111111111111', + 'github', + '583231', + 'legacy-user', + 'legacy@example.com', + 1785000000000, + 'https://github.com' + ); + + -- Better Auth's non-atomic D1 fallback inserted this row before the + -- canonical-user projection rejected its duplicate email. + INSERT INTO auth_users ( + id, name, email, emailVerified, image, createdAt, updatedAt + ) VALUES ( + '33333333333333333333333333333333', + 'Legacy User', + 'legacy@example.com', + 1, + 'https://avatars.example/legacy', + '2026-07-26T21:47:56.000Z', + '2026-07-26T21:47:56.000Z' + ); + + -- Better Auth creates the provider account in the same logical + -- transaction as the user. D1's non-atomic transaction fallback can + -- retain both rows when the post-create canonical projection fails. + INSERT INTO auth_accounts ( + id, accountId, providerId, userId, createdAt, updatedAt + ) VALUES ( + 'partial-account', + '583231', + 'github', + '33333333333333333333333333333333', + '2026-07-26T21:47:56.000Z', + '2026-07-26T21:47:56.000Z' + ); + + INSERT INTO auth_sessions ( + id, expiresAt, token, createdAt, updatedAt, userId + ) VALUES ( + 'partial-session', + '2026-08-02T21:47:56.000Z', + 'partial-session-token', + '2026-07-26T21:47:56.000Z', + '2026-07-26T21:47:56.000Z', + '33333333333333333333333333333333' + ); + `); + + const migrationSql = readFileSync(`${MIGRATIONS_DIRECTORY}/${BACKFILL_MIGRATION}`, "utf8"); + db.exec(migrationSql); + + expect( + db + .prepare( + `SELECT id, name, email, emailVerified, image + FROM auth_users` + ) + .get() + ).toEqual({ + id: "11111111111111111111111111111111", + name: "Legacy User", + email: "legacy@example.com", + emailVerified: 0, + image: "https://avatars.example/legacy", + }); + expect( + db + .prepare( + `SELECT id, accountId, providerId, userId, accessToken, refreshToken + FROM auth_accounts` + ) + .get() + ).toEqual({ + id: "22222222222222222222222222222222", + accountId: "583231", + providerId: "github", + userId: "11111111111111111111111111111111", + accessToken: null, + refreshToken: null, + }); + + // A lost migration response can safely be retried. + db.exec(migrationSql); + expect(db.prepare("SELECT COUNT(*) AS count FROM auth_users").get()).toEqual({ count: 1 }); + expect(db.prepare("SELECT COUNT(*) AS count FROM auth_accounts").get()).toEqual({ count: 1 }); + expect(db.prepare("SELECT COUNT(*) AS count FROM auth_sessions").get()).toEqual({ count: 0 }); + } finally { + db.close(); + } + }); + + it("reserves a legacy canonical email without implicitly linking a provider", () => { + const db = new DatabaseSync(":memory:"); + try { + db.exec("PRAGMA foreign_keys = ON"); + applyMigrationsBeforeBackfill(db); + db.exec(` + INSERT INTO users ( + id, display_name, email, avatar_url, created_at, updated_at + ) VALUES ( + '44444444444444444444444444444444', + 'Bot-created User', + 'bot-created@example.com', + NULL, + 1785000000000, + 1785000001000 + ); + + INSERT INTO auth_users ( + id, name, email, emailVerified, image, createdAt, updatedAt + ) VALUES ( + '55555555555555555555555555555555', + 'Bot-created User', + 'bot-created@example.com', + 1, + NULL, + '2026-07-26T21:47:56.000Z', + '2026-07-26T21:47:56.000Z' + ); + `); + + db.exec(readFileSync(`${MIGRATIONS_DIRECTORY}/${BACKFILL_MIGRATION}`, "utf8")); + + expect(db.prepare("SELECT id, emailVerified FROM auth_users").get()).toEqual({ + id: "44444444444444444444444444444444", + emailVerified: 0, + }); + expect(db.prepare("SELECT COUNT(*) AS count FROM auth_accounts").get()).toEqual({ + count: 0, + }); + } finally { + db.close(); + } + }); +}); diff --git a/packages/control-plane/src/db/browser-auth-sessions.ts b/packages/control-plane/src/db/browser-auth-sessions.ts deleted file mode 100644 index dba70eb21..000000000 --- a/packages/control-plane/src/db/browser-auth-sessions.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { base64UrlEncode } from "../auth/encoding"; -import { hashToken } from "../auth/crypto"; -import type { SqlDatabase } from "./sql-database"; - -export const BROWSER_SESSION_PREFIX = "oi_bsess_"; -export const BROWSER_SESSION_IDLE_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000; -export const BROWSER_SESSION_ABSOLUTE_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000; -export const BROWSER_SESSION_TOUCH_INTERVAL_MS = 24 * 60 * 60 * 1000; - -const BROWSER_SESSION_CREDENTIAL_PATTERN = /^oi_bsess_[A-Za-z0-9_-]{43}$/; - -declare const browserSessionCredentialBrand: unique symbol; -declare const browserSessionIdBrand: unique symbol; - -export type BrowserSessionCredential = string & { - readonly [browserSessionCredentialBrand]: true; -}; - -export type BrowserSessionId = string & { - readonly [browserSessionIdBrand]: true; -}; - -export interface Clock { - now(): number; -} - -export interface BrowserSessionCredentialGenerator { - generate(): string; -} - -export interface BrowserSessionIdGenerator { - generate(): string; -} - -export interface TokenHasher { - hash(value: string): Promise; -} - -export interface BrowserAuthSessionStoreDependencies { - readonly clock: Clock; - readonly credentialGenerator: BrowserSessionCredentialGenerator; - readonly idGenerator: BrowserSessionIdGenerator; - readonly tokenHasher: TokenHasher; -} - -export interface CreateBrowserAuthSessionInput { - userId: string; - providerIdentityId: string; -} - -export interface CreatedBrowserAuthSession { - credential: BrowserSessionCredential; - credentialId: BrowserSessionId; - expiresAt: number; - absoluteExpiresAt: number; -} - -export interface AuthenticatedBrowserSession { - credentialId: BrowserSessionId; - userId: string; - providerIdentityId: string; - lastUsedAt: number; - expiresAt: number; - absoluteExpiresAt: number; -} - -export type BrowserSessionRevocationReason = "logout" | "operator" | "provider_identity"; - -type BrowserAuthSessionRow = { - id: string; - client_id: string; - user_id: string; - provider_identity_id: string; - last_used_at: number; - expires_at: number; - absolute_expires_at: number; - revoked_at: number | null; -}; - -export type BrowserSessionRejection = - | "malformed" - | "unknown" - | "revoked" - | "idle_expired" - | "absolute_expired" - | "corrupt"; - -export class BrowserSessionAuthenticationError extends Error { - constructor(readonly rejection: BrowserSessionRejection) { - super("Browser session is not valid"); - this.name = "BrowserSessionAuthenticationError"; - } -} - -function defaultCredential(): string { - return `${BROWSER_SESSION_PREFIX}${base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)))}`; -} - -function isFiniteInteger(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value); -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.length > 0; -} - -export function parseBrowserSessionCredential(value: string): BrowserSessionCredential { - if (!BROWSER_SESSION_CREDENTIAL_PATTERN.test(value)) { - throw new BrowserSessionAuthenticationError("malformed"); - } - return value as BrowserSessionCredential; -} - -export function parseBrowserSessionId(value: string): BrowserSessionId { - if (!isNonEmptyString(value)) { - throw new BrowserSessionAuthenticationError("malformed"); - } - return value as BrowserSessionId; -} - -function decodeBrowserAuthSessionRow(row: BrowserAuthSessionRow): AuthenticatedBrowserSession { - if ( - !isNonEmptyString(row.id) || - row.client_id !== "web" || - !isNonEmptyString(row.user_id) || - !isNonEmptyString(row.provider_identity_id) || - !isFiniteInteger(row.last_used_at) || - !isFiniteInteger(row.expires_at) || - !isFiniteInteger(row.absolute_expires_at) || - (row.revoked_at !== null && !isFiniteInteger(row.revoked_at)) - ) { - throw new BrowserSessionAuthenticationError("corrupt"); - } - - return { - credentialId: row.id as BrowserSessionId, - userId: row.user_id, - providerIdentityId: row.provider_identity_id, - lastUsedAt: row.last_used_at, - expiresAt: row.expires_at, - absoluteExpiresAt: row.absolute_expires_at, - }; -} - -export class BrowserAuthSessionStore { - constructor( - private readonly db: SqlDatabase, - private readonly dependencies: BrowserAuthSessionStoreDependencies - ) {} - - async create(input: CreateBrowserAuthSessionInput): Promise { - if (!isNonEmptyString(input.userId) || !isNonEmptyString(input.providerIdentityId)) { - throw new Error("Browser session requires a user and provider identity"); - } - - const now = this.dependencies.clock.now(); - const generatedCredential = this.dependencies.credentialGenerator.generate(); - if (!BROWSER_SESSION_CREDENTIAL_PATTERN.test(generatedCredential)) { - throw new Error("Browser session credential generator returned an invalid credential"); - } - const credential = generatedCredential as BrowserSessionCredential; - - const generatedCredentialId = this.dependencies.idGenerator.generate(); - if (!isNonEmptyString(generatedCredentialId)) { - throw new Error("Browser session id generator returned an invalid id"); - } - const credentialId = generatedCredentialId as BrowserSessionId; - const tokenHash = await this.dependencies.tokenHasher.hash(credential); - const expiresAt = now + BROWSER_SESSION_IDLE_LIFETIME_MS; - const absoluteExpiresAt = now + BROWSER_SESSION_ABSOLUTE_LIFETIME_MS; - - const result = await this.db - .prepare( - `INSERT INTO browser_auth_sessions ( - id, token_hash, user_id, client_id, provider_identity_id, - created_at, last_used_at, expires_at, absolute_expires_at, - revoked_at, revoked_reason - ) VALUES (?, ?, ?, 'web', ?, ?, ?, ?, ?, NULL, NULL)` - ) - .bind( - credentialId, - tokenHash, - input.userId, - input.providerIdentityId, - now, - now, - expiresAt, - absoluteExpiresAt - ) - .run(); - - if (result.meta.changes !== 1) { - throw new Error("Browser session was not created"); - } - - return { credential, credentialId, expiresAt, absoluteExpiresAt }; - } - - async authenticate(credential: BrowserSessionCredential): Promise { - const tokenHash = await this.dependencies.tokenHasher.hash(credential); - const row = await this.db - .prepare( - `SELECT - id, client_id, user_id, provider_identity_id, last_used_at, expires_at, - absolute_expires_at, revoked_at - FROM browser_auth_sessions - WHERE token_hash = ?` - ) - .bind(tokenHash) - .first(); - - return this.validateAuthenticatedRow(row); - } - - /** - * Revalidates a derived credential's parent without retaining or replaying - * the raw browser bearer. - */ - async authenticateById(credentialId: BrowserSessionId): Promise { - const row = await this.db - .prepare( - `SELECT - id, client_id, user_id, provider_identity_id, last_used_at, expires_at, - absolute_expires_at, revoked_at - FROM browser_auth_sessions - WHERE id = ?` - ) - .bind(credentialId) - .first(); - - return this.validateAuthenticatedRow(row); - } - - async revoke( - credential: BrowserSessionCredential, - reason: BrowserSessionRevocationReason - ): Promise { - const tokenHash = await this.dependencies.tokenHasher.hash(credential); - const result = await this.db - .prepare( - `UPDATE browser_auth_sessions - SET revoked_at = ?, revoked_reason = ? - WHERE token_hash = ? AND revoked_at IS NULL` - ) - .bind(this.dependencies.clock.now(), reason, tokenHash) - .run(); - - return result.meta.changes === 1; - } - - async revokeById( - credentialId: BrowserSessionId, - reason: BrowserSessionRevocationReason - ): Promise { - const result = await this.db - .prepare( - `UPDATE browser_auth_sessions - SET revoked_at = ?, revoked_reason = ? - WHERE id = ? AND revoked_at IS NULL` - ) - .bind(this.dependencies.clock.now(), reason, credentialId) - .run(); - - return result.meta.changes === 1; - } - - async touchQualifyingActivity(credentialId: BrowserSessionId): Promise { - const now = this.dependencies.clock.now(); - await this.db - .prepare( - `UPDATE browser_auth_sessions - SET last_used_at = ?, - expires_at = min(?, absolute_expires_at) - WHERE id = ? - AND revoked_at IS NULL - AND expires_at > ? - AND absolute_expires_at > ? - AND last_used_at <= ?` - ) - .bind( - now, - now + BROWSER_SESSION_IDLE_LIFETIME_MS, - credentialId, - now, - now, - now - BROWSER_SESSION_TOUCH_INTERVAL_MS - ) - .run(); - } - - private validateAuthenticatedRow(row: BrowserAuthSessionRow | null): AuthenticatedBrowserSession { - if (!row) throw new BrowserSessionAuthenticationError("unknown"); - const session = decodeBrowserAuthSessionRow(row); - const now = this.dependencies.clock.now(); - if (row.revoked_at !== null) throw new BrowserSessionAuthenticationError("revoked"); - if (session.absoluteExpiresAt <= now) { - throw new BrowserSessionAuthenticationError("absolute_expired"); - } - if (session.expiresAt <= now) { - throw new BrowserSessionAuthenticationError("idle_expired"); - } - return session; - } -} - -export const defaultBrowserAuthSessionStoreDependencies: BrowserAuthSessionStoreDependencies = { - clock: { now: () => Date.now() }, - credentialGenerator: { generate: defaultCredential }, - idGenerator: { generate: () => crypto.randomUUID() }, - tokenHasher: { hash: hashToken }, -}; diff --git a/packages/control-plane/src/db/canonical-user-projection.ts b/packages/control-plane/src/db/canonical-user-projection.ts new file mode 100644 index 000000000..0cb15ad35 --- /dev/null +++ b/packages/control-plane/src/db/canonical-user-projection.ts @@ -0,0 +1,50 @@ +import { isCanonicalUserId } from "@open-inspect/shared"; +import type { + CanonicalUserProjection, + UserProjectionInput, +} from "../auth/user/canonical-user-projection"; +import type { SqlDatabase } from "./sql-database"; + +function requireNonEmpty(value: string, field: string): string { + const normalized = value.trim(); + if (!normalized) { + throw new Error(`Canonical user projection ${field} is empty`); + } + return normalized; +} + +function requireTimestamp(value: Date, field: string): number { + const timestamp = value.getTime(); + if (!Number.isFinite(timestamp)) { + throw new Error(`Canonical user projection ${field} is invalid`); + } + return timestamp; +} + +export class D1CanonicalUserProjection implements CanonicalUserProjection { + constructor(private readonly db: SqlDatabase) {} + + async project(user: UserProjectionInput): Promise { + const id = requireNonEmpty(user.id, "id"); + if (!isCanonicalUserId(id)) { + throw new Error("Projected user id is not canonical"); + } + const email = requireNonEmpty(user.email, "email").toLowerCase(); + const createdAt = requireTimestamp(user.createdAt, "createdAt"); + const updatedAt = requireTimestamp(user.updatedAt, "updatedAt"); + + await this.db + .prepare( + `INSERT INTO users ( + id, display_name, email, avatar_url, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + display_name = excluded.display_name, + email = excluded.email, + avatar_url = excluded.avatar_url, + updated_at = excluded.updated_at` + ) + .bind(id, user.name.trim() || null, email, user.image ?? null, createdAt, updatedAt) + .run(); + } +} diff --git a/packages/control-plane/src/db/errors.ts b/packages/control-plane/src/db/errors.ts index 065dc82ee..c400e48a7 100644 --- a/packages/control-plane/src/db/errors.ts +++ b/packages/control-plane/src/db/errors.ts @@ -7,3 +7,8 @@ export function isUniqueConstraintError(err: unknown): boolean { const msg = err instanceof Error ? err.message : String(err); return msg.toLowerCase().includes("unique constraint failed"); } + +export function isCheckConstraintError(err: unknown, constraint: string): boolean { + const message = err instanceof Error ? err.message : String(err); + return message.toLowerCase().includes(`check constraint failed: ${constraint.toLowerCase()}`); +} diff --git a/packages/control-plane/src/db/oauth-authorization-codes.ts b/packages/control-plane/src/db/oauth-authorization-codes.ts deleted file mode 100644 index ca2fd1249..000000000 --- a/packages/control-plane/src/db/oauth-authorization-codes.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { timingSafeEqual } from "@open-inspect/shared"; -import { - InvalidPkceVerifierError, - createPkceS256Challenge, - isPkceS256Challenge, -} from "../auth/pkce"; -import { - BROWSER_SESSION_ABSOLUTE_LIFETIME_MS, - BROWSER_SESSION_IDLE_LIFETIME_MS, - parseBrowserSessionCredential, - parseBrowserSessionId, - type Clock, - type CreatedBrowserAuthSession, - type TokenHasher, -} from "./browser-auth-sessions"; -import type { SqlDatabase } from "./sql-database"; - -export const OAUTH_AUTHORIZATION_CODE_LIFETIME_MS = 60 * 1000; - -const AUTHORIZATION_CODE_PATTERN = /^oi_code_[A-Za-z0-9_-]{43}$/; - -export interface OAuthAuthorizationCodeStoreDependencies { - readonly clock: Clock; - readonly tokenHasher: TokenHasher; - readonly authorizationCodeGenerator: { generate(): string }; - readonly browserCredentialGenerator: { generate(): string }; - readonly idGenerator: { generate(): string }; -} - -export interface IssueOAuthAuthorizationCodeInput { - userId: string; - providerIdentityId: string; - clientId: "web"; - redirectUri: string; - codeChallenge: string; -} - -export interface RedeemOAuthAuthorizationCodeInput { - code: string; - clientId: "web"; - redirectUri: string; - codeVerifier: string; -} - -type AuthorizationCodeRow = { - id: string; - client_id: string; - redirect_uri: string; - code_challenge: string; - expires_at: number; - consumed_at: number | null; -}; - -export type OAuthAuthorizationCodeRejection = - | "malformed" - | "unknown" - | "binding_mismatch" - | "pkce_failed" - | "expired" - | "already_consumed" - | "race_lost" - | "corrupt"; - -export class OAuthAuthorizationCodeRedemptionError extends Error { - constructor(readonly rejection: OAuthAuthorizationCodeRejection) { - super("OAuth authorization code is not valid"); - this.name = "OAuthAuthorizationCodeRedemptionError"; - } -} - -export class InvalidOAuthAuthorizationCodeInputError extends Error { - constructor(message: string) { - super(message); - this.name = "InvalidOAuthAuthorizationCodeInputError"; - } -} - -function isFiniteInteger(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value); -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.length > 0; -} - -function decodeAuthorizationCodeRow(value: unknown): AuthorizationCodeRow { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new OAuthAuthorizationCodeRedemptionError("corrupt"); - } - const row = value as Record; - if ( - !isNonEmptyString(row.id) || - row.client_id !== "web" || - !isNonEmptyString(row.redirect_uri) || - !isPkceS256Challenge(row.code_challenge) || - !isFiniteInteger(row.expires_at) || - (row.consumed_at !== null && !isFiniteInteger(row.consumed_at)) - ) { - throw new OAuthAuthorizationCodeRedemptionError("corrupt"); - } - return { - id: row.id, - client_id: row.client_id, - redirect_uri: row.redirect_uri, - code_challenge: row.code_challenge, - expires_at: row.expires_at, - consumed_at: row.consumed_at, - }; -} - -function validateIssueInput(input: IssueOAuthAuthorizationCodeInput): void { - if ( - !isNonEmptyString(input.userId) || - !isNonEmptyString(input.providerIdentityId) || - input.clientId !== "web" || - !isNonEmptyString(input.redirectUri) || - !isPkceS256Challenge(input.codeChallenge) - ) { - throw new InvalidOAuthAuthorizationCodeInputError( - "OAuth authorization code binding is malformed" - ); - } -} - -export class OAuthAuthorizationCodeStore { - constructor( - private readonly db: SqlDatabase, - private readonly dependencies: OAuthAuthorizationCodeStoreDependencies - ) {} - - async issue( - input: IssueOAuthAuthorizationCodeInput - ): Promise<{ code: string; expiresAt: number }> { - validateIssueInput(input); - const code = this.dependencies.authorizationCodeGenerator.generate(); - if (!AUTHORIZATION_CODE_PATTERN.test(code)) { - throw new Error("OAuth authorization code generator returned an invalid code"); - } - const codeId = this.dependencies.idGenerator.generate(); - if (!isNonEmptyString(codeId)) { - throw new Error("OAuth authorization code id generator returned an invalid id"); - } - - const now = this.dependencies.clock.now(); - const expiresAt = now + OAUTH_AUTHORIZATION_CODE_LIFETIME_MS; - const codeHash = await this.dependencies.tokenHasher.hash(code); - const result = await this.db - .prepare( - `INSERT INTO oauth_authorization_codes ( - id, code_hash, user_id, provider_identity_id, client_id, - redirect_uri, code_challenge, created_at, expires_at, - consumed_at, consumed_by - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)` - ) - .bind( - codeId, - codeHash, - input.userId, - input.providerIdentityId, - input.clientId, - input.redirectUri, - input.codeChallenge, - now, - expiresAt - ) - .run(); - if (result.meta.changes !== 1) { - throw new Error("OAuth authorization code was not created"); - } - return { code, expiresAt }; - } - - async redeem(input: RedeemOAuthAuthorizationCodeInput): Promise { - if (!AUTHORIZATION_CODE_PATTERN.test(input.code)) { - throw new OAuthAuthorizationCodeRedemptionError("malformed"); - } - - let presentedChallenge: string; - try { - presentedChallenge = await createPkceS256Challenge(input.codeVerifier); - } catch (error) { - if (error instanceof InvalidPkceVerifierError) { - throw new OAuthAuthorizationCodeRedemptionError("pkce_failed"); - } - throw error; - } - - const codeHash = await this.dependencies.tokenHasher.hash(input.code); - const found = await this.db - .prepare( - `SELECT - id, client_id, redirect_uri, code_challenge, expires_at, consumed_at - FROM oauth_authorization_codes - WHERE code_hash = ?` - ) - .bind(codeHash) - .first>(); - if (!found) throw new OAuthAuthorizationCodeRedemptionError("unknown"); - - const row = decodeAuthorizationCodeRow(found); - if (row.client_id !== input.clientId || row.redirect_uri !== input.redirectUri) { - throw new OAuthAuthorizationCodeRedemptionError("binding_mismatch"); - } - if (!timingSafeEqual(row.code_challenge, presentedChallenge)) { - throw new OAuthAuthorizationCodeRedemptionError("pkce_failed"); - } - if (row.consumed_at !== null) { - throw new OAuthAuthorizationCodeRedemptionError("already_consumed"); - } - - const now = this.dependencies.clock.now(); - if (row.expires_at <= now) { - throw new OAuthAuthorizationCodeRedemptionError("expired"); - } - - const credential = parseBrowserSessionCredential( - this.dependencies.browserCredentialGenerator.generate() - ); - const credentialId = parseBrowserSessionId(this.dependencies.idGenerator.generate()); - const credentialHash = await this.dependencies.tokenHasher.hash(credential); - const expiresAt = now + BROWSER_SESSION_IDLE_LIFETIME_MS; - const absoluteExpiresAt = now + BROWSER_SESSION_ABSOLUTE_LIFETIME_MS; - - const results = await this.db.batch([ - this.db - .prepare( - `UPDATE oauth_authorization_codes - SET consumed_at = ?, consumed_by = ? - WHERE code_hash = ? - AND client_id = ? - AND redirect_uri = ? - AND consumed_at IS NULL - AND expires_at > ?` - ) - .bind(now, credentialId, codeHash, input.clientId, input.redirectUri, now), - this.db - .prepare( - `INSERT INTO browser_auth_sessions ( - id, token_hash, user_id, client_id, provider_identity_id, - created_at, last_used_at, expires_at, absolute_expires_at, - revoked_at, revoked_reason - ) - SELECT - ?, ?, user_id, 'web', provider_identity_id, - ?, ?, ?, ?, NULL, NULL - FROM oauth_authorization_codes - WHERE code_hash = ? - AND consumed_by = ? - AND consumed_at = ?` - ) - .bind( - credentialId, - credentialHash, - now, - now, - expiresAt, - absoluteExpiresAt, - codeHash, - credentialId, - now - ), - ]); - const [consumeResult, sessionResult] = results; - if (!consumeResult || !sessionResult) { - throw new Error("OAuth authorization code redemption batch returned an invalid result"); - } - - if (consumeResult.meta.changes === 0 && sessionResult.meta.changes === 0) { - return this.throwCurrentRejection(codeHash, now); - } - if (consumeResult.meta.changes !== 1 || sessionResult.meta.changes !== 1) { - throw new Error("OAuth authorization code redemption batch violated its result invariant"); - } - return { credential, credentialId, expiresAt, absoluteExpiresAt }; - } - - private async throwCurrentRejection(codeHash: string, now: number): Promise { - const current = await this.db - .prepare( - `SELECT consumed_at, expires_at - FROM oauth_authorization_codes - WHERE code_hash = ?` - ) - .bind(codeHash) - .first>(); - if (!current) throw new OAuthAuthorizationCodeRedemptionError("race_lost"); - if ( - (current.consumed_at !== null && !isFiniteInteger(current.consumed_at)) || - !isFiniteInteger(current.expires_at) - ) { - throw new OAuthAuthorizationCodeRedemptionError("corrupt"); - } - if (current.consumed_at !== null) { - throw new OAuthAuthorizationCodeRedemptionError("already_consumed"); - } - if (current.expires_at <= now) { - throw new OAuthAuthorizationCodeRedemptionError("expired"); - } - throw new OAuthAuthorizationCodeRedemptionError("race_lost"); - } -} diff --git a/packages/control-plane/src/db/oauth-flow-state.ts b/packages/control-plane/src/db/oauth-flow-state.ts deleted file mode 100644 index b061b3291..000000000 --- a/packages/control-plane/src/db/oauth-flow-state.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { isPkceS256Challenge, isPkceVerifier } from "../auth/pkce"; -import { - OAuthFlowVerifierIntegrityError, - type OAuthFlowVerifierBinding, - type OAuthFlowVerifierCipher, -} from "../auth/oauth-flow-verifier"; -import type { - ConsumedOAuthFlowState, - ConsumedOAuthFlowStateFor, - CreateOAuthFlowStateInput, -} from "../auth/oauth-flow-state"; -import { isSignInProvider, type SignInProvider } from "../auth/sign-in-provider"; -import type { Clock, TokenHasher } from "./browser-auth-sessions"; -import type { SqlDatabase } from "./sql-database"; - -export const OAUTH_FLOW_LIFETIME_MS = 10 * 60 * 1000; -export const OAUTH_FLOW_KEY_VERSION = 1; - -const OPAQUE_VALUE_PATTERN = /^[A-Za-z0-9_-]{32,128}$/; - -export interface OAuthFlowStateStoreDependencies { - readonly clock: Clock; - readonly idGenerator: { generate(): string }; - readonly tokenHasher: TokenHasher; -} - -interface OAuthFlowRowBinding { - id: string; - clientId: "web"; - redirectUri: string; - clientCodeChallenge: string; - providerPkceVerifierCiphertext: string; - providerPkceKeyVersion: number; - expiresAt: number; - consumedAt: number | null; -} - -type OAuthFlowRow = - | (OAuthFlowRowBinding & { - provider: "github"; - oidcNonceHash: null; - }) - | (OAuthFlowRowBinding & { - provider: "google"; - oidcNonceHash: string; - }); - -export type OAuthFlowStateRejection = - | "malformed" - | "unknown" - | "provider_mismatch" - | "expired" - | "already_consumed" - | "race_lost" - | "corrupt"; - -export class OAuthFlowStateConsumptionError extends Error { - constructor(readonly rejection: OAuthFlowStateRejection) { - super("OAuth flow state is not valid"); - this.name = "OAuthFlowStateConsumptionError"; - } -} - -export class InvalidOAuthFlowStateInputError extends Error { - constructor(message: string) { - super(message); - this.name = "InvalidOAuthFlowStateInputError"; - } -} - -function isFiniteInteger(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value); -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.length > 0; -} - -function decodeOAuthFlowRow(value: unknown): OAuthFlowRow { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new OAuthFlowStateConsumptionError("corrupt"); - } - const row = value as Record; - if ( - !isNonEmptyString(row.id) || - !isSignInProvider(row.provider) || - row.client_id !== "web" || - !isNonEmptyString(row.redirect_uri) || - !isPkceS256Challenge(row.client_code_challenge) || - !isNonEmptyString(row.provider_pkce_verifier_ciphertext) || - row.provider_pkce_key_version !== OAUTH_FLOW_KEY_VERSION || - !isFiniteInteger(row.expires_at) || - (row.consumed_at !== null && !isFiniteInteger(row.consumed_at)) - ) { - throw new OAuthFlowStateConsumptionError("corrupt"); - } - - const binding = { - id: row.id, - clientId: "web" as const, - redirectUri: row.redirect_uri, - clientCodeChallenge: row.client_code_challenge, - providerPkceVerifierCiphertext: row.provider_pkce_verifier_ciphertext, - providerPkceKeyVersion: row.provider_pkce_key_version, - expiresAt: row.expires_at, - consumedAt: row.consumed_at, - }; - if (row.provider === "github") { - if (row.oidc_nonce_hash !== null) { - throw new OAuthFlowStateConsumptionError("corrupt"); - } - return { ...binding, provider: "github", oidcNonceHash: null }; - } - if (typeof row.oidc_nonce_hash !== "string" || !/^[0-9a-f]{64}$/.test(row.oidc_nonce_hash)) { - throw new OAuthFlowStateConsumptionError("corrupt"); - } - return { - ...binding, - provider: "google", - oidcNonceHash: row.oidc_nonce_hash, - }; -} - -function validateCreateInput(input: CreateOAuthFlowStateInput): void { - if (!OPAQUE_VALUE_PATTERN.test(input.state)) { - throw new InvalidOAuthFlowStateInputError("OAuth state is malformed"); - } - if ( - input.clientId !== "web" || - !isNonEmptyString(input.redirectUri) || - !isPkceS256Challenge(input.clientCodeChallenge) || - !isPkceVerifier(input.providerPkceVerifier) - ) { - throw new InvalidOAuthFlowStateInputError("OAuth flow binding is malformed"); - } - if (input.provider === "google" && !OPAQUE_VALUE_PATTERN.test(input.oidcNonce ?? "")) { - throw new InvalidOAuthFlowStateInputError("Google OAuth flows require a valid OIDC nonce"); - } - if (input.provider === "github" && input.oidcNonce !== undefined) { - throw new InvalidOAuthFlowStateInputError("GitHub OAuth flows cannot carry an OIDC nonce"); - } -} - -export class OAuthFlowStateStore { - constructor( - private readonly db: SqlDatabase, - private readonly verifierCipher: OAuthFlowVerifierCipher, - private readonly dependencies: OAuthFlowStateStoreDependencies - ) {} - - async create(input: CreateOAuthFlowStateInput): Promise<{ flowId: string }> { - validateCreateInput(input); - const flowId = this.dependencies.idGenerator.generate(); - if (!isNonEmptyString(flowId)) { - throw new Error("OAuth flow id generator returned an invalid id"); - } - - const now = this.dependencies.clock.now(); - const binding: OAuthFlowVerifierBinding = { - flowId, - provider: input.provider, - keyVersion: OAUTH_FLOW_KEY_VERSION, - }; - const [stateHash, verifierCiphertext, oidcNonceHash] = await Promise.all([ - this.dependencies.tokenHasher.hash(input.state), - this.verifierCipher.encrypt(input.providerPkceVerifier, binding), - input.provider === "google" - ? this.dependencies.tokenHasher.hash(input.oidcNonce) - : Promise.resolve(null), - ]); - - const result = await this.db - .prepare( - `INSERT INTO oauth_flow_state ( - id, state_hash, provider, client_id, redirect_uri, - client_code_challenge, provider_pkce_verifier_ciphertext, - provider_pkce_key_version, oidc_nonce_hash, - created_at, expires_at, consumed_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)` - ) - .bind( - flowId, - stateHash, - input.provider, - input.clientId, - input.redirectUri, - input.clientCodeChallenge, - verifierCiphertext, - OAUTH_FLOW_KEY_VERSION, - oidcNonceHash, - now, - now + OAUTH_FLOW_LIFETIME_MS - ) - .run(); - if (result.meta.changes !== 1) { - throw new Error("OAuth flow state was not created"); - } - return { flowId }; - } - - async consume

( - state: string, - expectedProvider: P - ): Promise> { - if (!OPAQUE_VALUE_PATTERN.test(state)) { - throw new OAuthFlowStateConsumptionError("malformed"); - } - - const stateHash = await this.dependencies.tokenHasher.hash(state); - const found = await this.db - .prepare( - `SELECT - id, provider, client_id, redirect_uri, client_code_challenge, - provider_pkce_verifier_ciphertext, provider_pkce_key_version, - oidc_nonce_hash, expires_at, consumed_at - FROM oauth_flow_state - WHERE state_hash = ?` - ) - .bind(stateHash) - .first>(); - if (!found) throw new OAuthFlowStateConsumptionError("unknown"); - - const row = decodeOAuthFlowRow(found); - if (row.provider !== expectedProvider) { - throw new OAuthFlowStateConsumptionError("provider_mismatch"); - } - if (row.consumedAt !== null) { - throw new OAuthFlowStateConsumptionError("already_consumed"); - } - const now = this.dependencies.clock.now(); - if (row.expiresAt <= now) { - throw new OAuthFlowStateConsumptionError("expired"); - } - - let providerPkceVerifier: string; - try { - providerPkceVerifier = await this.verifierCipher.decrypt(row.providerPkceVerifierCiphertext, { - flowId: row.id, - provider: row.provider, - keyVersion: row.providerPkceKeyVersion, - }); - } catch (error) { - if (error instanceof OAuthFlowVerifierIntegrityError) { - throw new OAuthFlowStateConsumptionError("corrupt"); - } - throw error; - } - if (!isPkceVerifier(providerPkceVerifier)) { - throw new OAuthFlowStateConsumptionError("corrupt"); - } - - const consumed = await this.db - .prepare( - `UPDATE oauth_flow_state - SET consumed_at = ? - WHERE id = ? AND consumed_at IS NULL AND expires_at > ?` - ) - .bind(now, row.id, now) - .run(); - if (consumed.meta.changes !== 1) { - await this.throwCurrentRejection(row.id, now); - } - - const consumedBinding = { - flowId: row.id, - clientId: row.clientId, - redirectUri: row.redirectUri, - clientCodeChallenge: row.clientCodeChallenge, - providerPkceVerifier, - }; - const result: ConsumedOAuthFlowState = - row.provider === "github" - ? { ...consumedBinding, provider: "github", oidcNonceHash: null } - : { - ...consumedBinding, - provider: "google", - oidcNonceHash: row.oidcNonceHash, - }; - // The provider equality check above establishes the generic correlation. - return result as ConsumedOAuthFlowStateFor

; - } - - private async throwCurrentRejection(flowId: string, now: number): Promise { - const current = await this.db - .prepare("SELECT consumed_at, expires_at FROM oauth_flow_state WHERE id = ?") - .bind(flowId) - .first>(); - if (!current) { - throw new OAuthFlowStateConsumptionError("race_lost"); - } - if ( - (current.consumed_at !== null && !isFiniteInteger(current.consumed_at)) || - !isFiniteInteger(current.expires_at) - ) { - throw new OAuthFlowStateConsumptionError("corrupt"); - } - if (current.consumed_at !== null) { - throw new OAuthFlowStateConsumptionError("already_consumed"); - } - if (current.expires_at <= now) { - throw new OAuthFlowStateConsumptionError("expired"); - } - throw new OAuthFlowStateConsumptionError("race_lost"); - } -} diff --git a/packages/control-plane/src/db/provider-credentials.ts b/packages/control-plane/src/db/provider-credentials.ts deleted file mode 100644 index 009d2b20f..000000000 --- a/packages/control-plane/src/db/provider-credentials.ts +++ /dev/null @@ -1,544 +0,0 @@ -import { - ProviderCredentialIntegrityError, - type ProviderCredentialCipherBinding, - type ProviderCredentialCipherPort, -} from "../auth/provider-credential-cipher"; -import type { ProviderCredentialInput, ProviderCredentialKind } from "../auth/provider-credential"; -import type { Clock } from "./browser-auth-sessions"; -import { isUniqueConstraintError } from "./errors"; -import type { SqlDatabase, SqlStatement } from "./sql-database"; - -export const CURRENT_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSION = 1; -const SUPPORTED_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSIONS: ReadonlySet = new Set([ - CURRENT_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSION, -]); -const MAX_SIGN_IN_UPSERT_ATTEMPTS = 4; - -interface ProviderCredentialMetadata { - providerIdentityId: string; - encryptionKeyVersion: number; - rowVersion: number; - updatedAt: number; -} - -export type ProviderCredential = - | (ProviderCredentialMetadata & { - kind: "refreshable"; - accessToken: string; - accessExpiresAt: number; - refreshToken: string; - refreshExpiresAt: number | null; - }) - | (ProviderCredentialMetadata & { - kind: "access_only_expiring"; - accessToken: string; - accessExpiresAt: number; - }) - | (ProviderCredentialMetadata & { - kind: "access_only_nonexpiring"; - accessToken: string; - }); - -interface ProviderCredentialRowMetadata { - providerIdentityId: string; - accessTokenCiphertext: string; - encryptionKeyVersion: number; - rowVersion: number; - updatedAt: number; -} - -type ProviderCredentialRow = - | (ProviderCredentialRowMetadata & { - credentialKind: "refreshable"; - accessExpiresAt: number; - refreshTokenCiphertext: string; - refreshExpiresAt: number | null; - }) - | (ProviderCredentialRowMetadata & { - credentialKind: "access_only_expiring"; - accessExpiresAt: number; - refreshTokenCiphertext: null; - refreshExpiresAt: null; - }) - | (ProviderCredentialRowMetadata & { - credentialKind: "access_only_nonexpiring"; - accessExpiresAt: null; - refreshTokenCiphertext: null; - refreshExpiresAt: null; - }); - -interface EncryptedProviderCredential { - accessTokenCiphertext: string; - accessExpiresAt: number | null; - refreshTokenCiphertext: string | null; - refreshExpiresAt: number | null; -} - -export class InvalidProviderCredentialInputError extends Error { - constructor(message: string) { - super(message); - this.name = "InvalidProviderCredentialInputError"; - } -} - -export class StoredProviderCredentialCorruptError extends Error { - constructor() { - super("Stored provider credential is invalid"); - this.name = "StoredProviderCredentialCorruptError"; - } -} - -export class ProviderCredentialVersionConflictError extends Error { - constructor() { - super("Provider credential changed concurrently"); - this.name = "ProviderCredentialVersionConflictError"; - } -} - -function isFiniteInteger(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value); -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.length > 0; -} - -function isSupportedProviderCredentialEncryptionKeyVersion(value: unknown): value is number { - return ( - isFiniteInteger(value) && - value > 0 && - SUPPORTED_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSIONS.has(value) - ); -} - -function validateInput(providerIdentityId: string, input: ProviderCredentialInput): void { - if (!isNonEmptyString(providerIdentityId) || !isNonEmptyString(input.accessToken)) { - throw new InvalidProviderCredentialInputError( - "Provider credential requires an identity and access token" - ); - } - if ("accessExpiresAt" in input && !isFiniteInteger(input.accessExpiresAt)) { - throw new InvalidProviderCredentialInputError( - "Provider access-token expiry must be an integer" - ); - } - if ( - input.kind === "refreshable" && - (!isNonEmptyString(input.refreshToken) || - (input.refreshExpiresAt !== null && !isFiniteInteger(input.refreshExpiresAt))) - ) { - throw new InvalidProviderCredentialInputError("Refreshable provider credential is malformed"); - } -} - -function validateObservedVersion(providerIdentityId: string, observedRowVersion: number): void { - if ( - !isNonEmptyString(providerIdentityId) || - !isFiniteInteger(observedRowVersion) || - observedRowVersion < 1 - ) { - throw new InvalidProviderCredentialInputError( - "Provider identity and positive observed row version are required" - ); - } -} - -function decodeProviderCredentialRow(value: unknown): ProviderCredentialRow { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new StoredProviderCredentialCorruptError(); - } - const row = value as Record; - if ( - !isNonEmptyString(row.provider_identity_id) || - !isNonEmptyString(row.access_token_ciphertext) || - !isSupportedProviderCredentialEncryptionKeyVersion(row.encryption_key_version) || - !isFiniteInteger(row.row_version) || - row.row_version < 1 || - !isFiniteInteger(row.updated_at) - ) { - throw new StoredProviderCredentialCorruptError(); - } - - const metadata = { - providerIdentityId: row.provider_identity_id, - accessTokenCiphertext: row.access_token_ciphertext, - encryptionKeyVersion: row.encryption_key_version, - rowVersion: row.row_version, - updatedAt: row.updated_at, - }; - if ( - row.credential_kind === "refreshable" && - isFiniteInteger(row.access_expires_at) && - isNonEmptyString(row.refresh_token_ciphertext) && - (row.refresh_expires_at === null || isFiniteInteger(row.refresh_expires_at)) - ) { - return { - ...metadata, - credentialKind: "refreshable", - accessExpiresAt: row.access_expires_at, - refreshTokenCiphertext: row.refresh_token_ciphertext, - refreshExpiresAt: row.refresh_expires_at, - }; - } - if ( - row.credential_kind === "access_only_expiring" && - isFiniteInteger(row.access_expires_at) && - row.refresh_token_ciphertext === null && - row.refresh_expires_at === null - ) { - return { - ...metadata, - credentialKind: "access_only_expiring", - accessExpiresAt: row.access_expires_at, - refreshTokenCiphertext: null, - refreshExpiresAt: null, - }; - } - if ( - row.credential_kind === "access_only_nonexpiring" && - row.access_expires_at === null && - row.refresh_token_ciphertext === null && - row.refresh_expires_at === null - ) { - return { - ...metadata, - credentialKind: "access_only_nonexpiring", - accessExpiresAt: null, - refreshTokenCiphertext: null, - refreshExpiresAt: null, - }; - } - throw new StoredProviderCredentialCorruptError(); -} - -function cipherBinding( - providerIdentityId: string, - credentialKind: ProviderCredentialKind, - tokenRole: "access" | "refresh", - encryptionKeyVersion: number, - rowVersion: number -): ProviderCredentialCipherBinding { - return { - providerIdentityId, - credentialKind, - tokenRole, - encryptionKeyVersion, - rowVersion, - }; -} - -export class ProviderCredentialStore { - constructor( - private readonly db: SqlDatabase, - private readonly cipher: ProviderCredentialCipherPort, - private readonly clock: Clock - ) {} - - /** - * Prepares, but does not execute, an initial credential insert so identity - * resolution can commit the user, identity, email claim, and credential in - * one caller-owned database batch. - */ - async prepareInitialInsert( - providerIdentityId: string, - credential: ProviderCredentialInput, - updatedAt = this.clock.now() - ): Promise { - validateInput(providerIdentityId, credential); - if (!isFiniteInteger(updatedAt)) { - throw new InvalidProviderCredentialInputError( - "Provider credential update time must be an integer" - ); - } - const rowVersion = 1; - const encrypted = await this.encryptCredential(providerIdentityId, credential, rowVersion); - return this.prepareInsertStatement( - providerIdentityId, - credential.kind, - encrypted, - rowVersion, - updatedAt - ); - } - - async upsertFromSignIn( - providerIdentityId: string, - credential: ProviderCredentialInput - ): Promise { - validateInput(providerIdentityId, credential); - - for (let attempt = 0; attempt < MAX_SIGN_IN_UPSERT_ATTEMPTS; attempt += 1) { - const previousVersion = await this.readRowVersion(providerIdentityId); - const rowVersion = (previousVersion ?? 0) + 1; - const encrypted = await this.encryptCredential(providerIdentityId, credential, rowVersion); - const updatedAt = this.clock.now(); - - if (previousVersion === null) { - try { - const inserted = await this.prepareInsertStatement( - providerIdentityId, - credential.kind, - encrypted, - rowVersion, - updatedAt - ).run(); - if (inserted.meta.changes === 1) return rowVersion; - } catch (error) { - if (!isUniqueConstraintError(error)) throw error; - } - continue; - } - - if ( - await this.updateObservedVersion( - providerIdentityId, - previousVersion, - rowVersion, - credential.kind, - encrypted, - updatedAt - ) - ) { - return rowVersion; - } - } - - throw new ProviderCredentialVersionConflictError(); - } - - private async readRowVersion(providerIdentityId: string): Promise { - const row = await this.db - .prepare( - `SELECT row_version - FROM provider_credentials - WHERE provider_identity_id = ?` - ) - .bind(providerIdentityId) - .first>(); - if (!row) return null; - if (!isFiniteInteger(row.row_version) || row.row_version < 1) { - throw new StoredProviderCredentialCorruptError(); - } - return row.row_version; - } - - private async encryptCredential( - providerIdentityId: string, - credential: ProviderCredentialInput, - rowVersion: number - ): Promise { - const [accessTokenCiphertext, refreshTokenCiphertext] = await Promise.all([ - this.cipher.encrypt( - credential.accessToken, - cipherBinding( - providerIdentityId, - credential.kind, - "access", - CURRENT_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSION, - rowVersion - ) - ), - credential.kind === "refreshable" - ? this.cipher.encrypt( - credential.refreshToken, - cipherBinding( - providerIdentityId, - credential.kind, - "refresh", - CURRENT_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSION, - rowVersion - ) - ) - : Promise.resolve(null), - ]); - return { - accessTokenCiphertext, - accessExpiresAt: - credential.kind === "access_only_nonexpiring" ? null : credential.accessExpiresAt, - refreshTokenCiphertext, - refreshExpiresAt: credential.kind === "refreshable" ? credential.refreshExpiresAt : null, - }; - } - - private prepareInsertStatement( - providerIdentityId: string, - credentialKind: ProviderCredentialKind, - encrypted: EncryptedProviderCredential, - rowVersion: number, - updatedAt: number - ): SqlStatement { - return this.db - .prepare( - `INSERT INTO provider_credentials ( - provider_identity_id, credential_kind, - access_token_ciphertext, access_expires_at, - refresh_token_ciphertext, refresh_expires_at, - encryption_key_version, row_version, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - ) - .bind( - providerIdentityId, - credentialKind, - encrypted.accessTokenCiphertext, - encrypted.accessExpiresAt, - encrypted.refreshTokenCiphertext, - encrypted.refreshExpiresAt, - CURRENT_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSION, - rowVersion, - updatedAt - ); - } - - private async updateObservedVersion( - providerIdentityId: string, - observedRowVersion: number, - rowVersion: number, - credentialKind: ProviderCredentialKind, - encrypted: EncryptedProviderCredential, - updatedAt: number - ): Promise { - const result = await this.db - .prepare( - `UPDATE provider_credentials - SET credential_kind = ?, - access_token_ciphertext = ?, - access_expires_at = ?, - refresh_token_ciphertext = ?, - refresh_expires_at = ?, - encryption_key_version = ?, - row_version = ?, - updated_at = ? - WHERE provider_identity_id = ? AND row_version = ?` - ) - .bind( - credentialKind, - encrypted.accessTokenCiphertext, - encrypted.accessExpiresAt, - encrypted.refreshTokenCiphertext, - encrypted.refreshExpiresAt, - CURRENT_PROVIDER_CREDENTIAL_ENCRYPTION_KEY_VERSION, - rowVersion, - updatedAt, - providerIdentityId, - observedRowVersion - ) - .run(); - return result.meta.changes === 1; - } - - async get(providerIdentityId: string): Promise { - if (!isNonEmptyString(providerIdentityId)) { - throw new InvalidProviderCredentialInputError("Provider identity is required"); - } - const found = await this.db - .prepare( - `SELECT - provider_identity_id, credential_kind, access_token_ciphertext, - access_expires_at, refresh_token_ciphertext, refresh_expires_at, - encryption_key_version, row_version, updated_at - FROM provider_credentials - WHERE provider_identity_id = ?` - ) - .bind(providerIdentityId) - .first>(); - if (!found) return null; - const row = decodeProviderCredentialRow(found); - - try { - const accessToken = await this.cipher.decrypt( - row.accessTokenCiphertext, - cipherBinding( - row.providerIdentityId, - row.credentialKind, - "access", - row.encryptionKeyVersion, - row.rowVersion - ) - ); - if (!isNonEmptyString(accessToken)) { - throw new StoredProviderCredentialCorruptError(); - } - const metadata = { - providerIdentityId: row.providerIdentityId, - accessToken, - encryptionKeyVersion: row.encryptionKeyVersion, - rowVersion: row.rowVersion, - updatedAt: row.updatedAt, - }; - if (row.credentialKind === "refreshable") { - const refreshToken = await this.cipher.decrypt( - row.refreshTokenCiphertext, - cipherBinding( - row.providerIdentityId, - row.credentialKind, - "refresh", - row.encryptionKeyVersion, - row.rowVersion - ) - ); - if (!isNonEmptyString(refreshToken)) { - throw new StoredProviderCredentialCorruptError(); - } - return { - ...metadata, - kind: row.credentialKind, - accessExpiresAt: row.accessExpiresAt, - refreshToken, - refreshExpiresAt: row.refreshExpiresAt, - }; - } - if (row.credentialKind === "access_only_expiring") { - return { - ...metadata, - kind: row.credentialKind, - accessExpiresAt: row.accessExpiresAt, - }; - } - return { ...metadata, kind: row.credentialKind }; - } catch (error) { - if (error instanceof ProviderCredentialIntegrityError) { - throw new StoredProviderCredentialCorruptError(); - } - throw error; - } - } - - async invalidateObservedVersion( - providerIdentityId: string, - observedRowVersion: number - ): Promise { - validateObservedVersion(providerIdentityId, observedRowVersion); - const result = await this.db - .prepare( - `DELETE FROM provider_credentials - WHERE provider_identity_id = ? AND row_version = ?` - ) - .bind(providerIdentityId, observedRowVersion) - .run(); - return result.meta.changes === 1; - } - - async replaceObservedVersion( - providerIdentityId: string, - observedRowVersion: number, - credential: ProviderCredentialInput - ): Promise { - validateObservedVersion(providerIdentityId, observedRowVersion); - validateInput(providerIdentityId, credential); - const rowVersion = observedRowVersion + 1; - const encrypted = await this.encryptCredential(providerIdentityId, credential, rowVersion); - if ( - !(await this.updateObservedVersion( - providerIdentityId, - observedRowVersion, - rowVersion, - credential.kind, - encrypted, - this.clock.now() - )) - ) { - throw new ProviderCredentialVersionConflictError(); - } - return rowVersion; - } -} diff --git a/packages/control-plane/src/router.analytics.test.ts b/packages/control-plane/src/router.analytics.test.ts index 722e039c4..4d162fcb7 100644 --- a/packages/control-plane/src/router.analytics.test.ts +++ b/packages/control-plane/src/router.analytics.test.ts @@ -52,7 +52,9 @@ describe("analytics router integration", () => { }; const response = await handleRequest( - await signedServiceRequest("https://test.local/analytics/summary"), + await signedServiceRequest("https://test.local/analytics/summary", { + service: "modal", + }), env as never ); diff --git a/packages/control-plane/src/router.auth.test.ts b/packages/control-plane/src/router.auth.test.ts index cf0bea00a..b7f617953 100644 --- a/packages/control-plane/src/router.auth.test.ts +++ b/packages/control-plane/src/router.auth.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { handleRequest } from "./router"; -import { signedServiceRequest, TEST_SERVICE_SECRETS } from "./router.test-support"; function createEnv(verifyStatus: number) { const fetch = vi @@ -79,28 +78,18 @@ describe("router sandbox-token fallback", () => { }); }); -describe("auth token routes are SCM-agnostic", () => { - // Guards the isScmAgnosticRoute entry for /auth/tokens/*: dropping it would - // 501 exchange/refresh on non-GitHub deployments before the handlers run. - it.each(["exchange", "refresh"])( - "reaches /auth/tokens/%s under a gitlab provider", - async (route) => { - const env = { - ...TEST_SERVICE_SECRETS, - SCM_PROVIDER: "gitlab", - DB: { prepare: vi.fn(), batch: vi.fn(), exec: vi.fn(), dump: vi.fn() }, - }; - - const response = await handleRequest( - await signedServiceRequest(`https://test.local/auth/tokens/${route}`, { - method: "POST", - body: JSON.stringify({}), - }), - env as never - ); +describe("retired browser-auth routes", () => { + it.each([ + ["POST", "/auth/tokens/exchange"], + ["POST", "/auth/tokens/refresh"], + ["PUT", "/provider-identities/github/583231"], + ])("does not expose %s %s", async (method, path) => { + const { env } = createEnv(401); + const response = await handleRequest( + new Request(`https://test.local${path}`, { method }), + env as never + ); - // 400 = the handler's schema rejection; the SCM gate (501) did not fire. - expect(response.status).toBe(400); - } - ); + expect(response.status).toBe(404); + }); }); diff --git a/packages/control-plane/src/router.create-session.test.ts b/packages/control-plane/src/router.create-session.test.ts index 6ef5eec1e..aff27b100 100644 --- a/packages/control-plane/src/router.create-session.test.ts +++ b/packages/control-plane/src/router.create-session.test.ts @@ -26,13 +26,7 @@ vi.mock("./routes/shared", async (importOriginal) => { const USER_PRINCIPAL: Principal = { kind: "user", - user: { - provider: "github", - providerUserId: "583231", - canonicalUserId: "user-1", - participantUserId: "user-1", - }, - tokenId: "token-1", + userId: "user-1", }; describe("handleCreateSession D1 ordering", () => { diff --git a/packages/control-plane/src/router.provider-identities.test.ts b/packages/control-plane/src/router.provider-identities.test.ts deleted file mode 100644 index e85a94a14..000000000 --- a/packages/control-plane/src/router.provider-identities.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { handleRequest } from "./router"; - -vi.mock("./auth/web-session-tokens", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { - ...actual, - WebSessionTokenService: vi.fn(function () { - return { - verifyAccessToken: async (token: string) => ({ - ok: true, - tokenId: token, - userId: - token === "oi_at_google" - ? "fedcba9876543210fedcba9876543210" - : "0123456789abcdef0123456789abcdef", - provider: token === "oi_at_google" ? "google" : "github", - providerUserId: token === "oi_at_google" ? "google-sub-1" : "12345", - }), - }; - }), - }; -}); - -function createEnv() { - return { - SCM_PROVIDER: "gitlab", - DB: { - prepare: vi.fn(), - batch: vi.fn(), - exec: vi.fn(), - dump: vi.fn(), - }, - }; -} - -function userRequest(path: string, token: string): Request { - return new Request(`https://test.local${path}`, { - method: "PUT", - headers: { Authorization: `Bearer ${token}` }, - }); -} - -describe("provider identity router integration", () => { - it("resolves a GitHub identity for its matching user when the SCM provider is not github", async () => { - const response = await handleRequest( - userRequest("/provider-identities/github/12345", "oi_at_github"), - createEnv() as never - ); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - userId: "0123456789abcdef0123456789abcdef", - }); - }); - - it("resolves a Google identity for its matching user when the SCM provider is not github", async () => { - // Guards the widened isScmAgnosticRoute regex: a typo dropping `google` - // would make this 501 (SCM not implemented) instead of reaching the handler. - const response = await handleRequest( - userRequest("/provider-identities/google/google-sub-1", "oi_at_google"), - createEnv() as never - ); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - userId: "fedcba9876543210fedcba9876543210", - }); - }); - - it("rejects non-GitHub provider identity paths when the SCM provider is not github", async () => { - const response = await handleRequest( - userRequest("/provider-identities/gitlab/U123", "oi_at_github"), - createEnv() as never - ); - - expect(response.status).toBe(501); - await expect(response.json()).resolves.toEqual({ - error: "SCM provider 'gitlab' is not implemented in this deployment.", - }); - }); -}); diff --git a/packages/control-plane/src/router.scm-credentials.test.ts b/packages/control-plane/src/router.scm-credentials.test.ts index 5ba6189fc..aaa8528cb 100644 --- a/packages/control-plane/src/router.scm-credentials.test.ts +++ b/packages/control-plane/src/router.scm-credentials.test.ts @@ -44,6 +44,7 @@ describe("SCM credentials router provider gate", () => { const response = await handleRequest( await signedServiceRequest("https://test.local/sessions/session-1/scm-credentials", { method: "POST", + service: "modal", }), env as never ); @@ -58,7 +59,9 @@ describe("SCM credentials router provider gate", () => { const { env, fetch } = createEnv(); const response = await handleRequest( - await signedServiceRequest("https://test.local/sessions/session-1/tunnel-urls"), + await signedServiceRequest("https://test.local/sessions/session-1/tunnel-urls", { + service: "modal", + }), env as never ); @@ -102,6 +105,7 @@ describe("SCM credentials router provider gate", () => { const response = await handleRequest( await signedServiceRequest("https://test.local/sessions/session-1/pr", { method: "POST", + service: "modal", }), env as never ); diff --git a/packages/control-plane/src/router.session-prompt.test.ts b/packages/control-plane/src/router.session-prompt.test.ts index dce816c43..d352f560e 100644 --- a/packages/control-plane/src/router.session-prompt.test.ts +++ b/packages/control-plane/src/router.session-prompt.test.ts @@ -1,41 +1,53 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { UserStore } from "./db/user-store"; +import { resolveGitHubEnrichmentForRequest } from "./session/identity"; import { handleRequest } from "./router"; +import { signedServiceRequest, TEST_SERVICE_SECRETS } from "./router.test-support"; vi.mock("./db/user-store", () => ({ UserStore: vi.fn(), })); -// Prompts attribute to the verified principal, never a body field. Resolve -// the bearer token to a fixed user principal so the tests exercise the -// principal-derived author path through the real router. -vi.mock("./auth/web-session-tokens", async (importOriginal) => { +vi.mock("./session/identity", async (importOriginal) => { const actual = (await importOriginal()) as Record; return { ...actual, - WebSessionTokenService: vi.fn(function () { - return { - verifyAccessToken: async () => ({ - ok: true, - tokenId: "token-1", - userId: "user-1", - provider: "github", - providerUserId: "583231", - }), - }; - }), + resolveGitHubEnrichmentForRequest: vi.fn(), }; }); -function userPromptRequest(body: Record): Request { - return new Request("https://test.local/sessions/session-1/prompt", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer oi_at_test-token", +vi.mock("./auth/user/runtime", () => ({ + getUserAuth: vi.fn(() => ({ + api: { + listUserAccounts: vi.fn(async () => [ + { + providerId: "github", + accountId: "583231", + userId: "user-1", + }, + ]), + }, + })), +})); + +vi.mock("./auth/user/session-authenticator", () => ({ + SessionIntegrityError: class SessionIntegrityError extends Error {}, + authenticateSession: vi.fn(async () => ({ + userId: "user-1", + authentication: { + mechanism: "browser_session", + credentialId: "session-1", + channel: { kind: "sig1", service: "web" }, }, + })), +})); + +function userPromptRequest(body: Record): Promise { + return signedServiceRequest("https://test.local/sessions/session-1/prompt", { + method: "POST", body: JSON.stringify(body), + headers: { Cookie: "__Secure-openinspect.session_token=session.signature" }, }); } @@ -47,6 +59,7 @@ function createEnv(sessionFetch: ReturnType): Record ({ meta: { changes: 0 } })), }; return { + ...TEST_SERVICE_SECRETS, SCM_PROVIDER: "github", DB: { prepare: vi.fn(() => statement), @@ -70,16 +83,14 @@ describe("session prompt identity enrichment", () => { vi.mocked(UserStore).mockImplementation(function () { return { getUserById: async () => ({ id: "user-1", displayName: "Trusted Ada" }), - getIdentitiesForUser: async () => [ - { - provider: "github", - providerUserId: "1001", - providerLogin: "ada", - providerEmail: "private@example.com", - }, - ], } as never; }); + vi.mocked(resolveGitHubEnrichmentForRequest).mockResolvedValue({ + scmUserId: "1001", + scmLogin: "ada", + displayName: "Trusted Ada", + email: "1001+ada@users.noreply.github.com", + }); const sessionFetch = vi.fn(async (request: Request) => { const body = (await request.json()) as Record; expect(body).toMatchObject({ @@ -97,7 +108,7 @@ describe("session prompt identity enrichment", () => { return Response.json({ status: "queued" }); }); const response = await handleRequest( - userPromptRequest({ content: "Fix the bug" }), + await userPromptRequest({ content: "Fix the bug" }), createEnv(sessionFetch) as never ); @@ -120,7 +131,7 @@ describe("session prompt identity enrichment", () => { return Response.json({ status: "queued" }); }); const response = await handleRequest( - userPromptRequest({ content: "Fix the bug" }), + await userPromptRequest({ content: "Fix the bug" }), createEnv(sessionFetch) as never ); @@ -132,9 +143,9 @@ describe("session prompt identity enrichment", () => { vi.mocked(UserStore).mockImplementation(function () { return { getUserById: async () => ({ id: "user-1", displayName: "Unlinked User" }), - getIdentitiesForUser: async () => [], } as never; }); + vi.mocked(resolveGitHubEnrichmentForRequest).mockResolvedValue(null); const sessionFetch = vi.fn(async (request: Request) => { const body = (await request.json()) as Record; expect(body.authorId).toBe("user-1"); @@ -142,7 +153,7 @@ describe("session prompt identity enrichment", () => { return Response.json({ status: "queued" }); }); const response = await handleRequest( - userPromptRequest({ content: "Fix the bug" }), + await userPromptRequest({ content: "Fix the bug" }), createEnv(sessionFetch) as never ); @@ -153,7 +164,7 @@ describe("session prompt identity enrichment", () => { it("rejects a caller-asserted authorId without forwarding to the runtime", async () => { const sessionFetch = vi.fn(async () => Response.json({ status: "queued" })); const response = await handleRequest( - userPromptRequest({ content: "Fix the bug", authorId: "someone-else" }), + await userPromptRequest({ content: "Fix the bug", authorId: "someone-else" }), createEnv(sessionFetch) as never ); diff --git a/packages/control-plane/src/router.spawn-child.test.ts b/packages/control-plane/src/router.spawn-child.test.ts index 774b54ff1..227bac0b7 100644 --- a/packages/control-plane/src/router.spawn-child.test.ts +++ b/packages/control-plane/src/router.spawn-child.test.ts @@ -90,6 +90,7 @@ describe("handleSpawnChild prompt enqueue handling", () => { await signedServiceRequest(`https://test.local/sessions/${parentId}/children`, { method: "POST", body: JSON.stringify({ title: "Child task", prompt: "Do the thing" }), + service: "modal", }), env as never ); @@ -217,6 +218,7 @@ describe("handleSpawnChild prompt enqueue handling", () => { const response = await handleRequest( await signedServiceRequest(`https://test.local/sessions/${parentId}/children`, { method: "POST", + service: "modal", body: JSON.stringify({ title: "Child task", prompt: "Do the thing", @@ -278,6 +280,7 @@ describe("handleSpawnChild prompt enqueue handling", () => { const response = await handleRequest( await signedServiceRequest(`https://test.local/sessions/${parentId}/children`, { method: "POST", + service: "modal", body: JSON.stringify({ title: "Child task" }), }), env as never @@ -414,6 +417,7 @@ describe("handleSpawnChild prompt enqueue handling", () => { const response = await handleRequest( await signedServiceRequest(`https://test.local/sessions/${parentId}/children`, { method: "POST", + service: "modal", body: JSON.stringify({ title: "Child task", prompt: "Do the thing", diff --git a/packages/control-plane/src/router.ts b/packages/control-plane/src/router.ts index 16f62c294..238f2520e 100644 --- a/packages/control-plane/src/router.ts +++ b/packages/control-plane/src/router.ts @@ -3,8 +3,10 @@ */ import type { Env } from "./types"; +import { isBrowserAuthProxyRoute } from "@open-inspect/shared"; import { authenticate, isAuthError } from "./auth/authenticate"; import type { Principal } from "./auth/principal"; +import { getUserAuth } from "./auth/user/runtime"; import { resolveScmProviderFromEnv, SourceControlProviderError, @@ -23,7 +25,7 @@ import { error, HttpError, } from "./routes/shared"; -import { authTokenRoutes } from "./routes/auth-tokens"; +import { browserAuthRoutes } from "./routes/browser-auth"; import { integrationSettingsRoutes } from "./routes/integration-settings"; import { commitSigningRoutes } from "./routes/commit-signing"; import { modelPreferencesRoutes } from "./routes/model-preferences"; @@ -35,7 +37,6 @@ import { imageBuildRoutes } from "./routes/image-builds"; import { automationRoutes } from "./routes/automations"; import { mcpServerRoutes } from "./routes/mcp-servers"; import { analyticsRoutes } from "./routes/analytics"; -import { providerIdentityRoutes } from "./routes/provider-identities"; import { sessionRoutes } from "./routes/sessions"; import { handleSlackNotify } from "./routes/slack-notify"; import { webhookRoutes } from "./webhooks"; @@ -157,14 +158,10 @@ function isSandboxAuthOnlyRoute(path: string): boolean { return SANDBOX_AUTH_ONLY_ROUTES.some((pattern) => pattern.test(path)); } -function isScmAgnosticRoute(path: string): boolean { +function isScmAgnosticRoute(method: string, path: string): boolean { return ( - // Token issuance is identity work, independent of the SCM provider. - /^\/auth\/tokens\/(exchange|refresh)$/.test(path) || + isBrowserAuthProxyRoute(method, path) || /^\/analytics\/(summary|timeseries|breakdown|pull-requests)$/.test(path) || - // Identity resolution is independent of the SCM provider. Only the known - // auth providers are agnostic; an unimplemented SCM (e.g. gitlab) still 501s. - /^\/provider-identities\/(github|slack|linear|google)\/[^/]+$/.test(path) || /^\/sessions\/[^/]+\/(tunnel-urls|commit-signing)$/.test(path) || /^\/sessions\/[^/]+\/diff(?:\/.*)?$/.test(path) ); @@ -176,6 +173,7 @@ function isProviderImplementedRoute(provider: SourceControlProviderName, path: s } function enforceImplementedScmProvider( + method: string, path: string, env: Env, ctx: RequestContext @@ -185,7 +183,7 @@ function enforceImplementedScmProvider( if ( !isProviderImplementedRoute(provider, path) && !isPublicRoute(path) && - !isScmAgnosticRoute(path) + !isScmAgnosticRoute(method, path) ) { logger.warn("SCM provider not implemented", { event: "scm.provider_not_implemented", @@ -292,7 +290,7 @@ function logPrincipal(principal: Principal, ctx: RequestContext, path: string): fields.session_id = principal.sessionId; break; case "user": - fields.user_id = principal.user.canonicalUserId ?? undefined; + fields.user_id = principal.userId; break; } logger.info("auth.principal", { @@ -315,8 +313,7 @@ const routes: Route[] = [ handler: async () => json({ status: "healthy", service: "open-inspect-control-plane" }), }, - // Token issuance (exchange + refresh; web service principal only) - ...authTokenRoutes, + ...browserAuthRoutes, // Session management ...sessionRoutes, @@ -358,9 +355,6 @@ const routes: Route[] = [ // Analytics ...analyticsRoutes, - // Provider identities - ...providerIdentityRoutes, - // Webhooks (public routes — auth handled per-route) ...webhookRoutes, ]; @@ -401,6 +395,8 @@ export async function handleRequest( metrics, // eslint-disable-next-line no-restricted-syntax -- composition root: the one route-layer env.DB read db: instrumentD1(env.DB, metrics), + // eslint-disable-next-line no-restricted-syntax -- composition root injects the raw D1 adapter required by Better Auth + getUserAuth: () => getUserAuth(env, env.DB), executionCtx, }; @@ -418,6 +414,17 @@ export async function handleRequest( }); } + const matchedRoute = routes + .filter((route) => route.method === method) + .map((route) => ({ route, match: path.match(route.pattern) })) + .find( + (candidate): candidate is { route: Route; match: RegExpMatchArray } => + candidate.match !== null + ); + if (!matchedRoute) { + return withCorsAndTraceHeaders(error("Not found", 404), ctx); + } + // Require authentication for non-public routes if (!isPublicRoute(path)) { const requiresSandboxAuth = isSandboxAuthOnlyRoute(path); @@ -431,12 +438,14 @@ export async function handleRequest( ? await verifySandboxAuth(request, env, sandboxSessionId, ctx) : error("Unauthorized: Invalid session path", 401); } else { - const authResult = await authenticate(request, env, ctx); + const authResult = await authenticate(request, env, ctx, { + webService: isBrowserAuthProxyRoute(method, path) ? "service" : "user", + }); if (isAuthError(authResult)) { - // A service-credential or user-token attempt is terminal; only a - // request with no recognized credential may still be a sandbox-token - // call on a sandbox-accepting route. + // A service-credential attempt is terminal; only a request with no + // recognized credential may still be a sandbox-token call on a + // sandbox-accepting route. authError = error(authResult.reason, authResult.status); if ( @@ -449,6 +458,7 @@ export async function handleRequest( } else { authError = null; ctx.principal = authResult.principal; + ctx.authentication = authResult.authentication; request = authResult.request; } } @@ -462,60 +472,50 @@ export async function handleRequest( } } - const providerCheck = enforceImplementedScmProvider(path, env, ctx); + const providerCheck = enforceImplementedScmProvider(method, path, env, ctx); if (providerCheck) { return providerCheck; } - // Find matching route - for (const route of routes) { - if (route.method !== method) continue; - - const match = path.match(route.pattern); - if (match) { - let response: Response; - let outcome: "success" | "error"; - try { - response = await route.handler(request, env, match, ctx); - outcome = response.status >= 500 ? "error" : "success"; - } catch (e) { - if (e instanceof HttpError) { - response = error(e.message, e.status); - outcome = e.status >= 500 ? "error" : "success"; - } else { - const durationMs = Date.now() - startTime; - logger.error("http.request", { - event: "http.request", - request_id: ctx.request_id, - trace_id: ctx.trace_id, - http_method: method, - http_path: path, - http_status: 500, - duration_ms: durationMs, - outcome: "error", - error: e instanceof Error ? e : String(e), - ...ctx.metrics.summarize(), - }); - return withCorsAndTraceHeaders(error("Internal server error", 500), ctx); - } - } - + let response: Response; + let outcome: "success" | "error"; + try { + response = await matchedRoute.route.handler(request, env, matchedRoute.match, ctx); + outcome = response.status >= 500 ? "error" : "success"; + } catch (e) { + if (e instanceof HttpError) { + response = error(e.message, e.status); + outcome = e.status >= 500 ? "error" : "success"; + } else { const durationMs = Date.now() - startTime; - logger.info("http.request", { + logger.error("http.request", { event: "http.request", request_id: ctx.request_id, trace_id: ctx.trace_id, http_method: method, http_path: path, - http_status: response.status, + http_status: 500, duration_ms: durationMs, - outcome, + outcome: "error", + error: e instanceof Error ? e : String(e), ...ctx.metrics.summarize(), }); - - return withCorsAndTraceHeaders(response, ctx); + return withCorsAndTraceHeaders(error("Internal server error", 500), ctx); } } - return error("Not found", 404); + const durationMs = Date.now() - startTime; + logger.info("http.request", { + event: "http.request", + request_id: ctx.request_id, + trace_id: ctx.trace_id, + http_method: method, + http_path: path, + http_status: response.status, + duration_ms: durationMs, + outcome, + ...ctx.metrics.summarize(), + }); + + return withCorsAndTraceHeaders(response, ctx); } diff --git a/packages/control-plane/src/routes/auth-tokens.ts b/packages/control-plane/src/routes/auth-tokens.ts deleted file mode 100644 index 93478aebb..000000000 --- a/packages/control-plane/src/routes/auth-tokens.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Token issuance for web user identity: the exchange and the - * refresh grant. Internal, service-authenticated (`web` only) — this is NOT - * the public OAuth surface (that is P2's job; both feed the same store). - */ - -import { z } from "zod"; - -import { SUBJECT_TOKEN_TYPES } from "../auth/subject-verification"; -import { performExchange } from "../auth/token-exchange"; -import { WebSessionTokenService, type WebSessionTokenPair } from "../auth/web-session-tokens"; -import { ApiTokenStore } from "../db/api-tokens"; -import { createLogger } from "../logger"; -import type { Env } from "../types"; -import { type RequestContext, type Route, error, json, parsePattern } from "./shared"; - -const logger = createLogger("auth-tokens"); - -const exchangeRequestSchema = z.strictObject({ - subjectTokenType: z.enum(SUBJECT_TOKEN_TYPES), - subjectToken: z.string().min(1), - scmRefreshToken: z.string().min(1).optional(), - scmTokenExpiresAt: z.number().int().positive().optional(), -}); - -const refreshRequestSchema = z.strictObject({ - refreshToken: z.string().min(1), -}); - -/** Only web's own service credential may mint or refresh user tokens. */ -function requireWebServicePrincipal(ctx: RequestContext): Response | null { - const principal = ctx.principal; - if (!principal || principal.kind !== "service" || principal.service !== "web") { - return error("exchange_forbidden", 403); - } - return null; -} - -function createTokenService(ctx: RequestContext): WebSessionTokenService { - return new WebSessionTokenService(new ApiTokenStore(ctx.db)); -} - -/** WebSessionTokenPair is exactly the wire shape — the pair is the body. */ -function tokenPairResponse(pair: WebSessionTokenPair): Response { - return json(pair); -} - -async function handleTokenExchange( - request: Request, - env: Env, - _match: RegExpMatchArray, - ctx: RequestContext -): Promise { - const gate = requireWebServicePrincipal(ctx); - if (gate) return gate; - - const raw: unknown = await request.json().catch(() => null); - const parsed = exchangeRequestSchema.safeParse(raw); - if (!parsed.success) { - return error("invalid_request", 400); - } - const body = parsed.data; - - const result = await performExchange( - body, - ctx.db, - createTokenService(ctx), - env.TOKEN_ENCRYPTION_KEY - ); - if (!result.ok) { - logger.warn("Token exchange rejected", { - event: "auth.token.exchange_rejected", - subject_token_type: body.subjectTokenType, - failure: result.failure, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - return result.failure === "subject_rejected" - ? error("subject_rejected", 401) - : error("provider_unavailable", 502); - } - - logger.info("Web session token pair minted", { - event: "auth.token.minted", - user_id: result.userId, - provider: result.provider, - token_kind: "web_session", - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - - return tokenPairResponse(result.pair); -} - -async function handleTokenRefresh( - request: Request, - _env: Env, - _match: RegExpMatchArray, - ctx: RequestContext -): Promise { - const gate = requireWebServicePrincipal(ctx); - if (gate) return gate; - - const raw: unknown = await request.json().catch(() => null); - const parsed = refreshRequestSchema.safeParse(raw); - if (!parsed.success) { - return error("invalid_request", 400); - } - - const redemption = await createTokenService(ctx).redeemRefreshToken(parsed.data.refreshToken); - if (!redemption.ok) { - if (redemption.failure === "refresh_superseded") { - // Benign concurrent renewal — the winner's pair is live. Info, not - // warn: this is expected multi-tab/wake concurrency, not a fault. - logger.info("Refresh grant superseded by a concurrent renewal", { - event: "auth.token.refresh_superseded", - family_id: redemption.familyId, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - } else { - logger.warn("Refresh grant rejected", { - event: - redemption.failure === "refresh_reuse_detected" - ? "auth.token.refresh_reuse_detected" - : "auth.token.refresh_rejected", - failure: redemption.failure, - family_id: redemption.familyId, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - } - return error(redemption.failure, 401); - } - - logger.info("Web session token pair refreshed", { - event: "auth.token.refreshed", - user_id: redemption.userId, - family_id: redemption.familyId, - request_id: ctx.request_id, - trace_id: ctx.trace_id, - }); - - return tokenPairResponse(redemption.pair); -} - -export const authTokenRoutes: Route[] = [ - { method: "POST", pattern: parsePattern("/auth/tokens/exchange"), handler: handleTokenExchange }, - { method: "POST", pattern: parsePattern("/auth/tokens/refresh"), handler: handleTokenRefresh }, -]; diff --git a/packages/control-plane/src/routes/automations.test.ts b/packages/control-plane/src/routes/automations.test.ts index 75a2bac95..8da2002a4 100644 --- a/packages/control-plane/src/routes/automations.test.ts +++ b/packages/control-plane/src/routes/automations.test.ts @@ -117,13 +117,7 @@ function createEnv(): Env { const USER_PRINCIPAL: Principal = { kind: "user", - user: { - provider: "github", - providerUserId: "583231", - canonicalUserId: "user-1", - participantUserId: "user-1", - }, - tokenId: "token-1", + userId: "user-1", }; const SLACK_BOT_PRINCIPAL: Principal = { diff --git a/packages/control-plane/src/routes/browser-auth.test.ts b/packages/control-plane/src/routes/browser-auth.test.ts new file mode 100644 index 000000000..363433e1b --- /dev/null +++ b/packages/control-plane/src/routes/browser-auth.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import { forwardBrowserAuthRequest } from "./browser-auth"; + +describe("forwardBrowserAuthRequest", () => { + it("uses the direct API wrapper for session lookup", async () => { + const getSession = vi.fn(async () => Response.json({ user: { id: "user-1" } })); + const handler = vi.fn(async () => { + throw new Error("HTTP handler should not serve session lookup"); + }); + const auth = { + api: { getSession }, + handler, + } as never; + const request = new Request("https://control-plane.test/api/auth/get-session", { + headers: { Cookie: "session=value" }, + }); + + const response = await forwardBrowserAuthRequest(auth, request); + + expect(response.status).toBe(200); + expect(getSession).toHaveBeenCalledWith({ + headers: request.headers, + asResponse: true, + }); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/control-plane/src/routes/browser-auth.ts b/packages/control-plane/src/routes/browser-auth.ts new file mode 100644 index 000000000..c27d108b1 --- /dev/null +++ b/packages/control-plane/src/routes/browser-auth.ts @@ -0,0 +1,91 @@ +import { BROWSER_AUTH_PROXY_ROUTES } from "@open-inspect/shared"; +import { type BetterAuthRuntime, UserAuthConfigurationError } from "../auth/user/runtime"; +import { createLogger } from "../logger"; +import { error, parsePattern, type Route } from "./shared"; + +const logger = createLogger("browser-auth"); + +function copyBrowserAuthResponseHeaders(upstream: Headers): Headers { + const headers = new Headers(); + upstream.forEach((value, name) => { + if (name.toLowerCase() !== "set-cookie") { + headers.append(name, value); + } + }); + const getSetCookie = (upstream as Headers & { getSetCookie?: () => string[] }).getSetCookie; + const setCookieValues = getSetCookie?.call(upstream) ?? []; + if (setCookieValues.length === 0) { + const value = upstream.get("Set-Cookie"); + if (value) setCookieValues.push(value); + } + for (const value of setCookieValues) { + headers.append("Set-Cookie", value); + } + return headers; +} + +/** + * Better Auth's direct API establishes its request-state context explicitly. + * Use it for session reads because Cloudflare Workers can lose the HTTP + * handler's AsyncLocalStorage state before session-refresh policy is read. + */ +export async function forwardBrowserAuthRequest( + auth: BetterAuthRuntime, + request: Request +): Promise { + const url = new URL(request.url); + if (request.method === "GET" && url.pathname === "/api/auth/get-session") { + return auth.api.getSession({ + headers: request.headers, + asResponse: true, + }); + } + return auth.handler(request); +} + +function requireWebService(route: Route["handler"]): Route["handler"] { + return async (request, env, match, ctx) => { + if (ctx.principal?.kind !== "service" || ctx.principal.service !== "web") { + return error("Unauthorized", 401); + } + return route(request, env, match, ctx); + }; +} + +const handleBrowserAuth: Route["handler"] = async (request, _env, _match, ctx) => { + try { + if (!ctx.getUserAuth) { + throw new UserAuthConfigurationError("User authentication runtime is unavailable"); + } + const response = await forwardBrowserAuthRequest(ctx.getUserAuth(), request); + const headers = copyBrowserAuthResponseHeaders(response.headers); + headers.set("Cache-Control", "no-store"); + headers.set("Referrer-Policy", "no-referrer"); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + } catch (cause) { + if (cause instanceof UserAuthConfigurationError) { + logger.error("Browser authentication is not configured", { + event: "auth.browser.misconfigured", + error: cause, + request_id: ctx.request_id, + trace_id: ctx.trace_id, + }); + return error("Browser authentication is not configured", 503); + } + throw cause; + } +}; + +/** + * The browser can reach only this positive Better Auth allowlist, and only + * through a freshly signed service:web proxy request. + */ +export const browserAuthRoutes: Route[] = BROWSER_AUTH_PROXY_ROUTES.map(([method, path]) => ({ + method, + pattern: parsePattern(path), + handler: requireWebService(handleBrowserAuth), +})); diff --git a/packages/control-plane/src/routes/provider-identities.test.ts b/packages/control-plane/src/routes/provider-identities.test.ts deleted file mode 100644 index 1950063c6..000000000 --- a/packages/control-plane/src/routes/provider-identities.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { SqlDatabase } from "../db/sql-database"; -import type { Env } from "../types"; -import { providerIdentityRoutes } from "./provider-identities"; -import type { RequestContext } from "./shared"; - -function createEnv(): Env { - return { - DB: {} as D1Database, - } as Env; -} - -function createCtx(): RequestContext { - return { - trace_id: "trace-1", - request_id: "req-1", - db: {} as SqlDatabase, - principal: { kind: "service", service: "web", actor: null }, - metrics: { - d1Queries: [], - spans: {}, - time: async (_name: string, fn: () => Promise) => fn(), - summarize: () => ({}), - }, - }; -} - -function userCtx( - canonicalUserId: string, - provider: "github" | "google" | "slack" | "linear" = "github", - providerUserId = "12345" -): RequestContext { - return { - ...createCtx(), - principal: { - kind: "user", - user: { - provider, - providerUserId, - canonicalUserId, - participantUserId: canonicalUserId, - }, - tokenId: "tok-1", - }, - }; -} - -async function callProviderIdentityRoute( - path: string, - ctx: RequestContext = createCtx(), - body?: unknown -): Promise { - const route = providerIdentityRoutes.find((candidate) => candidate.method === "PUT")!; - const match = path.match(route.pattern); - if (!match) throw new Error(`No route match for ${path}`); - - return route.handler( - new Request(`https://test.local${path}`, { - method: "PUT", - ...(body === undefined - ? {} - : { - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }), - }), - createEnv(), - match, - ctx - ); -} - -describe("PUT /provider-identities/:provider/:providerUserId", () => { - it("denies the web service now that identity creation happens only during token exchange", async () => { - vi.spyOn(console, "warn").mockImplementation(() => undefined); - - const response = await callProviderIdentityRoute( - "/provider-identities/github/12345", - createCtx(), - { - providerEmail: "victim@example.com", - } - ); - - expect(response.status).toBe(403); - }); - - it("matches every supported provider identity path and captures the provider", () => { - const route = providerIdentityRoutes.find((candidate) => candidate.method === "PUT")!; - - for (const [path, provider, providerUserId] of [ - ["/provider-identities/github/12345", "github", "12345"], - ["/provider-identities/slack/U123", "slack", "U123"], - ["/provider-identities/linear/abc", "linear", "abc"], - ["/provider-identities/google/google-sub-1", "google", "google-sub-1"], - ] as const) { - const match = path.match(route.pattern); - expect(match?.groups).toMatchObject({ provider, providerUserId }); - } - }); - - it("rejects unsupported providers before authorization", async () => { - const response = await callProviderIdentityRoute("/provider-identities/gitlab/U123"); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ - error: "provider must be one of: github, slack, linear, google", - }); - }); - - it("rejects blank provider user IDs", async () => { - const response = await callProviderIdentityRoute("/provider-identities/github/%20%20%20"); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ error: "providerUserId is required" }); - }); - - it("rejects invalid path encoding for provider user IDs", async () => { - const response = await callProviderIdentityRoute("/provider-identities/github/%E0%A4%A"); - - expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ error: "providerUserId is required" }); - }); - - it("returns a matching user's token-fixed canonical id without requiring a body", async () => { - const response = await callProviderIdentityRoute( - "/provider-identities/github/12345", - userCtx("0123456789abcdef0123456789abcdef") - ); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - userId: "0123456789abcdef0123456789abcdef", - }); - }); - - it("ignores body identity fields when resolving the matching user", async () => { - const response = await callProviderIdentityRoute( - "/provider-identities/github/12345", - userCtx("0123456789abcdef0123456789abcdef"), - { providerEmail: "victim@example.com" } - ); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ - userId: "0123456789abcdef0123456789abcdef", - }); - }); - - it("403s a user principal targeting a different identity path", async () => { - vi.spyOn(console, "warn").mockImplementation(() => undefined); - const response = await callProviderIdentityRoute( - "/provider-identities/github/999999", - userCtx("0123456789abcdef0123456789abcdef") - ); - - expect(response.status).toBe(403); - }); -}); diff --git a/packages/control-plane/src/routes/provider-identities.ts b/packages/control-plane/src/routes/provider-identities.ts deleted file mode 100644 index 9cdf4924c..000000000 --- a/packages/control-plane/src/routes/provider-identities.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { authorizeProviderIdentityRequest } from "../auth/identity-enforcement"; -import type { Env } from "../types"; -import { type RequestContext, type Route, error, json, parsePattern } from "./shared"; - -/** Providers that may be resolved through this authenticated route. */ -const ALLOWED_PROVIDERS = ["github", "slack", "linear", "google"] as const; -type AllowedProvider = (typeof ALLOWED_PROVIDERS)[number]; - -function isAllowedProvider(value: string | undefined): value is AllowedProvider { - return value !== undefined && (ALLOWED_PROVIDERS as readonly string[]).includes(value); -} - -function pathSegment(value: string | undefined): string | undefined { - if (!value) return undefined; - try { - const decoded = decodeURIComponent(value).trim(); - return decoded.length > 0 ? decoded : undefined; - } catch { - return undefined; - } -} - -export async function handleResolveProviderIdentity( - _request: Request, - _env: Env, - match: RegExpMatchArray, - ctx: RequestContext -): Promise { - const provider = match.groups?.provider; - if (!isAllowedProvider(provider)) { - return error(`provider must be one of: ${ALLOWED_PROVIDERS.join(", ")}`, 400); - } - - const providerUserId = pathSegment(match.groups?.providerUserId); - if (!providerUserId) { - return error("providerUserId is required", 400); - } - - const authz = authorizeProviderIdentityRequest(ctx, provider, providerUserId); - if (authz.action === "deny") return authz.response; - // The matching user token already fixes the canonical id. The request body - // is deliberately ignored so this route cannot mutate identity linkage. - return json({ userId: authz.canonicalUserId }); -} - -export const providerIdentityRoutes: Route[] = [ - { - method: "PUT", - pattern: parsePattern("/provider-identities/:provider/:providerUserId"), - handler: handleResolveProviderIdentity, - }, -]; diff --git a/packages/control-plane/src/routes/session-create.ts b/packages/control-plane/src/routes/session-create.ts index acbc9c0e5..0ce2361a7 100644 --- a/packages/control-plane/src/routes/session-create.ts +++ b/packages/control-plane/src/routes/session-create.ts @@ -4,6 +4,7 @@ import { type RepositoryRef, } from "@open-inspect/shared"; import { generateId } from "../auth/crypto"; +import { resolveGitHubCredentialAuthority } from "../source-control/github-credential-authority"; import { applyIdentityEnforcement, resolveCanonicalUserId } from "../auth/identity-enforcement"; import { resolveEnvironmentTarget, resolveSessionRepositories } from "../repos/resolve"; import { resolveScmProviderFromEnv } from "../source-control"; @@ -12,7 +13,7 @@ import { UserStore } from "../db/user-store"; import { createLogger } from "../logger"; import { parseCreateSessionInput } from "../session/create-session-input"; import { initializeSession, type SessionInitInput } from "../session/initialize"; -import { resolveGitHubEnrichment } from "../session/identity"; +import { resolveGitHubEnrichmentForRequest } from "../session/identity"; import { resolveSessionScopedSettings } from "../session/integration-settings-resolution"; import type { CreateSessionResponse, Env } from "../types"; import { @@ -136,22 +137,19 @@ async function handleCreateSession( let scmTokenEncrypted: string | null = null; let scmRefreshTokenEncrypted: string | null = null; - // On GitHub deployments, enrich the owner with their linked GitHub identity - // from D1: fill in SCM fields the caller didn't provide (email, display name, - // OAuth token). Other SCM deployments retain their provider-native identity - // and credentials unchanged. - // - // This intentionally applies even when the session was authenticated via a - // non-GitHub provider (e.g. Google): if the canonical user has ALSO linked a - // verified-email GitHub identity, enrichment surfaces THAT identity's token so - // the same human keeps GitHub-attributed commits/PRs. resolveGitHubEnrichment - // keys off the linked `provider === "github"` identity, never the Google - // credential; a user with no linked GitHub identity gets null here and falls - // back to the App bot. The invariant is "a Google credential is never used as - // an SCM credential", not "a Google-authenticated session carries no SCM state". + // Browser sessions resolve a linked GitHub identity/token through Better + // Auth only when SCM enrichment is needed. Transitional callers retain the + // legacy D1 lookup. A user without a linked GitHub account uses the GitHub + // App bot fallback; account linking is intentionally deferred. if (githubDeployment) { try { - const enrichment = await resolveGitHubEnrichment(env, ctx.db, userStore, resolvedUserId); + const enrichment = await resolveGitHubEnrichmentForRequest( + env, + ctx.db, + userStore, + resolvedUserId, + await resolveGitHubCredentialAuthority(ctx, request.headers) + ); if (enrichment) { scmUserId = enrichment.scmUserId; scmLogin ??= enrichment.scmLogin; diff --git a/packages/control-plane/src/routes/session-prompt.ts b/packages/control-plane/src/routes/session-prompt.ts index 7ae518f8e..d69a08d77 100644 --- a/packages/control-plane/src/routes/session-prompt.ts +++ b/packages/control-plane/src/routes/session-prompt.ts @@ -5,11 +5,16 @@ import { type SessionAttachmentReference, } from "@open-inspect/shared"; import { applyIdentityEnforcement, mayAttachCallbackContext } from "../auth/identity-enforcement"; +import { resolveGitHubCredentialAuthority } from "../source-control/github-credential-authority"; import { SessionIndexStore } from "../db/session-index"; import { UserStore } from "../db/user-store"; import { createLogger } from "../logger"; import { SessionInternalPaths } from "../session/contracts"; -import { parseAuthorId, resolveGitHubEnrichment, type GitHubEnrichment } from "../session/identity"; +import { + parseAuthorId, + resolveGitHubEnrichmentForRequest, + type GitHubEnrichment, +} from "../session/identity"; import type { Env } from "../types"; import { error, parsePattern, type Route } from "./shared"; import { sessionRoute, type SessionRouteContext } from "./session-route"; @@ -85,7 +90,14 @@ async function handleSessionPrompt( userId = (await userStore.getUserById(authorId))?.id; } if (userId) { - enrichment = (await resolveGitHubEnrichment(env, ctx.db, userStore, userId)) ?? undefined; + enrichment = + (await resolveGitHubEnrichmentForRequest( + env, + ctx.db, + userStore, + userId, + await resolveGitHubCredentialAuthority(ctx, request.headers) + )) ?? undefined; } } catch (e) { logger.warn("Failed to enrich prompt with GitHub identity", { diff --git a/packages/control-plane/src/routes/session-runtime-proxy.test.ts b/packages/control-plane/src/routes/session-runtime-proxy.test.ts index 717d87676..cbdef115a 100644 --- a/packages/control-plane/src/routes/session-runtime-proxy.test.ts +++ b/packages/control-plane/src/routes/session-runtime-proxy.test.ts @@ -12,13 +12,7 @@ function createCtx(): RequestContext { db: {} as SqlDatabase, principal: { kind: "user", - user: { - provider: "github", - providerUserId: "583231", - canonicalUserId: "user-1", - participantUserId: "user-1", - }, - tokenId: "token-1", + userId: "user-1", }, metrics: { d1Queries: [], diff --git a/packages/control-plane/src/routes/shared.ts b/packages/control-plane/src/routes/shared.ts index 5ef96adbe..d28c7b624 100644 --- a/packages/control-plane/src/routes/shared.ts +++ b/packages/control-plane/src/routes/shared.ts @@ -4,11 +4,12 @@ import { decodeRepositoryPathSegments } from "@open-inspect/shared"; import type { CorrelationContext } from "../logger"; -import type { Principal } from "../auth/principal"; +import type { AuthenticationContext, Principal } from "../auth/principal"; import type { RequestMetrics } from "../db/instrumented-d1"; import type { SqlDatabase } from "../db/sql-database"; import type { Env } from "../types"; import type { Logger } from "../logger"; +import type { BetterAuthRuntime } from "../auth/user/runtime"; import { createSourceControlProviderFromEnv, SourceControlProviderError, @@ -30,11 +31,15 @@ export type RequestContext = CorrelationContext & { db: SqlDatabase; /** Worker ExecutionContext for waitUntil (background tasks). */ executionCtx?: ExecutionContext; + /** Lazy runtime dependency used by user-session authentication and credential access. */ + getUserAuth?: () => BetterAuthRuntime; /** * The request's verified principal. Absent only on public routes and CORS * preflights — every authenticated request carries one. */ principal?: Principal; + /** Authentication provenance, separate from the principal being authorized. */ + authentication?: AuthenticationContext; }; /** diff --git a/packages/control-plane/src/scheduler/durable-object.ts b/packages/control-plane/src/scheduler/durable-object.ts index 0c5813834..92de0686d 100644 --- a/packages/control-plane/src/scheduler/durable-object.ts +++ b/packages/control-plane/src/scheduler/durable-object.ts @@ -22,7 +22,7 @@ import { type TriggerConfig, } from "@open-inspect/shared"; import { z } from "zod"; -import { callbackSigningSecret } from "../auth/callback-signing"; +import { callbackSigningSecret } from "../auth/service/callback-signing"; import { AutomationStore, toAutomationRun, @@ -34,7 +34,6 @@ import { type AutomationRepositoryInsert, type AutomationEnvironmentRow, } from "../db/automation-store"; -import { ApiTokenStore } from "../db/api-tokens"; import { SlackChannelStore } from "../db/slack-channel-store"; import { buildSlackCompletionNotification, @@ -510,11 +509,7 @@ export class SchedulerDO extends DurableObject { // 1. Recovery sweep await this.recoverySweep(store); - // 2. Retention sweep: purge api_tokens rows long past expiry — nothing - // else ever deletes them (rotation mints 2 rows per user per period). - await this.apiTokenRetentionSweep(now); - - // 3. Process overdue automations, bounded by the per-tick child budget. + // 2. Process overdue automations, bounded by the per-tick child budget. const overdue = await store.getOverdueAutomations(now, MAX_PER_TICK); const [repositoriesByAutomation, environmentsByAutomation] = await Promise.all([ store.getRepositoriesForAutomationIds(overdue.map((automation) => automation.id)), @@ -596,25 +591,6 @@ export class SchedulerDO extends DurableObject { }); } - // ─── Retention sweep ───────────────────────────────────────────────────── - - private async apiTokenRetentionSweep(now: number): Promise { - try { - const deleted = await new ApiTokenStore(this.db).deleteExpired(now); - if (deleted > 0) { - this.log.info("Expired api_tokens rows purged", { - event: "scheduler.api_token_retention", - deleted, - }); - } - } catch (e) { - this.log.error("api_tokens retention sweep failed", { - event: "scheduler.api_token_retention_error", - error: e instanceof Error ? e.message : String(e), - }); - } - } - // ─── Recovery sweep ────────────────────────────────────────────────────── private async recoverySweep(store: AutomationStore): Promise { @@ -1238,7 +1214,7 @@ export class SchedulerDO extends DurableObject { // (handleCreateAutomation resolves it for both GitHub and Google users), so this // lookup is skipped for them. The fallback below only covers legacy rows with // user_id = NULL: those predate Google login and store the GitHub numeric user ID - // in created_by (from NextAuth session.user.id), so a github-only identity lookup + // in created_by (from the canonical browser principal), so a GitHub-only identity lookup // recovers the canonical user. It becomes dead code once legacy rows are backfilled. let userId = automation.user_id; if (!userId && automation.created_by && automation.created_by !== "anonymous") { diff --git a/packages/control-plane/src/session/callback-notification-service.ts b/packages/control-plane/src/session/callback-notification-service.ts index 74e8a13df..2b7598b44 100644 --- a/packages/control-plane/src/session/callback-notification-service.ts +++ b/packages/control-plane/src/session/callback-notification-service.ts @@ -8,7 +8,7 @@ */ import { computeHmacHex } from "@open-inspect/shared"; -import { callbackSigningSecret, type CallbackDestination } from "../auth/callback-signing"; +import { callbackSigningSecret, type CallbackDestination } from "../auth/service/callback-signing"; import type { Logger } from "../logger"; import { deliverWithRetry } from "./callback-delivery"; import { notifyLinearStarted } from "./linear-start-callback"; diff --git a/packages/control-plane/src/session/identity.test.ts b/packages/control-plane/src/session/identity.test.ts index 4816f41a8..25846a435 100644 --- a/packages/control-plane/src/session/identity.test.ts +++ b/packages/control-plane/src/session/identity.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { UserStore } from "../db/user-store"; import type { Env } from "../types"; -import { parseAuthorId, resolveGitAuthorIdentity, resolveGitHubEnrichment } from "./identity"; +import { + parseAuthorId, + resolveBrowserGitHubEnrichment, + resolveGitAuthorIdentity, + resolveGitHubEnrichment, +} from "./identity"; describe("resolveGitAuthorIdentity", () => { it("derives a canonical noreply author from a trusted GitHub id and login", () => { @@ -184,3 +189,84 @@ describe("resolveGitHubEnrichment", () => { expect(enrichment?.email).toBe("42+pm-dev@users.noreply.github.com"); }); }); + +describe("resolveBrowserGitHubEnrichment", () => { + const githubAccount = { + subject: "42", + }; + + it("gets a current Better Auth token and binds it to the verified GitHub profile", async () => { + const getAccessToken = vi.fn(async () => ({ + accessToken: "current-access-token", + accessTokenExpiresAt: new Date("2030-01-01T00:00:00.000Z"), + scopes: [], + })); + const getAccountInfo = vi.fn(async () => ({ + user: { + id: "42", + name: "Ada Lovelace", + email: "private@example.com", + emailVerified: true, + }, + data: { + provider: "github", + issuer: "https://github.com", + subject: "42", + login: "ada", + displayName: "Ada Lovelace", + verifiedEmails: ["private@example.com"], + primaryEmail: "private@example.com", + }, + })); + const encryptAccessToken = vi.fn(async () => "encrypted-current-access-token"); + + await expect( + resolveBrowserGitHubEnrichment("0123456789abcdef0123456789abcdef", githubAccount, { + getAccessToken, + getAccountInfo, + encryptAccessToken, + }) + ).resolves.toEqual({ + scmUserId: "42", + scmLogin: "ada", + displayName: "Ada Lovelace", + email: "42+ada@users.noreply.github.com", + accessTokenEncrypted: "encrypted-current-access-token", + tokenExpiresAt: new Date("2030-01-01T00:00:00.000Z").getTime(), + }); + + const accountSelection = { + providerId: "github", + accountId: "42", + userId: "0123456789abcdef0123456789abcdef", + }; + expect(getAccessToken).toHaveBeenCalledWith(accountSelection); + expect(getAccountInfo).toHaveBeenCalledWith(accountSelection); + expect(encryptAccessToken).toHaveBeenCalledWith("current-access-token"); + }); + + it("rejects provider profile substitution", async () => { + await expect( + resolveBrowserGitHubEnrichment("0123456789abcdef0123456789abcdef", githubAccount, { + getAccessToken: async () => ({ accessToken: "token" }), + getAccountInfo: async () => ({ + user: { + id: "7", + name: "Mallory", + email: "mallory@example.com", + emailVerified: true, + }, + data: { + provider: "github", + issuer: "https://github.com", + subject: "7", + login: "mallory", + verifiedEmails: ["mallory@example.com"], + primaryEmail: "mallory@example.com", + }, + }), + encryptAccessToken: async () => "encrypted", + }) + ).rejects.toThrow("Better Auth returned a mismatched GitHub account"); + }); +}); diff --git a/packages/control-plane/src/session/identity.ts b/packages/control-plane/src/session/identity.ts index 776e1d5f8..8048ee4c4 100644 --- a/packages/control-plane/src/session/identity.ts +++ b/packages/control-plane/src/session/identity.ts @@ -1,4 +1,10 @@ import { formatGitHubNoreplyEmail, githubLoginSchema } from "@open-inspect/shared"; +import { z } from "zod"; +import { encryptToken } from "../auth/crypto"; +import type { + GitHubAccountSelection, + GitHubCredentialAuthority, +} from "../source-control/github-credential-authority"; import { UserScmTokenStore } from "../db/user-scm-tokens"; import type { UserStore } from "../db/user-store"; import type { SourceControlProviderName } from "../source-control"; @@ -52,6 +58,84 @@ export interface GitHubEnrichment { tokenExpiresAt?: number; } +const browserAccessTokenSchema = z.object({ + accessToken: z.string().min(1), + accessTokenExpiresAt: z.coerce.date().optional(), +}); + +const browserGitHubAccountInfoSchema = z.object({ + user: z.object({ + id: z.string().min(1), + }), + data: z.object({ + provider: z.literal("github"), + issuer: z.literal("https://github.com"), + subject: z.string().min(1), + login: githubLoginSchema, + displayName: z.string().min(1).optional(), + verifiedEmails: z.array(z.string()), + primaryEmail: z.string().nullable(), + }), +}); + +export interface BrowserGitHubEnrichmentDependencies { + readonly getAccessToken: (selection: { + providerId: "github"; + accountId: string; + userId: string; + }) => Promise; + readonly getAccountInfo: (selection: { + providerId: "github"; + accountId: string; + userId: string; + }) => Promise; + readonly encryptAccessToken: (accessToken: string) => Promise; +} + +/** + * Resolve GitHub attribution and a current provider token from Better Auth. + * + * Better Auth owns refresh-token storage and rotation. Session state receives + * only a re-encrypted, currently valid access token; it never copies the + * long-lived refresh credential into a second store. + */ +export async function resolveBrowserGitHubEnrichment( + userId: string, + account: GitHubAccountSelection, + dependencies: BrowserGitHubEnrichmentDependencies +): Promise { + const selection = { + providerId: "github" as const, + accountId: account.subject, + userId, + }; + const token = browserAccessTokenSchema.parse(await dependencies.getAccessToken(selection)); + const profile = browserGitHubAccountInfoSchema.parse( + await dependencies.getAccountInfo(selection) + ); + if (profile.user.id !== account.subject || profile.data.subject !== account.subject) { + throw new Error("Better Auth returned a mismatched GitHub account"); + } + + const accessTokenEncrypted = await dependencies.encryptAccessToken(token.accessToken); + const author = resolveGitAuthorIdentity({ + scmProvider: "github", + scmUserId: profile.data.subject, + scmLogin: profile.data.login, + scmName: profile.data.displayName, + scmEmail: profile.data.primaryEmail, + }); + + return { + scmUserId: profile.data.subject, + scmLogin: profile.data.login, + displayName: profile.data.displayName ?? profile.data.login, + email: author?.email, + accessTokenEncrypted, + ...(token.accessTokenExpiresAt ? { tokenExpiresAt: token.accessTokenExpiresAt.getTime() } : {}), + }; +} + /** * Parse a bot-format authorId into provider + providerUserId. * Returns null for web client authorIds (plain user IDs without a prefix). @@ -106,3 +190,30 @@ export async function resolveGitHubEnrichment( tokenExpiresAt: tokens?.expiresAt, }; } + +/** + * Select the credential authority associated with the authenticated request. + * + * Browser sessions read/refresh through Better Auth. Bot identities retain + * their existing actor identity/token-store lookup. + */ +export async function resolveGitHubEnrichmentForRequest( + env: Env, + db: SqlDatabase, + userStore: UserStore, + userId: string, + authority: GitHubCredentialAuthority +): Promise { + if (authority.kind === "legacy") { + return resolveGitHubEnrichment(env, db, userStore, userId); + } + + const accountClient = authority.accountClient; + const githubAccount = authority.githubAccount; + if (!githubAccount) return null; + return resolveBrowserGitHubEnrichment(userId, githubAccount, { + getAccessToken: (selection) => accountClient.getAccessToken({ body: selection }), + getAccountInfo: (selection) => accountClient.accountInfo({ query: selection }), + encryptAccessToken: (accessToken) => encryptToken(accessToken, env.TOKEN_ENCRYPTION_KEY), + }); +} diff --git a/packages/control-plane/src/source-control/github-credential-authority.test.ts b/packages/control-plane/src/source-control/github-credential-authority.test.ts new file mode 100644 index 000000000..4a2c5b8c9 --- /dev/null +++ b/packages/control-plane/src/source-control/github-credential-authority.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from "vitest"; +import { + resolveGitHubCredentialAuthority, + type GitHubCredentialAuthorityContext, + type ProviderAccountClient, +} from "./github-credential-authority"; +import type { AuthenticationContext } from "../auth/principal"; + +const BROWSER_AUTHENTICATION: AuthenticationContext = { + mechanism: "browser_session", + credentialId: "session-1", + channel: { + kind: "sig1", + service: "web", + }, +}; + +const BROWSER_HEADERS = new Headers({ + Cookie: "openinspect.session_token=session-token", +}); + +function createContext( + overrides: Partial +): GitHubCredentialAuthorityContext { + return { + principal: { + kind: "service", + service: "modal", + actor: null, + }, + ...overrides, + }; +} + +function createUserContext(accounts: unknown[]) { + const listUserAccounts = vi.fn(async () => accounts); + const accountClient: ProviderAccountClient = { + listUserAccounts, + getAccessToken: vi.fn(async () => null), + accountInfo: vi.fn(async () => null), + }; + const runtime = { + api: accountClient, + }; + return { + context: createContext({ + principal: { kind: "user", userId: "user-1" }, + authentication: BROWSER_AUTHENTICATION, + getUserAuth: () => runtime, + }), + listUserAccounts, + accountClient, + }; +} + +describe("resolveGitHubCredentialAuthority", () => { + it("selects a linked GitHub account only when credential authority is requested", async () => { + const { context, listUserAccounts, accountClient } = createUserContext([ + { + providerId: "github", + accountId: "583231", + userId: "user-1", + }, + { + providerId: "google", + accountId: "google-subject", + userId: "user-1", + }, + ]); + + await expect(resolveGitHubCredentialAuthority(context, BROWSER_HEADERS)).resolves.toEqual({ + kind: "browser_session", + accountClient, + githubAccount: { subject: "583231" }, + }); + expect(listUserAccounts).toHaveBeenCalledWith({ headers: BROWSER_HEADERS }); + }); + + it("allows browser users without a linked GitHub account", async () => { + const { context, accountClient } = createUserContext([ + { + providerId: "google", + accountId: "google-subject", + userId: "user-1", + }, + ]); + + await expect(resolveGitHubCredentialAuthority(context, BROWSER_HEADERS)).resolves.toEqual({ + kind: "browser_session", + accountClient, + githubAccount: null, + }); + }); + + it("rejects cross-user GitHub account authority", async () => { + const { context } = createUserContext([ + { + providerId: "github", + accountId: "583231", + userId: "different-user", + }, + ]); + + await expect(resolveGitHubCredentialAuthority(context, BROWSER_HEADERS)).rejects.toThrow( + "GitHub account authority is corrupt" + ); + }); + + it("rejects multiple linked GitHub accounts", async () => { + const { context } = createUserContext([ + { providerId: "github", accountId: "583231", userId: "user-1" }, + { providerId: "github", accountId: "987654", userId: "user-1" }, + ]); + await expect(resolveGitHubCredentialAuthority(context, BROWSER_HEADERS)).rejects.toThrow( + "User resolves to multiple GitHub provider accounts" + ); + }); + + it("rejects a user principal without browser-session provenance", async () => { + await expect( + resolveGitHubCredentialAuthority( + createContext({ + principal: { kind: "user", userId: "user-1" }, + }), + BROWSER_HEADERS + ) + ).rejects.toThrow("User principal is missing browser-session provenance"); + }); + + it("uses the legacy credential authority only for non-browser principals", async () => { + await expect( + resolveGitHubCredentialAuthority(createContext({}), BROWSER_HEADERS) + ).resolves.toEqual({ kind: "legacy" }); + }); +}); diff --git a/packages/control-plane/src/source-control/github-credential-authority.ts b/packages/control-plane/src/source-control/github-credential-authority.ts new file mode 100644 index 000000000..e47118e8e --- /dev/null +++ b/packages/control-plane/src/source-control/github-credential-authority.ts @@ -0,0 +1,92 @@ +import { z } from "zod"; +import type { AuthenticationContext, Principal } from "../auth/principal"; + +const providerAccountSchema = z.object({ + providerId: z.string().min(1), + accountId: z.string().min(1), + userId: z.string().min(1), +}); + +export interface GitHubAccountSelection { + readonly subject: string; +} + +interface ProviderAccountSelection { + readonly providerId: "github"; + readonly accountId: string; + readonly userId: string; +} + +export interface ProviderAccountClient { + listUserAccounts(input: { readonly headers: Headers }): Promise; + getAccessToken(input: { readonly body: ProviderAccountSelection }): Promise; + accountInfo(input: { readonly query: ProviderAccountSelection }): Promise; +} + +export type GitHubCredentialAuthority = + | { + readonly kind: "browser_session"; + readonly accountClient: ProviderAccountClient; + readonly githubAccount: GitHubAccountSelection | null; + } + | { + readonly kind: "legacy"; + }; + +export interface GitHubCredentialAuthorityContext { + readonly principal?: Principal; + readonly authentication?: AuthenticationContext; + readonly getUserAuth?: () => { readonly api: ProviderAccountClient }; +} + +/** + * Select the credential store associated with the verified principal. + * + * A browser user must never silently fall back to the legacy token store when + * its authentication provenance is missing. Linked GitHub accounts are + * enumerated here, only when an SCM workflow requests them; they are not part + * of browser-session authentication. Service actors are the only transitional + * callers that retain the legacy authority. + */ +export async function resolveGitHubCredentialAuthority( + context: GitHubCredentialAuthorityContext, + headers: Headers +): Promise { + if (!context.principal) { + throw new Error("Verified principal is unavailable"); + } + + if (context.principal.kind === "user") { + const userId = context.principal.userId; + if (!context.authentication) { + throw new Error("User principal is missing browser-session provenance"); + } + if (!context.getUserAuth) { + throw new Error("User authentication runtime is unavailable"); + } + const accountClient = context.getUserAuth().api; + const parsedAccounts = z + .array(providerAccountSchema) + .safeParse(await accountClient.listUserAccounts({ headers })); + if ( + !parsedAccounts.success || + parsedAccounts.data.some((account) => account.userId !== userId) + ) { + throw new Error("GitHub account authority is corrupt"); + } + const githubAccounts = parsedAccounts.data.filter((account) => account.providerId === "github"); + if (githubAccounts.length > 1) { + throw new Error("User resolves to multiple GitHub provider accounts"); + } + return { + kind: "browser_session", + accountClient, + githubAccount: githubAccounts[0] ? { subject: githubAccounts[0].accountId } : null, + }; + } + + if (context.authentication) { + throw new Error("Non-user principal cannot carry browser-session provenance"); + } + return { kind: "legacy" }; +} diff --git a/packages/control-plane/src/types.ts b/packages/control-plane/src/types.ts index cae32b2c9..7244c5fd9 100644 --- a/packages/control-plane/src/types.ts +++ b/packages/control-plane/src/types.ts @@ -59,6 +59,9 @@ export interface Env { // Secrets GITHUB_CLIENT_ID?: string; GITHUB_CLIENT_SECRET?: string; + GOOGLE_CLIENT_ID?: string; + GOOGLE_CLIENT_SECRET?: string; + BROWSER_AUTH_SECRET?: string; TOKEN_ENCRYPTION_KEY: string; REPO_SECRETS_ENCRYPTION_KEY?: string; MODAL_TOKEN_ID?: string; @@ -94,6 +97,11 @@ export interface Env { SCM_PROVIDER?: string; // Source control provider for this deployment (default: github) WORKER_URL?: string; // Base URL for the worker (for callbacks) WEB_APP_URL?: string; // Base URL for the web app (for PR links) + ALLOWED_USERS?: string; + ALLOWED_EMAIL_DOMAINS?: string; + ALLOWED_EMAILS?: string; + ALLOWED_GITHUB_ORGS?: string; + UNSAFE_ALLOW_ALL_USERS?: string; CF_ACCOUNT_ID?: string; // Cloudflare account ID SANDBOX_PROVIDER?: string; // "modal" (default), "daytona", "vercel", "opencomputer", or "e2b" MODAL_WORKSPACE?: string; // Modal workspace name diff --git a/packages/control-plane/src/types/error.d.ts b/packages/control-plane/src/types/error.d.ts index d4c03a8ec..98a1afc6a 100644 --- a/packages/control-plane/src/types/error.d.ts +++ b/packages/control-plane/src/types/error.d.ts @@ -1,9 +1,9 @@ // workerd (V8) provides Error.captureStackTrace at runtime, but // @cloudflare/workers-types no longer declares it. Declare just this optional -// API rather than adding @types/node to the production type surface: this -// worker has no `nodejs_compat`, so keeping node:*/process/Buffer untyped makes -// accidental Node usage in worker code a typecheck error instead of a runtime -// failure (see tsconfig.json). +// API rather than adding @types/node to the production type surface. The +// runtime enables `nodejs_compat` for audited dependencies, while keeping +// node:*/process/Buffer untyped here makes accidental application-level Node +// usage a typecheck error (see tsconfig.json). interface ErrorConstructor { captureStackTrace?( targetObject: object, diff --git a/packages/control-plane/src/worker-build.test.ts b/packages/control-plane/src/worker-build.test.ts new file mode 100644 index 000000000..42f7a59c4 --- /dev/null +++ b/packages/control-plane/src/worker-build.test.ts @@ -0,0 +1,32 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const packageDirectory = fileURLToPath(new URL("..", import.meta.url)); +const repositoryDirectory = fileURLToPath(new URL("../../..", import.meta.url)); +const WORKER_BUILD_TIMEOUT_MS = 60_000; + +describe("control-plane worker build", () => { + it( + "uses the workerd AsyncLocalStorage implementation", + () => { + execFileSync("npm", ["run", "build", "-w", "@open-inspect/shared"], { + cwd: repositoryDirectory, + stdio: "pipe", + }); + execFileSync("npm", ["run", "build"], { + cwd: packageDirectory, + stdio: "pipe", + }); + + const bundle = readFileSync(new URL("../dist/index.js", import.meta.url), "utf8"); + + expect(bundle.includes('"node:async_hooks"')).toBe(true); + expect(bundle.includes("AsyncLocalStoragePolyfill")).toBe(false); + expect(bundle.includes("@opentelemetry/semantic-conventions/build/esm/")).toBe(true); + expect(bundle.includes("@opentelemetry/semantic-conventions/build/src/")).toBe(false); + }, + WORKER_BUILD_TIMEOUT_MS + ); +}); diff --git a/packages/control-plane/test/integration/auth-tokens.test.ts b/packages/control-plane/test/integration/auth-tokens.test.ts deleted file mode 100644 index a195d5c85..000000000 --- a/packages/control-plane/test/integration/auth-tokens.test.ts +++ /dev/null @@ -1,669 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { SELF, env } from "cloudflare:test"; -import { - buildServiceAuthHeaders, - generateInternalToken, - type ServiceName, -} from "@open-inspect/shared"; -import { - ApiTokenStore, - EXPIRED_TOKEN_RETENTION_MS, - type NewApiToken, -} from "../../src/db/api-tokens"; -import { REFRESH_REUSE_GRACE_MS } from "../../src/auth/web-session-tokens"; -import { UserStore } from "../../src/db/user-store"; -import { cleanD1Tables } from "./cleanup"; - -const originalFetch = globalThis.fetch; - -interface ProviderMockState { - githubUserStatus: number; - githubEmailsStatus: number; - githubEmailsBody: unknown; - googleStatus: number; -} - -const providerMock: ProviderMockState = { - githubUserStatus: 200, - githubEmailsStatus: 200, - githubEmailsBody: [{ email: "octocat@example.com", primary: true, verified: true }], - googleStatus: 200, -}; - -function installProviderFetchMock(): void { - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - - if (url === "https://api.github.com/user") { - if (providerMock.githubUserStatus !== 200) { - return Response.json({ message: "nope" }, { status: providerMock.githubUserStatus }); - } - return Response.json({ - id: 583231, - login: "octocat", - // No public profile email — the exchange must resolve the verified - // primary from /user/emails, matching the web sign-in flow. - email: null, - name: "The Octocat", - avatar_url: "https://avatars.example/octocat", - }); - } - if (url === "https://api.github.com/user/emails") { - if (providerMock.githubEmailsStatus !== 200) { - return Response.json({ message: "nope" }, { status: providerMock.githubEmailsStatus }); - } - return Response.json(providerMock.githubEmailsBody); - } - if (url === "https://openidconnect.googleapis.com/v1/userinfo") { - if (providerMock.googleStatus !== 200) { - return Response.json({ error: "invalid_token" }, { status: providerMock.googleStatus }); - } - return Response.json({ - sub: "1078462347", - email: "person@example.com", - email_verified: true, - name: "A Person", - }); - } - return originalFetch(input, init); - }) - ); -} - -async function serviceFetch(p: { - service?: ServiceName; - path: string; - body: unknown; -}): Promise { - const service = p.service ?? "web"; - const url = `https://test.local${p.path}`; - const body = JSON.stringify(p.body); - const headers = await buildServiceAuthHeaders({ - service, - secret: `test-service-secret-${service}`, - method: "POST", - url, - body, - }); - return SELF.fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json", ...headers }, - body, - }); -} - -interface TokenPair { - accessToken: string; - accessTokenExpiresAtEpochMs: number; - refreshToken: string; - refreshTokenExpiresAtEpochMs: number; -} - -async function exchangeGitHub(): Promise { - const response = await serviceFetch({ - path: "/auth/tokens/exchange", - body: { - subjectTokenType: "github-access-token", - subjectToken: "gho_valid", - scmRefreshToken: "ghr_refresh", - scmTokenExpiresAt: Date.now() + 60_000, - }, - }); - expect(response.status).toBe(200); - return response.json(); -} - -async function expectNoDurableAuthState(): Promise { - for (const table of ["users", "user_identities", "user_scm_tokens", "api_tokens"]) { - const count = await env.DB.prepare(`SELECT COUNT(*) AS n FROM ${table}`).first<{ - n: number; - }>(); - expect(count?.n, table).toBe(0); - } -} - -describe("token exchange and refresh grant", () => { - beforeEach(async () => { - await cleanD1Tables(); - providerMock.githubUserStatus = 200; - providerMock.githubEmailsStatus = 200; - providerMock.githubEmailsBody = [ - { email: "octocat@example.com", primary: true, verified: true }, - ]; - providerMock.googleStatus = 200; - installProviderFetchMock(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("exchanges a GitHub subject: verified identity, canonical user, SCM capture, token pair", async () => { - const pair = await exchangeGitHub(); - expect(pair.accessToken).toMatch(/^oi_at_/); - expect(pair.refreshToken).toMatch(/^oi_rt_/); - expect(pair.accessTokenExpiresAtEpochMs).toBeGreaterThan(Date.now()); - - // Canonical user created from the VERIFIED identity (id 583231), not any asserted field. - const identity = await new UserStore(env.DB).getIdentity("github", "583231"); - expect(identity).not.toBeNull(); - expect(identity!.providerLogin).toBe("octocat"); - - // SCM tokens captured under the verified provider id. - const scmRow = await env.DB.prepare( - "SELECT user_id FROM user_scm_tokens WHERE provider_user_id = ?" - ) - .bind("583231") - .first<{ user_id: string }>(); - expect(scmRow?.user_id).toBe(identity!.userId); - }); - - it("authenticates CP requests with the minted access token (user principal end-to-end)", async () => { - const pair = await exchangeGitHub(); - const response = await SELF.fetch("https://test.local/sessions", { - headers: { Authorization: `Bearer ${pair.accessToken}` }, - }); - expect(response.status).toBe(200); - }); - - it("never re-links a user principal from providerEmail supplied to the identity route", async () => { - const victim = await new UserStore(env.DB).createUser({ - displayName: "Victim", - email: "victim@example.com", - avatarUrl: null, - }); - const pair = await exchangeGitHub(); - const before = await new UserStore(env.DB).getIdentity("github", "583231"); - expect(before).not.toBeNull(); - expect(before!.userId).not.toBe(victim.id); - - const response = await SELF.fetch("https://test.local/provider-identities/github/583231", { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${pair.accessToken}`, - }, - body: JSON.stringify({ providerEmail: "victim@example.com" }), - }); - - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ userId: before!.userId }); - const after = await new UserStore(env.DB).getIdentity("github", "583231"); - expect(after?.userId).toBe(before!.userId); - }); - - it("forbids a user token from resolving a different provider identity", async () => { - const pair = await exchangeGitHub(); - - const response = await SELF.fetch("https://test.local/provider-identities/github/999999", { - method: "PUT", - headers: { Authorization: `Bearer ${pair.accessToken}` }, - }); - - expect(response.status).toBe(403); - expect(await response.json()).toEqual({ - error: "Path identity does not match the authenticated user", - }); - }); - - it("persists session ownership from the user token rather than the request body", async () => { - const pair = await exchangeGitHub(); - const identity = await new UserStore(env.DB).getIdentity("github", "583231"); - expect(identity).not.toBeNull(); - - const created = await SELF.fetch("https://test.local/sessions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${pair.accessToken}`, - }, - body: JSON.stringify({ - title: "Token-owned session", - model: "anthropic/claude-haiku-4-5", - }), - }); - expect(created.status).toBe(201); - - const listed = await SELF.fetch("https://test.local/sessions", { - headers: { Authorization: `Bearer ${pair.accessToken}` }, - }); - expect(listed.status).toBe(200); - const body = await listed.json<{ sessions: Array<{ title: string; userId: string }> }>(); - expect(body.sessions).toContainEqual( - expect.objectContaining({ - title: "Token-owned session", - userId: identity!.userId, - }) - ); - }); - - it("rejects caller-supplied session identity before creating durable state", async () => { - const pair = await exchangeGitHub(); - - const created = await SELF.fetch("https://test.local/sessions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${pair.accessToken}`, - }, - body: JSON.stringify({ - title: "Forged owner", - model: "anthropic/claude-haiku-4-5", - userId: "victim-user-id", - }), - }); - - expect(created.status).toBe(400); - expect(await created.json()).toEqual({ - error: "Field 'userId' is not accepted from verified callers", - }); - const sessionCount = await env.DB.prepare("SELECT COUNT(*) AS n FROM sessions").first<{ - n: number; - }>(); - expect(sessionCount?.n).toBe(0); - }); - - it("exchanges a Google subject without SCM capture", async () => { - const response = await serviceFetch({ - path: "/auth/tokens/exchange", - body: { subjectTokenType: "google-access-token", subjectToken: "ya29.valid" }, - }); - expect(response.status).toBe(200); - const identity = await new UserStore(env.DB).getIdentity("google", "1078462347"); - expect(identity).not.toBeNull(); - const scmCount = await env.DB.prepare("SELECT COUNT(*) AS n FROM user_scm_tokens").first<{ - n: number; - }>(); - expect(scmCount?.n).toBe(0); - }); - - it("returns the same canonical user across repeated exchanges", async () => { - await exchangeGitHub(); - await exchangeGitHub(); - const users = await env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>(); - expect(users?.n).toBe(1); - }); - - it("links a GitHub exchange with no public email to the email owner and mints the family there", async () => { - // A canonical user already owns the email (e.g. a prior Google sign-in). - const existing = await new UserStore(env.DB).createUser({ - displayName: "Octo", - email: "octocat@example.com", - avatarUrl: null, - }); - - // GitHub /user.email is null; the verified primary resolved from - // /user/emails must link this exchange to `existing` instead of forking a - // second canonical user and stranding the 90-day family on the orphan. - await exchangeGitHub(); - - const users = await env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>(); - expect(users?.n).toBe(1); - - const identity = await new UserStore(env.DB).getIdentity("github", "583231"); - expect(identity?.userId).toBe(existing.id); - - // The minted token family is attached to the existing user, not an orphan. - const familyRow = await env.DB.prepare( - "SELECT DISTINCT user_id FROM api_tokens WHERE kind = 'web_session'" - ).all<{ user_id: string }>(); - expect(familyRow.results.map((r) => r.user_id)).toEqual([existing.id]); - }); - - it("fails closed without durable identity state when GitHub email evidence is transiently unavailable", async () => { - providerMock.githubEmailsStatus = 500; - - const response = await serviceFetch({ - path: "/auth/tokens/exchange", - body: { - subjectTokenType: "github-access-token", - subjectToken: "gho_valid", - scmRefreshToken: "ghr_refresh", - }, - }); - - expect(response.status).toBe(502); - expect(await response.json()).toEqual({ error: "provider_unavailable" }); - await expectNoDurableAuthState(); - }); - - it("fails closed without durable identity state on malformed GitHub email evidence", async () => { - providerMock.githubEmailsBody = { email: "octocat@example.com" }; - - const response = await serviceFetch({ - path: "/auth/tokens/exchange", - body: { - subjectTokenType: "github-access-token", - subjectToken: "gho_valid", - scmRefreshToken: "ghr_refresh", - }, - }); - - expect(response.status).toBe(502); - expect(await response.json()).toEqual({ error: "provider_unavailable" }); - await expectNoDurableAuthState(); - }); - - it("rotates via the refresh grant; immediate replay is rejected without revoking the family", async () => { - const first = await exchangeGitHub(); - - const refreshResponse = await serviceFetch({ - path: "/auth/tokens/refresh", - body: { refreshToken: first.refreshToken }, - }); - expect(refreshResponse.status).toBe(200); - const second = await refreshResponse.json(); - expect(second.accessToken).not.toBe(first.accessToken); - - // The rotated pair works. - const ok = await SELF.fetch("https://test.local/sessions", { - headers: { Authorization: `Bearer ${second.accessToken}` }, - }); - expect(ok.status).toBe(200); - - // Immediate replay of the consumed token = benign concurrent renewal: - // superseded (NOT a dead grant), family left alive (grace window). - // Post-grace replay revokes the family — covered by the service unit - // tests. - const replay = await serviceFetch({ - path: "/auth/tokens/refresh", - body: { refreshToken: first.refreshToken }, - }); - expect(replay.status).toBe(401); - expect(await replay.json()).toMatchObject({ error: "refresh_superseded" }); - - const stillValid = await SELF.fetch("https://test.local/sessions", { - headers: { Authorization: `Bearer ${second.accessToken}` }, - }); - expect(stillValid.status).toBe(200); - }); - - it("returns one winner when two refresh requests overlap", async () => { - const first = await exchangeGitHub(); - const requests = await Promise.all([ - serviceFetch({ - path: "/auth/tokens/refresh", - body: { refreshToken: first.refreshToken }, - }), - serviceFetch({ - path: "/auth/tokens/refresh", - body: { refreshToken: first.refreshToken }, - }), - ]); - - expect(requests.map((response) => response.status).sort()).toEqual([200, 401]); - const winnerResponse = requests.find((response) => response.status === 200)!; - const loserResponse = requests.find((response) => response.status === 401)!; - const winner = await winnerResponse.json(); - expect(await loserResponse.json()).toEqual({ error: "refresh_superseded" }); - - const winnerAccess = await SELF.fetch("https://test.local/sessions", { - headers: { Authorization: `Bearer ${winner.accessToken}` }, - }); - expect(winnerAccess.status).toBe(200); - - const liveRefreshLeaves = await env.DB.prepare( - `SELECT COUNT(*) AS n FROM api_tokens - WHERE kind = 'web_session_refresh' AND rotated_to IS NULL AND revoked_at IS NULL` - ).first<{ n: number }>(); - expect(liveRefreshLeaves?.n).toBe(1); - }); - - it("revokes the real D1 family when a consumed refresh token is replayed after grace", async () => { - const first = await exchangeGitHub(); - const rotated = await serviceFetch({ - path: "/auth/tokens/refresh", - body: { refreshToken: first.refreshToken }, - }); - expect(rotated.status).toBe(200); - const second = await rotated.json(); - - const ancestor = await env.DB.prepare( - "SELECT family_id, rotated_to FROM api_tokens WHERE kind = 'web_session_refresh' AND rotated_to IS NOT NULL" - ).first<{ family_id: string; rotated_to: string }>(); - expect(ancestor).not.toBeNull(); - await env.DB.prepare("UPDATE api_tokens SET created_at = ? WHERE id = ?") - .bind(Date.now() - REFRESH_REUSE_GRACE_MS - 1000, ancestor!.rotated_to) - .run(); - - const replay = await serviceFetch({ - path: "/auth/tokens/refresh", - body: { refreshToken: first.refreshToken }, - }); - expect(replay.status).toBe(401); - expect(await replay.json()).toEqual({ error: "refresh_reuse_detected" }); - - const winnerAccess = await SELF.fetch("https://test.local/sessions", { - headers: { Authorization: `Bearer ${second.accessToken}` }, - }); - expect(winnerAccess.status).toBe(401); - - const familyRows = await env.DB.prepare("SELECT revoked_at FROM api_tokens WHERE family_id = ?") - .bind(ancestor!.family_id) - .all<{ revoked_at: number | null }>(); - expect(familyRows.results.length).toBeGreaterThan(0); - expect(familyRows.results.every((row) => row.revoked_at !== null)).toBe(true); - }); - - it("rejects invalid refresh tokens", async () => { - const response = await serviceFetch({ - path: "/auth/tokens/refresh", - body: { refreshToken: "oi_rt_never_issued" }, - }); - expect(response.status).toBe(401); - expect(await response.json()).toMatchObject({ error: "invalid_refresh_token" }); - }); - - it("forbids refresh to every principal except the web service", async () => { - const pair = await exchangeGitHub(); - const body = { refreshToken: pair.refreshToken }; - - for (const service of ["slack-bot", "github-bot", "linear-bot", "modal"] as const) { - const response = await serviceFetch({ - service, - path: "/auth/tokens/refresh", - body, - }); - expect(response.status, service).toBe(403); - } - - const asUser = await SELF.fetch("https://test.local/auth/tokens/refresh", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${pair.accessToken}`, - }, - body: JSON.stringify(body), - }); - expect(asUser.status).toBe(403); - - const unauthenticated = await SELF.fetch("https://test.local/auth/tokens/refresh", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - expect(unauthenticated.status).toBe(401); - - const sharedToken = await generateInternalToken("test-hmac-secret-for-integration-tests"); - const asSharedBearer = await SELF.fetch("https://test.local/auth/tokens/refresh", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${sharedToken}`, - }, - body: JSON.stringify(body), - }); - expect(asSharedBearer.status).toBe(401); - - const asWeb = await serviceFetch({ - path: "/auth/tokens/refresh", - body, - }); - expect(asWeb.status).toBe(200); - }); - - it("maps provider rejection to subject_rejected and provider outage to provider_unavailable", async () => { - providerMock.githubUserStatus = 401; - const rejected = await serviceFetch({ - path: "/auth/tokens/exchange", - body: { subjectTokenType: "github-access-token", subjectToken: "gho_bad" }, - }); - expect(rejected.status).toBe(401); - expect(await rejected.json()).toMatchObject({ error: "subject_rejected" }); - - providerMock.githubUserStatus = 500; - const unavailable = await serviceFetch({ - path: "/auth/tokens/exchange", - body: { subjectTokenType: "github-access-token", subjectToken: "gho_any" }, - }); - expect(unavailable.status).toBe(502); - expect(await unavailable.json()).toMatchObject({ error: "provider_unavailable" }); - - // Fail closed: no user, no tokens. - const users = await env.DB.prepare("SELECT COUNT(*) AS n FROM users").first<{ n: number }>(); - expect(users?.n).toBe(0); - }); - - it("rejects malformed exchange bodies", async () => { - const response = await serviceFetch({ - path: "/auth/tokens/exchange", - body: { subjectTokenType: "github-access-token", subjectToken: "", extra: true }, - }); - expect(response.status).toBe(400); - expect(await response.json()).toMatchObject({ error: "invalid_request" }); - }); - - it("forbids exchange to any principal but web's service credential", async () => { - const bodies = { - subjectTokenType: "github-access-token", - subjectToken: "gho_valid", - }; - - const asSlackBot = await serviceFetch({ - service: "slack-bot", - path: "/auth/tokens/exchange", - body: bodies, - }); - expect(asSlackBot.status).toBe(403); - - // The retired shared bearer no longer authenticates at all — rejected at - // the edge (401), before the route's 403 gate is even reached. - const sharedToken = await generateInternalToken("test-hmac-secret-for-integration-tests"); - const asSharedBearer = await SELF.fetch("https://test.local/auth/tokens/exchange", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${sharedToken}`, - }, - body: JSON.stringify(bodies), - }); - expect(asSharedBearer.status).toBe(401); - - // A minted user token cannot mint further tokens either. - const pair = await exchangeGitHub(); - const asUser = await SELF.fetch("https://test.local/auth/tokens/exchange", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${pair.accessToken}`, - }, - body: JSON.stringify(bodies), - }); - expect(asUser.status).toBe(403); - }); -}); - -describe("api_tokens retention sweep", () => { - beforeEach(async () => { - await cleanD1Tables(); - }); - - function newToken(suffix: string, expiresAt: number): NewApiToken { - return { - tokenHash: `hash-${suffix}`, - kind: "web_session", - userId: "user-1", - provider: "github", - providerUserId: "583231", - familyId: `family-${suffix}`, - expiresAt, - familyExpiresAt: null, - }; - } - - it("deletes only rows past the retention window", async () => { - const store = new ApiTokenStore(env.DB); - const now = Date.now(); - // One pair long past expiry, one expired but within retention, one live. - await store.createPair([ - newToken("stale-a", now - EXPIRED_TOKEN_RETENTION_MS - 60_000), - newToken("stale-b", now - EXPIRED_TOKEN_RETENTION_MS - 60_000), - ]); - await store.createPair([newToken("recent-a", now - 60_000), newToken("live-a", now + 60_000)]); - - expect(await store.deleteExpired(now)).toBe(2); - - const remaining = await env.DB.prepare("SELECT token_hash FROM api_tokens").all<{ - token_hash: string; - }>(); - expect(remaining.results.map((r) => r.token_hash).sort()).toEqual([ - "hash-live-a", - "hash-recent-a", - ]); - }); - - it("retains family-scoped refresh rows until the family expires", async () => { - const store = new ApiTokenStore(env.DB); - const now = Date.now(); - const longPast = now - EXPIRED_TOKEN_RETENTION_MS - 60_000; - // Both rows are long past their own expiry; only the dead family's row - // may go — a consumed ancestor in a live family must survive so its - // replay still reads as reuse instead of an unknown token. - await store.createPair([ - { - ...newToken("live-family", longPast), - kind: "web_session_refresh", - familyExpiresAt: now + 60_000, - }, - { - ...newToken("dead-family", longPast), - kind: "web_session_refresh", - familyExpiresAt: longPast, - }, - ]); - - expect(await store.deleteExpired(now)).toBe(1); - - const remaining = await env.DB.prepare("SELECT token_hash FROM api_tokens").all<{ - token_hash: string; - }>(); - expect(remaining.results.map((r) => r.token_hash)).toEqual(["hash-live-family"]); - }); - - it("admits exactly one successor when refresh consumers race in D1", async () => { - const store = new ApiTokenStore(env.DB); - const now = Date.now(); - const [, refreshId] = await store.createPair([ - newToken("race-access", now + 60_000), - { - ...newToken("race-refresh", now + 60_000), - kind: "web_session_refresh", - familyId: "family-race", - familyExpiresAt: now + 120_000, - }, - ]); - - const outcomes = await Promise.all([ - store.consumeRefreshToken(refreshId, "successor-a"), - store.consumeRefreshToken(refreshId, "successor-b"), - ]); - - expect(outcomes.filter(Boolean)).toHaveLength(1); - expect((await store.getById(refreshId))?.rotatedTo).toBe( - outcomes[0] ? "successor-a" : "successor-b" - ); - }); -}); diff --git a/packages/control-plane/test/integration/auth.test.ts b/packages/control-plane/test/integration/auth.test.ts index 8e87aa8ad..69a864fa2 100644 --- a/packages/control-plane/test/integration/auth.test.ts +++ b/packages/control-plane/test/integration/auth.test.ts @@ -30,7 +30,7 @@ describe("Edge authentication", () => { expect(response.status).toBe(401); }); - it("accepts a service-signed request and returns the session list", async () => { + it("accepts a compound browser request and returns the session list", async () => { const response = await serviceFetch("https://test.local/sessions"); expect(response.status).toBe(200); const body = await response.json<{ sessions: unknown[]; hasMore: boolean }>(); diff --git a/packages/control-plane/test/integration/browser-auth-callback.test.ts b/packages/control-plane/test/integration/browser-auth-callback.test.ts new file mode 100644 index 000000000..5259d9921 --- /dev/null +++ b/packages/control-plane/test/integration/browser-auth-callback.test.ts @@ -0,0 +1,368 @@ +import { env } from "cloudflare:test"; +import { buildServiceAuthHeaders, isCanonicalUserId } from "@open-inspect/shared"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { getUserAuth } from "../../src/auth/user/runtime"; +import { resolveGitHubCredentialAuthority } from "../../src/source-control/github-credential-authority"; +import { decryptToken } from "../../src/auth/crypto"; +import { UserStore } from "../../src/db/user-store"; +import { handleRequest } from "../../src/router"; +import { resolveGitHubEnrichmentForRequest } from "../../src/session/identity"; +import { cleanD1Tables } from "./cleanup"; + +const CONTROL_PLANE_ORIGIN = "https://control-plane.test.local"; +const PUBLIC_WEB_ORIGIN = "https://app.test.local"; +const WEB_SERVICE_SECRET = "test-service-secret-web"; + +async function signedWebRequest( + path: string, + init: { + method: "GET" | "POST"; + body?: string; + cookie?: string; + } +): Promise { + const url = `${CONTROL_PLANE_ORIGIN}${path}`; + return new Request(url, { + method: init.method, + headers: { + ...(init.body ? { "Content-Type": "application/json" } : {}), + ...(init.cookie ? { Cookie: init.cookie } : {}), + Origin: PUBLIC_WEB_ORIGIN, + ...(await buildServiceAuthHeaders({ + service: "web", + secret: WEB_SERVICE_SECRET, + method: init.method, + url, + body: init.body, + })), + }, + body: init.body, + }); +} + +function cookiePair(response: Response, cookieName: string): string { + const cookie = response.headers + .getSetCookie() + .find((value) => value.startsWith(`${cookieName}=`)); + if (!cookie) throw new Error(`Missing ${cookieName} cookie`); + return cookie.split(";", 1)[0]; +} + +beforeAll(() => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = input instanceof Request ? input.url : String(input); + if (url === "https://github.com/login/oauth/access_token") { + return Response.json({ + access_token: "github-access-token", + token_type: "bearer", + expires_in: 28_800, + refresh_token: "github-refresh-token", + refresh_token_expires_in: 15_897_600, + }); + } + if (url === "https://api.github.com/user") { + return Response.json({ + id: 583_231, + login: "octocat", + name: "The Octocat", + avatar_url: "https://avatars.example/octocat", + }); + } + if (url.startsWith("https://api.github.com/user/emails")) { + return Response.json([ + { + email: "octocat@example.com", + primary: true, + verified: true, + visibility: "private", + }, + ]); + } + throw new Error(`Unexpected external request: ${url}`); + }); +}); + +beforeEach(cleanD1Tables); + +afterAll(() => { + vi.restoreAllMocks(); +}); + +describe("browser auth callback", () => { + it("creates and resolves a GitHub browser session through the signed proxy", async () => { + const initiationBody = JSON.stringify({ + provider: "github", + callbackURL: "/after-sign-in", + disableRedirect: true, + }); + const initiationResponse = await handleRequest( + await signedWebRequest("/api/auth/sign-in/social", { + method: "POST", + body: initiationBody, + }), + env + ); + expect(initiationResponse.status).toBe(200); + const providerUrl = new URL((await initiationResponse.json<{ url: string }>()).url); + const state = providerUrl.searchParams.get("state"); + expect(state).toBeTruthy(); + const stateCookie = cookiePair(initiationResponse, "__Secure-openinspect.state"); + + const callbackResponse = await handleRequest( + await signedWebRequest( + `/api/auth/callback/github?code=authorization-code&state=${encodeURIComponent(state ?? "")}`, + { + method: "GET", + cookie: stateCookie, + } + ), + env + ); + + expect(callbackResponse.status).toBe(302); + expect(callbackResponse.headers.get("Location")).toBe("/after-sign-in"); + expect( + callbackResponse.headers + .getSetCookie() + .some((cookie) => cookie.startsWith("__Secure-openinspect.state=")) + ).toBe(true); + const sessionCookie = cookiePair(callbackResponse, "__Secure-openinspect.session_token"); + + const sessionResponse = await handleRequest( + await signedWebRequest("/api/auth/get-session", { + method: "GET", + cookie: sessionCookie, + }), + env + ); + + expect(sessionResponse.status).toBe(200); + const session = await sessionResponse.json<{ + user: { id: string; name: string; email: string }; + session: { id: string; userId: string }; + }>(); + expect(isCanonicalUserId(session.user.id)).toBe(true); + expect(session).toMatchObject({ + user: { + id: expect.any(String), + name: "The Octocat", + email: "octocat@example.com", + }, + session: { + userId: expect.any(String), + }, + }); + + const account = await env.DB.prepare( + `SELECT id + FROM auth_accounts + WHERE userId = ?` + ) + .bind(session.user.id) + .first<{ id: string }>(); + expect(account).not.toBeNull(); + + const enrichment = await resolveGitHubEnrichmentForRequest( + env, + env.DB, + new UserStore(env.DB), + session.user.id, + await resolveGitHubCredentialAuthority( + { + principal: { kind: "user", userId: session.user.id }, + authentication: { + mechanism: "browser_session", + credentialId: session.session.id, + channel: { kind: "sig1", service: "web" }, + }, + getUserAuth: () => getUserAuth(env, env.DB), + }, + new Headers({ Cookie: sessionCookie }) + ) + ); + expect(enrichment).toMatchObject({ + scmUserId: "583231", + scmLogin: "octocat", + email: "583231+octocat@users.noreply.github.com", + accessTokenEncrypted: expect.any(String), + }); + await expect( + decryptToken(enrichment?.accessTokenEncrypted ?? "", env.TOKEN_ENCRYPTION_KEY) + ).resolves.toBe("github-access-token"); + + await expect( + env.DB.prepare( + `SELECT id, display_name, email, avatar_url + FROM users + WHERE id = ?` + ) + .bind(session.user.id) + .first() + ).resolves.toEqual({ + id: session.user.id, + display_name: "The Octocat", + email: "octocat@example.com", + avatar_url: "https://avatars.example/octocat", + }); + + const resourceResponse = await handleRequest( + await signedWebRequest("/model-preferences", { + method: "GET", + cookie: sessionCookie, + }), + env + ); + expect(resourceResponse.status).toBe(200); + + const channelOnlyResponse = await handleRequest( + await signedWebRequest("/model-preferences", { + method: "GET", + }), + env + ); + expect(channelOnlyResponse.status).toBe(401); + }); + + it("signs an existing canonical user in through a migrated GitHub account", async () => { + const canonicalUserId = "11111111111111111111111111111111"; + const providerIdentityId = "22222222222222222222222222222222"; + const now = new Date("2026-07-26T21:47:56.000Z"); + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO users ( + id, display_name, email, avatar_url, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?)` + ).bind( + canonicalUserId, + "Legacy User", + "octocat@example.com", + null, + now.getTime(), + now.getTime() + ), + env.DB.prepare( + `INSERT INTO user_identities ( + id, user_id, provider, provider_user_id, provider_login, + provider_email, created_at, provider_issuer + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).bind( + providerIdentityId, + canonicalUserId, + "github", + "583231", + "octocat", + "octocat@example.com", + now.getTime(), + "https://github.com" + ), + env.DB.prepare( + `INSERT INTO auth_users ( + id, name, email, emailVerified, image, createdAt, updatedAt + ) VALUES (?, ?, ?, ?, ?, ?, ?)` + ).bind( + canonicalUserId, + "Legacy User", + "octocat@example.com", + 0, + null, + now.toISOString(), + now.toISOString() + ), + env.DB.prepare( + `INSERT INTO auth_accounts ( + id, accountId, providerId, userId, accessToken, refreshToken, + idToken, accessTokenExpiresAt, refreshTokenExpiresAt, scope, + password, createdAt, updatedAt + ) VALUES (?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?)` + ).bind( + providerIdentityId, + "583231", + "github", + canonicalUserId, + now.toISOString(), + now.toISOString() + ), + env.DB.prepare( + `INSERT INTO auth_accounts ( + id, accountId, providerId, userId, accessToken, refreshToken, + idToken, accessTokenExpiresAt, refreshTokenExpiresAt, scope, + password, createdAt, updatedAt + ) VALUES (?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?)` + ).bind( + "33333333333333333333333333333333", + "google-subject", + "google", + canonicalUserId, + now.toISOString(), + now.toISOString() + ), + ]); + + const initiationBody = JSON.stringify({ + provider: "github", + callbackURL: "/after-sign-in", + disableRedirect: true, + }); + const initiationResponse = await handleRequest( + await signedWebRequest("/api/auth/sign-in/social", { + method: "POST", + body: initiationBody, + }), + env + ); + const providerUrl = new URL((await initiationResponse.json<{ url: string }>()).url); + const state = providerUrl.searchParams.get("state"); + const stateCookie = cookiePair(initiationResponse, "__Secure-openinspect.state"); + + const callbackResponse = await handleRequest( + await signedWebRequest( + `/api/auth/callback/github?code=authorization-code&state=${encodeURIComponent(state ?? "")}`, + { + method: "GET", + cookie: stateCookie, + } + ), + env + ); + + expect(callbackResponse.status).toBe(302); + const sessionCookie = cookiePair(callbackResponse, "__Secure-openinspect.session_token"); + const sessionResponse = await handleRequest( + await signedWebRequest("/api/auth/get-session", { + method: "GET", + cookie: sessionCookie, + }), + env + ); + expect(await sessionResponse.json<{ user: { id: string } }>()).toMatchObject({ + user: { id: canonicalUserId }, + }); + expect( + await env.DB.prepare( + `SELECT COUNT(*) AS count + FROM users + WHERE email = ?` + ) + .bind("octocat@example.com") + .first<{ count: number }>() + ).toEqual({ count: 1 }); + expect( + await env.DB.prepare( + `SELECT emailVerified + FROM auth_users + WHERE id = ?` + ) + .bind(canonicalUserId) + .first<{ emailVerified: number }>() + ).toEqual({ emailVerified: 1 }); + + const resourceResponse = await handleRequest( + await signedWebRequest("/model-preferences", { + method: "GET", + cookie: sessionCookie, + }), + env + ); + expect(resourceResponse.status).toBe(200); + }); +}); diff --git a/packages/control-plane/test/integration/browser-auth-router.test.ts b/packages/control-plane/test/integration/browser-auth-router.test.ts new file mode 100644 index 000000000..98b3d77e6 --- /dev/null +++ b/packages/control-plane/test/integration/browser-auth-router.test.ts @@ -0,0 +1,114 @@ +import { env } from "cloudflare:test"; +import { buildServiceAuthHeaders, type ServiceName } from "@open-inspect/shared"; +import { describe, expect, it } from "vitest"; +import { handleRequest } from "../../src/router"; +import type { Env } from "../../src/types"; + +const CONTROL_PLANE_ORIGIN = "https://control-plane.test.local"; +const PUBLIC_WEB_ORIGIN = "https://app.test.local"; +const WEB_SERVICE_SECRET = "test-service-secret-web"; + +async function signedServiceRequest( + path: string, + body: unknown, + service: ServiceName = "web", + secret = WEB_SERVICE_SECRET +): Promise { + const url = `${CONTROL_PLANE_ORIGIN}${path}`; + const serializedBody = JSON.stringify(body); + return new Request(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: PUBLIC_WEB_ORIGIN, + ...(await buildServiceAuthHeaders({ + service, + secret, + method: "POST", + url, + body: serializedBody, + })), + }, + body: serializedBody, + }); +} + +describe("browser auth router", () => { + it("accepts a signed web-channel request on the social sign-in endpoint", async () => { + const request = await signedServiceRequest("/api/auth/sign-in/social", { + provider: "github", + callbackURL: "/", + disableRedirect: true, + }); + + const response = await handleRequest(request, env); + + expect(response.status).toBe(200); + const body = await response.json<{ url: string }>(); + const providerUrl = new URL(body.url); + expect(providerUrl.origin).toBe("https://github.com"); + expect(providerUrl.searchParams.get("redirect_uri")).toBe( + `${PUBLIC_WEB_ORIGIN}/api/auth/callback/github` + ); + }); + + it("keeps browser authentication available on GitLab deployments", async () => { + const request = await signedServiceRequest("/api/auth/sign-in/social", { + provider: "github", + callbackURL: "/", + disableRedirect: true, + }); + + const response = await handleRequest(request, { + ...env, + SCM_PROVIDER: "gitlab", + } as Env); + + expect(response.status).toBe(200); + }); + + it("rejects a direct browser request without the web channel", async () => { + const response = await handleRequest( + new Request(`${CONTROL_PLANE_ORIGIN}/api/auth/sign-in/social`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: PUBLIC_WEB_ORIGIN, + }, + body: JSON.stringify({ + provider: "github", + callbackURL: "/", + disableRedirect: true, + }), + }), + env + ); + + expect(response.status).toBe(401); + }); + + it("rejects a non-web service on the browser-auth proxy", async () => { + const request = await signedServiceRequest( + "/api/auth/sign-in/social", + { + provider: "github", + callbackURL: "/", + disableRedirect: true, + }, + "modal", + "test-service-secret-modal" + ); + + const response = await handleRequest(request, env); + + expect(response.status).toBe(401); + }); + + it("does not expose Better Auth endpoints outside the positive allowlist", async () => { + const request = await signedServiceRequest("/api/auth/list-sessions", {}); + + const response = await handleRequest(request, env); + + expect(response.status).toBe(404); + }); +}); diff --git a/packages/control-plane/test/integration/browser-auth-sessions.test.ts b/packages/control-plane/test/integration/browser-auth-sessions.test.ts deleted file mode 100644 index 29ed5a00b..000000000 --- a/packages/control-plane/test/integration/browser-auth-sessions.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { env } from "cloudflare:test"; -import { - BROWSER_SESSION_ABSOLUTE_LIFETIME_MS, - BROWSER_SESSION_IDLE_LIFETIME_MS, - BROWSER_SESSION_TOUCH_INTERVAL_MS, - BrowserAuthSessionStore, - parseBrowserSessionCredential, - parseBrowserSessionId, -} from "../../src/db/browser-auth-sessions"; -import type { BrowserSessionAuthenticationError } from "../../src/db/browser-auth-sessions"; -import { hashToken } from "../../src/auth/crypto"; -import { cleanD1Tables } from "./cleanup"; - -const NOW_MS = 1_800_000_000_000; -const DAY_MS = 24 * 60 * 60 * 1000; -const CREDENTIAL = parseBrowserSessionCredential(`oi_bsess_${"a".repeat(43)}`); -const TOKEN_HASHER = { hash: hashToken }; - -describe("BrowserAuthSessionStore", () => { - beforeEach(async () => { - await cleanD1Tables(); - await env.DB.batch([ - env.DB.prepare( - `INSERT INTO users - (id, display_name, email, avatar_url, created_at, updated_at) - VALUES ('user-1', NULL, 'user@example.com', NULL, ?, ?)` - ).bind(NOW_MS, NOW_MS), - env.DB.prepare( - `INSERT INTO user_identities - (id, user_id, provider, provider_issuer, provider_user_id, created_at) - VALUES ( - 'identity-1', 'user-1', 'github', 'https://github.com', - 'github-subject', ? - )` - ).bind(NOW_MS), - ]); - }); - - it("locks the browser-session lifetime policy", () => { - expect(BROWSER_SESSION_IDLE_LIFETIME_MS).toBe(7 * DAY_MS); - expect(BROWSER_SESSION_ABSOLUTE_LIFETIME_MS).toBe(30 * DAY_MS); - expect(BROWSER_SESSION_TOUCH_INTERVAL_MS).toBe(DAY_MS); - }); - - it("returns an opaque credential once and authenticates it without persisting the raw value", async () => { - const store = new BrowserAuthSessionStore(env.DB, { - clock: { now: () => NOW_MS }, - credentialGenerator: { generate: () => CREDENTIAL }, - idGenerator: { generate: () => "browser-session-1" }, - tokenHasher: { hash: hashToken }, - }); - - await expect( - store.create({ userId: "user-1", providerIdentityId: "identity-1" }) - ).resolves.toEqual({ - credential: CREDENTIAL, - credentialId: "browser-session-1", - expiresAt: NOW_MS + BROWSER_SESSION_IDLE_LIFETIME_MS, - absoluteExpiresAt: NOW_MS + BROWSER_SESSION_ABSOLUTE_LIFETIME_MS, - }); - - const persisted = await env.DB.prepare( - `SELECT token_hash, user_id, provider_identity_id - FROM browser_auth_sessions - WHERE id = 'browser-session-1'` - ).first(); - expect(persisted).toEqual({ - token_hash: await hashToken(CREDENTIAL), - user_id: "user-1", - provider_identity_id: "identity-1", - }); - expect(JSON.stringify(persisted)).not.toContain(CREDENTIAL); - - await expect(store.authenticate(CREDENTIAL)).resolves.toMatchObject({ - credentialId: "browser-session-1", - userId: "user-1", - providerIdentityId: "identity-1", - }); - }); - - it("rejects malformed creation inputs before persisting a session", async () => { - const store = new BrowserAuthSessionStore(env.DB, { - clock: { now: () => NOW_MS }, - credentialGenerator: { generate: () => CREDENTIAL }, - idGenerator: { generate: () => "" }, - tokenHasher: TOKEN_HASHER, - }); - - await expect(store.create({ userId: "", providerIdentityId: "identity-1" })).rejects.toThrow( - "requires a user and provider identity" - ); - await expect( - store.create({ userId: "user-1", providerIdentityId: "identity-1" }) - ).rejects.toThrow("id generator returned an invalid id"); - - await expect( - env.DB.prepare("SELECT COUNT(*) AS count FROM browser_auth_sessions").first() - ).resolves.toEqual({ count: 0 }); - }); - - it("revokes a browser session idempotently and rejects a copied credential", async () => { - const store = new BrowserAuthSessionStore(env.DB, { - clock: { now: () => NOW_MS }, - credentialGenerator: { generate: () => CREDENTIAL }, - idGenerator: { generate: () => "browser-session-1" }, - tokenHasher: TOKEN_HASHER, - }); - await store.create({ - userId: "user-1", - providerIdentityId: "identity-1", - }); - - await expect(store.revoke(CREDENTIAL, "logout")).resolves.toBe(true); - await expect(store.revoke(CREDENTIAL, "logout")).resolves.toBe(false); - await expect(store.authenticate(CREDENTIAL)).rejects.toEqual( - expect.objectContaining>({ - rejection: "revoked", - }) - ); - }); - - it("distinguishes malformed, unknown, idle-expired, and absolute-expired credentials", async () => { - let now = NOW_MS; - const store = new BrowserAuthSessionStore(env.DB, { - clock: { now: () => now }, - credentialGenerator: { generate: () => CREDENTIAL }, - idGenerator: { generate: () => "browser-session-1" }, - tokenHasher: TOKEN_HASHER, - }); - await store.create({ - userId: "user-1", - providerIdentityId: "identity-1", - }); - - expect(() => parseBrowserSessionCredential("not-a-browser-session")).toThrow( - expect.objectContaining>({ - rejection: "malformed", - }) - ); - expect(() => parseBrowserSessionId("")).toThrow( - expect.objectContaining>({ - rejection: "malformed", - }) - ); - await expect( - store.authenticate(parseBrowserSessionCredential(`oi_bsess_${"b".repeat(43)}`)) - ).rejects.toEqual( - expect.objectContaining>({ - rejection: "unknown", - }) - ); - - now += BROWSER_SESSION_IDLE_LIFETIME_MS; - await expect(store.authenticate(CREDENTIAL)).rejects.toEqual( - expect.objectContaining>({ - rejection: "idle_expired", - }) - ); - - now = NOW_MS + BROWSER_SESSION_ABSOLUTE_LIFETIME_MS; - await expect(store.authenticate(CREDENTIAL)).rejects.toEqual( - expect.objectContaining>({ - rejection: "absolute_expired", - }) - ); - }); - - it("revalidates a live parent session by credential id without the raw bearer", async () => { - const store = new BrowserAuthSessionStore(env.DB, { - clock: { now: () => NOW_MS }, - credentialGenerator: { generate: () => CREDENTIAL }, - idGenerator: { generate: () => "browser-session-1" }, - tokenHasher: TOKEN_HASHER, - }); - const created = await store.create({ - userId: "user-1", - providerIdentityId: "identity-1", - }); - - await expect(store.authenticateById(created.credentialId)).resolves.toMatchObject({ - credentialId: "browser-session-1", - userId: "user-1", - providerIdentityId: "identity-1", - }); - await expect(store.authenticateById(parseBrowserSessionId("missing"))).rejects.toEqual( - expect.objectContaining>({ - rejection: "unknown", - }) - ); - }); - - it("revokes an operator-selected session by credential id", async () => { - const store = new BrowserAuthSessionStore(env.DB, { - clock: { now: () => NOW_MS }, - credentialGenerator: { generate: () => CREDENTIAL }, - idGenerator: { generate: () => "browser-session-1" }, - tokenHasher: TOKEN_HASHER, - }); - const created = await store.create({ - userId: "user-1", - providerIdentityId: "identity-1", - }); - - await expect(store.revokeById(created.credentialId, "operator")).resolves.toBe(true); - await expect(store.revokeById(created.credentialId, "operator")).resolves.toBe(false); - await expect(store.authenticateById(created.credentialId)).rejects.toEqual( - expect.objectContaining>({ - rejection: "revoked", - }) - ); - }); - - it("coalesces qualifying activity and never extends past the absolute deadline", async () => { - let now = NOW_MS; - const store = new BrowserAuthSessionStore(env.DB, { - clock: { now: () => now }, - credentialGenerator: { generate: () => CREDENTIAL }, - idGenerator: { generate: () => "browser-session-1" }, - tokenHasher: TOKEN_HASHER, - }); - const created = await store.create({ - userId: "user-1", - providerIdentityId: "identity-1", - }); - - now += BROWSER_SESSION_TOUCH_INTERVAL_MS - 1; - await store.touchQualifyingActivity(created.credentialId); - let row = await env.DB.prepare( - "SELECT last_used_at, expires_at FROM browser_auth_sessions WHERE id = ?" - ) - .bind(created.credentialId) - .first<{ last_used_at: number; expires_at: number }>(); - expect(row).toEqual({ - last_used_at: NOW_MS, - expires_at: NOW_MS + BROWSER_SESSION_IDLE_LIFETIME_MS, - }); - - now = NOW_MS + BROWSER_SESSION_TOUCH_INTERVAL_MS; - await store.touchQualifyingActivity(created.credentialId); - row = await env.DB.prepare( - "SELECT last_used_at, expires_at FROM browser_auth_sessions WHERE id = ?" - ) - .bind(created.credentialId) - .first<{ last_used_at: number; expires_at: number }>(); - expect(row).toEqual({ - last_used_at: now, - expires_at: now + BROWSER_SESSION_IDLE_LIFETIME_MS, - }); - - for (const day of [6, 12, 18, 24, 28]) { - now = NOW_MS + day * BROWSER_SESSION_TOUCH_INTERVAL_MS; - await store.touchQualifyingActivity(created.credentialId); - } - row = await env.DB.prepare( - "SELECT last_used_at, expires_at FROM browser_auth_sessions WHERE id = ?" - ) - .bind(created.credentialId) - .first<{ last_used_at: number; expires_at: number }>(); - expect(row).toEqual({ - last_used_at: now, - expires_at: created.absoluteExpiresAt, - }); - }); - - it("does not renew a revoked or expired session", async () => { - let now = NOW_MS; - let credentialSequence = 0; - let idSequence = 0; - const store = new BrowserAuthSessionStore(env.DB, { - clock: { now: () => now }, - credentialGenerator: { - generate: () => - `oi_bsess_${String.fromCharCode("a".charCodeAt(0) + credentialSequence++).repeat(43)}`, - }, - idGenerator: { generate: () => `browser-session-${++idSequence}` }, - tokenHasher: TOKEN_HASHER, - }); - const revoked = await store.create({ - userId: "user-1", - providerIdentityId: "identity-1", - }); - const expired = await store.create({ - userId: "user-1", - providerIdentityId: "identity-1", - }); - await store.revoke(revoked.credential, "operator"); - - now += BROWSER_SESSION_IDLE_LIFETIME_MS; - await store.touchQualifyingActivity(revoked.credentialId); - await store.touchQualifyingActivity(expired.credentialId); - - const rows = await env.DB.prepare( - `SELECT id, last_used_at, expires_at - FROM browser_auth_sessions - ORDER BY id` - ).all<{ id: string; last_used_at: number; expires_at: number }>(); - expect(rows.results).toEqual([ - { - id: revoked.credentialId, - last_used_at: NOW_MS, - expires_at: revoked.expiresAt, - }, - { - id: expired.credentialId, - last_used_at: NOW_MS, - expires_at: expired.expiresAt, - }, - ]); - }); -}); diff --git a/packages/control-plane/test/integration/browser-auth.test.ts b/packages/control-plane/test/integration/browser-auth.test.ts new file mode 100644 index 000000000..b49c7f577 --- /dev/null +++ b/packages/control-plane/test/integration/browser-auth.test.ts @@ -0,0 +1,287 @@ +import { env } from "cloudflare:test"; +import { BROWSER_AUTH_CLIENT_IP_HEADER } from "@open-inspect/shared"; +import { getMigrations } from "better-auth/db/migration"; +import { describe, expect, it } from "vitest"; +import { + SESSION_EXPIRES_IN_MS, + SESSION_UPDATE_AGE_MS, + createUserAuth, +} from "../../src/auth/user/better-auth"; + +const PUBLIC_WEB_ORIGIN = "https://web.test.local"; +const SECRET = "test-only-better-auth-secret-with-at-least-32-characters"; +const MS_PER_SECOND = 1000; +const UNUSED_PROFILE_RESOLVER = async () => null; +const UNUSED_USER_PROJECTION = { project: async () => {} }; + +const EXPECTED_COLUMNS = { + auth_users: [ + ["id", "TEXT", 1, 1], + ["name", "TEXT", 1, 0], + ["email", "TEXT", 1, 0], + ["emailVerified", "INTEGER", 1, 0], + ["image", "TEXT", 0, 0], + ["createdAt", "DATE", 1, 0], + ["updatedAt", "DATE", 1, 0], + ], + auth_sessions: [ + ["id", "TEXT", 1, 1], + ["expiresAt", "DATE", 1, 0], + ["token", "TEXT", 1, 0], + ["createdAt", "DATE", 1, 0], + ["updatedAt", "DATE", 1, 0], + ["ipAddress", "TEXT", 0, 0], + ["userAgent", "TEXT", 0, 0], + ["userId", "TEXT", 1, 0], + ], + auth_accounts: [ + ["id", "TEXT", 1, 1], + ["accountId", "TEXT", 1, 0], + ["providerId", "TEXT", 1, 0], + ["userId", "TEXT", 1, 0], + ["accessToken", "TEXT", 0, 0], + ["refreshToken", "TEXT", 0, 0], + ["idToken", "TEXT", 0, 0], + ["accessTokenExpiresAt", "DATE", 0, 0], + ["refreshTokenExpiresAt", "DATE", 0, 0], + ["scope", "TEXT", 0, 0], + ["password", "TEXT", 0, 0], + ["createdAt", "DATE", 1, 0], + ["updatedAt", "DATE", 1, 0], + ], + auth_verifications: [ + ["id", "TEXT", 1, 1], + ["identifier", "TEXT", 1, 0], + ["value", "TEXT", 1, 0], + ["expiresAt", "DATE", 1, 0], + ["createdAt", "DATE", 1, 0], + ["updatedAt", "DATE", 1, 0], + ], +} as const; + +function createTestAuth() { + return createUserAuth({ + database: env.DB, + publicWebOrigin: PUBLIC_WEB_ORIGIN, + secret: SECRET, + userProjection: UNUSED_USER_PROJECTION, + }); +} + +describe("browser authentication", () => { + it("keeps the static schema aligned with the pinned Better Auth runtime", async () => { + const migrations = await getMigrations(createTestAuth().options); + expect(migrations.toBeCreated).toEqual([]); + expect(migrations.toBeAdded).toEqual([]); + + for (const [table, expectedColumns] of Object.entries(EXPECTED_COLUMNS)) { + const columns = await env.DB.prepare(`PRAGMA table_info(${table})`).all<{ + name: string; + type: string; + notnull: number; + pk: number; + }>(); + expect( + columns.results.map(({ name, type, notnull, pk }) => [name, type, notnull, pk]) + ).toEqual(expectedColumns); + } + + const providerIdentityIndex = await env.DB.prepare( + `SELECT "unique" + FROM pragma_index_list('auth_accounts') + WHERE name = 'idx_auth_accounts_provider_identity'` + ).first<{ unique: number }>(); + expect(providerIdentityIndex?.unique).toBe(1); + }); + + it("serves an anonymous session through Better Auth on Workers and D1", async () => { + const auth = createTestAuth(); + const response = await auth.handler(new Request(`${PUBLIC_WEB_ORIGIN}/api/auth/get-session`)); + + expect(response.status).toBe(200); + expect(await response.json()).toBeNull(); + }); + + it("initiates GitHub App sign-in with PKCE and no classic OAuth scopes", async () => { + const auth = createUserAuth({ + database: env.DB, + publicWebOrigin: PUBLIC_WEB_ORIGIN, + secret: SECRET, + userProjection: UNUSED_USER_PROJECTION, + github: { + clientId: "github-app-client-id", + clientSecret: "github-app-client-secret", + getUserInfo: UNUSED_PROFILE_RESOLVER, + }, + }); + + const response = await auth.handler( + new Request(`${PUBLIC_WEB_ORIGIN}/api/auth/sign-in/social`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: PUBLIC_WEB_ORIGIN, + }, + body: JSON.stringify({ + provider: "github", + callbackURL: "/", + disableRedirect: true, + }), + }) + ); + + expect(response.status).toBe(200); + const body = await response.json<{ redirect: boolean; url: string }>(); + const providerUrl = new URL(body.url); + expect(body.redirect).toBe(false); + expect(providerUrl.origin).toBe("https://github.com"); + expect(providerUrl.pathname).toBe("/login/oauth/authorize"); + expect(providerUrl.searchParams.get("client_id")).toBe("github-app-client-id"); + expect(providerUrl.searchParams.get("redirect_uri")).toBe( + `${PUBLIC_WEB_ORIGIN}/api/auth/callback/github` + ); + expect(providerUrl.searchParams.get("scope")).toBe(""); + expect(providerUrl.searchParams.get("code_challenge_method")).toBe("S256"); + expect(providerUrl.searchParams.get("state")).toBeTruthy(); + + const stateCookie = response.headers.get("set-cookie"); + expect(stateCookie).toContain("__Secure-openinspect.state="); + expect(stateCookie?.toLowerCase()).toContain("httponly"); + expect(stateCookie?.toLowerCase()).toContain("secure"); + expect(stateCookie?.toLowerCase()).toContain("samesite=lax"); + expect(stateCookie?.toLowerCase()).not.toContain("domain="); + }); + + it("rate limits repeated browser sign-in attempts by the trusted client IP", async () => { + const auth = createUserAuth({ + database: env.DB, + publicWebOrigin: PUBLIC_WEB_ORIGIN, + secret: SECRET, + userProjection: UNUSED_USER_PROJECTION, + github: { + clientId: "github-app-client-id", + clientSecret: "github-app-client-secret", + getUserInfo: UNUSED_PROFILE_RESOLVER, + }, + }); + const signIn = () => + auth.handler( + new Request(`${PUBLIC_WEB_ORIGIN}/api/auth/sign-in/social`, { + method: "POST", + headers: { + [BROWSER_AUTH_CLIENT_IP_HEADER]: "203.0.113.73", + "Content-Type": "application/json", + Origin: PUBLIC_WEB_ORIGIN, + }, + body: JSON.stringify({ + provider: "github", + callbackURL: "/", + disableRedirect: true, + }), + }) + ); + + await expect(signIn()).resolves.toMatchObject({ status: 200 }); + await expect(signIn()).resolves.toMatchObject({ status: 200 }); + await expect(signIn()).resolves.toMatchObject({ status: 200 }); + + const limited = await signIn(); + expect(limited.status).toBe(429); + expect(limited.headers.get("X-Retry-After")).toBeTruthy(); + }); + + it("uses a non-Secure host-only cookie only for loopback HTTP development", async () => { + const localOrigin = "http://localhost:3000"; + const auth = createUserAuth({ + database: env.DB, + publicWebOrigin: localOrigin, + secret: SECRET, + userProjection: UNUSED_USER_PROJECTION, + github: { + clientId: "github-app-client-id", + clientSecret: "github-app-client-secret", + getUserInfo: UNUSED_PROFILE_RESOLVER, + }, + }); + + const response = await auth.handler( + new Request(`${localOrigin}/api/auth/sign-in/social`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: localOrigin, + }, + body: JSON.stringify({ + provider: "github", + callbackURL: "/", + disableRedirect: true, + }), + }) + ); + + expect(response.status).toBe(200); + const stateCookie = response.headers.get("set-cookie"); + expect(stateCookie).toContain("openinspect.state="); + expect(stateCookie).not.toContain("__Secure-"); + expect(stateCookie?.toLowerCase()).not.toContain("; secure"); + expect(stateCookie?.toLowerCase()).toContain("httponly"); + }); + + it("initiates Google OIDC sign-in with PKCE and minimum identity scopes", async () => { + const auth = createUserAuth({ + database: env.DB, + publicWebOrigin: PUBLIC_WEB_ORIGIN, + secret: SECRET, + userProjection: UNUSED_USER_PROJECTION, + google: { + clientId: "google-client-id", + clientSecret: "google-client-secret", + getUserInfo: UNUSED_PROFILE_RESOLVER, + }, + }); + + const response = await auth.handler( + new Request(`${PUBLIC_WEB_ORIGIN}/api/auth/sign-in/social`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: PUBLIC_WEB_ORIGIN, + }, + body: JSON.stringify({ + provider: "google", + callbackURL: "/", + disableRedirect: true, + }), + }) + ); + + expect(response.status).toBe(200); + const body = await response.json<{ redirect: boolean; url: string }>(); + const providerUrl = new URL(body.url); + expect(body.redirect).toBe(false); + expect(providerUrl.origin).toBe("https://accounts.google.com"); + expect(providerUrl.pathname).toBe("/o/oauth2/v2/auth"); + expect(providerUrl.searchParams.get("client_id")).toBe("google-client-id"); + expect(providerUrl.searchParams.get("redirect_uri")).toBe( + `${PUBLIC_WEB_ORIGIN}/api/auth/callback/google` + ); + expect(new Set(providerUrl.searchParams.get("scope")?.split(" "))).toEqual( + new Set(["email", "openid", "profile"]) + ); + expect(providerUrl.searchParams.get("code_challenge_method")).toBe("S256"); + expect(providerUrl.searchParams.get("state")).toBeTruthy(); + }); + + it("uses canonical ids and converts millisecond durations at the library boundary", () => { + const auth = createTestAuth(); + const generateId = auth.options.advanced?.database?.generateId; + + expect(generateId).toBeTypeOf("function"); + if (typeof generateId !== "function") { + throw new Error("Better Auth canonical ID generator is not configured"); + } + expect(generateId({ model: "user" })).toMatch(/^[a-f0-9]{32}$/); + expect(auth.options.session?.expiresIn).toBe(SESSION_EXPIRES_IN_MS / MS_PER_SECOND); + expect(auth.options.session?.updateAge).toBe(SESSION_UPDATE_AGE_MS / MS_PER_SECOND); + }); +}); diff --git a/packages/control-plane/test/integration/cleanup.ts b/packages/control-plane/test/integration/cleanup.ts index 77ae21ec1..3917a0cbe 100644 --- a/packages/control-plane/test/integration/cleanup.ts +++ b/packages/control-plane/test/integration/cleanup.ts @@ -6,6 +6,6 @@ import { env } from "cloudflare:test"; */ export async function cleanD1Tables(): Promise { await env.DB.exec( - "DELETE FROM oauth_authorization_codes; DELETE FROM browser_auth_sessions; DELETE FROM provider_credentials; DELETE FROM verified_email_claims; DELETE FROM oauth_flow_state; DELETE FROM automation_slack_channels; DELETE FROM automation_runs; DELETE FROM automation_invocations; DELETE FROM automation_repositories; DELETE FROM automation_environments; DELETE FROM automations; DELETE FROM session_pull_requests; DELETE FROM session_repositories; DELETE FROM sessions; DELETE FROM user_scm_tokens; DELETE FROM repo_metadata; DELETE FROM repo_secrets; DELETE FROM global_secrets; DELETE FROM commit_signing_configuration; DELETE FROM integration_settings; DELETE FROM integration_repo_settings; DELETE FROM integration_environment_settings; DELETE FROM model_preferences; DELETE FROM mcp_servers; DELETE FROM user_identities; DELETE FROM users; DELETE FROM api_tokens; DELETE FROM image_builds; DELETE FROM environment_secrets; DELETE FROM environment_repositories; DELETE FROM environments;" + "DELETE FROM auth_verifications; DELETE FROM auth_sessions; DELETE FROM auth_accounts; DELETE FROM auth_users; DELETE FROM oauth_authorization_codes; DELETE FROM browser_auth_sessions; DELETE FROM provider_credentials; DELETE FROM verified_email_claims; DELETE FROM oauth_flow_state; DELETE FROM automation_slack_channels; DELETE FROM automation_runs; DELETE FROM automation_invocations; DELETE FROM automation_repositories; DELETE FROM automation_environments; DELETE FROM automations; DELETE FROM session_pull_requests; DELETE FROM session_repositories; DELETE FROM sessions; DELETE FROM user_scm_tokens; DELETE FROM repo_metadata; DELETE FROM repo_secrets; DELETE FROM global_secrets; DELETE FROM commit_signing_configuration; DELETE FROM integration_settings; DELETE FROM integration_repo_settings; DELETE FROM integration_environment_settings; DELETE FROM model_preferences; DELETE FROM mcp_servers; DELETE FROM user_identities; DELETE FROM users; DELETE FROM api_tokens; DELETE FROM image_builds; DELETE FROM environment_secrets; DELETE FROM environment_repositories; DELETE FROM environments;" ); } diff --git a/packages/control-plane/test/integration/helpers.ts b/packages/control-plane/test/integration/helpers.ts index 0d9f5e66b..9f9e13ad3 100644 --- a/packages/control-plane/test/integration/helpers.ts +++ b/packages/control-plane/test/integration/helpers.ts @@ -5,12 +5,113 @@ import type { SessionDO } from "../../src/session/durable-object"; import { hashToken } from "../../src/auth/crypto"; const DEFAULT_WAIT_FOR_SANDBOX_STATUS_TIMEOUT_MS = 3000; +const TEST_BROWSER_USER_ID = "11111111111111111111111111111111"; +const TEST_BROWSER_ACCOUNT_ID = "test-browser-account"; +const TEST_BROWSER_PROVIDER_SUBJECT = "583231"; +const TEST_BROWSER_SESSION_ID = "test-browser-session"; +const TEST_BROWSER_SESSION_TOKEN = "test-browser-session-token"; +const TEST_BROWSER_SESSION_COOKIE = "__Secure-openinspect.session_token"; + +async function signCookieValue(value: string, secret: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const signature = new Uint8Array( + await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value)) + ); + const signatureBase64 = btoa(String.fromCharCode(...signature)); + return encodeURIComponent(`${value}.${signatureBase64}`); +} + +/** + * Seed one real Better Auth user/account/session and return its signed cookie. + * + * Integration route tests exercise browser-owned endpoints, so their default + * web request must carry the same compound credential as production. Direct + * service-auth tests intentionally build their own bare sig1 requests. + */ +async function testBrowserSessionCookie(): Promise { + const secret = env.BROWSER_AUTH_SECRET; + if (!secret) throw new Error("BROWSER_AUTH_SECRET is not configured for integration tests"); + + const now = new Date(); + const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + const applicationTimestamp = now.getTime(); + await env.DB.batch([ + env.DB.prepare( + `INSERT OR IGNORE INTO auth_users + (id, name, email, emailVerified, image, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).bind( + TEST_BROWSER_USER_ID, + "Integration Browser User", + "browser@test.local", + 1, + null, + now.toISOString(), + now.toISOString() + ), + env.DB.prepare( + `INSERT OR IGNORE INTO users + (id, display_name, email, avatar_url, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)` + ).bind( + TEST_BROWSER_USER_ID, + "Integration Browser User", + "browser@test.local", + null, + applicationTimestamp, + applicationTimestamp + ), + env.DB.prepare( + `INSERT OR IGNORE INTO auth_accounts + (id, accountId, providerId, userId, accessToken, refreshToken, idToken, + accessTokenExpiresAt, refreshTokenExpiresAt, scope, password, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).bind( + TEST_BROWSER_ACCOUNT_ID, + TEST_BROWSER_PROVIDER_SUBJECT, + "github", + TEST_BROWSER_USER_ID, + null, + null, + null, + null, + null, + null, + null, + now.toISOString(), + now.toISOString() + ), + env.DB.prepare( + `INSERT OR IGNORE INTO auth_sessions + (id, expiresAt, token, createdAt, updatedAt, ipAddress, userAgent, userId) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).bind( + TEST_BROWSER_SESSION_ID, + expiresAt.toISOString(), + TEST_BROWSER_SESSION_TOKEN, + now.toISOString(), + now.toISOString(), + "127.0.0.1", + "integration-test", + TEST_BROWSER_USER_ID + ), + ]); + + const signedToken = await signCookieValue(TEST_BROWSER_SESSION_TOKEN, secret); + return `${TEST_BROWSER_SESSION_COOKIE}=${signedToken}`; +} /** - * Fetch a control-plane route as a service principal. Signs per request — - * sig1 binds method, URL, and body, so headers can never be reused across - * calls. Defaults to the `web` service; secrets follow the - * `test-service-secret-` bindings in vitest.integration.config.ts. + * Fetch a control-plane route with production-equivalent credentials. Web + * calls carry both sig1 and a Better Auth browser session; other services + * carry their service credential. Signs per request because sig1 binds method, + * URL, and body. */ export async function serviceFetch( url: string, @@ -32,10 +133,12 @@ export async function serviceFetch( body: init?.body, actor: init?.actor, }); + const browserCookie = service === "web" ? await testBrowserSessionCookie() : undefined; return SELF.fetch(url, { method, headers: { ...(init?.body === undefined ? {} : { "Content-Type": "application/json" }), + ...(browserCookie ? { Cookie: browserCookie } : {}), ...init?.headers, ...auth, }, diff --git a/packages/control-plane/test/integration/oauth-authorization-codes.test.ts b/packages/control-plane/test/integration/oauth-authorization-codes.test.ts deleted file mode 100644 index ebdb8ba39..000000000 --- a/packages/control-plane/test/integration/oauth-authorization-codes.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { env } from "cloudflare:test"; -import { beforeEach, describe, expect, it } from "vitest"; -import { hashToken } from "../../src/auth/crypto"; -import { createPkceS256Challenge } from "../../src/auth/pkce"; -import { - BROWSER_SESSION_ABSOLUTE_LIFETIME_MS, - BROWSER_SESSION_IDLE_LIFETIME_MS, - BrowserAuthSessionStore, - parseBrowserSessionCredential, - type BrowserAuthSessionStoreDependencies, -} from "../../src/db/browser-auth-sessions"; -import { - InvalidOAuthAuthorizationCodeInputError, - OAUTH_AUTHORIZATION_CODE_LIFETIME_MS, - OAuthAuthorizationCodeRedemptionError, - OAuthAuthorizationCodeStore, - type IssueOAuthAuthorizationCodeInput, -} from "../../src/db/oauth-authorization-codes"; -import { cleanD1Tables } from "./cleanup"; - -const NOW_MS = 1_800_000_000_000; -const AUTHORIZATION_CODE = `oi_code_${"a".repeat(43)}`; -const BROWSER_CREDENTIAL = `oi_bsess_${"b".repeat(43)}`; -const CODE_VERIFIER = "v".repeat(43); -const REDIRECT_URI = "https://web.example/api/auth/callback"; - -describe("OAuthAuthorizationCodeStore", () => { - beforeEach(async () => { - await cleanD1Tables(); - await env.DB.batch([ - env.DB.prepare( - `INSERT INTO users - (id, display_name, email, avatar_url, created_at, updated_at) - VALUES ('user-1', NULL, NULL, NULL, ?, ?)` - ).bind(NOW_MS, NOW_MS), - env.DB.prepare( - `INSERT INTO user_identities - (id, user_id, provider, provider_issuer, provider_user_id, created_at) - VALUES ( - 'identity-1', 'user-1', 'github', 'https://github.com', - 'github-subject', ? - )` - ).bind(NOW_MS), - ]); - }); - - function createStore(now = NOW_MS): OAuthAuthorizationCodeStore { - const ids = ["code-1", "browser-session-1", "browser-session-2"]; - return new OAuthAuthorizationCodeStore(env.DB, { - clock: { now: () => now }, - tokenHasher: { hash: hashToken }, - authorizationCodeGenerator: { generate: () => AUTHORIZATION_CODE }, - browserCredentialGenerator: { generate: () => BROWSER_CREDENTIAL }, - idGenerator: { - generate: () => { - const id = ids.shift(); - if (!id) throw new Error("Unexpected id request"); - return id; - }, - }, - }); - } - - it("redeems a bound code into an authenticatable browser session", async () => { - const store = createStore(); - const challenge = await createPkceS256Challenge(CODE_VERIFIER); - - await expect( - store.issue({ - userId: "user-1", - providerIdentityId: "identity-1", - clientId: "web", - redirectUri: REDIRECT_URI, - codeChallenge: challenge, - }) - ).resolves.toEqual({ - code: AUTHORIZATION_CODE, - expiresAt: NOW_MS + OAUTH_AUTHORIZATION_CODE_LIFETIME_MS, - }); - await expect( - env.DB.prepare("SELECT code_hash FROM oauth_authorization_codes WHERE id = 'code-1'").first() - ).resolves.toEqual({ code_hash: await hashToken(AUTHORIZATION_CODE) }); - - const redeemed = await store.redeem({ - code: AUTHORIZATION_CODE, - clientId: "web", - redirectUri: REDIRECT_URI, - codeVerifier: CODE_VERIFIER, - }); - - expect(redeemed).toEqual({ - credential: BROWSER_CREDENTIAL, - credentialId: "browser-session-1", - expiresAt: NOW_MS + BROWSER_SESSION_IDLE_LIFETIME_MS, - absoluteExpiresAt: NOW_MS + BROWSER_SESSION_ABSOLUTE_LIFETIME_MS, - }); - await expect( - env.DB.prepare( - "SELECT consumed_by FROM oauth_authorization_codes WHERE id = 'code-1'" - ).first() - ).resolves.toEqual({ consumed_by: redeemed.credentialId }); - - const sessionDependencies: BrowserAuthSessionStoreDependencies = { - clock: { now: () => NOW_MS }, - tokenHasher: { hash: hashToken }, - credentialGenerator: { generate: () => BROWSER_CREDENTIAL }, - idGenerator: { generate: () => "unused" }, - }; - const sessions = new BrowserAuthSessionStore(env.DB, sessionDependencies); - await expect( - sessions.authenticate(parseBrowserSessionCredential(BROWSER_CREDENTIAL)) - ).resolves.toMatchObject({ - credentialId: "browser-session-1", - userId: "user-1", - providerIdentityId: "identity-1", - }); - }); - - it("does not consume a code when its redirect binding or PKCE verifier is wrong", async () => { - const store = createStore(); - await store.issue({ - userId: "user-1", - providerIdentityId: "identity-1", - clientId: "web", - redirectUri: REDIRECT_URI, - codeChallenge: await createPkceS256Challenge(CODE_VERIFIER), - }); - - await expect( - store.redeem({ - code: AUTHORIZATION_CODE, - clientId: "web", - redirectUri: "https://attacker.example/callback", - codeVerifier: CODE_VERIFIER, - }) - ).rejects.toMatchObject({ rejection: "binding_mismatch" }); - await expect( - store.redeem({ - code: AUTHORIZATION_CODE, - clientId: "web", - redirectUri: REDIRECT_URI, - codeVerifier: "x".repeat(43), - }) - ).rejects.toMatchObject({ rejection: "pkce_failed" }); - - await expect( - env.DB.prepare( - "SELECT consumed_at, consumed_by FROM oauth_authorization_codes WHERE id = 'code-1'" - ).first() - ).resolves.toEqual({ consumed_at: null, consumed_by: null }); - }); - - it("distinguishes malformed and unknown authorization codes", async () => { - const redemption = { - clientId: "web" as const, - redirectUri: REDIRECT_URI, - codeVerifier: CODE_VERIFIER, - }; - - await expect( - createStore().redeem({ ...redemption, code: "not-an-authorization-code" }) - ).rejects.toMatchObject({ rejection: "malformed" }); - await expect( - createStore().redeem({ ...redemption, code: `oi_code_${"z".repeat(43)}` }) - ).rejects.toMatchObject({ rejection: "unknown" }); - }); - - it("rejects malformed authorization-code bindings before persistence", async () => { - const validInput: IssueOAuthAuthorizationCodeInput = { - userId: "user-1", - providerIdentityId: "identity-1", - clientId: "web", - redirectUri: REDIRECT_URI, - codeChallenge: await createPkceS256Challenge(CODE_VERIFIER), - }; - const invalidBindings: Array< - [string, Partial>] - > = [ - ["missing user", { userId: "" }], - ["missing provider identity", { providerIdentityId: "" }], - ["unsupported client", { clientId: "cli" }], - ["missing redirect URI", { redirectUri: "" }], - ["malformed PKCE challenge", { codeChallenge: "not-a-challenge" }], - ]; - - for (const [name, override] of invalidBindings) { - const issue = createStore().issue({ - ...validInput, - ...override, - } as IssueOAuthAuthorizationCodeInput); - await expect(issue, name).rejects.toBeInstanceOf(InvalidOAuthAuthorizationCodeInputError); - } - await expect( - env.DB.prepare("SELECT count(*) AS count FROM oauth_authorization_codes").first() - ).resolves.toEqual({ count: 0 }); - }); - - it("rejects a code at its exact expiry boundary without creating a session", async () => { - await createStore().issue({ - userId: "user-1", - providerIdentityId: "identity-1", - clientId: "web", - redirectUri: REDIRECT_URI, - codeChallenge: await createPkceS256Challenge(CODE_VERIFIER), - }); - - await expect( - createStore(NOW_MS + OAUTH_AUTHORIZATION_CODE_LIFETIME_MS).redeem({ - code: AUTHORIZATION_CODE, - clientId: "web", - redirectUri: REDIRECT_URI, - codeVerifier: CODE_VERIFIER, - }) - ).rejects.toMatchObject({ rejection: "expired" }); - await expect( - env.DB.prepare("SELECT count(*) AS count FROM browser_auth_sessions").first() - ).resolves.toEqual({ count: 0 }); - }); - - it("classifies sequential authorization-code replay as already consumed", async () => { - const store = createStore(); - await store.issue({ - userId: "user-1", - providerIdentityId: "identity-1", - clientId: "web", - redirectUri: REDIRECT_URI, - codeChallenge: await createPkceS256Challenge(CODE_VERIFIER), - }); - const redemption = { - code: AUTHORIZATION_CODE, - clientId: "web" as const, - redirectUri: REDIRECT_URI, - codeVerifier: CODE_VERIFIER, - }; - - await expect(store.redeem(redemption)).resolves.toMatchObject({ - credentialId: "browser-session-1", - }); - await expect(store.redeem(redemption)).rejects.toMatchObject({ - rejection: "already_consumed", - }); - await expect( - env.DB.prepare("SELECT count(*) AS count FROM browser_auth_sessions").first() - ).resolves.toEqual({ count: 1 }); - }); - - it("allows exactly one concurrent redemption without creating an orphan session", async () => { - const store = createStore(); - await store.issue({ - userId: "user-1", - providerIdentityId: "identity-1", - clientId: "web", - redirectUri: REDIRECT_URI, - codeChallenge: await createPkceS256Challenge(CODE_VERIFIER), - }); - const redemption = { - code: AUTHORIZATION_CODE, - clientId: "web" as const, - redirectUri: REDIRECT_URI, - codeVerifier: CODE_VERIFIER, - }; - - const results = await Promise.allSettled([store.redeem(redemption), store.redeem(redemption)]); - - expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1); - const rejection = results.find(({ status }) => status === "rejected"); - expect(rejection).toMatchObject({ - status: "rejected", - reason: expect.any(OAuthAuthorizationCodeRedemptionError), - }); - await expect( - env.DB.prepare("SELECT count(*) AS count FROM browser_auth_sessions").first() - ).resolves.toEqual({ count: 1 }); - }); - - it("rolls back code consumption when browser-session insertion fails", async () => { - const store = createStore(); - await store.issue({ - userId: "user-1", - providerIdentityId: "identity-1", - clientId: "web", - redirectUri: REDIRECT_URI, - codeChallenge: await createPkceS256Challenge(CODE_VERIFIER), - }); - await env.DB.prepare( - `INSERT INTO browser_auth_sessions ( - id, token_hash, user_id, client_id, provider_identity_id, - created_at, last_used_at, expires_at, absolute_expires_at, - revoked_at, revoked_reason - ) VALUES (?, ?, 'user-1', 'web', 'identity-1', ?, ?, ?, ?, NULL, NULL)` - ) - .bind( - "browser-session-1", - "c".repeat(64), - NOW_MS, - NOW_MS, - NOW_MS + BROWSER_SESSION_IDLE_LIFETIME_MS, - NOW_MS + BROWSER_SESSION_ABSOLUTE_LIFETIME_MS - ) - .run(); - - await expect( - store.redeem({ - code: AUTHORIZATION_CODE, - clientId: "web", - redirectUri: REDIRECT_URI, - codeVerifier: CODE_VERIFIER, - }) - ).rejects.toThrow(); - await expect( - env.DB.prepare( - "SELECT consumed_at, consumed_by FROM oauth_authorization_codes WHERE id = 'code-1'" - ).first() - ).resolves.toEqual({ consumed_at: null, consumed_by: null }); - await expect( - env.DB.prepare("SELECT count(*) AS count FROM browser_auth_sessions").first() - ).resolves.toEqual({ count: 1 }); - - await expect( - store.redeem({ - code: AUTHORIZATION_CODE, - clientId: "web", - redirectUri: REDIRECT_URI, - codeVerifier: CODE_VERIFIER, - }) - ).resolves.toMatchObject({ credentialId: "browser-session-2" }); - await expect( - env.DB.prepare( - "SELECT consumed_by FROM oauth_authorization_codes WHERE id = 'code-1'" - ).first() - ).resolves.toEqual({ consumed_by: "browser-session-2" }); - }); -}); diff --git a/packages/control-plane/test/integration/oauth-flow-state.test.ts b/packages/control-plane/test/integration/oauth-flow-state.test.ts deleted file mode 100644 index a31ac6b57..000000000 --- a/packages/control-plane/test/integration/oauth-flow-state.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { env } from "cloudflare:test"; -import { beforeEach, describe, expect, expectTypeOf, it } from "vitest"; -import { hashToken } from "../../src/auth/crypto"; -import { - OAuthFlowVerifierIntegrityError, - type OAuthFlowVerifierCipher, -} from "../../src/auth/oauth-flow-verifier"; -import type { CreateOAuthFlowStateInput } from "../../src/auth/oauth-flow-state"; -import { ProviderPkceFlowCipher } from "../../src/auth/auth-encryption"; -import { - InvalidOAuthFlowStateInputError, - OAuthFlowStateStore, -} from "../../src/db/oauth-flow-state"; -import type { OAuthFlowStateConsumptionError } from "../../src/db/oauth-flow-state"; -import { cleanD1Tables } from "./cleanup"; - -const NOW_MS = 1_800_000_000_000; -const ROOT_KEY_BASE64 = Buffer.from( - "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", - "hex" -).toString("base64"); -const STATE = "s".repeat(43); -const CLIENT_CHALLENGE = "c".repeat(43); -const PROVIDER_VERIFIER = "v".repeat(43); -const OIDC_NONCE = "n".repeat(43); - -describe("OAuthFlowStateStore", () => { - beforeEach(cleanD1Tables); - - function createStore( - now = NOW_MS, - verifierCipher: OAuthFlowVerifierCipher = new ProviderPkceFlowCipher(ROOT_KEY_BASE64) - ): OAuthFlowStateStore { - return new OAuthFlowStateStore(env.DB, verifierCipher, { - clock: { now: () => now }, - idGenerator: { generate: () => "flow-1" }, - tokenHasher: { hash: hashToken }, - }); - } - - it("stores only protected transaction values and consumes the bound flow once", async () => { - const store = createStore(); - await store.create({ - state: STATE, - provider: "google", - clientId: "web", - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - oidcNonce: OIDC_NONCE, - }); - - const row = await env.DB.prepare("SELECT * FROM oauth_flow_state").first(); - expect(row).toMatchObject({ - id: "flow-1", - state_hash: await hashToken(STATE), - provider: "google", - provider_pkce_key_version: 1, - oidc_nonce_hash: await hashToken(OIDC_NONCE), - consumed_at: null, - }); - expect(JSON.stringify(row)).not.toContain(STATE); - expect(JSON.stringify(row)).not.toContain(PROVIDER_VERIFIER); - expect(JSON.stringify(row)).not.toContain(OIDC_NONCE); - - const consumed = await store.consume(STATE, "google"); - expectTypeOf(consumed.oidcNonceHash).toEqualTypeOf(); - expect(consumed).toMatchObject({ - flowId: "flow-1", - provider: "google", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - oidcNonceHash: await hashToken(OIDC_NONCE), - }); - await expect(store.consume(STATE, "google")).rejects.toEqual( - expect.objectContaining({ - name: "OAuthFlowStateConsumptionError", - rejection: "already_consumed", - }) - ); - }); - - it("does not consume a flow on provider mix-up", async () => { - const store = createStore(); - await store.create({ - state: STATE, - provider: "github", - clientId: "web", - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - }); - - await expect(store.consume(STATE, "google")).rejects.toEqual( - expect.objectContaining({ - rejection: "provider_mismatch", - }) - ); - await expect(store.consume(STATE, "github")).resolves.toMatchObject({ - provider: "github", - }); - }); - - it("allows exactly one concurrent consumer", async () => { - const store = createStore(); - await store.create({ - state: STATE, - provider: "github", - clientId: "web", - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - }); - - const results = await Promise.allSettled([ - store.consume(STATE, "github"), - store.consume(STATE, "github"), - ]); - - expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); - expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); - }); - - it("rejects a flow at the exact expiry boundary without consuming it", async () => { - await createStore().create({ - state: STATE, - provider: "github", - clientId: "web", - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - }); - - const expiredStore = createStore(NOW_MS + 10 * 60 * 1000); - await expect(expiredStore.consume(STATE, "github")).rejects.toEqual( - expect.objectContaining({ - rejection: "expired", - }) - ); - await expect( - env.DB.prepare("SELECT consumed_at FROM oauth_flow_state WHERE id = 'flow-1'").first() - ).resolves.toEqual({ consumed_at: null }); - }); - - it("fails closed on ciphertext corruption without consuming the flow", async () => { - const store = createStore(); - await store.create({ - state: STATE, - provider: "google", - clientId: "web", - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - oidcNonce: OIDC_NONCE, - }); - await env.DB.prepare( - "UPDATE oauth_flow_state SET provider_pkce_verifier_ciphertext = ? WHERE id = ?" - ) - .bind(btoa("corrupt-ciphertext-that-is-long-enough"), "flow-1") - .run(); - - await expect(store.consume(STATE, "google")).rejects.toEqual( - expect.objectContaining({ - rejection: "corrupt", - }) - ); - await expect( - env.DB.prepare("SELECT consumed_at FROM oauth_flow_state WHERE id = 'flow-1'").first() - ).resolves.toEqual({ consumed_at: null }); - }); - - it("normalizes integrity failures declared by the cipher port", async () => { - await createStore().create({ - state: STATE, - provider: "github", - clientId: "web", - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - }); - const failingCipher: OAuthFlowVerifierCipher = { - encrypt: async () => { - throw new Error("unexpected encryption"); - }, - decrypt: async () => { - throw new OAuthFlowVerifierIntegrityError(); - }, - }; - - await expect(createStore(NOW_MS, failingCipher).consume(STATE, "github")).rejects.toEqual( - expect.objectContaining({ - rejection: "corrupt", - }) - ); - }); - - it("rejects provider-inconsistent nonce input before writing state", async () => { - const store = createStore(); - await expect( - store.create({ - state: STATE, - provider: "google", - clientId: "web", - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - } as unknown as CreateOAuthFlowStateInput) - ).rejects.toBeInstanceOf(InvalidOAuthFlowStateInputError); - await expect( - store.create({ - state: STATE, - provider: "github", - clientId: "web", - redirectUri: "https://web.example/api/auth/callback", - clientCodeChallenge: CLIENT_CHALLENGE, - providerPkceVerifier: PROVIDER_VERIFIER, - oidcNonce: OIDC_NONCE, - } as unknown as CreateOAuthFlowStateInput) - ).rejects.toBeInstanceOf(InvalidOAuthFlowStateInputError); - await expect( - env.DB.prepare("SELECT count(*) AS count FROM oauth_flow_state").first() - ).resolves.toEqual({ count: 0 }); - }); -}); diff --git a/packages/control-plane/test/integration/provider-credentials.test.ts b/packages/control-plane/test/integration/provider-credentials.test.ts deleted file mode 100644 index b0459ca7b..000000000 --- a/packages/control-plane/test/integration/provider-credentials.test.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { env } from "cloudflare:test"; -import { beforeEach, describe, expect, it } from "vitest"; -import { ProviderCredentialCipher } from "../../src/auth/auth-encryption"; -import type { ProviderCredentialCipherPort } from "../../src/auth/provider-credential-cipher"; -import { - ProviderCredentialStore, - ProviderCredentialVersionConflictError, - StoredProviderCredentialCorruptError, -} from "../../src/db/provider-credentials"; -import { cleanD1Tables } from "./cleanup"; - -const NOW_MS = 1_800_000_000_000; -const ROOT_KEY_BASE64 = Buffer.from( - "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", - "hex" -).toString("base64"); - -describe("ProviderCredentialStore", () => { - let store: ProviderCredentialStore; - - beforeEach(async () => { - await cleanD1Tables(); - await env.DB.batch([ - env.DB.prepare( - `INSERT INTO users - (id, display_name, email, avatar_url, created_at, updated_at) - VALUES ('user-1', NULL, NULL, NULL, ?, ?)` - ).bind(NOW_MS, NOW_MS), - env.DB.prepare( - `INSERT INTO user_identities - (id, user_id, provider, provider_issuer, provider_user_id, created_at) - VALUES ( - 'identity-1', 'user-1', 'github', 'https://github.com', - 'github-subject', ? - )` - ).bind(NOW_MS), - ]); - store = new ProviderCredentialStore(env.DB, new ProviderCredentialCipher(ROOT_KEY_BASE64), { - now: () => NOW_MS, - }); - }); - - it("round-trips refreshable credentials without storing plaintext tokens", async () => { - await expect( - store.upsertFromSignIn("identity-1", { - kind: "refreshable", - accessToken: "github-access-token", - accessExpiresAt: NOW_MS + 60_000, - refreshToken: "github-refresh-token", - refreshExpiresAt: null, - }) - ).resolves.toBe(1); - await expect(store.get("identity-1")).resolves.toEqual({ - providerIdentityId: "identity-1", - kind: "refreshable", - accessToken: "github-access-token", - accessExpiresAt: NOW_MS + 60_000, - refreshToken: "github-refresh-token", - refreshExpiresAt: null, - encryptionKeyVersion: 1, - rowVersion: 1, - updatedAt: NOW_MS, - }); - - const persisted = await env.DB.prepare( - `SELECT access_token_ciphertext, refresh_token_ciphertext - FROM provider_credentials - WHERE provider_identity_id = 'identity-1'` - ).first(); - expect(JSON.stringify(persisted)).not.toContain("github-access-token"); - expect(JSON.stringify(persisted)).not.toContain("github-refresh-token"); - }); - - it("does not let stale invalidation delete credentials from a newer sign-in", async () => { - const firstVersion = await store.upsertFromSignIn("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "old-access-token", - }); - - const secondVersion = await store.upsertFromSignIn("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "new-access-token", - }); - - await expect(store.invalidateObservedVersion("identity-1", firstVersion)).resolves.toBe(false); - await expect(store.get("identity-1")).resolves.toMatchObject({ - accessToken: "new-access-token", - rowVersion: secondVersion, - }); - }); - - it("serializes concurrent sign-ins through row-version retries", async () => { - const versions = await Promise.all([ - store.upsertFromSignIn("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "first-concurrent-token", - }), - store.upsertFromSignIn("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "second-concurrent-token", - }), - ]); - - expect(versions.sort()).toEqual([1, 2]); - await expect(store.get("identity-1")).resolves.toMatchObject({ - rowVersion: 2, - }); - }); - - it("does not let a stale refresh overwrite credentials from a newer sign-in", async () => { - const observedVersion = await store.upsertFromSignIn("identity-1", { - kind: "refreshable", - accessToken: "expired-access-token", - accessExpiresAt: NOW_MS - 1, - refreshToken: "refresh-token", - refreshExpiresAt: null, - }); - const signInVersion = await store.upsertFromSignIn("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "new-sign-in-token", - }); - - await expect( - store.replaceObservedVersion("identity-1", observedVersion, { - kind: "refreshable", - accessToken: "stale-refresh-access-token", - accessExpiresAt: NOW_MS + 60_000, - refreshToken: "stale-refresh-token", - refreshExpiresAt: null, - }) - ).rejects.toBeInstanceOf(ProviderCredentialVersionConflictError); - await expect(store.get("identity-1")).resolves.toMatchObject({ - accessToken: "new-sign-in-token", - rowVersion: signInVersion, - }); - }); - - it("replaces the exact credential version observed by a provider refresh", async () => { - const observedVersion = await store.upsertFromSignIn("identity-1", { - kind: "refreshable", - accessToken: "expired-access-token", - accessExpiresAt: NOW_MS - 1, - refreshToken: "refresh-token", - refreshExpiresAt: null, - }); - - await expect( - store.replaceObservedVersion("identity-1", observedVersion, { - kind: "refreshable", - accessToken: "refreshed-access-token", - accessExpiresAt: NOW_MS + 60_000, - refreshToken: "rotated-refresh-token", - refreshExpiresAt: NOW_MS + 120_000, - }) - ).resolves.toBe(2); - await expect(store.get("identity-1")).resolves.toMatchObject({ - accessToken: "refreshed-access-token", - refreshToken: "rotated-refresh-token", - refreshExpiresAt: NOW_MS + 120_000, - rowVersion: 2, - }); - }); - - it("supports both current access-only credential shapes", async () => { - await store.upsertFromSignIn("identity-1", { - kind: "access_only_expiring", - accessToken: "expiring-access-token", - accessExpiresAt: NOW_MS + 60_000, - }); - await expect(store.get("identity-1")).resolves.toMatchObject({ - kind: "access_only_expiring", - accessToken: "expiring-access-token", - accessExpiresAt: NOW_MS + 60_000, - rowVersion: 1, - }); - - await store.upsertFromSignIn("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "nonexpiring-access-token", - }); - await expect(store.get("identity-1")).resolves.toEqual({ - providerIdentityId: "identity-1", - kind: "access_only_nonexpiring", - accessToken: "nonexpiring-access-token", - encryptionKeyVersion: 1, - rowVersion: 2, - updatedAt: NOW_MS, - }); - }); - - it("fails closed when ciphertext is moved to a different row version", async () => { - await store.upsertFromSignIn("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "access-token", - }); - await env.DB.prepare( - `UPDATE provider_credentials - SET row_version = 2 - WHERE provider_identity_id = 'identity-1'` - ).run(); - - await expect(store.get("identity-1")).rejects.toBeInstanceOf( - StoredProviderCredentialCorruptError - ); - }); - - it("composes initial credential creation into an identity-owned atomic batch", async () => { - const credentialInsert = await store.prepareInitialInsert("identity-2", { - kind: "access_only_nonexpiring", - accessToken: "new-identity-access-token", - }); - - await env.DB.batch([ - env.DB.prepare( - `INSERT INTO users - (id, display_name, email, avatar_url, created_at, updated_at) - VALUES ('user-2', NULL, NULL, NULL, ?, ?)` - ).bind(NOW_MS, NOW_MS), - env.DB.prepare( - `INSERT INTO user_identities - (id, user_id, provider, provider_issuer, provider_user_id, created_at) - VALUES ( - 'identity-2', 'user-2', 'github', 'https://github.com', - 'github-subject-2', ? - )` - ).bind(NOW_MS), - credentialInsert, - ]); - - await expect(store.get("identity-2")).resolves.toMatchObject({ - providerIdentityId: "identity-2", - accessToken: "new-identity-access-token", - rowVersion: 1, - }); - }); - - it("fails closed when authenticated ciphertext decodes to an empty token", async () => { - const emptyPlaintextCipher: ProviderCredentialCipherPort = { - encrypt: async () => "authenticated-ciphertext", - decrypt: async () => "", - }; - const corruptStore = new ProviderCredentialStore(env.DB, emptyPlaintextCipher, { - now: () => NOW_MS, - }); - await corruptStore.upsertFromSignIn("identity-1", { - kind: "access_only_nonexpiring", - accessToken: "nonempty-input-token", - }); - - await expect(corruptStore.get("identity-1")).rejects.toBeInstanceOf( - StoredProviderCredentialCorruptError - ); - }); -}); diff --git a/packages/control-plane/test/integration/service-auth.test.ts b/packages/control-plane/test/integration/service-auth.test.ts index ad70cb46f..3e6f297ba 100644 --- a/packages/control-plane/test/integration/service-auth.test.ts +++ b/packages/control-plane/test/integration/service-auth.test.ts @@ -47,8 +47,10 @@ async function signedFetch(p: { describe("sig1 service-credential authentication", () => { beforeEach(cleanD1Tables); - it("accepts a signed GET from every registered service", async () => { - for (const service of Object.keys(SERVICE_SECRET) as ServiceName[]) { + it("accepts a signed GET from every non-web service", async () => { + for (const service of Object.keys(SERVICE_SECRET).filter( + (candidate): candidate is Exclude => candidate !== "web" + )) { const response = await signedFetch({ service, method: "GET", @@ -60,12 +62,21 @@ describe("sig1 service-credential authentication", () => { } }); + it("requires a browser session in addition to the web service channel", async () => { + const response = await signedFetch({ + service: "web", + method: "GET", + url: "https://test.local/sessions", + }); + expect(response.status).toBe(401); + }); + it("accepts a signed request with a query string regardless of param order", async () => { const createdBy = "a".repeat(32); const signedUrl = `https://test.local/sessions?limit=5&createdBy=${createdBy}`; const headers = await buildServiceAuthHeaders({ - service: "web", - secret: SERVICE_SECRET.web, + service: "modal", + secret: SERVICE_SECRET.modal, method: "GET", url: signedUrl, }); @@ -80,7 +91,7 @@ describe("sig1 service-credential authentication", () => { it("delivers the signed body intact to the handler (D1 write lands)", async () => { const response = await signedFetch({ - service: "web", + service: "modal", method: "PUT", url: "https://test.local/secrets", body: JSON.stringify({ secrets: { SIGNED_BODY_TEST: "intact" } }), @@ -94,32 +105,29 @@ describe("sig1 service-credential authentication", () => { expect(secrets.SIGNED_BODY_TEST).toBe("intact"); }); - it("does not let the web service credential mutate provider identities", async () => { - const response = await signedFetch({ - service: "web", - method: "PUT", - url: "https://test.local/provider-identities/github/424242", - body: JSON.stringify({ providerEmail: "victim@example.com" }), - }); - - expect(response.status).toBe(403); - }); - it("rejects a body tampered after signing", async () => { - const url = "https://test.local/provider-identities/github/424242"; + const url = "https://test.local/secrets"; + const intactBody = JSON.stringify({ secrets: { SIGNED_BODY_TEST: "intact" } }); const headers = await buildServiceAuthHeaders({ - service: "web", - secret: SERVICE_SECRET.web, + service: "modal", + secret: SERVICE_SECRET.modal, method: "PUT", url, - body: JSON.stringify({ providerLogin: "octocat" }), + body: intactBody, }); - const response = await SELF.fetch(url, { + const intact = await SELF.fetch(url, { method: "PUT", headers: { "Content-Type": "application/json", ...headers }, - body: JSON.stringify({ providerLogin: "evilcat" }), + body: intactBody, }); - expect(response.status).toBe(401); + expect(intact.status).toBe(200); + + const tampered = await SELF.fetch(url, { + method: "PUT", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({ secrets: { SIGNED_BODY_TEST: "tampered" } }), + }); + expect(tampered.status).toBe(401); }); it("rejects a signature replayed against a different method or path", async () => { @@ -201,9 +209,10 @@ describe("sig1 service-credential authentication", () => { const identity = await new UserStore(env.DB).getIdentity("slack", "U0001"); expect(identity).not.toBeNull(); const listed = await signedFetch({ - service: "web", + service: "slack-bot", method: "GET", url: "https://test.local/sessions", + actor: "slack:U0001", }); const body = await listed.json<{ sessions: Array<{ title: string; userId: string; spawnSource: string }>; @@ -228,7 +237,7 @@ describe("sig1 service-credential authentication", () => { model: "anthropic/claude-haiku-4-5", }), }); - expect(response.status, service).toBe(403); + expect(response.status, service).toBe(service === "web" ? 401 : 403); } const sessionCount = await env.DB.prepare("SELECT COUNT(*) AS n FROM sessions").first<{ diff --git a/packages/control-plane/tsconfig.test.json b/packages/control-plane/tsconfig.test.json index cdad2f0c0..51cd3baef 100644 --- a/packages/control-plane/tsconfig.test.json +++ b/packages/control-plane/tsconfig.test.json @@ -1,8 +1,9 @@ { // Test-only typecheck pass. Co-located *.test.ts files use Node APIs // (node:crypto, node:fs, import.meta.url) that the production tsconfig.json - // deliberately excludes — the worker has no `nodejs_compat`, so Node types - // must stay off the production type surface (see tsconfig.json). + // deliberately excludes. Production enables `nodejs_compat` for audited + // dependencies, while Node globals stay off the application type surface so + // new runtime dependencies remain explicit (see tsconfig.json). "extends": "./tsconfig.json", "compilerOptions": { "types": ["@cloudflare/workers-types", "node"] diff --git a/packages/control-plane/vitest.integration.config.ts b/packages/control-plane/vitest.integration.config.ts index 3be880dd4..a3bb08c87 100644 --- a/packages/control-plane/vitest.integration.config.ts +++ b/packages/control-plane/vitest.integration.config.ts @@ -45,6 +45,10 @@ export default defineConfig({ configPath: "./wrangler.jsonc", }, miniflare: { + // Match the Terraform-managed production runtime. The Vitest pool + // otherwise defaults its runner to today's compatibility date. + compatibilityDate: "2024-09-23", + compatibilityFlags: ["nodejs_compat"], bindings: { IMAGE_CALLBACK_TOKEN_PEPPER: "test-callback-pepper", SERVICE_AUTH_SECRET_WEB: "test-service-secret-web", @@ -52,6 +56,12 @@ export default defineConfig({ SERVICE_AUTH_SECRET_GITHUB_BOT: "test-service-secret-github-bot", SERVICE_AUTH_SECRET_LINEAR_BOT: "test-service-secret-linear-bot", SERVICE_AUTH_SECRET_MODAL: "test-service-secret-modal", + BROWSER_AUTH_SECRET: "test-browser-auth-secret-with-at-least-32-characters", + GITHUB_CLIENT_ID: "github-app-client-id", + GITHUB_CLIENT_SECRET: "github-app-client-secret", + GOOGLE_CLIENT_ID: "google-client-id", + GOOGLE_CLIENT_SECRET: "google-client-secret", + UNSAFE_ALLOW_ALL_USERS: "true", // Must be valid base64 for 32 bytes — the exchange route's SCM // capture encrypts with it inline (fail-closed) rather than // inside a swallowed waitUntil. @@ -72,5 +82,26 @@ export default defineConfig({ test: { include: ["test/integration/**/*.test.ts"], setupFiles: ["test/integration/apply-migrations.ts"], + onUnhandledError(error) { + // Better Auth implements OAuth callback redirects as thrown APIError + // values. Its handler catches and converts them to the expected 3xx + // response, but the Workers pool reports the intermediate rejection as + // unhandled. Filter only that library-owned redirect control flow; every + // other unhandled error remains fatal. + const betterAuthStack = + "errorStack" in error && typeof error.errorStack === "string" + ? error.errorStack + : error.stack; + if ( + error.name === "APIError" && + "statusCode" in error && + typeof error.statusCode === "number" && + error.statusCode >= 300 && + error.statusCode < 400 && + betterAuthStack?.includes("/better-auth/dist/api/routes/") + ) { + return false; + } + }, }, }); diff --git a/packages/control-plane/wrangler.jsonc b/packages/control-plane/wrangler.jsonc index 07d90ce4c..a42e934a7 100644 --- a/packages/control-plane/wrangler.jsonc +++ b/packages/control-plane/wrangler.jsonc @@ -2,7 +2,8 @@ { "name": "open-inspect-control-plane-test", "main": "src/index.ts", - "compatibility_date": "2024-12-30", + "compatibility_date": "2024-09-23", + "compatibility_flags": ["nodejs_compat"], "durable_objects": { "bindings": [ { "name": "SESSION", "class_name": "SessionDO" }, diff --git a/packages/linear-bot/src/classifier/index.test.ts b/packages/linear-bot/src/classifier/index.test.ts new file mode 100644 index 000000000..24d1b337c --- /dev/null +++ b/packages/linear-bot/src/classifier/index.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { anthropicMessagesResponseSchema, classifyToolInputSchema } from "./index"; + +describe("anthropicMessagesResponseSchema", () => { + it("parses a response with the consumed tool block fields", () => { + const parsed = anthropicMessagesResponseSchema.safeParse({ + id: "msg_1", + content: [ + { + type: "tool_use", + id: "toolu_1", + name: "classify_repository", + input: { + repoId: "org/repo", + confidence: "high", + reasoning: "The issue names the repo.", + alternatives: [], + }, + }, + ], + }); + + expect(parsed.success).toBe(true); + }); + + it("rejects a response without content", () => { + const parsed = anthropicMessagesResponseSchema.safeParse({ id: "msg_1" }); + + expect(parsed.success).toBe(false); + }); +}); + +describe("classifyToolInputSchema", () => { + it("parses a valid classification tool input", () => { + const parsed = classifyToolInputSchema.safeParse({ + repoId: "org/repo", + confidence: "medium", + reasoning: "The labels match this repository.", + alternatives: ["org/other"], + }); + + expect(parsed.success).toBe(true); + }); + + it("parses a null repoId for low-confidence classifications", () => { + const parsed = classifyToolInputSchema.safeParse({ + repoId: null, + confidence: "low", + reasoning: "No repository was a clear match.", + alternatives: ["org/api", "org/web"], + }); + + expect(parsed.success).toBe(true); + }); + + it("rejects malformed or partial tool input", () => { + const parsed = classifyToolInputSchema.safeParse({ + repoId: "org/repo", + confidence: "certain", + reasoning: "Invalid confidence value.", + }); + + expect(parsed.success).toBe(false); + }); +}); diff --git a/packages/linear-bot/src/classifier/index.ts b/packages/linear-bot/src/classifier/index.ts index 3c1c4feb3..e591acf94 100644 --- a/packages/linear-bot/src/classifier/index.ts +++ b/packages/linear-bot/src/classifier/index.ts @@ -4,7 +4,7 @@ */ import type { Env, RepoConfig, ClassificationResult } from "../types"; -import type { ConfidenceLevel } from "@open-inspect/shared"; +import { z } from "zod"; import { getAvailableRepos, buildRepoDescriptions } from "./repos"; import { createLogger } from "../logger"; @@ -12,24 +12,24 @@ const log = createLogger("classifier"); const CLASSIFY_REPO_TOOL_NAME = "classify_repository"; -interface ClassifyToolInput { - repoId: string | null; - confidence: ConfidenceLevel; - reasoning: string; - alternatives: string[]; -} - -interface AnthropicContentBlock { - type: string; - id?: string; - name?: string; - input?: unknown; - text?: string; -} - -interface AnthropicResponse { - content: AnthropicContentBlock[]; -} +export const classifyToolInputSchema = z.object({ + repoId: z.string().nullable(), + confidence: z.enum(["high", "medium", "low"]), + reasoning: z.string(), + alternatives: z.array(z.string()), +}); + +export type ClassifyToolInput = z.infer; + +export const anthropicMessagesResponseSchema = z.object({ + content: z.array( + z.object({ + type: z.string(), + name: z.string().optional(), + input: z.unknown().optional(), + }) + ), +}); /** * Build classification prompt from Linear issue context. @@ -138,22 +138,19 @@ async function callAnthropic(apiKey: string, prompt: string): Promise b.type === "tool_use" && b.name === CLASSIFY_REPO_TOOL_NAME ); if (!toolBlock) throw new Error("No tool_use block in Anthropic response"); - const input = toolBlock.input as Record; - return { - repoId: input.repoId === null ? null : typeof input.repoId === "string" ? input.repoId : null, - confidence: (input.confidence as ConfidenceLevel) || "low", - reasoning: String(input.reasoning || ""), - alternatives: Array.isArray(input.alternatives) - ? input.alternatives.filter((a): a is string => typeof a === "string") - : [], - }; + const input = classifyToolInputSchema.safeParse(toolBlock.input); + if (!input.success) throw new Error("Malformed Anthropic tool input"); + + return input.data; } /** diff --git a/packages/linear-bot/src/utils/integration-config.test.ts b/packages/linear-bot/src/utils/integration-config.test.ts index b992abe2a..f0deb3f79 100644 --- a/packages/linear-bot/src/utils/integration-config.test.ts +++ b/packages/linear-bot/src/utils/integration-config.test.ts @@ -3,6 +3,23 @@ import type { Env } from "../types"; import { getLinearConfig } from "./integration-config"; describe("getLinearConfig", () => { + function envForFetchResponse(response: Response): Env { + const fetch = vi.fn().mockResolvedValue(response); + return { + SERVICE_AUTH_SECRET: "test-secret", + CONTROL_PLANE: { fetch }, + } as unknown as Env; + } + + function envForResponse(body: unknown): Env { + return envForFetchResponse( + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + } + it("encodes nested repository owners as one route segment", async () => { const fetch = vi.fn().mockResolvedValue( new Response(JSON.stringify({ config: null }), { @@ -22,4 +39,75 @@ describe("getLinearConfig", () => { expect.any(Object) ); }); + + it("returns a parsed resolved config", async () => { + await expect( + getLinearConfig( + envForResponse({ + config: { + model: "openai/gpt-5.4", + reasoningEffort: null, + allowUserPreferenceOverride: false, + allowLabelModelOverride: true, + emitToolProgressActivities: false, + issueSessionInstructions: "Use small commits.", + enabledRepos: ["acme/backend"], + }, + }), + "acme/backend" + ) + ).resolves.toEqual({ + model: "openai/gpt-5.4", + reasoningEffort: null, + allowUserPreferenceOverride: false, + allowLabelModelOverride: true, + emitToolProgressActivities: false, + issueSessionInstructions: "Use small commits.", + enabledRepos: ["acme/backend"], + }); + }); + + it("falls back when the response shape is malformed", async () => { + await expect( + getLinearConfig( + envForResponse({ + config: { + model: "openai/gpt-5.4", + allowUserPreferenceOverride: "yes", + }, + }), + "acme/backend" + ) + ).resolves.toEqual({ + model: null, + reasoningEffort: null, + allowUserPreferenceOverride: true, + allowLabelModelOverride: true, + emitToolProgressActivities: true, + issueSessionInstructions: null, + enabledRepos: null, + }); + }); + + it("falls back when the response is invalid JSON", async () => { + await expect( + getLinearConfig( + envForFetchResponse( + new Response("{not-json", { + status: 200, + headers: { "content-type": "application/json" }, + }) + ), + "acme/backend" + ) + ).resolves.toEqual({ + model: null, + reasoningEffort: null, + allowUserPreferenceOverride: true, + allowLabelModelOverride: true, + emitToolProgressActivities: true, + issueSessionInstructions: null, + enabledRepos: null, + }); + }); }); diff --git a/packages/linear-bot/src/utils/integration-config.ts b/packages/linear-bot/src/utils/integration-config.ts index 691550a71..b85b243fc 100644 --- a/packages/linear-bot/src/utils/integration-config.ts +++ b/packages/linear-bot/src/utils/integration-config.ts @@ -1,16 +1,23 @@ import { encodeRepositoryPathSegments, parseRepositoryFullName } from "@open-inspect/shared"; +import { z } from "zod"; import type { Env } from "../types"; import { signedControlPlaneFetch } from "../internal-auth"; -export interface ResolvedLinearConfig { - model: string | null; - reasoningEffort: string | null; - allowUserPreferenceOverride: boolean; - allowLabelModelOverride: boolean; - emitToolProgressActivities: boolean; - issueSessionInstructions: string | null; - enabledRepos: string[] | null; -} +const resolvedLinearConfigSchema = z.object({ + model: z.string().nullable(), + reasoningEffort: z.string().nullable(), + allowUserPreferenceOverride: z.boolean(), + allowLabelModelOverride: z.boolean(), + emitToolProgressActivities: z.boolean(), + issueSessionInstructions: z.string().nullable(), + enabledRepos: z.array(z.string()).nullable(), +}); + +const resolvedLinearConfigResponseSchema = z.object({ + config: resolvedLinearConfigSchema.nullable(), +}); + +export type ResolvedLinearConfig = z.infer; const DEFAULT_CONFIG: ResolvedLinearConfig = { model: null, @@ -45,10 +52,12 @@ export async function getLinearConfig(env: Env, repo: string): Promise null) + ); + if (!parsed.success || !parsed.data.config) { return DEFAULT_CONFIG; } - return data.config; + return parsed.data.config; } diff --git a/packages/linear-bot/src/webhook-handler.test.ts b/packages/linear-bot/src/webhook-handler.test.ts index 4ad50da6b..2e333211c 100644 --- a/packages/linear-bot/src/webhook-handler.test.ts +++ b/packages/linear-bot/src/webhook-handler.test.ts @@ -259,7 +259,10 @@ describe("handleAgentSessionEvent environment targets", () => { return { ok: true, json: () => Promise.resolve({ config: null }) }; } if (url === "https://internal/sessions") { - return { ok: true, json: () => Promise.resolve({ sessionId: "session-xyz" }) }; + return { + ok: true, + json: () => Promise.resolve({ sessionId: "session-xyz", status: "created" }), + }; } if (url === "https://internal/sessions/session-xyz/prompt") { return { ok: true, json: () => Promise.resolve({ ok: true }) }; @@ -288,6 +291,74 @@ describe("handleAgentSessionEvent environment targets", () => { return JSON.parse(String((call[1] as RequestInit).body)) as Record; } + async function runWithCreateSessionResponse(response: Response, traceId: string) { + const { kv, store } = createFakeKV({ + "oauth:client-credentials:org-1": validToken(), + "config:project-repos": JSON.stringify({ "project-1": { environmentId: "env_abc" } }), + }); + const env = makeLinearBotEnv(kv); + const fetchMock = stubControlPlane(env); + fetchMock.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "https://internal/environments") { + return Response.json({ environments: [environment], total: 1 }); + } + if (url.startsWith("https://internal/integration-settings/linear/resolved/")) { + return Response.json({ config: null }); + } + if (url === "https://internal/sessions") return response; + if (url === "https://internal/repos") return Response.json({ repos: [] }); + throw new Error(`Unexpected control-plane fetch to ${url}`); + }); + + await handleAgentSessionEvent(makeWebhook(), env, traceId); + + return { + issueSessionStored: store.has("issue:issue-1"), + requestedUrls: fetchMock.mock.calls.map(([input]) => String(input)), + }; + } + + async function followUpPromptForEventsResponse( + eventsResponse: Response, + traceId: string + ): Promise> { + const { kv } = createFakeKV({ + "oauth:client-credentials:org-1": validToken(), + "issue:issue-1": JSON.stringify({ + sessionId: "session-xyz", + issueId: "issue-1", + issueIdentifier: "ENG-42", + repoOwner: "acme", + repoName: "backend", + model: "anthropic/claude-haiku-4-5", + createdAt: Date.now(), + }), + }); + const env = makeLinearBotEnv(kv); + const controlPlaneFetch = (env.CONTROL_PLANE as unknown as { fetch: ReturnType }) + .fetch; + controlPlaneFetch.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/events?type=token&limit=20")) return eventsResponse; + if (url.endsWith("/prompt")) return Response.json({ ok: true }); + throw new Error(`Unexpected control-plane fetch to ${url}`); + }); + const webhook = makeWebhook(); + webhook.action = "prompted"; + webhook.agentActivity = { + userId: "follow-up-human-user", + content: { type: "prompt", body: "Please continue." }, + }; + + await handleAgentSessionEvent(webhook, env, traceId); + + const promptCall = controlPlaneFetch.mock.calls.find(([input]) => + String(input).endsWith("/prompt") + ); + return JSON.parse(String(promptCall?.[1]?.body)) as Record; + } + it("transitions an existing installation and creates an environment session", async () => { const { kv, store } = createFakeKV({ "oauth:token:org-1": JSON.stringify({ @@ -342,6 +413,29 @@ describe("handleAgentSessionEvent environment targets", () => { expect(tokenBody.has("refresh_token")).toBe(false); }); + it("does not store or prompt when the create-session response is malformed", async () => { + const result = await runWithCreateSessionResponse( + Response.json({ id: "session-xyz" }), + "trace-malformed-session" + ); + + expect(result.issueSessionStored).toBe(false); + expect(result.requestedUrls).not.toContain("https://internal/sessions/session-xyz/prompt"); + }); + + it("does not store or prompt when the create-session response is invalid JSON", async () => { + const result = await runWithCreateSessionResponse( + new Response("{not-json", { + status: 201, + headers: { "content-type": "application/json" }, + }), + "trace-invalid-json-session" + ); + + expect(result.issueSessionStored).toBe(false); + expect(result.requestedUrls).not.toContain("https://internal/sessions/session-xyz/prompt"); + }); + it("creates an environment session from a label-matched team mapping", async () => { const { kv } = createFakeKV({ "oauth:client-credentials:org-1": validToken(), @@ -470,7 +564,7 @@ describe("handleAgentSessionEvent environment targets", () => { controlPlaneFetch.mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); if (url.includes("/integration-settings/")) return Response.json({ config: null }); - if (url.endsWith("/events?limit=20")) return Response.json({ events: [] }); + if (url.endsWith("/events?type=token&limit=20")) return Response.json({ events: [] }); if (url.endsWith("/prompt")) return Response.json({ ok: true }); throw new Error(`Unexpected control-plane fetch to ${url}`); }); @@ -505,6 +599,43 @@ describe("handleAgentSessionEvent environment targets", () => { expect(body.callbackContext).not.toHaveProperty("transitionIssueOnStart"); }); + it("adds prior token context from a parsed events response", async () => { + const body = await followUpPromptForEventsResponse( + Response.json({ + events: [ + { type: "token", data: { content: "Most recent response." } }, + { type: "token", data: { content: "Older response." } }, + ], + }), + "trace-follow-up-context" + ); + + expect(body.content).toContain("Previous agent response"); + expect(body.content).toContain("Most recent response."); + expect(body.content).not.toContain("Older response."); + }); + + it("skips prior token context when the events response is malformed", async () => { + const body = await followUpPromptForEventsResponse( + Response.json({ events: [{ type: "token", data: { content: 123 } }] }), + "trace-follow-up-bad-events" + ); + + expect(body.content).not.toContain("Previous agent response"); + }); + + it("skips prior token context when the events response is invalid JSON", async () => { + const body = await followUpPromptForEventsResponse( + new Response("{not-json", { + status: 200, + headers: { "content-type": "application/json" }, + }), + "trace-follow-up-invalid-json-events" + ); + + expect(body.content).not.toContain("Previous agent response"); + }); + it("stops an existing session when Linear sends a stop signal", async () => { const { kv, store } = createFakeKV({ "issue:issue-1": JSON.stringify({ @@ -597,7 +728,7 @@ describe("handleAgentSessionEvent environment targets", () => { }, }); } - if (url.endsWith("/events?limit=20")) return Response.json({ events: [] }); + if (url.endsWith("/events?type=token&limit=20")) return Response.json({ events: [] }); if (url.endsWith("/prompt")) return Response.json({ ok: true }); throw new Error(`Unexpected control-plane fetch to ${url}`); }); @@ -638,7 +769,7 @@ describe("handleAgentSessionEvent environment targets", () => { controlPlaneFetch.mockImplementation(async (input: RequestInfo | URL) => { const url = String(input); if (url.includes("/integration-settings/")) return Response.json({ config: null }); - if (url.endsWith("/events?limit=20")) return Response.json({ events: [] }); + if (url.endsWith("/events?type=token&limit=20")) return Response.json({ events: [] }); if (url.endsWith("/prompt")) return Response.json({ ok: true }); throw new Error(`Unexpected control-plane fetch to ${url}`); }); diff --git a/packages/linear-bot/src/webhook-handler.ts b/packages/linear-bot/src/webhook-handler.ts index e216da324..6b5237c6e 100644 --- a/packages/linear-bot/src/webhook-handler.ts +++ b/packages/linear-bot/src/webhook-handler.ts @@ -3,6 +3,8 @@ * Extracted from index.ts for modularity. */ +import { createSessionResponseSchema } from "@open-inspect/shared"; +import { z } from "zod"; import type { Env, LinearCallbackContext, @@ -36,6 +38,17 @@ import { getUserPreferences, lookupIssueSession, storeIssueSession } from "./kv- const log = createLogger("handler"); +const sessionEventsSummaryResponseSchema = z.object({ + events: z.array( + z.object({ + type: z.literal("token"), + data: z.object({ + content: z.string(), + }), + }) + ), +}); + export function escapeHtml(s: string): string { return s .replace(/&/g, "&") @@ -162,8 +175,11 @@ async function createSession( return { ok: false, status: response.status, body }; } - const result = (await response.json()) as { sessionId: string }; - return { ok: true, sessionId: result.sessionId }; + const result = createSessionResponseSchema.safeParse(await response.json().catch(() => null)); + if (!result.success) { + return { ok: false, status: response.status, body: "invalid response" }; + } + return { ok: true, sessionId: result.data.sessionId }; } // ─── Sub-handlers ──────────────────────────────────────────────────────────── @@ -366,22 +382,19 @@ async function handleFollowUp( let sessionContextSummary = ""; try { - const eventsUrl = `https://internal/sessions/${existingSession.sessionId}/events?limit=20`; + const eventsUrl = `https://internal/sessions/${existingSession.sessionId}/events?type=token&limit=20`; const eventsRes = await signedControlPlaneFetch(env, { method: "GET", url: eventsUrl, traceId, }); if (eventsRes.ok) { - const eventsData = (await eventsRes.json()) as { - events: Array<{ type: string; data: Record }>; - }; - const recentTokens = eventsData.events.filter((e) => e.type === "token").slice(-1); - if (recentTokens.length > 0) { - const lastContent = String(recentTokens[0].data.content ?? ""); - if (lastContent) { - sessionContextSummary = lastContent.slice(0, 500); - } + const eventsData = sessionEventsSummaryResponseSchema.safeParse(await eventsRes.json()); + const latestContent = eventsData.success + ? eventsData.data.events[0]?.data.content + : undefined; + if (latestContent) { + sessionContextSummary = latestContent.slice(0, 500); } } } catch { diff --git a/packages/shared/src/browser-auth-routes.test.ts b/packages/shared/src/browser-auth-routes.test.ts new file mode 100644 index 000000000..d0122e5df --- /dev/null +++ b/packages/shared/src/browser-auth-routes.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { BROWSER_AUTH_PROXY_ROUTES, isBrowserAuthProxyRoute } from "./browser-auth-routes"; + +describe("browser auth proxy route contract", () => { + it("keeps the cross-service allowlist exact and method-bound", () => { + expect(BROWSER_AUTH_PROXY_ROUTES).toEqual([ + ["POST", "/api/auth/sign-in/social"], + ["GET", "/api/auth/callback/github"], + ["GET", "/api/auth/callback/google"], + ["GET", "/api/auth/get-session"], + ["POST", "/api/auth/sign-out"], + ["GET", "/api/auth/error"], + ]); + + expect(isBrowserAuthProxyRoute("get", "/api/auth/get-session")).toBe(true); + expect(isBrowserAuthProxyRoute("POST", "/api/auth/get-session")).toBe(false); + expect(isBrowserAuthProxyRoute("GET", "/api/auth/get-session/extra")).toBe(false); + }); +}); diff --git a/packages/shared/src/browser-auth-routes.ts b/packages/shared/src/browser-auth-routes.ts new file mode 100644 index 000000000..db32f4a2d --- /dev/null +++ b/packages/shared/src/browser-auth-routes.ts @@ -0,0 +1,22 @@ +/** + * Exact browser-reachable Better Auth surface shared by the web BFF and + * control plane. Keeping one declaration prevents either side from silently + * widening or narrowing the signed proxy contract. + */ +export const BROWSER_AUTH_CLIENT_IP_HEADER = "X-OpenInspect-Client-IP"; + +export const BROWSER_AUTH_PROXY_ROUTES = [ + ["POST", "/api/auth/sign-in/social"], + ["GET", "/api/auth/callback/github"], + ["GET", "/api/auth/callback/google"], + ["GET", "/api/auth/get-session"], + ["POST", "/api/auth/sign-out"], + ["GET", "/api/auth/error"], +] as const; + +export function isBrowserAuthProxyRoute(method: string, path: string): boolean { + const normalizedMethod = method.toUpperCase(); + return BROWSER_AUTH_PROXY_ROUTES.some( + ([allowedMethod, allowedPath]) => allowedMethod === normalizedMethod && allowedPath === path + ); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 485998259..ab96cb6bc 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -16,4 +16,5 @@ export * from "./logger"; export * from "./cache-store"; export * from "./app-name"; export * from "./user-id"; +export * from "./browser-auth-routes"; export * from "./slack"; diff --git a/packages/web/.env.example b/packages/web/.env.example index 8a4d55c49..8e1200b0d 100644 --- a/packages/web/.env.example +++ b/packages/web/.env.example @@ -1,23 +1,8 @@ -# GitHub OAuth (create at https://github.com/settings/developers) -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= - -# Google OAuth (optional — enables "Sign in with Google"). -# Create an OAuth 2.0 Web Client at https://console.cloud.google.com/apis/credentials -# Authorized redirect URI: http://localhost:3000/api/auth/callback/google -# Set both to enable Google login; leave both empty for GitHub-only. -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= -# Build-time flag that reveals the "Sign in with Google" button. Set to "true" -# when Google is configured. NEXT_PUBLIC_* vars are inlined at build time — -# restart `npm run dev` after changing. (For local dev only: deployed -# environments derive this automatically from local.google_enabled in Terraform.) +# Provider credentials and admission policy live in the control plane. +# Match this build-time flag to the providers enabled there, then restart the +# development server because NEXT_PUBLIC_* values are inlined at build time. NEXT_PUBLIC_GOOGLE_ENABLED= -# NextAuth (generate with: openssl rand -base64 32) -NEXTAUTH_URL=http://localhost:3000 -NEXTAUTH_SECRET= - # Control Plane CONTROL_PLANE_URL=https://open-inspect-control-plane.YOUR-ACCOUNT.workers.dev NEXT_PUBLIC_WS_URL=wss://open-inspect-control-plane.YOUR-ACCOUNT.workers.dev @@ -27,19 +12,3 @@ NEXT_PUBLIC_WS_URL=wss://open-inspect-control-plane.YOUR-ACCOUNT.workers.dev # generates it — read it from terraform state or the deployed web app's env; # do not generate your own against an existing backend). SERVICE_AUTH_SECRET= - -# Access Control (comma-separated; configure at least one allowlist unless UNSAFE_ALLOW_ALL_USERS=true) -# OR-based: a user is admitted if they match ANY allowlist below (username, email -# domain, exact email, or active GitHub org membership). -ALLOWED_EMAIL_DOMAINS= -ALLOWED_USERS= -# Exact email addresses (matched against any provider's verified email) — for -# individual users on shared domains like gmail.com where a domain allowlist -# would be too broad. -ALLOWED_EMAILS= -# GitHub organizations whose active members can sign in. Requests read:org only -# when set, then checks active org membership with the signing-in user's OAuth -# token. Existing sessions last until session expiry. Requires GitHub App -# Organization permissions: Members read-only. -ALLOWED_GITHUB_ORGS= -UNSAFE_ALLOW_ALL_USERS=false diff --git a/packages/web/README.md b/packages/web/README.md index 3224bcea0..53d5f969c 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -4,7 +4,7 @@ Next.js web application for interacting with Open-Inspect coding sessions. ## Features -- GitHub OAuth authentication +- GitHub and optional Google authentication through the control plane - Session dashboard with list view - Real-time streaming via WebSocket - Message timeline with tool calls @@ -25,7 +25,7 @@ Next.js web application for interacting with Open-Inspect coding sessions. │ └──────────────────────────────────────────────────────────┘ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ API Routes │ │ -│ │ /api/auth/[...nextauth] - GitHub OAuth │ │ +│ │ /api/auth/[...auth] - Signed auth proxy │ │ │ │ /api/sessions - Session CRUD │ │ │ │ /api/repos - Repository list │ │ │ │ /api/repos/:owner/:name/secrets - Secrets CRUD │ │ @@ -73,34 +73,18 @@ Required permissions for the GitHub App: Create `.env.local`: ```bash -# GitHub App (for user authentication) -GITHUB_CLIENT_ID=your_github_app_client_id -GITHUB_CLIENT_SECRET=your_github_app_client_secret - -# NextAuth -NEXTAUTH_URL=http://localhost:3000 -NEXTAUTH_SECRET=your_random_secret # Generate: openssl rand -base64 32 - -# Access Control -ALLOWED_USERS=username1,username2 # Comma-separated GitHub usernames -ALLOWED_EMAIL_DOMAINS=example.com,corp.io # Comma-separated email domains -ALLOWED_GITHUB_ORGS=acme,umbrella # Comma-separated GitHub orgs with active members allowed -UNSAFE_ALLOW_ALL_USERS=false # Set true to explicitly allow all users when all lists are empty - # Control Plane CONTROL_PLANE_URL=http://localhost:8787 NEXT_PUBLIC_WS_URL=ws://localhost:8787 +SERVICE_AUTH_SECRET=your_web_service_sig1_secret + +# Match the control plane's enabled providers +NEXT_PUBLIC_GOOGLE_ENABLED=false ``` -> **Access Control**: If `ALLOWED_USERS`, `ALLOWED_EMAIL_DOMAINS`, and `ALLOWED_GITHUB_ORGS` are all -> empty, sign-in is denied unless `UNSAFE_ALLOW_ALL_USERS=true`. For Terraform-managed production -> deploys, Terraform also fails validation unless you set at least one allowlist or explicitly opt -> in with `unsafe_allow_all_users = true`. **Allowlists use OR semantics**: matching any configured -> username, email domain, or active GitHub org membership grants access. `ALLOWED_GITHUB_ORGS` is -> checked at sign-in with the signing-in user's OAuth token; existing sessions last until session -> expiry. The `read:org` OAuth scope is requested only when `ALLOWED_GITHUB_ORGS` is configured. -> GitHub Apps using org access need Organization permissions: Members read-only; existing GitHub -> Apps must republish/request approval after that permission changes. +The web app is a framework-free BFF. It signs requests with `SERVICE_AUTH_SECRET`, forwards only +Better Auth's opaque session cookie, and does not hold OAuth provider credentials or admission +policy. Configure those on the control plane through Terraform. ### Development diff --git a/packages/web/package.json b/packages/web/package.json index 4ddcda179..35f849434 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -35,7 +35,6 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "next": "^16.2.11", - "next-auth": "^4.24.15", "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/packages/web/src/app/access-denied/page.tsx b/packages/web/src/app/access-denied/page.tsx index d1d8463a5..eb570cd49 100644 --- a/packages/web/src/app/access-denied/page.tsx +++ b/packages/web/src/app/access-denied/page.tsx @@ -9,9 +9,8 @@ function AccessDeniedContent() { const searchParams = useSearchParams(); const error = searchParams.get("error"); - // NextAuth passes error=AccessDenied when signIn callback returns false const message = - error === "AccessDenied" + error === "AccessDenied" || error === "access_denied" ? "Your account is not authorized to use this application." : "An error occurred during sign in. Please try again."; diff --git a/packages/web/src/app/api/auth/[...auth]/route.test.ts b/packages/web/src/app/api/auth/[...auth]/route.test.ts new file mode 100644 index 000000000..45a25b845 --- /dev/null +++ b/packages/web/src/app/api/auth/[...auth]/route.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + proxyBrowserAuthRequest: vi.fn(), +})); + +vi.mock("@/lib/browser-auth-proxy", () => ({ + proxyBrowserAuthRequest: mocks.proxyBrowserAuthRequest, +})); + +import { GET, POST } from "./route"; + +describe("/api/auth/*", () => { + it.each([ + ["GET", GET], + ["POST", POST], + ] as const)("passes %s requests to the positive browser-auth proxy", async (_method, handler) => { + const upstream = new Response("proxied", { status: 202 }); + mocks.proxyBrowserAuthRequest.mockResolvedValueOnce(upstream); + const request = new Request("https://app.example/api/auth/get-session", { + method: _method, + }); + + await expect(handler(request)).resolves.toBe(upstream); + expect(mocks.proxyBrowserAuthRequest).toHaveBeenCalledWith(request); + }); +}); diff --git a/packages/web/src/app/api/auth/[...auth]/route.ts b/packages/web/src/app/api/auth/[...auth]/route.ts new file mode 100644 index 000000000..f4dc07ffb --- /dev/null +++ b/packages/web/src/app/api/auth/[...auth]/route.ts @@ -0,0 +1,4 @@ +import { proxyBrowserAuthRequest } from "@/lib/browser-auth-proxy"; + +export const GET = proxyBrowserAuthRequest; +export const POST = proxyBrowserAuthRequest; diff --git a/packages/web/src/app/api/auth/[...nextauth]/route.ts b/packages/web/src/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index 7b38c1bb4..000000000 --- a/packages/web/src/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -import NextAuth from "next-auth"; -import { authOptions } from "@/lib/auth"; - -const handler = NextAuth(authOptions); - -export { handler as GET, handler as POST }; diff --git a/packages/web/src/app/api/auth/oi-refresh/route.test.ts b/packages/web/src/app/api/auth/oi-refresh/route.test.ts deleted file mode 100644 index 88edd87e9..000000000 --- a/packages/web/src/app/api/auth/oi-refresh/route.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { encode, decode } from "next-auth/jwt"; - -vi.mock("next/headers", () => ({ - cookies: vi.fn(), -})); - -vi.mock("@/lib/control-plane-transport", () => ({ - controlPlaneTokenFetch: vi.fn(), -})); - -import { cookies } from "next/headers"; -import { controlPlaneTokenFetch } from "@/lib/control-plane-transport"; -import { OI_ACCESS_TOKEN_RENEW_WINDOW_MS } from "@/lib/oi-session"; -import { POST } from "./route"; - -const tokenFetch = vi.mocked(controlPlaneTokenFetch); - -const SECRET = "test-nextauth-secret-for-oi-refresh"; -const SECURE_COOKIE = "__Secure-next-auth.session-token"; - -const FRESH_PAIR = { - accessToken: "oi_at_rotated", - accessTokenExpiresAtEpochMs: Date.now() + 8 * 60 * 60 * 1000, - refreshToken: "oi_rt_rotated", - refreshTokenExpiresAtEpochMs: Date.now() + 30 * 24 * 60 * 60 * 1000, -}; - -interface SetCall { - name: string; - value: string; - options: { maxAge: number }; -} - -function fakeCookieStore(initial: Record) { - const sets: SetCall[] = []; - const store = { - sets, - getAll: () => Object.entries(initial).map(([name, value]) => ({ name, value })), - set: (name: string, value: string, options: SetCall["options"]) => { - sets.push({ name, value, options }); - }, - }; - vi.mocked(cookies).mockResolvedValue(store as never); - return store; -} - -async function encodeSession(oiFields: Record): Promise { - return encode({ - token: { sub: "user-1", provider: "github", ...oiFields }, - secret: SECRET, - }); -} - -beforeEach(() => { - tokenFetch.mockReset(); - vi.mocked(cookies).mockReset(); - vi.stubEnv("NEXTAUTH_SECRET", SECRET); - vi.stubEnv("NEXTAUTH_URL", "https://open-inspect.example"); -}); - -describe("POST /api/auth/oi-refresh", () => { - it("rotates a near-expiry pair and persists the re-encoded session cookie", async () => { - tokenFetch.mockResolvedValue(new Response(JSON.stringify(FRESH_PAIR), { status: 200 })); - const jwt = await encodeSession({ - oiAccessToken: "oi_at_near_expiry", - oiAccessTokenExpiresAt: Date.now() + OI_ACCESS_TOKEN_RENEW_WINDOW_MS - 60_000, - oiRefreshToken: "oi_rt_current", - }); - const store = fakeCookieStore({ [SECURE_COOKIE]: jwt }); - - const response = await POST(); - const body = (await response.json()) as { renewed: boolean }; - - expect(response.status).toBe(200); - expect(body.renewed).toBe(true); - expect(tokenFetch).toHaveBeenCalledWith("/auth/tokens/refresh", { - method: "POST", - body: JSON.stringify({ refreshToken: "oi_rt_current" }), - }); - - // The rotated pair must round-trip out of the persisted cookie, and the - // untouched claims must survive the re-encode. - const written = store.sets.find((s) => s.name === SECURE_COOKIE && s.options.maxAge > 0); - expect(written).toBeDefined(); - const decoded = await decode({ token: written!.value, secret: SECRET }); - expect(decoded).toMatchObject({ - sub: "user-1", - provider: "github", - oiAccessToken: "oi_at_rotated", - oiRefreshToken: "oi_rt_rotated", - }); - }); - - it("does not write when the pair is still fresh", async () => { - const jwt = await encodeSession({ - oiAccessToken: "oi_at_live", - oiAccessTokenExpiresAt: Date.now() + OI_ACCESS_TOKEN_RENEW_WINDOW_MS + 60_000, - oiRefreshToken: "oi_rt_live", - }); - const store = fakeCookieStore({ [SECURE_COOKIE]: jwt }); - - const response = await POST(); - const body = (await response.json()) as { renewed: boolean }; - - expect(response.status).toBe(200); - expect(body.renewed).toBe(false); - expect(tokenFetch).not.toHaveBeenCalled(); - expect(store.sets).toHaveLength(0); - }); - - it("requires reauthentication when the NextAuth session predates OI token exchange", async () => { - const jwt = await encodeSession({}); - const store = fakeCookieStore({ [SECURE_COOKIE]: jwt }); - - const response = await POST(); - - expect(response.status).toBe(401); - expect(tokenFetch).not.toHaveBeenCalled(); - expect(store.sets).toHaveLength(0); - }); - - it("persists cleared fields and requires reauthentication when the refresh grant is dead", async () => { - tokenFetch.mockResolvedValue( - new Response(JSON.stringify({ error: "refresh_reuse_detected" }), { status: 401 }) - ); - const jwt = await encodeSession({ - oiAccessToken: "oi_at_stolen", - oiAccessTokenExpiresAt: Date.now() + OI_ACCESS_TOKEN_RENEW_WINDOW_MS - 60_000, - oiRefreshToken: "oi_rt_stolen", - }); - const store = fakeCookieStore({ [SECURE_COOKIE]: jwt }); - - const response = await POST(); - - const written = store.sets.find((s) => s.name === SECURE_COOKIE && s.options.maxAge > 0); - const decoded = await decode({ token: written!.value, secret: SECRET }); - expect(decoded?.oiAccessToken).toBeUndefined(); - expect(decoded?.oiRefreshToken).toBeUndefined(); - expect(response.status).toBe(401); - }); - - it("returns a retryable failure without clearing the cookie when refresh is temporarily unavailable", async () => { - tokenFetch.mockRejectedValue(new Error("control plane unavailable")); - const jwt = await encodeSession({ - oiAccessToken: "oi_at_expired", - oiAccessTokenExpiresAt: Date.now() - 60_000, - oiRefreshToken: "oi_rt_retryable", - }); - const store = fakeCookieStore({ [SECURE_COOKIE]: jwt }); - - const response = await POST(); - - expect(response.status).toBe(503); - expect(tokenFetch).toHaveBeenCalledWith("/auth/tokens/refresh", { - method: "POST", - body: JSON.stringify({ refreshToken: "oi_rt_retryable" }), - }); - expect(store.sets).toHaveLength(0); - }); - - it("401s when there is no decodable session", async () => { - fakeCookieStore({}); - const response = await POST(); - expect(response.status).toBe(401); - }); -}); diff --git a/packages/web/src/app/api/auth/oi-refresh/route.ts b/packages/web/src/app/api/auth/oi-refresh/route.ts deleted file mode 100644 index c938355a1..000000000 --- a/packages/web/src/app/api/auth/oi-refresh/route.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Client-invoked renewal of the web session token pair (`oi_at_`/`oi_rt_`). - * - * This is the ONLY place the rotating refresh grant is redeemed. NextAuth v4's - * jwt callback also runs under `getServerSession`, which cannot persist a - * rotated cookie — redeeming there would orphan the cookie's refresh token — - * so renewal lives in this route handler, which re-encodes the session JWT - * and writes it back (chunk-aware) in the same response. - * - * The client calls this route on mount, on window focus, and on an interval - * comfortably inside the renew window (see `WebSessionGate`). - * Concurrent refresh requests from multiple tabs are safe within the control - * plane's refresh-reuse grace window; the remaining stale-writer race is - * documented in `renewWebSessionTokens` and requires the Phase B cookie - * redesign. - */ - -import { NextResponse } from "next/server"; -import { getToken, encode } from "next-auth/jwt"; -import { cookies } from "next/headers"; -import { createLogger } from "@/lib/logger"; -import { renewWebSessionTokens } from "@/lib/oi-session"; -import { SESSION_COOKIE_MAX_AGE_SECONDS, writeSessionCookie } from "@/lib/session-cookie"; - -const log = createLogger("oi-refresh"); - -export async function POST(): Promise { - const secret = process.env.NEXTAUTH_SECRET; - if (!secret) { - log.error("oi_refresh.misconfigured", { reason: "NEXTAUTH_SECRET not configured" }); - return NextResponse.json({ error: "Auth not configured" }, { status: 500 }); - } - - const cookieStore = await cookies(); - const cookiePairs = Object.fromEntries( - cookieStore.getAll().map((cookie) => [cookie.name, cookie.value]) - ); - // getToken reads req.cookies only — pass the parsed pairs so chunked - // session cookies reassemble (same contract as oi-session's reader). - const token = await getToken({ - req: { headers: {}, cookies: cookiePairs } as Parameters[0]["req"], - }); - if (!token) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if (!token.oiAccessToken || !token.oiAccessTokenExpiresAt || !token.oiRefreshToken) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const renewal = await renewWebSessionTokens(token); - if (renewal.changed) { - const encoded = await encode({ token, secret, maxAge: SESSION_COOKIE_MAX_AGE_SECONDS }); - writeSessionCookie(cookieStore, encoded); - } - if (renewal.status === "unauthenticated") { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - if (renewal.status === "temporarily_unavailable") { - return NextResponse.json({ error: "Authentication temporarily unavailable" }, { status: 503 }); - } - - return NextResponse.json({ - renewed: renewal.changed, - accessTokenExpiresAt: token.oiAccessTokenExpiresAt ?? null, - }); -} diff --git a/packages/web/src/app/api/automations/route.test.ts b/packages/web/src/app/api/automations/route.test.ts index aebcac897..31fac7432 100644 --- a/packages/web/src/app/api/automations/route.test.ts +++ b/packages/web/src/app/api/automations/route.test.ts @@ -48,15 +48,13 @@ describe("automations API route (POST)", () => { expect(controlPlaneUserFetch).not.toHaveBeenCalled(); }); - it("sends auth* display and scm* attribution for a GitHub user — never identity or credentials", async () => { + it("sends cosmetic auth display without identity or SCM assertions", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "12345", - login: "ada", + id: "0123456789abcdef0123456789abcdef", name: "Ada Lovelace", email: "ada@example.com", image: "https://avatars.githubusercontent.com/u/12345", - provider: "github", }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( @@ -77,10 +75,6 @@ describe("automations API route (POST)", () => { authEmail: "ada@example.com", authName: "Ada Lovelace", authAvatarUrl: "https://avatars.githubusercontent.com/u/12345", - scmLogin: "ada", - scmName: "Ada Lovelace", - scmEmail: "ada@example.com", - scmAvatarUrl: "https://avatars.githubusercontent.com/u/12345", }); // Forbidden under strict identity enforcement: the control plane derives // created_by from the Bearer principal. @@ -94,14 +88,13 @@ describe("automations API route (POST)", () => { expect(sent.scmTokenExpiresAt).toBeUndefined(); }); - it("sends auth* display but no scm* for a Google user (F1/F2: a Google sub must never become a GitHub identity)", async () => { + it("uses the same display-only shape for another provider", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "google-sub-1", + id: "fedcba9876543210fedcba9876543210", name: "Pat PM", email: "pm@gmail.com", image: "https://lh3.googleusercontent.com/a/pat", - provider: "google", }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( @@ -119,9 +112,7 @@ describe("automations API route (POST)", () => { expect(sent.userId).toBeUndefined(); expect(sent.authProvider).toBeUndefined(); expect(sent.authUserId).toBeUndefined(); - // Regression guard: the bug sent scmUserId = user.id = the Google sub, which - // the control plane then stored under provider='github'. After the fix there - // is no scm* block at all for a Google user. + // Provider identity and SCM provenance come from control-plane auth state. expect(sent.scmUserId).toBeUndefined(); expect(sent.scmToken).toBeUndefined(); expect(sent.scmLogin).toBeUndefined(); @@ -132,7 +123,7 @@ describe("automations API route (POST)", () => { it("drops non-allowlisted fields (including client-asserted identity) from the forwarded body", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ - user: { id: "12345", login: "ada", provider: "github" }, + user: { id: "0123456789abcdef0123456789abcdef" }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( Response.json({ automation: { id: "auto3" } }, { status: 201 }) diff --git a/packages/web/src/app/api/automations/route.ts b/packages/web/src/app/api/automations/route.ts index 0f66e85a8..91050b96c 100644 --- a/packages/web/src/app/api/automations/route.ts +++ b/packages/web/src/app/api/automations/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getServerAuthSession } from "@/lib/server-auth-session"; -import { buildAuthDisplay, buildScmAttribution } from "@/lib/build-auth-identity"; +import { buildAuthDisplay } from "@/lib/build-auth-identity"; import { controlPlaneUserFetch } from "@/lib/control-plane"; import { buildControlPlanePath } from "@/lib/control-plane-query"; @@ -32,13 +32,8 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); - // Explicitly pick allowed fields from the client body (the same pattern - // as the sessions route). Creator identity is derived by the control - // plane from the Bearer principal and rejected in the body — send only - // the automation definition plus the display/attribution blocks: auth* - // display for BOTH GitHub and Google, while the GitHub-only scm* - // attribution block is empty for Google — so a Google sub never reaches - // the SCM path (F1/F2). + // Explicitly pick allowed fields from the client body. Creator identity + // and SCM provenance derive from authenticated control-plane state. const user = session.user; const automationBody = { @@ -55,7 +50,6 @@ export async function POST(request: NextRequest) { repositories: body.repositories, environmentIds: body.environmentIds, ...buildAuthDisplay(user), - ...buildScmAttribution(user), }; const response = await controlPlaneUserFetch("/automations", { diff --git a/packages/web/src/app/api/sessions/[id]/attachments/[attachmentId]/route.test.ts b/packages/web/src/app/api/sessions/[id]/attachments/[attachmentId]/route.test.ts index ed8e32461..bd2cac808 100644 --- a/packages/web/src/app/api/sessions/[id]/attachments/[attachmentId]/route.test.ts +++ b/packages/web/src/app/api/sessions/[id]/attachments/[attachmentId]/route.test.ts @@ -66,4 +66,25 @@ describe("session attachment download API route", () => { expect(response.headers.get("Content-Range")).toBe("bytes 0-4/10"); expect(response.headers.get("Cache-Control")).toBe("private, no-store"); }); + + it("does not reuse the encoded payload length for a decoded attachment stream", async () => { + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + new Response("decoded attachment", { + headers: { + "Content-Type": "text/plain", + "Content-Encoding": "gzip", + "Content-Length": "8", + }, + }) + ); + + const response = await GET( + new Request("http://localhost/api/sessions/session-1/attachments/attachment-1"), + PARAMS + ); + + expect(response.headers.get("Content-Encoding")).toBeNull(); + expect(response.headers.get("Content-Length")).toBeNull(); + await expect(response.text()).resolves.toBe("decoded attachment"); + }); }); diff --git a/packages/web/src/app/api/sessions/[id]/attachments/[attachmentId]/route.ts b/packages/web/src/app/api/sessions/[id]/attachments/[attachmentId]/route.ts index ae78369cc..7542686f0 100644 --- a/packages/web/src/app/api/sessions/[id]/attachments/[attachmentId]/route.ts +++ b/packages/web/src/app/api/sessions/[id]/attachments/[attachmentId]/route.ts @@ -42,13 +42,7 @@ export async function GET( Vary: "Cookie", }); - for (const headerName of [ - "Content-Type", - "Content-Length", - "Content-Range", - "Accept-Ranges", - "ETag", - ]) { + for (const headerName of ["Content-Type", "Content-Range", "Accept-Ranges", "ETag"]) { const headerValue = response.headers.get(headerName); if (headerValue) { headers.set(headerName, headerValue); diff --git a/packages/web/src/app/api/sessions/[id]/diff/[revisionId]/files/[fileId]/route.test.ts b/packages/web/src/app/api/sessions/[id]/diff/[revisionId]/files/[fileId]/route.test.ts index a9879a124..1543c6228 100644 --- a/packages/web/src/app/api/sessions/[id]/diff/[revisionId]/files/[fileId]/route.test.ts +++ b/packages/web/src/app/api/sessions/[id]/diff/[revisionId]/files/[fileId]/route.test.ts @@ -45,6 +45,25 @@ describe("session diff file API route", () => { await expect(response.text()).resolves.toContain("diff --git"); }); + it("does not reuse the encoded payload length for a decoded patch stream", async () => { + vi.mocked(getServerAuthSession).mockResolvedValue({ user: { id: "user-1" } } as never); + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + new Response("decoded patch", { + headers: { + "Content-Type": "text/x-diff", + "Content-Encoding": "br", + "Content-Length": "5", + }, + }) + ); + + const response = await GET(new Request("http://local/patch"), context); + + expect(response.headers.get("Content-Encoding")).toBeNull(); + expect(response.headers.get("Content-Length")).toBeNull(); + await expect(response.text()).resolves.toBe("decoded patch"); + }); + it("preserves the stale-revision payload and status", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { id: "user-1" } } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( diff --git a/packages/web/src/app/api/sessions/[id]/diff/[revisionId]/files/[fileId]/route.ts b/packages/web/src/app/api/sessions/[id]/diff/[revisionId]/files/[fileId]/route.ts index 418c41e2c..12a32423c 100644 --- a/packages/web/src/app/api/sessions/[id]/diff/[revisionId]/files/[fileId]/route.ts +++ b/packages/web/src/app/api/sessions/[id]/diff/[revisionId]/files/[fileId]/route.ts @@ -33,10 +33,8 @@ export async function GET( "X-Content-Type-Options": "nosniff", Vary: "Cookie", }); - for (const name of ["Content-Length", "ETag"]) { - const value = upstream.headers.get(name); - if (value) headers.set(name, value); - } + const etag = upstream.headers.get("ETag"); + if (etag) headers.set("ETag", etag); return new Response(upstream.body, { status: upstream.status, headers }); } catch (error) { console.error("Failed to fetch session diff file:", error); diff --git a/packages/web/src/app/api/sessions/[id]/diff/retry/route.ts b/packages/web/src/app/api/sessions/[id]/diff/retry/route.ts index 6e10fb820..01351d0cc 100644 --- a/packages/web/src/app/api/sessions/[id]/diff/retry/route.ts +++ b/packages/web/src/app/api/sessions/[id]/diff/retry/route.ts @@ -3,7 +3,7 @@ import { NextResponse } from "next/server"; import { getServerAuthSession } from "@/lib/server-auth-session"; import { controlPlaneUserFetch } from "@/lib/control-plane"; -/** Request a best-effort diff refresh after verifying the browser's NextAuth session. */ +/** Request a best-effort diff refresh after verifying the browser session. */ export async function POST( _request: Request, { params }: { params: Promise<{ id: string }> } diff --git a/packages/web/src/app/api/sessions/[id]/media/[artifactId]/route.test.ts b/packages/web/src/app/api/sessions/[id]/media/[artifactId]/route.test.ts index ee73c08cf..35cfff7cd 100644 --- a/packages/web/src/app/api/sessions/[id]/media/[artifactId]/route.test.ts +++ b/packages/web/src/app/api/sessions/[id]/media/[artifactId]/route.test.ts @@ -93,13 +93,39 @@ describe("session media API route", () => { expect(response.headers.get("Cache-Control")).toBe("private, no-store"); expect(response.headers.get("Vary")).toBe("Cookie"); expect(response.headers.get("Content-Type")).toBe("image/png"); - expect(response.headers.get("Content-Length")).toBe(String(upstreamBody.byteLength)); + expect(response.headers.get("Content-Length")).toBeNull(); expect(response.headers.get("ETag")).toBe('"artifact-etag"'); expect(Array.from(new Uint8Array(await response.arrayBuffer()))).toEqual( Array.from(upstreamBody) ); }); + it("does not reuse the encoded payload length for a decoded media stream", async () => { + vi.mocked(getServerAuthSession).mockResolvedValue({ + user: { id: "user-1" }, + } as never); + vi.mocked(controlPlaneUserFetch).mockResolvedValue( + new Response("decoded media", { + headers: { + "Content-Type": "text/plain", + "Content-Encoding": "br", + "Content-Length": "4", + }, + }) + ); + + const response = await GET(new Request("http://localhost/api/sessions/session-1/media/a1"), { + params: Promise.resolve({ + id: "session-1", + artifactId: "artifact-1", + }), + }); + + expect(response.headers.get("Content-Encoding")).toBeNull(); + expect(response.headers.get("Content-Length")).toBeNull(); + await expect(response.text()).resolves.toBe("decoded media"); + }); + it("forwards range requests and range response headers", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { id: "user-1" }, @@ -135,7 +161,7 @@ describe("session media API route", () => { }); expect(response.status).toBe(206); expect(response.headers.get("Content-Type")).toBe("video/mp4"); - expect(response.headers.get("Content-Length")).toBe(String(upstreamBody.byteLength)); + expect(response.headers.get("Content-Length")).toBeNull(); expect(response.headers.get("Content-Range")).toBe("bytes 4-7/24"); expect(response.headers.get("Accept-Ranges")).toBe("bytes"); expect(response.headers.get("ETag")).toBe('"video-etag"'); diff --git a/packages/web/src/app/api/sessions/[id]/media/[artifactId]/route.ts b/packages/web/src/app/api/sessions/[id]/media/[artifactId]/route.ts index c02946e8e..223db5d0b 100644 --- a/packages/web/src/app/api/sessions/[id]/media/[artifactId]/route.ts +++ b/packages/web/src/app/api/sessions/[id]/media/[artifactId]/route.ts @@ -39,13 +39,7 @@ export async function GET( Vary: "Cookie", }); - for (const headerName of [ - "Content-Type", - "Content-Length", - "Content-Range", - "Accept-Ranges", - "ETag", - ]) { + for (const headerName of ["Content-Type", "Content-Range", "Accept-Ranges", "ETag"]) { const headerValue = response.headers.get(headerName); if (headerValue) { headers.set(headerName, headerValue); diff --git a/packages/web/src/app/api/sessions/[id]/ws-token/route.test.ts b/packages/web/src/app/api/sessions/[id]/ws-token/route.test.ts index b81b3549c..d5288b5f2 100644 --- a/packages/web/src/app/api/sessions/[id]/ws-token/route.test.ts +++ b/packages/web/src/app/api/sessions/[id]/ws-token/route.test.ts @@ -40,15 +40,13 @@ describe("ws-token API route", () => { expect(controlPlaneUserFetch).not.toHaveBeenCalled(); }); - it("sends scm* attribution for a GitHub user — never userId or credentials (forbidden under strict)", async () => { + it("sends only provider-independent display data", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "12345", - login: "ada", + id: "0123456789abcdef0123456789abcdef", name: "Ada Lovelace", email: "ada@example.com", image: "https://avatars.githubusercontent.com/u/12345", - provider: "github", }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( @@ -64,21 +62,16 @@ describe("ws-token API route", () => { ); expect(sentBody()).toEqual({ authName: "Ada Lovelace", - scmLogin: "ada", - scmName: "Ada Lovelace", - scmEmail: "ada@example.com", - scmAvatarUrl: "https://avatars.githubusercontent.com/u/12345", }); }); - it("omits scm* entirely for a Google user — identity comes from the Bearer principal", async () => { + it("uses the same body shape regardless of sign-in provider", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "google-sub-1", + id: "fedcba9876543210fedcba9876543210", name: "Pat PM", email: "pm@gmail.com", image: "https://lh3.googleusercontent.com/a/pat", - provider: "google", }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( diff --git a/packages/web/src/app/api/sessions/[id]/ws-token/route.ts b/packages/web/src/app/api/sessions/[id]/ws-token/route.ts index f198c01ed..8d43170e6 100644 --- a/packages/web/src/app/api/sessions/[id]/ws-token/route.ts +++ b/packages/web/src/app/api/sessions/[id]/ws-token/route.ts @@ -1,14 +1,14 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getServerAuthSession } from "@/lib/server-auth-session"; -import { buildAuthDisplay, buildScmAttribution } from "@/lib/build-auth-identity"; +import { buildAuthDisplay } from "@/lib/build-auth-identity"; import { controlPlaneUserFetch } from "@/lib/control-plane"; /** * Generate a WebSocket authentication token for the current user. * * This endpoint: - * 1. Verifies the user is authenticated via NextAuth + * 1. Verifies the Better Auth browser session * 2. Extracts user info from the session * 3. Proxies the request to the control plane to generate a token * 4. Returns the token to the client for WebSocket connection @@ -26,10 +26,8 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< const { id: sessionId } = await params; try { - // Extract user info from NextAuth session. Participant identity (userId) - // and SCM credentials are derived by the control plane from the Bearer - // principal and are rejected in the body under strict enforcement — the - // body carries display/attribution fields only. + // Participant identity and SCM provenance are derived by the control + // plane. The request carries only a cosmetic presence name. const user = session.user; const { authName } = buildAuthDisplay(user); @@ -38,8 +36,6 @@ export async function POST(_request: NextRequest, { params }: { params: Promise< method: "POST", body: JSON.stringify({ authName, - // GitHub-only commit attribution; empty for Google. - ...buildScmAttribution(user), }), }); const fetchMs = Date.now() - fetchStart; diff --git a/packages/web/src/app/api/sessions/route.test.ts b/packages/web/src/app/api/sessions/route.test.ts index eca61e526..b51af31e5 100644 --- a/packages/web/src/app/api/sessions/route.test.ts +++ b/packages/web/src/app/api/sessions/route.test.ts @@ -11,7 +11,6 @@ vi.mock("@/lib/control-plane", () => ({ import { getServerAuthSession } from "@/lib/server-auth-session"; import { controlPlaneUserFetch } from "@/lib/control-plane"; -import { clearCurrentUserIdCacheForTests } from "@/lib/current-user"; import { GET, POST } from "./route"; function request(path: string) { @@ -34,7 +33,6 @@ function controlPlaneBody(callIndex = 0): Record { describe("sessions API route", () => { beforeEach(() => { vi.resetAllMocks(); - clearCurrentUserIdCacheForTests(); }); it("returns 401 when the user session is missing", async () => { @@ -48,7 +46,9 @@ describe("sessions API route", () => { }); it("forwards allowed session query params", async () => { - vi.mocked(getServerAuthSession).mockResolvedValue({ user: { id: "12345" } } as never); + vi.mocked(getServerAuthSession).mockResolvedValue({ + user: { id: "0123456789abcdef0123456789abcdef" }, + }); vi.mocked(controlPlaneUserFetch).mockResolvedValue( Response.json({ sessions: [], hasMore: false }, { status: 200 }) ); @@ -66,72 +66,46 @@ describe("sessions API route", () => { await expect(response.json()).resolves.toEqual({ sessions: [], hasMore: false }); }); - it("resolves createdBy=me before forwarding sessions to the control plane", async () => { + it("replaces createdBy=me with the canonical session principal", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "12345", - login: "ada", + id: "0123456789abcdef0123456789abcdef", name: "Ada Lovelace", email: "ada@example.com", image: "https://avatars.githubusercontent.com/u/12345", }, - } as never); - vi.mocked(controlPlaneUserFetch) - .mockResolvedValueOnce(Response.json({ userId: "0123456789abcdef0123456789abcdef" })) - .mockResolvedValueOnce(Response.json({ sessions: [], hasMore: false }, { status: 200 })); + }); + vi.mocked(controlPlaneUserFetch).mockResolvedValueOnce( + Response.json({ sessions: [], hasMore: false }, { status: 200 }) + ); const response = await GET( request("/api/sessions?limit=50&offset=0&excludeStatus=archived&createdBy=me") ); - expect(controlPlaneUserFetch).toHaveBeenNthCalledWith(1, "/provider-identities/github/12345", { - method: "PUT", - }); - expect(controlPlaneUserFetch).toHaveBeenNthCalledWith( - 2, + expect(controlPlaneUserFetch).toHaveBeenCalledWith( "/sessions?limit=50&offset=0&excludeStatus=archived&createdBy=0123456789abcdef0123456789abcdef" ); expect(response.status).toBe(200); }); - it("returns 409 when createdBy=me cannot resolve a user id", async () => { - vi.mocked(getServerAuthSession).mockResolvedValue({ - user: { email: "ada@example.com" }, - } as never); - - const response = await GET(request("/api/sessions?createdBy=me")); - - expect(response.status).toBe(409); - await expect(response.json()).resolves.toEqual({ error: "User id unavailable" }); - expect(controlPlaneUserFetch).not.toHaveBeenCalled(); - }); - - it("resolves createdBy=me for a Google user via the google provider route", async () => { + it("does not branch on the provider used to authenticate the session", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "google-sub-1", + id: "fedcba9876543210fedcba9876543210", name: "Pat PM", email: "pm@gmail.com", image: "https://lh3.googleusercontent.com/a/pat", - provider: "google", }, - } as never); - vi.mocked(controlPlaneUserFetch) - .mockResolvedValueOnce(Response.json({ userId: "0123456789abcdef0123456789abcdef" })) - .mockResolvedValueOnce(Response.json({ sessions: [], hasMore: false }, { status: 200 })); + }); + vi.mocked(controlPlaneUserFetch).mockResolvedValueOnce( + Response.json({ sessions: [], hasMore: false }, { status: 200 }) + ); const response = await GET(request("/api/sessions?limit=50&createdBy=me")); - expect(controlPlaneUserFetch).toHaveBeenNthCalledWith( - 1, - "/provider-identities/google/google-sub-1", - { - method: "PUT", - } - ); - expect(controlPlaneUserFetch).toHaveBeenNthCalledWith( - 2, - "/sessions?limit=50&createdBy=0123456789abcdef0123456789abcdef" + expect(controlPlaneUserFetch).toHaveBeenCalledWith( + "/sessions?limit=50&createdBy=fedcba9876543210fedcba9876543210" ); expect(response.status).toBe(200); }); @@ -139,59 +113,49 @@ describe("sessions API route", () => { it("resolves createdBy=me alongside explicit creator filters", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "12345", - login: "ada", + id: "0123456789abcdef0123456789abcdef", name: "Ada Lovelace", email: "ada@example.com", image: "https://avatars.githubusercontent.com/u/12345", }, - } as never); - vi.mocked(controlPlaneUserFetch) - .mockResolvedValueOnce(Response.json({ userId: "0123456789abcdef0123456789abcdef" })) - .mockResolvedValueOnce(Response.json({ sessions: [], hasMore: false }, { status: 200 })); + }); + vi.mocked(controlPlaneUserFetch).mockResolvedValueOnce( + Response.json({ sessions: [], hasMore: false }, { status: 200 }) + ); const response = await GET( request("/api/sessions?createdBy=ffffffffffffffffffffffffffffffff&createdBy=me&limit=25") ); - expect(controlPlaneUserFetch).toHaveBeenNthCalledWith(1, "/provider-identities/github/12345", { - method: "PUT", - }); - expect(controlPlaneUserFetch).toHaveBeenNthCalledWith( - 2, + expect(controlPlaneUserFetch).toHaveBeenCalledWith( "/sessions?limit=25&createdBy=ffffffffffffffffffffffffffffffff&createdBy=0123456789abcdef0123456789abcdef" ); expect(response.status).toBe(200); }); - it("reuses the resolved current user across createdBy=me pagination requests", async () => { + it("uses the same canonical principal across pagination requests", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "12345", - login: "ada", + id: "0123456789abcdef0123456789abcdef", name: "Ada Lovelace", email: "ada@example.com", image: "https://avatars.githubusercontent.com/u/12345", }, - } as never); + }); vi.mocked(controlPlaneUserFetch) - .mockResolvedValueOnce(Response.json({ userId: "0123456789abcdef0123456789abcdef" })) .mockResolvedValueOnce(Response.json({ sessions: [], hasMore: true }, { status: 200 })) .mockResolvedValueOnce(Response.json({ sessions: [], hasMore: false }, { status: 200 })); await GET(request("/api/sessions?limit=50&offset=0&excludeStatus=archived&createdBy=me")); await GET(request("/api/sessions?limit=50&offset=50&excludeStatus=archived&createdBy=me")); - expect(controlPlaneUserFetch).toHaveBeenCalledTimes(3); - expect(controlPlaneUserFetch).toHaveBeenNthCalledWith(1, "/provider-identities/github/12345", { - method: "PUT", - }); + expect(controlPlaneUserFetch).toHaveBeenCalledTimes(2); expect(controlPlaneUserFetch).toHaveBeenNthCalledWith( - 2, + 1, "/sessions?limit=50&offset=0&excludeStatus=archived&createdBy=0123456789abcdef0123456789abcdef" ); expect(controlPlaneUserFetch).toHaveBeenNthCalledWith( - 3, + 2, "/sessions?limit=50&offset=50&excludeStatus=archived&createdBy=0123456789abcdef0123456789abcdef" ); }); @@ -211,15 +175,13 @@ describe("sessions API route (POST)", () => { expect(controlPlaneUserFetch).not.toHaveBeenCalled(); }); - it("sends auth* display and scm* attribution for a GitHub session — never identity or credentials", async () => { + it("sends display fields without identity or SCM assertions", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "12345", - login: "ada", + id: "0123456789abcdef0123456789abcdef", name: "Ada Lovelace", email: "ada@example.com", image: "https://avatars.githubusercontent.com/u/12345", - provider: "github", }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( @@ -241,10 +203,6 @@ describe("sessions API route (POST)", () => { authEmail: "ada@example.com", authName: "Ada Lovelace", authAvatarUrl: "https://avatars.githubusercontent.com/u/12345", - scmLogin: "ada", - scmName: "Ada Lovelace", - scmEmail: "ada@example.com", - scmAvatarUrl: "https://avatars.githubusercontent.com/u/12345", }); // Forbidden under strict identity enforcement: the control plane derives // these from the Bearer principal, so the web must not send them. @@ -259,14 +217,13 @@ describe("sessions API route (POST)", () => { expect(sent.scmTokenExpiresAt).toBeUndefined(); }); - it("sends auth* display but no scm* for a Google session", async () => { + it("uses the same display-only body for another sign-in provider", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ user: { - id: "google-sub-1", + id: "fedcba9876543210fedcba9876543210", name: "Pat PM", email: "pm@gmail.com", image: "https://lh3.googleusercontent.com/a/pat", - provider: "google", }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( @@ -292,7 +249,7 @@ describe("sessions API route (POST)", () => { it("forwards environmentId for environment launches", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ - user: { id: "12345", provider: "github" }, + user: { id: "0123456789abcdef0123456789abcdef" }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( Response.json({ id: "sess3" }, { status: 201 }) @@ -310,7 +267,7 @@ describe("sessions API route (POST)", () => { it("forwards the repositories list for ad-hoc multi-repo launches", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ - user: { id: "12345", provider: "github" }, + user: { id: "0123456789abcdef0123456789abcdef" }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( Response.json({ id: "sess4" }, { status: 201 }) @@ -330,7 +287,7 @@ describe("sessions API route (POST)", () => { it("still strips fields outside the allowlist", async () => { vi.mocked(getServerAuthSession).mockResolvedValue({ - user: { id: "12345", provider: "github" }, + user: { id: "0123456789abcdef0123456789abcdef" }, } as never); vi.mocked(controlPlaneUserFetch).mockResolvedValue( Response.json({ id: "sess5" }, { status: 201 }) diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index 3c738943f..c0b17ba42 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -1,13 +1,12 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getServerAuthSession } from "@/lib/server-auth-session"; -import { buildAuthDisplay, buildScmAttribution } from "@/lib/build-auth-identity"; +import { buildAuthDisplay } from "@/lib/build-auth-identity"; import { controlPlaneUserFetch } from "@/lib/control-plane"; import { buildControlPlanePath, SESSION_CONTROL_PLANE_QUERY_PARAMS, } from "@/lib/control-plane-query"; -import { resolveCurrentUserId } from "@/lib/current-user"; import { CURRENT_USER_CREATED_BY } from "@/lib/session-list"; export async function GET(request: NextRequest) { @@ -25,16 +24,11 @@ export async function GET(request: NextRequest) { const createdByValues = searchParams.getAll("createdBy"); if (createdByValues.includes(CURRENT_USER_CREATED_BY)) { - const resolved = await resolveCurrentUserId(session.user); - if (!resolved.ok) { - return NextResponse.json(resolved.body, { status: resolved.status }); - } - searchParams.delete("createdBy"); for (const value of createdByValues) { searchParams.append( "createdBy", - value === CURRENT_USER_CREATED_BY ? resolved.userId : value + value === CURRENT_USER_CREATED_BY ? session.user.id : value ); } } @@ -71,11 +65,8 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); - // Explicitly pick allowed fields from the client body. Identity - // (userId/spawnSource/authProvider/authUserId/SCM credentials) is derived - // by the control plane from the authenticated Bearer principal and is - // rejected in the body under strict enforcement — send only the - // display/attribution blocks, which stay body-carried by design. + // Explicitly pick allowed fields from the client body. Identity and SCM + // provenance derive from authenticated control-plane state. const user = session.user; const sessionBody = { @@ -90,10 +81,7 @@ export async function POST(request: NextRequest) { // side): a named environment or an ad-hoc repository list. environmentId: body.environmentId, repositories: body.repositories, - // Display-only auth block (GitHub or Google); GitHub-only scm* - // attribution is empty for Google. ...buildAuthDisplay(user), - ...buildScmAttribution(user), }; const response = await controlPlaneUserFetch("/sessions", { diff --git a/packages/web/src/app/providers.test.tsx b/packages/web/src/app/providers.test.tsx index c80ec19a3..fada1acaf 100644 --- a/packages/web/src/app/providers.test.tsx +++ b/packages/web/src/app/providers.test.tsx @@ -1,8 +1,7 @@ import { describe, expect, it } from "vitest"; import { isValidElement, type ReactElement, type ReactNode } from "react"; +import { SWRConfig } from "swr"; -import { WebSessionGate } from "@/components/web-session-gate"; -import { AuthSessionProvider } from "@/lib/auth-session"; import { Providers } from "./providers"; function findByType(node: ReactNode, type: unknown): ReactElement | undefined { @@ -19,18 +18,11 @@ function findByType(node: ReactNode, type: unknown): ReactElement | undefined { } describe("Providers", () => { - it("nests the application gate and children inside the authentication provider", () => { + it("nests application children inside the shared SWR provider", () => { const child =

Protected application
; - const authProvider = findByType(Providers({ children: child }), AuthSessionProvider); + const provider = findByType(Providers({ children: child }), SWRConfig); - expect(authProvider).toBeDefined(); - - const gate = findByType( - (authProvider as ReactElement<{ children?: ReactNode }>).props.children, - WebSessionGate - ); - - expect(gate).toBeDefined(); - expect((gate as ReactElement<{ children?: ReactNode }>).props.children).toBe(child); + expect(provider).toBeDefined(); + expect((provider as ReactElement<{ children?: ReactNode }>).props.children).toContain(child); }); }); diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx index a76c9e9a8..1c1760f97 100644 --- a/packages/web/src/app/providers.tsx +++ b/packages/web/src/app/providers.tsx @@ -2,10 +2,8 @@ import { ThemeProvider } from "next-themes"; import { SWRConfig } from "swr"; -import { WebSessionGate } from "@/components/web-session-gate"; import { Toaster } from "@/components/ui/sonner"; import { SyntaxHighlightTheme } from "@/components/syntax-highlight-theme"; -import { AuthSessionProvider } from "@/lib/auth-session"; import { browserApiFetch, type BrowserApiPath } from "@/lib/browser-api-fetch"; async function swrFetcher(url: BrowserApiPath): Promise { @@ -18,20 +16,9 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( - {/* - refetchOnWindowFocus must stay off: /api/auth/session re-writes the - session cookie from the claims it decoded, so a focus refetch races - the oi-refresh rotation write and can re-persist an already-consumed - refresh token (family revocation once outside the reuse grace). - WebSessionGate owns focus/interval renewal; the one mount-time - session fetch is safe because WebSessionGate checks only after - it resolves. - */} - - {children} - - - + {children} + + ); diff --git a/packages/web/src/components/sidebar-layout.test.tsx b/packages/web/src/components/sidebar-layout.test.tsx index a8661c4b8..22ee0effc 100644 --- a/packages/web/src/components/sidebar-layout.test.tsx +++ b/packages/web/src/components/sidebar-layout.test.tsx @@ -38,7 +38,7 @@ afterEach(cleanup); describe("CollapsedSidebarControls", () => { it("renders the sidebar, search, and new session actions inline", () => { vi.mocked(useAuthSession).mockReturnValue({ - data: { user: { name: "Test User" } }, + data: { user: { id: "user-1", name: "Test User" } }, status: "authenticated", }); const push = vi.fn(); diff --git a/packages/web/src/components/web-session-gate.integration.test.tsx b/packages/web/src/components/web-session-gate.integration.test.tsx deleted file mode 100644 index fc8671afc..000000000 --- a/packages/web/src/components/web-session-gate.integration.test.tsx +++ /dev/null @@ -1,77 +0,0 @@ -// @vitest-environment jsdom - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; - -const mocks = vi.hoisted(() => ({ - status: "authenticated", - signOut: vi.fn(), - cookies: vi.fn(), - controlPlaneTokenFetch: vi.fn(), - getToken: vi.fn(), -})); - -vi.mock("@/lib/auth-session", () => ({ - useAuthSession: () => ({ data: null, status: mocks.status }), - signOut: mocks.signOut, -})); - -vi.mock("next/headers", () => ({ - cookies: mocks.cookies, -})); - -vi.mock("@/lib/control-plane-transport", () => ({ - controlPlaneTokenFetch: mocks.controlPlaneTokenFetch, -})); - -vi.mock("next-auth/jwt", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { ...actual, getToken: mocks.getToken }; -}); - -import { POST } from "@/app/api/auth/oi-refresh/route"; -import { WebSessionGate } from "./web-session-gate"; - -const SECRET = "test-nextauth-secret-for-web-session-gate"; - -beforeEach(() => { - mocks.status = "authenticated"; - mocks.signOut.mockReset(); - mocks.cookies.mockReset(); - mocks.controlPlaneTokenFetch.mockReset(); - mocks.getToken.mockReset(); - vi.stubEnv("NEXTAUTH_SECRET", SECRET); - vi.stubEnv("NEXTAUTH_URL", "https://open-inspect.example"); -}); - -afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); -}); - -describe("pre-exchange web session composition", () => { - it("keeps application children hidden and signs out when the refresh route returns 401", async () => { - const cookieWrites: unknown[] = []; - mocks.getToken.mockResolvedValue({ sub: "user-1", provider: "github" }); - mocks.cookies.mockResolvedValue({ - getAll: () => [], - set: (...args: unknown[]) => cookieWrites.push(args), - }); - vi.stubGlobal( - "fetch", - vi.fn(async () => POST()) - ); - - render( - -
Protected application
-
- ); - - expect(screen.queryByText("Protected application")).toBeNull(); - await waitFor(() => expect(mocks.signOut).toHaveBeenCalledTimes(1)); - expect(mocks.controlPlaneTokenFetch).not.toHaveBeenCalled(); - expect(cookieWrites).toHaveLength(0); - }); -}); diff --git a/packages/web/src/components/web-session-gate.test.tsx b/packages/web/src/components/web-session-gate.test.tsx deleted file mode 100644 index eaa30fdc3..000000000 --- a/packages/web/src/components/web-session-gate.test.tsx +++ /dev/null @@ -1,204 +0,0 @@ -// @vitest-environment jsdom - -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; - -import { WebSessionGate } from "./web-session-gate"; - -const mocks = vi.hoisted(() => ({ - status: "loading", - signOut: vi.fn(), -})); - -vi.mock("@/lib/auth-session", () => ({ - useAuthSession: () => ({ data: null, status: mocks.status }), - signOut: mocks.signOut, -})); - -let fetchSpy: ReturnType; - -function setVisibilityState(state: DocumentVisibilityState): void { - Object.defineProperty(document, "visibilityState", { value: state, configurable: true }); -} - -beforeEach(() => { - mocks.status = "loading"; - mocks.signOut.mockReset(); - fetchSpy = vi.fn().mockResolvedValue(new Response("{}")); - vi.stubGlobal("fetch", fetchSpy); -}); - -afterEach(() => { - cleanup(); - vi.unstubAllGlobals(); -}); - -describe("WebSessionGate", () => { - it("waits for the SessionProvider's own session fetch before checking", () => { - // Mount-time sequencing: the one /api/auth/session cookie write must land - // before the first rotation write, or the two could interleave stale over - // fresh. Waiting for "authenticated" is what orders them. - const { rerender } = render(); - expect(fetchSpy).not.toHaveBeenCalled(); - - mocks.status = "authenticated"; - rerender(); - expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(fetchSpy).toHaveBeenCalledWith("/api/auth/oi-refresh", { - method: "POST", - mode: "same-origin", - credentials: "same-origin", - }); - }); - - it("signs out when renewal reports that the session is no longer authenticated", async () => { - mocks.status = "authenticated"; - fetchSpy.mockResolvedValue(Response.json({ error: "Unauthorized" }, { status: 401 })); - - render(); - - await waitFor(() => expect(mocks.signOut).toHaveBeenCalledTimes(1)); - }); - - it("can retry sign-out when NextAuth's first sign-out request fails", async () => { - mocks.status = "authenticated"; - fetchSpy.mockResolvedValue(Response.json({ error: "Unauthorized" }, { status: 401 })); - mocks.signOut - .mockRejectedValueOnce(new Error("sign-out request failed")) - .mockResolvedValueOnce(undefined); - - render(); - - expect(await screen.findByText("Authentication temporarily unavailable")).toBeTruthy(); - expect(mocks.signOut).toHaveBeenCalledTimes(1); - - fireEvent.click(screen.getByRole("button", { name: "Retry" })); - - await waitFor(() => expect(mocks.signOut).toHaveBeenCalledTimes(2)); - }); - - it("holds authenticated children until web-session validity is confirmed", async () => { - mocks.status = "authenticated"; - let resolveCheck: ((response: Response) => void) | undefined; - fetchSpy.mockImplementation( - () => - new Promise((resolve) => { - resolveCheck = resolve; - }) - ); - - render( - -
Protected application
-
- ); - - expect(screen.queryByText("Protected application")).toBeNull(); - resolveCheck?.(new Response(null, { status: 204 })); - expect(await screen.findByText("Protected application")).toBeTruthy(); - }); - - it("offers retry without signing out when authentication is temporarily unavailable", async () => { - mocks.status = "authenticated"; - fetchSpy - .mockResolvedValueOnce( - Response.json({ error: "Authentication temporarily unavailable" }, { status: 503 }) - ) - .mockResolvedValueOnce(new Response(null, { status: 204 })); - - render( - -
Protected application
-
- ); - - expect(await screen.findByText("Authentication temporarily unavailable")).toBeTruthy(); - expect(mocks.signOut).not.toHaveBeenCalled(); - expect(screen.queryByText("Protected application")).toBeNull(); - - fireEvent.click(screen.getByRole("button", { name: "Retry" })); - - expect(await screen.findByText("Protected application")).toBeTruthy(); - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); - - it("checks a newly authenticated session before revealing children again", async () => { - mocks.status = "authenticated"; - fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 })); - const { rerender } = render( - -
Protected application
-
- ); - - expect(await screen.findByText("Protected application")).toBeTruthy(); - - mocks.status = "unauthenticated"; - rerender( - -
Protected application
-
- ); - await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1)); - - let resolveNewSession: ((response: Response) => void) | undefined; - fetchSpy.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveNewSession = resolve; - }) - ); - mocks.status = "authenticated"; - rerender( - -
Protected application
-
- ); - - expect(screen.queryByText("Protected application")).toBeNull(); - resolveNewSession?.(new Response(null, { status: 204 })); - expect(await screen.findByText("Protected application")).toBeTruthy(); - }); - - it("does not start an overlapping check when focus returns during renewal", () => { - mocks.status = "authenticated"; - fetchSpy.mockImplementation(() => new Promise(() => undefined)); - render(); - expect(fetchSpy).toHaveBeenCalledTimes(1); - - window.dispatchEvent(new Event("focus")); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); - - it("checks again when the tab becomes visible, not while hidden", async () => { - mocks.status = "authenticated"; - render( - -
Protected application
-
- ); - expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(await screen.findByText("Protected application")).toBeTruthy(); - - // Explicit state on both sides — the handler gates on visibilityState, - // so the test must not lean on jsdom's default being "visible". - setVisibilityState("hidden"); - document.dispatchEvent(new Event("visibilitychange")); - expect(fetchSpy).toHaveBeenCalledTimes(1); - - setVisibilityState("visible"); - document.dispatchEvent(new Event("visibilitychange")); - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); - - it("stops checking after unmount", () => { - mocks.status = "authenticated"; - const { unmount } = render(); - expect(fetchSpy).toHaveBeenCalledTimes(1); - - unmount(); - document.dispatchEvent(new Event("visibilitychange")); - window.dispatchEvent(new Event("focus")); - expect(fetchSpy).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/web/src/components/web-session-gate.tsx b/packages/web/src/components/web-session-gate.tsx deleted file mode 100644 index 09bcf310f..000000000 --- a/packages/web/src/components/web-session-gate.tsx +++ /dev/null @@ -1,110 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState, type ReactNode } from "react"; -import { signOut, useAuthSession } from "@/lib/auth-session"; -import { browserApiFetch } from "@/lib/browser-api-fetch"; - -/** - * Check interval for web session token renewal. Must sit comfortably inside - * OI_ACCESS_TOKEN_RENEW_WINDOW_MS (15 min) so a token entering the renew - * window is rotated well before it expires. - */ -const WEB_SESSION_CHECK_INTERVAL_MS = 5 * 60 * 1000; - -/** - * Confirms that NextAuth and the control-plane token pair form a usable web - * session before rendering authenticated children, then keeps that pair fresh. - * Renewal cannot live in the NextAuth jwt callback (getServerSession cannot - * persist rotated cookies), so this client-side gate drives rotation on - * mount, focus/visibility, and an interval. - */ -export function WebSessionGate({ children }: { children?: ReactNode }) { - const { status } = useAuthSession(); - const signingOutRef = useRef(false); - const [webSessionStatus, setWebSessionStatus] = useState< - "checking" | "ready" | "temporarily_unavailable" - >("checking"); - const [retryGeneration, setRetryGeneration] = useState(0); - - useEffect(() => { - if (status === "authenticated") return; - setWebSessionStatus("checking"); - signingOutRef.current = false; - }, [status]); - - useEffect(() => { - if (status !== "authenticated") return; - let cancelled = false; - let checkInFlight = false; - - const checkWebSession = async () => { - if (checkInFlight) return; - checkInFlight = true; - try { - const response = await browserApiFetch("/api/auth/oi-refresh", { method: "POST" }); - if (cancelled) return; - if (response.status === 401 && !signingOutRef.current) { - signingOutRef.current = true; - try { - await signOut(); - } catch { - if (!cancelled) { - signingOutRef.current = false; - setWebSessionStatus("temporarily_unavailable"); - } - } - return; - } - if (response.ok) { - setWebSessionStatus("ready"); - return; - } - setWebSessionStatus("temporarily_unavailable"); - } catch { - if (!cancelled) { - setWebSessionStatus("temporarily_unavailable"); - } - } finally { - checkInFlight = false; - } - }; - - void checkWebSession(); - const checkInterval = setInterval(() => void checkWebSession(), WEB_SESSION_CHECK_INTERVAL_MS); - const checkWhenVisible = () => { - if (document.visibilityState === "visible") void checkWebSession(); - }; - window.addEventListener("focus", checkWhenVisible); - document.addEventListener("visibilitychange", checkWhenVisible); - return () => { - cancelled = true; - clearInterval(checkInterval); - window.removeEventListener("focus", checkWhenVisible); - document.removeEventListener("visibilitychange", checkWhenVisible); - }; - }, [retryGeneration, status]); - - if (status === "unauthenticated") return children ?? null; - if (status !== "authenticated") return null; - if (webSessionStatus === "temporarily_unavailable") { - return ( -
-
-

Authentication temporarily unavailable

- -
-
- ); - } - if (webSessionStatus !== "ready") return null; - return children ?? null; -} diff --git a/packages/web/src/lib/access-control.test.ts b/packages/web/src/lib/access-control.test.ts deleted file mode 100644 index def41feef..000000000 --- a/packages/web/src/lib/access-control.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - parseAllowlist, - parseBooleanEnv, - checkAccessAllowed, - getAccessAllowReason, -} from "./access-control"; - -describe("parseAllowlist", () => { - it("returns empty array for undefined", () => { - expect(parseAllowlist(undefined)).toEqual([]); - }); - - it("returns empty array for empty string", () => { - expect(parseAllowlist("")).toEqual([]); - }); - - it("parses single value", () => { - expect(parseAllowlist("user1")).toEqual(["user1"]); - }); - - it("parses comma-separated values", () => { - expect(parseAllowlist("user1,user2,user3")).toEqual(["user1", "user2", "user3"]); - }); - - it("trims whitespace", () => { - expect(parseAllowlist(" user1 , user2 , user3 ")).toEqual(["user1", "user2", "user3"]); - }); - - it("converts to lowercase", () => { - expect(parseAllowlist("User1,USER2,UsEr3")).toEqual(["user1", "user2", "user3"]); - }); - - it("filters empty values", () => { - expect(parseAllowlist("user1,,user2, ,user3")).toEqual(["user1", "user2", "user3"]); - }); -}); - -describe("parseBooleanEnv", () => { - it("returns false for undefined and empty values", () => { - expect(parseBooleanEnv(undefined)).toBe(false); - expect(parseBooleanEnv("")).toBe(false); - expect(parseBooleanEnv(" ")).toBe(false); - }); - - it("returns true only for true", () => { - expect(parseBooleanEnv("true")).toBe(true); - expect(parseBooleanEnv(" TRUE ")).toBe(true); - expect(parseBooleanEnv("false")).toBe(false); - expect(parseBooleanEnv("1")).toBe(false); - }); -}); - -describe("checkAccessAllowed", () => { - describe("when all allowlists are empty", () => { - it("denies all users by default", () => { - const config = { - allowedDomains: [], - allowedUsers: [], - allowedEmails: [], - unsafeAllowAllUsers: false, - }; - - expect(checkAccessAllowed(config, {})).toBe(false); - expect(checkAccessAllowed(config, { githubUsername: "anyuser" })).toBe(false); - expect(checkAccessAllowed(config, { emails: ["anyone@example.com"] })).toBe(false); - }); - - it("allows all users when unsafeAllowAllUsers is enabled", () => { - const config = { - allowedDomains: [], - allowedUsers: [], - allowedEmails: [], - unsafeAllowAllUsers: true, - }; - - expect(checkAccessAllowed(config, {})).toBe(true); - expect(checkAccessAllowed(config, { githubUsername: "anyuser" })).toBe(true); - expect(checkAccessAllowed(config, { emails: ["anyone@example.com"] })).toBe(true); - }); - - it("a populated allowedEmails disables the unsafe allow-all gate", () => { - const config = { - allowedDomains: [], - allowedUsers: [], - allowedEmails: ["listed@gmail.com"], - unsafeAllowAllUsers: true, - }; - - // The gate only fires when ALL three lists are empty; once allowedEmails is - // set, enforcement applies even with unsafeAllowAllUsers on. - expect(checkAccessAllowed(config, { emails: ["listed@gmail.com"] })).toBe(true); - expect(checkAccessAllowed(config, { emails: ["other@gmail.com"] })).toBe(false); - expect(checkAccessAllowed(config, {})).toBe(false); - }); - }); - - describe("when only allowedOrganizations is set", () => { - // Org membership is resolved asynchronously (checkGitHubOrganizationAccess), - // never by this synchronous policy — so it can only deny here, while still - // refusing to fall back to unsafe allow-all. - const config = { - allowedDomains: [], - allowedUsers: [], - allowedEmails: [], - allowedOrganizations: ["acme"], - unsafeAllowAllUsers: false, - }; - - it("denies synchronously — membership is checked asynchronously elsewhere", () => { - expect(checkAccessAllowed(config, {})).toBe(false); - expect(checkAccessAllowed(config, { githubUsername: "anyuser" })).toBe(false); - expect(checkAccessAllowed(config, { emails: ["anyone@example.com"] })).toBe(false); - }); - - it("keeps the unsafe allow-all gate closed because an allowlist is configured", () => { - expect(checkAccessAllowed({ ...config, unsafeAllowAllUsers: true }, {})).toBe(false); - }); - }); - - describe("when allowedUsers is set", () => { - const config = { - allowedDomains: [], - allowedUsers: ["alloweduser"], - allowedEmails: [], - unsafeAllowAllUsers: false, - }; - - it("allows users in the list", () => { - expect(checkAccessAllowed(config, { githubUsername: "alloweduser" })).toBe(true); - }); - - it("allows users with different case", () => { - expect(checkAccessAllowed(config, { githubUsername: "AllowedUser" })).toBe(true); - expect(checkAccessAllowed(config, { githubUsername: "ALLOWEDUSER" })).toBe(true); - }); - - it("denies users not in the list", () => { - expect(checkAccessAllowed(config, { githubUsername: "otheruser" })).toBe(false); - }); - - it("denies when no username provided", () => { - expect(checkAccessAllowed(config, {})).toBe(false); - expect(checkAccessAllowed(config, { emails: ["user@example.com"] })).toBe(false); - }); - }); - - describe("when allowedEmails is set", () => { - const config = { - allowedDomains: [], - allowedUsers: [], - allowedEmails: ["pm@gmail.com", "support@gmail.com"], - unsafeAllowAllUsers: false, - }; - - it("allows an exact listed email — even on a shared domain like gmail.com", () => { - expect(checkAccessAllowed(config, { emails: ["pm@gmail.com"] })).toBe(true); - expect(checkAccessAllowed(config, { emails: ["support@gmail.com"] })).toBe(true); - }); - - it("matches case-insensitively", () => { - expect(checkAccessAllowed(config, { emails: ["PM@Gmail.com"] })).toBe(true); - }); - - it("does NOT admit other addresses on the same shared domain", () => { - // The whole point of the exact-email list: a gmail.com address is admitted - // without admitting every gmail.com account. - expect(checkAccessAllowed(config, { emails: ["stranger@gmail.com"] })).toBe(false); - }); - - it("denies when no email provided", () => { - expect(checkAccessAllowed(config, {})).toBe(false); - expect(checkAccessAllowed(config, { githubUsername: "pm" })).toBe(false); - }); - }); - - describe("when allowedDomains is set", () => { - const config = { - allowedDomains: ["company.com"], - allowedUsers: [], - allowedEmails: [], - unsafeAllowAllUsers: false, - }; - - it("allows users with matching email domain", () => { - expect(checkAccessAllowed(config, { emails: ["user@company.com"] })).toBe(true); - }); - - it("allows users with different case email", () => { - expect(checkAccessAllowed(config, { emails: ["User@COMPANY.COM"] })).toBe(true); - }); - - it("denies users with non-matching email domain", () => { - expect(checkAccessAllowed(config, { emails: ["user@other.com"] })).toBe(false); - }); - - it("denies when no email provided", () => { - expect(checkAccessAllowed(config, {})).toBe(false); - expect(checkAccessAllowed(config, { githubUsername: "someuser" })).toBe(false); - }); - }); - - describe("when user has multiple emails", () => { - const config = { - allowedDomains: ["company.com"], - allowedUsers: [], - allowedEmails: [], - unsafeAllowAllUsers: false, - }; - - it("allows access when any email exactly matches the email allowlist", () => { - expect( - checkAccessAllowed( - { ...config, allowedDomains: [], allowedEmails: ["user@company.com"] }, - { - emails: ["user@personal.com", "user@company.com"], - } - ) - ).toBe(true); - }); - - it("allows access when any email matches the domain", () => { - expect( - checkAccessAllowed(config, { - emails: ["user@personal.com", "user@company.com"], - }) - ).toBe(true); - }); - - it("denies access when no email matches the domain", () => { - expect( - checkAccessAllowed(config, { - emails: ["user@personal.com", "user@gmail.com"], - }) - ).toBe(false); - }); - }); - - describe("when both allowedUsers, allowedEmails, and allowedDomains are set (OR logic)", () => { - const config = { - allowedDomains: ["company.com"], - allowedUsers: ["specialuser"], - allowedEmails: ["contractor@gmail.com"], - unsafeAllowAllUsers: false, - }; - - it("allows users matching username", () => { - expect(checkAccessAllowed(config, { githubUsername: "specialuser" })).toBe(true); - }); - - it("allows users matching exact email", () => { - expect(checkAccessAllowed(config, { emails: ["contractor@gmail.com"] })).toBe(true); - }); - - it("allows users matching email domain", () => { - expect(checkAccessAllowed(config, { emails: ["someone@company.com"] })).toBe(true); - }); - - it("allows users matching any condition", () => { - expect( - checkAccessAllowed(config, { - githubUsername: "specialuser", - emails: ["user@other.com"], - }) - ).toBe(true); - - expect( - checkAccessAllowed(config, { - githubUsername: "otheruser", - emails: ["user@company.com"], - }) - ).toBe(true); - }); - - it("denies users matching no condition", () => { - expect( - checkAccessAllowed(config, { - githubUsername: "randomuser", - emails: ["user@other.com"], - }) - ).toBe(false); - }); - }); - - describe("when allowedUsers, allowedDomains, and allowedOrganizations are set (OR logic)", () => { - const config = { - allowedDomains: ["company.com"], - allowedUsers: ["specialuser"], - allowedEmails: [], - allowedOrganizations: ["acme"], - unsafeAllowAllUsers: false, - }; - - it("allows users matching a synchronous list (username or domain)", () => { - expect(checkAccessAllowed(config, { githubUsername: "specialuser" })).toBe(true); - expect(checkAccessAllowed(config, { emails: ["user@company.com"] })).toBe(true); - }); - - it("denies users matching none of the synchronous lists (org checked elsewhere)", () => { - expect( - checkAccessAllowed(config, { - githubUsername: "randomuser", - emails: ["user@other.com"], - }) - ).toBe(false); - }); - }); - - describe("when unsafeAllowAllUsers is true with populated allowlists", () => { - const config = { - allowedDomains: ["company.com"], - allowedUsers: ["specialuser"], - allowedEmails: [], - unsafeAllowAllUsers: true, - }; - - it("still enforces the allowlist for matching users", () => { - expect(checkAccessAllowed(config, { githubUsername: "specialuser" })).toBe(true); - expect(checkAccessAllowed(config, { emails: ["user@company.com"] })).toBe(true); - }); - - it("denies users not in the allowlist", () => { - expect(checkAccessAllowed(config, { githubUsername: "randomuser" })).toBe(false); - expect(checkAccessAllowed(config, { emails: ["user@other.com"] })).toBe(false); - }); - - it("does not bypass a populated organization allowlist", () => { - const orgConfig = { - allowedDomains: [], - allowedUsers: [], - allowedEmails: [], - allowedOrganizations: ["acme"], - unsafeAllowAllUsers: true, - }; - - // A populated org allowlist keeps the unsafe allow-all gate closed; the - // synchronous check then denies (membership is resolved asynchronously). - expect(checkAccessAllowed(orgConfig, {})).toBe(false); - expect(checkAccessAllowed(orgConfig, { githubUsername: "anyuser" })).toBe(false); - }); - }); - - describe("multiple values in allowlists", () => { - const config = { - allowedDomains: ["company.com", "partner.org"], - allowedUsers: ["admin", "developer"], - allowedEmails: [], - unsafeAllowAllUsers: false, - }; - - it("allows any user from the list", () => { - expect(checkAccessAllowed(config, { githubUsername: "admin" })).toBe(true); - expect(checkAccessAllowed(config, { githubUsername: "developer" })).toBe(true); - }); - - it("allows any domain from the list", () => { - expect(checkAccessAllowed(config, { emails: ["user@company.com"] })).toBe(true); - expect(checkAccessAllowed(config, { emails: ["user@partner.org"] })).toBe(true); - }); - }); -}); - -describe("getAccessAllowReason", () => { - it("returns the matching allow reason", () => { - expect( - getAccessAllowReason( - { - allowedDomains: [], - allowedUsers: ["alice"], - allowedEmails: [], - unsafeAllowAllUsers: false, - }, - { githubUsername: "Alice" } - ) - ).toBe("username_allowlist"); - - expect( - getAccessAllowReason( - { - allowedDomains: [], - allowedUsers: [], - allowedEmails: ["pm@gmail.com"], - unsafeAllowAllUsers: false, - }, - { emails: ["PM@gmail.com"] } - ) - ).toBe("email_allowlist"); - - expect( - getAccessAllowReason( - { - allowedDomains: ["company.com"], - allowedUsers: [], - allowedEmails: [], - unsafeAllowAllUsers: false, - }, - { emails: ["user@company.com"] } - ) - ).toBe("email_domain_allowlist"); - }); -}); diff --git a/packages/web/src/lib/access-control.ts b/packages/web/src/lib/access-control.ts deleted file mode 100644 index 95f2aa8f2..000000000 --- a/packages/web/src/lib/access-control.ts +++ /dev/null @@ -1,103 +0,0 @@ -export interface AccessControlConfig { - allowedDomains: string[]; - allowedUsers: string[]; - allowedEmails: string[]; - allowedOrganizations?: string[]; - unsafeAllowAllUsers: boolean; -} - -export interface AccessCheckParams { - githubUsername?: string; - emails?: string[]; -} - -export type AccessAllowReason = - | "unsafe_allow_all" - | "username_allowlist" - | "email_allowlist" - | "email_domain_allowlist"; - -/** - * Parse comma-separated environment variable into a lowercase, trimmed array - */ -export function parseAllowlist(value: string | undefined): string[] { - if (!value) return []; - return value - .split(",") - .map((item) => item.trim().toLowerCase()) - .filter(Boolean); -} - -export function parseBooleanEnv(value: string | undefined): boolean { - return value?.trim().toLowerCase() === "true"; -} - -function parseDomain(email: string): string | null { - const parts = email.split("@"); - return parts.length === 2 ? parts[1].toLowerCase() : null; -} - -/** - * Boolean convenience over getAccessAllowReason: true when any synchronous - * allowlist admits the user. Does NOT cover GitHub organization membership, which - * is resolved asynchronously (see checkGitHubOrganizationAccess). - */ -export function checkAccessAllowed( - config: AccessControlConfig, - params: AccessCheckParams -): boolean { - return getAccessAllowReason(config, params) !== null; -} - -/** - * Resolve which allowlist (if any) admits a sign-in, or null to deny. - * - * Matching is OR-based across the username, exact-email, and email-domain lists. - * GitHub organization membership is deliberately NOT evaluated here — it requires - * an async GitHub API call and is owned entirely by checkGitHubOrganizationAccess, - * which the sign-in callback applies as a fallback when this returns null. - * `allowedOrganizations` still participates in the empty-allowlist guard below so - * an org-only configuration does not collapse into unsafe allow-all. - */ -export function getAccessAllowReason( - config: AccessControlConfig, - params: AccessCheckParams -): AccessAllowReason | null { - const { allowedDomains, allowedUsers, allowedEmails, unsafeAllowAllUsers } = config; - const allowedOrganizations = config.allowedOrganizations ?? []; - const { githubUsername, emails } = params; - - // Empty allowlists only permit sign-in when explicitly enabled. - if ( - allowedDomains.length === 0 && - allowedUsers.length === 0 && - allowedEmails.length === 0 && - allowedOrganizations.length === 0 - ) { - return unsafeAllowAllUsers ? "unsafe_allow_all" : null; - } - - // Check explicit user allowlist (GitHub username) - if (githubUsername && allowedUsers.includes(githubUsername.toLowerCase())) { - return "username_allowlist"; - } - - // Check exact email allowlist. Provider-agnostic, and the only way to admit a - // specific address on a shared domain (e.g. one gmail.com user) without - // domain-allowing every gmail.com account. - if (emails?.some((email) => allowedEmails.includes(email.toLowerCase()))) { - return "email_allowlist"; - } - - // Check email domain allowlist. - if ( - emails - ?.map((email) => parseDomain(email)) - ?.filter((domain) => domain !== null) - ?.some((domain) => allowedDomains.includes(domain)) - ) { - return "email_domain_allowlist"; - } - - return null; -} diff --git a/packages/web/src/lib/auth-session.test.tsx b/packages/web/src/lib/auth-session.test.tsx index 0e4f117f0..ab9af10d4 100644 --- a/packages/web/src/lib/auth-session.test.tsx +++ b/packages/web/src/lib/auth-session.test.tsx @@ -1,24 +1,19 @@ // @vitest-environment jsdom -import { cleanup, render, renderHook, screen } from "@testing-library/react"; -import type { ReactNode } from "react"; +import { cleanup, renderHook } from "@testing-library/react"; import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; -vi.mock("next-auth/react", () => ({ - SessionProvider: vi.fn(({ children }: { children?: ReactNode }) => children), - signIn: vi.fn(), - signOut: vi.fn(), - useSession: vi.fn(), +const mocks = vi.hoisted(() => ({ + mutate: vi.fn(), + useSWR: vi.fn(), +})); + +vi.mock("swr", () => ({ + default: mocks.useSWR, + mutate: mocks.mutate, })); import { - SessionProvider, - signIn as nextAuthSignIn, - signOut as nextAuthSignOut, - useSession, -} from "next-auth/react"; -import { - AuthSessionProvider, signIn, signOut, useAuthSession, @@ -29,6 +24,7 @@ import { afterEach(() => { cleanup(); vi.resetAllMocks(); + vi.unstubAllGlobals(); }); describe("useAuthSession", () => { @@ -45,7 +41,7 @@ describe("useAuthSession", () => { assertState({ status: "loading", data: null }); }); - it("exposes the current NextAuth session through the app-owned hook", () => { + it("exposes the Better Auth session through the app-owned hook", () => { const data = { user: { id: "user-1", @@ -53,12 +49,16 @@ describe("useAuthSession", () => { email: "ada@example.com", image: null, }, - expires: "2099-01-01", + session: { + id: "session-1", + userId: "user-1", + expiresAt: "2099-01-01T00:00:00.000Z", + }, }; - vi.mocked(useSession).mockReturnValue({ + mocks.useSWR.mockReturnValue({ data, - status: "authenticated", - update: vi.fn(), + error: undefined, + isLoading: false, }); const { result } = renderHook(() => useAuthSession()); @@ -69,53 +69,142 @@ describe("useAuthSession", () => { }); }); - it("exposes no session data while NextAuth is loading", () => { - vi.mocked(useSession).mockReturnValue({ + it("exposes no session data while Better Auth is loading", () => { + mocks.useSWR.mockReturnValue({ + data: undefined, + error: undefined, + isLoading: true, + }); + + const { result } = renderHook(() => useAuthSession()); + + expect(result.current).toEqual({ data: null, status: "loading", - update: vi.fn(), + }); + }); + + it("treats a completed empty response as unauthenticated", () => { + mocks.useSWR.mockReturnValue({ + data: null, + error: undefined, + isLoading: false, }); const { result } = renderHook(() => useAuthSession()); expect(result.current).toEqual({ data: null, - status: "loading", + status: "unauthenticated", }); }); -}); -describe("AuthSessionProvider", () => { - it("preserves the disabled NextAuth focus refetch behavior", () => { - render( - -
Application
-
- ); + it("fails closed without crashing when the session lookup fails", () => { + mocks.useSWR.mockReturnValue({ + data: undefined, + error: new Error("control plane unavailable"), + isLoading: false, + }); + + const { result } = renderHook(() => useAuthSession()); + + expect(result.current).toEqual({ + data: null, + status: "unauthenticated", + }); + }); + + it("retains a cached authenticated session during a failed revalidation", () => { + const data: AuthSession = { + user: { + id: "user-1", + name: "Ada", + email: "ada@example.com", + image: null, + }, + }; + mocks.useSWR.mockReturnValue({ + data, + error: new Error("transient revalidation failure"), + isLoading: false, + }); + + const { result } = renderHook(() => useAuthSession()); - expect(screen.getByText("Application")).toBeTruthy(); - expect(vi.mocked(SessionProvider).mock.calls[0]?.[0]).toMatchObject({ - refetchOnWindowFocus: false, + expect(result.current).toEqual({ + data, + status: "authenticated", }); }); }); describe("signIn", () => { - it("starts the existing NextAuth provider flow", async () => { - vi.mocked(nextAuthSignIn).mockResolvedValue(undefined); + it("starts a proxied Better Auth provider flow", async () => { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + url: "https://github.com/login/oauth/authorize?state=state", + redirect: true, + }) + ); + const assign = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("location", { + origin: "https://app.example", + assign, + }); await signIn("github"); - expect(nextAuthSignIn).toHaveBeenCalledWith("github"); + expect(fetchMock).toHaveBeenCalledWith("/api/auth/sign-in/social", { + method: "POST", + mode: "same-origin", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "github", + callbackURL: "/", + disableRedirect: true, + }), + }); + expect(assign).toHaveBeenCalledWith("https://github.com/login/oauth/authorize?state=state"); + }); + + it("throws instead of navigating when sign-in fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(Response.json({ error: "Unavailable" }, { status: 503 })) + ); + vi.stubGlobal("location", { origin: "https://app.example", assign: vi.fn() }); + + await expect(signIn("google")).rejects.toThrow("Sign-in failed with status 503"); + expect(location.assign).not.toHaveBeenCalled(); }); }); describe("signOut", () => { - it("ends the existing NextAuth session", async () => { - vi.mocked(nextAuthSignOut).mockResolvedValue(undefined); + it("ends the Better Auth session and clears the session cache", async () => { + const fetchMock = vi.fn().mockResolvedValue(Response.json({ success: true })); + vi.stubGlobal("fetch", fetchMock); await signOut(); - expect(nextAuthSignOut).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith("/api/auth/sign-out", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + mode: "same-origin", + credentials: "same-origin", + }); + expect(mocks.mutate).toHaveBeenCalledWith("/api/auth/get-session", null, false); + }); + + it("does not clear local state when server-side sign-out fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(Response.json({ error: "Unavailable" }, { status: 503 })) + ); + + await expect(signOut()).rejects.toThrow("Sign-out failed with status 503"); + expect(mocks.mutate).not.toHaveBeenCalled(); }); }); diff --git a/packages/web/src/lib/auth-session.tsx b/packages/web/src/lib/auth-session.tsx index 84e167202..46e2ec5d0 100644 --- a/packages/web/src/lib/auth-session.tsx +++ b/packages/web/src/lib/auth-session.tsx @@ -1,21 +1,20 @@ "use client"; -import type { ReactNode } from "react"; +import useSWR, { mutate } from "swr"; +import { z } from "zod"; import { - SessionProvider, - signIn as nextAuthSignIn, - signOut as nextAuthSignOut, - useSession, -} from "next-auth/react"; + browserAuthSessionResponseSchema, + type BrowserAuthSessionUser, +} from "./browser-auth-session-contract"; +import { browserApiFetch } from "./browser-api-fetch"; import type { AuthProvider } from "./build-auth-identity"; -export interface AuthSessionUser { - name?: string | null; - image?: string | null; -} +const BROWSER_AUTH_SESSION_PATH = "/api/auth/get-session"; + +export type AuthSessionUser = BrowserAuthSessionUser; export interface AuthSession { - user?: AuthSessionUser | null; + user: AuthSessionUser; } export type SignInProvider = AuthProvider; @@ -32,28 +31,64 @@ export type AuthSessionState = export type AuthSessionStatus = AuthSessionState["status"]; -export function AuthSessionProvider({ children }: { children: ReactNode }) { - return {children}; -} - export async function signIn(provider: SignInProvider): Promise { - await nextAuthSignIn(provider); + const response = await browserApiFetch("/api/auth/sign-in/social", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider, + callbackURL: "/", + disableRedirect: true, + }), + }); + if (!response.ok) { + throw new Error(`Sign-in failed with status ${response.status}`); + } + + const { url } = z.object({ url: z.url() }).parse(await response.json()); + const destination = new URL(url); + if (destination.protocol !== "https:") { + throw new Error("Sign-in returned an insecure redirect URL"); + } + globalThis.location.assign(destination.href); } export async function signOut(): Promise { - await nextAuthSignOut(); + const response = await browserApiFetch("/api/auth/sign-out", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + if (!response.ok) { + throw new Error(`Sign-out failed with status ${response.status}`); + } + await mutate(BROWSER_AUTH_SESSION_PATH, null, false); } /** * App-owned client authentication boundary. - * - * The current implementation delegates to NextAuth. Terminal browser auth can - * replace this module without another repository-wide consumer migration. */ export function useAuthSession(): AuthSessionState { - const state = useSession(); - if (state.status === "authenticated") { - return { data: state.data, status: state.status }; + const { data, error, isLoading } = useSWR( + BROWSER_AUTH_SESSION_PATH, + async () => { + const response = await browserApiFetch(BROWSER_AUTH_SESSION_PATH); + if (!response.ok) { + throw new Error(`Session lookup failed with status ${response.status}`); + } + const payload: unknown = await response.json(); + if (payload === null) return null; + const session = browserAuthSessionResponseSchema.parse(payload); + return { user: session.user }; + } + ); + + if (data) return { data, status: "authenticated" }; + if (error) { + return { data: null, status: "unauthenticated" }; + } + if (isLoading || data === undefined) { + return { data: null, status: "loading" }; } - return { data: null, status: state.status }; + return { data: null, status: "unauthenticated" }; } diff --git a/packages/web/src/lib/auth.test.ts b/packages/web/src/lib/auth.test.ts deleted file mode 100644 index cbd2e93cf..000000000 --- a/packages/web/src/lib/auth.test.ts +++ /dev/null @@ -1,816 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { Account, NextAuthOptions, Profile, Session } from "next-auth"; -import type { JWT } from "next-auth/jwt"; -import type { AccessControlConfig } from "./access-control"; -import { - applyJwtClaims, - applySessionUser, - getStaticSignInReason, - getVerifiedGitHubEmails, - normalizeGitHubUserInfoProfile, -} from "./auth"; - -vi.mock("@open-inspect/shared", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { ...actual, DEFAULT_APP_NAME: "Open-Inspect" }; -}); - -vi.mock("next-auth/providers/github", () => ({ - default: (config: unknown) => ({ - id: "github", - type: "oauth", - options: config, - }), -})); - -const ORIGINAL_ENV = { ...process.env }; - -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - resetAuthEnv(); -}); - -function cfg(overrides: Partial = {}): AccessControlConfig { - return { - allowedDomains: [], - allowedUsers: [], - allowedEmails: [], - unsafeAllowAllUsers: false, - ...overrides, - }; -} - -describe("buildGitHubOAuthScope", () => { - it("requests base scopes when organization access is disabled", async () => { - const { BASE_GITHUB_OAUTH_SCOPE, buildGitHubOAuthScope } = await importAuthModule(); - - expect(buildGitHubOAuthScope([])).toBe(BASE_GITHUB_OAUTH_SCOPE); - }); - - it("requests read:org only when organization access is configured", async () => { - const { BASE_GITHUB_OAUTH_SCOPE, buildGitHubOAuthScope } = await importAuthModule(); - - expect(buildGitHubOAuthScope(["acme"])).toBe(`${BASE_GITHUB_OAUTH_SCOPE} read:org`); - }); -}); - -describe("GitHub provider scope", () => { - it("omits read:org when organization access is disabled", async () => { - const { authOptions, BASE_GITHUB_OAUTH_SCOPE } = await importAuthModule({ - ALLOWED_GITHUB_ORGS: "", - }); - - expect(getGitHubProviderScope(authOptions)).toBe(BASE_GITHUB_OAUTH_SCOPE); - }); - - it("includes read:org when organization access is configured", async () => { - const { authOptions, BASE_GITHUB_OAUTH_SCOPE } = await importAuthModule({ - ALLOWED_GITHUB_ORGS: "acme", - }); - - expect(getGitHubProviderScope(authOptions)).toBe(`${BASE_GITHUB_OAUTH_SCOPE} read:org`); - }); -}); - -describe("authOptions signIn", () => { - it("logs static allow decisions without sensitive token data", async () => { - const { authOptions } = await importAuthModule({ - ALLOWED_USERS: "alice", - }); - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - - await expect( - getSignIn(authOptions)({ - account: { access_token: "secret-token" }, - profile: { login: "Alice" }, - user: { email: "alice@example.com" }, - } as never) - ).resolves.toBe(true); - - expect(info).toHaveBeenCalledWith("[auth] sign-in decision", { - login: "Alice", - decision: "allow", - reason: "username_allowlist", - }); - expect(JSON.stringify(info.mock.calls)).not.toContain("secret-token"); - }); - - it("checks configured organization membership with the OAuth access token", async () => { - const { authOptions } = await importAuthModule({ - ALLOWED_GITHUB_ORGS: "acme", - NEXT_PUBLIC_APP_NAME: "Test App", - }); - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - const fetchImpl = vi.fn( - async () => new Response(JSON.stringify({ state: "active" })) - ) as unknown as typeof fetch; - vi.stubGlobal("fetch", fetchImpl); - - await expect( - getSignIn(authOptions)({ - account: { access_token: "oauth-token" }, - profile: { login: "member" }, - user: { email: "member@example.com" }, - } as never) - ).resolves.toBe(true); - - expect(fetchImpl).toHaveBeenCalledWith( - "https://api.github.com/user/memberships/orgs/acme", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer oauth-token", - "User-Agent": "Test App", - }) as HeadersInit, - }) - ); - expect(info).toHaveBeenCalledWith("[auth] sign-in decision", { - login: "member", - decision: "allow", - reason: "org_membership", - }); - }); - - it("denies organization access when the OAuth access token is missing", async () => { - const { authOptions } = await importAuthModule({ - ALLOWED_GITHUB_ORGS: "acme", - }); - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn() as unknown as typeof fetch; - vi.stubGlobal("fetch", fetchImpl); - - await expect( - getSignIn(authOptions)({ - account: {}, - profile: { login: "member" }, - user: { email: "member@example.com" }, - } as never) - ).resolves.toBe(false); - - expect(fetchImpl).not.toHaveBeenCalled(); - expect(warn).toHaveBeenCalledWith("[github-org-access] membership check skipped", { - reason: "missing_access_token", - organizationCount: 1, - }); - expect(info).toHaveBeenCalledWith("[auth] sign-in decision", { - login: "member", - decision: "deny", - reason: "org_membership_unavailable", - }); - }); - - it.each([ - ["404 response", () => new Response("Not Found", { status: 404 })], - ["pending membership", () => new Response(JSON.stringify({ state: "pending" }))], - ])("denies organization access for %s", async (_label, responseFactory) => { - const { authOptions } = await importAuthModule({ - ALLOWED_GITHUB_ORGS: "acme", - }); - vi.spyOn(console, "info").mockImplementation(() => {}); - vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn(async () => responseFactory()) as unknown as typeof fetch; - vi.stubGlobal("fetch", fetchImpl); - - await expect( - getSignIn(authOptions)({ - account: { access_token: "oauth-token" }, - profile: { login: "member" }, - user: { email: "member@example.com" }, - } as never) - ).resolves.toBe(false); - }); - - it.each([ - ["429 response", () => new Response("Rate Limited", { status: 429 })], - ["server error", () => new Response("Server Error", { status: 500 })], - [ - "network error", - () => { - throw new TypeError("fetch failed"); - }, - ], - ["malformed JSON", () => new Response("not-json")], - ])("reports organization verification unavailable for %s", async (_label, responseFactory) => { - const { authOptions } = await importAuthModule({ - ALLOWED_GITHUB_ORGS: "acme", - }); - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn(async () => responseFactory()) as unknown as typeof fetch; - vi.stubGlobal("fetch", fetchImpl); - - await expect( - getSignIn(authOptions)({ - account: { access_token: "oauth-token" }, - profile: { login: "member" }, - user: { email: "member@example.com" }, - } as never) - ).resolves.toBe(false); - - expect(info).toHaveBeenCalledWith("[auth] sign-in decision", { - login: "member", - decision: "deny", - reason: "org_membership_unavailable", - }); - }); - - it("does not let unsafe open access bypass configured org allowlists", async () => { - const { authOptions } = await importAuthModule({ - ALLOWED_GITHUB_ORGS: "acme", - UNSAFE_ALLOW_ALL_USERS: "true", - }); - vi.spyOn(console, "info").mockImplementation(() => {}); - vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn(async () => new Response("Not Found", { status: 404 })); - vi.stubGlobal("fetch", fetchImpl); - - await expect( - getSignIn(authOptions)({ - account: { access_token: "oauth-token" }, - profile: { login: "outsider" }, - user: { email: "outsider@example.com" }, - } as never) - ).resolves.toBe(false); - }); - - it("denies a sign-in from an unrecognized provider", async () => { - const { authOptions } = await importAuthModule({ - ALLOWED_EMAIL_DOMAINS: "company.com", - }); - vi.spyOn(console, "info").mockImplementation(() => {}); - const fetchImpl = vi.fn() as unknown as typeof fetch; - vi.stubGlobal("fetch", fetchImpl); - - await expect( - getSignIn(authOptions)({ - account: { provider: "gitlab", access_token: "glpat-x" }, - profile: { login: "stranger", email_verified: true }, - user: { email: "stranger@company.com" }, - } as never) - ).resolves.toBe(false); - expect(fetchImpl).not.toHaveBeenCalled(); - }); - - it("does not run the GitHub org fallback for an unrecognized provider", async () => { - const { authOptions } = await importAuthModule({ - ALLOWED_GITHUB_ORGS: "acme", - }); - vi.spyOn(console, "info").mockImplementation(() => {}); - const fetchImpl = vi.fn() as unknown as typeof fetch; - vi.stubGlobal("fetch", fetchImpl); - - await expect( - getSignIn(authOptions)({ - account: { provider: "gitlab", access_token: "glpat-x" }, - profile: { login: "stranger" }, - user: { email: "stranger@example.com" }, - } as never) - ).resolves.toBe(false); - // The org fallback is GitHub-only, so a non-GitHub token never reaches GitHub. - expect(fetchImpl).not.toHaveBeenCalled(); - }); - - it("admits a GitHub user whose non-primary verified email matches the domain allowlist", async () => { - // The core behavior of PR #829: the gate considers ALL verified emails, not - // just the primary. Here the primary (personal.com) does not match but a - // non-primary verified company.com email does. Before the fix this user was - // silently denied because only user.email (the primary) was checked. - const { authOptions } = await importAuthModule({ - ALLOWED_EMAIL_DOMAINS: "company.com", - }); - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - - await expect( - getSignIn(authOptions)({ - account: { provider: "github", access_token: "gho_token" }, - profile: { - login: "octocat", - verifiedEmails: [ - { email: "octo@personal.com", primary: true, verified: true, visibility: "private" }, - { email: "octo@company.com", primary: false, verified: true, visibility: null }, - ], - }, - user: { email: "octo@personal.com" }, - } as never) - ).resolves.toBe(true); - - expect(info).toHaveBeenCalledWith("[auth] sign-in decision", { - login: "octocat", - decision: "allow", - reason: "email_domain_allowlist", - }); - }); - - it("denies a GitHub user when none of the verified emails match the allowlists", async () => { - const { authOptions } = await importAuthModule({ - ALLOWED_EMAIL_DOMAINS: "company.com", - ALLOWED_EMAILS: "exact@gmail.com", - }); - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - - await expect( - getSignIn(authOptions)({ - account: { provider: "github", access_token: "gho_token" }, - profile: { - login: "stranger", - verifiedEmails: [ - { - email: "stranger@personal.com", - primary: true, - verified: true, - visibility: "private", - }, - { email: "stranger@other.com", primary: false, verified: true, visibility: null }, - ], - }, - user: { email: "stranger@personal.com" }, - } as never) - ).resolves.toBe(false); - - expect(info).toHaveBeenCalledWith("[auth] sign-in decision", { - login: "stranger", - decision: "deny", - reason: "no_matching_policy", - }); - }); - - it("does not trust user.email for the GitHub email/domain gate when verified emails are unavailable", async () => { - // Fail-closed guard: if the verified-email fetch came back empty (e.g. - // /user/emails 403'd for lack of the Email-addresses permission), the gate - // must NOT fall back to user.email — that value is not independently verified - // here. A user.email on an allowed domain must still be denied. - const { authOptions } = await importAuthModule({ - ALLOWED_EMAIL_DOMAINS: "company.com", - }); - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - - await expect( - getSignIn(authOptions)({ - account: { provider: "github", access_token: "gho_token" }, - profile: { login: "octocat", verifiedEmails: [] }, - user: { email: "octo@company.com" }, - } as never) - ).resolves.toBe(false); - - expect(info).toHaveBeenCalledWith("[auth] sign-in decision", { - login: "octocat", - decision: "deny", - reason: "no_matching_policy", - }); - }); -}); - -describe("getVerifiedGitHubEmails", () => { - it("returns all verified emails", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response( - JSON.stringify([ - { email: "other@example.com", primary: false, verified: true, visibility: "private" }, - { email: "user@company.com", primary: true, verified: true, visibility: "private" }, - ]) - ) - ); - - await expect(getVerifiedGitHubEmails({ accessToken: "token" })).resolves.toEqual([ - { email: "other@example.com", primary: false, verified: true, visibility: "private" }, - { email: "user@company.com", primary: true, verified: true, visibility: "private" }, - ]); - }); - - it("excludes unverified emails", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response( - JSON.stringify([ - { email: "user@company.com", primary: true, verified: false, visibility: "private" }, - ]) - ) - ); - - await expect(getVerifiedGitHubEmails({ accessToken: "token" })).resolves.toEqual([]); - }); - - it("returns empty array for malformed GitHub email responses", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify([{ email: "user@company.com", verified: true }])) - ); - - await expect(getVerifiedGitHubEmails({ accessToken: "token" })).resolves.toEqual([]); - - expect(warn).toHaveBeenCalledWith( - "[github-email-fetch] invalid response", - expect.objectContaining({ elapsedMs: expect.any(Number) }) - ); - }); - - it("returns empty array when GitHub email lookup fails", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 403 })); - - await expect(getVerifiedGitHubEmails({ accessToken: "token" })).resolves.toEqual([]); - }); - - it("hints at the missing Email-addresses permission on a 403", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 403 })); - - await expect(getVerifiedGitHubEmails({ accessToken: "token" })).resolves.toEqual([]); - - expect(warn).toHaveBeenCalledWith( - "[github-email-fetch] request failed", - expect.objectContaining({ - status: 403, - hint: expect.stringContaining("Email addresses"), - }) - ); - }); - - it("does not attach the permission hint on non-403 failures", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 500 })); - - await expect(getVerifiedGitHubEmails({ accessToken: "token" })).resolves.toEqual([]); - - expect(warn).toHaveBeenCalledWith( - "[github-email-fetch] request failed", - expect.not.objectContaining({ hint: expect.anything() }) - ); - }); -}); - -describe("getStaticSignInReason", () => { - describe("Google", () => { - const config = cfg({ allowedEmails: ["pm@gmail.com"] }); - - it("denies an unverified email (null) before any allowlist match", () => { - expect( - getStaticSignInReason({ - provider: "google", - profile: { email_verified: false } as unknown as Profile, - emails: ["pm@gmail.com"], - config, - }) - ).toBeNull(); - }); - - it('denies an unverified email when email_verified is the string "false"', () => { - expect( - getStaticSignInReason({ - provider: "google", - profile: { email_verified: "false" } as unknown as Profile, - emails: ["pm@gmail.com"], - config, - }) - ).toBeNull(); - }); - - it("denies when email_verified is absent", () => { - expect( - getStaticSignInReason({ - provider: "google", - profile: {} as Profile, - emails: ["pm@gmail.com"], - config, - }) - ).toBeNull(); - }); - - it("admits a verified (boolean true) allowlisted email with the email reason", () => { - expect( - getStaticSignInReason({ - provider: "google", - profile: { email_verified: true } as unknown as Profile, - emails: ["pm@gmail.com"], - config, - }) - ).toBe("email_allowlist"); - }); - - it('admits a verified email when email_verified is the string "true"', () => { - expect( - getStaticSignInReason({ - provider: "google", - profile: { email_verified: "true" } as unknown as Profile, - emails: ["pm@gmail.com"], - config, - }) - ).toBe("email_allowlist"); - }); - - it('accepts a mixed-case "True" string (case-insensitive normalization)', () => { - expect( - getStaticSignInReason({ - provider: "google", - profile: { email_verified: "True" } as unknown as Profile, - emails: ["pm@gmail.com"], - config, - }) - ).toBe("email_allowlist"); - }); - - it("denies a verified email that is not on any allowlist", () => { - expect( - getStaticSignInReason({ - provider: "google", - profile: { email_verified: true } as unknown as Profile, - emails: ["stranger@gmail.com"], - config, - }) - ).toBeNull(); - }); - }); - - describe("GitHub", () => { - it("admits an allowlisted GitHub username without an email_verified check", () => { - expect( - getStaticSignInReason({ - provider: "github", - profile: { login: "octocat" } as unknown as Profile, - emails: ["octo@company.com"], - config: cfg({ allowedUsers: ["octocat"] }), - }) - ).toBe("username_allowlist"); - }); - - it("denies a non-allowlisted GitHub user", () => { - expect( - getStaticSignInReason({ - provider: "github", - profile: { login: "stranger" } as unknown as Profile, - emails: ["stranger@other.com"], - config: cfg({ allowedDomains: ["company.com"], allowedUsers: ["octocat"] }), - }) - ).toBeNull(); - }); - - it("treats an undefined provider as the GitHub path", () => { - expect( - getStaticSignInReason({ - provider: undefined, - profile: { login: "octocat" } as unknown as Profile, - emails: ["octo@company.com"], - config: cfg({ allowedUsers: ["octocat"] }), - }) - ).toBe("username_allowlist"); - }); - }); - - describe("unrecognized provider", () => { - it("denies an unknown provider even when its email matches an allowlist", () => { - // Previously a non-google provider fell through to the GitHub branch and - // could be admitted by email/domain; an unrecognized provider now fails - // closed instead. - expect( - getStaticSignInReason({ - provider: "gitlab", - profile: { email_verified: true } as unknown as Profile, - emails: ["user@company.com"], - config: cfg({ allowedDomains: ["company.com"] }), - }) - ).toBeNull(); - }); - }); -}); - -describe("normalizeGitHubUserInfoProfile", () => { - const verifiedEmails = [ - { email: "octo@company.com", primary: true, verified: true, visibility: null }, - { email: "other@company.com", primary: false, verified: true, visibility: "private" }, - ]; - - it("parses a valid GitHub profile and attaches verified emails", () => { - expect( - normalizeGitHubUserInfoProfile( - { - id: 12345, - login: "octocat", - email: "old@example.com", - avatar_url: "https://example.com/a.png", - }, - verifiedEmails - ) - ).toEqual({ - id: 12345, - login: "octocat", - email: "octo@company.com", - avatar_url: "https://example.com/a.png", - verifiedEmails, - }); - }); - - it("accepts GitHub profiles with a null email", () => { - expect( - normalizeGitHubUserInfoProfile({ id: 12345, login: "octocat", email: null }, []) - ).toEqual({ id: 12345, login: "octocat", email: null, verifiedEmails: [] }); - }); - - it("rejects malformed or partial GitHub profiles", () => { - expect(normalizeGitHubUserInfoProfile({ id: 12345 }, verifiedEmails)).toBeNull(); - expect(normalizeGitHubUserInfoProfile({ id: {}, login: "octocat" }, verifiedEmails)).toBeNull(); - }); -}); - -describe("applyJwtClaims", () => { - it("captures SCM credentials and identity for a GitHub sign-in", () => { - const token = applyJwtClaims( - {}, - { - provider: "github", - type: "oauth", - providerAccountId: "12345", - access_token: "gho_abc", - refresh_token: "ghr_def", - expires_at: 1_700_000_000, - } as Account, - { id: 12345, login: "octocat" } as unknown as Profile - ); - - expect(token.provider).toBe("github"); - expect(token.providerUserId).toBe("12345"); - expect(token.githubUserId).toBe("12345"); - expect(token.githubLogin).toBe("octocat"); - expect(token.accessToken).toBe("gho_abc"); - expect(token.refreshToken).toBe("ghr_def"); - expect(token.accessTokenExpiresAt).toBe(1_700_000_000 * 1000); - }); - - it("does NOT capture an access token for a Google sign-in (F1 credential-leak gate)", () => { - const token = applyJwtClaims( - {}, - { - provider: "google", - type: "oauth", - providerAccountId: "google-sub-1", - access_token: "ya29.google-token", - refresh_token: "1//google-refresh", - expires_at: 1_700_000_000, - } as Account, - { sub: "google-sub-1", email: "pm@gmail.com", email_verified: true } as unknown as Profile - ); - - expect(token.accessToken).toBeUndefined(); - expect(token.refreshToken).toBeUndefined(); - expect(token.accessTokenExpiresAt).toBeUndefined(); - expect(token.provider).toBe("google"); - expect(token.providerUserId).toBe("google-sub-1"); - expect(token.githubUserId).toBeUndefined(); - expect(token.githubLogin).toBeUndefined(); - }); - - it("clears stale GitHub claims when a prior GitHub JWT is reused for a Google sign-in", () => { - const token = applyJwtClaims( - { - provider: "github", - providerUserId: "12345", - githubUserId: "12345", - githubLogin: "octocat", - accessToken: "gho_abc", - refreshToken: "ghr_def", - accessTokenExpiresAt: 1_700_000_000 * 1000, - } as JWT, - { - provider: "google", - type: "oauth", - providerAccountId: "google-sub-1", - access_token: "ya29.google-token", - refresh_token: "1//google-refresh", - expires_at: 1_700_000_000, - } as Account, - { sub: "google-sub-1", email: "pm@gmail.com", email_verified: true } as unknown as Profile - ); - - expect(token.provider).toBe("google"); - expect(token.providerUserId).toBe("google-sub-1"); - expect(token.accessToken).toBeUndefined(); - expect(token.refreshToken).toBeUndefined(); - expect(token.accessTokenExpiresAt).toBeUndefined(); - expect(token.githubUserId).toBeUndefined(); - expect(token.githubLogin).toBeUndefined(); - }); - - it("backfills provider/providerUserId for a legacy GitHub JWT with no account on the request", () => { - const token = applyJwtClaims({ githubUserId: "999" } as JWT, null, undefined); - - expect(token.provider).toBe("github"); - expect(token.providerUserId).toBe("999"); - // No account on the request, so no fresh credentials are captured. - expect(token.accessToken).toBeUndefined(); - }); - - it("leaves an anonymous token untouched", () => { - const token = applyJwtClaims({}, null, undefined); - - expect(token.provider).toBeUndefined(); - expect(token.providerUserId).toBeUndefined(); - }); - - it("stores no provider and clears GitHub claims for an unrecognized provider", () => { - // Defensive: such a session is already denied at signIn, but if a JWT for an - // unrecognized provider were ever produced it must carry no SCM/GitHub state. - const token = applyJwtClaims( - { - accessToken: "gho_old", - githubUserId: "12345", - githubLogin: "octocat", - } as JWT, - { - provider: "gitlab", - type: "oauth", - providerAccountId: "gl-1", - access_token: "glpat-xyz", - } as unknown as Account, - undefined - ); - - expect(token.provider).toBeUndefined(); - expect(token.providerUserId).toBeUndefined(); - expect(token.accessToken).toBeUndefined(); - expect(token.githubUserId).toBeUndefined(); - expect(token.githubLogin).toBeUndefined(); - }); -}); - -describe("applySessionUser", () => { - function emptySession(): Session { - return { user: {}, expires: "" }; - } - - it("maps a GitHub token onto the session user", () => { - const session = applySessionUser(emptySession(), { - provider: "github", - providerUserId: "12345", - githubUserId: "12345", - githubLogin: "octocat", - } as JWT); - - expect(session.user.id).toBe("12345"); - expect(session.user.provider).toBe("github"); - expect(session.user.login).toBe("octocat"); - }); - - it("maps a Google token onto the session user with no login", () => { - const session = applySessionUser(emptySession(), { - provider: "google", - providerUserId: "google-sub-1", - } as JWT); - - expect(session.user.id).toBe("google-sub-1"); - expect(session.user.provider).toBe("google"); - expect(session.user.login).toBeUndefined(); - }); - - it("falls back to githubUserId for a legacy token without providerUserId", () => { - const session = applySessionUser(emptySession(), { githubUserId: "999" } as JWT); - - expect(session.user.id).toBe("999"); - }); -}); - -async function importAuthModule(env: Record = {}) { - vi.resetModules(); - resetAuthEnv(); - for (const [key, value] of Object.entries(env)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - - return import("./auth"); -} - -function resetAuthEnv(): void { - for (const key of [ - "ALLOWED_EMAIL_DOMAINS", - "ALLOWED_USERS", - "ALLOWED_EMAILS", - "ALLOWED_GITHUB_ORGS", - "UNSAFE_ALLOW_ALL_USERS", - "NEXT_PUBLIC_APP_NAME", - "GOOGLE_CLIENT_ID", - "GOOGLE_CLIENT_SECRET", - ]) { - if (ORIGINAL_ENV[key] === undefined) { - delete process.env[key]; - } else { - process.env[key] = ORIGINAL_ENV[key]; - } - } -} - -function getGitHubProviderScope(authOptions: NextAuthOptions): string { - const provider = authOptions.providers[0] as { - options: { authorization: { params: { scope: string } } }; - }; - return provider.options.authorization.params.scope; -} - -function getSignIn(authOptions: NextAuthOptions) { - const signIn = authOptions.callbacks?.signIn; - if (!signIn) { - throw new Error("signIn callback is not configured"); - } - - return signIn; -} diff --git a/packages/web/src/lib/auth.ts b/packages/web/src/lib/auth.ts deleted file mode 100644 index 8e78dca3c..000000000 --- a/packages/web/src/lib/auth.ts +++ /dev/null @@ -1,469 +0,0 @@ -import type { Account, NextAuthOptions, Profile, Session } from "next-auth"; -import type { JWT } from "next-auth/jwt"; -import GitHubProvider from "next-auth/providers/github"; -import type { GithubProfile } from "next-auth/providers/github"; -import GoogleProvider from "next-auth/providers/google"; -import { z } from "zod"; -import { DEFAULT_APP_NAME } from "@open-inspect/shared"; -import { - type AccessAllowReason, - type AccessControlConfig, - getAccessAllowReason, - parseAllowlist, - parseBooleanEnv, -} from "./access-control"; -import { - checkGitHubOrganizationAccess, - type GitHubOrganizationAccessResult, -} from "./github-org-membership"; -import { type AuthProvider, isAuthProvider } from "./build-auth-identity"; -import { applyOiSessionTokens } from "./oi-session"; -import { githubEmailListSchema, type GitHubEmail } from "./github-email-schema"; - -const GITHUB_EMAIL_FETCH_TIMEOUT_MS = 5_000; - -interface GitHubEmailFetchParams { - accessToken: string | undefined; - fetchImpl?: typeof fetch; - userAgent?: string; - timeoutMs?: number; -} - -const githubUserInfoProfileSchema = z - .object({ - id: z.union([z.number(), z.string()]), - login: z.string(), - email: z.string().nullable().optional(), - }) - .passthrough(); - -type GitHubUserInfoProfile = z.infer & { - email: string | null; - verifiedEmails: GitHubEmail[]; -}; - -type ProfileWithVerifiedEmails = Profile & { verifiedEmails?: GitHubEmail[] }; - -export function normalizeGitHubUserInfoProfile( - profile: unknown, - verifiedEmails: GitHubEmail[] -): GitHubUserInfoProfile | null { - const result = githubUserInfoProfileSchema.safeParse(profile); - if (!result.success) { - return null; - } - - return { - ...result.data, - email: verifiedEmails.find((e) => e.primary)?.email ?? null, - verifiedEmails, - }; -} - -/** - * Fetch verified email addresses from GitHub's API. - * - * Returns all verified emails for the authenticated user. If the access token - * is missing or the request fails, returns an empty array (fails closed). - * Requests are aborted after the timeout to prevent hanging. - */ -export async function getVerifiedGitHubEmails({ - accessToken, - fetchImpl = fetch, - userAgent = "Open-Inspect", - timeoutMs = GITHUB_EMAIL_FETCH_TIMEOUT_MS, -}: GitHubEmailFetchParams): Promise { - if (!accessToken) return []; - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - const startedAt = performance.now(); - - try { - const response = await fetchImpl("https://api.github.com/user/emails", { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": userAgent, - }, - signal: controller.signal, - }); - if (!response.ok) { - console.warn("[github-email-fetch] request failed", { - status: response.status, - elapsedMs: Math.round(performance.now() - startedAt), - // A 403 here almost always means the GitHub App is missing the "Email - // addresses" account permission (read-only), so /user/emails is forbidden - // even with a valid token. Without it, ALLOWED_EMAILS / - // ALLOWED_EMAIL_DOMAINS can never match a GitHub sign-in, which otherwise - // looks like an unexplained "no_matching_policy" denial. Surface a fix. - // (OAuth App deployments authorize this via the user:email scope instead.) - ...(response.status === 403 && { - hint: "GitHub App is likely missing the 'Email addresses: Read-only' account permission; grant it and re-approve the installation, or ALLOWED_EMAILS/ALLOWED_EMAIL_DOMAINS will not match GitHub sign-ins.", - }), - }); - return []; - } - const result = githubEmailListSchema.safeParse(await response.json()); - if (!result.success) { - console.warn("[github-email-fetch] invalid response", { - elapsedMs: Math.round(performance.now() - startedAt), - }); - return []; - } - return result.data.filter((e) => e.verified); - } catch (error) { - console.warn("[github-email-fetch] request error", { - error: error instanceof Error ? error.name : "unknown", - message: error instanceof Error ? error.message : String(error), - elapsedMs: Math.round(performance.now() - startedAt), - }); - return []; - } finally { - clearTimeout(timeout); - } -} - -// Extend NextAuth types to include provider-agnostic identity plus the -// GitHub-only SCM fields. -declare module "next-auth" { - interface Session { - user: { - id?: string; // Canonical provider user id: GitHub numeric id or Google sub - login?: string; // GitHub username (GitHub-only) - provider?: AuthProvider; // Which provider authenticated this session - name?: string | null; - email?: string | null; - image?: string | null; - }; - } -} - -declare module "next-auth/jwt" { - interface JWT { - accessToken?: string; - refreshToken?: string; - accessTokenExpiresAt?: number; // Unix timestamp in milliseconds - githubUserId?: string; - githubLogin?: string; - provider?: AuthProvider; - providerUserId?: string; // GitHub numeric id or Google sub - // CP-issued web session tokens: minted by the provider-verified - // exchange at sign-in, renewed via the rotating refresh grant through - // the /api/auth/oi-refresh route (the jwt callback never renews). - oiAccessToken?: string; - oiAccessTokenExpiresAt?: number; // Unix timestamp in milliseconds - oiRefreshToken?: string; - } -} - -export const BASE_GITHUB_OAUTH_SCOPE = "read:user user:email repo"; - -export function buildGitHubOAuthScope( - allowedOrganizations = parseAllowlist(process.env.ALLOWED_GITHUB_ORGS) -): string { - return allowedOrganizations.length > 0 - ? `${BASE_GITHUB_OAUTH_SCOPE} read:org` - : BASE_GITHUB_OAUTH_SCOPE; -} - -/** - * Normalize Google's `email_verified` claim. Google has returned it as boolean - * `true` or the string "true" (case-insensitive) depending on the flow; anything - * else is treated as unverified and fails closed. - */ -function isVerifiedGoogleEmail(profile: Profile | undefined): boolean { - const googleProfile = profile as { email_verified?: boolean | string } | undefined; - return ( - googleProfile?.email_verified === true || - String(googleProfile?.email_verified).toLowerCase() === "true" - ); -} - -/** - * Resolve the static (synchronous) allow reason for a sign-in attempt, or null - * when the static allowlists don't admit it. Pure and exported so the policy is - * unit-testable — NextAuth's inline signIn callback otherwise can't be reached. - * - * Providers are handled explicitly; an unrecognized provider is denied - * (default-closed) rather than treated as GitHub. - * - * - GitHub: the email was already resolved to the verified primary in the - * provider's userinfo override, so only the allowlist gate applies. - * - Google: the email MUST be verified (see isVerifiedGoogleEmail) before any - * allowlist match. All email-based admission (the email allowlist here, and - * cross-provider account linking downstream) trusts this, so it is the single - * most security-sensitive check in the sign-in path. - * - * GitHub organization membership is intentionally NOT resolved here: it needs an - * async call to GitHub's API, so the signIn callback applies it as a fallback - * when this returns null. - */ -export function getStaticSignInReason(args: { - provider: string | undefined; - profile: Profile | undefined; - emails: string[] | undefined; - config: AccessControlConfig; -}): AccessAllowReason | null { - const { provider, profile, emails, config } = args; - - switch (provider) { - case "google": { - // The email must be verified before any email-based allowlist match. - if (!isVerifiedGoogleEmail(profile)) { - return null; - } - return getAccessAllowReason(config, { emails }); - } - case "github": - case undefined: { - // GitHub, including legacy sessions minted before the provider field - // existed (treated as GitHub, matching resolveAuthProvider). The email was - // already resolved to the verified primary in the provider's userinfo - // override, so only the allowlist gate applies. - const githubProfile = profile as { login?: string } | undefined; - return getAccessAllowReason(config, { - githubUsername: githubProfile?.login, - emails, - }); - } - default: - // Any other provider is denied rather than treated as GitHub, so admitting - // a new provider is a deliberate case here and never relies on an email - // this app has not verified for that provider. - return null; - } -} - -/** - * Apply provider claims to the JWT. Pure and exported for testing. - * - * SCM credentials (accessToken/refreshToken/expiry) are captured ONLY for - * GitHub. A Google `access_token` must never populate `token.accessToken`: both - * the session-create and ws-token routes forward `token.accessToken` as - * `scmToken`, after which the control plane would use a Google token against - * GitHub's API and refresh it at GitHub's OAuth endpoint (credential leak). - * - * On a non-GitHub sign-in we also CLEAR any GitHub SCM/identity claims carried - * over from a prior GitHub session on the same JWT (NextAuth passes the previous - * token into this callback), so a Google token can never hold stale GitHub - * credentials. Cross-provider GitHub attribution for a linked user is resolved - * server-side from D1, not from these cookie claims. - */ -export function applyJwtClaims( - token: JWT, - account: Account | null | undefined, - profile: Profile | undefined -): JWT { - if (account) { - // Validate the provider against the supported set instead of casting. Only a - // validated provider contributes an identity: an unrecognized provider stores - // no provider/providerUserId and falls to the claim-clearing branch below, so - // it can't surface as a legacy GitHub session via resolveAuthProvider. - const provider = isAuthProvider(account.provider) ? account.provider : undefined; - token.provider = provider; - token.providerUserId = provider ? account.providerAccountId : undefined; - - if (provider === "github") { - token.accessToken = account.access_token; - token.refreshToken = account.refresh_token; - // expires_at is in seconds, convert to milliseconds (only set if provided) - token.accessTokenExpiresAt = account.expires_at ? account.expires_at * 1000 : undefined; - } else { - // Non-GitHub sign-in: drop any GitHub SCM/identity claims left on a token - // reused from a prior GitHub session, so a Google JWT carries no SCM state. - token.accessToken = undefined; - token.refreshToken = undefined; - token.accessTokenExpiresAt = undefined; - token.githubUserId = undefined; - token.githubLogin = undefined; - } - } - - if (profile) { - // GitHub profile carries id (numeric) and login (username); Google profiles - // carry neither, so these stay unset for Google sessions. - const githubProfile = profile as { id?: number; login?: string }; - if (githubProfile.id) { - token.githubUserId = githubProfile.id.toString(); - } - if (githubProfile.login) { - token.githubLogin = githubProfile.login; - } - } - - // Back-compat for the staggered deploy: GitHub JWTs minted before - // provider/providerUserId existed carry githubUserId but no provider. Backfill - // from githubUserId so session.user.id/provider stay correct without forcing a - // re-login. Never fires for Google (no githubUserId) or fresh logins - // (providerUserId is already set from the account above). - if (!token.providerUserId && token.githubUserId) { - token.provider = "github"; - token.providerUserId = token.githubUserId; - } - - return token; -} - -/** - * Map JWT claims onto the session user. Pure and exported for testing. - */ -export function applySessionUser(session: Session, token: JWT): Session { - if (session.user) { - // Canonical provider user id, falling back to githubUserId so legacy GitHub - // JWTs (minted before providerUserId existed) keep a stable session.user.id - // across the deploy. - session.user.id = token.providerUserId ?? token.githubUserId; - session.user.provider = token.provider; - // login is GitHub-only; undefined for Google sessions. - session.user.login = token.githubLogin; - } - return session; -} - -function logSignInDecision( - login: string | undefined, - decision: "allow" | "deny", - reason: string -): void { - console.info("[auth] sign-in decision", { - login: login ?? null, - decision, - reason, - }); -} - -function getOrgMembershipDecisionReason(orgMembership: GitHubOrganizationAccessResult): string { - if (orgMembership.allowed) { - return "org_membership"; - } - - return orgMembership.reason === "unavailable" - ? "org_membership_unavailable" - : "org_membership_denied"; -} - -const providers: NextAuthOptions["providers"] = [ - GitHubProvider({ - clientId: process.env.GITHUB_CLIENT_ID!, - clientSecret: process.env.GITHUB_CLIENT_SECRET!, - authorization: { - params: { - scope: buildGitHubOAuthScope(), - }, - }, - userinfo: { - url: "https://api.github.com/user", - async request({ client, tokens }) { - const verifiedEmails = await getVerifiedGitHubEmails({ accessToken: tokens.access_token! }); - const profile = normalizeGitHubUserInfoProfile( - await client.userinfo(tokens.access_token!), - verifiedEmails - ); - if (!profile) { - throw new Error("Invalid GitHub userinfo response"); - } - // SAFETY: NextAuth's Profile type is narrower than GitHub's numeric `id`, - // but this object has just been validated against the GitHub userinfo shape. - return profile as Profile; - }, - }, - }), -]; - -// Google is opt-in: enabled only when both credentials are configured, so -// GitHub-only deployments are byte-unchanged. Scopes stay within the -// non-sensitive openid/email/profile set (no SCM access, no Google review). -const googleClientId = process.env.GOOGLE_CLIENT_ID; -const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET; -if (googleClientId && googleClientSecret) { - providers.push( - GoogleProvider({ - clientId: googleClientId, - clientSecret: googleClientSecret, - authorization: { - params: { - scope: "openid email profile", - }, - }, - }) - ); -} - -export const authOptions: NextAuthOptions = { - debug: process.env.NODE_ENV === "development" || process.env.NEXTAUTH_DEBUG === "true", - providers, - callbacks: { - async signIn({ account, profile, user }) { - const config: AccessControlConfig = { - allowedDomains: parseAllowlist(process.env.ALLOWED_EMAIL_DOMAINS), - allowedUsers: parseAllowlist(process.env.ALLOWED_USERS), - allowedEmails: parseAllowlist(process.env.ALLOWED_EMAILS), - allowedOrganizations: parseAllowlist(process.env.ALLOWED_GITHUB_ORGS), - unsafeAllowAllUsers: parseBooleanEnv(process.env.UNSAFE_ALLOW_ALL_USERS), - }; - - const provider = account?.provider; - const githubProfile = profile as { login?: string } | undefined; - const isGitHubProvider = provider === "github" || provider === undefined; - const hasAllowLists = config.allowedDomains.length > 0 || config.allowedEmails.length > 0; - - let emails: string[] | undefined = undefined; - if (isGitHubProvider && hasAllowLists) { - const verifiedEmails = (profile as ProfileWithVerifiedEmails | undefined)?.verifiedEmails; - emails = verifiedEmails?.map((e) => e.email); - } else { - emails = user.email ? [user.email] : undefined; - } - - // Static, synchronous allowlist gate. Provider-aware: Google requires a - // verified email before any email-based match (see getStaticSignInReason). - const staticReason = getStaticSignInReason({ - provider, - profile, - emails, - config, - }); - if (staticReason) { - logSignInDecision(githubProfile?.login, "allow", staticReason); - return true; - } - - // GitHub organization membership fallback. Org membership is a GitHub - // concept and the async check calls GitHub's API with the OAuth token, so - // it runs only for GitHub sign-ins (including legacy sessions with no - // provider) when at least one org is configured. Any other provider — - // Google or unrecognized — fails closed here without contacting GitHub, so - // a non-GitHub OAuth token is never sent to GitHub's API. - const allowedOrganizations = config.allowedOrganizations ?? []; - if (!isGitHubProvider || allowedOrganizations.length === 0) { - logSignInDecision(githubProfile?.login, "deny", "no_matching_policy"); - return false; - } - - const orgMembership = await checkGitHubOrganizationAccess({ - accessToken: account?.access_token, - allowedOrganizations, - userAgent: process.env.NEXT_PUBLIC_APP_NAME?.trim() || DEFAULT_APP_NAME, - }); - - logSignInDecision( - githubProfile?.login, - orgMembership.allowed ? "allow" : "deny", - getOrgMembershipDecisionReason(orgMembership) - ); - - return orgMembership.allowed; - }, - async jwt({ token, account, profile }) { - return applyOiSessionTokens(applyJwtClaims(token, account, profile), account); - }, - async session({ session, token }) { - return applySessionUser(session, token); - }, - }, - pages: { - error: "/access-denied", - }, -}; diff --git a/packages/web/src/lib/browser-auth-proxy.test.ts b/packages/web/src/lib/browser-auth-proxy.test.ts new file mode 100644 index 000000000..3b15c56e5 --- /dev/null +++ b/packages/web/src/lib/browser-auth-proxy.test.ts @@ -0,0 +1,250 @@ +import { sha256Hex, verifyServiceSignature } from "@open-inspect/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + dispatchControlPlaneFetch: vi.fn(), +})); + +vi.mock("./control-plane-transport", () => ({ + dispatchControlPlaneFetch: mocks.dispatchControlPlaneFetch, + getControlPlaneUrl: () => "https://control-plane.example", +})); + +import { dispatchBrowserAuthRequest, proxyBrowserAuthRequest } from "./browser-auth-proxy"; + +describe("proxyBrowserAuthRequest", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetAllMocks(); + process.env = { + ...originalEnv, + SERVICE_AUTH_SECRET: "web-service-secret", + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("forwards the auth request transparently with a fresh web signature", async () => { + const upstreamHeaders = new Headers({ + Location: "/after-sign-in", + "Cache-Control": "private", + }); + upstreamHeaders.append( + "Set-Cookie", + "__Secure-openinspect.session_token=session.signature; Path=/; Secure; HttpOnly" + ); + upstreamHeaders.append( + "Set-Cookie", + "__Secure-openinspect.state=; Path=/; Max-Age=0; Secure; HttpOnly" + ); + mocks.dispatchControlPlaneFetch.mockResolvedValue( + new Response("redirecting", { + status: 302, + headers: upstreamHeaders, + }) + ); + const body = JSON.stringify({ + provider: "github", + callbackURL: "/after-sign-in", + disableRedirect: true, + }); + + const response = await proxyBrowserAuthRequest( + new Request("https://web.example/api/auth/sign-in/social?return=1", { + method: "POST", + headers: { + Authorization: "Bearer caller-controlled", + Connection: "keep-alive", + Cookie: "__Secure-openinspect.state=state-cookie", + "Content-Type": "application/json", + Origin: "https://web.example", + "User-Agent": "Test Browser", + "X-Forwarded-For": "203.0.113.42", + "X-OpenInspect-Client-IP": "198.51.100.99", + "X-OpenInspect-Service": "modal", + "X-OpenInspect-Service-Signature": "caller-controlled", + }, + body, + }) + ); + + const [url, init] = mocks.dispatchControlPlaneFetch.mock.calls[0] ?? []; + expect(url).toBe("https://control-plane.example/api/auth/sign-in/social?return=1"); + expect(init).toMatchObject({ + method: "POST", + redirect: "manual", + cache: "no-store", + }); + expect(new TextDecoder().decode(init?.body as Uint8Array)).toBe(body); + + const sentHeaders = new Headers(init?.headers); + expect(sentHeaders.get("Cookie")).toBe("__Secure-openinspect.state=state-cookie"); + expect(sentHeaders.get("Content-Type")).toBe("application/json"); + expect(sentHeaders.get("Origin")).toBe("https://web.example"); + expect(sentHeaders.get("User-Agent")).toBe("Test Browser"); + expect(sentHeaders.get("X-OpenInspect-Client-IP")).toBeNull(); + expect(sentHeaders.get("Authorization")).toBeNull(); + expect(sentHeaders.get("Connection")).toBeNull(); + expect(sentHeaders.get("X-OpenInspect-Service")).toBe("web"); + expect(sentHeaders.get("X-OpenInspect-Service-Signature")).toMatch(/^sig1\./); + + const verification = await verifyServiceSignature({ + signatureHeader: sentHeaders.get("X-OpenInspect-Service-Signature") ?? "", + service: "web", + secret: "web-service-secret", + method: "POST", + url: String(url), + bodySha256Hex: await sha256Hex(body), + actor: "", + }); + expect(verification.ok).toBe(true); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/after-sign-in"); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(response.headers.getSetCookie()).toHaveLength(2); + expect(await response.text()).toBe("redirecting"); + }); + + it("dispatches server-side auth calls without requiring a synthetic request origin", async () => { + mocks.dispatchControlPlaneFetch.mockResolvedValue(Response.json({ user: { id: "user-1" } })); + + const response = await dispatchBrowserAuthRequest({ + method: "GET", + pathname: "/api/auth/get-session", + headers: { + Cookie: "__Secure-openinspect.session_token=session.signature", + "X-Trace-Id": "trace-1", + }, + }); + + const [url, init, metadata] = mocks.dispatchControlPlaneFetch.mock.calls[0] ?? []; + expect(url).toBe("https://control-plane.example/api/auth/get-session"); + expect(init).toMatchObject({ + method: "GET", + redirect: "manual", + cache: "no-store", + }); + expect(metadata).toEqual({}); + + const sentHeaders = new Headers(init?.headers); + expect(sentHeaders.get("Cookie")).toBe("__Secure-openinspect.session_token=session.signature"); + expect(sentHeaders.get("X-Trace-Id")).toBe("trace-1"); + expect(sentHeaders.get("X-OpenInspect-Service")).toBe("web"); + + const verification = await verifyServiceSignature({ + signatureHeader: sentHeaders.get("X-OpenInspect-Service-Signature") ?? "", + service: "web", + secret: "web-service-secret", + method: "GET", + url: String(url), + bodySha256Hex: await sha256Hex(""), + actor: "", + }); + expect(verification.ok).toBe(true); + expect(response.status).toBe(200); + }); + + it("rejects typed dispatches outside the positive proxy allowlist", async () => { + const response = await dispatchBrowserAuthRequest({ + method: "GET", + pathname: "/api/auth/list-sessions", + }); + + expect(response.status).toBe(404); + expect(mocks.dispatchControlPlaneFetch).not.toHaveBeenCalled(); + }); + + it("forwards Vercel's trusted client IP header on Vercel", async () => { + process.env.VERCEL = "1"; + mocks.dispatchControlPlaneFetch.mockResolvedValue(Response.json({ ok: true })); + + await proxyBrowserAuthRequest( + new Request("https://web.example/api/auth/get-session", { + headers: { + "X-Vercel-Forwarded-For": "203.0.113.42", + "X-Forwarded-For": "192.0.2.55", + }, + }) + ); + + const [, init] = mocks.dispatchControlPlaneFetch.mock.calls[0] ?? []; + expect(new Headers(init?.headers).get("X-OpenInspect-Client-IP")).toBe("203.0.113.42"); + }); + + it("forwards Cloudflare's trusted client IP header on Cloudflare", async () => { + mocks.dispatchControlPlaneFetch.mockResolvedValue(Response.json({ ok: true })); + const request = new Request("https://web.example/api/auth/get-session", { + headers: { + "CF-Connecting-IP": "198.51.100.24", + "X-Forwarded-For": "192.0.2.55", + }, + }); + Object.defineProperty(request, "cf", { value: {} }); + + await proxyBrowserAuthRequest(request); + + const [, init] = mocks.dispatchControlPlaneFetch.mock.calls[0] ?? []; + expect(new Headers(init?.headers).get("X-OpenInspect-Client-IP")).toBe("198.51.100.24"); + }); + + it("rejects endpoints outside the positive proxy allowlist", async () => { + const response = await proxyBrowserAuthRequest( + new Request("https://web.example/api/auth/list-sessions", { + method: "GET", + }) + ); + + expect(response.status).toBe(404); + expect(mocks.dispatchControlPlaneFetch).not.toHaveBeenCalled(); + }); + + it("does not advertise upstream compression after fetch decodes the response body", async () => { + mocks.dispatchControlPlaneFetch.mockResolvedValue( + new Response(JSON.stringify({ url: "https://github.example/authorize" }), { + status: 200, + headers: { + "Content-Encoding": "br", + "Content-Length": "999", + "Content-Type": "application/json", + }, + }) + ); + + const response = await proxyBrowserAuthRequest( + new Request("https://web.example/api/auth/sign-in/social", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + provider: "github", + callbackURL: "/", + disableRedirect: true, + }), + }) + ); + + expect(response.headers.get("Content-Encoding")).toBeNull(); + expect(response.headers.get("Content-Length")).toBeNull(); + await expect(response.json()).resolves.toEqual({ + url: "https://github.example/authorize", + }); + }); + + it("fails closed when the web signing secret is unavailable", async () => { + delete process.env.SERVICE_AUTH_SECRET; + + await expect( + proxyBrowserAuthRequest( + new Request("https://web.example/api/auth/get-session", { + method: "GET", + }) + ) + ).rejects.toThrow("SERVICE_AUTH_SECRET not configured"); + expect(mocks.dispatchControlPlaneFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/lib/browser-auth-proxy.ts b/packages/web/src/lib/browser-auth-proxy.ts new file mode 100644 index 000000000..6a957cec9 --- /dev/null +++ b/packages/web/src/lib/browser-auth-proxy.ts @@ -0,0 +1,162 @@ +import { + BROWSER_AUTH_CLIENT_IP_HEADER, + buildServiceAuthHeaders, + isBrowserAuthProxyRoute, +} from "@open-inspect/shared"; +import { dispatchControlPlaneFetch, getControlPlaneUrl } from "./control-plane-transport"; + +const REQUEST_HEADERS = [ + "Accept", + "Accept-Language", + "Content-Type", + "Cookie", + "Origin", + "User-Agent", +] as const; + +const HOP_BY_HOP_RESPONSE_HEADERS = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +const DECODED_BODY_RESPONSE_HEADERS = new Set(["content-encoding", "content-length"]); + +/** + * A logical browser-auth request for server code that has no incoming URL. + * The dispatcher resolves the configured control-plane origin before signing. + */ +export interface BrowserAuthDispatchRequest { + readonly method: string; + readonly pathname: string; + readonly search?: string; + readonly headers?: HeadersInit; + readonly body?: Uint8Array; + readonly clientIp?: string | null; +} + +function copyRequestHeaders(source: Headers, clientIp?: string | null): Headers { + const headers = new Headers(); + for (const name of REQUEST_HEADERS) { + const value = source.get(name); + if (value !== null) headers.set(name, value); + } + if (clientIp != null) { + headers.set(BROWSER_AUTH_CLIENT_IP_HEADER, clientIp); + } + return headers; +} + +function trustedClientIp(request: Request): string | null { + if (process.env.VERCEL === "1") { + return request.headers.get("X-Vercel-Forwarded-For"); + } + return "cf" in request ? request.headers.get("CF-Connecting-IP") : null; +} + +function getSetCookieValues(headers: Headers): string[] { + const withGetSetCookie = headers as Headers & { + getSetCookie?: () => string[]; + }; + const values = withGetSetCookie.getSetCookie?.() ?? []; + if (values.length > 0) return values; + const singleValue = headers.get("Set-Cookie"); + return singleValue ? [singleValue] : []; +} + +function copyResponseHeaders(upstream: Headers): Headers { + const headers = new Headers(); + upstream.forEach((value, name) => { + const normalizedName = name.toLowerCase(); + if ( + normalizedName !== "set-cookie" && + !HOP_BY_HOP_RESPONSE_HEADERS.has(normalizedName) && + !DECODED_BODY_RESPONSE_HEADERS.has(normalizedName) + ) { + headers.append(name, value); + } + }); + for (const value of getSetCookieValues(upstream)) { + headers.append("Set-Cookie", value); + } + headers.set("Cache-Control", "no-store"); + headers.set("Pragma", "no-cache"); + return headers; +} + +async function dispatchAllowedBrowserAuthRequest( + request: BrowserAuthDispatchRequest +): Promise { + const method = request.method; + const secret = process.env.SERVICE_AUTH_SECRET; + if (!secret) { + throw new Error("SERVICE_AUTH_SECRET not configured"); + } + + const upstreamUrl = `${getControlPlaneUrl()}${request.pathname}${request.search ?? ""}`; + const sourceHeaders = new Headers(request.headers); + const headers = copyRequestHeaders(sourceHeaders, request.clientIp); + const serviceHeaders = await buildServiceAuthHeaders({ + service: "web", + secret, + method, + url: upstreamUrl, + body: request.body, + traceId: sourceHeaders.get("x-trace-id") ?? undefined, + }); + for (const [name, value] of Object.entries(serviceHeaders)) { + headers.set(name, value); + } + + const upstream = await dispatchControlPlaneFetch( + upstreamUrl, + { + method, + headers, + body: request.body, + redirect: "manual", + cache: "no-store", + }, + {} + ); + + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: copyResponseHeaders(upstream.headers), + }); +} + +export async function dispatchBrowserAuthRequest( + request: BrowserAuthDispatchRequest +): Promise { + const method = request.method.toUpperCase(); + if (!isBrowserAuthProxyRoute(method, request.pathname)) { + return Response.json({ error: "Not found" }, { status: 404 }); + } + return dispatchAllowedBrowserAuthRequest({ ...request, method }); +} + +export async function proxyBrowserAuthRequest(request: Request): Promise { + const incomingUrl = new URL(request.url); + const method = request.method.toUpperCase(); + if (!isBrowserAuthProxyRoute(method, incomingUrl.pathname)) { + return Response.json({ error: "Not found" }, { status: 404 }); + } + + const body = + method === "GET" || method === "HEAD" ? undefined : new Uint8Array(await request.arrayBuffer()); + return dispatchAllowedBrowserAuthRequest({ + method, + pathname: incomingUrl.pathname, + search: incomingUrl.search, + headers: request.headers, + body, + clientIp: trustedClientIp(request), + }); +} diff --git a/packages/web/src/lib/browser-auth-session-contract.ts b/packages/web/src/lib/browser-auth-session-contract.ts new file mode 100644 index 000000000..dde039ed7 --- /dev/null +++ b/packages/web/src/lib/browser-auth-session-contract.ts @@ -0,0 +1,27 @@ +import { isCanonicalUserId } from "@open-inspect/shared"; +import { z } from "zod"; + +export const browserAuthSessionUserSchema = z.object({ + id: z.string().refine(isCanonicalUserId, "Browser session user id is not canonical"), + name: z.string().nullable().optional(), + email: z.string().nullable().optional(), + image: z.string().nullable().optional(), +}); + +export const browserAuthSessionResponseSchema = z + .object({ + user: browserAuthSessionUserSchema, + session: z + .object({ + id: z.string().min(1), + userId: z.string().min(1), + expiresAt: z.string().min(1), + }) + .passthrough(), + }) + .refine(({ session, user }) => session.userId === user.id, { + message: "Browser session user does not match its principal", + }); + +export type BrowserAuthSessionUser = z.infer; +export type BrowserAuthSessionResponse = z.infer; diff --git a/packages/web/src/lib/browser-session-cookie.test.ts b/packages/web/src/lib/browser-session-cookie.test.ts new file mode 100644 index 000000000..64a17000c --- /dev/null +++ b/packages/web/src/lib/browser-session-cookie.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { serializeBrowserSessionCookies } from "./browser-session-cookie"; + +describe("serializeBrowserSessionCookies", () => { + it("forwards only the opaque Better Auth session cookie", () => { + expect( + serializeBrowserSessionCookies([ + { name: "__Secure-openinspect.state", value: "state" }, + { name: "__Secure-openinspect.session_token", value: "session.signature" }, + { name: "analytics", value: "tracking" }, + ]) + ).toBe("__Secure-openinspect.session_token=session.signature"); + }); + + it("supports numeric chunks without accepting lookalike cookie names", () => { + expect( + serializeBrowserSessionCookies([ + { name: "__Secure-openinspect.session_token.0", value: "first" }, + { name: "__Secure-openinspect.session_token.attacker", value: "ignored" }, + { name: "__Secure-openinspect.session_token.1", value: "second" }, + ]) + ).toBe( + "__Secure-openinspect.session_token.0=first; __Secure-openinspect.session_token.1=second" + ); + }); + + it("supports Better Auth's host-only localhost cookie without widening the name", () => { + expect( + serializeBrowserSessionCookies([ + { name: "openinspect.session_token", value: "local-session.signature" }, + { name: "openinspect.state", value: "state" }, + { name: "openinspect.session_token.attacker", value: "ignored" }, + ]) + ).toBe("openinspect.session_token=local-session.signature"); + }); + + it("returns null when the session cookie is absent", () => { + expect(serializeBrowserSessionCookies([])).toBeNull(); + }); + + it("rejects duplicate names and invalid values", () => { + expect(() => + serializeBrowserSessionCookies([ + { name: "__Secure-openinspect.session_token", value: "first" }, + { name: "__Secure-openinspect.session_token", value: "second" }, + ]) + ).toThrow("Duplicate browser session cookie"); + + expect(() => + serializeBrowserSessionCookies([ + { name: "__Secure-openinspect.session_token", value: "valid; injected=value" }, + ]) + ).toThrow("Invalid browser session cookie value"); + }); +}); diff --git a/packages/web/src/lib/browser-session-cookie.ts b/packages/web/src/lib/browser-session-cookie.ts new file mode 100644 index 000000000..ed480c82c --- /dev/null +++ b/packages/web/src/lib/browser-session-cookie.ts @@ -0,0 +1,41 @@ +const BROWSER_SESSION_COOKIE_PATTERN = + /^(?:__Secure-openinspect|openinspect)\.session_token(?:\.[0-9]+)?$/; + +export interface BrowserCookie { + readonly name: string; + readonly value: string; +} + +function hasInvalidCookieValueCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0); + if (character === ";" || codePoint === undefined || codePoint <= 0x20 || codePoint === 0x7f) { + return true; + } + } + return false; +} + +/** + * Serialize only Better Auth's opaque browser-session cookie. + * + * OAuth transaction cookies and unrelated browser state must not become + * credentials on control-plane resource requests. + */ +export function serializeBrowserSessionCookies(cookies: readonly BrowserCookie[]): string | null { + const sessionCookies = cookies.filter(({ name }) => BROWSER_SESSION_COOKIE_PATTERN.test(name)); + if (sessionCookies.length === 0) return null; + + const names = new Set(); + for (const { name, value } of sessionCookies) { + if (names.has(name)) { + throw new Error(`Duplicate browser session cookie: ${name}`); + } + if (hasInvalidCookieValueCharacter(value)) { + throw new Error("Invalid browser session cookie value"); + } + names.add(name); + } + + return sessionCookies.map(({ name, value }) => `${name}=${value}`).join("; "); +} diff --git a/packages/web/src/lib/build-auth-identity.test.ts b/packages/web/src/lib/build-auth-identity.test.ts index 0bd72b845..794cdba55 100644 --- a/packages/web/src/lib/build-auth-identity.test.ts +++ b/packages/web/src/lib/build-auth-identity.test.ts @@ -1,122 +1,35 @@ import { describe, expect, it } from "vitest"; -import { - buildAuthDisplay, - buildAuthIdentity, - buildScmAttribution, - isAuthProvider, - resolveAuthProvider, - type AuthIdentityUser, -} from "./build-auth-identity"; - -const githubUser: AuthIdentityUser = { - id: "12345", - login: "ada", - name: "Ada Lovelace", - email: "ada@example.com", - image: "https://avatars.githubusercontent.com/u/12345", - provider: "github", -}; - -const googleUser: AuthIdentityUser = { - id: "google-sub-1", - name: "Pat PM", - email: "pm@gmail.com", - image: "https://lh3.googleusercontent.com/a/pat", - provider: "google", -}; - -describe("resolveAuthProvider", () => { - it("returns the explicit provider", () => { - expect(resolveAuthProvider(githubUser)).toBe("github"); - expect(resolveAuthProvider(googleUser)).toBe("google"); - }); - - it("defaults a missing provider to github (legacy session back-compat)", () => { - expect(resolveAuthProvider({ id: "12345" })).toBe("github"); - expect(resolveAuthProvider(null)).toBe("github"); - expect(resolveAuthProvider(undefined)).toBe("github"); - }); -}); +import { buildAuthDisplay, isAuthProvider } from "./build-auth-identity"; describe("isAuthProvider", () => { - it("accepts supported providers", () => { + it("accepts only executable sign-in providers", () => { expect(isAuthProvider("github")).toBe(true); expect(isAuthProvider("google")).toBe(true); - }); - - it("rejects unknown or missing providers", () => { expect(isAuthProvider("gitlab")).toBe(false); - expect(isAuthProvider("")).toBe(false); expect(isAuthProvider(undefined)).toBe(false); - expect(isAuthProvider(null)).toBe(false); - }); -}); - -describe("buildAuthIdentity", () => { - it("maps a GitHub user to the auth* block", () => { - expect(buildAuthIdentity(githubUser)).toEqual({ - authProvider: "github", - authUserId: "12345", - }); - }); - - it("maps a Google user to the auth* block", () => { - expect(buildAuthIdentity(googleUser)).toEqual({ - authProvider: "google", - authUserId: "google-sub-1", - }); - }); - - it("defaults the provider", () => { - expect(buildAuthIdentity({ id: "12345", name: null, email: null, image: null })).toEqual({ - authProvider: "github", - authUserId: "12345", - }); }); }); describe("buildAuthDisplay", () => { - it("returns display fields only — never authProvider/authUserId (forbidden under strict)", () => { - expect(buildAuthDisplay(githubUser)).toEqual({ + it("returns cosmetic fields without identity or SCM assertions", () => { + expect( + buildAuthDisplay({ + name: "Ada Lovelace", + email: "ada@example.com", + image: "https://avatars.example/ada", + }) + ).toEqual({ authEmail: "ada@example.com", authName: "Ada Lovelace", - authAvatarUrl: "https://avatars.githubusercontent.com/u/12345", - }); - expect(buildAuthDisplay(googleUser)).toEqual({ - authEmail: "pm@gmail.com", - authName: "Pat PM", - authAvatarUrl: "https://lh3.googleusercontent.com/a/pat", + authAvatarUrl: "https://avatars.example/ada", }); }); it("normalizes null fields to undefined", () => { - expect(buildAuthDisplay({ id: "12345", name: null, email: null, image: null })).toEqual({ + expect(buildAuthDisplay({ name: null, email: null, image: null })).toEqual({ authEmail: undefined, authName: undefined, authAvatarUrl: undefined, }); }); }); - -describe("buildScmAttribution", () => { - it("returns the GitHub attribution block — never credentials (forbidden under strict)", () => { - expect(buildScmAttribution(githubUser)).toEqual({ - scmLogin: "ada", - scmName: "Ada Lovelace", - scmEmail: "ada@example.com", - scmAvatarUrl: "https://avatars.githubusercontent.com/u/12345", - }); - }); - - it("returns an empty object for Google — no scm* fields at all", () => { - // The provider gate (F1/F2): a Google session must never carry scm* - // attribution, which the control plane would store as GitHub identity. - expect(buildScmAttribution(googleUser)).toEqual({}); - }); - - it("treats a missing provider as GitHub (legacy session back-compat)", () => { - expect(buildScmAttribution({ id: "12345", login: "ada" })).toMatchObject({ - scmLogin: "ada", - }); - }); -}); diff --git a/packages/web/src/lib/build-auth-identity.ts b/packages/web/src/lib/build-auth-identity.ts index 9afb85668..31a67fdda 100644 --- a/packages/web/src/lib/build-auth-identity.ts +++ b/packages/web/src/lib/build-auth-identity.ts @@ -1,120 +1,32 @@ -/** - * Single chokepoint for the auth-provider discriminator on the web side. - * - * Under identity enforcement the control plane derives WHO the caller is - * (userId, authProvider/authUserId, SCM credentials) from the authenticated - * Bearer principal and rejects those fields in identity-route bodies. What the - * web still sends over the wire is display-only: - * - * - `auth*` display block (`buildAuthDisplay`) — email/name/avatar for BOTH - * GitHub and Google. - * - `scm*` attribution block (`buildScmAttribution`) — GitHub-only - * login/name/email/avatar for git-commit attribution; a Google session - * carries no `scm*` at all. - * - * `buildAuthIdentity` normalizes the provider/id pair used in the - * provider-identity resolution path. The pair is carried in the URL only; the - * control plane authorizes it against the Bearer principal. - * - * Keeping the `provider === "github"` decision in this one module is the whole - * point of the 4B split — otherwise the branch sprawls across every route and - * GitHub-only fields can leak into a Google request. The `sessions`, - * `ws-token`, and `automations` routes build their bodies from these helpers - * and never branch on provider themselves. - */ - export type AuthProvider = "github" | "google"; -/** - * Validated narrowing for the auth-provider discriminator. Returns true only for - * a provider this app explicitly supports, so an unrecognized value fails closed - * at the boundary instead of being cast onto the union. - */ export function isAuthProvider(value: string | null | undefined): value is AuthProvider { return value === "github" || value === "google"; } -export interface AuthIdentityUser { - id?: string | null; - login?: string | null; - name?: string | null; - email?: string | null; - image?: string | null; - provider?: AuthProvider; -} - -export interface AuthIdentity { - authProvider: AuthProvider; - authUserId?: string; +export interface AuthDisplayUser { + readonly name?: string | null; + readonly email?: string | null; + readonly image?: string | null; } export interface AuthDisplay { - authEmail?: string; - authName?: string; - authAvatarUrl?: string; -} - -export interface ScmAttribution { - scmLogin?: string; - scmName?: string; - scmEmail?: string; - scmAvatarUrl?: string; -} - -/** - * Resolve the authentication provider for a session user. Legacy GitHub - * sessions were minted before `provider` existed, so a missing provider is - * treated as GitHub — the same back-compat default the control plane applies - * (`authProvider ?? "github"`). - */ -export function resolveAuthProvider(user: AuthIdentityUser | null | undefined): AuthProvider { - return user?.provider ?? "github"; -} - -/** - * Provider-scoped identity reference used to resolve the canonical user via - * `/provider-identities/:provider/:id`. The values select the URL; no request - * body is sent, and the control plane requires an exactly matching user token. - */ -export function buildAuthIdentity(user: AuthIdentityUser | null | undefined): AuthIdentity { - return { - authProvider: resolveAuthProvider(user), - authUserId: user?.id ?? undefined, - }; + readonly authEmail?: string; + readonly authName?: string; + readonly authAvatarUrl?: string; } /** - * Display-only auth block for identity-route bodies. The control plane keeps - * these body-carried by design; the identifying fields (`authProvider`, - * `authUserId`) come from the Bearer principal and must not be sent. + * Cosmetic user attributes allowed in session and automation bodies. + * + * The authenticated principal and provider/SCM provenance are control-plane + * state; this helper deliberately cannot express those authority-bearing + * fields. */ -export function buildAuthDisplay(user: AuthIdentityUser | null | undefined): AuthDisplay { +export function buildAuthDisplay(user: AuthDisplayUser | null | undefined): AuthDisplay { return { authEmail: user?.email ?? undefined, authName: user?.name ?? undefined, authAvatarUrl: user?.image ?? undefined, }; } - -/** - * GitHub-only git-commit attribution (display fields, no credentials). Returns - * an empty object for non-GitHub providers (e.g. Google) so their request - * bodies carry no `scm*` fields at all — the provider gate the F1/F2 findings - * call for, enforced here at the single source rather than at each call site. - * - * SCM credentials (`scmUserId`/`scmToken`/`scmRefreshToken`/expiry) are never - * sent: the control plane derives them from the authenticated principal's - * token store and strict enforcement rejects them in the body. - */ -export function buildScmAttribution(user: AuthIdentityUser | null | undefined): ScmAttribution { - if (resolveAuthProvider(user) !== "github") { - return {}; - } - - return { - scmLogin: user?.login ?? undefined, - scmName: user?.name ?? undefined, - scmEmail: user?.email ?? undefined, - scmAvatarUrl: user?.image ?? undefined, - }; -} diff --git a/packages/web/src/lib/client-auth-boundary-eslint.test.ts b/packages/web/src/lib/client-auth-boundary-eslint.test.ts index c69efa745..47620ea62 100644 --- a/packages/web/src/lib/client-auth-boundary-eslint.test.ts +++ b/packages/web/src/lib/client-auth-boundary-eslint.test.ts @@ -50,10 +50,10 @@ describe("client authentication boundaries", () => { ).resolves.toHaveLength(0); }); - it("allows the auth seam to own the NextAuth client integration", async () => { + it("keeps the app-owned auth seam framework-independent", async () => { await expect( boundaryMessages('import { useSession } from "next-auth/react";', authSessionPath) - ).resolves.toHaveLength(0); + ).resolves.toHaveLength(1); }); it("allows the browser request seam to own fetch", async () => { diff --git a/packages/web/src/lib/control-plane-transport.test.ts b/packages/web/src/lib/control-plane-transport.test.ts deleted file mode 100644 index 7253b5cbf..000000000 --- a/packages/web/src/lib/control-plane-transport.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("next/headers", () => ({ - headers: vi.fn(), -})); - -import { headers } from "next/headers"; -import { controlPlaneTokenFetch } from "./control-plane-transport"; - -describe("controlPlaneTokenFetch", () => { - const originalEnv = { ...process.env }; - const fetchMock = vi.fn(); - - beforeEach(() => { - vi.resetAllMocks(); - process.env = { - ...originalEnv, - CONTROL_PLANE_URL: "https://control-plane.example", - SERVICE_AUTH_SECRET: "web-service-secret", - NODE_ENV: "development", - }; - vi.mocked(headers).mockResolvedValue(new Headers({})); - vi.stubGlobal("fetch", fetchMock); - fetchMock.mockResolvedValue(Response.json({ ok: true })); - }); - - afterEach(() => { - process.env = originalEnv; - vi.unstubAllGlobals(); - }); - - it("signs with web's sig1 credential and never the legacy bearer", async () => { - await controlPlaneTokenFetch("/auth/tokens/exchange", { - method: "POST", - body: JSON.stringify({ subjectTokenType: "github-access-token", subjectToken: "t" }), - }); - - const [url, init] = fetchMock.mock.calls[0] ?? []; - const sentHeaders = new Headers(init?.headers); - expect(url).toBe("https://control-plane.example/auth/tokens/exchange"); - expect(sentHeaders.get("X-OpenInspect-Service")).toBe("web"); - expect(sentHeaders.get("X-OpenInspect-Service-Signature")).toMatch(/^sig1\./); - expect(sentHeaders.get("Authorization")).toBeNull(); - }); - - it("throws when SERVICE_AUTH_SECRET is not configured", async () => { - delete process.env.SERVICE_AUTH_SECRET; - await expect( - controlPlaneTokenFetch("/auth/tokens/refresh", { method: "POST", body: "{}" }) - ).rejects.toThrow("SERVICE_AUTH_SECRET not configured"); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("rejects service-authenticated requests outside token issuance", async () => { - await expect(controlPlaneTokenFetch("/sessions", { method: "GET" })).rejects.toThrow( - "Service authentication is restricted to token endpoints" - ); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/web/src/lib/control-plane-transport.ts b/packages/web/src/lib/control-plane-transport.ts index 26fa99ff0..8fc2eaf40 100644 --- a/packages/web/src/lib/control-plane-transport.ts +++ b/packages/web/src/lib/control-plane-transport.ts @@ -6,17 +6,14 @@ * same-account worker-to-worker fetch restrictions (error 1042). Falls back * to URL-based fetch for Vercel / local development. * - * This module sits below both `control-plane.ts` (user-credentialed - * requests) and `oi-session.ts` (token exchange/refresh), keeping the import - * graph a DAG. + * This module sits below both the browser-auth proxy and resource-request + * transport, keeping platform-specific dispatch outside their protocol logic. */ -import { buildServiceAuthHeaders } from "@open-inspect/shared"; import { createLogger } from "@/lib/logger"; -import { getCorrelationLogFields } from "@/lib/request-correlation"; -import { getRequestCorrelation } from "@/lib/request-context"; const log = createLogger("control-plane-transport"); +export const CONTROL_PLANE_FETCH_TIMEOUT_MS = 15_000; /** * Get the control plane base URL (no trailing slash) from environment. @@ -90,75 +87,21 @@ export async function dispatchControlPlaneFetch( fetchOptions: RequestInit, correlationFields: Record ): Promise { + const timeoutSignal = AbortSignal.timeout(CONTROL_PLANE_FETCH_TIMEOUT_MS); + const signal = fetchOptions.signal + ? AbortSignal.any([fetchOptions.signal, timeoutSignal]) + : timeoutSignal; + const boundedFetchOptions = { + ...fetchOptions, + signal, + }; + // On Cloudflare Workers, use the service binding to call the control plane const binding = await getServiceBinding(correlationFields); if (binding) { - return binding.fetch(url, fetchOptions); + return binding.fetch(url, boundedFetchOptions); } // Fallback: direct fetch (works on Vercel / local dev) - return fetch(url, fetchOptions); -} - -/** - * Make a control-plane request signed with web's own sig1 service - * credential — never a user token. - * - * Reserved for the token endpoints (exchange/refresh): issuance must be - * reachable only through web's per-service identity. Throws when - * SERVICE_AUTH_SECRET is not configured; callers treat that as an exchange - * failure. - */ -/** - * Token calls sit on the sign-in path and the background refresh check — an - * unresponsive control plane must fail fast into the callers' existing - * exchange_fallback/request_failed paths, not hang until the platform's own - * timeout. - */ -const SERVICE_FETCH_TIMEOUT_MS = 10_000; - -export async function controlPlaneTokenFetch( - path: string, - init: { method: string; body?: string } -): Promise { - if ( - init.method !== "POST" || - (path !== "/auth/tokens/exchange" && path !== "/auth/tokens/refresh") - ) { - throw new Error("Service authentication is restricted to token endpoints"); - } - const secret = process.env.SERVICE_AUTH_SECRET; - if (!secret) { - throw new Error("SERVICE_AUTH_SECRET not configured"); - } - - const normalizedPath = path.startsWith("/") ? path : `/${path}`; - const correlation = await getRequestCorrelation(); - const correlationFields = getCorrelationLogFields(correlation); - // The signature covers method, path, query, and body hash — not the host — - // so signing the URL-based form stays valid across the service binding. - const url = `${getControlPlaneUrl()}${normalizedPath}`; - - const headers = { - "Content-Type": "application/json", - ...(await buildServiceAuthHeaders({ - service: "web", - secret, - method: init.method, - url, - body: init.body, - traceId: correlation.traceId, - })), - }; - - return dispatchControlPlaneFetch( - url, - { - method: init.method, - headers, - body: init.body, - signal: AbortSignal.timeout(SERVICE_FETCH_TIMEOUT_MS), - }, - correlationFields - ); + return fetch(url, boundedFetchOptions); } diff --git a/packages/web/src/lib/control-plane.test.ts b/packages/web/src/lib/control-plane.test.ts index 0b99d2383..93d1eb7c8 100644 --- a/packages/web/src/lib/control-plane.test.ts +++ b/packages/web/src/lib/control-plane.test.ts @@ -1,3 +1,4 @@ +import { sha256Hex, verifyServiceSignature } from "@open-inspect/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("next/headers", () => ({ @@ -5,15 +6,10 @@ vi.mock("next/headers", () => ({ cookies: vi.fn(), })); -vi.mock("next-auth/jwt", () => ({ - getToken: vi.fn(), -})); - -import { headers, cookies } from "next/headers"; -import { getToken } from "next-auth/jwt"; +import { cookies, headers } from "next/headers"; import { controlPlaneUserFetch } from "./control-plane"; -describe("controlPlaneUserFetch correlation", () => { +describe("controlPlaneUserFetch", () => { const originalEnv = { ...process.env }; const fetchMock = vi.fn(); @@ -27,12 +23,19 @@ describe("controlPlaneUserFetch correlation", () => { }; vi.stubGlobal("fetch", fetchMock); fetchMock.mockResolvedValue(Response.json({ ok: true })); + vi.mocked(headers).mockResolvedValue( + new Headers({ + "x-trace-id": "trace-123", + "x-request-id": "client-hop-1", + "x-open-inspect-request-id": "webhop01", + }) + ); vi.mocked(cookies).mockResolvedValue({ - getAll: () => [{ name: "next-auth.session-token", value: "cookie-value" }], - } as never); - vi.mocked(getToken).mockResolvedValue({ - oiAccessToken: "oi_at_live_token", - oiAccessTokenExpiresAt: Date.now() + 60 * 60 * 1000, + getAll: () => [ + { name: "__Secure-openinspect.session_token", value: "session.signature" }, + { name: "__Secure-openinspect.state", value: "oauth-state" }, + { name: "unrelated", value: "do-not-forward" }, + ], } as never); }); @@ -41,51 +44,72 @@ describe("controlPlaneUserFetch correlation", () => { vi.unstubAllGlobals(); }); - it("propagates the current request trace id downstream", async () => { - vi.mocked(headers).mockResolvedValue( - new Headers({ - "x-trace-id": "trace-123", - "x-request-id": "client-hop-1", - "x-open-inspect-request-id": "webhop01", - }) - ); - - await controlPlaneUserFetch("/sessions", { + it("combines the browser session with a fresh web signature", async () => { + const body = JSON.stringify({ ok: true }); + await controlPlaneUserFetch("/sessions?archived=false", { method: "POST", - headers: { Range: "bytes=0-5" }, - body: JSON.stringify({ ok: true }), + headers: { + Authorization: "Bearer caller-controlled", + Cookie: "caller=controlled", + Range: "bytes=0-5", + "X-OpenInspect-Service": "modal", + "X-OpenInspect-Service-Signature": "caller-controlled", + }, + body, }); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0] ?? []; - const forwardedHeaders = new Headers(init?.headers); - - expect(url).toBe("https://control-plane.example/sessions"); - expect(forwardedHeaders.get("x-trace-id")).toBe("trace-123"); - expect(forwardedHeaders.get("x-request-id")).toBeNull(); - expect(forwardedHeaders.get("Range")).toBe("bytes=0-5"); - expect(forwardedHeaders.get("Authorization")).toBe("Bearer oi_at_live_token"); - expect(forwardedHeaders.get("X-OpenInspect-Service")).toBeNull(); + const sentHeaders = new Headers(init?.headers); + + expect(url).toBe("https://control-plane.example/sessions?archived=false"); + expect(sentHeaders.get("Cookie")).toBe("__Secure-openinspect.session_token=session.signature"); + expect(sentHeaders.get("Authorization")).toBeNull(); + expect(sentHeaders.get("Range")).toBe("bytes=0-5"); + expect(sentHeaders.get("x-trace-id")).toBe("trace-123"); + expect(sentHeaders.get("x-request-id")).toBeNull(); + expect(sentHeaders.get("X-OpenInspect-Service")).toBe("web"); + expect(sentHeaders.get("X-OpenInspect-Service-Signature")).toMatch(/^sig1\./); + + const verification = await verifyServiceSignature({ + signatureHeader: sentHeaders.get("X-OpenInspect-Service-Signature") ?? "", + service: "web", + secret: "web-sig1-secret", + method: "POST", + url: String(url), + bodySha256Hex: await sha256Hex(body), + actor: "", + }); + expect(verification.ok).toBe(true); }); - it("merges tuple and Headers option headers without dropping values", async () => { - vi.mocked(headers).mockResolvedValue( - new Headers({ - "x-trace-id": "trace-123", - "x-open-inspect-request-id": "webhop01", - }) - ); - + it("merges Headers options without dropping caller values", async () => { await controlPlaneUserFetch("/sessions", { headers: new Headers({ Accept: "application/json" }), }); const [, init] = fetchMock.mock.calls[0] ?? []; - const forwardedHeaders = new Headers(init?.headers); + const sentHeaders = new Headers(init?.headers); - expect(forwardedHeaders.get("Accept")).toBe("application/json"); - expect(forwardedHeaders.get("Content-Type")).toBe("application/json"); - expect(forwardedHeaders.get("x-trace-id")).toBe("trace-123"); + expect(sentHeaders.get("Accept")).toBe("application/json"); + expect(sentHeaders.get("Content-Type")).toBe("application/json"); + expect(sentHeaders.get("x-trace-id")).toBe("trace-123"); + }); + + it("preserves caller cancellation while enforcing the transport timeout", async () => { + const caller = new AbortController(); + + await controlPlaneUserFetch("/sessions", { signal: caller.signal }); + + const [, init] = fetchMock.mock.calls[0] ?? []; + expect(init?.signal).toBeInstanceOf(AbortSignal); + expect(init?.signal).not.toBe(caller.signal); + expect(init?.signal?.aborted).toBe(false); + + caller.abort("caller disconnected"); + + expect(init?.signal?.aborted).toBe(true); + expect(init?.signal?.reason).toBe("caller disconnected"); }); it("generates a fresh trace id when the inbound one is invalid", async () => { @@ -101,54 +125,13 @@ describe("controlPlaneUserFetch correlation", () => { const [, init] = fetchMock.mock.calls[0] ?? []; const traceId = new Headers(init?.headers).get("x-trace-id"); - expect(traceId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + expect(traceId).toMatch(/^[0-9a-f-]{36}$/i); expect(traceId).not.toBe("not a valid trace id"); }); - it("attaches the web session token as the Bearer credential when live", async () => { - vi.mocked(headers).mockResolvedValue(new Headers({})); + it("returns 401 without dispatching when the browser session cookie is absent", async () => { vi.mocked(cookies).mockResolvedValue({ - getAll: () => [{ name: "next-auth.session-token", value: "cookie-value" }], - } as never); - vi.mocked(getToken).mockResolvedValue({ - oiAccessToken: "oi_at_live_token", - oiAccessTokenExpiresAt: Date.now() + 60 * 60 * 1000, - } as never); - - await controlPlaneUserFetch("/sessions"); - - const [, init] = fetchMock.mock.calls[0] ?? []; - const forwardedHeaders = new Headers(init?.headers); - expect(forwardedHeaders.get("Authorization")).toBe("Bearer oi_at_live_token"); - }); - - it("never lets a caller-supplied Authorization header override the credential", async () => { - vi.mocked(headers).mockResolvedValue(new Headers({})); - vi.mocked(cookies).mockResolvedValue({ - getAll: () => [{ name: "next-auth.session-token", value: "cookie-value" }], - } as never); - vi.mocked(getToken).mockResolvedValue({ - oiAccessToken: "oi_at_live_token", - oiAccessTokenExpiresAt: Date.now() + 60 * 60 * 1000, - } as never); - - await controlPlaneUserFetch("/sessions", { - headers: { Authorization: "Bearer caller-supplied" }, - }); - - const [, init] = fetchMock.mock.calls[0] ?? []; - const forwardedHeaders = new Headers(init?.headers); - expect(forwardedHeaders.get("Authorization")).toBe("Bearer oi_at_live_token"); - }); - - it("returns 401 without dispatching when the web session token is expired", async () => { - vi.mocked(headers).mockResolvedValue(new Headers({})); - vi.mocked(cookies).mockResolvedValue({ - getAll: () => [{ name: "next-auth.session-token", value: "cookie-value" }], - } as never); - vi.mocked(getToken).mockResolvedValue({ - oiAccessToken: "oi_at_expired", - oiAccessTokenExpiresAt: Date.now() - 1000, + getAll: () => [{ name: "__Secure-openinspect.state", value: "oauth-state" }], } as never); const response = await controlPlaneUserFetch("/sessions"); @@ -158,44 +141,47 @@ describe("controlPlaneUserFetch correlation", () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it("does not require the web service credential for user-facing calls", async () => { + it("fails closed when the web signing secret is unavailable", async () => { delete process.env.SERVICE_AUTH_SECRET; - vi.mocked(headers).mockResolvedValue(new Headers({})); - const response = await controlPlaneUserFetch("/sessions"); - - expect(response.status).toBe(200); - const [, init] = fetchMock.mock.calls[0] ?? []; - expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer oi_at_live_token"); - }); - - it("never falls back to web's sig1 service credential when no web session token is live", async () => { - process.env.SERVICE_AUTH_SECRET = "web-sig1-secret"; - vi.mocked(headers).mockResolvedValue(new Headers({})); - vi.mocked(cookies).mockResolvedValue({ getAll: () => [] } as never); - vi.mocked(getToken).mockResolvedValue(null); - - const body = JSON.stringify({ title: "t" }); - const response = await controlPlaneUserFetch("/sessions/abc/title", { method: "POST", body }); - - expect(response.status).toBe(401); + await expect(controlPlaneUserFetch("/sessions")).rejects.toThrow( + "SERVICE_AUTH_SECRET not configured" + ); expect(fetchMock).not.toHaveBeenCalled(); }); - it("forwards the exact bytes of a buffered binary body and keeps the caller Content-Type", async () => { - vi.mocked(headers).mockResolvedValue(new Headers({})); - - const body = new TextEncoder().encode("--boundary\r\nfake multipart\r\n--boundary--").buffer; + it("forwards the exact bytes of a buffered binary body", async () => { + const body = new TextEncoder().encode("--boundary\r\nbinary\u0000body\r\n--boundary--").buffer; await controlPlaneUserFetch("/sessions/abc/attachments", { method: "POST", body, headers: { "Content-Type": "multipart/form-data; boundary=boundary" }, }); - const [, init] = fetchMock.mock.calls[0] ?? []; - const forwardedHeaders = new Headers(init?.headers); - expect(forwardedHeaders.get("Content-Type")).toBe("multipart/form-data; boundary=boundary"); - expect(forwardedHeaders.get("Authorization")).toBe("Bearer oi_at_live_token"); + const [url, init] = fetchMock.mock.calls[0] ?? []; + const sentHeaders = new Headers(init?.headers); + expect(sentHeaders.get("Content-Type")).toBe("multipart/form-data; boundary=boundary"); expect(init?.body).toBe(body); + + const verification = await verifyServiceSignature({ + signatureHeader: sentHeaders.get("X-OpenInspect-Service-Signature") ?? "", + service: "web", + secret: "web-sig1-secret", + method: "POST", + url: String(url), + bodySha256Hex: await sha256Hex(body), + actor: "", + }); + expect(verification.ok).toBe(true); + }); + + it("rejects body types whose exact dispatched bytes cannot be signed", async () => { + await expect( + controlPlaneUserFetch("/sessions", { + method: "POST", + body: new URLSearchParams({ title: "hello" }), + }) + ).rejects.toThrow("Unsupported control-plane request body"); + expect(fetchMock).not.toHaveBeenCalled(); }); }); diff --git a/packages/web/src/lib/control-plane.ts b/packages/web/src/lib/control-plane.ts index ed0f32990..46d7dcd13 100644 --- a/packages/web/src/lib/control-plane.ts +++ b/packages/web/src/lib/control-plane.ts @@ -5,54 +5,114 @@ * vs. URL-based fetch) to `control-plane-transport.ts`. */ +import { buildServiceAuthHeaders } from "@open-inspect/shared"; +import { cookies } from "next/headers"; +import { serializeBrowserSessionCookies } from "@/lib/browser-session-cookie"; import { dispatchControlPlaneFetch, getControlPlaneUrl } from "@/lib/control-plane-transport"; import { createLogger } from "@/lib/logger"; -import { getOiAccessTokenFromCookies } from "@/lib/oi-session"; import { getCorrelationLogFields } from "@/lib/request-correlation"; import { getRequestCorrelation } from "@/lib/request-context"; const log = createLogger("control-plane-client"); /** - * Create authenticated headers for a control plane request. + * Return the exact body representation accepted by sig1. * - * Returns the signed-in user's web session token (`Authorization: Bearer - * oi_at_…`), which resolves to a verified user principal at the control - * plane. User-facing calls never fall back to web's broad service credential: - * without a live token the caller receives a local 401 and must reauthenticate. + * Fetch can serialize several higher-level BodyInit variants. Reject those + * here because signing a value before Fetch serializes it would authenticate + * different bytes than the control plane receives. + */ +function getSignableBody( + body: BodyInit | null | undefined +): ArrayBuffer | Uint8Array | string | undefined { + if (body === undefined || body === null) return undefined; + if (typeof body === "string" || body instanceof ArrayBuffer || body instanceof Uint8Array) { + return body; + } + throw new Error("Unsupported control-plane request body"); +} + +/** + * Combine web's channel identity with the opaque browser-session credential. + * Both are required by browser-authenticated control-plane routes. */ async function getControlPlaneHeaders(request: { method: string; url: string; traceId: string; -}): Promise { - const oiAccessToken = await getOiAccessTokenFromCookies(); - if (oiAccessToken) { - return { - "Content-Type": "application/json", - Authorization: `Bearer ${oiAccessToken}`, - "x-trace-id": request.traceId, - }; + body: ArrayBuffer | Uint8Array | string | undefined; +}): Promise { + const cookieHeader = serializeBrowserSessionCookies((await cookies()).getAll()); + if (!cookieHeader) { + log.warn("auth.user_session_missing", { + event: "auth.user_session_missing", + http_path: new URL(request.url).pathname, + http_method: request.method, + trace_id: request.traceId, + }); + return null; } - log.warn("auth.user_session_missing", { - event: "auth.user_session_missing", - http_path: new URL(request.url).pathname, - http_method: request.method, - trace_id: request.traceId, + + const secret = process.env.SERVICE_AUTH_SECRET; + if (!secret) { + throw new Error("SERVICE_AUTH_SECRET not configured"); + } + + const headers = new Headers({ + "Content-Type": "application/json", + Cookie: cookieHeader, + }); + const serviceHeaders = await buildServiceAuthHeaders({ + service: "web", + secret, + method: request.method, + url: request.url, + body: request.body, + traceId: request.traceId, }); - return null; + for (const [name, value] of Object.entries(serviceHeaders)) { + headers.set(name, value); + } + return headers; +} + +function unauthorizedResponse(correlation: { requestId: string; traceId: string }): Response { + return Response.json( + { error: "Unauthorized" }, + { + status: 401, + headers: { + "x-request-id": correlation.requestId, + "x-trace-id": correlation.traceId, + }, + } + ); +} + +function mergeAuthenticatedHeaders( + callerHeaders: HeadersInit | undefined, + authenticatedHeaders: Headers +): Headers { + const headers = new Headers(callerHeaders); + headers.delete("Authorization"); + headers.delete("Cookie"); + headers.delete("X-OpenInspect-Actor"); + headers.delete("X-OpenInspect-Service"); + headers.delete("X-OpenInspect-Service-Signature"); + + const callerContentType = headers.get("Content-Type"); + authenticatedHeaders.forEach((value, name) => headers.set(name, value)); + if (callerContentType !== null) { + headers.set("Content-Type", callerContentType); + } + return headers; } /** - * Make a user-authenticated request to the control plane. - * - * The credential is applied after caller-supplied headers, so an - * `Authorization` header in `options` can never override the identity - * attached here. + * Make a browser-session-authenticated request to the control plane. * - * @param path - API path (e.g., "/sessions") - * @param options - Fetch options (method, body, etc.) - * @returns Fetch Response + * Every request carries both a fresh `service:web` signature and the opaque + * Better Auth session cookie. Caller-supplied identity headers are discarded. */ export async function controlPlaneUserFetch( path: string, @@ -64,44 +124,24 @@ export async function controlPlaneUserFetch( try { const url = `${getControlPlaneUrl()}${normalizedPath}`; - const credentialHeaderValues = await getControlPlaneHeaders({ - method: options.method ?? "GET", + const method = options.method ?? "GET"; + const body = getSignableBody(options.body); + const authenticatedHeaders = await getControlPlaneHeaders({ + method, url, traceId: correlation.traceId, + body, }); - if (!credentialHeaderValues) { - return Response.json( - { error: "Unauthorized" }, - { - status: 401, - headers: { - "x-request-id": correlation.requestId, - "x-trace-id": correlation.traceId, - }, - } - ); - } - const credentialHeaders = new Headers(credentialHeaderValues); - - // Caller headers first, credential headers on top: the credential wins - // over any caller-supplied Authorization or signature header. Content-Type - // is the one caller-overridable credential header — it defaults to JSON - // and is not signature-covered (e.g. buffered multipart uploads). - const mergedHeaders = new Headers(options.headers); - const callerContentType = mergedHeaders.get("Content-Type"); - credentialHeaders.forEach((value, key) => { - mergedHeaders.set(key, value); - }); - if (callerContentType !== null) { - mergedHeaders.set("Content-Type", callerContentType); - } - - const fetchOptions: RequestInit = { - ...options, - headers: mergedHeaders, - }; + if (!authenticatedHeaders) return unauthorizedResponse(correlation); - return await dispatchControlPlaneFetch(url, fetchOptions, correlationFields); + return await dispatchControlPlaneFetch( + url, + { + ...options, + headers: mergeAuthenticatedHeaders(options.headers, authenticatedHeaders), + }, + correlationFields + ); } catch (error) { log.error("control_plane.fetch_failed", { ...correlationFields, diff --git a/packages/web/src/lib/current-user.test.ts b/packages/web/src/lib/current-user.test.ts deleted file mode 100644 index 3a3245eb1..000000000 --- a/packages/web/src/lib/current-user.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/control-plane", () => ({ - controlPlaneUserFetch: vi.fn(), -})); - -import { controlPlaneUserFetch } from "@/lib/control-plane"; -import { clearCurrentUserIdCacheForTests, resolveCurrentUserId } from "./current-user"; - -describe("resolveCurrentUserId — provider-scoped cache", () => { - beforeEach(() => { - vi.resetAllMocks(); - clearCurrentUserIdCacheForTests(); - }); - - it("does not alias a GitHub id and a numerically identical Google sub", async () => { - const githubUserId = "0123456789abcdef0123456789abcdef"; - const googleUserId = "fedcba9876543210fedcba9876543210"; - vi.mocked(controlPlaneUserFetch) - .mockResolvedValueOnce(Response.json({ userId: githubUserId })) - .mockResolvedValueOnce(Response.json({ userId: googleUserId })); - - // Same numeric id ("123") under two providers must resolve independently. - const gh = await resolveCurrentUserId({ id: "123", provider: "github", login: "ada" }); - const google = await resolveCurrentUserId({ - id: "123", - provider: "google", - email: "pm@gmail.com", - }); - - expect(gh).toEqual({ ok: true, userId: githubUserId }); - expect(google).toEqual({ ok: true, userId: googleUserId }); - expect(controlPlaneUserFetch).toHaveBeenNthCalledWith(1, "/provider-identities/github/123", { - method: "PUT", - }); - expect(controlPlaneUserFetch).toHaveBeenNthCalledWith(2, "/provider-identities/google/123", { - method: "PUT", - }); - - // A second GitHub resolution must come from the GitHub-scoped cache entry, - // not the Google one, and without a third control-plane call. - const githubAgain = await resolveCurrentUserId({ id: "123", provider: "github", login: "ada" }); - expect(githubAgain).toEqual({ ok: true, userId: githubUserId }); - expect(controlPlaneUserFetch).toHaveBeenCalledTimes(2); - }); - - it("rejects malformed current-user responses from the control plane", async () => { - vi.mocked(controlPlaneUserFetch).mockResolvedValueOnce( - Response.json({ userId: "not-canonical" }) - ); - - await expect(resolveCurrentUserId({ id: "123", provider: "github" })).resolves.toEqual({ - ok: false, - status: 502, - body: { error: "Invalid current user response" }, - }); - }); - - it("rejects partial current-user responses without a userId", async () => { - vi.mocked(controlPlaneUserFetch).mockResolvedValueOnce(Response.json({})); - - await expect(resolveCurrentUserId({ id: "123", provider: "github" })).resolves.toEqual({ - ok: false, - status: 502, - body: { error: "Invalid current user response" }, - }); - }); -}); diff --git a/packages/web/src/lib/current-user.ts b/packages/web/src/lib/current-user.ts deleted file mode 100644 index f6e50e610..000000000 --- a/packages/web/src/lib/current-user.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { isCanonicalUserId } from "@open-inspect/shared"; -import { - buildAuthIdentity, - type AuthIdentity, - type AuthIdentityUser, -} from "@/lib/build-auth-identity"; -import { controlPlaneUserFetch } from "@/lib/control-plane"; - -export type CurrentUserIdentityInput = AuthIdentityUser; - -type ResolveCurrentUserResult = - | { - ok: true; - userId: string; - } - | { - ok: false; - status: number; - body: unknown; - }; - -const CURRENT_USER_ID_CACHE_TTL_MS = 5 * 60 * 1000; -const currentUserIdCache = new Map(); -const pendingCurrentUserIdResolutions = new Map>(); - -function getResponseUserId(data: unknown): unknown { - return data && typeof data === "object" ? (data as { userId?: unknown }).userId : undefined; -} - -export function clearCurrentUserIdCacheForTests() { - currentUserIdCache.clear(); - pendingCurrentUserIdResolutions.clear(); -} - -export async function resolveCurrentUserId( - user: CurrentUserIdentityInput | null | undefined -): Promise { - const identity = buildAuthIdentity(user); - const authUserId = identity.authUserId; - if (!authUserId) { - return { - ok: false, - status: 409, - body: { error: "User id unavailable" }, - }; - } - - // Resolution is provider-scoped (the route path carries the provider), so the - // cache must be too — the same id under two providers must never alias. - const cacheKey = `${identity.authProvider}:${authUserId}`; - const cached = currentUserIdCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return { - ok: true, - userId: cached.userId, - }; - } - - const pending = pendingCurrentUserIdResolutions.get(cacheKey); - if (pending) { - return pending; - } - - const resolution = resolveCurrentUserIdUncached(identity, authUserId, cacheKey).finally(() => { - pendingCurrentUserIdResolutions.delete(cacheKey); - }); - pendingCurrentUserIdResolutions.set(cacheKey, resolution); - return resolution; -} - -async function resolveCurrentUserIdUncached( - identity: AuthIdentity, - authUserId: string, - cacheKey: string -): Promise { - const response = await controlPlaneUserFetch( - `/provider-identities/${identity.authProvider}/${encodeURIComponent(authUserId)}`, - { method: "PUT" } - ); - - const data = await response.json(); - if (!response.ok) { - return { - ok: false, - status: response.status, - body: data, - }; - } - - const userId = getResponseUserId(data); - if (!isCanonicalUserId(userId)) { - return { - ok: false, - status: 502, - body: { error: "Invalid current user response" }, - }; - } - - currentUserIdCache.set(cacheKey, { - userId, - expiresAt: Date.now() + CURRENT_USER_ID_CACHE_TTL_MS, - }); - - return { - ok: true, - userId, - }; -} diff --git a/packages/web/src/lib/github-email-schema.test.ts b/packages/web/src/lib/github-email-schema.test.ts deleted file mode 100644 index f0e2079be..000000000 --- a/packages/web/src/lib/github-email-schema.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { githubEmailListSchema } from "./github-email-schema"; - -describe("githubEmailListSchema", () => { - it("parses valid GitHub email API responses", () => { - const result = githubEmailListSchema.safeParse([ - { email: "user@example.com", primary: true, verified: true, visibility: "private" }, - ]); - - expect(result.success).toBe(true); - }); - - it("accepts nullable visibility from GitHub", () => { - const result = githubEmailListSchema.safeParse([ - { email: "user@example.com", primary: true, verified: true, visibility: null }, - ]); - - expect(result.success).toBe(true); - }); - - it("rejects malformed or partial email responses", () => { - expect(githubEmailListSchema.safeParse({ email: "user@example.com" }).success).toBe(false); - expect( - githubEmailListSchema.safeParse([ - { email: "user@example.com", primary: true, verified: true }, - ]).success - ).toBe(false); - expect( - githubEmailListSchema.safeParse([ - { email: "user@example.com", primary: true, verified: "yes", visibility: null }, - ]).success - ).toBe(false); - }); -}); diff --git a/packages/web/src/lib/github-email-schema.ts b/packages/web/src/lib/github-email-schema.ts deleted file mode 100644 index 585406e27..000000000 --- a/packages/web/src/lib/github-email-schema.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { z } from "zod"; - -export const githubEmailSchema = z.object({ - email: z.string(), - primary: z.boolean(), - verified: z.boolean(), - visibility: z.string().nullable(), -}); - -export const githubEmailListSchema = z.array(githubEmailSchema); - -export type GitHubEmail = z.infer; diff --git a/packages/web/src/lib/github-org-membership.test.ts b/packages/web/src/lib/github-org-membership.test.ts deleted file mode 100644 index d784684a9..000000000 --- a/packages/web/src/lib/github-org-membership.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { afterEach, describe, it, expect, vi } from "vitest"; -import { checkGitHubOrganizationAccess } from "./github-org-membership"; - -afterEach(() => { - vi.restoreAllMocks(); - vi.useRealTimers(); -}); - -describe("checkGitHubOrganizationAccess", () => { - it("returns true when any configured organization membership is active", async () => { - vi.spyOn(console, "info").mockImplementation(() => {}); - const fetchImpl = vi - .fn() - .mockResolvedValueOnce(new Response(JSON.stringify({ state: "pending" }))) - .mockResolvedValueOnce( - new Response(JSON.stringify({ state: "active" })) - ) as unknown as typeof fetch; - - await expect( - checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["pending-org", "active-org"], - fetchImpl, - userAgent: "Test App", - }) - ).resolves.toEqual({ allowed: true, reason: "active_membership", organization: "active-org" }); - - expect(fetchImpl).toHaveBeenCalledWith( - "https://api.github.com/user/memberships/orgs/active-org", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer token", - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "Test App", - }) as HeadersInit, - }) - ); - }); - - it("returns early after the first active membership", async () => { - const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ state: "active" }))); - - await expect( - checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["active-org", "other-org"], - fetchImpl: fetchImpl as unknown as typeof fetch, - }) - ).resolves.toEqual({ allowed: true, reason: "active_membership", organization: "active-org" }); - - expect(fetchImpl).toHaveBeenCalledTimes(1); - }); - - it("returns false for pending membership", async () => { - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ state: "pending" }))); - - await expect( - checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["acme"], - fetchImpl: fetchImpl as unknown as typeof fetch, - }) - ).resolves.toEqual({ allowed: false, reason: "not_member" }); - - expect(info).toHaveBeenCalledWith( - "[github-org-access] membership not active", - expect.objectContaining({ - org: "acme", - state: "pending", - elapsedMs: expect.any(Number), - }) - ); - }); - - it("returns not_member for denied GitHub responses", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn( - async () => - new Response("Not Found", { - status: 404, - headers: { - "x-github-request-id": "github-request-id", - "x-ratelimit-limit": "60", - "x-ratelimit-remaining": "59", - "x-ratelimit-reset": "1710000000", - }, - }) - ); - - await expect( - checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["acme"], - fetchImpl: fetchImpl as unknown as typeof fetch, - }) - ).resolves.toEqual({ allowed: false, reason: "not_member" }); - - expect(warn).toHaveBeenCalledWith( - "[github-org-access] membership request failed", - expect.objectContaining({ - org: "acme", - status: 404, - requestId: "github-request-id", - rateLimitLimit: "60", - rateLimitRemaining: "59", - rateLimitReset: "1710000000", - elapsedMs: expect.any(Number), - hint: expect.any(String), - }) - ); - }); - - it("returns unavailable for operational GitHub responses", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn( - async () => - new Response("rate limited", { - status: 429, - headers: { - "x-github-request-id": "github-request-id", - "x-ratelimit-remaining": "0", - "retry-after": "30", - }, - }) - ); - - await expect( - checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["acme"], - fetchImpl: fetchImpl as unknown as typeof fetch, - }) - ).resolves.toEqual({ allowed: false, reason: "unavailable" }); - - expect(warn).toHaveBeenCalledWith( - "[github-org-access] membership request failed", - expect.objectContaining({ - org: "acme", - status: 429, - requestId: "github-request-id", - rateLimitRemaining: "0", - retryAfter: "30", - elapsedMs: expect.any(Number), - }) - ); - }); - - it("returns false without an access token or org allowlist", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - - await expect( - checkGitHubOrganizationAccess({ accessToken: undefined, allowedOrganizations: ["acme"] }) - ).resolves.toEqual({ allowed: false, reason: "unavailable" }); - - await expect( - checkGitHubOrganizationAccess({ accessToken: "token", allowedOrganizations: [] }) - ).resolves.toEqual({ allowed: false, reason: "not_member" }); - - expect(warn).toHaveBeenCalledWith("[github-org-access] membership check skipped", { - reason: "missing_access_token", - organizationCount: 1, - }); - }); - - it("URL-encodes organization names", async () => { - const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ state: "active" }))); - - await checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["acme labs"], - fetchImpl: fetchImpl as unknown as typeof fetch, - }); - - expect(fetchImpl).toHaveBeenCalledWith( - "https://api.github.com/user/memberships/orgs/acme%20labs", - expect.any(Object) - ); - }); - - it("flags a missing membership state as unusable", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ state: null }))); - - await expect( - checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["acme"], - fetchImpl: fetchImpl as unknown as typeof fetch, - }) - ).resolves.toEqual({ allowed: false, reason: "unavailable" }); - - expect(warn).toHaveBeenCalledWith( - "[github-org-access] membership response unusable state", - expect.objectContaining({ - org: "acme", - state: null, - elapsedMs: expect.any(Number), - }) - ); - }); - - it("flags a non-object membership response as unusable", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn(async () => new Response(JSON.stringify("active"))); - - await expect( - checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["acme"], - fetchImpl: fetchImpl as unknown as typeof fetch, - }) - ).resolves.toEqual({ allowed: false, reason: "unavailable" }); - - expect(warn).toHaveBeenCalledWith( - "[github-org-access] membership response unusable state", - expect.objectContaining({ - org: "acme", - state: null, - elapsedMs: expect.any(Number), - }) - ); - }); - - it("flags an unexpected membership state as unusable", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ state: "unknown" }))); - - await expect( - checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["acme"], - fetchImpl: fetchImpl as unknown as typeof fetch, - }) - ).resolves.toEqual({ allowed: false, reason: "unavailable" }); - - expect(warn).toHaveBeenCalledWith( - "[github-org-access] membership response unusable state", - expect.objectContaining({ - org: "acme", - state: "unknown", - elapsedMs: expect.any(Number), - }) - ); - }); - - it("returns unavailable for malformed membership responses", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn(async () => new Response("not-json")); - - await expect( - checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["acme"], - fetchImpl: fetchImpl as unknown as typeof fetch, - }) - ).resolves.toEqual({ allowed: false, reason: "unavailable" }); - - expect(warn).toHaveBeenCalledWith( - "[github-org-access] membership request error", - expect.objectContaining({ - org: "acme", - error: expect.any(String), - message: expect.any(String), - elapsedMs: expect.any(Number), - }) - ); - }); - - it("aborts timed out membership requests", async () => { - vi.useFakeTimers(); - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - const fetchImpl = vi.fn( - (_url, init) => - new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => { - reject(new DOMException("Aborted", "AbortError")); - }); - }) - ) as unknown as typeof fetch; - - const result = checkGitHubOrganizationAccess({ - accessToken: "token", - allowedOrganizations: ["acme"], - fetchImpl, - timeoutMs: 50, - }); - - await vi.advanceTimersByTimeAsync(50); - await expect(result).resolves.toEqual({ allowed: false, reason: "unavailable" }); - expect(warn).toHaveBeenCalledWith( - "[github-org-access] membership request error", - expect.objectContaining({ - org: "acme", - error: "AbortError", - message: "Aborted", - elapsedMs: expect.any(Number), - }) - ); - }); -}); diff --git a/packages/web/src/lib/github-org-membership.ts b/packages/web/src/lib/github-org-membership.ts deleted file mode 100644 index 147d6f395..000000000 --- a/packages/web/src/lib/github-org-membership.ts +++ /dev/null @@ -1,172 +0,0 @@ -export interface GitHubOrganizationAccessParams { - accessToken?: string; - allowedOrganizations: string[]; - fetchImpl?: typeof fetch; - userAgent?: string; - timeoutMs?: number; -} - -export const GITHUB_MEMBERSHIP_CHECK_TIMEOUT_MS = 10_000; - -export type GitHubOrganizationAccessResult = - | { - allowed: true; - reason: "active_membership"; - organization: string; - } - | { - allowed: false; - reason: "not_member" | "unavailable"; - }; - -function getMembershipState(data: unknown): unknown { - return data && typeof data === "object" ? (data as { state?: unknown }).state : undefined; -} - -/** - * Check whether a GitHub user access token belongs to at least one allowed - * organization. This is the sole, asynchronous source of truth for org-based - * access — the synchronous allowlist policy in access-control.ts deliberately - * does not evaluate org membership. - * - * Fails closed: any outcome other than a confirmed active membership denies. The - * result distinguishes a definitive non-membership (`not_member`) from an - * operational failure (`unavailable` — missing/blocked token, rate limit, GitHub - * outage, or an unreadable membership response) so the caller can log why a - * sign-in was rejected. - */ -export async function checkGitHubOrganizationAccess({ - accessToken, - allowedOrganizations, - fetchImpl = fetch, - userAgent = "Open-Inspect", - timeoutMs = GITHUB_MEMBERSHIP_CHECK_TIMEOUT_MS, -}: GitHubOrganizationAccessParams): Promise { - if (allowedOrganizations.length === 0) { - return { allowed: false, reason: "not_member" }; - } - - if (!accessToken) { - console.warn("[github-org-access] membership check skipped", { - reason: "missing_access_token", - organizationCount: allowedOrganizations.length, - }); - return { allowed: false, reason: "unavailable" }; - } - - let isUnavailable = false; - - for (const org of allowedOrganizations) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - const startedAt = performance.now(); - - try { - const response = await fetchImpl( - `https://api.github.com/user/memberships/orgs/${encodeURIComponent(org)}`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": userAgent, - }, - signal: controller.signal, - } - ); - - if (!response.ok) { - console.warn("[github-org-access] membership request failed", { - org, - status: response.status, - ...getGitHubResponseDiagnostics(response, startedAt), - hint: getGitHubMembershipFailureHint(response.status), - }); - if (isGitHubMembershipUnavailableStatus(response.status)) { - isUnavailable = true; - } - continue; - } - - const state = getMembershipState(await response.json()); - if (state === "active") { - return { allowed: true, reason: "active_membership", organization: org }; - } - - if (state === "pending") { - // Expected non-active state (invited, not yet joined): deny, but this is - // not an outage, so leave isUnavailable untouched (reads as not_member). - console.info("[github-org-access] membership not active", { - org, - state, - ...getGitHubResponseDiagnostics(response, startedAt), - }); - } else { - // Missing or unrecognized state — fail closed AND flag unavailable, since - // an unusable response can't prove non-membership. `state` in the payload - // still distinguishes a null state from an unexpected literal. - isUnavailable = true; - console.warn("[github-org-access] membership response unusable state", { - org, - state: state ?? null, - ...getGitHubResponseDiagnostics(response, startedAt), - }); - } - } catch (error) { - isUnavailable = true; - console.warn("[github-org-access] membership request error", { - org, - error: error instanceof Error ? error.name : "unknown", - message: error instanceof Error ? error.message : String(error), - elapsedMs: getElapsedMs(startedAt), - }); - } finally { - clearTimeout(timeout); - } - } - - return { allowed: false, reason: isUnavailable ? "unavailable" : "not_member" }; -} - -function getGitHubMembershipFailureHint(status: number): string | undefined { - if (status === 401) { - return "GitHub rejected the OAuth token while checking organization membership."; - } - - if (status === 403) { - return "Verify the GitHub OAuth token has read:org access and any organization SAML requirements are satisfied. If this deployment also uses a GitHub App, make sure membership read permission changes were republished and approved."; - } - - if (status === 429) { - return "GitHub rate limited the organization membership check."; - } - - if (status === 404) { - return "GitHub returns 404 when the user is not an organization member or the token cannot read that membership."; - } - - if (status >= 500) { - return "GitHub returned a server error while checking organization membership."; - } - - return undefined; -} - -function isGitHubMembershipUnavailableStatus(status: number): boolean { - return status !== 404; -} - -function getGitHubResponseDiagnostics(response: Response, startedAt: number) { - return { - requestId: response.headers.get("x-github-request-id"), - rateLimitLimit: response.headers.get("x-ratelimit-limit"), - rateLimitRemaining: response.headers.get("x-ratelimit-remaining"), - rateLimitReset: response.headers.get("x-ratelimit-reset"), - retryAfter: response.headers.get("retry-after"), - elapsedMs: getElapsedMs(startedAt), - }; -} - -function getElapsedMs(startedAt: number): number { - return Math.round(performance.now() - startedAt); -} diff --git a/packages/web/src/lib/oi-session.test.ts b/packages/web/src/lib/oi-session.test.ts deleted file mode 100644 index f8b1c2972..000000000 --- a/packages/web/src/lib/oi-session.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { JWT } from "next-auth/jwt"; -import type { Account } from "next-auth"; - -vi.mock("@/lib/control-plane-transport", () => ({ - controlPlaneTokenFetch: vi.fn(), -})); - -import { controlPlaneTokenFetch } from "@/lib/control-plane-transport"; -import { encode } from "next-auth/jwt"; -import { - applyOiSessionTokens, - getLiveOiAccessToken, - readOiAccessTokenFromCookiePairs, - renewWebSessionTokens, - OI_ACCESS_TOKEN_RENEW_WINDOW_MS, -} from "@/lib/oi-session"; - -const tokenFetch = vi.mocked(controlPlaneTokenFetch); - -const PAIR = { - accessToken: "oi_at_fresh", - accessTokenExpiresAtEpochMs: Date.now() + 8 * 60 * 60 * 1000, - refreshToken: "oi_rt_fresh", - refreshTokenExpiresAtEpochMs: Date.now() + 30 * 24 * 60 * 60 * 1000, -}; - -function githubAccount(overrides: Partial = {}): Account { - return { - provider: "github", - providerAccountId: "583231", - type: "oauth", - access_token: "gho_subject", - refresh_token: "ghr_refresh", - expires_at: Math.floor(Date.now() / 1000) + 3600, - ...overrides, - } as Account; -} - -function pairResponse(): Response { - return new Response(JSON.stringify(PAIR), { status: 200 }); -} - -function errorResponse(status: number, error: string): Response { - return new Response(JSON.stringify({ error }), { status }); -} - -beforeEach(() => { - tokenFetch.mockReset(); -}); - -describe("applyOiSessionTokens — sign-in exchange", () => { - it("exchanges a GitHub subject with SCM capture fields", async () => { - tokenFetch.mockResolvedValue(pairResponse()); - const token = await applyOiSessionTokens({} as JWT, githubAccount()); - - expect(tokenFetch).toHaveBeenCalledWith("/auth/tokens/exchange", { - method: "POST", - body: expect.any(String), - }); - const body = JSON.parse(tokenFetch.mock.calls[0][1].body!) as Record; - expect(body).toMatchObject({ - subjectTokenType: "github-access-token", - subjectToken: "gho_subject", - scmRefreshToken: "ghr_refresh", - }); - expect(typeof body.scmTokenExpiresAt).toBe("number"); - - expect(token.oiAccessToken).toBe("oi_at_fresh"); - expect(token.oiRefreshToken).toBe("oi_rt_fresh"); - expect(token.oiAccessTokenExpiresAt).toBe(PAIR.accessTokenExpiresAtEpochMs); - }); - - it("exchanges a Google subject without SCM fields", async () => { - tokenFetch.mockResolvedValue(pairResponse()); - await applyOiSessionTokens( - {} as JWT, - githubAccount({ provider: "google", refresh_token: "google-refresh" }) - ); - const body = JSON.parse(tokenFetch.mock.calls[0][1].body!) as Record; - expect(body.subjectTokenType).toBe("google-access-token"); - expect(body.scmRefreshToken).toBeUndefined(); - }); - - it("falls back with unset fields when the exchange fails", async () => { - tokenFetch.mockResolvedValue(errorResponse(401, "subject_rejected")); - const token = await applyOiSessionTokens( - { oiAccessToken: "oi_at_stale" } as JWT, - githubAccount() - ); - expect(token.oiAccessToken).toBeUndefined(); - expect(token.oiRefreshToken).toBeUndefined(); - }); - - it("falls back when the service credential is unavailable", async () => { - tokenFetch.mockRejectedValue(new Error("SERVICE_AUTH_SECRET not configured")); - const token = await applyOiSessionTokens({} as JWT, githubAccount()); - expect(token.oiAccessToken).toBeUndefined(); - }); - - it("clears stale fields for unrecognized providers", async () => { - const token = await applyOiSessionTokens( - { oiAccessToken: "oi_at_stale" } as JWT, - githubAccount({ provider: "gitlab" }) - ); - expect(tokenFetch).not.toHaveBeenCalled(); - expect(token.oiAccessToken).toBeUndefined(); - }); -}); - -describe("applyOiSessionTokens — jwt callback never renews", () => { - it("leaves a near-expiry token untouched without an account (renewal is the refresh route's job)", async () => { - const token = { - oiAccessToken: "oi_at_old", - oiAccessTokenExpiresAt: Date.now() + OI_ACCESS_TOKEN_RENEW_WINDOW_MS - 60_000, - oiRefreshToken: "oi_rt_old", - } as JWT; - const result = await applyOiSessionTokens(token, null); - expect(tokenFetch).not.toHaveBeenCalled(); - expect(result.oiAccessToken).toBe("oi_at_old"); - expect(result.oiRefreshToken).toBe("oi_rt_old"); - }); -}); - -describe("renewWebSessionTokens", () => { - function nearExpiryToken(): JWT { - return { - oiAccessToken: "oi_at_old", - oiAccessTokenExpiresAt: Date.now() + OI_ACCESS_TOKEN_RENEW_WINDOW_MS - 60_000, - oiRefreshToken: "oi_rt_old", - } as JWT; - } - - it("redeems the refresh grant when the access token nears expiry", async () => { - tokenFetch.mockResolvedValue(pairResponse()); - const token = nearExpiryToken(); - const result = await renewWebSessionTokens(token); - - expect(tokenFetch).toHaveBeenCalledWith("/auth/tokens/refresh", { - method: "POST", - body: JSON.stringify({ refreshToken: "oi_rt_old" }), - }); - expect(result).toEqual({ status: "authenticated", changed: true }); - expect(token.oiAccessToken).toBe("oi_at_fresh"); - expect(token.oiRefreshToken).toBe("oi_rt_fresh"); - }); - - it("leaves fresh tokens alone", async () => { - const token = { - oiAccessToken: "oi_at_live", - oiAccessTokenExpiresAt: Date.now() + OI_ACCESS_TOKEN_RENEW_WINDOW_MS + 60_000, - oiRefreshToken: "oi_rt_live", - } as JWT; - const result = await renewWebSessionTokens(token); - expect(tokenFetch).not.toHaveBeenCalled(); - expect(result).toEqual({ status: "authenticated", changed: false }); - expect(token.oiAccessToken).toBe("oi_at_live"); - }); - - it("does nothing when the token carries no oi fields", async () => { - const result = await renewWebSessionTokens({} as JWT); - expect(tokenFetch).not.toHaveBeenCalled(); - expect(result).toEqual({ status: "unauthenticated", changed: false }); - }); - - it("keeps the fields when a concurrent renewal won the race (refresh_superseded)", async () => { - tokenFetch.mockResolvedValue(errorResponse(401, "refresh_superseded")); - const token = nearExpiryToken(); - const result = await renewWebSessionTokens(token); - expect(result).toEqual({ status: "authenticated", changed: false }); - expect(token.oiAccessToken).toBe("oi_at_old"); - expect(token.oiRefreshToken).toBe("oi_rt_old"); - }); - - it("keeps the fields on refresh_superseded even when the access token has expired", async () => { - // The 2026-07-24 prod incident: a wake-from-idle race loser carries a - // long-expired access token — it must NOT wipe the identity the race - // winner just persisted. The dead-vs-superseded call is the CP's alone. - tokenFetch.mockResolvedValue(errorResponse(401, "refresh_superseded")); - const token = { - oiAccessToken: "oi_at_idle", - oiAccessTokenExpiresAt: Date.now() - 4 * 60 * 60 * 1000, - oiRefreshToken: "oi_rt_idle", - } as JWT; - const result = await renewWebSessionTokens(token); - expect(result).toEqual({ status: "authenticated", changed: false }); - expect(token.oiAccessToken).toBe("oi_at_idle"); - expect(token.oiRefreshToken).toBe("oi_rt_idle"); - }); - - it("clears the fields on refresh reuse detection and reports the change for persistence", async () => { - tokenFetch.mockResolvedValue(errorResponse(401, "refresh_reuse_detected")); - const token = nearExpiryToken(); - const result = await renewWebSessionTokens(token); - expect(result).toEqual({ status: "unauthenticated", changed: true }); - expect(token.oiAccessToken).toBeUndefined(); - expect(token.oiRefreshToken).toBeUndefined(); - }); - - it("clears the fields when the grant is genuinely dead (invalid_refresh_token)", async () => { - tokenFetch.mockResolvedValue(errorResponse(401, "invalid_refresh_token")); - const token = nearExpiryToken(); - const result = await renewWebSessionTokens(token); - expect(result).toEqual({ status: "unauthenticated", changed: true }); - expect(token.oiAccessToken).toBeUndefined(); - expect(token.oiRefreshToken).toBeUndefined(); - }); - - it("keeps the fields on transient request failures", async () => { - tokenFetch.mockRejectedValue(new Error("network down")); - const token = nearExpiryToken(); - const result = await renewWebSessionTokens(token); - expect(result).toEqual({ status: "authenticated", changed: false }); - expect(token.oiAccessToken).toBe("oi_at_old"); - expect(token.oiRefreshToken).toBe("oi_rt_old"); - }); - - it("reports temporary unavailability when a transient failure outlasts the access token", async () => { - tokenFetch.mockRejectedValue(new Error("network down")); - const token = { - oiAccessToken: "oi_at_expired", - oiAccessTokenExpiresAt: Date.now() - 1, - oiRefreshToken: "oi_rt_retryable", - } as JWT; - - const result = await renewWebSessionTokens(token); - - expect(result).toEqual({ status: "temporarily_unavailable", changed: false }); - expect(token.oiAccessToken).toBe("oi_at_expired"); - expect(token.oiRefreshToken).toBe("oi_rt_retryable"); - }); -}); - -describe("getLiveOiAccessToken", () => { - it("returns every unexpired token and rejects only missing or expired tokens", () => { - expect( - getLiveOiAccessToken({ - oiAccessToken: "oi_at_x", - oiAccessTokenExpiresAt: Date.now() + 10 * 60 * 1000, - } as JWT) - ).toBe("oi_at_x"); - expect( - getLiveOiAccessToken({ - oiAccessToken: "oi_at_x", - oiAccessTokenExpiresAt: Date.now() + 30_000, - } as JWT) - ).toBe("oi_at_x"); - expect( - getLiveOiAccessToken({ - oiAccessToken: "oi_at_x", - oiAccessTokenExpiresAt: Date.now() - 1, - } as JWT) - ).toBeNull(); - expect(getLiveOiAccessToken({} as JWT)).toBeNull(); - expect(getLiveOiAccessToken(null)).toBeNull(); - }); -}); - -describe("readOiAccessTokenFromCookiePairs", () => { - const SECURE_COOKIE = "__Secure-next-auth.session-token"; - const SECRET = "test-nextauth-secret-for-round-trip"; - - beforeEach(() => { - vi.stubEnv("NEXTAUTH_SECRET", SECRET); - // https URL → getToken looks for the __Secure- cookie name, as in prod. - vi.stubEnv("NEXTAUTH_URL", "https://open-inspect.example"); - }); - - async function encodedJwtWithPair(): Promise { - // Real next-auth encode — no mocking. This pins the exact seam that - // regressed: getToken reads req.cookies, never a headers.cookie string. - return encode({ - token: { - oiAccessToken: "oi_at_round_trip", - oiAccessTokenExpiresAt: Date.now() + 8 * 60 * 60 * 1000, - oiRefreshToken: "oi_rt_round_trip", - }, - secret: SECRET, - }); - } - - it("round-trips a live token through a real encoded session cookie", async () => { - const jwt = await encodedJwtWithPair(); - await expect(readOiAccessTokenFromCookiePairs({ [SECURE_COOKIE]: jwt })).resolves.toBe( - "oi_at_round_trip" - ); - }); - - it("reassembles chunked session cookies", async () => { - const jwt = await encodedJwtWithPair(); - const half = Math.ceil(jwt.length / 2); - await expect( - readOiAccessTokenFromCookiePairs({ - [`${SECURE_COOKIE}.0`]: jwt.slice(0, half), - [`${SECURE_COOKIE}.1`]: jwt.slice(half), - }) - ).resolves.toBe("oi_at_round_trip"); - }); - - it("returns null for unrelated cookies and undecodable tokens", async () => { - await expect(readOiAccessTokenFromCookiePairs({ other: "value" })).resolves.toBeNull(); - await expect( - readOiAccessTokenFromCookiePairs({ [SECURE_COOKIE]: "not-a-jwe" }) - ).resolves.toBeNull(); - }); -}); diff --git a/packages/web/src/lib/oi-session.ts b/packages/web/src/lib/oi-session.ts deleted file mode 100644 index 09d0eb2c5..000000000 --- a/packages/web/src/lib/oi-session.ts +++ /dev/null @@ -1,291 +0,0 @@ -/** - * Web session tokens (`oi_at_`/`oi_rt_`): the CP-issued user credential that - * replaces asserted body identity. - * - * Sign-in exchanges the user's provider token — while it is in scope in the - * NextAuth jwt callback — for a token pair minted only after the control - * plane verifies the subject with the provider. Renewal is a rotating - * refresh grant; provider evidence is never needed again. Exchange and - * refresh calls are signed with web's sig1 service credential. - */ - -import { getToken, type JWT } from "next-auth/jwt"; -import { z } from "zod"; -import type { Account } from "next-auth"; - -import { controlPlaneTokenFetch } from "@/lib/control-plane-transport"; -import { createLogger } from "@/lib/logger"; - -const log = createLogger("oi-session"); - -/** Renew when the access token expires within this window. */ -export const OI_ACCESS_TOKEN_RENEW_WINDOW_MS = 15 * 60 * 1000; - -const tokenPairSchema = z.object({ - accessToken: z.string().min(1), - accessTokenExpiresAtEpochMs: z.number().int().positive(), - refreshToken: z.string().min(1), - refreshTokenExpiresAtEpochMs: z.number().int().positive(), -}); - -type WebSessionTokenPair = z.infer; - -async function postTokenEndpoint( - path: "/auth/tokens/exchange" | "/auth/tokens/refresh", - body: Record -): Promise<{ ok: true; pair: WebSessionTokenPair } | { ok: false; status: number; error: string }> { - const response = await controlPlaneTokenFetch(path, { - method: "POST", - body: JSON.stringify(body), - }); - const data: unknown = await response.json().catch(() => null); - if (!response.ok) { - const error = - typeof data === "object" && data !== null && "error" in data - ? String((data as { error: unknown }).error) - : `http_${response.status}`; - return { ok: false, status: response.status, error }; - } - const parsed = tokenPairSchema.safeParse(data); - if (!parsed.success) { - return { ok: false, status: response.status, error: "invalid_token_pair_response" }; - } - return { ok: true, pair: parsed.data }; -} - -/** - * Exchange the provider credential captured at sign-in for a web session - * token pair. Returns null on any failure (`auth.exchange_fallback`) — the - * caller leaves the `oi*` JWT fields unset, so the session gate requires - * a new sign-in before any user-facing control-plane request is dispatched. - */ -async function exchangeForWebSessionTokens(params: { - provider: "github" | "google"; - subjectToken: string; - scmRefreshToken?: string; - scmTokenExpiresAt?: number; -}): Promise { - try { - const result = await postTokenEndpoint("/auth/tokens/exchange", { - subjectTokenType: - params.provider === "github" ? "github-access-token" : "google-access-token", - subjectToken: params.subjectToken, - ...(params.scmRefreshToken ? { scmRefreshToken: params.scmRefreshToken } : {}), - ...(params.scmTokenExpiresAt ? { scmTokenExpiresAt: params.scmTokenExpiresAt } : {}), - }); - if (!result.ok) { - log.warn("oi_session.exchange_fallback", { - event: "auth.exchange_fallback", - provider: params.provider, - http_status: result.status, - reason: result.error, - }); - return null; - } - return result.pair; - } catch (error) { - log.warn("oi_session.exchange_fallback", { - event: "auth.exchange_fallback", - provider: params.provider, - reason: "request_failed", - error: error instanceof Error ? error : new Error(String(error)), - }); - return null; - } -} - -type RefreshOutcome = - | { ok: true; pair: WebSessionTokenPair } - | { - ok: false; - reason: - | "invalid_refresh_token" - | "refresh_superseded" - | "refresh_reuse_detected" - | "request_failed"; - }; - -/** Redeem the rotating refresh grant for a new pair. */ -async function redeemWebSessionRefresh(refreshToken: string): Promise { - try { - const result = await postTokenEndpoint("/auth/tokens/refresh", { refreshToken }); - if (result.ok) { - return { ok: true, pair: result.pair }; - } - return { - ok: false, - reason: - result.error === "invalid_refresh_token" || - result.error === "refresh_superseded" || - result.error === "refresh_reuse_detected" - ? result.error - : "request_failed", - }; - } catch { - return { ok: false, reason: "request_failed" }; - } -} - -/** - * Read the current request's NextAuth JWT and return a live web session - * token, or null when the pair is absent/expired or there is no request - * context. Read-only — renewal happens in the persistable oi-refresh route, - * never here. - */ -export async function getOiAccessTokenFromCookies(): Promise { - let cookiePairs: Record; - try { - const { cookies } = await import("next/headers"); - const cookieStore = await cookies(); - cookiePairs = Object.fromEntries( - cookieStore.getAll().map((cookie) => [cookie.name, cookie.value]) - ); - } catch { - // Not in a request context (build, background work) — no user identity. - return null; - } - if (Object.keys(cookiePairs).length === 0) return null; - return readOiAccessTokenFromCookiePairs(cookiePairs); -} - -/** - * Decode the NextAuth JWT from parsed cookie pairs and return a live web - * session token. `getToken` reads `req.cookies` only — it never parses a - * `headers.cookie` string — so the request stub must carry the parsed pairs - * (this also lets next-auth reassemble chunked session cookies). - */ -export async function readOiAccessTokenFromCookiePairs( - cookiePairs: Record -): Promise { - try { - const token = await getToken({ - req: { headers: {}, cookies: cookiePairs } as Parameters[0]["req"], - }); - return getLiveOiAccessToken(token); - } catch (error) { - log.warn("oi_session.jwt_read_failed", { - error: error instanceof Error ? error : new Error(String(error)), - }); - return null; - } -} - -/** The JWT's access token while it remains unexpired, else null. */ -export function getLiveOiAccessToken(token: JWT | null): string | null { - if (!token?.oiAccessToken || !token.oiAccessTokenExpiresAt) return null; - if (token.oiAccessTokenExpiresAt <= Date.now()) return null; - return token.oiAccessToken; -} - -/** - * Set the `oi*` JWT fields at sign-in — the only moment provider evidence - * exists — by exchanging the provider credential for a token pair. Runs in - * the NextAuth jwt callback, whose cookie the sign-in flow persists. - * - * Renewal deliberately does NOT happen here: the jwt callback also runs - * under `getServerSession`, which cannot persist a rotated cookie — a - * renewal there would consume the rotating refresh grant and orphan the - * cookie's copy. Renewal lives in the `/api/auth/oi-refresh` route handler - * (`renewWebSessionTokens`), which the client invokes and which CAN persist. - */ -export async function applyOiSessionTokens( - token: JWT, - account: Account | null | undefined -): Promise { - if (!account) { - return token; - } - if ((account.provider === "github" || account.provider === "google") && account.access_token) { - const pair = await exchangeForWebSessionTokens({ - provider: account.provider, - subjectToken: account.access_token, - scmRefreshToken: account.provider === "github" ? account.refresh_token : undefined, - scmTokenExpiresAt: account.expires_at ? account.expires_at * 1000 : undefined, - }); - setOiFields(token, pair); - } else { - setOiFields(token, null); - } - return token; -} - -/** - * Renew the `oi*` fields on a decoded JWT via the rotating refresh grant, - * mutating the token in place. The result describes whether the complete web - * session remains usable, separately from whether the cookie changed. A change - * (rotated pair, or fields cleared because the grant is dead) MUST be persisted - * by the caller, so this is only called from contexts that can write the - * session cookie (the oi-refresh route handler). - */ -export async function renewWebSessionTokens(token: JWT): Promise<{ - status: "authenticated" | "unauthenticated" | "temporarily_unavailable"; - changed: boolean; -}> { - const { oiAccessToken, oiAccessTokenExpiresAt, oiRefreshToken } = token; - if (!oiAccessToken || !oiAccessTokenExpiresAt || !oiRefreshToken) { - return { status: "unauthenticated", changed: false }; - } - if (oiAccessTokenExpiresAt - Date.now() > OI_ACCESS_TOKEN_RENEW_WINDOW_MS) { - return { status: "authenticated", changed: false }; - } - - const outcome = await redeemWebSessionRefresh(oiRefreshToken); - if (outcome.ok) { - setOiFields(token, outcome.pair); - return { status: "authenticated", changed: true }; - } - log.warn("oi_session.refresh_failed", { - event: "auth.refresh_failed", - reason: outcome.reason, - }); - switch (outcome.reason) { - case "request_failed": - // A transient refresh failure is harmless while the current access token - // is still valid. Once it expires, report temporary unavailability so the - // client can retry without conflating an outage with terminal auth loss. - return { - status: oiAccessTokenExpiresAt > Date.now() ? "authenticated" : "temporarily_unavailable", - changed: false, - }; - case "refresh_superseded": - // A concurrent renewal won (CP grace window). In the common case the - // cookie jar holds the winner's fresh pair — never persist over it. - // The CP makes this call from row state; it must NOT be inferred here - // from access-token freshness (at wake-from-idle the access token is - // always expired, and clearing on a lost race wiped a live identity — - // the 2026-07-24 prod incident). - // - // KNOWN GAP: the jar holding the winner is not guaranteed. NextAuth's - // session route re-encodes the decoded JWT on every session read, so a - // stale in-flight response can restore the consumed token over the - // winner's cookie; the next renewal of that restored token outside the - // grace window then reads as reuse and revokes the family. Not fixable - // here: the winner's pair is unrecoverable (hash-at-rest), the doomed - // jar state is unobservable in this request, and clearing is strictly - // worse (wipes a live identity in the common case). The fix is moving - // the pair out of the NextAuth JWT into a single-writer store — - // session-auth roadmap Phase B. - return { status: "authenticated", changed: false }; - case "invalid_refresh_token": - // The grant is genuinely dead (unknown, revoked, or expired) — clear - // the fields, and persist the cleared state so later checks stop - // replaying a dead grant (re-login required). - setOiFields(token, null); - return { status: "unauthenticated", changed: true }; - case "refresh_reuse_detected": - // Rotation reuse is the token-theft signal — always clear the fields - // (re-login required). - setOiFields(token, null); - return { status: "unauthenticated", changed: true }; - default: { - const exhaustive: never = outcome.reason; - throw new Error(`Unhandled refresh outcome: ${String(exhaustive)}`); - } - } -} - -function setOiFields(token: JWT, pair: WebSessionTokenPair | null): void { - token.oiAccessToken = pair?.accessToken; - token.oiAccessTokenExpiresAt = pair?.accessTokenExpiresAtEpochMs; - token.oiRefreshToken = pair?.refreshToken; -} diff --git a/packages/web/src/lib/server-auth-boundary-eslint.test.ts b/packages/web/src/lib/server-auth-boundary-eslint.test.ts index defec386c..20212532f 100644 --- a/packages/web/src/lib/server-auth-boundary-eslint.test.ts +++ b/packages/web/src/lib/server-auth-boundary-eslint.test.ts @@ -38,9 +38,9 @@ describe("server authentication import boundary", () => { ).resolves.toHaveLength(0); }); - it("allows auth endpoints to own the framework integration", async () => { + it("rejects framework imports from auth proxy endpoints", async () => { await expect( restrictedImportMessages('import NextAuth from "next-auth";', authRoutePath) - ).resolves.toHaveLength(0); + ).resolves.toHaveLength(1); }); }); diff --git a/packages/web/src/lib/server-auth-session.test.ts b/packages/web/src/lib/server-auth-session.test.ts index 3da006dad..84402ecf8 100644 --- a/packages/web/src/lib/server-auth-session.test.ts +++ b/packages/web/src/lib/server-auth-session.test.ts @@ -1,29 +1,115 @@ import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest"; -vi.mock("next-auth", () => ({ - getServerSession: vi.fn(), +const mocks = vi.hoisted(() => ({ + cookies: vi.fn(), + dispatchBrowserAuthRequest: vi.fn(), })); -vi.mock("./auth", () => ({ - authOptions: { providers: [] }, +vi.mock("next/headers", () => ({ + cookies: mocks.cookies, +})); + +vi.mock("./browser-auth-proxy", () => ({ + dispatchBrowserAuthRequest: mocks.dispatchBrowserAuthRequest, })); -import { getServerSession } from "next-auth"; -import { authOptions } from "./auth"; import { getServerAuthSession, type ServerAuthSession } from "./server-auth-session"; describe("getServerAuthSession", () => { beforeEach(() => { vi.resetAllMocks(); + mocks.cookies.mockResolvedValue({ + getAll: () => [ + { name: "__Secure-openinspect.session_token", value: "session.signature" }, + { name: "__Secure-openinspect.state", value: "oauth-state" }, + { name: "unrelated", value: "do-not-forward" }, + ], + }); + }); + + it("resolves the app session through the signed browser-auth proxy", async () => { + const session = { + user: { + id: "0123456789abcdef0123456789abcdef", + name: "Ada", + email: "ada@example.com", + image: "https://images.example/ada", + }, + session: { + id: "session-1", + userId: "0123456789abcdef0123456789abcdef", + expiresAt: "2099-01-01T00:00:00.000Z", + }, + }; + mocks.dispatchBrowserAuthRequest.mockResolvedValue(Response.json(session)); + + await expect(getServerAuthSession()).resolves.toEqual({ user: session.user }); + + expect(mocks.dispatchBrowserAuthRequest).toHaveBeenCalledWith({ + method: "GET", + pathname: "/api/auth/get-session", + headers: { + Cookie: "__Secure-openinspect.session_token=session.signature", + }, + }); + }); + + it("returns null without dispatching when the browser session cookie is absent", async () => { + mocks.cookies.mockResolvedValue({ + getAll: () => [{ name: "__Secure-openinspect.state", value: "oauth-state" }], + }); + + await expect(getServerAuthSession()).resolves.toBeNull(); + expect(mocks.dispatchBrowserAuthRequest).not.toHaveBeenCalled(); + }); + + it("returns null when Better Auth rejects the browser session", async () => { + mocks.dispatchBrowserAuthRequest.mockResolvedValue( + Response.json({ error: "Unauthorized" }, { status: 401 }) + ); + + await expect(getServerAuthSession()).resolves.toBeNull(); + }); + + it("returns null when Better Auth reports no current session", async () => { + mocks.dispatchBrowserAuthRequest.mockResolvedValue(Response.json(null)); + + await expect(getServerAuthSession()).resolves.toBeNull(); + }); + + it("throws when the auth service fails instead of treating failure as logout", async () => { + mocks.dispatchBrowserAuthRequest.mockResolvedValue( + Response.json({ error: "Unavailable" }, { status: 503 }) + ); + + await expect(getServerAuthSession()).rejects.toThrow( + "Browser authentication failed with status 503" + ); + }); + + it("rejects malformed successful session responses", async () => { + mocks.dispatchBrowserAuthRequest.mockResolvedValue( + Response.json({ user: { id: 42 }, session: { userId: "user-1" } }) + ); + + await expect(getServerAuthSession()).rejects.toThrow(); }); - it("delegates to the current NextAuth server session implementation", async () => { - const session = { user: { id: "user-1" } }; - vi.mocked(getServerSession).mockResolvedValue(session as never); + it("rejects a noncanonical browser user id", async () => { + mocks.dispatchBrowserAuthRequest.mockResolvedValue( + Response.json({ + user: { id: "better-auth-default-id" }, + session: { + id: "session-1", + userId: "better-auth-default-id", + expiresAt: "2099-01-01T00:00:00.000Z", + }, + }) + ); - await expect(getServerAuthSession()).resolves.toBe(session); - expect(getServerSession).toHaveBeenCalledOnce(); - expect(getServerSession).toHaveBeenCalledWith(authOptions); + await expect(getServerAuthSession()).rejects.toThrow( + "Browser session user id is not canonical" + ); }); it("exposes an app-owned session contract", () => { diff --git a/packages/web/src/lib/server-auth-session.ts b/packages/web/src/lib/server-auth-session.ts index c81c13dbb..27ee91358 100644 --- a/packages/web/src/lib/server-auth-session.ts +++ b/packages/web/src/lib/server-auth-session.ts @@ -1,8 +1,12 @@ -import { getServerSession } from "next-auth"; -import { authOptions } from "./auth"; -import type { AuthIdentityUser } from "./build-auth-identity"; +import { cookies } from "next/headers"; +import { dispatchBrowserAuthRequest } from "./browser-auth-proxy"; +import { + browserAuthSessionResponseSchema, + type BrowserAuthSessionUser, +} from "./browser-auth-session-contract"; +import { serializeBrowserSessionCookies } from "./browser-session-cookie"; -export type ServerAuthUser = AuthIdentityUser; +export type ServerAuthUser = BrowserAuthSessionUser; /** * App-owned session contract consumed by server-side BFF routes. @@ -11,16 +15,33 @@ export type ServerAuthUser = AuthIdentityUser; * route authorization does not depend on framework-owned session types. */ export interface ServerAuthSession { - user?: ServerAuthUser | null; + user: ServerAuthUser; } /** * Server-side authentication seam for BFF routes. * - * This deliberately delegates to the existing NextAuth implementation. A - * later terminal-auth change can replace this boundary without another - * repository-wide route migration. + * The web is a framework-free BFF for browser authentication. Only the opaque + * Better Auth session cookie crosses this boundary; OAuth transaction cookies + * and unrelated browser cookies are never forwarded by server-side callers. */ -export function getServerAuthSession(): Promise { - return getServerSession(authOptions); +export async function getServerAuthSession(): Promise { + const cookieStore = await cookies(); + const cookieHeader = serializeBrowserSessionCookies(cookieStore.getAll()); + if (!cookieHeader) return null; + const response = await dispatchBrowserAuthRequest({ + method: "GET", + pathname: "/api/auth/get-session", + headers: { Cookie: cookieHeader }, + }); + + if (response.status === 401) return null; + if (!response.ok) { + throw new Error(`Browser authentication failed with status ${response.status}`); + } + + const payload: unknown = await response.json(); + if (payload === null) return null; + const session = browserAuthSessionResponseSchema.parse(payload); + return { user: session.user }; } diff --git a/packages/web/src/lib/session-cookie.test.ts b/packages/web/src/lib/session-cookie.test.ts deleted file mode 100644 index 41edf75b7..000000000 --- a/packages/web/src/lib/session-cookie.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { encode } from "next-auth/jwt"; -import { readOiAccessTokenFromCookiePairs } from "@/lib/oi-session"; -import { sessionCookieName, writeSessionCookie } from "@/lib/session-cookie"; - -vi.mock("@/lib/control-plane-transport", () => ({ - controlPlaneTokenFetch: vi.fn(), -})); - -const SECRET = "test-nextauth-secret-for-round-trip"; -const SECURE_COOKIE = "__Secure-next-auth.session-token"; - -interface SetCall { - name: string; - value: string; - options: { maxAge: number; secure: boolean; httpOnly: boolean }; -} - -function fakeStore(initial: Record = {}) { - const sets: SetCall[] = []; - return { - sets, - getAll: () => Object.entries(initial).map(([name, value]) => ({ name, value })), - set: (name: string, value: string, options: SetCall["options"]) => { - sets.push({ name, value, options }); - }, - }; -} - -/** Apply the writer's set calls the way a browser would, then read back. */ -function toCookiePairs(initial: Record, sets: SetCall[]): Record { - const pairs = { ...initial }; - for (const { name, value, options } of sets) { - if (options.maxAge === 0) delete pairs[name]; - else pairs[name] = value; - } - return pairs; -} - -async function encodeSessionJwt(extraClaims: Record = {}): Promise { - return encode({ - token: { - oiAccessToken: "oi_at_round_trip", - oiAccessTokenExpiresAt: Date.now() + 8 * 60 * 60 * 1000, - oiRefreshToken: "oi_rt_round_trip", - ...extraClaims, - }, - secret: SECRET, - }); -} - -beforeEach(() => { - vi.stubEnv("NEXTAUTH_SECRET", SECRET); - vi.stubEnv("NEXTAUTH_URL", "https://open-inspect.example"); -}); - -describe("sessionCookieName", () => { - it("uses the __Secure- prefix exactly when NEXTAUTH_URL is https", () => { - expect(sessionCookieName()).toBe(SECURE_COOKIE); - vi.stubEnv("NEXTAUTH_URL", "http://localhost:3000"); - expect(sessionCookieName()).toBe("next-auth.session-token"); - }); - - it("falls back to the VERCEL secure-cookie default when NEXTAUTH_URL is unset", () => { - // Vercel preview deployments: https, VERCEL injected, no NEXTAUTH_URL. - vi.stubEnv("NEXTAUTH_URL", undefined); - vi.stubEnv("VERCEL", "1"); - expect(sessionCookieName()).toBe(SECURE_COOKIE); - vi.stubEnv("VERCEL", undefined); - expect(sessionCookieName()).toBe("next-auth.session-token"); - }); -}); - -describe("writeSessionCookie", () => { - it("writes a single secure cookie that next-auth's reader decodes", async () => { - const jwt = await encodeSessionJwt(); - const store = fakeStore(); - - writeSessionCookie(store, jwt); - - expect(store.sets).toHaveLength(1); - expect(store.sets[0]).toMatchObject({ - name: SECURE_COOKIE, - options: { httpOnly: true, secure: true }, - }); - await expect(readOiAccessTokenFromCookiePairs(toCookiePairs({}, store.sets))).resolves.toBe( - "oi_at_round_trip" - ); - }); - - it("persists to the secure cookie next-auth reads on Vercel previews", async () => { - vi.stubEnv("NEXTAUTH_URL", undefined); - vi.stubEnv("VERCEL", "1"); - const jwt = await encodeSessionJwt(); - const store = fakeStore(); - - writeSessionCookie(store, jwt); - - expect(store.sets).toHaveLength(1); - expect(store.sets[0]).toMatchObject({ - name: SECURE_COOKIE, - options: { httpOnly: true, secure: true }, - }); - // The reader is next-auth's own getToken — this fails if writer and - // reader ever disagree on the preview cookie name again. - await expect(readOiAccessTokenFromCookiePairs(toCookiePairs({}, store.sets))).resolves.toBe( - "oi_at_round_trip" - ); - }); - - it("chunks oversized values the way next-auth reassembles them", async () => { - const jwt = await encodeSessionJwt({ padding: "x".repeat(6000) }); - const store = fakeStore(); - - writeSessionCookie(store, jwt); - - expect(store.sets.length).toBeGreaterThan(1); - expect(store.sets.map((s) => s.name)).toEqual( - store.sets.map((_, i) => `${SECURE_COOKIE}.${i}`) - ); - for (const set of store.sets) { - expect(set.value.length).toBeLessThanOrEqual(4096 - 163); - } - await expect(readOiAccessTokenFromCookiePairs(toCookiePairs({}, store.sets))).resolves.toBe( - "oi_at_round_trip" - ); - }); - - it("expires stale chunks when a new value fits in one cookie", async () => { - const jwt = await encodeSessionJwt(); - const stale = { - [`${SECURE_COOKIE}.0`]: "stale-first-half", - [`${SECURE_COOKIE}.1`]: "stale-second-half", - }; - const store = fakeStore(stale); - - writeSessionCookie(store, jwt); - - const expired = store.sets.filter((s) => s.options.maxAge === 0).map((s) => s.name); - expect(expired).toEqual([`${SECURE_COOKIE}.0`, `${SECURE_COOKIE}.1`]); - await expect(readOiAccessTokenFromCookiePairs(toCookiePairs(stale, store.sets))).resolves.toBe( - "oi_at_round_trip" - ); - }); - - it("expires the stale base cookie when the new value chunks", async () => { - const jwt = await encodeSessionJwt({ padding: "x".repeat(6000) }); - const stale = { [SECURE_COOKIE]: "stale-unchunked" }; - const store = fakeStore(stale); - - writeSessionCookie(store, jwt); - - const expired = store.sets.filter((s) => s.options.maxAge === 0).map((s) => s.name); - expect(expired).toEqual([SECURE_COOKIE]); - await expect(readOiAccessTokenFromCookiePairs(toCookiePairs(stale, store.sets))).resolves.toBe( - "oi_at_round_trip" - ); - }); -}); diff --git a/packages/web/src/lib/session-cookie.ts b/packages/web/src/lib/session-cookie.ts deleted file mode 100644 index 3a0fc5723..000000000 --- a/packages/web/src/lib/session-cookie.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Chunk-aware writer for the NextAuth v4 session cookie. - * - * next-auth v4 never exposes its cookie writer, but the oi-refresh route must - * persist a re-encoded JWT exactly the way next-auth's `SessionStore` reads it - * back: a single cookie under the session name when the value fits, or - * `name.0`, `name.1`, … chunks when it does not. The constants and split rule - * mirror `next-auth/core/lib/cookie.js` byte for byte — the reader joins every - * cookie whose name starts with the session name, so any stale complementary - * form (old chunks after an unchunked write, or the old base cookie after a - * chunked write) MUST be expired in the same response or the reader would - * concatenate stale and fresh values. - */ - -// Mirrors ALLOWED_COOKIE_SIZE - ESTIMATED_EMPTY_COOKIE_SIZE in next-auth v4. -const COOKIE_CHUNK_SIZE = 4096 - 163; - -/** next-auth v4's default session maxAge (30 days). */ -export const SESSION_COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; - -/** - * Whether next-auth uses `__Secure-`-prefixed cookies. Must resolve exactly - * like the paired reader — next-auth v4 `getToken`'s `secureCookie` default: - * an https NEXTAUTH_URL when the variable is set, else the presence of - * Vercel's injected VERCEL env. Vercel preview deployments serve https - * without NEXTAUTH_URL, so dropping the fallback would write a cookie - * next-auth never reads back. - */ -function secureCookiesEnabled(): boolean { - return process.env.NEXTAUTH_URL?.startsWith("https://") ?? Boolean(process.env.VERCEL); -} - -export function sessionCookieName(): string { - return `${secureCookiesEnabled() ? "__Secure-" : ""}next-auth.session-token`; -} - -/** The subset of Next's request cookie store the writer needs. */ -export interface WritableCookieStore { - getAll(): { name: string; value: string }[]; - set( - name: string, - value: string, - options: { - httpOnly: boolean; - sameSite: "lax"; - path: string; - secure: boolean; - maxAge: number; - } - ): unknown; -} - -/** - * Persist an encoded NextAuth session JWT to the response, splitting into - * chunks when it exceeds the single-cookie budget and expiring every stale - * session cookie the new write does not replace. - */ -export function writeSessionCookie(cookieStore: WritableCookieStore, encodedJwt: string): void { - const name = sessionCookieName(); - const options = { - httpOnly: true, - sameSite: "lax" as const, - path: "/", - secure: secureCookiesEnabled(), - maxAge: SESSION_COOKIE_MAX_AGE_SECONDS, - }; - - const chunks: { name: string; value: string }[] = []; - if (encodedJwt.length <= COOKIE_CHUNK_SIZE) { - chunks.push({ name, value: encodedJwt }); - } else { - for (let i = 0; i * COOKIE_CHUNK_SIZE < encodedJwt.length; i++) { - chunks.push({ - name: `${name}.${i}`, - value: encodedJwt.slice(i * COOKIE_CHUNK_SIZE, (i + 1) * COOKIE_CHUNK_SIZE), - }); - } - } - - const written = new Set(chunks.map((chunk) => chunk.name)); - for (const existing of cookieStore.getAll()) { - const isSessionCookie = existing.name === name || existing.name.startsWith(`${name}.`); - if (isSessionCookie && !written.has(existing.name)) { - cookieStore.set(existing.name, "", { ...options, maxAge: 0 }); - } - } - for (const chunk of chunks) { - cookieStore.set(chunk.name, chunk.value, options); - } -} diff --git a/packages/web/src/lib/site-config.ts b/packages/web/src/lib/site-config.ts index 9e6627541..247b4866b 100644 --- a/packages/web/src/lib/site-config.ts +++ b/packages/web/src/lib/site-config.ts @@ -19,9 +19,8 @@ export const APP_FAVICON_URL = APP_ICON_URL || DEFAULT_FAVICON_URL; /** * Whether to show the "Sign in with Google" button. Build-time flag mirroring - * the server-side conditional GoogleProvider (enabled only when both - * GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET are set). The provider set is static - * per deployment, so a build-time flag avoids an async getProviders() round-trip - * in the sign-in client component. + * whether the control plane has the Google provider configured. The provider + * set is static per deployment, so a build-time flag avoids an authentication + * metadata round-trip in the sign-in client component. */ export const GOOGLE_LOGIN_ENABLED = process.env.NEXT_PUBLIC_GOOGLE_ENABLED?.trim() === "true"; diff --git a/scripts/wrangler-secrets.sh b/scripts/wrangler-secrets.sh index d5254bead..0078fcda7 100755 --- a/scripts/wrangler-secrets.sh +++ b/scripts/wrangler-secrets.sh @@ -1,27 +1,20 @@ #!/usr/bin/env bash set -euo pipefail -# Upload secrets to a Cloudflare Worker via wrangler. +# Upload current secrets and remove retired web-auth secrets via wrangler. # Required environment variables: -# WORKER_NAME - target worker name -# GITHUB_CLIENT_SECRET - GitHub OAuth client secret -# NEXTAUTH_SECRET - NextAuth.js signing secret -# SERVICE_AUTH_SECRET - web's per-service sig1 signing secret -# Optional environment variables: -# GOOGLE_CLIENT_SECRET - Google OAuth client secret (uploaded only when set; -# empty for GitHub-only deployments) +# WORKER_NAME - target worker name +# SERVICE_AUTH_SECRET - web's per-service sig1 signing secret echo "Uploading secrets to worker: ${WORKER_NAME}" -echo "${GITHUB_CLIENT_SECRET}" | npx wrangler secret put GITHUB_CLIENT_SECRET --name "${WORKER_NAME}" -echo "${NEXTAUTH_SECRET}" | npx wrangler secret put NEXTAUTH_SECRET --name "${WORKER_NAME}" echo "${SERVICE_AUTH_SECRET}" | npx wrangler secret put SERVICE_AUTH_SECRET --name "${WORKER_NAME}" -# Google login is opt-in: only upload the secret when configured. (Disabling -# Google after enabling leaves the old secret in place; delete it manually if needed.) -if [ -n "${GOOGLE_CLIENT_SECRET:-}" ]; then - echo "${GOOGLE_CLIENT_SECRET}" | npx wrangler secret put GOOGLE_CLIENT_SECRET --name "${WORKER_NAME}" -fi - +existing_secrets="$(npx wrangler secret list --name "${WORKER_NAME}" --format json)" +for retired_secret in GITHUB_CLIENT_SECRET GOOGLE_CLIENT_SECRET NEXTAUTH_SECRET; do + if [[ "${existing_secrets}" =~ \"name\"[[:space:]]*:[[:space:]]*\"${retired_secret}\" ]]; then + printf 'y\n' | npx wrangler secret delete "${retired_secret}" --name "${WORKER_NAME}" + fi +done echo "Secrets uploaded successfully" diff --git a/terraform/README.md b/terraform/README.md index 6365af769..f59fa9cd3 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -193,7 +193,6 @@ WEB_PLATFORM # Optional; defaults to vercel VERCEL_API_TOKEN VERCEL_TEAM_ID VERCEL_PROJECT_ID -NEXTAUTH_URL # Used by the Vercel web deploy workflow # Modal MODAL_TOKEN_ID @@ -221,7 +220,7 @@ VERCEL_SANDBOX_RUNTIME # Optional; defaults to node24 VERCEL_SNAPSHOT_EXPIRATION_MS # Optional; defaults to 0 VERCEL_SANDBOX_API_BASE_URL # Optional advanced Vercel Sandbox API base URL override -# GitHub OAuth App +# GitHub App OAuth credentials GH_OAUTH_CLIENT_ID GH_OAUTH_CLIENT_SECRET @@ -252,7 +251,7 @@ ANTHROPIC_API_KEY # Security Secrets TOKEN_ENCRYPTION_KEY REPO_SECRETS_ENCRYPTION_KEY -NEXTAUTH_SECRET +NEXTAUTH_SECRET # Browser-auth secret; legacy Actions secret name # Access control ALLOWED_USERS diff --git a/terraform/d1/migrations/0048_better_auth_core.sql b/terraform/d1/migrations/0048_better_auth_core.sql new file mode 100644 index 000000000..507029f57 --- /dev/null +++ b/terraform/d1/migrations/0048_better_auth_core.sql @@ -0,0 +1,72 @@ +-- Better Auth browser identity, account, and session authority. +-- +-- This schema is generated from the exact-pinned Better Auth 1.6.25 core +-- configuration in packages/control-plane/src/auth/user/better-auth.ts. It is +-- additive and inert until the final browser-auth routes are activated. +-- +-- At activation, auth_users.id is projected unchanged into canonical users.id. +-- auth_accounts then owns browser-provider credentials; the legacy +-- provider_credentials table is not dual-written by this runtime. + +CREATE TABLE auth_users ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + emailVerified INTEGER NOT NULL, + image TEXT, + createdAt DATE NOT NULL, + updatedAt DATE NOT NULL, + CHECK (emailVerified IN (0, 1)) +); + +CREATE TABLE auth_sessions ( + id TEXT NOT NULL PRIMARY KEY, + expiresAt DATE NOT NULL, + token TEXT NOT NULL UNIQUE, + createdAt DATE NOT NULL, + updatedAt DATE NOT NULL, + ipAddress TEXT, + userAgent TEXT, + userId TEXT NOT NULL, + FOREIGN KEY (userId) REFERENCES auth_users(id) ON DELETE CASCADE +); + +CREATE INDEX auth_sessions_userId_idx + ON auth_sessions(userId); + +CREATE TABLE auth_accounts ( + id TEXT NOT NULL PRIMARY KEY, + accountId TEXT NOT NULL, + providerId TEXT NOT NULL, + userId TEXT NOT NULL, + accessToken TEXT, + refreshToken TEXT, + idToken TEXT, + accessTokenExpiresAt DATE, + refreshTokenExpiresAt DATE, + scope TEXT, + password TEXT, + createdAt DATE NOT NULL, + updatedAt DATE NOT NULL, + FOREIGN KEY (userId) REFERENCES auth_users(id) ON DELETE CASCADE +); + +CREATE INDEX auth_accounts_userId_idx + ON auth_accounts(userId); + +-- Better Auth 1.6.25 does not generate this uniqueness constraint. Provider +-- subject is the immutable sign-in identity and must resolve to one account. +CREATE UNIQUE INDEX idx_auth_accounts_provider_identity + ON auth_accounts(providerId, accountId); + +CREATE TABLE auth_verifications ( + id TEXT NOT NULL PRIMARY KEY, + identifier TEXT NOT NULL, + value TEXT NOT NULL, + expiresAt DATE NOT NULL, + createdAt DATE NOT NULL, + updatedAt DATE NOT NULL +); + +CREATE INDEX auth_verifications_identifier_idx + ON auth_verifications(identifier); diff --git a/terraform/d1/migrations/0049_backfill_better_auth_accounts.sql b/terraform/d1/migrations/0049_backfill_better_auth_accounts.sql new file mode 100644 index 000000000..791c2450c --- /dev/null +++ b/terraform/d1/migrations/0049_backfill_better_auth_accounts.sql @@ -0,0 +1,104 @@ +-- Seed Better Auth users from the canonical users that predate its activation, +-- then seed accounts from immutable sign-in identities. Email reserves an +-- existing canonical user against implicit linking, but account ownership +-- comes only from user_identities' exact provider issuer and subject. + +-- Better Auth's D1 transaction fallback is non-atomic. A failed first sign-in +-- can therefore leave the new auth user, account, and session behind when the +-- canonical users projection rejects a duplicate email. Remove that entire +-- partial identity graph before inserting the canonical row below. The foreign +-- keys cascade only from a Better Auth user whose normalized email is already +-- owned by a different canonical user; unrelated Better Auth identities are +-- untouched. +DELETE FROM auth_users +WHERE EXISTS ( + SELECT 1 + FROM users + WHERE users.id <> auth_users.id + AND lower(trim(users.email)) = lower(trim(auth_users.email)) + ); + +INSERT INTO auth_users ( + id, + name, + email, + emailVerified, + image, + createdAt, + updatedAt +) +SELECT + users.id, + coalesce(nullif(trim(users.display_name), ''), lower(trim(users.email))), + lower(trim(users.email)), + 0, + users.avatar_url, + strftime('%Y-%m-%dT%H:%M:%fZ', users.created_at / 1000.0, 'unixepoch'), + strftime('%Y-%m-%dT%H:%M:%fZ', users.updated_at / 1000.0, 'unixepoch') +FROM users +WHERE users.email IS NOT NULL + AND length(trim(users.email)) > 0 + AND NOT EXISTS ( + SELECT 1 + FROM auth_users + WHERE auth_users.id = users.id + AND lower(trim(auth_users.email)) = lower(trim(users.email)) + ); + +INSERT INTO auth_accounts ( + id, + accountId, + providerId, + userId, + accessToken, + refreshToken, + idToken, + accessTokenExpiresAt, + refreshTokenExpiresAt, + scope, + password, + createdAt, + updatedAt +) +SELECT + user_identities.id, + user_identities.provider_user_id, + user_identities.provider, + user_identities.user_id, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + strftime( + '%Y-%m-%dT%H:%M:%fZ', + user_identities.created_at / 1000.0, + 'unixepoch' + ), + strftime( + '%Y-%m-%dT%H:%M:%fZ', + user_identities.created_at / 1000.0, + 'unixepoch' + ) +FROM user_identities +JOIN auth_users + ON auth_users.id = user_identities.user_id +WHERE ( + ( + user_identities.provider = 'github' + AND user_identities.provider_issuer = 'https://github.com' + ) + OR ( + user_identities.provider = 'google' + AND user_identities.provider_issuer = 'https://accounts.google.com' + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM auth_accounts + WHERE auth_accounts.providerId = user_identities.provider + AND auth_accounts.accountId = user_identities.provider_user_id + AND auth_accounts.userId = user_identities.user_id + ); diff --git a/terraform/d1/migrations/0050_purge_retired_api_tokens.sql b/terraform/d1/migrations/0050_purge_retired_api_tokens.sql new file mode 100644 index 000000000..6a565cccb --- /dev/null +++ b/terraform/d1/migrations/0050_purge_retired_api_tokens.sql @@ -0,0 +1,4 @@ +-- Browser token exchange and refresh-token rotation are retired by the Better +-- Auth cutover. Remove the historical hashed credentials once; the empty table +-- remains for additive migration compatibility and receives no new rows. +DELETE FROM api_tokens; diff --git a/terraform/environments/production/.terraform.lock.hcl b/terraform/environments/production/.terraform.lock.hcl index 6646d9da9..724ecc2f5 100644 --- a/terraform/environments/production/.terraform.lock.hcl +++ b/terraform/environments/production/.terraform.lock.hcl @@ -82,6 +82,27 @@ provider "registry.terraform.io/hashicorp/null" { ] } +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = "~> 3.0" + hashes = [ + "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", + "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", + "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", + "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", + "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", + "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", + "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", + "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", + "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", + "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", + "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", + "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", + ] +} + provider "registry.terraform.io/vercel/vercel" { version = "2.15.1" constraints = ">= 2.0.0, ~> 2.0" diff --git a/terraform/environments/production/checks.tf b/terraform/environments/production/checks.tf index 9a6ed6ed8..86cf33644 100644 --- a/terraform/environments/production/checks.tf +++ b/terraform/environments/production/checks.tf @@ -1,6 +1,6 @@ # Verify the Vercel production URL matches our hardcoded pattern. If Vercel -# assigns a different domain (e.g., due to naming conflicts), NEXTAUTH_URL and -# cross-service references will silently break. +# assigns a different domain (e.g., due to naming conflicts), browser-auth +# redirects and cross-service references will silently break. check "vercel_url_matches" { assert { condition = ( diff --git a/terraform/environments/production/locals.tf b/terraform/environments/production/locals.tf index 53a7ea2d0..44faa5fe6 100644 --- a/terraform/environments/production/locals.tf +++ b/terraform/environments/production/locals.tf @@ -8,7 +8,7 @@ locals { # Google login is enabled only when both OAuth credentials are configured. # Drives the build-time NEXT_PUBLIC_GOOGLE_ENABLED flag (sign-in button) and - # mirrors the server-side conditional GoogleProvider in packages/web/src/lib/auth.ts. + # mirrors the control plane's conditional Better Auth Google provider. google_enabled = trimspace(var.google_client_id) != "" && trimspace(var.google_client_secret) != "" # URLs for cross-service configuration diff --git a/terraform/environments/production/terraform.tfvars.example b/terraform/environments/production/terraform.tfvars.example index 758eab4e8..895920ed9 100644 --- a/terraform/environments/production/terraform.tfvars.example +++ b/terraform/environments/production/terraform.tfvars.example @@ -235,7 +235,7 @@ repo_secrets_encryption_key = "" # Generate with: openssl rand -hex 32 modal_api_secret = "" -# NextAuth.js secret +# Browser authentication secret (the Terraform input retains its legacy name) nextauth_secret = "" # ============================================================================= diff --git a/terraform/environments/production/variables.tf b/terraform/environments/production/variables.tf index 25b23ed33..63698f42a 100644 --- a/terraform/environments/production/variables.tf +++ b/terraform/environments/production/variables.tf @@ -486,9 +486,14 @@ variable "e2b_auto_pause" { } variable "nextauth_secret" { - description = "NextAuth.js secret (generate with: openssl rand -base64 32)" + description = "Browser authentication secret used by the control plane (legacy Terraform input name; generate with: openssl rand -base64 32)" type = string sensitive = true + + validation { + condition = length(regexall("\\S", var.nextauth_secret)) >= 32 + error_message = "nextauth_secret must contain at least 32 non-whitespace characters." + } } # ============================================================================= diff --git a/terraform/environments/production/web-cloudflare.tf b/terraform/environments/production/web-cloudflare.tf index d85393f80..9cbff7443 100644 --- a/terraform/environments/production/web-cloudflare.tf +++ b/terraform/environments/production/web-cloudflare.tf @@ -33,9 +33,6 @@ resource "null_resource" "web_app_cloudflare_secrets" { triggers = { secrets_hash = sha256(join(",", [ - var.github_client_secret, - var.google_client_secret, - var.nextauth_secret, random_password.service_auth_secret_web.result, ])) } @@ -48,9 +45,6 @@ resource "null_resource" "web_app_cloudflare_secrets" { CLOUDFLARE_API_TOKEN = var.cloudflare_api_token CLOUDFLARE_ACCOUNT_ID = var.cloudflare_account_id WORKER_NAME = local.web_worker_name - GITHUB_CLIENT_SECRET = var.github_client_secret - GOOGLE_CLIENT_SECRET = var.google_client_secret - NEXTAUTH_SECRET = var.nextauth_secret SERVICE_AUTH_SECRET = random_password.service_auth_secret_web.result } } @@ -69,14 +63,10 @@ resource "local_file" "web_app_wrangler_production" { compatibility_date = "2025-08-15" compatibility_flags = ["nodejs_compat", "global_fetch_strictly_public"] - # The workers.dev route is disabled when a custom domain is attached, so the - # app is only reachable on the origin NEXTAUTH_URL points at. + # A custom-domain deployment has one canonical browser origin. workers_dev = ${local.web_custom_domain_enabled ? "false" : "true"} [vars] - GITHUB_CLIENT_ID = "${var.github_client_id}" - GOOGLE_CLIENT_ID = "${var.google_client_id}" - NEXTAUTH_URL = "${local.web_app_url}" CONTROL_PLANE_URL = "${local.control_plane_url}" NEXT_PUBLIC_WS_URL = "${local.ws_url}" NEXT_PUBLIC_SANDBOX_PROVIDER = "${var.sandbox_provider}" @@ -84,11 +74,6 @@ resource "local_file" "web_app_wrangler_production" { NEXT_PUBLIC_APP_SHORT_NAME = "${var.app_short_name}" NEXT_PUBLIC_APP_ICON_URL = "${var.app_icon_url}" NEXT_PUBLIC_GOOGLE_ENABLED = "${tostring(local.google_enabled)}" - ALLOWED_USERS = "${var.allowed_users}" - ALLOWED_EMAIL_DOMAINS = "${var.allowed_email_domains}" - ALLOWED_EMAILS = "${var.allowed_emails}" - ALLOWED_GITHUB_ORGS = "${var.allowed_github_orgs}" - UNSAFE_ALLOW_ALL_USERS = "${tostring(var.unsafe_allow_all_users)}" [assets] directory = ".open-next/assets" diff --git a/terraform/environments/production/web-vercel.tf b/terraform/environments/production/web-vercel.tf index 2d6f1f6b2..732e7cde7 100644 --- a/terraform/environments/production/web-vercel.tf +++ b/terraform/environments/production/web-vercel.tf @@ -16,32 +16,6 @@ module "web_app" { build_command = "next build" environment_variables = [ - # GitHub OAuth - { - key = "GITHUB_CLIENT_ID" - value = var.github_client_id - targets = ["production", "preview"] - sensitive = false - }, - { - key = "GITHUB_CLIENT_SECRET" - value = var.github_client_secret - targets = ["production", "preview"] - sensitive = true - }, - # NextAuth - { - key = "NEXTAUTH_URL" - value = local.web_app_url - targets = ["production"] - sensitive = false - }, - { - key = "NEXTAUTH_SECRET" - value = var.nextauth_secret - targets = ["production", "preview"] - sensitive = true - }, # Control Plane { key = "CONTROL_PLANE_URL" @@ -86,50 +60,10 @@ module "web_app" { targets = ["production", "preview"] sensitive = true }, - # Access Control - { - key = "ALLOWED_USERS" - value = var.allowed_users - targets = ["production", "preview"] - sensitive = false - }, - { - key = "ALLOWED_EMAIL_DOMAINS" - value = var.allowed_email_domains - targets = ["production", "preview"] - sensitive = false - }, - { - key = "ALLOWED_GITHUB_ORGS" - value = var.allowed_github_orgs - targets = ["production", "preview"] - sensitive = false - }, - { - key = "UNSAFE_ALLOW_ALL_USERS" - value = tostring(var.unsafe_allow_all_users) - targets = ["production", "preview"] - sensitive = false - }, - # New env vars MUST be appended here. The module's env-var resource is - # count-indexed by list position (modules/vercel-project/main.tf uses count, - # because Vercel values are sensitive and can't be for_each keys), so - # inserting mid-list renumbers every downstream var and forces Vercel to - # destroy/recreate them — which races into ENV_CONFLICT. Appending keeps - # existing indices stable. - # Google OAuth (optional; both empty for GitHub-only deployments) - { - key = "GOOGLE_CLIENT_ID" - value = var.google_client_id - targets = ["production", "preview"] - sensitive = false - }, - { - key = "GOOGLE_CLIENT_SECRET" - value = var.google_client_secret - targets = ["production", "preview"] - sensitive = true - }, + # This cutover intentionally removes the legacy web-owned auth variables, + # so the first apply replaces the module's count-indexed env resources. + # After that one-time transition, append new variables to keep indices + # stable and avoid Vercel ENV_CONFLICT replacement races. # Build-time flag that reveals the "Sign in with Google" button. Inlined into # the client bundle, so it must be present at build time (not just runtime). { @@ -138,11 +72,5 @@ module "web_app" { targets = ["production", "preview"] sensitive = false }, - { - key = "ALLOWED_EMAILS" - value = var.allowed_emails - targets = ["production", "preview"] - sensitive = false - }, ] } diff --git a/terraform/environments/production/workers-control-plane.tf b/terraform/environments/production/workers-control-plane.tf index 5c6c790a7..c986b1c57 100644 --- a/terraform/environments/production/workers-control-plane.tf +++ b/terraform/environments/production/workers-control-plane.tf @@ -65,7 +65,13 @@ module "control_plane_worker" { plain_text_bindings = concat( [ { name = "GITHUB_CLIENT_ID", value = var.github_client_id }, + { name = "GOOGLE_CLIENT_ID", value = var.google_client_id }, { name = "WEB_APP_URL", value = local.web_app_url }, + { name = "ALLOWED_USERS", value = var.allowed_users }, + { name = "ALLOWED_EMAIL_DOMAINS", value = var.allowed_email_domains }, + { name = "ALLOWED_EMAILS", value = var.allowed_emails }, + { name = "ALLOWED_GITHUB_ORGS", value = var.allowed_github_orgs }, + { name = "UNSAFE_ALLOW_ALL_USERS", value = tostring(var.unsafe_allow_all_users) }, { name = "WORKER_URL", value = local.control_plane_url }, { name = "DEPLOYMENT_NAME", value = var.deployment_name }, { name = "APP_NAME", value = var.app_name }, @@ -119,6 +125,10 @@ module "control_plane_worker" { secrets = concat( [ + # The existing operator-managed auth secret now signs Better Auth state + # and cookies in the control plane. Keeping the Terraform input stable + # avoids coupling secret rotation to the browser-auth cutover. + { name = "BROWSER_AUTH_SECRET", value = var.nextauth_secret }, { name = "GITHUB_CLIENT_SECRET", value = var.github_client_secret }, { name = "TOKEN_ENCRYPTION_KEY", value = var.token_encryption_key }, { name = "REPO_SECRETS_ENCRYPTION_KEY", value = var.repo_secrets_encryption_key }, @@ -135,6 +145,9 @@ module "control_plane_worker" { { name = "GITHUB_APP_PRIVATE_KEY", value = var.github_app_private_key }, { name = "GITHUB_APP_INSTALLATION_ID", value = var.github_app_installation_id }, ], + local.google_enabled ? [ + { name = "GOOGLE_CLIENT_SECRET", value = var.google_client_secret }, + ] : [], local.use_modal_backend ? [ { name = "MODAL_TOKEN_ID", value = var.modal_token_id }, { name = "MODAL_TOKEN_SECRET", value = var.modal_token_secret },