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: {