diff --git a/.env.example b/.env.example index d251db87e..d2f9dfdfd 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,13 @@ 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 + +### 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 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 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/(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/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..ed2f33b2f --- /dev/null +++ b/src/app/api/auth/oauth/callback/ory/route.ts @@ -0,0 +1,115 @@ +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, + 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, + sealSessionCookie, + sessionCookieOptions, +} from '@/core/server/auth/ory/session-cookie' +import { + buildOryLogoutUrl, + ORY_POST_LOGOUT_PATH, +} from '@/core/server/auth/ory/signout' +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 +// 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 = await openOryFlowState( + 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, + // 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( + { + 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 + ? await buildOryLogoutUrl({ idToken: tokens.idToken, origin }) + : null + return finalize( + NextResponse.redirect(logoutUrl ?? new URL(ORY_POST_LOGOUT_PATH, origin)) + ) + } + + const sealed = await sealSessionCookie({ + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + idToken: tokens.idToken, + expiresAt: tokens.expiresAt, + }) + + // 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, + sealed, + sessionCookieOptions(request.nextUrl.host) + ) + 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/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/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/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 e504f1c10..1bd7462c9 100644 --- a/src/app/api/auth/oauth/start/route.ts +++ b/src/app/api/auth/oauth/start/route.ts @@ -1,34 +1,107 @@ -import { NextResponse } from 'next/server' -import { signIn } from '@/auth' +import { type NextRequest, NextResponse } from 'next/server' 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, + 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, 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, relayTarget } = resolveOryRedirectUri(origin) + + let authorization: Awaited> + try { + // 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( + { + key: 'oauth_start:authorization_request_failed', + error: serializeErrorForLog(error), + }, + 'failed to build the Ory authorization request' + ) + return NextResponse.redirect( + new URL(`${ORY_RECOVER_PATH}?error=oauth_start_failed`, origin) + ) + } + + 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(`${ORY_RECOVER_PATH}?error=oauth_start_failed`, origin) + ) + } + + const response = NextResponse.redirect(authorization.url) + + response.cookies.set( + E2B_OAUTH_FLOW_COOKIE, + sealedFlow, + 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..fe1046134 100644 --- a/src/app/api/auth/sign-out/route.ts +++ b/src/app/api/auth/sign-out/route.ts @@ -2,16 +2,17 @@ import 'server-only' import { type NextRequest, NextResponse } from 'next/server' import { signOut } from '@/core/server/auth' +import { clearAppSessionCookies } from '@/core/server/auth/ory/clear-session-cookies' -// 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 the client hard-navigates to, so the logout +// 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 }) - return NextResponse.redirect(new URL(redirectTo, request.nextUrl.origin)) + const response = NextResponse.redirect( + new URL(redirectTo, request.nextUrl.origin) + ) + clearAppSessionCookies(request, response) + return response } 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/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..1c26fcab8 --- /dev/null +++ b/src/app/consent/route.ts @@ -0,0 +1,105 @@ +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 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' + +// 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 { 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 + ? 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 Partial + const claims: Record = {} + + if (grantScope.includes('email') && traits.email) { + claims.email = traits.email + } + + if (grantScope.includes('profile')) { + const name = traits.name?.trim() + if (name) claims.name = name + } + + return claims +} 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 23ad74264..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}
@@ -14,14 +15,48 @@ 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 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() + 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?{' '} - + Sign up . @@ -35,7 +70,12 @@ export function OryCardFooter() { return (

Remember your password?{' '} - + Sign in . @@ -51,7 +91,12 @@ export function OryCardFooter() {

Already have an account?{' '} - + Sign in . @@ -78,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 a322aca03..59b07f7d4 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,37 @@ export function OryLabel({ node, children, fieldError }: OryNodeLabelProps) { ? String((fieldError as { text: unknown }).text) : undefined + // 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 && } + {label && + (recoverLabel ? ( +
+ + + {recoverLabel} + +
+ ) : ( + + ))} {children} {messages.map((message) => ( node.group)) + const credential: ReauthCredential | null = groups.has( + UiNodeGroupEnum.Password + ) + ? 'password' + : groups.has(UiNodeGroupEnum.Oidc) + ? 'social' + : null + + return { isReauthLogin, credential } +} 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/configs/urls.ts b/src/configs/urls.ts index 8fad936af..d0f85c608 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -1,7 +1,8 @@ export const AUTH_URLS = { - FORGOT_PASSWORD: '/forgot-password', + 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/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/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..c1576bdd1 100644 --- a/src/core/server/auth/index.ts +++ b/src/core/server/auth/index.ts @@ -3,9 +3,9 @@ import 'server-only' export type { AuthUser } from '@/core/modules/auth/models' export { getAuthContext, - getAuthContextFromOrySession, getUserProfile, handleCredentialChangeSuccess, + revokeCurrentSession, signOut, startReauthForAccountSettings, updateUser, 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/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/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/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/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..a724db90e 100644 --- a/src/core/server/auth/ory/identity.ts +++ b/src/core/server/auth/ory/identity.ts @@ -1,22 +1,87 @@ import 'server-only' import type { Identity } from '@ory/client-fetch' -import type { Session } from 'next-auth' +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 + .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 } -// 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 { +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 +// 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 { + const traits = parseOryTraits(identity.traits, { + identityId: identity.id, + source: 'kratos_session', + }) return { - id: session.user.id, - email: session.user.email ?? null, - name: session.user.name ?? null, - avatarUrl: session.user.image ?? null, + id: requireExternalId(identity), + identityId: identity.id, + email: readString(traits, 'email'), + name: readString(traits, 'name'), + avatarUrl: readPublicPicture(identity.metadata_public), providers: [], canChangeEmail: false, canChangePassword: false, @@ -26,15 +91,14 @@ export function fromAuthSession(session: Session): AuthUser { // Rich path: build the user from a full Kratos Identity (traits + credentials). // Used wherever we've fetched the identity via the admin API — admin lookups and // the live profile query. -export function fromOryIdentity( - identity: Identity, - options: FromOryIdentityOptions = {} -): AuthUser { - const traits = (identity.traits ?? {}) as Record +export function fromOryIdentity(identity: Identity): AuthUser { + 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 @@ -43,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, @@ -100,20 +165,3 @@ function readString( const value = traits[key] return typeof value === 'string' && value.length > 0 ? value : null } - -function readDisplayName(traits: Record): string | null { - // ory's default schema nests name as { first, last } or stores it flat - const flat = readString(traits, 'name') - if (flat) return flat - - const nested = traits.name - if (nested && typeof nested === 'object') { - const obj = nested as Record - const first = readString(obj, 'first') - const last = readString(obj, 'last') - const composite = [first, last].filter(Boolean).join(' ').trim() - if (composite) return composite - } - - return null -} diff --git a/src/core/server/auth/ory/kratos-session-edge.ts b/src/core/server/auth/ory/kratos-session-edge.ts new file mode 100644 index 000000000..29671a409 --- /dev/null +++ b/src/core/server/auth/ory/kratos-session-edge.ts @@ -0,0 +1,37 @@ +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 +// 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 { + const sdkUrl = process.env.NEXT_PUBLIC_ORY_SDK_URL ?? process.env.ORY_SDK_URL + 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 { + 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 + identity?: { external_id?: string | null } + } + return session.active === true && !!session.identity?.external_id + } catch { + return false + } +} 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/oauth-client.ts b/src/core/server/auth/ory/oauth-client.ts new file mode 100644 index 000000000..a54f441ea --- /dev/null +++ b/src/core/server/auth/ory/oauth-client.ts @@ -0,0 +1,191 @@ +import * as oauth from 'oauth4webapi' +import { isLoopbackUrl } from '@/core/shared/schemas/url' +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. Restricted to loopback so a + // non-local HTTP issuer can never silently disable TLS — it fails closed. + 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:' && isLoopbackUrl(issuer.href), + } +} + +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, + options?: { state?: 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) + // 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) + 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..06e650d61 --- /dev/null +++ b/src/core/server/auth/ory/oauth-flow.ts @@ -0,0 +1,87 @@ +// 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, 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' + +// 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' + +// 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 = { + state: string + nonce: string + codeVerifier: string + returnTo?: string +} + +export type OryFlowCookieOptions = { + httpOnly: true + sameSite: 'lax' + path: '/' + secure: boolean + maxAge: number +} + +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 async function openOryFlowState( + value: string | undefined | null +): Promise { + if (!value) return null + + try { + const { payload } = await jwtDecrypt(value, await deriveKey()) + const { state, nonce, codeVerifier, returnTo } = payload + if ( + typeof state !== 'string' || + typeof nonce !== 'string' || + typeof codeVerifier !== 'string' + ) { + return null + } + + return { + state, + nonce, + codeVerifier, + returnTo: typeof returnTo === 'string' ? 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/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/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..e8c2c7539 --- /dev/null +++ b/src/core/server/auth/ory/session-cookie.ts @@ -0,0 +1,132 @@ +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 +// 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' + +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. +const SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30 + +export type SessionTokens = { + accessToken: string + refreshToken?: string + idToken?: string + // Absolute access-token expiry, epoch seconds. + expiresAt: number +} + +export type SessionCookieOptions = { + httpOnly: true + sameSite: 'lax' + path: '/' + secure: boolean + maxAge: number + domain?: string +} + +export type SessionCookieDeleteOptions = { + name: typeof E2B_SESSION_COOKIE + path: '/' + domain?: string +} + +export async function sealSessionCookie( + tokens: SessionTokens +): 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 openSessionCookie( + 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 sessionCookieOptions( + host?: string | null +): SessionCookieOptions { + 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, + 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 sessionCookieDeleteOptions( + host?: string | null +): SessionCookieDeleteOptions { + 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(payload: Record): SessionTokens | 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..476f49a06 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, headers } 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,80 +14,127 @@ 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 { revokeKratosSessionsForIdentity } from './kratos-session' +import { isKratosSessionFresh } from './freshness' +import { fromKratosSessionIdentity, fromOryIdentity } from './identity' +import { + revokeKratosSession, + revokeKratosSessionsForIdentity, +} from './kratos-session' import { revokeOryOAuthSessionsForSubject } from './oauth-session' +import { + E2B_SESSION_COOKIE, + openSessionCookie, + 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` -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 + + // 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 readSessionTokens() + 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) : null +} + +// 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), } } 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, @@ -95,15 +142,7 @@ export async function updateUser( password: input.password, }) - if (!result.ok) return result - - return { - ...result, - user: { - ...result.user, - id: session.user.id, - }, - } + return result } export async function startReauthForAccountSettings(): Promise { @@ -112,88 +151,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 clearSessionCookie() +} +// 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 readSessionTokens = cache(async () => { + const cookieStore = await cookies() + return openSessionCookie(cookieStore.get(E2B_SESSION_COOKIE)?.value) +}) -const readSession = cache(async (): Promise => { +async function clearSessionCookie(): Promise { try { - return await authjs() + const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]) + cookieStore.delete(sessionCookieDeleteOptions(headerStore.get('host'))) } 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..35a367779 100644 --- a/src/core/server/auth/ory/signout-flow.ts +++ b/src/core/server/auth/ory/signout-flow.ts @@ -1,53 +1,37 @@ 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, 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 +// 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 openSessionCookie( + 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() + 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 978cb485f..c2f647e79 100644 --- a/src/core/server/auth/ory/signout.ts +++ b/src/core/server/auth/ory/signout.ts @@ -1,32 +1,46 @@ -// 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' +import { + OAUTH_LOGOUT_RELAY_PATH, + readRelayOrigin, + sealRelayState, +} from './oauth-relay' export const ORY_POST_LOGOUT_PATH = '/' -export function buildOryLogoutUrl({ +// 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 async function buildOryLogoutUrl({ idToken, origin, }: { idToken: string origin: string -}): URL | null { - const sdkUrl = process.env.ORY_SDK_URL - if (!sdkUrl) return null +}): Promise { + const issuer = process.env.ORY_HYDRA_PUBLIC_URL ?? process.env.ORY_SDK_URL + if (!issuer) return null + + // 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 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( 'post_logout_redirect_uri', postLogoutUrl.toString() ) + if (relayState) logoutUrl.searchParams.set('state', relayState) return logoutUrl } diff --git a/src/core/server/auth/ory/signup-metadata.ts b/src/core/server/auth/ory/signup-metadata.ts index 37c36e1aa..b123f29a8 100644 --- a/src/core/server/auth/ory/signup-metadata.ts +++ b/src/core/server/auth/ory/signup-metadata.ts @@ -1,12 +1,9 @@ import 'server-only' import { createHmac, timingSafeEqual } from 'node:crypto' -import { type JsonPatch, JsonPatchOpEnum } from '@ory/client-fetch' import { cookies } from 'next/headers' -import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' -import { getOryIdentityApi } from './client' - -export const ORY_SIGNUP_METADATA_COOKIE = 'e2b-ory-signup-metadata' +import { l } from '@/core/shared/clients/logger/logger' +import { ORY_SIGNUP_METADATA_COOKIE } from './session-cookie' const SIGNUP_METADATA_COOKIE_MAX_AGE_SECONDS = 30 * 60 const MAX_IP_LENGTH = 128 @@ -17,6 +14,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 +36,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 +90,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 +147,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..7869fa947 --- /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 { 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 +// 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: 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' } + // 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 refreshSessionTokens( + current: SessionTokens +): 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/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/src/core/server/proxy/classifier.ts b/src/core/server/proxy/classifier.ts index 3dbdd46d6..15305ec35 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,10 +47,14 @@ 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' } +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 1af02e99e..824d1ce25 100644 --- a/src/core/server/proxy/runtime.ts +++ b/src/core/server/proxy/runtime.ts @@ -6,16 +6,26 @@ 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, + openSessionCookie, + sealSessionCookie, + sessionCookieDeleteOptions, + sessionCookieOptions, +} from '@/core/server/auth/ory/session-cookie' +import { + isAccessTokenExpiring, + refreshSessionTokens, +} from '@/core/server/auth/ory/token-refresh' import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger' import { getAuthRouteRedirect } from './auth-routes' import { classifyProxyRequest, + isAuthEndpointRoute, type ProxyPlan, - planNeedsAuthJsSession, + planNeedsAuthGate, } from './classifier' import { handleAuthGate, @@ -29,7 +39,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 +57,118 @@ function isOrySdkProxyPath(pathname: string): boolean { export async function runDashboardProxy( request: NextRequest, - event: NextFetchEvent + _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). 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) } const plan = classifyProxyRequest(request.nextUrl.pathname) - if (!planNeedsAuthJsSession(plan)) { - return runProxyConcerns(request, plan) + // 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. + // + // 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)) + } + + // 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 + +const skipRefresh: SessionRefresh = { hasToken: false, persist: noPersist } + +async function refreshSessionCookie( + request: NextRequest +): Promise { + const tokens = await openSessionCookie( + 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 refreshSessionTokens(tokens) + + if (result.status === 'refreshed') { + const sealed = await sealSessionCookie(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, + sessionCookieOptions(request.nextUrl.host) + ) + } + 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( + sessionCookieDeleteOptions(request.nextUrl.host) + ) + } + 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/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/src/lib/env.ts b/src/lib/env.ts index a9344787a..36477ebe2 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -12,19 +12,22 @@ 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(), 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(), @@ -76,10 +79,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 +88,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 +106,7 @@ function requireEnvVars( ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `Auth.js/Ory requires ${envVar}`, + message: `Ory requires ${envVar}`, path: [envVar], }) } @@ -133,7 +134,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..4b52e34b1 100644 --- a/tests/integration/auth-ory-account-security.test.ts +++ b/tests/integration/auth-ory-account-security.test.ts @@ -1,18 +1,40 @@ 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 revokeKratosSessionMock = vi.hoisted(() => vi.fn()) +const openSessionCookieMock = 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, + }), + headers: () => + Promise.resolve({ + get: vi.fn(() => 'app.e2b.dev'), + }), +})) + +vi.mock('@/core/server/auth/ory/session-cookie', () => ({ + E2B_SESSION_COOKIE: 'e2b_session', + openSessionCookie: openSessionCookieMock, + sessionCookieDeleteOptions: (host: string | null | undefined) => ({ + name: 'e2b_session', + path: '/', + domain: host ? `.${host}` : undefined, + }), })) vi.mock('@/core/server/auth/ory/client', () => ({ @@ -29,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', () => ({ @@ -36,34 +59,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 +72,90 @@ const currentIdentity = { metadata_admin: { admin: true }, } satisfies Partial -describe('Ory account security', () => { +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: { + id: identityId, + external_id: 'e2b-user-id', + 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) + revokeKratosSessionMock.mockReset().mockResolvedValue(undefined) + openSessionCookieMock.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: 'e2b-user-id', + identityId: '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 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()) + openSessionCookieMock.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 +165,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 +178,55 @@ 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: 'e2b-user-id', identityId: '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({ + name: 'e2b_session', + path: '/', + domain: '.app.e2b.dev', + }) + }) + + 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( + '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=') + // 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-callback.test.ts b/tests/integration/auth-ory-callback.test.ts new file mode 100644 index 000000000..de040320b --- /dev/null +++ b/tests/integration/auth-ory-callback.test.ts @@ -0,0 +1,129 @@ +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + E2B_OAUTH_FLOW_COOKIE, + sealOryFlowState, +} from '@/core/server/auth/ory/oauth-flow' +import { + E2B_SESSION_COOKIE, + ORY_SIGNUP_METADATA_COOKIE, + openSessionCookie, +} from '@/core/server/auth/ory/session-cookie' + +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, +} + +async function callbackRequest({ + withFlow = true, + returnTo, +}: { + withFlow?: boolean + returnTo?: string +} = {}): Promise { + const headers: Record = {} + if (withFlow) { + const flow = await sealOryFlowState({ + 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( + await 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 openSessionCookie(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(await 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(await 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(await 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(await 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..68a73f931 --- /dev/null +++ b/tests/integration/auth-ory-consent.test.ts @@ -0,0 +1,108 @@ +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({ + client: { client_id: 'test-ory-client-id' }, + 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: 'Local 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({ + client: { client_id: 'test-ory-client-id' }, + 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..950e9925c 100644 --- a/tests/integration/auth-ory-entrypoints.test.ts +++ b/tests/integration/auth-ory-entrypoints.test.ts @@ -1,41 +1,49 @@ 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 openSessionCookieMock = vi.hoisted(() => vi.fn()) +const isAccessTokenExpiringMock = 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 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', + ORY_SIGNUP_METADATA_COOKIE: 'e2b-ory-signup-metadata', + openSessionCookie: openSessionCookieMock, + sealSessionCookie: sealSessionCookieMock, + sessionCookieOptions: () => ({ httpOnly: true, path: '/' }), + sessionCookieDeleteOptions: () => ({ name: 'e2b_session', path: '/' }), +})) + +vi.mock('@/core/server/auth/ory/token-refresh', () => ({ + isAccessTokenExpiring: isAccessTokenExpiringMock, + refreshSessionTokens: refreshSessionTokensMock, +})) + +vi.mock('@/core/server/auth/ory/oauth-client', () => ({ + buildOryAuthorizationRequest: buildAuthorizationRequestMock, })) vi.mock('@/core/server/auth/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 +54,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) + openSessionCookieMock.mockReset().mockResolvedValue(null) + isAccessTokenExpiringMock.mockReset().mockReturnValue(false) + refreshSessionTokensMock.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 +81,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) + 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 () => { + openSessionCookieMock.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(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' }) + openSessionCookieMock.mockResolvedValue({ + accessToken: 'a', + expiresAt: 1_900_000_000, + }) + isKratosSessionActiveMock.mockResolvedValue(true) const response = await proxy(request('/sign-in'), {} as NextFetchEvent) @@ -119,56 +120,142 @@ 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, + } + + beforeEach(() => { + openSessionCookieMock.mockReset().mockResolvedValue(expiring) + isKratosSessionActiveMock.mockReset().mockResolvedValue(true) + isAccessTokenExpiringMock.mockReset().mockReturnValue(true) + refreshSessionTokensMock.mockReset() + sealSessionCookieMock.mockReset().mockResolvedValue('sealed-new') + }) + + it('refreshes an expiring token and persists it on the response', async () => { + refreshSessionTokensMock.mockResolvedValue({ + status: 'refreshed', + tokens: { accessToken: 'new-access', expiresAt: 2_000 }, + }) - expect(authMiddlewareMock).not.toHaveBeenCalled() + const response = await proxy( + request('/dashboard/acme/sandboxes'), + {} as NextFetchEvent + ) + + 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('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('clears the cookie and redirects when the refresh is dead', async () => { + refreshSessionTokensMock.mockResolvedValue({ status: 'dead' }) + + const response = await proxy( + request('/dashboard/acme/sandboxes'), + {} as NextFetchEvent ) - expect(signInMock).toHaveBeenLastCalledWith( - 'ory', - { redirectTo: '/dashboard' }, - undefined + + 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).not.toHaveBeenCalled() - await oauthStartGET( - new Request( - 'https://app.e2b.dev/api/auth/oauth/start?intent=signup&returnTo=%2Fdashboard' - ) + expect(refreshSessionTokensMock).not.toHaveBeenCalled() + expect(sealSessionCookieMock).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. + refreshSessionTokensMock.mockResolvedValue({ status: 'dead' }) + + const response = await proxy( + request('/api/auth/sign-out'), + {} as NextFetchEvent ) - expect(readSignupMetadataMock).toHaveBeenCalled() - expect(setSignupMetadataCookieMock).toHaveBeenCalledWith({ - signup_ip: '203.0.113.10', - signup_user_agent: 'Mozilla/5.0', + + expect(openSessionCookieMock).not.toHaveBeenCalled() + expect(refreshSessionTokensMock).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(buildAuthorizationRequestMock).toHaveBeenCalledWith( + 'signin', + 'https://app.e2b.dev/api/auth/oauth/callback/ory' ) - expect(signInMock).toHaveBeenLastCalledWith( - 'ory', - { redirectTo: '/dashboard' }, - { prompt: 'login' } + 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/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) + }) +}) diff --git a/tests/integration/auth-switch-account.test.ts b/tests/integration/auth-switch-account.test.ts new file mode 100644 index 000000000..2eefff24e --- /dev/null +++ b/tests/integration/auth-switch-account.test.ts @@ -0,0 +1,68 @@ +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/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/identity-traits.test.ts b/tests/unit/identity-traits.test.ts new file mode 100644 index 000000000..2e348aded --- /dev/null +++ b/tests/unit/identity-traits.test.ts @@ -0,0 +1,160 @@ +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', external_id: 'e2b-user-1', ...overrides } +} + +function adminIdentity(overrides: { + traits?: unknown + metadata_public?: unknown +}): 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( + 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() + }) +}) diff --git a/tests/unit/kratos-session-edge.test.ts b/tests/unit/kratos-session-edge.test.ts new file mode 100644 index 000000000..34a53b798 --- /dev/null +++ b/tests/unit/kratos-session-edge.test.ts @@ -0,0 +1,135 @@ +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, identity: { external_id: 'ext-1' } }), + }) + 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('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', + 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/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) + }) +}) diff --git a/tests/unit/proxy-plan.test.ts b/tests/unit/proxy-plan.test.ts index 13f27c25a..82a0e1500 100644 --- a/tests/unit/proxy-plan.test.ts +++ b/tests/unit/proxy-plan.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { classifyProxyRequest, - planNeedsAuthJsSession, + isAuthEndpointRoute, + planNeedsAuthGate, } from '@/core/server/proxy/classifier' describe('classifyProxyRequest', () => { @@ -10,16 +11,29 @@ 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) + }) +}) + +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) }) }) diff --git a/tests/unit/reauth-info.test.ts b/tests/unit/reauth-info.test.ts new file mode 100644 index 000000000..3660108e7 --- /dev/null +++ b/tests/unit/reauth-info.test.ts @@ -0,0 +1,66 @@ +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) + }) +}) diff --git a/tests/unit/session-cookie.test.ts b/tests/unit/session-cookie.test.ts new file mode 100644 index 000000000..6cf3119e5 --- /dev/null +++ b/tests/unit/session-cookie.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + openSessionCookie, + resolveSessionCookieDomain, + type SessionTokens, + sealSessionCookie, + sessionCookieDeleteOptions, + sessionCookieOptions, +} from '@/core/server/auth/ory/session-cookie' + +const tokens: SessionTokens = { + 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 sealSessionCookie(tokens) + + expect(sealed).not.toContain('access-token') + expect(await openSessionCookie(sealed)).toEqual(tokens) + }) + + it('preserves a session without optional tokens', async () => { + const minimal: SessionTokens = { + accessToken: 'only-access', + expiresAt: 123, + } + + expect(await openSessionCookie(await sealSessionCookie(minimal))).toEqual( + minimal + ) + }) + + it('returns null for missing or empty values', async () => { + 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 sealSessionCookie(tokens) + + expect(await openSessionCookie(`${sealed}tamper`)).toBeNull() + }) + + it('returns null when sealed under a different secret', async () => { + const sealed = await sealSessionCookie(tokens) + + vi.stubEnv('E2B_SESSION_SECRET', 'a-different-secret') + + expect(await openSessionCookie(sealed)).toBeNull() + }) + + it('rejects sealing without a configured secret', async () => { + vi.stubEnv('E2B_SESSION_SECRET', '') + + 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(sessionCookieOptions()).toMatchObject({ + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: true, + }) + + vi.stubEnv('NODE_ENV', 'development') + expect(sessionCookieOptions().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(sessionCookieOptions('app.e2b-staging.dev').domain).toBe( + '.e2b-staging.dev' + ) + 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 new file mode 100644 index 000000000..963eadb6d --- /dev/null +++ b/tests/unit/token-refresh.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SessionTokens } from '@/core/server/auth/ory/session-cookie' +import { + isAccessTokenExpiring, + refreshSessionTokens, +} 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: SessionTokens = { + 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('refreshSessionTokens', () => { + 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 refreshSessionTokens({ 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 refreshSessionTokens(current)).toEqual({ status: 'dead' }) + }) + + it('is unchanged on a transient server error', async () => { + stubTokenResponse(new Response('upstream down', { status: 503 })) + + expect(await refreshSessionTokens(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 refreshSessionTokens(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 refreshSessionTokens(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/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( diff --git a/tests/unit/user-router.test.ts b/tests/unit/user-router.test.ts index 054c1a9a1..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, @@ -79,13 +80,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/], }, }, },