From 77a5a852c19da9926a556fe14b46e59696f79652 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Sun, 24 May 2026 18:04:08 -0700 Subject: [PATCH 01/16] feat(auth): add Ory Auth.js integration Wire the dashboard to Ory through Auth.js while preserving Supabase mode behind the auth provider switch. --- .env.example | 16 +- bun.lock | 22 +- package.json | 2 + spec/openapi.dashboard-api.yaml | 198 ++ .../forgot-password/forgot-password-form.tsx | 114 + src/app/(auth)/forgot-password/page.tsx | 121 +- src/app/(auth)/sign-in/login-form.tsx | 172 ++ src/app/(auth)/sign-in/page.tsx | 179 +- src/app/(auth)/sign-up/page.tsx | 223 +- src/app/(auth)/sign-up/signup-form.tsx | 214 ++ src/app/api/auth/oauth/[...nextauth]/route.ts | 3 + src/app/api/auth/oauth/signed-out/route.ts | 14 + src/app/api/auth/oauth/signout-flow/route.ts | 80 + .../inspect/sandbox/[sandboxId]/route.ts | 4 +- src/app/dashboard/account/route.ts | 22 +- src/app/dashboard/route.ts | 22 +- src/app/dashboard/terminal/page.tsx | 4 +- src/app/sbx/new/route.ts | 4 +- src/auth.ts | 172 ++ src/configs/api.ts | 20 +- src/core/modules/billing/repository.server.ts | 28 +- src/core/modules/builds/repository.server.ts | 6 +- src/core/modules/keys/repository.server.ts | 6 +- .../modules/sandboxes/repository.server.ts | 6 +- .../modules/teams/teams-repository.server.ts | 6 +- .../teams/user-teams-repository.server.ts | 6 +- .../modules/templates/repository.server.ts | 8 +- .../modules/webhooks/repository.server.ts | 6 +- src/core/server/actions/auth-actions.ts | 16 +- src/core/server/actions/ory-auth-actions.ts | 14 + src/core/server/actions/sandbox-actions.ts | 4 +- src/core/server/auth/index.ts | 12 +- src/core/server/auth/ory/admin.ts | 70 +- src/core/server/auth/ory/bootstrap.ts | 154 ++ src/core/server/auth/ory/client.ts | 29 + src/core/server/auth/ory/identity.ts | 64 + src/core/server/auth/ory/provider.ts | 79 +- src/core/server/auth/ory/signout.ts | 131 ++ .../sandboxes/get-team-metrics-core.ts | 4 +- .../sandboxes/get-team-metrics-max.ts | 4 +- .../shared/contracts/dashboard-api.types.ts | 1926 +++++++++-------- .../auth/ory-hosted-auth-redirect.tsx | 36 + .../dashboard/sandbox/inspect/context.tsx | 4 +- .../dashboard/terminal/sandbox-session.ts | 4 +- src/lib/env.ts | 9 + src/lib/utils/server.ts | 6 +- src/proxy.ts | 41 +- tests/integration/auth-ory-bootstrap.test.ts | 163 ++ tests/unit/auth-headers.test.ts | 33 + tests/unit/auth-ory-provider.test.ts | 126 ++ vitest.config.ts | 8 + 51 files changed, 3131 insertions(+), 1484 deletions(-) create mode 100644 src/app/(auth)/forgot-password/forgot-password-form.tsx create mode 100644 src/app/(auth)/sign-in/login-form.tsx create mode 100644 src/app/(auth)/sign-up/signup-form.tsx create mode 100644 src/app/api/auth/oauth/[...nextauth]/route.ts create mode 100644 src/app/api/auth/oauth/signed-out/route.ts create mode 100644 src/app/api/auth/oauth/signout-flow/route.ts create mode 100644 src/auth.ts create mode 100644 src/core/server/actions/ory-auth-actions.ts create mode 100644 src/core/server/auth/ory/bootstrap.ts create mode 100644 src/core/server/auth/ory/client.ts create mode 100644 src/core/server/auth/ory/identity.ts create mode 100644 src/core/server/auth/ory/signout.ts create mode 100644 src/features/auth/ory-hosted-auth-redirect.tsx create mode 100644 tests/integration/auth-ory-bootstrap.test.ts create mode 100644 tests/unit/auth-headers.test.ts create mode 100644 tests/unit/auth-ory-provider.test.ts diff --git a/.env.example b/.env.example index 239f6cd20..4cb53b133 100644 --- a/.env.example +++ b/.env.example @@ -24,8 +24,22 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key ### Auth provider: supabase (default) or ory # AUTH_PROVIDER=supabase -### Ory Network SDK URL (required when AUTH_PROVIDER=ory) +### Ory Network configuration (required when AUTH_PROVIDER=ory) +### SDK URL of the Ory Network project (or custom domain like https://auth.e2b.dev) # ORY_SDK_URL=https://your-project.projects.oryapis.com +### OAuth2 client credentials issued by Ory for this dashboard deployment +# ORY_OAUTH2_CLIENT_ID= +# ORY_OAUTH2_CLIENT_SECRET= +### Access-token audience requested from Ory. Must match infra AUTH_PROVIDER_CONFIG.jwt[].issuer.audiences. +# ORY_OAUTH2_AUDIENCE=https://api.e2b.dev +### Ory project admin API token used by oryAuthAdmin (IdentityApi lookups) +# ORY_PROJECT_API_TOKEN= + +### Auth.js configuration (required when AUTH_PROVIDER=ory) +### Generate with `npx auth secret` or `openssl rand -hex 32`. Used to encrypt the JWT session cookie. +# AUTH_SECRET= +### Set to 1 outside Vercel-hosted production to allow Auth.js to trust the Host header +# AUTH_TRUST_HOST=1 ### Billing API URL (Required if NEXT_PUBLIC_INCLUDE_BILLING=1) # BILLING_API_URL=https://billing.e2b.dev diff --git a/bun.lock b/bun.lock index a97d38482..e29e0ffe6 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "@opentelemetry/sdk-metrics": "^2.0.1", "@opentelemetry/sdk-node": "^0.203.0", "@opentelemetry/semantic-conventions": "^1.36.0", + "@ory/client-fetch": "^1.22.37", "@radix-ui/react-avatar": "^1.1.4", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -69,6 +70,7 @@ "motion": "^12.23.25", "nanoid": "^5.0.9", "next": "^16.2.6", + "next-auth": "^5.0.0-beta.31", "next-safe-action": "^8.0.11", "next-themes": "^0.4.6", "nuqs": "^2.7.0", @@ -143,6 +145,8 @@ "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + "@auth/core": ["@auth/core@0.41.2", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w=="], + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], @@ -561,6 +565,8 @@ "@opentelemetry/sql-common": ["@opentelemetry/sql-common@0.41.2", "", { "dependencies": { "@opentelemetry/core": "^2.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0" } }, "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ=="], + "@ory/client-fetch": ["@ory/client-fetch@1.22.37", "", {}, "sha512-OFPso6JcQ1NVA7UF4Ip112b9/3yoFlGF2kM78fy6gG3uwciC5eUXZWHBGLZdCEi7eKe1JVMJwraR5j6QVmS8vw=="], + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.128.0", "", { "os": "android", "cpu": "arm" }, "sha512-aca6ZvzmCBUGOANQRiRQRZuRKYI3ENhcit6GisnknOOmcezfQc7xJ4dxlPU7MV7mOvrC7RNR1u3LAD7xyaiCxA=="], "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.128.0", "", { "os": "android", "cpu": "arm64" }, "sha512-BbeDmuohoJ7Rz/it5wnkj69i/OsCPS3Z51nLEzwO/Y6YshtC4JU+15oNwhY8v4LRKRYclRc7ggOikwrsJ/eOEQ=="], @@ -643,6 +649,8 @@ "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.19.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw=="], + "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], "@pivanov/utils": ["@pivanov/utils@0.0.2", "", { "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-q9CN0bFWxWgMY5hVVYyBgez1jGiLBa6I+LkG37ycylPhFvEGOOeaADGtUSu46CaZasPnlY8fCdVJZmrgKb1EPA=="], @@ -1017,8 +1025,6 @@ "@vercel/speed-insights": ["@vercel/speed-insights@1.2.0", "", { "peerDependencies": { "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-y9GVzrUJ2xmgtQlzFP2KhVRoCglwfRQgjyfY607aU0hh0Un6d0OUyrJkjuAlsV18qR4zfoFPs/BiIj9YDS6Wzw=="], - "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], - "@vitest/coverage-v8": ["@vitest/coverage-v8@3.2.4", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^1.0.2", "ast-v8-to-istanbul": "^0.3.3", "debug": "^4.4.1", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.17", "magicast": "^0.3.5", "std-env": "^3.9.0", "test-exclude": "^7.0.1", "tinyrainbow": "^2.0.0" }, "peerDependencies": { "@vitest/browser": "3.2.4", "vitest": "3.2.4" }, "optionalPeers": ["@vitest/browser"] }, "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ=="], "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], @@ -1037,6 +1043,8 @@ "@vitest/utils": ["@vitest/utils@3.0.7", "", { "dependencies": { "@vitest/pretty-format": "3.0.7", "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" } }, "sha512-xePVpCRfooFX3rANQjwoditoXgWb1MaFbzmGuPP59MK6i13mrnDw/yEIyJudLeW6/38mCNcwCiJIGmpDPibAIg=="], + "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], @@ -1393,6 +1401,8 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], "js-levenshtein": ["js-levenshtein@1.1.6", "", {}, "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g=="], @@ -1503,6 +1513,8 @@ "next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="], + "next-auth": ["next-auth@5.0.0-beta.31", "", { "dependencies": { "@auth/core": "0.41.2" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", "nodemailer": "^7.0.7", "react": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q=="], + "next-safe-action": ["next-safe-action@8.0.11", "", { "peerDependencies": { "next": ">= 14.0.0", "react": ">= 18.2.0", "react-dom": ">= 18.2.0" } }, "sha512-gqJLmnQLAoFCq1kRBopN46New+vx1n9J9Y/qDQLXpv/VqU40AWxDakvshwwnWAt8R0kLvlakNYNLX5PqlXWSMg=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], @@ -1515,6 +1527,8 @@ "nuqs": ["nuqs@2.7.2", "", { "dependencies": { "@standard-schema/spec": "1.0.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^6 || ^7", "react-router-dom": "^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-wOPJoz5om7jMJQick9zU1S/Q+joL+B2DZTZxfCleHEcUzjUnPoujGod4+nAmUWb+G9TwZnyv+mfNqlyfEi8Zag=="], + "oauth4webapi": ["oauth4webapi@3.8.6", "", {}, "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], @@ -1593,6 +1607,8 @@ "preact": ["preact@10.27.2", "", {}, "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg=="], + "preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="], + "pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], @@ -1925,6 +1941,8 @@ "@asamuzakjp/dom-selector/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + "@auth/core/preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="], + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], diff --git a/package.json b/package.json index 2477377ca..75d6469fc 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@opentelemetry/sdk-metrics": "^2.0.1", "@opentelemetry/sdk-node": "^0.203.0", "@opentelemetry/semantic-conventions": "^1.36.0", + "@ory/client-fetch": "^1.22.37", "@radix-ui/react-avatar": "^1.1.4", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -111,6 +112,7 @@ "motion": "^12.23.25", "nanoid": "^5.0.9", "next": "^16.2.6", + "next-auth": "^5.0.0-beta.31", "next-safe-action": "^8.0.11", "next-themes": "^0.4.6", "nuqs": "^2.7.0", diff --git a/spec/openapi.dashboard-api.yaml b/spec/openapi.dashboard-api.yaml index 453c94c8b..a8d86bfef 100644 --- a/spec/openapi.dashboard-api.yaml +++ b/spec/openapi.dashboard-api.yaml @@ -10,6 +10,9 @@ components: type: apiKey in: header name: X-Admin-Token + # Generated code uses security schemas in the alphabetical order. + # In order to check first the token, and then the team (so we can already use the user), + # there is a 1 and 2 present in the names of the security schemas. Supabase1TokenAuth: type: apiKey in: header @@ -18,6 +21,16 @@ components: type: apiKey in: header name: X-Supabase-Team + # AuthProviderBearerAuth / AuthProviderTeamAuth: B before T in the name + # so Bearer is validated before Team (same reason as Supabase1/2 above). + AuthProviderBearerAuth: + type: http + scheme: bearer + bearerFormat: access_token + AuthProviderTeamAuth: + type: apiKey + in: header + name: X-Team-ID parameters: build_id: @@ -156,6 +169,71 @@ components: type: string description: Error message. + AdminAuthProviderProfile: + type: object + required: + - userId + - email + properties: + userId: + type: string + format: uuid + description: Internal E2B user identifier. + email: + type: string + nullable: true + description: Email address from the configured auth provider. + + AdminAuthProviderProfilesResponse: + type: object + required: + - profiles + properties: + profiles: + type: array + items: + $ref: "#/components/schemas/AdminAuthProviderProfile" + + AdminAuthProviderProfilesResolveRequest: + type: object + required: + - userIds + properties: + userIds: + type: array + minItems: 1 + maxItems: 100 + uniqueItems: true + items: + type: string + format: uuid + + AdminAuthProviderProfilesLookupEmailRequest: + type: object + required: + - email + properties: + email: + type: string + format: email + + AdminAuthProviderUserBootstrapRequest: + type: object + required: + - oidc_user_id + - oidc_user_email + - oidc_user_name + properties: + oidc_user_id: + type: string + minLength: 1 + oidc_user_email: + type: string + format: email + oidc_user_name: + type: string + nullable: true + BuildStatus: type: string description: Build status mapped for dashboard clients. @@ -617,6 +695,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/build_id_or_template" - $ref: "#/components/parameters/build_statuses" @@ -645,6 +725,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/build_ids" @@ -671,6 +753,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/build_id" responses: @@ -696,6 +780,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/sandboxID" responses: @@ -721,6 +807,7 @@ paths: tags: [teams] security: - Supabase1TokenAuth: [] + - AuthProviderBearerAuth: [] responses: "200": description: Successfully returned user teams. @@ -737,6 +824,7 @@ paths: tags: [teams] security: - Supabase1TokenAuth: [] + - AuthProviderBearerAuth: [] requestBody: required: true content: @@ -777,6 +865,106 @@ paths: "500": $ref: "#/components/responses/500" + /admin/users/bootstrap: + post: + summary: Bootstrap auth provider user + tags: [teams] + security: + - AdminTokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminAuthProviderUserBootstrapRequest" + responses: + "200": + description: Successfully bootstrapped user. + content: + application/json: + schema: + $ref: "#/components/schemas/TeamResolveResponse" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + + /admin/user-profiles/resolve: + post: + summary: Resolve user profiles + tags: [admin] + security: + - AdminTokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminAuthProviderProfilesResolveRequest" + responses: + "200": + description: Successfully resolved profiles. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminAuthProviderProfilesResponse" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + + /admin/user-profiles/by-email: + post: + summary: Lookup user profiles by email + tags: [admin] + security: + - AdminTokenAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminAuthProviderProfilesLookupEmailRequest" + responses: + "200": + description: Successfully found matching profiles. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminAuthProviderProfilesResponse" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + + /admin/user-profiles/{userId}: + get: + summary: Get user profile + tags: [admin] + security: + - AdminTokenAuth: [] + parameters: + - $ref: "#/components/parameters/userId" + responses: + "200": + description: Successfully found profile. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminAuthProviderProfilesResponse" + "400": + $ref: "#/components/responses/400" + "401": + $ref: "#/components/responses/401" + "500": + $ref: "#/components/responses/500" + /teams/resolve: get: summary: Resolve team identity @@ -784,6 +972,7 @@ paths: tags: [teams] security: - Supabase1TokenAuth: [] + - AuthProviderBearerAuth: [] parameters: - $ref: "#/components/parameters/teamSlug" responses: @@ -809,6 +998,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/teamID" requestBody: @@ -840,6 +1031,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/teamID" responses: @@ -861,6 +1054,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/teamID" requestBody: @@ -890,6 +1085,8 @@ paths: security: - Supabase1TokenAuth: [] Supabase2TeamAuth: [] + - AuthProviderBearerAuth: [] + AuthProviderTeamAuth: [] parameters: - $ref: "#/components/parameters/teamID" - $ref: "#/components/parameters/userId" @@ -912,6 +1109,7 @@ paths: tags: [templates] security: - Supabase1TokenAuth: [] + - AuthProviderBearerAuth: [] responses: "200": description: Successfully returned default templates. diff --git a/src/app/(auth)/forgot-password/forgot-password-form.tsx b/src/app/(auth)/forgot-password/forgot-password-form.tsx new file mode 100644 index 000000000..8c8e1d4ac --- /dev/null +++ b/src/app/(auth)/forgot-password/forgot-password-form.tsx @@ -0,0 +1,114 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' +import { useSearchParams } from 'next/navigation' +import { useEffect, useState } from 'react' +import { AUTH_URLS } from '@/configs/urls' +import { + getTimeoutMsFromUserMessage, + USER_MESSAGES, +} from '@/configs/user-messages' +import { forgotPasswordAction } from '@/core/server/actions/auth-actions' +import { forgotPasswordSchema } from '@/core/server/functions/auth/auth.types' +import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' +import { Button } from '@/ui/primitives/button' +import { Input } from '@/ui/primitives/input' +import { Label } from '@/ui/primitives/label' + +export default function ForgotPassword() { + const searchParams = useSearchParams() + const [message, setMessage] = useState() + + const { + form, + handleSubmitWithAction, + action: { isExecuting }, + } = useHookFormAction( + forgotPasswordAction, + zodResolver(forgotPasswordSchema), + { + actionProps: { + onSuccess: () => { + form.reset() + setMessage({ success: USER_MESSAGES.passwordReset.message }) + }, + onError: ({ error }) => { + if (error.serverError) { + setMessage({ error: error.serverError }) + } + }, + }, + } + ) + + useEffect(() => { + const email = searchParams.get('email') + if (email) { + form.setValue('email', email) + } + }, [searchParams, form]) + + useEffect(() => { + if ( + message && + (('success' in message && message.success) || + ('error' in message && message.error)) + ) { + const timer = setTimeout( + () => setMessage(undefined), + getTimeoutMsFromUserMessage( + 'success' in message + ? message.success! + : 'error' in message + ? message.error! + : '' + ) || 5000 + ) + return () => clearTimeout(timer) + } + }, [message]) + + const handleBackToSignIn = () => { + const email = form.getValues('email') + const searchParams = email ? `?email=${encodeURIComponent(email)}` : '' + window.location.href = `${AUTH_URLS.SIGN_IN}${searchParams}` + } + + return ( +
+

Reset Password

+

+ Remember your password?{' '} + + . +

+ +
+ + + +
+ + {message && } +
+ ) +} diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx index 8c8e1d4ac..3edf03093 100644 --- a/src/app/(auth)/forgot-password/page.tsx +++ b/src/app/(auth)/forgot-password/page.tsx @@ -1,114 +1,15 @@ -'use client' +import { isOryAuthEnabled } from '@/configs/flags' +import { OryHostedAuthRedirect } from '@/features/auth/ory-hosted-auth-redirect' +import ForgotPassword from './forgot-password-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' -import { useSearchParams } from 'next/navigation' -import { useEffect, useState } from 'react' -import { AUTH_URLS } from '@/configs/urls' -import { - getTimeoutMsFromUserMessage, - USER_MESSAGES, -} from '@/configs/user-messages' -import { forgotPasswordAction } from '@/core/server/actions/auth-actions' -import { forgotPasswordSchema } from '@/core/server/functions/auth/auth.types' -import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' -import { Button } from '@/ui/primitives/button' -import { Input } from '@/ui/primitives/input' -import { Label } from '@/ui/primitives/label' - -export default function ForgotPassword() { - const searchParams = useSearchParams() - const [message, setMessage] = useState() - - const { - form, - handleSubmitWithAction, - action: { isExecuting }, - } = useHookFormAction( - forgotPasswordAction, - zodResolver(forgotPasswordSchema), - { - actionProps: { - onSuccess: () => { - form.reset() - setMessage({ success: USER_MESSAGES.passwordReset.message }) - }, - onError: ({ error }) => { - if (error.serverError) { - setMessage({ error: error.serverError }) - } - }, - }, - } - ) - - useEffect(() => { - const email = searchParams.get('email') - if (email) { - form.setValue('email', email) - } - }, [searchParams, form]) - - useEffect(() => { - if ( - message && - (('success' in message && message.success) || - ('error' in message && message.error)) - ) { - const timer = setTimeout( - () => setMessage(undefined), - getTimeoutMsFromUserMessage( - 'success' in message - ? message.success! - : 'error' in message - ? message.error! - : '' - ) || 5000 - ) - return () => clearTimeout(timer) - } - }, [message]) +interface PageProps { + searchParams: Promise<{ returnTo?: string }> +} - const handleBackToSignIn = () => { - const email = form.getValues('email') - const searchParams = email ? `?email=${encodeURIComponent(email)}` : '' - window.location.href = `${AUTH_URLS.SIGN_IN}${searchParams}` +export default async function Page({ searchParams }: PageProps) { + if (isOryAuthEnabled()) { + const { returnTo } = await searchParams + return } - - return ( -
-

Reset Password

-

- Remember your password?{' '} - - . -

- -
- - - -
- - {message && } -
- ) + return } diff --git a/src/app/(auth)/sign-in/login-form.tsx b/src/app/(auth)/sign-in/login-form.tsx new file mode 100644 index 000000000..6da5d319f --- /dev/null +++ b/src/app/(auth)/sign-in/login-form.tsx @@ -0,0 +1,172 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' +import Link from 'next/link' +import { useSearchParams } from 'next/navigation' +import { Suspense, useEffect, useState } from 'react' +import { AUTH_URLS } from '@/configs/urls' +import { USER_MESSAGES } from '@/configs/user-messages' +import { signInAction } from '@/core/server/actions/auth-actions' +import { signInSchema } from '@/core/server/functions/auth/auth.types' +import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' +import { OAuthProviders } from '@/features/auth/oauth-provider-buttons' +import { Button } from '@/ui/primitives/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/ui/primitives/form' +import { Input } from '@/ui/primitives/input' +import TextSeparator from '@/ui/text-separator' + +export default function Login() { + 'use no memo' + + const searchParams = useSearchParams() + + const [message, setMessage] = useState(() => { + const error = searchParams.get('error') + const success = searchParams.get('success') + if (error) return { error } + if (success) return { success } + return undefined + }) + + const { + form, + handleSubmitWithAction, + action: { isExecuting }, + } = useHookFormAction(signInAction, zodResolver(signInSchema), { + actionProps: { + onError: ({ error }) => { + if ( + error.serverError === USER_MESSAGES.signInEmailNotConfirmed.message + ) { + setMessage({ success: error.serverError }) + return + } + + if (error.serverError) { + setMessage({ error: error.serverError }) + } + }, + }, + }) + + const returnTo = searchParams.get('returnTo') || undefined + + useEffect(() => { + form.setValue('returnTo', returnTo) + }, [returnTo, form]) + + // Handle email prefill from forgot password flow + useEffect(() => { + const email = searchParams.get('email') + if (email) { + form.setValue('email', email) + // Focus password field if email is prefilled + form.setFocus('password') + } else { + // Focus email field if no prefill + form.setFocus('email') + } + }, [searchParams, form]) + + const handleForgotPassword = () => { + const email = form.getValues('email') + const params = new URLSearchParams() + if (email) params.set('email', email) + if (returnTo) params.set('returnTo', returnTo) + window.location.href = `${AUTH_URLS.FORGOT_PASSWORD}?${params.toString()}` + } + + return ( +
+

Sign in

+ + + + + + + +
+ + ( + + E-Mail + + + + + + )} + /> + +
+ Password + +
+ + ( + + + + + + + )} + /> + + + + + + + +

+ Don't have an account?{' '} + + Sign up + + . +

+ + {message && } +
+ ) +} diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx index 6da5d319f..c33bfe8bc 100644 --- a/src/app/(auth)/sign-in/page.tsx +++ b/src/app/(auth)/sign-in/page.tsx @@ -1,172 +1,15 @@ -'use client' +import { isOryAuthEnabled } from '@/configs/flags' +import { OryHostedAuthRedirect } from '@/features/auth/ory-hosted-auth-redirect' +import Login from './login-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' -import Link from 'next/link' -import { useSearchParams } from 'next/navigation' -import { Suspense, useEffect, useState } from 'react' -import { AUTH_URLS } from '@/configs/urls' -import { USER_MESSAGES } from '@/configs/user-messages' -import { signInAction } from '@/core/server/actions/auth-actions' -import { signInSchema } from '@/core/server/functions/auth/auth.types' -import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' -import { OAuthProviders } from '@/features/auth/oauth-provider-buttons' -import { Button } from '@/ui/primitives/button' -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/ui/primitives/form' -import { Input } from '@/ui/primitives/input' -import TextSeparator from '@/ui/text-separator' - -export default function Login() { - 'use no memo' - - const searchParams = useSearchParams() - - const [message, setMessage] = useState(() => { - const error = searchParams.get('error') - const success = searchParams.get('success') - if (error) return { error } - if (success) return { success } - return undefined - }) - - const { - form, - handleSubmitWithAction, - action: { isExecuting }, - } = useHookFormAction(signInAction, zodResolver(signInSchema), { - actionProps: { - onError: ({ error }) => { - if ( - error.serverError === USER_MESSAGES.signInEmailNotConfirmed.message - ) { - setMessage({ success: error.serverError }) - return - } - - if (error.serverError) { - setMessage({ error: error.serverError }) - } - }, - }, - }) - - const returnTo = searchParams.get('returnTo') || undefined - - useEffect(() => { - form.setValue('returnTo', returnTo) - }, [returnTo, form]) - - // Handle email prefill from forgot password flow - useEffect(() => { - const email = searchParams.get('email') - if (email) { - form.setValue('email', email) - // Focus password field if email is prefilled - form.setFocus('password') - } else { - // Focus email field if no prefill - form.setFocus('email') - } - }, [searchParams, form]) +interface PageProps { + searchParams: Promise<{ returnTo?: string }> +} - const handleForgotPassword = () => { - const email = form.getValues('email') - const params = new URLSearchParams() - if (email) params.set('email', email) - if (returnTo) params.set('returnTo', returnTo) - window.location.href = `${AUTH_URLS.FORGOT_PASSWORD}?${params.toString()}` +export default async function Page({ searchParams }: PageProps) { + if (isOryAuthEnabled()) { + const { returnTo } = await searchParams + return } - - return ( -
-

Sign in

- - - - - - - -
- - ( - - E-Mail - - - - - - )} - /> - -
- Password - -
- - ( - - - - - - - )} - /> - - - - - - - -

- Don't have an account?{' '} - - Sign up - - . -

- - {message && } -
- ) + return } diff --git a/src/app/(auth)/sign-up/page.tsx b/src/app/(auth)/sign-up/page.tsx index 6397103d3..cc7a0d1b9 100644 --- a/src/app/(auth)/sign-up/page.tsx +++ b/src/app/(auth)/sign-up/page.tsx @@ -1,214 +1,15 @@ -'use client' +import { isOryAuthEnabled } from '@/configs/flags' +import { OryHostedAuthRedirect } from '@/features/auth/ory-hosted-auth-redirect' +import SignUp from './signup-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' -import Link from 'next/link' -import { useSearchParams } from 'next/navigation' -import { Suspense, useEffect, useRef, useState } from 'react' -import { CAPTCHA_REQUIRED_CLIENT } from '@/configs/flags' -import { AUTH_URLS } from '@/configs/urls' -import { - getTimeoutMsFromUserMessage, - USER_MESSAGES, -} from '@/configs/user-messages' -import { signUpAction } from '@/core/server/actions/auth-actions' -import { signUpSchema } from '@/core/server/functions/auth/auth.types' -import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' -import { OAuthProviders } from '@/features/auth/oauth-provider-buttons' -import { TurnstileWidget } from '@/features/auth/turnstile-widget' -import { useTurnstile } from '@/features/auth/use-turnstile' -import { Button } from '@/ui/primitives/button' -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/ui/primitives/form' -import { Input } from '@/ui/primitives/input' -import TextSeparator from '@/ui/text-separator' - -export default function SignUp() { - 'use no memo' - - const searchParams = useSearchParams() - const [message, setMessage] = useState(() => { - const error = searchParams.get('error') - const success = searchParams.get('success') - if (error) return { error } - if (success) return { success } - - return undefined - }) - - const turnstileResetRef = useRef<() => void>(() => {}) - - const returnTo = searchParams.get('returnTo') || undefined - - const { - form, - handleSubmitWithAction, - action: { isExecuting }, - } = useHookFormAction(signUpAction, zodResolver(signUpSchema), { - actionProps: { - onSuccess: () => { - turnstileResetRef.current() - setMessage({ success: USER_MESSAGES.signUpVerification.message }) - }, - onError: ({ error }) => { - turnstileResetRef.current() - - if (error.serverError) { - setMessage({ error: error.serverError }) - } - }, - }, - }) - - const turnstile = useTurnstile(form) - turnstileResetRef.current = turnstile.reset - - useEffect(() => { - form.setValue('returnTo', returnTo) - }, [returnTo, form]) - - // Handle email prefill - useEffect(() => { - const email = searchParams.get('email') - if (email) { - form.setValue('email', email) - form.setFocus('password') - } else { - form.setFocus('email') - } - }, [searchParams, form]) - - useEffect(() => { - if (message && 'success' in message && message.success) { - const timer = setTimeout( - () => setMessage(undefined), - getTimeoutMsFromUserMessage(message.success) || 5000 - ) - return () => clearTimeout(timer) - } - }, [message]) - - return ( -
-

Sign up

- - - - - - - -
- - ( - - E-Mail - - - - - - )} - /> - - Password - ( - - - - - - - )} - /> - - ( - - - - - - - )} - /> - - - - - - - - - - -

- Already have an account?{' '} - - Sign in - - . -

-

- By signing up, you agree to our{' '} - - Terms of Service - {' '} - and{' '} - - Privacy Policy - - . -

+interface PageProps { + searchParams: Promise<{ returnTo?: string }> +} - {message && } -
- ) +export default async function Page({ searchParams }: PageProps) { + if (isOryAuthEnabled()) { + const { returnTo } = await searchParams + return + } + return } diff --git a/src/app/(auth)/sign-up/signup-form.tsx b/src/app/(auth)/sign-up/signup-form.tsx new file mode 100644 index 000000000..6397103d3 --- /dev/null +++ b/src/app/(auth)/sign-up/signup-form.tsx @@ -0,0 +1,214 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' +import Link from 'next/link' +import { useSearchParams } from 'next/navigation' +import { Suspense, useEffect, useRef, useState } from 'react' +import { CAPTCHA_REQUIRED_CLIENT } from '@/configs/flags' +import { AUTH_URLS } from '@/configs/urls' +import { + getTimeoutMsFromUserMessage, + USER_MESSAGES, +} from '@/configs/user-messages' +import { signUpAction } from '@/core/server/actions/auth-actions' +import { signUpSchema } from '@/core/server/functions/auth/auth.types' +import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message' +import { OAuthProviders } from '@/features/auth/oauth-provider-buttons' +import { TurnstileWidget } from '@/features/auth/turnstile-widget' +import { useTurnstile } from '@/features/auth/use-turnstile' +import { Button } from '@/ui/primitives/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/ui/primitives/form' +import { Input } from '@/ui/primitives/input' +import TextSeparator from '@/ui/text-separator' + +export default function SignUp() { + 'use no memo' + + const searchParams = useSearchParams() + const [message, setMessage] = useState(() => { + const error = searchParams.get('error') + const success = searchParams.get('success') + if (error) return { error } + if (success) return { success } + + return undefined + }) + + const turnstileResetRef = useRef<() => void>(() => {}) + + const returnTo = searchParams.get('returnTo') || undefined + + const { + form, + handleSubmitWithAction, + action: { isExecuting }, + } = useHookFormAction(signUpAction, zodResolver(signUpSchema), { + actionProps: { + onSuccess: () => { + turnstileResetRef.current() + setMessage({ success: USER_MESSAGES.signUpVerification.message }) + }, + onError: ({ error }) => { + turnstileResetRef.current() + + if (error.serverError) { + setMessage({ error: error.serverError }) + } + }, + }, + }) + + const turnstile = useTurnstile(form) + turnstileResetRef.current = turnstile.reset + + useEffect(() => { + form.setValue('returnTo', returnTo) + }, [returnTo, form]) + + // Handle email prefill + useEffect(() => { + const email = searchParams.get('email') + if (email) { + form.setValue('email', email) + form.setFocus('password') + } else { + form.setFocus('email') + } + }, [searchParams, form]) + + useEffect(() => { + if (message && 'success' in message && message.success) { + const timer = setTimeout( + () => setMessage(undefined), + getTimeoutMsFromUserMessage(message.success) || 5000 + ) + return () => clearTimeout(timer) + } + }, [message]) + + return ( +
+

Sign up

+ + + + + + + +
+ + ( + + E-Mail + + + + + + )} + /> + + Password + ( + + + + + + + )} + /> + + ( + + + + + + + )} + /> + + + + + + + + + + +

+ Already have an account?{' '} + + Sign in + + . +

+

+ By signing up, you agree to our{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + . +

+ + {message && } +
+ ) +} diff --git a/src/app/api/auth/oauth/[...nextauth]/route.ts b/src/app/api/auth/oauth/[...nextauth]/route.ts new file mode 100644 index 000000000..c4ea2950b --- /dev/null +++ b/src/app/api/auth/oauth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from '@/auth' + +export const { GET, POST } = handlers diff --git a/src/app/api/auth/oauth/signed-out/route.ts b/src/app/api/auth/oauth/signed-out/route.ts new file mode 100644 index 000000000..23a7f643b --- /dev/null +++ b/src/app/api/auth/oauth/signed-out/route.ts @@ -0,0 +1,14 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { + getLogoutFinalUrl, + parseLogoutState, +} from '@/core/server/auth/ory/signout' + +export async function GET(request: NextRequest) { + const options = parseLogoutState(request.nextUrl.searchParams.get('state')) + + return NextResponse.redirect( + getLogoutFinalUrl(options, request.nextUrl.origin) + ) +} diff --git a/src/app/api/auth/oauth/signout-flow/route.ts b/src/app/api/auth/oauth/signout-flow/route.ts new file mode 100644 index 000000000..8a11dc2d3 --- /dev/null +++ b/src/app/api/auth/oauth/signout-flow/route.ts @@ -0,0 +1,80 @@ +import 'server-only' + +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { auth, signOut } from '@/auth' +import { + buildLogoutState, + getLogoutFinalUrl, + ORY_POST_LOGOUT_CALLBACK_PATH, + type OrySignOutOptions, +} from '@/core/server/auth/ory/signout' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' + +function getSignOutOptions(request: NextRequest): OrySignOutOptions { + const messageType = request.nextUrl.searchParams.get('messageType') + const message = request.nextUrl.searchParams.get('message') + + return { + returnTo: request.nextUrl.searchParams.get('returnTo') ?? undefined, + message: + (messageType === 'error' || + messageType === 'success' || + messageType === 'message') && + message + ? { type: messageType, value: message } + : undefined, + } +} + +export async function GET(request: NextRequest) { + const origin = request.nextUrl.origin + const signOutOptions = getSignOutOptions(request) + const state = buildLogoutState(signOutOptions) + const postLogoutRedirect = new URL(ORY_POST_LOGOUT_CALLBACK_PATH, origin) + + let idToken: string | undefined + try { + const session = await auth() + idToken = session?.idToken + } catch (error) { + l.warn( + { + key: 'oauth_signout:read_session:error', + error: serializeErrorForLog(error), + }, + 'failed to read Auth.js session before sign-out' + ) + } + + try { + await signOut({ redirect: false }) + } catch (error) { + l.warn( + { + key: 'oauth_signout:authjs_sign_out:error', + error: serializeErrorForLog(error), + }, + 'Auth.js signOut() failed' + ) + } + + const sdkUrl = process.env.ORY_SDK_URL + if (!idToken || !sdkUrl) { + return NextResponse.redirect(getLogoutFinalUrl(signOutOptions, origin)) + } + + const hydraLogout = new URL( + `${sdkUrl.replace(/\/$/, '')}/oauth2/sessions/logout` + ) + hydraLogout.searchParams.set('id_token_hint', idToken) + hydraLogout.searchParams.set( + 'post_logout_redirect_uri', + postLogoutRedirect.toString() + ) + if (state) { + hydraLogout.searchParams.set('state', state) + } + + return NextResponse.redirect(hydraLogout.toString()) +} diff --git a/src/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route.ts b/src/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route.ts index af2718918..26fa32148 100644 --- a/src/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route.ts +++ b/src/app/dashboard/(resolvers)/inspect/sandbox/[sandboxId]/route.ts @@ -1,6 +1,6 @@ import { cookies } from 'next/headers' import { type NextRequest, NextResponse } from 'next/server' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { COOKIE_KEYS } from '@/configs/cookies' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' @@ -59,7 +59,7 @@ async function hasSandboxInTeam( }, }, headers: { - ...SUPABASE_AUTH_HEADERS(accessToken, teamId), + ...authHeaders(accessToken, teamId), }, cache: 'no-store', }) diff --git a/src/app/dashboard/account/route.ts b/src/app/dashboard/account/route.ts index 3a89050b2..260e06dab 100644 --- a/src/app/dashboard/account/route.ts +++ b/src/app/dashboard/account/route.ts @@ -1,6 +1,8 @@ import { type NextRequest, NextResponse } from 'next/server' +import { isOryAuthEnabled } from '@/configs/flags' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { auth } from '@/core/server/auth' +import { getOrySignOutPath } from '@/core/server/auth/ory/signout' import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' import { encodedRedirect } from '@/lib/utils/auth' import { setTeamCookies } from '@/lib/utils/cookies' @@ -18,14 +20,26 @@ export async function GET(request: NextRequest) { ) if (!team) { - await auth.signOut() + const error = 'No personal team found. Please contact support.' + + if (isOryAuthEnabled()) { + return NextResponse.redirect( + new URL( + getOrySignOutPath({ + returnTo: AUTH_URLS.SIGN_IN, + message: { type: 'error', value: error }, + }), + request.url + ) + ) + } - const signInUrl = new URL(AUTH_URLS.SIGN_IN, request.url) + await auth.signOut() return encodedRedirect( 'error', - signInUrl.toString(), - 'No personal team found. Please contact support.' + new URL(AUTH_URLS.SIGN_IN, request.url).toString(), + error ) } diff --git a/src/app/dashboard/route.ts b/src/app/dashboard/route.ts index a44259813..0999831f1 100644 --- a/src/app/dashboard/route.ts +++ b/src/app/dashboard/route.ts @@ -1,7 +1,9 @@ import { type NextRequest, NextResponse } from 'next/server' import { TAB_URL_MAP } from '@/configs/dashboard-tab-url-map' +import { isOryAuthEnabled } from '@/configs/flags' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { auth } from '@/core/server/auth' +import { getOrySignOutPath } from '@/core/server/auth/ory/signout' import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' import { encodedRedirect } from '@/lib/utils/auth' import { setTeamCookies } from '@/lib/utils/cookies' @@ -34,14 +36,26 @@ export async function GET(request: NextRequest) { ) if (!team) { - await auth.signOut() + const error = 'No personal team found. Please contact support.' + + if (isOryAuthEnabled()) { + return NextResponse.redirect( + new URL( + getOrySignOutPath({ + returnTo: AUTH_URLS.SIGN_IN, + message: { type: 'error', value: error }, + }), + request.url + ) + ) + } - const signInUrl = new URL(AUTH_URLS.SIGN_IN, request.url) + await auth.signOut() return encodedRedirect( 'error', - signInUrl.toString(), - 'No personal team found. Please contact support.' + new URL(AUTH_URLS.SIGN_IN, request.url).toString(), + error ) } diff --git a/src/app/dashboard/terminal/page.tsx b/src/app/dashboard/terminal/page.tsx index a5454a625..39cfd1ca5 100644 --- a/src/app/dashboard/terminal/page.tsx +++ b/src/app/dashboard/terminal/page.tsx @@ -1,6 +1,6 @@ import Link from 'next/link' import type { Metadata } from 'next/types' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { AUTH_URLS } from '@/configs/urls' import type { TeamModel } from '@/core/modules/teams/models' import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server' @@ -181,7 +181,7 @@ async function hasSandboxInTeam({ }, }, headers: { - ...SUPABASE_AUTH_HEADERS(accessToken, teamId), + ...authHeaders(accessToken, teamId), }, cache: 'no-store', }) diff --git a/src/app/sbx/new/route.ts b/src/app/sbx/new/route.ts index d55bf2aa0..f6d39ac66 100644 --- a/src/app/sbx/new/route.ts +++ b/src/app/sbx/new/route.ts @@ -1,6 +1,6 @@ import Sandbox from 'e2b' import { type NextRequest, NextResponse } from 'next/server' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { auth } from '@/core/server/auth' import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' @@ -32,7 +32,7 @@ export const GET = async (req: NextRequest) => { const sbx = await Sandbox.create('base', { domain: process.env.NEXT_PUBLIC_E2B_DOMAIN, headers: { - ...SUPABASE_AUTH_HEADERS(authContext.accessToken, team.id), + ...authHeaders(authContext.accessToken, team.id), }, }) diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 000000000..97cf3c4b7 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,172 @@ +import 'next-auth/jwt' + +import NextAuth from 'next-auth' +import type { JWT } from 'next-auth/jwt' +import OryHydra from 'next-auth/providers/ory-hydra' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' + +const oryOAuth2Audience = process.env.ORY_OAUTH2_AUDIENCE + +const oryProvider = OryHydra({ + id: 'ory', + name: 'Ory', + issuer: process.env.ORY_SDK_URL, + clientId: process.env.ORY_OAUTH2_CLIENT_ID, + clientSecret: process.env.ORY_OAUTH2_CLIENT_SECRET, + authorization: { + params: { + scope: 'openid offline_access email profile', + ...(oryOAuth2Audience ? { audience: oryOAuth2Audience } : {}), + }, + }, + checks: ['state'], +}) + +export const { handlers, auth, signIn, signOut } = NextAuth({ + // isolates from existing /api/auth/{callback,email-callback,verify-otp} + basePath: '/api/auth/oauth', + secret: process.env.AUTH_SECRET, + session: { strategy: 'jwt' }, + providers: [oryProvider], + callbacks: { + async jwt({ token, account }) { + if (account) { + return { + ...token, + accessToken: account.access_token, + refreshToken: account.refresh_token, + idToken: account.id_token, + expiresAt: account.expires_at ?? null, + } + } + + if (token.expiresAt && Date.now() / 1000 > token.expiresAt - 60) { + return refreshOryToken(token) + } + + return token + }, + + async session({ session, token }) { + session.user.id = token.sub ?? session.user.id + session.accessToken = token.accessToken + session.idToken = token.idToken + session.error = token.error + return session + }, + }, + + events: { + async signIn({ account }) { + if (!account?.access_token) return + const { bootstrapOryUser } = await import( + '@/core/server/auth/ory/bootstrap' + ) + await bootstrapOryUser({ + accessToken: account.access_token, + idToken: account.id_token, + provider: account.provider, + }) + }, + }, +}) + +async function refreshOryToken(token: JWT): Promise { + if (!token.refreshToken) { + return { ...token, error: 'NoRefreshToken' } + } + + const sdkUrl = process.env.ORY_SDK_URL + const clientId = process.env.ORY_OAUTH2_CLIENT_ID + const clientSecret = process.env.ORY_OAUTH2_CLIENT_SECRET + + if (!sdkUrl || !clientId || !clientSecret) { + l.error( + { key: 'auth_provider:refresh_token:misconfigured' }, + 'Ory token refresh attempted but ORY_SDK_URL / ORY_OAUTH2_CLIENT_ID / ORY_OAUTH2_CLIENT_SECRET are not all set' + ) + return { ...token, error: 'RefreshTokenError' } + } + + try { + const credentials = btoa(`${clientId}:${clientSecret}`) + const tokenEndpoint = `${sdkUrl.replace(/\/$/, '')}/oauth2/token` + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: token.refreshToken, + }).toString(), + }) + + if (!response.ok) { + const text = await response.text().catch(() => '') + l.warn( + { + key: 'auth_provider:refresh_token:rejected', + context: { + status: response.status, + body: text.slice(0, 200), + }, + }, + `Ory rejected refresh_token (status ${response.status})` + ) + return { ...token, error: 'RefreshTokenError' } + } + + const fresh = (await response.json()) as { + access_token: string + token_type: string + expires_in: number + refresh_token?: string + id_token?: string + scope?: string + } + + return { + ...token, + accessToken: fresh.access_token, + refreshToken: fresh.refresh_token ?? token.refreshToken, + idToken: fresh.id_token ?? token.idToken, + expiresAt: Math.floor(Date.now() / 1000) + fresh.expires_in, + error: undefined, + } + } catch (error) { + l.error( + { + key: 'auth_provider:refresh_token:exception', + error: serializeErrorForLog(error), + }, + 'Ory refresh_token request threw unexpected exception' + ) + return { ...token, error: 'RefreshTokenError' } + } +} + +declare module 'next-auth' { + interface Session { + accessToken?: string + idToken?: string + error?: string + user: { + id: string + email?: string | null + name?: string | null + image?: string | null + } + } +} + +declare module 'next-auth/jwt' { + interface JWT { + accessToken?: string + refreshToken?: string + idToken?: string + expiresAt?: number | null + error?: string + } +} diff --git a/src/configs/api.ts b/src/configs/api.ts index 226386aef..c4e47d58f 100644 --- a/src/configs/api.ts +++ b/src/configs/api.ts @@ -1,14 +1,26 @@ +import { isOryAuthEnabled } from './flags' + export const API_KEY_PREFIX = 'e2b_' export const ACCESS_TOKEN_PREFIX = 'sk_e2b_' export const SUPABASE_TOKEN_HEADER = 'X-Supabase-Token' export const SUPABASE_TEAM_HEADER = 'X-Supabase-Team' +export const AUTH_PROVIDER_TEAM_HEADER = 'X-Team-ID' export const ENVD_ACCESS_TOKEN_HEADER = 'X-Access-Token' export const ADMIN_TOKEN_HEADER = 'X-Admin-Token' -export const SUPABASE_AUTH_HEADERS = (token: string, teamId?: string) => ({ - [SUPABASE_TOKEN_HEADER]: token, - ...(teamId && { [SUPABASE_TEAM_HEADER]: teamId }), -}) +export const authHeaders = ( + token: string, + teamId?: string +): Record => { + const isOry = isOryAuthEnabled() + const headers: Record = isOry + ? { Authorization: `Bearer ${token}` } + : { [SUPABASE_TOKEN_HEADER]: token } + if (teamId) { + headers[isOry ? AUTH_PROVIDER_TEAM_HEADER : SUPABASE_TEAM_HEADER] = teamId + } + return headers +} export const ADMIN_AUTH_HEADERS = (token: string) => ({ [ADMIN_TOKEN_HEADER]: token, diff --git a/src/core/modules/billing/repository.server.ts b/src/core/modules/billing/repository.server.ts index 4cc79c1e3..8d2561bb1 100644 --- a/src/core/modules/billing/repository.server.ts +++ b/src/core/modules/billing/repository.server.ts @@ -1,7 +1,7 @@ import 'server-only' import { z } from 'zod' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import type { AddOnOrderConfirmResponse, AddOnOrderCreateResponse, @@ -68,7 +68,7 @@ export function createBillingRepository( method: 'POST', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, body: JSON.stringify({ teamID: scope.teamId, @@ -93,7 +93,7 @@ export function createBillingRepository( headers: { 'Content-Type': 'application/json', ...(origin ? { Origin: origin } : {}), - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, }) @@ -110,7 +110,7 @@ export function createBillingRepository( method: 'GET', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, } ) @@ -128,7 +128,7 @@ export function createBillingRepository( method: 'GET', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, } ) @@ -151,7 +151,7 @@ export function createBillingRepository( `${deps.billingApiUrl}/teams/${scope.teamId}/invoices`, { headers: { - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, } ) @@ -169,7 +169,7 @@ export function createBillingRepository( method: 'GET', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, } ) @@ -191,7 +191,7 @@ export function createBillingRepository( method: 'PATCH', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, body: JSON.stringify({ [key]: value, @@ -212,7 +212,7 @@ export function createBillingRepository( method: 'DELETE', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, } ) @@ -230,7 +230,7 @@ export function createBillingRepository( method: 'POST', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, body: JSON.stringify({ items: [{ name: itemId, quantity: 1 }], @@ -251,7 +251,7 @@ export function createBillingRepository( method: 'POST', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, } ) @@ -269,7 +269,7 @@ export function createBillingRepository( method: 'POST', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, } ) @@ -287,7 +287,7 @@ export function createBillingRepository( method: 'POST', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, } ) @@ -315,7 +315,7 @@ export function createBillingRepository( method: 'POST', headers: { 'Content-Type': 'application/json', - ...SUPABASE_AUTH_HEADERS(scope.accessToken, scope.teamId), + ...authHeaders(scope.accessToken, scope.teamId), }, } ) diff --git a/src/core/modules/builds/repository.server.ts b/src/core/modules/builds/repository.server.ts index 6148dc772..1cfaab6d9 100644 --- a/src/core/modules/builds/repository.server.ts +++ b/src/core/modules/builds/repository.server.ts @@ -1,6 +1,6 @@ import 'server-only' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import type { components as InfraComponents } from '@/contracts/infra-api' import { INITIAL_BUILD_STATUSES } from '@/core/modules/builds/constants' import type { @@ -17,7 +17,7 @@ import { err, ok, type RepoResult } from '@/core/shared/result' type BuildsRepositoryDeps = { apiClient: typeof api infraClient: typeof infra - authHeaders: typeof SUPABASE_AUTH_HEADERS + authHeaders: typeof authHeaders } export type BuildsScope = TeamRequestScope @@ -86,7 +86,7 @@ export function createBuildsRepository( deps: BuildsRepositoryDeps = { apiClient: api, infraClient: infra, - authHeaders: SUPABASE_AUTH_HEADERS, + authHeaders: authHeaders, } ): BuildsRepository { return { diff --git a/src/core/modules/keys/repository.server.ts b/src/core/modules/keys/repository.server.ts index a0f24f510..0d408e4f4 100644 --- a/src/core/modules/keys/repository.server.ts +++ b/src/core/modules/keys/repository.server.ts @@ -1,6 +1,6 @@ import 'server-only' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import type { CreatedTeamAPIKey, TeamAPIKey } from '@/core/modules/keys/models' import { type AuthUserEmailResolver, @@ -14,7 +14,7 @@ import { err, ok, type RepoResult } from '@/core/shared/result' type KeysRepositoryDeps = { infraClient: typeof infra - authHeaders: typeof SUPABASE_AUTH_HEADERS + authHeaders: typeof authHeaders resolveAuthUserEmailsById: AuthUserEmailResolver } @@ -30,7 +30,7 @@ export function createKeysRepository( scope: KeysScope, deps: KeysRepositoryDeps = { infraClient: infra, - authHeaders: SUPABASE_AUTH_HEADERS, + authHeaders: authHeaders, resolveAuthUserEmailsById: getAuthUserEmailsById, } ): KeysRepository { diff --git a/src/core/modules/sandboxes/repository.server.ts b/src/core/modules/sandboxes/repository.server.ts index 2f4ad1aba..0d69d4e34 100644 --- a/src/core/modules/sandboxes/repository.server.ts +++ b/src/core/modules/sandboxes/repository.server.ts @@ -1,6 +1,6 @@ import 'server-only' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import type { components as DashboardComponents } from '@/contracts/dashboard-api' import type { components as InfraComponents } from '@/contracts/infra-api' import type { @@ -18,7 +18,7 @@ import { err, ok, type RepoResult } from '@/core/shared/result' type SandboxesRepositoryDeps = { apiClient: typeof api infraClient: typeof infra - authHeaders: typeof SUPABASE_AUTH_HEADERS + authHeaders: typeof authHeaders } export type SandboxesRequestScope = TeamRequestScope @@ -86,7 +86,7 @@ export function createSandboxesRepository( deps: SandboxesRepositoryDeps = { apiClient: api, infraClient: infra, - authHeaders: SUPABASE_AUTH_HEADERS, + authHeaders: authHeaders, } ): SandboxesRepository { return { diff --git a/src/core/modules/teams/teams-repository.server.ts b/src/core/modules/teams/teams-repository.server.ts index 1e5524791..cadd72ebd 100644 --- a/src/core/modules/teams/teams-repository.server.ts +++ b/src/core/modules/teams/teams-repository.server.ts @@ -1,6 +1,6 @@ import 'server-only' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import type { components as DashboardComponents } from '@/contracts/dashboard-api' import type { AuthAdmin } from '@/core/server/auth' import { authAdmin } from '@/core/server/auth' @@ -12,7 +12,7 @@ import type { TeamMember } from './models' type TeamsRepositoryDeps = { apiClient: typeof api - authHeaders: typeof SUPABASE_AUTH_HEADERS + authHeaders: typeof authHeaders authAdmin: Pick } @@ -50,7 +50,7 @@ export function createTeamsRepository( scope: TeamsRequestScope, deps: TeamsRepositoryDeps = { apiClient: api, - authHeaders: SUPABASE_AUTH_HEADERS, + authHeaders: authHeaders, authAdmin, } ): TeamsRepository { diff --git a/src/core/modules/teams/user-teams-repository.server.ts b/src/core/modules/teams/user-teams-repository.server.ts index de701eabf..e5776104c 100644 --- a/src/core/modules/teams/user-teams-repository.server.ts +++ b/src/core/modules/teams/user-teams-repository.server.ts @@ -1,7 +1,7 @@ import 'server-only' import { secondsInMinute } from 'date-fns/constants' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import type { components as DashboardComponents } from '@/contracts/dashboard-api' import { api } from '@/core/shared/clients/api' import { createRepoError, repoErrorFromHttp } from '@/core/shared/errors' @@ -11,7 +11,7 @@ import type { ResolvedTeam, TeamModel } from './models' type UserTeamsRepositoryDeps = { apiClient: typeof api - authHeaders: typeof SUPABASE_AUTH_HEADERS + authHeaders: typeof authHeaders } export type UserTeamsRequestScope = RequestScope @@ -31,7 +31,7 @@ export function createUserTeamsRepository( scope: UserTeamsRequestScope, deps: UserTeamsRepositoryDeps = { apiClient: api, - authHeaders: SUPABASE_AUTH_HEADERS, + authHeaders: authHeaders, } ): UserTeamsRepository { const listApiUserTeams = async (): Promise> => { diff --git a/src/core/modules/templates/repository.server.ts b/src/core/modules/templates/repository.server.ts index 9d282af3e..680d2fbcb 100644 --- a/src/core/modules/templates/repository.server.ts +++ b/src/core/modules/templates/repository.server.ts @@ -1,6 +1,6 @@ import 'server-only' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { CACHE_TAGS } from '@/configs/cache' import { USE_MOCK_DATA } from '@/configs/flags' import { @@ -24,7 +24,7 @@ import { err, ok, type RepoResult } from '@/core/shared/result' type TemplatesRepositoryDeps = { apiClient: typeof api infraClient: typeof infra - authHeaders: typeof SUPABASE_AUTH_HEADERS + authHeaders: typeof authHeaders resolveAuthUserEmailsById: AuthUserEmailResolver } @@ -48,7 +48,7 @@ export function createTemplatesRepository( deps: TemplatesRepositoryDeps = { apiClient: api, infraClient: infra, - authHeaders: SUPABASE_AUTH_HEADERS, + authHeaders: authHeaders, resolveAuthUserEmailsById: getAuthUserEmailsById, } ): TeamTemplatesRepository { @@ -145,7 +145,7 @@ export function createDefaultTemplatesRepository( scope: RequestScope, deps: Pick = { apiClient: api, - authHeaders: SUPABASE_AUTH_HEADERS, + authHeaders: authHeaders, } ): DefaultTemplatesRepository { return { diff --git a/src/core/modules/webhooks/repository.server.ts b/src/core/modules/webhooks/repository.server.ts index f7188d7df..04c9501e9 100644 --- a/src/core/modules/webhooks/repository.server.ts +++ b/src/core/modules/webhooks/repository.server.ts @@ -1,6 +1,6 @@ import 'server-only' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { infra } from '@/core/shared/clients/api' import type { components as ArgusComponents } from '@/core/shared/contracts/argus-api.types' import { repoErrorFromHttp } from '@/core/shared/errors' @@ -9,7 +9,7 @@ import { err, ok, type RepoResult } from '@/core/shared/result' type WebhooksRepositoryDeps = { infraClient: typeof infra - authHeaders: typeof SUPABASE_AUTH_HEADERS + authHeaders: typeof authHeaders } export type WebhooksScope = TeamRequestScope @@ -40,7 +40,7 @@ export function createWebhooksRepository( scope: WebhooksScope, deps: WebhooksRepositoryDeps = { infraClient: infra, - authHeaders: SUPABASE_AUTH_HEADERS, + authHeaders: authHeaders, } ): WebhooksRepository { return { diff --git a/src/core/server/actions/auth-actions.ts b/src/core/server/actions/auth-actions.ts index 17e3c66c0..8be28b274 100644 --- a/src/core/server/actions/auth-actions.ts +++ b/src/core/server/actions/auth-actions.ts @@ -4,12 +4,13 @@ import { headers } from 'next/headers' import { redirect } from 'next/navigation' import { returnValidationErrors } from 'next-safe-action' import { z } from 'zod' -import { CAPTCHA_REQUIRED_SERVER } from '@/configs/flags' +import { CAPTCHA_REQUIRED_SERVER, isOryAuthEnabled } from '@/configs/flags' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { USER_MESSAGES } from '@/configs/user-messages' import { actionClient } from '@/core/server/actions/client' import { returnServerError } from '@/core/server/actions/utils' import { auth } from '@/core/server/auth' +import { getOrySignOutPath } from '@/core/server/auth/ory/signout' import { supabaseAuthFlows } from '@/core/server/auth/supabase/flows' import { forgotPasswordSchema, @@ -356,10 +357,15 @@ export const forgotPasswordAction = actionClient }) export async function signOutAction(returnTo?: string) { + const signInPath = + AUTH_URLS.SIGN_IN + + (returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : '') + + if (isOryAuthEnabled()) { + throw redirect(getOrySignOutPath({ returnTo: signInPath })) + } + await auth.signOut() - throw redirect( - AUTH_URLS.SIGN_IN + - (returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : '') - ) + throw redirect(signInPath) } diff --git a/src/core/server/actions/ory-auth-actions.ts b/src/core/server/actions/ory-auth-actions.ts new file mode 100644 index 000000000..ae5230c24 --- /dev/null +++ b/src/core/server/actions/ory-auth-actions.ts @@ -0,0 +1,14 @@ +'use server' + +import { signIn } from '@/auth' + +// thin wrapper around Auth.js's signIn() that exists so client components +// can submit a form to it. signIn() throws a redirect; never returns normally. +export async function signInWithOryAction(formData: FormData) { + const returnTo = formData.get('returnTo') + const redirectTo = + typeof returnTo === 'string' && returnTo.length > 0 + ? returnTo + : '/dashboard' + await signIn('ory', { redirectTo }) +} diff --git a/src/core/server/actions/sandbox-actions.ts b/src/core/server/actions/sandbox-actions.ts index b0c35cb27..e41e569c9 100644 --- a/src/core/server/actions/sandbox-actions.ts +++ b/src/core/server/actions/sandbox-actions.ts @@ -2,7 +2,7 @@ import { updateTag } from 'next/cache' import { z } from 'zod' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { CACHE_TAGS } from '@/configs/cache' import { authActionClient, @@ -28,7 +28,7 @@ export const killSandboxAction = authActionClient const res = await infra.DELETE('/sandboxes/{sandboxID}', { headers: { - ...SUPABASE_AUTH_HEADERS(session.access_token, teamId), + ...authHeaders(session.access_token, teamId), }, params: { path: { diff --git a/src/core/server/auth/index.ts b/src/core/server/auth/index.ts index 569e821be..d24c06bbc 100644 --- a/src/core/server/auth/index.ts +++ b/src/core/server/auth/index.ts @@ -4,11 +4,7 @@ import type { NextRequest, NextResponse } from 'next/server' import { isOryAuthEnabled } from '@/configs/flags' import type { AuthAdmin } from './admin' import { oryAuthAdmin } from './ory/admin' -import { - createOryAuthForHeaders, - createOryAuthForProxy, - OryHostedAuthProvider, -} from './ory/provider' +import { oryAuthProvider } from './ory/provider' import type { AuthProvider } from './provider' import { supabaseAuthAdmin } from './supabase/admin' import { @@ -18,7 +14,7 @@ import { } from './supabase/provider' export const auth: AuthProvider = isOryAuthEnabled() - ? new OryHostedAuthProvider() + ? oryAuthProvider : new SupabaseAuthProvider() export const authAdmin: AuthAdmin = isOryAuthEnabled() @@ -30,13 +26,13 @@ export function createAuthForProxy( response: NextResponse ): AuthProvider { return isOryAuthEnabled() - ? createOryAuthForProxy(request, response) + ? oryAuthProvider : createSupabaseAuthForProxy(request, response) } export function createAuthForHeaders(headers: Headers): AuthProvider { return isOryAuthEnabled() - ? createOryAuthForHeaders(headers) + ? oryAuthProvider : createSupabaseAuthForHeaders(headers) } diff --git a/src/core/server/auth/ory/admin.ts b/src/core/server/auth/ory/admin.ts index 5c420edf4..8c9c86231 100644 --- a/src/core/server/auth/ory/admin.ts +++ b/src/core/server/auth/ory/admin.ts @@ -1,14 +1,74 @@ import 'server-only' +import { ResponseError } from '@ory/client-fetch' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import type { AuthAdmin } from '../admin' +import { getOryIdentityApi } from './client' +import { fromOryIdentity } from './identity' + +const ORY_LIST_IDENTITIES_MAX_PAGE_SIZE = 1000 export const oryAuthAdmin: AuthAdmin = { - // fail-closed: callers treat null as unauthenticated / missing - getUserById(_userId) { - return Promise.resolve(null) + async getUserById(userId) { + try { + const identity = await getOryIdentityApi().getIdentity({ id: userId }) + return fromOryIdentity(identity) + } catch (error) { + if (error instanceof ResponseError && error.response.status === 404) { + return null + } + l.error( + { + key: 'auth_admin:ory_get_user_by_id:error', + user_id: userId, + error: serializeErrorForLog(error), + }, + 'oryAuthAdmin.getUserById failed' + ) + return null + } }, - getEmailsByIds(_userIds) { - return Promise.resolve(new Map()) + async getEmailsByIds(userIds) { + const uniqueIds = [...new Set(userIds.filter(Boolean))] + if (uniqueIds.length === 0) { + return new Map() + } + + try { + const result = new Map() + + for ( + let start = 0; + start < uniqueIds.length; + start += ORY_LIST_IDENTITIES_MAX_PAGE_SIZE + ) { + const ids = uniqueIds.slice( + start, + start + ORY_LIST_IDENTITIES_MAX_PAGE_SIZE + ) + const identities = await getOryIdentityApi().listIdentities({ + ids, + pageSize: ids.length, + }) + + for (const identity of identities) { + const { email } = fromOryIdentity(identity) + result.set(identity.id, email) + } + } + + return result + } catch (error) { + l.error( + { + key: 'auth_admin:ory_get_emails_by_ids:error', + context: { count: uniqueIds.length }, + error: serializeErrorForLog(error), + }, + 'oryAuthAdmin.getEmailsByIds failed' + ) + return new Map() + } }, } diff --git a/src/core/server/auth/ory/bootstrap.ts b/src/core/server/auth/ory/bootstrap.ts new file mode 100644 index 000000000..3c9d5657b --- /dev/null +++ b/src/core/server/auth/ory/bootstrap.ts @@ -0,0 +1,154 @@ +import 'server-only' + +import { ADMIN_AUTH_HEADERS } from '@/configs/api' +import { api } from '@/core/shared/clients/api' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { repoErrorFromHttp } from '@/core/shared/errors' + +type BootstrapOryUserInput = { + accessToken: string + idToken?: string + provider?: string +} + +type OryTokenClaims = { + iss?: unknown + sub?: unknown + email?: unknown + name?: unknown + given_name?: unknown + preferred_username?: unknown +} + +export async function bootstrapOryUser( + input: BootstrapOryUserInput +): Promise { + try { + const accessClaims = decodeJwtClaims(input.accessToken) + const idClaims = input.idToken ? decodeJwtClaims(input.idToken) : null + const oidcUserId = readRequiredStringClaim(accessClaims, 'sub') + const oidcUserEmail = + readStringClaim(accessClaims, 'email') ?? + readStringClaim(idClaims, 'email') + const oidcUserName = + readDisplayName(accessClaims) ?? readDisplayName(idClaims) + + if (!oidcUserId || !oidcUserEmail) { + l.error( + { + key: 'auth_events:bootstrap_user:missing_claims', + context: { + provider: input.provider, + access_token_format: tokenFormat(input.accessToken), + id_token_format: input.idToken + ? tokenFormat(input.idToken) + : 'missing', + has_access_claims: !!accessClaims, + has_id_claims: !!idClaims, + has_sub: !!oidcUserId, + has_email: !!oidcUserEmail, + has_name: !!oidcUserName, + }, + }, + 'Ory access token is missing required bootstrap claims' + ) + return + } + + const adminToken = process.env.DASHBOARD_API_ADMIN_TOKEN + if (!adminToken) { + l.error( + { + key: 'auth_events:bootstrap_user:missing_admin_token', + context: { provider: input.provider }, + }, + 'DASHBOARD_API_ADMIN_TOKEN is not configured' + ) + return + } + + const body = { + oidc_user_id: oidcUserId, + oidc_user_email: oidcUserEmail, + oidc_user_name: oidcUserName, + } + + const { error, response } = await api.POST('/admin/users/bootstrap', { + body, + headers: ADMIN_AUTH_HEADERS(adminToken), + }) + + if (!response.ok || error) { + const repoError = repoErrorFromHttp( + response.status, + error?.message ?? 'Failed to bootstrap user', + error + ) + l.error( + { + key: 'auth_events:bootstrap_user:error', + context: { + provider: input.provider, + error_status: response.status, + has_oidc_user_id: body.oidc_user_id !== '', + has_oidc_user_email: body.oidc_user_email !== '', + has_oidc_user_name: body.oidc_user_name !== null, + }, + }, + `bootstrap_user failed: ${repoError.message}` + ) + } + } catch (error) { + l.error( + { + key: 'auth_events:bootstrap_user:exception', + context: { + provider: input.provider, + }, + error: serializeErrorForLog(error), + }, + 'bootstrap_user threw unexpected exception' + ) + } +} + +function decodeJwtClaims(token: string): OryTokenClaims | null { + const [, payload] = token.split('.') + if (!payload) return null + + try { + return JSON.parse( + Buffer.from(payload, 'base64url').toString('utf8') + ) as OryTokenClaims + } catch { + return null + } +} + +function readRequiredStringClaim( + claims: OryTokenClaims | null, + name: keyof OryTokenClaims +): string | null { + return readStringClaim(claims, name) +} + +function readStringClaim( + claims: OryTokenClaims | null, + name: keyof OryTokenClaims +): string | null { + const value = claims?.[name] + return typeof value === 'string' && value.trim() !== '' ? value.trim() : null +} + +function readDisplayName(claims: OryTokenClaims | null): string | null { + return ( + readStringClaim(claims, 'name') ?? + readStringClaim(claims, 'given_name') ?? + readStringClaim(claims, 'preferred_username') + ) +} + +function tokenFormat(token: string): 'jwt' | 'opaque' | 'empty' { + if (!token) return 'empty' + return token.split('.').length === 3 ? 'jwt' : 'opaque' +} diff --git a/src/core/server/auth/ory/client.ts b/src/core/server/auth/ory/client.ts new file mode 100644 index 000000000..2ed25a79f --- /dev/null +++ b/src/core/server/auth/ory/client.ts @@ -0,0 +1,29 @@ +import 'server-only' + +import { Configuration, IdentityApi } from '@ory/client-fetch' + +let cached: IdentityApi | null = null + +// the IdentityApi requires the Ory project admin token (PAT). callers should +// ensure ORY_PROJECT_API_TOKEN is set at deploy time when AUTH_PROVIDER=ory. +export function getOryIdentityApi(): IdentityApi { + if (cached) return cached + + const basePath = process.env.ORY_SDK_URL + const accessToken = process.env.ORY_PROJECT_API_TOKEN + + if (!basePath) { + throw new Error('ORY_SDK_URL is not configured') + } + if (!accessToken) { + throw new Error('ORY_PROJECT_API_TOKEN is not configured') + } + + cached = new IdentityApi( + new Configuration({ + basePath: basePath.replace(/\/$/, ''), + accessToken, + }) + ) + return cached +} diff --git a/src/core/server/auth/ory/identity.ts b/src/core/server/auth/ory/identity.ts new file mode 100644 index 000000000..f3bee304c --- /dev/null +++ b/src/core/server/auth/ory/identity.ts @@ -0,0 +1,64 @@ +import 'server-only' + +import type { Identity } from '@ory/client-fetch' +import type { Session } from 'next-auth' +import type { AuthUser } from '../types' + +// auth.js sessions only carry the basic user shape; identity providers list +// requires an Ory IdentityApi lookup. fromAuthSession is the cheap path used +// during request-time getAuthContext. +export function fromAuthSession(session: Session): AuthUser { + return { + id: session.user.id, + email: session.user.email ?? null, + name: session.user.name ?? null, + avatarUrl: session.user.image ?? null, + providers: [], + } +} + +// fromOryIdentity is used by oryAuthAdmin (admin lookups) where we have the +// full Identity object including credentials and traits. +export function fromOryIdentity(identity: Identity): AuthUser { + const traits = (identity.traits ?? {}) as Record + const email = readString(traits, 'email') + const name = readDisplayName(traits) + const avatarUrl = + readString(traits, 'picture') ?? readString(traits, 'avatar_url') + const providers = identity.credentials + ? Object.keys(identity.credentials) + : [] + + return { + id: identity.id, + email, + name, + avatarUrl, + providers, + } +} + +function readString( + traits: Record, + key: string +): string | null { + const value = traits[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +function readDisplayName(traits: Record): string | null { + // ory's default schema nests name as { first, last } or stores it flat + const flat = readString(traits, 'name') + if (flat) return flat + + const nested = traits.name + if (nested && typeof nested === 'object') { + const obj = nested as Record + const first = readString(obj, 'first') + const last = readString(obj, 'last') + const composite = [first, last].filter(Boolean).join(' ').trim() + if (composite) return composite + } + + return null +} diff --git a/src/core/server/auth/ory/provider.ts b/src/core/server/auth/ory/provider.ts index c50bc63ae..f6be01251 100644 --- a/src/core/server/auth/ory/provider.ts +++ b/src/core/server/auth/ory/provider.ts @@ -1,45 +1,56 @@ import 'server-only' -import type { NextRequest, NextResponse } from 'next/server' -import { l } from '@/core/shared/clients/logger/logger' +import type { Session } from 'next-auth' +import { auth as authjs } from '@/auth' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import type { AuthProvider } from '../provider' -import type { AuthContext, SignOutOptions, SignOutResult } from '../types' +import { fromAuthSession } from './identity' -export class OryHostedAuthProvider implements AuthProvider { - constructor(private readonly cookie: string = '') {} +export const oryAuthProvider: AuthProvider = { + async getAuthContext() { + let session: Session | null + try { + session = await authjs() + } catch (error) { + l.error( + { + key: 'auth_provider:ory_get_session:error', + error: serializeErrorForLog(error), + }, + 'Auth.js auth() helper threw while reading session' + ) + return null + } - // fail-closed until ory is wired: callers (proxy, middleware) treat null as - // unauthenticated and redirect to sign-in instead of letting requests through - getAuthContext(): Promise { - void this.cookie - l.warn( - { - key: 'auth_provider:ory_stub_unauthenticated', - }, - 'OryHostedAuthProvider.getAuthContext is a stub and always returns null' - ) - return Promise.resolve(null) - } + if (!session?.user?.id || !session.accessToken) { + return null + } + + if (session.error) { + l.warn( + { + key: 'auth_provider:ory_session_error', + user_id: session.user.id, + context: { error: session.error }, + }, + `Auth.js session reports error '${session.error}'; treating as unauthenticated` + ) + return null + } - signOut(_options?: SignOutOptions): Promise { + return { + user: fromAuthSession(session), + accessToken: session.accessToken, + } + }, + + signOut() { return Promise.resolve({ error: { - message: 'OryHostedAuthProvider.signOut is not implemented yet', - code: 'ory_stub_not_implemented', + message: + 'Ory sign-out must redirect through /api/auth/oauth/signout-flow', + code: 'ory_sign_out_requires_route', }, }) - } -} - -export function createOryAuthForProxy( - request: NextRequest, - _response: NextResponse -): OryHostedAuthProvider { - return new OryHostedAuthProvider(request.headers.get('cookie') ?? '') -} - -export function createOryAuthForHeaders( - headers: Headers -): OryHostedAuthProvider { - return new OryHostedAuthProvider(headers.get('cookie') ?? '') + }, } diff --git a/src/core/server/auth/ory/signout.ts b/src/core/server/auth/ory/signout.ts new file mode 100644 index 000000000..dfd919db7 --- /dev/null +++ b/src/core/server/auth/ory/signout.ts @@ -0,0 +1,131 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' + +export const ORY_SIGN_OUT_FLOW_PATH = '/api/auth/oauth/signout-flow' +export const ORY_POST_LOGOUT_CALLBACK_PATH = '/api/auth/oauth/signed-out' + +const LOGOUT_STATE_MAX_AGE_MS = 10 * 60 * 1000 + +type SignOutMessage = { + type: 'error' | 'success' | 'message' + value: string +} + +export type OrySignOutOptions = { + returnTo?: string + message?: SignOutMessage +} + +type LogoutState = OrySignOutOptions & { + expiresAt: number +} + +export function getOrySignOutPath(options: OrySignOutOptions = {}): string { + const params = new URLSearchParams() + + if (options.returnTo) params.set('returnTo', options.returnTo) + if (options.message) { + params.set('messageType', options.message.type) + params.set('message', options.message.value) + } + + const query = params.toString() + return query ? `${ORY_SIGN_OUT_FLOW_PATH}?${query}` : ORY_SIGN_OUT_FLOW_PATH +} + +export function getLogoutFinalUrl( + options: OrySignOutOptions | null, + origin: string +): string { + const url = new URL(resolveReturnTo(options?.returnTo, origin)) + + if (options?.message) { + url.searchParams.set(options.message.type, options.message.value) + } + + return url.toString() +} + +export function buildLogoutState(options: OrySignOutOptions): string | null { + const secret = process.env.AUTH_SECRET + if (!secret) return null + + const state: LogoutState = { + ...options, + expiresAt: Date.now() + LOGOUT_STATE_MAX_AGE_MS, + } + const payload = base64UrlEncode(JSON.stringify(state)) + const signature = sign(payload, secret) + + return `${payload}.${signature}` +} + +export function parseLogoutState( + state: string | null +): OrySignOutOptions | null { + if (!state) return null + + const secret = process.env.AUTH_SECRET + if (!secret) return null + + const [payload, signature] = state.split('.') + if (!payload || !signature || !safeEqual(signature, sign(payload, secret))) { + return null + } + + try { + const parsed = JSON.parse(base64UrlDecode(payload)) as Partial + if (!parsed.expiresAt || parsed.expiresAt < Date.now()) return null + + return { + returnTo: + typeof parsed.returnTo === 'string' ? parsed.returnTo : undefined, + message: isSignOutMessage(parsed.message) ? parsed.message : undefined, + } + } catch { + return null + } +} + +function isSignOutMessage(value: unknown): value is SignOutMessage { + if (!value || typeof value !== 'object') return false + + const message = value as Partial + return ( + (message.type === 'error' || + message.type === 'success' || + message.type === 'message') && + typeof message.value === 'string' + ) +} + +function resolveReturnTo(returnTo: string | undefined, origin: string): string { + if (!returnTo) return `${origin}/` + if (returnTo.startsWith('/')) return `${origin}${returnTo}` + + try { + if (new URL(returnTo).origin === origin) return returnTo + } catch { + return `${origin}/` + } + + return `${origin}/` +} + +function sign(payload: string, secret: string): string { + return createHmac('sha256', secret).update(payload).digest('base64url') +} + +function safeEqual(a: string, b: string): boolean { + const left = Buffer.from(a) + const right = Buffer.from(b) + + return left.length === right.length && timingSafeEqual(left, right) +} + +function base64UrlEncode(value: string): string { + return Buffer.from(value).toString('base64url') +} + +function base64UrlDecode(value: string): string { + return Buffer.from(value, 'base64url').toString('utf8') +} diff --git a/src/core/server/functions/sandboxes/get-team-metrics-core.ts b/src/core/server/functions/sandboxes/get-team-metrics-core.ts index 2457b305a..a6851d89f 100644 --- a/src/core/server/functions/sandboxes/get-team-metrics-core.ts +++ b/src/core/server/functions/sandboxes/get-team-metrics-core.ts @@ -1,7 +1,7 @@ import 'server-only' import { cache } from 'react' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { USE_MOCK_DATA } from '@/configs/flags' import { calculateTeamMetricsStep, @@ -89,7 +89,7 @@ export const getTeamMetricsCore = cache( }, }, headers: { - ...SUPABASE_AUTH_HEADERS(accessToken, teamId), + ...authHeaders(accessToken, teamId), }, cache: 'no-store', }) diff --git a/src/core/server/functions/sandboxes/get-team-metrics-max.ts b/src/core/server/functions/sandboxes/get-team-metrics-max.ts index 0a2078f8a..9a5a4a066 100644 --- a/src/core/server/functions/sandboxes/get-team-metrics-max.ts +++ b/src/core/server/functions/sandboxes/get-team-metrics-max.ts @@ -1,7 +1,7 @@ import 'server-only' import { z } from 'zod' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { USE_MOCK_DATA } from '@/configs/flags' import { MOCK_TEAM_METRICS_MAX_DATA } from '@/configs/mock-data' import { @@ -79,7 +79,7 @@ export const getTeamMetricsMax = authActionClient }, }, headers: { - ...SUPABASE_AUTH_HEADERS(session.access_token, teamId), + ...authHeaders(session.access_token, teamId), }, cache: 'no-store', }) diff --git a/src/core/shared/contracts/dashboard-api.types.ts b/src/core/shared/contracts/dashboard-api.types.ts index 6cf043667..b5b5ccee7 100644 --- a/src/core/shared/contracts/dashboard-api.types.ts +++ b/src/core/shared/contracts/dashboard-api.types.ts @@ -4,871 +4,1067 @@ */ export interface paths { - '/health': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Health check */ - get: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description Health check successful */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['HealthResponse'] - } - } - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/builds': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** List team builds */ - get: { - parameters: { - query?: { - /** @description Optional filter by build identifier, template identifier, or template alias. */ - build_id_or_template?: components['parameters']['build_id_or_template'] - /** @description Comma-separated list of build statuses to include. */ - statuses?: components['parameters']['build_statuses'] - /** @description Maximum number of items to return per page. */ - limit?: components['parameters']['builds_limit'] - /** @description Cursor returned by the previous list response in `created_at|build_id` format. */ - cursor?: components['parameters']['builds_cursor'] - } - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned paginated builds. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['BuildsListResponse'] - } - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 403: components['responses']['403'] - 500: components['responses']['500'] - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/builds/statuses': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Get build statuses */ - get: { - parameters: { - query: { - /** @description Comma-separated list of build IDs to get statuses for. */ - build_ids: components['parameters']['build_ids'] - } - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned build statuses */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['BuildsStatusesResponse'] - } - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 403: components['responses']['403'] - 500: components['responses']['500'] - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/builds/{build_id}': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Get build details */ - get: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the build. */ - build_id: components['parameters']['build_id'] - } - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned build details. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['BuildInfo'] - } - } - 401: components['responses']['401'] - 403: components['responses']['403'] - 404: components['responses']['404'] - 500: components['responses']['500'] - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/sandboxes/{sandboxID}/record': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Get sandbox record */ - get: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the sandbox. */ - sandboxID: components['parameters']['sandboxID'] - } - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned sandbox details. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['SandboxRecord'] - } - } - 401: components['responses']['401'] - 403: components['responses']['403'] - 404: components['responses']['404'] - 500: components['responses']['500'] - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/teams': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** - * List user teams - * @description Returns all teams the authenticated user belongs to, with limits and default flag. - */ - get: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned user teams. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['UserTeamsResponse'] - } - } - 401: components['responses']['401'] - 500: components['responses']['500'] - } - } - put?: never - /** Create team */ - post: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['CreateTeamRequest'] - } - } - responses: { - /** @description Successfully created team. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['TeamResolveResponse'] - } - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 500: components['responses']['500'] - } - } - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/admin/users/{userId}/bootstrap': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - /** Bootstrap user */ - post: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the user. */ - userId: components['parameters']['userId'] - } - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully bootstrapped user. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['TeamResolveResponse'] - } - } - 401: components['responses']['401'] - 500: components['responses']['500'] - } - } - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/teams/resolve': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** - * Resolve team identity - * @description Resolves a team slug to the team's identity, validating the user is a member. - */ - get: { - parameters: { - query: { - /** @description Team slug to resolve. */ - slug: components['parameters']['teamSlug'] - } - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully resolved team. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['TeamResolveResponse'] - } - } - 401: components['responses']['401'] - 403: components['responses']['403'] - 404: components['responses']['404'] - 500: components['responses']['500'] - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/teams/{teamID}': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - post?: never - delete?: never - options?: never - head?: never - /** Update team */ - patch: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the team. */ - teamID: components['parameters']['teamID'] - } - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['UpdateTeamRequest'] - } - } - responses: { - /** @description Successfully updated team. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['UpdateTeamResponse'] - } - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 403: components['responses']['403'] - 500: components['responses']['500'] - } - } - trace?: never - } - '/teams/{teamID}/members': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** List team members */ - get: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the team. */ - teamID: components['parameters']['teamID'] - } - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned team members. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['TeamMembersResponse'] - } - } - 401: components['responses']['401'] - 403: components['responses']['403'] - 500: components['responses']['500'] - } - } - put?: never - /** Add team member */ - post: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the team. */ - teamID: components['parameters']['teamID'] - } - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['AddTeamMemberRequest'] - } - } - responses: { - /** @description Successfully added team member. */ - 201: { - headers: { - [name: string]: unknown - } - content?: never - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 403: components['responses']['403'] - 404: components['responses']['404'] - 500: components['responses']['500'] - } - } - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/teams/{teamID}/members/{userId}': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - post?: never - /** Remove team member */ - delete: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the team. */ - teamID: components['parameters']['teamID'] - /** @description Identifier of the user. */ - userId: components['parameters']['userId'] - } - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully removed team member. */ - 204: { - headers: { - [name: string]: unknown - } - content?: never - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 403: components['responses']['403'] - 500: components['responses']['500'] - } - } - options?: never - head?: never - patch?: never - trace?: never - } - '/templates/defaults': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** - * List default templates - * @description Returns the list of default templates with their latest build info and aliases. - */ - get: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned default templates. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['DefaultTemplatesResponse'] - } - } - 401: components['responses']['401'] - 500: components['responses']['500'] - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health check */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Health check successful */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HealthResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/builds": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List team builds */ + get: { + parameters: { + query?: { + /** @description Optional filter by build identifier, template identifier, or template alias. */ + build_id_or_template?: components["parameters"]["build_id_or_template"]; + /** @description Comma-separated list of build statuses to include. */ + statuses?: components["parameters"]["build_statuses"]; + /** @description Maximum number of items to return per page. */ + limit?: components["parameters"]["builds_limit"]; + /** @description Cursor returned by the previous list response in `created_at|build_id` format. */ + cursor?: components["parameters"]["builds_cursor"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned paginated builds. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BuildsListResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/builds/statuses": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get build statuses */ + get: { + parameters: { + query: { + /** @description Comma-separated list of build IDs to get statuses for. */ + build_ids: components["parameters"]["build_ids"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned build statuses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BuildsStatusesResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/builds/{build_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get build details */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the build. */ + build_id: components["parameters"]["build_id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned build details. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BuildInfo"]; + }; + }; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/{sandboxID}/record": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get sandbox record */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the sandbox. */ + sandboxID: components["parameters"]["sandboxID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned sandbox details. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SandboxRecord"]; + }; + }; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/teams": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List user teams + * @description Returns all teams the authenticated user belongs to, with limits and default flag. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned user teams. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserTeamsResponse"]; + }; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + /** Create team */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateTeamRequest"]; + }; + }; + responses: { + /** @description Successfully created team. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TeamResolveResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/users/{userId}/bootstrap": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Bootstrap user */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the user. */ + userId: components["parameters"]["userId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully bootstrapped user. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TeamResolveResponse"]; + }; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/users/bootstrap": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Bootstrap auth provider user */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminAuthProviderUserBootstrapRequest"]; + }; + }; + responses: { + /** @description Successfully bootstrapped user. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TeamResolveResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/user-profiles/resolve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Resolve user profiles */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminAuthProviderProfilesResolveRequest"]; + }; + }; + responses: { + /** @description Successfully resolved profiles. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAuthProviderProfilesResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/user-profiles/by-email": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Lookup user profiles by email */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminAuthProviderProfilesLookupEmailRequest"]; + }; + }; + responses: { + /** @description Successfully found matching profiles. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAuthProviderProfilesResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/user-profiles/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get user profile */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the user. */ + userId: components["parameters"]["userId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully found profile. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAuthProviderProfilesResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/teams/resolve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Resolve team identity + * @description Resolves a team slug to the team's identity, validating the user is a member. + */ + get: { + parameters: { + query: { + /** @description Team slug to resolve. */ + slug: components["parameters"]["teamSlug"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully resolved team. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TeamResolveResponse"]; + }; + }; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/teams/{teamID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Update team */ + patch: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the team. */ + teamID: components["parameters"]["teamID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateTeamRequest"]; + }; + }; + responses: { + /** @description Successfully updated team. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UpdateTeamResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 500: components["responses"]["500"]; + }; + }; + trace?: never; + }; + "/teams/{teamID}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List team members */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the team. */ + teamID: components["parameters"]["teamID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned team members. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TeamMembersResponse"]; + }; + }; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + /** Add team member */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the team. */ + teamID: components["parameters"]["teamID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddTeamMemberRequest"]; + }; + }; + responses: { + /** @description Successfully added team member. */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/teams/{teamID}/members/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove team member */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the team. */ + teamID: components["parameters"]["teamID"]; + /** @description Identifier of the user. */ + userId: components["parameters"]["userId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully removed team member. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 500: components["responses"]["500"]; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/templates/defaults": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List default templates + * @description Returns the list of default templates with their latest build info and aliases. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned default templates. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DefaultTemplatesResponse"]; + }; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } -export type webhooks = Record +export type webhooks = Record; export interface components { - schemas: { - Error: { - /** - * Format: int32 - * @description Error code. - */ - code: number - /** @description Error message. */ - message: string - } - /** - * @description Build status mapped for dashboard clients. - * @enum {string} - */ - BuildStatus: 'building' | 'failed' | 'success' - ListedBuild: { - /** - * Format: uuid - * @description Identifier of the build. - */ - id: string - /** @description Template alias when present, otherwise template ID. */ - template: string - /** @description Identifier of the template. */ - templateId: string - status: components['schemas']['BuildStatus'] - /** @description Failure message when status is `failed`, otherwise `null`. */ - statusMessage: string | null - /** - * Format: date-time - * @description Build creation timestamp in RFC3339 format. - */ - createdAt: string - /** - * Format: date-time - * @description Build completion timestamp in RFC3339 format, if finished. - */ - finishedAt: string | null - } - BuildsListResponse: { - data: components['schemas']['ListedBuild'][] - /** @description Cursor to pass to the next list request, or `null` if there is no next page. */ - nextCursor: string | null - } - BuildStatusItem: { - /** - * Format: uuid - * @description Identifier of the build. - */ - id: string - status: components['schemas']['BuildStatus'] - /** - * Format: date-time - * @description Build completion timestamp in RFC3339 format, if finished. - */ - finishedAt: string | null - /** @description Failure message when status is `failed`, otherwise `null`. */ - statusMessage: string | null - } - BuildsStatusesResponse: { - /** @description List of build statuses */ - buildStatuses: components['schemas']['BuildStatusItem'][] - } - BuildInfo: { - /** @description Template names related to this build, if available. */ - names?: string[] | null - /** - * Format: date-time - * @description Build creation timestamp in RFC3339 format. - */ - createdAt: string - /** - * Format: date-time - * @description Build completion timestamp in RFC3339 format, if finished. - */ - finishedAt: string | null - status: components['schemas']['BuildStatus'] - /** @description Failure message when status is `failed`, otherwise `null`. */ - statusMessage: string | null - } - /** - * Format: int64 - * @description CPU cores for the sandbox - */ - CPUCount: number - /** - * Format: int64 - * @description Memory for the sandbox in MiB - */ - MemoryMB: number - /** - * Format: int64 - * @description Disk size for the sandbox in MiB - */ - DiskSizeMB: number - SandboxRecord: { - /** @description Identifier of the template from which is the sandbox created */ - templateID: string - /** @description Alias of the template */ - alias?: string - /** @description Identifier of the sandbox */ - sandboxID: string - /** - * Format: date-time - * @description Time when the sandbox was started - */ - startedAt: string - /** - * Format: date-time - * @description Time when the sandbox was stopped - */ - stoppedAt?: string | null - /** @description Base domain where the sandbox traffic is accessible */ - domain?: string | null - cpuCount: components['schemas']['CPUCount'] - memoryMB: components['schemas']['MemoryMB'] - diskSizeMB: components['schemas']['DiskSizeMB'] - } - HealthResponse: { - /** @description Human-readable health check result. */ - message: string - } - UserTeamLimits: { - /** Format: int64 */ - maxLengthHours: number - /** Format: int32 */ - concurrentSandboxes: number - /** Format: int32 */ - concurrentTemplateBuilds: number - /** Format: int32 */ - maxVcpu: number - /** Format: int32 */ - maxRamMb: number - /** Format: int32 */ - diskMb: number - } - UserTeam: { - /** Format: uuid */ - id: string - name: string - slug: string - tier: string - email: string - profilePictureUrl: string | null - isBlocked: boolean - isBanned: boolean - blockedReason: string | null - isDefault: boolean - limits: components['schemas']['UserTeamLimits'] - /** Format: date-time */ - createdAt: string - } - UserTeamsResponse: { - teams: components['schemas']['UserTeam'][] - } - TeamMember: { - /** Format: uuid */ - id: string - email: string - isDefault: boolean - /** Format: uuid */ - addedBy?: string | null - /** Format: date-time */ - createdAt: string | null - } - TeamMembersResponse: { - members: components['schemas']['TeamMember'][] - } - UpdateTeamRequest: { - name?: string - profilePictureUrl?: string | null - } - UpdateTeamResponse: { - /** Format: uuid */ - id: string - name: string - profilePictureUrl?: string | null - } - AddTeamMemberRequest: { - /** Format: email */ - email: string - } - CreateTeamRequest: { - name: string - } - DefaultTemplateAlias: { - alias: string - namespace?: string | null - } - DefaultTemplate: { - id: string - aliases: components['schemas']['DefaultTemplateAlias'][] - /** Format: uuid */ - buildId: string - /** Format: int64 */ - ramMb: number - /** Format: int64 */ - vcpu: number - /** Format: int64 */ - totalDiskSizeMb: number | null - envdVersion?: string | null - /** Format: date-time */ - createdAt: string - public: boolean - /** Format: int32 */ - buildCount: number - /** Format: int64 */ - spawnCount: number - } - DefaultTemplatesResponse: { - templates: components['schemas']['DefaultTemplate'][] - } - TeamResolveResponse: { - /** Format: uuid */ - id: string - slug: string - } - } - responses: { - /** @description Bad request */ - 400: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['Error'] - } - } - /** @description Authentication error */ - 401: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['Error'] - } - } - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['Error'] - } - } - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['Error'] - } - } - /** @description Server error */ - 500: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['Error'] - } - } - } - parameters: { - /** @description Identifier of the build. */ - build_id: string - /** @description Identifier of the sandbox. */ - sandboxID: string - /** @description Maximum number of items to return per page. */ - builds_limit: number - /** @description Cursor returned by the previous list response in `created_at|build_id` format. */ - builds_cursor: string - /** @description Optional filter by build identifier, template identifier, or template alias. */ - build_id_or_template: string - /** @description Comma-separated list of build statuses to include. */ - build_statuses: components['schemas']['BuildStatus'][] - /** @description Comma-separated list of build IDs to get statuses for. */ - build_ids: string[] - /** @description Identifier of the team. */ - teamID: string - /** @description Identifier of the user. */ - userId: string - /** @description Team slug to resolve. */ - teamSlug: string - } - requestBodies: never - headers: never - pathItems: never + schemas: { + Error: { + /** + * Format: int32 + * @description Error code. + */ + code: number; + /** @description Error message. */ + message: string; + }; + AdminAuthProviderProfile: { + /** + * Format: uuid + * @description Internal E2B user identifier. + */ + userId: string; + /** @description Email address from the configured auth provider. */ + email: string | null; + }; + AdminAuthProviderProfilesResponse: { + profiles: components["schemas"]["AdminAuthProviderProfile"][]; + }; + AdminAuthProviderProfilesResolveRequest: { + userIds: string[]; + }; + AdminAuthProviderProfilesLookupEmailRequest: { + /** Format: email */ + email: string; + }; + AdminAuthProviderUserBootstrapRequest: { + oidc_user_id: string; + /** Format: email */ + oidc_user_email: string; + oidc_user_name: string | null; + }; + /** + * @description Build status mapped for dashboard clients. + * @enum {string} + */ + BuildStatus: "building" | "failed" | "success"; + ListedBuild: { + /** + * Format: uuid + * @description Identifier of the build. + */ + id: string; + /** @description Template alias when present, otherwise template ID. */ + template: string; + /** @description Identifier of the template. */ + templateId: string; + status: components["schemas"]["BuildStatus"]; + /** @description Failure message when status is `failed`, otherwise `null`. */ + statusMessage: string | null; + /** + * Format: date-time + * @description Build creation timestamp in RFC3339 format. + */ + createdAt: string; + /** + * Format: date-time + * @description Build completion timestamp in RFC3339 format, if finished. + */ + finishedAt: string | null; + }; + BuildsListResponse: { + data: components["schemas"]["ListedBuild"][]; + /** @description Cursor to pass to the next list request, or `null` if there is no next page. */ + nextCursor: string | null; + }; + BuildStatusItem: { + /** + * Format: uuid + * @description Identifier of the build. + */ + id: string; + status: components["schemas"]["BuildStatus"]; + /** + * Format: date-time + * @description Build completion timestamp in RFC3339 format, if finished. + */ + finishedAt: string | null; + /** @description Failure message when status is `failed`, otherwise `null`. */ + statusMessage: string | null; + }; + BuildsStatusesResponse: { + /** @description List of build statuses */ + buildStatuses: components["schemas"]["BuildStatusItem"][]; + }; + BuildInfo: { + /** @description Template names related to this build, if available. */ + names?: string[] | null; + /** + * Format: date-time + * @description Build creation timestamp in RFC3339 format. + */ + createdAt: string; + /** + * Format: date-time + * @description Build completion timestamp in RFC3339 format, if finished. + */ + finishedAt: string | null; + status: components["schemas"]["BuildStatus"]; + /** @description Failure message when status is `failed`, otherwise `null`. */ + statusMessage: string | null; + }; + /** + * Format: int64 + * @description CPU cores for the sandbox + */ + CPUCount: number; + /** + * Format: int64 + * @description Memory for the sandbox in MiB + */ + MemoryMB: number; + /** + * Format: int64 + * @description Disk size for the sandbox in MiB + */ + DiskSizeMB: number; + SandboxRecord: { + /** @description Identifier of the template from which is the sandbox created */ + templateID: string; + /** @description Alias of the template */ + alias?: string; + /** @description Identifier of the sandbox */ + sandboxID: string; + /** + * Format: date-time + * @description Time when the sandbox was started + */ + startedAt: string; + /** + * Format: date-time + * @description Time when the sandbox was stopped + */ + stoppedAt?: string | null; + /** @description Base domain where the sandbox traffic is accessible */ + domain?: string | null; + cpuCount: components["schemas"]["CPUCount"]; + memoryMB: components["schemas"]["MemoryMB"]; + diskSizeMB: components["schemas"]["DiskSizeMB"]; + }; + HealthResponse: { + /** @description Human-readable health check result. */ + message: string; + }; + UserTeamLimits: { + /** Format: int64 */ + maxLengthHours: number; + /** Format: int32 */ + concurrentSandboxes: number; + /** Format: int32 */ + concurrentTemplateBuilds: number; + /** Format: int32 */ + maxVcpu: number; + /** Format: int32 */ + maxRamMb: number; + /** Format: int32 */ + diskMb: number; + }; + UserTeam: { + /** Format: uuid */ + id: string; + name: string; + slug: string; + tier: string; + email: string; + profilePictureUrl: string | null; + isBlocked: boolean; + isBanned: boolean; + blockedReason: string | null; + isDefault: boolean; + limits: components["schemas"]["UserTeamLimits"]; + /** Format: date-time */ + createdAt: string; + }; + UserTeamsResponse: { + teams: components["schemas"]["UserTeam"][]; + }; + TeamMember: { + /** Format: uuid */ + id: string; + email: string; + isDefault: boolean; + /** Format: uuid */ + addedBy?: string | null; + /** Format: date-time */ + createdAt: string | null; + }; + TeamMembersResponse: { + members: components["schemas"]["TeamMember"][]; + }; + UpdateTeamRequest: { + name?: string; + profilePictureUrl?: string | null; + }; + UpdateTeamResponse: { + /** Format: uuid */ + id: string; + name: string; + profilePictureUrl?: string | null; + }; + AddTeamMemberRequest: { + /** Format: email */ + email: string; + }; + CreateTeamRequest: { + name: string; + }; + DefaultTemplateAlias: { + alias: string; + namespace?: string | null; + }; + DefaultTemplate: { + id: string; + aliases: components["schemas"]["DefaultTemplateAlias"][]; + /** Format: uuid */ + buildId: string; + /** Format: int64 */ + ramMb: number; + /** Format: int64 */ + vcpu: number; + /** Format: int64 */ + totalDiskSizeMb: number | null; + envdVersion?: string | null; + /** Format: date-time */ + createdAt: string; + public: boolean; + /** Format: int32 */ + buildCount: number; + /** Format: int64 */ + spawnCount: number; + }; + DefaultTemplatesResponse: { + templates: components["schemas"]["DefaultTemplate"][]; + }; + TeamResolveResponse: { + /** Format: uuid */ + id: string; + slug: string; + }; + }; + responses: { + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Authentication error */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + parameters: { + /** @description Identifier of the build. */ + build_id: string; + /** @description Identifier of the sandbox. */ + sandboxID: string; + /** @description Maximum number of items to return per page. */ + builds_limit: number; + /** @description Cursor returned by the previous list response in `created_at|build_id` format. */ + builds_cursor: string; + /** @description Optional filter by build identifier, template identifier, or template alias. */ + build_id_or_template: string; + /** @description Comma-separated list of build statuses to include. */ + build_statuses: components["schemas"]["BuildStatus"][]; + /** @description Comma-separated list of build IDs to get statuses for. */ + build_ids: string[]; + /** @description Identifier of the team. */ + teamID: string; + /** @description Identifier of the user. */ + userId: string; + /** @description Team slug to resolve. */ + teamSlug: string; + }; + requestBodies: never; + headers: never; + pathItems: never; } -export type $defs = Record -export type operations = Record +export type $defs = Record; +export type operations = Record; diff --git a/src/features/auth/ory-hosted-auth-redirect.tsx b/src/features/auth/ory-hosted-auth-redirect.tsx new file mode 100644 index 000000000..02f954199 --- /dev/null +++ b/src/features/auth/ory-hosted-auth-redirect.tsx @@ -0,0 +1,36 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { signInWithOryAction } from '@/core/server/actions/ory-auth-actions' + +interface OryHostedAuthRedirectProps { + returnTo?: string +} + +export function OryHostedAuthRedirect({ + returnTo, +}: OryHostedAuthRedirectProps) { + const formRef = useRef(null) + + useEffect(() => { + formRef.current?.requestSubmit() + }, []) + + return ( +
+

Redirecting…

+

+ Hold on while we send you to the sign-in page. +

+
+ + +
+
+ ) +} diff --git a/src/features/dashboard/sandbox/inspect/context.tsx b/src/features/dashboard/sandbox/inspect/context.tsx index e15acc0d7..3148eb6ca 100644 --- a/src/features/dashboard/sandbox/inspect/context.tsx +++ b/src/features/dashboard/sandbox/inspect/context.tsx @@ -11,7 +11,7 @@ import { useMemo, useRef, } from 'react' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { AUTH_URLS } from '@/configs/urls' import { supabase } from '@/core/shared/clients/supabase/client' import { useSandboxInspectAnalytics } from '@/lib/hooks/use-analytics' @@ -193,7 +193,7 @@ export default function SandboxInspectProvider({ // Keep inspect connections from extending sandbox TTL via SDK default connect timeout. timeoutMs: 1_000, headers: { - ...SUPABASE_AUTH_HEADERS(data.session.access_token, teamId), + ...authHeaders(data.session.access_token, teamId), }, }) diff --git a/src/features/dashboard/terminal/sandbox-session.ts b/src/features/dashboard/terminal/sandbox-session.ts index 2e6f86700..a0179d4c5 100644 --- a/src/features/dashboard/terminal/sandbox-session.ts +++ b/src/features/dashboard/terminal/sandbox-session.ts @@ -1,5 +1,5 @@ import Sandbox from 'e2b' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { supabase } from '@/core/shared/clients/supabase/client' import { TERMINAL_SANDBOX_TIMEOUT_MS } from './constants' import { @@ -30,7 +30,7 @@ export async function openTerminalSandbox({ } const userId = data.session.user.id - const headers = SUPABASE_AUTH_HEADERS(data.session.access_token, teamId) + const headers = authHeaders(data.session.access_token, teamId) if (sandboxId) { onStatus(`Connecting to terminal sandbox ${sandboxId}...\r\n`) diff --git a/src/lib/env.ts b/src/lib/env.ts index ab016be32..0034e4b82 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -14,6 +14,15 @@ export const serverSchema = z.object({ TURNSTILE_SECRET_KEY: z.string().optional(), + AUTH_PROVIDER: z.enum(['supabase', 'ory']).optional(), + AUTH_SECRET: z.string().min(1).optional(), + AUTH_TRUST_HOST: z.string().optional(), + ORY_SDK_URL: z.url().optional(), + ORY_OAUTH2_CLIENT_ID: z.string().min(1).optional(), + ORY_OAUTH2_CLIENT_SECRET: z.string().min(1).optional(), + ORY_OAUTH2_AUDIENCE: z.string().min(1).optional(), + ORY_PROJECT_API_TOKEN: z.string().min(1).optional(), + OTEL_SERVICE_NAME: z.string().optional(), OTEL_EXPORTER_OTLP_ENDPOINT: z.url().optional(), OTEL_EXPORTER_OTLP_PROTOCOL: z diff --git a/src/lib/utils/server.ts b/src/lib/utils/server.ts index 40732c351..2d8d1bd8c 100644 --- a/src/lib/utils/server.ts +++ b/src/lib/utils/server.ts @@ -3,7 +3,7 @@ import 'server-only' import { cookies } from 'next/headers' import { cache } from 'react' import { z } from 'zod' -import { SUPABASE_AUTH_HEADERS } from '@/configs/api' +import { authHeaders } from '@/configs/api' import { COOKIE_KEYS } from '@/configs/cookies' import { returnServerError } from '@/core/server/actions/utils' import { infra } from '@/core/shared/clients/api' @@ -12,7 +12,7 @@ import { l } from '@/core/shared/clients/logger/logger' /* * This function generates an e2b user access token for a given user. */ -export async function generateE2BUserAccessToken(supabaseAccessToken: string) { +export async function generateE2BUserAccessToken(accessToken: string) { const TOKEN_NAME = 'e2b_dashboard_generated_access_token' const res = await infra.POST('/access-tokens', { @@ -20,7 +20,7 @@ export async function generateE2BUserAccessToken(supabaseAccessToken: string) { name: TOKEN_NAME, }, headers: { - ...SUPABASE_AUTH_HEADERS(supabaseAccessToken), + ...authHeaders(accessToken), }, }) diff --git a/src/proxy.ts b/src/proxy.ts index 98aa919f7..ede4d44f3 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,12 +1,20 @@ -import { type NextRequest, NextResponse } from 'next/server' -import { ALLOW_SEO_INDEXING } from './configs/flags' +import { + type NextFetchEvent, + type NextRequest, + NextResponse, +} from 'next/server' +import { auth as authjsMiddleware } from '@/auth' +import { ALLOW_SEO_INDEXING, isOryAuthEnabled } from './configs/flags' import { createAuthForProxy } from './core/server/auth' import { getAuthRedirect } from './core/server/http/proxy' import { l, serializeErrorForLog } from './core/shared/clients/logger/logger' import { getMiddlewareRedirectFromPath } from './lib/utils/redirects' import { getRewriteForPath } from './lib/utils/rewrites' -export async function proxy(request: NextRequest) { +async function proxyCore( + request: NextRequest, + resolvedIsAuthenticated?: boolean +): Promise { try { const pathname = request.nextUrl.pathname @@ -75,11 +83,17 @@ export async function proxy(request: NextRequest) { const response = NextResponse.next({ request, }) - const authContext = await createAuthForProxy( - request, - response - ).getAuthContext() - const isAuthenticated = !!authContext + + let isAuthenticated: boolean + if (resolvedIsAuthenticated !== undefined) { + isAuthenticated = resolvedIsAuthenticated + } else { + const authContext = await createAuthForProxy( + request, + response + ).getAuthContext() + isAuthenticated = !!authContext + } const authRedirect = getAuthRedirect(request, isAuthenticated) @@ -108,6 +122,17 @@ export async function proxy(request: NextRequest) { } } +const proxyWithOryAuth = authjsMiddleware(async (req, _event: NextFetchEvent) => + proxyCore(req, !!req.auth) +) + +export async function proxy(request: NextRequest, event: NextFetchEvent) { + if (isOryAuthEnabled()) { + return proxyWithOryAuth(request, event) + } + return proxyCore(request) +} + export const config = { matcher: [ /* diff --git a/tests/integration/auth-ory-bootstrap.test.ts b/tests/integration/auth-ory-bootstrap.test.ts new file mode 100644 index 000000000..f46322737 --- /dev/null +++ b/tests/integration/auth-ory-bootstrap.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const loggerMocks = vi.hoisted(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})) + +const apiPostMock = vi.hoisted(() => vi.fn()) +const originalDashboardApiAdminToken = process.env.DASHBOARD_API_ADMIN_TOKEN + +function jwt(claims: Record) { + return [ + Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString( + 'base64url' + ), + Buffer.from(JSON.stringify(claims)).toString('base64url'), + 'signature', + ].join('.') +} + +vi.mock('@/core/shared/clients/logger/logger', () => ({ + l: loggerMocks, + serializeErrorForLog: vi.fn((error: unknown) => error), +})) + +vi.mock('@/configs/api', () => ({ + ADMIN_AUTH_HEADERS: vi.fn((token: string) => ({ 'X-Admin-Token': token })), +})) + +vi.mock('@/core/shared/clients/api', () => ({ + api: { + POST: apiPostMock, + }, +})) + +const { bootstrapOryUser } = await import('@/core/server/auth/ory/bootstrap') + +describe('bootstrapOryUser (Auth.js events.signIn handler)', () => { + beforeEach(() => { + process.env.DASHBOARD_API_ADMIN_TOKEN = 'admin-token' + loggerMocks.error.mockClear() + loggerMocks.warn.mockClear() + apiPostMock.mockReset() + }) + + afterEach(() => { + process.env.DASHBOARD_API_ADMIN_TOKEN = originalDashboardApiAdminToken + }) + + it('calls dashboard-api bootstrap with Ory user fields', async () => { + apiPostMock.mockResolvedValue({ + data: { id: 'team-1', slug: 'team-1' }, + error: null, + response: { ok: true, status: 200, statusText: 'OK' }, + }) + + await bootstrapOryUser({ + accessToken: jwt({ + iss: 'https://ory.example.test', + sub: 'access-token-sub', + email: 'access-token-user@example.com', + name: 'Access Token User', + }), + provider: 'ory', + }) + + expect(apiPostMock).toHaveBeenCalledTimes(1) + expect(apiPostMock).toHaveBeenCalledWith('/admin/users/bootstrap', { + body: { + oidc_user_id: 'access-token-sub', + oidc_user_email: 'access-token-user@example.com', + oidc_user_name: 'Access Token User', + }, + headers: { 'X-Admin-Token': 'admin-token' }, + }) + expect(loggerMocks.error).not.toHaveBeenCalled() + }) + + it('falls back to id_token email and name while keeping access-token subject', async () => { + apiPostMock.mockResolvedValue({ + data: { id: 'team-1', slug: 'team-1' }, + error: null, + response: { ok: true, status: 200, statusText: 'OK' }, + }) + + await bootstrapOryUser({ + accessToken: jwt({ + iss: 'https://ory.example.test', + sub: 'access-token-sub', + }), + idToken: jwt({ + iss: 'https://ory.example.test', + sub: 'id-token-sub', + email: 'id-token-user@example.com', + given_name: 'Id Token User', + }), + provider: 'ory', + }) + + expect(apiPostMock).toHaveBeenCalledWith('/admin/users/bootstrap', { + body: { + oidc_user_id: 'access-token-sub', + oidc_user_email: 'id-token-user@example.com', + oidc_user_name: 'Id Token User', + }, + headers: { 'X-Admin-Token': 'admin-token' }, + }) + }) + + it('logs but does not throw when the bootstrap call returns an api error', async () => { + apiPostMock.mockResolvedValue({ + data: null, + error: { status: 503, message: 'dashboard-api unavailable' }, + response: { ok: false, status: 503, statusText: 'Service Unavailable' }, + }) + + await expect( + bootstrapOryUser({ + accessToken: jwt({ + sub: 'access-token-sub', + email: 'user@example.com', + name: 'User', + }), + provider: 'ory', + }) + ).resolves.toBeUndefined() + + expect(loggerMocks.error).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'auth_events:bootstrap_user:error', + context: expect.objectContaining({ + provider: 'ory', + error_status: 503, + }), + }), + expect.stringContaining('dashboard-api unavailable') + ) + }) + + it('logs but does not throw when the repository throws', async () => { + const failure = new Error('network down') + apiPostMock.mockRejectedValue(failure) + + await expect( + bootstrapOryUser({ + accessToken: jwt({ + sub: 'access-token-sub', + email: 'user@example.com', + }), + }) + ).resolves.toBeUndefined() + + expect(loggerMocks.error).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'auth_events:bootstrap_user:exception', + error: failure, + }), + expect.stringContaining('threw unexpected exception') + ) + }) +}) diff --git a/tests/unit/auth-headers.test.ts b/tests/unit/auth-headers.test.ts new file mode 100644 index 000000000..4e8ac9e28 --- /dev/null +++ b/tests/unit/auth-headers.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + AUTH_PROVIDER_TEAM_HEADER, + authHeaders, + SUPABASE_TEAM_HEADER, + SUPABASE_TOKEN_HEADER, +} from '@/configs/api' + +const originalAuthProvider = process.env.AUTH_PROVIDER + +afterEach(() => { + process.env.AUTH_PROVIDER = originalAuthProvider +}) + +describe('authHeaders', () => { + it('uses Supabase headers by default', () => { + process.env.AUTH_PROVIDER = 'supabase' + + expect(authHeaders('token', 'team-id')).toEqual({ + [SUPABASE_TOKEN_HEADER]: 'token', + [SUPABASE_TEAM_HEADER]: 'team-id', + }) + }) + + it('uses Authorization and X-Team-ID in Ory mode', () => { + process.env.AUTH_PROVIDER = 'ory' + + expect(authHeaders('token', 'team-id')).toEqual({ + Authorization: 'Bearer token', + [AUTH_PROVIDER_TEAM_HEADER]: 'team-id', + }) + }) +}) diff --git a/tests/unit/auth-ory-provider.test.ts b/tests/unit/auth-ory-provider.test.ts new file mode 100644 index 000000000..9afc4cd3a --- /dev/null +++ b/tests/unit/auth-ory-provider.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const loggerMocks = vi.hoisted(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})) + +const authjsMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/core/shared/clients/logger/logger', () => ({ + l: loggerMocks, + serializeErrorForLog: vi.fn((error: unknown) => error), +})) + +vi.mock('@/auth', () => ({ + auth: authjsMock, +})) + +const { oryAuthProvider } = await import('@/core/server/auth/ory/provider') + +describe('OryAuthProvider', () => { + beforeEach(() => { + loggerMocks.error.mockClear() + loggerMocks.warn.mockClear() + authjsMock.mockReset() + }) + + describe('getAuthContext', () => { + it('returns null when there is no session', async () => { + authjsMock.mockResolvedValue(null) + + const result = await oryAuthProvider.getAuthContext() + + expect(result).toBeNull() + expect(loggerMocks.error).not.toHaveBeenCalled() + }) + + it('returns null when accessToken is missing', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'user-1', email: 'a@b.dev' }, + accessToken: undefined, + }) + + const result = await oryAuthProvider.getAuthContext() + + expect(result).toBeNull() + }) + + it('returns null and warns when the session reports a refresh error', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'user-1', email: 'a@b.dev' }, + accessToken: 'access-token', + error: 'RefreshTokenError', + }) + + const result = await oryAuthProvider.getAuthContext() + + expect(result).toBeNull() + expect(loggerMocks.warn).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'auth_provider:ory_session_error', + user_id: 'user-1', + context: expect.objectContaining({ error: 'RefreshTokenError' }), + }), + expect.stringContaining("error 'RefreshTokenError'") + ) + }) + + it('returns null and logs when Auth.js auth() throws', async () => { + const failure = new Error('boom') + authjsMock.mockRejectedValue(failure) + + const result = await oryAuthProvider.getAuthContext() + + expect(result).toBeNull() + expect(loggerMocks.error).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'auth_provider:ory_get_session:error', + error: failure, + }), + expect.stringContaining('Auth.js auth() helper threw') + ) + }) + + it('returns AuthContext on a happy session', async () => { + authjsMock.mockResolvedValue({ + user: { + id: 'user-1', + email: 'a@b.dev', + name: 'Alice', + image: 'https://example.test/a.png', + }, + accessToken: 'access-token', + }) + + const result = await oryAuthProvider.getAuthContext() + + expect(result).toEqual({ + user: { + id: 'user-1', + email: 'a@b.dev', + name: 'Alice', + avatarUrl: 'https://example.test/a.png', + providers: [], + }, + accessToken: 'access-token', + }) + expect(loggerMocks.error).not.toHaveBeenCalled() + }) + }) + + describe('signOut', () => { + it('returns an explicit error because Ory sign-out must go through the route handler', async () => { + const result = await oryAuthProvider.signOut() + expect(result).toEqual({ + error: { + message: + 'Ory sign-out must redirect through /api/auth/oauth/signout-flow', + code: 'ory_sign_out_requires_route', + }, + }) + }) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index 121a72515..22f0805f2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,6 +15,14 @@ export default defineConfig({ reporter: ['text', 'json', 'html'], }, setupFiles: ['./tests/setup.ts'], + server: { + deps: { + // next-auth ships ESM that imports 'next/server' without the .js extension + // which vitest's default resolver cannot follow. inlining lets vite's + // bundler resolve next.js exports correctly. + inline: [/next-auth/, /@auth\/core/], + }, + }, }, resolve: { From 03866672d80d91cbd7ab04ce5bfcbd41f7325ebf Mon Sep 17 00:00:00 2001 From: ben-fornefeld <50748440+ben-fornefeld@users.noreply.github.com> Date: Mon, 25 May 2026 01:32:26 +0000 Subject: [PATCH 02/16] style: apply biome formatting --- .../shared/contracts/dashboard-api.types.ts | 2122 ++++++++--------- 1 file changed, 1061 insertions(+), 1061 deletions(-) diff --git a/src/core/shared/contracts/dashboard-api.types.ts b/src/core/shared/contracts/dashboard-api.types.ts index b5b5ccee7..5a5cad2b4 100644 --- a/src/core/shared/contracts/dashboard-api.types.ts +++ b/src/core/shared/contracts/dashboard-api.types.ts @@ -4,1067 +4,1067 @@ */ export interface paths { - "/health": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Health check */ - get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Health check successful */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HealthResponse"]; - }; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/builds": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** List team builds */ - get: { - parameters: { - query?: { - /** @description Optional filter by build identifier, template identifier, or template alias. */ - build_id_or_template?: components["parameters"]["build_id_or_template"]; - /** @description Comma-separated list of build statuses to include. */ - statuses?: components["parameters"]["build_statuses"]; - /** @description Maximum number of items to return per page. */ - limit?: components["parameters"]["builds_limit"]; - /** @description Cursor returned by the previous list response in `created_at|build_id` format. */ - cursor?: components["parameters"]["builds_cursor"]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successfully returned paginated builds. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BuildsListResponse"]; - }; - }; - 400: components["responses"]["400"]; - 401: components["responses"]["401"]; - 403: components["responses"]["403"]; - 500: components["responses"]["500"]; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/builds/statuses": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get build statuses */ - get: { - parameters: { - query: { - /** @description Comma-separated list of build IDs to get statuses for. */ - build_ids: components["parameters"]["build_ids"]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successfully returned build statuses */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BuildsStatusesResponse"]; - }; - }; - 400: components["responses"]["400"]; - 401: components["responses"]["401"]; - 403: components["responses"]["403"]; - 500: components["responses"]["500"]; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/builds/{build_id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get build details */ - get: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Identifier of the build. */ - build_id: components["parameters"]["build_id"]; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successfully returned build details. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["BuildInfo"]; - }; - }; - 401: components["responses"]["401"]; - 403: components["responses"]["403"]; - 404: components["responses"]["404"]; - 500: components["responses"]["500"]; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/sandboxes/{sandboxID}/record": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get sandbox record */ - get: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Identifier of the sandbox. */ - sandboxID: components["parameters"]["sandboxID"]; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successfully returned sandbox details. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SandboxRecord"]; - }; - }; - 401: components["responses"]["401"]; - 403: components["responses"]["403"]; - 404: components["responses"]["404"]; - 500: components["responses"]["500"]; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/teams": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * List user teams - * @description Returns all teams the authenticated user belongs to, with limits and default flag. - */ - get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successfully returned user teams. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["UserTeamsResponse"]; - }; - }; - 401: components["responses"]["401"]; - 500: components["responses"]["500"]; - }; - }; - put?: never; - /** Create team */ - post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateTeamRequest"]; - }; - }; - responses: { - /** @description Successfully created team. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["TeamResolveResponse"]; - }; - }; - 400: components["responses"]["400"]; - 401: components["responses"]["401"]; - 500: components["responses"]["500"]; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/admin/users/{userId}/bootstrap": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Bootstrap user */ - post: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Identifier of the user. */ - userId: components["parameters"]["userId"]; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successfully bootstrapped user. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["TeamResolveResponse"]; - }; - }; - 401: components["responses"]["401"]; - 500: components["responses"]["500"]; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/admin/users/bootstrap": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Bootstrap auth provider user */ - post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminAuthProviderUserBootstrapRequest"]; - }; - }; - responses: { - /** @description Successfully bootstrapped user. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["TeamResolveResponse"]; - }; - }; - 400: components["responses"]["400"]; - 401: components["responses"]["401"]; - 500: components["responses"]["500"]; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/admin/user-profiles/resolve": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Resolve user profiles */ - post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminAuthProviderProfilesResolveRequest"]; - }; - }; - responses: { - /** @description Successfully resolved profiles. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AdminAuthProviderProfilesResponse"]; - }; - }; - 400: components["responses"]["400"]; - 401: components["responses"]["401"]; - 500: components["responses"]["500"]; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/admin/user-profiles/by-email": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Lookup user profiles by email */ - post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AdminAuthProviderProfilesLookupEmailRequest"]; - }; - }; - responses: { - /** @description Successfully found matching profiles. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AdminAuthProviderProfilesResponse"]; - }; - }; - 400: components["responses"]["400"]; - 401: components["responses"]["401"]; - 500: components["responses"]["500"]; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/admin/user-profiles/{userId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get user profile */ - get: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Identifier of the user. */ - userId: components["parameters"]["userId"]; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successfully found profile. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["AdminAuthProviderProfilesResponse"]; - }; - }; - 400: components["responses"]["400"]; - 401: components["responses"]["401"]; - 500: components["responses"]["500"]; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/teams/resolve": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Resolve team identity - * @description Resolves a team slug to the team's identity, validating the user is a member. - */ - get: { - parameters: { - query: { - /** @description Team slug to resolve. */ - slug: components["parameters"]["teamSlug"]; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successfully resolved team. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["TeamResolveResponse"]; - }; - }; - 401: components["responses"]["401"]; - 403: components["responses"]["403"]; - 404: components["responses"]["404"]; - 500: components["responses"]["500"]; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/teams/{teamID}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - /** Update team */ - patch: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Identifier of the team. */ - teamID: components["parameters"]["teamID"]; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateTeamRequest"]; - }; - }; - responses: { - /** @description Successfully updated team. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["UpdateTeamResponse"]; - }; - }; - 400: components["responses"]["400"]; - 401: components["responses"]["401"]; - 403: components["responses"]["403"]; - 500: components["responses"]["500"]; - }; - }; - trace?: never; - }; - "/teams/{teamID}/members": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** List team members */ - get: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Identifier of the team. */ - teamID: components["parameters"]["teamID"]; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successfully returned team members. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["TeamMembersResponse"]; - }; - }; - 401: components["responses"]["401"]; - 403: components["responses"]["403"]; - 500: components["responses"]["500"]; - }; - }; - put?: never; - /** Add team member */ - post: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Identifier of the team. */ - teamID: components["parameters"]["teamID"]; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["AddTeamMemberRequest"]; - }; - }; - responses: { - /** @description Successfully added team member. */ - 201: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 400: components["responses"]["400"]; - 401: components["responses"]["401"]; - 403: components["responses"]["403"]; - 404: components["responses"]["404"]; - 500: components["responses"]["500"]; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/teams/{teamID}/members/{userId}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - post?: never; - /** Remove team member */ - delete: { - parameters: { - query?: never; - header?: never; - path: { - /** @description Identifier of the team. */ - teamID: components["parameters"]["teamID"]; - /** @description Identifier of the user. */ - userId: components["parameters"]["userId"]; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successfully removed team member. */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 400: components["responses"]["400"]; - 401: components["responses"]["401"]; - 403: components["responses"]["403"]; - 500: components["responses"]["500"]; - }; - }; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/templates/defaults": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * List default templates - * @description Returns the list of default templates with their latest build info and aliases. - */ - get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successfully returned default templates. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["DefaultTemplatesResponse"]; - }; - }; - 401: components["responses"]["401"]; - 500: components["responses"]["500"]; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; + '/health': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Health check */ + get: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + /** @description Health check successful */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['HealthResponse'] + } + } + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/builds': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** List team builds */ + get: { + parameters: { + query?: { + /** @description Optional filter by build identifier, template identifier, or template alias. */ + build_id_or_template?: components['parameters']['build_id_or_template'] + /** @description Comma-separated list of build statuses to include. */ + statuses?: components['parameters']['build_statuses'] + /** @description Maximum number of items to return per page. */ + limit?: components['parameters']['builds_limit'] + /** @description Cursor returned by the previous list response in `created_at|build_id` format. */ + cursor?: components['parameters']['builds_cursor'] + } + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + /** @description Successfully returned paginated builds. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['BuildsListResponse'] + } + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 403: components['responses']['403'] + 500: components['responses']['500'] + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/builds/statuses': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Get build statuses */ + get: { + parameters: { + query: { + /** @description Comma-separated list of build IDs to get statuses for. */ + build_ids: components['parameters']['build_ids'] + } + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + /** @description Successfully returned build statuses */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['BuildsStatusesResponse'] + } + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 403: components['responses']['403'] + 500: components['responses']['500'] + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/builds/{build_id}': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Get build details */ + get: { + parameters: { + query?: never + header?: never + path: { + /** @description Identifier of the build. */ + build_id: components['parameters']['build_id'] + } + cookie?: never + } + requestBody?: never + responses: { + /** @description Successfully returned build details. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['BuildInfo'] + } + } + 401: components['responses']['401'] + 403: components['responses']['403'] + 404: components['responses']['404'] + 500: components['responses']['500'] + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/sandboxes/{sandboxID}/record': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Get sandbox record */ + get: { + parameters: { + query?: never + header?: never + path: { + /** @description Identifier of the sandbox. */ + sandboxID: components['parameters']['sandboxID'] + } + cookie?: never + } + requestBody?: never + responses: { + /** @description Successfully returned sandbox details. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['SandboxRecord'] + } + } + 401: components['responses']['401'] + 403: components['responses']['403'] + 404: components['responses']['404'] + 500: components['responses']['500'] + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/teams': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** + * List user teams + * @description Returns all teams the authenticated user belongs to, with limits and default flag. + */ + get: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + /** @description Successfully returned user teams. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['UserTeamsResponse'] + } + } + 401: components['responses']['401'] + 500: components['responses']['500'] + } + } + put?: never + /** Create team */ + post: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['CreateTeamRequest'] + } + } + responses: { + /** @description Successfully created team. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['TeamResolveResponse'] + } + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 500: components['responses']['500'] + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/admin/users/{userId}/bootstrap': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + /** Bootstrap user */ + post: { + parameters: { + query?: never + header?: never + path: { + /** @description Identifier of the user. */ + userId: components['parameters']['userId'] + } + cookie?: never + } + requestBody?: never + responses: { + /** @description Successfully bootstrapped user. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['TeamResolveResponse'] + } + } + 401: components['responses']['401'] + 500: components['responses']['500'] + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/admin/users/bootstrap': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + /** Bootstrap auth provider user */ + post: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['AdminAuthProviderUserBootstrapRequest'] + } + } + responses: { + /** @description Successfully bootstrapped user. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['TeamResolveResponse'] + } + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 500: components['responses']['500'] + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/admin/user-profiles/resolve': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + /** Resolve user profiles */ + post: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['AdminAuthProviderProfilesResolveRequest'] + } + } + responses: { + /** @description Successfully resolved profiles. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['AdminAuthProviderProfilesResponse'] + } + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 500: components['responses']['500'] + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/admin/user-profiles/by-email': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + /** Lookup user profiles by email */ + post: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['AdminAuthProviderProfilesLookupEmailRequest'] + } + } + responses: { + /** @description Successfully found matching profiles. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['AdminAuthProviderProfilesResponse'] + } + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 500: components['responses']['500'] + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/admin/user-profiles/{userId}': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Get user profile */ + get: { + parameters: { + query?: never + header?: never + path: { + /** @description Identifier of the user. */ + userId: components['parameters']['userId'] + } + cookie?: never + } + requestBody?: never + responses: { + /** @description Successfully found profile. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['AdminAuthProviderProfilesResponse'] + } + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 500: components['responses']['500'] + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/teams/resolve': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** + * Resolve team identity + * @description Resolves a team slug to the team's identity, validating the user is a member. + */ + get: { + parameters: { + query: { + /** @description Team slug to resolve. */ + slug: components['parameters']['teamSlug'] + } + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + /** @description Successfully resolved team. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['TeamResolveResponse'] + } + } + 401: components['responses']['401'] + 403: components['responses']['403'] + 404: components['responses']['404'] + 500: components['responses']['500'] + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/teams/{teamID}': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + post?: never + delete?: never + options?: never + head?: never + /** Update team */ + patch: { + parameters: { + query?: never + header?: never + path: { + /** @description Identifier of the team. */ + teamID: components['parameters']['teamID'] + } + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['UpdateTeamRequest'] + } + } + responses: { + /** @description Successfully updated team. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['UpdateTeamResponse'] + } + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 403: components['responses']['403'] + 500: components['responses']['500'] + } + } + trace?: never + } + '/teams/{teamID}/members': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** List team members */ + get: { + parameters: { + query?: never + header?: never + path: { + /** @description Identifier of the team. */ + teamID: components['parameters']['teamID'] + } + cookie?: never + } + requestBody?: never + responses: { + /** @description Successfully returned team members. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['TeamMembersResponse'] + } + } + 401: components['responses']['401'] + 403: components['responses']['403'] + 500: components['responses']['500'] + } + } + put?: never + /** Add team member */ + post: { + parameters: { + query?: never + header?: never + path: { + /** @description Identifier of the team. */ + teamID: components['parameters']['teamID'] + } + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['AddTeamMemberRequest'] + } + } + responses: { + /** @description Successfully added team member. */ + 201: { + headers: { + [name: string]: unknown + } + content?: never + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 403: components['responses']['403'] + 404: components['responses']['404'] + 500: components['responses']['500'] + } + } + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/teams/{teamID}/members/{userId}': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + post?: never + /** Remove team member */ + delete: { + parameters: { + query?: never + header?: never + path: { + /** @description Identifier of the team. */ + teamID: components['parameters']['teamID'] + /** @description Identifier of the user. */ + userId: components['parameters']['userId'] + } + cookie?: never + } + requestBody?: never + responses: { + /** @description Successfully removed team member. */ + 204: { + headers: { + [name: string]: unknown + } + content?: never + } + 400: components['responses']['400'] + 401: components['responses']['401'] + 403: components['responses']['403'] + 500: components['responses']['500'] + } + } + options?: never + head?: never + patch?: never + trace?: never + } + '/templates/defaults': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** + * List default templates + * @description Returns the list of default templates with their latest build info and aliases. + */ + get: { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + /** @description Successfully returned default templates. */ + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['DefaultTemplatesResponse'] + } + } + 401: components['responses']['401'] + 500: components['responses']['500'] + } + } + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } } -export type webhooks = Record; +export type webhooks = Record export interface components { - schemas: { - Error: { - /** - * Format: int32 - * @description Error code. - */ - code: number; - /** @description Error message. */ - message: string; - }; - AdminAuthProviderProfile: { - /** - * Format: uuid - * @description Internal E2B user identifier. - */ - userId: string; - /** @description Email address from the configured auth provider. */ - email: string | null; - }; - AdminAuthProviderProfilesResponse: { - profiles: components["schemas"]["AdminAuthProviderProfile"][]; - }; - AdminAuthProviderProfilesResolveRequest: { - userIds: string[]; - }; - AdminAuthProviderProfilesLookupEmailRequest: { - /** Format: email */ - email: string; - }; - AdminAuthProviderUserBootstrapRequest: { - oidc_user_id: string; - /** Format: email */ - oidc_user_email: string; - oidc_user_name: string | null; - }; - /** - * @description Build status mapped for dashboard clients. - * @enum {string} - */ - BuildStatus: "building" | "failed" | "success"; - ListedBuild: { - /** - * Format: uuid - * @description Identifier of the build. - */ - id: string; - /** @description Template alias when present, otherwise template ID. */ - template: string; - /** @description Identifier of the template. */ - templateId: string; - status: components["schemas"]["BuildStatus"]; - /** @description Failure message when status is `failed`, otherwise `null`. */ - statusMessage: string | null; - /** - * Format: date-time - * @description Build creation timestamp in RFC3339 format. - */ - createdAt: string; - /** - * Format: date-time - * @description Build completion timestamp in RFC3339 format, if finished. - */ - finishedAt: string | null; - }; - BuildsListResponse: { - data: components["schemas"]["ListedBuild"][]; - /** @description Cursor to pass to the next list request, or `null` if there is no next page. */ - nextCursor: string | null; - }; - BuildStatusItem: { - /** - * Format: uuid - * @description Identifier of the build. - */ - id: string; - status: components["schemas"]["BuildStatus"]; - /** - * Format: date-time - * @description Build completion timestamp in RFC3339 format, if finished. - */ - finishedAt: string | null; - /** @description Failure message when status is `failed`, otherwise `null`. */ - statusMessage: string | null; - }; - BuildsStatusesResponse: { - /** @description List of build statuses */ - buildStatuses: components["schemas"]["BuildStatusItem"][]; - }; - BuildInfo: { - /** @description Template names related to this build, if available. */ - names?: string[] | null; - /** - * Format: date-time - * @description Build creation timestamp in RFC3339 format. - */ - createdAt: string; - /** - * Format: date-time - * @description Build completion timestamp in RFC3339 format, if finished. - */ - finishedAt: string | null; - status: components["schemas"]["BuildStatus"]; - /** @description Failure message when status is `failed`, otherwise `null`. */ - statusMessage: string | null; - }; - /** - * Format: int64 - * @description CPU cores for the sandbox - */ - CPUCount: number; - /** - * Format: int64 - * @description Memory for the sandbox in MiB - */ - MemoryMB: number; - /** - * Format: int64 - * @description Disk size for the sandbox in MiB - */ - DiskSizeMB: number; - SandboxRecord: { - /** @description Identifier of the template from which is the sandbox created */ - templateID: string; - /** @description Alias of the template */ - alias?: string; - /** @description Identifier of the sandbox */ - sandboxID: string; - /** - * Format: date-time - * @description Time when the sandbox was started - */ - startedAt: string; - /** - * Format: date-time - * @description Time when the sandbox was stopped - */ - stoppedAt?: string | null; - /** @description Base domain where the sandbox traffic is accessible */ - domain?: string | null; - cpuCount: components["schemas"]["CPUCount"]; - memoryMB: components["schemas"]["MemoryMB"]; - diskSizeMB: components["schemas"]["DiskSizeMB"]; - }; - HealthResponse: { - /** @description Human-readable health check result. */ - message: string; - }; - UserTeamLimits: { - /** Format: int64 */ - maxLengthHours: number; - /** Format: int32 */ - concurrentSandboxes: number; - /** Format: int32 */ - concurrentTemplateBuilds: number; - /** Format: int32 */ - maxVcpu: number; - /** Format: int32 */ - maxRamMb: number; - /** Format: int32 */ - diskMb: number; - }; - UserTeam: { - /** Format: uuid */ - id: string; - name: string; - slug: string; - tier: string; - email: string; - profilePictureUrl: string | null; - isBlocked: boolean; - isBanned: boolean; - blockedReason: string | null; - isDefault: boolean; - limits: components["schemas"]["UserTeamLimits"]; - /** Format: date-time */ - createdAt: string; - }; - UserTeamsResponse: { - teams: components["schemas"]["UserTeam"][]; - }; - TeamMember: { - /** Format: uuid */ - id: string; - email: string; - isDefault: boolean; - /** Format: uuid */ - addedBy?: string | null; - /** Format: date-time */ - createdAt: string | null; - }; - TeamMembersResponse: { - members: components["schemas"]["TeamMember"][]; - }; - UpdateTeamRequest: { - name?: string; - profilePictureUrl?: string | null; - }; - UpdateTeamResponse: { - /** Format: uuid */ - id: string; - name: string; - profilePictureUrl?: string | null; - }; - AddTeamMemberRequest: { - /** Format: email */ - email: string; - }; - CreateTeamRequest: { - name: string; - }; - DefaultTemplateAlias: { - alias: string; - namespace?: string | null; - }; - DefaultTemplate: { - id: string; - aliases: components["schemas"]["DefaultTemplateAlias"][]; - /** Format: uuid */ - buildId: string; - /** Format: int64 */ - ramMb: number; - /** Format: int64 */ - vcpu: number; - /** Format: int64 */ - totalDiskSizeMb: number | null; - envdVersion?: string | null; - /** Format: date-time */ - createdAt: string; - public: boolean; - /** Format: int32 */ - buildCount: number; - /** Format: int64 */ - spawnCount: number; - }; - DefaultTemplatesResponse: { - templates: components["schemas"]["DefaultTemplate"][]; - }; - TeamResolveResponse: { - /** Format: uuid */ - id: string; - slug: string; - }; - }; - responses: { - /** @description Bad request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Authentication error */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - /** @description Server error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - }; - parameters: { - /** @description Identifier of the build. */ - build_id: string; - /** @description Identifier of the sandbox. */ - sandboxID: string; - /** @description Maximum number of items to return per page. */ - builds_limit: number; - /** @description Cursor returned by the previous list response in `created_at|build_id` format. */ - builds_cursor: string; - /** @description Optional filter by build identifier, template identifier, or template alias. */ - build_id_or_template: string; - /** @description Comma-separated list of build statuses to include. */ - build_statuses: components["schemas"]["BuildStatus"][]; - /** @description Comma-separated list of build IDs to get statuses for. */ - build_ids: string[]; - /** @description Identifier of the team. */ - teamID: string; - /** @description Identifier of the user. */ - userId: string; - /** @description Team slug to resolve. */ - teamSlug: string; - }; - requestBodies: never; - headers: never; - pathItems: never; + schemas: { + Error: { + /** + * Format: int32 + * @description Error code. + */ + code: number + /** @description Error message. */ + message: string + } + AdminAuthProviderProfile: { + /** + * Format: uuid + * @description Internal E2B user identifier. + */ + userId: string + /** @description Email address from the configured auth provider. */ + email: string | null + } + AdminAuthProviderProfilesResponse: { + profiles: components['schemas']['AdminAuthProviderProfile'][] + } + AdminAuthProviderProfilesResolveRequest: { + userIds: string[] + } + AdminAuthProviderProfilesLookupEmailRequest: { + /** Format: email */ + email: string + } + AdminAuthProviderUserBootstrapRequest: { + oidc_user_id: string + /** Format: email */ + oidc_user_email: string + oidc_user_name: string | null + } + /** + * @description Build status mapped for dashboard clients. + * @enum {string} + */ + BuildStatus: 'building' | 'failed' | 'success' + ListedBuild: { + /** + * Format: uuid + * @description Identifier of the build. + */ + id: string + /** @description Template alias when present, otherwise template ID. */ + template: string + /** @description Identifier of the template. */ + templateId: string + status: components['schemas']['BuildStatus'] + /** @description Failure message when status is `failed`, otherwise `null`. */ + statusMessage: string | null + /** + * Format: date-time + * @description Build creation timestamp in RFC3339 format. + */ + createdAt: string + /** + * Format: date-time + * @description Build completion timestamp in RFC3339 format, if finished. + */ + finishedAt: string | null + } + BuildsListResponse: { + data: components['schemas']['ListedBuild'][] + /** @description Cursor to pass to the next list request, or `null` if there is no next page. */ + nextCursor: string | null + } + BuildStatusItem: { + /** + * Format: uuid + * @description Identifier of the build. + */ + id: string + status: components['schemas']['BuildStatus'] + /** + * Format: date-time + * @description Build completion timestamp in RFC3339 format, if finished. + */ + finishedAt: string | null + /** @description Failure message when status is `failed`, otherwise `null`. */ + statusMessage: string | null + } + BuildsStatusesResponse: { + /** @description List of build statuses */ + buildStatuses: components['schemas']['BuildStatusItem'][] + } + BuildInfo: { + /** @description Template names related to this build, if available. */ + names?: string[] | null + /** + * Format: date-time + * @description Build creation timestamp in RFC3339 format. + */ + createdAt: string + /** + * Format: date-time + * @description Build completion timestamp in RFC3339 format, if finished. + */ + finishedAt: string | null + status: components['schemas']['BuildStatus'] + /** @description Failure message when status is `failed`, otherwise `null`. */ + statusMessage: string | null + } + /** + * Format: int64 + * @description CPU cores for the sandbox + */ + CPUCount: number + /** + * Format: int64 + * @description Memory for the sandbox in MiB + */ + MemoryMB: number + /** + * Format: int64 + * @description Disk size for the sandbox in MiB + */ + DiskSizeMB: number + SandboxRecord: { + /** @description Identifier of the template from which is the sandbox created */ + templateID: string + /** @description Alias of the template */ + alias?: string + /** @description Identifier of the sandbox */ + sandboxID: string + /** + * Format: date-time + * @description Time when the sandbox was started + */ + startedAt: string + /** + * Format: date-time + * @description Time when the sandbox was stopped + */ + stoppedAt?: string | null + /** @description Base domain where the sandbox traffic is accessible */ + domain?: string | null + cpuCount: components['schemas']['CPUCount'] + memoryMB: components['schemas']['MemoryMB'] + diskSizeMB: components['schemas']['DiskSizeMB'] + } + HealthResponse: { + /** @description Human-readable health check result. */ + message: string + } + UserTeamLimits: { + /** Format: int64 */ + maxLengthHours: number + /** Format: int32 */ + concurrentSandboxes: number + /** Format: int32 */ + concurrentTemplateBuilds: number + /** Format: int32 */ + maxVcpu: number + /** Format: int32 */ + maxRamMb: number + /** Format: int32 */ + diskMb: number + } + UserTeam: { + /** Format: uuid */ + id: string + name: string + slug: string + tier: string + email: string + profilePictureUrl: string | null + isBlocked: boolean + isBanned: boolean + blockedReason: string | null + isDefault: boolean + limits: components['schemas']['UserTeamLimits'] + /** Format: date-time */ + createdAt: string + } + UserTeamsResponse: { + teams: components['schemas']['UserTeam'][] + } + TeamMember: { + /** Format: uuid */ + id: string + email: string + isDefault: boolean + /** Format: uuid */ + addedBy?: string | null + /** Format: date-time */ + createdAt: string | null + } + TeamMembersResponse: { + members: components['schemas']['TeamMember'][] + } + UpdateTeamRequest: { + name?: string + profilePictureUrl?: string | null + } + UpdateTeamResponse: { + /** Format: uuid */ + id: string + name: string + profilePictureUrl?: string | null + } + AddTeamMemberRequest: { + /** Format: email */ + email: string + } + CreateTeamRequest: { + name: string + } + DefaultTemplateAlias: { + alias: string + namespace?: string | null + } + DefaultTemplate: { + id: string + aliases: components['schemas']['DefaultTemplateAlias'][] + /** Format: uuid */ + buildId: string + /** Format: int64 */ + ramMb: number + /** Format: int64 */ + vcpu: number + /** Format: int64 */ + totalDiskSizeMb: number | null + envdVersion?: string | null + /** Format: date-time */ + createdAt: string + public: boolean + /** Format: int32 */ + buildCount: number + /** Format: int64 */ + spawnCount: number + } + DefaultTemplatesResponse: { + templates: components['schemas']['DefaultTemplate'][] + } + TeamResolveResponse: { + /** Format: uuid */ + id: string + slug: string + } + } + responses: { + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['Error'] + } + } + /** @description Authentication error */ + 401: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['Error'] + } + } + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['Error'] + } + } + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['Error'] + } + } + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['Error'] + } + } + } + parameters: { + /** @description Identifier of the build. */ + build_id: string + /** @description Identifier of the sandbox. */ + sandboxID: string + /** @description Maximum number of items to return per page. */ + builds_limit: number + /** @description Cursor returned by the previous list response in `created_at|build_id` format. */ + builds_cursor: string + /** @description Optional filter by build identifier, template identifier, or template alias. */ + build_id_or_template: string + /** @description Comma-separated list of build statuses to include. */ + build_statuses: components['schemas']['BuildStatus'][] + /** @description Comma-separated list of build IDs to get statuses for. */ + build_ids: string[] + /** @description Identifier of the team. */ + teamID: string + /** @description Identifier of the user. */ + userId: string + /** @description Team slug to resolve. */ + teamSlug: string + } + requestBodies: never + headers: never + pathItems: never } -export type $defs = Record; -export type operations = Record; +export type $defs = Record +export type operations = Record From b07a33af1b1db9db38aa3736dd940779cf74780b Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Sun, 24 May 2026 19:28:45 -0700 Subject: [PATCH 03/16] refactor(auth): push sign-out behind AuthProvider, slim Ory wiring Move the sign-out redirect target into the AuthProvider contract so route handlers drop their isOryAuthEnabled() branches. Delete the HMAC-state sign-out machinery (we no longer thread a "you were signed out because X" message through Hydra). Extract refreshOryToken into its own module and convert the bootstrap import to a static one. --- src/app/api/auth/oauth/signed-out/route.ts | 14 -- src/app/api/auth/oauth/signout-flow/route.ts | 37 +----- src/app/dashboard/account/route.ts | 34 ++--- src/app/dashboard/route.ts | 34 ++--- src/auth.ts | 115 +++------------- src/configs/api.ts | 32 +++-- src/core/server/actions/auth-actions.ts | 15 +-- src/core/server/auth/ory/provider.ts | 9 +- src/core/server/auth/ory/refresh-token.ts | 64 +++++++++ src/core/server/auth/ory/signout.ts | 130 +------------------ src/core/server/auth/supabase/provider.ts | 16 ++- src/core/server/auth/types.ts | 6 +- tests/unit/auth-ory-provider.test.ts | 8 +- tests/unit/auth-supabase-provider.test.ts | 22 +++- 14 files changed, 179 insertions(+), 357 deletions(-) delete mode 100644 src/app/api/auth/oauth/signed-out/route.ts create mode 100644 src/core/server/auth/ory/refresh-token.ts diff --git a/src/app/api/auth/oauth/signed-out/route.ts b/src/app/api/auth/oauth/signed-out/route.ts deleted file mode 100644 index 23a7f643b..000000000 --- a/src/app/api/auth/oauth/signed-out/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' -import { - getLogoutFinalUrl, - parseLogoutState, -} from '@/core/server/auth/ory/signout' - -export async function GET(request: NextRequest) { - const options = parseLogoutState(request.nextUrl.searchParams.get('state')) - - return NextResponse.redirect( - getLogoutFinalUrl(options, request.nextUrl.origin) - ) -} diff --git a/src/app/api/auth/oauth/signout-flow/route.ts b/src/app/api/auth/oauth/signout-flow/route.ts index 8a11dc2d3..bf2dd055d 100644 --- a/src/app/api/auth/oauth/signout-flow/route.ts +++ b/src/app/api/auth/oauth/signout-flow/route.ts @@ -3,35 +3,12 @@ import 'server-only' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { auth, signOut } from '@/auth' -import { - buildLogoutState, - getLogoutFinalUrl, - ORY_POST_LOGOUT_CALLBACK_PATH, - type OrySignOutOptions, -} from '@/core/server/auth/ory/signout' +import { AUTH_URLS } from '@/configs/urls' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' -function getSignOutOptions(request: NextRequest): OrySignOutOptions { - const messageType = request.nextUrl.searchParams.get('messageType') - const message = request.nextUrl.searchParams.get('message') - - return { - returnTo: request.nextUrl.searchParams.get('returnTo') ?? undefined, - message: - (messageType === 'error' || - messageType === 'success' || - messageType === 'message') && - message - ? { type: messageType, value: message } - : undefined, - } -} - export async function GET(request: NextRequest) { const origin = request.nextUrl.origin - const signOutOptions = getSignOutOptions(request) - const state = buildLogoutState(signOutOptions) - const postLogoutRedirect = new URL(ORY_POST_LOGOUT_CALLBACK_PATH, origin) + const signInUrl = new URL(AUTH_URLS.SIGN_IN, origin) let idToken: string | undefined try { @@ -61,20 +38,14 @@ export async function GET(request: NextRequest) { const sdkUrl = process.env.ORY_SDK_URL if (!idToken || !sdkUrl) { - return NextResponse.redirect(getLogoutFinalUrl(signOutOptions, origin)) + return NextResponse.redirect(signInUrl) } const hydraLogout = new URL( `${sdkUrl.replace(/\/$/, '')}/oauth2/sessions/logout` ) hydraLogout.searchParams.set('id_token_hint', idToken) - hydraLogout.searchParams.set( - 'post_logout_redirect_uri', - postLogoutRedirect.toString() - ) - if (state) { - hydraLogout.searchParams.set('state', state) - } + hydraLogout.searchParams.set('post_logout_redirect_uri', signInUrl.toString()) return NextResponse.redirect(hydraLogout.toString()) } diff --git a/src/app/dashboard/account/route.ts b/src/app/dashboard/account/route.ts index 260e06dab..3ae1d5383 100644 --- a/src/app/dashboard/account/route.ts +++ b/src/app/dashboard/account/route.ts @@ -1,10 +1,8 @@ import { type NextRequest, NextResponse } from 'next/server' -import { isOryAuthEnabled } from '@/configs/flags' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { auth } from '@/core/server/auth' -import { getOrySignOutPath } from '@/core/server/auth/ory/signout' import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' -import { encodedRedirect } from '@/lib/utils/auth' +import { l } from '@/core/shared/clients/logger/logger' import { setTeamCookies } from '@/lib/utils/cookies' export async function GET(request: NextRequest) { @@ -20,27 +18,17 @@ export async function GET(request: NextRequest) { ) if (!team) { - const error = 'No personal team found. Please contact support.' - - if (isOryAuthEnabled()) { - return NextResponse.redirect( - new URL( - getOrySignOutPath({ - returnTo: AUTH_URLS.SIGN_IN, - message: { type: 'error', value: error }, - }), - request.url - ) - ) - } - - await auth.signOut() - - return encodedRedirect( - 'error', - new URL(AUTH_URLS.SIGN_IN, request.url).toString(), - error + l.warn( + { + key: 'dashboard_account:no_personal_team', + user_id: authContext.user.id, + }, + 'no personal team for user, signing out' ) + + const { redirectTo } = await auth.signOut() + + return NextResponse.redirect(new URL(redirectTo, request.url)) } await setTeamCookies(team.id, team.slug) diff --git a/src/app/dashboard/route.ts b/src/app/dashboard/route.ts index 0999831f1..e27cdc161 100644 --- a/src/app/dashboard/route.ts +++ b/src/app/dashboard/route.ts @@ -1,11 +1,9 @@ import { type NextRequest, NextResponse } from 'next/server' import { TAB_URL_MAP } from '@/configs/dashboard-tab-url-map' -import { isOryAuthEnabled } from '@/configs/flags' -import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' +import { PROTECTED_URLS } from '@/configs/urls' import { auth } from '@/core/server/auth' -import { getOrySignOutPath } from '@/core/server/auth/ory/signout' import { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team' -import { encodedRedirect } from '@/lib/utils/auth' +import { l } from '@/core/shared/clients/logger/logger' import { setTeamCookies } from '@/lib/utils/cookies' function getTabRedirectPath(tab: string | null, teamSlug: string) { @@ -36,27 +34,17 @@ export async function GET(request: NextRequest) { ) if (!team) { - const error = 'No personal team found. Please contact support.' - - if (isOryAuthEnabled()) { - return NextResponse.redirect( - new URL( - getOrySignOutPath({ - returnTo: AUTH_URLS.SIGN_IN, - message: { type: 'error', value: error }, - }), - request.url - ) - ) - } + l.warn( + { + key: 'dashboard:no_personal_team', + user_id: authContext.user.id, + }, + 'no personal team for user, signing out' + ) - await auth.signOut() + const { redirectTo } = await auth.signOut() - return encodedRedirect( - 'error', - new URL(AUTH_URLS.SIGN_IN, request.url).toString(), - error - ) + return NextResponse.redirect(new URL(redirectTo, request.url)) } await setTeamCookies(team.id, team.slug) diff --git a/src/auth.ts b/src/auth.ts index 97cf3c4b7..bcec837c5 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,33 +1,33 @@ import 'next-auth/jwt' import NextAuth from 'next-auth' -import type { JWT } from 'next-auth/jwt' import OryHydra from 'next-auth/providers/ory-hydra' -import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { bootstrapOryUser } from '@/core/server/auth/ory/bootstrap' +import { refreshOryToken } from '@/core/server/auth/ory/refresh-token' const oryOAuth2Audience = process.env.ORY_OAUTH2_AUDIENCE -const oryProvider = OryHydra({ - id: 'ory', - name: 'Ory', - issuer: process.env.ORY_SDK_URL, - clientId: process.env.ORY_OAUTH2_CLIENT_ID, - clientSecret: process.env.ORY_OAUTH2_CLIENT_SECRET, - authorization: { - params: { - scope: 'openid offline_access email profile', - ...(oryOAuth2Audience ? { audience: oryOAuth2Audience } : {}), - }, - }, - checks: ['state'], -}) - export const { handlers, auth, signIn, signOut } = NextAuth({ // isolates from existing /api/auth/{callback,email-callback,verify-otp} basePath: '/api/auth/oauth', secret: process.env.AUTH_SECRET, session: { strategy: 'jwt' }, - providers: [oryProvider], + providers: [ + OryHydra({ + id: 'ory', + name: 'Ory', + issuer: process.env.ORY_SDK_URL, + clientId: process.env.ORY_OAUTH2_CLIENT_ID, + clientSecret: process.env.ORY_OAUTH2_CLIENT_SECRET, + authorization: { + params: { + scope: 'openid offline_access email profile', + ...(oryOAuth2Audience ? { audience: oryOAuth2Audience } : {}), + }, + }, + checks: ['state'], + }), + ], callbacks: { async jwt({ token, account }) { if (account) { @@ -59,9 +59,6 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ events: { async signIn({ account }) { if (!account?.access_token) return - const { bootstrapOryUser } = await import( - '@/core/server/auth/ory/bootstrap' - ) await bootstrapOryUser({ accessToken: account.access_token, idToken: account.id_token, @@ -71,82 +68,6 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, }) -async function refreshOryToken(token: JWT): Promise { - if (!token.refreshToken) { - return { ...token, error: 'NoRefreshToken' } - } - - const sdkUrl = process.env.ORY_SDK_URL - const clientId = process.env.ORY_OAUTH2_CLIENT_ID - const clientSecret = process.env.ORY_OAUTH2_CLIENT_SECRET - - if (!sdkUrl || !clientId || !clientSecret) { - l.error( - { key: 'auth_provider:refresh_token:misconfigured' }, - 'Ory token refresh attempted but ORY_SDK_URL / ORY_OAUTH2_CLIENT_ID / ORY_OAUTH2_CLIENT_SECRET are not all set' - ) - return { ...token, error: 'RefreshTokenError' } - } - - try { - const credentials = btoa(`${clientId}:${clientSecret}`) - const tokenEndpoint = `${sdkUrl.replace(/\/$/, '')}/oauth2/token` - const response = await fetch(tokenEndpoint, { - method: 'POST', - headers: { - Authorization: `Basic ${credentials}`, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: token.refreshToken, - }).toString(), - }) - - if (!response.ok) { - const text = await response.text().catch(() => '') - l.warn( - { - key: 'auth_provider:refresh_token:rejected', - context: { - status: response.status, - body: text.slice(0, 200), - }, - }, - `Ory rejected refresh_token (status ${response.status})` - ) - return { ...token, error: 'RefreshTokenError' } - } - - const fresh = (await response.json()) as { - access_token: string - token_type: string - expires_in: number - refresh_token?: string - id_token?: string - scope?: string - } - - return { - ...token, - accessToken: fresh.access_token, - refreshToken: fresh.refresh_token ?? token.refreshToken, - idToken: fresh.id_token ?? token.idToken, - expiresAt: Math.floor(Date.now() / 1000) + fresh.expires_in, - error: undefined, - } - } catch (error) { - l.error( - { - key: 'auth_provider:refresh_token:exception', - error: serializeErrorForLog(error), - }, - 'Ory refresh_token request threw unexpected exception' - ) - return { ...token, error: 'RefreshTokenError' } - } -} - declare module 'next-auth' { interface Session { accessToken?: string diff --git a/src/configs/api.ts b/src/configs/api.ts index c4e47d58f..05a643404 100644 --- a/src/configs/api.ts +++ b/src/configs/api.ts @@ -8,17 +8,33 @@ export const AUTH_PROVIDER_TEAM_HEADER = 'X-Team-ID' export const ENVD_ACCESS_TOKEN_HEADER = 'X-Access-Token' export const ADMIN_TOKEN_HEADER = 'X-Admin-Token' -export const authHeaders = ( +type AuthHeaderStrategy = { + tokenHeader: string + tokenPrefix: string + teamHeader: string +} + +const oryHeaderStrategy: AuthHeaderStrategy = { + tokenHeader: 'Authorization', + tokenPrefix: 'Bearer ', + teamHeader: AUTH_PROVIDER_TEAM_HEADER, +} + +const supabaseHeaderStrategy: AuthHeaderStrategy = { + tokenHeader: SUPABASE_TOKEN_HEADER, + tokenPrefix: '', + teamHeader: SUPABASE_TEAM_HEADER, +} + +export function authHeaders( token: string, teamId?: string -): Record => { - const isOry = isOryAuthEnabled() - const headers: Record = isOry - ? { Authorization: `Bearer ${token}` } - : { [SUPABASE_TOKEN_HEADER]: token } - if (teamId) { - headers[isOry ? AUTH_PROVIDER_TEAM_HEADER : SUPABASE_TEAM_HEADER] = teamId +): Record { + const s = isOryAuthEnabled() ? oryHeaderStrategy : supabaseHeaderStrategy + const headers: Record = { + [s.tokenHeader]: `${s.tokenPrefix}${token}`, } + if (teamId) headers[s.teamHeader] = teamId return headers } diff --git a/src/core/server/actions/auth-actions.ts b/src/core/server/actions/auth-actions.ts index 8be28b274..7c21e6674 100644 --- a/src/core/server/actions/auth-actions.ts +++ b/src/core/server/actions/auth-actions.ts @@ -4,13 +4,12 @@ import { headers } from 'next/headers' import { redirect } from 'next/navigation' import { returnValidationErrors } from 'next-safe-action' import { z } from 'zod' -import { CAPTCHA_REQUIRED_SERVER, isOryAuthEnabled } from '@/configs/flags' +import { CAPTCHA_REQUIRED_SERVER } from '@/configs/flags' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { USER_MESSAGES } from '@/configs/user-messages' import { actionClient } from '@/core/server/actions/client' import { returnServerError } from '@/core/server/actions/utils' import { auth } from '@/core/server/auth' -import { getOrySignOutPath } from '@/core/server/auth/ory/signout' import { supabaseAuthFlows } from '@/core/server/auth/supabase/flows' import { forgotPasswordSchema, @@ -357,15 +356,7 @@ export const forgotPasswordAction = actionClient }) export async function signOutAction(returnTo?: string) { - const signInPath = - AUTH_URLS.SIGN_IN + - (returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : '') + const { redirectTo } = await auth.signOut({ returnTo }) - if (isOryAuthEnabled()) { - throw redirect(getOrySignOutPath({ returnTo: signInPath })) - } - - await auth.signOut() - - throw redirect(signInPath) + throw redirect(redirectTo) } diff --git a/src/core/server/auth/ory/provider.ts b/src/core/server/auth/ory/provider.ts index f6be01251..40348219d 100644 --- a/src/core/server/auth/ory/provider.ts +++ b/src/core/server/auth/ory/provider.ts @@ -5,6 +5,7 @@ import { auth as authjs } from '@/auth' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import type { AuthProvider } from '../provider' import { fromAuthSession } from './identity' +import { getOrySignOutPath } from './signout' export const oryAuthProvider: AuthProvider = { async getAuthContext() { @@ -45,12 +46,6 @@ export const oryAuthProvider: AuthProvider = { }, signOut() { - return Promise.resolve({ - error: { - message: - 'Ory sign-out must redirect through /api/auth/oauth/signout-flow', - code: 'ory_sign_out_requires_route', - }, - }) + return Promise.resolve({ redirectTo: getOrySignOutPath() }) }, } diff --git a/src/core/server/auth/ory/refresh-token.ts b/src/core/server/auth/ory/refresh-token.ts new file mode 100644 index 000000000..c6daad209 --- /dev/null +++ b/src/core/server/auth/ory/refresh-token.ts @@ -0,0 +1,64 @@ +import 'server-only' + +import type { JWT } from 'next-auth/jwt' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' + +type OryTokenResponse = { + access_token: string + expires_in: number + refresh_token?: string + id_token?: string +} + +export async function refreshOryToken(token: JWT): Promise { + if (!token.refreshToken) return { ...token, error: 'NoRefreshToken' } + + const sdkUrl = process.env.ORY_SDK_URL!.replace(/\/$/, '') + const credentials = btoa( + `${process.env.ORY_OAUTH2_CLIENT_ID}:${process.env.ORY_OAUTH2_CLIENT_SECRET}` + ) + + try { + const res = await fetch(`${sdkUrl}/oauth2/token`, { + method: 'POST', + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: token.refreshToken, + }), + }) + + if (!res.ok) { + l.warn( + { + key: 'auth_provider:refresh_token:rejected', + context: { status: res.status }, + }, + `Ory refresh_token rejected (${res.status})` + ) + return { ...token, error: 'RefreshTokenError' } + } + + const fresh = (await res.json()) as OryTokenResponse + return { + ...token, + accessToken: fresh.access_token, + refreshToken: fresh.refresh_token ?? token.refreshToken, + idToken: fresh.id_token ?? token.idToken, + expiresAt: Math.floor(Date.now() / 1000) + fresh.expires_in, + error: undefined, + } + } catch (error) { + l.error( + { + key: 'auth_provider:refresh_token:exception', + error: serializeErrorForLog(error), + }, + 'Ory refresh_token threw' + ) + return { ...token, error: 'RefreshTokenError' } + } +} diff --git a/src/core/server/auth/ory/signout.ts b/src/core/server/auth/ory/signout.ts index dfd919db7..0dd3cc65e 100644 --- a/src/core/server/auth/ory/signout.ts +++ b/src/core/server/auth/ory/signout.ts @@ -1,131 +1,5 @@ -import { createHmac, timingSafeEqual } from 'node:crypto' - export const ORY_SIGN_OUT_FLOW_PATH = '/api/auth/oauth/signout-flow' -export const ORY_POST_LOGOUT_CALLBACK_PATH = '/api/auth/oauth/signed-out' - -const LOGOUT_STATE_MAX_AGE_MS = 10 * 60 * 1000 - -type SignOutMessage = { - type: 'error' | 'success' | 'message' - value: string -} - -export type OrySignOutOptions = { - returnTo?: string - message?: SignOutMessage -} - -type LogoutState = OrySignOutOptions & { - expiresAt: number -} - -export function getOrySignOutPath(options: OrySignOutOptions = {}): string { - const params = new URLSearchParams() - - if (options.returnTo) params.set('returnTo', options.returnTo) - if (options.message) { - params.set('messageType', options.message.type) - params.set('message', options.message.value) - } - - const query = params.toString() - return query ? `${ORY_SIGN_OUT_FLOW_PATH}?${query}` : ORY_SIGN_OUT_FLOW_PATH -} - -export function getLogoutFinalUrl( - options: OrySignOutOptions | null, - origin: string -): string { - const url = new URL(resolveReturnTo(options?.returnTo, origin)) - - if (options?.message) { - url.searchParams.set(options.message.type, options.message.value) - } - - return url.toString() -} - -export function buildLogoutState(options: OrySignOutOptions): string | null { - const secret = process.env.AUTH_SECRET - if (!secret) return null - - const state: LogoutState = { - ...options, - expiresAt: Date.now() + LOGOUT_STATE_MAX_AGE_MS, - } - const payload = base64UrlEncode(JSON.stringify(state)) - const signature = sign(payload, secret) - - return `${payload}.${signature}` -} - -export function parseLogoutState( - state: string | null -): OrySignOutOptions | null { - if (!state) return null - - const secret = process.env.AUTH_SECRET - if (!secret) return null - - const [payload, signature] = state.split('.') - if (!payload || !signature || !safeEqual(signature, sign(payload, secret))) { - return null - } - - try { - const parsed = JSON.parse(base64UrlDecode(payload)) as Partial - if (!parsed.expiresAt || parsed.expiresAt < Date.now()) return null - - return { - returnTo: - typeof parsed.returnTo === 'string' ? parsed.returnTo : undefined, - message: isSignOutMessage(parsed.message) ? parsed.message : undefined, - } - } catch { - return null - } -} - -function isSignOutMessage(value: unknown): value is SignOutMessage { - if (!value || typeof value !== 'object') return false - - const message = value as Partial - return ( - (message.type === 'error' || - message.type === 'success' || - message.type === 'message') && - typeof message.value === 'string' - ) -} - -function resolveReturnTo(returnTo: string | undefined, origin: string): string { - if (!returnTo) return `${origin}/` - if (returnTo.startsWith('/')) return `${origin}${returnTo}` - - try { - if (new URL(returnTo).origin === origin) return returnTo - } catch { - return `${origin}/` - } - - return `${origin}/` -} - -function sign(payload: string, secret: string): string { - return createHmac('sha256', secret).update(payload).digest('base64url') -} - -function safeEqual(a: string, b: string): boolean { - const left = Buffer.from(a) - const right = Buffer.from(b) - - return left.length === right.length && timingSafeEqual(left, right) -} - -function base64UrlEncode(value: string): string { - return Buffer.from(value).toString('base64url') -} -function base64UrlDecode(value: string): string { - return Buffer.from(value, 'base64url').toString('utf8') +export function getOrySignOutPath(): string { + return ORY_SIGN_OUT_FLOW_PATH } diff --git a/src/core/server/auth/supabase/provider.ts b/src/core/server/auth/supabase/provider.ts index 47f65d24d..a4e04d024 100644 --- a/src/core/server/auth/supabase/provider.ts +++ b/src/core/server/auth/supabase/provider.ts @@ -1,6 +1,7 @@ import 'server-only' import type { NextRequest, NextResponse } from 'next/server' +import { AUTH_URLS } from '@/configs/urls' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import { createClient } from '@/core/shared/clients/supabase/server' import type { AuthProvider } from '../provider' @@ -61,7 +62,9 @@ export class SupabaseAuthProvider implements AuthProvider { async signOut(options?: SignOutOptions): Promise { const client = await this.resolveClient() - const { error } = await client.auth.signOut(options) + const { error } = await client.auth.signOut( + options?.scope ? { scope: options.scope } : undefined + ) if (error) { l.error( @@ -78,7 +81,10 @@ export class SupabaseAuthProvider implements AuthProvider { ) } - return { error: error ?? null } + return { + redirectTo: buildSignInRedirect(options?.returnTo), + error: error ?? null, + } } private resolveClient(): Promise { @@ -98,3 +104,9 @@ export function createSupabaseAuthForHeaders( ): SupabaseAuthProvider { return new SupabaseAuthProvider(createServerClientForHeaders(headers)) } + +function buildSignInRedirect(returnTo?: string): string { + if (!returnTo) return AUTH_URLS.SIGN_IN + const params = new URLSearchParams({ returnTo }) + return `${AUTH_URLS.SIGN_IN}?${params.toString()}` +} diff --git a/src/core/server/auth/types.ts b/src/core/server/auth/types.ts index c6520b495..407521c75 100644 --- a/src/core/server/auth/types.ts +++ b/src/core/server/auth/types.ts @@ -13,6 +13,7 @@ export type AuthContext = { export type SignOutOptions = { scope?: 'local' | 'others' | 'global' + returnTo?: string } export type AuthError = { @@ -21,4 +22,7 @@ export type AuthError = { status?: number } -export type SignOutResult = { error: AuthError | null } +export type SignOutResult = { + redirectTo: string + error?: AuthError | null +} diff --git a/tests/unit/auth-ory-provider.test.ts b/tests/unit/auth-ory-provider.test.ts index 9afc4cd3a..2462fde35 100644 --- a/tests/unit/auth-ory-provider.test.ts +++ b/tests/unit/auth-ory-provider.test.ts @@ -112,14 +112,10 @@ describe('OryAuthProvider', () => { }) describe('signOut', () => { - it('returns an explicit error because Ory sign-out must go through the route handler', async () => { + it('returns the signout-flow route URL so callers redirect to it', async () => { const result = await oryAuthProvider.signOut() expect(result).toEqual({ - error: { - message: - 'Ory sign-out must redirect through /api/auth/oauth/signout-flow', - code: 'ory_sign_out_requires_route', - }, + redirectTo: '/api/auth/oauth/signout-flow', }) }) }) diff --git a/tests/unit/auth-supabase-provider.test.ts b/tests/unit/auth-supabase-provider.test.ts index 690b7a866..468772127 100644 --- a/tests/unit/auth-supabase-provider.test.ts +++ b/tests/unit/auth-supabase-provider.test.ts @@ -150,7 +150,7 @@ describe('SupabaseAuthProvider', () => { const result = await provider.signOut({ scope: 'others' }) - expect(result).toEqual({ error: signOutError }) + expect(result).toEqual({ redirectTo: '/sign-in', error: signOutError }) expect(loggerMocks.error).toHaveBeenCalledWith( expect.objectContaining({ key: 'auth_provider:sign_out:error', @@ -165,7 +165,7 @@ describe('SupabaseAuthProvider', () => { ) }) - it('returns { error: null } on success without logging', async () => { + it('returns the sign-in redirect with null error on success without logging', async () => { const client = buildClient({ signOut: vi.fn().mockResolvedValue({ error: null }), }) @@ -173,8 +173,24 @@ describe('SupabaseAuthProvider', () => { const result = await provider.signOut() - expect(result).toEqual({ error: null }) + expect(result).toEqual({ redirectTo: '/sign-in', error: null }) expect(loggerMocks.error).not.toHaveBeenCalled() }) + + it('preserves returnTo as a sign-in query param for the reauth flow', async () => { + const client = buildClient({ + signOut: vi.fn().mockResolvedValue({ error: null }), + }) + const provider = new SupabaseAuthProvider(client) + + const result = await provider.signOut({ + returnTo: '/dashboard/account', + }) + + expect(result).toEqual({ + redirectTo: '/sign-in?returnTo=%2Fdashboard%2Faccount', + error: null, + }) + }) }) }) From aeb1d01a9c5d44702b064cb8d69050bab1350988 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 26 May 2026 15:24:37 -0700 Subject: [PATCH 04/16] refactor(teams): consume member profile fields from dashboard-api Drop the per-member authAdmin.getUserById fanout in listTeamMembers. dashboard-api now returns name, profilePictureUrl, and providers on each TeamMember directly, so the repository just maps the response shape. Removes the auth admin dependency from createTeamsRepository. Re-syncs openapi.dashboard-api.yaml from infra and regenerates the typed contracts. --- spec/openapi.dashboard-api.yaml | 17 +- .../modules/teams/teams-repository.server.ts | 43 +- .../shared/contracts/dashboard-api.types.ts | 2127 +++++++++-------- tests/unit/teams-repository.test.ts | 13 - 4 files changed, 1098 insertions(+), 1102 deletions(-) diff --git a/spec/openapi.dashboard-api.yaml b/spec/openapi.dashboard-api.yaml index a8d86bfef..00318ad13 100644 --- a/spec/openapi.dashboard-api.yaml +++ b/spec/openapi.dashboard-api.yaml @@ -220,10 +220,13 @@ components: AdminAuthProviderUserBootstrapRequest: type: object required: + - oidc_issuer - oidc_user_id - oidc_user_email - - oidc_user_name properties: + oidc_issuer: + type: string + minLength: 1 oidc_user_id: type: string minLength: 1 @@ -516,12 +519,24 @@ components: - email - isDefault - createdAt + - providers properties: id: type: string format: uuid email: type: string + name: + type: string + nullable: true + profilePictureUrl: + type: string + format: uri + nullable: true + providers: + type: array + items: + type: string isDefault: type: boolean addedBy: diff --git a/src/core/modules/teams/teams-repository.server.ts b/src/core/modules/teams/teams-repository.server.ts index cadd72ebd..b8286248e 100644 --- a/src/core/modules/teams/teams-repository.server.ts +++ b/src/core/modules/teams/teams-repository.server.ts @@ -2,8 +2,6 @@ import 'server-only' import { authHeaders } from '@/configs/api' import type { components as DashboardComponents } from '@/contracts/dashboard-api' -import type { AuthAdmin } from '@/core/server/auth' -import { authAdmin } from '@/core/server/auth' import { api } from '@/core/shared/clients/api' import { createRepoError, repoErrorFromHttp } from '@/core/shared/errors' import type { RequestScope } from '@/core/shared/repository-scope' @@ -13,7 +11,6 @@ import type { TeamMember } from './models' type TeamsRepositoryDeps = { apiClient: typeof api authHeaders: typeof authHeaders - authAdmin: Pick } export type TeamsRequestScope = RequestScope & { @@ -51,7 +48,6 @@ export function createTeamsRepository( deps: TeamsRepositoryDeps = { apiClient: api, authHeaders: authHeaders, - authAdmin, } ): TeamsRepository { return { @@ -79,29 +75,22 @@ export function createTeamsRepository( ) } - const members = data?.members ?? [] - const enrichedMembers = await Promise.all( - members.map(async (member) => { - const user = await deps.authAdmin.getUserById(member.id) - - return { - info: { - id: member.id, - email: member.email, - name: user?.name ?? undefined, - avatar_url: user?.avatarUrl ?? undefined, - providers: user?.providers ?? [], - createdAt: member.createdAt, - }, - relation: { - added_by: member.addedBy ?? null, - is_default: member.isDefault, - }, - } satisfies TeamMember - }) - ) - - return ok(enrichedMembers) + const mapped: TeamMember[] = (data?.members ?? []).map((member) => ({ + info: { + id: member.id, + email: member.email, + name: member.name ?? undefined, + avatar_url: member.profilePictureUrl ?? undefined, + providers: member.providers ?? [], + createdAt: member.createdAt, + }, + relation: { + added_by: member.addedBy ?? null, + is_default: member.isDefault, + }, + })) + + return ok(mapped) }, async updateTeamName( name diff --git a/src/core/shared/contracts/dashboard-api.types.ts b/src/core/shared/contracts/dashboard-api.types.ts index 5a5cad2b4..c45658aa3 100644 --- a/src/core/shared/contracts/dashboard-api.types.ts +++ b/src/core/shared/contracts/dashboard-api.types.ts @@ -4,1067 +4,1072 @@ */ export interface paths { - '/health': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Health check */ - get: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description Health check successful */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['HealthResponse'] - } - } - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/builds': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** List team builds */ - get: { - parameters: { - query?: { - /** @description Optional filter by build identifier, template identifier, or template alias. */ - build_id_or_template?: components['parameters']['build_id_or_template'] - /** @description Comma-separated list of build statuses to include. */ - statuses?: components['parameters']['build_statuses'] - /** @description Maximum number of items to return per page. */ - limit?: components['parameters']['builds_limit'] - /** @description Cursor returned by the previous list response in `created_at|build_id` format. */ - cursor?: components['parameters']['builds_cursor'] - } - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned paginated builds. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['BuildsListResponse'] - } - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 403: components['responses']['403'] - 500: components['responses']['500'] - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/builds/statuses': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Get build statuses */ - get: { - parameters: { - query: { - /** @description Comma-separated list of build IDs to get statuses for. */ - build_ids: components['parameters']['build_ids'] - } - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned build statuses */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['BuildsStatusesResponse'] - } - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 403: components['responses']['403'] - 500: components['responses']['500'] - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/builds/{build_id}': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Get build details */ - get: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the build. */ - build_id: components['parameters']['build_id'] - } - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned build details. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['BuildInfo'] - } - } - 401: components['responses']['401'] - 403: components['responses']['403'] - 404: components['responses']['404'] - 500: components['responses']['500'] - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/sandboxes/{sandboxID}/record': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Get sandbox record */ - get: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the sandbox. */ - sandboxID: components['parameters']['sandboxID'] - } - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned sandbox details. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['SandboxRecord'] - } - } - 401: components['responses']['401'] - 403: components['responses']['403'] - 404: components['responses']['404'] - 500: components['responses']['500'] - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/teams': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** - * List user teams - * @description Returns all teams the authenticated user belongs to, with limits and default flag. - */ - get: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned user teams. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['UserTeamsResponse'] - } - } - 401: components['responses']['401'] - 500: components['responses']['500'] - } - } - put?: never - /** Create team */ - post: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['CreateTeamRequest'] - } - } - responses: { - /** @description Successfully created team. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['TeamResolveResponse'] - } - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 500: components['responses']['500'] - } - } - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/admin/users/{userId}/bootstrap': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - /** Bootstrap user */ - post: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the user. */ - userId: components['parameters']['userId'] - } - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully bootstrapped user. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['TeamResolveResponse'] - } - } - 401: components['responses']['401'] - 500: components['responses']['500'] - } - } - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/admin/users/bootstrap': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - /** Bootstrap auth provider user */ - post: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['AdminAuthProviderUserBootstrapRequest'] - } - } - responses: { - /** @description Successfully bootstrapped user. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['TeamResolveResponse'] - } - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 500: components['responses']['500'] - } - } - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/admin/user-profiles/resolve': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - /** Resolve user profiles */ - post: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['AdminAuthProviderProfilesResolveRequest'] - } - } - responses: { - /** @description Successfully resolved profiles. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['AdminAuthProviderProfilesResponse'] - } - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 500: components['responses']['500'] - } - } - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/admin/user-profiles/by-email': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - /** Lookup user profiles by email */ - post: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['AdminAuthProviderProfilesLookupEmailRequest'] - } - } - responses: { - /** @description Successfully found matching profiles. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['AdminAuthProviderProfilesResponse'] - } - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 500: components['responses']['500'] - } - } - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/admin/user-profiles/{userId}': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** Get user profile */ - get: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the user. */ - userId: components['parameters']['userId'] - } - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully found profile. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['AdminAuthProviderProfilesResponse'] - } - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 500: components['responses']['500'] - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/teams/resolve': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** - * Resolve team identity - * @description Resolves a team slug to the team's identity, validating the user is a member. - */ - get: { - parameters: { - query: { - /** @description Team slug to resolve. */ - slug: components['parameters']['teamSlug'] - } - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully resolved team. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['TeamResolveResponse'] - } - } - 401: components['responses']['401'] - 403: components['responses']['403'] - 404: components['responses']['404'] - 500: components['responses']['500'] - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/teams/{teamID}': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - post?: never - delete?: never - options?: never - head?: never - /** Update team */ - patch: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the team. */ - teamID: components['parameters']['teamID'] - } - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['UpdateTeamRequest'] - } - } - responses: { - /** @description Successfully updated team. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['UpdateTeamResponse'] - } - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 403: components['responses']['403'] - 500: components['responses']['500'] - } - } - trace?: never - } - '/teams/{teamID}/members': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** List team members */ - get: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the team. */ - teamID: components['parameters']['teamID'] - } - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned team members. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['TeamMembersResponse'] - } - } - 401: components['responses']['401'] - 403: components['responses']['403'] - 500: components['responses']['500'] - } - } - put?: never - /** Add team member */ - post: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the team. */ - teamID: components['parameters']['teamID'] - } - cookie?: never - } - requestBody: { - content: { - 'application/json': components['schemas']['AddTeamMemberRequest'] - } - } - responses: { - /** @description Successfully added team member. */ - 201: { - headers: { - [name: string]: unknown - } - content?: never - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 403: components['responses']['403'] - 404: components['responses']['404'] - 500: components['responses']['500'] - } - } - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } - '/teams/{teamID}/members/{userId}': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - get?: never - put?: never - post?: never - /** Remove team member */ - delete: { - parameters: { - query?: never - header?: never - path: { - /** @description Identifier of the team. */ - teamID: components['parameters']['teamID'] - /** @description Identifier of the user. */ - userId: components['parameters']['userId'] - } - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully removed team member. */ - 204: { - headers: { - [name: string]: unknown - } - content?: never - } - 400: components['responses']['400'] - 401: components['responses']['401'] - 403: components['responses']['403'] - 500: components['responses']['500'] - } - } - options?: never - head?: never - patch?: never - trace?: never - } - '/templates/defaults': { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - /** - * List default templates - * @description Returns the list of default templates with their latest build info and aliases. - */ - get: { - parameters: { - query?: never - header?: never - path?: never - cookie?: never - } - requestBody?: never - responses: { - /** @description Successfully returned default templates. */ - 200: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['DefaultTemplatesResponse'] - } - } - 401: components['responses']['401'] - 500: components['responses']['500'] - } - } - put?: never - post?: never - delete?: never - options?: never - head?: never - patch?: never - trace?: never - } + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health check */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Health check successful */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HealthResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/builds": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List team builds */ + get: { + parameters: { + query?: { + /** @description Optional filter by build identifier, template identifier, or template alias. */ + build_id_or_template?: components["parameters"]["build_id_or_template"]; + /** @description Comma-separated list of build statuses to include. */ + statuses?: components["parameters"]["build_statuses"]; + /** @description Maximum number of items to return per page. */ + limit?: components["parameters"]["builds_limit"]; + /** @description Cursor returned by the previous list response in `created_at|build_id` format. */ + cursor?: components["parameters"]["builds_cursor"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned paginated builds. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BuildsListResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/builds/statuses": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get build statuses */ + get: { + parameters: { + query: { + /** @description Comma-separated list of build IDs to get statuses for. */ + build_ids: components["parameters"]["build_ids"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned build statuses */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BuildsStatusesResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/builds/{build_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get build details */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the build. */ + build_id: components["parameters"]["build_id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned build details. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BuildInfo"]; + }; + }; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/sandboxes/{sandboxID}/record": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get sandbox record */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the sandbox. */ + sandboxID: components["parameters"]["sandboxID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned sandbox details. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SandboxRecord"]; + }; + }; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/teams": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List user teams + * @description Returns all teams the authenticated user belongs to, with limits and default flag. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned user teams. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserTeamsResponse"]; + }; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + /** Create team */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateTeamRequest"]; + }; + }; + responses: { + /** @description Successfully created team. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TeamResolveResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/users/{userId}/bootstrap": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Bootstrap user */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the user. */ + userId: components["parameters"]["userId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully bootstrapped user. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TeamResolveResponse"]; + }; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/users/bootstrap": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Bootstrap auth provider user */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminAuthProviderUserBootstrapRequest"]; + }; + }; + responses: { + /** @description Successfully bootstrapped user. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TeamResolveResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/user-profiles/resolve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Resolve user profiles */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminAuthProviderProfilesResolveRequest"]; + }; + }; + responses: { + /** @description Successfully resolved profiles. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAuthProviderProfilesResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/user-profiles/by-email": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Lookup user profiles by email */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AdminAuthProviderProfilesLookupEmailRequest"]; + }; + }; + responses: { + /** @description Successfully found matching profiles. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAuthProviderProfilesResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/user-profiles/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get user profile */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the user. */ + userId: components["parameters"]["userId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully found profile. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAuthProviderProfilesResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/teams/resolve": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Resolve team identity + * @description Resolves a team slug to the team's identity, validating the user is a member. + */ + get: { + parameters: { + query: { + /** @description Team slug to resolve. */ + slug: components["parameters"]["teamSlug"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully resolved team. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TeamResolveResponse"]; + }; + }; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/teams/{teamID}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Update team */ + patch: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the team. */ + teamID: components["parameters"]["teamID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateTeamRequest"]; + }; + }; + responses: { + /** @description Successfully updated team. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UpdateTeamResponse"]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 500: components["responses"]["500"]; + }; + }; + trace?: never; + }; + "/teams/{teamID}/members": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List team members */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the team. */ + teamID: components["parameters"]["teamID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned team members. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TeamMembersResponse"]; + }; + }; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + /** Add team member */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the team. */ + teamID: components["parameters"]["teamID"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddTeamMemberRequest"]; + }; + }; + responses: { + /** @description Successfully added team member. */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + 500: components["responses"]["500"]; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/teams/{teamID}/members/{userId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove team member */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Identifier of the team. */ + teamID: components["parameters"]["teamID"]; + /** @description Identifier of the user. */ + userId: components["parameters"]["userId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully removed team member. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 500: components["responses"]["500"]; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/templates/defaults": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List default templates + * @description Returns the list of default templates with their latest build info and aliases. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successfully returned default templates. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DefaultTemplatesResponse"]; + }; + }; + 401: components["responses"]["401"]; + 500: components["responses"]["500"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } -export type webhooks = Record +export type webhooks = Record; export interface components { - schemas: { - Error: { - /** - * Format: int32 - * @description Error code. - */ - code: number - /** @description Error message. */ - message: string - } - AdminAuthProviderProfile: { - /** - * Format: uuid - * @description Internal E2B user identifier. - */ - userId: string - /** @description Email address from the configured auth provider. */ - email: string | null - } - AdminAuthProviderProfilesResponse: { - profiles: components['schemas']['AdminAuthProviderProfile'][] - } - AdminAuthProviderProfilesResolveRequest: { - userIds: string[] - } - AdminAuthProviderProfilesLookupEmailRequest: { - /** Format: email */ - email: string - } - AdminAuthProviderUserBootstrapRequest: { - oidc_user_id: string - /** Format: email */ - oidc_user_email: string - oidc_user_name: string | null - } - /** - * @description Build status mapped for dashboard clients. - * @enum {string} - */ - BuildStatus: 'building' | 'failed' | 'success' - ListedBuild: { - /** - * Format: uuid - * @description Identifier of the build. - */ - id: string - /** @description Template alias when present, otherwise template ID. */ - template: string - /** @description Identifier of the template. */ - templateId: string - status: components['schemas']['BuildStatus'] - /** @description Failure message when status is `failed`, otherwise `null`. */ - statusMessage: string | null - /** - * Format: date-time - * @description Build creation timestamp in RFC3339 format. - */ - createdAt: string - /** - * Format: date-time - * @description Build completion timestamp in RFC3339 format, if finished. - */ - finishedAt: string | null - } - BuildsListResponse: { - data: components['schemas']['ListedBuild'][] - /** @description Cursor to pass to the next list request, or `null` if there is no next page. */ - nextCursor: string | null - } - BuildStatusItem: { - /** - * Format: uuid - * @description Identifier of the build. - */ - id: string - status: components['schemas']['BuildStatus'] - /** - * Format: date-time - * @description Build completion timestamp in RFC3339 format, if finished. - */ - finishedAt: string | null - /** @description Failure message when status is `failed`, otherwise `null`. */ - statusMessage: string | null - } - BuildsStatusesResponse: { - /** @description List of build statuses */ - buildStatuses: components['schemas']['BuildStatusItem'][] - } - BuildInfo: { - /** @description Template names related to this build, if available. */ - names?: string[] | null - /** - * Format: date-time - * @description Build creation timestamp in RFC3339 format. - */ - createdAt: string - /** - * Format: date-time - * @description Build completion timestamp in RFC3339 format, if finished. - */ - finishedAt: string | null - status: components['schemas']['BuildStatus'] - /** @description Failure message when status is `failed`, otherwise `null`. */ - statusMessage: string | null - } - /** - * Format: int64 - * @description CPU cores for the sandbox - */ - CPUCount: number - /** - * Format: int64 - * @description Memory for the sandbox in MiB - */ - MemoryMB: number - /** - * Format: int64 - * @description Disk size for the sandbox in MiB - */ - DiskSizeMB: number - SandboxRecord: { - /** @description Identifier of the template from which is the sandbox created */ - templateID: string - /** @description Alias of the template */ - alias?: string - /** @description Identifier of the sandbox */ - sandboxID: string - /** - * Format: date-time - * @description Time when the sandbox was started - */ - startedAt: string - /** - * Format: date-time - * @description Time when the sandbox was stopped - */ - stoppedAt?: string | null - /** @description Base domain where the sandbox traffic is accessible */ - domain?: string | null - cpuCount: components['schemas']['CPUCount'] - memoryMB: components['schemas']['MemoryMB'] - diskSizeMB: components['schemas']['DiskSizeMB'] - } - HealthResponse: { - /** @description Human-readable health check result. */ - message: string - } - UserTeamLimits: { - /** Format: int64 */ - maxLengthHours: number - /** Format: int32 */ - concurrentSandboxes: number - /** Format: int32 */ - concurrentTemplateBuilds: number - /** Format: int32 */ - maxVcpu: number - /** Format: int32 */ - maxRamMb: number - /** Format: int32 */ - diskMb: number - } - UserTeam: { - /** Format: uuid */ - id: string - name: string - slug: string - tier: string - email: string - profilePictureUrl: string | null - isBlocked: boolean - isBanned: boolean - blockedReason: string | null - isDefault: boolean - limits: components['schemas']['UserTeamLimits'] - /** Format: date-time */ - createdAt: string - } - UserTeamsResponse: { - teams: components['schemas']['UserTeam'][] - } - TeamMember: { - /** Format: uuid */ - id: string - email: string - isDefault: boolean - /** Format: uuid */ - addedBy?: string | null - /** Format: date-time */ - createdAt: string | null - } - TeamMembersResponse: { - members: components['schemas']['TeamMember'][] - } - UpdateTeamRequest: { - name?: string - profilePictureUrl?: string | null - } - UpdateTeamResponse: { - /** Format: uuid */ - id: string - name: string - profilePictureUrl?: string | null - } - AddTeamMemberRequest: { - /** Format: email */ - email: string - } - CreateTeamRequest: { - name: string - } - DefaultTemplateAlias: { - alias: string - namespace?: string | null - } - DefaultTemplate: { - id: string - aliases: components['schemas']['DefaultTemplateAlias'][] - /** Format: uuid */ - buildId: string - /** Format: int64 */ - ramMb: number - /** Format: int64 */ - vcpu: number - /** Format: int64 */ - totalDiskSizeMb: number | null - envdVersion?: string | null - /** Format: date-time */ - createdAt: string - public: boolean - /** Format: int32 */ - buildCount: number - /** Format: int64 */ - spawnCount: number - } - DefaultTemplatesResponse: { - templates: components['schemas']['DefaultTemplate'][] - } - TeamResolveResponse: { - /** Format: uuid */ - id: string - slug: string - } - } - responses: { - /** @description Bad request */ - 400: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['Error'] - } - } - /** @description Authentication error */ - 401: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['Error'] - } - } - /** @description Forbidden */ - 403: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['Error'] - } - } - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['Error'] - } - } - /** @description Server error */ - 500: { - headers: { - [name: string]: unknown - } - content: { - 'application/json': components['schemas']['Error'] - } - } - } - parameters: { - /** @description Identifier of the build. */ - build_id: string - /** @description Identifier of the sandbox. */ - sandboxID: string - /** @description Maximum number of items to return per page. */ - builds_limit: number - /** @description Cursor returned by the previous list response in `created_at|build_id` format. */ - builds_cursor: string - /** @description Optional filter by build identifier, template identifier, or template alias. */ - build_id_or_template: string - /** @description Comma-separated list of build statuses to include. */ - build_statuses: components['schemas']['BuildStatus'][] - /** @description Comma-separated list of build IDs to get statuses for. */ - build_ids: string[] - /** @description Identifier of the team. */ - teamID: string - /** @description Identifier of the user. */ - userId: string - /** @description Team slug to resolve. */ - teamSlug: string - } - requestBodies: never - headers: never - pathItems: never + schemas: { + Error: { + /** + * Format: int32 + * @description Error code. + */ + code: number; + /** @description Error message. */ + message: string; + }; + AdminAuthProviderProfile: { + /** + * Format: uuid + * @description Internal E2B user identifier. + */ + userId: string; + /** @description Email address from the configured auth provider. */ + email: string | null; + }; + AdminAuthProviderProfilesResponse: { + profiles: components["schemas"]["AdminAuthProviderProfile"][]; + }; + AdminAuthProviderProfilesResolveRequest: { + userIds: string[]; + }; + AdminAuthProviderProfilesLookupEmailRequest: { + /** Format: email */ + email: string; + }; + AdminAuthProviderUserBootstrapRequest: { + oidc_issuer: string; + oidc_user_id: string; + /** Format: email */ + oidc_user_email: string; + oidc_user_name?: string | null; + }; + /** + * @description Build status mapped for dashboard clients. + * @enum {string} + */ + BuildStatus: "building" | "failed" | "success"; + ListedBuild: { + /** + * Format: uuid + * @description Identifier of the build. + */ + id: string; + /** @description Template alias when present, otherwise template ID. */ + template: string; + /** @description Identifier of the template. */ + templateId: string; + status: components["schemas"]["BuildStatus"]; + /** @description Failure message when status is `failed`, otherwise `null`. */ + statusMessage: string | null; + /** + * Format: date-time + * @description Build creation timestamp in RFC3339 format. + */ + createdAt: string; + /** + * Format: date-time + * @description Build completion timestamp in RFC3339 format, if finished. + */ + finishedAt: string | null; + }; + BuildsListResponse: { + data: components["schemas"]["ListedBuild"][]; + /** @description Cursor to pass to the next list request, or `null` if there is no next page. */ + nextCursor: string | null; + }; + BuildStatusItem: { + /** + * Format: uuid + * @description Identifier of the build. + */ + id: string; + status: components["schemas"]["BuildStatus"]; + /** + * Format: date-time + * @description Build completion timestamp in RFC3339 format, if finished. + */ + finishedAt: string | null; + /** @description Failure message when status is `failed`, otherwise `null`. */ + statusMessage: string | null; + }; + BuildsStatusesResponse: { + /** @description List of build statuses */ + buildStatuses: components["schemas"]["BuildStatusItem"][]; + }; + BuildInfo: { + /** @description Template names related to this build, if available. */ + names?: string[] | null; + /** + * Format: date-time + * @description Build creation timestamp in RFC3339 format. + */ + createdAt: string; + /** + * Format: date-time + * @description Build completion timestamp in RFC3339 format, if finished. + */ + finishedAt: string | null; + status: components["schemas"]["BuildStatus"]; + /** @description Failure message when status is `failed`, otherwise `null`. */ + statusMessage: string | null; + }; + /** + * Format: int64 + * @description CPU cores for the sandbox + */ + CPUCount: number; + /** + * Format: int64 + * @description Memory for the sandbox in MiB + */ + MemoryMB: number; + /** + * Format: int64 + * @description Disk size for the sandbox in MiB + */ + DiskSizeMB: number; + SandboxRecord: { + /** @description Identifier of the template from which is the sandbox created */ + templateID: string; + /** @description Alias of the template */ + alias?: string; + /** @description Identifier of the sandbox */ + sandboxID: string; + /** + * Format: date-time + * @description Time when the sandbox was started + */ + startedAt: string; + /** + * Format: date-time + * @description Time when the sandbox was stopped + */ + stoppedAt?: string | null; + /** @description Base domain where the sandbox traffic is accessible */ + domain?: string | null; + cpuCount: components["schemas"]["CPUCount"]; + memoryMB: components["schemas"]["MemoryMB"]; + diskSizeMB: components["schemas"]["DiskSizeMB"]; + }; + HealthResponse: { + /** @description Human-readable health check result. */ + message: string; + }; + UserTeamLimits: { + /** Format: int64 */ + maxLengthHours: number; + /** Format: int32 */ + concurrentSandboxes: number; + /** Format: int32 */ + concurrentTemplateBuilds: number; + /** Format: int32 */ + maxVcpu: number; + /** Format: int32 */ + maxRamMb: number; + /** Format: int32 */ + diskMb: number; + }; + UserTeam: { + /** Format: uuid */ + id: string; + name: string; + slug: string; + tier: string; + email: string; + profilePictureUrl: string | null; + isBlocked: boolean; + isBanned: boolean; + blockedReason: string | null; + isDefault: boolean; + limits: components["schemas"]["UserTeamLimits"]; + /** Format: date-time */ + createdAt: string; + }; + UserTeamsResponse: { + teams: components["schemas"]["UserTeam"][]; + }; + TeamMember: { + /** Format: uuid */ + id: string; + email: string; + name?: string | null; + /** Format: uri */ + profilePictureUrl?: string | null; + providers: string[]; + isDefault: boolean; + /** Format: uuid */ + addedBy?: string | null; + /** Format: date-time */ + createdAt: string | null; + }; + TeamMembersResponse: { + members: components["schemas"]["TeamMember"][]; + }; + UpdateTeamRequest: { + name?: string; + profilePictureUrl?: string | null; + }; + UpdateTeamResponse: { + /** Format: uuid */ + id: string; + name: string; + profilePictureUrl?: string | null; + }; + AddTeamMemberRequest: { + /** Format: email */ + email: string; + }; + CreateTeamRequest: { + name: string; + }; + DefaultTemplateAlias: { + alias: string; + namespace?: string | null; + }; + DefaultTemplate: { + id: string; + aliases: components["schemas"]["DefaultTemplateAlias"][]; + /** Format: uuid */ + buildId: string; + /** Format: int64 */ + ramMb: number; + /** Format: int64 */ + vcpu: number; + /** Format: int64 */ + totalDiskSizeMb: number | null; + envdVersion?: string | null; + /** Format: date-time */ + createdAt: string; + public: boolean; + /** Format: int32 */ + buildCount: number; + /** Format: int64 */ + spawnCount: number; + }; + DefaultTemplatesResponse: { + templates: components["schemas"]["DefaultTemplate"][]; + }; + TeamResolveResponse: { + /** Format: uuid */ + id: string; + slug: string; + }; + }; + responses: { + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Authentication error */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + parameters: { + /** @description Identifier of the build. */ + build_id: string; + /** @description Identifier of the sandbox. */ + sandboxID: string; + /** @description Maximum number of items to return per page. */ + builds_limit: number; + /** @description Cursor returned by the previous list response in `created_at|build_id` format. */ + builds_cursor: string; + /** @description Optional filter by build identifier, template identifier, or template alias. */ + build_id_or_template: string; + /** @description Comma-separated list of build statuses to include. */ + build_statuses: components["schemas"]["BuildStatus"][]; + /** @description Comma-separated list of build IDs to get statuses for. */ + build_ids: string[]; + /** @description Identifier of the team. */ + teamID: string; + /** @description Identifier of the user. */ + userId: string; + /** @description Team slug to resolve. */ + teamSlug: string; + }; + requestBodies: never; + headers: never; + pathItems: never; } -export type $defs = Record -export type operations = Record +export type $defs = Record; +export type operations = Record; diff --git a/tests/unit/teams-repository.test.ts b/tests/unit/teams-repository.test.ts index 493d2951d..f9f9a77a3 100644 --- a/tests/unit/teams-repository.test.ts +++ b/tests/unit/teams-repository.test.ts @@ -1,16 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { createTeamsRepository } from '@/core/modules/teams/teams-repository.server' -vi.mock('@/core/shared/clients/supabase/admin', () => ({ - supabaseAdmin: { - auth: { - admin: { - getUserById: vi.fn(), - }, - }, - }, -})) - describe('createTeamsRepository', () => { it('returns a repo error instead of throwing when a team-scoped method has no teamId', async () => { const repository = createTeamsRepository( @@ -23,9 +13,6 @@ describe('createTeamsRepository', () => { DELETE: vi.fn(), } as unknown as typeof import('@/core/shared/clients/api').api, authHeaders: vi.fn(() => ({ 'X-Supabase-Token': 'token' })), - authAdmin: { - getUserById: vi.fn(), - }, } ) From 6f1a150dea9c7183e3634eb23075343ca9bb9165 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Tue, 26 May 2026 16:00:57 -0700 Subject: [PATCH 05/16] feat(auth): add AUTH_MIGRATION_IN_PROGRESS flag Freezes user/team membership churn while the identity store migrates. - New NEXT_PUBLIC_AUTH_MIGRATION_IN_PROGRESS env in src/configs/flags.ts. - signUpAction returns a "paused" error when the flag is on. - /sign-up route renders a migration notice instead of the form/OAuth buttons. - tRPC teams.addMember procedure throws FORBIDDEN when the flag is on, and the members page hides the Add new member dialog. Sign-in (returning users) is intentionally unaffected. To also block brand-new OIDC account creation via the sign-in page, toggle "Registration" off in the Ory Console for the duration of the migration; the dashboard flag is the front-end and add-member chokepoint. --- src/app/(auth)/sign-up/signup-form.tsx | 21 ++++++++++++++++++- src/configs/flags.ts | 7 +++++++ src/core/server/actions/auth-actions.ts | 11 +++++++++- src/core/server/api/routers/teams.ts | 9 ++++++++ .../members/members-page-content.tsx | 3 ++- 5 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/app/(auth)/sign-up/signup-form.tsx b/src/app/(auth)/sign-up/signup-form.tsx index 6397103d3..2b4acc7b9 100644 --- a/src/app/(auth)/sign-up/signup-form.tsx +++ b/src/app/(auth)/sign-up/signup-form.tsx @@ -5,7 +5,10 @@ import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hoo import Link from 'next/link' import { useSearchParams } from 'next/navigation' import { Suspense, useEffect, useRef, useState } from 'react' -import { CAPTCHA_REQUIRED_CLIENT } from '@/configs/flags' +import { + AUTH_MIGRATION_IN_PROGRESS, + CAPTCHA_REQUIRED_CLIENT, +} from '@/configs/flags' import { AUTH_URLS } from '@/configs/urls' import { getTimeoutMsFromUserMessage, @@ -94,6 +97,22 @@ export default function SignUp() { } }, [message]) + if (AUTH_MIGRATION_IN_PROGRESS) { + return ( +
+

Sign up

+

+ New sign-ups are temporarily paused while we migrate our + authentication system. Existing users can still{' '} + + sign in + + . +

+
+ ) + } + return (

Sign up

diff --git a/src/configs/flags.ts b/src/configs/flags.ts index 66a0b604f..bcfd247a4 100644 --- a/src/configs/flags.ts +++ b/src/configs/flags.ts @@ -29,3 +29,10 @@ export const CAPTCHA_REQUIRED_SERVER = export function isOryAuthEnabled() { return process.env.AUTH_PROVIDER === 'ory' } + +// freezes user/team membership mutations while we migrate identity stores. +// when on: blocks new sign-ups (email/password + freshly-registered OIDC +// identities) and rejects add-team-member requests. existing users keep +// signing in normally. +export const AUTH_MIGRATION_IN_PROGRESS = + process.env.NEXT_PUBLIC_AUTH_MIGRATION_IN_PROGRESS === '1' diff --git a/src/core/server/actions/auth-actions.ts b/src/core/server/actions/auth-actions.ts index 7c21e6674..ff1d2cfdf 100644 --- a/src/core/server/actions/auth-actions.ts +++ b/src/core/server/actions/auth-actions.ts @@ -4,7 +4,10 @@ import { headers } from 'next/headers' import { redirect } from 'next/navigation' import { returnValidationErrors } from 'next-safe-action' import { z } from 'zod' -import { CAPTCHA_REQUIRED_SERVER } from '@/configs/flags' +import { + AUTH_MIGRATION_IN_PROGRESS, + CAPTCHA_REQUIRED_SERVER, +} from '@/configs/flags' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { USER_MESSAGES } from '@/configs/user-messages' import { actionClient } from '@/core/server/actions/client' @@ -177,6 +180,12 @@ export const signUpAction = actionClient async ({ parsedInput: { email, password, returnTo = '', captchaToken }, }) => { + if (AUTH_MIGRATION_IN_PROGRESS) { + return returnServerError( + 'Sign-ups are temporarily paused while we migrate our authentication system. Please try again later.' + ) + } + const captchaError = await validateCaptcha(captchaToken) if (captchaError) return captchaError diff --git a/src/core/server/api/routers/teams.ts b/src/core/server/api/routers/teams.ts index f55bbad17..7267c39ef 100644 --- a/src/core/server/api/routers/teams.ts +++ b/src/core/server/api/routers/teams.ts @@ -3,6 +3,7 @@ import { fileTypeFromBuffer } from 'file-type' import { revalidatePath } from 'next/cache' import { after } from 'next/server' import { z } from 'zod' +import { AUTH_MIGRATION_IN_PROGRESS } from '@/configs/flags' import { createKeysRepository } from '@/core/modules/keys/repository.server' import { CreateApiKeySchema } from '@/core/modules/keys/schemas' import { @@ -158,6 +159,14 @@ export const teamsRouter = createTRPCRouter({ addMember: teamsRepositoryProcedure .input(AddTeamMemberSchema) .mutation(async ({ ctx, input }) => { + if (AUTH_MIGRATION_IN_PROGRESS) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: + 'Adding team members is temporarily paused while we migrate our authentication system. Please try again later.', + }) + } + const result = await ctx.teamsRepository.addTeamMember(input.email) if (!result.ok) throwTRPCErrorFromRepoError(result.error) diff --git a/src/features/dashboard/members/members-page-content.tsx b/src/features/dashboard/members/members-page-content.tsx index f2bf12cdb..4e9c776d8 100644 --- a/src/features/dashboard/members/members-page-content.tsx +++ b/src/features/dashboard/members/members-page-content.tsx @@ -2,6 +2,7 @@ import { useSuspenseQuery } from '@tanstack/react-query' import { Suspense, useMemo, useState } from 'react' +import { AUTH_MIGRATION_IN_PROGRESS } from '@/configs/flags' import { useDashboard } from '@/features/dashboard/context' import { cn } from '@/lib/utils' import { pluralize } from '@/lib/utils/formatting' @@ -94,7 +95,7 @@ export const MembersPageContent = ({ className }: MembersPageContentProps) => { value={query} />
- + {!AUTH_MIGRATION_IN_PROGRESS && } From 609663f7e10168e07496e47dca68b154aa6fab5d Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 28 May 2026 18:29:17 -0700 Subject: [PATCH 06/16] feat(auth): add Ory OAuth entry/exit route handlers Server route handlers that drive the Ory OAuth2 flow: - oauth-start: server-side signIn entry (sets state/PKCE cookies), mapping intent -> prompt (registration/login) for signup and reauth. - oauth-recover: catches Auth.js OAuth errors, logs them, and bounces to /sign-in with a short-lived loop guard. - signout-flow: Auth.js sign-out + Kratos session revocation (by the resolved identityId, not the OIDC subject) + Hydra RP-initiated logout. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/api/auth/oauth-recover/route.ts | 48 ++++++++++++ src/app/api/auth/oauth-start/route.ts | 30 ++++++++ src/app/api/auth/oauth/signout-flow/route.ts | 23 +++++- tests/unit/signout-flow.test.ts | 77 ++++++++++++++++++++ 4 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 src/app/api/auth/oauth-recover/route.ts create mode 100644 src/app/api/auth/oauth-start/route.ts create mode 100644 tests/unit/signout-flow.test.ts diff --git a/src/app/api/auth/oauth-recover/route.ts b/src/app/api/auth/oauth-recover/route.ts new file mode 100644 index 000000000..bb12c75b5 --- /dev/null +++ b/src/app/api/auth/oauth-recover/route.ts @@ -0,0 +1,48 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { AUTH_URLS } from '@/configs/urls' +import { l } from '@/core/shared/clients/logger/logger' + +// Auth.js renders its built-in `${basePath}/error` page when something fails +// during the OAuth dance (most commonly a stale state/PKCE/nonce cookie that +// expired while the user lingered on the Ory hosted UI). We point +// `pages.error` here so the user never sees that page - we log the failure +// for observability and bounce them back to /sign-in, which restarts the +// flow with fresh cookies via the middleware -> oauth-start chain. +// +// A short-lived cookie prevents tight loops when the underlying failure is +// genuinely persistent (e.g. ORY_SDK_URL misconfigured). After one recovery +// attempt in the window, subsequent failures fall back to the marketing +// root so the user isn't bounced indefinitely. +const RECOVERY_COOKIE = 'auth_recover_attempted' +const RECOVERY_COOKIE_MAX_AGE_SECONDS = 30 + +export async function GET(request: NextRequest) { + const errorCode = request.nextUrl.searchParams.get('error') ?? 'unknown' + const alreadyAttempted = request.cookies.get(RECOVERY_COOKIE)?.value === '1' + + l.error( + { + key: 'oauth_recover:auth_js_error', + context: { error_code: errorCode, already_attempted: alreadyAttempted }, + }, + 'Auth.js OAuth flow failed; recovering user' + ) + + const destination = alreadyAttempted ? '/' : AUTH_URLS.SIGN_IN + const response = NextResponse.redirect(new URL(destination, request.url)) + + if (alreadyAttempted) { + response.cookies.delete(RECOVERY_COOKIE) + } else { + response.cookies.set(RECOVERY_COOKIE, '1', { + maxAge: RECOVERY_COOKIE_MAX_AGE_SECONDS, + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: process.env.NODE_ENV === 'production', + }) + } + + return response +} diff --git a/src/app/api/auth/oauth-start/route.ts b/src/app/api/auth/oauth-start/route.ts new file mode 100644 index 000000000..a25e3beeb --- /dev/null +++ b/src/app/api/auth/oauth-start/route.ts @@ -0,0 +1,30 @@ +import { signIn } from '@/auth' + +// Server-side entry point for the Ory OAuth2 flow. Pages redirect here +// instead of rendering a client-side form so that Auth.js can set its +// state/PKCE cookies (only allowed in route handlers / server actions +// / middleware) without any client JS in the loop. +// +// `intent=signup` forwards `prompt=registration` to Hydra, which routes +// to its registration UI (`urls.registration`, default `/ui/registration`) +// instead of the login UI. +// +// `intent=reauth` forwards `prompt=login`, forcing Hydra to redo the login +// flow even with an active session so we get a fresh `auth_time`. Used to +// re-authenticate before sensitive account changes (password). +// https://www.ory.com/docs/oauth2-oidc/authorization-code-flow +export async function GET(request: Request) { + const url = new URL(request.url) + const intent = url.searchParams.get('intent') + const returnTo = url.searchParams.get('returnTo') + const redirectTo = returnTo && returnTo.length > 0 ? returnTo : '/dashboard' + + const authorizationParams = + intent === 'signup' + ? { prompt: 'registration' } + : intent === 'reauth' + ? { prompt: 'login' } + : undefined + + await signIn('ory', { redirectTo }, authorizationParams) +} diff --git a/src/app/api/auth/oauth/signout-flow/route.ts b/src/app/api/auth/oauth/signout-flow/route.ts index bf2dd055d..b9a994279 100644 --- a/src/app/api/auth/oauth/signout-flow/route.ts +++ b/src/app/api/auth/oauth/signout-flow/route.ts @@ -3,17 +3,25 @@ import 'server-only' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { auth, signOut } from '@/auth' -import { AUTH_URLS } from '@/configs/urls' +import { revokeKratosSessionsForIdentity } from '@/core/server/auth/ory/kratos-session' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +// lands users on the marketing root instead of /sign-in so they don't get +// bounced straight back to the Ory-hosted login UI after signing out. +const ORY_POST_LOGOUT_PATH = '/' + export async function GET(request: NextRequest) { const origin = request.nextUrl.origin - const signInUrl = new URL(AUTH_URLS.SIGN_IN, origin) + const postLogoutUrl = new URL(ORY_POST_LOGOUT_PATH, origin) let idToken: string | undefined + let identityId: string | undefined try { const session = await auth() idToken = session?.idToken + // The Kratos identity id resolved at sign-in — NOT the OIDC subject (which + // is the E2B user id) — so we revoke the right identity's Kratos sessions. + identityId = session?.identityId } catch (error) { l.warn( { @@ -36,16 +44,23 @@ export async function GET(request: NextRequest) { ) } + if (identityId) { + await revokeKratosSessionsForIdentity(identityId) + } + const sdkUrl = process.env.ORY_SDK_URL if (!idToken || !sdkUrl) { - return NextResponse.redirect(signInUrl) + return NextResponse.redirect(postLogoutUrl) } const hydraLogout = new URL( `${sdkUrl.replace(/\/$/, '')}/oauth2/sessions/logout` ) hydraLogout.searchParams.set('id_token_hint', idToken) - hydraLogout.searchParams.set('post_logout_redirect_uri', signInUrl.toString()) + hydraLogout.searchParams.set( + 'post_logout_redirect_uri', + postLogoutUrl.toString() + ) return NextResponse.redirect(hydraLogout.toString()) } diff --git a/tests/unit/signout-flow.test.ts b/tests/unit/signout-flow.test.ts new file mode 100644 index 000000000..fa50b306d --- /dev/null +++ b/tests/unit/signout-flow.test.ts @@ -0,0 +1,77 @@ +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const authMock = vi.hoisted(() => vi.fn()) +const signOutMock = vi.hoisted(() => vi.fn()) +const revokeKratosSessionsMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/auth', () => ({ auth: authMock, signOut: signOutMock })) + +vi.mock('@/core/server/auth/ory/kratos-session', () => ({ + revokeKratosSessionsForIdentity: revokeKratosSessionsMock, +})) + +vi.mock('@/core/shared/clients/logger/logger', () => ({ + l: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, + serializeErrorForLog: vi.fn((error: unknown) => error), +})) + +const { GET } = await import('@/app/api/auth/oauth/signout-flow/route') + +function request(): NextRequest { + return new NextRequest('https://app.e2b.dev/api/auth/oauth/signout-flow') +} + +beforeEach(() => { + authMock.mockReset() + signOutMock.mockReset().mockResolvedValue(undefined) + revokeKratosSessionsMock.mockReset().mockResolvedValue(undefined) + vi.stubEnv('ORY_SDK_URL', 'https://project.oryapis.com') +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('signout-flow GET', () => { + it('revokes Kratos sessions using the resolved identityId (not the OIDC sub)', async () => { + authMock.mockResolvedValue({ + idToken: 'id.token.sig', + identityId: 'kratos-uuid', + }) + + await GET(request()) + + expect(revokeKratosSessionsMock).toHaveBeenCalledWith('kratos-uuid') + }) + + it('skips revocation when the session has no resolved identityId', async () => { + authMock.mockResolvedValue({ idToken: 'id.token.sig' }) + + await GET(request()) + + expect(revokeKratosSessionsMock).not.toHaveBeenCalled() + }) + + it('redirects to the Hydra logout endpoint with the id_token hint', async () => { + authMock.mockResolvedValue({ + idToken: 'id.token.sig', + identityId: 'kratos-uuid', + }) + + const response = await GET(request()) + const location = response.headers.get('location') ?? '' + + expect(location).toContain('/oauth2/sessions/logout') + expect(location).toContain('id_token_hint=id.token.sig') + }) + + it('redirects to the marketing root when there is no id_token', async () => { + authMock.mockResolvedValue({ identityId: 'kratos-uuid' }) + + const response = await GET(request()) + const location = response.headers.get('location') ?? '' + + expect(location).toBe('https://app.e2b.dev/') + }) +}) From c0ce1f7453ff0dfeeb8fee10476d2560d49646c0 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 28 May 2026 18:29:23 -0700 Subject: [PATCH 07/16] chore(auth): drop legacy auth actions and simplify (auth) pages for Ory Removes the unused ory-auth-actions module and the OryHostedAuthRedirect component; the redirect to the Ory hosted UI now happens at the middleware layer (getOryAuthRouteRedirect). The (auth) sign-in/sign-up/forgot-password pages no longer branch on the Ory flag. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/(auth)/forgot-password/page.tsx | 12 +------ src/app/(auth)/sign-in/page.tsx | 12 +------ src/app/(auth)/sign-up/page.tsx | 12 +------ src/core/server/actions/ory-auth-actions.ts | 14 -------- .../auth/ory-hosted-auth-redirect.tsx | 36 ------------------- 5 files changed, 3 insertions(+), 83 deletions(-) delete mode 100644 src/core/server/actions/ory-auth-actions.ts delete mode 100644 src/features/auth/ory-hosted-auth-redirect.tsx diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx index 3edf03093..4b75ef913 100644 --- a/src/app/(auth)/forgot-password/page.tsx +++ b/src/app/(auth)/forgot-password/page.tsx @@ -1,15 +1,5 @@ -import { isOryAuthEnabled } from '@/configs/flags' -import { OryHostedAuthRedirect } from '@/features/auth/ory-hosted-auth-redirect' import ForgotPassword from './forgot-password-form' -interface PageProps { - searchParams: Promise<{ returnTo?: string }> -} - -export default async function Page({ searchParams }: PageProps) { - if (isOryAuthEnabled()) { - const { returnTo } = await searchParams - return - } +export default function Page() { return } diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx index c33bfe8bc..30f2a3f11 100644 --- a/src/app/(auth)/sign-in/page.tsx +++ b/src/app/(auth)/sign-in/page.tsx @@ -1,15 +1,5 @@ -import { isOryAuthEnabled } from '@/configs/flags' -import { OryHostedAuthRedirect } from '@/features/auth/ory-hosted-auth-redirect' import Login from './login-form' -interface PageProps { - searchParams: Promise<{ returnTo?: string }> -} - -export default async function Page({ searchParams }: PageProps) { - if (isOryAuthEnabled()) { - const { returnTo } = await searchParams - return - } +export default function Page() { return } diff --git a/src/app/(auth)/sign-up/page.tsx b/src/app/(auth)/sign-up/page.tsx index cc7a0d1b9..12d998e4e 100644 --- a/src/app/(auth)/sign-up/page.tsx +++ b/src/app/(auth)/sign-up/page.tsx @@ -1,15 +1,5 @@ -import { isOryAuthEnabled } from '@/configs/flags' -import { OryHostedAuthRedirect } from '@/features/auth/ory-hosted-auth-redirect' import SignUp from './signup-form' -interface PageProps { - searchParams: Promise<{ returnTo?: string }> -} - -export default async function Page({ searchParams }: PageProps) { - if (isOryAuthEnabled()) { - const { returnTo } = await searchParams - return - } +export default function Page() { return } diff --git a/src/core/server/actions/ory-auth-actions.ts b/src/core/server/actions/ory-auth-actions.ts deleted file mode 100644 index ae5230c24..000000000 --- a/src/core/server/actions/ory-auth-actions.ts +++ /dev/null @@ -1,14 +0,0 @@ -'use server' - -import { signIn } from '@/auth' - -// thin wrapper around Auth.js's signIn() that exists so client components -// can submit a form to it. signIn() throws a redirect; never returns normally. -export async function signInWithOryAction(formData: FormData) { - const returnTo = formData.get('returnTo') - const redirectTo = - typeof returnTo === 'string' && returnTo.length > 0 - ? returnTo - : '/dashboard' - await signIn('ory', { redirectTo }) -} diff --git a/src/features/auth/ory-hosted-auth-redirect.tsx b/src/features/auth/ory-hosted-auth-redirect.tsx deleted file mode 100644 index 02f954199..000000000 --- a/src/features/auth/ory-hosted-auth-redirect.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'use client' - -import { useEffect, useRef } from 'react' -import { signInWithOryAction } from '@/core/server/actions/ory-auth-actions' - -interface OryHostedAuthRedirectProps { - returnTo?: string -} - -export function OryHostedAuthRedirect({ - returnTo, -}: OryHostedAuthRedirectProps) { - const formRef = useRef(null) - - useEffect(() => { - formRef.current?.requestSubmit() - }, []) - - return ( -
-

Redirecting…

-

- Hold on while we send you to the sign-in page. -

-
- - -
-
- ) -} From f3881c5627d046fdbfd06c4af891587a4b0b7181 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 28 May 2026 18:29:31 -0700 Subject: [PATCH 08/16] refactor(proxy): split middleware into a pipeline of concern handlers Extracts the middleware-redirect / route-rewrite / middleware-rewrite / auth-gate concerns into named handlers in core/server/http/proxy.ts, so proxyCore reads as a first-non-null pipeline. Collapses the awkward isAuthenticated threading into handleAuthGate(knownAuth) and names the poisoned-session guard isSessionAuthenticated. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/server/http/proxy.ts | 81 +++++++++++++++++ src/proxy.ts | 140 +++++++++--------------------- tests/unit/proxy-handlers.test.ts | 118 +++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 99 deletions(-) create mode 100644 tests/unit/proxy-handlers.test.ts diff --git a/src/core/server/http/proxy.ts b/src/core/server/http/proxy.ts index e49242d32..13cb8f9c3 100644 --- a/src/core/server/http/proxy.ts +++ b/src/core/server/http/proxy.ts @@ -1,7 +1,11 @@ import 'server-cli-only' import { type NextRequest, NextResponse } from 'next/server' +import { ALLOW_SEO_INDEXING } from '@/configs/flags' import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' +import { createAuthForProxy } from '@/core/server/auth' +import { getMiddlewareRedirectFromPath } from '@/lib/utils/redirects' +import { getRewriteForPath } from '@/lib/utils/rewrites' export function isAuthRoute(pathname: string): boolean { return ( @@ -45,3 +49,80 @@ export function getAuthRedirect( return null } + +// The handlers below are the ordered concerns the proxy runs for every request. +// Each returns a Response when it handles the request, or null to fall through +// to the next concern. They live here (not in static next.config matchers) +// because they need custom headers / runtime path logic. + +// Redirects that require custom response headers. +export function handleMiddlewareRedirect( + request: NextRequest +): NextResponse | null { + const redirect = getMiddlewareRedirectFromPath(request.nextUrl.pathname) + if (!redirect) return null + + return NextResponse.redirect(new URL(redirect.destination, request.url), { + status: redirect.statusCode, + headers: new Headers(redirect.headers), + }) +} + +// Catch-all route rewrites are resolved by the route itself, so the proxy just +// passes them through untouched. +export function handleRouteRewritePassthrough( + request: NextRequest +): NextResponse | null { + const { config } = getRewriteForPath(request.nextUrl.pathname, 'route') + return config ? NextResponse.next({ request }) : null +} + +// Rewrites the proxy performs itself (serving another origin under our domain), +// tagging the request/response with the SEO-indexing intent. +export function handleMiddlewareRewrite( + request: NextRequest +): NextResponse | null { + const { config, rule } = getRewriteForPath( + request.nextUrl.pathname, + 'middleware' + ) + if (!config) return null + + const rewriteUrl = new URL(request.url) + rewriteUrl.hostname = config.domain + rewriteUrl.protocol = 'https' + rewriteUrl.port = '' + if (rule?.pathPreprocessor) { + rewriteUrl.pathname = rule.pathPreprocessor(rewriteUrl.pathname) + } + + const requestHeaders = new Headers(request.headers) + if (ALLOW_SEO_INDEXING) { + requestHeaders.set('x-e2b-should-index', '1') + } + + const response = NextResponse.rewrite(rewriteUrl, { + request: { headers: requestHeaders }, + }) + response.headers.set( + 'X-Robots-Tag', + ALLOW_SEO_INDEXING ? 'index, follow' : 'noindex, nofollow' + ) + return response +} + +// Terminal concern: gate dashboard/auth routes on authentication. `knownAuth` +// is supplied in Ory mode (resolved by the Auth.js middleware wrapper); in +// Supabase mode it's resolved here from the request/response cookies. +export async function handleAuthGate( + request: NextRequest, + knownAuth?: boolean +): Promise { + const response = NextResponse.next({ request }) + + const isAuthenticated = + knownAuth ?? + !!(await createAuthForProxy(request, response).getAuthContext()) + + return getAuthRedirect(request, isAuthenticated) ?? response +} diff --git a/src/proxy.ts b/src/proxy.ts index ede4d44f3..8662fcf0b 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -3,105 +3,32 @@ import { type NextRequest, NextResponse, } from 'next/server' +import type { Session } from 'next-auth' import { auth as authjsMiddleware } from '@/auth' -import { ALLOW_SEO_INDEXING, isOryAuthEnabled } from './configs/flags' -import { createAuthForProxy } from './core/server/auth' -import { getAuthRedirect } from './core/server/http/proxy' +import { isOryAuthEnabled } from './configs/flags' +import { getOryAuthRouteRedirect } from './core/server/auth/ory/auth-route-redirect' +import { + handleAuthGate, + handleMiddlewareRedirect, + handleMiddlewareRewrite, + handleRouteRewritePassthrough, +} from './core/server/http/proxy' import { l, serializeErrorForLog } from './core/shared/clients/logger/logger' -import { getMiddlewareRedirectFromPath } from './lib/utils/redirects' -import { getRewriteForPath } from './lib/utils/rewrites' +// Runs the proxy's ordered concerns: the first handler that returns a Response +// wins; otherwise we fall through to the auth gate. `knownAuth` is passed in Ory +// mode (resolved by the Auth.js middleware wrapper) and omitted in Supabase mode. async function proxyCore( request: NextRequest, - resolvedIsAuthenticated?: boolean + knownAuth?: boolean ): Promise { try { - const pathname = request.nextUrl.pathname - - // Redirects, that require custom headers - // NOTE: We don't handle this via config matchers, because nextjs configs need to be static - const middlewareRedirect = getMiddlewareRedirectFromPath( - request.nextUrl.pathname + return ( + handleMiddlewareRedirect(request) ?? + handleRouteRewritePassthrough(request) ?? + handleMiddlewareRewrite(request) ?? + (await handleAuthGate(request, knownAuth)) ) - - if (middlewareRedirect) { - const headers = new Headers(middlewareRedirect.headers) - const url = new URL(middlewareRedirect.destination, request.url) - - return NextResponse.redirect(url, { - status: middlewareRedirect.statusCode, - headers, - }) - } - - // Catch-all route rewrite paths should not be handled by middleware - // NOTE: We don't handle this via config matchers, because nextjs configs need to be static - const { config: routeRewriteConfig } = getRewriteForPath(pathname, 'route') - - if (routeRewriteConfig) { - return NextResponse.next({ - request, - }) - } - - // Check if the path should be rewritten by middleware - const { config: middlewareRewriteConfig, rule: middlewareRewriteRule } = - getRewriteForPath(pathname, 'middleware') - - if (middlewareRewriteConfig) { - const rewriteUrl = new URL(request.url) - rewriteUrl.hostname = middlewareRewriteConfig.domain - rewriteUrl.protocol = 'https' - rewriteUrl.port = '' - if (middlewareRewriteRule?.pathPreprocessor) { - rewriteUrl.pathname = middlewareRewriteRule.pathPreprocessor( - rewriteUrl.pathname - ) - } - - const headers = new Headers(request.headers) - - if (ALLOW_SEO_INDEXING) { - headers.set('x-e2b-should-index', '1') - } - - const response = NextResponse.rewrite(rewriteUrl, { - request: { - headers, - }, - }) - - if (ALLOW_SEO_INDEXING) { - response.headers.set('X-Robots-Tag', 'index, follow') - } else { - response.headers.set('X-Robots-Tag', 'noindex, nofollow') - } - - return response - } - - const response = NextResponse.next({ - request, - }) - - let isAuthenticated: boolean - if (resolvedIsAuthenticated !== undefined) { - isAuthenticated = resolvedIsAuthenticated - } else { - const authContext = await createAuthForProxy( - request, - response - ).getAuthContext() - isAuthenticated = !!authContext - } - - const authRedirect = getAuthRedirect(request, isAuthenticated) - - if (authRedirect) { - return authRedirect - } - - return response } catch (error) { l.error( { @@ -116,21 +43,36 @@ async function proxyCore( ) // return a basic response to avoid infinite loops - return NextResponse.next({ - request, - }) + return NextResponse.next({ request }) } } -const proxyWithOryAuth = authjsMiddleware(async (req, _event: NextFetchEvent) => - proxyCore(req, !!req.auth) +// req.auth is truthy even when the session carries a RefreshTokenError, so we +// must check session.error too — otherwise the auth-route guard treats a +// poisoned session as "logged in" and ping-pongs the user between /dashboard +// (redirects to /sign-in via getAuthContext()) and /sign-in (redirects back to +// /dashboard via the proxy's authenticated-on-auth-route rule). +function isSessionAuthenticated(session: Session | null): boolean { + return !!session && !session.error +} + +// In Ory mode the Auth.js middleware wrapper populates req.auth and manages its +// session cookies, so auth is resolved here and threaded into proxyCore. +const proxyWithOryAuth = authjsMiddleware((req, _event: NextFetchEvent) => + proxyCore(req, isSessionAuthenticated(req.auth)) ) export async function proxy(request: NextRequest, event: NextFetchEvent) { - if (isOryAuthEnabled()) { - return proxyWithOryAuth(request, event) + if (!isOryAuthEnabled()) { + return proxyCore(request) } - return proxyCore(request) + + // Bounce the legacy auth pages straight to the Ory hosted UI before the + // (auth) layout can render. + const authRouteRedirect = getOryAuthRouteRedirect(request) + if (authRouteRedirect) return authRouteRedirect + + return proxyWithOryAuth(request, event) } export const config = { diff --git a/tests/unit/proxy-handlers.test.ts b/tests/unit/proxy-handlers.test.ts new file mode 100644 index 000000000..6ed05e357 --- /dev/null +++ b/tests/unit/proxy-handlers.test.ts @@ -0,0 +1,118 @@ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const getMiddlewareRedirectMock = vi.hoisted(() => vi.fn()) +const getRewriteForPathMock = vi.hoisted(() => vi.fn()) +const createAuthForProxyMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/configs/flags', () => ({ ALLOW_SEO_INDEXING: false })) + +vi.mock('@/lib/utils/redirects', () => ({ + getMiddlewareRedirectFromPath: getMiddlewareRedirectMock, +})) + +vi.mock('@/lib/utils/rewrites', () => ({ + getRewriteForPath: getRewriteForPathMock, +})) + +vi.mock('@/core/server/auth', () => ({ + createAuthForProxy: createAuthForProxyMock, +})) + +const { + handleMiddlewareRedirect, + handleRouteRewritePassthrough, + handleMiddlewareRewrite, + handleAuthGate, +} = await import('@/core/server/http/proxy') + +function request(path: string): NextRequest { + return new NextRequest(`https://app.e2b.dev${path}`) +} + +beforeEach(() => { + getMiddlewareRedirectMock.mockReset().mockReturnValue(undefined) + getRewriteForPathMock.mockReset().mockReturnValue({ config: undefined }) + createAuthForProxyMock.mockReset() +}) + +describe('handleMiddlewareRedirect', () => { + it('returns a redirect with the configured status and headers', () => { + getMiddlewareRedirectMock.mockReturnValue({ + destination: '/new-home', + statusCode: 308, + headers: { 'x-custom': 'yes' }, + }) + + const response = handleMiddlewareRedirect(request('/old-home')) + + expect(response?.status).toBe(308) + expect(response?.headers.get('location')).toContain('/new-home') + }) + + it('returns null when there is no matching redirect', () => { + expect(handleMiddlewareRedirect(request('/anything'))).toBeNull() + }) +}) + +describe('handleRouteRewritePassthrough', () => { + it('passes through when a catch-all route rewrite matches', () => { + getRewriteForPathMock.mockReturnValue({ config: { domain: 'x' } }) + + const response = handleRouteRewritePassthrough(request('/docs')) + + expect(response).not.toBeNull() + expect(response?.headers.get('location')).toBeNull() + }) + + it('returns null when no route rewrite matches', () => { + expect(handleRouteRewritePassthrough(request('/docs'))).toBeNull() + }) +}) + +describe('handleMiddlewareRewrite', () => { + it('rewrites to the configured domain, applies the path preprocessor, and tags no-index', () => { + getRewriteForPathMock.mockReturnValue({ + config: { domain: 'docs.e2b.dev' }, + rule: { pathPreprocessor: (p: string) => p.replace('/docs', '') }, + }) + + const response = handleMiddlewareRewrite(request('/docs/guide')) + + expect(response).not.toBeNull() + const rewrittenTo = response?.headers.get('x-middleware-rewrite') ?? '' + expect(rewrittenTo).toContain('docs.e2b.dev') + expect(rewrittenTo).toContain('/guide') + expect(response?.headers.get('X-Robots-Tag')).toBe('noindex, nofollow') + }) + + it('returns null when no middleware rewrite matches', () => { + expect(handleMiddlewareRewrite(request('/dashboard'))).toBeNull() + }) +}) + +describe('handleAuthGate', () => { + it('redirects an unauthenticated dashboard request without resolving auth when knownAuth is provided', async () => { + const response = await handleAuthGate(request('/dashboard/team-x'), false) + + expect(response.headers.get('location')).toContain('/sign-in') + expect(createAuthForProxyMock).not.toHaveBeenCalled() + }) + + it('resolves auth from the request when knownAuth is omitted', async () => { + createAuthForProxyMock.mockReturnValue({ + getAuthContext: vi.fn().mockResolvedValue(null), + }) + + const response = await handleAuthGate(request('/dashboard/team-x')) + + expect(createAuthForProxyMock).toHaveBeenCalled() + expect(response.headers.get('location')).toContain('/sign-in') + }) + + it('passes through when authenticated on a neutral route', async () => { + const response = await handleAuthGate(request('/some/page'), true) + + expect(response.headers.get('location')).toBeNull() + }) +}) From 9ad7a18654b8389066ea37ae75fc2ce15921b6af Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 28 May 2026 18:36:48 -0700 Subject: [PATCH 09/16] feat(auth): resolve Kratos identity and shape the Ory session Server auth core for the dashboard-as-Hydra-OIDC-client setup: - find-identity/auth-callbacks resolve the Kratos identity (profile.sub -> token.sub -> verified email, with external_id fallback) and cache it on the Auth.js session as identityId; the OIDC subject (token.sub) stays the E2B user id used by dashboard-api/infra. - AuthProvider gains getUserProfile; the Ory and Supabase providers implement it. - jwt-claims/freshness/ory-error/flows/kratos-session/build-start-url/ auth-route-redirect helpers; fromOryIdentity normalizes the password credential to the email provider so the account UI gate is provider-agnostic. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/auth.ts | 65 +---- src/core/server/auth/ory/auth-callbacks.ts | 135 +++++++++++ .../server/auth/ory/auth-route-redirect.ts | 24 ++ src/core/server/auth/ory/bootstrap.ts | 49 +--- src/core/server/auth/ory/build-start-url.ts | 14 ++ src/core/server/auth/ory/find-identity.ts | 137 +++++++++++ src/core/server/auth/ory/flows.ts | 125 ++++++++++ src/core/server/auth/ory/freshness.ts | 33 +++ src/core/server/auth/ory/identity.ts | 30 ++- src/core/server/auth/ory/jwt-claims.ts | 26 ++ src/core/server/auth/ory/kratos-session.ts | 69 ++++++ src/core/server/auth/ory/ory-error.ts | 60 +++++ src/core/server/auth/ory/provider.ts | 134 +++++++++-- src/core/server/auth/ory/refresh-token.ts | 21 +- src/core/server/auth/ory/signout.ts | 6 +- src/core/server/auth/provider.ts | 18 +- src/core/server/auth/supabase/flows.ts | 21 -- src/core/server/auth/supabase/provider.ts | 113 ++++++++- src/core/server/auth/types.ts | 26 ++ tests/integration/auth-ory-bootstrap.test.ts | 27 +++ tests/unit/auth-ory-callbacks.test.ts | 132 +++++++++++ tests/unit/auth-ory-find-identity.test.ts | 162 +++++++++++++ tests/unit/auth-ory-flows.test.ts | 156 ++++++++++++ tests/unit/auth-ory-freshness.test.ts | 65 +++++ tests/unit/auth-ory-identity.test.ts | 57 +++++ tests/unit/auth-ory-provider-account.test.ts | 224 ++++++++++++++++++ tests/unit/auth-ory-provider-profile.test.ts | 121 ++++++++++ 27 files changed, 1903 insertions(+), 147 deletions(-) create mode 100644 src/core/server/auth/ory/auth-callbacks.ts create mode 100644 src/core/server/auth/ory/auth-route-redirect.ts create mode 100644 src/core/server/auth/ory/build-start-url.ts create mode 100644 src/core/server/auth/ory/find-identity.ts create mode 100644 src/core/server/auth/ory/flows.ts create mode 100644 src/core/server/auth/ory/freshness.ts create mode 100644 src/core/server/auth/ory/jwt-claims.ts create mode 100644 src/core/server/auth/ory/kratos-session.ts create mode 100644 src/core/server/auth/ory/ory-error.ts create mode 100644 tests/unit/auth-ory-callbacks.test.ts create mode 100644 tests/unit/auth-ory-find-identity.test.ts create mode 100644 tests/unit/auth-ory-flows.test.ts create mode 100644 tests/unit/auth-ory-freshness.test.ts create mode 100644 tests/unit/auth-ory-identity.test.ts create mode 100644 tests/unit/auth-ory-provider-account.test.ts create mode 100644 tests/unit/auth-ory-provider-profile.test.ts diff --git a/src/auth.ts b/src/auth.ts index bcec837c5..25b02adc0 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,9 +1,10 @@ -import 'next-auth/jwt' - import NextAuth from 'next-auth' import OryHydra from 'next-auth/providers/ory-hydra' +import { + applyTokenToSession, + resolveOryJwt, +} from '@/core/server/auth/ory/auth-callbacks' import { bootstrapOryUser } from '@/core/server/auth/ory/bootstrap' -import { refreshOryToken } from '@/core/server/auth/ory/refresh-token' const oryOAuth2Audience = process.env.ORY_OAUTH2_AUDIENCE @@ -12,6 +13,11 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ basePath: '/api/auth/oauth', secret: process.env.AUTH_SECRET, session: { strategy: 'jwt' }, + // route handler that logs the failure and redirects to /sign-in so users + // never see Auth.js's built-in error page; see oauth-recover/route.ts. + pages: { + error: '/api/auth/oauth-recover', + }, providers: [ OryHydra({ id: 'ory', @@ -29,33 +35,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }), ], callbacks: { - async jwt({ token, account }) { - if (account) { - return { - ...token, - accessToken: account.access_token, - refreshToken: account.refresh_token, - idToken: account.id_token, - expiresAt: account.expires_at ?? null, - } - } - - if (token.expiresAt && Date.now() / 1000 > token.expiresAt - 60) { - return refreshOryToken(token) - } - - return token - }, - - async session({ session, token }) { - session.user.id = token.sub ?? session.user.id - session.accessToken = token.accessToken - session.idToken = token.idToken - session.error = token.error - return session - }, + jwt: ({ token, account, profile }) => + resolveOryJwt({ token, account, profile }), + session: ({ session, token }) => applyTokenToSession(session, token), }, - events: { async signIn({ account }) { if (!account?.access_token) return @@ -67,27 +50,3 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, }, }) - -declare module 'next-auth' { - interface Session { - accessToken?: string - idToken?: string - error?: string - user: { - id: string - email?: string | null - name?: string | null - image?: string | null - } - } -} - -declare module 'next-auth/jwt' { - interface JWT { - accessToken?: string - refreshToken?: string - idToken?: string - expiresAt?: number | null - error?: string - } -} diff --git a/src/core/server/auth/ory/auth-callbacks.ts b/src/core/server/auth/ory/auth-callbacks.ts new file mode 100644 index 000000000..9398253a7 --- /dev/null +++ b/src/core/server/auth/ory/auth-callbacks.ts @@ -0,0 +1,135 @@ +import 'server-only' + +import type { Account, Profile, Session } from 'next-auth' +import type { JWT } from 'next-auth/jwt' +import { resolveOryIdentity } from './find-identity' +import { decodeJwtClaims, readStringClaim } from './jwt-claims' +import { refreshOryToken } from './refresh-token' + +// Refresh the access token slightly before it actually expires so we never hand +// a token that dies mid-request to downstream APIs. +const ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 60 + +// Implements the Auth.js `jwt` callback: mint the token on fresh sign-in, +// otherwise refresh it as it nears expiry. +export async function resolveOryJwt(params: { + token: JWT + account?: Account | null + profile?: Profile +}): Promise { + const { token, account, profile } = params + + if (account) { + return buildSignInToken(token, account, profile) + } + + // Once a refresh has failed we stop retrying. The dead token (cleared + // access/refresh) propagates to the session, oryAuthProvider returns null, + // and the proxy redirects to /sign-in. + if (token.error) { + return token + } + + if (isAccessTokenExpiring(token)) { + return refreshOryToken(token) + } + + return token +} + +// Implements the Auth.js `session` callback: project the persisted token fields +// onto the session the rest of the app reads. +export function applyTokenToSession(session: Session, token: JWT): Session { + session.user.id = token.sub ?? session.user.id + session.accessToken = token.accessToken + session.idToken = token.idToken + session.identityId = token.identityId + session.error = token.error + return session +} + +// Persist the Ory tokens on a fresh sign-in and cache the resolved Kratos +// identity id. Clears any RefreshTokenError carried over from a previously +// poisoned cookie so the new session starts clean. +async function buildSignInToken( + token: JWT, + account: Account, + profile?: Profile +): Promise { + return { + ...token, + accessToken: account.access_token, + refreshToken: account.refresh_token, + idToken: account.id_token, + expiresAt: account.expires_at ?? null, + identityId: await resolveKratosIdentityId(token, account, profile), + error: undefined, + } +} + +// The Kratos identity id is NOT the OIDC subject the dashboard uses as the E2B +// user id (`token.sub`, consumed by dashboard-api and infra). It is surfaced via +// the OIDC profile `sub`. Resolve it once at sign-in — by profile.sub, then +// token.sub, then the verified email — so account operations can use a stable +// Kratos id without a per-request lookup. Returns undefined on failure; the +// provider then falls back to a per-request lookup, so sign-in is never blocked. +async function resolveKratosIdentityId( + token: JWT, + account: Account, + profile?: Profile +): Promise { + const profileSub = typeof profile?.sub === 'string' ? profile.sub : undefined + + const identity = await resolveOryIdentity({ + subjects: [profileSub, token.sub], + email: readEmailClaim(account), + }) + + return identity?.id +} + +function readEmailClaim(account: Account): string | undefined { + for (const jwt of [account.id_token, account.access_token]) { + if (typeof jwt !== 'string') continue + const email = readStringClaim(decodeJwtClaims(jwt), 'email') + if (email) return email + } + return undefined +} + +function isAccessTokenExpiring( + token: JWT, + nowSeconds: number = Math.floor(Date.now() / 1000) +): boolean { + if (token.expiresAt == null) return false + return nowSeconds > token.expiresAt - ACCESS_TOKEN_REFRESH_SKEW_SECONDS +} + +declare module 'next-auth' { + interface Session { + accessToken?: string + idToken?: string + // Kratos identity id, resolved from the OIDC subject at sign-in. Differs + // from user.id (the OIDC subject / E2B user id) when the project customizes + // the OAuth2 subject. + identityId?: string + error?: string + user: { + id: string + email?: string | null + name?: string | null + image?: string | null + } + } +} + +declare module 'next-auth/jwt' { + interface JWT { + accessToken?: string + refreshToken?: string + idToken?: string + identityId?: string + expiresAt?: number | null + error?: string + } +} diff --git a/src/core/server/auth/ory/auth-route-redirect.ts b/src/core/server/auth/ory/auth-route-redirect.ts new file mode 100644 index 000000000..6e98b22b1 --- /dev/null +++ b/src/core/server/auth/ory/auth-route-redirect.ts @@ -0,0 +1,24 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { buildOryStartURL, type OryAuthIntent } from './build-start-url' + +// Map each legacy auth page to the intent we want the Ory hosted UI to +// open with. Done at the middleware layer so the (auth) layout never +// renders in Ory mode - otherwise the user briefly sees the auth shell +// before the page-level redirect kicks in. +const INTENT_BY_PATH: Record = { + '/sign-in': 'signin', + '/sign-up': 'signup', + '/forgot-password': 'signin', +} + +export function getOryAuthRouteRedirect( + request: NextRequest +): NextResponse | null { + const intent = INTENT_BY_PATH[request.nextUrl.pathname] + if (!intent) return null + + const returnTo = request.nextUrl.searchParams.get('returnTo') ?? undefined + const target = new URL(buildOryStartURL(intent, returnTo), request.url) + + return NextResponse.redirect(target) +} diff --git a/src/core/server/auth/ory/bootstrap.ts b/src/core/server/auth/ory/bootstrap.ts index 3c9d5657b..21975b8e2 100644 --- a/src/core/server/auth/ory/bootstrap.ts +++ b/src/core/server/auth/ory/bootstrap.ts @@ -4,6 +4,7 @@ import { ADMIN_AUTH_HEADERS } from '@/configs/api' import { api } from '@/core/shared/clients/api' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import { repoErrorFromHttp } from '@/core/shared/errors' +import { decodeJwtClaims, readStringClaim, tokenFormat } from './jwt-claims' type BootstrapOryUserInput = { accessToken: string @@ -24,16 +25,20 @@ export async function bootstrapOryUser( input: BootstrapOryUserInput ): Promise { try { - const accessClaims = decodeJwtClaims(input.accessToken) - const idClaims = input.idToken ? decodeJwtClaims(input.idToken) : null - const oidcUserId = readRequiredStringClaim(accessClaims, 'sub') + const accessClaims = decodeJwtClaims(input.accessToken) + const idClaims = input.idToken + ? decodeJwtClaims(input.idToken) + : null + const oidcIssuer = + readStringClaim(accessClaims, 'iss') ?? readStringClaim(idClaims, 'iss') + const oidcUserId = readStringClaim(accessClaims, 'sub') const oidcUserEmail = readStringClaim(accessClaims, 'email') ?? readStringClaim(idClaims, 'email') const oidcUserName = readDisplayName(accessClaims) ?? readDisplayName(idClaims) - if (!oidcUserId || !oidcUserEmail) { + if (!oidcIssuer || !oidcUserId || !oidcUserEmail) { l.error( { key: 'auth_events:bootstrap_user:missing_claims', @@ -45,6 +50,7 @@ export async function bootstrapOryUser( : 'missing', has_access_claims: !!accessClaims, has_id_claims: !!idClaims, + has_iss: !!oidcIssuer, has_sub: !!oidcUserId, has_email: !!oidcUserEmail, has_name: !!oidcUserName, @@ -68,6 +74,7 @@ export async function bootstrapOryUser( } const body = { + oidc_issuer: oidcIssuer, oidc_user_id: oidcUserId, oidc_user_email: oidcUserEmail, oidc_user_name: oidcUserName, @@ -90,6 +97,7 @@ export async function bootstrapOryUser( context: { provider: input.provider, error_status: response.status, + has_oidc_issuer: body.oidc_issuer !== '', has_oidc_user_id: body.oidc_user_id !== '', has_oidc_user_email: body.oidc_user_email !== '', has_oidc_user_name: body.oidc_user_name !== null, @@ -112,34 +120,6 @@ export async function bootstrapOryUser( } } -function decodeJwtClaims(token: string): OryTokenClaims | null { - const [, payload] = token.split('.') - if (!payload) return null - - try { - return JSON.parse( - Buffer.from(payload, 'base64url').toString('utf8') - ) as OryTokenClaims - } catch { - return null - } -} - -function readRequiredStringClaim( - claims: OryTokenClaims | null, - name: keyof OryTokenClaims -): string | null { - return readStringClaim(claims, name) -} - -function readStringClaim( - claims: OryTokenClaims | null, - name: keyof OryTokenClaims -): string | null { - const value = claims?.[name] - return typeof value === 'string' && value.trim() !== '' ? value.trim() : null -} - function readDisplayName(claims: OryTokenClaims | null): string | null { return ( readStringClaim(claims, 'name') ?? @@ -147,8 +127,3 @@ function readDisplayName(claims: OryTokenClaims | null): string | null { readStringClaim(claims, 'preferred_username') ) } - -function tokenFormat(token: string): 'jwt' | 'opaque' | 'empty' { - if (!token) return 'empty' - return token.split('.').length === 3 ? 'jwt' : 'opaque' -} diff --git a/src/core/server/auth/ory/build-start-url.ts b/src/core/server/auth/ory/build-start-url.ts new file mode 100644 index 000000000..ffc559ab5 --- /dev/null +++ b/src/core/server/auth/ory/build-start-url.ts @@ -0,0 +1,14 @@ +export type OryAuthIntent = 'signin' | 'signup' | 'reauth' + +const ORY_START_PATH = '/api/auth/oauth-start' + +export function buildOryStartURL( + intent: OryAuthIntent, + returnTo?: string +): string { + const params = new URLSearchParams({ intent }) + if (returnTo && returnTo.length > 0) { + params.set('returnTo', returnTo) + } + return `${ORY_START_PATH}?${params}` +} diff --git a/src/core/server/auth/ory/find-identity.ts b/src/core/server/auth/ory/find-identity.ts new file mode 100644 index 000000000..36a2acfb1 --- /dev/null +++ b/src/core/server/auth/ory/find-identity.ts @@ -0,0 +1,137 @@ +import 'server-only' + +import { type Identity, ResponseError } from '@ory/client-fetch' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { getOryIdentityApi } from './client' +import { readOryError } from './ory-error' + +// Resolving the Kratos identity for the logged-in user is not a simple +// "getIdentity(sub)" because the OIDC subject the dashboard sees is not +// guaranteed to be the Kratos identity id: +// - In a vanilla Ory setup the OAuth2 subject IS the Kratos identity id. +// - Projects that customize the subject (e.g. to keep a stable app user id +// across a migration) expose the Kratos id under a *different* OIDC subject, +// or only via the id_token/userinfo profile `sub`. +// - Migrated identities may carry a legacy id as `external_id`. +// So we try every identifier we have (a list of candidate subjects, then the +// verified email) and return the first identity that resolves. + +export type ResolveOryIdentityInput = { + // Candidate subject ids, in priority order (e.g. profile.sub, then token.sub). + // Falsy entries are ignored and duplicates de-duped. + subjects?: Array + // Verified login email — the unambiguous fallback for password identities. + email?: string | null +} + +export async function resolveOryIdentity( + input: ResolveOryIdentityInput +): Promise { + const subjects = [ + ...new Set( + (input.subjects ?? []).filter( + (subject): subject is string => typeof subject === 'string' && !!subject + ) + ), + ] + + for (const subject of subjects) { + const identity = await findOryIdentityBySubject(subject) + if (identity) return identity + } + + if (input.email) { + const identity = await findOryIdentityByEmail(input.email) + if (identity) return identity + } + + l.error( + { + key: 'auth_provider:resolve_identity:not_found', + context: { + attempted_subjects: subjects, + attempted_email: input.email ?? null, + // The project we queried — a mismatch with the token issuer points to a + // misconfigured admin client (wrong Ory project). + ory_sdk_url: process.env.ORY_SDK_URL ?? null, + }, + }, + 'no Kratos identity found by subject(s) or email' + ) + return null +} + +// Tries a single subject as a Kratos identity id, then as an external_id. A 404 +// means "not this strategy" and falls through; any other error is unexpected, +// logged, and stops the search. The terminal "not found" belongs to +// resolveOryIdentity once every strategy is exhausted. +export async function findOryIdentityBySubject( + subject: string +): Promise { + const api = getOryIdentityApi() + + try { + return await api.getIdentity({ id: subject }) + } catch (error) { + if (!isNotFound(error)) { + await logLookupError('by_id', error) + return null + } + } + + try { + return await api.getIdentityByExternalID({ externalID: subject }) + } catch (error) { + if (!isNotFound(error)) { + await logLookupError('by_external_id', error) + } + return null + } +} + +export async function findOryIdentityByEmail( + email: string +): Promise { + try { + const identities = await getOryIdentityApi().listIdentities({ + credentialsIdentifier: email, + pageSize: 2, + }) + + if (identities.length === 0) return null + + // Prefer an exact email-trait match; fall back to the first result. + const exact = identities.find( + (identity) => emailTrait(identity)?.toLowerCase() === email.toLowerCase() + ) + return exact ?? identities[0] ?? null + } catch (error) { + await logLookupError('by_email', error) + return null + } +} + +function emailTrait(identity: Identity): string | null { + const traits = (identity.traits ?? {}) as Record + return typeof traits.email === 'string' ? traits.email : null +} + +function isNotFound(error: unknown): boolean { + return error instanceof ResponseError && error.response.status === 404 +} + +async function logLookupError( + stage: 'by_id' | 'by_external_id' | 'by_email', + error: unknown +): Promise { + const ory = error instanceof ResponseError ? await readOryError(error) : null + + l.error( + { + key: 'auth_provider:resolve_identity:error', + context: { stage, ory }, + error: serializeErrorForLog(error), + }, + `Ory identity lookup failed (${stage})` + ) +} diff --git a/src/core/server/auth/ory/flows.ts b/src/core/server/auth/ory/flows.ts new file mode 100644 index 000000000..413b7bb94 --- /dev/null +++ b/src/core/server/auth/ory/flows.ts @@ -0,0 +1,125 @@ +import 'server-only' + +import { type JsonPatch, JsonPatchOpEnum } from '@ory/client-fetch' +import { l } from '@/core/shared/clients/logger/logger' +import type { UpdateUserErrorCode, UpdateUserResult } from '../types' +import { getOryIdentityApi } from './client' +import { fromOryIdentity } from './identity' +import { isOryResponseError, readOryError } from './ory-error' + +type OryUpdateUserInput = { + identityId: string + name?: string + email?: string + password?: string +} + +export const oryAuthFlows = { + async updateUser({ + identityId, + name, + email, + password, + }: OryUpdateUserInput): Promise { + const jsonPatch = buildIdentityPatches({ name, email, password }) + + try { + const identity = await getOryIdentityApi().patchIdentity({ + id: identityId, + jsonPatch, + }) + + return { ok: true, user: fromOryIdentity(identity) } + } catch (error) { + return mapUpdateUserError(error, identityId) + } + }, +} + +// Assumes a flat `name` trait. If the project's identity schema nests name as +// `{ first, last }`, this patch path needs to target those sub-paths instead. +function buildIdentityPatches({ + name, + email, + password, +}: Omit): JsonPatch[] { + const patches: JsonPatch[] = [] + + if (name !== undefined) { + patches.push({ + op: JsonPatchOpEnum.Replace, + path: '/traits/name', + value: name, + }) + } + if (email !== undefined) { + patches.push({ + op: JsonPatchOpEnum.Replace, + path: '/traits/email', + value: email, + }) + } + if (password !== undefined) { + // The password-settings UI is only shown for identities that already have + // the email/password credential, so the config object exists to replace. + patches.push({ + op: JsonPatchOpEnum.Replace, + path: '/credentials/password/config/password', + value: password, + }) + } + + return patches +} + +async function mapUpdateUserError( + error: unknown, + identityId: string +): Promise { + if (!isOryResponseError(error)) { + throw error + } + + const details = await readOryError(error) + const code = classifyUpdateError( + details.status, + details.reason, + details.message + ) + + l.error( + { + key: 'auth_provider:ory_update_user:error', + user_id: identityId, + context: { ory: details, mapped_code: code }, + }, + 'Ory identity update failed' + ) + + // Unclassified failures (5xx, unexpected 4xx) are surfaced as unexpected + // server errors rather than a misleading user-facing message. + if (!code) { + throw error + } + + return { ok: false, code, message: details.message } +} + +function classifyUpdateError( + status: number, + reason?: string, + message?: string +): UpdateUserErrorCode | null { + const haystack = `${reason ?? ''} ${message ?? ''}`.toLowerCase() + + if (status === 409) return 'email_exists' + + if (status === 400) { + if (haystack.includes('password')) return 'weak_password' + if (haystack.includes('email') || haystack.includes('valid')) { + return 'email_invalid' + } + } + + return null +} diff --git a/src/core/server/auth/ory/freshness.ts b/src/core/server/auth/ory/freshness.ts new file mode 100644 index 000000000..479053b54 --- /dev/null +++ b/src/core/server/auth/ory/freshness.ts @@ -0,0 +1,33 @@ +import { decodeJwtClaims } from './jwt-claims' + +// How recently the user must have authenticated (via the OAuth2 login flow) +// for a sensitive operation like a password change to be allowed without a +// forced re-auth round-trip. +export const REAUTH_FRESHNESS_WINDOW_SECONDS = 300 + +type AuthTimeClaims = { + auth_time?: unknown +} + +// Reads the OIDC `auth_time` claim (epoch seconds) from the id_token. Hydra +// stamps this with the moment the user last actively authenticated, which is +// what `prompt=login` refreshes. +export function readAuthTime(idToken: string | undefined): number | null { + if (!idToken) return null + + const claims = decodeJwtClaims(idToken) + const authTime = claims?.auth_time + return typeof authTime === 'number' && Number.isFinite(authTime) + ? authTime + : null +} + +export function isReauthFresh( + idToken: string | undefined, + nowSeconds: number = Math.floor(Date.now() / 1000) +): boolean { + const authTime = readAuthTime(idToken) + if (authTime === null) return false + + return nowSeconds - authTime <= REAUTH_FRESHNESS_WINDOW_SECONDS +} diff --git a/src/core/server/auth/ory/identity.ts b/src/core/server/auth/ory/identity.ts index f3bee304c..f934c4acb 100644 --- a/src/core/server/auth/ory/identity.ts +++ b/src/core/server/auth/ory/identity.ts @@ -4,9 +4,9 @@ import type { Identity } from '@ory/client-fetch' import type { Session } from 'next-auth' import type { AuthUser } from '../types' -// auth.js sessions only carry the basic user shape; identity providers list -// requires an Ory IdentityApi lookup. fromAuthSession is the cheap path used -// during request-time getAuthContext. +// Cheap path: build the user from the Auth.js session alone (no Ory call). Used +// at request time by getAuthContext. `providers` is empty because the session +// doesn't carry credential info — use fromOryIdentity when that's needed. export function fromAuthSession(session: Session): AuthUser { return { id: session.user.id, @@ -17,17 +17,16 @@ export function fromAuthSession(session: Session): AuthUser { } } -// fromOryIdentity is used by oryAuthAdmin (admin lookups) where we have the -// full Identity object including credentials and traits. +// Rich path: build the user from a full Kratos Identity (traits + credentials). +// Used wherever we've fetched the identity via the admin API — admin lookups and +// the live profile query. export function fromOryIdentity(identity: Identity): AuthUser { const traits = (identity.traits ?? {}) as Record const email = readString(traits, 'email') const name = readDisplayName(traits) const avatarUrl = readString(traits, 'picture') ?? readString(traits, 'avatar_url') - const providers = identity.credentials - ? Object.keys(identity.credentials) - : [] + const providers = normalizeProviders(identity.credentials) return { id: identity.id, @@ -38,6 +37,21 @@ export function fromOryIdentity(identity: Identity): AuthUser { } } +// Kratos credential keys (`password`, `oidc`, …) don't match the provider +// vocabulary the dashboard UI expects (Supabase emits `email` for the +// email/password credential). Map `password` → `email` so the account-settings +// provider gate (`providers.includes('email')`) stays provider-agnostic, while +// preserving other keys like `oidc`. +function normalizeProviders(credentials: Identity['credentials']): string[] { + if (!credentials) return [] + + const mapped = Object.keys(credentials).map((key) => + key === 'password' ? 'email' : key + ) + + return [...new Set(mapped)] +} + function readString( traits: Record, key: string diff --git a/src/core/server/auth/ory/jwt-claims.ts b/src/core/server/auth/ory/jwt-claims.ts new file mode 100644 index 000000000..b2e225c25 --- /dev/null +++ b/src/core/server/auth/ory/jwt-claims.ts @@ -0,0 +1,26 @@ +export function decodeJwtClaims>( + token: string +): T | null { + const [, payload] = token.split('.') + if (!payload) return null + + try { + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as T + } catch { + return null + } +} + +export function tokenFormat(token: string): 'jwt' | 'opaque' | 'empty' { + if (!token) return 'empty' + return token.split('.').length === 3 ? 'jwt' : 'opaque' +} + +// Reads a non-empty string claim, trimming surrounding whitespace. +export function readStringClaim( + claims: Record | null | undefined, + name: string +): string | null { + const value = claims?.[name] + return typeof value === 'string' && value.trim() !== '' ? value.trim() : null +} diff --git a/src/core/server/auth/ory/kratos-session.ts b/src/core/server/auth/ory/kratos-session.ts new file mode 100644 index 000000000..c981a1a42 --- /dev/null +++ b/src/core/server/auth/ory/kratos-session.ts @@ -0,0 +1,69 @@ +import 'server-only' + +import { ResponseError } from '@ory/client-fetch' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { getOryIdentityApi } from './client' +import { readOryError } from './ory-error' + +/** + * Revokes every Kratos identity session for the given identity. + * + * Hydra's /oauth2/sessions/logout only ends the OAuth2 session; the Kratos + * identity cookie on the Ory domain is independent and is what causes the + * Account Experience to show "Reauthenticate as " on the next + * sign-in instead of a fresh provider chooser. + * + * We can't surgically target a single session because the OIDC `sid` claim + * from Hydra is Hydra's own OAuth2 session id, not a Kratos session id, and + * we don't have access to the user's Kratos cookie from this side. Revoking + * all identity sessions matches the expected "sign out of identity provider" + * semantics anyway. + */ +// Ory uses optimistic locking on identity rows; concurrent writes (e.g. our +// admin DELETE racing with Hydra's RP-initiated logout cleanup during the +// same signout flow) return 429 with reason "Conflicting concurrent +// requests". Retrying after a short backoff lets the in-flight write +// settle so ours can proceed. +const REVOKE_MAX_ATTEMPTS = 3 +const REVOKE_BACKOFF_MS = 150 + +export async function revokeKratosSessionsForIdentity( + identityId: string +): Promise { + for (let attempt = 1; attempt <= REVOKE_MAX_ATTEMPTS; attempt++) { + try { + await getOryIdentityApi().deleteIdentitySessions({ id: identityId }) + return + } catch (error) { + if (error instanceof ResponseError && error.response.status === 404) { + return + } + + const isContention = + error instanceof ResponseError && error.response.status === 429 + const lastAttempt = attempt === REVOKE_MAX_ATTEMPTS + + if (isContention && !lastAttempt) { + await sleep(REVOKE_BACKOFF_MS * attempt) + continue + } + + const oryDetails = + error instanceof ResponseError ? await readOryError(error) : null + + l.error( + { + key: 'auth_provider:revoke_kratos_sessions:error', + context: { ory: oryDetails, attempt }, + error: serializeErrorForLog(error), + }, + 'failed to revoke Kratos sessions; user may see reauth UX on next sign-in' + ) + return + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/core/server/auth/ory/ory-error.ts b/src/core/server/auth/ory/ory-error.ts new file mode 100644 index 000000000..9abc2045b --- /dev/null +++ b/src/core/server/auth/ory/ory-error.ts @@ -0,0 +1,60 @@ +import { ResponseError } from '@ory/client-fetch' + +export type OryErrorDetails = { + status: number + url: string + code?: number + reason?: string + message?: string + request_id?: string + body?: string +} + +// Ory returns a structured error envelope like +// { "error": { "code": 401, "status": "Unauthorized", "reason": "...", "message": "...", "id": "..." } } +// The SDK's ResponseError doesn't unpack it, so we read the body here to +// surface the actual cause instead of "Response returned an error code". +export async function readOryError( + error: ResponseError +): Promise { + const { response } = error + const base: OryErrorDetails = { status: response.status, url: response.url } + + let raw: string + try { + raw = await response.clone().text() + } catch { + return base + } + + try { + const parsed = JSON.parse(raw) as { + error?: { + code?: unknown + reason?: unknown + message?: unknown + id?: unknown + request?: unknown + } + } + const oryError = parsed.error ?? {} + return { + ...base, + code: typeof oryError.code === 'number' ? oryError.code : undefined, + reason: stringOrUndefined(oryError.reason), + message: stringOrUndefined(oryError.message), + request_id: + stringOrUndefined(oryError.id) ?? stringOrUndefined(oryError.request), + } + } catch { + return { ...base, body: raw.slice(0, 500) } + } +} + +export function isOryResponseError(error: unknown): error is ResponseError { + return error instanceof ResponseError +} + +function stringOrUndefined(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} diff --git a/src/core/server/auth/ory/provider.ts b/src/core/server/auth/ory/provider.ts index 40348219d..4a254e55b 100644 --- a/src/core/server/auth/ory/provider.ts +++ b/src/core/server/auth/ory/provider.ts @@ -2,28 +2,34 @@ import 'server-only' import type { Session } from 'next-auth' import { auth as authjs } from '@/auth' +import { PROTECTED_URLS } from '@/configs/urls' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import type { AuthProvider } from '../provider' -import { fromAuthSession } from './identity' -import { getOrySignOutPath } from './signout' +import type { + AuthContext, + AuthUser, + ReauthDispatch, + UpdateUserInput, + UpdateUserResult, +} from '../types' +import { buildOryStartURL } from './build-start-url' +import { resolveOryIdentity } from './find-identity' +import { oryAuthFlows } from './flows' +import { isReauthFresh } from './freshness' +import { fromAuthSession, fromOryIdentity } from './identity' +import { revokeKratosSessionsForIdentity } from './kratos-session' +import { ORY_SIGN_OUT_FLOW_PATH } from './signout' + +// Where the account-settings page expects to land after a forced re-auth so it +// reveals the password form (matches the Supabase ?reauth=1 contract). +const ACCOUNT_SETTINGS_REAUTH_RETURN_TO = `${PROTECTED_URLS.ACCOUNT_SETTINGS}?reauth=1` export const oryAuthProvider: AuthProvider = { async getAuthContext() { - let session: Session | null - try { - session = await authjs() - } catch (error) { - l.error( - { - key: 'auth_provider:ory_get_session:error', - error: serializeErrorForLog(error), - }, - 'Auth.js auth() helper threw while reading session' - ) - return null - } + const session = await readSession() + if (!session) return null - if (!session?.user?.id || !session.accessToken) { + if (!session.user?.id || !session.accessToken) { return null } @@ -42,10 +48,102 @@ export const oryAuthProvider: AuthProvider = { return { user: fromAuthSession(session), accessToken: session.accessToken, - } + } satisfies AuthContext + }, + + async getUserProfile(): Promise { + const session = await readSession() + if (!session?.user?.id) return null + + // The live profile needs the full Kratos identity (traits + credentials). + // The cached session.identityId hits directly; user.id and email are + // fallbacks. Callers (the tRPC profile query) time this out and fall back to + // the cheap session user, so a null/slow response never blocks the dashboard. + const identity = await resolveOryIdentity({ + subjects: [session.identityId, session.user.id], + email: session.user.email, + }) + return identity ? fromOryIdentity(identity) : null }, signOut() { - return Promise.resolve({ redirectTo: getOrySignOutPath() }) + return Promise.resolve({ redirectTo: ORY_SIGN_OUT_FLOW_PATH }) + }, + + async updateUser(input: UpdateUserInput): Promise { + const session = await readSession() + if (!session?.user?.id) { + throw new Error('updateUser called without an authenticated Ory session') + } + + // Changing the password is privileged: require a recent active login so a + // stolen dashboard session can't silently reset credentials. The caller + // turns this into the forced OAuth2 re-auth round-trip. + if (input.password !== undefined && !isReauthFresh(session.idToken)) { + return { ok: false, code: 'reauthentication_needed' } + } + + const identityId = await resolveIdentityId(session) + if (!identityId) { + throw new Error( + 'updateUser could not resolve an Ory identity for the session subject' + ) + } + + return oryAuthFlows.updateUser({ + identityId, + name: input.name, + email: input.email, + password: input.password, + }) + }, + + async startReauthForAccountSettings(): Promise { + return { + kind: 'redirect', + to: buildOryStartURL('reauth', ACCOUNT_SETTINGS_REAUTH_RETURN_TO), + } + }, + + async signOutOtherSessions(): Promise { + const session = await readSession() + if (!session?.user?.id) return + + const identityId = await resolveIdentityId(session) + if (!identityId) return + + // The dashboard session is the Auth.js JWT, independent of Kratos identity + // sessions, so revoking all Kratos sessions invalidates other browsers + // without logging the current dashboard session out. + await revokeKratosSessionsForIdentity(identityId) }, } + +// The Kratos identity id is resolved once at sign-in and cached on the session +// (see src/auth.ts). Fall back to a per-request lookup (by the E2B user id, then +// the verified email) for sessions minted before that wiring existed or when +// the sign-in resolution failed. +async function resolveIdentityId(session: Session): Promise { + if (session.identityId) return session.identityId + + const identity = await resolveOryIdentity({ + subjects: [session.user.id], + email: session.user.email, + }) + return identity?.id ?? null +} + +async function readSession(): Promise { + try { + return await authjs() + } catch (error) { + l.error( + { + key: 'auth_provider:ory_get_session:error', + error: serializeErrorForLog(error), + }, + 'Auth.js auth() helper threw while reading session' + ) + return null + } +} diff --git a/src/core/server/auth/ory/refresh-token.ts b/src/core/server/auth/ory/refresh-token.ts index c6daad209..f25dc5aa1 100644 --- a/src/core/server/auth/ory/refresh-token.ts +++ b/src/core/server/auth/ory/refresh-token.ts @@ -10,8 +10,23 @@ type OryTokenResponse = { id_token?: string } +// returned on every failure path so the next jwt-callback invocation +// short-circuits instead of re-presenting an already-invalidated refresh_token +// in a loop. expiresAt is zeroed so isExpired() checks don't matter — the +// error gate kicks in first. +function deadToken(token: JWT, error: string): JWT { + return { + ...token, + accessToken: undefined, + refreshToken: undefined, + idToken: undefined, + expiresAt: 0, + error, + } +} + export async function refreshOryToken(token: JWT): Promise { - if (!token.refreshToken) return { ...token, error: 'NoRefreshToken' } + if (!token.refreshToken) return deadToken(token, 'NoRefreshToken') const sdkUrl = process.env.ORY_SDK_URL!.replace(/\/$/, '') const credentials = btoa( @@ -39,7 +54,7 @@ export async function refreshOryToken(token: JWT): Promise { }, `Ory refresh_token rejected (${res.status})` ) - return { ...token, error: 'RefreshTokenError' } + return deadToken(token, 'RefreshTokenError') } const fresh = (await res.json()) as OryTokenResponse @@ -59,6 +74,6 @@ export async function refreshOryToken(token: JWT): Promise { }, 'Ory refresh_token threw' ) - return { ...token, error: 'RefreshTokenError' } + return deadToken(token, 'RefreshTokenError') } } diff --git a/src/core/server/auth/ory/signout.ts b/src/core/server/auth/ory/signout.ts index 0dd3cc65e..b5d40d656 100644 --- a/src/core/server/auth/ory/signout.ts +++ b/src/core/server/auth/ory/signout.ts @@ -1,5 +1,3 @@ +// Route handler that performs the full Ory sign-out (Auth.js + Kratos sessions +// + Hydra RP-initiated logout). The provider redirects here on signOut(). export const ORY_SIGN_OUT_FLOW_PATH = '/api/auth/oauth/signout-flow' - -export function getOrySignOutPath(): string { - return ORY_SIGN_OUT_FLOW_PATH -} diff --git a/src/core/server/auth/provider.ts b/src/core/server/auth/provider.ts index 6953cce2d..9fb435caa 100644 --- a/src/core/server/auth/provider.ts +++ b/src/core/server/auth/provider.ts @@ -1,6 +1,22 @@ -import type { AuthContext, SignOutOptions, SignOutResult } from './types' +import type { + AuthContext, + AuthUser, + ReauthDispatch, + SignOutOptions, + SignOutResult, + UpdateUserInput, + UpdateUserResult, +} from './types' export interface AuthProvider { getAuthContext(): Promise + // Live profile lookup from the identity provider (Ory IdentityApi / Supabase + // getUser). Unlike getAuthContext's cheap session path, this carries the full + // traits and credential-derived providers. Heavier — call it once per + // dashboard load behind a cache, not on every request. + getUserProfile(): Promise signOut(options?: SignOutOptions): Promise + updateUser(input: UpdateUserInput): Promise + startReauthForAccountSettings(): Promise + signOutOtherSessions(): Promise } diff --git a/src/core/server/auth/supabase/flows.ts b/src/core/server/auth/supabase/flows.ts index b7351875e..468483224 100644 --- a/src/core/server/auth/supabase/flows.ts +++ b/src/core/server/auth/supabase/flows.ts @@ -20,13 +20,6 @@ type SignUpOptions = { data?: Record } -type UpdateUserOptions = { - email?: string - password?: string - name?: string - emailRedirectTo?: string -} - export const supabaseAuthFlows = { async signInWithOAuth({ provider, @@ -63,20 +56,6 @@ export const supabaseAuthFlows = { return client.auth.resetPasswordForEmail(email) }, - async updateUser({ - email, - password, - name, - emailRedirectTo, - }: UpdateUserOptions) { - const client = await createClient() - - return client.auth.updateUser( - { email, password, data: { name } }, - emailRedirectTo ? { emailRedirectTo } : undefined - ) - }, - async verifyOtp(...args: Parameters) { const client = await createClient() return client.auth.verifyOtp(...args) diff --git a/src/core/server/auth/supabase/provider.ts b/src/core/server/auth/supabase/provider.ts index a4e04d024..a506eebd9 100644 --- a/src/core/server/auth/supabase/provider.ts +++ b/src/core/server/auth/supabase/provider.ts @@ -1,11 +1,21 @@ import 'server-only' +import { headers } from 'next/headers' import type { NextRequest, NextResponse } from 'next/server' -import { AUTH_URLS } from '@/configs/urls' +import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import { createClient } from '@/core/shared/clients/supabase/server' import type { AuthProvider } from '../provider' -import type { AuthContext, SignOutOptions, SignOutResult } from '../types' +import type { + AuthContext, + AuthUser, + ReauthDispatch, + SignOutOptions, + SignOutResult, + UpdateUserErrorCode, + UpdateUserInput, + UpdateUserResult, +} from '../types' import { createServerClientForHeaders, createServerClientForProxy, @@ -60,6 +70,26 @@ export class SupabaseAuthProvider implements AuthProvider { } } + async getUserProfile(): Promise { + const client = await this.resolveClient() + const { data, error } = await client.auth.getUser() + + if (error || !data.user) { + if (error) { + l.error( + { + key: 'auth_provider:get_user_profile:error', + error: serializeErrorForLog(error), + }, + `supabase getUser failed: ${error.message}` + ) + } + return null + } + + return toAuthUser(data.user) + } + async signOut(options?: SignOutOptions): Promise { const client = await this.resolveClient() const { error } = await client.auth.signOut( @@ -87,11 +117,90 @@ export class SupabaseAuthProvider implements AuthProvider { } } + async updateUser(input: UpdateUserInput): Promise { + const emailRedirectTo = input.email + ? await buildEmailVerificationRedirect(input.email) + : undefined + + const client = await this.resolveClient() + const { data, error } = await client.auth.updateUser( + { + email: input.email, + password: input.password, + data: { name: input.name }, + }, + emailRedirectTo ? { emailRedirectTo } : undefined + ) + + if (!error) { + return { ok: true, user: toAuthUser(data.user) } + } + + const code = mapSupabaseUpdateError(error.code) + // Preserve the original action behavior of throwing on unmapped errors so + // they surface as unexpected server errors. + if (!code) { + throw error + } + + return { ok: false, code, message: error.message } + } + + async startReauthForAccountSettings(): Promise { + return { kind: 'sign-out', returnTo: PROTECTED_URLS.ACCOUNT_SETTINGS } + } + + async signOutOtherSessions(): Promise { + const client = await this.resolveClient() + const { error } = await client.auth.signOut({ scope: 'others' }) + + if (error) { + l.error( + { + key: 'auth_provider:sign_out_others:error', + error: serializeErrorForLog(error), + context: { error_code: error.code, error_status: error.status }, + }, + `supabase signOut(others) failed: ${error.message}` + ) + } + } + private resolveClient(): Promise { return Promise.resolve(this.client ?? createClient()) } } +async function buildEmailVerificationRedirect(email: string): Promise { + const origin = (await headers()).get('origin') + if (!origin) { + throw new Error('Missing origin header for email update redirect') + } + + const url = new URL('/api/auth/email-callback', origin) + url.searchParams.set('new_email', email) + return url.toString() +} + +function mapSupabaseUpdateError( + code: string | undefined +): UpdateUserErrorCode | null { + switch (code) { + case 'email_address_invalid': + return 'email_invalid' + case 'email_exists': + return 'email_exists' + case 'same_password': + return 'same_password' + case 'weak_password': + return 'weak_password' + case 'reauthentication_needed': + return 'reauthentication_needed' + default: + return null + } +} + export function createSupabaseAuthForProxy( request: NextRequest, response: NextResponse diff --git a/src/core/server/auth/types.ts b/src/core/server/auth/types.ts index 407521c75..d956396ba 100644 --- a/src/core/server/auth/types.ts +++ b/src/core/server/auth/types.ts @@ -26,3 +26,29 @@ export type SignOutResult = { redirectTo: string error?: AuthError | null } + +export type UpdateUserInput = { + name?: string + email?: string + password?: string +} + +// Expected, user-facing update failures. Anything else throws and is handled +// as an unexpected server error by the action client. +export type UpdateUserErrorCode = + | 'email_exists' + | 'email_invalid' + | 'weak_password' + | 'same_password' + | 'reauthentication_needed' + +export type UpdateUserResult = + | { ok: true; user: AuthUser } + | { ok: false; code: UpdateUserErrorCode; message?: string } + +// How the caller should drive the account-settings re-authentication step. +// Supabase signs the user out and bounces through /sign-in; Ory forces a +// fresh OAuth2 login via the oauth-start route. +export type ReauthDispatch = + | { kind: 'sign-out'; returnTo: string } + | { kind: 'redirect'; to: string } diff --git a/tests/integration/auth-ory-bootstrap.test.ts b/tests/integration/auth-ory-bootstrap.test.ts index f46322737..f7e82a74b 100644 --- a/tests/integration/auth-ory-bootstrap.test.ts +++ b/tests/integration/auth-ory-bootstrap.test.ts @@ -69,6 +69,7 @@ describe('bootstrapOryUser (Auth.js events.signIn handler)', () => { expect(apiPostMock).toHaveBeenCalledTimes(1) expect(apiPostMock).toHaveBeenCalledWith('/admin/users/bootstrap', { body: { + oidc_issuer: 'https://ory.example.test', oidc_user_id: 'access-token-sub', oidc_user_email: 'access-token-user@example.com', oidc_user_name: 'Access Token User', @@ -101,6 +102,7 @@ describe('bootstrapOryUser (Auth.js events.signIn handler)', () => { expect(apiPostMock).toHaveBeenCalledWith('/admin/users/bootstrap', { body: { + oidc_issuer: 'https://ory.example.test', oidc_user_id: 'access-token-sub', oidc_user_email: 'id-token-user@example.com', oidc_user_name: 'Id Token User', @@ -109,6 +111,29 @@ describe('bootstrapOryUser (Auth.js events.signIn handler)', () => { }) }) + it('skips the bootstrap call and logs when iss is missing', async () => { + await bootstrapOryUser({ + accessToken: jwt({ + sub: 'access-token-sub', + email: 'user@example.com', + }), + provider: 'ory', + }) + + expect(apiPostMock).not.toHaveBeenCalled() + expect(loggerMocks.error).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'auth_events:bootstrap_user:missing_claims', + context: expect.objectContaining({ + has_iss: false, + has_sub: true, + has_email: true, + }), + }), + expect.stringContaining('missing required bootstrap claims') + ) + }) + it('logs but does not throw when the bootstrap call returns an api error', async () => { apiPostMock.mockResolvedValue({ data: null, @@ -119,6 +144,7 @@ describe('bootstrapOryUser (Auth.js events.signIn handler)', () => { await expect( bootstrapOryUser({ accessToken: jwt({ + iss: 'https://ory.example.test', sub: 'access-token-sub', email: 'user@example.com', name: 'User', @@ -146,6 +172,7 @@ describe('bootstrapOryUser (Auth.js events.signIn handler)', () => { await expect( bootstrapOryUser({ accessToken: jwt({ + iss: 'https://ory.example.test', sub: 'access-token-sub', email: 'user@example.com', }), diff --git a/tests/unit/auth-ory-callbacks.test.ts b/tests/unit/auth-ory-callbacks.test.ts new file mode 100644 index 000000000..89bd54d34 --- /dev/null +++ b/tests/unit/auth-ory-callbacks.test.ts @@ -0,0 +1,132 @@ +import type { Session } from 'next-auth' +import type { JWT } from 'next-auth/jwt' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const resolveIdentityMock = vi.hoisted(() => vi.fn()) +const refreshOryTokenMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/core/server/auth/ory/find-identity', () => ({ + resolveOryIdentity: resolveIdentityMock, +})) + +vi.mock('@/core/server/auth/ory/refresh-token', () => ({ + refreshOryToken: refreshOryTokenMock, +})) + +const { resolveOryJwt, applyTokenToSession } = await import( + '@/core/server/auth/ory/auth-callbacks' +) + +function makeJwt(claims: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString( + 'base64url' + ) + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url') + return `${header}.${payload}.sig` +} + +const nowSeconds = Math.floor(Date.now() / 1000) + +describe('resolveOryJwt', () => { + beforeEach(() => { + resolveIdentityMock.mockReset() + refreshOryTokenMock.mockReset() + }) + + it('persists Ory tokens and the resolved Kratos id on fresh sign-in', async () => { + resolveIdentityMock.mockResolvedValue({ id: 'kratos-uuid' }) + + const result = await resolveOryJwt({ + token: { sub: 'e2b-user-id', error: 'StalePoison' } as JWT, + account: { + provider: 'ory', + type: 'oidc', + providerAccountId: 'x', + access_token: 'at', + refresh_token: 'rt', + id_token: makeJwt({ email: 'ada@example.test' }), + expires_at: 1234, + }, + profile: { sub: 'profile-sub' }, + }) + + expect(resolveIdentityMock).toHaveBeenCalledWith({ + subjects: ['profile-sub', 'e2b-user-id'], + email: 'ada@example.test', + }) + expect(result).toMatchObject({ + sub: 'e2b-user-id', + accessToken: 'at', + refreshToken: 'rt', + expiresAt: 1234, + identityId: 'kratos-uuid', + error: undefined, + }) + }) + + it('leaves identityId undefined when resolution fails (sign-in not blocked)', async () => { + resolveIdentityMock.mockResolvedValue(null) + + const result = await resolveOryJwt({ + token: {} as JWT, + account: { + provider: 'ory', + type: 'oidc', + providerAccountId: 'x', + access_token: 'at', + }, + }) + + expect(result.identityId).toBeUndefined() + expect(result.accessToken).toBe('at') + }) + + it('stops retrying once the token carries a refresh error', async () => { + const token = { error: 'RefreshTokenError', sub: 'x' } as JWT + + const result = await resolveOryJwt({ token, account: null }) + + expect(result).toBe(token) + expect(refreshOryTokenMock).not.toHaveBeenCalled() + }) + + it('refreshes when the access token is near expiry', async () => { + refreshOryTokenMock.mockResolvedValue({ accessToken: 'fresh' }) + + const result = await resolveOryJwt({ + token: { expiresAt: nowSeconds + 30 } as JWT, + account: null, + }) + + expect(refreshOryTokenMock).toHaveBeenCalled() + expect(result).toEqual({ accessToken: 'fresh' }) + }) + + it('leaves a still-valid token untouched', async () => { + const token = { expiresAt: nowSeconds + 3600 } as JWT + + const result = await resolveOryJwt({ token, account: null }) + + expect(result).toBe(token) + expect(refreshOryTokenMock).not.toHaveBeenCalled() + }) +}) + +describe('applyTokenToSession', () => { + it('projects the token fields onto the session', () => { + const session = { user: { id: 'placeholder' } } as Session + + const result = applyTokenToSession(session, { + sub: 'e2b-user-id', + accessToken: 'at', + idToken: 'it', + identityId: 'kratos-uuid', + error: undefined, + } as JWT) + + expect(result.user.id).toBe('e2b-user-id') + expect(result.accessToken).toBe('at') + expect(result.idToken).toBe('it') + expect(result.identityId).toBe('kratos-uuid') + }) +}) diff --git a/tests/unit/auth-ory-find-identity.test.ts b/tests/unit/auth-ory-find-identity.test.ts new file mode 100644 index 000000000..5648f5f19 --- /dev/null +++ b/tests/unit/auth-ory-find-identity.test.ts @@ -0,0 +1,162 @@ +import { ResponseError } from '@ory/client-fetch' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const loggerMocks = vi.hoisted(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})) + +const getIdentityMock = vi.hoisted(() => vi.fn()) +const getIdentityByExternalIDMock = vi.hoisted(() => vi.fn()) +const listIdentitiesMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/core/shared/clients/logger/logger', () => ({ + l: loggerMocks, + serializeErrorForLog: vi.fn((error: unknown) => error), +})) + +vi.mock('@/core/server/auth/ory/client', () => ({ + getOryIdentityApi: () => ({ + getIdentity: getIdentityMock, + getIdentityByExternalID: getIdentityByExternalIDMock, + listIdentities: listIdentitiesMock, + }), +})) + +const { resolveOryIdentity, findOryIdentityBySubject, findOryIdentityByEmail } = + await import('@/core/server/auth/ory/find-identity') + +function notFound(): ResponseError { + return new ResponseError(new Response(null, { status: 404 }), 'not found') +} + +describe('findOryIdentityBySubject', () => { + beforeEach(() => { + getIdentityMock.mockReset() + getIdentityByExternalIDMock.mockReset() + loggerMocks.error.mockClear() + }) + + it('resolves by Kratos id without an external_id lookup', async () => { + getIdentityMock.mockResolvedValue({ id: 'sub-is-kratos-id' }) + + const identity = await findOryIdentityBySubject('sub-is-kratos-id') + + expect(identity).toEqual({ id: 'sub-is-kratos-id' }) + expect(getIdentityByExternalIDMock).not.toHaveBeenCalled() + }) + + it('falls back to external_id when the id lookup 404s', async () => { + getIdentityMock.mockRejectedValue(notFound()) + getIdentityByExternalIDMock.mockResolvedValue({ id: 'kratos-uuid' }) + + const identity = await findOryIdentityBySubject('legacy-id') + + expect(getIdentityByExternalIDMock).toHaveBeenCalledWith({ + externalID: 'legacy-id', + }) + expect(identity).toEqual({ id: 'kratos-uuid' }) + }) + + it('returns null without a terminal error log when both miss', async () => { + getIdentityMock.mockRejectedValue(notFound()) + getIdentityByExternalIDMock.mockRejectedValue(notFound()) + + const identity = await findOryIdentityBySubject('ghost') + + expect(identity).toBeNull() + // the terminal not_found error belongs to resolveOryIdentity, not here + expect(loggerMocks.error).not.toHaveBeenCalled() + }) +}) + +describe('findOryIdentityByEmail', () => { + beforeEach(() => { + listIdentitiesMock.mockReset() + loggerMocks.error.mockClear() + }) + + it('queries by credentials identifier and prefers an exact email match', async () => { + listIdentitiesMock.mockResolvedValue([ + { id: 'other', traits: { email: 'someone@else.test' } }, + { id: 'match', traits: { email: 'Ada@Example.test' } }, + ]) + + const identity = await findOryIdentityByEmail('ada@example.test') + + expect(listIdentitiesMock).toHaveBeenCalledWith({ + credentialsIdentifier: 'ada@example.test', + pageSize: 2, + }) + expect(identity?.id).toBe('match') + }) + + it('returns null when no identity has that credential', async () => { + listIdentitiesMock.mockResolvedValue([]) + + const identity = await findOryIdentityByEmail('nobody@example.test') + + expect(identity).toBeNull() + }) +}) + +describe('resolveOryIdentity', () => { + beforeEach(() => { + getIdentityMock.mockReset() + getIdentityByExternalIDMock.mockReset() + listIdentitiesMock.mockReset() + loggerMocks.error.mockClear() + }) + + it('tries subjects in order and returns the first hit', async () => { + getIdentityMock + .mockRejectedValueOnce(notFound()) // profile.sub by id + .mockResolvedValueOnce({ id: 'kratos-uuid' }) // token.sub by id + getIdentityByExternalIDMock.mockRejectedValue(notFound()) + + const identity = await resolveOryIdentity({ + subjects: ['profile-sub', 'token-sub'], + }) + + expect(identity).toEqual({ id: 'kratos-uuid' }) + expect(listIdentitiesMock).not.toHaveBeenCalled() + }) + + it('falls back to email when every subject misses', async () => { + getIdentityMock.mockRejectedValue(notFound()) + getIdentityByExternalIDMock.mockRejectedValue(notFound()) + listIdentitiesMock.mockResolvedValue([ + { id: 'kratos-uuid', traits: { email: 'ada@example.test' } }, + ]) + + const identity = await resolveOryIdentity({ + subjects: ['e2b-user-id'], + email: 'ada@example.test', + }) + + expect(identity?.id).toBe('kratos-uuid') + }) + + it('de-dupes falsy/duplicate subjects and logs not_found when all strategies fail', async () => { + getIdentityMock.mockRejectedValue(notFound()) + getIdentityByExternalIDMock.mockRejectedValue(notFound()) + listIdentitiesMock.mockResolvedValue([]) + + const identity = await resolveOryIdentity({ + subjects: ['x', 'x', null, undefined], + email: 'ghost@example.test', + }) + + expect(identity).toBeNull() + // 'x' resolved once despite duplicates → 1 id + 1 external_id call + expect(getIdentityMock).toHaveBeenCalledTimes(1) + expect(loggerMocks.error).toHaveBeenCalledWith( + expect.objectContaining({ + key: 'auth_provider:resolve_identity:not_found', + }), + expect.any(String) + ) + }) +}) diff --git a/tests/unit/auth-ory-flows.test.ts b/tests/unit/auth-ory-flows.test.ts new file mode 100644 index 000000000..6eb5cc712 --- /dev/null +++ b/tests/unit/auth-ory-flows.test.ts @@ -0,0 +1,156 @@ +import { ResponseError } from '@ory/client-fetch' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const loggerMocks = vi.hoisted(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})) + +const patchIdentityMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/core/shared/clients/logger/logger', () => ({ + l: loggerMocks, + serializeErrorForLog: vi.fn((error: unknown) => error), +})) + +vi.mock('@/core/server/auth/ory/client', () => ({ + getOryIdentityApi: () => ({ patchIdentity: patchIdentityMock }), +})) + +const { oryAuthFlows } = await import('@/core/server/auth/ory/flows') + +function oryError( + status: number, + body: Record +): ResponseError { + return new ResponseError( + new Response(JSON.stringify(body), { status }), + 'Response returned an error code' + ) +} + +describe('oryAuthFlows.updateUser', () => { + beforeEach(() => { + patchIdentityMock.mockReset() + loggerMocks.error.mockClear() + }) + + it('patches only the provided traits and returns the mapped user', async () => { + patchIdentityMock.mockResolvedValue({ + id: 'identity-1', + traits: { email: 'new@example.test', name: 'Ada' }, + credentials: { password: {} }, + }) + + const result = await oryAuthFlows.updateUser({ + identityId: 'identity-1', + name: 'Ada', + email: 'new@example.test', + }) + + expect(patchIdentityMock).toHaveBeenCalledWith({ + id: 'identity-1', + jsonPatch: [ + { op: 'replace', path: '/traits/name', value: 'Ada' }, + { op: 'replace', path: '/traits/email', value: 'new@example.test' }, + ], + }) + expect(result).toEqual({ + ok: true, + user: expect.objectContaining({ + id: 'identity-1', + email: 'new@example.test', + name: 'Ada', + // `password` credential is normalized to the `email` provider vocabulary + providers: ['email'], + }), + }) + }) + + it('patches the password credential config when a password is provided', async () => { + patchIdentityMock.mockResolvedValue({ + id: 'identity-1', + traits: { email: 'a@b.test' }, + credentials: { password: {} }, + }) + + await oryAuthFlows.updateUser({ + identityId: 'identity-1', + password: 'super-secret', + }) + + expect(patchIdentityMock).toHaveBeenCalledWith({ + id: 'identity-1', + jsonPatch: [ + { + op: 'replace', + path: '/credentials/password/config/password', + value: 'super-secret', + }, + ], + }) + }) + + it('maps a 409 conflict to email_exists', async () => { + patchIdentityMock.mockRejectedValue( + oryError(409, { + error: { code: 409, reason: 'identity address already exists' }, + }) + ) + + const result = await oryAuthFlows.updateUser({ + identityId: 'identity-1', + email: 'taken@example.test', + }) + + expect(result).toEqual({ + ok: false, + code: 'email_exists', + message: undefined, + }) + }) + + it('maps a 400 password policy violation to weak_password', async () => { + patchIdentityMock.mockRejectedValue( + oryError(400, { + error: { + code: 400, + reason: 'the password does not fulfill the password policy', + message: 'password too short', + }, + }) + ) + + const result = await oryAuthFlows.updateUser({ + identityId: 'identity-1', + password: 'short', + }) + + expect(result).toEqual({ + ok: false, + code: 'weak_password', + message: 'password too short', + }) + }) + + it('rethrows unclassified Ory errors as unexpected', async () => { + patchIdentityMock.mockRejectedValue( + oryError(500, { error: { code: 500, reason: 'internal error' } }) + ) + + await expect( + oryAuthFlows.updateUser({ identityId: 'identity-1', name: 'X' }) + ).rejects.toBeInstanceOf(ResponseError) + expect(loggerMocks.error).toHaveBeenCalled() + }) + + it('rethrows non-Ory errors', async () => { + patchIdentityMock.mockRejectedValue(new Error('network down')) + + await expect( + oryAuthFlows.updateUser({ identityId: 'identity-1', name: 'X' }) + ).rejects.toThrow('network down') + }) +}) diff --git a/tests/unit/auth-ory-freshness.test.ts b/tests/unit/auth-ory-freshness.test.ts new file mode 100644 index 000000000..d562687c5 --- /dev/null +++ b/tests/unit/auth-ory-freshness.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { + isReauthFresh, + REAUTH_FRESHNESS_WINDOW_SECONDS, + readAuthTime, +} from '@/core/server/auth/ory/freshness' + +function makeIdToken(claims: Record): string { + const header = Buffer.from( + JSON.stringify({ alg: 'RS256', typ: 'JWT' }) + ).toString('base64url') + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url') + return `${header}.${payload}.signature` +} + +describe('readAuthTime', () => { + it('returns null for undefined token', () => { + expect(readAuthTime(undefined)).toBeNull() + }) + + it('returns null when auth_time claim is missing', () => { + expect(readAuthTime(makeIdToken({ sub: 'user-1' }))).toBeNull() + }) + + it('returns null when auth_time is not a number', () => { + expect(readAuthTime(makeIdToken({ auth_time: 'nope' }))).toBeNull() + }) + + it('returns the auth_time epoch seconds when present', () => { + expect(readAuthTime(makeIdToken({ auth_time: 1_700_000_000 }))).toBe( + 1_700_000_000 + ) + }) + + it('returns null for a malformed token', () => { + expect(readAuthTime('not-a-jwt')).toBeNull() + }) +}) + +describe('isReauthFresh', () => { + const now = 1_700_000_000 + + it('is true when auth_time is within the window', () => { + const token = makeIdToken({ auth_time: now - 60 }) + expect(isReauthFresh(token, now)).toBe(true) + }) + + it('is true exactly at the window boundary', () => { + const token = makeIdToken({ + auth_time: now - REAUTH_FRESHNESS_WINDOW_SECONDS, + }) + expect(isReauthFresh(token, now)).toBe(true) + }) + + it('is false when auth_time is older than the window', () => { + const token = makeIdToken({ + auth_time: now - REAUTH_FRESHNESS_WINDOW_SECONDS - 1, + }) + expect(isReauthFresh(token, now)).toBe(false) + }) + + it('is false when there is no id_token', () => { + expect(isReauthFresh(undefined, now)).toBe(false) + }) +}) diff --git a/tests/unit/auth-ory-identity.test.ts b/tests/unit/auth-ory-identity.test.ts new file mode 100644 index 000000000..8cea7e7a2 --- /dev/null +++ b/tests/unit/auth-ory-identity.test.ts @@ -0,0 +1,57 @@ +import type { Identity } from '@ory/client-fetch' +import { describe, expect, it } from 'vitest' +import { fromOryIdentity } from '@/core/server/auth/ory/identity' + +function identity(partial: Partial): Identity { + return { + id: 'identity-1', + schema_id: 'default', + schema_url: '', + traits: {}, + ...partial, + } as Identity +} + +describe('fromOryIdentity providers normalization', () => { + it('maps the Kratos `password` credential to `email`', () => { + const user = fromOryIdentity(identity({ credentials: { password: {} } })) + expect(user.providers).toEqual(['email']) + }) + + it('maps `password` to `email` and preserves other keys like `oidc`', () => { + const user = fromOryIdentity( + identity({ credentials: { password: {}, oidc: {} } }) + ) + expect(user.providers).toEqual(['email', 'oidc']) + }) + + it('leaves oauth-only identities without the email provider', () => { + const user = fromOryIdentity(identity({ credentials: { oidc: {} } })) + expect(user.providers).toEqual(['oidc']) + }) + + it('returns no providers when credentials are absent', () => { + const user = fromOryIdentity(identity({ credentials: undefined })) + expect(user.providers).toEqual([]) + }) +}) + +describe('fromOryIdentity traits', () => { + it('reads the flat `name` trait (the project schema shape)', () => { + const user = fromOryIdentity( + identity({ + traits: { email: 'ada@example.test', name: 'Ada Lovelace' }, + credentials: { password: {} }, + }) + ) + expect(user.email).toBe('ada@example.test') + expect(user.name).toBe('Ada Lovelace') + }) + + it('falls back to a nested { first, last } name', () => { + const user = fromOryIdentity( + identity({ traits: { name: { first: 'Ada', last: 'Lovelace' } } }) + ) + expect(user.name).toBe('Ada Lovelace') + }) +}) diff --git a/tests/unit/auth-ory-provider-account.test.ts b/tests/unit/auth-ory-provider-account.test.ts new file mode 100644 index 000000000..2bc109ba9 --- /dev/null +++ b/tests/unit/auth-ory-provider-account.test.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const loggerMocks = vi.hoisted(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})) + +const authjsMock = vi.hoisted(() => vi.fn()) +const updateUserMock = vi.hoisted(() => vi.fn()) +const revokeSessionsMock = vi.hoisted(() => vi.fn()) +const resolveIdentityMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/core/shared/clients/logger/logger', () => ({ + l: loggerMocks, + serializeErrorForLog: vi.fn((error: unknown) => error), +})) + +vi.mock('@/auth', () => ({ auth: authjsMock })) + +vi.mock('@/core/server/auth/ory/flows', () => ({ + oryAuthFlows: { updateUser: updateUserMock }, +})) + +vi.mock('@/core/server/auth/ory/find-identity', () => ({ + resolveOryIdentity: resolveIdentityMock, +})) + +vi.mock('@/core/server/auth/ory/kratos-session', () => ({ + revokeKratosSessionsForIdentity: revokeSessionsMock, +})) + +const { oryAuthProvider } = await import('@/core/server/auth/ory/provider') + +function makeIdToken(claims: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString( + 'base64url' + ) + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url') + return `${header}.${payload}.sig` +} + +const nowSeconds = Math.floor(Date.now() / 1000) + +describe('oryAuthProvider account operations', () => { + beforeEach(() => { + authjsMock.mockReset() + updateUserMock.mockReset() + revokeSessionsMock.mockReset() + resolveIdentityMock.mockReset() + // Vanilla case: the OIDC subject is the Kratos identity id. + resolveIdentityMock.mockResolvedValue({ id: 'identity-1' }) + loggerMocks.error.mockClear() + }) + + describe('startReauthForAccountSettings', () => { + it('redirects through oauth-start with the reauth intent', async () => { + const dispatch = await oryAuthProvider.startReauthForAccountSettings() + + expect(dispatch).toEqual({ + kind: 'redirect', + to: '/api/auth/oauth-start?intent=reauth&returnTo=%2Fdashboard%2Faccount%3Freauth%3D1', + }) + }) + }) + + describe('updateUser', () => { + it('throws when there is no authenticated session', async () => { + authjsMock.mockResolvedValue(null) + + await expect(oryAuthProvider.updateUser({ name: 'X' })).rejects.toThrow( + 'updateUser called without an authenticated Ory session' + ) + }) + + it('forwards a name-only change without a freshness gate', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'identity-1' }, + accessToken: 'a', + idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }), + }) + updateUserMock.mockResolvedValue({ ok: true, user: { id: 'identity-1' } }) + + const result = await oryAuthProvider.updateUser({ name: 'Ada' }) + + expect(updateUserMock).toHaveBeenCalledWith({ + identityId: 'identity-1', + name: 'Ada', + email: undefined, + password: undefined, + }) + expect(result).toEqual({ ok: true, user: { id: 'identity-1' } }) + }) + + it('uses the identity id cached on the session without a lookup', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'legacy-id' }, + identityId: 'kratos-uuid', + accessToken: 'a', + idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }), + }) + updateUserMock.mockResolvedValue({ + ok: true, + user: { id: 'kratos-uuid' }, + }) + + await oryAuthProvider.updateUser({ name: 'Ada' }) + + expect(resolveIdentityMock).not.toHaveBeenCalled() + expect(updateUserMock).toHaveBeenCalledWith( + expect.objectContaining({ identityId: 'kratos-uuid' }) + ) + }) + + it('patches the resolved Kratos id when the subject is an external_id', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'legacy-id' }, + accessToken: 'a', + idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }), + }) + resolveIdentityMock.mockResolvedValue({ id: 'kratos-uuid' }) + updateUserMock.mockResolvedValue({ + ok: true, + user: { id: 'kratos-uuid' }, + }) + + await oryAuthProvider.updateUser({ name: 'Ada' }) + + expect(resolveIdentityMock).toHaveBeenCalledWith( + expect.objectContaining({ subjects: ['legacy-id'] }) + ) + expect(updateUserMock).toHaveBeenCalledWith( + expect.objectContaining({ identityId: 'kratos-uuid' }) + ) + }) + + it('throws when the Ory identity cannot be resolved', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'ghost' }, + accessToken: 'a', + idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }), + }) + resolveIdentityMock.mockResolvedValue(null) + + await expect(oryAuthProvider.updateUser({ name: 'Ada' })).rejects.toThrow( + 'could not resolve an Ory identity' + ) + expect(updateUserMock).not.toHaveBeenCalled() + }) + + it('requires reauth for a password change when auth_time is stale', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'identity-1' }, + accessToken: 'a', + idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }), + }) + + const result = await oryAuthProvider.updateUser({ + password: 'new-secret', + }) + + expect(result).toEqual({ ok: false, code: 'reauthentication_needed' }) + expect(updateUserMock).not.toHaveBeenCalled() + }) + + it('requires reauth for a password change when there is no id_token', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'identity-1' }, + accessToken: 'a', + }) + + const result = await oryAuthProvider.updateUser({ + password: 'new-secret', + }) + + expect(result).toEqual({ ok: false, code: 'reauthentication_needed' }) + expect(updateUserMock).not.toHaveBeenCalled() + }) + + it('forwards a password change when auth_time is fresh', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'identity-1' }, + accessToken: 'a', + idToken: makeIdToken({ auth_time: nowSeconds - 30 }), + }) + updateUserMock.mockResolvedValue({ ok: true, user: { id: 'identity-1' } }) + + const result = await oryAuthProvider.updateUser({ + password: 'new-secret', + }) + + expect(updateUserMock).toHaveBeenCalledWith({ + identityId: 'identity-1', + name: undefined, + email: undefined, + password: 'new-secret', + }) + expect(result).toEqual({ ok: true, user: { id: 'identity-1' } }) + }) + }) + + describe('signOutOtherSessions', () => { + it('revokes all Kratos sessions for the current identity', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'identity-1' }, + accessToken: 'a', + }) + revokeSessionsMock.mockResolvedValue(undefined) + + await oryAuthProvider.signOutOtherSessions() + + expect(revokeSessionsMock).toHaveBeenCalledWith('identity-1') + }) + + it('no-ops when there is no session', async () => { + authjsMock.mockResolvedValue(null) + + await oryAuthProvider.signOutOtherSessions() + + expect(revokeSessionsMock).not.toHaveBeenCalled() + }) + }) +}) diff --git a/tests/unit/auth-ory-provider-profile.test.ts b/tests/unit/auth-ory-provider-profile.test.ts new file mode 100644 index 000000000..435576ec3 --- /dev/null +++ b/tests/unit/auth-ory-provider-profile.test.ts @@ -0,0 +1,121 @@ +import { ResponseError } from '@ory/client-fetch' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const loggerMocks = vi.hoisted(() => ({ + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})) + +const authjsMock = vi.hoisted(() => vi.fn()) +const getIdentityMock = vi.hoisted(() => vi.fn()) +const getIdentityByExternalIDMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/core/shared/clients/logger/logger', () => ({ + l: loggerMocks, + serializeErrorForLog: vi.fn((error: unknown) => error), +})) + +vi.mock('@/auth', () => ({ + auth: authjsMock, +})) + +vi.mock('@/core/server/auth/ory/client', () => ({ + getOryIdentityApi: () => ({ + getIdentity: getIdentityMock, + getIdentityByExternalID: getIdentityByExternalIDMock, + }), +})) + +const { oryAuthProvider } = await import('@/core/server/auth/ory/provider') + +describe('oryAuthProvider.getUserProfile', () => { + beforeEach(() => { + authjsMock.mockReset() + getIdentityMock.mockReset() + getIdentityByExternalIDMock.mockReset() + loggerMocks.error.mockClear() + }) + + it('returns the normalized profile from the live identity lookup', async () => { + authjsMock.mockResolvedValue({ user: { id: 'identity-1' } }) + getIdentityMock.mockResolvedValue({ + id: 'identity-1', + traits: { email: 'ada@example.test', name: 'Ada' }, + credentials: { password: {} }, + }) + + const profile = await oryAuthProvider.getUserProfile() + + expect(getIdentityMock).toHaveBeenCalledWith({ id: 'identity-1' }) + expect(profile).toEqual({ + id: 'identity-1', + email: 'ada@example.test', + name: 'Ada', + avatarUrl: null, + providers: ['email'], + }) + }) + + it('uses the identity id cached on the session, skipping external_id', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'legacy-id' }, + identityId: 'kratos-uuid', + }) + getIdentityMock.mockResolvedValue({ + id: 'kratos-uuid', + traits: { email: 'ada@example.test' }, + credentials: { password: {} }, + }) + + const profile = await oryAuthProvider.getUserProfile() + + expect(getIdentityMock).toHaveBeenCalledWith({ id: 'kratos-uuid' }) + expect(getIdentityByExternalIDMock).not.toHaveBeenCalled() + expect(profile?.id).toBe('kratos-uuid') + }) + + it('returns null when there is no session', async () => { + authjsMock.mockResolvedValue(null) + + const profile = await oryAuthProvider.getUserProfile() + + expect(profile).toBeNull() + expect(getIdentityMock).not.toHaveBeenCalled() + }) + + it('falls back to external_id when the subject is not a Kratos id', async () => { + authjsMock.mockResolvedValue({ user: { id: 'legacy-id' } }) + getIdentityMock.mockRejectedValue( + new ResponseError(new Response(null, { status: 404 }), 'not found') + ) + getIdentityByExternalIDMock.mockResolvedValue({ + id: 'kratos-uuid', + traits: { email: 'ada@example.test', name: 'Ada' }, + credentials: { password: {} }, + }) + + const profile = await oryAuthProvider.getUserProfile() + + expect(getIdentityByExternalIDMock).toHaveBeenCalledWith({ + externalID: 'legacy-id', + }) + expect(profile?.id).toBe('kratos-uuid') + expect(profile?.providers).toEqual(['email']) + }) + + it('returns null when neither id nor external_id matches', async () => { + authjsMock.mockResolvedValue({ user: { id: 'missing' } }) + getIdentityMock.mockRejectedValue( + new ResponseError(new Response(null, { status: 404 }), 'not found') + ) + getIdentityByExternalIDMock.mockRejectedValue( + new ResponseError(new Response(null, { status: 404 }), 'not found') + ) + + const profile = await oryAuthProvider.getUserProfile() + + expect(profile).toBeNull() + }) +}) From 4ad201955c5f8c359cb0ad9520b304d402c6f70a Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Thu, 28 May 2026 18:36:55 -0700 Subject: [PATCH 10/16] feat(account): user profile and account mutations over tRPC Replaces the user-update/access-token server actions with a tRPC user router: a cached profile query (live Kratos lookup with a timeout fallback to the session user), an update mutation returning a discriminated result, and createAccessToken. The dashboard layout prefetches the profile and team-gate injects it into DashboardContext; the account settings forms consume the mutations and refresh the profile cache. Reauth remains a redirect-throwing server action. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/dashboard/[teamSlug]/layout.tsx | 20 ++- src/app/dashboard/[teamSlug]/team-gate.tsx | 17 ++- src/core/application/user/queries.ts | 10 ++ src/core/server/actions/auth-actions.ts | 13 ++ src/core/server/actions/user-actions.ts | 140 ------------------ src/core/server/api/routers/index.ts | 2 + src/core/server/api/routers/user.ts | 120 +++++++++++++++ .../dashboard/account/email-settings.tsx | 55 ++++--- .../dashboard/account/name-settings.tsx | 34 +++-- .../dashboard/account/password-settings.tsx | 60 ++++---- .../dashboard/account/reauth-dialog.tsx | 5 +- .../dashboard/account/user-access-token.tsx | 18 +-- 12 files changed, 268 insertions(+), 226 deletions(-) create mode 100644 src/core/application/user/queries.ts delete mode 100644 src/core/server/actions/user-actions.ts create mode 100644 src/core/server/api/routers/user.ts diff --git a/src/app/dashboard/[teamSlug]/layout.tsx b/src/app/dashboard/[teamSlug]/layout.tsx index 389737aaa..99251f84d 100644 --- a/src/app/dashboard/[teamSlug]/layout.tsx +++ b/src/app/dashboard/[teamSlug]/layout.tsx @@ -6,6 +6,7 @@ import { COOKIE_KEYS } from '@/configs/cookies' import { METADATA } from '@/configs/metadata' import { AUTH_URLS } from '@/configs/urls' import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' +import { DASHBOARD_USER_PROFILE_QUERY_OPTIONS } from '@/core/application/user/queries' import { auth } from '@/core/server/auth' import DashboardLayoutView from '@/features/dashboard/layouts/layout' import Sidebar from '@/features/dashboard/sidebar/sidebar' @@ -43,13 +44,24 @@ export default async function DashboardLayout({ throw redirect(AUTH_URLS.SIGN_IN) } - await prefetchAsync( - trpc.teams.list.queryOptions(undefined, DASHBOARD_TEAMS_LIST_QUERY_OPTIONS) - ) + await Promise.all([ + prefetchAsync( + trpc.teams.list.queryOptions( + undefined, + DASHBOARD_TEAMS_LIST_QUERY_OPTIONS + ) + ), + prefetchAsync( + trpc.user.profile.queryOptions( + undefined, + DASHBOARD_USER_PROFILE_QUERY_OPTIONS + ) + ), + ]) return ( - + diff --git a/src/app/dashboard/[teamSlug]/team-gate.tsx b/src/app/dashboard/[teamSlug]/team-gate.tsx index 4278c92d0..db1ab0dbf 100644 --- a/src/app/dashboard/[teamSlug]/team-gate.tsx +++ b/src/app/dashboard/[teamSlug]/team-gate.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries' -import type { AuthUser } from '@/core/server/auth' +import { DASHBOARD_USER_PROFILE_QUERY_OPTIONS } from '@/core/application/user/queries' import { DashboardContextProvider } from '@/features/dashboard/context' import LoadingLayout from '@/features/dashboard/loading-layout' import { useTRPC } from '@/trpc/client' @@ -10,28 +10,33 @@ import Unauthorized from '../unauthorized' interface DashboardTeamGateProps { teamSlug: string - user: AuthUser children: React.ReactNode } export function DashboardTeamGate({ teamSlug, - user, children, }: DashboardTeamGateProps) { const trpc = useTRPC() - const { data: teams, isPending } = useQuery( + const { data: teams, isPending: teamsPending } = useQuery( trpc.teams.list.queryOptions(undefined, DASHBOARD_TEAMS_LIST_QUERY_OPTIONS) ) - if (isPending) { + const { data: user, isPending: userPending } = useQuery( + trpc.user.profile.queryOptions( + undefined, + DASHBOARD_USER_PROFILE_QUERY_OPTIONS + ) + ) + + if (teamsPending || userPending) { return } const team = teams?.find((candidate) => candidate.slug === teamSlug) - if (!team || !teams) { + if (!team || !teams || !user) { return } diff --git a/src/core/application/user/queries.ts b/src/core/application/user/queries.ts new file mode 100644 index 000000000..eaa1419d2 --- /dev/null +++ b/src/core/application/user/queries.ts @@ -0,0 +1,10 @@ +// Mirrors DASHBOARD_TEAMS_LIST_QUERY_OPTIONS: the profile is prefetched once in +// the dashboard layout and treated as fresh on the client, so it isn't refetched +// on every mount/focus. Cache updates after account mutations come from explicit +// setQueryData calls in the account-settings forms. +export const DASHBOARD_USER_PROFILE_QUERY_OPTIONS = { + staleTime: 5 * 60 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, +} as const diff --git a/src/core/server/actions/auth-actions.ts b/src/core/server/actions/auth-actions.ts index ff1d2cfdf..429eb7b17 100644 --- a/src/core/server/actions/auth-actions.ts +++ b/src/core/server/actions/auth-actions.ts @@ -369,3 +369,16 @@ export async function signOutAction(returnTo?: string) { throw redirect(redirectTo) } + +// Drives the account-settings re-authentication step. Supabase signs the user +// out and bounces through /sign-in (which lands back on the account page with +// ?reauth=1); Ory forces a fresh OAuth2 login via the oauth-start route. +export async function reauthForAccountSettingsAction() { + const dispatch = await auth.startReauthForAccountSettings() + + if (dispatch.kind === 'sign-out') { + return signOutAction(dispatch.returnTo) + } + + throw redirect(dispatch.to) +} diff --git a/src/core/server/actions/user-actions.ts b/src/core/server/actions/user-actions.ts deleted file mode 100644 index 78a97eb7b..000000000 --- a/src/core/server/actions/user-actions.ts +++ /dev/null @@ -1,140 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' -import { headers } from 'next/headers' -import { returnValidationErrors } from 'next-safe-action' -import { z } from 'zod' -import { authActionClient } from '@/core/server/actions/client' -import { auth } from '@/core/server/auth' -import { supabaseAuthFlows } from '@/core/server/auth/supabase/flows' -import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' -import { generateE2BUserAccessToken } from '@/lib/utils/server' - -const UpdateUserSchema = z - .object({ - email: z.email().optional(), - password: z.string().min(8).optional(), - name: z.string().min(1).max(100).optional(), - }) - .refine( - (data) => { - return Boolean(data.email || data.password || data.name) - }, - { - message: 'At least one field must be provided (email, password, name)', - path: [], - } - ) - -export type UpdateUserSchemaType = z.infer - -export const updateUserAction = authActionClient - .schema(UpdateUserSchema) - .metadata({ actionName: 'updateUser' }) - .action(async ({ parsedInput, ctx }) => { - const { user } = ctx - - // basic security check, that password does not equal e-mail - if (parsedInput.password) { - const passwordAsUserEmail = - parsedInput.password.toLowerCase() === user?.email?.toLowerCase() - const passwordAsEmail = - parsedInput.password.toLowerCase() === parsedInput.email?.toLowerCase() - - if (passwordAsUserEmail || passwordAsEmail) { - return returnValidationErrors(UpdateUserSchema, { - password: { - _errors: ['Password is too weak.'], - }, - }) - } - } - - const origin = (await headers()).get('origin') - - let emailRedirectTo: string | undefined - - if (parsedInput.email) { - if (!origin) { - throw new Error('Missing origin header for email update redirect') - } - - const redirectUrl = new URL('/api/auth/email-callback', origin) - redirectUrl.searchParams.set('new_email', parsedInput.email) - emailRedirectTo = redirectUrl.toString() - } - - const { data: updateData, error } = await supabaseAuthFlows.updateUser({ - email: parsedInput.email, - password: parsedInput.password, - name: parsedInput.name, - emailRedirectTo, - }) - - if (!error) { - // ensure other sessions are logged out if password was changed - if (parsedInput.password) { - const { error: signOutError } = await auth.signOut({ scope: 'others' }) - - if (signOutError) { - l.error( - { - key: 'update_user_action:sign_out_others_failed', - user_id: user.id, - error: serializeErrorForLog(signOutError), - }, - 'failed to invalidate other sessions after password change' - ) - } - } - - revalidatePath('/dashboard', 'layout') - - return { - user: updateData.user, - } - } - - switch (error?.code) { - case 'email_address_invalid': - return returnValidationErrors(UpdateUserSchema, { - email: { - _errors: ['Invalid e-mail address.'], - }, - }) - case 'email_exists': - return returnValidationErrors(UpdateUserSchema, { - email: { - _errors: ['E-mail already in use.'], - }, - }) - case 'same_password': - return returnValidationErrors(UpdateUserSchema, { - password: { - _errors: ['New password cannot be the same as the old password.'], - }, - }) - case 'weak_password': - return returnValidationErrors(UpdateUserSchema, { - password: { - _errors: ['Password is too weak.'], - }, - }) - case 'reauthentication_needed': - return { - requiresReauth: true, - } - default: - throw error - } - }) - -export const getUserAccessTokenAction = authActionClient - .metadata({ actionName: 'getUserAccessToken' }) - .action(async ({ ctx }) => { - const { session } = ctx - - const token = await generateE2BUserAccessToken(session.access_token) - - return token - }) diff --git a/src/core/server/api/routers/index.ts b/src/core/server/api/routers/index.ts index 530503282..eb8ad34a8 100644 --- a/src/core/server/api/routers/index.ts +++ b/src/core/server/api/routers/index.ts @@ -6,6 +6,7 @@ import { sandboxesRouter } from './sandboxes' import { supportRouter } from './support' import { teamsRouter } from './teams' import { templatesRouter } from './templates' +import { userRouter } from './user' export const trpcAppRouter = createTRPCRouter({ sandbox: sandboxRouter, @@ -15,6 +16,7 @@ export const trpcAppRouter = createTRPCRouter({ billing: billingRouter, support: supportRouter, teams: teamsRouter, + user: userRouter, }) export type TRPCAppRouter = typeof trpcAppRouter diff --git a/src/core/server/api/routers/user.ts b/src/core/server/api/routers/user.ts new file mode 100644 index 000000000..b6aeb02bb --- /dev/null +++ b/src/core/server/api/routers/user.ts @@ -0,0 +1,120 @@ +import { TRPCError } from '@trpc/server' +import { z } from 'zod' +import type { AuthUser } from '@/core/server/auth' +import { createAuthForHeaders } from '@/core/server/auth' +import { createTRPCRouter } from '@/core/server/trpc/init' +import { protectedProcedure } from '@/core/server/trpc/procedures' +import { l } from '@/core/shared/clients/logger/logger' +import { generateE2BUserAccessToken } from '@/lib/utils/server' + +// How long the live identity-provider profile lookup is allowed to take before +// we fall back to the cheap session user. Keeps a slow Ory admin API out of the +// critical render path for every dashboard page. +const PROFILE_LOOKUP_TIMEOUT_MS = 3000 + +const UpdateUserSchema = z + .object({ + email: z.email().optional(), + password: z.string().min(8).optional(), + name: z.string().min(1).max(100).optional(), + }) + .refine((data) => Boolean(data.email || data.password || data.name), { + message: 'At least one field must be provided (email, password, name)', + path: [], + }) + +const TIMEOUT = Symbol('profile-lookup-timeout') + +function withTimeout( + promise: Promise, + ms: number +): Promise { + return Promise.race([ + promise, + new Promise((resolve) => { + setTimeout(() => resolve(TIMEOUT), ms) + }), + ]) +} + +export const userRouter = createTRPCRouter({ + // Live profile (full traits + credential-derived providers). Prefetched once + // per dashboard load and injected into DashboardContext. The lookup is raced + // against a timeout and falls back to the cheap session user so the dashboard + // never hangs on the identity provider. + profile: protectedProcedure.query(async ({ ctx }): Promise => { + const provider = createAuthForHeaders(ctx.headers) + + const result = await withTimeout( + provider.getUserProfile().catch(() => null), + PROFILE_LOOKUP_TIMEOUT_MS + ) + + if (result && result !== TIMEOUT) { + return result + } + + l.error( + { + key: 'trpc_user_profile:fallback', + user_id: ctx.user.id, + context: { timed_out: result === TIMEOUT }, + }, + 'user profile lookup failed or timed out; falling back to session user' + ) + + return ctx.user + }), + + update: protectedProcedure + .input(UpdateUserSchema) + .mutation(async ({ ctx, input }) => { + // Basic security check: a password must not equal the account email + // (current or the new one being set in the same request). + if (input.password) { + const password = input.password.toLowerCase() + const matchesCurrentEmail = password === ctx.user.email?.toLowerCase() + const matchesNewEmail = + input.email !== undefined && password === input.email.toLowerCase() + + if (matchesCurrentEmail || matchesNewEmail) { + return { status: 'error' as const, code: 'weak_password' as const } + } + } + + const provider = createAuthForHeaders(ctx.headers) + const result = await provider.updateUser({ + email: input.email, + password: input.password, + name: input.name, + }) + + if (result.ok) { + // Invalidate other sessions when the password changed. + if (input.password) { + await provider.signOutOtherSessions() + } + + return { status: 'ok' as const, user: result.user } + } + + if (result.code === 'reauthentication_needed') { + return { status: 'reauth' as const } + } + + return { status: 'error' as const, code: result.code } + }), + + // Creates (POSTs) a fresh E2B access token — non-idempotent, fired on demand. + createAccessToken: protectedProcedure.mutation(async ({ ctx }) => { + try { + return await generateE2BUserAccessToken(ctx.session.access_token) + } catch (error) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to generate access token', + cause: error, + }) + } + }), +}) diff --git a/src/features/dashboard/account/email-settings.tsx b/src/features/dashboard/account/email-settings.tsx index 6d363eb1f..65f1ae5d6 100644 --- a/src/features/dashboard/account/email-settings.tsx +++ b/src/features/dashboard/account/email-settings.tsx @@ -1,19 +1,19 @@ 'use client' import { zodResolver } from '@hookform/resolvers/zod' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useSearchParams } from 'next/navigation' -import { useAction } from 'next-safe-action/hooks' import { useEffect, useMemo } from 'react' import { useForm } from 'react-hook-form' import { z } from 'zod' import { USER_MESSAGES } from '@/configs/user-messages' -import { updateUserAction } from '@/core/server/actions/user-actions' import { defaultErrorToast, defaultSuccessToast, useToast, } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' +import { useTRPC } from '@/trpc/client' import { Button } from '@/ui/primitives/button' import { Card, @@ -49,6 +49,8 @@ export function EmailSettings({ className }: EmailSettingsProps) { const { user } = useDashboard() const searchParams = useSearchParams() const { toast } = useToast() + const trpc = useTRPC() + const queryClient = useQueryClient() const form = useForm({ resolver: zodResolver(formSchema), @@ -65,25 +67,36 @@ export function EmailSettings({ className }: EmailSettingsProps) { [user] ) - const { execute: updateEmail, isPending } = useAction(updateUserAction, { - onSuccess: () => { - toast( - defaultSuccessToast(USER_MESSAGES.emailUpdateVerification.message, { - duration: USER_MESSAGES.emailUpdateVerification.timeoutMs, - }) - ) - }, - onError: ({ error }) => { - if (error.validationErrors?.fieldErrors?.email?.[0]) { - form.setError('email', { - message: error.validationErrors.fieldErrors.email?.[0], - }) - return - } - - toast(defaultErrorToast(error.serverError || 'Failed to update e-mail.')) - }, - }) + const { mutate: updateEmail, isPending } = useMutation( + trpc.user.update.mutationOptions({ + onSuccess: (data) => { + if (data.status === 'ok') { + queryClient.setQueryData(trpc.user.profile.queryKey(), data.user) + toast( + defaultSuccessToast(USER_MESSAGES.emailUpdateVerification.message, { + duration: USER_MESSAGES.emailUpdateVerification.timeoutMs, + }) + ) + return + } + + if (data.status === 'error' && data.code === 'email_exists') { + form.setError('email', { message: 'E-mail already in use.' }) + return + } + + if (data.status === 'error' && data.code === 'email_invalid') { + form.setError('email', { message: 'Invalid e-mail address.' }) + return + } + + toast(defaultErrorToast('Failed to update e-mail.')) + }, + onError: () => { + toast(defaultErrorToast('Failed to update e-mail.')) + }, + }) + ) useEffect(() => { if ( diff --git a/src/features/dashboard/account/name-settings.tsx b/src/features/dashboard/account/name-settings.tsx index 44cb659f5..4bd493eec 100644 --- a/src/features/dashboard/account/name-settings.tsx +++ b/src/features/dashboard/account/name-settings.tsx @@ -1,17 +1,17 @@ 'use client' import { zodResolver } from '@hookform/resolvers/zod' -import { useAction } from 'next-safe-action/hooks' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useForm } from 'react-hook-form' import { z } from 'zod' import { USER_MESSAGES } from '@/configs/user-messages' -import { updateUserAction } from '@/core/server/actions/user-actions' import { defaultErrorToast, defaultSuccessToast, useToast, } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' +import { useTRPC } from '@/trpc/client' import { Button } from '@/ui/primitives/button' import { Card, @@ -49,6 +49,8 @@ export function NameSettings({ className }: NameSettingsProps) { const { user } = useDashboard() const { toast } = useToast() + const trpc = useTRPC() + const queryClient = useQueryClient() const form = useForm({ resolver: zodResolver(formSchema), @@ -60,18 +62,22 @@ export function NameSettings({ className }: NameSettingsProps) { }, }) - const { execute: updateName, isPending } = useAction(updateUserAction, { - onSuccess: async () => { - toast(defaultSuccessToast(USER_MESSAGES.nameUpdated.message)) - }, - onError: ({ error }) => { - toast( - defaultErrorToast( - error.serverError || USER_MESSAGES.failedUpdateName.message - ) - ) - }, - }) + const { mutate: updateName, isPending } = useMutation( + trpc.user.update.mutationOptions({ + onSuccess: (data) => { + if (data.status === 'ok') { + queryClient.setQueryData(trpc.user.profile.queryKey(), data.user) + toast(defaultSuccessToast(USER_MESSAGES.nameUpdated.message)) + return + } + + toast(defaultErrorToast(USER_MESSAGES.failedUpdateName.message)) + }, + onError: () => { + toast(defaultErrorToast(USER_MESSAGES.failedUpdateName.message)) + }, + }) + ) if (!user) return null diff --git a/src/features/dashboard/account/password-settings.tsx b/src/features/dashboard/account/password-settings.tsx index 6f240c90f..ebe64d419 100644 --- a/src/features/dashboard/account/password-settings.tsx +++ b/src/features/dashboard/account/password-settings.tsx @@ -1,18 +1,18 @@ 'use client' import { zodResolver } from '@hookform/resolvers/zod' -import { useAction } from 'next-safe-action/hooks' +import { useMutation } from '@tanstack/react-query' import { useEffect, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { z } from 'zod' import { USER_MESSAGES } from '@/configs/user-messages' -import { updateUserAction } from '@/core/server/actions/user-actions' import { defaultErrorToast, defaultSuccessToast, useToast, } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' +import { useTRPC } from '@/trpc/client' import { Button } from '@/ui/primitives/button' import { Card, @@ -61,6 +61,7 @@ export function PasswordSettings({ const { user } = useDashboard() const { toast } = useToast() + const trpc = useTRPC() const [reauthDialogOpen, setReauthDialogOpen] = useState(false) const [clientShowPasswordForm, setClientShowPasswordForm] = useState( showPasswordChangeForm @@ -83,33 +84,34 @@ export function PasswordSettings({ }, }) - const { execute: updatePassword, isPending } = useAction(updateUserAction, { - onSuccess: ({ data }) => { - if (data?.requiresReauth) { - setReauthDialogOpen(true) - return - } - - toast(defaultSuccessToast(USER_MESSAGES.passwordUpdated.message)) - - form.reset() - setClientShowPasswordForm(false) - window.history.replaceState({}, '', window.location.pathname) - }, - onError: ({ error }) => { - if (error.validationErrors?.fieldErrors?.password) { - form.setError('confirmPassword', { - message: error.validationErrors.fieldErrors.password?.[0], - }) - } else { - toast( - defaultErrorToast( - error.serverError || USER_MESSAGES.failedUpdatePassword.message - ) - ) - } - }, - }) + const { mutate: updatePassword, isPending } = useMutation( + trpc.user.update.mutationOptions({ + onSuccess: (data) => { + if (data.status === 'reauth') { + setReauthDialogOpen(true) + return + } + + if (data.status === 'error') { + const message = + data.code === 'same_password' + ? 'New password cannot be the same as the old password.' + : 'Password is too weak.' + form.setError('confirmPassword', { message }) + return + } + + toast(defaultSuccessToast(USER_MESSAGES.passwordUpdated.message)) + + form.reset() + setClientShowPasswordForm(false) + window.history.replaceState({}, '', window.location.pathname) + }, + onError: () => { + toast(defaultErrorToast(USER_MESSAGES.failedUpdatePassword.message)) + }, + }) + ) function onSubmit(values: FormValues) { updatePassword({ password: values.password }) diff --git a/src/features/dashboard/account/reauth-dialog.tsx b/src/features/dashboard/account/reauth-dialog.tsx index c5d322709..c85da5d9d 100644 --- a/src/features/dashboard/account/reauth-dialog.tsx +++ b/src/features/dashboard/account/reauth-dialog.tsx @@ -1,7 +1,6 @@ 'use client' -import { PROTECTED_URLS } from '@/configs/urls' -import { signOutAction } from '@/core/server/actions/auth-actions' +import { reauthForAccountSettingsAction } from '@/core/server/actions/auth-actions' import { AlertDialog } from '@/ui/alert-dialog' interface ReauthDialogProps { @@ -11,7 +10,7 @@ interface ReauthDialogProps { export function ReauthDialog({ open, onOpenChange }: ReauthDialogProps) { const handleReauth = () => { - signOutAction(PROTECTED_URLS.ACCOUNT_SETTINGS) + reauthForAccountSettingsAction() } return ( diff --git a/src/features/dashboard/account/user-access-token.tsx b/src/features/dashboard/account/user-access-token.tsx index 433b26c22..23ea04127 100644 --- a/src/features/dashboard/account/user-access-token.tsx +++ b/src/features/dashboard/account/user-access-token.tsx @@ -1,9 +1,9 @@ 'use client' -import { useAction } from 'next-safe-action/hooks' +import { useMutation } from '@tanstack/react-query' import { useState } from 'react' -import { getUserAccessTokenAction } from '@/core/server/actions/user-actions' import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast' +import { useTRPC } from '@/trpc/client' import CopyButton from '@/ui/copy-button' import { IconButton } from '@/ui/primitives/icon-button' import { EyeIcon, EyeOffIcon } from '@/ui/primitives/icons' @@ -16,22 +16,22 @@ interface UserAccessTokenProps { export default function UserAccessToken({ className }: UserAccessTokenProps) { const { toast } = useToast() + const trpc = useTRPC() const [token, setToken] = useState() const [isVisible, setIsVisible] = useState(false) - const { execute: fetchToken, isPending } = useAction( - getUserAccessTokenAction, - { - onSuccess: (result) => { - if (result.data) { - setToken(result.data.token) + const { mutate: fetchToken, isPending } = useMutation( + trpc.user.createAccessToken.mutationOptions({ + onSuccess: (data) => { + if (data?.token) { + setToken(data.token) setIsVisible(true) } }, onError: () => { toast(defaultErrorToast('Failed to fetch access token')) }, - } + }) ) return ( From 56f847177da2bd94d165f8ef7f4b4b2f0a118b0b Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 29 May 2026 10:01:23 -0700 Subject: [PATCH 11/16] fix(auth): set Ory password via updateIdentity so Kratos hashes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A JSON-Patch write to /credentials/password/config/password is accepted with 200 but stored raw — hashed_password is left untouched, so the change appeared to succeed while the OLD password kept working and the new one never did. Route password changes through updateIdentity (the credential import path), which Kratos hashes; trait-only changes keep the lighter patch. Re-sends schema_id/state/traits/external_id/metadata so the full update doesn't clobber them, and preserves existing non-password credentials (e.g. oidc). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/server/auth/ory/flows.ts | 88 ++++++++++++++++++++++++------- tests/unit/auth-ory-flows.test.ts | 47 ++++++++++++----- 2 files changed, 103 insertions(+), 32 deletions(-) diff --git a/src/core/server/auth/ory/flows.ts b/src/core/server/auth/ory/flows.ts index 413b7bb94..5157f8cd6 100644 --- a/src/core/server/auth/ory/flows.ts +++ b/src/core/server/auth/ory/flows.ts @@ -1,6 +1,10 @@ import 'server-only' -import { type JsonPatch, JsonPatchOpEnum } from '@ory/client-fetch' +import { + type Identity, + type JsonPatch, + JsonPatchOpEnum, +} from '@ory/client-fetch' import { l } from '@/core/shared/clients/logger/logger' import type { UpdateUserErrorCode, UpdateUserResult } from '../types' import { getOryIdentityApi } from './client' @@ -21,13 +25,13 @@ export const oryAuthFlows = { email, password, }: OryUpdateUserInput): Promise { - const jsonPatch = buildIdentityPatches({ name, email, password }) - try { - const identity = await getOryIdentityApi().patchIdentity({ - id: identityId, - jsonPatch, - }) + // A password change must go through updateIdentity (the credential import + // path) — see setPassword. Trait-only changes use the lighter patch. + const identity = + password !== undefined + ? await setPassword(identityId, { name, email, password }) + : await patchTraits(identityId, { name, email }) return { ok: true, user: fromOryIdentity(identity) } } catch (error) { @@ -36,13 +40,66 @@ export const oryAuthFlows = { }, } +// Kratos only hashes a cleartext password when it runs through the credential +// IMPORT pipeline (updateIdentity / createIdentity). A JSON-Patch write to +// `/credentials/password/config/password` is accepted with 200 but stored raw — +// `hashed_password` is left untouched, so the change appears to succeed while +// the OLD password keeps working and the new one never does. So we set the +// password via updateIdentity (PUT). Only the password credential is supplied, +// which Kratos hashes; existing credentials (e.g. oidc) are preserved. We +// re-send schema_id/state/traits/external_id/metadata to avoid clobbering them +// on the full update. +async function setPassword( + identityId: string, + { name, email, password }: Omit +): Promise { + const api = getOryIdentityApi() + const current = await api.getIdentity({ id: identityId }) + + return api.updateIdentity({ + id: identityId, + updateIdentityBody: { + schema_id: current.schema_id, + state: current.state ?? 'active', + traits: mergeTraits(current.traits, { name, email }), + external_id: current.external_id, + metadata_public: current.metadata_public, + metadata_admin: current.metadata_admin, + credentials: { password: { config: { password } } }, + }, + }) +} + +async function patchTraits( + identityId: string, + { name, email }: Pick +): Promise { + const api = getOryIdentityApi() + const jsonPatch = buildTraitPatches({ name, email }) + + if (jsonPatch.length === 0) { + return api.getIdentity({ id: identityId }) + } + + return api.patchIdentity({ id: identityId, jsonPatch }) +} + +function mergeTraits( + current: unknown, + { name, email }: Pick +): Record { + const traits = { ...((current as Record) ?? {}) } + if (name !== undefined) traits.name = name + if (email !== undefined) traits.email = email + return traits +} + // Assumes a flat `name` trait. If the project's identity schema nests name as -// `{ first, last }`, this patch path needs to target those sub-paths instead. -function buildIdentityPatches({ +// `{ first, last }`, these patch paths need to target those sub-paths instead. +function buildTraitPatches({ name, email, - password, -}: Omit): JsonPatch[] { +}: Pick): JsonPatch[] { const patches: JsonPatch[] = [] if (name !== undefined) { @@ -59,15 +116,6 @@ function buildIdentityPatches({ value: email, }) } - if (password !== undefined) { - // The password-settings UI is only shown for identities that already have - // the email/password credential, so the config object exists to replace. - patches.push({ - op: JsonPatchOpEnum.Replace, - path: '/credentials/password/config/password', - value: password, - }) - } return patches } diff --git a/tests/unit/auth-ory-flows.test.ts b/tests/unit/auth-ory-flows.test.ts index 6eb5cc712..1c0881aea 100644 --- a/tests/unit/auth-ory-flows.test.ts +++ b/tests/unit/auth-ory-flows.test.ts @@ -9,6 +9,8 @@ const loggerMocks = vi.hoisted(() => ({ })) const patchIdentityMock = vi.hoisted(() => vi.fn()) +const getIdentityMock = vi.hoisted(() => vi.fn()) +const updateIdentityMock = vi.hoisted(() => vi.fn()) vi.mock('@/core/shared/clients/logger/logger', () => ({ l: loggerMocks, @@ -16,7 +18,11 @@ vi.mock('@/core/shared/clients/logger/logger', () => ({ })) vi.mock('@/core/server/auth/ory/client', () => ({ - getOryIdentityApi: () => ({ patchIdentity: patchIdentityMock }), + getOryIdentityApi: () => ({ + patchIdentity: patchIdentityMock, + getIdentity: getIdentityMock, + updateIdentity: updateIdentityMock, + }), })) const { oryAuthFlows } = await import('@/core/server/auth/ory/flows') @@ -34,6 +40,8 @@ function oryError( describe('oryAuthFlows.updateUser', () => { beforeEach(() => { patchIdentityMock.mockReset() + getIdentityMock.mockReset() + updateIdentityMock.mockReset() loggerMocks.error.mockClear() }) @@ -69,8 +77,15 @@ describe('oryAuthFlows.updateUser', () => { }) }) - it('patches the password credential config when a password is provided', async () => { - patchIdentityMock.mockResolvedValue({ + it('sets the password via updateIdentity (import path) so Kratos hashes it', async () => { + getIdentityMock.mockResolvedValue({ + id: 'identity-1', + schema_id: 'default', + state: 'active', + traits: { email: 'a@b.test', name: 'Ada' }, + external_id: 'legacy-id', + }) + updateIdentityMock.mockResolvedValue({ id: 'identity-1', traits: { email: 'a@b.test' }, credentials: { password: {} }, @@ -81,15 +96,17 @@ describe('oryAuthFlows.updateUser', () => { password: 'super-secret', }) - expect(patchIdentityMock).toHaveBeenCalledWith({ + // not the raw patch — that writes cleartext without hashing + expect(patchIdentityMock).not.toHaveBeenCalled() + expect(updateIdentityMock).toHaveBeenCalledWith({ id: 'identity-1', - jsonPatch: [ - { - op: 'replace', - path: '/credentials/password/config/password', - value: 'super-secret', - }, - ], + updateIdentityBody: expect.objectContaining({ + schema_id: 'default', + state: 'active', + external_id: 'legacy-id', + traits: { email: 'a@b.test', name: 'Ada' }, + credentials: { password: { config: { password: 'super-secret' } } }, + }), }) }) @@ -113,7 +130,13 @@ describe('oryAuthFlows.updateUser', () => { }) it('maps a 400 password policy violation to weak_password', async () => { - patchIdentityMock.mockRejectedValue( + getIdentityMock.mockResolvedValue({ + id: 'identity-1', + schema_id: 'default', + state: 'active', + traits: { email: 'a@b.test' }, + }) + updateIdentityMock.mockRejectedValue( oryError(400, { error: { code: 400, From f002873c2cddfa38a8b50b7e4d9014e6de320229 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 29 May 2026 10:01:30 -0700 Subject: [PATCH 12/16] fix(auth): harden account re-auth (hard-nav redirect + gate email changes) - Reauth returns the oauth-start URL for a client window.location navigation instead of a server-action redirect(). The soft RSC navigation was prefetching/re-invoking the side-effecting oauth-start GET, corrupting the OAuth state/callback-url cookies so the post-reauth callback fell back to '/'. - Require fresh re-authentication for EMAIL changes too (not just password): otherwise a stolen session could take over the account by swapping the email and resetting the password via the attacker's inbox. Wire the email form to the reauth dialog and make the (now shared) dialog copy generic. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/server/actions/auth-actions.ts | 24 +++- src/core/server/auth/ory/provider.ts | 9 +- .../dashboard/account/email-settings.tsx | 116 ++++++++++-------- .../dashboard/account/reauth-dialog.tsx | 12 +- tests/unit/auth-ory-provider-account.test.ts | 15 +++ 5 files changed, 112 insertions(+), 64 deletions(-) diff --git a/src/core/server/actions/auth-actions.ts b/src/core/server/actions/auth-actions.ts index 429eb7b17..8ec1ea95a 100644 --- a/src/core/server/actions/auth-actions.ts +++ b/src/core/server/actions/auth-actions.ts @@ -370,15 +370,27 @@ export async function signOutAction(returnTo?: string) { throw redirect(redirectTo) } -// Drives the account-settings re-authentication step. Supabase signs the user -// out and bounces through /sign-in (which lands back on the account page with -// ?reauth=1); Ory forces a fresh OAuth2 login via the oauth-start route. -export async function reauthForAccountSettingsAction() { +// Drives the account-settings re-authentication step and returns the URL the +// client should HARD-navigate to. Supabase signs the user out and bounces +// through /sign-in (which lands back on the account page with ?reauth=1); Ory +// forces a fresh OAuth2 login via the oauth-start route. +// +// We deliberately return the URL instead of redirect()-ing: a server-action +// redirect is a soft RSC navigation, which prefetches and re-invokes the +// oauth-start GET (a side-effecting endpoint that mints OAuth state/pkce/ +// callback-url cookies). Those duplicate invocations corrupt the cookies so the +// post-reauth callback loses its callbackUrl and falls back to "/". A single +// window.location navigation on the client avoids that entirely. +export async function reauthForAccountSettingsAction(): Promise<{ + url: string +}> { const dispatch = await auth.startReauthForAccountSettings() if (dispatch.kind === 'sign-out') { - return signOutAction(dispatch.returnTo) + // Supabase: clear the session server-side, then hand back the sign-in URL. + const { redirectTo } = await auth.signOut({ returnTo: dispatch.returnTo }) + return { url: redirectTo } } - throw redirect(dispatch.to) + return { url: dispatch.to } } diff --git a/src/core/server/auth/ory/provider.ts b/src/core/server/auth/ory/provider.ts index 4a254e55b..2e0f80afa 100644 --- a/src/core/server/auth/ory/provider.ts +++ b/src/core/server/auth/ory/provider.ts @@ -76,10 +76,13 @@ export const oryAuthProvider: AuthProvider = { throw new Error('updateUser called without an authenticated Ory session') } - // Changing the password is privileged: require a recent active login so a - // stolen dashboard session can't silently reset credentials. The caller + // Changing the password OR the email is privileged: require a recent active + // login so a stolen dashboard session can't silently take over the account + // (swap the email, then reset the password via the new inbox). The caller // turns this into the forced OAuth2 re-auth round-trip. - if (input.password !== undefined && !isReauthFresh(session.idToken)) { + const changesCredentials = + input.password !== undefined || input.email !== undefined + if (changesCredentials && !isReauthFresh(session.idToken)) { return { ok: false, code: 'reauthentication_needed' } } diff --git a/src/features/dashboard/account/email-settings.tsx b/src/features/dashboard/account/email-settings.tsx index 65f1ae5d6..68eae510c 100644 --- a/src/features/dashboard/account/email-settings.tsx +++ b/src/features/dashboard/account/email-settings.tsx @@ -3,7 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useSearchParams } from 'next/navigation' -import { useEffect, useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { z } from 'zod' import { USER_MESSAGES } from '@/configs/user-messages' @@ -32,6 +32,7 @@ import { } from '@/ui/primitives/form' import { Input } from '@/ui/primitives/input' import { useDashboard } from '../context' +import { ReauthDialog } from './reauth-dialog' const formSchema = z.object({ email: z.email('Invalid e-mail address'), @@ -51,6 +52,7 @@ export function EmailSettings({ className }: EmailSettingsProps) { const { toast } = useToast() const trpc = useTRPC() const queryClient = useQueryClient() + const [reauthDialogOpen, setReauthDialogOpen] = useState(false) const form = useForm({ resolver: zodResolver(formSchema), @@ -70,6 +72,11 @@ export function EmailSettings({ className }: EmailSettingsProps) { const { mutate: updateEmail, isPending } = useMutation( trpc.user.update.mutationOptions({ onSuccess: (data) => { + if (data.status === 'reauth') { + setReauthDialogOpen(true) + return + } + if (data.status === 'ok') { queryClient.setQueryData(trpc.user.profile.queryKey(), data.user) toast( @@ -130,55 +137,62 @@ export function EmailSettings({ className }: EmailSettingsProps) { if (!user || !hasEmailProvider) return null return ( -
- - updateEmail({ email: values.email }) - )} - className="w-full" - > - - - E-Mail - Update your e-mail address. - - - - ( - - - - - - - )} - /> - - - -

- Has to be a valid e-mail address. -

- -
-
-
- + <> +
+ + updateEmail({ email: values.email }) + )} + className="w-full" + > + + + E-Mail + Update your e-mail address. + + + + ( + + + + + + + )} + /> + + + +

+ Has to be a valid e-mail address. +

+ +
+
+
+ + + + ) } diff --git a/src/features/dashboard/account/reauth-dialog.tsx b/src/features/dashboard/account/reauth-dialog.tsx index c85da5d9d..6e15f4061 100644 --- a/src/features/dashboard/account/reauth-dialog.tsx +++ b/src/features/dashboard/account/reauth-dialog.tsx @@ -9,8 +9,12 @@ interface ReauthDialogProps { } export function ReauthDialog({ open, onOpenChange }: ReauthDialogProps) { - const handleReauth = () => { - reauthForAccountSettingsAction() + const handleReauth = async () => { + // Hard navigation (not the Next router): oauth-start is a side-effecting GET + // that must run exactly once, so a soft RSC navigation would corrupt the + // OAuth flow. See reauthForAccountSettingsAction. + const { url } = await reauthForAccountSettingsAction() + window.location.href = url } return ( @@ -20,8 +24,8 @@ export function ReauthDialog({ open, onOpenChange }: ReauthDialogProps) { title="Re-authentication Required" description={

- To change your password, you'll need to{' '} - re-authenticate for security. + To make this change, you'll need to re-authenticate{' '} + for security.

} confirm="Sign in again" diff --git a/tests/unit/auth-ory-provider-account.test.ts b/tests/unit/auth-ory-provider-account.test.ts index 2bc109ba9..0e47364a6 100644 --- a/tests/unit/auth-ory-provider-account.test.ts +++ b/tests/unit/auth-ory-provider-account.test.ts @@ -149,6 +149,21 @@ describe('oryAuthProvider account operations', () => { expect(updateUserMock).not.toHaveBeenCalled() }) + it('requires reauth for an email change when auth_time is stale', async () => { + authjsMock.mockResolvedValue({ + user: { id: 'identity-1' }, + accessToken: 'a', + idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }), + }) + + const result = await oryAuthProvider.updateUser({ + email: 'new@example.test', + }) + + expect(result).toEqual({ ok: false, code: 'reauthentication_needed' }) + expect(updateUserMock).not.toHaveBeenCalled() + }) + it('requires reauth for a password change when auth_time is stale', async () => { authjsMock.mockResolvedValue({ user: { id: 'identity-1' }, From fce1ad8512000763905215b6c45fc0712e379d86 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 29 May 2026 13:00:30 -0700 Subject: [PATCH 13/16] chore(repo): mark generated API artifacts --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..71c1aa0de --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +spec/openapi.dashboard-api.yaml linguist-generated=true +src/core/shared/contracts/dashboard-api.types.ts linguist-generated=true From 7ae985c254ea34cd9dacff5b26358d19c6693081 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 29 May 2026 14:38:04 -0700 Subject: [PATCH 14/16] fix(auth): gate account credentials by linked providers --- src/core/server/api/routers/user.ts | 16 +++ src/core/server/auth/ory/find-identity.ts | 45 +++++-- src/core/server/auth/ory/identity.ts | 39 +++++- src/core/server/auth/ory/provider.ts | 113 +++++++++++++++++- src/core/server/auth/supabase/user.ts | 16 ++- src/core/server/auth/types.ts | 2 + .../dashboard/account/email-settings.tsx | 11 +- .../dashboard/account/password-settings.tsx | 14 +-- tests/unit/auth-ory-find-identity.test.ts | 30 +++++ tests/unit/auth-ory-identity.test.ts | 36 ++++++ tests/unit/auth-ory-provider-profile.test.ts | 57 ++++++++- tests/unit/auth-ory-provider.test.ts | 2 + 12 files changed, 349 insertions(+), 32 deletions(-) diff --git a/src/core/server/api/routers/user.ts b/src/core/server/api/routers/user.ts index b6aeb02bb..a9e386a82 100644 --- a/src/core/server/api/routers/user.ts +++ b/src/core/server/api/routers/user.ts @@ -83,6 +83,22 @@ export const userRouter = createTRPCRouter({ } const provider = createAuthForHeaders(ctx.headers) + + if (input.email !== undefined || input.password !== undefined) { + const profile = await provider.getUserProfile() + + if ( + !profile || + (input.email !== undefined && !profile.canChangeEmail) || + (input.password !== undefined && !profile.canChangePassword) + ) { + return { + status: 'error' as const, + code: 'account_credentials_not_changeable' as const, + } + } + } + const result = await provider.updateUser({ email: input.email, password: input.password, diff --git a/src/core/server/auth/ory/find-identity.ts b/src/core/server/auth/ory/find-identity.ts index 36a2acfb1..e89cfcabb 100644 --- a/src/core/server/auth/ory/find-identity.ts +++ b/src/core/server/auth/ory/find-identity.ts @@ -1,6 +1,11 @@ import 'server-only' -import { type Identity, ResponseError } from '@ory/client-fetch' +import { + type GetIdentityByExternalIDIncludeCredentialEnum, + type GetIdentityIncludeCredentialEnum, + type Identity, + ResponseError, +} from '@ory/client-fetch' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import { getOryIdentityApi } from './client' import { readOryError } from './ory-error' @@ -22,8 +27,14 @@ export type ResolveOryIdentityInput = { subjects?: Array // Verified login email — the unambiguous fallback for password identities. email?: string | null + // Optional credential config needed by callers that decide account + // capabilities. Leave unset on hot paths that only need the identity id. + includeCredential?: OryIdentityCredentialInclude[] } +export type OryIdentityCredentialInclude = GetIdentityIncludeCredentialEnum & + GetIdentityByExternalIDIncludeCredentialEnum + export async function resolveOryIdentity( input: ResolveOryIdentityInput ): Promise { @@ -36,12 +47,18 @@ export async function resolveOryIdentity( ] for (const subject of subjects) { - const identity = await findOryIdentityBySubject(subject) + const identity = await findOryIdentityBySubject( + subject, + input.includeCredential + ) if (identity) return identity } if (input.email) { - const identity = await findOryIdentityByEmail(input.email) + const identity = await findOryIdentityByEmail( + input.email, + input.includeCredential + ) if (identity) return identity } @@ -66,12 +83,15 @@ export async function resolveOryIdentity( // logged, and stops the search. The terminal "not found" belongs to // resolveOryIdentity once every strategy is exhausted. export async function findOryIdentityBySubject( - subject: string + subject: string, + includeCredential?: OryIdentityCredentialInclude[] ): Promise { const api = getOryIdentityApi() try { - return await api.getIdentity({ id: subject }) + return await api.getIdentity( + withIncludedCredentials({ id: subject }, includeCredential) + ) } catch (error) { if (!isNotFound(error)) { await logLookupError('by_id', error) @@ -80,7 +100,9 @@ export async function findOryIdentityBySubject( } try { - return await api.getIdentityByExternalID({ externalID: subject }) + return await api.getIdentityByExternalID( + withIncludedCredentials({ externalID: subject }, includeCredential) + ) } catch (error) { if (!isNotFound(error)) { await logLookupError('by_external_id', error) @@ -90,12 +112,14 @@ export async function findOryIdentityBySubject( } export async function findOryIdentityByEmail( - email: string + email: string, + includeCredential?: OryIdentityCredentialInclude[] ): Promise { try { const identities = await getOryIdentityApi().listIdentities({ credentialsIdentifier: email, pageSize: 2, + ...(includeCredential ? { includeCredential } : {}), }) if (identities.length === 0) return null @@ -111,6 +135,13 @@ export async function findOryIdentityByEmail( } } +function withIncludedCredentials>( + params: T, + includeCredential: OryIdentityCredentialInclude[] | undefined +): T & { includeCredential?: OryIdentityCredentialInclude[] } { + return includeCredential ? { ...params, includeCredential } : params +} + function emailTrait(identity: Identity): string | null { const traits = (identity.traits ?? {}) as Record return typeof traits.email === 'string' ? traits.email : null diff --git a/src/core/server/auth/ory/identity.ts b/src/core/server/auth/ory/identity.ts index f934c4acb..bbfc256fe 100644 --- a/src/core/server/auth/ory/identity.ts +++ b/src/core/server/auth/ory/identity.ts @@ -14,6 +14,8 @@ export function fromAuthSession(session: Session): AuthUser { name: session.user.name ?? null, avatarUrl: session.user.image ?? null, providers: [], + canChangeEmail: false, + canChangePassword: false, } } @@ -27,6 +29,11 @@ export function fromOryIdentity(identity: Identity): AuthUser { const avatarUrl = readString(traits, 'picture') ?? readString(traits, 'avatar_url') const providers = normalizeProviders(identity.credentials) + const hasPasswordCredential = hasUsablePasswordCredential( + identity.credentials?.password + ) + const hasOidcCredential = hasLinkedOidcCredential(identity.credentials?.oidc) + const canChangeEmail = hasPasswordCredential && !hasOidcCredential return { id: identity.id, @@ -34,14 +41,15 @@ export function fromOryIdentity(identity: Identity): AuthUser { name, avatarUrl, providers, + canChangeEmail, + canChangePassword: canChangeEmail, } } // Kratos credential keys (`password`, `oidc`, …) don't match the provider // vocabulary the dashboard UI expects (Supabase emits `email` for the -// email/password credential). Map `password` → `email` so the account-settings -// provider gate (`providers.includes('email')`) stays provider-agnostic, while -// preserving other keys like `oidc`. +// email/password credential). Map `password` → `email` for display parity, +// while preserving other keys like `oidc`. function normalizeProviders(credentials: Identity['credentials']): string[] { if (!credentials) return [] @@ -52,6 +60,31 @@ function normalizeProviders(credentials: Identity['credentials']): string[] { return [...new Set(mapped)] } +function hasUsablePasswordCredential( + credential: NonNullable[string] | undefined +): boolean { + const config = credential?.config as Record | undefined + return ( + (typeof config?.hashed_password === 'string' && + config.hashed_password !== '') || + config?.use_password_migration_hook === true + ) +} + +function hasLinkedOidcCredential( + credential: NonNullable[string] | undefined +): boolean { + if (!credential) return false + + if (credential.identifiers && credential.identifiers.length > 0) { + return true + } + + const config = credential.config as Record | undefined + const providers = config?.providers + return Array.isArray(providers) && providers.length > 0 +} + function readString( traits: Record, key: string diff --git a/src/core/server/auth/ory/provider.ts b/src/core/server/auth/ory/provider.ts index 2e0f80afa..e5e9588de 100644 --- a/src/core/server/auth/ory/provider.ts +++ b/src/core/server/auth/ory/provider.ts @@ -1,5 +1,6 @@ import 'server-only' +import type { Identity, IdentityCredentials } from '@ory/client-fetch' import type { Session } from 'next-auth' import { auth as authjs } from '@/auth' import { PROTECTED_URLS } from '@/configs/urls' @@ -13,7 +14,10 @@ import type { UpdateUserResult, } from '../types' import { buildOryStartURL } from './build-start-url' -import { resolveOryIdentity } from './find-identity' +import { + type OryIdentityCredentialInclude, + resolveOryIdentity, +} from './find-identity' import { oryAuthFlows } from './flows' import { isReauthFresh } from './freshness' import { fromAuthSession, fromOryIdentity } from './identity' @@ -23,6 +27,10 @@ import { ORY_SIGN_OUT_FLOW_PATH } from './signout' // Where the account-settings page expects to land after a forced re-auth so it // reveals the password form (matches the Supabase ?reauth=1 contract). const ACCOUNT_SETTINGS_REAUTH_RETURN_TO = `${PROTECTED_URLS.ACCOUNT_SETTINGS}?reauth=1` +const PROFILE_IDENTITY_CREDENTIALS = [ + 'password', + 'oidc', +] satisfies OryIdentityCredentialInclude[] export const oryAuthProvider: AuthProvider = { async getAuthContext() { @@ -62,7 +70,22 @@ export const oryAuthProvider: AuthProvider = { const identity = await resolveOryIdentity({ subjects: [session.identityId, session.user.id], email: session.user.email, + includeCredential: PROFILE_IDENTITY_CREDENTIALS, }) + + l.debug( + { + key: 'auth_provider:ory_get_user_profile:identity', + user_id: session.user.id, + context: { + session_identity_id: session.identityId ?? null, + session_user_email: session.user.email ?? null, + identity: identity ? summarizeIdentityForLog(identity) : null, + }, + }, + 'resolved Ory identity for dashboard user profile' + ) + return identity ? fromOryIdentity(identity) : null }, @@ -150,3 +173,91 @@ async function readSession(): Promise { return null } } + +function summarizeIdentityForLog(identity: Identity) { + const traits = (identity.traits ?? {}) as Record + + return { + id: identity.id, + external_id: identity.external_id ?? null, + schema_id: identity.schema_id, + state: identity.state ?? null, + traits: { + email: readTraitString(traits, 'email'), + name: readTraitString(traits, 'name'), + keys: Object.keys(traits), + }, + credential_keys: Object.keys(identity.credentials ?? {}), + credentials: summarizeCredentialsForLog(identity.credentials), + } +} + +function summarizeCredentialsForLog(credentials: Identity['credentials']) { + if (!credentials) return null + + return { + password_credential: summarizeCredentialForLog(credentials.password), + oidc_credential: summarizeCredentialForLog(credentials.oidc), + other_credential_keys: Object.keys(credentials).filter( + (key) => key !== 'password' && key !== 'oidc' + ), + } +} + +function summarizeCredentialForLog( + credential: IdentityCredentials | undefined +) { + if (!credential) return null + + const config = credential.config as Record | undefined + + return { + type: credential.type ?? null, + identifiers: credential.identifiers ?? [], + has_config: !!config, + config_keys: config ? Object.keys(config) : [], + has_hashed_password: + typeof config?.hashed_password === 'string' && + config.hashed_password !== '', + uses_password_migration_hook: config?.use_password_migration_hook === true, + oidc_providers: readOidcProvidersForLog(config), + } +} + +function readOidcProvidersForLog(config: Record | undefined) { + const providers = config?.providers + if (!Array.isArray(providers)) return [] + + return providers + .filter( + (provider): provider is Record => + provider !== null && typeof provider === 'object' + ) + .map((provider) => ({ + provider: readTraitString(provider, 'provider'), + organization: readTraitString(provider, 'organization'), + has_subject: + typeof provider.subject === 'string' && provider.subject !== '', + use_auto_link: + typeof provider.use_auto_link === 'boolean' + ? provider.use_auto_link + : null, + has_initial_access_token: + typeof provider.initial_access_token === 'string' && + provider.initial_access_token !== '', + has_initial_id_token: + typeof provider.initial_id_token === 'string' && + provider.initial_id_token !== '', + has_initial_refresh_token: + typeof provider.initial_refresh_token === 'string' && + provider.initial_refresh_token !== '', + })) +} + +function readTraitString( + source: Record, + field: string +): string | null { + const value = source[field] + return typeof value === 'string' && value.length > 0 ? value : null +} diff --git a/src/core/server/auth/supabase/user.ts b/src/core/server/auth/supabase/user.ts index a89ff9a64..2801e6b04 100644 --- a/src/core/server/auth/supabase/user.ts +++ b/src/core/server/auth/supabase/user.ts @@ -2,12 +2,17 @@ import type { User } from '@supabase/supabase-js' import type { AuthUser } from '../types' export function toAuthUser(user: User): AuthUser { + const providers = extractProviders(user) + const canChangeEmail = canChangeEmailPasswordSettings(providers) + return { id: user.id, email: user.email ?? null, name: getStringFromMetadata(user.user_metadata, 'name'), avatarUrl: getStringFromMetadata(user.user_metadata, 'avatar_url'), - providers: extractProviders(user), + providers, + canChangeEmail, + canChangePassword: canChangeEmail, } } @@ -34,3 +39,12 @@ function extractProviders(user: User): string[] { return [...new Set([...fromAppMetadata, ...fromIdentities])] } + +function canChangeEmailPasswordSettings(providers: string[]): boolean { + return ( + providers.includes('email') && + providers.every((provider) => { + return provider === 'email' + }) + ) +} diff --git a/src/core/server/auth/types.ts b/src/core/server/auth/types.ts index d956396ba..1c0869e6f 100644 --- a/src/core/server/auth/types.ts +++ b/src/core/server/auth/types.ts @@ -4,6 +4,8 @@ export type AuthUser = { name: string | null avatarUrl: string | null providers: string[] + canChangeEmail: boolean + canChangePassword: boolean } export type AuthContext = { diff --git a/src/features/dashboard/account/email-settings.tsx b/src/features/dashboard/account/email-settings.tsx index 68eae510c..c0e6fb772 100644 --- a/src/features/dashboard/account/email-settings.tsx +++ b/src/features/dashboard/account/email-settings.tsx @@ -3,7 +3,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useSearchParams } from 'next/navigation' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { z } from 'zod' import { USER_MESSAGES } from '@/configs/user-messages' @@ -64,11 +64,6 @@ export function EmailSettings({ className }: EmailSettingsProps) { }, }) - const hasEmailProvider = useMemo( - () => user.providers.includes('email'), - [user] - ) - const { mutate: updateEmail, isPending } = useMutation( trpc.user.update.mutationOptions({ onSuccess: (data) => { @@ -132,9 +127,9 @@ export function EmailSettings({ className }: EmailSettingsProps) { ) ) } - }, [searchParams]) + }, [searchParams, toast]) - if (!user || !hasEmailProvider) return null + if (!user || !user.canChangeEmail) return null return ( <> diff --git a/src/features/dashboard/account/password-settings.tsx b/src/features/dashboard/account/password-settings.tsx index ebe64d419..0423f094c 100644 --- a/src/features/dashboard/account/password-settings.tsx +++ b/src/features/dashboard/account/password-settings.tsx @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useMutation } from '@tanstack/react-query' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { z } from 'zod' import { USER_MESSAGES } from '@/configs/user-messages' @@ -71,11 +71,6 @@ export function PasswordSettings({ setClientShowPasswordForm(showPasswordChangeForm) }, [showPasswordChangeForm]) - const hasEmailProvider = useMemo( - () => user.providers.includes('email'), - [user] - ) - const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { @@ -93,6 +88,11 @@ export function PasswordSettings({ } if (data.status === 'error') { + if (data.code === 'account_credentials_not_changeable') { + toast(defaultErrorToast(USER_MESSAGES.failedUpdatePassword.message)) + return + } + const message = data.code === 'same_password' ? 'New password cannot be the same as the old password.' @@ -121,7 +121,7 @@ export function PasswordSettings({ setReauthDialogOpen(true) } - if (!user || !hasEmailProvider) return null + if (!user || !user.canChangePassword) return null return ( <> diff --git a/tests/unit/auth-ory-find-identity.test.ts b/tests/unit/auth-ory-find-identity.test.ts index 5648f5f19..9971cfac4 100644 --- a/tests/unit/auth-ory-find-identity.test.ts +++ b/tests/unit/auth-ory-find-identity.test.ts @@ -60,6 +60,22 @@ describe('findOryIdentityBySubject', () => { expect(identity).toEqual({ id: 'kratos-uuid' }) }) + it('passes included credential requests through both subject lookup strategies', async () => { + getIdentityMock.mockRejectedValue(notFound()) + getIdentityByExternalIDMock.mockResolvedValue({ id: 'kratos-uuid' }) + + await findOryIdentityBySubject('legacy-id', ['password', 'oidc']) + + expect(getIdentityMock).toHaveBeenCalledWith({ + id: 'legacy-id', + includeCredential: ['password', 'oidc'], + }) + expect(getIdentityByExternalIDMock).toHaveBeenCalledWith({ + externalID: 'legacy-id', + includeCredential: ['password', 'oidc'], + }) + }) + it('returns null without a terminal error log when both miss', async () => { getIdentityMock.mockRejectedValue(notFound()) getIdentityByExternalIDMock.mockRejectedValue(notFound()) @@ -100,6 +116,20 @@ describe('findOryIdentityByEmail', () => { expect(identity).toBeNull() }) + + it('passes included credential requests through email lookups', async () => { + listIdentitiesMock.mockResolvedValue([ + { id: 'match', traits: { email: 'ada@example.test' } }, + ]) + + await findOryIdentityByEmail('ada@example.test', ['password', 'oidc']) + + expect(listIdentitiesMock).toHaveBeenCalledWith({ + credentialsIdentifier: 'ada@example.test', + pageSize: 2, + includeCredential: ['password', 'oidc'], + }) + }) }) describe('resolveOryIdentity', () => { diff --git a/tests/unit/auth-ory-identity.test.ts b/tests/unit/auth-ory-identity.test.ts index 8cea7e7a2..eefda8673 100644 --- a/tests/unit/auth-ory-identity.test.ts +++ b/tests/unit/auth-ory-identity.test.ts @@ -36,6 +36,42 @@ describe('fromOryIdentity providers normalization', () => { }) }) +describe('fromOryIdentity account capabilities', () => { + it('allows email and password changes for password-only identities with password material', () => { + const user = fromOryIdentity( + identity({ + credentials: { + password: { config: { hashed_password: 'hash' } }, + }, + }) + ) + + expect(user.canChangeEmail).toBe(true) + expect(user.canChangePassword).toBe(true) + }) + + it('does not treat a bare password credential key as a usable email/password account', () => { + const user = fromOryIdentity(identity({ credentials: { password: {} } })) + + expect(user.canChangeEmail).toBe(false) + expect(user.canChangePassword).toBe(false) + }) + + it('blocks account credential changes when an oidc credential is linked', () => { + const user = fromOryIdentity( + identity({ + credentials: { + password: { config: { hashed_password: 'hash' } }, + oidc: { identifiers: ['github:123'] }, + }, + }) + ) + + expect(user.canChangeEmail).toBe(false) + expect(user.canChangePassword).toBe(false) + }) +}) + describe('fromOryIdentity traits', () => { it('reads the flat `name` trait (the project schema shape)', () => { const user = fromOryIdentity( diff --git a/tests/unit/auth-ory-provider-profile.test.ts b/tests/unit/auth-ory-provider-profile.test.ts index 435576ec3..6a4b368c5 100644 --- a/tests/unit/auth-ory-provider-profile.test.ts +++ b/tests/unit/auth-ory-provider-profile.test.ts @@ -36,6 +36,7 @@ describe('oryAuthProvider.getUserProfile', () => { getIdentityMock.mockReset() getIdentityByExternalIDMock.mockReset() loggerMocks.error.mockClear() + loggerMocks.debug.mockClear() }) it('returns the normalized profile from the live identity lookup', async () => { @@ -43,18 +44,27 @@ describe('oryAuthProvider.getUserProfile', () => { getIdentityMock.mockResolvedValue({ id: 'identity-1', traits: { email: 'ada@example.test', name: 'Ada' }, - credentials: { password: {} }, + credentials: { + password: { + config: { hashed_password: 'hash' }, + }, + }, }) const profile = await oryAuthProvider.getUserProfile() - expect(getIdentityMock).toHaveBeenCalledWith({ id: 'identity-1' }) + expect(getIdentityMock).toHaveBeenCalledWith({ + id: 'identity-1', + includeCredential: ['password', 'oidc'], + }) expect(profile).toEqual({ id: 'identity-1', email: 'ada@example.test', name: 'Ada', avatarUrl: null, providers: ['email'], + canChangeEmail: true, + canChangePassword: true, }) }) @@ -66,12 +76,19 @@ describe('oryAuthProvider.getUserProfile', () => { getIdentityMock.mockResolvedValue({ id: 'kratos-uuid', traits: { email: 'ada@example.test' }, - credentials: { password: {} }, + credentials: { + password: { + config: { hashed_password: 'hash' }, + }, + }, }) const profile = await oryAuthProvider.getUserProfile() - expect(getIdentityMock).toHaveBeenCalledWith({ id: 'kratos-uuid' }) + expect(getIdentityMock).toHaveBeenCalledWith({ + id: 'kratos-uuid', + includeCredential: ['password', 'oidc'], + }) expect(getIdentityByExternalIDMock).not.toHaveBeenCalled() expect(profile?.id).toBe('kratos-uuid') }) @@ -93,18 +110,48 @@ describe('oryAuthProvider.getUserProfile', () => { getIdentityByExternalIDMock.mockResolvedValue({ id: 'kratos-uuid', traits: { email: 'ada@example.test', name: 'Ada' }, - credentials: { password: {} }, + credentials: { + password: { + config: { hashed_password: 'hash' }, + }, + }, }) const profile = await oryAuthProvider.getUserProfile() expect(getIdentityByExternalIDMock).toHaveBeenCalledWith({ externalID: 'legacy-id', + includeCredential: ['password', 'oidc'], }) expect(profile?.id).toBe('kratos-uuid') expect(profile?.providers).toEqual(['email']) }) + it('does not allow account credential changes for oidc-linked identities', async () => { + authjsMock.mockResolvedValue({ user: { id: 'identity-1' } }) + getIdentityMock.mockResolvedValue({ + id: 'identity-1', + traits: { email: 'ada@example.test', name: 'Ada' }, + credentials: { + password: { + config: { hashed_password: 'hash' }, + }, + oidc: { + identifiers: ['github:123'], + }, + }, + }) + + const profile = await oryAuthProvider.getUserProfile() + + expect(profile).toEqual( + expect.objectContaining({ + canChangeEmail: false, + canChangePassword: false, + }) + ) + }) + it('returns null when neither id nor external_id matches', async () => { authjsMock.mockResolvedValue({ user: { id: 'missing' } }) getIdentityMock.mockRejectedValue( diff --git a/tests/unit/auth-ory-provider.test.ts b/tests/unit/auth-ory-provider.test.ts index 2462fde35..04ea78b25 100644 --- a/tests/unit/auth-ory-provider.test.ts +++ b/tests/unit/auth-ory-provider.test.ts @@ -104,6 +104,8 @@ describe('OryAuthProvider', () => { name: 'Alice', avatarUrl: 'https://example.test/a.png', providers: [], + canChangeEmail: false, + canChangePassword: false, }, accessToken: 'access-token', }) From 645d2aad99cd952c4be399df57a03296300c6d38 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 29 May 2026 15:40:53 -0700 Subject: [PATCH 15/16] fix(auth): disable Ory email changes --- src/core/server/api/routers/user.ts | 8 +++ src/core/server/auth/ory/identity.ts | 8 ++- src/core/server/auth/types.ts | 1 + .../dashboard/account/email-settings.tsx | 39 ++++++++--- tests/unit/auth-ory-identity.test.ts | 4 +- tests/unit/auth-ory-provider-profile.test.ts | 2 +- tests/unit/user-router.test.ts | 64 +++++++++++++++++++ 7 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 tests/unit/user-router.test.ts diff --git a/src/core/server/api/routers/user.ts b/src/core/server/api/routers/user.ts index a9e386a82..c6716dd20 100644 --- a/src/core/server/api/routers/user.ts +++ b/src/core/server/api/routers/user.ts @@ -1,5 +1,6 @@ import { TRPCError } from '@trpc/server' import { z } from 'zod' +import { isOryAuthEnabled } from '@/configs/flags' import type { AuthUser } from '@/core/server/auth' import { createAuthForHeaders } from '@/core/server/auth' import { createTRPCRouter } from '@/core/server/trpc/init' @@ -69,6 +70,13 @@ export const userRouter = createTRPCRouter({ update: protectedProcedure .input(UpdateUserSchema) .mutation(async ({ ctx, input }) => { + if (input.email !== undefined && isOryAuthEnabled()) { + return { + status: 'error' as const, + code: 'account_credentials_not_changeable' as const, + } + } + // Basic security check: a password must not equal the account email // (current or the new one being set in the same request). if (input.password) { diff --git a/src/core/server/auth/ory/identity.ts b/src/core/server/auth/ory/identity.ts index bbfc256fe..5958bb74c 100644 --- a/src/core/server/auth/ory/identity.ts +++ b/src/core/server/auth/ory/identity.ts @@ -33,7 +33,7 @@ export function fromOryIdentity(identity: Identity): AuthUser { identity.credentials?.password ) const hasOidcCredential = hasLinkedOidcCredential(identity.credentials?.oidc) - const canChangeEmail = hasPasswordCredential && !hasOidcCredential + const canChangePassword = hasPasswordCredential && !hasOidcCredential return { id: identity.id, @@ -41,8 +41,10 @@ export function fromOryIdentity(identity: Identity): AuthUser { name, avatarUrl, providers, - canChangeEmail, - canChangePassword: canChangeEmail, + // Email changes are disabled until the custom UI drives Ory's + // settings/verification flows instead of patching traits directly. + canChangeEmail: false, + canChangePassword, } } diff --git a/src/core/server/auth/types.ts b/src/core/server/auth/types.ts index 1c0869e6f..582b5163f 100644 --- a/src/core/server/auth/types.ts +++ b/src/core/server/auth/types.ts @@ -43,6 +43,7 @@ export type UpdateUserErrorCode = | 'weak_password' | 'same_password' | 'reauthentication_needed' + | 'account_credentials_not_changeable' export type UpdateUserResult = | { ok: true; user: AuthUser } diff --git a/src/features/dashboard/account/email-settings.tsx b/src/features/dashboard/account/email-settings.tsx index c0e6fb772..34b6252ec 100644 --- a/src/features/dashboard/account/email-settings.tsx +++ b/src/features/dashboard/account/email-settings.tsx @@ -53,6 +53,9 @@ export function EmailSettings({ className }: EmailSettingsProps) { const trpc = useTRPC() const queryClient = useQueryClient() const [reauthDialogOpen, setReauthDialogOpen] = useState(false) + const showEmailSettings = + Boolean(user?.canChangeEmail) || Boolean(user?.providers.includes('email')) + const canChangeEmail = Boolean(user?.canChangeEmail) const form = useForm({ resolver: zodResolver(formSchema), @@ -92,6 +95,14 @@ export function EmailSettings({ className }: EmailSettingsProps) { return } + if ( + data.status === 'error' && + data.code === 'account_credentials_not_changeable' + ) { + toast(defaultErrorToast('E-mail changes are currently unavailable.')) + return + } + toast(defaultErrorToast('Failed to update e-mail.')) }, onError: () => { @@ -129,21 +140,28 @@ export function EmailSettings({ className }: EmailSettingsProps) { } }, [searchParams, toast]) - if (!user || !user.canChangeEmail) return null + if (!user || !showEmailSettings) return null + + function submitEmailChange(values: FormValues) { + if (!canChangeEmail) return + updateEmail({ email: values.email }) + } return ( <>
- updateEmail({ email: values.email }) - )} + onSubmit={form.handleSubmit(submitEmailChange)} className="w-full" > E-Mail - Update your e-mail address. + + {canChangeEmail + ? 'Update your e-mail address.' + : 'E-mail changes are currently unavailable.'} + @@ -157,6 +175,7 @@ export function EmailSettings({ className }: EmailSettingsProps) { placeholder="E-Mail" className="md:max-w-[17rem]" {...field} + disabled={!canChangeEmail || isPending} /> @@ -171,11 +190,13 @@ export function EmailSettings({ className }: EmailSettingsProps) {

diff --git a/tests/unit/auth-ory-identity.test.ts b/tests/unit/auth-ory-identity.test.ts index eefda8673..4b400b533 100644 --- a/tests/unit/auth-ory-identity.test.ts +++ b/tests/unit/auth-ory-identity.test.ts @@ -37,7 +37,7 @@ describe('fromOryIdentity providers normalization', () => { }) describe('fromOryIdentity account capabilities', () => { - it('allows email and password changes for password-only identities with password material', () => { + it('blocks email changes but allows password changes for password-only identities with password material', () => { const user = fromOryIdentity( identity({ credentials: { @@ -46,7 +46,7 @@ describe('fromOryIdentity account capabilities', () => { }) ) - expect(user.canChangeEmail).toBe(true) + expect(user.canChangeEmail).toBe(false) expect(user.canChangePassword).toBe(true) }) diff --git a/tests/unit/auth-ory-provider-profile.test.ts b/tests/unit/auth-ory-provider-profile.test.ts index 6a4b368c5..e0e20f0ab 100644 --- a/tests/unit/auth-ory-provider-profile.test.ts +++ b/tests/unit/auth-ory-provider-profile.test.ts @@ -63,7 +63,7 @@ describe('oryAuthProvider.getUserProfile', () => { name: 'Ada', avatarUrl: null, providers: ['email'], - canChangeEmail: true, + canChangeEmail: false, canChangePassword: true, }) }) diff --git a/tests/unit/user-router.test.ts b/tests/unit/user-router.test.ts new file mode 100644 index 000000000..8e97a1a20 --- /dev/null +++ b/tests/unit/user-router.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTRPCContext } from '@/core/server/trpc/init' + +const providerMock = vi.hoisted(() => ({ + getAuthContext: vi.fn(), + getUserProfile: vi.fn(), + updateUser: vi.fn(), + signOut: vi.fn(), + startReauthForAccountSettings: vi.fn(), + signOutOtherSessions: vi.fn(), +})) + +vi.mock('@/core/server/auth', () => ({ + createAuthForHeaders: vi.fn(() => providerMock), +})) + +vi.mock('@/lib/utils/server', () => ({ + generateE2BUserAccessToken: vi.fn(), +})) + +const { createCallerFactory } = await import('@/core/server/trpc/init') +const { userRouter } = await import('@/core/server/api/routers/user') + +const createCaller = createCallerFactory(userRouter) + +const authUser = { + id: 'user-1', + email: 'old@example.test', + name: 'Ada', + avatarUrl: null, + providers: ['email'], + canChangeEmail: false, + canChangePassword: true, +} + +describe('userRouter.update', () => { + beforeEach(() => { + vi.stubEnv('AUTH_PROVIDER', 'ory') + providerMock.getAuthContext.mockResolvedValue({ + user: authUser, + accessToken: 'access-token', + }) + providerMock.getUserProfile.mockReset() + providerMock.updateUser.mockReset() + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('denies email changes in Ory mode before updating the provider user', async () => { + const ctx = await createTRPCContext({ headers: new Headers() }) + const caller = createCaller(ctx) + + const result = await caller.update({ email: 'new@example.test' }) + + expect(result).toEqual({ + status: 'error', + code: 'account_credentials_not_changeable', + }) + expect(providerMock.getUserProfile).not.toHaveBeenCalled() + expect(providerMock.updateUser).not.toHaveBeenCalled() + }) +}) From 4c0307c689c20465df6ff36149172e44c114cc20 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Fri, 29 May 2026 16:05:50 -0700 Subject: [PATCH 16/16] fix(teams): allow adding members during auth migration --- src/configs/flags.ts | 7 +++---- src/core/server/api/routers/teams.ts | 9 --------- src/features/dashboard/members/members-page-content.tsx | 3 +-- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/src/configs/flags.ts b/src/configs/flags.ts index bcfd247a4..45ee382b2 100644 --- a/src/configs/flags.ts +++ b/src/configs/flags.ts @@ -30,9 +30,8 @@ export function isOryAuthEnabled() { return process.env.AUTH_PROVIDER === 'ory' } -// freezes user/team membership mutations while we migrate identity stores. -// when on: blocks new sign-ups (email/password + freshly-registered OIDC -// identities) and rejects add-team-member requests. existing users keep -// signing in normally. +// Freezes new identity creation while we migrate identity stores. +// When on: blocks new sign-ups (email/password + freshly-registered OIDC +// identities). Existing users keep signing in normally. export const AUTH_MIGRATION_IN_PROGRESS = process.env.NEXT_PUBLIC_AUTH_MIGRATION_IN_PROGRESS === '1' diff --git a/src/core/server/api/routers/teams.ts b/src/core/server/api/routers/teams.ts index 7267c39ef..f55bbad17 100644 --- a/src/core/server/api/routers/teams.ts +++ b/src/core/server/api/routers/teams.ts @@ -3,7 +3,6 @@ import { fileTypeFromBuffer } from 'file-type' import { revalidatePath } from 'next/cache' import { after } from 'next/server' import { z } from 'zod' -import { AUTH_MIGRATION_IN_PROGRESS } from '@/configs/flags' import { createKeysRepository } from '@/core/modules/keys/repository.server' import { CreateApiKeySchema } from '@/core/modules/keys/schemas' import { @@ -159,14 +158,6 @@ export const teamsRouter = createTRPCRouter({ addMember: teamsRepositoryProcedure .input(AddTeamMemberSchema) .mutation(async ({ ctx, input }) => { - if (AUTH_MIGRATION_IN_PROGRESS) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: - 'Adding team members is temporarily paused while we migrate our authentication system. Please try again later.', - }) - } - const result = await ctx.teamsRepository.addTeamMember(input.email) if (!result.ok) throwTRPCErrorFromRepoError(result.error) diff --git a/src/features/dashboard/members/members-page-content.tsx b/src/features/dashboard/members/members-page-content.tsx index 4e9c776d8..f2bf12cdb 100644 --- a/src/features/dashboard/members/members-page-content.tsx +++ b/src/features/dashboard/members/members-page-content.tsx @@ -2,7 +2,6 @@ import { useSuspenseQuery } from '@tanstack/react-query' import { Suspense, useMemo, useState } from 'react' -import { AUTH_MIGRATION_IN_PROGRESS } from '@/configs/flags' import { useDashboard } from '@/features/dashboard/context' import { cn } from '@/lib/utils' import { pluralize } from '@/lib/utils/formatting' @@ -95,7 +94,7 @@ export const MembersPageContent = ({ className }: MembersPageContentProps) => { value={query} /> - {!AUTH_MIGRATION_IN_PROGRESS && } +