diff --git a/.env.example b/.env.example
index ec4447f9c..970fef612 100644
--- a/.env.example
+++ b/.env.example
@@ -24,8 +24,28 @@ 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=
+### Dashboard API admin token used to bootstrap newly signed-in Ory users
+# DASHBOARD_API_ADMIN_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
+
+### Legacy Supabase bootstrap fallback used by dashboard route team resolution.
+### Ory sign-in bootstrap does not depend on this flag.
+# ENABLE_USER_BOOTSTRAP=0
### 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 cf993b3b5..dbe1fe3fd 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.7",
+ "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=="],
@@ -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.7", "", { "dependencies": { "@next/env": "16.2.7", "@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.7", "@next/swc-darwin-x64": "16.2.7", "@next/swc-linux-arm64-gnu": "16.2.7", "@next/swc-linux-arm64-musl": "16.2.7", "@next/swc-linux-x64-gnu": "16.2.7", "@next/swc-linux-x64-musl": "16.2.7", "@next/swc-win32-arm64-msvc": "16.2.7", "@next/swc-win32-x64-msvc": "16.2.7", "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-eMJxgjRzBaj3olkP4cBamHDXL79A8FC6u1GcsO1D1Tsx8bw/LLXUJCaoajVxtnhD3A1IJqIT8IcRJjgBIPJq4w=="],
+ "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 db2e0ff78..c44936273 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.7",
+ "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/scripts/check-app-env.ts b/scripts/check-app-env.ts
index 4b384075e..8bbabad7b 100644
--- a/scripts/check-app-env.ts
+++ b/scripts/check-app-env.ts
@@ -58,5 +58,25 @@ const schema = serverSchema
path: ['PLAIN_API_KEY'],
}
)
+ .refine(
+ (data) => {
+ if (data.AUTH_PROVIDER !== 'ory') return true
+
+ return Boolean(
+ data.AUTH_SECRET &&
+ data.ORY_SDK_URL &&
+ data.ORY_OAUTH2_CLIENT_ID &&
+ data.ORY_OAUTH2_CLIENT_SECRET &&
+ data.ORY_OAUTH2_AUDIENCE &&
+ data.ORY_PROJECT_API_TOKEN &&
+ data.DASHBOARD_API_ADMIN_TOKEN
+ )
+ },
+ {
+ message:
+ 'AUTH_PROVIDER=ory requires AUTH_SECRET, ORY_SDK_URL, ORY_OAUTH2_CLIENT_ID, ORY_OAUTH2_CLIENT_SECRET, ORY_OAUTH2_AUDIENCE, ORY_PROJECT_API_TOKEN, and DASHBOARD_API_ADMIN_TOKEN',
+ path: ['AUTH_PROVIDER'],
+ }
+ )
validateEnv(schema)
diff --git a/src/app/(auth)/sign-in/login-form.tsx b/src/app/(auth)/sign-in/login-form.tsx
index af160fad8..44e5093b6 100644
--- a/src/app/(auth)/sign-in/login-form.tsx
+++ b/src/app/(auth)/sign-in/login-form.tsx
@@ -85,6 +85,18 @@ export default function Login() {
window.location.href = `${AUTH_URLS.FORGOT_PASSWORD}?${params.toString()}`
}
+ if (AUTH_MIGRATION_IN_PROGRESS) {
+ return (
+
+
Sign in
+
+ Sign-ups and sign-ins are temporarily paused while we migrate our
+ authentication system. Please try again later.
+
+
+ )
+ }
+
return (
Sign in
@@ -153,12 +165,6 @@ export default function Login() {
diff --git a/src/app/api/auth/oauth-recover/route.ts b/src/app/api/auth/oauth-recover/route.ts
new file mode 100644
index 000000000..bb12c75b5
--- /dev/null
+++ b/src/app/api/auth/oauth-recover/route.ts
@@ -0,0 +1,48 @@
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import { AUTH_URLS } from '@/configs/urls'
+import { l } from '@/core/shared/clients/logger/logger'
+
+// Auth.js renders its built-in `${basePath}/error` page when something fails
+// during the OAuth dance (most commonly a stale state/PKCE/nonce cookie that
+// expired while the user lingered on the Ory hosted UI). We point
+// `pages.error` here so the user never sees that page - we log the failure
+// for observability and bounce them back to /sign-in, which restarts the
+// flow with fresh cookies via the middleware -> oauth-start chain.
+//
+// A short-lived cookie prevents tight loops when the underlying failure is
+// genuinely persistent (e.g. ORY_SDK_URL misconfigured). After one recovery
+// attempt in the window, subsequent failures fall back to the marketing
+// root so the user isn't bounced indefinitely.
+const RECOVERY_COOKIE = 'auth_recover_attempted'
+const RECOVERY_COOKIE_MAX_AGE_SECONDS = 30
+
+export async function GET(request: NextRequest) {
+ const errorCode = request.nextUrl.searchParams.get('error') ?? 'unknown'
+ const alreadyAttempted = request.cookies.get(RECOVERY_COOKIE)?.value === '1'
+
+ l.error(
+ {
+ key: 'oauth_recover:auth_js_error',
+ context: { error_code: errorCode, already_attempted: alreadyAttempted },
+ },
+ 'Auth.js OAuth flow failed; recovering user'
+ )
+
+ const destination = alreadyAttempted ? '/' : AUTH_URLS.SIGN_IN
+ const response = NextResponse.redirect(new URL(destination, request.url))
+
+ if (alreadyAttempted) {
+ response.cookies.delete(RECOVERY_COOKIE)
+ } else {
+ response.cookies.set(RECOVERY_COOKIE, '1', {
+ maxAge: RECOVERY_COOKIE_MAX_AGE_SECONDS,
+ httpOnly: true,
+ sameSite: 'lax',
+ path: '/',
+ secure: process.env.NODE_ENV === 'production',
+ })
+ }
+
+ return response
+}
diff --git a/src/app/api/auth/oauth-start/route.ts b/src/app/api/auth/oauth-start/route.ts
new file mode 100644
index 000000000..7ddb595a5
--- /dev/null
+++ b/src/app/api/auth/oauth-start/route.ts
@@ -0,0 +1,41 @@
+import { signIn } from '@/auth'
+import { normalizeOryReturnTo } from '@/core/server/auth/ory/build-start-url'
+import {
+ readOrySignupMetadataFromHeaders,
+ setOrySignupMetadataCookie,
+} from '@/core/server/auth/ory/signup-metadata'
+
+// Server-side entry point for the Ory OAuth2 flow. Pages redirect here
+// instead of rendering a client-side form so that Auth.js can set its
+// state/PKCE cookies (only allowed in route handlers / server actions
+// / middleware) without any client JS in the loop.
+//
+// `intent=signup` forwards `prompt=registration` to Hydra, which routes
+// to its registration UI (`urls.registration`, default `/ui/registration`)
+// instead of the login UI.
+//
+// `intent=reauth` forwards `prompt=login`, forcing Hydra to redo the login
+// flow even with an active session so we get a fresh `auth_time`. Used to
+// re-authenticate before sensitive account changes (password).
+// https://www.ory.com/docs/oauth2-oidc/authorization-code-flow
+export async function GET(request: Request) {
+ const url = new URL(request.url)
+ const intent = url.searchParams.get('intent')
+ const redirectTo =
+ normalizeOryReturnTo(url.searchParams.get('returnTo')) ?? '/dashboard'
+
+ const authorizationParams =
+ intent === 'signup'
+ ? { prompt: 'registration' }
+ : intent === 'reauth'
+ ? { prompt: 'login' }
+ : undefined
+
+ if (intent === 'signup') {
+ await setOrySignupMetadataCookie(
+ readOrySignupMetadataFromHeaders(request.headers)
+ )
+ }
+
+ await signIn('ory', { redirectTo }, authorizationParams)
+}
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/bootstrap-failed/route.ts b/src/app/api/auth/oauth/bootstrap-failed/route.ts
new file mode 100644
index 000000000..fc0b5231b
--- /dev/null
+++ b/src/app/api/auth/oauth/bootstrap-failed/route.ts
@@ -0,0 +1,51 @@
+import 'server-only'
+
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import { signOut } from '@/auth'
+import {
+ buildOryLogoutUrl,
+ ORY_BOOTSTRAP_FAILURE_ID_TOKEN_COOKIE,
+ ORY_POST_LOGOUT_PATH,
+} from '@/core/server/auth/ory/signout'
+import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
+
+export async function GET(request: NextRequest) {
+ const origin = request.nextUrl.origin
+ const idToken = request.cookies.get(
+ ORY_BOOTSTRAP_FAILURE_ID_TOKEN_COOKIE
+ )?.value
+
+ try {
+ await signOut({ redirect: false })
+ } catch (error) {
+ l.warn(
+ {
+ key: 'oauth_bootstrap_failed:authjs_sign_out:error',
+ error: serializeErrorForLog(error),
+ },
+ 'Auth.js signOut() failed after Ory bootstrap failure'
+ )
+ }
+
+ const logoutUrl = idToken ? buildOryLogoutUrl({ idToken, origin }) : null
+
+ if (!logoutUrl) {
+ l.error(
+ {
+ key: 'oauth_bootstrap_failed:missing_logout_context',
+ context: {
+ has_id_token: !!idToken,
+ has_ory_sdk_url: !!process.env.ORY_SDK_URL,
+ },
+ },
+ 'Could not perform Ory logout after bootstrap failure'
+ )
+ }
+
+ const response = NextResponse.redirect(
+ logoutUrl ?? new URL(ORY_POST_LOGOUT_PATH, origin)
+ )
+ response.cookies.delete(ORY_BOOTSTRAP_FAILURE_ID_TOKEN_COOKIE)
+ return response
+}
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..f243882dd
--- /dev/null
+++ b/src/app/api/auth/oauth/signout-flow/route.ts
@@ -0,0 +1,57 @@
+import 'server-only'
+
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import { auth, signOut } from '@/auth'
+import { revokeKratosSessionsForIdentity } from '@/core/server/auth/ory/kratos-session'
+import {
+ buildOryLogoutUrl,
+ ORY_POST_LOGOUT_PATH,
+} from '@/core/server/auth/ory/signout'
+import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
+
+export async function GET(request: NextRequest) {
+ const origin = request.nextUrl.origin
+ const postLogoutUrl = new URL(ORY_POST_LOGOUT_PATH, origin)
+
+ let idToken: string | undefined
+ let identityId: string | undefined
+ try {
+ const session = await auth()
+ idToken = session?.idToken
+ // The Kratos identity id resolved at sign-in — NOT the OIDC subject (which
+ // is the E2B user id) — so we revoke the right identity's Kratos sessions.
+ identityId = session?.identityId
+ } catch (error) {
+ l.warn(
+ {
+ 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'
+ )
+ }
+
+ if (identityId) {
+ await revokeKratosSessionsForIdentity(identityId)
+ }
+
+ const logoutUrl = idToken ? buildOryLogoutUrl({ idToken, origin }) : null
+ if (!logoutUrl) {
+ return NextResponse.redirect(postLogoutUrl)
+ }
+
+ return NextResponse.redirect(logoutUrl.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..de1f8ec2d 100644
--- a/src/app/dashboard/account/route.ts
+++ b/src/app/dashboard/account/route.ts
@@ -1,7 +1,9 @@
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 { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team'
+import { l } from '@/core/shared/clients/logger/logger'
import { encodedRedirect } from '@/lib/utils/auth'
import { setTeamCookies } from '@/lib/utils/cookies'
@@ -18,15 +20,27 @@ export async function GET(request: NextRequest) {
)
if (!team) {
- await auth.signOut()
+ l.warn(
+ {
+ key: 'dashboard_account:no_personal_team',
+ user_id: authContext.user.id,
+ },
+ 'no personal team for user, signing out'
+ )
- const signInUrl = new URL(AUTH_URLS.SIGN_IN, request.url)
+ const { redirectTo } = await auth.signOut()
- return encodedRedirect(
- 'error',
- signInUrl.toString(),
- 'No personal team found. Please contact support.'
- )
+ if (!isOryAuthEnabled()) {
+ const signInUrl = new URL(AUTH_URLS.SIGN_IN, request.url)
+
+ return encodedRedirect(
+ 'error',
+ signInUrl.toString(),
+ 'No personal team found. Please contact support.'
+ )
+ }
+
+ return NextResponse.redirect(new URL(redirectTo, request.url))
}
await setTeamCookies(team.id, team.slug)
diff --git a/src/app/dashboard/route.ts b/src/app/dashboard/route.ts
index a44259813..5419bf132 100644
--- a/src/app/dashboard/route.ts
+++ b/src/app/dashboard/route.ts
@@ -1,8 +1,10 @@
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 { resolveUserTeam } from '@/core/server/functions/team/resolve-user-team'
+import { l } from '@/core/shared/clients/logger/logger'
import { encodedRedirect } from '@/lib/utils/auth'
import { setTeamCookies } from '@/lib/utils/cookies'
@@ -34,15 +36,27 @@ export async function GET(request: NextRequest) {
)
if (!team) {
- await auth.signOut()
+ l.warn(
+ {
+ key: 'dashboard:no_personal_team',
+ user_id: authContext.user.id,
+ },
+ 'no personal team for user, signing out'
+ )
- const signInUrl = new URL(AUTH_URLS.SIGN_IN, request.url)
+ const { redirectTo } = await auth.signOut()
- return encodedRedirect(
- 'error',
- signInUrl.toString(),
- 'No personal team found. Please contact support.'
- )
+ if (!isOryAuthEnabled()) {
+ const signInUrl = new URL(AUTH_URLS.SIGN_IN, request.url)
+
+ return encodedRedirect(
+ 'error',
+ signInUrl.toString(),
+ 'No personal team found. Please contact support.'
+ )
+ }
+
+ return NextResponse.redirect(new URL(redirectTo, request.url))
}
await setTeamCookies(team.id, team.slug)
diff --git a/src/app/dashboard/terminal/page.tsx b/src/app/dashboard/terminal/page.tsx
index 27059d7e0..a7347c227 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'
@@ -185,7 +185,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..b09d53fb1
--- /dev/null
+++ b/src/auth.ts
@@ -0,0 +1,44 @@
+import NextAuth from 'next-auth'
+import OryHydra from 'next-auth/providers/ory-hydra'
+import {
+ handleOryAuthJsSignIn,
+ persistOryTokensInAuthJsJwt,
+ projectOryJwtToAuthJsSession,
+} from '@/core/server/auth/ory/authjs-callbacks'
+
+const oryOAuth2Audience = process.env.ORY_OAUTH2_AUDIENCE
+
+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' },
+ // route handler that logs the failure and redirects to /sign-in so users
+ // never see Auth.js's built-in error page; see oauth-recover/route.ts.
+ pages: {
+ error: '/api/auth/oauth-recover',
+ },
+ providers: [
+ OryHydra({
+ id: 'ory',
+ name: 'Ory',
+ issuer: process.env.ORY_SDK_URL,
+ clientId: process.env.ORY_OAUTH2_CLIENT_ID,
+ clientSecret: process.env.ORY_OAUTH2_CLIENT_SECRET,
+ authorization: {
+ params: {
+ scope: 'openid offline_access email profile',
+ ...(oryOAuth2Audience ? { audience: oryOAuth2Audience } : {}),
+ },
+ },
+ checks: ['state'],
+ }),
+ ],
+ callbacks: {
+ signIn: ({ account }) => handleOryAuthJsSignIn({ account }),
+ jwt: ({ token, account, profile }) =>
+ persistOryTokensInAuthJsJwt({ token, account, profile }),
+ session: ({ session, token }) =>
+ projectOryJwtToAuthJsSession({ session, token }),
+ },
+})
diff --git a/src/configs/api.ts b/src/configs/api.ts
index 226386aef..05a643404 100644
--- a/src/configs/api.ts
+++ b/src/configs/api.ts
@@ -1,14 +1,42 @@
+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 }),
-})
+type AuthHeaderStrategy = {
+ tokenHeader: string
+ tokenPrefix: string
+ teamHeader: string
+}
+
+const oryHeaderStrategy: AuthHeaderStrategy = {
+ tokenHeader: 'Authorization',
+ tokenPrefix: 'Bearer ',
+ teamHeader: AUTH_PROVIDER_TEAM_HEADER,
+}
+
+const supabaseHeaderStrategy: AuthHeaderStrategy = {
+ tokenHeader: SUPABASE_TOKEN_HEADER,
+ tokenPrefix: '',
+ teamHeader: SUPABASE_TEAM_HEADER,
+}
+
+export function authHeaders(
+ token: string,
+ teamId?: string
+): Record
{
+ const s = isOryAuthEnabled() ? oryHeaderStrategy : supabaseHeaderStrategy
+ const headers: Record = {
+ [s.tokenHeader]: `${s.tokenPrefix}${token}`,
+ }
+ if (teamId) headers[s.teamHeader] = 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 5840d76a0..b8286248e 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 { api } from '@/core/shared/clients/api'
import { createRepoError, repoErrorFromHttp } from '@/core/shared/errors'
@@ -10,7 +10,7 @@ import type { TeamMember } from './models'
type TeamsRepositoryDeps = {
apiClient: typeof api
- authHeaders: typeof SUPABASE_AUTH_HEADERS
+ authHeaders: typeof authHeaders
}
export type TeamsRequestScope = RequestScope & {
@@ -47,7 +47,7 @@ export function createTeamsRepository(
scope: TeamsRequestScope,
deps: TeamsRepositoryDeps = {
apiClient: api,
- authHeaders: SUPABASE_AUTH_HEADERS,
+ authHeaders: authHeaders,
}
): TeamsRepository {
return {
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 66c6c25d5..b9c0abf02 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 type { UpsertWebhookInput } from '@/core/server/functions/webhooks/schema'
import { infra } from '@/core/shared/clients/api'
import type { components as ArgusComponents } from '@/core/shared/contracts/argus-api.types'
@@ -10,7 +10,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
@@ -31,7 +31,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/sandbox-actions.ts b/src/core/server/actions/sandbox-actions.ts
index b0c35cb27..59c719307 100644
--- a/src/core/server/actions/sandbox-actions.ts
+++ b/src/core/server/actions/sandbox-actions.ts
@@ -1,9 +1,7 @@
'use server'
-import { updateTag } from 'next/cache'
import { z } from 'zod'
-import { SUPABASE_AUTH_HEADERS } from '@/configs/api'
-import { CACHE_TAGS } from '@/configs/cache'
+import { authHeaders } from '@/configs/api'
import {
authActionClient,
withTeamSlugResolution,
@@ -28,7 +26,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 a9b3fd128..26e54de84 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..d8c1c4df3 100644
--- a/src/core/server/auth/ory/admin.ts
+++ b/src/core/server/auth/ory/admin.ts
@@ -1,14 +1,91 @@
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 {
+ ACCOUNT_IDENTITY_CREDENTIALS,
+ findOryIdentityBySubject,
+} from './find-identity'
+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 findOryIdentityBySubject(
+ userId,
+ ACCOUNT_IDENTITY_CREDENTIALS
+ )
+ return identity ? fromOryIdentity(identity, { userId }) : null
+ } 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)
+ }
+ }
+
+ for (const userId of uniqueIds) {
+ if (result.has(userId)) continue
+
+ const identity = await findOryIdentityBySubject(userId)
+ if (!identity) continue
+
+ const { email } = fromOryIdentity(identity, { userId })
+ result.set(userId, 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/auth-route-redirect.ts b/src/core/server/auth/ory/auth-route-redirect.ts
new file mode 100644
index 000000000..e5c1b77f2
--- /dev/null
+++ b/src/core/server/auth/ory/auth-route-redirect.ts
@@ -0,0 +1,30 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { PROTECTED_URLS } from '@/configs/urls'
+import { buildOryStartURL, type OryAuthIntent } from './build-start-url'
+
+// Map each legacy auth page to the intent we want the Ory hosted UI to
+// open with. Done at the middleware layer so the (auth) layout never
+// renders in Ory mode - otherwise the user briefly sees the auth shell
+// before the page-level redirect kicks in.
+const INTENT_BY_PATH: Record = {
+ '/sign-in': 'signin',
+ '/sign-up': 'signup',
+ '/forgot-password': 'signin',
+}
+
+export function getOryAuthRouteRedirect(
+ request: NextRequest,
+ isAuthenticated = false
+): NextResponse | null {
+ const intent = INTENT_BY_PATH[request.nextUrl.pathname]
+ if (!intent) return null
+
+ if (isAuthenticated) {
+ return NextResponse.redirect(new URL(PROTECTED_URLS.DASHBOARD, request.url))
+ }
+
+ const returnTo = request.nextUrl.searchParams.get('returnTo') ?? undefined
+ const target = new URL(buildOryStartURL(intent, returnTo), request.url)
+
+ return NextResponse.redirect(target)
+}
diff --git a/src/core/server/auth/ory/authjs-boundary.ts b/src/core/server/auth/ory/authjs-boundary.ts
new file mode 100644
index 000000000..b8b4287f6
--- /dev/null
+++ b/src/core/server/auth/ory/authjs-boundary.ts
@@ -0,0 +1,105 @@
+import 'server-only'
+
+import type { Account, Profile, Session } from 'next-auth'
+import type { JWT } from 'next-auth/jwt'
+import { decodeJwtClaims, readStringClaim } from './jwt-claims'
+
+/**
+ * Auth.js uses OAuth/OIDC-generic names. In this adapter those names mean:
+ *
+ * - `account`: Ory OAuth2 token endpoint response. This is where Auth.js gives
+ * us the Ory access/id/refresh tokens.
+ * - `profile`: OIDC profile claims decoded by Auth.js from the id_token and/or
+ * userinfo response.
+ * - `user`: Auth.js's synthetic user derived from the OIDC profile. It is not
+ * the dashboard AuthUser and not the Kratos Identity.
+ * - `token`: Auth.js encrypted JWT session-cookie payload. We persist selected
+ * Ory token fields there, then project them onto `session`.
+ */
+
+export type OryAuthJsAccount = Account & {
+ provider: 'ory'
+ type: 'oidc'
+ access_token: string
+ id_token?: string
+ refresh_token?: string
+ expires_at?: number
+}
+
+export type OryAuthJsProfile = Profile & {
+ // OIDC subject from id_token/userinfo. In our Ory project this may be the
+ // Kratos identity id, while Auth.js `token.sub` is the dashboard/E2B user id.
+ sub?: string | null
+ email?: string | null
+ name?: string | null
+}
+
+export type OryAuthJsJwt = JWT & {
+ // Ory access token forwarded to dashboard-api/infra.
+ accessToken?: string
+ // Ory refresh token used by refreshOryToken.
+ refreshToken?: string
+ // Ory ID token used for re-auth freshness and RP-initiated logout.
+ idToken?: string
+ // Kratos identity id resolved at sign-in for admin IdentityApi operations.
+ identityId?: string
+ // Auth.js absolute expiration timestamp, in seconds.
+ expiresAt?: number | null
+ error?: string
+}
+
+export type OryAuthJsSignInInput = {
+ account?: Account | null
+}
+
+export type OryAuthJsJwtInput = {
+ token: OryAuthJsJwt
+ account?: Account | null
+ profile?: OryAuthJsProfile
+}
+
+export type OryAuthJsSessionInput = {
+ session: Session
+ token: OryAuthJsJwt
+}
+
+export function readOryAuthJsAccount(
+ account?: Account | null
+): OryAuthJsAccount | null {
+ if (
+ account?.provider !== 'ory' ||
+ account.type !== 'oidc' ||
+ typeof account.access_token !== 'string' ||
+ account.access_token.length === 0
+ ) {
+ return null
+ }
+
+ return account as OryAuthJsAccount
+}
+
+export function readOryProfileSubject(
+ profile?: OryAuthJsProfile
+): string | undefined {
+ const subject = profile?.sub
+ return typeof subject === 'string' && subject.length > 0 ? subject : undefined
+}
+
+export function readOryAccessTokenSubject(
+ account: OryAuthJsAccount
+): string | undefined {
+ return (
+ readStringClaim(decodeJwtClaims(account.access_token), 'sub') ?? undefined
+ )
+}
+
+export function readOryEmailClaim(
+ account: OryAuthJsAccount
+): string | undefined {
+ for (const jwt of [account.id_token, account.access_token]) {
+ if (typeof jwt !== 'string') continue
+ const email = readStringClaim(decodeJwtClaims(jwt), 'email')
+ if (email) return email
+ }
+ return undefined
+}
diff --git a/src/core/server/auth/ory/authjs-callbacks.ts b/src/core/server/auth/ory/authjs-callbacks.ts
new file mode 100644
index 000000000..11c8ed9ce
--- /dev/null
+++ b/src/core/server/auth/ory/authjs-callbacks.ts
@@ -0,0 +1,211 @@
+import 'server-only'
+
+import { cookies } from 'next/headers'
+import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
+import {
+ type OryAuthJsAccount,
+ type OryAuthJsJwt,
+ type OryAuthJsJwtInput,
+ type OryAuthJsSessionInput,
+ type OryAuthJsSignInInput,
+ readOryAccessTokenSubject,
+ readOryAuthJsAccount,
+ readOryEmailClaim,
+ readOryProfileSubject,
+} from './authjs-boundary'
+import { ensureOryUserBootstrapped } from './dashboard-bootstrap'
+import { resolveOryIdentity } from './find-identity'
+import { refreshOryToken } from './refresh-token'
+import {
+ ORY_BOOTSTRAP_FAILURE_FLOW_PATH,
+ ORY_BOOTSTRAP_FAILURE_ID_TOKEN_COOKIE,
+} from './signout'
+import { persistOrySignupMetadataFromCookie } from './signup-metadata'
+
+/**
+ * Auth.js <-> Ory data flow:
+ *
+ * signIn callback:
+ * `account` is the OAuth token endpoint response (access/id/refresh tokens).
+ * `profile` is OIDC claims from the id_token/userinfo response.
+ * `user` is Auth.js's synthetic profile user, not our AuthUser/Kratos Identity.
+ *
+ * jwt callback:
+ * Persists selected Ory token fields into Auth.js's encrypted JWT cookie.
+ *
+ * session callback:
+ * Projects those fields from the JWT cookie onto the Session object consumed
+ * by our AuthProvider. Live Kratos traits/credentials are fetched separately
+ * through getUserProfile().
+ */
+
+// Refresh the access token slightly before it actually expires so we never hand
+// a token that dies mid-request to downstream APIs.
+const ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 60
+
+const BOOTSTRAP_FAILURE_COOKIE_MAX_AGE_SECONDS = 60
+
+// Implements the Auth.js `signIn` callback. This is intentionally a callback,
+// not an event: returning a URL here denies the sign-in before Auth.js finalizes
+// the new session cookie. On failure, we hand the id_token to a local route via
+// a short-lived httpOnly cookie so that route can perform Ory RP-initiated
+// logout in the browser.
+export async function handleOryAuthJsSignIn(
+ params: OryAuthJsSignInInput
+): Promise {
+ const account = readOryAuthJsAccount(params.account)
+
+ if (!account) {
+ l.error(
+ {
+ key: 'auth_callbacks:sign_in:missing_access_token',
+ context: { provider: params.account?.provider ?? null },
+ },
+ 'Ory sign-in missing access token; denying sign-in'
+ )
+ return prepareBootstrapFailureRedirect(params.account)
+ }
+
+ const bootstrapped = await ensureOryUserBootstrapped({
+ accessToken: account.access_token,
+ idToken: account.id_token,
+ provider: account.provider,
+ })
+
+ if (bootstrapped) return true
+
+ l.error(
+ {
+ key: 'auth_callbacks:sign_in:bootstrap_failed',
+ context: { provider: account.provider },
+ },
+ 'Ory user bootstrap could not be confirmed; denying sign-in'
+ )
+ return prepareBootstrapFailureRedirect(account)
+}
+
+// Implements the Auth.js `jwt` callback: mint the token on fresh sign-in,
+// otherwise refresh it as it nears expiry.
+export async function persistOryTokensInAuthJsJwt(
+ params: OryAuthJsJwtInput
+): Promise {
+ const { token, account, profile } = params
+
+ if (account) {
+ const oryAccount = readOryAuthJsAccount(account)
+ if (!oryAccount) {
+ return { ...token, error: 'InvalidOryAccount' }
+ }
+
+ return buildSignInToken(token, oryAccount, profile)
+ }
+
+ // Once a refresh has failed we stop retrying. The dead token (cleared
+ // access/refresh) propagates to the session, oryAuthProvider returns null,
+ // and the proxy redirects to /sign-in.
+ if (token.error) {
+ return token
+ }
+
+ if (isAccessTokenExpiring(token)) {
+ return refreshOryToken(token)
+ }
+
+ return token
+}
+
+// Implements the Auth.js `session` callback: project the persisted token fields
+// onto the session the rest of the app reads.
+export function projectOryJwtToAuthJsSession({
+ session,
+ token,
+}: OryAuthJsSessionInput) {
+ session.user.id = token.sub ?? session.user.id
+ session.accessToken = token.accessToken
+ session.idToken = token.idToken
+ session.identityId = token.identityId
+ session.error = token.error
+ return session
+}
+
+// Persist the Ory tokens on a fresh sign-in and cache the resolved Kratos
+// identity id. Clears any RefreshTokenError carried over from a previously
+// poisoned cookie so the new session starts clean.
+async function buildSignInToken(
+ token: OryAuthJsJwt,
+ account: OryAuthJsAccount,
+ profile: OryAuthJsJwtInput['profile']
+): Promise {
+ const userId = readOryAccessTokenSubject(account) ?? token.sub
+ const nextToken = {
+ ...token,
+ sub: userId,
+ }
+ const identityId = await resolveKratosIdentityId(nextToken, account, profile)
+
+ await persistOrySignupMetadataFromCookie(identityId)
+
+ return {
+ ...nextToken,
+ accessToken: account.access_token,
+ refreshToken: account.refresh_token,
+ idToken: account.id_token,
+ expiresAt: account.expires_at ?? null,
+ identityId,
+ error: undefined,
+ }
+}
+
+// The Kratos identity id is NOT the OIDC subject the dashboard uses as the E2B
+// user id (`token.sub`, consumed by dashboard-api and infra). It is surfaced via
+// the OIDC profile `sub`. Resolve it once at sign-in — by profile.sub, then
+// token.sub, then the verified email — so account operations can use a stable
+// Kratos id without a per-request lookup. Returns undefined on failure; the
+// provider then falls back to a per-request lookup, so sign-in is never blocked.
+async function resolveKratosIdentityId(
+ token: OryAuthJsJwt,
+ account: OryAuthJsAccount,
+ profile: OryAuthJsJwtInput['profile']
+): Promise {
+ const identity = await resolveOryIdentity({
+ subjects: [readOryProfileSubject(profile), token.sub],
+ email: readOryEmailClaim(account),
+ })
+
+ return identity?.id
+}
+
+async function prepareBootstrapFailureRedirect(
+ account?: { id_token?: string } | null
+): Promise {
+ if (!account?.id_token) return ORY_BOOTSTRAP_FAILURE_FLOW_PATH
+
+ try {
+ const cookieStore = await cookies()
+ cookieStore.set(ORY_BOOTSTRAP_FAILURE_ID_TOKEN_COOKIE, account.id_token, {
+ httpOnly: true,
+ sameSite: 'lax',
+ path: '/',
+ maxAge: BOOTSTRAP_FAILURE_COOKIE_MAX_AGE_SECONDS,
+ secure: process.env.NODE_ENV === 'production',
+ })
+ } catch (error) {
+ l.warn(
+ {
+ key: 'auth_callbacks:sign_in:bootstrap_failure_cookie_error',
+ error: serializeErrorForLog(error),
+ },
+ 'Failed to persist Ory bootstrap-failure logout handoff cookie'
+ )
+ }
+
+ return ORY_BOOTSTRAP_FAILURE_FLOW_PATH
+}
+
+function isAccessTokenExpiring(
+ token: OryAuthJsJwt,
+ nowSeconds: number = Math.floor(Date.now() / 1000)
+): boolean {
+ if (token.expiresAt == null) return !!token.refreshToken
+ return nowSeconds > token.expiresAt - ACCESS_TOKEN_REFRESH_SKEW_SECONDS
+}
diff --git a/src/core/server/auth/ory/build-start-url.ts b/src/core/server/auth/ory/build-start-url.ts
new file mode 100644
index 000000000..efecc48c8
--- /dev/null
+++ b/src/core/server/auth/ory/build-start-url.ts
@@ -0,0 +1,24 @@
+import { relativeUrlSchema } from '@/core/shared/schemas/url'
+
+export type OryAuthIntent = 'signin' | 'signup' | 'reauth'
+
+const ORY_START_PATH = '/api/auth/oauth-start'
+
+export function normalizeOryReturnTo(
+ returnTo?: string | null
+): string | undefined {
+ const parsedReturnTo = relativeUrlSchema.safeParse(returnTo)
+ return parsedReturnTo.success ? parsedReturnTo.data : undefined
+}
+
+export function buildOryStartURL(
+ intent: OryAuthIntent,
+ returnTo?: string
+): string {
+ const params = new URLSearchParams({ intent })
+ const safeReturnTo = normalizeOryReturnTo(returnTo)
+ if (safeReturnTo) {
+ params.set('returnTo', safeReturnTo)
+ }
+ return `${ORY_START_PATH}?${params}`
+}
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/dashboard-bootstrap.ts b/src/core/server/auth/ory/dashboard-bootstrap.ts
new file mode 100644
index 000000000..769f62785
--- /dev/null
+++ b/src/core/server/auth/ory/dashboard-bootstrap.ts
@@ -0,0 +1,206 @@
+import 'server-only'
+
+import { ADMIN_AUTH_HEADERS } from '@/configs/api'
+import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server'
+import { api } from '@/core/shared/clients/api'
+import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
+import { repoErrorFromHttp } from '@/core/shared/errors'
+import { decodeJwtClaims, readStringClaim, tokenFormat } from './jwt-claims'
+
+type BootstrapOryUserInput = {
+ accessToken: string
+ idToken?: string
+ provider?: string
+}
+
+type OryBootstrapClaims = {
+ oidcIssuer: string
+ oidcUserId: string
+ oidcUserEmail: string
+ oidcUserName: string | null
+}
+
+type OryTokenClaims = {
+ iss?: unknown
+ sub?: unknown
+ email?: unknown
+ name?: unknown
+ given_name?: unknown
+ preferred_username?: unknown
+}
+
+type BootstrapTeamCheck = 'has-team' | 'missing-team' | 'failed'
+
+export async function ensureOryUserBootstrapped(
+ input: BootstrapOryUserInput
+): Promise {
+ const claims = readBootstrapClaims(input)
+ if (!claims) return false
+
+ const teamCheck = await checkBootstrappedUserTeam(
+ claims.oidcUserId,
+ input.accessToken
+ )
+
+ if (teamCheck === 'failed') {
+ return false
+ }
+
+ if (teamCheck === 'has-team') {
+ return true
+ }
+
+ return bootstrapOryUserWithClaims(claims, input.provider)
+}
+
+export async function bootstrapOryUser(
+ input: BootstrapOryUserInput
+): Promise {
+ const claims = readBootstrapClaims(input)
+ if (!claims) return false
+
+ return bootstrapOryUserWithClaims(claims, input.provider)
+}
+
+async function checkBootstrappedUserTeam(
+ userId: string,
+ accessToken: string
+): Promise {
+ const userTeamsRepository = createUserTeamsRepository({ accessToken })
+ const teamsResult = await userTeamsRepository.listUserTeams()
+
+ if (!teamsResult.ok) {
+ l.error(
+ {
+ key: 'auth_events:bootstrap_user:team_check_error',
+ user_id: userId,
+ },
+ 'Failed to check whether Ory user already has a dashboard team'
+ )
+ return 'failed'
+ }
+
+ return teamsResult.data.length > 0 ? 'has-team' : 'missing-team'
+}
+
+function readBootstrapClaims(
+ input: BootstrapOryUserInput
+): OryBootstrapClaims | null {
+ const accessClaims = decodeJwtClaims(input.accessToken)
+ const idClaims = input.idToken
+ ? decodeJwtClaims(input.idToken)
+ : null
+ const oidcIssuer =
+ readStringClaim(accessClaims, 'iss') ?? readStringClaim(idClaims, 'iss')
+ const oidcUserId = readStringClaim(accessClaims, 'sub')
+ const oidcUserEmail =
+ readStringClaim(accessClaims, 'email') ?? readStringClaim(idClaims, 'email')
+ const oidcUserName =
+ readDisplayName(accessClaims) ?? readDisplayName(idClaims)
+
+ if (!oidcIssuer || !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_iss: !!oidcIssuer,
+ has_sub: !!oidcUserId,
+ has_email: !!oidcUserEmail,
+ has_name: !!oidcUserName,
+ },
+ },
+ 'Ory access token is missing required bootstrap claims'
+ )
+ return null
+ }
+
+ return {
+ oidcIssuer,
+ oidcUserId,
+ oidcUserEmail,
+ oidcUserName,
+ }
+}
+
+async function bootstrapOryUserWithClaims(
+ claims: OryBootstrapClaims,
+ provider?: string
+): Promise {
+ try {
+ const adminToken = process.env.DASHBOARD_API_ADMIN_TOKEN
+ if (!adminToken) {
+ l.error(
+ {
+ key: 'auth_events:bootstrap_user:missing_admin_token',
+ context: { provider },
+ },
+ 'DASHBOARD_API_ADMIN_TOKEN is not configured'
+ )
+ return false
+ }
+
+ const body = {
+ oidc_issuer: claims.oidcIssuer,
+ oidc_user_id: claims.oidcUserId,
+ oidc_user_email: claims.oidcUserEmail,
+ oidc_user_name: claims.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,
+ error_status: response.status,
+ has_oidc_issuer: body.oidc_issuer !== '',
+ has_oidc_user_id: body.oidc_user_id !== '',
+ has_oidc_user_email: body.oidc_user_email !== '',
+ has_oidc_user_name: body.oidc_user_name !== null,
+ },
+ },
+ `bootstrap_user failed: ${repoError.message}`
+ )
+ return false
+ }
+
+ return true
+ } catch (error) {
+ l.error(
+ {
+ key: 'auth_events:bootstrap_user:exception',
+ context: {
+ provider,
+ },
+ error: serializeErrorForLog(error),
+ },
+ 'bootstrap_user threw unexpected exception'
+ )
+ return false
+ }
+}
+
+function readDisplayName(claims: OryTokenClaims | null): string | null {
+ return (
+ readStringClaim(claims, 'name') ??
+ readStringClaim(claims, 'given_name') ??
+ readStringClaim(claims, 'preferred_username')
+ )
+}
diff --git a/src/core/server/auth/ory/find-identity.ts b/src/core/server/auth/ory/find-identity.ts
new file mode 100644
index 000000000..8dfd16739
--- /dev/null
+++ b/src/core/server/auth/ory/find-identity.ts
@@ -0,0 +1,173 @@
+import 'server-only'
+
+import {
+ type GetIdentityByExternalIDIncludeCredentialEnum,
+ type GetIdentityIncludeCredentialEnum,
+ type Identity,
+ ResponseError,
+} from '@ory/client-fetch'
+import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
+import { getOryIdentityApi } from './client'
+import { readOryError } from './ory-error'
+
+// Resolving the Kratos identity for the logged-in user is not a simple
+// "getIdentity(sub)" because the OIDC subject the dashboard sees is not
+// guaranteed to be the Kratos identity id:
+// - In a vanilla Ory setup the OAuth2 subject IS the Kratos identity id.
+// - Projects that customize the subject (e.g. to keep a stable app user id
+// across a migration) expose the Kratos id under a *different* OIDC subject,
+// or only via the id_token/userinfo profile `sub`.
+// - Migrated identities may carry a legacy id as `external_id`.
+// So we try every identifier we have (a list of candidate subjects, then the
+// verified email) and return the first identity that resolves.
+
+export type ResolveOryIdentityInput = {
+ // Candidate subject ids, in priority order (e.g. profile.sub, then token.sub).
+ // Falsy entries are ignored and duplicates de-duped.
+ subjects?: Array
+ // Verified login email — the unambiguous fallback for password identities.
+ email?: string | null
+ // Optional credential config needed by callers that decide account
+ // capabilities. Leave unset on hot paths that only need the identity id.
+ includeCredential?: OryIdentityCredentialInclude[]
+}
+
+export type OryIdentityCredentialInclude = GetIdentityIncludeCredentialEnum &
+ GetIdentityByExternalIDIncludeCredentialEnum
+
+export const ACCOUNT_IDENTITY_CREDENTIALS = [
+ 'password',
+ 'oidc',
+] satisfies OryIdentityCredentialInclude[]
+
+export async function resolveOryIdentity(
+ input: ResolveOryIdentityInput
+): Promise {
+ const subjects = [
+ ...new Set(
+ (input.subjects ?? []).filter(
+ (subject): subject is string => typeof subject === 'string' && !!subject
+ )
+ ),
+ ]
+
+ for (const subject of subjects) {
+ const identity = await findOryIdentityBySubject(
+ subject,
+ input.includeCredential
+ )
+ if (identity) return identity
+ }
+
+ if (input.email) {
+ const identity = await findOryIdentityByEmail(
+ input.email,
+ input.includeCredential
+ )
+ if (identity) return identity
+ }
+
+ l.error(
+ {
+ key: 'auth_provider:resolve_identity:not_found',
+ context: {
+ attempted_subjects: subjects,
+ attempted_email: input.email ?? null,
+ // The project we queried — a mismatch with the token issuer points to a
+ // misconfigured admin client (wrong Ory project).
+ ory_sdk_url: process.env.ORY_SDK_URL ?? null,
+ },
+ },
+ 'no Kratos identity found by subject(s) or email'
+ )
+ return null
+}
+
+// Tries a single subject as a Kratos identity id, then as an external_id. A 404
+// means "not this strategy" and falls through; any other error is unexpected,
+// logged, and stops the search. The terminal "not found" belongs to
+// resolveOryIdentity once every strategy is exhausted.
+export async function findOryIdentityBySubject(
+ subject: string,
+ includeCredential?: OryIdentityCredentialInclude[]
+): Promise {
+ const api = getOryIdentityApi()
+
+ try {
+ return await api.getIdentity(
+ withIncludedCredentials({ id: subject }, includeCredential)
+ )
+ } catch (error) {
+ if (!isNotFound(error)) {
+ await logLookupError('by_id', error)
+ return null
+ }
+ }
+
+ try {
+ return await api.getIdentityByExternalID(
+ withIncludedCredentials({ externalID: subject }, includeCredential)
+ )
+ } catch (error) {
+ if (!isNotFound(error)) {
+ await logLookupError('by_external_id', error)
+ }
+ return null
+ }
+}
+
+export async function findOryIdentityByEmail(
+ email: string,
+ includeCredential?: OryIdentityCredentialInclude[]
+): Promise {
+ try {
+ const identities = await getOryIdentityApi().listIdentities({
+ credentialsIdentifier: email,
+ pageSize: 2,
+ ...(includeCredential ? { includeCredential } : {}),
+ })
+
+ if (identities.length === 0) return null
+
+ // Prefer an exact email-trait match; fall back to the first result.
+ const exact = identities.find(
+ (identity) => emailTrait(identity)?.toLowerCase() === email.toLowerCase()
+ )
+ return exact ?? identities[0] ?? null
+ } catch (error) {
+ await logLookupError('by_email', error)
+ return null
+ }
+}
+
+function withIncludedCredentials>(
+ params: T,
+ includeCredential: OryIdentityCredentialInclude[] | undefined
+): T & { includeCredential?: OryIdentityCredentialInclude[] } {
+ return includeCredential ? { ...params, includeCredential } : params
+}
+
+function emailTrait(identity: Identity): string | null {
+ const traits = (identity.traits ?? {}) as Record
+ return typeof traits.email === 'string' ? traits.email : null
+}
+
+function isNotFound(error: unknown): boolean {
+ return error instanceof ResponseError && error.response.status === 404
+}
+
+async function logLookupError(
+ stage: 'by_id' | 'by_external_id' | 'by_email',
+ error: unknown
+): Promise {
+ const ory = error instanceof ResponseError ? await readOryError(error) : null
+
+ l.error(
+ {
+ key: 'auth_provider:resolve_identity:error',
+ context: { stage, ory },
+ error: serializeErrorForLog(error),
+ },
+ `Ory identity lookup failed (${stage})`
+ )
+}
diff --git a/src/core/server/auth/ory/flows.ts b/src/core/server/auth/ory/flows.ts
new file mode 100644
index 000000000..39d46ae0d
--- /dev/null
+++ b/src/core/server/auth/ory/flows.ts
@@ -0,0 +1,185 @@
+import 'server-only'
+
+import {
+ type Identity,
+ type JsonPatch,
+ JsonPatchOpEnum,
+} from '@ory/client-fetch'
+import { l } from '@/core/shared/clients/logger/logger'
+import type { UpdateUserErrorCode, UpdateUserResult } from '../types'
+import { getOryIdentityApi } from './client'
+import { ACCOUNT_IDENTITY_CREDENTIALS } from './find-identity'
+import { fromOryIdentity } from './identity'
+import { isOryResponseError, readOryError } from './ory-error'
+
+type OryUpdateUserInput = {
+ identityId: string
+ name?: string
+ email?: string
+ password?: string
+}
+
+export const oryAuthFlows = {
+ async updateUser({
+ identityId,
+ name,
+ email,
+ password,
+ }: OryUpdateUserInput): Promise {
+ try {
+ // A password change must go through updateIdentity (the credential import
+ // path) — see setPassword. Trait-only changes use the lighter patch.
+ if (password !== undefined) {
+ await setPassword(identityId, { name, email, password })
+ } else {
+ await patchTraits(identityId, { name, email })
+ }
+
+ const identity = await getIdentityWithAccountCredentials(identityId)
+ return { ok: true, user: fromOryIdentity(identity) }
+ } catch (error) {
+ return mapUpdateUserError(error, identityId)
+ }
+ },
+}
+
+async function getIdentityWithAccountCredentials(
+ identityId: string
+): Promise {
+ return getOryIdentityApi().getIdentity({
+ id: identityId,
+ includeCredential: ACCOUNT_IDENTITY_CREDENTIALS,
+ })
+}
+
+// Kratos only hashes a cleartext password when it runs through the credential
+// IMPORT pipeline (updateIdentity / createIdentity). A JSON-Patch write to
+// `/credentials/password/config/password` is accepted with 200 but stored raw —
+// `hashed_password` is left untouched, so the change appears to succeed while
+// the OLD password keeps working and the new one never does. So we set the
+// password via updateIdentity (PUT). Only the password credential is supplied,
+// which Kratos hashes; existing credentials (e.g. oidc) are preserved. We
+// re-send schema_id/state/traits/external_id/metadata to avoid clobbering them
+// on the full update.
+async function setPassword(
+ identityId: string,
+ { name, email, password }: Omit
+): Promise {
+ const api = getOryIdentityApi()
+ const current = await api.getIdentity({ id: identityId })
+
+ await api.updateIdentity({
+ id: identityId,
+ updateIdentityBody: {
+ schema_id: current.schema_id,
+ state: current.state ?? 'active',
+ traits: mergeTraits(current.traits, { name, email }),
+ external_id: current.external_id,
+ metadata_public: current.metadata_public,
+ metadata_admin: current.metadata_admin,
+ credentials: { password: { config: { password } } },
+ },
+ })
+}
+
+async function patchTraits(
+ identityId: string,
+ { name, email }: Pick
+): Promise {
+ const api = getOryIdentityApi()
+ const jsonPatch = buildTraitPatches({ name, email })
+
+ if (jsonPatch.length === 0) {
+ return
+ }
+
+ await api.patchIdentity({ id: identityId, jsonPatch })
+}
+
+function mergeTraits(
+ current: unknown,
+ { name, email }: Pick
+): Record {
+ const traits = { ...((current as Record) ?? {}) }
+ if (name !== undefined) traits.name = name
+ if (email !== undefined) traits.email = email
+ return traits
+}
+
+// Assumes a flat `name` trait. If the project's identity schema nests name as
+// `{ first, last }`, these patch paths need to target those sub-paths instead.
+function buildTraitPatches({
+ name,
+ email,
+}: Pick): JsonPatch[] {
+ const patches: JsonPatch[] = []
+
+ if (name !== undefined) {
+ patches.push({
+ op: JsonPatchOpEnum.Replace,
+ path: '/traits/name',
+ value: name,
+ })
+ }
+ if (email !== undefined) {
+ patches.push({
+ op: JsonPatchOpEnum.Replace,
+ path: '/traits/email',
+ value: email,
+ })
+ }
+
+ return patches
+}
+
+async function mapUpdateUserError(
+ error: unknown,
+ identityId: string
+): Promise {
+ if (!isOryResponseError(error)) {
+ throw error
+ }
+
+ const details = await readOryError(error)
+ const code = classifyUpdateError(
+ details.status,
+ details.reason,
+ details.message
+ )
+
+ l.error(
+ {
+ key: 'auth_provider:ory_update_user:error',
+ user_id: identityId,
+ context: { ory: details, mapped_code: code },
+ },
+ 'Ory identity update failed'
+ )
+
+ // Unclassified failures (5xx, unexpected 4xx) are surfaced as unexpected
+ // server errors rather than a misleading user-facing message.
+ if (!code) {
+ throw error
+ }
+
+ return { ok: false, code, message: details.message }
+}
+
+function classifyUpdateError(
+ status: number,
+ reason?: string,
+ message?: string
+): UpdateUserErrorCode | null {
+ const haystack = `${reason ?? ''} ${message ?? ''}`.toLowerCase()
+
+ if (status === 409) return 'email_exists'
+
+ if (status === 400) {
+ if (haystack.includes('password')) return 'weak_password'
+ if (haystack.includes('email') || haystack.includes('valid')) {
+ return 'email_invalid'
+ }
+ }
+
+ return null
+}
diff --git a/src/core/server/auth/ory/freshness.ts b/src/core/server/auth/ory/freshness.ts
new file mode 100644
index 000000000..479053b54
--- /dev/null
+++ b/src/core/server/auth/ory/freshness.ts
@@ -0,0 +1,33 @@
+import { decodeJwtClaims } from './jwt-claims'
+
+// How recently the user must have authenticated (via the OAuth2 login flow)
+// for a sensitive operation like a password change to be allowed without a
+// forced re-auth round-trip.
+export const REAUTH_FRESHNESS_WINDOW_SECONDS = 300
+
+type AuthTimeClaims = {
+ auth_time?: unknown
+}
+
+// Reads the OIDC `auth_time` claim (epoch seconds) from the id_token. Hydra
+// stamps this with the moment the user last actively authenticated, which is
+// what `prompt=login` refreshes.
+export function readAuthTime(idToken: string | undefined): number | null {
+ if (!idToken) return null
+
+ const claims = decodeJwtClaims(idToken)
+ const authTime = claims?.auth_time
+ return typeof authTime === 'number' && Number.isFinite(authTime)
+ ? authTime
+ : null
+}
+
+export function isReauthFresh(
+ idToken: string | undefined,
+ nowSeconds: number = Math.floor(Date.now() / 1000)
+): boolean {
+ const authTime = readAuthTime(idToken)
+ if (authTime === null) return false
+
+ return nowSeconds - authTime <= REAUTH_FRESHNESS_WINDOW_SECONDS
+}
diff --git a/src/core/server/auth/ory/identity.ts b/src/core/server/auth/ory/identity.ts
new file mode 100644
index 000000000..bda66cdb5
--- /dev/null
+++ b/src/core/server/auth/ory/identity.ts
@@ -0,0 +1,120 @@
+import 'server-only'
+
+import type { Identity } from '@ory/client-fetch'
+import type { Session } from 'next-auth'
+import type { AuthUser } from '../types'
+
+type FromOryIdentityOptions = {
+ userId?: string
+}
+
+// Cheap path: build the user from the Auth.js session alone (no Ory call). Used
+// at request time by getAuthContext. `providers` is empty because the session
+// doesn't carry credential info — use fromOryIdentity when that's needed.
+export function fromAuthSession(session: Session): AuthUser {
+ return {
+ id: session.user.id,
+ email: session.user.email ?? null,
+ name: session.user.name ?? null,
+ avatarUrl: session.user.image ?? null,
+ providers: [],
+ canChangeEmail: false,
+ canChangePassword: false,
+ }
+}
+
+// Rich path: build the user from a full Kratos Identity (traits + credentials).
+// Used wherever we've fetched the identity via the admin API — admin lookups and
+// the live profile query.
+export function fromOryIdentity(
+ identity: Identity,
+ options: FromOryIdentityOptions = {}
+): 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 = normalizeProviders(identity.credentials)
+ const hasPasswordCredential = hasUsablePasswordCredential(
+ identity.credentials?.password
+ )
+ const hasOidcCredential = hasLinkedOidcCredential(identity.credentials?.oidc)
+ const canChangePassword = hasPasswordCredential && !hasOidcCredential
+
+ return {
+ id: options.userId ?? identity.id,
+ email,
+ name,
+ avatarUrl,
+ providers,
+ // Email changes are disabled until the custom UI drives Ory's
+ // settings/verification flows instead of patching traits directly.
+ canChangeEmail: false,
+ canChangePassword,
+ }
+}
+
+// Kratos credential keys (`password`, `oidc`, …) don't match the provider
+// vocabulary the dashboard UI expects (Supabase emits `email` for the
+// email/password credential). Map `password` → `email` for display parity,
+// while preserving other keys like `oidc`.
+function normalizeProviders(credentials: Identity['credentials']): string[] {
+ if (!credentials) return []
+
+ const mapped = Object.keys(credentials).map((key) =>
+ key === 'password' ? 'email' : key
+ )
+
+ return [...new Set(mapped)]
+}
+
+function hasUsablePasswordCredential(
+ credential: NonNullable[string] | undefined
+): boolean {
+ const config = credential?.config as Record | undefined
+ return (
+ (typeof config?.hashed_password === 'string' &&
+ config.hashed_password !== '') ||
+ config?.use_password_migration_hook === true
+ )
+}
+
+function hasLinkedOidcCredential(
+ credential: NonNullable[string] | undefined
+): boolean {
+ if (!credential) return false
+
+ if (credential.identifiers && credential.identifiers.length > 0) {
+ return true
+ }
+
+ const config = credential.config as Record | undefined
+ const providers = config?.providers
+ return Array.isArray(providers) && providers.length > 0
+}
+
+function readString(
+ traits: Record,
+ key: string
+): 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/jwt-claims.ts b/src/core/server/auth/ory/jwt-claims.ts
new file mode 100644
index 000000000..b2e225c25
--- /dev/null
+++ b/src/core/server/auth/ory/jwt-claims.ts
@@ -0,0 +1,26 @@
+export function decodeJwtClaims>(
+ token: string
+): T | null {
+ const [, payload] = token.split('.')
+ if (!payload) return null
+
+ try {
+ return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as T
+ } catch {
+ return null
+ }
+}
+
+export function tokenFormat(token: string): 'jwt' | 'opaque' | 'empty' {
+ if (!token) return 'empty'
+ return token.split('.').length === 3 ? 'jwt' : 'opaque'
+}
+
+// Reads a non-empty string claim, trimming surrounding whitespace.
+export function readStringClaim(
+ claims: Record | null | undefined,
+ name: string
+): string | null {
+ const value = claims?.[name]
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
+}
diff --git a/src/core/server/auth/ory/kratos-session.ts b/src/core/server/auth/ory/kratos-session.ts
new file mode 100644
index 000000000..c981a1a42
--- /dev/null
+++ b/src/core/server/auth/ory/kratos-session.ts
@@ -0,0 +1,69 @@
+import 'server-only'
+
+import { ResponseError } from '@ory/client-fetch'
+import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
+import { getOryIdentityApi } from './client'
+import { readOryError } from './ory-error'
+
+/**
+ * Revokes every Kratos identity session for the given identity.
+ *
+ * Hydra's /oauth2/sessions/logout only ends the OAuth2 session; the Kratos
+ * identity cookie on the Ory domain is independent and is what causes the
+ * Account Experience to show "Reauthenticate as " on the next
+ * sign-in instead of a fresh provider chooser.
+ *
+ * We can't surgically target a single session because the OIDC `sid` claim
+ * from Hydra is Hydra's own OAuth2 session id, not a Kratos session id, and
+ * we don't have access to the user's Kratos cookie from this side. Revoking
+ * all identity sessions matches the expected "sign out of identity provider"
+ * semantics anyway.
+ */
+// Ory uses optimistic locking on identity rows; concurrent writes (e.g. our
+// admin DELETE racing with Hydra's RP-initiated logout cleanup during the
+// same signout flow) return 429 with reason "Conflicting concurrent
+// requests". Retrying after a short backoff lets the in-flight write
+// settle so ours can proceed.
+const REVOKE_MAX_ATTEMPTS = 3
+const REVOKE_BACKOFF_MS = 150
+
+export async function revokeKratosSessionsForIdentity(
+ identityId: string
+): Promise {
+ for (let attempt = 1; attempt <= REVOKE_MAX_ATTEMPTS; attempt++) {
+ try {
+ await getOryIdentityApi().deleteIdentitySessions({ id: identityId })
+ return
+ } catch (error) {
+ if (error instanceof ResponseError && error.response.status === 404) {
+ return
+ }
+
+ const isContention =
+ error instanceof ResponseError && error.response.status === 429
+ const lastAttempt = attempt === REVOKE_MAX_ATTEMPTS
+
+ if (isContention && !lastAttempt) {
+ await sleep(REVOKE_BACKOFF_MS * attempt)
+ continue
+ }
+
+ const oryDetails =
+ error instanceof ResponseError ? await readOryError(error) : null
+
+ l.error(
+ {
+ key: 'auth_provider:revoke_kratos_sessions:error',
+ context: { ory: oryDetails, attempt },
+ error: serializeErrorForLog(error),
+ },
+ 'failed to revoke Kratos sessions; user may see reauth UX on next sign-in'
+ )
+ return
+ }
+ }
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
diff --git a/src/core/server/auth/ory/ory-error.ts b/src/core/server/auth/ory/ory-error.ts
new file mode 100644
index 000000000..b03a8fea2
--- /dev/null
+++ b/src/core/server/auth/ory/ory-error.ts
@@ -0,0 +1,73 @@
+import { ResponseError } from '@ory/client-fetch'
+
+export type OryErrorDetails = {
+ status: number
+ path?: string
+ code?: number
+ reason?: string
+ message?: string
+ request_id?: string
+ body?: string
+}
+
+// Ory returns a structured error envelope like
+// { "error": { "code": 401, "status": "Unauthorized", "reason": "...", "message": "...", "id": "..." } }
+// The SDK's ResponseError doesn't unpack it, so we read the body here to
+// surface the actual cause instead of "Response returned an error code".
+export async function readOryError(
+ error: ResponseError
+): Promise {
+ const { response } = error
+ const base: OryErrorDetails = {
+ status: response.status,
+ path: responsePath(response.url),
+ }
+
+ let raw: string
+ try {
+ raw = await response.clone().text()
+ } catch {
+ return base
+ }
+
+ try {
+ const parsed = JSON.parse(raw) as {
+ error?: {
+ code?: unknown
+ reason?: unknown
+ message?: unknown
+ id?: unknown
+ request?: unknown
+ }
+ }
+ const oryError = parsed.error ?? {}
+ return {
+ ...base,
+ code: typeof oryError.code === 'number' ? oryError.code : undefined,
+ reason: stringOrUndefined(oryError.reason),
+ message: stringOrUndefined(oryError.message),
+ request_id:
+ stringOrUndefined(oryError.id) ?? stringOrUndefined(oryError.request),
+ }
+ } catch {
+ return { ...base, body: raw.slice(0, 500) }
+ }
+}
+
+export function isOryResponseError(error: unknown): error is ResponseError {
+ return error instanceof ResponseError
+}
+
+function stringOrUndefined(value: unknown): string | undefined {
+ return typeof value === 'string' && value.length > 0 ? value : undefined
+}
+
+function responsePath(url: string): string | undefined {
+ if (!url) return undefined
+
+ try {
+ return new URL(url, 'https://dashboard.e2b.dev').pathname
+ } catch {
+ return undefined
+ }
+}
diff --git a/src/core/server/auth/ory/provider.ts b/src/core/server/auth/ory/provider.ts
index c51029f10..babf7e758 100644
--- a/src/core/server/auth/ory/provider.ts
+++ b/src/core/server/auth/ory/provider.ts
@@ -1,78 +1,169 @@
import 'server-only'
-import type { NextRequest, NextResponse } from 'next/server'
-import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
-import { l } from '@/core/shared/clients/logger/logger'
+import type { Session } from 'next-auth'
+import { auth as authjs } from '@/auth'
+import { PROTECTED_URLS } from '@/configs/urls'
+import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
import type { AuthProvider } from '../provider'
import type {
AuthContext,
AuthUser,
ReauthDispatch,
- SignOutOptions,
- SignOutResult,
UpdateUserInput,
UpdateUserResult,
} from '../types'
+import { buildOryStartURL } from './build-start-url'
+import {
+ ACCOUNT_IDENTITY_CREDENTIALS,
+ resolveOryIdentity,
+} from './find-identity'
+import { oryAuthFlows } from './flows'
+import { isReauthFresh } from './freshness'
+import { fromAuthSession, fromOryIdentity } from './identity'
+import { revokeKratosSessionsForIdentity } from './kratos-session'
+import { ORY_SIGN_OUT_FLOW_PATH } from './signout'
-export class OryHostedAuthProvider implements AuthProvider {
- constructor(private readonly cookie: string = '') {}
+// Where the account-settings page expects to land after a forced re-auth so it
+// reveals the password form (matches the Supabase ?reauth=1 contract).
+const ACCOUNT_SETTINGS_REAUTH_RETURN_TO = `${PROTECTED_URLS.ACCOUNT_SETTINGS}?reauth=1`
- // 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)
- }
+export const oryAuthProvider: AuthProvider = {
+ async getAuthContext() {
+ const session = await readSession()
+ if (!session) return null
- getUserProfile(): Promise {
- return Promise.resolve(null)
- }
+ if (!session.user?.id || !session.accessToken) {
+ return null
+ }
- signOut(_options?: SignOutOptions): Promise {
- return Promise.resolve({
- redirectTo: AUTH_URLS.SIGN_IN,
- error: {
- message: 'OryHostedAuthProvider.signOut is not implemented yet',
- code: 'ory_stub_not_implemented',
- },
- })
- }
+ 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
+ }
- updateUser(_input: UpdateUserInput): Promise {
- return Promise.resolve({
- ok: false,
- code: 'account_credentials_not_changeable',
- message: 'OryHostedAuthProvider.updateUser is not implemented yet',
+ return {
+ user: fromAuthSession(session),
+ accessToken: session.accessToken,
+ } satisfies AuthContext
+ },
+
+ async getUserProfile(): Promise {
+ const session = await readSession()
+ if (!session?.user?.id) return null
+
+ // The live profile needs the full Kratos identity (traits + credentials).
+ // The cached session.identityId hits directly; user.id and email are
+ // fallbacks. Callers (the tRPC profile query) time this out and fall back to
+ // the cheap session user, so a null/slow response never blocks the dashboard.
+ const identity = await resolveOryIdentity({
+ subjects: [session.identityId, session.user.id],
+ email: session.user.email,
+ includeCredential: ACCOUNT_IDENTITY_CREDENTIALS,
})
- }
- startReauthForAccountSettings(): Promise {
- return Promise.resolve({
- kind: 'sign-out',
- returnTo: PROTECTED_URLS.ACCOUNT_SETTINGS,
+ return identity
+ ? fromOryIdentity(identity, { userId: session.user.id })
+ : null
+ },
+
+ signOut() {
+ return Promise.resolve({ redirectTo: ORY_SIGN_OUT_FLOW_PATH })
+ },
+
+ async updateUser(input: UpdateUserInput): Promise {
+ const session = await readSession()
+ if (!session?.user?.id) {
+ throw new Error('updateUser called without an authenticated Ory session')
+ }
+
+ // Changing the password OR the email is privileged: require a recent active
+ // login so a stolen dashboard session can't silently take over the account
+ // (swap the email, then reset the password via the new inbox). The caller
+ // turns this into the forced OAuth2 re-auth round-trip.
+ const changesCredentials =
+ input.password !== undefined || input.email !== undefined
+ if (changesCredentials && !isReauthFresh(session.idToken)) {
+ return { ok: false, code: 'reauthentication_needed' }
+ }
+
+ const identityId = await resolveIdentityId(session)
+ if (!identityId) {
+ throw new Error(
+ 'updateUser could not resolve an Ory identity for the session subject'
+ )
+ }
+
+ const result = await oryAuthFlows.updateUser({
+ identityId,
+ name: input.name,
+ email: input.email,
+ password: input.password,
})
- }
- signOutOtherSessions(): Promise {
- return Promise.resolve()
- }
+ if (!result.ok) return result
+
+ return {
+ ...result,
+ user: {
+ ...result.user,
+ id: session.user.id,
+ },
+ }
+ },
+
+ async startReauthForAccountSettings(): Promise {
+ return {
+ kind: 'redirect',
+ to: buildOryStartURL('reauth', ACCOUNT_SETTINGS_REAUTH_RETURN_TO),
+ }
+ },
+
+ async signOutOtherSessions(): Promise {
+ const session = await readSession()
+ if (!session?.user?.id) return
+
+ const identityId = await resolveIdentityId(session)
+ if (!identityId) return
+
+ // The dashboard session is the Auth.js JWT, independent of Kratos identity
+ // sessions, so revoking all Kratos sessions invalidates other browsers
+ // without logging the current dashboard session out.
+ await revokeKratosSessionsForIdentity(identityId)
+ },
}
-export function createOryAuthForProxy(
- request: NextRequest,
- _response: NextResponse
-): OryHostedAuthProvider {
- return new OryHostedAuthProvider(request.headers.get('cookie') ?? '')
+// The Kratos identity id is resolved once at sign-in and cached on the session
+// (see src/auth.ts). Fall back to a per-request lookup (by the E2B user id, then
+// the verified email) for sessions minted before that wiring existed or when
+// the sign-in resolution failed.
+async function resolveIdentityId(session: Session): Promise {
+ if (session.identityId) return session.identityId
+
+ const identity = await resolveOryIdentity({
+ subjects: [session.user.id],
+ email: session.user.email,
+ })
+ return identity?.id ?? null
}
-export function createOryAuthForHeaders(
- headers: Headers
-): OryHostedAuthProvider {
- return new OryHostedAuthProvider(headers.get('cookie') ?? '')
+async function readSession(): Promise {
+ try {
+ return await authjs()
+ } catch (error) {
+ l.error(
+ {
+ key: 'auth_provider:ory_get_session:error',
+ error: serializeErrorForLog(error),
+ },
+ 'Auth.js auth() helper threw while reading session'
+ )
+ return null
+ }
}
diff --git a/src/core/server/auth/ory/refresh-token.ts b/src/core/server/auth/ory/refresh-token.ts
new file mode 100644
index 000000000..83027734d
--- /dev/null
+++ b/src/core/server/auth/ory/refresh-token.ts
@@ -0,0 +1,98 @@
+import 'server-only'
+
+import type { JWT } from 'next-auth/jwt'
+import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
+
+type OryTokenResponse = {
+ access_token: string
+ expires_in: number
+ refresh_token?: string
+ id_token?: string
+}
+
+// returned on every failure path so the next jwt-callback invocation
+// short-circuits instead of re-presenting an already-invalidated refresh_token
+// in a loop. expiresAt is zeroed so isExpired() checks don't matter — the
+// error gate kicks in first.
+function deadToken(token: JWT, error: string): JWT {
+ return {
+ ...token,
+ accessToken: undefined,
+ refreshToken: undefined,
+ idToken: undefined,
+ expiresAt: 0,
+ error,
+ }
+}
+
+export async function refreshOryToken(token: JWT): Promise {
+ if (!token.refreshToken) return deadToken(token, 'NoRefreshToken')
+
+ const sdkUrl = process.env.ORY_SDK_URL?.replace(/\/$/, '')
+ 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',
+ context: {
+ hasSdkUrl: !!sdkUrl,
+ hasClientId: !!clientId,
+ hasClientSecret: !!clientSecret,
+ },
+ },
+ 'Ory refresh_token cannot run because OAuth2 client env is missing'
+ )
+ return deadToken(token, 'RefreshTokenError')
+ }
+
+ const credentials = Buffer.from(
+ `${clientId}:${clientSecret}`,
+ 'utf8'
+ ).toString('base64')
+
+ try {
+ const res = await fetch(`${sdkUrl}/oauth2/token`, {
+ method: 'POST',
+ headers: {
+ Authorization: `Basic ${credentials}`,
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({
+ grant_type: 'refresh_token',
+ refresh_token: token.refreshToken,
+ }),
+ })
+
+ if (!res.ok) {
+ l.warn(
+ {
+ key: 'auth_provider:refresh_token:rejected',
+ context: { status: res.status },
+ },
+ `Ory refresh_token rejected (${res.status})`
+ )
+ return deadToken(token, 'RefreshTokenError')
+ }
+
+ const fresh = (await res.json()) as OryTokenResponse
+ return {
+ ...token,
+ accessToken: fresh.access_token,
+ refreshToken: fresh.refresh_token ?? token.refreshToken,
+ idToken: fresh.id_token ?? token.idToken,
+ expiresAt: Math.floor(Date.now() / 1000) + fresh.expires_in,
+ error: undefined,
+ }
+ } catch (error) {
+ l.error(
+ {
+ key: 'auth_provider:refresh_token:exception',
+ error: serializeErrorForLog(error),
+ },
+ 'Ory refresh_token threw'
+ )
+ return deadToken(token, 'RefreshTokenError')
+ }
+}
diff --git a/src/core/server/auth/ory/signout.ts b/src/core/server/auth/ory/signout.ts
new file mode 100644
index 000000000..fbd7eefcc
--- /dev/null
+++ b/src/core/server/auth/ory/signout.ts
@@ -0,0 +1,36 @@
+// Route handler that performs the full Ory sign-out (Auth.js + Kratos sessions
+// + Hydra RP-initiated logout). The provider redirects here on signOut().
+export const ORY_SIGN_OUT_FLOW_PATH = '/api/auth/oauth/signout-flow'
+
+// Used when sign-in bootstrap fails before Auth.js finalizes a session. The
+// callback stores the id_token in this short-lived httpOnly cookie, then
+// redirects through this route so the browser can clear the Ory session.
+export const ORY_BOOTSTRAP_FAILURE_FLOW_PATH =
+ '/api/auth/oauth/bootstrap-failed'
+export const ORY_BOOTSTRAP_FAILURE_ID_TOKEN_COOKIE =
+ 'e2b-ory-bootstrap-failed-id-token'
+
+export const ORY_POST_LOGOUT_PATH = '/'
+
+export function buildOryLogoutUrl({
+ idToken,
+ origin,
+}: {
+ idToken: string
+ origin: string
+}): URL | null {
+ const sdkUrl = process.env.ORY_SDK_URL
+ if (!sdkUrl) return null
+
+ const postLogoutUrl = new URL(ORY_POST_LOGOUT_PATH, origin)
+ const logoutUrl = new URL(
+ `${sdkUrl.replace(/\/$/, '')}/oauth2/sessions/logout`
+ )
+ logoutUrl.searchParams.set('id_token_hint', idToken)
+ logoutUrl.searchParams.set(
+ 'post_logout_redirect_uri',
+ postLogoutUrl.toString()
+ )
+
+ return logoutUrl
+}
diff --git a/src/core/server/auth/ory/signup-metadata.ts b/src/core/server/auth/ory/signup-metadata.ts
new file mode 100644
index 000000000..ff8cee8ef
--- /dev/null
+++ b/src/core/server/auth/ory/signup-metadata.ts
@@ -0,0 +1,233 @@
+import 'server-only'
+
+import { createHmac, timingSafeEqual } from 'node:crypto'
+import { type JsonPatch, JsonPatchOpEnum } from '@ory/client-fetch'
+import { cookies } from 'next/headers'
+import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
+import { getOryIdentityApi } from './client'
+
+export const ORY_SIGNUP_METADATA_COOKIE = 'e2b-ory-signup-metadata'
+
+const SIGNUP_METADATA_COOKIE_MAX_AGE_SECONDS = 30 * 60
+const MAX_IP_LENGTH = 128
+const MAX_USER_AGENT_LENGTH = 1024
+
+export type OrySignupMetadata = {
+ signup_ip?: string
+ signup_user_agent?: string
+}
+
+export function readOrySignupMetadataFromHeaders(
+ headers: Headers
+): OrySignupMetadata | null {
+ const metadata = {
+ signup_ip: readClientIp(headers),
+ signup_user_agent: normalizeHeaderValue(
+ headers.get('user-agent'),
+ MAX_USER_AGENT_LENGTH
+ ),
+ } satisfies OrySignupMetadata
+
+ return metadata.signup_ip || metadata.signup_user_agent ? metadata : null
+}
+
+export async function setOrySignupMetadataCookie(
+ metadata: OrySignupMetadata | null
+): Promise {
+ if (!metadata) return
+
+ const encoded = encodeSignupMetadata(metadata)
+ if (!encoded) {
+ l.warn(
+ { key: 'auth_provider:ory_signup_metadata:missing_secret' },
+ 'Skipping Ory signup metadata handoff because AUTH_SECRET is not configured'
+ )
+ return
+ }
+
+ const cookieStore = await cookies()
+ cookieStore.set(ORY_SIGNUP_METADATA_COOKIE, encoded, {
+ httpOnly: true,
+ sameSite: 'lax',
+ path: '/',
+ maxAge: SIGNUP_METADATA_COOKIE_MAX_AGE_SECONDS,
+ secure: process.env.NODE_ENV === 'production',
+ })
+}
+
+export async function persistOrySignupMetadataFromCookie(
+ identityId?: string
+): Promise {
+ const metadata = await consumeOrySignupMetadataCookie()
+ if (!metadata) return
+
+ if (!identityId) {
+ l.warn(
+ { key: 'auth_provider:ory_signup_metadata:missing_identity' },
+ 'Could not persist Ory signup metadata because the Kratos identity id is missing'
+ )
+ return
+ }
+
+ try {
+ await persistOrySignupMetadata(identityId, metadata)
+ } catch (error) {
+ l.error(
+ {
+ key: 'auth_provider:ory_signup_metadata:update_error',
+ user_id: identityId,
+ error: serializeErrorForLog(error),
+ },
+ 'Failed to persist Ory signup metadata'
+ )
+ }
+}
+
+export async function persistOrySignupMetadata(
+ identityId: string,
+ metadata: OrySignupMetadata
+): Promise {
+ const api = getOryIdentityApi()
+ const identity = await api.getIdentity({ id: identityId })
+ const currentMetadata = objectMetadata(identity.metadata_admin)
+ const existingMetadata = currentMetadata ?? {}
+ const fieldsToAdd: OrySignupMetadata = {}
+
+ if (metadata.signup_ip && !Object.hasOwn(existingMetadata, 'signup_ip')) {
+ fieldsToAdd.signup_ip = metadata.signup_ip
+ }
+
+ if (
+ metadata.signup_user_agent &&
+ !Object.hasOwn(existingMetadata, 'signup_user_agent')
+ ) {
+ fieldsToAdd.signup_user_agent = metadata.signup_user_agent
+ }
+
+ if (!fieldsToAdd.signup_ip && !fieldsToAdd.signup_user_agent) return
+
+ const jsonPatch: JsonPatch[] = currentMetadata
+ ? Object.entries(fieldsToAdd).map(([key, value]) => ({
+ op: JsonPatchOpEnum.Add,
+ path: `/metadata_admin/${escapeJsonPointer(key)}`,
+ value,
+ }))
+ : [
+ {
+ op: JsonPatchOpEnum.Add,
+ path: '/metadata_admin',
+ value: fieldsToAdd,
+ },
+ ]
+
+ await api.patchIdentity({ id: identityId, jsonPatch })
+}
+
+async function consumeOrySignupMetadataCookie(): Promise {
+ const cookieStore = await cookies()
+ const encoded = cookieStore.get(ORY_SIGNUP_METADATA_COOKIE)?.value
+
+ cookieStore.delete(ORY_SIGNUP_METADATA_COOKIE)
+
+ if (!encoded) return null
+
+ const metadata = decodeSignupMetadata(encoded)
+ if (!metadata) {
+ l.warn(
+ { key: 'auth_provider:ory_signup_metadata:invalid_cookie' },
+ 'Ignoring invalid Ory signup metadata cookie'
+ )
+ }
+
+ return metadata
+}
+
+function encodeSignupMetadata(metadata: OrySignupMetadata): string | null {
+ const secret = process.env.AUTH_SECRET
+ if (!secret) return null
+
+ const payload = Buffer.from(JSON.stringify(metadata), 'utf8').toString(
+ 'base64url'
+ )
+ const signature = createHmac('sha256', secret)
+ .update(payload)
+ .digest('base64url')
+
+ return `${payload}.${signature}`
+}
+
+function decodeSignupMetadata(value: string): OrySignupMetadata | null {
+ const secret = process.env.AUTH_SECRET
+ if (!secret) return null
+
+ const [payload, signature] = value.split('.')
+ if (!payload || !signature) return null
+
+ const expectedSignature = createHmac('sha256', secret)
+ .update(payload)
+ .digest('base64url')
+
+ if (!safeEqual(signature, expectedSignature)) return null
+
+ try {
+ const parsed = JSON.parse(
+ Buffer.from(payload, 'base64url').toString('utf8')
+ ) as OrySignupMetadata
+ return sanitizeSignupMetadata(parsed)
+ } catch {
+ return null
+ }
+}
+
+function sanitizeSignupMetadata(
+ metadata: OrySignupMetadata
+): OrySignupMetadata | null {
+ const sanitized = {
+ signup_ip: normalizeHeaderValue(metadata.signup_ip, MAX_IP_LENGTH),
+ signup_user_agent: normalizeHeaderValue(
+ metadata.signup_user_agent,
+ MAX_USER_AGENT_LENGTH
+ ),
+ } satisfies OrySignupMetadata
+
+ return sanitized.signup_ip || sanitized.signup_user_agent ? sanitized : null
+}
+
+function readClientIp(headers: Headers): string | undefined {
+ return (
+ normalizeHeaderValue(
+ headers.get('x-forwarded-for')?.split(',')[0],
+ MAX_IP_LENGTH
+ ) ??
+ normalizeHeaderValue(headers.get('x-real-ip'), MAX_IP_LENGTH) ??
+ normalizeHeaderValue(headers.get('cf-connecting-ip'), MAX_IP_LENGTH)
+ )
+}
+
+function normalizeHeaderValue(
+ value: string | null | undefined,
+ maxLength: number
+): string | undefined {
+ const trimmed = value?.trim()
+ if (!trimmed) return undefined
+ return trimmed.slice(0, maxLength)
+}
+
+function objectMetadata(value: unknown): Record | null {
+ return value && typeof value === 'object' && !Array.isArray(value)
+ ? (value as Record)
+ : null
+}
+
+function escapeJsonPointer(value: string): string {
+ return value.replaceAll('~', '~0').replaceAll('/', '~1')
+}
+
+function safeEqual(left: string, right: string): boolean {
+ const leftBuffer = Buffer.from(left)
+ const rightBuffer = Buffer.from(right)
+ return (
+ leftBuffer.length === rightBuffer.length &&
+ timingSafeEqual(leftBuffer, rightBuffer)
+ )
+}
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/sandbox-management-auth.server.ts b/src/core/shared/sandbox-management-auth.server.ts
index 893a67398..b958e0a34 100644
--- a/src/core/shared/sandbox-management-auth.server.ts
+++ b/src/core/shared/sandbox-management-auth.server.ts
@@ -1,6 +1,6 @@
import 'server-only'
-import { SUPABASE_AUTH_HEADERS } from '@/configs/api'
+import { authHeaders } from '@/configs/api'
import type { AuthContext } from '@/core/server/auth/types'
import type { SandboxManagementAuth } from './sandbox-management-auth'
@@ -9,7 +9,7 @@ export function createSandboxManagementAuth(
teamId: string
): SandboxManagementAuth {
return {
- headers: SUPABASE_AUTH_HEADERS(authContext.accessToken, teamId),
+ headers: authHeaders(authContext.accessToken, teamId),
userId: authContext.user.id,
}
}
diff --git a/src/lib/env.ts b/src/lib/env.ts
index d9a047222..bd3cc08f0 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 2b22904a6..327b8bbe5 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -1,4 +1,12 @@
-import { type NextRequest, NextResponse } from 'next/server'
+import {
+ type NextFetchEvent,
+ type NextRequest,
+ NextResponse,
+} from 'next/server'
+import type { Session } from 'next-auth'
+import { auth as authjsMiddleware } from '@/auth'
+import { isOryAuthEnabled } from './configs/flags'
+import { getOryAuthRouteRedirect } from './core/server/auth/ory/auth-route-redirect'
import {
handleAuthGate,
handleMiddlewareRedirect,
@@ -8,14 +16,18 @@ import {
import { l, serializeErrorForLog } from './core/shared/clients/logger/logger'
// Runs the proxy's ordered concerns: the first handler that returns a Response
-// wins; otherwise we fall through to the auth gate.
-async function proxyCore(request: NextRequest): Promise {
+// wins; otherwise we fall through to the auth gate. `knownAuth` is passed in Ory
+// mode (resolved by the Auth.js middleware wrapper) and omitted in Supabase mode.
+async function proxyCore(
+ request: NextRequest,
+ knownAuth?: boolean
+): Promise {
try {
return (
handleMiddlewareRedirect(request) ??
handleRouteRewritePassthrough(request) ??
handleMiddlewareRewrite(request) ??
- (await handleAuthGate(request))
+ (await handleAuthGate(request, knownAuth))
)
} catch (error) {
l.error(
@@ -31,14 +43,37 @@ async function proxyCore(request: NextRequest): Promise {
)
// return a basic response to avoid infinite loops
- return NextResponse.next({
- request,
- })
+ return NextResponse.next({ request })
}
}
-export async function proxy(request: NextRequest) {
- return proxyCore(request)
+// req.auth is truthy even when the session carries a RefreshTokenError, so we
+// must check session.error too — otherwise the auth-route guard treats a
+// poisoned session as "logged in" and ping-pongs the user between /dashboard
+// (redirects to /sign-in via getAuthContext()) and /sign-in (redirects back to
+// /dashboard via the proxy's authenticated-on-auth-route rule).
+function isSessionAuthenticated(session: Session | null): boolean {
+ return !!session && !session.error
+}
+
+// In Ory mode the Auth.js middleware wrapper populates req.auth and manages its
+// session cookies, so auth is resolved here and threaded into proxyCore. Auth
+// pages still bypass the local UI, but only after checking whether an existing
+// session should send the user back to the dashboard instead of the hosted UI.
+const proxyWithOryAuth = authjsMiddleware((req, _event: NextFetchEvent) => {
+ const isAuthenticated = isSessionAuthenticated(req.auth)
+ const authRouteRedirect = getOryAuthRouteRedirect(req, isAuthenticated)
+ if (authRouteRedirect) return authRouteRedirect
+
+ return proxyCore(req, isAuthenticated)
+})
+
+export async function proxy(request: NextRequest, event: NextFetchEvent) {
+ if (!isOryAuthEnabled()) {
+ return proxyCore(request)
+ }
+
+ return proxyWithOryAuth(request, event)
}
export const config = {
diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts
new file mode 100644
index 000000000..02a041a60
--- /dev/null
+++ b/src/types/next-auth.d.ts
@@ -0,0 +1,28 @@
+import type { DefaultSession } from 'next-auth'
+
+declare module 'next-auth' {
+ interface Session {
+ // Ory access token forwarded to dashboard-api/infra from server code.
+ accessToken?: string
+ // Ory ID token used server-side for re-auth freshness and Ory logout.
+ idToken?: string
+ // Kratos identity id resolved from Ory at sign-in. This can differ from
+ // user.id, which is the OIDC subject / dashboard E2B user id.
+ identityId?: string
+ error?: string
+ user: {
+ id: string
+ } & DefaultSession['user']
+ }
+}
+
+declare module 'next-auth/jwt' {
+ interface JWT {
+ accessToken?: string
+ refreshToken?: string
+ idToken?: string
+ identityId?: string
+ expiresAt?: number | null
+ error?: string
+ }
+}
diff --git a/tests/integration/auth-ory-dashboard-bootstrap.test.ts b/tests/integration/auth-ory-dashboard-bootstrap.test.ts
new file mode 100644
index 000000000..ca49c0b95
--- /dev/null
+++ b/tests/integration/auth-ory-dashboard-bootstrap.test.ts
@@ -0,0 +1,171 @@
+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 listUserTeamsMock = 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,
+ },
+}))
+
+vi.mock('@/core/modules/teams/user-teams-repository.server', () => ({
+ createUserTeamsRepository: vi.fn(() => ({
+ listUserTeams: listUserTeamsMock,
+ })),
+}))
+
+const { bootstrapOryUser, ensureOryUserBootstrapped } = await import(
+ '@/core/server/auth/ory/dashboard-bootstrap'
+)
+
+describe('dashboard bootstrap for Ory users', () => {
+ beforeEach(() => {
+ process.env.DASHBOARD_API_ADMIN_TOKEN = 'admin-token'
+ apiPostMock.mockReset()
+ listUserTeamsMock.mockReset()
+ loggerMocks.error.mockClear()
+ })
+
+ afterEach(() => {
+ process.env.DASHBOARD_API_ADMIN_TOKEN = originalDashboardApiAdminToken
+ })
+
+ it('imports the access-token subject with id_token profile fallback', async () => {
+ apiPostMock.mockResolvedValue({
+ data: { id: 'team-1', slug: 'team-1' },
+ error: null,
+ response: { ok: true, status: 200, statusText: 'OK' },
+ })
+
+ const result = await bootstrapOryUser({
+ accessToken: jwt({
+ iss: 'https://ory.example.test',
+ sub: 'e2b-user-id',
+ }),
+ idToken: jwt({
+ email: 'ada@example.test',
+ given_name: 'Ada',
+ sub: 'kratos-uuid',
+ }),
+ provider: 'ory',
+ })
+
+ expect(result).toBe(true)
+ expect(apiPostMock).toHaveBeenCalledWith('/admin/users/bootstrap', {
+ body: {
+ oidc_issuer: 'https://ory.example.test',
+ oidc_user_id: 'e2b-user-id',
+ oidc_user_email: 'ada@example.test',
+ oidc_user_name: 'Ada',
+ },
+ headers: { 'X-Admin-Token': 'admin-token' },
+ })
+ })
+
+ it('does not bootstrap after a successful lookup returns any team', async () => {
+ listUserTeamsMock.mockResolvedValue({
+ ok: true,
+ data: [{ id: 'team-1', slug: null, isDefault: true }],
+ })
+
+ const result = await ensureOryUserBootstrapped({
+ accessToken: jwt({
+ iss: 'https://ory.example.test',
+ sub: 'e2b-user-id',
+ email: 'ada@example.test',
+ }),
+ provider: 'ory',
+ })
+
+ expect(result).toBe(true)
+ expect(apiPostMock).not.toHaveBeenCalled()
+ })
+
+ it('does not bootstrap when the team lookup fails', async () => {
+ listUserTeamsMock.mockResolvedValue({
+ ok: false,
+ error: new Error('dashboard-api unavailable'),
+ })
+
+ const result = await ensureOryUserBootstrapped({
+ accessToken: jwt({
+ iss: 'https://ory.example.test',
+ sub: 'e2b-user-id',
+ email: 'ada@example.test',
+ }),
+ provider: 'ory',
+ })
+
+ expect(result).toBe(false)
+ expect(apiPostMock).not.toHaveBeenCalled()
+ })
+
+ it('bootstraps only after a successful empty team lookup', async () => {
+ listUserTeamsMock.mockResolvedValue({ ok: true, data: [] })
+ apiPostMock.mockResolvedValue({
+ data: { id: 'team-1', slug: 'team-1' },
+ error: null,
+ response: { ok: true, status: 200, statusText: 'OK' },
+ })
+
+ const result = await ensureOryUserBootstrapped({
+ accessToken: jwt({
+ iss: 'https://ory.example.test',
+ sub: 'e2b-user-id',
+ email: 'ada@example.test',
+ }),
+ provider: 'ory',
+ })
+
+ expect(result).toBe(true)
+ expect(apiPostMock).toHaveBeenCalledTimes(1)
+ })
+
+ it('denies bootstrap confirmation when the admin bootstrap call fails', async () => {
+ listUserTeamsMock.mockResolvedValue({ ok: true, data: [] })
+ apiPostMock.mockResolvedValue({
+ data: null,
+ error: { status: 503, message: 'dashboard-api unavailable' },
+ response: { ok: false, status: 503, statusText: 'Service Unavailable' },
+ })
+
+ const result = await ensureOryUserBootstrapped({
+ accessToken: jwt({
+ iss: 'https://ory.example.test',
+ sub: 'e2b-user-id',
+ email: 'ada@example.test',
+ }),
+ provider: 'ory',
+ })
+
+ expect(result).toBe(false)
+ })
+})
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-admin.test.ts b/tests/unit/auth-ory-admin.test.ts
new file mode 100644
index 000000000..f369b617a
--- /dev/null
+++ b/tests/unit/auth-ory-admin.test.ts
@@ -0,0 +1,83 @@
+import { ResponseError } from '@ory/client-fetch'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const loggerMocks = vi.hoisted(() => ({
+ error: vi.fn(),
+ warn: vi.fn(),
+ info: vi.fn(),
+ debug: vi.fn(),
+}))
+
+const identityApiMocks = vi.hoisted(() => ({
+ getIdentity: vi.fn(),
+ getIdentityByExternalID: vi.fn(),
+ listIdentities: vi.fn(),
+}))
+
+vi.mock('@/core/shared/clients/logger/logger', () => ({
+ l: loggerMocks,
+ serializeErrorForLog: vi.fn((error: unknown) => error),
+}))
+
+vi.mock('@/core/server/auth/ory/client', () => ({
+ getOryIdentityApi: () => identityApiMocks,
+}))
+
+const { oryAuthAdmin } = await import('@/core/server/auth/ory/admin')
+
+function notFound(): ResponseError {
+ return new ResponseError(new Response(null, { status: 404 }), 'not found')
+}
+
+describe('oryAuthAdmin', () => {
+ beforeEach(() => {
+ identityApiMocks.getIdentity.mockReset()
+ identityApiMocks.getIdentityByExternalID.mockReset()
+ identityApiMocks.listIdentities.mockReset()
+ loggerMocks.error.mockClear()
+ })
+
+ it('returns AuthUser keyed by the requested app user id', async () => {
+ identityApiMocks.getIdentity.mockRejectedValue(notFound())
+ identityApiMocks.getIdentityByExternalID.mockResolvedValue({
+ id: 'kratos-uuid',
+ traits: { email: 'ada@example.test', name: 'Ada' },
+ credentials: { password: { config: { hashed_password: 'hash' } } },
+ })
+
+ const user = await oryAuthAdmin.getUserById('e2b-user-id')
+
+ expect(identityApiMocks.getIdentity).toHaveBeenCalledWith({
+ id: 'e2b-user-id',
+ includeCredential: ['password', 'oidc'],
+ })
+ expect(identityApiMocks.getIdentityByExternalID).toHaveBeenCalledWith({
+ externalID: 'e2b-user-id',
+ includeCredential: ['password', 'oidc'],
+ })
+ expect(user).toEqual(
+ expect.objectContaining({
+ id: 'e2b-user-id',
+ email: 'ada@example.test',
+ providers: ['email'],
+ })
+ )
+ })
+
+ it('resolves emails by app user id when the Kratos id differs', async () => {
+ identityApiMocks.listIdentities.mockResolvedValue([])
+ identityApiMocks.getIdentity.mockRejectedValue(notFound())
+ identityApiMocks.getIdentityByExternalID.mockResolvedValue({
+ id: 'kratos-uuid',
+ traits: { email: 'ada@example.test' },
+ })
+
+ const emails = await oryAuthAdmin.getEmailsByIds(['e2b-user-id'])
+
+ expect(identityApiMocks.listIdentities).toHaveBeenCalledWith({
+ ids: ['e2b-user-id'],
+ pageSize: 1,
+ })
+ expect(emails.get('e2b-user-id')).toBe('ada@example.test')
+ })
+})
diff --git a/tests/unit/auth-ory-authjs-callbacks.test.ts b/tests/unit/auth-ory-authjs-callbacks.test.ts
new file mode 100644
index 000000000..47ec2e39c
--- /dev/null
+++ b/tests/unit/auth-ory-authjs-callbacks.test.ts
@@ -0,0 +1,189 @@
+import type { Session } from 'next-auth'
+import type { JWT } from 'next-auth/jwt'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const resolveIdentityMock = vi.hoisted(() => vi.fn())
+const refreshOryTokenMock = vi.hoisted(() => vi.fn())
+const ensureBootstrappedMock = vi.hoisted(() => vi.fn())
+const persistSignupMetadataMock = vi.hoisted(() => vi.fn())
+const cookieSetMock = vi.hoisted(() => vi.fn())
+const loggerMocks = vi.hoisted(() => ({
+ error: vi.fn(),
+ warn: vi.fn(),
+ info: vi.fn(),
+ debug: vi.fn(),
+}))
+
+vi.mock('next/headers', () => ({
+ cookies: vi.fn(() => Promise.resolve({ set: cookieSetMock })),
+}))
+
+vi.mock('@/core/server/auth/ory/find-identity', () => ({
+ resolveOryIdentity: resolveIdentityMock,
+}))
+
+vi.mock('@/core/server/auth/ory/dashboard-bootstrap', () => ({
+ ensureOryUserBootstrapped: ensureBootstrappedMock,
+}))
+
+vi.mock('@/core/server/auth/ory/refresh-token', () => ({
+ refreshOryToken: refreshOryTokenMock,
+}))
+
+vi.mock('@/core/server/auth/ory/signup-metadata', () => ({
+ persistOrySignupMetadataFromCookie: persistSignupMetadataMock,
+}))
+
+vi.mock('@/core/shared/clients/logger/logger', () => ({
+ l: loggerMocks,
+ serializeErrorForLog: vi.fn((error: unknown) => error),
+}))
+
+const {
+ handleOryAuthJsSignIn,
+ persistOryTokensInAuthJsJwt,
+ projectOryJwtToAuthJsSession,
+} = await import('@/core/server/auth/ory/authjs-callbacks')
+
+function makeJwt(claims: Record): string {
+ const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString(
+ 'base64url'
+ )
+ const payload = Buffer.from(JSON.stringify(claims)).toString('base64url')
+ return `${header}.${payload}.sig`
+}
+
+describe('handleOryAuthJsSignIn', () => {
+ beforeEach(() => {
+ ensureBootstrappedMock.mockReset()
+ cookieSetMock.mockReset()
+ loggerMocks.error.mockClear()
+ })
+
+ it('allows sign-in only after dashboard bootstrap is confirmed', async () => {
+ ensureBootstrappedMock.mockResolvedValue(true)
+
+ const result = await handleOryAuthJsSignIn({
+ account: {
+ provider: 'ory',
+ type: 'oidc',
+ providerAccountId: 'x',
+ access_token: 'at',
+ id_token: 'it',
+ },
+ })
+
+ expect(result).toBe(true)
+ expect(ensureBootstrappedMock).toHaveBeenCalledWith({
+ accessToken: 'at',
+ idToken: 'it',
+ provider: 'ory',
+ })
+ })
+
+ it('redirects to the bootstrap-failed logout flow on bootstrap failure', async () => {
+ ensureBootstrappedMock.mockResolvedValue(false)
+
+ const result = await handleOryAuthJsSignIn({
+ account: {
+ provider: 'ory',
+ type: 'oidc',
+ providerAccountId: 'x',
+ access_token: 'at',
+ id_token: 'id-token',
+ },
+ })
+
+ expect(result).toBe('/api/auth/oauth/bootstrap-failed')
+ expect(cookieSetMock).toHaveBeenCalledWith(
+ 'e2b-ory-bootstrap-failed-id-token',
+ 'id-token',
+ expect.objectContaining({ httpOnly: true, maxAge: 60 })
+ )
+ })
+})
+
+describe('persistOryTokensInAuthJsJwt', () => {
+ beforeEach(() => {
+ resolveIdentityMock.mockReset()
+ refreshOryTokenMock.mockReset()
+ persistSignupMetadataMock.mockReset()
+ })
+
+ it('uses the Hydra access-token subject as the app user id', async () => {
+ resolveIdentityMock.mockResolvedValue({ id: 'kratos-uuid' })
+ const accessToken = makeJwt({ sub: 'e2b-user-id' })
+
+ const result = await persistOryTokensInAuthJsJwt({
+ token: { sub: 'profile-sub-before-access-token' } as JWT,
+ account: {
+ provider: 'ory',
+ type: 'oidc',
+ providerAccountId: 'x',
+ access_token: accessToken,
+ refresh_token: 'rt',
+ id_token: makeJwt({ email: 'ada@example.test' }),
+ expires_at: 1234,
+ },
+ profile: { sub: 'profile-sub' },
+ })
+
+ expect(resolveIdentityMock).toHaveBeenCalledWith({
+ subjects: ['profile-sub', 'e2b-user-id'],
+ email: 'ada@example.test',
+ })
+ expect(result).toMatchObject({
+ sub: 'e2b-user-id',
+ accessToken,
+ refreshToken: 'rt',
+ expiresAt: 1234,
+ identityId: 'kratos-uuid',
+ })
+ expect(persistSignupMetadataMock).toHaveBeenCalledWith('kratos-uuid')
+ })
+
+ it('refreshes when the access token is near expiry', async () => {
+ refreshOryTokenMock.mockResolvedValue({ accessToken: 'fresh' })
+
+ const result = await persistOryTokensInAuthJsJwt({
+ token: { expiresAt: Math.floor(Date.now() / 1000) + 30 } as JWT,
+ account: null,
+ })
+
+ expect(refreshOryTokenMock).toHaveBeenCalled()
+ expect(result).toEqual({ accessToken: 'fresh' })
+ })
+
+ it('refreshes when token expiry is missing but a refresh token is present', async () => {
+ refreshOryTokenMock.mockResolvedValue({ accessToken: 'fresh' })
+
+ const result = await persistOryTokensInAuthJsJwt({
+ token: { refreshToken: 'rt', expiresAt: null } as JWT,
+ account: null,
+ })
+
+ expect(refreshOryTokenMock).toHaveBeenCalled()
+ expect(result).toEqual({ accessToken: 'fresh' })
+ })
+})
+
+describe('projectOryJwtToAuthJsSession', () => {
+ it('projects token fields onto the session', () => {
+ const session = { user: { id: 'placeholder' } } as Session
+
+ const result = projectOryJwtToAuthJsSession({
+ session,
+ token: {
+ sub: 'e2b-user-id',
+ accessToken: 'at',
+ idToken: 'it',
+ identityId: 'kratos-uuid',
+ } as JWT,
+ })
+
+ expect(result.user.id).toBe('e2b-user-id')
+ expect(result.accessToken).toBe('at')
+ expect(result.idToken).toBe('it')
+ expect(result.identityId).toBe('kratos-uuid')
+ })
+})
diff --git a/tests/unit/auth-ory-build-start-url.test.ts b/tests/unit/auth-ory-build-start-url.test.ts
new file mode 100644
index 000000000..1bf413917
--- /dev/null
+++ b/tests/unit/auth-ory-build-start-url.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest'
+import {
+ buildOryStartURL,
+ normalizeOryReturnTo,
+} from '@/core/server/auth/ory/build-start-url'
+
+describe('buildOryStartURL', () => {
+ it('preserves safe relative returnTo values', () => {
+ expect(buildOryStartURL('reauth', '/dashboard/account?reauth=1')).toBe(
+ '/api/auth/oauth-start?intent=reauth&returnTo=%2Fdashboard%2Faccount%3Freauth%3D1'
+ )
+ })
+
+ it('drops unsafe returnTo values', () => {
+ expect(normalizeOryReturnTo('https://evil.test/dashboard')).toBeUndefined()
+ expect(normalizeOryReturnTo('//evil.test/dashboard')).toBeUndefined()
+ expect(normalizeOryReturnTo('javascript:alert(1)')).toBeUndefined()
+ expect(buildOryStartURL('signin', '//evil.test/dashboard')).toBe(
+ '/api/auth/oauth-start?intent=signin'
+ )
+ })
+})
diff --git a/tests/unit/auth-ory-error.test.ts b/tests/unit/auth-ory-error.test.ts
new file mode 100644
index 000000000..540ccf225
--- /dev/null
+++ b/tests/unit/auth-ory-error.test.ts
@@ -0,0 +1,39 @@
+import { ResponseError } from '@ory/client-fetch'
+import { describe, expect, it } from 'vitest'
+import { readOryError } from '@/core/server/auth/ory/ory-error'
+
+function responseWithUrl(url: string): Response {
+ const response = new Response(
+ JSON.stringify({
+ error: {
+ code: 401,
+ message: 'not authorized',
+ id: 'req-id',
+ },
+ }),
+ { status: 401 }
+ )
+ Object.defineProperty(response, 'url', { value: url })
+ return response
+}
+
+describe('readOryError', () => {
+ it('keeps only the response path for logs', async () => {
+ const details = await readOryError(
+ new ResponseError(
+ responseWithUrl(
+ 'https://project.oryapis.com/admin/identities?email=user%40example.com'
+ )
+ )
+ )
+
+ expect(details).toEqual({
+ status: 401,
+ path: '/admin/identities',
+ code: 401,
+ message: 'not authorized',
+ request_id: 'req-id',
+ })
+ expect(details).not.toHaveProperty('url')
+ })
+})
diff --git a/tests/unit/auth-ory-find-identity.test.ts b/tests/unit/auth-ory-find-identity.test.ts
new file mode 100644
index 000000000..4e830698a
--- /dev/null
+++ b/tests/unit/auth-ory-find-identity.test.ts
@@ -0,0 +1,100 @@
+import { ResponseError } from '@ory/client-fetch'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const loggerMocks = vi.hoisted(() => ({
+ error: vi.fn(),
+ warn: vi.fn(),
+ info: vi.fn(),
+ debug: vi.fn(),
+}))
+
+const getIdentityMock = vi.hoisted(() => vi.fn())
+const getIdentityByExternalIDMock = vi.hoisted(() => vi.fn())
+const listIdentitiesMock = vi.hoisted(() => vi.fn())
+
+vi.mock('@/core/shared/clients/logger/logger', () => ({
+ l: loggerMocks,
+ serializeErrorForLog: vi.fn((error: unknown) => error),
+}))
+
+vi.mock('@/core/server/auth/ory/client', () => ({
+ getOryIdentityApi: () => ({
+ getIdentity: getIdentityMock,
+ getIdentityByExternalID: getIdentityByExternalIDMock,
+ listIdentities: listIdentitiesMock,
+ }),
+}))
+
+const { resolveOryIdentity, findOryIdentityBySubject, findOryIdentityByEmail } =
+ await import('@/core/server/auth/ory/find-identity')
+
+function notFound(): ResponseError {
+ return new ResponseError(new Response(null, { status: 404 }), 'not found')
+}
+
+beforeEach(() => {
+ getIdentityMock.mockReset()
+ getIdentityByExternalIDMock.mockReset()
+ listIdentitiesMock.mockReset()
+ loggerMocks.error.mockClear()
+})
+
+describe('findOryIdentityBySubject', () => {
+ it('falls back from Kratos id to external_id and preserves credential includes', async () => {
+ getIdentityMock.mockRejectedValue(notFound())
+ getIdentityByExternalIDMock.mockResolvedValue({ id: 'kratos-uuid' })
+
+ const identity = await findOryIdentityBySubject('e2b-user-id', [
+ 'password',
+ 'oidc',
+ ])
+
+ expect(identity).toEqual({ id: 'kratos-uuid' })
+ expect(getIdentityMock).toHaveBeenCalledWith({
+ id: 'e2b-user-id',
+ includeCredential: ['password', 'oidc'],
+ })
+ expect(getIdentityByExternalIDMock).toHaveBeenCalledWith({
+ externalID: 'e2b-user-id',
+ includeCredential: ['password', 'oidc'],
+ })
+ })
+})
+
+describe('findOryIdentityByEmail', () => {
+ it('queries by credentials identifier and prefers an exact email trait match', async () => {
+ listIdentitiesMock.mockResolvedValue([
+ { id: 'other', traits: { email: 'someone@else.test' } },
+ { id: 'match', traits: { email: 'Ada@Example.test' } },
+ ])
+
+ const identity = await findOryIdentityByEmail('ada@example.test', [
+ 'password',
+ 'oidc',
+ ])
+
+ expect(identity?.id).toBe('match')
+ expect(listIdentitiesMock).toHaveBeenCalledWith({
+ credentialsIdentifier: 'ada@example.test',
+ pageSize: 2,
+ includeCredential: ['password', 'oidc'],
+ })
+ })
+})
+
+describe('resolveOryIdentity', () => {
+ it('falls back to verified email when subject lookup misses', async () => {
+ getIdentityMock.mockRejectedValue(notFound())
+ getIdentityByExternalIDMock.mockRejectedValue(notFound())
+ listIdentitiesMock.mockResolvedValue([
+ { id: 'kratos-uuid', traits: { email: 'ada@example.test' } },
+ ])
+
+ const identity = await resolveOryIdentity({
+ subjects: ['e2b-user-id'],
+ email: 'ada@example.test',
+ })
+
+ expect(identity?.id).toBe('kratos-uuid')
+ })
+})
diff --git a/tests/unit/auth-ory-flows.test.ts b/tests/unit/auth-ory-flows.test.ts
new file mode 100644
index 000000000..0d00c8300
--- /dev/null
+++ b/tests/unit/auth-ory-flows.test.ts
@@ -0,0 +1,171 @@
+import { ResponseError } from '@ory/client-fetch'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const loggerMocks = vi.hoisted(() => ({
+ error: vi.fn(),
+ warn: vi.fn(),
+ info: vi.fn(),
+ debug: vi.fn(),
+}))
+
+const patchIdentityMock = vi.hoisted(() => vi.fn())
+const getIdentityMock = vi.hoisted(() => vi.fn())
+const updateIdentityMock = vi.hoisted(() => vi.fn())
+
+vi.mock('@/core/shared/clients/logger/logger', () => ({
+ l: loggerMocks,
+ serializeErrorForLog: vi.fn((error: unknown) => error),
+}))
+
+vi.mock('@/core/server/auth/ory/client', () => ({
+ getOryIdentityApi: () => ({
+ patchIdentity: patchIdentityMock,
+ getIdentity: getIdentityMock,
+ updateIdentity: updateIdentityMock,
+ }),
+}))
+
+const { oryAuthFlows } = await import('@/core/server/auth/ory/flows')
+
+function oryError(
+ status: number,
+ body: Record
+): ResponseError {
+ return new ResponseError(
+ new Response(JSON.stringify(body), { status }),
+ 'Response returned an error code'
+ )
+}
+
+describe('oryAuthFlows.updateUser', () => {
+ beforeEach(() => {
+ patchIdentityMock.mockReset()
+ getIdentityMock.mockReset()
+ updateIdentityMock.mockReset()
+ loggerMocks.error.mockClear()
+ })
+
+ it('patches only the provided traits and returns the mapped user', async () => {
+ patchIdentityMock.mockResolvedValue({})
+ getIdentityMock.mockResolvedValue({
+ id: 'identity-1',
+ traits: { email: 'new@example.test', name: 'Ada' },
+ credentials: { password: { config: { hashed_password: 'hash' } } },
+ })
+
+ const result = await oryAuthFlows.updateUser({
+ identityId: 'identity-1',
+ name: 'Ada',
+ email: 'new@example.test',
+ })
+
+ expect(patchIdentityMock).toHaveBeenCalledWith({
+ id: 'identity-1',
+ jsonPatch: [
+ { op: 'replace', path: '/traits/name', value: 'Ada' },
+ { op: 'replace', path: '/traits/email', value: 'new@example.test' },
+ ],
+ })
+ expect(getIdentityMock).toHaveBeenCalledWith({
+ id: 'identity-1',
+ includeCredential: ['password', 'oidc'],
+ })
+ expect(result).toEqual({
+ ok: true,
+ user: expect.objectContaining({
+ id: 'identity-1',
+ email: 'new@example.test',
+ name: 'Ada',
+ // `password` credential is normalized to the `email` provider vocabulary
+ providers: ['email'],
+ }),
+ })
+ })
+
+ it('sets the password via updateIdentity (import path) so Kratos hashes it', async () => {
+ getIdentityMock
+ .mockResolvedValueOnce({
+ id: 'identity-1',
+ schema_id: 'default',
+ state: 'active',
+ traits: { email: 'a@b.test', name: 'Ada' },
+ external_id: 'legacy-id',
+ })
+ .mockResolvedValueOnce({
+ id: 'identity-1',
+ traits: { email: 'a@b.test' },
+ credentials: { password: { config: { hashed_password: 'hash' } } },
+ })
+ updateIdentityMock.mockResolvedValue({})
+
+ await oryAuthFlows.updateUser({
+ identityId: 'identity-1',
+ password: 'super-secret',
+ })
+
+ // not the raw patch — that writes cleartext without hashing
+ expect(patchIdentityMock).not.toHaveBeenCalled()
+ expect(updateIdentityMock).toHaveBeenCalledWith({
+ id: 'identity-1',
+ updateIdentityBody: expect.objectContaining({
+ schema_id: 'default',
+ state: 'active',
+ external_id: 'legacy-id',
+ traits: { email: 'a@b.test', name: 'Ada' },
+ credentials: { password: { config: { password: 'super-secret' } } },
+ }),
+ })
+ expect(getIdentityMock).toHaveBeenLastCalledWith({
+ id: 'identity-1',
+ includeCredential: ['password', 'oidc'],
+ })
+ })
+
+ it('maps a 409 conflict to email_exists', async () => {
+ patchIdentityMock.mockRejectedValue(
+ oryError(409, {
+ error: { code: 409, reason: 'identity address already exists' },
+ })
+ )
+
+ const result = await oryAuthFlows.updateUser({
+ identityId: 'identity-1',
+ email: 'taken@example.test',
+ })
+
+ expect(result).toEqual({
+ ok: false,
+ code: 'email_exists',
+ message: undefined,
+ })
+ })
+
+ it('maps a 400 password policy violation to weak_password', async () => {
+ getIdentityMock.mockResolvedValue({
+ id: 'identity-1',
+ schema_id: 'default',
+ state: 'active',
+ traits: { email: 'a@b.test' },
+ })
+ updateIdentityMock.mockRejectedValue(
+ oryError(400, {
+ error: {
+ code: 400,
+ reason: 'the password does not fulfill the password policy',
+ message: 'password too short',
+ },
+ })
+ )
+
+ const result = await oryAuthFlows.updateUser({
+ identityId: 'identity-1',
+ password: 'short',
+ })
+
+ expect(result).toEqual({
+ ok: false,
+ code: 'weak_password',
+ message: 'password too short',
+ })
+ })
+})
diff --git a/tests/unit/auth-ory-identity.test.ts b/tests/unit/auth-ory-identity.test.ts
new file mode 100644
index 000000000..b9116bc56
--- /dev/null
+++ b/tests/unit/auth-ory-identity.test.ts
@@ -0,0 +1,56 @@
+import type { Identity } from '@ory/client-fetch'
+import { describe, expect, it } from 'vitest'
+import { fromOryIdentity } from '@/core/server/auth/ory/identity'
+
+function identity(partial: Partial): Identity {
+ return {
+ id: 'identity-1',
+ schema_id: 'default',
+ schema_url: '',
+ traits: {},
+ ...partial,
+ } as Identity
+}
+
+describe('fromOryIdentity', () => {
+ it('uses an explicit app user id when the Kratos id differs', () => {
+ const user = fromOryIdentity(identity({ id: 'kratos-uuid' }), {
+ userId: 'e2b-user-id',
+ })
+
+ expect(user.id).toBe('e2b-user-id')
+ })
+
+ it('maps password credentials to the dashboard email provider vocabulary', () => {
+ const user = fromOryIdentity(
+ identity({
+ traits: { email: 'ada@example.test', name: 'Ada' },
+ credentials: { password: { config: { hashed_password: 'hash' } } },
+ })
+ )
+
+ expect(user).toEqual(
+ expect.objectContaining({
+ email: 'ada@example.test',
+ name: 'Ada',
+ providers: ['email'],
+ canChangeEmail: false,
+ canChangePassword: true,
+ })
+ )
+ })
+
+ it('blocks password changes when an OIDC credential is linked', () => {
+ const user = fromOryIdentity(
+ identity({
+ credentials: {
+ password: { config: { hashed_password: 'hash' } },
+ oidc: { identifiers: ['github:123'] },
+ },
+ })
+ )
+
+ expect(user.canChangeEmail).toBe(false)
+ expect(user.canChangePassword).toBe(false)
+ })
+})
diff --git a/tests/unit/auth-ory-oauth-start-route.test.ts b/tests/unit/auth-ory-oauth-start-route.test.ts
new file mode 100644
index 000000000..24b88ff18
--- /dev/null
+++ b/tests/unit/auth-ory-oauth-start-route.test.ts
@@ -0,0 +1,69 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const signInMock = vi.hoisted(() => vi.fn())
+const readSignupMetadataMock = vi.hoisted(() => vi.fn())
+const setSignupMetadataCookieMock = vi.hoisted(() => vi.fn())
+
+vi.mock('@/auth', () => ({
+ signIn: signInMock,
+}))
+
+vi.mock('@/core/server/auth/ory/signup-metadata', () => ({
+ readOrySignupMetadataFromHeaders: readSignupMetadataMock,
+ setOrySignupMetadataCookie: setSignupMetadataCookieMock,
+}))
+
+const { GET } = await import('@/app/api/auth/oauth-start/route')
+
+describe('oauth-start GET', () => {
+ beforeEach(() => {
+ signInMock.mockReset()
+ signInMock.mockResolvedValue(undefined)
+ readSignupMetadataMock.mockReset()
+ readSignupMetadataMock.mockReturnValue({
+ signup_ip: '203.0.113.10',
+ signup_user_agent: 'Mozilla/5.0',
+ })
+ setSignupMetadataCookieMock.mockReset()
+ setSignupMetadataCookieMock.mockResolvedValue(undefined)
+ })
+
+ it('captures signup metadata before starting Ory registration', async () => {
+ const request = new Request(
+ 'https://app.e2b.dev/api/auth/oauth-start?intent=signup&returnTo=%2Fdashboard',
+ {
+ headers: {
+ 'x-forwarded-for': '203.0.113.10',
+ 'user-agent': 'Mozilla/5.0',
+ },
+ }
+ )
+
+ await GET(request)
+
+ expect(readSignupMetadataMock).toHaveBeenCalledWith(request.headers)
+ expect(setSignupMetadataCookieMock).toHaveBeenCalledWith({
+ signup_ip: '203.0.113.10',
+ signup_user_agent: 'Mozilla/5.0',
+ })
+ expect(signInMock).toHaveBeenCalledWith(
+ 'ory',
+ { redirectTo: '/dashboard' },
+ { prompt: 'registration' }
+ )
+ })
+
+ it('does not capture signup metadata for sign-in', async () => {
+ await GET(
+ new Request('https://app.e2b.dev/api/auth/oauth-start?intent=signin')
+ )
+
+ expect(readSignupMetadataMock).not.toHaveBeenCalled()
+ expect(setSignupMetadataCookieMock).not.toHaveBeenCalled()
+ expect(signInMock).toHaveBeenCalledWith(
+ 'ory',
+ { redirectTo: '/dashboard' },
+ undefined
+ )
+ })
+})
diff --git a/tests/unit/auth-ory-provider-account.test.ts b/tests/unit/auth-ory-provider-account.test.ts
new file mode 100644
index 000000000..1a1f8c26c
--- /dev/null
+++ b/tests/unit/auth-ory-provider-account.test.ts
@@ -0,0 +1,160 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const loggerMocks = vi.hoisted(() => ({
+ error: vi.fn(),
+ warn: vi.fn(),
+ info: vi.fn(),
+ debug: vi.fn(),
+}))
+
+const authjsMock = vi.hoisted(() => vi.fn())
+const updateUserMock = vi.hoisted(() => vi.fn())
+const revokeSessionsMock = vi.hoisted(() => vi.fn())
+const resolveIdentityMock = vi.hoisted(() => vi.fn())
+
+vi.mock('@/core/shared/clients/logger/logger', () => ({
+ l: loggerMocks,
+ serializeErrorForLog: vi.fn((error: unknown) => error),
+}))
+
+vi.mock('@/auth', () => ({ auth: authjsMock }))
+
+vi.mock('@/core/server/auth/ory/flows', () => ({
+ oryAuthFlows: { updateUser: updateUserMock },
+}))
+
+vi.mock('@/core/server/auth/ory/find-identity', () => ({
+ resolveOryIdentity: resolveIdentityMock,
+}))
+
+vi.mock('@/core/server/auth/ory/kratos-session', () => ({
+ revokeKratosSessionsForIdentity: revokeSessionsMock,
+}))
+
+const { oryAuthProvider } = await import('@/core/server/auth/ory/provider')
+
+function makeIdToken(claims: Record): string {
+ const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString(
+ 'base64url'
+ )
+ const payload = Buffer.from(JSON.stringify(claims)).toString('base64url')
+ return `${header}.${payload}.sig`
+}
+
+const nowSeconds = Math.floor(Date.now() / 1000)
+
+describe('oryAuthProvider account operations', () => {
+ beforeEach(() => {
+ authjsMock.mockReset()
+ updateUserMock.mockReset()
+ revokeSessionsMock.mockReset()
+ resolveIdentityMock.mockReset()
+ resolveIdentityMock.mockResolvedValue({ id: 'kratos-uuid' })
+ })
+
+ it('starts account reauth through the Ory OAuth flow', async () => {
+ const dispatch = await oryAuthProvider.startReauthForAccountSettings()
+
+ expect(dispatch).toEqual({
+ kind: 'redirect',
+ to: '/api/auth/oauth-start?intent=reauth&returnTo=%2Fdashboard%2Faccount%3Freauth%3D1',
+ })
+ })
+
+ it('patches the cached Kratos id but returns the app user id', async () => {
+ authjsMock.mockResolvedValue({
+ user: { id: 'e2b-user-id' },
+ identityId: 'kratos-uuid',
+ accessToken: 'a',
+ idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }),
+ })
+ updateUserMock.mockResolvedValue({
+ ok: true,
+ user: { id: 'kratos-uuid', email: 'ada@example.test' },
+ })
+
+ const result = await oryAuthProvider.updateUser({ name: 'Ada' })
+
+ expect(resolveIdentityMock).not.toHaveBeenCalled()
+ expect(updateUserMock).toHaveBeenCalledWith({
+ identityId: 'kratos-uuid',
+ name: 'Ada',
+ email: undefined,
+ password: undefined,
+ })
+ expect(result).toEqual({
+ ok: true,
+ user: { id: 'e2b-user-id', email: 'ada@example.test' },
+ })
+ })
+
+ it('resolves a Kratos id when the session only has the app user id', async () => {
+ authjsMock.mockResolvedValue({
+ user: { id: 'e2b-user-id' },
+ accessToken: 'a',
+ idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }),
+ })
+ updateUserMock.mockResolvedValue({ ok: true, user: { id: 'kratos-uuid' } })
+
+ await oryAuthProvider.updateUser({ name: 'Ada' })
+
+ expect(resolveIdentityMock).toHaveBeenCalledWith(
+ expect.objectContaining({ subjects: ['e2b-user-id'] })
+ )
+ expect(updateUserMock).toHaveBeenCalledWith(
+ expect.objectContaining({ identityId: 'kratos-uuid' })
+ )
+ })
+
+ it('requires fresh authentication before changing credentials', async () => {
+ authjsMock.mockResolvedValue({
+ user: { id: 'e2b-user-id' },
+ accessToken: 'a',
+ idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }),
+ })
+
+ const result = await oryAuthProvider.updateUser({
+ password: 'new-secret',
+ })
+
+ expect(result).toEqual({ ok: false, code: 'reauthentication_needed' })
+ expect(updateUserMock).not.toHaveBeenCalled()
+ })
+
+ it('forwards a credential change when auth_time is fresh', async () => {
+ authjsMock.mockResolvedValue({
+ user: { id: 'e2b-user-id' },
+ identityId: 'kratos-uuid',
+ accessToken: 'a',
+ idToken: makeIdToken({ auth_time: nowSeconds - 30 }),
+ })
+ updateUserMock.mockResolvedValue({
+ ok: true,
+ user: { id: 'kratos-uuid' },
+ })
+
+ const result = await oryAuthProvider.updateUser({
+ password: 'new-secret',
+ })
+
+ expect(updateUserMock).toHaveBeenCalledWith({
+ identityId: 'kratos-uuid',
+ name: undefined,
+ email: undefined,
+ password: 'new-secret',
+ })
+ expect(result).toEqual({ ok: true, user: { id: 'e2b-user-id' } })
+ })
+
+ it('revokes other sessions by Kratos identity id', async () => {
+ authjsMock.mockResolvedValue({
+ user: { id: 'e2b-user-id' },
+ identityId: 'kratos-uuid',
+ accessToken: 'a',
+ })
+
+ await oryAuthProvider.signOutOtherSessions()
+
+ expect(revokeSessionsMock).toHaveBeenCalledWith('kratos-uuid')
+ })
+})
diff --git a/tests/unit/auth-ory-provider-profile.test.ts b/tests/unit/auth-ory-provider-profile.test.ts
new file mode 100644
index 000000000..ad5b9d9fe
--- /dev/null
+++ b/tests/unit/auth-ory-provider-profile.test.ts
@@ -0,0 +1,113 @@
+import { ResponseError } from '@ory/client-fetch'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const loggerMocks = vi.hoisted(() => ({
+ error: vi.fn(),
+ warn: vi.fn(),
+ info: vi.fn(),
+ debug: vi.fn(),
+}))
+
+const authjsMock = vi.hoisted(() => vi.fn())
+const getIdentityMock = vi.hoisted(() => vi.fn())
+const getIdentityByExternalIDMock = vi.hoisted(() => vi.fn())
+
+vi.mock('@/core/shared/clients/logger/logger', () => ({
+ l: loggerMocks,
+ serializeErrorForLog: vi.fn((error: unknown) => error),
+}))
+
+vi.mock('@/auth', () => ({
+ auth: authjsMock,
+}))
+
+vi.mock('@/core/server/auth/ory/client', () => ({
+ getOryIdentityApi: () => ({
+ getIdentity: getIdentityMock,
+ getIdentityByExternalID: getIdentityByExternalIDMock,
+ }),
+}))
+
+const { oryAuthProvider } = await import('@/core/server/auth/ory/provider')
+
+describe('oryAuthProvider.getUserProfile', () => {
+ beforeEach(() => {
+ authjsMock.mockReset()
+ getIdentityMock.mockReset()
+ getIdentityByExternalIDMock.mockReset()
+ })
+
+ it('returns a live Kratos profile keyed by the app user id', async () => {
+ authjsMock.mockResolvedValue({
+ user: { id: 'e2b-user-id' },
+ identityId: 'kratos-uuid',
+ })
+ getIdentityMock.mockResolvedValue({
+ id: 'kratos-uuid',
+ traits: { email: 'ada@example.test', name: 'Ada' },
+ credentials: {
+ password: {
+ config: { hashed_password: 'hash' },
+ },
+ },
+ })
+
+ const profile = await oryAuthProvider.getUserProfile()
+
+ expect(getIdentityMock).toHaveBeenCalledWith({
+ id: 'kratos-uuid',
+ includeCredential: ['password', 'oidc'],
+ })
+ expect(profile).toEqual({
+ id: 'e2b-user-id',
+ email: 'ada@example.test',
+ name: 'Ada',
+ avatarUrl: null,
+ providers: ['email'],
+ canChangeEmail: false,
+ canChangePassword: true,
+ })
+ })
+
+ it('falls back to external_id when the app user id is not a Kratos id', async () => {
+ authjsMock.mockResolvedValue({ user: { id: 'e2b-user-id' } })
+ getIdentityMock.mockRejectedValue(
+ new ResponseError(new Response(null, { status: 404 }), 'not found')
+ )
+ getIdentityByExternalIDMock.mockResolvedValue({
+ id: 'kratos-uuid',
+ traits: { email: 'ada@example.test' },
+ credentials: { password: { config: { hashed_password: 'hash' } } },
+ })
+
+ const profile = await oryAuthProvider.getUserProfile()
+
+ expect(getIdentityByExternalIDMock).toHaveBeenCalledWith({
+ externalID: 'e2b-user-id',
+ includeCredential: ['password', 'oidc'],
+ })
+ expect(profile?.id).toBe('e2b-user-id')
+ expect(profile?.providers).toEqual(['email'])
+ })
+
+ it('does not allow password changes for OIDC-linked identities', async () => {
+ authjsMock.mockResolvedValue({ user: { id: 'identity-1' } })
+ getIdentityMock.mockResolvedValue({
+ id: 'identity-1',
+ traits: { email: 'ada@example.test' },
+ credentials: {
+ password: { config: { hashed_password: 'hash' } },
+ oidc: { identifiers: ['github:123'] },
+ },
+ })
+
+ const profile = await oryAuthProvider.getUserProfile()
+
+ expect(profile).toEqual(
+ expect.objectContaining({
+ canChangeEmail: false,
+ canChangePassword: false,
+ })
+ )
+ })
+})
diff --git a/tests/unit/auth-ory-provider.test.ts b/tests/unit/auth-ory-provider.test.ts
new file mode 100644
index 000000000..41bad8254
--- /dev/null
+++ b/tests/unit/auth-ory-provider.test.ts
@@ -0,0 +1,74 @@
+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.getAuthContext', () => {
+ beforeEach(() => {
+ authjsMock.mockReset()
+ loggerMocks.warn.mockClear()
+ })
+
+ it('treats a session refresh error as unauthenticated', 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',
+ }),
+ expect.stringContaining("error 'RefreshTokenError'")
+ )
+ })
+
+ it('returns AuthContext from a valid Auth.js 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: [],
+ canChangeEmail: false,
+ canChangePassword: false,
+ },
+ accessToken: 'access-token',
+ })
+ })
+})
diff --git a/tests/unit/auth-ory-refresh-token.test.ts b/tests/unit/auth-ory-refresh-token.test.ts
new file mode 100644
index 000000000..bf9beeb37
--- /dev/null
+++ b/tests/unit/auth-ory-refresh-token.test.ts
@@ -0,0 +1,47 @@
+import type { JWT } from 'next-auth/jwt'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/core/shared/clients/logger/logger', () => ({
+ l: {
+ error: vi.fn(),
+ warn: vi.fn(),
+ },
+ serializeErrorForLog: vi.fn((error: unknown) => error),
+}))
+
+const { refreshOryToken } = await import('@/core/server/auth/ory/refresh-token')
+
+describe('refreshOryToken', () => {
+ afterEach(() => {
+ vi.unstubAllEnvs()
+ vi.unstubAllGlobals()
+ })
+
+ it('builds Basic auth credentials from UTF-8 client credentials', async () => {
+ vi.stubEnv('ORY_SDK_URL', 'https://ory.test')
+ vi.stubEnv('ORY_OAUTH2_CLIENT_ID', 'client')
+ vi.stubEnv('ORY_OAUTH2_CLIENT_SECRET', 'päss')
+
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: vi.fn().mockResolvedValue({
+ access_token: 'fresh-access-token',
+ expires_in: 3600,
+ }),
+ })
+ vi.stubGlobal('fetch', fetchMock)
+
+ await refreshOryToken({ refreshToken: 'refresh-token' } as JWT)
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ 'https://ory.test/oauth2/token',
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: `Basic ${Buffer.from('client:päss', 'utf8').toString(
+ 'base64'
+ )}`,
+ }),
+ })
+ )
+ })
+})
diff --git a/tests/unit/auth-ory-signup-metadata.test.ts b/tests/unit/auth-ory-signup-metadata.test.ts
new file mode 100644
index 000000000..6b4eb603c
--- /dev/null
+++ b/tests/unit/auth-ory-signup-metadata.test.ts
@@ -0,0 +1,135 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const cookieStoreMock = vi.hoisted(() => {
+ let value: string | undefined
+
+ return {
+ set: vi.fn((_: string, nextValue: string) => {
+ value = nextValue
+ }),
+ get: vi.fn(() => (value ? { value } : undefined)),
+ delete: vi.fn(() => {
+ value = undefined
+ }),
+ reset: () => {
+ value = undefined
+ },
+ }
+})
+
+const getIdentityMock = vi.hoisted(() => vi.fn())
+const patchIdentityMock = vi.hoisted(() => vi.fn())
+const loggerMocks = vi.hoisted(() => ({
+ error: vi.fn(),
+ warn: vi.fn(),
+ info: vi.fn(),
+ debug: vi.fn(),
+}))
+
+vi.mock('next/headers', () => ({
+ cookies: vi.fn(() => Promise.resolve(cookieStoreMock)),
+}))
+
+vi.mock('@/core/server/auth/ory/client', () => ({
+ getOryIdentityApi: () => ({
+ getIdentity: getIdentityMock,
+ patchIdentity: patchIdentityMock,
+ }),
+}))
+
+vi.mock('@/core/shared/clients/logger/logger', () => ({
+ l: loggerMocks,
+ serializeErrorForLog: vi.fn((error: unknown) => error),
+}))
+
+const {
+ readOrySignupMetadataFromHeaders,
+ setOrySignupMetadataCookie,
+ persistOrySignupMetadata,
+ persistOrySignupMetadataFromCookie,
+} = await import('@/core/server/auth/ory/signup-metadata')
+
+describe('Ory signup metadata', () => {
+ beforeEach(() => {
+ process.env.AUTH_SECRET = 'test-secret'
+ cookieStoreMock.reset()
+ cookieStoreMock.set.mockClear()
+ cookieStoreMock.get.mockClear()
+ cookieStoreMock.delete.mockClear()
+ getIdentityMock.mockReset()
+ patchIdentityMock.mockReset()
+ loggerMocks.error.mockClear()
+ loggerMocks.warn.mockClear()
+ })
+
+ it('reads the client IP and user agent from request headers', () => {
+ const headers = new Headers({
+ 'x-forwarded-for': '203.0.113.10, 10.0.0.1',
+ 'user-agent': 'Mozilla/5.0',
+ })
+
+ expect(readOrySignupMetadataFromHeaders(headers)).toEqual({
+ signup_ip: '203.0.113.10',
+ signup_user_agent: 'Mozilla/5.0',
+ })
+ })
+
+ it('persists signup metadata from the signed handoff cookie', async () => {
+ getIdentityMock.mockResolvedValue({ id: 'kratos-id', metadata_admin: {} })
+ patchIdentityMock.mockResolvedValue({})
+
+ await setOrySignupMetadataCookie({
+ signup_ip: '203.0.113.10',
+ signup_user_agent: 'Mozilla/5.0',
+ })
+ await persistOrySignupMetadataFromCookie('kratos-id')
+
+ expect(cookieStoreMock.set).toHaveBeenCalledWith(
+ 'e2b-ory-signup-metadata',
+ expect.any(String),
+ expect.objectContaining({ httpOnly: true, sameSite: 'lax' })
+ )
+ expect(cookieStoreMock.delete).toHaveBeenCalledWith(
+ 'e2b-ory-signup-metadata'
+ )
+ expect(patchIdentityMock).toHaveBeenCalledWith({
+ id: 'kratos-id',
+ jsonPatch: [
+ {
+ op: 'add',
+ path: '/metadata_admin/signup_ip',
+ value: '203.0.113.10',
+ },
+ {
+ op: 'add',
+ path: '/metadata_admin/signup_user_agent',
+ value: 'Mozilla/5.0',
+ },
+ ],
+ })
+ })
+
+ it('does not overwrite existing signup metadata', async () => {
+ getIdentityMock.mockResolvedValue({
+ id: 'kratos-id',
+ metadata_admin: { signup_ip: '198.51.100.1' },
+ })
+ patchIdentityMock.mockResolvedValue({})
+
+ await persistOrySignupMetadata('kratos-id', {
+ signup_ip: '203.0.113.10',
+ signup_user_agent: 'Mozilla/5.0',
+ })
+
+ expect(patchIdentityMock).toHaveBeenCalledWith({
+ id: 'kratos-id',
+ jsonPatch: [
+ {
+ op: 'add',
+ path: '/metadata_admin/signup_user_agent',
+ value: 'Mozilla/5.0',
+ },
+ ],
+ })
+ })
+})
diff --git a/tests/unit/bootstrap-failed-route.test.ts b/tests/unit/bootstrap-failed-route.test.ts
new file mode 100644
index 000000000..d55ddfd71
--- /dev/null
+++ b/tests/unit/bootstrap-failed-route.test.ts
@@ -0,0 +1,45 @@
+import { NextRequest } from 'next/server'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const signOutMock = vi.hoisted(() => vi.fn())
+
+vi.mock('@/auth', () => ({ signOut: signOutMock }))
+
+vi.mock('@/core/shared/clients/logger/logger', () => ({
+ l: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
+ serializeErrorForLog: vi.fn((error: unknown) => error),
+}))
+
+const { GET } = await import('@/app/api/auth/oauth/bootstrap-failed/route')
+
+function request(cookie: string): NextRequest {
+ return new NextRequest(
+ 'https://app.e2b.dev/api/auth/oauth/bootstrap-failed',
+ { headers: { cookie } }
+ )
+}
+
+describe('bootstrap-failed GET', () => {
+ beforeEach(() => {
+ signOutMock.mockReset().mockResolvedValue(undefined)
+ vi.stubEnv('ORY_SDK_URL', 'https://project.oryapis.com')
+ })
+
+ afterEach(() => {
+ vi.unstubAllEnvs()
+ })
+
+ it('clears the app session and redirects through Ory logout with the handoff id_token', async () => {
+ const response = await GET(
+ request('e2b-ory-bootstrap-failed-id-token=id.token.sig')
+ )
+ const location = response.headers.get('location') ?? ''
+
+ expect(signOutMock).toHaveBeenCalledWith({ redirect: false })
+ expect(location).toContain('/oauth2/sessions/logout')
+ expect(location).toContain('id_token_hint=id.token.sig')
+ expect(response.cookies.get('e2b-ory-bootstrap-failed-id-token')).toEqual(
+ expect.objectContaining({ value: '' })
+ )
+ })
+})
diff --git a/tests/unit/ory-proxy.test.ts b/tests/unit/ory-proxy.test.ts
new file mode 100644
index 000000000..9315d3e06
--- /dev/null
+++ b/tests/unit/ory-proxy.test.ts
@@ -0,0 +1,84 @@
+import { type NextFetchEvent, NextRequest } from 'next/server'
+import type { Session } from 'next-auth'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const authSession = vi.hoisted(() => ({
+ current: null as Session | null,
+}))
+
+vi.mock('@/auth', () => ({
+ auth: vi.fn(
+ (
+ handler: (
+ request: NextRequest & { auth: Session | null },
+ event: NextFetchEvent
+ ) => Response | Promise
+ ) =>
+ (request: NextRequest, event: NextFetchEvent) => {
+ Object.defineProperty(request, 'auth', {
+ configurable: true,
+ value: authSession.current,
+ })
+ return handler(request as NextRequest & { auth: Session | null }, event)
+ }
+ ),
+}))
+
+vi.mock('@/core/shared/clients/logger/logger', () => ({
+ l: { error: vi.fn() },
+ serializeErrorForLog: vi.fn((error: unknown) => error),
+}))
+
+const { proxy } = await import('@/proxy')
+
+const originalAuthProvider = process.env.AUTH_PROVIDER
+
+function request(path: string): NextRequest {
+ return new NextRequest(`https://app.e2b.dev${path}`)
+}
+
+describe('Ory proxy auth routes', () => {
+ beforeEach(() => {
+ process.env.AUTH_PROVIDER = 'ory'
+ authSession.current = null
+ })
+
+ afterEach(() => {
+ process.env.AUTH_PROVIDER = originalAuthProvider
+ authSession.current = null
+ })
+
+ it('redirects authenticated users from sign-in to the dashboard', async () => {
+ authSession.current = { user: { id: 'user-id' } } as Session
+
+ const response = await proxy(request('/sign-in'), {} as NextFetchEvent)
+
+ expect(response.headers.get('location')).toBe(
+ 'https://app.e2b.dev/dashboard'
+ )
+ })
+
+ it('redirects unauthenticated users from sign-up to Ory registration', async () => {
+ const response = await proxy(
+ request('/sign-up?returnTo=%2Fdashboard%2Fterminal'),
+ {} as NextFetchEvent
+ )
+
+ const location = response.headers.get('location') ?? ''
+ expect(location).toContain('/api/auth/oauth-start?intent=signup')
+ expect(location).toContain('returnTo=%2Fdashboard%2Fterminal')
+ })
+
+ it('treats Auth.js error sessions as unauthenticated on auth routes', async () => {
+ authSession.current = {
+ user: { id: 'user-id' },
+ error: 'RefreshTokenError',
+ } as Session
+
+ const response = await proxy(request('/sign-in'), {} as NextFetchEvent)
+
+ expect(response.headers.get('location')).toContain(
+ '/api/auth/oauth-start?intent=signin'
+ )
+ })
+})
diff --git a/tests/unit/signout-flow.test.ts b/tests/unit/signout-flow.test.ts
new file mode 100644
index 000000000..1e3ecaf92
--- /dev/null
+++ b/tests/unit/signout-flow.test.ts
@@ -0,0 +1,50 @@
+import { NextRequest } from 'next/server'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const authMock = vi.hoisted(() => vi.fn())
+const signOutMock = vi.hoisted(() => vi.fn())
+const revokeKratosSessionsMock = vi.hoisted(() => vi.fn())
+
+vi.mock('@/auth', () => ({ auth: authMock, signOut: signOutMock }))
+
+vi.mock('@/core/server/auth/ory/kratos-session', () => ({
+ revokeKratosSessionsForIdentity: revokeKratosSessionsMock,
+}))
+
+vi.mock('@/core/shared/clients/logger/logger', () => ({
+ l: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
+ serializeErrorForLog: vi.fn((error: unknown) => error),
+}))
+
+const { GET } = await import('@/app/api/auth/oauth/signout-flow/route')
+
+function request(): NextRequest {
+ return new NextRequest('https://app.e2b.dev/api/auth/oauth/signout-flow')
+}
+
+beforeEach(() => {
+ authMock.mockReset()
+ signOutMock.mockReset().mockResolvedValue(undefined)
+ revokeKratosSessionsMock.mockReset().mockResolvedValue(undefined)
+ vi.stubEnv('ORY_SDK_URL', 'https://project.oryapis.com')
+})
+
+afterEach(() => {
+ vi.unstubAllEnvs()
+})
+
+describe('signout-flow GET', () => {
+ it('revokes Kratos sessions and redirects through Hydra logout', async () => {
+ authMock.mockResolvedValue({
+ idToken: 'id.token.sig',
+ identityId: 'kratos-uuid',
+ })
+
+ const response = await GET(request())
+ const location = response.headers.get('location') ?? ''
+
+ expect(revokeKratosSessionsMock).toHaveBeenCalledWith('kratos-uuid')
+ expect(location).toContain('/oauth2/sessions/logout')
+ expect(location).toContain('id_token_hint=id.token.sig')
+ })
+})
diff --git a/vitest.config.ts b/vitest.config.ts
index 121a72515..dd019ff26 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,4 +1,4 @@
-import path from 'path'
+import path from 'node:path'
import { defineConfig } from 'vitest/config'
export default defineConfig({
@@ -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: {