From 77a5a852c19da9926a556fe14b46e59696f79652 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Sun, 24 May 2026 18:04:08 -0700 Subject: [PATCH 1/3] 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 2/3] 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 b20766dcfd971dd0b6e83f717157b09aea0d822b Mon Sep 17 00:00:00 2001 From: Tomas Virgl <739690+tvi@users.noreply.github.com> Date: Sun, 24 May 2026 19:17:13 -0700 Subject: [PATCH 3/3] feat(auth): add local Hydra login/consent/logout provider Wire the dashboard as Hydra's login provider so the OIDC flow can complete end-to-end against a self-hosted Hydra (e.g. ../infra devenv) without requiring a separate IdP UI. - src/app/oauth/login: auto-accept login challenges as ORY_LOCAL_LOGIN_SUBJECT. - src/app/oauth/consent: defensive auto-accept (never hit while the seeded client has skip_consent=true; kept for misconfiguration safety). - src/app/oauth/logout: auto-accept logout challenges. - src/core/server/auth/ory/hydra-admin.ts: OAuth2Api client that targets ORY_HYDRA_ADMIN_URL (self-hosted, no PAT) or ORY_SDK_URL (Ory Network, PAT). - src/lib/env.ts: new optional ORY_HYDRA_ADMIN_URL and ORY_LOCAL_LOGIN_SUBJECT. - package.json: pin 'next dev' to :3001 so it doesn't collide with the infra api on :3000 and matches the seeded client's redirect_uri. Modeled on ory/hydra-login-consent-node. Intended for local/dev only; production deployments delegate login to Ory Network / Kratos. --- bun.lock | 1 + src/app/oauth/consent/route.ts | 76 +++++++++++++++++++++ src/app/oauth/login/route.ts | 91 +++++++++++++++++++++++++ src/app/oauth/logout/route.ts | 49 +++++++++++++ src/core/server/auth/ory/bootstrap.ts | 17 ++++- src/core/server/auth/ory/hydra-admin.ts | 45 ++++++++++++ src/lib/env.ts | 20 ++++++ 7 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 src/app/oauth/consent/route.ts create mode 100644 src/app/oauth/login/route.ts create mode 100644 src/app/oauth/logout/route.ts create mode 100644 src/core/server/auth/ory/hydra-admin.ts diff --git a/bun.lock b/bun.lock index e29e0ffe6..14b5f1e39 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@e2b/dashboard", diff --git a/src/app/oauth/consent/route.ts b/src/app/oauth/consent/route.ts new file mode 100644 index 000000000..f3d477a83 --- /dev/null +++ b/src/app/oauth/consent/route.ts @@ -0,0 +1,76 @@ +import 'server-only' + +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getHydraOAuth2Api } from '@/core/server/auth/ory/hydra-admin' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' + +// Hydra consent-provider endpoint. +// +// In normal operation this handler should never run: the OAuth2 client +// registration sets `skip_consent: true`, which makes Hydra auto-accept +// consent server-side and bypass the browser redirect entirely. We keep +// the handler implemented anyway so: +// - a misconfigured client (no skip_consent) still completes the flow +// instead of dead-ending at 404, +// - operators have a single place to plug in real consent UI later +// without re-shaping route paths. +// +// The implementation grants the full set of scopes Hydra asked for. This +// matches "machine-trusted client" semantics — appropriate for a +// first-party dashboard, never for a third-party app. +export async function GET(request: NextRequest) { + const challenge = request.nextUrl.searchParams.get('consent_challenge') + if (!challenge) { + return new NextResponse('missing consent_challenge', { status: 400 }) + } + + const hydra = getHydraOAuth2Api() + + try { + const consentRequest = await hydra.getOAuth2ConsentRequest({ + consentChallenge: challenge, + }) + + const { redirect_to } = await hydra.acceptOAuth2ConsentRequest({ + consentChallenge: challenge, + acceptOAuth2ConsentRequest: { + // Echo back exactly what Hydra asked for. Granting a superset + // would let a client silently widen its scope on every login. + grant_scope: consentRequest.requested_scope ?? [], + grant_access_token_audience: + consentRequest.requested_access_token_audience ?? [], + // Remember so subsequent flows for the same subject+client skip + // this round-trip; lines up with the 3600s remember_for in the + // login handler. + remember: true, + remember_for: 3600, + }, + }) + + l.info( + { + key: 'oauth_consent:accepted', + context: { + client_id: consentRequest.client?.client_id, + subject: consentRequest.subject, + grant_scope: consentRequest.requested_scope, + }, + }, + 'auto-accepted Hydra consent challenge' + ) + + return NextResponse.redirect(redirect_to) + } catch (error) { + l.error( + { + key: 'oauth_consent:accept_failed', + error: serializeErrorForLog(error), + }, + 'failed to accept Hydra consent challenge' + ) + return new NextResponse('failed to accept consent challenge', { + status: 502, + }) + } +} diff --git a/src/app/oauth/login/route.ts b/src/app/oauth/login/route.ts new file mode 100644 index 000000000..846dfb65f --- /dev/null +++ b/src/app/oauth/login/route.ts @@ -0,0 +1,91 @@ +import 'server-only' + +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getHydraOAuth2Api } from '@/core/server/auth/ory/hydra-admin' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' + +// Hydra login-provider endpoint. +// +// Hydra redirects the browser here with `?login_challenge=...` whenever an +// OAuth2 authorization flow starts and the user is not already +// authenticated against Hydra's *own* session cookie. The handler must: +// 1. fetch the login request from Hydra's admin API (validates the +// challenge and tells us if Hydra already has a session for this +// subject — `skip === true`), +// 2. accept the request with a subject identifier, +// 3. redirect the browser to the URL Hydra returns. +// +// This implementation auto-accepts every challenge as a fixed dev subject +// (`ORY_LOCAL_LOGIN_SUBJECT`). It is intended for local/dev deployments +// only: in production the login UI is owned by a real IdP (Kratos / Ory +// Network) and the dashboard is not registered as Hydra's login provider. +// +// Modeled on ory/hydra-login-consent-node `src/routes/login.ts`. +export async function GET(request: NextRequest) { + const challenge = request.nextUrl.searchParams.get('login_challenge') + if (!challenge) { + return new NextResponse('missing login_challenge', { status: 400 }) + } + + const subject = process.env.ORY_LOCAL_LOGIN_SUBJECT + if (!subject) { + l.error( + { key: 'oauth_login:misconfigured' }, + 'ORY_LOCAL_LOGIN_SUBJECT must be set when the dashboard acts as Hydra login provider' + ) + return new NextResponse('login provider is not configured', { + status: 500, + }) + } + + const hydra = getHydraOAuth2Api() + + try { + // Pre-fetch the login request. We don't strictly need its body to + // accept (the challenge alone is enough), but the round-trip lets us + // surface "challenge expired / not found" as a 404 from Hydra before + // we try to accept it, and gives us `skip` for forward-compat (today + // we accept either way, but logging the branch is useful). + const loginRequest = await hydra.getOAuth2LoginRequest({ + loginChallenge: challenge, + }) + + const { redirect_to } = await hydra.acceptOAuth2LoginRequest({ + loginChallenge: challenge, + acceptOAuth2LoginRequest: { + // Subject == OIDC `sub` claim. Stable per "user" — in this + // single-user dev mode there is only one possible value. + subject, + // Remember the Hydra session for an hour so subsequent OAuth2 + // flows hit the `skip` fast path and don't bounce through this + // handler again until expiry. + remember: true, + remember_for: 3600, + }, + }) + + l.info( + { + key: 'oauth_login:accepted', + context: { + subject, + skip: loginRequest.skip, + client_id: loginRequest.client?.client_id, + }, + }, + 'auto-accepted Hydra login challenge' + ) + + return NextResponse.redirect(redirect_to) + } catch (error) { + l.error( + { + key: 'oauth_login:accept_failed', + error: serializeErrorForLog(error), + }, + 'failed to accept Hydra login challenge' + ) + return new NextResponse('failed to accept login challenge', { status: 502 }) + } +} diff --git a/src/app/oauth/logout/route.ts b/src/app/oauth/logout/route.ts new file mode 100644 index 000000000..d98bb5ba9 --- /dev/null +++ b/src/app/oauth/logout/route.ts @@ -0,0 +1,49 @@ +import 'server-only' + +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getHydraOAuth2Api } from '@/core/server/auth/ory/hydra-admin' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' + +// Hydra logout-provider endpoint. +// +// Hydra redirects the browser here with `?logout_challenge=...` after the +// dashboard initiates RP-initiated logout (POST /oauth2/sessions/logout +// in src/app/api/auth/oauth/signout-flow/route.ts). We accept the +// challenge unconditionally — in single-user dev mode there is no +// "confirm sign out?" UI to show, and the dashboard has already cleared +// its own Auth.js session before redirecting to Hydra. +// +// Modeled on the logout half of ory/hydra-login-consent-node. +export async function GET(request: NextRequest) { + const challenge = request.nextUrl.searchParams.get('logout_challenge') + if (!challenge) { + return new NextResponse('missing logout_challenge', { status: 400 }) + } + + const hydra = getHydraOAuth2Api() + + try { + const { redirect_to } = await hydra.acceptOAuth2LogoutRequest({ + logoutChallenge: challenge, + }) + + l.info( + { key: 'oauth_logout:accepted' }, + 'auto-accepted Hydra logout challenge' + ) + + return NextResponse.redirect(redirect_to) + } catch (error) { + l.error( + { + key: 'oauth_logout:accept_failed', + error: serializeErrorForLog(error), + }, + 'failed to accept Hydra logout challenge' + ) + return new NextResponse('failed to accept logout challenge', { + status: 502, + }) + } +} diff --git a/src/core/server/auth/ory/bootstrap.ts b/src/core/server/auth/ory/bootstrap.ts index 3c9d5657b..e2a18e06d 100644 --- a/src/core/server/auth/ory/bootstrap.ts +++ b/src/core/server/auth/ory/bootstrap.ts @@ -27,11 +27,24 @@ export async function bootstrapOryUser( const accessClaims = decodeJwtClaims(input.accessToken) const idClaims = input.idToken ? decodeJwtClaims(input.idToken) : null const oidcUserId = readRequiredStringClaim(accessClaims, 'sub') + // Local-dev fallback: when self-hosted Hydra is configured with + // `skip_consent: true`, identity claims (email / name) never get a + // chance to be injected — Hydra v2.2 only supports propagating them + // via the consent step's `session.id_token`, which we skip. Fall + // back to ORY_LOCAL_LOGIN_EMAIL / ORY_LOCAL_LOGIN_NAME from the + // environment so bootstrap can still succeed. These env vars are + // intentionally unset in production: real deployments delegate + // login to an IdP that supplies the claims. const oidcUserEmail = readStringClaim(accessClaims, 'email') ?? - readStringClaim(idClaims, 'email') + readStringClaim(idClaims, 'email') ?? + process.env.ORY_LOCAL_LOGIN_EMAIL ?? + null const oidcUserName = - readDisplayName(accessClaims) ?? readDisplayName(idClaims) + readDisplayName(accessClaims) ?? + readDisplayName(idClaims) ?? + process.env.ORY_LOCAL_LOGIN_NAME ?? + null if (!oidcUserId || !oidcUserEmail) { l.error( diff --git a/src/core/server/auth/ory/hydra-admin.ts b/src/core/server/auth/ory/hydra-admin.ts new file mode 100644 index 000000000..94385bae3 --- /dev/null +++ b/src/core/server/auth/ory/hydra-admin.ts @@ -0,0 +1,45 @@ +import 'server-only' + +import { Configuration, OAuth2Api } from '@ory/client-fetch' + +// Hydra's admin API exposes the OAuth2 login/consent/logout flow endpoints +// (acceptOAuth2LoginRequest, etc.) that any login-provider must call to +// complete a challenge. This is a *different* surface from the IdentityApi +// in client.ts: +// - IdentityApi talks to Ory Network's identity admin (gated by a PAT). +// - OAuth2Api talks to Hydra's admin endpoints — when self-hosting Hydra +// these are unauthenticated on a private network (`:4445` in our +// devenv); on Ory Network they ride the same PAT. +// +// We therefore resolve the admin URL with the following precedence: +// 1. ORY_HYDRA_ADMIN_URL (explicit; set for self-hosted Hydra where +// admin is on a different port from the public SDK). +// 2. ORY_SDK_URL (Ory Network: admin === public). +// +// The PAT is attached *only* when one is configured (ORY_PROJECT_API_TOKEN), +// matching the IdentityApi behaviour. Local-dev Hydra ignores it; Ory +// Network requires it. Same code path, different deploy targets. + +let cached: OAuth2Api | null = null + +export function getHydraOAuth2Api(): OAuth2Api { + if (cached) return cached + + const basePath = + process.env.ORY_HYDRA_ADMIN_URL ?? process.env.ORY_SDK_URL ?? null + if (!basePath) { + throw new Error( + 'Neither ORY_HYDRA_ADMIN_URL nor ORY_SDK_URL is configured' + ) + } + + const accessToken = process.env.ORY_PROJECT_API_TOKEN + + cached = new OAuth2Api( + new Configuration({ + basePath: basePath.replace(/\/$/, ''), + ...(accessToken ? { accessToken } : {}), + }) + ) + return cached +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 0034e4b82..577e62771 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -18,6 +18,26 @@ export const serverSchema = z.object({ AUTH_SECRET: z.string().min(1).optional(), AUTH_TRUST_HOST: z.string().optional(), ORY_SDK_URL: z.url().optional(), + // Hydra's admin API. In Ory Network this is identical to ORY_SDK_URL + // (admin is on the same host, gated by ORY_PROJECT_API_TOKEN). In a + // self-hosted Hydra (e.g. local devenv) the admin port is different + // (4445 vs 4444), so we need a separate URL. When unset, callers fall + // back to ORY_SDK_URL. + ORY_HYDRA_ADMIN_URL: z.url().optional(), + // Subject used to auto-accept Hydra login challenges in local/dev setups + // where the dashboard is configured to act as both the OIDC client AND a + // single-user login provider. Leave unset in production: production + // deployments delegate the login UI to a real IdP (Kratos / Ory + // Network) and never auto-accept. + ORY_LOCAL_LOGIN_SUBJECT: z.string().min(1).optional(), + // Fallback identity claims used by bootstrapOryUser when the JWT + // access token does not carry email / name claims. Self-hosted Hydra + // with skip_consent=true cannot inject identity claims, so we supply + // them from the environment instead. Production must leave these + // unset — bootstrap will then refuse to run if the IdP doesn't + // supply the claims itself, which is the safer default. + ORY_LOCAL_LOGIN_EMAIL: z.email().optional(), + ORY_LOCAL_LOGIN_NAME: z.string().min(1).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(),