From 7fd76fb72399c718892f30145b4c80a8500840b5 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Thu, 18 Jun 2026 19:30:33 +0200 Subject: [PATCH 01/29] feat: migrate to ory/elements and replace nextauth logic # Conflicts: # src/core/server/auth/ory/dashboard-bootstrap.ts --- .env.example | 15 +- bun.lock | 13 +- package.json | 3 +- src/app/api/auth/oauth/[...nextauth]/route.ts | 5 - .../api/auth/oauth/bootstrap-failed/route.ts | 66 ----- src/app/api/auth/oauth/callback/ory/route.ts | 107 +++++++ src/app/api/auth/oauth/recover/route.ts | 4 +- src/app/api/auth/oauth/start/route.ts | 74 ++++- src/app/api/auth/sign-out/route.ts | 20 +- src/app/api/trpc/[trpc]/route.ts | 48 ++-- src/app/consent/route.ts | 102 +++++++ src/app/login/page.tsx | 7 - src/app/logout/route.ts | 63 ++++ src/app/recovery/page.tsx | 7 - src/app/registration/page.tsx | 7 - src/app/verification/page.tsx | 10 +- src/auth.ts | 67 ----- src/configs/env-flags.ts | 6 - src/core/server/api/middlewares/auth.ts | 2 +- src/core/server/api/routers/user.ts | 19 +- src/core/server/auth/index.ts | 1 - src/core/server/auth/ory/authjs-boundary.ts | 105 ------- src/core/server/auth/ory/authjs-callbacks.ts | 213 -------------- .../auth/ory/authjs-session-boundary.ts | 133 --------- src/core/server/auth/ory/client.ts | 27 +- .../server/auth/ory/dashboard-bootstrap.ts | 24 +- src/core/server/auth/ory/freshness.ts | 44 +-- src/core/server/auth/ory/identity.ts | 23 +- .../server/auth/ory/kratos-session-edge.ts | 25 ++ src/core/server/auth/ory/oauth-client.ts | 186 ++++++++++++ src/core/server/auth/ory/oauth-flow.ts | 73 +++++ src/core/server/auth/ory/refresh-token.ts | 98 ------- src/core/server/auth/ory/session-cookie.ts | 107 +++++++ src/core/server/auth/ory/session.ts | 176 +++++------- src/core/server/auth/ory/signout-flow.ts | 53 ++-- src/core/server/auth/ory/signout.ts | 17 +- src/core/server/auth/ory/signup-metadata.ts | 148 +++------- src/core/server/auth/ory/token-refresh.ts | 121 ++++++++ src/core/server/proxy/classifier.ts | 6 +- src/core/server/proxy/runtime.ts | 109 +++++-- src/core/server/trpc/init.ts | 5 +- src/lib/env.ts | 21 +- src/types/next-auth.d.ts | 21 -- .../auth-ory-account-security.test.ts | 151 ++++++---- tests/integration/auth-ory-callback.test.ts | 127 ++++++++ tests/integration/auth-ory-consent.test.ts | 106 +++++++ .../integration/auth-ory-entrypoints.test.ts | 270 +++++++++++------- tests/integration/auth-ory-logout.test.ts | 87 ++++++ .../auth-ory-session-boundary.test.ts | 124 -------- tests/setup.ts | 2 +- tests/unit/kratos-session-edge.test.ts | 106 +++++++ tests/unit/oauth-client.test.ts | 109 +++++++ tests/unit/proxy-plan.test.ts | 8 +- tests/unit/session-cookie.test.ts | 81 ++++++ tests/unit/token-refresh.test.ts | 110 +++++++ tests/unit/user-router.test.ts | 13 +- vitest.config.ts | 8 +- 57 files changed, 2196 insertions(+), 1487 deletions(-) delete mode 100644 src/app/api/auth/oauth/[...nextauth]/route.ts delete mode 100644 src/app/api/auth/oauth/bootstrap-failed/route.ts create mode 100644 src/app/api/auth/oauth/callback/ory/route.ts create mode 100644 src/app/consent/route.ts create mode 100644 src/app/logout/route.ts delete mode 100644 src/auth.ts delete mode 100644 src/core/server/auth/ory/authjs-boundary.ts delete mode 100644 src/core/server/auth/ory/authjs-callbacks.ts delete mode 100644 src/core/server/auth/ory/authjs-session-boundary.ts create mode 100644 src/core/server/auth/ory/kratos-session-edge.ts create mode 100644 src/core/server/auth/ory/oauth-client.ts create mode 100644 src/core/server/auth/ory/oauth-flow.ts delete mode 100644 src/core/server/auth/ory/refresh-token.ts create mode 100644 src/core/server/auth/ory/session-cookie.ts create mode 100644 src/core/server/auth/ory/token-refresh.ts delete mode 100644 src/types/next-auth.d.ts create mode 100644 tests/integration/auth-ory-callback.test.ts create mode 100644 tests/integration/auth-ory-consent.test.ts create mode 100644 tests/integration/auth-ory-logout.test.ts delete mode 100644 tests/integration/auth-ory-session-boundary.test.ts create mode 100644 tests/unit/kratos-session-edge.test.ts create mode 100644 tests/unit/oauth-client.test.ts create mode 100644 tests/unit/session-cookie.test.ts create mode 100644 tests/unit/token-refresh.test.ts diff --git a/.env.example b/.env.example index d251db87e..1a4103694 100644 --- a/.env.example +++ b/.env.example @@ -5,12 +5,15 @@ ### Dashboard API admin token used for user bootstrap and creator email hydration DASHBOARD_API_ADMIN_TOKEN=your_dashboard_api_admin_token -### Auth.js configuration -### Generate with `npx auth secret` or `openssl rand -hex 32`. -AUTH_SECRET=your_auth_secret +### JWE key for the encrypted e2b_session cookie (carries the Hydra OIDC tokens). +### Generate with `openssl rand -hex 32`. +E2B_SESSION_SECRET=your_e2b_session_secret ### Ory Network configuration ORY_SDK_URL=https://your-project.projects.oryapis.com +### OIDC issuer for the OAuth client. Falls back to ORY_SDK_URL on Ory Network; +### set explicitly for self-hosted Hydra (e.g. http://localhost:4444). +# ORY_HYDRA_PUBLIC_URL=http://localhost:4444 ORY_OAUTH2_CLIENT_ID=your_ory_oauth2_client_id ORY_OAUTH2_CLIENT_SECRET=your_ory_oauth2_client_secret ### Access-token audience requested from Ory. Must match the backend JWT audience configuration. @@ -18,9 +21,7 @@ ORY_OAUTH2_AUDIENCE=https://api.e2b.dev ### Ory project admin API token used for IdentityApi lookups ORY_PROJECT_API_TOKEN=your_ory_project_api_token -### Custom Ory UI: "true" on Preview/Staging, unset on Production. -# NEXT_PUBLIC_ORY_CUSTOM_UI=true -### Kratos public URL for the custom UI (self-hosted :4433; falls back to ORY_SDK_URL). +### Browser-facing Kratos public URL for the Elements UI (self-hosted :4433; falls back to ORY_SDK_URL). # NEXT_PUBLIC_ORY_SDK_URL=http://localhost:4433 ### Domain for the E2B cluster @@ -41,8 +42,6 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev ### Set both when running self-hosted; leave unset to use Ory Network with the PAT above. # ORY_KRATOS_ADMIN_URL=http://localhost:4434 # ORY_HYDRA_ADMIN_URL=http://localhost:4445 -### Set to 1 outside Vercel-hosted production to allow Auth.js to trust the Host header -# AUTH_TRUST_HOST=1 # ENABLE_USER_BOOTSTRAP=0 diff --git a/bun.lock b/bun.lock index 4d271e3b4..f2bbbc88d 100644 --- a/bun.lock +++ b/bun.lock @@ -70,14 +70,15 @@ "fast-xml-parser": "^5.3.5", "immer": "^10.1.1", "input-otp": "^1.4.2", + "jose": "^6.2.3", "micromatch": "^4.0.8", "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", + "oauth4webapi": "^3.8.6", "openapi-fetch": "^0.14.0", "pathe": "^2.0.3", "pino": "^9.7.0", @@ -150,8 +151,6 @@ "@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=="], @@ -684,8 +683,6 @@ "@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=="], @@ -1604,8 +1601,6 @@ "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=="], @@ -1698,8 +1693,6 @@ "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=="], @@ -2036,8 +2029,6 @@ "@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 256d9e69d..8cbf4a59c 100644 --- a/package.json +++ b/package.json @@ -110,14 +110,15 @@ "fast-xml-parser": "^5.3.5", "immer": "^10.1.1", "input-otp": "^1.4.2", + "jose": "^6.2.3", "micromatch": "^4.0.8", "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", + "oauth4webapi": "^3.8.6", "openapi-fetch": "^0.14.0", "pathe": "^2.0.3", "pino": "^9.7.0", diff --git a/src/app/api/auth/oauth/[...nextauth]/route.ts b/src/app/api/auth/oauth/[...nextauth]/route.ts deleted file mode 100644 index 2da706f20..000000000 --- a/src/app/api/auth/oauth/[...nextauth]/route.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { handlers } from '@/auth' -import { withSanitizedOryAuthJsHandler } from '@/core/server/auth/ory/authjs-session-boundary' - -export const GET = withSanitizedOryAuthJsHandler(handlers.GET) -export const POST = withSanitizedOryAuthJsHandler(handlers.POST) diff --git a/src/app/api/auth/oauth/bootstrap-failed/route.ts b/src/app/api/auth/oauth/bootstrap-failed/route.ts deleted file mode 100644 index a70aec97e..000000000 --- a/src/app/api/auth/oauth/bootstrap-failed/route.ts +++ /dev/null @@ -1,66 +0,0 @@ -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 - - if (!idToken) { - l.warn( - { - key: 'oauth_bootstrap_failed:missing_handoff_cookie', - }, - 'Ignoring bootstrap-failed request without the Ory handoff cookie' - ) - - const response = NextResponse.redirect( - new URL(ORY_POST_LOGOUT_PATH, origin) - ) - response.cookies.delete(ORY_BOOTSTRAP_FAILURE_ID_TOKEN_COOKIE) - return response - } - - 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 = buildOryLogoutUrl({ idToken, origin }) - - if (!logoutUrl) { - l.error( - { - key: 'oauth_bootstrap_failed:missing_logout_context', - context: { - has_id_token: true, - 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/callback/ory/route.ts b/src/app/api/auth/oauth/callback/ory/route.ts new file mode 100644 index 000000000..376f2a65b --- /dev/null +++ b/src/app/api/auth/oauth/callback/ory/route.ts @@ -0,0 +1,107 @@ +import 'server-only' + +import { type NextRequest, NextResponse } from 'next/server' +import { PROTECTED_URLS } from '@/configs/urls' +import { ensureOryUserBootstrapped } from '@/core/server/auth/ory/dashboard-bootstrap' +import { exchangeOryCallback } from '@/core/server/auth/ory/oauth-client' +import { + E2B_OAUTH_FLOW_COOKIE, + OAUTH_CALLBACK_PATH, + parseOryFlowState, +} from '@/core/server/auth/ory/oauth-flow' +import { + E2B_SESSION_COOKIE, + orySessionCookieOptions, + sealOrySession, +} from '@/core/server/auth/ory/session-cookie' +import { + buildOryLogoutUrl, + ORY_POST_LOGOUT_PATH, +} from '@/core/server/auth/ory/signout' +import { ORY_SIGNUP_METADATA_COOKIE } from '@/core/server/auth/ory/signup-metadata' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' + +// Failures land on the recover route, whose one-shot guard retries the flow +// once (via /sign-in → /start, which mints a fresh flow cookie) before bailing +// to home — so a stale/invalid callback can't loop. +const ORY_RECOVER_PATH = '/api/auth/oauth/recover' + +// Hydra redirects here with ?code after Kratos created the session. We exchange +// the code (validating state/nonce/PKCE), provision the dashboard user from the +// id_token, then seal the OIDC tokens into e2b_session. Kratos already owns the +// session at this point — this cookie only carries tokens for API access. +export async function GET(request: NextRequest) { + const origin = request.nextUrl.origin + const flow = parseOryFlowState( + request.cookies.get(E2B_OAUTH_FLOW_COOKIE)?.value + ) + + if (!flow) { + l.warn( + { key: 'oauth_callback:missing_flow_state' }, + 'Ory callback hit without a valid flow-state cookie' + ) + return finalize(NextResponse.redirect(new URL(ORY_RECOVER_PATH, origin))) + } + + let tokens: Awaited> + try { + tokens = await exchangeOryCallback({ + // A genuine global URL — oauth4webapi rejects NextURL (not `instanceof URL`). + currentUrl: new URL(request.url), + expectedState: flow.state, + expectedNonce: flow.nonce, + codeVerifier: flow.codeVerifier, + redirectUri: new URL(OAUTH_CALLBACK_PATH, origin).toString(), + }) + } catch (error) { + l.error( + { + key: 'oauth_callback:exchange_failed', + error: serializeErrorForLog(error), + }, + 'Ory authorization code exchange failed' + ) + return finalize(NextResponse.redirect(new URL(ORY_RECOVER_PATH, origin))) + } + + const bootstrapped = await ensureOryUserBootstrapped({ + accessToken: tokens.accessToken, + idToken: tokens.idToken, + provider: 'ory', + }) + + if (!bootstrapped) { + l.error( + { key: 'oauth_callback:bootstrap_failed' }, + 'dashboard bootstrap failed; ending the Ory session without a dashboard cookie' + ) + // Don't strand the user with a half-provisioned login: end the Ory + Kratos + // session via RP-logout (falling back to home if no id_token is available). + const logoutUrl = tokens.idToken + ? buildOryLogoutUrl({ idToken: tokens.idToken, origin }) + : null + return finalize( + NextResponse.redirect(logoutUrl ?? new URL(ORY_POST_LOGOUT_PATH, origin)) + ) + } + + const sealed = await sealOrySession({ + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + idToken: tokens.idToken, + expiresAt: tokens.expiresAt, + }) + + const destination = flow.returnTo ?? PROTECTED_URLS.DASHBOARD + const response = finalize(NextResponse.redirect(new URL(destination, origin))) + response.cookies.set(E2B_SESSION_COOKIE, sealed, orySessionCookieOptions()) + return response +} + +// Clears the one-shot transient cookies on every exit path. +function finalize(response: NextResponse): NextResponse { + response.cookies.delete(E2B_OAUTH_FLOW_COOKIE) + response.cookies.delete(ORY_SIGNUP_METADATA_COOKIE) + return response +} diff --git a/src/app/api/auth/oauth/recover/route.ts b/src/app/api/auth/oauth/recover/route.ts index 6a7cb3e78..0456606e0 100644 --- a/src/app/api/auth/oauth/recover/route.ts +++ b/src/app/api/auth/oauth/recover/route.ts @@ -12,10 +12,10 @@ export async function GET(request: NextRequest) { l.error( { - key: 'oauth_recover:auth_js_error', + key: 'oauth_recover:flow_failed', context: { error_code: errorCode, already_attempted: alreadyAttempted }, }, - 'Auth.js OAuth flow failed; recovering user' + 'OAuth flow failed; recovering user once before bailing to home' ) const destination = alreadyAttempted ? '/' : AUTH_URLS.SIGN_IN diff --git a/src/app/api/auth/oauth/start/route.ts b/src/app/api/auth/oauth/start/route.ts index e504f1c10..6e7f248de 100644 --- a/src/app/api/auth/oauth/start/route.ts +++ b/src/app/api/auth/oauth/start/route.ts @@ -1,34 +1,80 @@ -import { NextResponse } from 'next/server' -import { signIn } from '@/auth' +import { type NextRequest, NextResponse } from 'next/server' +import { AUTH_URLS } from '@/configs/urls' import { - authorizationParamsForOryIntent, normalizeOryReturnTo, readOryAuthIntent, shouldCaptureOrySignupMetadata, } from '@/core/server/auth/ory/build-start-url' +import { buildOryAuthorizationRequest } from '@/core/server/auth/ory/oauth-client' import { + E2B_OAUTH_FLOW_COOKIE, + OAUTH_CALLBACK_PATH, + oryFlowCookieOptions, + serializeOryFlowState, +} from '@/core/server/auth/ory/oauth-flow' +import { + encodeOrySignupMetadata, + ORY_SIGNUP_METADATA_COOKIE, readOrySignupMetadataFromHeaders, - setOrySignupMetadataCookie, + signupMetadataCookieOptions, } from '@/core/server/auth/ory/signup-metadata' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' -// 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 without any client JS in the loop. -export async function GET(request: Request) { - const url = new URL(request.url) - const intent = readOryAuthIntent(url.searchParams.get('intent')) - const redirectTo = - normalizeOryReturnTo(url.searchParams.get('returnTo')) ?? '/dashboard' +// Server-side entry point for the Ory OAuth2 flow. Builds the authorization URL +// (PKCE S256, state, nonce), stashes the verifier/state/nonce in a short-lived +// httpOnly cookie for the callback, and redirects the browser to Hydra. +export async function GET(request: NextRequest) { + const origin = request.nextUrl.origin + const intent = readOryAuthIntent(request.nextUrl.searchParams.get('intent')) if (!intent) { return new NextResponse('Invalid Ory auth intent', { status: 400 }) } + const returnTo = normalizeOryReturnTo( + request.nextUrl.searchParams.get('returnTo') + ) + const redirectUri = new URL(OAUTH_CALLBACK_PATH, origin).toString() + + let authorization: Awaited> + try { + authorization = await buildOryAuthorizationRequest(intent, redirectUri) + } catch (error) { + l.error( + { + key: 'oauth_start:authorization_request_failed', + error: serializeErrorForLog(error), + }, + 'failed to build the Ory authorization request' + ) + return NextResponse.redirect(new URL(AUTH_URLS.SIGN_IN, origin)) + } + + const response = NextResponse.redirect(authorization.url) + + response.cookies.set( + E2B_OAUTH_FLOW_COOKIE, + serializeOryFlowState({ + state: authorization.state, + nonce: authorization.nonce, + codeVerifier: authorization.codeVerifier, + returnTo, + }), + oryFlowCookieOptions() + ) + if (shouldCaptureOrySignupMetadata(intent)) { - await setOrySignupMetadataCookie( + const signupMetadata = encodeOrySignupMetadata( readOrySignupMetadataFromHeaders(request.headers) ) + if (signupMetadata) { + response.cookies.set( + ORY_SIGNUP_METADATA_COOKIE, + signupMetadata, + signupMetadataCookieOptions() + ) + } } - await signIn('ory', { redirectTo }, authorizationParamsForOryIntent(intent)) + return response } diff --git a/src/app/api/auth/sign-out/route.ts b/src/app/api/auth/sign-out/route.ts index d0f6ba85f..7ef3c199c 100644 --- a/src/app/api/auth/sign-out/route.ts +++ b/src/app/api/auth/sign-out/route.ts @@ -2,16 +2,18 @@ import 'server-only' import { type NextRequest, NextResponse } from 'next/server' import { signOut } from '@/core/server/auth' +import { E2B_SESSION_COOKIE } from '@/core/server/auth/ory/session-cookie' -// Sign-out lives in a plain route handler, deliberately NOT wrapped by the -// Auth.js `auth()` helper. When sign-out runs inside an auth()-wrapped request, -// the wrapper re-issues a refreshed JWT session cookie at the end of the -// request, which clobbers the session-cookie deletion that signOut() emits and -// leaves the user logged in. Here nothing re-wraps the request, so the deletion -// sticks. The client hard-navigates to this route, so the logout overlay stays -// up until the document unloads (no soft RSC redirect re-rendering the -// signed-out dashboard underneath it). +// Sign-out is a plain route handler. It reads the id_token from e2b_session to +// build Hydra's RP-logout URL, then clears the cookie on the redirect it emits +// (before handing off to Hydra, which ends the Ory + Kratos sessions). The +// client hard-navigates here so the logout overlay stays up until the document +// unloads. export async function GET(request: NextRequest) { const { redirectTo } = await signOut({ origin: request.nextUrl.origin }) - return NextResponse.redirect(new URL(redirectTo, request.nextUrl.origin)) + const response = NextResponse.redirect( + new URL(redirectTo, request.nextUrl.origin) + ) + response.cookies.delete(E2B_SESSION_COOKIE) + return response } diff --git a/src/app/api/trpc/[trpc]/route.ts b/src/app/api/trpc/[trpc]/route.ts index eea0fa551..613e6ebc1 100644 --- a/src/app/api/trpc/[trpc]/route.ts +++ b/src/app/api/trpc/[trpc]/route.ts @@ -1,47 +1,31 @@ import { fetchRequestHandler } from '@trpc/server/adapters/fetch' import type { NextRequest } from 'next/server' -import type { Session } from 'next-auth' -import { auth as authjs } from '@/auth' import { trpcAppRouter } from '@/core/server/api/routers' import { createTRPCContext } from '@/core/server/trpc/init' import { createRequestObservabilityContext } from '@/core/shared/clients/logger/request-observability' /** - * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when - * handling a HTTP request (e.g. when you make requests from Client Components). + * Adapts tRPC to the App Router fetch handler. Auth is resolved per-procedure by + * the auth middleware (Kratos whoami + e2b_session), so the request is not + * wrapped by any session helper here. */ -const createContext = async ( - req: NextRequest, - authSession?: Session | null -) => { - return createTRPCContext({ - headers: req.headers, - authSession, - requestUrl: req.url, - requestObservability: createRequestObservabilityContext({ - requestUrl: req.headers.get('referer') ?? req.url, - fallbackPath: '/api/trpc', - transport: 'trpc', - handlerName: 'http', - }), - }) -} - -const handler = (req: NextRequest, authSession?: Session | null) => +const handler = (req: NextRequest) => fetchRequestHandler({ endpoint: '/api/trpc', req, router: trpcAppRouter, - createContext: () => createContext(req, authSession), + createContext: () => + createTRPCContext({ + headers: req.headers, + requestUrl: req.url, + requestObservability: createRequestObservabilityContext({ + requestUrl: req.headers.get('referer') ?? req.url, + fallbackPath: '/api/trpc', + transport: 'trpc', + handlerName: 'http', + }), + }), }) -const oryHandler = authjs((req) => handler(req, req.auth)) - -type RouteContext = { params: Promise<{ trpc: string }> } - -function route(req: NextRequest, context: RouteContext) { - return oryHandler(req, context) -} - -export { route as GET, route as POST } +export { handler as GET, handler as POST } diff --git a/src/app/consent/route.ts b/src/app/consent/route.ts new file mode 100644 index 000000000..5879fd58e --- /dev/null +++ b/src/app/consent/route.ts @@ -0,0 +1,102 @@ +import 'server-only' + +import type { Identity } from '@ory/client-fetch' +import { type NextRequest, NextResponse } from 'next/server' +import { + getOryIdentityApi, + getOryOAuth2Api, +} from '@/core/server/auth/ory/client' +import { ORY_POST_LOGOUT_PATH } from '@/core/server/auth/ory/signout' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' + +// The dashboard is Hydra's consent provider. skip_consent only suppresses the +// consent SCREEN — Hydra still redirects here with a consent_challenge, and the +// provider must accept it AND fold the identity's profile traits into the +// id_token. Hydra holds no identity data of its own, so without this the issued +// tokens carry sub/iss but no email/name and user bootstrap rejects the login. +export async function GET(request: NextRequest) { + const home = new URL(ORY_POST_LOGOUT_PATH, request.nextUrl.origin) + const consentChallenge = request.nextUrl.searchParams.get('consent_challenge') + + if (!consentChallenge) { + return NextResponse.redirect(home) + } + + try { + const oauth2 = getOryOAuth2Api() + const consent = await oauth2.getOAuth2ConsentRequest({ consentChallenge }) + const grantScope = consent.requested_scope ?? [] + + const idTokenClaims = consent.subject + ? await profileClaimsForSubject(consent.subject, grantScope) + : {} + + const { redirect_to } = await oauth2.acceptOAuth2ConsentRequest({ + consentChallenge, + acceptOAuth2ConsentRequest: { + grant_scope: grantScope, + grant_access_token_audience: + consent.requested_access_token_audience ?? [], + session: { id_token: idTokenClaims }, + }, + }) + return NextResponse.redirect(redirect_to) + } catch (error) { + l.error( + { + key: 'oauth_consent:accept_failed', + error: serializeErrorForLog(error), + }, + 'failed to accept Hydra consent request' + ) + return NextResponse.redirect(home) + } +} + +// The OIDC profile claims come from the Kratos identity named by the consent +// subject (its UUID); email is gated on the email scope, name on profile — +// matching the scopes the dashboard client requests. +async function profileClaimsForSubject( + subject: string, + grantScope: string[] +): Promise> { + let identity: Identity + try { + identity = await getOryIdentityApi().getIdentity({ id: subject }) + } catch (error) { + l.warn( + { + key: 'oauth_consent:identity_lookup_failed', + error: serializeErrorForLog(error), + }, + 'could not load identity for consent; issuing token without profile claims' + ) + return {} + } + + const traits = (identity.traits ?? {}) as { + email?: string + name?: string | { first?: string; last?: string } + } + const claims: Record = {} + + if (grantScope.includes('email') && traits.email) { + claims.email = traits.email + } + + if (grantScope.includes('profile')) { + const name = fullName(traits.name) + if (name) claims.name = name + } + + return claims +} + +function fullName( + name: string | { first?: string; last?: string } | undefined +): string | null { + if (!name) return null + if (typeof name === 'string') return name.trim() || null + const joined = [name.first, name.last].filter(Boolean).join(' ').trim() + return joined || null +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index eddd1f1ab..706af19aa 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,8 +1,5 @@ import { getLoginFlow, type OryPageParams } from '@ory/nextjs/app' -import { redirect } from 'next/navigation' -import { isOryCustomUiEnabled } from '@/configs/env-flags' import oryConfig from '@/configs/ory' -import { AUTH_URLS } from '@/configs/urls' import { getOryConfigForRequest } from '@/core/server/auth/ory/request-config' import { LoginCard } from './login-card' @@ -12,10 +9,6 @@ export const dynamic = 'force-dynamic' // getLoginFlow handles both legs of the OAuth2 login: a `login_challenge` from // Hydra (creates the Kratos flow) and the resulting `?flow=` from Kratos. export default async function OryLoginPage(props: OryPageParams) { - if (!isOryCustomUiEnabled()) { - redirect(AUTH_URLS.SIGN_IN) - } - const flow = await getLoginFlow(oryConfig, props.searchParams) // null only on unrecoverable error (getLoginFlow has already redirected). diff --git a/src/app/logout/route.ts b/src/app/logout/route.ts new file mode 100644 index 000000000..fa29da731 --- /dev/null +++ b/src/app/logout/route.ts @@ -0,0 +1,63 @@ +import 'server-only' + +import { type NextRequest, NextResponse } from 'next/server' +import { + getOryFrontendApi, + getOryOAuth2Api, +} from '@/core/server/auth/ory/client' +import { ORY_POST_LOGOUT_PATH } from '@/core/server/auth/ory/signout' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' + +// The dashboard is Hydra's logout provider: RP-initiated logout +// (/oauth2/sessions/logout) redirects here with a logout_challenge. We accept +// the challenge to get Hydra's continuation URL, then send the browser through +// Kratos' own logout first so the ory_kratos_session cookie is cleared before +// Hydra finalizes. Without that hop the OAuth2 session ends but the identity +// session survives, and the next sign-in skips straight to the password step. +export async function GET(request: NextRequest) { + const home = new URL(ORY_POST_LOGOUT_PATH, request.nextUrl.origin) + const logoutChallenge = request.nextUrl.searchParams.get('logout_challenge') + + if (!logoutChallenge) { + return NextResponse.redirect(home) + } + + let continueTo: string + try { + const { redirect_to } = await getOryOAuth2Api().acceptOAuth2LogoutRequest({ + logoutChallenge, + }) + continueTo = redirect_to + } catch (error) { + l.error( + { + key: 'oauth_logout:accept_failed', + error: serializeErrorForLog(error), + }, + 'failed to accept Hydra logout request' + ) + return NextResponse.redirect(home) + } + + // Clear the Kratos identity session, then return to Hydra's continuation URL + // (its return_to must be in Kratos' allowed_return_urls) so Hydra finalizes + // the OAuth2 logout and honors post_logout_redirect_uri. + try { + const { logout_url } = await getOryFrontendApi().createBrowserLogoutFlow({ + cookie: request.headers.get('cookie') ?? undefined, + returnTo: continueTo, + }) + return NextResponse.redirect(logout_url) + } catch (error) { + // No live Kratos session (already signed out) or the flow couldn't be + // minted — let Hydra finalize anyway so logout still completes. + l.warn( + { + key: 'oauth_logout:kratos_flow_failed', + error: serializeErrorForLog(error), + }, + 'could not start Kratos logout; finalizing Hydra logout without it' + ) + return NextResponse.redirect(continueTo) + } +} diff --git a/src/app/recovery/page.tsx b/src/app/recovery/page.tsx index f37519c6c..071f488aa 100644 --- a/src/app/recovery/page.tsx +++ b/src/app/recovery/page.tsx @@ -1,8 +1,5 @@ import { getRecoveryFlow, type OryPageParams } from '@ory/nextjs/app' -import { redirect } from 'next/navigation' -import { isOryCustomUiEnabled } from '@/configs/env-flags' import oryConfig from '@/configs/ory' -import { AUTH_URLS } from '@/configs/urls' import { getOryConfigForRequest } from '@/core/server/auth/ory/request-config' import { RecoveryCard } from './recovery-card' @@ -10,10 +7,6 @@ export const dynamic = 'force-dynamic' // Mirrors /login; see src/app/login/page.tsx. export default async function OryRecoveryPage(props: OryPageParams) { - if (!isOryCustomUiEnabled()) { - redirect(AUTH_URLS.FORGOT_PASSWORD) - } - const flow = await getRecoveryFlow(oryConfig, props.searchParams) if (!flow) { diff --git a/src/app/registration/page.tsx b/src/app/registration/page.tsx index fffcb4cbc..23c489195 100644 --- a/src/app/registration/page.tsx +++ b/src/app/registration/page.tsx @@ -1,8 +1,5 @@ import { getRegistrationFlow, type OryPageParams } from '@ory/nextjs/app' -import { redirect } from 'next/navigation' -import { isOryCustomUiEnabled } from '@/configs/env-flags' import oryConfig from '@/configs/ory' -import { AUTH_URLS } from '@/configs/urls' import { getOryConfigForRequest } from '@/core/server/auth/ory/request-config' import { RegistrationCard } from './registration-card' @@ -10,10 +7,6 @@ export const dynamic = 'force-dynamic' // Mirrors /login; see src/app/login/page.tsx. export default async function OryRegistrationPage(props: OryPageParams) { - if (!isOryCustomUiEnabled()) { - redirect(AUTH_URLS.SIGN_UP) - } - const flow = await getRegistrationFlow(oryConfig, props.searchParams) if (!flow) { diff --git a/src/app/verification/page.tsx b/src/app/verification/page.tsx index 2b791fb4a..23ab2c4c3 100644 --- a/src/app/verification/page.tsx +++ b/src/app/verification/page.tsx @@ -1,20 +1,12 @@ import { getVerificationFlow, type OryPageParams } from '@ory/nextjs/app' -import { redirect } from 'next/navigation' -import { isOryCustomUiEnabled } from '@/configs/env-flags' import oryConfig from '@/configs/ory' -import { PROTECTED_URLS } from '@/configs/urls' import { getOryConfigForRequest } from '@/core/server/auth/ory/request-config' import { VerificationCard } from './verification-card' export const dynamic = 'force-dynamic' -// Mirrors /login; see src/app/login/page.tsx. No legacy page, so the disabled -// fallback is the dashboard. +// Mirrors /login; see src/app/login/page.tsx. export default async function OryVerificationPage(props: OryPageParams) { - if (!isOryCustomUiEnabled()) { - redirect(PROTECTED_URLS.DASHBOARD) - } - const flow = await getVerificationFlow(oryConfig, props.searchParams) if (!flow) { diff --git a/src/auth.ts b/src/auth.ts deleted file mode 100644 index 8716b8f01..000000000 --- a/src/auth.ts +++ /dev/null @@ -1,67 +0,0 @@ -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 - -const useSecureCookies = process.env.VERCEL_ENV === 'production' -// Standard Auth.js secure-cookie convention. -const securePrefix = useSecureCookies ? '__Secure-' : '' -// Cookies are scoped by host+path+name, NOT by port. Running two local -// dashboards on different localhost ports makes them share the default -// session cookie and clobber each other. AUTH_COOKIE_PREFIX lets each -// instance use a distinct cookie name. Unset in prod/preview. -const cookiePrefix = process.env.AUTH_COOKIE_PREFIX - ? `${process.env.AUTH_COOKIE_PREFIX}.` - : '' - -export const { handlers, auth, signIn, signOut } = NextAuth({ - // Keeps Auth.js OAuth routes under a dedicated namespace. - basePath: '/api/auth/oauth', - secret: process.env.AUTH_SECRET, - session: { strategy: 'jwt' }, - useSecureCookies, - cookies: { - sessionToken: { - name: `${securePrefix}${cookiePrefix}authjs.session-token`, - options: { - httpOnly: true, - sameSite: 'lax', - path: '/', - secure: useSecureCookies, - }, - }, - }, - // route handler that logs the failure and redirects to /sign-in so users - // never see Auth.js's built-in error page. - 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/env-flags.ts b/src/configs/env-flags.ts index 790581fcd..b279077a0 100644 --- a/src/configs/env-flags.ts +++ b/src/configs/env-flags.ts @@ -15,9 +15,3 @@ export const INCLUDE_DASHBOARD_FEEDBACK_SURVEY = export const INCLUDE_REPORT_ISSUE = process.env.NEXT_PUBLIC_INCLUDE_REPORT_ISSUE === '1' - -// Gates the custom Ory flow pages + same-origin SDK proxy. On (Preview/Staging) -// renders the Elements UI; off (Production) redirects to /sign-in etc. -export function isOryCustomUiEnabled() { - return process.env.NEXT_PUBLIC_ORY_CUSTOM_UI === 'true' -} diff --git a/src/core/server/api/middlewares/auth.ts b/src/core/server/api/middlewares/auth.ts index 7b8e6c6f6..71f8338da 100644 --- a/src/core/server/api/middlewares/auth.ts +++ b/src/core/server/api/middlewares/auth.ts @@ -15,7 +15,7 @@ export const authMiddleware = t.middleware(async ({ ctx, next }) => { const authContext = await context.with( trace.setSpan(context.active(), span), async () => { - return await getAuthContext(ctx.authSession) + return await getAuthContext() } ) diff --git a/src/core/server/api/routers/user.ts b/src/core/server/api/routers/user.ts index 99694dbeb..3f296e513 100644 --- a/src/core/server/api/routers/user.ts +++ b/src/core/server/api/routers/user.ts @@ -55,7 +55,7 @@ export const userRouter = createTRPCRouter({ // never hangs on the identity provider. profile: protectedProcedure.query(async ({ ctx }): Promise => { const result = await withTimeout( - getUserProfile(ctx.authSession).catch(() => null), + getUserProfile().catch(() => null), PROFILE_LOOKUP_TIMEOUT_MS ) @@ -93,7 +93,7 @@ export const userRouter = createTRPCRouter({ if (input.email !== undefined || input.password !== undefined) { const profile = await withTimeout( - getUserProfile(ctx.authSession).catch(() => null), + getUserProfile().catch(() => null), PROFILE_LOOKUP_TIMEOUT_MS ) const credentialProfile = @@ -121,19 +121,16 @@ export const userRouter = createTRPCRouter({ } } - const result = await updateUser( - { - email: input.email, - password: input.password, - name: input.name, - }, - ctx.authSession - ) + const result = await updateUser({ + email: input.email, + password: input.password, + name: input.name, + }) if (result.ok) { // Invalidate sessions when the password changed. if (input.password) { - await handleCredentialChangeSuccess(ctx.authSession) + await handleCredentialChangeSuccess() } return { status: 'ok' as const, user: result.user } diff --git a/src/core/server/auth/index.ts b/src/core/server/auth/index.ts index a01e65158..41d79c87a 100644 --- a/src/core/server/auth/index.ts +++ b/src/core/server/auth/index.ts @@ -3,7 +3,6 @@ import 'server-only' export type { AuthUser } from '@/core/modules/auth/models' export { getAuthContext, - getAuthContextFromOrySession, getUserProfile, handleCredentialChangeSuccess, signOut, diff --git a/src/core/server/auth/ory/authjs-boundary.ts b/src/core/server/auth/ory/authjs-boundary.ts deleted file mode 100644 index 905b80c50..000000000 --- a/src/core/server/auth/ory/authjs-boundary.ts +++ /dev/null @@ -1,105 +0,0 @@ -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 so server-side auth() can forward them to APIs. - */ - -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 deleted file mode 100644 index b89c10502..000000000 --- a/src/core/server/auth/ory/authjs-callbacks.ts +++ /dev/null @@ -1,213 +0,0 @@ -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 type { OryInternalAuthJsSession } from './authjs-session-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 token fields onto the Session object consumed by Auth.js's - * server-side auth() helper. The public /session route strips those fields - * before responding to browsers. - */ - -// 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, getAuthContext 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) { - const orySession = session as OryInternalAuthJsSession - orySession.user.id = token.sub ?? orySession.user.id - orySession.accessToken = token.accessToken - orySession.idToken = token.idToken - orySession.identityId = token.identityId - orySession.error = token.error - return orySession -} - -// 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/authjs-session-boundary.ts b/src/core/server/auth/ory/authjs-session-boundary.ts deleted file mode 100644 index d0278d7f0..000000000 --- a/src/core/server/auth/ory/authjs-session-boundary.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { NextRequest } from 'next/server' -import type { Session } from 'next-auth' - -/** - * Auth.js is a temporary adapter while the dashboard moves to Ory-native UI. - * Keep every Auth.js Session detail that contains Ory tokens behind this - * boundary, and wrap public Auth.js handlers with withSanitizedOryAuthJsHandler. - */ - -export type OrySessionFields = { - accessToken?: string - idToken?: string - identityId?: string - error?: string -} - -export type OryInternalAuthJsSession = Session & OrySessionFields - -type AuthJsRouteHandler = (request: NextRequest) => Response | Promise -type HeadersWithSetCookie = Headers & { getSetCookie?: () => string[] } - -const ORY_TOKEN_SESSION_KEYS = [ - 'accessToken', - 'idToken', - 'refreshToken', - 'identityId', -] as const - -export function readOrySessionFields( - session: Session | null | undefined -): OrySessionFields | null { - if (!session) return null - - const internalSession = session as OryInternalAuthJsSession - return { - accessToken: internalSession.accessToken, - idToken: internalSession.idToken, - identityId: internalSession.identityId, - error: internalSession.error, - } -} - -export function isOrySessionAuthenticated( - session: Session | null | undefined -): boolean { - const fields = readOrySessionFields(session) - return !!session?.user?.id && !!fields?.accessToken && !fields.error -} - -export function withSanitizedOryAuthJsHandler( - handler: AuthJsRouteHandler -): AuthJsRouteHandler { - return async (request) => { - const response = await handler(request) - return sanitizeOryAuthJsSessionResponse(request, response) - } -} - -async function sanitizeOryAuthJsSessionResponse( - request: Request, - response: Response -): Promise { - if (!isAuthJsSessionRoute(request.url)) { - return response - } - - const contentType = response.headers.get('content-type') - if (!contentType?.includes('application/json')) { - return response - } - - const body = (await response.json()) as unknown - const headers = copyResponseHeaders(response.headers) - - const sanitizedResponse = Response.json( - sanitizeOryPublicSessionPayload(body), - { - status: response.status, - headers, - } - ) - copySetCookieHeaders(response.headers, sanitizedResponse.headers) - return sanitizedResponse -} - -export function sanitizeOryPublicSessionPayload(value: unknown): unknown { - if (!value || typeof value !== 'object') return value - - const session = stripOryTokenFields(value as Record) - - if (session.user && typeof session.user === 'object') { - session.user = stripOryTokenFields(session.user as Record) - } - - return session -} - -function isAuthJsSessionRoute(url: string): boolean { - return new URL(url).pathname.replace(/\/+$/, '').endsWith('/session') -} - -function stripOryTokenFields( - value: Record -): Record { - const result = { ...value } - for (const key of ORY_TOKEN_SESSION_KEYS) { - delete result[key] - } - return result -} - -function copyResponseHeaders(source: Headers): Headers { - const target = new Headers() - source.forEach((value, key) => { - const lowerKey = key.toLowerCase() - if (lowerKey === 'content-length' || lowerKey === 'set-cookie') return - target.set(key, value) - }) - return target -} - -function copySetCookieHeaders(source: Headers, target: Headers): void { - const setCookies = (source as HeadersWithSetCookie).getSetCookie?.() - if (setCookies?.length) { - for (const cookie of setCookies) { - target.append('set-cookie', cookie) - } - return - } - - const cookie = source.get('set-cookie') - if (cookie) target.append('set-cookie', cookie) -} diff --git a/src/core/server/auth/ory/client.ts b/src/core/server/auth/ory/client.ts index f2a04e874..87685be5d 100644 --- a/src/core/server/auth/ory/client.ts +++ b/src/core/server/auth/ory/client.ts @@ -1,9 +1,15 @@ import 'server-only' -import { Configuration, IdentityApi, OAuth2Api } from '@ory/client-fetch' +import { + Configuration, + FrontendApi, + IdentityApi, + OAuth2Api, +} from '@ory/client-fetch' let cachedIdentityApi: IdentityApi | null = null let cachedOAuth2Api: OAuth2Api | null = null +let cachedFrontendApi: FrontendApi | null = null // IdentityApi resolution: // 1. ORY_KRATOS_ADMIN_URL — self-hosted Kratos admin (e.g. local devenv :4434). @@ -34,6 +40,25 @@ export function getOryOAuth2Api(): OAuth2Api { return cachedOAuth2Api } +// FrontendApi talks to Kratos' PUBLIC surface (browser-facing self-service, e.g. +// the logout flow). It authenticates by forwarding the browser's Kratos session +// cookie, not the admin PAT, so it targets the public SDK URL with no token — +// same resolution the edge whoami gate uses. +export function getOryFrontendApi(): FrontendApi { + if (cachedFrontendApi) return cachedFrontendApi + + const basePath = + process.env.NEXT_PUBLIC_ORY_SDK_URL ?? process.env.ORY_SDK_URL + if (!basePath) { + throw new Error('NEXT_PUBLIC_ORY_SDK_URL / ORY_SDK_URL is not configured') + } + + cachedFrontendApi = new FrontendApi( + new Configuration({ basePath: basePath.replace(/\/$/, '') }) + ) + return cachedFrontendApi +} + function getOryConfiguration(basePathOverride?: string): Configuration { const basePath = basePathOverride ?? process.env.ORY_SDK_URL diff --git a/src/core/server/auth/ory/dashboard-bootstrap.ts b/src/core/server/auth/ory/dashboard-bootstrap.ts index 2b96e5e03..9dc8a0c08 100644 --- a/src/core/server/auth/ory/dashboard-bootstrap.ts +++ b/src/core/server/auth/ory/dashboard-bootstrap.ts @@ -37,15 +37,6 @@ export async function ensureOryUserBootstrapped( return bootstrapOryUserWithRequest(body, input.provider) } -export async function bootstrapOryUser( - input: BootstrapOryUserInput -): Promise { - const body = await createOryUserBootstrapRequest(input) - if (!body) return false - - return bootstrapOryUserWithRequest(body, input.provider) -} - export async function createOryUserBootstrapRequest( input: BootstrapOryUserInput ): Promise< @@ -71,20 +62,25 @@ export async function createOryUserBootstrapRequest( } satisfies DashboardApiComponents['schemas']['AdminAuthProviderUserBootstrapRequest'] } +// Profile claims (issuer/email/name) prefer the cryptographically validated +// id_token, falling back to the access token. The OIDC subject, however, stays +// sourced from the access token — it is the bearer token dashboard-api receives +// and validates, and is the stable key for the (issuer, user_id) mapping. function readBootstrapClaims( input: BootstrapOryUserInput ): OryBootstrapClaims | null { - const accessClaims = decodeJwtClaims(input.accessToken) const idClaims = input.idToken ? decodeJwtClaims(input.idToken) : null + const accessClaims = decodeJwtClaims(input.accessToken) const oidcIssuer = - readStringClaim(accessClaims, 'iss') ?? readStringClaim(idClaims, 'iss') - const oidcUserId = readStringClaim(accessClaims, 'sub') + readStringClaim(idClaims, 'iss') ?? readStringClaim(accessClaims, 'iss') + const oidcUserId = + readStringClaim(accessClaims, 'sub') ?? readStringClaim(idClaims, 'sub') const oidcUserEmail = - readStringClaim(accessClaims, 'email') ?? readStringClaim(idClaims, 'email') + readStringClaim(idClaims, 'email') ?? readStringClaim(accessClaims, 'email') const oidcUserName = - readDisplayName(accessClaims) ?? readDisplayName(idClaims) + readDisplayName(idClaims) ?? readDisplayName(accessClaims) if (!oidcIssuer || !oidcUserId || !oidcUserEmail) { l.error( diff --git a/src/core/server/auth/ory/freshness.ts b/src/core/server/auth/ory/freshness.ts index 479053b54..938f96edb 100644 --- a/src/core/server/auth/ory/freshness.ts +++ b/src/core/server/auth/ory/freshness.ts @@ -1,33 +1,19 @@ -import { decodeJwtClaims } from './jwt-claims' +// Must match Kratos's `selfservice.flows.settings.privileged_session_max_age` +// (15m). The dashboard changes credentials via the admin API, which bypasses +// Kratos's own privileged-session enforcement, so we mirror the window here. +export const KRATOS_PRIVILEGED_SESSION_MAX_AGE_SECONDS = 900 -// 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) +// Kratos stamps `authenticated_at` with the last active authentication, which a +// `prompt=login` re-auth refreshes. Gates privileged operations (password/email +// change) against the same window Kratos enforces natively on its settings flow. +export function isKratosSessionFresh( + authenticatedAt: string | Date | null | undefined, + nowMs: number = Date.now() ): boolean { - const authTime = readAuthTime(idToken) - if (authTime === null) return false + if (!authenticatedAt) return false + + const authedMs = new Date(authenticatedAt).getTime() + if (Number.isNaN(authedMs)) return false - return nowSeconds - authTime <= REAUTH_FRESHNESS_WINDOW_SECONDS + return (nowMs - authedMs) / 1000 <= KRATOS_PRIVILEGED_SESSION_MAX_AGE_SECONDS } diff --git a/src/core/server/auth/ory/identity.ts b/src/core/server/auth/ory/identity.ts index 37c80d7c8..4bfc9204f 100644 --- a/src/core/server/auth/ory/identity.ts +++ b/src/core/server/auth/ory/identity.ts @@ -1,22 +1,27 @@ 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 { +// Build the user from a live Kratos session identity (whoami) — the source of +// truth for getAuthContext. The session identity carries traits but not +// credentials, so provider/credential flags stay false — use fromOryIdentity +// with an admin lookup when those are needed (e.g. the profile query). +export function fromKratosSessionIdentity(identity: { + id: string + traits?: unknown +}): AuthUser { + const traits = (identity.traits ?? {}) as Record return { - id: session.user.id, - email: session.user.email ?? null, - name: session.user.name ?? null, - avatarUrl: session.user.image ?? null, + id: identity.id, + email: readString(traits, 'email'), + name: readDisplayName(traits), + avatarUrl: + readString(traits, 'picture') ?? readString(traits, 'avatar_url'), providers: [], canChangeEmail: false, canChangePassword: false, diff --git a/src/core/server/auth/ory/kratos-session-edge.ts b/src/core/server/auth/ory/kratos-session-edge.ts new file mode 100644 index 000000000..2ecb43a62 --- /dev/null +++ b/src/core/server/auth/ory/kratos-session-edge.ts @@ -0,0 +1,25 @@ +import type { NextRequest } from 'next/server' + +// Edge-safe Kratos session check for the middleware gate. getServerSession() +// reads next/headers and can't run in the edge runtime, so we hit Kratos +// directly with the request's cookies. This gates redirects only — +// authoritative enforcement happens server-side in getAuthContext. +export async function isKratosSessionActive( + request: NextRequest +): Promise { + const sdkUrl = process.env.NEXT_PUBLIC_ORY_SDK_URL ?? process.env.ORY_SDK_URL + const cookie = request.headers.get('cookie') + if (!sdkUrl || !cookie) return false + + try { + const response = await fetch( + `${sdkUrl.replace(/\/$/, '')}/sessions/whoami`, + { headers: { cookie, accept: 'application/json' } } + ) + if (!response.ok) return false + const session = (await response.json()) as { active?: boolean } + return session.active === true + } catch { + return false + } +} diff --git a/src/core/server/auth/ory/oauth-client.ts b/src/core/server/auth/ory/oauth-client.ts new file mode 100644 index 000000000..0d01bc01a --- /dev/null +++ b/src/core/server/auth/ory/oauth-client.ts @@ -0,0 +1,186 @@ +import * as oauth from 'oauth4webapi' +import { + authorizationParamsForOryIntent, + type OryAuthIntent, +} from './build-start-url' + +// Hand-owned Hydra OIDC client (confidential, client_secret_basic) built on +// oauth4webapi. PKCE (S256) is always used even though the client is +// confidential — it protects against authorization-code injection regardless of +// client type. No next/headers import so this stays importable from the edge +// middleware (refresh path reuses the issuer/client config). + +const OAUTH_SCOPE = 'openid offline_access email profile' + +export type OryAuthorizationRequest = { + url: string + state: string + nonce: string + codeVerifier: string +} + +export type OryTokenExchange = { + accessToken: string + refreshToken?: string + idToken?: string + // Absolute access-token expiry, epoch seconds. + expiresAt: number +} + +type OryOAuthEnv = { + issuer: URL + clientId: string + clientSecret: string + audience?: string + // Hydra runs on plain HTTP loopback in local dev; oauth4webapi rejects + // non-HTTPS endpoints unless explicitly allowed. + insecure: boolean +} + +export function readOryOAuthEnv(): OryOAuthEnv { + const issuerValue = + process.env.ORY_HYDRA_PUBLIC_URL ?? process.env.ORY_SDK_URL + const clientId = process.env.ORY_OAUTH2_CLIENT_ID + const clientSecret = process.env.ORY_OAUTH2_CLIENT_SECRET + + if (!issuerValue || !clientId || !clientSecret) { + throw new Error( + 'Ory OAuth client is misconfigured (need ORY_HYDRA_PUBLIC_URL or ORY_SDK_URL, ORY_OAUTH2_CLIENT_ID, ORY_OAUTH2_CLIENT_SECRET)' + ) + } + + const issuer = new URL(issuerValue) + return { + issuer, + clientId, + clientSecret, + audience: process.env.ORY_OAUTH2_AUDIENCE, + insecure: issuer.protocol === 'http:', + } +} + +let cachedAs: { + issuer: string + as: Promise +} | null = null + +function discoverAuthorizationServer( + env: OryOAuthEnv +): Promise { + if (cachedAs?.issuer === env.issuer.href) return cachedAs.as + + const discovery = oauth + .discoveryRequest(env.issuer, { + algorithm: 'oidc', + ...(env.insecure ? { [oauth.allowInsecureRequests]: true } : {}), + }) + .then((response) => oauth.processDiscoveryResponse(env.issuer, response)) + + // A rejected discovery must not poison the cache — let the next call retry. + discovery.catch(() => { + if (cachedAs?.as === discovery) cachedAs = null + }) + + cachedAs = { issuer: env.issuer.href, as: discovery } + return discovery +} + +function oryClient(env: OryOAuthEnv): oauth.Client { + return { client_id: env.clientId } +} + +export async function buildOryAuthorizationRequest( + intent: OryAuthIntent, + redirectUri: string +): Promise { + const env = readOryOAuthEnv() + const as = await discoverAuthorizationServer(env) + + if (!as.authorization_endpoint) { + throw new Error('Ory discovery metadata is missing authorization_endpoint') + } + + const codeVerifier = oauth.generateRandomCodeVerifier() + const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier) + const state = oauth.generateRandomState() + const nonce = oauth.generateRandomNonce() + + const url = new URL(as.authorization_endpoint) + url.searchParams.set('client_id', env.clientId) + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('response_type', 'code') + url.searchParams.set('scope', OAUTH_SCOPE) + url.searchParams.set('code_challenge', codeChallenge) + url.searchParams.set('code_challenge_method', 'S256') + url.searchParams.set('state', state) + url.searchParams.set('nonce', nonce) + if (env.audience) url.searchParams.set('audience', env.audience) + + const prompt = authorizationParamsForOryIntent(intent)?.prompt + if (prompt) url.searchParams.set('prompt', prompt) + + return { url: url.toString(), state, nonce, codeVerifier } +} + +export async function exchangeOryCallback(params: { + currentUrl: URL + expectedState: string + expectedNonce: string + codeVerifier: string + redirectUri: string +}): Promise { + const env = readOryOAuthEnv() + const as = await discoverAuthorizationServer(env) + const client = oryClient(env) + const clientAuth = oauth.ClientSecretBasic(env.clientSecret) + + const callbackParams = oauth.validateAuthResponse( + as, + client, + params.currentUrl, + params.expectedState + ) + + const response = await oauth.authorizationCodeGrantRequest( + as, + client, + clientAuth, + callbackParams, + params.redirectUri, + params.codeVerifier, + env.insecure ? { [oauth.allowInsecureRequests]: true } : undefined + ) + + const processOptions: oauth.ProcessAuthorizationCodeResponseOptions = { + expectedNonce: params.expectedNonce, + requireIdToken: true, + } + // allowInsecureRequests is read off the same options object for the implicit + // JWKS fetch that verifies the id_token; it is not in the public option type. + if (env.insecure) { + ;(processOptions as Record)[oauth.allowInsecureRequests] = + true + } + + const result = await oauth.processAuthorizationCodeResponse( + as, + client, + response, + processOptions + ) + + return { + accessToken: result.access_token, + refreshToken: result.refresh_token, + idToken: result.id_token, + expiresAt: absoluteExpiry(result.expires_in), + } +} + +export function absoluteExpiry( + expiresIn: number | undefined, + nowSeconds: number = Math.floor(Date.now() / 1000) +): number { + // Hydra always returns expires_in; the fallback only guards a missing value. + return nowSeconds + (expiresIn ?? 300) +} diff --git a/src/core/server/auth/ory/oauth-flow.ts b/src/core/server/auth/ory/oauth-flow.ts new file mode 100644 index 000000000..bcbe520a1 --- /dev/null +++ b/src/core/server/auth/ory/oauth-flow.ts @@ -0,0 +1,73 @@ +// Transient state bridging the authorization request (start route) and the +// callback: the PKCE code_verifier plus the state/nonce the callback validates, +// and the post-login destination. Lives in a short-lived httpOnly cookie. Its +// secrecy is not the security boundary — state/nonce/PKCE validation at the +// callback is — so it is stored as plain JSON, not encrypted. + +export const E2B_OAUTH_FLOW_COOKIE = 'e2b_oauth_flow' + +// The registered redirect_uri. Must be byte-identical between the authorization +// request and the token exchange, so both routes derive it from this constant. +export const OAUTH_CALLBACK_PATH = '/api/auth/oauth/callback/ory' + +const FLOW_COOKIE_MAX_AGE_SECONDS = 60 * 10 + +export type OryFlowState = { + state: string + nonce: string + codeVerifier: string + returnTo?: string +} + +export type OryFlowCookieOptions = { + httpOnly: true + sameSite: 'lax' + path: '/' + secure: boolean + maxAge: number +} + +// base64url so the JSON survives as a cookie value — Next's cookie helpers do +// not encode/decode, and raw JSON contains characters illegal in cookie values. +export function serializeOryFlowState(flow: OryFlowState): string { + return Buffer.from(JSON.stringify(flow), 'utf8').toString('base64url') +} + +export function parseOryFlowState( + value: string | undefined | null +): OryFlowState | null { + if (!value) return null + + try { + const json = Buffer.from(value, 'base64url').toString('utf8') + const parsed = JSON.parse(json) as Partial + if ( + typeof parsed.state !== 'string' || + typeof parsed.nonce !== 'string' || + typeof parsed.codeVerifier !== 'string' + ) { + return null + } + + return { + state: parsed.state, + nonce: parsed.nonce, + codeVerifier: parsed.codeVerifier, + returnTo: + typeof parsed.returnTo === 'string' ? parsed.returnTo : undefined, + } + } catch { + return null + } +} + +export function oryFlowCookieOptions(): OryFlowCookieOptions { + return { + httpOnly: true, + // Lax so the cookie rides along on the top-level redirect back from Hydra. + sameSite: 'lax', + path: '/', + secure: process.env.NODE_ENV === 'production', + maxAge: FLOW_COOKIE_MAX_AGE_SECONDS, + } +} diff --git a/src/core/server/auth/ory/refresh-token.ts b/src/core/server/auth/ory/refresh-token.ts deleted file mode 100644 index 83027734d..000000000 --- a/src/core/server/auth/ory/refresh-token.ts +++ /dev/null @@ -1,98 +0,0 @@ -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/session-cookie.ts b/src/core/server/auth/ory/session-cookie.ts new file mode 100644 index 000000000..ddaec63c3 --- /dev/null +++ b/src/core/server/auth/ory/session-cookie.ts @@ -0,0 +1,107 @@ +import { EncryptJWT, jwtDecrypt } from 'jose' + +// The single encrypted cookie that carries the Hydra OIDC tokens for API +// access. Kratos owns the session; this cookie is never the auth gate — it is +// read by getAuthContext for the access token and refreshed by the middleware. +// No next/headers import here so the module stays usable from edge middleware. + +export const E2B_SESSION_COOKIE = 'e2b_session' + +// Persist across browser restarts. The cookie only caches tokens — a stale or +// expired cookie is re-minted from the live Kratos session, so the lifetime is +// intentionally generous and not the security boundary. +const SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30 + +const KEY_ALGORITHM = 'dir' +const CONTENT_ENCRYPTION = 'A256GCM' + +export type OrySessionTokens = { + accessToken: string + refreshToken?: string + idToken?: string + // Absolute access-token expiry, epoch seconds. + expiresAt: number +} + +export type OrySessionCookieOptions = { + httpOnly: true + sameSite: 'lax' + path: '/' + secure: boolean + maxAge: number +} + +// Cache the derived key per secret value so rotating E2B_SESSION_SECRET (and +// test env stubbing) takes effect without a stale key lingering. +let cached: { secret: string; key: Promise } | null = null + +function deriveKey(): Promise { + const secret = process.env.E2B_SESSION_SECRET + if (!secret) { + return Promise.reject(new Error('E2B_SESSION_SECRET is not configured')) + } + + if (cached?.secret === secret) return cached.key + + const key = crypto.subtle + .digest('SHA-256', new TextEncoder().encode(secret)) + .then((digest) => new Uint8Array(digest)) + + cached = { secret, key } + return key +} + +export async function sealOrySession( + tokens: OrySessionTokens +): Promise { + return new EncryptJWT({ + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + idToken: tokens.idToken, + expiresAt: tokens.expiresAt, + }) + .setProtectedHeader({ alg: KEY_ALGORITHM, enc: CONTENT_ENCRYPTION }) + .setIssuedAt() + .encrypt(await deriveKey()) +} + +export async function openOrySession( + value: string | undefined | null +): Promise { + if (!value) return null + + try { + const { payload } = await jwtDecrypt(value, await deriveKey()) + return parseTokens(payload) + } catch { + return null + } +} + +export function orySessionCookieOptions(): OrySessionCookieOptions { + return { + httpOnly: true, + sameSite: 'lax', + path: '/', + // Vercel deployments (preview + production) build with NODE_ENV=production + // and serve over HTTPS; local `next dev` is plain-HTTP loopback. + secure: process.env.NODE_ENV === 'production', + maxAge: SESSION_COOKIE_MAX_AGE_SECONDS, + } +} + +function parseTokens( + payload: Record +): OrySessionTokens | null { + const { accessToken, refreshToken, idToken, expiresAt } = payload + if (typeof accessToken !== 'string' || typeof expiresAt !== 'number') { + return null + } + + return { + accessToken, + refreshToken: typeof refreshToken === 'string' ? refreshToken : undefined, + idToken: typeof idToken === 'string' ? idToken : undefined, + expiresAt, + } +} diff --git a/src/core/server/auth/ory/session.ts b/src/core/server/auth/ory/session.ts index e5dd04c7c..6283cd54a 100644 --- a/src/core/server/auth/ory/session.ts +++ b/src/core/server/auth/ory/session.ts @@ -1,8 +1,8 @@ import 'server-only' -import type { Session } from 'next-auth' +import { getServerSession } from '@ory/nextjs/app' +import { cookies } from 'next/headers' import { cache } from 'react' -import { auth as authjs, signOut as authjsSignOut } from '@/auth' import { PROTECTED_URLS } from '@/configs/urls' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import type { @@ -14,45 +14,51 @@ import type { UpdateUserInput, UpdateUserResult, } from '../types' -import { readOrySessionFields } from './authjs-session-boundary' 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 { isKratosSessionFresh } from './freshness' +import { fromKratosSessionIdentity, fromOryIdentity } from './identity' import { revokeKratosSessionsForIdentity } from './kratos-session' import { revokeOryOAuthSessionsForSubject } from './oauth-session' +import { E2B_SESSION_COOKIE, openOrySession } from './session-cookie' import { completeOrySignOut } from './signout-flow' const ACCOUNT_SETTINGS_REAUTH_RETURN_TO = `${PROTECTED_URLS.ACCOUNT_SETTINGS}?reauth=1` -export async function getAuthContext( - authSession?: Session | null -): Promise { - return getAuthContextFromOrySession(await readCurrentSession(authSession)) +// Kratos owns the session. Identity (the gate) comes from `whoami`; the Hydra +// access token (API access only) comes from the e2b_session cookie, which the +// middleware keeps fresh. For E2B the OAuth subject equals the Kratos identity +// id, so kratos.identity.id is both the dashboard user id and the Kratos id used +// for admin operations. This module never refreshes — it is a pure reader. + +export async function getAuthContext(): Promise { + const kratos = await readKratosSession() + if (!kratos?.active || !kratos.identity) return null + + const tokens = await readOrySessionTokens() + if (!tokens?.accessToken) return null + + return { + user: fromKratosSessionIdentity(kratos.identity), + accessToken: tokens.accessToken, + } } -export async function getUserProfile( - authSession?: Session | null -): Promise { - const session = await readCurrentSession(authSession) - if (!session?.user?.id) return null - const serverFields = readOrySessionFields(session) +export async function getUserProfile(): Promise { + const identityId = (await readKratosSession())?.identity?.id + if (!identityId) return null - // The live profile needs the full Kratos identity (traits + credentials). - // The cached identity id hits directly; user.id and email are fallbacks. + // The rich profile needs the full identity (traits + credentials). const identity = await resolveOryIdentity({ - subjects: [serverFields?.identityId, session.user.id], - email: session.user.email, + subjects: [identityId], includeCredential: ACCOUNT_IDENTITY_CREDENTIALS, }) - return identity - ? fromOryIdentity(identity, { userId: session.user.id }) - : null + return identity ? fromOryIdentity(identity, { userId: identityId }) : null } export async function signOut( @@ -64,30 +70,23 @@ export async function signOut( } export async function updateUser( - input: UpdateUserInput, - authSession?: Session | null + input: UpdateUserInput ): Promise { - const session = await readCurrentSession(authSession) - if (!session?.user?.id) { - throw new Error('updateUser called without an authenticated Ory session') + const kratos = await readKratosSession() + const identityId = kratos?.identity?.id + if (!identityId) { + throw new Error('updateUser called without an authenticated Kratos session') } - const serverFields = readOrySessionFields(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. + // Changing the password OR the email is privileged: the dashboard mutates + // credentials via the admin API, which bypasses Kratos's own privileged- + // session enforcement, so we mirror the freshness window here. const changesCredentials = input.password !== undefined || input.email !== undefined - if (changesCredentials && !isReauthFresh(serverFields?.idToken)) { + if (changesCredentials && !isKratosSessionFresh(kratos?.authenticated_at)) { 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, @@ -97,13 +96,7 @@ export async function updateUser( if (!result.ok) return result - return { - ...result, - user: { - ...result.user, - id: session.user.id, - }, - } + return { ...result, user: { ...result.user, id: identityId } } } export async function startReauthForAccountSettings(): Promise { @@ -112,88 +105,51 @@ export async function startReauthForAccountSettings(): Promise { } } -export async function handleCredentialChangeSuccess( - authSession?: Session | null -): Promise { - const session = await readCurrentSession(authSession) - if (!session?.user?.id) return +export async function handleCredentialChangeSuccess(): Promise { + const identityId = (await readKratosSession())?.identity?.id + if (!identityId) return - await revokeOryOAuthSessionsForSubject(session.user.id) + await Promise.all([ + revokeOryOAuthSessionsForSubject(identityId), + revokeKratosSessionsForIdentity(identityId), + ]) - const identityId = await resolveIdentityId(session) - if (identityId) { - await revokeKratosSessionsForIdentity(identityId) - } + await clearOrySessionCookie() +} +// Live Kratos session (whoami), memoized per request. The authority for "is +// authenticated"; the e2b_session cookie only carries the API token. +const readKratosSession = cache(async () => { try { - await authjsSignOut({ redirect: false }) + return await getServerSession() } catch (error) { - l.warn( + l.error( { - key: 'auth_provider:ory_sign_out_after_credential_change:error', + key: 'auth_provider:kratos_get_session:error', error: serializeErrorForLog(error), }, - 'failed to clear current Auth.js session after credential change' - ) - } -} - -export function getAuthContextFromOrySession( - session: Session | null | undefined -): AuthContext | null { - if (!session?.user?.id) return null - - const serverFields = readOrySessionFields(session) - if (!serverFields?.accessToken) return null - - if (serverFields.error) { - l.warn( - { - key: 'auth_provider:ory_session_error', - user_id: session.user.id, - context: { error: serverFields.error }, - }, - `Auth.js session reports error '${serverFields.error}'; treating as unauthenticated` + 'getServerSession() threw while reading the Kratos session' ) return null } +}) - return { - user: fromAuthSession(session), - accessToken: serverFields.accessToken, - } satisfies AuthContext -} - -async function resolveIdentityId(session: Session): Promise { - const serverFields = readOrySessionFields(session) - if (serverFields?.identityId) return serverFields.identityId - - const identity = await resolveOryIdentity({ - subjects: [session.user.id], - email: session.user.email, - }) - return identity?.id ?? null -} - -function readCurrentSession( - authSession: Session | null | undefined -): Promise { - return authSession === undefined - ? readSession() - : Promise.resolve(authSession) -} +const readOrySessionTokens = cache(async () => { + const cookieStore = await cookies() + return openOrySession(cookieStore.get(E2B_SESSION_COOKIE)?.value) +}) -const readSession = cache(async (): Promise => { +async function clearOrySessionCookie(): Promise { try { - return await authjs() + const cookieStore = await cookies() + cookieStore.delete(E2B_SESSION_COOKIE) } catch (error) { - l.error( + l.warn( { - key: 'auth_provider:ory_get_session:error', + key: 'auth_provider:clear_session_cookie:error', error: serializeErrorForLog(error), }, - 'Auth.js auth() helper threw while reading session' + 'failed to clear the e2b_session cookie after credential change' ) - return null } -}) +} diff --git a/src/core/server/auth/ory/signout-flow.ts b/src/core/server/auth/ory/signout-flow.ts index 75b5a0090..4d2892fd2 100644 --- a/src/core/server/auth/ory/signout-flow.ts +++ b/src/core/server/auth/ory/signout-flow.ts @@ -1,53 +1,36 @@ import 'server-only' -import { auth, signOut } from '@/auth' +import { cookies } from 'next/headers' import { BASE_URL } from '@/configs/urls' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' -import { readOrySessionFields } from './authjs-session-boundary' -import { revokeKratosSessionsForIdentity } from './kratos-session' -import { revokeOryOAuthSessionsForSubject } from './oauth-session' -import { ORY_POST_LOGOUT_PATH } from './signout' +import { E2B_SESSION_COOKIE, openOrySession } from './session-cookie' +import { buildOryLogoutUrl, ORY_POST_LOGOUT_PATH } from './signout' +// RP-initiated logout: hand Hydra the id_token so it ends its own OAuth2 session +// and (since it delegates login to Kratos) the Kratos session, then returns to +// post_logout_redirect_uri. The sign-out route clears e2b_session on the +// redirect it emits. Falls back to home if there's no id_token to present. export async function completeOrySignOut(origin = BASE_URL): Promise { - let identityId: string | undefined - let userId: string | undefined - try { - const session = await auth() - const serverFields = readOrySessionFields(session) - userId = session?.user?.id - // 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 = serverFields?.identityId - } catch (error) { - l.warn( - { - key: 'oauth_signout:read_session:error', - error: serializeErrorForLog(error), - }, - 'failed to read Auth.js session before sign-out' - ) - } + const fallback = new URL(ORY_POST_LOGOUT_PATH, origin).toString() + let idToken: string | undefined try { - await signOut({ redirect: false }) + const cookieStore = await cookies() + const tokens = await openOrySession( + cookieStore.get(E2B_SESSION_COOKIE)?.value + ) + idToken = tokens?.idToken } catch (error) { l.warn( { - key: 'oauth_signout:authjs_sign_out:error', + key: 'oauth_signout:read_session:error', error: serializeErrorForLog(error), }, - 'Auth.js signOut() failed' + 'failed to read e2b_session before sign-out' ) } - // Hydra OAuth and Kratos session revocations are independent admin calls; - // run them concurrently to keep the sign-out action fast. Both helpers - // log-and-swallow their own errors, and the Kratos helper retries 429 - // contention, so Promise.all never rejects here. - await Promise.all([ - userId ? revokeOryOAuthSessionsForSubject(userId) : null, - identityId ? revokeKratosSessionsForIdentity(identityId) : null, - ]) + if (!idToken) return fallback - return new URL(ORY_POST_LOGOUT_PATH, origin).toString() + return buildOryLogoutUrl({ idToken, origin })?.toString() ?? fallback } diff --git a/src/core/server/auth/ory/signout.ts b/src/core/server/auth/ory/signout.ts index 978cb485f..896b45873 100644 --- a/src/core/server/auth/ory/signout.ts +++ b/src/core/server/auth/ory/signout.ts @@ -1,13 +1,8 @@ -// 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 = '/' +// Builds Hydra's RP-initiated logout URL. With the id_token as the hint Hydra +// ends both its own OAuth2 session and (since it delegates login to Kratos) the +// Kratos session, then returns the browser to post_logout_redirect_uri. export function buildOryLogoutUrl({ idToken, origin, @@ -15,12 +10,12 @@ export function buildOryLogoutUrl({ idToken: string origin: string }): URL | null { - const sdkUrl = process.env.ORY_SDK_URL - if (!sdkUrl) return null + const issuer = process.env.ORY_HYDRA_PUBLIC_URL ?? process.env.ORY_SDK_URL + if (!issuer) return null const postLogoutUrl = new URL(ORY_POST_LOGOUT_PATH, origin) const logoutUrl = new URL( - `${sdkUrl.replace(/\/$/, '')}/oauth2/sessions/logout` + `${issuer.replace(/\/$/, '')}/oauth2/sessions/logout` ) logoutUrl.searchParams.set('id_token_hint', idToken) logoutUrl.searchParams.set( diff --git a/src/core/server/auth/ory/signup-metadata.ts b/src/core/server/auth/ory/signup-metadata.ts index 37c36e1aa..ad028a332 100644 --- a/src/core/server/auth/ory/signup-metadata.ts +++ b/src/core/server/auth/ory/signup-metadata.ts @@ -1,10 +1,8 @@ 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' +import { l } from '@/core/shared/clients/logger/logger' export const ORY_SIGNUP_METADATA_COOKIE = 'e2b-ory-signup-metadata' @@ -17,6 +15,14 @@ export type OrySignupMetadata = { signup_user_agent?: string } +export type OrySignupMetadataCookieOptions = { + httpOnly: true + sameSite: 'lax' + path: '/' + secure: boolean + maxAge: number +} + export function readOrySignupMetadataFromHeaders( headers: Headers ): OrySignupMetadata | null { @@ -31,55 +37,40 @@ export function readOrySignupMetadataFromHeaders( return metadata.signup_ip || metadata.signup_user_agent ? metadata : null } -export async function setOrySignupMetadataCookie( +// Tamper-evident HMAC handoff between the start route (sets it on the redirect) +// and the callback's bootstrap (reads it). httpOnly + same-origin already gate +// access; the signature guards against a forged value. +export function encodeOrySignupMetadata( metadata: OrySignupMetadata | null -): Promise { - if (!metadata) return +): string | null { + if (!metadata) return null - const encoded = encodeSignupMetadata(metadata) - if (!encoded) { + const secret = process.env.E2B_SESSION_SECRET + if (!secret) { l.warn( { key: 'auth_provider:ory_signup_metadata:missing_secret' }, - 'Skipping Ory signup metadata handoff because AUTH_SECRET is not configured' + 'Skipping Ory signup metadata handoff because E2B_SESSION_SECRET is not configured' ) - return + return null } - const cookieStore = await cookies() - cookieStore.set(ORY_SIGNUP_METADATA_COOKIE, encoded, { + const payload = Buffer.from(JSON.stringify(metadata), 'utf8').toString( + 'base64url' + ) + const signature = createHmac('sha256', secret) + .update(payload) + .digest('base64url') + + return `${payload}.${signature}` +} + +export function signupMetadataCookieOptions(): OrySignupMetadataCookieOptions { + return { 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' - ) + maxAge: SIGNUP_METADATA_COOKIE_MAX_AGE_SECONDS, } } @@ -100,71 +91,8 @@ export async function readOrySignupMetadataCookie(): 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 metadata = await readOrySignupMetadataCookie() - - cookieStore.delete(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 + const secret = process.env.E2B_SESSION_SECRET if (!secret) return null const [payload, signature] = value.split('.') @@ -220,16 +148,6 @@ function normalizeHeaderValue( 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) diff --git a/src/core/server/auth/ory/token-refresh.ts b/src/core/server/auth/ory/token-refresh.ts new file mode 100644 index 000000000..3727864a6 --- /dev/null +++ b/src/core/server/auth/ory/token-refresh.ts @@ -0,0 +1,121 @@ +import * as oauth from 'oauth4webapi' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { absoluteExpiry, readOryOAuthEnv } from './oauth-client' +import type { OrySessionTokens } from './session-cookie' + +// Refresh the Hydra access token. Runs in the edge middleware, so it talks to +// the token endpoint directly (no discovery, no JWKS round-trip) and parses the +// response by hand. The refreshed id_token is not re-validated here — it was +// validated at the callback and only feeds the RP-logout hint. + +// Refresh slightly before expiry so a token never dies mid-request downstream. +const REFRESH_SKEW_SECONDS = 60 + +export type TokenRefreshResult = + | { status: 'refreshed'; tokens: OrySessionTokens } + // The refresh token is unusable (rotated out / revoked / expired). The caller + // clears the cookie and re-mints from the live Kratos session. + | { status: 'dead' } + // Transient failure (network/5xx/misconfig). Keep the current token and retry + // on the next request rather than forcing a re-auth on a blip. + | { status: 'unchanged' } + +export function isAccessTokenExpiring( + expiresAt: number, + nowSeconds: number = Math.floor(Date.now() / 1000) +): boolean { + return nowSeconds >= expiresAt - REFRESH_SKEW_SECONDS +} + +export async function refreshOrySession( + current: OrySessionTokens +): Promise { + if (!current.refreshToken) return { status: 'dead' } + + let env: ReturnType + try { + env = readOryOAuthEnv() + } catch (error) { + l.error( + { + key: 'auth_provider:refresh_token:misconfigured', + error: serializeErrorForLog(error), + }, + 'Ory refresh cannot run because the OAuth client env is missing' + ) + return { status: 'unchanged' } + } + + const as: oauth.AuthorizationServer = { + issuer: env.issuer.href, + token_endpoint: `${env.issuer.href.replace(/\/$/, '')}/oauth2/token`, + } + const client: oauth.Client = { client_id: env.clientId } + const clientAuth = oauth.ClientSecretBasic(env.clientSecret) + + try { + const response = await oauth.refreshTokenGrantRequest( + as, + client, + clientAuth, + current.refreshToken, + env.insecure ? { [oauth.allowInsecureRequests]: true } : undefined + ) + + if (!response.ok) { + if (await isInvalidGrant(response)) return { status: 'dead' } + + l.warn( + { + key: 'auth_provider:refresh_token:rejected', + context: { status: response.status }, + }, + `Ory refresh_token rejected (${response.status})` + ) + return { status: 'unchanged' } + } + + const body = (await response.json()) as Record + if (typeof body.access_token !== 'string') { + l.warn( + { key: 'auth_provider:refresh_token:no_access_token' }, + 'Ory refresh response had no access_token' + ) + return { status: 'unchanged' } + } + + return { + status: 'refreshed', + tokens: { + accessToken: body.access_token, + refreshToken: + typeof body.refresh_token === 'string' + ? body.refresh_token + : current.refreshToken, + idToken: + typeof body.id_token === 'string' ? body.id_token : current.idToken, + expiresAt: absoluteExpiry( + typeof body.expires_in === 'number' ? body.expires_in : undefined + ), + }, + } + } catch (error) { + l.error( + { + key: 'auth_provider:refresh_token:exception', + error: serializeErrorForLog(error), + }, + 'Ory refresh_token threw' + ) + return { status: 'unchanged' } + } +} + +async function isInvalidGrant(response: Response): Promise { + try { + const body = (await response.clone().json()) as { error?: unknown } + return body.error === 'invalid_grant' + } catch { + return false + } +} diff --git a/src/core/server/proxy/classifier.ts b/src/core/server/proxy/classifier.ts index 3dbdd46d6..36719ecb0 100644 --- a/src/core/server/proxy/classifier.ts +++ b/src/core/server/proxy/classifier.ts @@ -13,10 +13,10 @@ export type ProxyPlan = | { kind: 'public' } const TRPC_API_PREFIXES = ['/api/trpc'] as const -const AUTHJS_ENDPOINT_PREFIXES = ['/api/auth'] as const +const AUTH_ENDPOINT_PREFIXES = ['/api/auth'] as const export function classifyProxyRequest(pathname: string): ProxyPlan { - if (matchesAnyPrefix(pathname, AUTHJS_ENDPOINT_PREFIXES)) { + if (matchesAnyPrefix(pathname, AUTH_ENDPOINT_PREFIXES)) { return { kind: 'bypass' } } @@ -47,7 +47,7 @@ export function classifyProxyRequest(pathname: string): ProxyPlan { : { kind: 'public' } } -export function planNeedsAuthJsSession(plan: ProxyPlan): boolean { +export function planNeedsAuthGate(plan: ProxyPlan): boolean { return plan.kind === 'auth-page' || plan.kind === 'dashboard-page' } diff --git a/src/core/server/proxy/runtime.ts b/src/core/server/proxy/runtime.ts index 1af02e99e..21949b3c3 100644 --- a/src/core/server/proxy/runtime.ts +++ b/src/core/server/proxy/runtime.ts @@ -6,16 +6,24 @@ import { type NextRequest, NextResponse, } from 'next/server' -import { auth as authjsMiddleware } from '@/auth' -import { isOryCustomUiEnabled } from '@/configs/env-flags' import oryConfig from '@/configs/ory' -import { isOrySessionAuthenticated } from '@/core/server/auth/ory/authjs-session-boundary' +import { isKratosSessionActive } from '@/core/server/auth/ory/kratos-session-edge' +import { + E2B_SESSION_COOKIE, + openOrySession, + orySessionCookieOptions, + sealOrySession, +} from '@/core/server/auth/ory/session-cookie' +import { + isAccessTokenExpiring, + refreshOrySession, +} from '@/core/server/auth/ory/token-refresh' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import { getAuthRouteRedirect } from './auth-routes' import { classifyProxyRequest, type ProxyPlan, - planNeedsAuthJsSession, + planNeedsAuthGate, } from './classifier' import { handleAuthGate, @@ -29,7 +37,7 @@ type RunProxyOptions = { } // Same-origin paths the @ory/nextjs proxy forwards to Kratos (NEXT_PUBLIC_ORY_SDK_URL), -// so the custom UI's flow cookies stay first-party. +// so the Elements UI's flow cookies and whoami stay first-party. const ORY_SDK_PROXY_PREFIXES = [ '/self-service', '/sessions/whoami', @@ -47,30 +55,95 @@ function isOrySdkProxyPath(pathname: string): boolean { export async function runDashboardProxy( request: NextRequest, - event: NextFetchEvent + _event: NextFetchEvent ) { // Forward Ory SDK traffic to Kratos before classification (it would otherwise - // classify as a bypass and go to Next). Gated, so production is unaffected; - // path check first so the gate runs only for these paths. - if (isOrySdkProxyPath(request.nextUrl.pathname) && isOryCustomUiEnabled()) { + // classify as a bypass and go to Next). + if (isOrySdkProxyPath(request.nextUrl.pathname)) { return oryProxy(request) } + // Pattern B: refresh the e2b_session up front and propagate it to the same + // request (request.cookies) so RSC/route handlers and the gate below read the + // fresh token, then persist it on the outgoing response for the browser. + const session = await refreshSessionCookie(request) const plan = classifyProxyRequest(request.nextUrl.pathname) - if (!planNeedsAuthJsSession(plan)) { - return runProxyConcerns(request, plan) + if (!planNeedsAuthGate(plan)) { + return session.persist(await runProxyConcerns(request, plan)) + } + + // The Kratos session is the source of truth, checked via an edge-safe whoami. + // A valid API token must also be present; without one we skip whoami and let + // the redirect re-mint a token (or surface the login UI) through the OAuth + // start route. + const isAuthenticated = + session.hasToken && (await isKratosSessionActive(request)) + + const authRouteRedirect = getAuthRouteRedirect(request, isAuthenticated) + if (authRouteRedirect) return session.persist(authRouteRedirect) + + return session.persist( + await runProxyConcerns(request, plan, { isAuthenticated }) + ) +} + +type SessionRefresh = { + hasToken: boolean + persist: (response: Response) => Response +} + +const noPersist: SessionRefresh['persist'] = (response) => response + +async function refreshSessionCookie( + request: NextRequest +): Promise { + const tokens = await openOrySession( + request.cookies.get(E2B_SESSION_COOKIE)?.value + ) + + if (!tokens) return { hasToken: false, persist: noPersist } + if (!isAccessTokenExpiring(tokens.expiresAt)) { + return { hasToken: true, persist: noPersist } } - const proxyWithAuth = authjsMiddleware((req, _event: NextFetchEvent) => { - const isAuthenticated = isOrySessionAuthenticated(req.auth) - const authRouteRedirect = getAuthRouteRedirect(req, isAuthenticated) - if (authRouteRedirect) return authRouteRedirect + const result = await refreshOrySession(tokens) + + if (result.status === 'refreshed') { + const sealed = await sealOrySession(result.tokens) + request.cookies.set(E2B_SESSION_COOKIE, sealed) + return { + hasToken: true, + persist: (response) => { + if (response instanceof NextResponse) { + response.cookies.set( + E2B_SESSION_COOKIE, + sealed, + orySessionCookieOptions() + ) + } + return response + }, + } + } - return runProxyConcerns(req, plan, { isAuthenticated }) - }) + if (result.status === 'dead') { + // The refresh token is unusable. Drop the cookie; the gate then re-mints + // from the live Kratos session (or routes to the login UI). + request.cookies.delete(E2B_SESSION_COOKIE) + return { + hasToken: false, + persist: (response) => { + if (response instanceof NextResponse) { + response.cookies.delete(E2B_SESSION_COOKIE) + } + return response + }, + } + } - return proxyWithAuth(request, event) + // Transient failure: keep serving the current (still-valid) token. + return { hasToken: true, persist: noPersist } } async function runProxyConcerns( diff --git a/src/core/server/trpc/init.ts b/src/core/server/trpc/init.ts index 70fc356ba..9092959ac 100644 --- a/src/core/server/trpc/init.ts +++ b/src/core/server/trpc/init.ts @@ -1,5 +1,4 @@ import { initTRPC } from '@trpc/server' -import type { Session } from 'next-auth' import superjson from 'superjson' import { flattenError, ZodError } from 'zod' import type { AuthUser } from '@/core/server/auth' @@ -13,11 +12,11 @@ type AuthenticatedSession = { /** * TRPC Context Factory * - * Factory function that creates a TRPC context. If a session exists, we are trying resolve the correct user data. + * Auth is resolved per-procedure by the auth middleware (Kratos whoami + + * e2b_session), not threaded through the context. */ export const createTRPCContext = async (opts: { headers: Headers - authSession?: Session | null requestUrl?: string requestObservability?: RequestObservabilityContext }) => { diff --git a/src/lib/env.ts b/src/lib/env.ts index a9344787a..66b9ab11b 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -12,13 +12,12 @@ export const serverSchema = z.object({ LAUNCHDARKLY_SDK_KEY: z.string().min(1).optional(), - AUTH_SECRET: z.string().min(1).optional(), - AUTH_TRUST_HOST: z.string().optional(), - // Prefix for Auth.js cookie names to disambiguate multiple local - // instances sharing localhost (cookies aren't scoped by port). - // Leave unset in prod/preview. - AUTH_COOKIE_PREFIX: z.string().min(1).optional(), + // JWE key for the e2b_session cookie. Generate with `openssl rand -hex 32`. + E2B_SESSION_SECRET: z.string().min(1).optional(), ORY_SDK_URL: z.url().optional(), + // OIDC issuer (Hydra public URL). Falls back to ORY_SDK_URL on Ory Network; + // set explicitly for self-hosted Hydra (e.g. http://localhost:4444). + ORY_HYDRA_PUBLIC_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(), @@ -76,10 +75,8 @@ export const clientSchema = z.object({ NEXT_PUBLIC_E2B_SANDBOX_URL: z.url().optional(), NEXT_PUBLIC_DASHBOARD_API_URL: z.url().optional(), - // Browser-facing Kratos public URL for the custom Ory UI; falls back to ORY_SDK_URL. + // Browser-facing Kratos public URL for the Elements UI; falls back to ORY_SDK_URL. NEXT_PUBLIC_ORY_SDK_URL: z.url().optional(), - // Gates the custom Ory UI: 'true' on Preview/Staging, unset on Production. - NEXT_PUBLIC_ORY_CUSTOM_UI: z.string().optional(), }) const merged = serverSchema.merge(clientSchema) @@ -87,7 +84,7 @@ const merged = serverSchema.merge(clientSchema) type MergedEnv = z.infer const oryRequiredEnvVars = [ - 'AUTH_SECRET', + 'E2B_SESSION_SECRET', 'ORY_SDK_URL', 'ORY_OAUTH2_CLIENT_ID', 'ORY_OAUTH2_CLIENT_SECRET', @@ -105,7 +102,7 @@ function requireEnvVars( ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `Auth.js/Ory requires ${envVar}`, + message: `Ory requires ${envVar}`, path: [envVar], }) } @@ -133,7 +130,7 @@ function validateOryAdminEnv(data: MergedEnv, ctx: z.RefinementCtx) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: - 'Auth.js/Ory requires ORY_PROJECT_API_TOKEN (Ory Network) or both ORY_KRATOS_ADMIN_URL and ORY_HYDRA_ADMIN_URL (self-hosted)', + 'Ory requires ORY_PROJECT_API_TOKEN (Ory Network) or both ORY_KRATOS_ADMIN_URL and ORY_HYDRA_ADMIN_URL (self-hosted)', path: ['ORY_PROJECT_API_TOKEN'], }) } diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts deleted file mode 100644 index e1122f9b3..000000000 --- a/src/types/next-auth.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { DefaultSession } from 'next-auth' - -declare module 'next-auth' { - interface Session { - 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-account-security.test.ts b/tests/integration/auth-ory-account-security.test.ts index 85bd95098..bccc2b664 100644 --- a/tests/integration/auth-ory-account-security.test.ts +++ b/tests/integration/auth-ory-account-security.test.ts @@ -1,18 +1,30 @@ import type { Identity } from '@ory/client-fetch' -import type { Session } from 'next-auth' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const authjsMock = vi.hoisted(() => vi.fn()) -const authjsSignOutMock = vi.hoisted(() => vi.fn()) +const getServerSessionMock = vi.hoisted(() => vi.fn()) const getIdentityMock = vi.hoisted(() => vi.fn()) const updateIdentityMock = vi.hoisted(() => vi.fn()) const patchIdentityMock = vi.hoisted(() => vi.fn()) const revokeOAuthSessionsMock = vi.hoisted(() => vi.fn()) const revokeKratosSessionsMock = vi.hoisted(() => vi.fn()) +const openOrySessionMock = vi.hoisted(() => vi.fn()) +const cookieDeleteMock = vi.hoisted(() => vi.fn()) -vi.mock('@/auth', () => ({ - auth: authjsMock, - signOut: authjsSignOutMock, +vi.mock('@ory/nextjs/app', () => ({ + getServerSession: getServerSessionMock, +})) + +vi.mock('next/headers', () => ({ + cookies: () => + Promise.resolve({ + get: vi.fn(() => ({ value: 'sealed-cookie' })), + delete: cookieDeleteMock, + }), +})) + +vi.mock('@/core/server/auth/ory/session-cookie', () => ({ + E2B_SESSION_COOKIE: 'e2b_session', + openOrySession: openOrySessionMock, })) vi.mock('@/core/server/auth/ory/client', () => ({ @@ -36,34 +48,8 @@ vi.mock('@/core/shared/clients/logger/logger', () => ({ serializeErrorForLog: vi.fn((error: unknown) => error), })) -const { handleCredentialChangeSuccess, updateUser } = await import( - '@/core/server/auth/ory/session' -) - -const nowSeconds = Math.floor(Date.now() / 1000) - -function makeIdToken(authTime: number): string { - return [ - Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url'), - Buffer.from(JSON.stringify({ auth_time: authTime })).toString('base64url'), - 'sig', - ].join('.') -} - -function makeSession({ - idToken = makeIdToken(nowSeconds), - identityId = 'kratos-uuid', -}: { - idToken?: string - identityId?: string -} = {}): Session { - return { - user: { id: 'e2b-user-id' }, - accessToken: 'access-token', - idToken, - identityId, - } as Session -} +const { getAuthContext, handleCredentialChangeSuccess, signOut, updateUser } = + await import('@/core/server/auth/ory/session') const currentIdentity = { id: 'kratos-uuid', @@ -75,20 +61,73 @@ const currentIdentity = { metadata_admin: { admin: true }, } satisfies Partial -describe('Ory account security', () => { +function kratosSession({ + authenticatedAt = new Date(), + identityId = 'kratos-uuid', +}: { + authenticatedAt?: Date + identityId?: string +} = {}) { + return { + active: true, + authenticated_at: authenticatedAt, + identity: { + id: identityId, + traits: { email: 'ada@example.test', name: 'Ada' }, + }, + } +} + +describe('Ory account security (Kratos session + e2b_session)', () => { beforeEach(() => { - authjsMock.mockReset() - authjsSignOutMock.mockReset().mockResolvedValue(undefined) + vi.stubEnv('ORY_HYDRA_PUBLIC_URL', 'https://ory.example.com') + getServerSessionMock.mockReset() getIdentityMock.mockReset().mockResolvedValue(currentIdentity) updateIdentityMock.mockReset().mockResolvedValue(undefined) patchIdentityMock.mockReset().mockResolvedValue(undefined) revokeOAuthSessionsMock.mockReset().mockResolvedValue(undefined) revokeKratosSessionsMock.mockReset().mockResolvedValue(undefined) + openOrySessionMock.mockReset().mockResolvedValue({ + accessToken: 'hydra-access-token', + idToken: 'hydra-id-token', + expiresAt: 1_900_000_000, + }) + cookieDeleteMock.mockReset() + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('builds the auth context from the Kratos identity, token from e2b_session', async () => { + getServerSessionMock.mockResolvedValue(kratosSession()) + + expect(await getAuthContext()).toEqual({ + user: expect.objectContaining({ + id: 'kratos-uuid', + email: 'ada@example.test', + name: 'Ada', + }), + accessToken: 'hydra-access-token', + }) + }) + + it('returns null when the Kratos session is inactive despite a token', async () => { + getServerSessionMock.mockResolvedValue({ active: false }) + + expect(await getAuthContext()).toBeNull() + }) + + it('returns null when the Kratos session is active but no token is present', async () => { + getServerSessionMock.mockResolvedValue(kratosSession()) + openOrySessionMock.mockResolvedValue(null) + + expect(await getAuthContext()).toBeNull() }) - it('requires fresh authentication before credential changes', async () => { - authjsMock.mockResolvedValue( - makeSession({ idToken: makeIdToken(nowSeconds - 10_000) }) + it('requires a fresh Kratos session before credential changes', async () => { + getServerSessionMock.mockResolvedValue( + kratosSession({ authenticatedAt: new Date(Date.now() - 20 * 60_000) }) ) const result = await updateUser({ password: 'new-secret' }) @@ -98,7 +137,7 @@ describe('Ory account security', () => { }) it('uses Ory updateIdentity for fresh password changes', async () => { - authjsMock.mockResolvedValue(makeSession()) + getServerSessionMock.mockResolvedValue(kratosSession()) getIdentityMock .mockResolvedValueOnce(currentIdentity) .mockResolvedValueOnce({ @@ -111,24 +150,32 @@ describe('Ory account security', () => { expect(updateIdentityMock).toHaveBeenCalledWith({ id: 'kratos-uuid', updateIdentityBody: expect.objectContaining({ - schema_id: 'default', - state: 'active', - external_id: 'e2b-user-id', - metadata_public: { public: true }, - metadata_admin: { admin: true }, credentials: { password: { config: { password: 'new-secret' } } }, }), }) - expect(result).toMatchObject({ ok: true, user: { id: 'e2b-user-id' } }) + expect(result).toMatchObject({ ok: true, user: { id: 'kratos-uuid' } }) }) - it('revokes Ory/Kratos sessions and clears Auth.js after credential changes', async () => { - authjsMock.mockResolvedValue(makeSession()) + it('revokes Ory + Kratos sessions and clears e2b_session after a credential change', async () => { + getServerSessionMock.mockResolvedValue(kratosSession()) await handleCredentialChangeSuccess() - expect(revokeOAuthSessionsMock).toHaveBeenCalledWith('e2b-user-id') + expect(revokeOAuthSessionsMock).toHaveBeenCalledWith('kratos-uuid') expect(revokeKratosSessionsMock).toHaveBeenCalledWith('kratos-uuid') - expect(authjsSignOutMock).toHaveBeenCalledWith({ redirect: false }) + expect(cookieDeleteMock).toHaveBeenCalledWith('e2b_session') + }) + + it('signs out via Hydra RP-logout using the id_token hint', async () => { + const result = await signOut({ origin: 'https://app.e2b.dev' }) + + expect(result.redirectTo).toContain( + 'https://ory.example.com/oauth2/sessions/logout' + ) + expect(result.redirectTo).toContain('id_token_hint=hydra-id-token') + expect(result.redirectTo).toContain('post_logout_redirect_uri=') + // Single sign-out must not revoke every session. + expect(revokeKratosSessionsMock).not.toHaveBeenCalled() + expect(revokeOAuthSessionsMock).not.toHaveBeenCalled() }) }) diff --git a/tests/integration/auth-ory-callback.test.ts b/tests/integration/auth-ory-callback.test.ts new file mode 100644 index 000000000..37f3ff721 --- /dev/null +++ b/tests/integration/auth-ory-callback.test.ts @@ -0,0 +1,127 @@ +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + E2B_OAUTH_FLOW_COOKIE, + serializeOryFlowState, +} from '@/core/server/auth/ory/oauth-flow' +import { + E2B_SESSION_COOKIE, + openOrySession, +} from '@/core/server/auth/ory/session-cookie' +import { ORY_SIGNUP_METADATA_COOKIE } from '@/core/server/auth/ory/signup-metadata' + +const exchangeMock = vi.hoisted(() => vi.fn()) +const bootstrapMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/core/server/auth/ory/oauth-client', () => ({ + exchangeOryCallback: exchangeMock, +})) + +vi.mock('@/core/server/auth/ory/dashboard-bootstrap', () => ({ + ensureOryUserBootstrapped: bootstrapMock, +})) + +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/callback/ory/route') + +const tokens = { + accessToken: 'access-token', + refreshToken: 'refresh-token', + idToken: 'id-token', + expiresAt: 1_900_000_000, +} + +function callbackRequest({ + withFlow = true, + returnTo, +}: { + withFlow?: boolean + returnTo?: string +} = {}): NextRequest { + const headers: Record = {} + if (withFlow) { + const flow = serializeOryFlowState({ + state: 'state-value', + nonce: 'nonce-value', + codeVerifier: 'verifier-value', + returnTo, + }) + headers.cookie = `${E2B_OAUTH_FLOW_COOKIE}=${flow}` + } + return new NextRequest( + 'https://app.e2b.dev/api/auth/oauth/callback/ory?code=abc&state=state-value', + { headers } + ) +} + +describe('Ory OAuth callback', () => { + beforeEach(() => { + vi.stubEnv('E2B_SESSION_SECRET', 'callback-test-secret') + vi.stubEnv('ORY_HYDRA_PUBLIC_URL', 'https://ory.example.com') + exchangeMock.mockReset().mockResolvedValue(tokens) + bootstrapMock.mockReset().mockResolvedValue(true) + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('seals e2b_session and redirects to returnTo on success', async () => { + const response = await GET(callbackRequest({ returnTo: '/dashboard/team' })) + + expect(response.headers.get('location')).toBe( + 'https://app.e2b.dev/dashboard/team' + ) + + const sealed = response.cookies.get(E2B_SESSION_COOKIE)?.value + expect(await openOrySession(sealed)).toEqual(tokens) + + // The transient cookies are cleared on the way out. + expect(response.cookies.get(E2B_OAUTH_FLOW_COOKIE)?.value).toBe('') + expect(response.cookies.get(ORY_SIGNUP_METADATA_COOKIE)?.value).toBe('') + }) + + it('defaults to the dashboard when no returnTo is present', async () => { + const response = await GET(callbackRequest()) + + expect(response.headers.get('location')).toBe( + 'https://app.e2b.dev/dashboard' + ) + }) + + it('routes to recover when the flow-state cookie is missing', async () => { + const response = await GET(callbackRequest({ withFlow: false })) + + expect(response.headers.get('location')).toBe( + 'https://app.e2b.dev/api/auth/oauth/recover' + ) + expect(exchangeMock).not.toHaveBeenCalled() + expect(response.cookies.get(E2B_SESSION_COOKIE)?.value).toBeUndefined() + }) + + it('routes to recover when the code exchange fails', async () => { + exchangeMock.mockRejectedValueOnce(new Error('state mismatch')) + + const response = await GET(callbackRequest()) + + expect(response.headers.get('location')).toBe( + 'https://app.e2b.dev/api/auth/oauth/recover' + ) + expect(response.cookies.get(E2B_SESSION_COOKIE)?.value).toBeUndefined() + }) + + it('RP-logs-out (no dashboard cookie) when bootstrap fails', async () => { + bootstrapMock.mockResolvedValueOnce(false) + + const response = await GET(callbackRequest()) + const location = response.headers.get('location') ?? '' + + expect(location).toContain('https://ory.example.com/oauth2/sessions/logout') + expect(location).toContain('id_token_hint=id-token') + expect(response.cookies.get(E2B_SESSION_COOKIE)?.value).toBeUndefined() + }) +}) diff --git a/tests/integration/auth-ory-consent.test.ts b/tests/integration/auth-ory-consent.test.ts new file mode 100644 index 000000000..46f015a32 --- /dev/null +++ b/tests/integration/auth-ory-consent.test.ts @@ -0,0 +1,106 @@ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const getConsentMock = vi.hoisted(() => vi.fn()) +const acceptConsentMock = vi.hoisted(() => vi.fn()) +const getIdentityMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/core/server/auth/ory/client', () => ({ + getOryOAuth2Api: () => ({ + getOAuth2ConsentRequest: getConsentMock, + acceptOAuth2ConsentRequest: acceptConsentMock, + }), + getOryIdentityApi: () => ({ getIdentity: getIdentityMock }), +})) + +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/consent/route') + +const HYDRA_CONTINUE = + 'https://ory.example.com/oauth2/auth?consent_verifier=xyz' + +function consentRequest(challenge: string | null = 'consent-challenge') { + const url = new URL('https://app.e2b.dev/consent') + if (challenge !== null) url.searchParams.set('consent_challenge', challenge) + return new NextRequest(url) +} + +describe('Ory consent provider', () => { + beforeEach(() => { + getConsentMock.mockReset().mockResolvedValue({ + subject: 'identity-uuid', + requested_scope: ['openid', 'offline_access', 'email', 'profile'], + requested_access_token_audience: ['dashboard-api'], + }) + getIdentityMock.mockReset().mockResolvedValue({ + traits: { + email: 'local-dev@e2b.dev', + name: { first: 'Local', last: 'Dev' }, + }, + }) + acceptConsentMock + .mockReset() + .mockResolvedValue({ redirect_to: HYDRA_CONTINUE }) + }) + + it('folds the identity profile traits into the id_token and redirects', async () => { + const response = await GET(consentRequest()) + + expect(response.headers.get('location')).toBe(HYDRA_CONTINUE) + expect(getIdentityMock).toHaveBeenCalledWith({ id: 'identity-uuid' }) + expect(acceptConsentMock).toHaveBeenCalledWith({ + consentChallenge: 'consent-challenge', + acceptOAuth2ConsentRequest: { + grant_scope: ['openid', 'offline_access', 'email', 'profile'], + grant_access_token_audience: ['dashboard-api'], + session: { + id_token: { email: 'local-dev@e2b.dev', name: 'Local Dev' }, + }, + }, + }) + }) + + it('omits the email claim when the email scope is not granted', async () => { + getConsentMock.mockResolvedValueOnce({ + subject: 'identity-uuid', + requested_scope: ['openid'], + requested_access_token_audience: [], + }) + + await GET(consentRequest()) + + expect(acceptConsentMock).toHaveBeenCalledWith( + expect.objectContaining({ + acceptOAuth2ConsentRequest: expect.objectContaining({ + session: { id_token: {} }, + }), + }) + ) + }) + + it('still accepts (without profile claims) when the identity lookup fails', async () => { + getIdentityMock.mockRejectedValueOnce(new Error('404')) + + const response = await GET(consentRequest()) + + expect(response.headers.get('location')).toBe(HYDRA_CONTINUE) + expect(acceptConsentMock).toHaveBeenCalledWith( + expect.objectContaining({ + acceptOAuth2ConsentRequest: expect.objectContaining({ + session: { id_token: {} }, + }), + }) + ) + }) + + it('redirects home when there is no consent_challenge', async () => { + const response = await GET(consentRequest(null)) + + expect(response.headers.get('location')).toBe('https://app.e2b.dev/') + expect(getConsentMock).not.toHaveBeenCalled() + }) +}) diff --git a/tests/integration/auth-ory-entrypoints.test.ts b/tests/integration/auth-ory-entrypoints.test.ts index 0b5e1830f..5f6f04d1d 100644 --- a/tests/integration/auth-ory-entrypoints.test.ts +++ b/tests/integration/auth-ory-entrypoints.test.ts @@ -1,41 +1,48 @@ 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, -})) -const authMiddlewareMock = vi.hoisted(() => vi.fn()) -const signInMock = vi.hoisted(() => vi.fn()) +const isKratosSessionActiveMock = vi.hoisted(() => vi.fn()) +const openOrySessionMock = vi.hoisted(() => vi.fn()) +const isAccessTokenExpiringMock = vi.hoisted(() => vi.fn()) +const refreshOrySessionMock = vi.hoisted(() => vi.fn()) +const sealOrySessionMock = vi.hoisted(() => vi.fn()) +const buildAuthorizationRequestMock = vi.hoisted(() => vi.fn()) const readSignupMetadataMock = vi.hoisted(() => vi.fn()) -const setSignupMetadataCookieMock = vi.hoisted(() => vi.fn()) - -vi.mock('@/auth', () => ({ - auth: authMiddlewareMock.mockImplementation( - ( - 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) - } - ), - signIn: signInMock, +const encodeSignupMetadataMock = vi.hoisted(() => vi.fn()) + +vi.mock('@ory/nextjs/middleware', () => ({ + createOryMiddleware: () => vi.fn(), +})) + +vi.mock('@/core/server/auth/ory/kratos-session-edge', () => ({ + isKratosSessionActive: isKratosSessionActiveMock, +})) + +vi.mock('@/core/server/auth/ory/session-cookie', () => ({ + E2B_SESSION_COOKIE: 'e2b_session', + openOrySession: openOrySessionMock, + sealOrySession: sealOrySessionMock, + orySessionCookieOptions: () => ({ httpOnly: true, path: '/' }), +})) + +vi.mock('@/core/server/auth/ory/token-refresh', () => ({ + isAccessTokenExpiring: isAccessTokenExpiringMock, + refreshOrySession: refreshOrySessionMock, +})) + +vi.mock('@/core/server/auth/ory/oauth-client', () => ({ + buildOryAuthorizationRequest: buildAuthorizationRequestMock, })) vi.mock('@/core/server/auth/ory/signup-metadata', () => ({ + ORY_SIGNUP_METADATA_COOKIE: 'e2b-ory-signup-metadata', readOrySignupMetadataFromHeaders: readSignupMetadataMock, - setOrySignupMetadataCookie: setSignupMetadataCookieMock, + encodeOrySignupMetadata: encodeSignupMetadataMock, + signupMetadataCookieOptions: () => ({ httpOnly: true, path: '/' }), })) vi.mock('@/core/shared/clients/logger/logger', () => ({ - l: { error: vi.fn() }, + l: { error: vi.fn(), warn: vi.fn() }, serializeErrorForLog: vi.fn((error: unknown) => error), })) @@ -46,40 +53,19 @@ function request(path: string): NextRequest { return new NextRequest(`https://app.e2b.dev${path}`) } -function orySession({ - accessToken, - error, - userId = 'user-id', -}: { - accessToken?: string - error?: string - userId?: string -}): Session { - return { - user: userId ? { id: userId } : {}, - accessToken, - error, - } as Session -} - -describe('Ory auth entrypoints', () => { +describe('Ory auth entrypoints — proxy gate', () => { beforeEach(() => { - authSession.current = null - authMiddlewareMock.mockClear() - signInMock.mockReset().mockResolvedValue(undefined) - readSignupMetadataMock.mockReset().mockReturnValue({ - signup_ip: '203.0.113.10', - signup_user_agent: 'Mozilla/5.0', - }) - setSignupMetadataCookieMock.mockReset().mockResolvedValue(undefined) + isKratosSessionActiveMock.mockReset().mockResolvedValue(false) + openOrySessionMock.mockReset().mockResolvedValue(null) + isAccessTokenExpiringMock.mockReset().mockReturnValue(false) + refreshOrySessionMock.mockReset() }) afterEach(() => { vi.unstubAllEnvs() - authSession.current = null }) - it('routes auth pages to Ory', async () => { + it('routes unauthenticated auth pages to the OAuth start route', async () => { const signIn = await proxy(request('/sign-in/'), {} as NextFetchEvent) const signUp = await proxy( request('/sign-up?returnTo=%2Fdashboard'), @@ -94,23 +80,37 @@ describe('Ory auth entrypoints', () => { ) }) - it('does not treat incomplete Auth.js sessions as authenticated', async () => { - for (const session of [ - orySession({}), - orySession({ accessToken: 'access-token', userId: '' }), - orySession({ accessToken: 'access-token', error: 'RefreshTokenError' }), - ]) { - authSession.current = session - const response = await proxy(request('/sign-in'), {} as NextFetchEvent) - - expect(response.headers.get('location')).toContain( - '/api/auth/oauth/start?intent=signin' - ) - } + it('treats a request without an e2b_session token as unauthenticated', async () => { + // No token → the gate need not even consult Kratos. + const response = await proxy(request('/sign-in'), {} as NextFetchEvent) + + expect(response.headers.get('location')).toContain( + '/api/auth/oauth/start?intent=signin' + ) + expect(isKratosSessionActiveMock).not.toHaveBeenCalled() + }) + + it('treats a token without a live Kratos session as unauthenticated', async () => { + openOrySessionMock.mockResolvedValue({ + accessToken: 'a', + expiresAt: 1_900_000_000, + }) + isKratosSessionActiveMock.mockResolvedValue(false) + + const response = await proxy(request('/sign-in'), {} as NextFetchEvent) + + expect(response.headers.get('location')).toContain( + '/api/auth/oauth/start?intent=signin' + ) + expect(isKratosSessionActiveMock).toHaveBeenCalled() }) it('redirects authenticated users away from auth pages', async () => { - authSession.current = orySession({ accessToken: 'access-token' }) + openOrySessionMock.mockResolvedValue({ + accessToken: 'a', + expiresAt: 1_900_000_000, + }) + isKratosSessionActiveMock.mockResolvedValue(true) const response = await proxy(request('/sign-in'), {} as NextFetchEvent) @@ -119,56 +119,126 @@ describe('Ory auth entrypoints', () => { ) }) - it('does not run proxy Auth.js for API routes', async () => { + it('does not gate API routes on the Kratos session', async () => { await proxy(request('/api/trpc/user.update'), {} as NextFetchEvent) await proxy(request('/api/health'), {} as NextFetchEvent) - await proxy(request('/api/auth/oauth/session'), {} as NextFetchEvent) + await proxy(request('/api/auth/oauth/callback/ory'), {} as NextFetchEvent) + + expect(isKratosSessionActiveMock).not.toHaveBeenCalled() + }) +}) + +describe('Ory auth entrypoints — middleware refresh (Pattern B)', () => { + const expiring = { + accessToken: 'old-access', + refreshToken: 'old-refresh', + expiresAt: 1_000, + } - expect(authMiddlewareMock).not.toHaveBeenCalled() + beforeEach(() => { + openOrySessionMock.mockReset().mockResolvedValue(expiring) + isKratosSessionActiveMock.mockReset().mockResolvedValue(true) + isAccessTokenExpiringMock.mockReset().mockReturnValue(true) + refreshOrySessionMock.mockReset() + sealOrySessionMock.mockReset().mockResolvedValue('sealed-new') }) - it('starts Ory sign-in, registration, and re-auth with the right parameters', async () => { - await oauthStartGET( - new Request('https://app.e2b.dev/api/auth/oauth/start?intent=signin') + it('refreshes an expiring token and persists it on the response', async () => { + refreshOrySessionMock.mockResolvedValue({ + status: 'refreshed', + tokens: { accessToken: 'new-access', expiresAt: 2_000 }, + }) + + const response = await proxy( + request('/dashboard/acme/sandboxes'), + {} as NextFetchEvent ) - expect(signInMock).toHaveBeenLastCalledWith( - 'ory', - { redirectTo: '/dashboard' }, - undefined + + expect(refreshOrySessionMock).toHaveBeenCalledWith(expiring) + expect(response.cookies.get('e2b_session')?.value).toBe('sealed-new') + // Authenticated dashboard request is served (not redirected away). + expect(response.headers.get('location')).toBeNull() + }) + + it('clears the cookie and redirects when the refresh is dead', async () => { + refreshOrySessionMock.mockResolvedValue({ status: 'dead' }) + + const response = await proxy( + request('/dashboard/acme/sandboxes'), + {} as NextFetchEvent ) - expect(readSignupMetadataMock).not.toHaveBeenCalled() - await oauthStartGET( - new Request( - 'https://app.e2b.dev/api/auth/oauth/start?intent=signup&returnTo=%2Fdashboard' - ) + expect(response.cookies.get('e2b_session')?.value).toBe('') + expect(response.headers.get('location')).toContain('/sign-in') + // A dead token short-circuits the gate before consulting Kratos. + expect(isKratosSessionActiveMock).not.toHaveBeenCalled() + }) + + it('does not refresh a token that is not yet expiring', async () => { + isAccessTokenExpiringMock.mockReturnValue(false) + + const response = await proxy( + request('/dashboard/acme/sandboxes'), + {} as NextFetchEvent ) - expect(readSignupMetadataMock).toHaveBeenCalled() - expect(setSignupMetadataCookieMock).toHaveBeenCalledWith({ - signup_ip: '203.0.113.10', - signup_user_agent: 'Mozilla/5.0', + + expect(refreshOrySessionMock).not.toHaveBeenCalled() + expect(sealOrySessionMock).not.toHaveBeenCalled() + expect(response.cookies.get('e2b_session')).toBeUndefined() + }) +}) + +describe('Ory OAuth start route', () => { + beforeEach(() => { + buildAuthorizationRequestMock.mockReset().mockResolvedValue({ + url: 'https://ory.example.com/oauth2/auth?client_id=x&state=s', + state: 'state-value', + nonce: 'nonce-value', + codeVerifier: 'verifier-value', }) - expect(signInMock).toHaveBeenLastCalledWith( - 'ory', - { redirectTo: '/dashboard' }, - { prompt: 'registration' } + readSignupMetadataMock + .mockReset() + .mockReturnValue({ signup_ip: '203.0.113.10' }) + encodeSignupMetadataMock.mockReset().mockReturnValue('encoded-metadata') + }) + + it('builds the authorize URL and stashes the flow-state cookie', async () => { + const response = await oauthStartGET( + new NextRequest('https://app.e2b.dev/api/auth/oauth/start?intent=signin') ) - await oauthStartGET( - new Request('https://app.e2b.dev/api/auth/oauth/start?intent=reauth') + expect(response.headers.get('location')).toBe( + 'https://ory.example.com/oauth2/auth?client_id=x&state=s' ) - expect(signInMock).toHaveBeenLastCalledWith( - 'ory', - { redirectTo: '/dashboard' }, - { prompt: 'login' } + expect(buildAuthorizationRequestMock).toHaveBeenCalledWith( + 'signin', + 'https://app.e2b.dev/api/auth/oauth/callback/ory' + ) + expect(response.cookies.get('e2b_oauth_flow')?.value).toBeTruthy() + // No signup metadata captured for a plain sign-in. + expect(readSignupMetadataMock).not.toHaveBeenCalled() + }) + + it('captures signup metadata for the signup intent', async () => { + const response = await oauthStartGET( + new NextRequest('https://app.e2b.dev/api/auth/oauth/start?intent=signup') + ) + + expect(buildAuthorizationRequestMock).toHaveBeenCalledWith( + 'signup', + 'https://app.e2b.dev/api/auth/oauth/callback/ory' + ) + expect(readSignupMetadataMock).toHaveBeenCalled() + expect(response.cookies.get('e2b-ory-signup-metadata')?.value).toBe( + 'encoded-metadata' ) }) - it('rejects invalid Ory auth intents', async () => { + it('rejects an invalid auth intent', async () => { const response = await oauthStartGET( - new Request('https://app.e2b.dev/api/auth/oauth/start?intent=unknown') + new NextRequest('https://app.e2b.dev/api/auth/oauth/start?intent=nope') ) - expect(response?.status).toBe(400) + expect(response.status).toBe(400) }) }) diff --git a/tests/integration/auth-ory-logout.test.ts b/tests/integration/auth-ory-logout.test.ts new file mode 100644 index 000000000..288633060 --- /dev/null +++ b/tests/integration/auth-ory-logout.test.ts @@ -0,0 +1,87 @@ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const acceptLogoutMock = vi.hoisted(() => vi.fn()) +const createBrowserLogoutFlowMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/core/server/auth/ory/client', () => ({ + getOryOAuth2Api: () => ({ acceptOAuth2LogoutRequest: acceptLogoutMock }), + getOryFrontendApi: () => ({ + createBrowserLogoutFlow: createBrowserLogoutFlowMock, + }), +})) + +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/logout/route') + +const HYDRA_CONTINUE = + 'https://ory.example.com/oauth2/sessions/logout?logout_verifier=xyz' +const KRATOS_LOGOUT = + 'https://ory.example.com/self-service/logout?token=abc&return_to=' + + encodeURIComponent(HYDRA_CONTINUE) + +function logoutRequest({ + challenge = 'logout-challenge', + cookie = 'ory_kratos_session=session-token', +}: { + challenge?: string | null + cookie?: string +} = {}): NextRequest { + const url = new URL('https://app.e2b.dev/logout') + if (challenge !== null) url.searchParams.set('logout_challenge', challenge) + return new NextRequest(url, { headers: { cookie } }) +} + +describe('Ory logout provider', () => { + beforeEach(() => { + acceptLogoutMock + .mockReset() + .mockResolvedValue({ redirect_to: HYDRA_CONTINUE }) + createBrowserLogoutFlowMock + .mockReset() + .mockResolvedValue({ logout_url: KRATOS_LOGOUT, logout_token: 'tok' }) + }) + + it('clears Kratos then routes the browser to the Kratos logout URL', async () => { + const response = await GET(logoutRequest()) + + expect(response.headers.get('location')).toBe(KRATOS_LOGOUT) + expect(acceptLogoutMock).toHaveBeenCalledWith({ + logoutChallenge: 'logout-challenge', + }) + // The browser cookie is forwarded so Kratos mints a logout token for this + // session, and Hydra's continuation URL is the post-logout return target. + expect(createBrowserLogoutFlowMock).toHaveBeenCalledWith({ + cookie: 'ory_kratos_session=session-token', + returnTo: HYDRA_CONTINUE, + }) + }) + + it('redirects home when there is no logout_challenge', async () => { + const response = await GET(logoutRequest({ challenge: null })) + + expect(response.headers.get('location')).toBe('https://app.e2b.dev/') + expect(acceptLogoutMock).not.toHaveBeenCalled() + }) + + it('finalizes Hydra logout when no Kratos session is left to clear', async () => { + createBrowserLogoutFlowMock.mockRejectedValueOnce(new Error('401')) + + const response = await GET(logoutRequest()) + + expect(response.headers.get('location')).toBe(HYDRA_CONTINUE) + }) + + it('redirects home when accepting the Hydra logout fails', async () => { + acceptLogoutMock.mockRejectedValueOnce(new Error('unknown challenge')) + + const response = await GET(logoutRequest()) + + expect(response.headers.get('location')).toBe('https://app.e2b.dev/') + expect(createBrowserLogoutFlowMock).not.toHaveBeenCalled() + }) +}) diff --git a/tests/integration/auth-ory-session-boundary.test.ts b/tests/integration/auth-ory-session-boundary.test.ts deleted file mode 100644 index e2375b856..000000000 --- a/tests/integration/auth-ory-session-boundary.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const ensureBootstrappedMock = vi.hoisted(() => vi.fn()) -const cookieSetMock = vi.hoisted(() => vi.fn()) -const signOutMock = vi.hoisted(() => vi.fn()) -const authSessionGetMock = vi.hoisted(() => vi.fn()) -const authSessionPostMock = vi.hoisted(() => vi.fn()) - -vi.mock('next/headers', () => ({ - cookies: vi.fn(() => Promise.resolve({ set: cookieSetMock })), -})) - -vi.mock('@/auth', () => ({ - signOut: signOutMock, - handlers: { - GET: authSessionGetMock, - POST: authSessionPostMock, - }, -})) - -vi.mock('@/core/server/auth/ory/dashboard-bootstrap', () => ({ - ensureOryUserBootstrapped: ensureBootstrappedMock, -})) - -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 { handleOryAuthJsSignIn } = await import( - '@/core/server/auth/ory/authjs-callbacks' -) -const { GET: authSessionGET } = await import( - '@/app/api/auth/oauth/[...nextauth]/route' -) -const { GET: bootstrapFailedGET } = await import( - '@/app/api/auth/oauth/bootstrap-failed/route' -) - -describe('Ory Auth.js session boundary', () => { - beforeEach(() => { - ensureBootstrappedMock.mockReset() - cookieSetMock.mockReset() - signOutMock.mockReset().mockResolvedValue(undefined) - authSessionGetMock.mockReset().mockResolvedValue( - Response.json({ - user: { id: 'user-1' }, - accessToken: 'access-token', - idToken: 'id-token', - refreshToken: 'refresh-token', - identityId: 'kratos-id', - }) - ) - vi.stubEnv('ORY_SDK_URL', 'https://project.oryapis.com') - }) - - it('allows Auth.js sign-in only after dashboard bootstrap succeeds', async () => { - ensureBootstrappedMock.mockResolvedValueOnce(true) - - await expect( - handleOryAuthJsSignIn({ - account: { - provider: 'ory', - type: 'oidc', - providerAccountId: 'x', - access_token: 'access-token', - id_token: 'id-token', - }, - }) - ).resolves.toBe(true) - - ensureBootstrappedMock.mockResolvedValueOnce(false) - - await expect( - handleOryAuthJsSignIn({ - account: { - provider: 'ory', - type: 'oidc', - providerAccountId: 'x', - access_token: 'access-token', - id_token: 'id-token', - }, - }) - ).resolves.toBe('/api/auth/oauth/bootstrap-failed') - expect(cookieSetMock).toHaveBeenCalledWith( - 'e2b-ory-bootstrap-failed-id-token', - 'id-token', - expect.objectContaining({ httpOnly: true, maxAge: 60 }) - ) - }) - - it('strips Ory tokens from the public Auth.js session response', async () => { - const response = await authSessionGET( - new NextRequest('https://app.e2b.dev/api/auth/oauth/session') - ) - const body = await response.json() - - expect(body).toEqual({ user: { id: 'user-1' } }) - expect(JSON.stringify(body)).not.toContain('access-token') - expect(JSON.stringify(body)).not.toContain('id-token') - expect(JSON.stringify(body)).not.toContain('refresh-token') - }) - - it('only signs out from bootstrap-failed when the handoff cookie is present', async () => { - const withCookie = await bootstrapFailedGET( - new NextRequest('https://app.e2b.dev/api/auth/oauth/bootstrap-failed', { - headers: { cookie: 'e2b-ory-bootstrap-failed-id-token=id.token.sig' }, - }) - ) - expect(signOutMock).toHaveBeenCalledWith({ redirect: false }) - expect(withCookie.headers.get('location')).toContain( - '/oauth2/sessions/logout' - ) - - signOutMock.mockClear() - - const withoutCookie = await bootstrapFailedGET( - new NextRequest('https://app.e2b.dev/api/auth/oauth/bootstrap-failed') - ) - expect(signOutMock).not.toHaveBeenCalled() - expect(withoutCookie.headers.get('location')).toBe('https://app.e2b.dev/') - }) -}) diff --git a/tests/setup.ts b/tests/setup.ts index 1a43f97ad..606c0cbc3 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -5,7 +5,7 @@ const projectDir = process.cwd() loadEnvConfig(projectDir) // fall back to placeholder values for env-coupled clients that initialize at module load -process.env.AUTH_SECRET ??= 'test-auth-secret' +process.env.E2B_SESSION_SECRET ??= 'test-session-secret' process.env.ORY_SDK_URL ??= 'https://test-ory.projects.oryapis.com' process.env.ORY_OAUTH2_CLIENT_ID ??= 'test-ory-client-id' process.env.ORY_OAUTH2_CLIENT_SECRET ??= 'test-ory-client-secret' diff --git a/tests/unit/kratos-session-edge.test.ts b/tests/unit/kratos-session-edge.test.ts new file mode 100644 index 000000000..6eb7e554a --- /dev/null +++ b/tests/unit/kratos-session-edge.test.ts @@ -0,0 +1,106 @@ +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { isKratosSessionActive } from '@/core/server/auth/ory/kratos-session-edge' + +function requestWithCookie(cookie?: string): NextRequest { + return new NextRequest('https://app.e2b.dev/dashboard', { + headers: cookie ? { cookie } : {}, + }) +} + +describe('isKratosSessionActive', () => { + beforeEach(() => { + vi.stubEnv('NEXT_PUBLIC_ORY_SDK_URL', 'https://ory.example.com') + }) + + afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() + }) + + it('returns false without calling whoami when the request has no cookie', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + expect(await isKratosSessionActive(requestWithCookie())).toBe(false) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('returns false when no Ory SDK URL is configured', async () => { + vi.stubEnv('NEXT_PUBLIC_ORY_SDK_URL', '') + vi.stubEnv('ORY_SDK_URL', '') + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + expect( + await isKratosSessionActive(requestWithCookie('ory_session=abc')) + ).toBe(false) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('calls whoami with the request cookie and returns true for an active session', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ active: true }), + }) + vi.stubGlobal('fetch', fetchMock) + + const result = await isKratosSessionActive( + requestWithCookie('ory_session=abc') + ) + + expect(result).toBe(true) + expect(fetchMock).toHaveBeenCalledWith( + 'https://ory.example.com/sessions/whoami', + { headers: { cookie: 'ory_session=abc', accept: 'application/json' } } + ) + }) + + it('returns false for an inactive session', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue({ ok: true, json: async () => ({ active: false }) }) + ) + + expect( + await isKratosSessionActive(requestWithCookie('ory_session=abc')) + ).toBe(false) + }) + + it('returns false on a non-OK whoami response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) }) + ) + + expect( + await isKratosSessionActive(requestWithCookie('ory_session=abc')) + ).toBe(false) + }) + + it('returns false when the whoami request throws', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))) + + expect( + await isKratosSessionActive(requestWithCookie('ory_session=abc')) + ).toBe(false) + }) + + it('strips a trailing slash from the SDK URL', async () => { + vi.stubEnv('NEXT_PUBLIC_ORY_SDK_URL', 'https://ory.example.com/') + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ active: true }), + }) + vi.stubGlobal('fetch', fetchMock) + + await isKratosSessionActive(requestWithCookie('ory_session=abc')) + + expect(fetchMock).toHaveBeenCalledWith( + 'https://ory.example.com/sessions/whoami', + expect.anything() + ) + }) +}) diff --git a/tests/unit/oauth-client.test.ts b/tests/unit/oauth-client.test.ts new file mode 100644 index 000000000..2ae42d161 --- /dev/null +++ b/tests/unit/oauth-client.test.ts @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + absoluteExpiry, + buildOryAuthorizationRequest, + exchangeOryCallback, +} from '@/core/server/auth/ory/oauth-client' + +const ISSUER = 'https://ory.example.com' +const REDIRECT_URI = 'https://app.e2b.dev/api/auth/oauth/callback/ory' + +const discoveryDoc = { + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/oauth2/auth`, + token_endpoint: `${ISSUER}/oauth2/token`, + jwks_uri: `${ISSUER}/.well-known/jwks.json`, + userinfo_endpoint: `${ISSUER}/userinfo`, + end_session_endpoint: `${ISSUER}/oauth2/sessions/logout`, + response_types_supported: ['code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], +} + +function stubDiscovery() { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL) => { + const url = input.toString() + if (url.includes('/.well-known/openid-configuration')) { + return Response.json(discoveryDoc) + } + throw new Error(`unexpected fetch ${url}`) + }) + ) +} + +describe('oauth-client authorization request', () => { + beforeEach(() => { + vi.stubEnv('ORY_HYDRA_PUBLIC_URL', ISSUER) + vi.stubEnv('ORY_OAUTH2_CLIENT_ID', 'dashboard-client') + vi.stubEnv('ORY_OAUTH2_CLIENT_SECRET', 'dashboard-secret') + vi.stubEnv('ORY_OAUTH2_AUDIENCE', 'https://api.e2b.dev') + stubDiscovery() + }) + + afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() + }) + + it('builds a PKCE S256 authorization URL with state + nonce', async () => { + const request = await buildOryAuthorizationRequest('signin', REDIRECT_URI) + const url = new URL(request.url) + + expect(url.origin + url.pathname).toBe(`${ISSUER}/oauth2/auth`) + expect(url.searchParams.get('client_id')).toBe('dashboard-client') + expect(url.searchParams.get('redirect_uri')).toBe(REDIRECT_URI) + expect(url.searchParams.get('response_type')).toBe('code') + expect(url.searchParams.get('scope')).toBe( + 'openid offline_access email profile' + ) + expect(url.searchParams.get('code_challenge_method')).toBe('S256') + expect(url.searchParams.get('code_challenge')).toBeTruthy() + expect(url.searchParams.get('audience')).toBe('https://api.e2b.dev') + expect(url.searchParams.get('state')).toBe(request.state) + expect(url.searchParams.get('nonce')).toBe(request.nonce) + expect(request.codeVerifier).toBeTruthy() + // No prompt for a plain sign-in. + expect(url.searchParams.get('prompt')).toBeNull() + }) + + it('maps signup to prompt=registration and reauth to prompt=login', async () => { + const signup = await buildOryAuthorizationRequest('signup', REDIRECT_URI) + const reauth = await buildOryAuthorizationRequest('reauth', REDIRECT_URI) + + expect(new URL(signup.url).searchParams.get('prompt')).toBe('registration') + expect(new URL(reauth.url).searchParams.get('prompt')).toBe('login') + }) + + it('generates a fresh verifier/state/nonce per request', async () => { + const a = await buildOryAuthorizationRequest('signin', REDIRECT_URI) + const b = await buildOryAuthorizationRequest('signin', REDIRECT_URI) + + expect(a.codeVerifier).not.toBe(b.codeVerifier) + expect(a.state).not.toBe(b.state) + expect(a.nonce).not.toBe(b.nonce) + }) + + it('rejects a callback whose state does not match', async () => { + await expect( + exchangeOryCallback({ + currentUrl: new URL(`${REDIRECT_URI}?code=abc&state=returned-state`), + expectedState: 'different-state', + expectedNonce: 'nonce', + codeVerifier: 'verifier', + redirectUri: REDIRECT_URI, + }) + ).rejects.toThrow() + }) +}) + +describe('absoluteExpiry', () => { + it('adds expires_in to now', () => { + expect(absoluteExpiry(3600, 1_000)).toBe(4_600) + }) + + it('falls back to a short window when expires_in is missing', () => { + expect(absoluteExpiry(undefined, 1_000)).toBe(1_300) + }) +}) diff --git a/tests/unit/proxy-plan.test.ts b/tests/unit/proxy-plan.test.ts index 13f27c25a..42c690f52 100644 --- a/tests/unit/proxy-plan.test.ts +++ b/tests/unit/proxy-plan.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { classifyProxyRequest, - planNeedsAuthJsSession, + planNeedsAuthGate, } from '@/core/server/proxy/classifier' describe('classifyProxyRequest', () => { @@ -10,16 +10,16 @@ describe('classifyProxyRequest', () => { ['/sign-in', 'auth-page', true], ['/sign-up', 'auth-page', true], ['/api/trpc/user.update', 'trpc', false], - ['/api/auth/oauth/session', 'bypass', false], + ['/api/auth/oauth/callback/ory', 'bypass', false], ['/api/auth/oauth/start', 'bypass', false], ['/api/health', 'bypass', false], ['/docs/quickstart', 'rewrite', false], ['/', 'rewrite', false], ['/unknown-public-page', 'public', false], - ])('classifies %s', (pathname, kind, needsAuthJsSession) => { + ])('classifies %s', (pathname, kind, needsAuthGate) => { const plan = classifyProxyRequest(pathname) expect(plan.kind).toBe(kind) - expect(planNeedsAuthJsSession(plan)).toBe(needsAuthJsSession) + expect(planNeedsAuthGate(plan)).toBe(needsAuthGate) }) }) diff --git a/tests/unit/session-cookie.test.ts b/tests/unit/session-cookie.test.ts new file mode 100644 index 000000000..3ff7a8cc6 --- /dev/null +++ b/tests/unit/session-cookie.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + type OrySessionTokens, + openOrySession, + orySessionCookieOptions, + sealOrySession, +} from '@/core/server/auth/ory/session-cookie' + +const tokens: OrySessionTokens = { + accessToken: 'access-token', + refreshToken: 'refresh-token', + idToken: 'id-token', + expiresAt: 1_900_000_000, +} + +describe('e2b_session cookie', () => { + beforeEach(() => { + vi.stubEnv('E2B_SESSION_SECRET', 'unit-test-session-secret') + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('round-trips all token fields through seal/open', async () => { + const sealed = await sealOrySession(tokens) + + expect(sealed).not.toContain('access-token') + expect(await openOrySession(sealed)).toEqual(tokens) + }) + + it('preserves a session without optional tokens', async () => { + const minimal: OrySessionTokens = { + accessToken: 'only-access', + expiresAt: 123, + } + + expect(await openOrySession(await sealOrySession(minimal))).toEqual(minimal) + }) + + it('returns null for missing or empty values', async () => { + expect(await openOrySession(undefined)).toBeNull() + expect(await openOrySession(null)).toBeNull() + expect(await openOrySession('')).toBeNull() + }) + + it('returns null for a tampered token', async () => { + const sealed = await sealOrySession(tokens) + + expect(await openOrySession(`${sealed}tamper`)).toBeNull() + }) + + it('returns null when sealed under a different secret', async () => { + const sealed = await sealOrySession(tokens) + + vi.stubEnv('E2B_SESSION_SECRET', 'a-different-secret') + + expect(await openOrySession(sealed)).toBeNull() + }) + + it('rejects sealing without a configured secret', async () => { + vi.stubEnv('E2B_SESSION_SECRET', '') + + await expect(sealOrySession(tokens)).rejects.toThrow( + 'E2B_SESSION_SECRET is not configured' + ) + }) + + it('marks the cookie httpOnly + lax and toggles secure on NODE_ENV', () => { + vi.stubEnv('NODE_ENV', 'production') + expect(orySessionCookieOptions()).toMatchObject({ + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: true, + }) + + vi.stubEnv('NODE_ENV', 'development') + expect(orySessionCookieOptions().secure).toBe(false) + }) +}) diff --git a/tests/unit/token-refresh.test.ts b/tests/unit/token-refresh.test.ts new file mode 100644 index 000000000..9f1c7ca40 --- /dev/null +++ b/tests/unit/token-refresh.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrySessionTokens } from '@/core/server/auth/ory/session-cookie' +import { + isAccessTokenExpiring, + refreshOrySession, +} from '@/core/server/auth/ory/token-refresh' + +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 current: OrySessionTokens = { + accessToken: 'old-access', + refreshToken: 'old-refresh', + idToken: 'old-id', + expiresAt: 1_000, +} + +function stubTokenResponse(response: Response) { + vi.stubGlobal( + 'fetch', + vi.fn(async () => response) + ) +} + +describe('isAccessTokenExpiring', () => { + it('is true within the 60s skew and false beyond it', () => { + const now = 1_000 + expect(isAccessTokenExpiring(now + 30, now)).toBe(true) + expect(isAccessTokenExpiring(now + 60, now)).toBe(true) + expect(isAccessTokenExpiring(now + 120, now)).toBe(false) + }) +}) + +describe('refreshOrySession', () => { + beforeEach(() => { + vi.stubEnv('ORY_HYDRA_PUBLIC_URL', 'https://ory.example.com') + vi.stubEnv('ORY_OAUTH2_CLIENT_ID', 'dashboard-client') + vi.stubEnv('ORY_OAUTH2_CLIENT_SECRET', 'dashboard-secret') + }) + + afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() + }) + + it('is dead when there is no refresh token (no network call)', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + expect(await refreshOrySession({ accessToken: 'a', expiresAt: 1 })).toEqual( + { + status: 'dead', + } + ) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('is dead on invalid_grant', async () => { + stubTokenResponse( + new Response(JSON.stringify({ error: 'invalid_grant' }), { + status: 400, + headers: { 'content-type': 'application/json' }, + }) + ) + + expect(await refreshOrySession(current)).toEqual({ status: 'dead' }) + }) + + it('is unchanged on a transient server error', async () => { + stubTokenResponse(new Response('upstream down', { status: 503 })) + + expect(await refreshOrySession(current)).toEqual({ status: 'unchanged' }) + }) + + it('refreshes and returns rotated tokens', async () => { + stubTokenResponse( + Response.json({ + access_token: 'new-access', + refresh_token: 'new-refresh', + id_token: 'new-id', + expires_in: 3600, + }) + ) + + const result = await refreshOrySession(current) + + expect(result.status).toBe('refreshed') + if (result.status !== 'refreshed') throw new Error('unreachable') + expect(result.tokens.accessToken).toBe('new-access') + expect(result.tokens.refreshToken).toBe('new-refresh') + expect(result.tokens.idToken).toBe('new-id') + expect(result.tokens.expiresAt).toBeGreaterThan( + Math.floor(Date.now() / 1000) + ) + }) + + it('keeps the current refresh + id token when Hydra omits them', async () => { + stubTokenResponse( + Response.json({ access_token: 'new-access', expires_in: 3600 }) + ) + + const result = await refreshOrySession(current) + + if (result.status !== 'refreshed') throw new Error('expected refreshed') + expect(result.tokens.refreshToken).toBe('old-refresh') + expect(result.tokens.idToken).toBe('old-id') + }) +}) diff --git a/tests/unit/user-router.test.ts b/tests/unit/user-router.test.ts index 054c1a9a1..d2b0edadf 100644 --- a/tests/unit/user-router.test.ts +++ b/tests/unit/user-router.test.ts @@ -79,13 +79,10 @@ describe('userRouter.update', () => { expect(result).toEqual({ status: 'ok', user: authUser }) expect(authMock.getUserProfile).toHaveBeenCalled() - expect(authMock.updateUser).toHaveBeenCalledWith( - { - email: undefined, - password: 'new-password', - name: undefined, - }, - undefined - ) + expect(authMock.updateUser).toHaveBeenCalledWith({ + email: undefined, + password: 'new-password', + name: undefined, + }) }) }) diff --git a/vitest.config.ts b/vitest.config.ts index b64effb9e..8c8afeca3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,10 +17,10 @@ export default defineConfig({ setupFiles: ['./tests/setup.ts'], server: { deps: { - // next-auth and @ory/nextjs ship 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/, /@ory\/nextjs/], + // @ory/nextjs 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: [/@ory\/nextjs/], }, }, }, From dec4af8ca0c4e96ae5a2dea282586dd666af6172 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Fri, 19 Jun 2026 20:15:09 +0200 Subject: [PATCH 02/29] fix links --- src/app/login/components/custom-card.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/app/login/components/custom-card.tsx b/src/app/login/components/custom-card.tsx index 23ad74264..ad0a6b452 100644 --- a/src/app/login/components/custom-card.tsx +++ b/src/app/login/components/custom-card.tsx @@ -14,6 +14,12 @@ export function OryCard({ children }: PropsWithChildren) { // (→ /api/auth/oauth/start), not the raw flow pages: linking straight to // /registration would drop the in-flight Hydra login_challenge and orphan the // OAuth transaction. The start route re-establishes a valid one. +// +// They are plain , not next/link, on purpose: the start route 307-redirects +// cross-origin to Hydra's /oauth2/auth. A soft navigation (or hover prefetch) +// would chase that redirect with fetch(), turning it into a CORS request the +// authorize endpoint rejects. A full document navigation keeps the redirect +// chain top-level, where it belongs. export function OryCardFooter() { const { flowType } = useOryFlow() @@ -21,9 +27,9 @@ export function OryCardFooter() { return (

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

) @@ -35,9 +41,9 @@ export function OryCardFooter() { return (

Remember your password?{' '} - + Sign in - + .

) @@ -51,9 +57,9 @@ export function OryCardFooter() {

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

From ed28caa19e500319a3d2d1033295d27e47c180e5 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Fri, 19 Jun 2026 20:37:40 +0200 Subject: [PATCH 03/29] domain-wise cookie --- src/app/api/auth/oauth/callback/ory/route.ts | 6 +- src/app/api/auth/sign-out/route.ts | 4 +- src/core/server/auth/ory/session-cookie.ts | 43 ++++++++++++++- src/core/server/auth/ory/session.ts | 12 ++-- src/core/server/proxy/runtime.ts | 7 ++- .../auth-ory-account-security.test.ts | 15 ++++- .../integration/auth-ory-entrypoints.test.ts | 1 + tests/unit/session-cookie.test.ts | 55 +++++++++++++++++++ 8 files changed, 132 insertions(+), 11 deletions(-) diff --git a/src/app/api/auth/oauth/callback/ory/route.ts b/src/app/api/auth/oauth/callback/ory/route.ts index 376f2a65b..15a9c9e70 100644 --- a/src/app/api/auth/oauth/callback/ory/route.ts +++ b/src/app/api/auth/oauth/callback/ory/route.ts @@ -95,7 +95,11 @@ export async function GET(request: NextRequest) { const destination = flow.returnTo ?? PROTECTED_URLS.DASHBOARD const response = finalize(NextResponse.redirect(new URL(destination, origin))) - response.cookies.set(E2B_SESSION_COOKIE, sealed, orySessionCookieOptions()) + response.cookies.set( + E2B_SESSION_COOKIE, + sealed, + orySessionCookieOptions(request.nextUrl.host) + ) return response } diff --git a/src/app/api/auth/sign-out/route.ts b/src/app/api/auth/sign-out/route.ts index 7ef3c199c..6b5ece0f9 100644 --- a/src/app/api/auth/sign-out/route.ts +++ b/src/app/api/auth/sign-out/route.ts @@ -2,7 +2,7 @@ import 'server-only' import { type NextRequest, NextResponse } from 'next/server' import { signOut } from '@/core/server/auth' -import { E2B_SESSION_COOKIE } from '@/core/server/auth/ory/session-cookie' +import { orySessionCookieDeleteOptions } from '@/core/server/auth/ory/session-cookie' // Sign-out is a plain route handler. It reads the id_token from e2b_session to // build Hydra's RP-logout URL, then clears the cookie on the redirect it emits @@ -14,6 +14,6 @@ export async function GET(request: NextRequest) { const response = NextResponse.redirect( new URL(redirectTo, request.nextUrl.origin) ) - response.cookies.delete(E2B_SESSION_COOKIE) + response.cookies.delete(orySessionCookieDeleteOptions(request.nextUrl.host)) return response } diff --git a/src/core/server/auth/ory/session-cookie.ts b/src/core/server/auth/ory/session-cookie.ts index ddaec63c3..37bc1ed7c 100644 --- a/src/core/server/auth/ory/session-cookie.ts +++ b/src/core/server/auth/ory/session-cookie.ts @@ -29,6 +29,13 @@ export type OrySessionCookieOptions = { path: '/' secure: boolean maxAge: number + domain?: string +} + +export type OrySessionCookieDeleteOptions = { + name: typeof E2B_SESSION_COOKIE + path: '/' + domain?: string } // Cache the derived key per secret value so rotating E2B_SESSION_SECRET (and @@ -78,7 +85,9 @@ export async function openOrySession( } } -export function orySessionCookieOptions(): OrySessionCookieOptions { +export function orySessionCookieOptions( + host?: string | null +): OrySessionCookieOptions { return { httpOnly: true, sameSite: 'lax', @@ -87,7 +96,39 @@ export function orySessionCookieOptions(): OrySessionCookieOptions { // and serve over HTTPS; local `next dev` is plain-HTTP loopback. secure: process.env.NODE_ENV === 'production', maxAge: SESSION_COOKIE_MAX_AGE_SECONDS, + domain: resolveSessionCookieDomain(host), + } +} + +// Deleting a domain-scoped cookie requires the same domain attribute, so the +// clear paths must pass these options rather than the bare cookie name. +export function orySessionCookieDeleteOptions( + host?: string | null +): OrySessionCookieDeleteOptions { + return { + name: E2B_SESSION_COOKIE, + path: '/', + domain: resolveSessionCookieDomain(host), + } +} + +// Scope the cookie to the parent domain (e.g. `.e2b-staging.dev`) so it is +// shared across every subdomain of the deployment environment instead of being +// pinned to the exact host. Hosts that don't belong to NEXT_PUBLIC_E2B_DOMAIN +// (localhost, Vercel preview URLs) get a host-only cookie — a `.dev` domain +// attribute there would be rejected by the browser. +export function resolveSessionCookieDomain( + host: string | null | undefined +): string | undefined { + const base = process.env.NEXT_PUBLIC_E2B_DOMAIN + if (!base || !host) return undefined + + const hostname = host.split(':')[0] ?? host + if (hostname === base || hostname.endsWith(`.${base}`)) { + return `.${base}` } + + return undefined } function parseTokens( diff --git a/src/core/server/auth/ory/session.ts b/src/core/server/auth/ory/session.ts index 6283cd54a..68b72ad2f 100644 --- a/src/core/server/auth/ory/session.ts +++ b/src/core/server/auth/ory/session.ts @@ -1,7 +1,7 @@ import 'server-only' import { getServerSession } from '@ory/nextjs/app' -import { cookies } from 'next/headers' +import { cookies, headers } from 'next/headers' import { cache } from 'react' import { PROTECTED_URLS } from '@/configs/urls' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' @@ -24,7 +24,11 @@ import { isKratosSessionFresh } from './freshness' import { fromKratosSessionIdentity, fromOryIdentity } from './identity' import { revokeKratosSessionsForIdentity } from './kratos-session' import { revokeOryOAuthSessionsForSubject } from './oauth-session' -import { E2B_SESSION_COOKIE, openOrySession } from './session-cookie' +import { + E2B_SESSION_COOKIE, + openOrySession, + orySessionCookieDeleteOptions, +} from './session-cookie' import { completeOrySignOut } from './signout-flow' const ACCOUNT_SETTINGS_REAUTH_RETURN_TO = `${PROTECTED_URLS.ACCOUNT_SETTINGS}?reauth=1` @@ -141,8 +145,8 @@ const readOrySessionTokens = cache(async () => { async function clearOrySessionCookie(): Promise { try { - const cookieStore = await cookies() - cookieStore.delete(E2B_SESSION_COOKIE) + const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]) + cookieStore.delete(orySessionCookieDeleteOptions(headerStore.get('host'))) } catch (error) { l.warn( { diff --git a/src/core/server/proxy/runtime.ts b/src/core/server/proxy/runtime.ts index 21949b3c3..d35e92fe4 100644 --- a/src/core/server/proxy/runtime.ts +++ b/src/core/server/proxy/runtime.ts @@ -11,6 +11,7 @@ import { isKratosSessionActive } from '@/core/server/auth/ory/kratos-session-edg import { E2B_SESSION_COOKIE, openOrySession, + orySessionCookieDeleteOptions, orySessionCookieOptions, sealOrySession, } from '@/core/server/auth/ory/session-cookie' @@ -119,7 +120,7 @@ async function refreshSessionCookie( response.cookies.set( E2B_SESSION_COOKIE, sealed, - orySessionCookieOptions() + orySessionCookieOptions(request.nextUrl.host) ) } return response @@ -135,7 +136,9 @@ async function refreshSessionCookie( hasToken: false, persist: (response) => { if (response instanceof NextResponse) { - response.cookies.delete(E2B_SESSION_COOKIE) + response.cookies.delete( + orySessionCookieDeleteOptions(request.nextUrl.host) + ) } return response }, diff --git a/tests/integration/auth-ory-account-security.test.ts b/tests/integration/auth-ory-account-security.test.ts index bccc2b664..645770469 100644 --- a/tests/integration/auth-ory-account-security.test.ts +++ b/tests/integration/auth-ory-account-security.test.ts @@ -20,11 +20,20 @@ vi.mock('next/headers', () => ({ get: vi.fn(() => ({ value: 'sealed-cookie' })), delete: cookieDeleteMock, }), + headers: () => + Promise.resolve({ + get: vi.fn(() => 'app.e2b.dev'), + }), })) vi.mock('@/core/server/auth/ory/session-cookie', () => ({ E2B_SESSION_COOKIE: 'e2b_session', openOrySession: openOrySessionMock, + orySessionCookieDeleteOptions: (host: string | null | undefined) => ({ + name: 'e2b_session', + path: '/', + domain: host ? `.${host}` : undefined, + }), })) vi.mock('@/core/server/auth/ory/client', () => ({ @@ -163,7 +172,11 @@ describe('Ory account security (Kratos session + e2b_session)', () => { expect(revokeOAuthSessionsMock).toHaveBeenCalledWith('kratos-uuid') expect(revokeKratosSessionsMock).toHaveBeenCalledWith('kratos-uuid') - expect(cookieDeleteMock).toHaveBeenCalledWith('e2b_session') + expect(cookieDeleteMock).toHaveBeenCalledWith({ + name: 'e2b_session', + path: '/', + domain: '.app.e2b.dev', + }) }) it('signs out via Hydra RP-logout using the id_token hint', async () => { diff --git a/tests/integration/auth-ory-entrypoints.test.ts b/tests/integration/auth-ory-entrypoints.test.ts index 5f6f04d1d..23f6f1571 100644 --- a/tests/integration/auth-ory-entrypoints.test.ts +++ b/tests/integration/auth-ory-entrypoints.test.ts @@ -23,6 +23,7 @@ vi.mock('@/core/server/auth/ory/session-cookie', () => ({ openOrySession: openOrySessionMock, sealOrySession: sealOrySessionMock, orySessionCookieOptions: () => ({ httpOnly: true, path: '/' }), + orySessionCookieDeleteOptions: () => ({ name: 'e2b_session', path: '/' }), })) vi.mock('@/core/server/auth/ory/token-refresh', () => ({ diff --git a/tests/unit/session-cookie.test.ts b/tests/unit/session-cookie.test.ts index 3ff7a8cc6..fd344980f 100644 --- a/tests/unit/session-cookie.test.ts +++ b/tests/unit/session-cookie.test.ts @@ -2,7 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { type OrySessionTokens, openOrySession, + orySessionCookieDeleteOptions, orySessionCookieOptions, + resolveSessionCookieDomain, sealOrySession, } from '@/core/server/auth/ory/session-cookie' @@ -79,3 +81,56 @@ describe('e2b_session cookie', () => { expect(orySessionCookieOptions().secure).toBe(false) }) }) + +describe('e2b_session cookie domain', () => { + beforeEach(() => { + vi.stubEnv('NEXT_PUBLIC_E2B_DOMAIN', 'e2b-staging.dev') + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('scopes a subdomain host to the parent domain', () => { + expect(resolveSessionCookieDomain('dashboard.e2b-staging.dev')).toBe( + '.e2b-staging.dev' + ) + }) + + it('scopes the apex host to the parent domain', () => { + expect(resolveSessionCookieDomain('e2b-staging.dev')).toBe( + '.e2b-staging.dev' + ) + }) + + it('ignores the port when matching', () => { + expect(resolveSessionCookieDomain('e2b-staging.dev:3000')).toBe( + '.e2b-staging.dev' + ) + }) + + it('returns no domain for unrelated hosts (localhost, previews)', () => { + expect(resolveSessionCookieDomain('localhost')).toBeUndefined() + expect(resolveSessionCookieDomain('preview.vercel.app')).toBeUndefined() + // A suffix that is not a domain boundary must not match. + expect(resolveSessionCookieDomain('evil-e2b-staging.dev')).toBeUndefined() + }) + + it('returns no domain when the env is unset', () => { + vi.stubEnv('NEXT_PUBLIC_E2B_DOMAIN', '') + expect( + resolveSessionCookieDomain('dashboard.e2b-staging.dev') + ).toBeUndefined() + }) + + it('flows the resolved domain into set and delete options', () => { + expect(orySessionCookieOptions('app.e2b-staging.dev').domain).toBe( + '.e2b-staging.dev' + ) + expect(orySessionCookieDeleteOptions('app.e2b-staging.dev')).toEqual({ + name: 'e2b_session', + path: '/', + domain: '.e2b-staging.dev', + }) + }) +}) From 665e72c372d8c06c1078e81bbebfe744e7278dc1 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Sun, 21 Jun 2026 20:11:08 +0200 Subject: [PATCH 04/29] Use Link component with prefetch false --- src/app/login/components/custom-card.tsx | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/app/login/components/custom-card.tsx b/src/app/login/components/custom-card.tsx index ad0a6b452..b8f84bcec 100644 --- a/src/app/login/components/custom-card.tsx +++ b/src/app/login/components/custom-card.tsx @@ -27,9 +27,13 @@ export function OryCardFooter() { return (

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

) @@ -41,9 +45,13 @@ export function OryCardFooter() { return (

Remember your password?{' '} - + Sign in - + .

) @@ -57,9 +65,13 @@ export function OryCardFooter() {

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

From a79dc14196e4f4dcb6f433b1d54889e4f6e5b4f8 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Sun, 21 Jun 2026 20:40:00 +0200 Subject: [PATCH 05/29] parse ory identity traits and populate avatarUrl from public metadata --- src/app/consent/route.ts | 17 +--- src/core/server/auth/ory/identity.ts | 85 ++++++++++++------ tests/unit/identity-traits.test.ts | 125 +++++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 40 deletions(-) create mode 100644 tests/unit/identity-traits.test.ts diff --git a/src/app/consent/route.ts b/src/app/consent/route.ts index 5879fd58e..1e2f65090 100644 --- a/src/app/consent/route.ts +++ b/src/app/consent/route.ts @@ -6,6 +6,7 @@ import { getOryIdentityApi, getOryOAuth2Api, } from '@/core/server/auth/ory/client' +import type { OryIdentityTraits } from '@/core/server/auth/ory/identity' import { ORY_POST_LOGOUT_PATH } from '@/core/server/auth/ory/signout' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' @@ -74,10 +75,7 @@ async function profileClaimsForSubject( return {} } - const traits = (identity.traits ?? {}) as { - email?: string - name?: string | { first?: string; last?: string } - } + const traits = (identity.traits ?? {}) as Partial const claims: Record = {} if (grantScope.includes('email') && traits.email) { @@ -85,18 +83,9 @@ async function profileClaimsForSubject( } if (grantScope.includes('profile')) { - const name = fullName(traits.name) + const name = traits.name?.trim() if (name) claims.name = name } return claims } - -function fullName( - name: string | { first?: string; last?: string } | undefined -): string | null { - if (!name) return null - if (typeof name === 'string') return name.trim() || null - const joined = [name.first, name.last].filter(Boolean).join(' ').trim() - return joined || null -} diff --git a/src/core/server/auth/ory/identity.ts b/src/core/server/auth/ory/identity.ts index 4bfc9204f..411f4446a 100644 --- a/src/core/server/auth/ory/identity.ts +++ b/src/core/server/auth/ory/identity.ts @@ -1,12 +1,57 @@ import 'server-only' import type { Identity } from '@ory/client-fetch' +import { z } from 'zod' +import { l } from '@/core/shared/clients/logger/logger' import type { AuthUser } from '../types' type FromOryIdentityOptions = { userId?: string } +export const oryIdentityTraitsSchema = z + .object({ + email: z.email().max(320), + name: z.string().max(320).optional(), + }) + .strict() + +export type OryIdentityTraits = z.infer + +type TraitSource = 'kratos_session' | 'admin_identity' + +function parseOryTraits( + raw: unknown, + ctx: { identityId: string; source: TraitSource } +): Record { + const traits = (raw ?? {}) as Record + const result = oryIdentityTraitsSchema.safeParse(traits) + + if (!result.success) { + l.error( + { + key: 'auth_events:identity_traits:schema_drift', + context: { + identity_id: ctx.identityId, + source: ctx.source, + issues: result.error.issues.map((issue) => ({ + path: issue.path.join('.'), + code: issue.code, + })), + }, + }, + 'Ory identity traits failed schema validation (possible schema drift)' + ) + } + + return traits +} + +function readPublicPicture(metadataPublic: unknown): string | null { + const meta = (metadataPublic ?? {}) as Record + return readString(meta, 'picture') +} + // Build the user from a live Kratos session identity (whoami) — the source of // truth for getAuthContext. The session identity carries traits but not // credentials, so provider/credential flags stay false — use fromOryIdentity @@ -14,14 +59,17 @@ type FromOryIdentityOptions = { export function fromKratosSessionIdentity(identity: { id: string traits?: unknown + metadata_public?: unknown }): AuthUser { - const traits = (identity.traits ?? {}) as Record + const traits = parseOryTraits(identity.traits, { + identityId: identity.id, + source: 'kratos_session', + }) return { id: identity.id, email: readString(traits, 'email'), - name: readDisplayName(traits), - avatarUrl: - readString(traits, 'picture') ?? readString(traits, 'avatar_url'), + name: readString(traits, 'name'), + avatarUrl: readPublicPicture(identity.metadata_public), providers: [], canChangeEmail: false, canChangePassword: false, @@ -35,11 +83,13 @@ export function fromOryIdentity( identity: Identity, options: FromOryIdentityOptions = {} ): AuthUser { - const traits = (identity.traits ?? {}) as Record + const traits = parseOryTraits(identity.traits, { + identityId: identity.id, + source: 'admin_identity', + }) const email = readString(traits, 'email') - const name = readDisplayName(traits) - const avatarUrl = - readString(traits, 'picture') ?? readString(traits, 'avatar_url') + const name = readString(traits, 'name') + const avatarUrl = readPublicPicture(identity.metadata_public) const providers = normalizeProviders(identity.credentials) const hasPasswordCredential = hasUsablePasswordCredential( identity.credentials?.password @@ -104,21 +154,4 @@ function readString( ): 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 -} +} \ No newline at end of file diff --git a/tests/unit/identity-traits.test.ts b/tests/unit/identity-traits.test.ts new file mode 100644 index 000000000..9a54126c3 --- /dev/null +++ b/tests/unit/identity-traits.test.ts @@ -0,0 +1,125 @@ +import type { Identity } from '@ory/client-fetch' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + fromKratosSessionIdentity, + fromOryIdentity, +} from '@/core/server/auth/ory/identity' +import { l } from '@/core/shared/clients/logger/logger' + +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 errorMock = vi.mocked(l.error) + +afterEach(() => { + errorMock.mockReset() +}) + +const DRIFT_KEY = 'auth_events:identity_traits:schema_drift' + +function driftLogged(): boolean { + return errorMock.mock.calls.some( + ([meta]) => + typeof meta === 'object' && + meta !== null && + (meta as { key?: string }).key === DRIFT_KEY + ) +} + +function sessionIdentity(overrides: { + traits?: unknown + metadata_public?: unknown +}) { + return { id: 'identity-1', ...overrides } +} + +function adminIdentity(overrides: { + traits?: unknown + metadata_public?: unknown +}): Identity { + return { id: 'identity-1', ...overrides } as Identity +} + +describe('parseOryTraits via identity mappers', () => { + it('accepts valid traits without logging drift', () => { + const user = fromKratosSessionIdentity( + sessionIdentity({ traits: { email: 'jane@e2b.dev', name: 'Jane Doe' } }) + ) + + expect(user.email).toBe('jane@e2b.dev') + expect(user.name).toBe('Jane Doe') + expect(driftLogged()).toBe(false) + }) + + it('accepts traits without the optional name', () => { + const user = fromKratosSessionIdentity( + sessionIdentity({ traits: { email: 'jane@e2b.dev' } }) + ) + + expect(user.name).toBeNull() + expect(driftLogged()).toBe(false) + }) + + it('logs drift when the required email is missing but still returns a user', () => { + const user = fromOryIdentity(adminIdentity({ traits: { name: 'Jane' } })) + + expect(driftLogged()).toBe(true) + expect(user.email).toBeNull() + expect(user.name).toBe('Jane') + }) + + it('logs drift when name is retyped (legacy { first, last } shape)', () => { + const user = fromOryIdentity( + adminIdentity({ + traits: { email: 'jane@e2b.dev', name: { first: 'Jane', last: 'Doe' } }, + }) + ) + + expect(driftLogged()).toBe(true) + // No longer composited — the preset stores name as a flat string. + expect(user.name).toBeNull() + }) + + it('logs drift on an unexpected extra trait (.strict)', () => { + fromKratosSessionIdentity( + sessionIdentity({ + traits: { email: 'jane@e2b.dev', picture: 'https://x/y.png' }, + }) + ) + + expect(driftLogged()).toBe(true) + }) +}) + +describe('avatar resolves from metadata_public, not traits', () => { + it('reads picture from metadata_public', () => { + const url = 'https://lh3.googleusercontent.com/a/abc=s96-c' + const fromSession = fromKratosSessionIdentity( + sessionIdentity({ + traits: { email: 'jane@e2b.dev' }, + metadata_public: { picture: url }, + }) + ) + const fromAdmin = fromOryIdentity( + adminIdentity({ + traits: { email: 'jane@e2b.dev' }, + metadata_public: { picture: url }, + }) + ) + + expect(fromSession.avatarUrl).toBe(url) + expect(fromAdmin.avatarUrl).toBe(url) + }) + + it('ignores a picture placed in traits and yields null avatar', () => { + const user = fromKratosSessionIdentity( + sessionIdentity({ + traits: { email: 'jane@e2b.dev', picture: 'https://x/y.png' }, + }) + ) + + expect(user.avatarUrl).toBeNull() + }) +}) From 2dcb3ce11c8c705fdd6820d3de03d128c44eb0ad Mon Sep 17 00:00:00 2001 From: drankou <25752851+drankou@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:40:35 +0000 Subject: [PATCH 06/29] style: apply biome formatting --- src/core/server/auth/ory/identity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/server/auth/ory/identity.ts b/src/core/server/auth/ory/identity.ts index 411f4446a..21ac22fc0 100644 --- a/src/core/server/auth/ory/identity.ts +++ b/src/core/server/auth/ory/identity.ts @@ -154,4 +154,4 @@ function readString( ): string | null { const value = traits[key] return typeof value === 'string' && value.length > 0 ? value : null -} \ No newline at end of file +} From 5d656267bcd55c736281037787bdb7956a161231 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Mon, 22 Jun 2026 10:04:44 +0200 Subject: [PATCH 07/29] encrypt pkce flow cookie for consistency --- src/app/api/auth/oauth/callback/ory/route.ts | 4 +- src/app/api/auth/oauth/start/route.ts | 28 ++++-- src/core/server/auth/ory/cookie-crypto.ts | 26 ++++++ src/core/server/auth/ory/oauth-flow.ts | 47 ++++++---- src/core/server/auth/ory/session-cookie.ts | 24 +---- tests/unit/oauth-flow.test.ts | 97 ++++++++++++++++++++ 6 files changed, 175 insertions(+), 51 deletions(-) create mode 100644 src/core/server/auth/ory/cookie-crypto.ts create mode 100644 tests/unit/oauth-flow.test.ts diff --git a/src/app/api/auth/oauth/callback/ory/route.ts b/src/app/api/auth/oauth/callback/ory/route.ts index 15a9c9e70..d2509459d 100644 --- a/src/app/api/auth/oauth/callback/ory/route.ts +++ b/src/app/api/auth/oauth/callback/ory/route.ts @@ -7,7 +7,7 @@ import { exchangeOryCallback } from '@/core/server/auth/ory/oauth-client' import { E2B_OAUTH_FLOW_COOKIE, OAUTH_CALLBACK_PATH, - parseOryFlowState, + openOryFlowState, } from '@/core/server/auth/ory/oauth-flow' import { E2B_SESSION_COOKIE, @@ -32,7 +32,7 @@ const ORY_RECOVER_PATH = '/api/auth/oauth/recover' // session at this point — this cookie only carries tokens for API access. export async function GET(request: NextRequest) { const origin = request.nextUrl.origin - const flow = parseOryFlowState( + const flow = await openOryFlowState( request.cookies.get(E2B_OAUTH_FLOW_COOKIE)?.value ) diff --git a/src/app/api/auth/oauth/start/route.ts b/src/app/api/auth/oauth/start/route.ts index 6e7f248de..d343c08f0 100644 --- a/src/app/api/auth/oauth/start/route.ts +++ b/src/app/api/auth/oauth/start/route.ts @@ -10,7 +10,7 @@ import { E2B_OAUTH_FLOW_COOKIE, OAUTH_CALLBACK_PATH, oryFlowCookieOptions, - serializeOryFlowState, + sealOryFlowState, } from '@/core/server/auth/ory/oauth-flow' import { encodeOrySignupMetadata, @@ -50,16 +50,30 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(new URL(AUTH_URLS.SIGN_IN, origin)) } - const response = NextResponse.redirect(authorization.url) - - response.cookies.set( - E2B_OAUTH_FLOW_COOKIE, - serializeOryFlowState({ + let sealedFlow: string + try { + sealedFlow = await sealOryFlowState({ state: authorization.state, nonce: authorization.nonce, codeVerifier: authorization.codeVerifier, returnTo, - }), + }) + } catch (error) { + l.error( + { + key: 'oauth_start:seal_flow_failed', + error: serializeErrorForLog(error), + }, + 'failed to seal the Ory flow-state cookie' + ) + return NextResponse.redirect(new URL(AUTH_URLS.SIGN_IN, origin)) + } + + const response = NextResponse.redirect(authorization.url) + + response.cookies.set( + E2B_OAUTH_FLOW_COOKIE, + sealedFlow, oryFlowCookieOptions() ) diff --git a/src/core/server/auth/ory/cookie-crypto.ts b/src/core/server/auth/ory/cookie-crypto.ts new file mode 100644 index 000000000..e299329ab --- /dev/null +++ b/src/core/server/auth/ory/cookie-crypto.ts @@ -0,0 +1,26 @@ +// Shared symmetric encryption for the Ory auth cookies (e2b_session and +// e2b_oauth_flow). Both seal their payload as a JWE keyed off E2B_SESSION_SECRET. +// No next/headers import so this stays usable from edge middleware. + +export const KEY_ALGORITHM = 'dir' +export const CONTENT_ENCRYPTION = 'A256GCM' + +// Cache the derived key per secret value so rotating E2B_SESSION_SECRET (and +// test env stubbing) takes effect without a stale key lingering. +let cached: { secret: string; key: Promise } | null = null + +export function deriveKey(): Promise { + const secret = process.env.E2B_SESSION_SECRET + if (!secret) { + return Promise.reject(new Error('E2B_SESSION_SECRET is not configured')) + } + + if (cached?.secret === secret) return cached.key + + const key = crypto.subtle + .digest('SHA-256', new TextEncoder().encode(secret)) + .then((digest) => new Uint8Array(digest)) + + cached = { secret, key } + return key +} diff --git a/src/core/server/auth/ory/oauth-flow.ts b/src/core/server/auth/ory/oauth-flow.ts index bcbe520a1..4e25bad2e 100644 --- a/src/core/server/auth/ory/oauth-flow.ts +++ b/src/core/server/auth/ory/oauth-flow.ts @@ -1,8 +1,12 @@ // Transient state bridging the authorization request (start route) and the // callback: the PKCE code_verifier plus the state/nonce the callback validates, -// and the post-login destination. Lives in a short-lived httpOnly cookie. Its -// secrecy is not the security boundary — state/nonce/PKCE validation at the -// callback is — so it is stored as plain JSON, not encrypted. +// and the post-login destination. Lives in a short-lived httpOnly cookie, sealed +// as a JWE via the shared cookie crypto. Its secrecy is not the security +// boundary — state/nonce/PKCE validation at the callback is — encryption only +// adds tamper-resistance and keeps one sealing convention with e2b_session. + +import { EncryptJWT, jwtDecrypt } from 'jose' +import { CONTENT_ENCRYPTION, deriveKey, KEY_ALGORITHM } from './cookie-crypto' export const E2B_OAUTH_FLOW_COOKIE = 'e2b_oauth_flow' @@ -27,34 +31,39 @@ export type OryFlowCookieOptions = { maxAge: number } -// base64url so the JSON survives as a cookie value — Next's cookie helpers do -// not encode/decode, and raw JSON contains characters illegal in cookie values. -export function serializeOryFlowState(flow: OryFlowState): string { - return Buffer.from(JSON.stringify(flow), 'utf8').toString('base64url') +export async function sealOryFlowState(flow: OryFlowState): Promise { + return new EncryptJWT({ + state: flow.state, + nonce: flow.nonce, + codeVerifier: flow.codeVerifier, + returnTo: flow.returnTo, + }) + .setProtectedHeader({ alg: KEY_ALGORITHM, enc: CONTENT_ENCRYPTION }) + .setIssuedAt() + .encrypt(await deriveKey()) } -export function parseOryFlowState( +export async function openOryFlowState( value: string | undefined | null -): OryFlowState | null { +): Promise { if (!value) return null try { - const json = Buffer.from(value, 'base64url').toString('utf8') - const parsed = JSON.parse(json) as Partial + const { payload } = await jwtDecrypt(value, await deriveKey()) + const { state, nonce, codeVerifier, returnTo } = payload if ( - typeof parsed.state !== 'string' || - typeof parsed.nonce !== 'string' || - typeof parsed.codeVerifier !== 'string' + typeof state !== 'string' || + typeof nonce !== 'string' || + typeof codeVerifier !== 'string' ) { return null } return { - state: parsed.state, - nonce: parsed.nonce, - codeVerifier: parsed.codeVerifier, - returnTo: - typeof parsed.returnTo === 'string' ? parsed.returnTo : undefined, + state, + nonce, + codeVerifier, + returnTo: typeof returnTo === 'string' ? returnTo : undefined, } } catch { return null diff --git a/src/core/server/auth/ory/session-cookie.ts b/src/core/server/auth/ory/session-cookie.ts index 37bc1ed7c..2ca80f0bd 100644 --- a/src/core/server/auth/ory/session-cookie.ts +++ b/src/core/server/auth/ory/session-cookie.ts @@ -1,4 +1,5 @@ import { EncryptJWT, jwtDecrypt } from 'jose' +import { CONTENT_ENCRYPTION, deriveKey, KEY_ALGORITHM } from './cookie-crypto' // The single encrypted cookie that carries the Hydra OIDC tokens for API // access. Kratos owns the session; this cookie is never the auth gate — it is @@ -12,9 +13,6 @@ export const E2B_SESSION_COOKIE = 'e2b_session' // intentionally generous and not the security boundary. const SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30 -const KEY_ALGORITHM = 'dir' -const CONTENT_ENCRYPTION = 'A256GCM' - export type OrySessionTokens = { accessToken: string refreshToken?: string @@ -38,26 +36,6 @@ export type OrySessionCookieDeleteOptions = { domain?: string } -// Cache the derived key per secret value so rotating E2B_SESSION_SECRET (and -// test env stubbing) takes effect without a stale key lingering. -let cached: { secret: string; key: Promise } | null = null - -function deriveKey(): Promise { - const secret = process.env.E2B_SESSION_SECRET - if (!secret) { - return Promise.reject(new Error('E2B_SESSION_SECRET is not configured')) - } - - if (cached?.secret === secret) return cached.key - - const key = crypto.subtle - .digest('SHA-256', new TextEncoder().encode(secret)) - .then((digest) => new Uint8Array(digest)) - - cached = { secret, key } - return key -} - export async function sealOrySession( tokens: OrySessionTokens ): Promise { diff --git a/tests/unit/oauth-flow.test.ts b/tests/unit/oauth-flow.test.ts new file mode 100644 index 000000000..3131e4141 --- /dev/null +++ b/tests/unit/oauth-flow.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + type OryFlowState, + openOryFlowState, + oryFlowCookieOptions, + sealOryFlowState, +} from '@/core/server/auth/ory/oauth-flow' + +const flow: OryFlowState = { + state: 'state-value', + nonce: 'nonce-value', + codeVerifier: 'code-verifier-value', + returnTo: '/dashboard', +} + +describe('e2b_oauth_flow cookie', () => { + beforeEach(() => { + vi.stubEnv('E2B_SESSION_SECRET', 'unit-test-session-secret') + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('round-trips all fields through seal/open', async () => { + const sealed = await sealOryFlowState(flow) + + expect(sealed).not.toContain('code-verifier-value') + expect(await openOryFlowState(sealed)).toEqual(flow) + }) + + it('preserves a flow without returnTo', async () => { + const minimal: OryFlowState = { + state: 'state-value', + nonce: 'nonce-value', + codeVerifier: 'code-verifier-value', + } + + expect(await openOryFlowState(await sealOryFlowState(minimal))).toEqual( + minimal + ) + }) + + it('returns null for missing or empty values', async () => { + expect(await openOryFlowState(undefined)).toBeNull() + expect(await openOryFlowState(null)).toBeNull() + expect(await openOryFlowState('')).toBeNull() + }) + + it('returns null for a garbage value', async () => { + expect(await openOryFlowState('not-a-valid-jwe')).toBeNull() + }) + + it('returns null for a tampered cookie', async () => { + const sealed = await sealOryFlowState(flow) + + expect(await openOryFlowState(`${sealed}tamper`)).toBeNull() + }) + + it('returns null when sealed under a different secret', async () => { + const sealed = await sealOryFlowState(flow) + + vi.stubEnv('E2B_SESSION_SECRET', 'a-different-secret') + + expect(await openOryFlowState(sealed)).toBeNull() + }) + + it('returns null when a required field is missing', async () => { + const partial = await sealOryFlowState({ + state: 'state-value', + codeVerifier: 'code-verifier-value', + } as OryFlowState) + + expect(await openOryFlowState(partial)).toBeNull() + }) + + it('rejects sealing without a configured secret', async () => { + vi.stubEnv('E2B_SESSION_SECRET', '') + + await expect(sealOryFlowState(flow)).rejects.toThrow( + 'E2B_SESSION_SECRET is not configured' + ) + }) + + it('marks the cookie httpOnly + lax and toggles secure on NODE_ENV', () => { + vi.stubEnv('NODE_ENV', 'production') + expect(oryFlowCookieOptions()).toMatchObject({ + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: true, + }) + + vi.stubEnv('NODE_ENV', 'development') + expect(oryFlowCookieOptions().secure).toBe(false) + }) +}) From 576a07158539abe51ee9434a6ff8ca237b12c1fa Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Mon, 22 Jun 2026 14:35:29 +0200 Subject: [PATCH 08/29] fix envs for integration tests --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 819192be4..1a3c5f3f8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,7 +45,7 @@ jobs: runs-on: ubuntu-latest needs: unit-tests env: - AUTH_SECRET: test-auth-secret + E2B_SESSION_SECRET: test-session-secret ORY_SDK_URL: https://test-ory.projects.oryapis.com ORY_OAUTH2_CLIENT_ID: test-ory-client-id ORY_OAUTH2_CLIENT_SECRET: test-ory-client-secret From 4f9a33e6e3a21e492b383a753aeedee8f7bb22d3 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Mon, 22 Jun 2026 15:04:00 +0200 Subject: [PATCH 09/29] redirect to recover path on error --- src/app/api/auth/oauth/callback/ory/route.ts | 6 +----- src/app/api/auth/oauth/start/route.ts | 10 +++++++--- src/core/server/auth/ory/oauth-flow.ts | 5 +++++ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/app/api/auth/oauth/callback/ory/route.ts b/src/app/api/auth/oauth/callback/ory/route.ts index d2509459d..5b44c644b 100644 --- a/src/app/api/auth/oauth/callback/ory/route.ts +++ b/src/app/api/auth/oauth/callback/ory/route.ts @@ -7,6 +7,7 @@ import { exchangeOryCallback } from '@/core/server/auth/ory/oauth-client' import { E2B_OAUTH_FLOW_COOKIE, OAUTH_CALLBACK_PATH, + ORY_RECOVER_PATH, openOryFlowState, } from '@/core/server/auth/ory/oauth-flow' import { @@ -21,11 +22,6 @@ import { import { ORY_SIGNUP_METADATA_COOKIE } from '@/core/server/auth/ory/signup-metadata' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' -// Failures land on the recover route, whose one-shot guard retries the flow -// once (via /sign-in → /start, which mints a fresh flow cookie) before bailing -// to home — so a stale/invalid callback can't loop. -const ORY_RECOVER_PATH = '/api/auth/oauth/recover' - // Hydra redirects here with ?code after Kratos created the session. We exchange // the code (validating state/nonce/PKCE), provision the dashboard user from the // id_token, then seal the OIDC tokens into e2b_session. Kratos already owns the diff --git a/src/app/api/auth/oauth/start/route.ts b/src/app/api/auth/oauth/start/route.ts index d343c08f0..e28dd00f6 100644 --- a/src/app/api/auth/oauth/start/route.ts +++ b/src/app/api/auth/oauth/start/route.ts @@ -1,5 +1,4 @@ import { type NextRequest, NextResponse } from 'next/server' -import { AUTH_URLS } from '@/configs/urls' import { normalizeOryReturnTo, readOryAuthIntent, @@ -9,6 +8,7 @@ import { buildOryAuthorizationRequest } from '@/core/server/auth/ory/oauth-clien import { E2B_OAUTH_FLOW_COOKIE, OAUTH_CALLBACK_PATH, + ORY_RECOVER_PATH, oryFlowCookieOptions, sealOryFlowState, } from '@/core/server/auth/ory/oauth-flow' @@ -47,7 +47,9 @@ export async function GET(request: NextRequest) { }, 'failed to build the Ory authorization request' ) - return NextResponse.redirect(new URL(AUTH_URLS.SIGN_IN, origin)) + return NextResponse.redirect( + new URL(`${ORY_RECOVER_PATH}?error=oauth_start_failed`, origin) + ) } let sealedFlow: string @@ -66,7 +68,9 @@ export async function GET(request: NextRequest) { }, 'failed to seal the Ory flow-state cookie' ) - return NextResponse.redirect(new URL(AUTH_URLS.SIGN_IN, origin)) + return NextResponse.redirect( + new URL(`${ORY_RECOVER_PATH}?error=oauth_start_failed`, origin) + ) } const response = NextResponse.redirect(authorization.url) diff --git a/src/core/server/auth/ory/oauth-flow.ts b/src/core/server/auth/ory/oauth-flow.ts index 4e25bad2e..06e650d61 100644 --- a/src/core/server/auth/ory/oauth-flow.ts +++ b/src/core/server/auth/ory/oauth-flow.ts @@ -14,6 +14,11 @@ export const E2B_OAUTH_FLOW_COOKIE = 'e2b_oauth_flow' // request and the token exchange, so both routes derive it from this constant. export const OAUTH_CALLBACK_PATH = '/api/auth/oauth/callback/ory' +// Failures across the OAuth flow land here. The recover route's one-shot guard +// retries once (via /sign-in → /start, minting a fresh flow cookie) before +// bailing to home, so a persistent failure can't loop the browser. +export const ORY_RECOVER_PATH = '/api/auth/oauth/recover' + const FLOW_COOKIE_MAX_AGE_SECONDS = 60 * 10 export type OryFlowState = { From 16d07ff1bf21b96e9713bd4e882a8fc8c26fc117 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Mon, 22 Jun 2026 15:10:49 +0200 Subject: [PATCH 10/29] address Link target --- src/app/login/components/custom-card.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/app/login/components/custom-card.tsx b/src/app/login/components/custom-card.tsx index b8f84bcec..70d3b8af8 100644 --- a/src/app/login/components/custom-card.tsx +++ b/src/app/login/components/custom-card.tsx @@ -15,11 +15,12 @@ export function OryCard({ children }: PropsWithChildren) { // /registration would drop the in-flight Hydra login_challenge and orphan the // OAuth transaction. The start route re-establishes a valid one. // -// They are plain , not next/link, on purpose: the start route 307-redirects -// cross-origin to Hydra's /oauth2/auth. A soft navigation (or hover prefetch) -// would chase that redirect with fetch(), turning it into a CORS request the -// authorize endpoint rejects. A full document navigation keeps the redirect -// chain top-level, where it belongs. +// They force a full top-level document navigation via target="_top" (and skip +// prefetch): the start route 307-redirects cross-origin to Hydra's /oauth2/auth. +// A soft navigation or hover prefetch would chase that redirect with fetch(), +// turning it into a CORS request the authorize endpoint rejects. target="_top" +// makes next/link fall back to a browser navigation, keeping the redirect chain +// top-level where it belongs. export function OryCardFooter() { const { flowType } = useOryFlow() @@ -29,6 +30,7 @@ export function OryCardFooter() { Don't have an account?{' '} @@ -47,6 +49,7 @@ export function OryCardFooter() { Remember your password?{' '} @@ -67,6 +70,7 @@ export function OryCardFooter() { Already have an account?{' '} From 771c4e525d99854feac40e91dc457472d8cb145d Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Mon, 22 Jun 2026 15:11:08 +0200 Subject: [PATCH 11/29] check for loopback url for oauth client --- src/core/server/auth/ory/oauth-client.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/core/server/auth/ory/oauth-client.ts b/src/core/server/auth/ory/oauth-client.ts index 0d01bc01a..da454caa8 100644 --- a/src/core/server/auth/ory/oauth-client.ts +++ b/src/core/server/auth/ory/oauth-client.ts @@ -1,4 +1,5 @@ import * as oauth from 'oauth4webapi' +import { isLoopbackUrl } from '@/core/shared/schemas/url' import { authorizationParamsForOryIntent, type OryAuthIntent, @@ -33,7 +34,8 @@ type OryOAuthEnv = { clientSecret: string audience?: string // Hydra runs on plain HTTP loopback in local dev; oauth4webapi rejects - // non-HTTPS endpoints unless explicitly allowed. + // non-HTTPS endpoints unless explicitly allowed. Restricted to loopback so a + // non-local HTTP issuer can never silently disable TLS — it fails closed. insecure: boolean } @@ -55,7 +57,7 @@ export function readOryOAuthEnv(): OryOAuthEnv { clientId, clientSecret, audience: process.env.ORY_OAUTH2_AUDIENCE, - insecure: issuer.protocol === 'http:', + insecure: issuer.protocol === 'http:' && isLoopbackUrl(issuer.href), } } From a42e110325afb5f7e4275c3f604daaf4516ccb6e Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Mon, 22 Jun 2026 15:17:25 +0200 Subject: [PATCH 12/29] fix test --- tests/integration/auth-ory-callback.test.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/integration/auth-ory-callback.test.ts b/tests/integration/auth-ory-callback.test.ts index 37f3ff721..29953e966 100644 --- a/tests/integration/auth-ory-callback.test.ts +++ b/tests/integration/auth-ory-callback.test.ts @@ -2,7 +2,7 @@ import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { E2B_OAUTH_FLOW_COOKIE, - serializeOryFlowState, + sealOryFlowState, } from '@/core/server/auth/ory/oauth-flow' import { E2B_SESSION_COOKIE, @@ -35,16 +35,16 @@ const tokens = { expiresAt: 1_900_000_000, } -function callbackRequest({ +async function callbackRequest({ withFlow = true, returnTo, }: { withFlow?: boolean returnTo?: string -} = {}): NextRequest { +} = {}): Promise { const headers: Record = {} if (withFlow) { - const flow = serializeOryFlowState({ + const flow = await sealOryFlowState({ state: 'state-value', nonce: 'nonce-value', codeVerifier: 'verifier-value', @@ -71,7 +71,9 @@ describe('Ory OAuth callback', () => { }) it('seals e2b_session and redirects to returnTo on success', async () => { - const response = await GET(callbackRequest({ returnTo: '/dashboard/team' })) + const response = await GET( + await callbackRequest({ returnTo: '/dashboard/team' }) + ) expect(response.headers.get('location')).toBe( 'https://app.e2b.dev/dashboard/team' @@ -86,7 +88,7 @@ describe('Ory OAuth callback', () => { }) it('defaults to the dashboard when no returnTo is present', async () => { - const response = await GET(callbackRequest()) + const response = await GET(await callbackRequest()) expect(response.headers.get('location')).toBe( 'https://app.e2b.dev/dashboard' @@ -94,7 +96,7 @@ describe('Ory OAuth callback', () => { }) it('routes to recover when the flow-state cookie is missing', async () => { - const response = await GET(callbackRequest({ withFlow: false })) + const response = await GET(await callbackRequest({ withFlow: false })) expect(response.headers.get('location')).toBe( 'https://app.e2b.dev/api/auth/oauth/recover' @@ -106,7 +108,7 @@ describe('Ory OAuth callback', () => { it('routes to recover when the code exchange fails', async () => { exchangeMock.mockRejectedValueOnce(new Error('state mismatch')) - const response = await GET(callbackRequest()) + const response = await GET(await callbackRequest()) expect(response.headers.get('location')).toBe( 'https://app.e2b.dev/api/auth/oauth/recover' @@ -117,7 +119,7 @@ describe('Ory OAuth callback', () => { it('RP-logs-out (no dashboard cookie) when bootstrap fails', async () => { bootstrapMock.mockResolvedValueOnce(false) - const response = await GET(callbackRequest()) + const response = await GET(await callbackRequest()) const location = response.headers.get('location') ?? '' expect(location).toContain('https://ory.example.com/oauth2/sessions/logout') From 26bfa3349871948d9674e0fd4fb8aaceca8d26ac Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Mon, 22 Jun 2026 15:24:31 +0200 Subject: [PATCH 13/29] fix test --- tests/integration/auth-ory-consent.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/auth-ory-consent.test.ts b/tests/integration/auth-ory-consent.test.ts index 46f015a32..a48dd8843 100644 --- a/tests/integration/auth-ory-consent.test.ts +++ b/tests/integration/auth-ory-consent.test.ts @@ -39,7 +39,7 @@ describe('Ory consent provider', () => { getIdentityMock.mockReset().mockResolvedValue({ traits: { email: 'local-dev@e2b.dev', - name: { first: 'Local', last: 'Dev' }, + name: 'Local Dev', }, }) acceptConsentMock From 55fd1fa2d6da2c74950248d9096e56b872dec759 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Mon, 22 Jun 2026 15:30:32 +0200 Subject: [PATCH 14/29] improve relative url schema check --- src/app/api/auth/oauth/callback/ory/route.ts | 8 ++- src/core/shared/schemas/url.ts | 13 ++++- tests/unit/url-schema.test.ts | 58 ++++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/app/api/auth/oauth/callback/ory/route.ts b/src/app/api/auth/oauth/callback/ory/route.ts index 5b44c644b..fdd6ca415 100644 --- a/src/app/api/auth/oauth/callback/ory/route.ts +++ b/src/app/api/auth/oauth/callback/ory/route.ts @@ -21,6 +21,7 @@ import { } from '@/core/server/auth/ory/signout' import { ORY_SIGNUP_METADATA_COOKIE } from '@/core/server/auth/ory/signup-metadata' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { relativeUrlSchema } from '@/core/shared/schemas/url' // Hydra redirects here with ?code after Kratos created the session. We exchange // the code (validating state/nonce/PKCE), provision the dashboard user from the @@ -89,7 +90,12 @@ export async function GET(request: NextRequest) { expiresAt: tokens.expiresAt, }) - const destination = flow.returnTo ?? PROTECTED_URLS.DASHBOARD + // Re-validate here too: the flow cookie is read back as a raw string, and + // `new URL()` would otherwise escape the origin on a crafted returnTo. + const parsedReturnTo = relativeUrlSchema.safeParse(flow.returnTo) + const destination = parsedReturnTo.success + ? parsedReturnTo.data + : PROTECTED_URLS.DASHBOARD const response = finalize(NextResponse.redirect(new URL(destination, origin))) response.cookies.set( E2B_SESSION_COOKIE, diff --git a/src/core/shared/schemas/url.ts b/src/core/shared/schemas/url.ts index 28b6eaac1..d23acad1d 100644 --- a/src/core/shared/schemas/url.ts +++ b/src/core/shared/schemas/url.ts @@ -18,14 +18,23 @@ export const relativeUrlSchema = z .trim() .refine( (url) => { - if (!url.startsWith('/')) { + if (!url.startsWith('/') || url.startsWith('//')) { return false } - if (url.includes('://') || url.startsWith('//')) { + if (url.includes('://')) { return false } + // Backslashes and control chars let `new URL()` escape the origin: + // http(s) normalizes `\` to `/`, so `/\evil.com` resolves to evil.com. + for (let i = 0; i < url.length; i++) { + const code = url.charCodeAt(i) + if (url[i] === '\\' || code <= 0x1f || code === 0x7f) { + return false + } + } + return true }, { diff --git a/tests/unit/url-schema.test.ts b/tests/unit/url-schema.test.ts index ee0f2231e..3fca0a678 100644 --- a/tests/unit/url-schema.test.ts +++ b/tests/unit/url-schema.test.ts @@ -3,6 +3,7 @@ import { httpUrlSchema, isLoopbackUrl, loopbackUrlSchema, + relativeUrlSchema, } from '@/core/shared/schemas/url' describe('httpUrlSchema', () => { @@ -150,6 +151,63 @@ describe('isLoopbackUrl', () => { }) }) +describe('relativeUrlSchema', () => { + describe('accepts safe relative paths', () => { + it('accepts a bare path', () => { + expect(relativeUrlSchema.safeParse('/dashboard').success).toBe(true) + }) + + it('accepts a path with query params', () => { + expect( + relativeUrlSchema.safeParse('/dashboard?tab=settings').success + ).toBe(true) + }) + + it('accepts a nested path', () => { + expect(relativeUrlSchema.safeParse('/a/b/c').success).toBe(true) + }) + }) + + describe('rejects open-redirect bypass attempts', () => { + it('rejects the backslash bypass', () => { + // `new URL('/\\evil.com', origin)` normalizes to https://evil.com/ + expect(relativeUrlSchema.safeParse('/\\evil.com').success).toBe(false) + }) + + it('rejects protocol-relative URLs', () => { + expect(relativeUrlSchema.safeParse('//evil.com').success).toBe(false) + }) + + it('rejects absolute http(s) URLs', () => { + expect(relativeUrlSchema.safeParse('https://evil.com').success).toBe( + false + ) + }) + + it('rejects an embedded scheme', () => { + expect(relativeUrlSchema.safeParse('/path://evil.com').success).toBe( + false + ) + }) + + it('rejects a tab control char', () => { + expect(relativeUrlSchema.safeParse('/\tevil.com').success).toBe(false) + }) + + it('rejects a newline control char', () => { + expect(relativeUrlSchema.safeParse('/foo\nbar').success).toBe(false) + }) + + it('rejects a NUL control char', () => { + expect(relativeUrlSchema.safeParse('/\x00').success).toBe(false) + }) + + it('rejects a non-relative path', () => { + expect(relativeUrlSchema.safeParse('dashboard').success).toBe(false) + }) + }) +}) + describe('loopbackUrlSchema', () => { it('parses a genuine loopback URL', () => { expect(loopbackUrlSchema.safeParse('http://localhost:3000').success).toBe( From e40566a43930fcc8d50fce36a0b5db247dd0304a Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Mon, 22 Jun 2026 15:38:38 +0200 Subject: [PATCH 15/29] add clientId check before auto consent scopes --- src/app/consent/route.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/app/consent/route.ts b/src/app/consent/route.ts index 1e2f65090..1c26fcab8 100644 --- a/src/app/consent/route.ts +++ b/src/app/consent/route.ts @@ -7,6 +7,7 @@ import { getOryOAuth2Api, } from '@/core/server/auth/ory/client' import type { OryIdentityTraits } from '@/core/server/auth/ory/identity' +import { readOryOAuthEnv } from '@/core/server/auth/ory/oauth-client' import { ORY_POST_LOGOUT_PATH } from '@/core/server/auth/ory/signout' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' @@ -26,6 +27,19 @@ export async function GET(request: NextRequest) { try { const oauth2 = getOryOAuth2Api() const consent = await oauth2.getOAuth2ConsentRequest({ consentChallenge }) + + const { clientId } = readOryOAuthEnv() + if (consent.client?.client_id !== clientId) { + l.warn( + { + key: 'oauth_consent:unexpected_client', + clientId: consent.client?.client_id, + }, + 'refusing to auto-consent for unexpected OAuth client' + ) + return NextResponse.redirect(home) + } + const grantScope = consent.requested_scope ?? [] const idTokenClaims = consent.subject From 870293268d43533cf33e00daffc5855d20e2972e Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Mon, 22 Jun 2026 15:49:28 +0200 Subject: [PATCH 16/29] filter out app cookies for kratos whoami --- src/app/api/auth/oauth/callback/ory/route.ts | 2 +- src/app/api/auth/oauth/start/route.ts | 2 +- .../server/auth/ory/kratos-session-edge.ts | 7 ++++- src/core/server/auth/ory/session-cookie.ts | 8 +++++ src/core/server/auth/ory/signup-metadata.ts | 3 +- tests/integration/auth-ory-callback.test.ts | 2 +- .../integration/auth-ory-entrypoints.test.ts | 2 +- tests/unit/kratos-session-edge.test.ts | 29 +++++++++++++++++++ 8 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/app/api/auth/oauth/callback/ory/route.ts b/src/app/api/auth/oauth/callback/ory/route.ts index fdd6ca415..6eb91770b 100644 --- a/src/app/api/auth/oauth/callback/ory/route.ts +++ b/src/app/api/auth/oauth/callback/ory/route.ts @@ -12,6 +12,7 @@ import { } from '@/core/server/auth/ory/oauth-flow' import { E2B_SESSION_COOKIE, + ORY_SIGNUP_METADATA_COOKIE, orySessionCookieOptions, sealOrySession, } from '@/core/server/auth/ory/session-cookie' @@ -19,7 +20,6 @@ import { buildOryLogoutUrl, ORY_POST_LOGOUT_PATH, } from '@/core/server/auth/ory/signout' -import { ORY_SIGNUP_METADATA_COOKIE } from '@/core/server/auth/ory/signup-metadata' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import { relativeUrlSchema } from '@/core/shared/schemas/url' diff --git a/src/app/api/auth/oauth/start/route.ts b/src/app/api/auth/oauth/start/route.ts index e28dd00f6..cdc8c244e 100644 --- a/src/app/api/auth/oauth/start/route.ts +++ b/src/app/api/auth/oauth/start/route.ts @@ -12,9 +12,9 @@ import { oryFlowCookieOptions, sealOryFlowState, } from '@/core/server/auth/ory/oauth-flow' +import { ORY_SIGNUP_METADATA_COOKIE } from '@/core/server/auth/ory/session-cookie' import { encodeOrySignupMetadata, - ORY_SIGNUP_METADATA_COOKIE, readOrySignupMetadataFromHeaders, signupMetadataCookieOptions, } from '@/core/server/auth/ory/signup-metadata' diff --git a/src/core/server/auth/ory/kratos-session-edge.ts b/src/core/server/auth/ory/kratos-session-edge.ts index 2ecb43a62..4730c3826 100644 --- a/src/core/server/auth/ory/kratos-session-edge.ts +++ b/src/core/server/auth/ory/kratos-session-edge.ts @@ -1,4 +1,5 @@ import type { NextRequest } from 'next/server' +import { APP_OWNED_COOKIES } from './session-cookie' // Edge-safe Kratos session check for the middleware gate. getServerSession() // reads next/headers and can't run in the edge runtime, so we hit Kratos @@ -8,7 +9,11 @@ export async function isKratosSessionActive( request: NextRequest ): Promise { const sdkUrl = process.env.NEXT_PUBLIC_ORY_SDK_URL ?? process.env.ORY_SDK_URL - const cookie = request.headers.get('cookie') + const cookie = request.cookies + .getAll() + .filter((c) => !APP_OWNED_COOKIES.has(c.name)) + .map((c) => `${c.name}=${c.value}`) + .join('; ') if (!sdkUrl || !cookie) return false try { diff --git a/src/core/server/auth/ory/session-cookie.ts b/src/core/server/auth/ory/session-cookie.ts index 2ca80f0bd..e472f2bde 100644 --- a/src/core/server/auth/ory/session-cookie.ts +++ b/src/core/server/auth/ory/session-cookie.ts @@ -8,6 +8,14 @@ import { CONTENT_ENCRYPTION, deriveKey, KEY_ALGORITHM } from './cookie-crypto' export const E2B_SESSION_COOKIE = 'e2b_session' +export const ORY_SIGNUP_METADATA_COOKIE = 'e2b-ory-signup-metadata' + +// Cookies the dashboard owns — never forwarded across the Ory trust boundary. +export const APP_OWNED_COOKIES = new Set([ + E2B_SESSION_COOKIE, + ORY_SIGNUP_METADATA_COOKIE, +]) + // Persist across browser restarts. The cookie only caches tokens — a stale or // expired cookie is re-minted from the live Kratos session, so the lifetime is // intentionally generous and not the security boundary. diff --git a/src/core/server/auth/ory/signup-metadata.ts b/src/core/server/auth/ory/signup-metadata.ts index ad028a332..b123f29a8 100644 --- a/src/core/server/auth/ory/signup-metadata.ts +++ b/src/core/server/auth/ory/signup-metadata.ts @@ -3,8 +3,7 @@ import 'server-only' import { createHmac, timingSafeEqual } from 'node:crypto' import { cookies } from 'next/headers' import { l } from '@/core/shared/clients/logger/logger' - -export const ORY_SIGNUP_METADATA_COOKIE = 'e2b-ory-signup-metadata' +import { ORY_SIGNUP_METADATA_COOKIE } from './session-cookie' const SIGNUP_METADATA_COOKIE_MAX_AGE_SECONDS = 30 * 60 const MAX_IP_LENGTH = 128 diff --git a/tests/integration/auth-ory-callback.test.ts b/tests/integration/auth-ory-callback.test.ts index 29953e966..de3970dfd 100644 --- a/tests/integration/auth-ory-callback.test.ts +++ b/tests/integration/auth-ory-callback.test.ts @@ -6,9 +6,9 @@ import { } from '@/core/server/auth/ory/oauth-flow' import { E2B_SESSION_COOKIE, + ORY_SIGNUP_METADATA_COOKIE, openOrySession, } from '@/core/server/auth/ory/session-cookie' -import { ORY_SIGNUP_METADATA_COOKIE } from '@/core/server/auth/ory/signup-metadata' const exchangeMock = vi.hoisted(() => vi.fn()) const bootstrapMock = vi.hoisted(() => vi.fn()) diff --git a/tests/integration/auth-ory-entrypoints.test.ts b/tests/integration/auth-ory-entrypoints.test.ts index 23f6f1571..16096b9ec 100644 --- a/tests/integration/auth-ory-entrypoints.test.ts +++ b/tests/integration/auth-ory-entrypoints.test.ts @@ -20,6 +20,7 @@ vi.mock('@/core/server/auth/ory/kratos-session-edge', () => ({ vi.mock('@/core/server/auth/ory/session-cookie', () => ({ E2B_SESSION_COOKIE: 'e2b_session', + ORY_SIGNUP_METADATA_COOKIE: 'e2b-ory-signup-metadata', openOrySession: openOrySessionMock, sealOrySession: sealOrySessionMock, orySessionCookieOptions: () => ({ httpOnly: true, path: '/' }), @@ -36,7 +37,6 @@ vi.mock('@/core/server/auth/ory/oauth-client', () => ({ })) vi.mock('@/core/server/auth/ory/signup-metadata', () => ({ - ORY_SIGNUP_METADATA_COOKIE: 'e2b-ory-signup-metadata', readOrySignupMetadataFromHeaders: readSignupMetadataMock, encodeOrySignupMetadata: encodeSignupMetadataMock, signupMetadataCookieOptions: () => ({ httpOnly: true, path: '/' }), diff --git a/tests/unit/kratos-session-edge.test.ts b/tests/unit/kratos-session-edge.test.ts index 6eb7e554a..f35d42cc5 100644 --- a/tests/unit/kratos-session-edge.test.ts +++ b/tests/unit/kratos-session-edge.test.ts @@ -56,6 +56,35 @@ describe('isKratosSessionActive', () => { ) }) + it('strips app-owned cookies before forwarding to whoami', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ active: true }), + }) + vi.stubGlobal('fetch', fetchMock) + + await isKratosSessionActive( + requestWithCookie( + 'ory_session=abc; e2b_session=secret; e2b-ory-signup-metadata=meta' + ) + ) + + expect(fetchMock).toHaveBeenCalledWith( + 'https://ory.example.com/sessions/whoami', + { headers: { cookie: 'ory_session=abc', accept: 'application/json' } } + ) + }) + + it('returns false when only app-owned cookies are present', async () => { + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + expect( + await isKratosSessionActive(requestWithCookie('e2b_session=secret')) + ).toBe(false) + expect(fetchMock).not.toHaveBeenCalled() + }) + it('returns false for an inactive session', async () => { vi.stubGlobal( 'fetch', From 981dc6da5348f8c9dfd0e929294c2552c47281e7 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Mon, 22 Jun 2026 15:56:07 +0200 Subject: [PATCH 17/29] fix session logout --- src/core/server/proxy/classifier.ts | 4 ++++ src/core/server/proxy/runtime.ts | 18 +++++++++++++++--- tests/integration/auth-ory-entrypoints.test.ts | 16 ++++++++++++++++ tests/unit/proxy-plan.test.ts | 14 ++++++++++++++ 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/core/server/proxy/classifier.ts b/src/core/server/proxy/classifier.ts index 36719ecb0..15305ec35 100644 --- a/src/core/server/proxy/classifier.ts +++ b/src/core/server/proxy/classifier.ts @@ -51,6 +51,10 @@ export function planNeedsAuthGate(plan: ProxyPlan): boolean { return plan.kind === 'auth-page' || plan.kind === 'dashboard-page' } +export function isAuthEndpointRoute(pathname: string): boolean { + return matchesAnyPrefix(pathname, AUTH_ENDPOINT_PREFIXES) +} + export function isProxyAuthRoute(pathname: string): boolean { const normalizedPath = normalizePath(pathname) return ( diff --git a/src/core/server/proxy/runtime.ts b/src/core/server/proxy/runtime.ts index d35e92fe4..b47e624f8 100644 --- a/src/core/server/proxy/runtime.ts +++ b/src/core/server/proxy/runtime.ts @@ -23,6 +23,7 @@ import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import { getAuthRouteRedirect } from './auth-routes' import { classifyProxyRequest, + isAuthEndpointRoute, type ProxyPlan, planNeedsAuthGate, } from './classifier' @@ -64,11 +65,20 @@ export async function runDashboardProxy( return oryProxy(request) } - // Pattern B: refresh the e2b_session up front and propagate it to the same + const plan = classifyProxyRequest(request.nextUrl.pathname) + + // refresh the e2b_session up front and propagate it to the same // request (request.cookies) so RSC/route handlers and the gate below read the // fresh token, then persist it on the outgoing response for the browser. - const session = await refreshSessionCookie(request) - const plan = classifyProxyRequest(request.nextUrl.pathname) + // + // Auth endpoints own their session lifecycle: sign-out reads the id_token from + // e2b_session before clearing it, the OAuth callback mints a fresh session. A + // dead refresh here would delete the cookie out of the propagated request + // before the handler reads it, breaking RP-initiated logout (Kratos/Hydra + // would never end the session), so skip the refresh for them. + const session = isAuthEndpointRoute(request.nextUrl.pathname) + ? skipRefresh + : await refreshSessionCookie(request) if (!planNeedsAuthGate(plan)) { return session.persist(await runProxyConcerns(request, plan)) @@ -96,6 +106,8 @@ type SessionRefresh = { const noPersist: SessionRefresh['persist'] = (response) => response +const skipRefresh: SessionRefresh = { hasToken: false, persist: noPersist } + async function refreshSessionCookie( request: NextRequest ): Promise { diff --git a/tests/integration/auth-ory-entrypoints.test.ts b/tests/integration/auth-ory-entrypoints.test.ts index 16096b9ec..8977c7be5 100644 --- a/tests/integration/auth-ory-entrypoints.test.ts +++ b/tests/integration/auth-ory-entrypoints.test.ts @@ -187,6 +187,22 @@ describe('Ory auth entrypoints — middleware refresh (Pattern B)', () => { expect(sealOrySessionMock).not.toHaveBeenCalled() expect(response.cookies.get('e2b_session')).toBeUndefined() }) + + it('skips the refresh for auth endpoints so sign-out keeps its id_token', async () => { + // A dead refresh would otherwise delete e2b_session out of the propagated + // request before the sign-out handler reads the id_token from it, dropping + // RP-initiated logout so Kratos/Hydra never end the session. + refreshOrySessionMock.mockResolvedValue({ status: 'dead' }) + + const response = await proxy( + request('/api/auth/sign-out'), + {} as NextFetchEvent + ) + + expect(openOrySessionMock).not.toHaveBeenCalled() + expect(refreshOrySessionMock).not.toHaveBeenCalled() + expect(response.cookies.get('e2b_session')).toBeUndefined() + }) }) describe('Ory OAuth start route', () => { diff --git a/tests/unit/proxy-plan.test.ts b/tests/unit/proxy-plan.test.ts index 42c690f52..82a0e1500 100644 --- a/tests/unit/proxy-plan.test.ts +++ b/tests/unit/proxy-plan.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { classifyProxyRequest, + isAuthEndpointRoute, planNeedsAuthGate, } from '@/core/server/proxy/classifier' @@ -23,3 +24,16 @@ describe('classifyProxyRequest', () => { expect(planNeedsAuthGate(plan)).toBe(needsAuthGate) }) }) + +describe('isAuthEndpointRoute', () => { + it.each([ + ['/api/auth/sign-out', true], + ['/api/auth/oauth/start', true], + ['/api/auth', true], + ['/api/health', false], + ['/api/trpc/user.update', false], + ['/dashboard/team/sandboxes', false], + ])('%s -> %s', (pathname, expected) => { + expect(isAuthEndpointRoute(pathname)).toBe(expected) + }) +}) From 65328f032ad98497c1663decdcebbeb5a98bae11 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Mon, 22 Jun 2026 18:56:44 +0200 Subject: [PATCH 18/29] don't rely on /logout and clear ory_session cookie on sign-out --- src/app/api/auth/sign-out/route.ts | 38 +++++++++-- src/core/server/auth/ory/kratos-session.ts | 56 ++++++++++----- src/core/server/auth/ory/session.ts | 18 ++++- .../auth-ory-account-security.test.ts | 26 ++++++- tests/integration/auth-ory-sign-out.test.ts | 68 +++++++++++++++++++ 5 files changed, 180 insertions(+), 26 deletions(-) create mode 100644 tests/integration/auth-ory-sign-out.test.ts diff --git a/src/app/api/auth/sign-out/route.ts b/src/app/api/auth/sign-out/route.ts index 6b5ece0f9..c7c1a0a5f 100644 --- a/src/app/api/auth/sign-out/route.ts +++ b/src/app/api/auth/sign-out/route.ts @@ -2,18 +2,44 @@ import 'server-only' import { type NextRequest, NextResponse } from 'next/server' import { signOut } from '@/core/server/auth' -import { orySessionCookieDeleteOptions } from '@/core/server/auth/ory/session-cookie' +import { + orySessionCookieDeleteOptions, + resolveSessionCookieDomain, +} from '@/core/server/auth/ory/session-cookie' -// Sign-out is a plain route handler. It reads the id_token from e2b_session to -// build Hydra's RP-logout URL, then clears the cookie on the redirect it emits -// (before handing off to Hydra, which ends the Ory + Kratos sessions). The -// client hard-navigates here so the logout overlay stays up until the document -// unloads. +// Ory's identity session cookie: `ory_kratos_session` self-hosted, +// `ory_session_` on Ory Network. Deliberately excludes Hydra's +// `ory_hydra_session*` cookie, which the RP-logout redirect ends, not us. +const ORY_IDENTITY_SESSION_COOKIE = /^ory_(kratos_)?session/ + +// Sign-out is a plain route handler the client hard-navigates to, so the logout +// overlay stays up until the document unloads. signOut() revokes the Kratos +// identity session server-side and the redirect ends Hydra's OAuth2 session; we +// drop the browser cookies here — e2b_session (our token cache) and the Ory +// identity cookie, which Hydra's logout leaves in place. export async function GET(request: NextRequest) { const { redirectTo } = await signOut({ origin: request.nextUrl.origin }) const response = NextResponse.redirect( new URL(redirectTo, request.nextUrl.origin) ) response.cookies.delete(orySessionCookieDeleteOptions(request.nextUrl.host)) + clearOryIdentitySessionCookies(request, response) return response } + +// The cookie name and scope depend on the deployment, so clear whatever Ory +// identity cookie the browser actually sent, scoped the same way it was issued: +// parent-domain on Ory Network (how the @ory/nextjs proxy sets it, e.g. +// `.e2b-staging.dev`), host-only otherwise. +function clearOryIdentitySessionCookies( + request: NextRequest, + response: NextResponse +): void { + const domain = resolveSessionCookieDomain(request.nextUrl.host) + for (const { name } of request.cookies.getAll()) { + if (!ORY_IDENTITY_SESSION_COOKIE.test(name)) continue + response.cookies.delete( + domain ? { name, path: '/', domain } : { name, path: '/' } + ) + } +} diff --git a/src/core/server/auth/ory/kratos-session.ts b/src/core/server/auth/ory/kratos-session.ts index c981a1a42..fd082c607 100644 --- a/src/core/server/auth/ory/kratos-session.ts +++ b/src/core/server/auth/ory/kratos-session.ts @@ -5,20 +5,6 @@ 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 @@ -27,12 +13,48 @@ import { readOryError } from './ory-error' const REVOKE_MAX_ATTEMPTS = 3 const REVOKE_BACKOFF_MS = 150 +/** + * Revokes a single Kratos session by its session id (admin DELETE + * /admin/sessions/{id}). + * + * This is the server-side equivalent of the browser self-service logout: it + * ends only the current session, preserving single sign-out semantics. We call + * it on sign-out because Hydra's /oauth2/sessions/logout skips the dashboard + * /logout -> Kratos bridge whenever Hydra holds no active authentication + * session (the production default, where the login is accepted with + * remember=false), which would otherwise leave the Kratos identity session + * alive and surface "Reauthenticate as " on the next sign-in. + */ +export async function revokeKratosSession(sessionId: string): Promise { + await revokeWithRetries('revoke_kratos_session', () => + getOryIdentityApi().disableSession({ id: sessionId }) + ) +} + +/** + * Revokes every Kratos identity session for the given identity (admin DELETE + * /admin/identities/{id}/sessions). + * + * Used after a credential change, where signing out every device is the + * intended "sign out of identity provider" behavior. The OIDC `sid` claim from + * Hydra is Hydra's own OAuth2 session id, not a Kratos session id, so + * single-session targeting isn't available on that path. + */ export async function revokeKratosSessionsForIdentity( identityId: string +): Promise { + await revokeWithRetries('revoke_kratos_sessions', () => + getOryIdentityApi().deleteIdentitySessions({ id: identityId }) + ) +} + +async function revokeWithRetries( + operation: string, + run: () => Promise ): Promise { for (let attempt = 1; attempt <= REVOKE_MAX_ATTEMPTS; attempt++) { try { - await getOryIdentityApi().deleteIdentitySessions({ id: identityId }) + await run() return } catch (error) { if (error instanceof ResponseError && error.response.status === 404) { @@ -53,11 +75,11 @@ export async function revokeKratosSessionsForIdentity( l.error( { - key: 'auth_provider:revoke_kratos_sessions:error', + key: `auth_provider:${operation}:error`, context: { ory: oryDetails, attempt }, error: serializeErrorForLog(error), }, - 'failed to revoke Kratos sessions; user may see reauth UX on next sign-in' + 'failed to revoke Kratos session(s); user may see reauth UX on next sign-in' ) return } diff --git a/src/core/server/auth/ory/session.ts b/src/core/server/auth/ory/session.ts index 68b72ad2f..82dfdb3a1 100644 --- a/src/core/server/auth/ory/session.ts +++ b/src/core/server/auth/ory/session.ts @@ -22,7 +22,10 @@ import { import { oryAuthFlows } from './flows' import { isKratosSessionFresh } from './freshness' import { fromKratosSessionIdentity, fromOryIdentity } from './identity' -import { revokeKratosSessionsForIdentity } from './kratos-session' +import { + revokeKratosSession, + revokeKratosSessionsForIdentity, +} from './kratos-session' import { revokeOryOAuthSessionsForSubject } from './oauth-session' import { E2B_SESSION_COOKIE, @@ -68,6 +71,19 @@ export async function getUserProfile(): Promise { export async function signOut( options?: SignOutOptions ): Promise { + // Hydra's RP-initiated logout only runs the /logout -> Kratos bridge when + // Hydra still holds an active authentication session. In production the login + // is accepted with remember=false (non-persistent sessions), so Hydra + // short-circuits to post_logout_redirect_uri and the bridge never fires, + // leaving the Kratos identity session alive. Revoke just this session + // server-side here so sign-out is deterministic across environments without + // signing the user out of their other devices; the redirect below still ends + // Hydra's OAuth2 session. + const sessionId = (await readKratosSession())?.id + if (sessionId) { + await revokeKratosSession(sessionId) + } + return { redirectTo: await completeOrySignOut(options?.origin), } diff --git a/tests/integration/auth-ory-account-security.test.ts b/tests/integration/auth-ory-account-security.test.ts index 645770469..a77740d00 100644 --- a/tests/integration/auth-ory-account-security.test.ts +++ b/tests/integration/auth-ory-account-security.test.ts @@ -7,6 +7,7 @@ const updateIdentityMock = vi.hoisted(() => vi.fn()) const patchIdentityMock = vi.hoisted(() => vi.fn()) const revokeOAuthSessionsMock = vi.hoisted(() => vi.fn()) const revokeKratosSessionsMock = vi.hoisted(() => vi.fn()) +const revokeKratosSessionMock = vi.hoisted(() => vi.fn()) const openOrySessionMock = vi.hoisted(() => vi.fn()) const cookieDeleteMock = vi.hoisted(() => vi.fn()) @@ -50,6 +51,7 @@ vi.mock('@/core/server/auth/ory/oauth-session', () => ({ vi.mock('@/core/server/auth/ory/kratos-session', () => ({ revokeKratosSessionsForIdentity: revokeKratosSessionsMock, + revokeKratosSession: revokeKratosSessionMock, })) vi.mock('@/core/shared/clients/logger/logger', () => ({ @@ -73,11 +75,14 @@ const currentIdentity = { function kratosSession({ authenticatedAt = new Date(), identityId = 'kratos-uuid', + sessionId = 'kratos-session-id', }: { authenticatedAt?: Date identityId?: string + sessionId?: string } = {}) { return { + id: sessionId, active: true, authenticated_at: authenticatedAt, identity: { @@ -96,6 +101,7 @@ describe('Ory account security (Kratos session + e2b_session)', () => { patchIdentityMock.mockReset().mockResolvedValue(undefined) revokeOAuthSessionsMock.mockReset().mockResolvedValue(undefined) revokeKratosSessionsMock.mockReset().mockResolvedValue(undefined) + revokeKratosSessionMock.mockReset().mockResolvedValue(undefined) openOrySessionMock.mockReset().mockResolvedValue({ accessToken: 'hydra-access-token', idToken: 'hydra-id-token', @@ -179,7 +185,9 @@ describe('Ory account security (Kratos session + e2b_session)', () => { }) }) - it('signs out via Hydra RP-logout using the id_token hint', async () => { + it('signs out via Hydra RP-logout and revokes only the current Kratos session', async () => { + getServerSessionMock.mockResolvedValue(kratosSession()) + const result = await signOut({ origin: 'https://app.e2b.dev' }) expect(result.redirectTo).toContain( @@ -187,8 +195,22 @@ describe('Ory account security (Kratos session + e2b_session)', () => { ) expect(result.redirectTo).toContain('id_token_hint=hydra-id-token') expect(result.redirectTo).toContain('post_logout_redirect_uri=') - // Single sign-out must not revoke every session. + // Revoke this session server-side so logout works even when Hydra skips the + // /logout -> Kratos bridge (no active authentication session in production). + expect(revokeKratosSessionMock).toHaveBeenCalledWith('kratos-session-id') + // ...but single sign-out must not revoke every device's session. expect(revokeKratosSessionsMock).not.toHaveBeenCalled() expect(revokeOAuthSessionsMock).not.toHaveBeenCalled() }) + + it('still signs out via Hydra RP-logout when no Kratos session is readable', async () => { + getServerSessionMock.mockResolvedValue(undefined) + + const result = await signOut({ origin: 'https://app.e2b.dev' }) + + expect(result.redirectTo).toContain( + 'https://ory.example.com/oauth2/sessions/logout' + ) + expect(revokeKratosSessionMock).not.toHaveBeenCalled() + }) }) diff --git a/tests/integration/auth-ory-sign-out.test.ts b/tests/integration/auth-ory-sign-out.test.ts new file mode 100644 index 000000000..b6a9345dc --- /dev/null +++ b/tests/integration/auth-ory-sign-out.test.ts @@ -0,0 +1,68 @@ +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const signOutMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/core/server/auth', () => ({ + signOut: signOutMock, +})) + +const { GET } = await import('@/app/api/auth/sign-out/route') + +// A cleared cookie carries an immediate expiry; Next emits Max-Age=0 and/or an +// epoch Expires depending on version, so accept either. +function clearedCookie(response: Response, name: string): string | undefined { + return response.headers + .getSetCookie() + .find( + (header) => + header.startsWith(`${name}=`) && + /(max-age=0|expires=thu, 01 jan 1970)/i.test(header) + ) +} + +describe('sign-out route cookie clearing', () => { + beforeEach(() => { + signOutMock + .mockReset() + .mockResolvedValue({ redirectTo: 'https://app.e2b.dev/' }) + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('clears the Ory Network identity cookie scoped to the parent domain', async () => { + vi.stubEnv('NEXT_PUBLIC_E2B_DOMAIN', 'e2b-staging.dev') + + const response = await GET( + new NextRequest('https://e2b-staging.dev/api/auth/sign-out', { + headers: { + cookie: + 'ory_session_abcdef=tok; e2b_session=sealed; ory_hydra_session_dev=h', + }, + }) + ) + + const oryClear = clearedCookie(response, 'ory_session_abcdef') + expect(oryClear).toBeDefined() + expect(oryClear).toMatch(/Domain=\.e2b-staging\.dev/i) + + // Our own token cache is dropped too. + expect(clearedCookie(response, 'e2b_session')).toBeDefined() + // Hydra's session cookie is ended by the RP-logout redirect, not here. + expect(clearedCookie(response, 'ory_hydra_session_dev')).toBeUndefined() + }) + + it('clears the self-hosted ory_kratos_session cookie host-only', async () => { + const response = await GET( + new NextRequest('http://localhost:3000/api/auth/sign-out', { + headers: { cookie: 'ory_kratos_session=tok' }, + }) + ) + + const oryClear = clearedCookie(response, 'ory_kratos_session') + expect(oryClear).toBeDefined() + expect(oryClear).not.toMatch(/Domain=/i) + }) +}) From de6cab02c49cc70553f0d4c18465125c4e364d89 Mon Sep 17 00:00:00 2001 From: ben-fornefeld Date: Mon, 22 Jun 2026 21:11:36 -0700 Subject: [PATCH 19/29] fix(auth): source AuthUser.id from Kratos external_id, add identityId AuthUser.id is now always public.users.id (the Kratos identity's external_id) rather than the Kratos identity id, and a new identityId field carries the Kratos id for admin/Kratos operations. This fixes PostHog identify, tRPC telemetry, and team resolution, which all expect public.users.id. - identity.ts mappers require external_id (no silent fallback to the Kratos id) and set identityId. - getAuthContext refuses a session whose identity has no external_id; the edge gate (isKratosSessionActive) rejects it too, so the user is routed to /sign-in instead of looping, where a fresh login re-runs bootstrap and backfills external_id. - Drop the updateUser override that re-stamped the Kratos id over id. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/core/modules/auth/models.ts | 3 ++ src/core/server/auth/ory/identity.ts | 26 +++++++++---- .../server/auth/ory/kratos-session-edge.ts | 11 +++++- src/core/server/auth/ory/session.ts | 22 +++++++++-- .../auth-ory-account-security.test.ts | 20 +++++++++- tests/unit/identity-traits.test.ts | 39 ++++++++++++++++++- tests/unit/user-router.test.ts | 1 + 7 files changed, 104 insertions(+), 18 deletions(-) diff --git a/src/core/modules/auth/models.ts b/src/core/modules/auth/models.ts index b31798fc2..70d5ca88f 100644 --- a/src/core/modules/auth/models.ts +++ b/src/core/modules/auth/models.ts @@ -1,5 +1,8 @@ export type AuthUser = { + // public.users.id, sourced from the Kratos identity's external_id. id: string + // Ory Kratos identity id (the OAuth2 subject); used for Kratos/admin ops. + identityId: string email: string | null name: string | null avatarUrl: string | null diff --git a/src/core/server/auth/ory/identity.ts b/src/core/server/auth/ory/identity.ts index 21ac22fc0..a724db90e 100644 --- a/src/core/server/auth/ory/identity.ts +++ b/src/core/server/auth/ory/identity.ts @@ -5,8 +5,18 @@ import { z } from 'zod' import { l } from '@/core/shared/clients/logger/logger' import type { AuthUser } from '../types' -type FromOryIdentityOptions = { - userId?: string +// AuthUser.id is always public.users.id (the identity's external_id), never the +// Kratos identity id. A provisioned user always has one; getAuthContext refuses +// sessions without it, so reaching here without an external_id is an invariant +// violation we fail loudly on rather than mislabel the user. +function requireExternalId(identity: { + id: string + external_id?: string | null +}): string { + if (!identity.external_id) { + throw new Error(`Ory identity ${identity.id} has no external_id`) + } + return identity.external_id } export const oryIdentityTraitsSchema = z @@ -58,6 +68,7 @@ function readPublicPicture(metadataPublic: unknown): string | null { // with an admin lookup when those are needed (e.g. the profile query). export function fromKratosSessionIdentity(identity: { id: string + external_id?: string | null traits?: unknown metadata_public?: unknown }): AuthUser { @@ -66,7 +77,8 @@ export function fromKratosSessionIdentity(identity: { source: 'kratos_session', }) return { - id: identity.id, + id: requireExternalId(identity), + identityId: identity.id, email: readString(traits, 'email'), name: readString(traits, 'name'), avatarUrl: readPublicPicture(identity.metadata_public), @@ -79,10 +91,7 @@ export function fromKratosSessionIdentity(identity: { // 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 { +export function fromOryIdentity(identity: Identity): AuthUser { const traits = parseOryTraits(identity.traits, { identityId: identity.id, source: 'admin_identity', @@ -98,7 +107,8 @@ export function fromOryIdentity( const canChangePassword = hasPasswordCredential && !hasOidcCredential return { - id: options.userId ?? identity.id, + id: requireExternalId(identity), + identityId: identity.id, email, name, avatarUrl, diff --git a/src/core/server/auth/ory/kratos-session-edge.ts b/src/core/server/auth/ory/kratos-session-edge.ts index 4730c3826..29671a409 100644 --- a/src/core/server/auth/ory/kratos-session-edge.ts +++ b/src/core/server/auth/ory/kratos-session-edge.ts @@ -5,6 +5,10 @@ import { APP_OWNED_COOKIES } from './session-cookie' // reads next/headers and can't run in the edge runtime, so we hit Kratos // directly with the request's cookies. This gates redirects only — // authoritative enforcement happens server-side in getAuthContext. +// +// external_id is required so this gate agrees with getAuthContext: a session +// without it is half-provisioned and getAuthContext rejects it, so we must too, +// otherwise the user loops between /sign-in and /dashboard. export async function isKratosSessionActive( request: NextRequest ): Promise { @@ -22,8 +26,11 @@ export async function isKratosSessionActive( { headers: { cookie, accept: 'application/json' } } ) if (!response.ok) return false - const session = (await response.json()) as { active?: boolean } - return session.active === true + const session = (await response.json()) as { + active?: boolean + identity?: { external_id?: string | null } + } + return session.active === true && !!session.identity?.external_id } catch { return false } diff --git a/src/core/server/auth/ory/session.ts b/src/core/server/auth/ory/session.ts index 82dfdb3a1..90714b610 100644 --- a/src/core/server/auth/ory/session.ts +++ b/src/core/server/auth/ory/session.ts @@ -46,6 +46,22 @@ export async function getAuthContext(): Promise { const kratos = await readKratosSession() if (!kratos?.active || !kratos.identity) return null + // public.users.id lives only on the Kratos identity's external_id. Without it + // the dashboard can't key the user to its own records (PostHog, telemetry, + // team membership), so we refuse the half-provisioned session. The edge gate + // (isKratosSessionActive) rejects it too, so the user is routed to /sign-in + // where a fresh login re-runs bootstrap and backfills external_id. + if (!kratos.identity.external_id) { + l.error( + { + key: 'auth_provider:identity_missing_external_id', + context: { identity_id: kratos.identity.id }, + }, + 'Kratos identity has no external_id; treating the session as unauthenticated' + ) + return null + } + const tokens = await readOrySessionTokens() if (!tokens?.accessToken) return null @@ -65,7 +81,7 @@ export async function getUserProfile(): Promise { includeCredential: ACCOUNT_IDENTITY_CREDENTIALS, }) - return identity ? fromOryIdentity(identity, { userId: identityId }) : null + return identity ? fromOryIdentity(identity) : null } export async function signOut( @@ -114,9 +130,7 @@ export async function updateUser( password: input.password, }) - if (!result.ok) return result - - return { ...result, user: { ...result.user, id: identityId } } + return result } export async function startReauthForAccountSettings(): Promise { diff --git a/tests/integration/auth-ory-account-security.test.ts b/tests/integration/auth-ory-account-security.test.ts index a77740d00..637bb793c 100644 --- a/tests/integration/auth-ory-account-security.test.ts +++ b/tests/integration/auth-ory-account-security.test.ts @@ -87,6 +87,7 @@ function kratosSession({ authenticated_at: authenticatedAt, identity: { id: identityId, + external_id: 'e2b-user-id', traits: { email: 'ada@example.test', name: 'Ada' }, }, } @@ -119,7 +120,8 @@ describe('Ory account security (Kratos session + e2b_session)', () => { expect(await getAuthContext()).toEqual({ user: expect.objectContaining({ - id: 'kratos-uuid', + id: 'e2b-user-id', + identityId: 'kratos-uuid', email: 'ada@example.test', name: 'Ada', }), @@ -133,6 +135,17 @@ describe('Ory account security (Kratos session + e2b_session)', () => { expect(await getAuthContext()).toBeNull() }) + it('returns null when the Kratos identity has no external_id', async () => { + getServerSessionMock.mockResolvedValue({ + id: 'kratos-session-id', + active: true, + authenticated_at: new Date(), + identity: { id: 'kratos-uuid', traits: { email: 'ada@example.test' } }, + }) + + expect(await getAuthContext()).toBeNull() + }) + it('returns null when the Kratos session is active but no token is present', async () => { getServerSessionMock.mockResolvedValue(kratosSession()) openOrySessionMock.mockResolvedValue(null) @@ -168,7 +181,10 @@ describe('Ory account security (Kratos session + e2b_session)', () => { credentials: { password: { config: { password: 'new-secret' } } }, }), }) - expect(result).toMatchObject({ ok: true, user: { id: 'kratos-uuid' } }) + expect(result).toMatchObject({ + ok: true, + user: { id: 'e2b-user-id', identityId: 'kratos-uuid' }, + }) }) it('revokes Ory + Kratos sessions and clears e2b_session after a credential change', async () => { diff --git a/tests/unit/identity-traits.test.ts b/tests/unit/identity-traits.test.ts index 9a54126c3..2e348aded 100644 --- a/tests/unit/identity-traits.test.ts +++ b/tests/unit/identity-traits.test.ts @@ -32,16 +32,51 @@ function sessionIdentity(overrides: { traits?: unknown metadata_public?: unknown }) { - return { id: 'identity-1', ...overrides } + return { id: 'identity-1', external_id: 'e2b-user-1', ...overrides } } function adminIdentity(overrides: { traits?: unknown metadata_public?: unknown }): Identity { - return { id: 'identity-1', ...overrides } as Identity + return { + id: 'identity-1', + external_id: 'e2b-user-1', + ...overrides, + } as Identity } +describe('AuthUser id sourcing', () => { + it('sets id from external_id and identityId from the Kratos id', () => { + const fromSession = fromKratosSessionIdentity( + sessionIdentity({ traits: { email: 'jane@e2b.dev' } }) + ) + const fromAdmin = fromOryIdentity( + adminIdentity({ traits: { email: 'jane@e2b.dev' } }) + ) + + for (const user of [fromSession, fromAdmin]) { + expect(user.id).toBe('e2b-user-1') + expect(user.identityId).toBe('identity-1') + } + }) + + it('throws when the identity has no external_id', () => { + expect(() => + fromKratosSessionIdentity({ + id: 'identity-1', + traits: { email: 'jane@e2b.dev' }, + }) + ).toThrow(/external_id/) + expect(() => + fromOryIdentity({ + id: 'identity-1', + traits: { email: 'jane@e2b.dev' }, + } as Identity) + ).toThrow(/external_id/) + }) +}) + describe('parseOryTraits via identity mappers', () => { it('accepts valid traits without logging drift', () => { const user = fromKratosSessionIdentity( diff --git a/tests/unit/user-router.test.ts b/tests/unit/user-router.test.ts index d2b0edadf..175a4d3b2 100644 --- a/tests/unit/user-router.test.ts +++ b/tests/unit/user-router.test.ts @@ -26,6 +26,7 @@ const createCaller = createCallerFactory(userRouter) const authUser = { id: 'user-1', + identityId: 'identity-1', email: 'old@example.test', name: 'Ada', avatarUrl: null, From 36fc59a965436d31a31805c55e494a3b0641b838 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Tue, 23 Jun 2026 13:13:57 +0200 Subject: [PATCH 20/29] add login/logout relay for previews --- .env.example | 7 ++ src/app/api/auth/oauth/callback/ory/route.ts | 8 +- src/app/api/auth/oauth/logout-relay/route.ts | 29 ++++++ src/app/api/auth/oauth/relay/route.ts | 39 ++++++++ src/app/api/auth/oauth/start/route.ts | 15 ++- src/core/server/auth/ory/oauth-client.ts | 7 +- src/core/server/auth/ory/oauth-relay.ts | 97 ++++++++++++++++++++ src/core/server/auth/ory/signout-flow.ts | 3 +- src/core/server/auth/ory/signout.ts | 25 ++++- src/lib/env.ts | 4 + 10 files changed, 222 insertions(+), 12 deletions(-) create mode 100644 src/app/api/auth/oauth/logout-relay/route.ts create mode 100644 src/app/api/auth/oauth/relay/route.ts create mode 100644 src/core/server/auth/ory/oauth-relay.ts diff --git a/.env.example b/.env.example index 1a4103694..d2f9dfdfd 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,13 @@ NEXT_PUBLIC_E2B_DOMAIN=e2b.dev # ORY_KRATOS_ADMIN_URL=http://localhost:4434 # ORY_HYDRA_ADMIN_URL=http://localhost:4445 +### Fixed host whose OAuth callback/logout relays are registered in Hydra. Set on +### preview deployments only (dynamic preview hosts can't register their own +### redirect URIs); leave unset on staging/production/local, where sign-in stays +### host-direct. The relay paths (/api/auth/oauth/relay, /api/auth/oauth/logout-relay) +### must be added to the OAuth2 client's redirect_uris / post_logout_redirect_uris. +# ORY_OAUTH_RELAY_ORIGIN=https://e2b-staging.dev + # ENABLE_USER_BOOTSTRAP=0 ### Billing API URL (Required if NEXT_PUBLIC_INCLUDE_BILLING=1) diff --git a/src/app/api/auth/oauth/callback/ory/route.ts b/src/app/api/auth/oauth/callback/ory/route.ts index 6eb91770b..9967e6f77 100644 --- a/src/app/api/auth/oauth/callback/ory/route.ts +++ b/src/app/api/auth/oauth/callback/ory/route.ts @@ -6,10 +6,10 @@ import { ensureOryUserBootstrapped } from '@/core/server/auth/ory/dashboard-boot import { exchangeOryCallback } from '@/core/server/auth/ory/oauth-client' import { E2B_OAUTH_FLOW_COOKIE, - OAUTH_CALLBACK_PATH, ORY_RECOVER_PATH, openOryFlowState, } from '@/core/server/auth/ory/oauth-flow' +import { resolveOryRedirectUri } from '@/core/server/auth/ory/oauth-relay' import { E2B_SESSION_COOKIE, ORY_SIGNUP_METADATA_COOKIE, @@ -49,7 +49,9 @@ export async function GET(request: NextRequest) { expectedState: flow.state, expectedNonce: flow.nonce, codeVerifier: flow.codeVerifier, - redirectUri: new URL(OAUTH_CALLBACK_PATH, origin).toString(), + // Must be byte-identical to the authorize-time value (the registered + // relay URI on previews), not the host the code was delivered to. + redirectUri: resolveOryRedirectUri(origin).redirectUri, }) } catch (error) { l.error( @@ -76,7 +78,7 @@ export async function GET(request: NextRequest) { // Don't strand the user with a half-provisioned login: end the Ory + Kratos // session via RP-logout (falling back to home if no id_token is available). const logoutUrl = tokens.idToken - ? buildOryLogoutUrl({ idToken: tokens.idToken, origin }) + ? await buildOryLogoutUrl({ idToken: tokens.idToken, origin }) : null return finalize( NextResponse.redirect(logoutUrl ?? new URL(ORY_POST_LOGOUT_PATH, origin)) diff --git a/src/app/api/auth/oauth/logout-relay/route.ts b/src/app/api/auth/oauth/logout-relay/route.ts new file mode 100644 index 000000000..879addca7 --- /dev/null +++ b/src/app/api/auth/oauth/logout-relay/route.ts @@ -0,0 +1,29 @@ +import 'server-only' + +import { type NextRequest, NextResponse } from 'next/server' +import { + isAllowedRelayTarget, + openRelayState, +} from '@/core/server/auth/ory/oauth-relay' +import { ORY_POST_LOGOUT_PATH } from '@/core/server/auth/ory/signout' +import { l } from '@/core/shared/clients/logger/logger' + +// Fixed-host post-logout relay (mirror of the login relay). Hydra returns here +// after ending the session, with the sealed `state` carrying the preview origin; +// we bounce the browser back to that preview's home. The sign-out route already +// cleared the cookies on the preview before the Hydra hop, so this is a pure +// redirect. See oauth-relay.ts. +export async function GET(request: NextRequest) { + const origin = request.nextUrl.origin + const target = await openRelayState(request.nextUrl.searchParams.get('state')) + + if (!target || !isAllowedRelayTarget(target)) { + l.warn( + { key: 'oauth_logout_relay:invalid_target' }, + 'Ory logout relay hit without a valid sealed target' + ) + return NextResponse.redirect(new URL(ORY_POST_LOGOUT_PATH, origin)) + } + + return NextResponse.redirect(new URL(ORY_POST_LOGOUT_PATH, target)) +} diff --git a/src/app/api/auth/oauth/relay/route.ts b/src/app/api/auth/oauth/relay/route.ts new file mode 100644 index 000000000..ed1552217 --- /dev/null +++ b/src/app/api/auth/oauth/relay/route.ts @@ -0,0 +1,39 @@ +import 'server-only' + +import { type NextRequest, NextResponse } from 'next/server' +import { + OAUTH_CALLBACK_PATH, + ORY_RECOVER_PATH, +} from '@/core/server/auth/ory/oauth-flow' +import { + isAllowedRelayTarget, + openRelayState, +} from '@/core/server/auth/ory/oauth-relay' +import { l } from '@/core/shared/clients/logger/logger' + +// Fixed-host relay for preview deployments. Hydra is configured with this host's +// /api/auth/oauth/relay as the single registered redirect_uri; previews encode +// their own origin in the sealed `state`. We bounce the browser — carrying +// code/state/iss (and any error) verbatim — to the originating preview's real +// callback, which finishes the PKCE exchange (its verifier never left that +// origin). See oauth-relay.ts. Never touches cookies. +export async function GET(request: NextRequest) { + const origin = request.nextUrl.origin + const state = request.nextUrl.searchParams.get('state') + const target = await openRelayState(state) + + if (!target || !isAllowedRelayTarget(target)) { + l.warn( + { + key: 'oauth_relay:invalid_target', + context: { hasState: Boolean(state) }, + }, + 'Ory relay hit without a valid sealed target' + ) + return NextResponse.redirect(new URL(ORY_RECOVER_PATH, origin)) + } + + const destination = new URL(OAUTH_CALLBACK_PATH, target) + destination.search = request.nextUrl.search + return NextResponse.redirect(destination) +} diff --git a/src/app/api/auth/oauth/start/route.ts b/src/app/api/auth/oauth/start/route.ts index cdc8c244e..1bd7462c9 100644 --- a/src/app/api/auth/oauth/start/route.ts +++ b/src/app/api/auth/oauth/start/route.ts @@ -7,11 +7,14 @@ import { import { buildOryAuthorizationRequest } from '@/core/server/auth/ory/oauth-client' import { E2B_OAUTH_FLOW_COOKIE, - OAUTH_CALLBACK_PATH, ORY_RECOVER_PATH, oryFlowCookieOptions, sealOryFlowState, } from '@/core/server/auth/ory/oauth-flow' +import { + resolveOryRedirectUri, + sealRelayState, +} from '@/core/server/auth/ory/oauth-relay' import { ORY_SIGNUP_METADATA_COOKIE } from '@/core/server/auth/ory/session-cookie' import { encodeOrySignupMetadata, @@ -34,11 +37,17 @@ export async function GET(request: NextRequest) { const returnTo = normalizeOryReturnTo( request.nextUrl.searchParams.get('returnTo') ) - const redirectUri = new URL(OAUTH_CALLBACK_PATH, origin).toString() + const { redirectUri, relayTarget } = resolveOryRedirectUri(origin) let authorization: Awaited> try { - authorization = await buildOryAuthorizationRequest(intent, redirectUri) + // Relay mode carries the preview origin in a sealed state; direct mode keeps + // the original two-arg call so staging/production behavior is unchanged. + authorization = relayTarget + ? await buildOryAuthorizationRequest(intent, redirectUri, { + state: await sealRelayState(relayTarget), + }) + : await buildOryAuthorizationRequest(intent, redirectUri) } catch (error) { l.error( { diff --git a/src/core/server/auth/ory/oauth-client.ts b/src/core/server/auth/ory/oauth-client.ts index da454caa8..a54f441ea 100644 --- a/src/core/server/auth/ory/oauth-client.ts +++ b/src/core/server/auth/ory/oauth-client.ts @@ -93,7 +93,8 @@ function oryClient(env: OryOAuthEnv): oauth.Client { export async function buildOryAuthorizationRequest( intent: OryAuthIntent, - redirectUri: string + redirectUri: string, + options?: { state?: string } ): Promise { const env = readOryOAuthEnv() const as = await discoverAuthorizationServer(env) @@ -104,7 +105,9 @@ export async function buildOryAuthorizationRequest( const codeVerifier = oauth.generateRandomCodeVerifier() const codeChallenge = await oauth.calculatePKCECodeChallenge(codeVerifier) - const state = oauth.generateRandomState() + // In relay mode the caller supplies a sealed state carrying the preview + // origin; it doubles as the CSRF state validated at the callback. + const state = options?.state ?? oauth.generateRandomState() const nonce = oauth.generateRandomNonce() const url = new URL(as.authorization_endpoint) diff --git a/src/core/server/auth/ory/oauth-relay.ts b/src/core/server/auth/ory/oauth-relay.ts new file mode 100644 index 000000000..b6f4eb785 --- /dev/null +++ b/src/core/server/auth/ory/oauth-relay.ts @@ -0,0 +1,97 @@ +// OAuth callback relay for preview deployments. Ory does not allow wildcard +// redirect URIs, so previews — whose host is dynamic per branch — cannot +// register their own callback. Instead we register ONE stable callback on a +// fixed host (ORY_OAUTH_RELAY_ORIGIN) and point Hydra there for every preview, +// encoding the originating preview origin in the sealed OAuth `state`. The fixed +// host bounces the browser (carrying code/state/iss) back to the preview's real +// callback, which finishes the PKCE exchange using the same registered +// redirect_uri string — the token request only requires the redirect_uri to +// match the authorize-time value, not to be where the code was delivered. +// +// The PKCE verifier lives in a host-only cookie on the preview and never reaches +// the relay. `state` is sealed with the shared cookie crypto (E2B_SESSION_SECRET, +// identical across the fixed host and previews), so the target is tamper-proof. +// +// No next/headers import here so this stays importable from edge middleware +// (signout.ts pulls it in for the post-logout path). + +import { EncryptJWT, jwtDecrypt } from 'jose' +import { isLoopbackUrl } from '@/core/shared/schemas/url' +import { CONTENT_ENCRYPTION, deriveKey, KEY_ALGORITHM } from './cookie-crypto' +import { OAUTH_CALLBACK_PATH } from './oauth-flow' + +export const OAUTH_RELAY_PATH = '/api/auth/oauth/relay' +export const OAUTH_LOGOUT_RELAY_PATH = '/api/auth/oauth/logout-relay' + +// The fixed host whose relay endpoints are registered in Hydra. Set on preview +// deployments only; unset on staging/production/local, where the flow stays +// host-direct and behaves exactly as before. +export function readRelayOrigin(): string | undefined { + const value = process.env.ORY_OAUTH_RELAY_ORIGIN + if (!value) return undefined + return value.replace(/\/$/, '') +} + +// Relay mode applies only when a fixed origin is configured AND differs from the +// request origin. On the fixed host itself (and everywhere relay is unset) the +// request resolves to its own callback, i.e. today's behavior. +export function resolveOryRedirectUri(requestOrigin: string): { + redirectUri: string + relayTarget?: string +} { + const relay = readRelayOrigin() + if (relay && relay !== requestOrigin) { + return { + redirectUri: new URL(OAUTH_RELAY_PATH, relay).toString(), + relayTarget: requestOrigin, + } + } + + return { redirectUri: new URL(OAUTH_CALLBACK_PATH, requestOrigin).toString() } +} + +// Carries the originating preview origin through Hydra in the OAuth `state` +// (login) or RP-logout `state`. The random `r` gives the login state CSRF +// entropy beyond the per-seal random IV. +export async function sealRelayState(target: string): Promise { + return new EncryptJWT({ t: target, r: crypto.randomUUID() }) + .setProtectedHeader({ alg: KEY_ALGORITHM, enc: CONTENT_ENCRYPTION }) + .setIssuedAt() + .encrypt(await deriveKey()) +} + +export async function openRelayState( + value: string | null | undefined +): Promise { + if (!value) return null + + try { + const { payload } = await jwtDecrypt(value, await deriveKey()) + return typeof payload.t === 'string' ? payload.t : null + } catch { + return null + } +} + +// Open-redirect guard: a relay target must be a first-party origin. Production +// requires HTTPS under NEXT_PUBLIC_E2B_DOMAIN (e.g. `*.e2b-staging.dev`); local +// dev also accepts loopback so the relay path can be exercised across ports. +export function isAllowedRelayTarget(target: string): boolean { + let url: URL + try { + url = new URL(target) + } catch { + return false + } + + if (url.protocol === 'http:' && isLoopbackUrl(target)) { + return process.env.NODE_ENV !== 'production' + } + + if (url.protocol !== 'https:') return false + + const base = process.env.NEXT_PUBLIC_E2B_DOMAIN + if (!base) return false + + return url.hostname === base || url.hostname.endsWith(`.${base}`) +} diff --git a/src/core/server/auth/ory/signout-flow.ts b/src/core/server/auth/ory/signout-flow.ts index 4d2892fd2..6b6d0dd8b 100644 --- a/src/core/server/auth/ory/signout-flow.ts +++ b/src/core/server/auth/ory/signout-flow.ts @@ -32,5 +32,6 @@ export async function completeOrySignOut(origin = BASE_URL): Promise { if (!idToken) return fallback - return buildOryLogoutUrl({ idToken, origin })?.toString() ?? fallback + const logoutUrl = await buildOryLogoutUrl({ idToken, origin }) + return logoutUrl?.toString() ?? fallback } diff --git a/src/core/server/auth/ory/signout.ts b/src/core/server/auth/ory/signout.ts index 896b45873..c2f647e79 100644 --- a/src/core/server/auth/ory/signout.ts +++ b/src/core/server/auth/ory/signout.ts @@ -1,19 +1,37 @@ +import { + OAUTH_LOGOUT_RELAY_PATH, + readRelayOrigin, + sealRelayState, +} from './oauth-relay' + export const ORY_POST_LOGOUT_PATH = '/' // Builds Hydra's RP-initiated logout URL. With the id_token as the hint Hydra // ends both its own OAuth2 session and (since it delegates login to Kratos) the // Kratos session, then returns the browser to post_logout_redirect_uri. -export function buildOryLogoutUrl({ +export async function buildOryLogoutUrl({ idToken, origin, }: { idToken: string origin: string -}): URL | null { +}): Promise { const issuer = process.env.ORY_HYDRA_PUBLIC_URL ?? process.env.ORY_SDK_URL if (!issuer) return null - const postLogoutUrl = new URL(ORY_POST_LOGOUT_PATH, origin) + // Previews can't register their dynamic host as a post_logout_redirect_uri, + // so route through the fixed relay host and carry the real origin in `state` + // (Hydra returns it to the post-logout URI). See oauth-relay.ts. + const relay = readRelayOrigin() + let postLogoutUrl: URL + let relayState: string | undefined + if (relay && relay !== origin) { + postLogoutUrl = new URL(OAUTH_LOGOUT_RELAY_PATH, relay) + relayState = await sealRelayState(origin) + } else { + postLogoutUrl = new URL(ORY_POST_LOGOUT_PATH, origin) + } + const logoutUrl = new URL( `${issuer.replace(/\/$/, '')}/oauth2/sessions/logout` ) @@ -22,6 +40,7 @@ export function buildOryLogoutUrl({ 'post_logout_redirect_uri', postLogoutUrl.toString() ) + if (relayState) logoutUrl.searchParams.set('state', relayState) return logoutUrl } diff --git a/src/lib/env.ts b/src/lib/env.ts index 66b9ab11b..36477ebe2 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -24,6 +24,10 @@ export const serverSchema = z.object({ ORY_PROJECT_API_TOKEN: z.string().min(1).optional(), ORY_KRATOS_ADMIN_URL: z.url().optional(), ORY_HYDRA_ADMIN_URL: z.url().optional(), + // Fixed host whose OAuth callback/logout relays are registered in Hydra. Set + // on preview deployments (dynamic hosts can't register their own redirect + // URIs); unset on staging/production/local, where the flow stays host-direct. + ORY_OAUTH_RELAY_ORIGIN: z.url().optional(), OTEL_SERVICE_NAME: z.string().optional(), OTEL_EXPORTER_OTLP_ENDPOINT: z.url().optional(), From 68ad9f492061ea2e83a873a7b88af2ba4d3b24a8 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Tue, 23 Jun 2026 13:25:41 +0200 Subject: [PATCH 21/29] fix unit test --- tests/unit/kratos-session-edge.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/kratos-session-edge.test.ts b/tests/unit/kratos-session-edge.test.ts index f35d42cc5..34a53b798 100644 --- a/tests/unit/kratos-session-edge.test.ts +++ b/tests/unit/kratos-session-edge.test.ts @@ -41,7 +41,7 @@ describe('isKratosSessionActive', () => { it('calls whoami with the request cookie and returns true for an active session', async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, - json: async () => ({ active: true }), + json: async () => ({ active: true, identity: { external_id: 'ext-1' } }), }) vi.stubGlobal('fetch', fetchMock) From ad6d09e7fa1ecf3f84beecca988bccc600818c4b Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Tue, 23 Jun 2026 13:42:13 +0200 Subject: [PATCH 22/29] fix integration tests --- tests/integration/auth-ory-consent.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration/auth-ory-consent.test.ts b/tests/integration/auth-ory-consent.test.ts index a48dd8843..68a73f931 100644 --- a/tests/integration/auth-ory-consent.test.ts +++ b/tests/integration/auth-ory-consent.test.ts @@ -32,6 +32,7 @@ function consentRequest(challenge: string | null = 'consent-challenge') { describe('Ory consent provider', () => { beforeEach(() => { getConsentMock.mockReset().mockResolvedValue({ + client: { client_id: 'test-ory-client-id' }, subject: 'identity-uuid', requested_scope: ['openid', 'offline_access', 'email', 'profile'], requested_access_token_audience: ['dashboard-api'], @@ -66,6 +67,7 @@ describe('Ory consent provider', () => { it('omits the email claim when the email scope is not granted', async () => { getConsentMock.mockResolvedValueOnce({ + client: { client_id: 'test-ory-client-id' }, subject: 'identity-uuid', requested_scope: ['openid'], requested_access_token_audience: [], From c24747a2e58929110e7491e90a1ee9b69e148719 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Tue, 23 Jun 2026 15:30:47 +0200 Subject: [PATCH 23/29] proxy oauth2 requests to hydra --- src/core/server/proxy/runtime.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/core/server/proxy/runtime.ts b/src/core/server/proxy/runtime.ts index b47e624f8..8723f197d 100644 --- a/src/core/server/proxy/runtime.ts +++ b/src/core/server/proxy/runtime.ts @@ -59,6 +59,16 @@ export async function runDashboardProxy( request: NextRequest, _event: NextFetchEvent ) { + if (request.nextUrl.pathname.startsWith('/oauth2/')) { + const hydra = process.env.ORY_HYDRA_PUBLIC_URL ?? process.env.ORY_SDK_URL + if (hydra) { + return NextResponse.redirect( + new URL(request.nextUrl.pathname + request.nextUrl.search, hydra), + 307 + ) + } + } + // Forward Ory SDK traffic to Kratos before classification (it would otherwise // classify as a bypass and go to Next). if (isOrySdkProxyPath(request.nextUrl.pathname)) { From 8d509d041ec94727e43591e54093a1221243d604 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Tue, 23 Jun 2026 17:25:17 +0200 Subject: [PATCH 24/29] drop ory prefix from e2b session cookie management --- src/app/api/auth/oauth/callback/ory/route.ts | 8 ++-- src/app/api/auth/sign-out/route.ts | 4 +- src/core/server/auth/ory/session-cookie.ts | 24 +++++----- src/core/server/auth/ory/session.ts | 16 +++---- src/core/server/auth/ory/signout-flow.ts | 4 +- src/core/server/auth/ory/token-refresh.ts | 8 ++-- src/core/server/proxy/runtime.ts | 20 ++++---- .../auth-ory-account-security.test.ts | 10 ++-- tests/integration/auth-ory-callback.test.ts | 4 +- .../integration/auth-ory-entrypoints.test.ts | 46 +++++++++---------- tests/unit/session-cookie.test.ts | 44 +++++++++--------- tests/unit/token-refresh.test.ts | 18 ++++---- 12 files changed, 103 insertions(+), 103 deletions(-) diff --git a/src/app/api/auth/oauth/callback/ory/route.ts b/src/app/api/auth/oauth/callback/ory/route.ts index 9967e6f77..ed2f33b2f 100644 --- a/src/app/api/auth/oauth/callback/ory/route.ts +++ b/src/app/api/auth/oauth/callback/ory/route.ts @@ -13,8 +13,8 @@ import { resolveOryRedirectUri } from '@/core/server/auth/ory/oauth-relay' import { E2B_SESSION_COOKIE, ORY_SIGNUP_METADATA_COOKIE, - orySessionCookieOptions, - sealOrySession, + sealSessionCookie, + sessionCookieOptions, } from '@/core/server/auth/ory/session-cookie' import { buildOryLogoutUrl, @@ -85,7 +85,7 @@ export async function GET(request: NextRequest) { ) } - const sealed = await sealOrySession({ + const sealed = await sealSessionCookie({ accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, idToken: tokens.idToken, @@ -102,7 +102,7 @@ export async function GET(request: NextRequest) { response.cookies.set( E2B_SESSION_COOKIE, sealed, - orySessionCookieOptions(request.nextUrl.host) + sessionCookieOptions(request.nextUrl.host) ) return response } diff --git a/src/app/api/auth/sign-out/route.ts b/src/app/api/auth/sign-out/route.ts index c7c1a0a5f..85c47d7fa 100644 --- a/src/app/api/auth/sign-out/route.ts +++ b/src/app/api/auth/sign-out/route.ts @@ -3,7 +3,7 @@ import 'server-only' import { type NextRequest, NextResponse } from 'next/server' import { signOut } from '@/core/server/auth' import { - orySessionCookieDeleteOptions, + sessionCookieDeleteOptions, resolveSessionCookieDomain, } from '@/core/server/auth/ory/session-cookie' @@ -22,7 +22,7 @@ export async function GET(request: NextRequest) { const response = NextResponse.redirect( new URL(redirectTo, request.nextUrl.origin) ) - response.cookies.delete(orySessionCookieDeleteOptions(request.nextUrl.host)) + response.cookies.delete(sessionCookieDeleteOptions(request.nextUrl.host)) clearOryIdentitySessionCookies(request, response) return response } diff --git a/src/core/server/auth/ory/session-cookie.ts b/src/core/server/auth/ory/session-cookie.ts index e472f2bde..46f89e035 100644 --- a/src/core/server/auth/ory/session-cookie.ts +++ b/src/core/server/auth/ory/session-cookie.ts @@ -21,7 +21,7 @@ export const APP_OWNED_COOKIES = new Set([ // intentionally generous and not the security boundary. const SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30 -export type OrySessionTokens = { +export type SessionTokens = { accessToken: string refreshToken?: string idToken?: string @@ -29,7 +29,7 @@ export type OrySessionTokens = { expiresAt: number } -export type OrySessionCookieOptions = { +export type SessionCookieOptions = { httpOnly: true sameSite: 'lax' path: '/' @@ -38,14 +38,14 @@ export type OrySessionCookieOptions = { domain?: string } -export type OrySessionCookieDeleteOptions = { +export type SessionCookieDeleteOptions = { name: typeof E2B_SESSION_COOKIE path: '/' domain?: string } -export async function sealOrySession( - tokens: OrySessionTokens +export async function sealSessionCookie( + tokens: SessionTokens ): Promise { return new EncryptJWT({ accessToken: tokens.accessToken, @@ -58,9 +58,9 @@ export async function sealOrySession( .encrypt(await deriveKey()) } -export async function openOrySession( +export async function openSessionCookie( value: string | undefined | null -): Promise { +): Promise { if (!value) return null try { @@ -71,9 +71,9 @@ export async function openOrySession( } } -export function orySessionCookieOptions( +export function sessionCookieOptions( host?: string | null -): OrySessionCookieOptions { +): SessionCookieOptions { return { httpOnly: true, sameSite: 'lax', @@ -88,9 +88,9 @@ export function orySessionCookieOptions( // Deleting a domain-scoped cookie requires the same domain attribute, so the // clear paths must pass these options rather than the bare cookie name. -export function orySessionCookieDeleteOptions( +export function sessionCookieDeleteOptions( host?: string | null -): OrySessionCookieDeleteOptions { +): SessionCookieDeleteOptions { return { name: E2B_SESSION_COOKIE, path: '/', @@ -119,7 +119,7 @@ export function resolveSessionCookieDomain( function parseTokens( payload: Record -): OrySessionTokens | null { +): SessionTokens | null { const { accessToken, refreshToken, idToken, expiresAt } = payload if (typeof accessToken !== 'string' || typeof expiresAt !== 'number') { return null diff --git a/src/core/server/auth/ory/session.ts b/src/core/server/auth/ory/session.ts index 90714b610..5d35fa88b 100644 --- a/src/core/server/auth/ory/session.ts +++ b/src/core/server/auth/ory/session.ts @@ -29,8 +29,8 @@ import { import { revokeOryOAuthSessionsForSubject } from './oauth-session' import { E2B_SESSION_COOKIE, - openOrySession, - orySessionCookieDeleteOptions, + openSessionCookie, + sessionCookieDeleteOptions, } from './session-cookie' import { completeOrySignOut } from './signout-flow' @@ -62,7 +62,7 @@ export async function getAuthContext(): Promise { return null } - const tokens = await readOrySessionTokens() + const tokens = await readSessionTokens() if (!tokens?.accessToken) return null return { @@ -148,7 +148,7 @@ export async function handleCredentialChangeSuccess(): Promise { revokeKratosSessionsForIdentity(identityId), ]) - await clearOrySessionCookie() + await clearSessionCookie() } // Live Kratos session (whoami), memoized per request. The authority for "is @@ -168,15 +168,15 @@ const readKratosSession = cache(async () => { } }) -const readOrySessionTokens = cache(async () => { +const readSessionTokens = cache(async () => { const cookieStore = await cookies() - return openOrySession(cookieStore.get(E2B_SESSION_COOKIE)?.value) + return openSessionCookie(cookieStore.get(E2B_SESSION_COOKIE)?.value) }) -async function clearOrySessionCookie(): Promise { +async function clearSessionCookie(): Promise { try { const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]) - cookieStore.delete(orySessionCookieDeleteOptions(headerStore.get('host'))) + cookieStore.delete(sessionCookieDeleteOptions(headerStore.get('host'))) } catch (error) { l.warn( { diff --git a/src/core/server/auth/ory/signout-flow.ts b/src/core/server/auth/ory/signout-flow.ts index 6b6d0dd8b..35a367779 100644 --- a/src/core/server/auth/ory/signout-flow.ts +++ b/src/core/server/auth/ory/signout-flow.ts @@ -3,7 +3,7 @@ import 'server-only' import { cookies } from 'next/headers' import { BASE_URL } from '@/configs/urls' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' -import { E2B_SESSION_COOKIE, openOrySession } from './session-cookie' +import { E2B_SESSION_COOKIE, openSessionCookie } from './session-cookie' import { buildOryLogoutUrl, ORY_POST_LOGOUT_PATH } from './signout' // RP-initiated logout: hand Hydra the id_token so it ends its own OAuth2 session @@ -16,7 +16,7 @@ export async function completeOrySignOut(origin = BASE_URL): Promise { let idToken: string | undefined try { const cookieStore = await cookies() - const tokens = await openOrySession( + const tokens = await openSessionCookie( cookieStore.get(E2B_SESSION_COOKIE)?.value ) idToken = tokens?.idToken diff --git a/src/core/server/auth/ory/token-refresh.ts b/src/core/server/auth/ory/token-refresh.ts index 3727864a6..7869fa947 100644 --- a/src/core/server/auth/ory/token-refresh.ts +++ b/src/core/server/auth/ory/token-refresh.ts @@ -1,7 +1,7 @@ import * as oauth from 'oauth4webapi' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import { absoluteExpiry, readOryOAuthEnv } from './oauth-client' -import type { OrySessionTokens } from './session-cookie' +import type { SessionTokens } from './session-cookie' // Refresh the Hydra access token. Runs in the edge middleware, so it talks to // the token endpoint directly (no discovery, no JWKS round-trip) and parses the @@ -12,7 +12,7 @@ import type { OrySessionTokens } from './session-cookie' const REFRESH_SKEW_SECONDS = 60 export type TokenRefreshResult = - | { status: 'refreshed'; tokens: OrySessionTokens } + | { status: 'refreshed'; tokens: SessionTokens } // The refresh token is unusable (rotated out / revoked / expired). The caller // clears the cookie and re-mints from the live Kratos session. | { status: 'dead' } @@ -27,8 +27,8 @@ export function isAccessTokenExpiring( return nowSeconds >= expiresAt - REFRESH_SKEW_SECONDS } -export async function refreshOrySession( - current: OrySessionTokens +export async function refreshSessionTokens( + current: SessionTokens ): Promise { if (!current.refreshToken) return { status: 'dead' } diff --git a/src/core/server/proxy/runtime.ts b/src/core/server/proxy/runtime.ts index 8723f197d..824d1ce25 100644 --- a/src/core/server/proxy/runtime.ts +++ b/src/core/server/proxy/runtime.ts @@ -10,14 +10,14 @@ import oryConfig from '@/configs/ory' import { isKratosSessionActive } from '@/core/server/auth/ory/kratos-session-edge' import { E2B_SESSION_COOKIE, - openOrySession, - orySessionCookieDeleteOptions, - orySessionCookieOptions, - sealOrySession, + openSessionCookie, + sealSessionCookie, + sessionCookieDeleteOptions, + sessionCookieOptions, } from '@/core/server/auth/ory/session-cookie' import { isAccessTokenExpiring, - refreshOrySession, + refreshSessionTokens, } from '@/core/server/auth/ory/token-refresh' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import { getAuthRouteRedirect } from './auth-routes' @@ -121,7 +121,7 @@ const skipRefresh: SessionRefresh = { hasToken: false, persist: noPersist } async function refreshSessionCookie( request: NextRequest ): Promise { - const tokens = await openOrySession( + const tokens = await openSessionCookie( request.cookies.get(E2B_SESSION_COOKIE)?.value ) @@ -130,10 +130,10 @@ async function refreshSessionCookie( return { hasToken: true, persist: noPersist } } - const result = await refreshOrySession(tokens) + const result = await refreshSessionTokens(tokens) if (result.status === 'refreshed') { - const sealed = await sealOrySession(result.tokens) + const sealed = await sealSessionCookie(result.tokens) request.cookies.set(E2B_SESSION_COOKIE, sealed) return { hasToken: true, @@ -142,7 +142,7 @@ async function refreshSessionCookie( response.cookies.set( E2B_SESSION_COOKIE, sealed, - orySessionCookieOptions(request.nextUrl.host) + sessionCookieOptions(request.nextUrl.host) ) } return response @@ -159,7 +159,7 @@ async function refreshSessionCookie( persist: (response) => { if (response instanceof NextResponse) { response.cookies.delete( - orySessionCookieDeleteOptions(request.nextUrl.host) + sessionCookieDeleteOptions(request.nextUrl.host) ) } return response diff --git a/tests/integration/auth-ory-account-security.test.ts b/tests/integration/auth-ory-account-security.test.ts index 637bb793c..4b52e34b1 100644 --- a/tests/integration/auth-ory-account-security.test.ts +++ b/tests/integration/auth-ory-account-security.test.ts @@ -8,7 +8,7 @@ const patchIdentityMock = vi.hoisted(() => vi.fn()) const revokeOAuthSessionsMock = vi.hoisted(() => vi.fn()) const revokeKratosSessionsMock = vi.hoisted(() => vi.fn()) const revokeKratosSessionMock = vi.hoisted(() => vi.fn()) -const openOrySessionMock = vi.hoisted(() => vi.fn()) +const openSessionCookieMock = vi.hoisted(() => vi.fn()) const cookieDeleteMock = vi.hoisted(() => vi.fn()) vi.mock('@ory/nextjs/app', () => ({ @@ -29,8 +29,8 @@ vi.mock('next/headers', () => ({ vi.mock('@/core/server/auth/ory/session-cookie', () => ({ E2B_SESSION_COOKIE: 'e2b_session', - openOrySession: openOrySessionMock, - orySessionCookieDeleteOptions: (host: string | null | undefined) => ({ + openSessionCookie: openSessionCookieMock, + sessionCookieDeleteOptions: (host: string | null | undefined) => ({ name: 'e2b_session', path: '/', domain: host ? `.${host}` : undefined, @@ -103,7 +103,7 @@ describe('Ory account security (Kratos session + e2b_session)', () => { revokeOAuthSessionsMock.mockReset().mockResolvedValue(undefined) revokeKratosSessionsMock.mockReset().mockResolvedValue(undefined) revokeKratosSessionMock.mockReset().mockResolvedValue(undefined) - openOrySessionMock.mockReset().mockResolvedValue({ + openSessionCookieMock.mockReset().mockResolvedValue({ accessToken: 'hydra-access-token', idToken: 'hydra-id-token', expiresAt: 1_900_000_000, @@ -148,7 +148,7 @@ describe('Ory account security (Kratos session + e2b_session)', () => { it('returns null when the Kratos session is active but no token is present', async () => { getServerSessionMock.mockResolvedValue(kratosSession()) - openOrySessionMock.mockResolvedValue(null) + openSessionCookieMock.mockResolvedValue(null) expect(await getAuthContext()).toBeNull() }) diff --git a/tests/integration/auth-ory-callback.test.ts b/tests/integration/auth-ory-callback.test.ts index de3970dfd..de040320b 100644 --- a/tests/integration/auth-ory-callback.test.ts +++ b/tests/integration/auth-ory-callback.test.ts @@ -7,7 +7,7 @@ import { import { E2B_SESSION_COOKIE, ORY_SIGNUP_METADATA_COOKIE, - openOrySession, + openSessionCookie, } from '@/core/server/auth/ory/session-cookie' const exchangeMock = vi.hoisted(() => vi.fn()) @@ -80,7 +80,7 @@ describe('Ory OAuth callback', () => { ) const sealed = response.cookies.get(E2B_SESSION_COOKIE)?.value - expect(await openOrySession(sealed)).toEqual(tokens) + expect(await openSessionCookie(sealed)).toEqual(tokens) // The transient cookies are cleared on the way out. expect(response.cookies.get(E2B_OAUTH_FLOW_COOKIE)?.value).toBe('') diff --git a/tests/integration/auth-ory-entrypoints.test.ts b/tests/integration/auth-ory-entrypoints.test.ts index 8977c7be5..950e9925c 100644 --- a/tests/integration/auth-ory-entrypoints.test.ts +++ b/tests/integration/auth-ory-entrypoints.test.ts @@ -2,10 +2,10 @@ import { type NextFetchEvent, NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const isKratosSessionActiveMock = vi.hoisted(() => vi.fn()) -const openOrySessionMock = vi.hoisted(() => vi.fn()) +const openSessionCookieMock = vi.hoisted(() => vi.fn()) const isAccessTokenExpiringMock = vi.hoisted(() => vi.fn()) -const refreshOrySessionMock = vi.hoisted(() => vi.fn()) -const sealOrySessionMock = vi.hoisted(() => vi.fn()) +const refreshSessionTokensMock = vi.hoisted(() => vi.fn()) +const sealSessionCookieMock = vi.hoisted(() => vi.fn()) const buildAuthorizationRequestMock = vi.hoisted(() => vi.fn()) const readSignupMetadataMock = vi.hoisted(() => vi.fn()) const encodeSignupMetadataMock = vi.hoisted(() => vi.fn()) @@ -21,15 +21,15 @@ vi.mock('@/core/server/auth/ory/kratos-session-edge', () => ({ vi.mock('@/core/server/auth/ory/session-cookie', () => ({ E2B_SESSION_COOKIE: 'e2b_session', ORY_SIGNUP_METADATA_COOKIE: 'e2b-ory-signup-metadata', - openOrySession: openOrySessionMock, - sealOrySession: sealOrySessionMock, - orySessionCookieOptions: () => ({ httpOnly: true, path: '/' }), - orySessionCookieDeleteOptions: () => ({ name: 'e2b_session', path: '/' }), + openSessionCookie: openSessionCookieMock, + sealSessionCookie: sealSessionCookieMock, + sessionCookieOptions: () => ({ httpOnly: true, path: '/' }), + sessionCookieDeleteOptions: () => ({ name: 'e2b_session', path: '/' }), })) vi.mock('@/core/server/auth/ory/token-refresh', () => ({ isAccessTokenExpiring: isAccessTokenExpiringMock, - refreshOrySession: refreshOrySessionMock, + refreshSessionTokens: refreshSessionTokensMock, })) vi.mock('@/core/server/auth/ory/oauth-client', () => ({ @@ -57,9 +57,9 @@ function request(path: string): NextRequest { describe('Ory auth entrypoints — proxy gate', () => { beforeEach(() => { isKratosSessionActiveMock.mockReset().mockResolvedValue(false) - openOrySessionMock.mockReset().mockResolvedValue(null) + openSessionCookieMock.mockReset().mockResolvedValue(null) isAccessTokenExpiringMock.mockReset().mockReturnValue(false) - refreshOrySessionMock.mockReset() + refreshSessionTokensMock.mockReset() }) afterEach(() => { @@ -92,7 +92,7 @@ describe('Ory auth entrypoints — proxy gate', () => { }) it('treats a token without a live Kratos session as unauthenticated', async () => { - openOrySessionMock.mockResolvedValue({ + openSessionCookieMock.mockResolvedValue({ accessToken: 'a', expiresAt: 1_900_000_000, }) @@ -107,7 +107,7 @@ describe('Ory auth entrypoints — proxy gate', () => { }) it('redirects authenticated users away from auth pages', async () => { - openOrySessionMock.mockResolvedValue({ + openSessionCookieMock.mockResolvedValue({ accessToken: 'a', expiresAt: 1_900_000_000, }) @@ -137,15 +137,15 @@ describe('Ory auth entrypoints — middleware refresh (Pattern B)', () => { } beforeEach(() => { - openOrySessionMock.mockReset().mockResolvedValue(expiring) + openSessionCookieMock.mockReset().mockResolvedValue(expiring) isKratosSessionActiveMock.mockReset().mockResolvedValue(true) isAccessTokenExpiringMock.mockReset().mockReturnValue(true) - refreshOrySessionMock.mockReset() - sealOrySessionMock.mockReset().mockResolvedValue('sealed-new') + refreshSessionTokensMock.mockReset() + sealSessionCookieMock.mockReset().mockResolvedValue('sealed-new') }) it('refreshes an expiring token and persists it on the response', async () => { - refreshOrySessionMock.mockResolvedValue({ + refreshSessionTokensMock.mockResolvedValue({ status: 'refreshed', tokens: { accessToken: 'new-access', expiresAt: 2_000 }, }) @@ -155,14 +155,14 @@ describe('Ory auth entrypoints — middleware refresh (Pattern B)', () => { {} as NextFetchEvent ) - expect(refreshOrySessionMock).toHaveBeenCalledWith(expiring) + expect(refreshSessionTokensMock).toHaveBeenCalledWith(expiring) expect(response.cookies.get('e2b_session')?.value).toBe('sealed-new') // Authenticated dashboard request is served (not redirected away). expect(response.headers.get('location')).toBeNull() }) it('clears the cookie and redirects when the refresh is dead', async () => { - refreshOrySessionMock.mockResolvedValue({ status: 'dead' }) + refreshSessionTokensMock.mockResolvedValue({ status: 'dead' }) const response = await proxy( request('/dashboard/acme/sandboxes'), @@ -183,8 +183,8 @@ describe('Ory auth entrypoints — middleware refresh (Pattern B)', () => { {} as NextFetchEvent ) - expect(refreshOrySessionMock).not.toHaveBeenCalled() - expect(sealOrySessionMock).not.toHaveBeenCalled() + expect(refreshSessionTokensMock).not.toHaveBeenCalled() + expect(sealSessionCookieMock).not.toHaveBeenCalled() expect(response.cookies.get('e2b_session')).toBeUndefined() }) @@ -192,15 +192,15 @@ describe('Ory auth entrypoints — middleware refresh (Pattern B)', () => { // A dead refresh would otherwise delete e2b_session out of the propagated // request before the sign-out handler reads the id_token from it, dropping // RP-initiated logout so Kratos/Hydra never end the session. - refreshOrySessionMock.mockResolvedValue({ status: 'dead' }) + refreshSessionTokensMock.mockResolvedValue({ status: 'dead' }) const response = await proxy( request('/api/auth/sign-out'), {} as NextFetchEvent ) - expect(openOrySessionMock).not.toHaveBeenCalled() - expect(refreshOrySessionMock).not.toHaveBeenCalled() + expect(openSessionCookieMock).not.toHaveBeenCalled() + expect(refreshSessionTokensMock).not.toHaveBeenCalled() expect(response.cookies.get('e2b_session')).toBeUndefined() }) }) diff --git a/tests/unit/session-cookie.test.ts b/tests/unit/session-cookie.test.ts index fd344980f..18b029eb4 100644 --- a/tests/unit/session-cookie.test.ts +++ b/tests/unit/session-cookie.test.ts @@ -1,14 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { - type OrySessionTokens, - openOrySession, - orySessionCookieDeleteOptions, - orySessionCookieOptions, + openSessionCookie, resolveSessionCookieDomain, - sealOrySession, + sealSessionCookie, + sessionCookieDeleteOptions, + sessionCookieOptions, + type SessionTokens, } from '@/core/server/auth/ory/session-cookie' -const tokens: OrySessionTokens = { +const tokens: SessionTokens = { accessToken: 'access-token', refreshToken: 'refresh-token', idToken: 'id-token', @@ -25,52 +25,52 @@ describe('e2b_session cookie', () => { }) it('round-trips all token fields through seal/open', async () => { - const sealed = await sealOrySession(tokens) + const sealed = await sealSessionCookie(tokens) expect(sealed).not.toContain('access-token') - expect(await openOrySession(sealed)).toEqual(tokens) + expect(await openSessionCookie(sealed)).toEqual(tokens) }) it('preserves a session without optional tokens', async () => { - const minimal: OrySessionTokens = { + const minimal: SessionTokens = { accessToken: 'only-access', expiresAt: 123, } - expect(await openOrySession(await sealOrySession(minimal))).toEqual(minimal) + expect(await openSessionCookie(await sealSessionCookie(minimal))).toEqual(minimal) }) it('returns null for missing or empty values', async () => { - expect(await openOrySession(undefined)).toBeNull() - expect(await openOrySession(null)).toBeNull() - expect(await openOrySession('')).toBeNull() + expect(await openSessionCookie(undefined)).toBeNull() + expect(await openSessionCookie(null)).toBeNull() + expect(await openSessionCookie('')).toBeNull() }) it('returns null for a tampered token', async () => { - const sealed = await sealOrySession(tokens) + const sealed = await sealSessionCookie(tokens) - expect(await openOrySession(`${sealed}tamper`)).toBeNull() + expect(await openSessionCookie(`${sealed}tamper`)).toBeNull() }) it('returns null when sealed under a different secret', async () => { - const sealed = await sealOrySession(tokens) + const sealed = await sealSessionCookie(tokens) vi.stubEnv('E2B_SESSION_SECRET', 'a-different-secret') - expect(await openOrySession(sealed)).toBeNull() + expect(await openSessionCookie(sealed)).toBeNull() }) it('rejects sealing without a configured secret', async () => { vi.stubEnv('E2B_SESSION_SECRET', '') - await expect(sealOrySession(tokens)).rejects.toThrow( + await expect(sealSessionCookie(tokens)).rejects.toThrow( 'E2B_SESSION_SECRET is not configured' ) }) it('marks the cookie httpOnly + lax and toggles secure on NODE_ENV', () => { vi.stubEnv('NODE_ENV', 'production') - expect(orySessionCookieOptions()).toMatchObject({ + expect(sessionCookieOptions()).toMatchObject({ httpOnly: true, sameSite: 'lax', path: '/', @@ -78,7 +78,7 @@ describe('e2b_session cookie', () => { }) vi.stubEnv('NODE_ENV', 'development') - expect(orySessionCookieOptions().secure).toBe(false) + expect(sessionCookieOptions().secure).toBe(false) }) }) @@ -124,10 +124,10 @@ describe('e2b_session cookie domain', () => { }) it('flows the resolved domain into set and delete options', () => { - expect(orySessionCookieOptions('app.e2b-staging.dev').domain).toBe( + expect(sessionCookieOptions('app.e2b-staging.dev').domain).toBe( '.e2b-staging.dev' ) - expect(orySessionCookieDeleteOptions('app.e2b-staging.dev')).toEqual({ + expect(sessionCookieDeleteOptions('app.e2b-staging.dev')).toEqual({ name: 'e2b_session', path: '/', domain: '.e2b-staging.dev', diff --git a/tests/unit/token-refresh.test.ts b/tests/unit/token-refresh.test.ts index 9f1c7ca40..aac76ad55 100644 --- a/tests/unit/token-refresh.test.ts +++ b/tests/unit/token-refresh.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { OrySessionTokens } from '@/core/server/auth/ory/session-cookie' +import type { SessionTokens } from '@/core/server/auth/ory/session-cookie' import { isAccessTokenExpiring, - refreshOrySession, + refreshSessionTokens, } from '@/core/server/auth/ory/token-refresh' vi.mock('@/core/shared/clients/logger/logger', () => ({ @@ -10,7 +10,7 @@ vi.mock('@/core/shared/clients/logger/logger', () => ({ serializeErrorForLog: vi.fn((error: unknown) => error), })) -const current: OrySessionTokens = { +const current: SessionTokens = { accessToken: 'old-access', refreshToken: 'old-refresh', idToken: 'old-id', @@ -33,7 +33,7 @@ describe('isAccessTokenExpiring', () => { }) }) -describe('refreshOrySession', () => { +describe('refreshSessionTokens', () => { beforeEach(() => { vi.stubEnv('ORY_HYDRA_PUBLIC_URL', 'https://ory.example.com') vi.stubEnv('ORY_OAUTH2_CLIENT_ID', 'dashboard-client') @@ -49,7 +49,7 @@ describe('refreshOrySession', () => { const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) - expect(await refreshOrySession({ accessToken: 'a', expiresAt: 1 })).toEqual( + expect(await refreshSessionTokens({ accessToken: 'a', expiresAt: 1 })).toEqual( { status: 'dead', } @@ -65,13 +65,13 @@ describe('refreshOrySession', () => { }) ) - expect(await refreshOrySession(current)).toEqual({ status: 'dead' }) + expect(await refreshSessionTokens(current)).toEqual({ status: 'dead' }) }) it('is unchanged on a transient server error', async () => { stubTokenResponse(new Response('upstream down', { status: 503 })) - expect(await refreshOrySession(current)).toEqual({ status: 'unchanged' }) + expect(await refreshSessionTokens(current)).toEqual({ status: 'unchanged' }) }) it('refreshes and returns rotated tokens', async () => { @@ -84,7 +84,7 @@ describe('refreshOrySession', () => { }) ) - const result = await refreshOrySession(current) + const result = await refreshSessionTokens(current) expect(result.status).toBe('refreshed') if (result.status !== 'refreshed') throw new Error('unreachable') @@ -101,7 +101,7 @@ describe('refreshOrySession', () => { Response.json({ access_token: 'new-access', expires_in: 3600 }) ) - const result = await refreshOrySession(current) + const result = await refreshSessionTokens(current) if (result.status !== 'refreshed') throw new Error('expected refreshed') expect(result.tokens.refreshToken).toBe('old-refresh') From 21386f2d9d4fadf47e3695875bebe8a2906db6c4 Mon Sep 17 00:00:00 2001 From: drankou <25752851+drankou@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:25:51 +0000 Subject: [PATCH 25/29] style: apply biome formatting --- src/app/api/auth/sign-out/route.ts | 2 +- src/core/server/auth/ory/session-cookie.ts | 4 +--- tests/unit/session-cookie.test.ts | 6 ++++-- tests/unit/token-refresh.test.ts | 10 +++++----- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/app/api/auth/sign-out/route.ts b/src/app/api/auth/sign-out/route.ts index 85c47d7fa..bf77e5612 100644 --- a/src/app/api/auth/sign-out/route.ts +++ b/src/app/api/auth/sign-out/route.ts @@ -3,8 +3,8 @@ import 'server-only' import { type NextRequest, NextResponse } from 'next/server' import { signOut } from '@/core/server/auth' import { - sessionCookieDeleteOptions, resolveSessionCookieDomain, + sessionCookieDeleteOptions, } from '@/core/server/auth/ory/session-cookie' // Ory's identity session cookie: `ory_kratos_session` self-hosted, diff --git a/src/core/server/auth/ory/session-cookie.ts b/src/core/server/auth/ory/session-cookie.ts index 46f89e035..e8c2c7539 100644 --- a/src/core/server/auth/ory/session-cookie.ts +++ b/src/core/server/auth/ory/session-cookie.ts @@ -117,9 +117,7 @@ export function resolveSessionCookieDomain( return undefined } -function parseTokens( - payload: Record -): SessionTokens | null { +function parseTokens(payload: Record): SessionTokens | null { const { accessToken, refreshToken, idToken, expiresAt } = payload if (typeof accessToken !== 'string' || typeof expiresAt !== 'number') { return null diff --git a/tests/unit/session-cookie.test.ts b/tests/unit/session-cookie.test.ts index 18b029eb4..6cf3119e5 100644 --- a/tests/unit/session-cookie.test.ts +++ b/tests/unit/session-cookie.test.ts @@ -2,10 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { openSessionCookie, resolveSessionCookieDomain, + type SessionTokens, sealSessionCookie, sessionCookieDeleteOptions, sessionCookieOptions, - type SessionTokens, } from '@/core/server/auth/ory/session-cookie' const tokens: SessionTokens = { @@ -37,7 +37,9 @@ describe('e2b_session cookie', () => { expiresAt: 123, } - expect(await openSessionCookie(await sealSessionCookie(minimal))).toEqual(minimal) + expect(await openSessionCookie(await sealSessionCookie(minimal))).toEqual( + minimal + ) }) it('returns null for missing or empty values', async () => { diff --git a/tests/unit/token-refresh.test.ts b/tests/unit/token-refresh.test.ts index aac76ad55..963eadb6d 100644 --- a/tests/unit/token-refresh.test.ts +++ b/tests/unit/token-refresh.test.ts @@ -49,11 +49,11 @@ describe('refreshSessionTokens', () => { const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) - expect(await refreshSessionTokens({ accessToken: 'a', expiresAt: 1 })).toEqual( - { - status: 'dead', - } - ) + expect( + await refreshSessionTokens({ accessToken: 'a', expiresAt: 1 }) + ).toEqual({ + status: 'dead', + }) expect(fetchMock).not.toHaveBeenCalled() }) From ff95136c4dba1d455f47d9252ee6a6185c45d300 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Tue, 23 Jun 2026 18:07:33 +0200 Subject: [PATCH 26/29] add forgot password option to login card --- src/app/(auth)/forgot-password/page.tsx | 10 ++------- src/app/login/components/custom-label.tsx | 27 +++++++++++++++++++++-- src/configs/urls.ts | 2 +- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx index 668e10e45..8760c1700 100644 --- a/src/app/(auth)/forgot-password/page.tsx +++ b/src/app/(auth)/forgot-password/page.tsx @@ -1,11 +1,5 @@ import { redirect } from 'next/navigation' -import { buildOryStartURL } from '@/core/server/auth/ory/build-start-url' -type PageProps = { - searchParams: Promise<{ returnTo?: string }> -} - -export default async function Page({ searchParams }: PageProps) { - const { returnTo } = await searchParams - redirect(buildOryStartURL('signin', returnTo)) +export default function Page() { + redirect('/recovery') } diff --git a/src/app/login/components/custom-label.tsx b/src/app/login/components/custom-label.tsx index a322aca03..d505197c8 100644 --- a/src/app/login/components/custom-label.tsx +++ b/src/app/login/components/custom-label.tsx @@ -1,10 +1,14 @@ 'use client' -import type { OryNodeLabelProps } from '@ory/elements-react' +import { FlowType } from '@ory/client-fetch' +import { type OryNodeLabelProps, useOryFlow } from '@ory/elements-react' +import Link from 'next/link' +import { AUTH_URLS } from '@/configs/urls' import { cn } from '@/lib/utils' import { Label } from '@/ui/primitives/label' export function OryLabel({ node, children, fieldError }: OryNodeLabelProps) { + const { flowType } = useOryFlow() const label = node.meta?.label?.text const messages = node.messages ?? [] const fieldErrorText = @@ -12,9 +16,28 @@ export function OryLabel({ node, children, fieldError }: OryNodeLabelProps) { ? String((fieldError as { text: unknown }).text) : undefined + const isPasswordOnLogin = + flowType === FlowType.Login && node.attributes.name === 'password' + return (

- {label && } + {label && + (isPasswordOnLogin ? ( +
+ + + Forgot password? + +
+ ) : ( + + ))} {children} {messages.map((message) => ( Date: Tue, 23 Jun 2026 20:19:54 +0200 Subject: [PATCH 27/29] improve login and reauth flow, cards style update --- src/app/api/auth/sign-out/route.ts | 37 ++--------- src/app/api/auth/switch-account/route.ts | 31 +++++++++ .../login/components/custom-card-header.tsx | 37 ++++++++++- src/app/login/components/custom-card.tsx | 47 +++++++++++++- src/app/login/components/custom-label.tsx | 17 +++-- src/app/login/components/reauth.ts | 41 ++++++++++++ src/configs/urls.ts | 1 + src/core/server/auth/index.ts | 1 + .../server/auth/ory/clear-session-cookies.ts | 32 +++++++++ src/core/server/auth/ory/session.ts | 34 ++++++---- src/core/server/auth/ory/token-revoke.ts | 56 ++++++++++++++++ tests/integration/auth-switch-account.test.ts | 65 +++++++++++++++++++ tests/unit/reauth-info.test.ts | 65 +++++++++++++++++++ 13 files changed, 413 insertions(+), 51 deletions(-) create mode 100644 src/app/api/auth/switch-account/route.ts create mode 100644 src/app/login/components/reauth.ts create mode 100644 src/core/server/auth/ory/clear-session-cookies.ts create mode 100644 src/core/server/auth/ory/token-revoke.ts create mode 100644 tests/integration/auth-switch-account.test.ts create mode 100644 tests/unit/reauth-info.test.ts diff --git a/src/app/api/auth/sign-out/route.ts b/src/app/api/auth/sign-out/route.ts index bf77e5612..fe1046134 100644 --- a/src/app/api/auth/sign-out/route.ts +++ b/src/app/api/auth/sign-out/route.ts @@ -2,44 +2,17 @@ import 'server-only' import { type NextRequest, NextResponse } from 'next/server' import { signOut } from '@/core/server/auth' -import { - resolveSessionCookieDomain, - sessionCookieDeleteOptions, -} from '@/core/server/auth/ory/session-cookie' - -// Ory's identity session cookie: `ory_kratos_session` self-hosted, -// `ory_session_` on Ory Network. Deliberately excludes Hydra's -// `ory_hydra_session*` cookie, which the RP-logout redirect ends, not us. -const ORY_IDENTITY_SESSION_COOKIE = /^ory_(kratos_)?session/ +import { clearAppSessionCookies } from '@/core/server/auth/ory/clear-session-cookies' // Sign-out is a plain route handler the client hard-navigates to, so the logout -// overlay stays up until the document unloads. signOut() revokes the Kratos -// identity session server-side and the redirect ends Hydra's OAuth2 session; we -// drop the browser cookies here — e2b_session (our token cache) and the Ory -// identity cookie, which Hydra's logout leaves in place. +// overlay stays up until the document unloads. signOut() revokes the OAuth +// tokens and the Kratos identity session server-side, and the redirect ends +// Hydra's OAuth2 session; we drop the browser cookies here. export async function GET(request: NextRequest) { const { redirectTo } = await signOut({ origin: request.nextUrl.origin }) const response = NextResponse.redirect( new URL(redirectTo, request.nextUrl.origin) ) - response.cookies.delete(sessionCookieDeleteOptions(request.nextUrl.host)) - clearOryIdentitySessionCookies(request, response) + clearAppSessionCookies(request, response) return response } - -// The cookie name and scope depend on the deployment, so clear whatever Ory -// identity cookie the browser actually sent, scoped the same way it was issued: -// parent-domain on Ory Network (how the @ory/nextjs proxy sets it, e.g. -// `.e2b-staging.dev`), host-only otherwise. -function clearOryIdentitySessionCookies( - request: NextRequest, - response: NextResponse -): void { - const domain = resolveSessionCookieDomain(request.nextUrl.host) - for (const { name } of request.cookies.getAll()) { - if (!ORY_IDENTITY_SESSION_COOKIE.test(name)) continue - response.cookies.delete( - domain ? { name, path: '/', domain } : { name, path: '/' } - ) - } -} diff --git a/src/app/api/auth/switch-account/route.ts b/src/app/api/auth/switch-account/route.ts new file mode 100644 index 000000000..ada505883 --- /dev/null +++ b/src/app/api/auth/switch-account/route.ts @@ -0,0 +1,31 @@ +import 'server-only' + +import { type NextRequest, NextResponse } from 'next/server' +import { revokeCurrentSession } from '@/core/server/auth' +import { + buildOryStartURL, + normalizeOryReturnTo, +} from '@/core/server/auth/ory/build-start-url' +import { clearAppSessionCookies } from '@/core/server/auth/ory/clear-session-cookies' + +// "Use a different account" from the reauth (refresh) login screen. Unlike +// sign-out, this does not bounce through Hydra's RP-logout: with login accepted +// remember=false Hydra holds no session, so the only thing pinning the last +// identity is the Kratos session. We revoke the current session (tokens + +// Kratos), clear the cookies, and start a fresh sign-in — Kratos then has no +// session and renders a normal login form, letting the user authenticate as +// anyone. The in-flight login_challenge is intentionally orphaned; the start +// route mints a fresh one. +export async function GET(request: NextRequest) { + const returnTo = normalizeOryReturnTo( + request.nextUrl.searchParams.get('returnTo') + ) + + await revokeCurrentSession() + + const response = NextResponse.redirect( + new URL(buildOryStartURL('signin', returnTo), request.nextUrl.origin) + ) + clearAppSessionCookies(request, response) + return response +} diff --git a/src/app/login/components/custom-card-header.tsx b/src/app/login/components/custom-card-header.tsx index 77667907d..931355f5c 100644 --- a/src/app/login/components/custom-card-header.tsx +++ b/src/app/login/components/custom-card-header.tsx @@ -2,6 +2,8 @@ import { FlowType } from '@ory/client-fetch' import { useOryFlow } from '@ory/elements-react' +import { E2BLogoSmall } from '@/ui/brand' +import { getReauthInfo, type ReauthCredential } from './reauth' const TITLE_BY_FLOW: Partial> = { [FlowType.Login]: 'Sign in', @@ -10,9 +12,38 @@ const TITLE_BY_FLOW: Partial> = { [FlowType.Verification]: 'Verify your email', } +const REAUTH_DESCRIPTION: Record = { + social: 'Confirm your identity with a social provider.', + password: 'Confirm your identity with your email and password.', +} + +const DESCRIPTION_BY_FLOW: Partial> = { + [FlowType.Login]: 'Sign in with a social provider or your email.', + [FlowType.Registration]: 'Sign up with a social provider or your email.', + [FlowType.Recovery]: 'Enter your email to recover your account.', + [FlowType.Verification]: 'Enter your email to verify your account.', +} + export function OryCardHeader() { - const { flowType } = useOryFlow() - const title = TITLE_BY_FLOW[flowType] ?? 'Sign in' + const oryFlow = useOryFlow() + const { isReauthLogin, credential } = getReauthInfo(oryFlow) + + const title = isReauthLogin + ? 'Reauthenticate' + : (TITLE_BY_FLOW[oryFlow.flowType] ?? 'Sign in') + const description = isReauthLogin + ? credential + ? REAUTH_DESCRIPTION[credential] + : null + : (DESCRIPTION_BY_FLOW[oryFlow.flowType] ?? null) - return

{title}

+ return ( +
+ +
+

{title}

+ {description &&

{description}

} +
+
+ ) } diff --git a/src/app/login/components/custom-card.tsx b/src/app/login/components/custom-card.tsx index 70d3b8af8..5908c6e03 100644 --- a/src/app/login/components/custom-card.tsx +++ b/src/app/login/components/custom-card.tsx @@ -5,6 +5,7 @@ import { useOryFlow } from '@ory/elements-react' import Link from 'next/link' import type { PropsWithChildren } from 'react' import { AUTH_URLS } from '@/configs/urls' +import { getReauthInfo } from './reauth' export function OryCard({ children }: PropsWithChildren) { return
{children}
@@ -22,9 +23,31 @@ export function OryCard({ children }: PropsWithChildren) { // makes next/link fall back to a browser navigation, keeping the redirect chain // top-level where it belongs. export function OryCardFooter() { - const { flowType } = useOryFlow() + const oryFlow = useOryFlow() + const { flowType } = oryFlow if (flowType === FlowType.Login) { + const { flow } = oryFlow + + // The reauth screen is pinned to the current identity; "Sign up" is noise + // there. Offer only the escape hatch — logging out drops the pinned session + // and lands on a clean sign-in (see the switch-account route). + if (getReauthInfo(oryFlow).isReauthLogin) { + return ( +

+ Something isn't working?{' '} + + Logout + +

+ ) + } + return (

Don't have an account?{' '} @@ -100,3 +123,25 @@ export function OryCardFooter() {

) } + +// The switch-account route 307s into the OAuth start route, so it needs a +// relative returnTo (its `normalizeOryReturnTo` rejects absolute URLs). Reduce +// the flow's stored return_to to a path without relying on `window`, so this +// stays correct during SSR of the client component. +function switchAccountHref(returnTo?: string): string { + const relative = toRelativeReturnTo(returnTo) + return relative + ? `${AUTH_URLS.SWITCH_ACCOUNT}?returnTo=${encodeURIComponent(relative)}` + : AUTH_URLS.SWITCH_ACCOUNT +} + +function toRelativeReturnTo(returnTo?: string): string | undefined { + if (!returnTo) return undefined + try { + const url = new URL(returnTo, 'http://relative.invalid') + const path = `${url.pathname}${url.search}` + return path.startsWith('/') ? path : undefined + } catch { + return undefined + } +} diff --git a/src/app/login/components/custom-label.tsx b/src/app/login/components/custom-label.tsx index d505197c8..59b07f7d4 100644 --- a/src/app/login/components/custom-label.tsx +++ b/src/app/login/components/custom-label.tsx @@ -16,13 +16,22 @@ export function OryLabel({ node, children, fieldError }: OryNodeLabelProps) { ? String((fieldError as { text: unknown }).text) : undefined - const isPasswordOnLogin = - flowType === FlowType.Login && node.attributes.name === 'password' + // identifier_first login splits the credential across steps, so the recovery + // link rides whichever field is on screen: the email on step one, the + // password on step two. + const recoverLabel = + flowType === FlowType.Login + ? node.attributes.name === 'identifier' + ? 'Recover Account' + : node.attributes.name === 'password' + ? 'Forgot password?' + : null + : null return (
{label && - (isPasswordOnLogin ? ( + (recoverLabel ? (
- Forgot password? + {recoverLabel}
) : ( diff --git a/src/app/login/components/reauth.ts b/src/app/login/components/reauth.ts new file mode 100644 index 000000000..e79f4cac0 --- /dev/null +++ b/src/app/login/components/reauth.ts @@ -0,0 +1,41 @@ +import { + AuthenticatorAssuranceLevel, + FlowType, + UiNodeGroupEnum, +} from '@ory/client-fetch' +import type { FlowContextValue } from '@ory/elements-react' + +export type ReauthCredential = 'social' | 'password' + +// A refresh flow (or an AAL2 step-up) is the reauth screen: Kratos pins it to +// the current identity and renders only the credentials that identity owns. We +// read the credential off the flow's node groups so the header can tell the +// user how to confirm — password takes precedence when both are present. +export function getReauthInfo(oryFlow: FlowContextValue): { + isReauthLogin: boolean + credential: ReauthCredential | null +} { + if (oryFlow.flowType !== FlowType.Login) { + return { isReauthLogin: false, credential: null } + } + + const { flow } = oryFlow + const isReauthLogin = + flow.refresh === true || + flow.requested_aal === AuthenticatorAssuranceLevel.Aal2 + + if (!isReauthLogin) { + return { isReauthLogin: false, credential: null } + } + + const groups = new Set(flow.ui.nodes.map((node) => node.group)) + const credential: ReauthCredential | null = groups.has( + UiNodeGroupEnum.Password + ) + ? 'password' + : groups.has(UiNodeGroupEnum.Oidc) + ? 'social' + : null + + return { isReauthLogin, credential } +} diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 8c3471c14..d0f85c608 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -2,6 +2,7 @@ export const AUTH_URLS = { FORGOT_PASSWORD: '/recovery', SIGN_IN: '/sign-in', SIGN_UP: '/sign-up', + SWITCH_ACCOUNT: '/api/auth/switch-account', CLI: '/auth/cli', } diff --git a/src/core/server/auth/index.ts b/src/core/server/auth/index.ts index 41d79c87a..c1576bdd1 100644 --- a/src/core/server/auth/index.ts +++ b/src/core/server/auth/index.ts @@ -5,6 +5,7 @@ export { getAuthContext, getUserProfile, handleCredentialChangeSuccess, + revokeCurrentSession, signOut, startReauthForAccountSettings, updateUser, diff --git a/src/core/server/auth/ory/clear-session-cookies.ts b/src/core/server/auth/ory/clear-session-cookies.ts new file mode 100644 index 000000000..e1f7d7bbc --- /dev/null +++ b/src/core/server/auth/ory/clear-session-cookies.ts @@ -0,0 +1,32 @@ +import 'server-only' + +import type { NextRequest, NextResponse } from 'next/server' +import { + resolveSessionCookieDomain, + sessionCookieDeleteOptions, +} from './session-cookie' + +// Ory's identity session cookie: `ory_kratos_session` self-hosted, +// `ory_session_` on Ory Network. Deliberately excludes Hydra's +// `ory_hydra_session*` cookie, which the RP-logout redirect ends, not us. +const ORY_IDENTITY_SESSION_COOKIE = /^ory_(kratos_)?session/ + +// Drops the browser-side session cookies on a logout response: e2b_session (our +// token cache) and the Ory identity cookie. The cookie name and scope depend on +// the deployment, so we clear whatever Ory identity cookie the browser actually +// sent, scoped the same way it was issued: parent-domain on Ory Network (how the +// @ory/nextjs proxy sets it, e.g. `.e2b-staging.dev`), host-only otherwise. +export function clearAppSessionCookies( + request: NextRequest, + response: NextResponse +): void { + response.cookies.delete(sessionCookieDeleteOptions(request.nextUrl.host)) + + const domain = resolveSessionCookieDomain(request.nextUrl.host) + for (const { name } of request.cookies.getAll()) { + if (!ORY_IDENTITY_SESSION_COOKIE.test(name)) continue + response.cookies.delete( + domain ? { name, path: '/', domain } : { name, path: '/' } + ) + } +} diff --git a/src/core/server/auth/ory/session.ts b/src/core/server/auth/ory/session.ts index 5d35fa88b..476f49a06 100644 --- a/src/core/server/auth/ory/session.ts +++ b/src/core/server/auth/ory/session.ts @@ -33,6 +33,7 @@ import { sessionCookieDeleteOptions, } from './session-cookie' import { completeOrySignOut } from './signout-flow' +import { revokeSessionTokens } from './token-revoke' const ACCOUNT_SETTINGS_REAUTH_RETURN_TO = `${PROTECTED_URLS.ACCOUNT_SETTINGS}?reauth=1` @@ -84,21 +85,32 @@ export async function getUserProfile(): Promise { return identity ? fromOryIdentity(identity) : null } -export async function signOut( - options?: SignOutOptions -): Promise { - // Hydra's RP-initiated logout only runs the /logout -> Kratos bridge when - // Hydra still holds an active authentication session. In production the login - // is accepted with remember=false (non-persistent sessions), so Hydra - // short-circuits to post_logout_redirect_uri and the bridge never fires, - // leaving the Kratos identity session alive. Revoke just this session - // server-side here so sign-out is deterministic across environments without - // signing the user out of their other devices; the redirect below still ends - // Hydra's OAuth2 session. +// Tears down the current login: revokes the OAuth tokens and the Kratos +// identity session for this device. Hydra's RP-initiated logout only runs the +// /logout -> Kratos bridge when Hydra still holds an active authentication +// session; in production the login is accepted with remember=false, so Hydra +// short-circuits and the bridge never fires — leaving both the refresh token +// and the Kratos session alive (the latter surfaces "Reauthenticate as "). Revoking here makes teardown deterministic across environments +// without signing the user out of their other devices. +export async function revokeCurrentSession(): Promise { + const tokens = await openSessionCookie( + (await cookies()).get(E2B_SESSION_COOKIE)?.value + ) + if (tokens) { + await revokeSessionTokens(tokens) + } + const sessionId = (await readKratosSession())?.id if (sessionId) { await revokeKratosSession(sessionId) } +} + +export async function signOut( + options?: SignOutOptions +): Promise { + await revokeCurrentSession() return { redirectTo: await completeOrySignOut(options?.origin), diff --git a/src/core/server/auth/ory/token-revoke.ts b/src/core/server/auth/ory/token-revoke.ts new file mode 100644 index 000000000..f169be988 --- /dev/null +++ b/src/core/server/auth/ory/token-revoke.ts @@ -0,0 +1,56 @@ +import * as oauth from 'oauth4webapi' +import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' +import { readOryOAuthEnv } from './oauth-client' +import type { SessionTokens } from './session-cookie' + +// Best-effort RFC 7009 revocation against Hydra's public /oauth2/revoke. We +// revoke the refresh token only: it is the durable credential (offline access), +// and revoking it at Hydra also invalidates the access token minted from that +// grant. Access tokens are short-lived bearer tokens — not sessions — so there +// is nothing to gain from revoking them separately. Failures are logged and +// swallowed: sign-out must never hinge on the revoke succeeding. +export async function revokeSessionTokens( + tokens: SessionTokens +): Promise { + if (!tokens.refreshToken) return + + let env: ReturnType + try { + env = readOryOAuthEnv() + } catch (error) { + l.error( + { + key: 'auth_provider:revoke_token:misconfigured', + error: serializeErrorForLog(error), + }, + 'Ory token revoke cannot run because the OAuth client env is missing' + ) + return + } + + const as: oauth.AuthorizationServer = { + issuer: env.issuer.href, + revocation_endpoint: `${env.issuer.href.replace(/\/$/, '')}/oauth2/revoke`, + } + const client: oauth.Client = { client_id: env.clientId } + const clientAuth = oauth.ClientSecretBasic(env.clientSecret) + + try { + const response = await oauth.revocationRequest( + as, + client, + clientAuth, + tokens.refreshToken, + env.insecure ? { [oauth.allowInsecureRequests]: true } : undefined + ) + await oauth.processRevocationResponse(response) + } catch (error) { + l.warn( + { + key: 'auth_provider:revoke_token:failed', + error: serializeErrorForLog(error), + }, + 'Ory refresh token revocation failed' + ) + } +} diff --git a/tests/integration/auth-switch-account.test.ts b/tests/integration/auth-switch-account.test.ts new file mode 100644 index 000000000..88f049764 --- /dev/null +++ b/tests/integration/auth-switch-account.test.ts @@ -0,0 +1,65 @@ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const revokeCurrentSessionMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/core/server/auth', () => ({ + revokeCurrentSession: revokeCurrentSessionMock, +})) + +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/switch-account/route') + +function switchRequest({ + returnTo, + cookie = 'e2b_session=tokencache; ory_kratos_session=session-token', +}: { returnTo?: string | null; cookie?: string } = {}): NextRequest { + const url = new URL('https://app.e2b.dev/api/auth/switch-account') + if (returnTo != null) url.searchParams.set('returnTo', returnTo) + return new NextRequest(url, { headers: { cookie } }) +} + +describe('switch-account route', () => { + beforeEach(() => { + revokeCurrentSessionMock.mockReset().mockResolvedValue(undefined) + }) + + it('revokes the current session and starts a fresh sign-in with returnTo', async () => { + const response = await GET(switchRequest({ returnTo: '/dashboard/keys' })) + + expect(revokeCurrentSessionMock).toHaveBeenCalledOnce() + + const location = response.headers.get('location') ?? '' + expect(location).toContain('/api/auth/oauth/start') + expect(location).toContain('intent=signin') + expect(location).toContain('returnTo=%2Fdashboard%2Fkeys') + }) + + it('clears the e2b_session and Ory identity cookies on the redirect', async () => { + const response = await GET(switchRequest()) + + expect(response.cookies.get('e2b_session')?.value).toBe('') + expect(response.cookies.get('ory_kratos_session')?.value).toBe('') + }) + + it('drops an absolute returnTo (open-redirect guard)', async () => { + const response = await GET( + switchRequest({ returnTo: 'https://evil.example.com/phish' }) + ) + + const location = response.headers.get('location') ?? '' + expect(location).toContain('intent=signin') + expect(location).not.toContain('returnTo=') + }) + + it('still completes when there is no Kratos session left to clear', async () => { + const response = await GET(switchRequest({ cookie: '' })) + + expect(revokeCurrentSessionMock).toHaveBeenCalledOnce() + expect(response.headers.get('location')).toContain('intent=signin') + }) +}) diff --git a/tests/unit/reauth-info.test.ts b/tests/unit/reauth-info.test.ts new file mode 100644 index 000000000..28df11ce5 --- /dev/null +++ b/tests/unit/reauth-info.test.ts @@ -0,0 +1,65 @@ +import { FlowType } from '@ory/client-fetch' +import type { FlowContextValue } from '@ory/elements-react' +import { describe, expect, it } from 'vitest' +import { getReauthInfo } from '@/app/login/components/reauth' + +function loginFlow({ + refresh, + requestedAal, + groups = [], +}: { + refresh?: boolean + requestedAal?: string + groups?: string[] +}): FlowContextValue { + return { + flowType: FlowType.Login, + flow: { + refresh, + requested_aal: requestedAal, + ui: { nodes: groups.map((group) => ({ group })) }, + }, + } as unknown as FlowContextValue +} + +describe('getReauthInfo', () => { + it('is not a reauth on a normal login flow', () => { + expect(getReauthInfo(loginFlow({ groups: ['password'] }))).toEqual({ + isReauthLogin: false, + credential: null, + }) + }) + + it('detects a refresh flow and its password credential', () => { + expect( + getReauthInfo(loginFlow({ refresh: true, groups: ['default', 'password'] })) + ).toEqual({ isReauthLogin: true, credential: 'password' }) + }) + + it('detects a social credential when only oidc nodes are present', () => { + expect( + getReauthInfo(loginFlow({ refresh: true, groups: ['default', 'oidc'] })) + ).toEqual({ isReauthLogin: true, credential: 'social' }) + }) + + it('prefers password when the identity has both', () => { + expect( + getReauthInfo( + loginFlow({ refresh: true, groups: ['password', 'oidc'] }) + ).credential + ).toBe('password') + }) + + it('treats an AAL2 step-up as reauth', () => { + const result = getReauthInfo( + loginFlow({ requestedAal: 'aal2', groups: ['totp'] }) + ) + expect(result.isReauthLogin).toBe(true) + expect(result.credential).toBeNull() + }) + + it('is not a reauth for non-login flows', () => { + const recovery = { flowType: FlowType.Recovery } as FlowContextValue + expect(getReauthInfo(recovery).isReauthLogin).toBe(false) + }) +}) From 69f97077fd3be0e1dbd6a1078e336d3f9dfabeda Mon Sep 17 00:00:00 2001 From: drankou <25752851+drankou@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:20:31 +0000 Subject: [PATCH 28/29] style: apply biome formatting --- tests/integration/auth-switch-account.test.ts | 5 ++++- tests/unit/reauth-info.test.ts | 9 +++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/integration/auth-switch-account.test.ts b/tests/integration/auth-switch-account.test.ts index 88f049764..2eefff24e 100644 --- a/tests/integration/auth-switch-account.test.ts +++ b/tests/integration/auth-switch-account.test.ts @@ -17,7 +17,10 @@ const { GET } = await import('@/app/api/auth/switch-account/route') function switchRequest({ returnTo, cookie = 'e2b_session=tokencache; ory_kratos_session=session-token', -}: { returnTo?: string | null; cookie?: string } = {}): NextRequest { +}: { + returnTo?: string | null + cookie?: string +} = {}): NextRequest { const url = new URL('https://app.e2b.dev/api/auth/switch-account') if (returnTo != null) url.searchParams.set('returnTo', returnTo) return new NextRequest(url, { headers: { cookie } }) diff --git a/tests/unit/reauth-info.test.ts b/tests/unit/reauth-info.test.ts index 28df11ce5..3660108e7 100644 --- a/tests/unit/reauth-info.test.ts +++ b/tests/unit/reauth-info.test.ts @@ -32,7 +32,9 @@ describe('getReauthInfo', () => { it('detects a refresh flow and its password credential', () => { expect( - getReauthInfo(loginFlow({ refresh: true, groups: ['default', 'password'] })) + getReauthInfo( + loginFlow({ refresh: true, groups: ['default', 'password'] }) + ) ).toEqual({ isReauthLogin: true, credential: 'password' }) }) @@ -44,9 +46,8 @@ describe('getReauthInfo', () => { it('prefers password when the identity has both', () => { expect( - getReauthInfo( - loginFlow({ refresh: true, groups: ['password', 'oidc'] }) - ).credential + getReauthInfo(loginFlow({ refresh: true, groups: ['password', 'oidc'] })) + .credential ).toBe('password') }) From 4178ff0f3c497114465faf10130d4b56e0890f47 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Tue, 23 Jun 2026 20:42:45 +0200 Subject: [PATCH 29/29] improve message padding --- src/app/login/components/custom-message.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/login/components/custom-message.tsx b/src/app/login/components/custom-message.tsx index ee79eab59..95b11454a 100644 --- a/src/app/login/components/custom-message.tsx +++ b/src/app/login/components/custom-message.tsx @@ -12,7 +12,7 @@ export function OryMessageContent({ message }: OryMessageContentProps) { return (