+ New sign-ups are temporarily paused while we migrate our
+ authentication system. Existing users can still{' '}
+
+ sign in
+
+ .
+
+
+ )
+ }
+
return (
Sign up
diff --git a/src/configs/flags.ts b/src/configs/flags.ts
index 66a0b604f..bcfd247a4 100644
--- a/src/configs/flags.ts
+++ b/src/configs/flags.ts
@@ -29,3 +29,10 @@ export const CAPTCHA_REQUIRED_SERVER =
export function isOryAuthEnabled() {
return process.env.AUTH_PROVIDER === 'ory'
}
+
+// freezes user/team membership mutations while we migrate identity stores.
+// when on: blocks new sign-ups (email/password + freshly-registered OIDC
+// identities) and rejects add-team-member requests. existing users keep
+// signing in normally.
+export const AUTH_MIGRATION_IN_PROGRESS =
+ process.env.NEXT_PUBLIC_AUTH_MIGRATION_IN_PROGRESS === '1'
diff --git a/src/core/server/actions/auth-actions.ts b/src/core/server/actions/auth-actions.ts
index 7c21e6674..ff1d2cfdf 100644
--- a/src/core/server/actions/auth-actions.ts
+++ b/src/core/server/actions/auth-actions.ts
@@ -4,7 +4,10 @@ import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { returnValidationErrors } from 'next-safe-action'
import { z } from 'zod'
-import { CAPTCHA_REQUIRED_SERVER } from '@/configs/flags'
+import {
+ AUTH_MIGRATION_IN_PROGRESS,
+ CAPTCHA_REQUIRED_SERVER,
+} from '@/configs/flags'
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
import { USER_MESSAGES } from '@/configs/user-messages'
import { actionClient } from '@/core/server/actions/client'
@@ -177,6 +180,12 @@ export const signUpAction = actionClient
async ({
parsedInput: { email, password, returnTo = '', captchaToken },
}) => {
+ if (AUTH_MIGRATION_IN_PROGRESS) {
+ return returnServerError(
+ 'Sign-ups are temporarily paused while we migrate our authentication system. Please try again later.'
+ )
+ }
+
const captchaError = await validateCaptcha(captchaToken)
if (captchaError) return captchaError
diff --git a/src/core/server/api/routers/teams.ts b/src/core/server/api/routers/teams.ts
index f55bbad17..7267c39ef 100644
--- a/src/core/server/api/routers/teams.ts
+++ b/src/core/server/api/routers/teams.ts
@@ -3,6 +3,7 @@ import { fileTypeFromBuffer } from 'file-type'
import { revalidatePath } from 'next/cache'
import { after } from 'next/server'
import { z } from 'zod'
+import { AUTH_MIGRATION_IN_PROGRESS } from '@/configs/flags'
import { createKeysRepository } from '@/core/modules/keys/repository.server'
import { CreateApiKeySchema } from '@/core/modules/keys/schemas'
import {
@@ -158,6 +159,14 @@ export const teamsRouter = createTRPCRouter({
addMember: teamsRepositoryProcedure
.input(AddTeamMemberSchema)
.mutation(async ({ ctx, input }) => {
+ if (AUTH_MIGRATION_IN_PROGRESS) {
+ throw new TRPCError({
+ code: 'FORBIDDEN',
+ message:
+ 'Adding team members is temporarily paused while we migrate our authentication system. Please try again later.',
+ })
+ }
+
const result = await ctx.teamsRepository.addTeamMember(input.email)
if (!result.ok) throwTRPCErrorFromRepoError(result.error)
diff --git a/src/features/dashboard/members/members-page-content.tsx b/src/features/dashboard/members/members-page-content.tsx
index f2bf12cdb..4e9c776d8 100644
--- a/src/features/dashboard/members/members-page-content.tsx
+++ b/src/features/dashboard/members/members-page-content.tsx
@@ -2,6 +2,7 @@
import { useSuspenseQuery } from '@tanstack/react-query'
import { Suspense, useMemo, useState } from 'react'
+import { AUTH_MIGRATION_IN_PROGRESS } from '@/configs/flags'
import { useDashboard } from '@/features/dashboard/context'
import { cn } from '@/lib/utils'
import { pluralize } from '@/lib/utils/formatting'
@@ -94,7 +95,7 @@ export const MembersPageContent = ({ className }: MembersPageContentProps) => {
value={query}
/>
-
+ {!AUTH_MIGRATION_IN_PROGRESS && }
From 609663f7e10168e07496e47dca68b154aa6fab5d Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Thu, 28 May 2026 18:29:17 -0700
Subject: [PATCH 06/16] feat(auth): add Ory OAuth entry/exit route handlers
Server route handlers that drive the Ory OAuth2 flow:
- oauth-start: server-side signIn entry (sets state/PKCE cookies), mapping
intent -> prompt (registration/login) for signup and reauth.
- oauth-recover: catches Auth.js OAuth errors, logs them, and bounces to
/sign-in with a short-lived loop guard.
- signout-flow: Auth.js sign-out + Kratos session revocation (by the
resolved identityId, not the OIDC subject) + Hydra RP-initiated logout.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
src/app/api/auth/oauth-recover/route.ts | 48 ++++++++++++
src/app/api/auth/oauth-start/route.ts | 30 ++++++++
src/app/api/auth/oauth/signout-flow/route.ts | 23 +++++-
tests/unit/signout-flow.test.ts | 77 ++++++++++++++++++++
4 files changed, 174 insertions(+), 4 deletions(-)
create mode 100644 src/app/api/auth/oauth-recover/route.ts
create mode 100644 src/app/api/auth/oauth-start/route.ts
create mode 100644 tests/unit/signout-flow.test.ts
diff --git a/src/app/api/auth/oauth-recover/route.ts b/src/app/api/auth/oauth-recover/route.ts
new file mode 100644
index 000000000..bb12c75b5
--- /dev/null
+++ b/src/app/api/auth/oauth-recover/route.ts
@@ -0,0 +1,48 @@
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import { AUTH_URLS } from '@/configs/urls'
+import { l } from '@/core/shared/clients/logger/logger'
+
+// Auth.js renders its built-in `${basePath}/error` page when something fails
+// during the OAuth dance (most commonly a stale state/PKCE/nonce cookie that
+// expired while the user lingered on the Ory hosted UI). We point
+// `pages.error` here so the user never sees that page - we log the failure
+// for observability and bounce them back to /sign-in, which restarts the
+// flow with fresh cookies via the middleware -> oauth-start chain.
+//
+// A short-lived cookie prevents tight loops when the underlying failure is
+// genuinely persistent (e.g. ORY_SDK_URL misconfigured). After one recovery
+// attempt in the window, subsequent failures fall back to the marketing
+// root so the user isn't bounced indefinitely.
+const RECOVERY_COOKIE = 'auth_recover_attempted'
+const RECOVERY_COOKIE_MAX_AGE_SECONDS = 30
+
+export async function GET(request: NextRequest) {
+ const errorCode = request.nextUrl.searchParams.get('error') ?? 'unknown'
+ const alreadyAttempted = request.cookies.get(RECOVERY_COOKIE)?.value === '1'
+
+ l.error(
+ {
+ key: 'oauth_recover:auth_js_error',
+ context: { error_code: errorCode, already_attempted: alreadyAttempted },
+ },
+ 'Auth.js OAuth flow failed; recovering user'
+ )
+
+ const destination = alreadyAttempted ? '/' : AUTH_URLS.SIGN_IN
+ const response = NextResponse.redirect(new URL(destination, request.url))
+
+ if (alreadyAttempted) {
+ response.cookies.delete(RECOVERY_COOKIE)
+ } else {
+ response.cookies.set(RECOVERY_COOKIE, '1', {
+ maxAge: RECOVERY_COOKIE_MAX_AGE_SECONDS,
+ httpOnly: true,
+ sameSite: 'lax',
+ path: '/',
+ secure: process.env.NODE_ENV === 'production',
+ })
+ }
+
+ return response
+}
diff --git a/src/app/api/auth/oauth-start/route.ts b/src/app/api/auth/oauth-start/route.ts
new file mode 100644
index 000000000..a25e3beeb
--- /dev/null
+++ b/src/app/api/auth/oauth-start/route.ts
@@ -0,0 +1,30 @@
+import { signIn } from '@/auth'
+
+// Server-side entry point for the Ory OAuth2 flow. Pages redirect here
+// instead of rendering a client-side form so that Auth.js can set its
+// state/PKCE cookies (only allowed in route handlers / server actions
+// / middleware) without any client JS in the loop.
+//
+// `intent=signup` forwards `prompt=registration` to Hydra, which routes
+// to its registration UI (`urls.registration`, default `/ui/registration`)
+// instead of the login UI.
+//
+// `intent=reauth` forwards `prompt=login`, forcing Hydra to redo the login
+// flow even with an active session so we get a fresh `auth_time`. Used to
+// re-authenticate before sensitive account changes (password).
+// https://www.ory.com/docs/oauth2-oidc/authorization-code-flow
+export async function GET(request: Request) {
+ const url = new URL(request.url)
+ const intent = url.searchParams.get('intent')
+ const returnTo = url.searchParams.get('returnTo')
+ const redirectTo = returnTo && returnTo.length > 0 ? returnTo : '/dashboard'
+
+ const authorizationParams =
+ intent === 'signup'
+ ? { prompt: 'registration' }
+ : intent === 'reauth'
+ ? { prompt: 'login' }
+ : undefined
+
+ await signIn('ory', { redirectTo }, authorizationParams)
+}
diff --git a/src/app/api/auth/oauth/signout-flow/route.ts b/src/app/api/auth/oauth/signout-flow/route.ts
index bf2dd055d..b9a994279 100644
--- a/src/app/api/auth/oauth/signout-flow/route.ts
+++ b/src/app/api/auth/oauth/signout-flow/route.ts
@@ -3,17 +3,25 @@ import 'server-only'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { auth, signOut } from '@/auth'
-import { AUTH_URLS } from '@/configs/urls'
+import { revokeKratosSessionsForIdentity } from '@/core/server/auth/ory/kratos-session'
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
+// lands users on the marketing root instead of /sign-in so they don't get
+// bounced straight back to the Ory-hosted login UI after signing out.
+const ORY_POST_LOGOUT_PATH = '/'
+
export async function GET(request: NextRequest) {
const origin = request.nextUrl.origin
- const signInUrl = new URL(AUTH_URLS.SIGN_IN, origin)
+ const postLogoutUrl = new URL(ORY_POST_LOGOUT_PATH, origin)
let idToken: string | undefined
+ let identityId: string | undefined
try {
const session = await auth()
idToken = session?.idToken
+ // The Kratos identity id resolved at sign-in — NOT the OIDC subject (which
+ // is the E2B user id) — so we revoke the right identity's Kratos sessions.
+ identityId = session?.identityId
} catch (error) {
l.warn(
{
@@ -36,16 +44,23 @@ export async function GET(request: NextRequest) {
)
}
+ if (identityId) {
+ await revokeKratosSessionsForIdentity(identityId)
+ }
+
const sdkUrl = process.env.ORY_SDK_URL
if (!idToken || !sdkUrl) {
- return NextResponse.redirect(signInUrl)
+ return NextResponse.redirect(postLogoutUrl)
}
const hydraLogout = new URL(
`${sdkUrl.replace(/\/$/, '')}/oauth2/sessions/logout`
)
hydraLogout.searchParams.set('id_token_hint', idToken)
- hydraLogout.searchParams.set('post_logout_redirect_uri', signInUrl.toString())
+ hydraLogout.searchParams.set(
+ 'post_logout_redirect_uri',
+ postLogoutUrl.toString()
+ )
return NextResponse.redirect(hydraLogout.toString())
}
diff --git a/tests/unit/signout-flow.test.ts b/tests/unit/signout-flow.test.ts
new file mode 100644
index 000000000..fa50b306d
--- /dev/null
+++ b/tests/unit/signout-flow.test.ts
@@ -0,0 +1,77 @@
+import { NextRequest } from 'next/server'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const authMock = vi.hoisted(() => vi.fn())
+const signOutMock = vi.hoisted(() => vi.fn())
+const revokeKratosSessionsMock = vi.hoisted(() => vi.fn())
+
+vi.mock('@/auth', () => ({ auth: authMock, signOut: signOutMock }))
+
+vi.mock('@/core/server/auth/ory/kratos-session', () => ({
+ revokeKratosSessionsForIdentity: revokeKratosSessionsMock,
+}))
+
+vi.mock('@/core/shared/clients/logger/logger', () => ({
+ l: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
+ serializeErrorForLog: vi.fn((error: unknown) => error),
+}))
+
+const { GET } = await import('@/app/api/auth/oauth/signout-flow/route')
+
+function request(): NextRequest {
+ return new NextRequest('https://app.e2b.dev/api/auth/oauth/signout-flow')
+}
+
+beforeEach(() => {
+ authMock.mockReset()
+ signOutMock.mockReset().mockResolvedValue(undefined)
+ revokeKratosSessionsMock.mockReset().mockResolvedValue(undefined)
+ vi.stubEnv('ORY_SDK_URL', 'https://project.oryapis.com')
+})
+
+afterEach(() => {
+ vi.unstubAllEnvs()
+})
+
+describe('signout-flow GET', () => {
+ it('revokes Kratos sessions using the resolved identityId (not the OIDC sub)', async () => {
+ authMock.mockResolvedValue({
+ idToken: 'id.token.sig',
+ identityId: 'kratos-uuid',
+ })
+
+ await GET(request())
+
+ expect(revokeKratosSessionsMock).toHaveBeenCalledWith('kratos-uuid')
+ })
+
+ it('skips revocation when the session has no resolved identityId', async () => {
+ authMock.mockResolvedValue({ idToken: 'id.token.sig' })
+
+ await GET(request())
+
+ expect(revokeKratosSessionsMock).not.toHaveBeenCalled()
+ })
+
+ it('redirects to the Hydra logout endpoint with the id_token hint', async () => {
+ authMock.mockResolvedValue({
+ idToken: 'id.token.sig',
+ identityId: 'kratos-uuid',
+ })
+
+ const response = await GET(request())
+ const location = response.headers.get('location') ?? ''
+
+ expect(location).toContain('/oauth2/sessions/logout')
+ expect(location).toContain('id_token_hint=id.token.sig')
+ })
+
+ it('redirects to the marketing root when there is no id_token', async () => {
+ authMock.mockResolvedValue({ identityId: 'kratos-uuid' })
+
+ const response = await GET(request())
+ const location = response.headers.get('location') ?? ''
+
+ expect(location).toBe('https://app.e2b.dev/')
+ })
+})
From c0ce1f7453ff0dfeeb8fee10476d2560d49646c0 Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Thu, 28 May 2026 18:29:23 -0700
Subject: [PATCH 07/16] chore(auth): drop legacy auth actions and simplify
(auth) pages for Ory
Removes the unused ory-auth-actions module and the OryHostedAuthRedirect
component; the redirect to the Ory hosted UI now happens at the middleware
layer (getOryAuthRouteRedirect). The (auth) sign-in/sign-up/forgot-password
pages no longer branch on the Ory flag.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
src/app/(auth)/forgot-password/page.tsx | 12 +------
src/app/(auth)/sign-in/page.tsx | 12 +------
src/app/(auth)/sign-up/page.tsx | 12 +------
src/core/server/actions/ory-auth-actions.ts | 14 --------
.../auth/ory-hosted-auth-redirect.tsx | 36 -------------------
5 files changed, 3 insertions(+), 83 deletions(-)
delete mode 100644 src/core/server/actions/ory-auth-actions.ts
delete mode 100644 src/features/auth/ory-hosted-auth-redirect.tsx
diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx
index 3edf03093..4b75ef913 100644
--- a/src/app/(auth)/forgot-password/page.tsx
+++ b/src/app/(auth)/forgot-password/page.tsx
@@ -1,15 +1,5 @@
-import { isOryAuthEnabled } from '@/configs/flags'
-import { OryHostedAuthRedirect } from '@/features/auth/ory-hosted-auth-redirect'
import ForgotPassword from './forgot-password-form'
-interface PageProps {
- searchParams: Promise<{ returnTo?: string }>
-}
-
-export default async function Page({ searchParams }: PageProps) {
- if (isOryAuthEnabled()) {
- const { returnTo } = await searchParams
- return
- }
+export default function Page() {
return
}
diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx
index c33bfe8bc..30f2a3f11 100644
--- a/src/app/(auth)/sign-in/page.tsx
+++ b/src/app/(auth)/sign-in/page.tsx
@@ -1,15 +1,5 @@
-import { isOryAuthEnabled } from '@/configs/flags'
-import { OryHostedAuthRedirect } from '@/features/auth/ory-hosted-auth-redirect'
import Login from './login-form'
-interface PageProps {
- searchParams: Promise<{ returnTo?: string }>
-}
-
-export default async function Page({ searchParams }: PageProps) {
- if (isOryAuthEnabled()) {
- const { returnTo } = await searchParams
- return
- }
+export default function Page() {
return
}
diff --git a/src/app/(auth)/sign-up/page.tsx b/src/app/(auth)/sign-up/page.tsx
index cc7a0d1b9..12d998e4e 100644
--- a/src/app/(auth)/sign-up/page.tsx
+++ b/src/app/(auth)/sign-up/page.tsx
@@ -1,15 +1,5 @@
-import { isOryAuthEnabled } from '@/configs/flags'
-import { OryHostedAuthRedirect } from '@/features/auth/ory-hosted-auth-redirect'
import SignUp from './signup-form'
-interface PageProps {
- searchParams: Promise<{ returnTo?: string }>
-}
-
-export default async function Page({ searchParams }: PageProps) {
- if (isOryAuthEnabled()) {
- const { returnTo } = await searchParams
- return
- }
+export default function Page() {
return
}
diff --git a/src/core/server/actions/ory-auth-actions.ts b/src/core/server/actions/ory-auth-actions.ts
deleted file mode 100644
index ae5230c24..000000000
--- a/src/core/server/actions/ory-auth-actions.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-'use server'
-
-import { signIn } from '@/auth'
-
-// thin wrapper around Auth.js's signIn() that exists so client components
-// can submit a form to it. signIn() throws a redirect; never returns normally.
-export async function signInWithOryAction(formData: FormData) {
- const returnTo = formData.get('returnTo')
- const redirectTo =
- typeof returnTo === 'string' && returnTo.length > 0
- ? returnTo
- : '/dashboard'
- await signIn('ory', { redirectTo })
-}
diff --git a/src/features/auth/ory-hosted-auth-redirect.tsx b/src/features/auth/ory-hosted-auth-redirect.tsx
deleted file mode 100644
index 02f954199..000000000
--- a/src/features/auth/ory-hosted-auth-redirect.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-'use client'
-
-import { useEffect, useRef } from 'react'
-import { signInWithOryAction } from '@/core/server/actions/ory-auth-actions'
-
-interface OryHostedAuthRedirectProps {
- returnTo?: string
-}
-
-export function OryHostedAuthRedirect({
- returnTo,
-}: OryHostedAuthRedirectProps) {
- const formRef = useRef(null)
-
- useEffect(() => {
- formRef.current?.requestSubmit()
- }, [])
-
- return (
-
-
Redirecting…
-
- Hold on while we send you to the sign-in page.
-
-
-
- )
-}
From f3881c5627d046fdbfd06c4af891587a4b0b7181 Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Thu, 28 May 2026 18:29:31 -0700
Subject: [PATCH 08/16] refactor(proxy): split middleware into a pipeline of
concern handlers
Extracts the middleware-redirect / route-rewrite / middleware-rewrite /
auth-gate concerns into named handlers in core/server/http/proxy.ts, so
proxyCore reads as a first-non-null pipeline. Collapses the awkward
isAuthenticated threading into handleAuthGate(knownAuth) and names the
poisoned-session guard isSessionAuthenticated.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
src/core/server/http/proxy.ts | 81 +++++++++++++++++
src/proxy.ts | 140 +++++++++---------------------
tests/unit/proxy-handlers.test.ts | 118 +++++++++++++++++++++++++
3 files changed, 240 insertions(+), 99 deletions(-)
create mode 100644 tests/unit/proxy-handlers.test.ts
diff --git a/src/core/server/http/proxy.ts b/src/core/server/http/proxy.ts
index e49242d32..13cb8f9c3 100644
--- a/src/core/server/http/proxy.ts
+++ b/src/core/server/http/proxy.ts
@@ -1,7 +1,11 @@
import 'server-cli-only'
import { type NextRequest, NextResponse } from 'next/server'
+import { ALLOW_SEO_INDEXING } from '@/configs/flags'
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
+import { createAuthForProxy } from '@/core/server/auth'
+import { getMiddlewareRedirectFromPath } from '@/lib/utils/redirects'
+import { getRewriteForPath } from '@/lib/utils/rewrites'
export function isAuthRoute(pathname: string): boolean {
return (
@@ -45,3 +49,80 @@ export function getAuthRedirect(
return null
}
+
+// The handlers below are the ordered concerns the proxy runs for every request.
+// Each returns a Response when it handles the request, or null to fall through
+// to the next concern. They live here (not in static next.config matchers)
+// because they need custom headers / runtime path logic.
+
+// Redirects that require custom response headers.
+export function handleMiddlewareRedirect(
+ request: NextRequest
+): NextResponse | null {
+ const redirect = getMiddlewareRedirectFromPath(request.nextUrl.pathname)
+ if (!redirect) return null
+
+ return NextResponse.redirect(new URL(redirect.destination, request.url), {
+ status: redirect.statusCode,
+ headers: new Headers(redirect.headers),
+ })
+}
+
+// Catch-all route rewrites are resolved by the route itself, so the proxy just
+// passes them through untouched.
+export function handleRouteRewritePassthrough(
+ request: NextRequest
+): NextResponse | null {
+ const { config } = getRewriteForPath(request.nextUrl.pathname, 'route')
+ return config ? NextResponse.next({ request }) : null
+}
+
+// Rewrites the proxy performs itself (serving another origin under our domain),
+// tagging the request/response with the SEO-indexing intent.
+export function handleMiddlewareRewrite(
+ request: NextRequest
+): NextResponse | null {
+ const { config, rule } = getRewriteForPath(
+ request.nextUrl.pathname,
+ 'middleware'
+ )
+ if (!config) return null
+
+ const rewriteUrl = new URL(request.url)
+ rewriteUrl.hostname = config.domain
+ rewriteUrl.protocol = 'https'
+ rewriteUrl.port = ''
+ if (rule?.pathPreprocessor) {
+ rewriteUrl.pathname = rule.pathPreprocessor(rewriteUrl.pathname)
+ }
+
+ const requestHeaders = new Headers(request.headers)
+ if (ALLOW_SEO_INDEXING) {
+ requestHeaders.set('x-e2b-should-index', '1')
+ }
+
+ const response = NextResponse.rewrite(rewriteUrl, {
+ request: { headers: requestHeaders },
+ })
+ response.headers.set(
+ 'X-Robots-Tag',
+ ALLOW_SEO_INDEXING ? 'index, follow' : 'noindex, nofollow'
+ )
+ return response
+}
+
+// Terminal concern: gate dashboard/auth routes on authentication. `knownAuth`
+// is supplied in Ory mode (resolved by the Auth.js middleware wrapper); in
+// Supabase mode it's resolved here from the request/response cookies.
+export async function handleAuthGate(
+ request: NextRequest,
+ knownAuth?: boolean
+): Promise {
+ const response = NextResponse.next({ request })
+
+ const isAuthenticated =
+ knownAuth ??
+ !!(await createAuthForProxy(request, response).getAuthContext())
+
+ return getAuthRedirect(request, isAuthenticated) ?? response
+}
diff --git a/src/proxy.ts b/src/proxy.ts
index ede4d44f3..8662fcf0b 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -3,105 +3,32 @@ import {
type NextRequest,
NextResponse,
} from 'next/server'
+import type { Session } from 'next-auth'
import { auth as authjsMiddleware } from '@/auth'
-import { ALLOW_SEO_INDEXING, isOryAuthEnabled } from './configs/flags'
-import { createAuthForProxy } from './core/server/auth'
-import { getAuthRedirect } from './core/server/http/proxy'
+import { isOryAuthEnabled } from './configs/flags'
+import { getOryAuthRouteRedirect } from './core/server/auth/ory/auth-route-redirect'
+import {
+ handleAuthGate,
+ handleMiddlewareRedirect,
+ handleMiddlewareRewrite,
+ handleRouteRewritePassthrough,
+} from './core/server/http/proxy'
import { l, serializeErrorForLog } from './core/shared/clients/logger/logger'
-import { getMiddlewareRedirectFromPath } from './lib/utils/redirects'
-import { getRewriteForPath } from './lib/utils/rewrites'
+// Runs the proxy's ordered concerns: the first handler that returns a Response
+// wins; otherwise we fall through to the auth gate. `knownAuth` is passed in Ory
+// mode (resolved by the Auth.js middleware wrapper) and omitted in Supabase mode.
async function proxyCore(
request: NextRequest,
- resolvedIsAuthenticated?: boolean
+ knownAuth?: boolean
): Promise {
try {
- const pathname = request.nextUrl.pathname
-
- // Redirects, that require custom headers
- // NOTE: We don't handle this via config matchers, because nextjs configs need to be static
- const middlewareRedirect = getMiddlewareRedirectFromPath(
- request.nextUrl.pathname
+ return (
+ handleMiddlewareRedirect(request) ??
+ handleRouteRewritePassthrough(request) ??
+ handleMiddlewareRewrite(request) ??
+ (await handleAuthGate(request, knownAuth))
)
-
- if (middlewareRedirect) {
- const headers = new Headers(middlewareRedirect.headers)
- const url = new URL(middlewareRedirect.destination, request.url)
-
- return NextResponse.redirect(url, {
- status: middlewareRedirect.statusCode,
- headers,
- })
- }
-
- // Catch-all route rewrite paths should not be handled by middleware
- // NOTE: We don't handle this via config matchers, because nextjs configs need to be static
- const { config: routeRewriteConfig } = getRewriteForPath(pathname, 'route')
-
- if (routeRewriteConfig) {
- return NextResponse.next({
- request,
- })
- }
-
- // Check if the path should be rewritten by middleware
- const { config: middlewareRewriteConfig, rule: middlewareRewriteRule } =
- getRewriteForPath(pathname, 'middleware')
-
- if (middlewareRewriteConfig) {
- const rewriteUrl = new URL(request.url)
- rewriteUrl.hostname = middlewareRewriteConfig.domain
- rewriteUrl.protocol = 'https'
- rewriteUrl.port = ''
- if (middlewareRewriteRule?.pathPreprocessor) {
- rewriteUrl.pathname = middlewareRewriteRule.pathPreprocessor(
- rewriteUrl.pathname
- )
- }
-
- const headers = new Headers(request.headers)
-
- if (ALLOW_SEO_INDEXING) {
- headers.set('x-e2b-should-index', '1')
- }
-
- const response = NextResponse.rewrite(rewriteUrl, {
- request: {
- headers,
- },
- })
-
- if (ALLOW_SEO_INDEXING) {
- response.headers.set('X-Robots-Tag', 'index, follow')
- } else {
- response.headers.set('X-Robots-Tag', 'noindex, nofollow')
- }
-
- return response
- }
-
- const response = NextResponse.next({
- request,
- })
-
- let isAuthenticated: boolean
- if (resolvedIsAuthenticated !== undefined) {
- isAuthenticated = resolvedIsAuthenticated
- } else {
- const authContext = await createAuthForProxy(
- request,
- response
- ).getAuthContext()
- isAuthenticated = !!authContext
- }
-
- const authRedirect = getAuthRedirect(request, isAuthenticated)
-
- if (authRedirect) {
- return authRedirect
- }
-
- return response
} catch (error) {
l.error(
{
@@ -116,21 +43,36 @@ async function proxyCore(
)
// return a basic response to avoid infinite loops
- return NextResponse.next({
- request,
- })
+ return NextResponse.next({ request })
}
}
-const proxyWithOryAuth = authjsMiddleware(async (req, _event: NextFetchEvent) =>
- proxyCore(req, !!req.auth)
+// req.auth is truthy even when the session carries a RefreshTokenError, so we
+// must check session.error too — otherwise the auth-route guard treats a
+// poisoned session as "logged in" and ping-pongs the user between /dashboard
+// (redirects to /sign-in via getAuthContext()) and /sign-in (redirects back to
+// /dashboard via the proxy's authenticated-on-auth-route rule).
+function isSessionAuthenticated(session: Session | null): boolean {
+ return !!session && !session.error
+}
+
+// In Ory mode the Auth.js middleware wrapper populates req.auth and manages its
+// session cookies, so auth is resolved here and threaded into proxyCore.
+const proxyWithOryAuth = authjsMiddleware((req, _event: NextFetchEvent) =>
+ proxyCore(req, isSessionAuthenticated(req.auth))
)
export async function proxy(request: NextRequest, event: NextFetchEvent) {
- if (isOryAuthEnabled()) {
- return proxyWithOryAuth(request, event)
+ if (!isOryAuthEnabled()) {
+ return proxyCore(request)
}
- return proxyCore(request)
+
+ // Bounce the legacy auth pages straight to the Ory hosted UI before the
+ // (auth) layout can render.
+ const authRouteRedirect = getOryAuthRouteRedirect(request)
+ if (authRouteRedirect) return authRouteRedirect
+
+ return proxyWithOryAuth(request, event)
}
export const config = {
diff --git a/tests/unit/proxy-handlers.test.ts b/tests/unit/proxy-handlers.test.ts
new file mode 100644
index 000000000..6ed05e357
--- /dev/null
+++ b/tests/unit/proxy-handlers.test.ts
@@ -0,0 +1,118 @@
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const getMiddlewareRedirectMock = vi.hoisted(() => vi.fn())
+const getRewriteForPathMock = vi.hoisted(() => vi.fn())
+const createAuthForProxyMock = vi.hoisted(() => vi.fn())
+
+vi.mock('@/configs/flags', () => ({ ALLOW_SEO_INDEXING: false }))
+
+vi.mock('@/lib/utils/redirects', () => ({
+ getMiddlewareRedirectFromPath: getMiddlewareRedirectMock,
+}))
+
+vi.mock('@/lib/utils/rewrites', () => ({
+ getRewriteForPath: getRewriteForPathMock,
+}))
+
+vi.mock('@/core/server/auth', () => ({
+ createAuthForProxy: createAuthForProxyMock,
+}))
+
+const {
+ handleMiddlewareRedirect,
+ handleRouteRewritePassthrough,
+ handleMiddlewareRewrite,
+ handleAuthGate,
+} = await import('@/core/server/http/proxy')
+
+function request(path: string): NextRequest {
+ return new NextRequest(`https://app.e2b.dev${path}`)
+}
+
+beforeEach(() => {
+ getMiddlewareRedirectMock.mockReset().mockReturnValue(undefined)
+ getRewriteForPathMock.mockReset().mockReturnValue({ config: undefined })
+ createAuthForProxyMock.mockReset()
+})
+
+describe('handleMiddlewareRedirect', () => {
+ it('returns a redirect with the configured status and headers', () => {
+ getMiddlewareRedirectMock.mockReturnValue({
+ destination: '/new-home',
+ statusCode: 308,
+ headers: { 'x-custom': 'yes' },
+ })
+
+ const response = handleMiddlewareRedirect(request('/old-home'))
+
+ expect(response?.status).toBe(308)
+ expect(response?.headers.get('location')).toContain('/new-home')
+ })
+
+ it('returns null when there is no matching redirect', () => {
+ expect(handleMiddlewareRedirect(request('/anything'))).toBeNull()
+ })
+})
+
+describe('handleRouteRewritePassthrough', () => {
+ it('passes through when a catch-all route rewrite matches', () => {
+ getRewriteForPathMock.mockReturnValue({ config: { domain: 'x' } })
+
+ const response = handleRouteRewritePassthrough(request('/docs'))
+
+ expect(response).not.toBeNull()
+ expect(response?.headers.get('location')).toBeNull()
+ })
+
+ it('returns null when no route rewrite matches', () => {
+ expect(handleRouteRewritePassthrough(request('/docs'))).toBeNull()
+ })
+})
+
+describe('handleMiddlewareRewrite', () => {
+ it('rewrites to the configured domain, applies the path preprocessor, and tags no-index', () => {
+ getRewriteForPathMock.mockReturnValue({
+ config: { domain: 'docs.e2b.dev' },
+ rule: { pathPreprocessor: (p: string) => p.replace('/docs', '') },
+ })
+
+ const response = handleMiddlewareRewrite(request('/docs/guide'))
+
+ expect(response).not.toBeNull()
+ const rewrittenTo = response?.headers.get('x-middleware-rewrite') ?? ''
+ expect(rewrittenTo).toContain('docs.e2b.dev')
+ expect(rewrittenTo).toContain('/guide')
+ expect(response?.headers.get('X-Robots-Tag')).toBe('noindex, nofollow')
+ })
+
+ it('returns null when no middleware rewrite matches', () => {
+ expect(handleMiddlewareRewrite(request('/dashboard'))).toBeNull()
+ })
+})
+
+describe('handleAuthGate', () => {
+ it('redirects an unauthenticated dashboard request without resolving auth when knownAuth is provided', async () => {
+ const response = await handleAuthGate(request('/dashboard/team-x'), false)
+
+ expect(response.headers.get('location')).toContain('/sign-in')
+ expect(createAuthForProxyMock).not.toHaveBeenCalled()
+ })
+
+ it('resolves auth from the request when knownAuth is omitted', async () => {
+ createAuthForProxyMock.mockReturnValue({
+ getAuthContext: vi.fn().mockResolvedValue(null),
+ })
+
+ const response = await handleAuthGate(request('/dashboard/team-x'))
+
+ expect(createAuthForProxyMock).toHaveBeenCalled()
+ expect(response.headers.get('location')).toContain('/sign-in')
+ })
+
+ it('passes through when authenticated on a neutral route', async () => {
+ const response = await handleAuthGate(request('/some/page'), true)
+
+ expect(response.headers.get('location')).toBeNull()
+ })
+})
From 9ad7a18654b8389066ea37ae75fc2ce15921b6af Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Thu, 28 May 2026 18:36:48 -0700
Subject: [PATCH 09/16] feat(auth): resolve Kratos identity and shape the Ory
session
Server auth core for the dashboard-as-Hydra-OIDC-client setup:
- find-identity/auth-callbacks resolve the Kratos identity (profile.sub ->
token.sub -> verified email, with external_id fallback) and cache it on the
Auth.js session as identityId; the OIDC subject (token.sub) stays the E2B
user id used by dashboard-api/infra.
- AuthProvider gains getUserProfile; the Ory and Supabase providers implement it.
- jwt-claims/freshness/ory-error/flows/kratos-session/build-start-url/
auth-route-redirect helpers; fromOryIdentity normalizes the password
credential to the email provider so the account UI gate is provider-agnostic.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
src/auth.ts | 65 +----
src/core/server/auth/ory/auth-callbacks.ts | 135 +++++++++++
.../server/auth/ory/auth-route-redirect.ts | 24 ++
src/core/server/auth/ory/bootstrap.ts | 49 +---
src/core/server/auth/ory/build-start-url.ts | 14 ++
src/core/server/auth/ory/find-identity.ts | 137 +++++++++++
src/core/server/auth/ory/flows.ts | 125 ++++++++++
src/core/server/auth/ory/freshness.ts | 33 +++
src/core/server/auth/ory/identity.ts | 30 ++-
src/core/server/auth/ory/jwt-claims.ts | 26 ++
src/core/server/auth/ory/kratos-session.ts | 69 ++++++
src/core/server/auth/ory/ory-error.ts | 60 +++++
src/core/server/auth/ory/provider.ts | 134 +++++++++--
src/core/server/auth/ory/refresh-token.ts | 21 +-
src/core/server/auth/ory/signout.ts | 6 +-
src/core/server/auth/provider.ts | 18 +-
src/core/server/auth/supabase/flows.ts | 21 --
src/core/server/auth/supabase/provider.ts | 113 ++++++++-
src/core/server/auth/types.ts | 26 ++
tests/integration/auth-ory-bootstrap.test.ts | 27 +++
tests/unit/auth-ory-callbacks.test.ts | 132 +++++++++++
tests/unit/auth-ory-find-identity.test.ts | 162 +++++++++++++
tests/unit/auth-ory-flows.test.ts | 156 ++++++++++++
tests/unit/auth-ory-freshness.test.ts | 65 +++++
tests/unit/auth-ory-identity.test.ts | 57 +++++
tests/unit/auth-ory-provider-account.test.ts | 224 ++++++++++++++++++
tests/unit/auth-ory-provider-profile.test.ts | 121 ++++++++++
27 files changed, 1903 insertions(+), 147 deletions(-)
create mode 100644 src/core/server/auth/ory/auth-callbacks.ts
create mode 100644 src/core/server/auth/ory/auth-route-redirect.ts
create mode 100644 src/core/server/auth/ory/build-start-url.ts
create mode 100644 src/core/server/auth/ory/find-identity.ts
create mode 100644 src/core/server/auth/ory/flows.ts
create mode 100644 src/core/server/auth/ory/freshness.ts
create mode 100644 src/core/server/auth/ory/jwt-claims.ts
create mode 100644 src/core/server/auth/ory/kratos-session.ts
create mode 100644 src/core/server/auth/ory/ory-error.ts
create mode 100644 tests/unit/auth-ory-callbacks.test.ts
create mode 100644 tests/unit/auth-ory-find-identity.test.ts
create mode 100644 tests/unit/auth-ory-flows.test.ts
create mode 100644 tests/unit/auth-ory-freshness.test.ts
create mode 100644 tests/unit/auth-ory-identity.test.ts
create mode 100644 tests/unit/auth-ory-provider-account.test.ts
create mode 100644 tests/unit/auth-ory-provider-profile.test.ts
diff --git a/src/auth.ts b/src/auth.ts
index bcec837c5..25b02adc0 100644
--- a/src/auth.ts
+++ b/src/auth.ts
@@ -1,9 +1,10 @@
-import 'next-auth/jwt'
-
import NextAuth from 'next-auth'
import OryHydra from 'next-auth/providers/ory-hydra'
+import {
+ applyTokenToSession,
+ resolveOryJwt,
+} from '@/core/server/auth/ory/auth-callbacks'
import { bootstrapOryUser } from '@/core/server/auth/ory/bootstrap'
-import { refreshOryToken } from '@/core/server/auth/ory/refresh-token'
const oryOAuth2Audience = process.env.ORY_OAUTH2_AUDIENCE
@@ -12,6 +13,11 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
basePath: '/api/auth/oauth',
secret: process.env.AUTH_SECRET,
session: { strategy: 'jwt' },
+ // route handler that logs the failure and redirects to /sign-in so users
+ // never see Auth.js's built-in error page; see oauth-recover/route.ts.
+ pages: {
+ error: '/api/auth/oauth-recover',
+ },
providers: [
OryHydra({
id: 'ory',
@@ -29,33 +35,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
}),
],
callbacks: {
- async jwt({ token, account }) {
- if (account) {
- return {
- ...token,
- accessToken: account.access_token,
- refreshToken: account.refresh_token,
- idToken: account.id_token,
- expiresAt: account.expires_at ?? null,
- }
- }
-
- if (token.expiresAt && Date.now() / 1000 > token.expiresAt - 60) {
- return refreshOryToken(token)
- }
-
- return token
- },
-
- async session({ session, token }) {
- session.user.id = token.sub ?? session.user.id
- session.accessToken = token.accessToken
- session.idToken = token.idToken
- session.error = token.error
- return session
- },
+ jwt: ({ token, account, profile }) =>
+ resolveOryJwt({ token, account, profile }),
+ session: ({ session, token }) => applyTokenToSession(session, token),
},
-
events: {
async signIn({ account }) {
if (!account?.access_token) return
@@ -67,27 +50,3 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
},
},
})
-
-declare module 'next-auth' {
- interface Session {
- accessToken?: string
- idToken?: string
- error?: string
- user: {
- id: string
- email?: string | null
- name?: string | null
- image?: string | null
- }
- }
-}
-
-declare module 'next-auth/jwt' {
- interface JWT {
- accessToken?: string
- refreshToken?: string
- idToken?: string
- expiresAt?: number | null
- error?: string
- }
-}
diff --git a/src/core/server/auth/ory/auth-callbacks.ts b/src/core/server/auth/ory/auth-callbacks.ts
new file mode 100644
index 000000000..9398253a7
--- /dev/null
+++ b/src/core/server/auth/ory/auth-callbacks.ts
@@ -0,0 +1,135 @@
+import 'server-only'
+
+import type { Account, Profile, Session } from 'next-auth'
+import type { JWT } from 'next-auth/jwt'
+import { resolveOryIdentity } from './find-identity'
+import { decodeJwtClaims, readStringClaim } from './jwt-claims'
+import { refreshOryToken } from './refresh-token'
+
+// 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
+
+// Implements the Auth.js `jwt` callback: mint the token on fresh sign-in,
+// otherwise refresh it as it nears expiry.
+export async function resolveOryJwt(params: {
+ token: JWT
+ account?: Account | null
+ profile?: Profile
+}): Promise {
+ const { token, account, profile } = params
+
+ if (account) {
+ return buildSignInToken(token, account, profile)
+ }
+
+ // Once a refresh has failed we stop retrying. The dead token (cleared
+ // access/refresh) propagates to the session, oryAuthProvider returns null,
+ // and the proxy redirects to /sign-in.
+ if (token.error) {
+ return token
+ }
+
+ if (isAccessTokenExpiring(token)) {
+ return refreshOryToken(token)
+ }
+
+ return token
+}
+
+// Implements the Auth.js `session` callback: project the persisted token fields
+// onto the session the rest of the app reads.
+export function applyTokenToSession(session: Session, token: JWT): Session {
+ session.user.id = token.sub ?? session.user.id
+ session.accessToken = token.accessToken
+ session.idToken = token.idToken
+ session.identityId = token.identityId
+ session.error = token.error
+ return session
+}
+
+// Persist the Ory tokens on a fresh sign-in and cache the resolved Kratos
+// identity id. Clears any RefreshTokenError carried over from a previously
+// poisoned cookie so the new session starts clean.
+async function buildSignInToken(
+ token: JWT,
+ account: Account,
+ profile?: Profile
+): Promise {
+ return {
+ ...token,
+ accessToken: account.access_token,
+ refreshToken: account.refresh_token,
+ idToken: account.id_token,
+ expiresAt: account.expires_at ?? null,
+ identityId: await resolveKratosIdentityId(token, account, profile),
+ 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: JWT,
+ account: Account,
+ profile?: Profile
+): Promise {
+ const profileSub = typeof profile?.sub === 'string' ? profile.sub : undefined
+
+ const identity = await resolveOryIdentity({
+ subjects: [profileSub, token.sub],
+ email: readEmailClaim(account),
+ })
+
+ return identity?.id
+}
+
+function readEmailClaim(account: Account): 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
+}
+
+function isAccessTokenExpiring(
+ token: JWT,
+ nowSeconds: number = Math.floor(Date.now() / 1000)
+): boolean {
+ if (token.expiresAt == null) return false
+ return nowSeconds > token.expiresAt - ACCESS_TOKEN_REFRESH_SKEW_SECONDS
+}
+
+declare module 'next-auth' {
+ interface Session {
+ accessToken?: string
+ idToken?: string
+ // Kratos identity id, resolved from the OIDC subject at sign-in. Differs
+ // from user.id (the OIDC subject / E2B user id) when the project customizes
+ // the OAuth2 subject.
+ identityId?: string
+ error?: string
+ user: {
+ id: string
+ email?: string | null
+ name?: string | null
+ image?: string | null
+ }
+ }
+}
+
+declare module 'next-auth/jwt' {
+ interface JWT {
+ accessToken?: string
+ refreshToken?: string
+ idToken?: string
+ identityId?: string
+ expiresAt?: number | null
+ error?: string
+ }
+}
diff --git a/src/core/server/auth/ory/auth-route-redirect.ts b/src/core/server/auth/ory/auth-route-redirect.ts
new file mode 100644
index 000000000..6e98b22b1
--- /dev/null
+++ b/src/core/server/auth/ory/auth-route-redirect.ts
@@ -0,0 +1,24 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { buildOryStartURL, type OryAuthIntent } from './build-start-url'
+
+// Map each legacy auth page to the intent we want the Ory hosted UI to
+// open with. Done at the middleware layer so the (auth) layout never
+// renders in Ory mode - otherwise the user briefly sees the auth shell
+// before the page-level redirect kicks in.
+const INTENT_BY_PATH: Record = {
+ '/sign-in': 'signin',
+ '/sign-up': 'signup',
+ '/forgot-password': 'signin',
+}
+
+export function getOryAuthRouteRedirect(
+ request: NextRequest
+): NextResponse | null {
+ const intent = INTENT_BY_PATH[request.nextUrl.pathname]
+ if (!intent) return null
+
+ const returnTo = request.nextUrl.searchParams.get('returnTo') ?? undefined
+ const target = new URL(buildOryStartURL(intent, returnTo), request.url)
+
+ return NextResponse.redirect(target)
+}
diff --git a/src/core/server/auth/ory/bootstrap.ts b/src/core/server/auth/ory/bootstrap.ts
index 3c9d5657b..21975b8e2 100644
--- a/src/core/server/auth/ory/bootstrap.ts
+++ b/src/core/server/auth/ory/bootstrap.ts
@@ -4,6 +4,7 @@ import { ADMIN_AUTH_HEADERS } from '@/configs/api'
import { api } from '@/core/shared/clients/api'
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
import { repoErrorFromHttp } from '@/core/shared/errors'
+import { decodeJwtClaims, readStringClaim, tokenFormat } from './jwt-claims'
type BootstrapOryUserInput = {
accessToken: string
@@ -24,16 +25,20 @@ export async function bootstrapOryUser(
input: BootstrapOryUserInput
): Promise {
try {
- const accessClaims = decodeJwtClaims(input.accessToken)
- const idClaims = input.idToken ? decodeJwtClaims(input.idToken) : null
- const oidcUserId = readRequiredStringClaim(accessClaims, 'sub')
+ const accessClaims = decodeJwtClaims(input.accessToken)
+ const idClaims = input.idToken
+ ? decodeJwtClaims(input.idToken)
+ : null
+ const oidcIssuer =
+ readStringClaim(accessClaims, 'iss') ?? readStringClaim(idClaims, 'iss')
+ const oidcUserId = readStringClaim(accessClaims, 'sub')
const oidcUserEmail =
readStringClaim(accessClaims, 'email') ??
readStringClaim(idClaims, 'email')
const oidcUserName =
readDisplayName(accessClaims) ?? readDisplayName(idClaims)
- if (!oidcUserId || !oidcUserEmail) {
+ if (!oidcIssuer || !oidcUserId || !oidcUserEmail) {
l.error(
{
key: 'auth_events:bootstrap_user:missing_claims',
@@ -45,6 +50,7 @@ export async function bootstrapOryUser(
: 'missing',
has_access_claims: !!accessClaims,
has_id_claims: !!idClaims,
+ has_iss: !!oidcIssuer,
has_sub: !!oidcUserId,
has_email: !!oidcUserEmail,
has_name: !!oidcUserName,
@@ -68,6 +74,7 @@ export async function bootstrapOryUser(
}
const body = {
+ oidc_issuer: oidcIssuer,
oidc_user_id: oidcUserId,
oidc_user_email: oidcUserEmail,
oidc_user_name: oidcUserName,
@@ -90,6 +97,7 @@ export async function bootstrapOryUser(
context: {
provider: input.provider,
error_status: response.status,
+ has_oidc_issuer: body.oidc_issuer !== '',
has_oidc_user_id: body.oidc_user_id !== '',
has_oidc_user_email: body.oidc_user_email !== '',
has_oidc_user_name: body.oidc_user_name !== null,
@@ -112,34 +120,6 @@ export async function bootstrapOryUser(
}
}
-function decodeJwtClaims(token: string): OryTokenClaims | null {
- const [, payload] = token.split('.')
- if (!payload) return null
-
- try {
- return JSON.parse(
- Buffer.from(payload, 'base64url').toString('utf8')
- ) as OryTokenClaims
- } catch {
- return null
- }
-}
-
-function readRequiredStringClaim(
- claims: OryTokenClaims | null,
- name: keyof OryTokenClaims
-): string | null {
- return readStringClaim(claims, name)
-}
-
-function readStringClaim(
- claims: OryTokenClaims | null,
- name: keyof OryTokenClaims
-): string | null {
- const value = claims?.[name]
- return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
-}
-
function readDisplayName(claims: OryTokenClaims | null): string | null {
return (
readStringClaim(claims, 'name') ??
@@ -147,8 +127,3 @@ function readDisplayName(claims: OryTokenClaims | null): string | null {
readStringClaim(claims, 'preferred_username')
)
}
-
-function tokenFormat(token: string): 'jwt' | 'opaque' | 'empty' {
- if (!token) return 'empty'
- return token.split('.').length === 3 ? 'jwt' : 'opaque'
-}
diff --git a/src/core/server/auth/ory/build-start-url.ts b/src/core/server/auth/ory/build-start-url.ts
new file mode 100644
index 000000000..ffc559ab5
--- /dev/null
+++ b/src/core/server/auth/ory/build-start-url.ts
@@ -0,0 +1,14 @@
+export type OryAuthIntent = 'signin' | 'signup' | 'reauth'
+
+const ORY_START_PATH = '/api/auth/oauth-start'
+
+export function buildOryStartURL(
+ intent: OryAuthIntent,
+ returnTo?: string
+): string {
+ const params = new URLSearchParams({ intent })
+ if (returnTo && returnTo.length > 0) {
+ params.set('returnTo', returnTo)
+ }
+ return `${ORY_START_PATH}?${params}`
+}
diff --git a/src/core/server/auth/ory/find-identity.ts b/src/core/server/auth/ory/find-identity.ts
new file mode 100644
index 000000000..36a2acfb1
--- /dev/null
+++ b/src/core/server/auth/ory/find-identity.ts
@@ -0,0 +1,137 @@
+import 'server-only'
+
+import { type Identity, ResponseError } from '@ory/client-fetch'
+import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
+import { getOryIdentityApi } from './client'
+import { readOryError } from './ory-error'
+
+// Resolving the Kratos identity for the logged-in user is not a simple
+// "getIdentity(sub)" because the OIDC subject the dashboard sees is not
+// guaranteed to be the Kratos identity id:
+// - In a vanilla Ory setup the OAuth2 subject IS the Kratos identity id.
+// - Projects that customize the subject (e.g. to keep a stable app user id
+// across a migration) expose the Kratos id under a *different* OIDC subject,
+// or only via the id_token/userinfo profile `sub`.
+// - Migrated identities may carry a legacy id as `external_id`.
+// So we try every identifier we have (a list of candidate subjects, then the
+// verified email) and return the first identity that resolves.
+
+export type ResolveOryIdentityInput = {
+ // Candidate subject ids, in priority order (e.g. profile.sub, then token.sub).
+ // Falsy entries are ignored and duplicates de-duped.
+ subjects?: Array
+ // Verified login email — the unambiguous fallback for password identities.
+ email?: string | null
+}
+
+export async function resolveOryIdentity(
+ input: ResolveOryIdentityInput
+): Promise {
+ const subjects = [
+ ...new Set(
+ (input.subjects ?? []).filter(
+ (subject): subject is string => typeof subject === 'string' && !!subject
+ )
+ ),
+ ]
+
+ for (const subject of subjects) {
+ const identity = await findOryIdentityBySubject(subject)
+ if (identity) return identity
+ }
+
+ if (input.email) {
+ const identity = await findOryIdentityByEmail(input.email)
+ if (identity) return identity
+ }
+
+ l.error(
+ {
+ key: 'auth_provider:resolve_identity:not_found',
+ context: {
+ attempted_subjects: subjects,
+ attempted_email: input.email ?? null,
+ // The project we queried — a mismatch with the token issuer points to a
+ // misconfigured admin client (wrong Ory project).
+ ory_sdk_url: process.env.ORY_SDK_URL ?? null,
+ },
+ },
+ 'no Kratos identity found by subject(s) or email'
+ )
+ return null
+}
+
+// Tries a single subject as a Kratos identity id, then as an external_id. A 404
+// means "not this strategy" and falls through; any other error is unexpected,
+// logged, and stops the search. The terminal "not found" belongs to
+// resolveOryIdentity once every strategy is exhausted.
+export async function findOryIdentityBySubject(
+ subject: string
+): Promise {
+ const api = getOryIdentityApi()
+
+ try {
+ return await api.getIdentity({ id: subject })
+ } catch (error) {
+ if (!isNotFound(error)) {
+ await logLookupError('by_id', error)
+ return null
+ }
+ }
+
+ try {
+ return await api.getIdentityByExternalID({ externalID: subject })
+ } catch (error) {
+ if (!isNotFound(error)) {
+ await logLookupError('by_external_id', error)
+ }
+ return null
+ }
+}
+
+export async function findOryIdentityByEmail(
+ email: string
+): Promise {
+ try {
+ const identities = await getOryIdentityApi().listIdentities({
+ credentialsIdentifier: email,
+ pageSize: 2,
+ })
+
+ if (identities.length === 0) return null
+
+ // Prefer an exact email-trait match; fall back to the first result.
+ const exact = identities.find(
+ (identity) => emailTrait(identity)?.toLowerCase() === email.toLowerCase()
+ )
+ return exact ?? identities[0] ?? null
+ } catch (error) {
+ await logLookupError('by_email', error)
+ return null
+ }
+}
+
+function emailTrait(identity: Identity): string | null {
+ const traits = (identity.traits ?? {}) as Record
+ return typeof traits.email === 'string' ? traits.email : null
+}
+
+function isNotFound(error: unknown): boolean {
+ return error instanceof ResponseError && error.response.status === 404
+}
+
+async function logLookupError(
+ stage: 'by_id' | 'by_external_id' | 'by_email',
+ error: unknown
+): Promise {
+ const ory = error instanceof ResponseError ? await readOryError(error) : null
+
+ l.error(
+ {
+ key: 'auth_provider:resolve_identity:error',
+ context: { stage, ory },
+ error: serializeErrorForLog(error),
+ },
+ `Ory identity lookup failed (${stage})`
+ )
+}
diff --git a/src/core/server/auth/ory/flows.ts b/src/core/server/auth/ory/flows.ts
new file mode 100644
index 000000000..413b7bb94
--- /dev/null
+++ b/src/core/server/auth/ory/flows.ts
@@ -0,0 +1,125 @@
+import 'server-only'
+
+import { type JsonPatch, JsonPatchOpEnum } from '@ory/client-fetch'
+import { l } from '@/core/shared/clients/logger/logger'
+import type { UpdateUserErrorCode, UpdateUserResult } from '../types'
+import { getOryIdentityApi } from './client'
+import { fromOryIdentity } from './identity'
+import { isOryResponseError, readOryError } from './ory-error'
+
+type OryUpdateUserInput = {
+ identityId: string
+ name?: string
+ email?: string
+ password?: string
+}
+
+export const oryAuthFlows = {
+ async updateUser({
+ identityId,
+ name,
+ email,
+ password,
+ }: OryUpdateUserInput): Promise {
+ const jsonPatch = buildIdentityPatches({ name, email, password })
+
+ try {
+ const identity = await getOryIdentityApi().patchIdentity({
+ id: identityId,
+ jsonPatch,
+ })
+
+ return { ok: true, user: fromOryIdentity(identity) }
+ } catch (error) {
+ return mapUpdateUserError(error, identityId)
+ }
+ },
+}
+
+// Assumes a flat `name` trait. If the project's identity schema nests name as
+// `{ first, last }`, this patch path needs to target those sub-paths instead.
+function buildIdentityPatches({
+ name,
+ email,
+ password,
+}: Omit): JsonPatch[] {
+ const patches: JsonPatch[] = []
+
+ if (name !== undefined) {
+ patches.push({
+ op: JsonPatchOpEnum.Replace,
+ path: '/traits/name',
+ value: name,
+ })
+ }
+ if (email !== undefined) {
+ patches.push({
+ op: JsonPatchOpEnum.Replace,
+ path: '/traits/email',
+ value: email,
+ })
+ }
+ if (password !== undefined) {
+ // The password-settings UI is only shown for identities that already have
+ // the email/password credential, so the config object exists to replace.
+ patches.push({
+ op: JsonPatchOpEnum.Replace,
+ path: '/credentials/password/config/password',
+ value: password,
+ })
+ }
+
+ return patches
+}
+
+async function mapUpdateUserError(
+ error: unknown,
+ identityId: string
+): Promise {
+ if (!isOryResponseError(error)) {
+ throw error
+ }
+
+ const details = await readOryError(error)
+ const code = classifyUpdateError(
+ details.status,
+ details.reason,
+ details.message
+ )
+
+ l.error(
+ {
+ key: 'auth_provider:ory_update_user:error',
+ user_id: identityId,
+ context: { ory: details, mapped_code: code },
+ },
+ 'Ory identity update failed'
+ )
+
+ // Unclassified failures (5xx, unexpected 4xx) are surfaced as unexpected
+ // server errors rather than a misleading user-facing message.
+ if (!code) {
+ throw error
+ }
+
+ return { ok: false, code, message: details.message }
+}
+
+function classifyUpdateError(
+ status: number,
+ reason?: string,
+ message?: string
+): UpdateUserErrorCode | null {
+ const haystack = `${reason ?? ''} ${message ?? ''}`.toLowerCase()
+
+ if (status === 409) return 'email_exists'
+
+ if (status === 400) {
+ if (haystack.includes('password')) return 'weak_password'
+ if (haystack.includes('email') || haystack.includes('valid')) {
+ return 'email_invalid'
+ }
+ }
+
+ return null
+}
diff --git a/src/core/server/auth/ory/freshness.ts b/src/core/server/auth/ory/freshness.ts
new file mode 100644
index 000000000..479053b54
--- /dev/null
+++ b/src/core/server/auth/ory/freshness.ts
@@ -0,0 +1,33 @@
+import { decodeJwtClaims } from './jwt-claims'
+
+// How recently the user must have authenticated (via the OAuth2 login flow)
+// for a sensitive operation like a password change to be allowed without a
+// forced re-auth round-trip.
+export const REAUTH_FRESHNESS_WINDOW_SECONDS = 300
+
+type AuthTimeClaims = {
+ auth_time?: unknown
+}
+
+// Reads the OIDC `auth_time` claim (epoch seconds) from the id_token. Hydra
+// stamps this with the moment the user last actively authenticated, which is
+// what `prompt=login` refreshes.
+export function readAuthTime(idToken: string | undefined): number | null {
+ if (!idToken) return null
+
+ const claims = decodeJwtClaims(idToken)
+ const authTime = claims?.auth_time
+ return typeof authTime === 'number' && Number.isFinite(authTime)
+ ? authTime
+ : null
+}
+
+export function isReauthFresh(
+ idToken: string | undefined,
+ nowSeconds: number = Math.floor(Date.now() / 1000)
+): boolean {
+ const authTime = readAuthTime(idToken)
+ if (authTime === null) return false
+
+ return nowSeconds - authTime <= REAUTH_FRESHNESS_WINDOW_SECONDS
+}
diff --git a/src/core/server/auth/ory/identity.ts b/src/core/server/auth/ory/identity.ts
index f3bee304c..f934c4acb 100644
--- a/src/core/server/auth/ory/identity.ts
+++ b/src/core/server/auth/ory/identity.ts
@@ -4,9 +4,9 @@ import type { Identity } from '@ory/client-fetch'
import type { Session } from 'next-auth'
import type { AuthUser } from '../types'
-// auth.js sessions only carry the basic user shape; identity providers list
-// requires an Ory IdentityApi lookup. fromAuthSession is the cheap path used
-// during request-time getAuthContext.
+// Cheap path: build the user from the Auth.js session alone (no Ory call). Used
+// at request time by getAuthContext. `providers` is empty because the session
+// doesn't carry credential info — use fromOryIdentity when that's needed.
export function fromAuthSession(session: Session): AuthUser {
return {
id: session.user.id,
@@ -17,17 +17,16 @@ export function fromAuthSession(session: Session): AuthUser {
}
}
-// fromOryIdentity is used by oryAuthAdmin (admin lookups) where we have the
-// full Identity object including credentials and traits.
+// 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): AuthUser {
const traits = (identity.traits ?? {}) as Record
const email = readString(traits, 'email')
const name = readDisplayName(traits)
const avatarUrl =
readString(traits, 'picture') ?? readString(traits, 'avatar_url')
- const providers = identity.credentials
- ? Object.keys(identity.credentials)
- : []
+ const providers = normalizeProviders(identity.credentials)
return {
id: identity.id,
@@ -38,6 +37,21 @@ export function fromOryIdentity(identity: Identity): AuthUser {
}
}
+// Kratos credential keys (`password`, `oidc`, …) don't match the provider
+// vocabulary the dashboard UI expects (Supabase emits `email` for the
+// email/password credential). Map `password` → `email` so the account-settings
+// provider gate (`providers.includes('email')`) stays provider-agnostic, while
+// preserving other keys like `oidc`.
+function normalizeProviders(credentials: Identity['credentials']): string[] {
+ if (!credentials) return []
+
+ const mapped = Object.keys(credentials).map((key) =>
+ key === 'password' ? 'email' : key
+ )
+
+ return [...new Set(mapped)]
+}
+
function readString(
traits: Record,
key: string
diff --git a/src/core/server/auth/ory/jwt-claims.ts b/src/core/server/auth/ory/jwt-claims.ts
new file mode 100644
index 000000000..b2e225c25
--- /dev/null
+++ b/src/core/server/auth/ory/jwt-claims.ts
@@ -0,0 +1,26 @@
+export function decodeJwtClaims>(
+ token: string
+): T | null {
+ const [, payload] = token.split('.')
+ if (!payload) return null
+
+ try {
+ return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as T
+ } catch {
+ return null
+ }
+}
+
+export function tokenFormat(token: string): 'jwt' | 'opaque' | 'empty' {
+ if (!token) return 'empty'
+ return token.split('.').length === 3 ? 'jwt' : 'opaque'
+}
+
+// Reads a non-empty string claim, trimming surrounding whitespace.
+export function readStringClaim(
+ claims: Record | null | undefined,
+ name: string
+): string | null {
+ const value = claims?.[name]
+ return typeof value === 'string' && value.trim() !== '' ? value.trim() : null
+}
diff --git a/src/core/server/auth/ory/kratos-session.ts b/src/core/server/auth/ory/kratos-session.ts
new file mode 100644
index 000000000..c981a1a42
--- /dev/null
+++ b/src/core/server/auth/ory/kratos-session.ts
@@ -0,0 +1,69 @@
+import 'server-only'
+
+import { ResponseError } from '@ory/client-fetch'
+import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
+import { getOryIdentityApi } from './client'
+import { readOryError } from './ory-error'
+
+/**
+ * Revokes every Kratos identity session for the given identity.
+ *
+ * Hydra's /oauth2/sessions/logout only ends the OAuth2 session; the Kratos
+ * identity cookie on the Ory domain is independent and is what causes the
+ * Account Experience to show "Reauthenticate as " on the next
+ * sign-in instead of a fresh provider chooser.
+ *
+ * We can't surgically target a single session because the OIDC `sid` claim
+ * from Hydra is Hydra's own OAuth2 session id, not a Kratos session id, and
+ * we don't have access to the user's Kratos cookie from this side. Revoking
+ * all identity sessions matches the expected "sign out of identity provider"
+ * semantics anyway.
+ */
+// Ory uses optimistic locking on identity rows; concurrent writes (e.g. our
+// admin DELETE racing with Hydra's RP-initiated logout cleanup during the
+// same signout flow) return 429 with reason "Conflicting concurrent
+// requests". Retrying after a short backoff lets the in-flight write
+// settle so ours can proceed.
+const REVOKE_MAX_ATTEMPTS = 3
+const REVOKE_BACKOFF_MS = 150
+
+export async function revokeKratosSessionsForIdentity(
+ identityId: string
+): Promise {
+ for (let attempt = 1; attempt <= REVOKE_MAX_ATTEMPTS; attempt++) {
+ try {
+ await getOryIdentityApi().deleteIdentitySessions({ id: identityId })
+ return
+ } catch (error) {
+ if (error instanceof ResponseError && error.response.status === 404) {
+ return
+ }
+
+ const isContention =
+ error instanceof ResponseError && error.response.status === 429
+ const lastAttempt = attempt === REVOKE_MAX_ATTEMPTS
+
+ if (isContention && !lastAttempt) {
+ await sleep(REVOKE_BACKOFF_MS * attempt)
+ continue
+ }
+
+ const oryDetails =
+ error instanceof ResponseError ? await readOryError(error) : null
+
+ l.error(
+ {
+ key: 'auth_provider:revoke_kratos_sessions:error',
+ context: { ory: oryDetails, attempt },
+ error: serializeErrorForLog(error),
+ },
+ 'failed to revoke Kratos sessions; user may see reauth UX on next sign-in'
+ )
+ return
+ }
+ }
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
diff --git a/src/core/server/auth/ory/ory-error.ts b/src/core/server/auth/ory/ory-error.ts
new file mode 100644
index 000000000..9abc2045b
--- /dev/null
+++ b/src/core/server/auth/ory/ory-error.ts
@@ -0,0 +1,60 @@
+import { ResponseError } from '@ory/client-fetch'
+
+export type OryErrorDetails = {
+ status: number
+ url: string
+ code?: number
+ reason?: string
+ message?: string
+ request_id?: string
+ body?: string
+}
+
+// Ory returns a structured error envelope like
+// { "error": { "code": 401, "status": "Unauthorized", "reason": "...", "message": "...", "id": "..." } }
+// The SDK's ResponseError doesn't unpack it, so we read the body here to
+// surface the actual cause instead of "Response returned an error code".
+export async function readOryError(
+ error: ResponseError
+): Promise {
+ const { response } = error
+ const base: OryErrorDetails = { status: response.status, url: response.url }
+
+ let raw: string
+ try {
+ raw = await response.clone().text()
+ } catch {
+ return base
+ }
+
+ try {
+ const parsed = JSON.parse(raw) as {
+ error?: {
+ code?: unknown
+ reason?: unknown
+ message?: unknown
+ id?: unknown
+ request?: unknown
+ }
+ }
+ const oryError = parsed.error ?? {}
+ return {
+ ...base,
+ code: typeof oryError.code === 'number' ? oryError.code : undefined,
+ reason: stringOrUndefined(oryError.reason),
+ message: stringOrUndefined(oryError.message),
+ request_id:
+ stringOrUndefined(oryError.id) ?? stringOrUndefined(oryError.request),
+ }
+ } catch {
+ return { ...base, body: raw.slice(0, 500) }
+ }
+}
+
+export function isOryResponseError(error: unknown): error is ResponseError {
+ return error instanceof ResponseError
+}
+
+function stringOrUndefined(value: unknown): string | undefined {
+ return typeof value === 'string' && value.length > 0 ? value : undefined
+}
diff --git a/src/core/server/auth/ory/provider.ts b/src/core/server/auth/ory/provider.ts
index 40348219d..4a254e55b 100644
--- a/src/core/server/auth/ory/provider.ts
+++ b/src/core/server/auth/ory/provider.ts
@@ -2,28 +2,34 @@ import 'server-only'
import type { Session } from 'next-auth'
import { auth as authjs } from '@/auth'
+import { PROTECTED_URLS } from '@/configs/urls'
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
import type { AuthProvider } from '../provider'
-import { fromAuthSession } from './identity'
-import { getOrySignOutPath } from './signout'
+import type {
+ AuthContext,
+ AuthUser,
+ ReauthDispatch,
+ UpdateUserInput,
+ UpdateUserResult,
+} from '../types'
+import { buildOryStartURL } from './build-start-url'
+import { resolveOryIdentity } from './find-identity'
+import { oryAuthFlows } from './flows'
+import { isReauthFresh } from './freshness'
+import { fromAuthSession, fromOryIdentity } from './identity'
+import { revokeKratosSessionsForIdentity } from './kratos-session'
+import { ORY_SIGN_OUT_FLOW_PATH } from './signout'
+
+// Where the account-settings page expects to land after a forced re-auth so it
+// reveals the password form (matches the Supabase ?reauth=1 contract).
+const ACCOUNT_SETTINGS_REAUTH_RETURN_TO = `${PROTECTED_URLS.ACCOUNT_SETTINGS}?reauth=1`
export const oryAuthProvider: AuthProvider = {
async getAuthContext() {
- let session: Session | null
- try {
- session = await authjs()
- } catch (error) {
- l.error(
- {
- key: 'auth_provider:ory_get_session:error',
- error: serializeErrorForLog(error),
- },
- 'Auth.js auth() helper threw while reading session'
- )
- return null
- }
+ const session = await readSession()
+ if (!session) return null
- if (!session?.user?.id || !session.accessToken) {
+ if (!session.user?.id || !session.accessToken) {
return null
}
@@ -42,10 +48,102 @@ export const oryAuthProvider: AuthProvider = {
return {
user: fromAuthSession(session),
accessToken: session.accessToken,
- }
+ } satisfies AuthContext
+ },
+
+ async getUserProfile(): Promise {
+ const session = await readSession()
+ if (!session?.user?.id) return null
+
+ // The live profile needs the full Kratos identity (traits + credentials).
+ // The cached session.identityId hits directly; user.id and email are
+ // fallbacks. Callers (the tRPC profile query) time this out and fall back to
+ // the cheap session user, so a null/slow response never blocks the dashboard.
+ const identity = await resolveOryIdentity({
+ subjects: [session.identityId, session.user.id],
+ email: session.user.email,
+ })
+ return identity ? fromOryIdentity(identity) : null
},
signOut() {
- return Promise.resolve({ redirectTo: getOrySignOutPath() })
+ return Promise.resolve({ redirectTo: ORY_SIGN_OUT_FLOW_PATH })
+ },
+
+ async updateUser(input: UpdateUserInput): Promise {
+ const session = await readSession()
+ if (!session?.user?.id) {
+ throw new Error('updateUser called without an authenticated Ory session')
+ }
+
+ // Changing the password is privileged: require a recent active login so a
+ // stolen dashboard session can't silently reset credentials. The caller
+ // turns this into the forced OAuth2 re-auth round-trip.
+ if (input.password !== undefined && !isReauthFresh(session.idToken)) {
+ return { ok: false, code: 'reauthentication_needed' }
+ }
+
+ const identityId = await resolveIdentityId(session)
+ if (!identityId) {
+ throw new Error(
+ 'updateUser could not resolve an Ory identity for the session subject'
+ )
+ }
+
+ return oryAuthFlows.updateUser({
+ identityId,
+ name: input.name,
+ email: input.email,
+ password: input.password,
+ })
+ },
+
+ async startReauthForAccountSettings(): Promise {
+ return {
+ kind: 'redirect',
+ to: buildOryStartURL('reauth', ACCOUNT_SETTINGS_REAUTH_RETURN_TO),
+ }
+ },
+
+ async signOutOtherSessions(): Promise {
+ const session = await readSession()
+ if (!session?.user?.id) return
+
+ const identityId = await resolveIdentityId(session)
+ if (!identityId) return
+
+ // The dashboard session is the Auth.js JWT, independent of Kratos identity
+ // sessions, so revoking all Kratos sessions invalidates other browsers
+ // without logging the current dashboard session out.
+ await revokeKratosSessionsForIdentity(identityId)
},
}
+
+// The Kratos identity id is resolved once at sign-in and cached on the session
+// (see src/auth.ts). Fall back to a per-request lookup (by the E2B user id, then
+// the verified email) for sessions minted before that wiring existed or when
+// the sign-in resolution failed.
+async function resolveIdentityId(session: Session): Promise {
+ if (session.identityId) return session.identityId
+
+ const identity = await resolveOryIdentity({
+ subjects: [session.user.id],
+ email: session.user.email,
+ })
+ return identity?.id ?? null
+}
+
+async function readSession(): Promise {
+ try {
+ return await authjs()
+ } catch (error) {
+ l.error(
+ {
+ key: 'auth_provider:ory_get_session:error',
+ error: serializeErrorForLog(error),
+ },
+ 'Auth.js auth() helper threw while reading session'
+ )
+ return null
+ }
+}
diff --git a/src/core/server/auth/ory/refresh-token.ts b/src/core/server/auth/ory/refresh-token.ts
index c6daad209..f25dc5aa1 100644
--- a/src/core/server/auth/ory/refresh-token.ts
+++ b/src/core/server/auth/ory/refresh-token.ts
@@ -10,8 +10,23 @@ type OryTokenResponse = {
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 { ...token, error: 'NoRefreshToken' }
+ if (!token.refreshToken) return deadToken(token, 'NoRefreshToken')
const sdkUrl = process.env.ORY_SDK_URL!.replace(/\/$/, '')
const credentials = btoa(
@@ -39,7 +54,7 @@ export async function refreshOryToken(token: JWT): Promise {
},
`Ory refresh_token rejected (${res.status})`
)
- return { ...token, error: 'RefreshTokenError' }
+ return deadToken(token, 'RefreshTokenError')
}
const fresh = (await res.json()) as OryTokenResponse
@@ -59,6 +74,6 @@ export async function refreshOryToken(token: JWT): Promise {
},
'Ory refresh_token threw'
)
- return { ...token, error: 'RefreshTokenError' }
+ return deadToken(token, 'RefreshTokenError')
}
}
diff --git a/src/core/server/auth/ory/signout.ts b/src/core/server/auth/ory/signout.ts
index 0dd3cc65e..b5d40d656 100644
--- a/src/core/server/auth/ory/signout.ts
+++ b/src/core/server/auth/ory/signout.ts
@@ -1,5 +1,3 @@
+// Route handler that performs the full Ory sign-out (Auth.js + Kratos sessions
+// + Hydra RP-initiated logout). The provider redirects here on signOut().
export const ORY_SIGN_OUT_FLOW_PATH = '/api/auth/oauth/signout-flow'
-
-export function getOrySignOutPath(): string {
- return ORY_SIGN_OUT_FLOW_PATH
-}
diff --git a/src/core/server/auth/provider.ts b/src/core/server/auth/provider.ts
index 6953cce2d..9fb435caa 100644
--- a/src/core/server/auth/provider.ts
+++ b/src/core/server/auth/provider.ts
@@ -1,6 +1,22 @@
-import type { AuthContext, SignOutOptions, SignOutResult } from './types'
+import type {
+ AuthContext,
+ AuthUser,
+ ReauthDispatch,
+ SignOutOptions,
+ SignOutResult,
+ UpdateUserInput,
+ UpdateUserResult,
+} from './types'
export interface AuthProvider {
getAuthContext(): Promise
+ // Live profile lookup from the identity provider (Ory IdentityApi / Supabase
+ // getUser). Unlike getAuthContext's cheap session path, this carries the full
+ // traits and credential-derived providers. Heavier — call it once per
+ // dashboard load behind a cache, not on every request.
+ getUserProfile(): Promise
signOut(options?: SignOutOptions): Promise
+ updateUser(input: UpdateUserInput): Promise
+ startReauthForAccountSettings(): Promise
+ signOutOtherSessions(): Promise
}
diff --git a/src/core/server/auth/supabase/flows.ts b/src/core/server/auth/supabase/flows.ts
index b7351875e..468483224 100644
--- a/src/core/server/auth/supabase/flows.ts
+++ b/src/core/server/auth/supabase/flows.ts
@@ -20,13 +20,6 @@ type SignUpOptions = {
data?: Record
}
-type UpdateUserOptions = {
- email?: string
- password?: string
- name?: string
- emailRedirectTo?: string
-}
-
export const supabaseAuthFlows = {
async signInWithOAuth({
provider,
@@ -63,20 +56,6 @@ export const supabaseAuthFlows = {
return client.auth.resetPasswordForEmail(email)
},
- async updateUser({
- email,
- password,
- name,
- emailRedirectTo,
- }: UpdateUserOptions) {
- const client = await createClient()
-
- return client.auth.updateUser(
- { email, password, data: { name } },
- emailRedirectTo ? { emailRedirectTo } : undefined
- )
- },
-
async verifyOtp(...args: Parameters) {
const client = await createClient()
return client.auth.verifyOtp(...args)
diff --git a/src/core/server/auth/supabase/provider.ts b/src/core/server/auth/supabase/provider.ts
index a4e04d024..a506eebd9 100644
--- a/src/core/server/auth/supabase/provider.ts
+++ b/src/core/server/auth/supabase/provider.ts
@@ -1,11 +1,21 @@
import 'server-only'
+import { headers } from 'next/headers'
import type { NextRequest, NextResponse } from 'next/server'
-import { AUTH_URLS } from '@/configs/urls'
+import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
import { createClient } from '@/core/shared/clients/supabase/server'
import type { AuthProvider } from '../provider'
-import type { AuthContext, SignOutOptions, SignOutResult } from '../types'
+import type {
+ AuthContext,
+ AuthUser,
+ ReauthDispatch,
+ SignOutOptions,
+ SignOutResult,
+ UpdateUserErrorCode,
+ UpdateUserInput,
+ UpdateUserResult,
+} from '../types'
import {
createServerClientForHeaders,
createServerClientForProxy,
@@ -60,6 +70,26 @@ export class SupabaseAuthProvider implements AuthProvider {
}
}
+ async getUserProfile(): Promise {
+ const client = await this.resolveClient()
+ const { data, error } = await client.auth.getUser()
+
+ if (error || !data.user) {
+ if (error) {
+ l.error(
+ {
+ key: 'auth_provider:get_user_profile:error',
+ error: serializeErrorForLog(error),
+ },
+ `supabase getUser failed: ${error.message}`
+ )
+ }
+ return null
+ }
+
+ return toAuthUser(data.user)
+ }
+
async signOut(options?: SignOutOptions): Promise {
const client = await this.resolveClient()
const { error } = await client.auth.signOut(
@@ -87,11 +117,90 @@ export class SupabaseAuthProvider implements AuthProvider {
}
}
+ async updateUser(input: UpdateUserInput): Promise {
+ const emailRedirectTo = input.email
+ ? await buildEmailVerificationRedirect(input.email)
+ : undefined
+
+ const client = await this.resolveClient()
+ const { data, error } = await client.auth.updateUser(
+ {
+ email: input.email,
+ password: input.password,
+ data: { name: input.name },
+ },
+ emailRedirectTo ? { emailRedirectTo } : undefined
+ )
+
+ if (!error) {
+ return { ok: true, user: toAuthUser(data.user) }
+ }
+
+ const code = mapSupabaseUpdateError(error.code)
+ // Preserve the original action behavior of throwing on unmapped errors so
+ // they surface as unexpected server errors.
+ if (!code) {
+ throw error
+ }
+
+ return { ok: false, code, message: error.message }
+ }
+
+ async startReauthForAccountSettings(): Promise {
+ return { kind: 'sign-out', returnTo: PROTECTED_URLS.ACCOUNT_SETTINGS }
+ }
+
+ async signOutOtherSessions(): Promise {
+ const client = await this.resolveClient()
+ const { error } = await client.auth.signOut({ scope: 'others' })
+
+ if (error) {
+ l.error(
+ {
+ key: 'auth_provider:sign_out_others:error',
+ error: serializeErrorForLog(error),
+ context: { error_code: error.code, error_status: error.status },
+ },
+ `supabase signOut(others) failed: ${error.message}`
+ )
+ }
+ }
+
private resolveClient(): Promise {
return Promise.resolve(this.client ?? createClient())
}
}
+async function buildEmailVerificationRedirect(email: string): Promise {
+ const origin = (await headers()).get('origin')
+ if (!origin) {
+ throw new Error('Missing origin header for email update redirect')
+ }
+
+ const url = new URL('/api/auth/email-callback', origin)
+ url.searchParams.set('new_email', email)
+ return url.toString()
+}
+
+function mapSupabaseUpdateError(
+ code: string | undefined
+): UpdateUserErrorCode | null {
+ switch (code) {
+ case 'email_address_invalid':
+ return 'email_invalid'
+ case 'email_exists':
+ return 'email_exists'
+ case 'same_password':
+ return 'same_password'
+ case 'weak_password':
+ return 'weak_password'
+ case 'reauthentication_needed':
+ return 'reauthentication_needed'
+ default:
+ return null
+ }
+}
+
export function createSupabaseAuthForProxy(
request: NextRequest,
response: NextResponse
diff --git a/src/core/server/auth/types.ts b/src/core/server/auth/types.ts
index 407521c75..d956396ba 100644
--- a/src/core/server/auth/types.ts
+++ b/src/core/server/auth/types.ts
@@ -26,3 +26,29 @@ export type SignOutResult = {
redirectTo: string
error?: AuthError | null
}
+
+export type UpdateUserInput = {
+ name?: string
+ email?: string
+ password?: string
+}
+
+// Expected, user-facing update failures. Anything else throws and is handled
+// as an unexpected server error by the action client.
+export type UpdateUserErrorCode =
+ | 'email_exists'
+ | 'email_invalid'
+ | 'weak_password'
+ | 'same_password'
+ | 'reauthentication_needed'
+
+export type UpdateUserResult =
+ | { ok: true; user: AuthUser }
+ | { ok: false; code: UpdateUserErrorCode; message?: string }
+
+// How the caller should drive the account-settings re-authentication step.
+// Supabase signs the user out and bounces through /sign-in; Ory forces a
+// fresh OAuth2 login via the oauth-start route.
+export type ReauthDispatch =
+ | { kind: 'sign-out'; returnTo: string }
+ | { kind: 'redirect'; to: string }
diff --git a/tests/integration/auth-ory-bootstrap.test.ts b/tests/integration/auth-ory-bootstrap.test.ts
index f46322737..f7e82a74b 100644
--- a/tests/integration/auth-ory-bootstrap.test.ts
+++ b/tests/integration/auth-ory-bootstrap.test.ts
@@ -69,6 +69,7 @@ describe('bootstrapOryUser (Auth.js events.signIn handler)', () => {
expect(apiPostMock).toHaveBeenCalledTimes(1)
expect(apiPostMock).toHaveBeenCalledWith('/admin/users/bootstrap', {
body: {
+ oidc_issuer: 'https://ory.example.test',
oidc_user_id: 'access-token-sub',
oidc_user_email: 'access-token-user@example.com',
oidc_user_name: 'Access Token User',
@@ -101,6 +102,7 @@ describe('bootstrapOryUser (Auth.js events.signIn handler)', () => {
expect(apiPostMock).toHaveBeenCalledWith('/admin/users/bootstrap', {
body: {
+ oidc_issuer: 'https://ory.example.test',
oidc_user_id: 'access-token-sub',
oidc_user_email: 'id-token-user@example.com',
oidc_user_name: 'Id Token User',
@@ -109,6 +111,29 @@ describe('bootstrapOryUser (Auth.js events.signIn handler)', () => {
})
})
+ it('skips the bootstrap call and logs when iss is missing', async () => {
+ await bootstrapOryUser({
+ accessToken: jwt({
+ sub: 'access-token-sub',
+ email: 'user@example.com',
+ }),
+ provider: 'ory',
+ })
+
+ expect(apiPostMock).not.toHaveBeenCalled()
+ expect(loggerMocks.error).toHaveBeenCalledWith(
+ expect.objectContaining({
+ key: 'auth_events:bootstrap_user:missing_claims',
+ context: expect.objectContaining({
+ has_iss: false,
+ has_sub: true,
+ has_email: true,
+ }),
+ }),
+ expect.stringContaining('missing required bootstrap claims')
+ )
+ })
+
it('logs but does not throw when the bootstrap call returns an api error', async () => {
apiPostMock.mockResolvedValue({
data: null,
@@ -119,6 +144,7 @@ describe('bootstrapOryUser (Auth.js events.signIn handler)', () => {
await expect(
bootstrapOryUser({
accessToken: jwt({
+ iss: 'https://ory.example.test',
sub: 'access-token-sub',
email: 'user@example.com',
name: 'User',
@@ -146,6 +172,7 @@ describe('bootstrapOryUser (Auth.js events.signIn handler)', () => {
await expect(
bootstrapOryUser({
accessToken: jwt({
+ iss: 'https://ory.example.test',
sub: 'access-token-sub',
email: 'user@example.com',
}),
diff --git a/tests/unit/auth-ory-callbacks.test.ts b/tests/unit/auth-ory-callbacks.test.ts
new file mode 100644
index 000000000..89bd54d34
--- /dev/null
+++ b/tests/unit/auth-ory-callbacks.test.ts
@@ -0,0 +1,132 @@
+import type { Session } from 'next-auth'
+import type { JWT } from 'next-auth/jwt'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const resolveIdentityMock = vi.hoisted(() => vi.fn())
+const refreshOryTokenMock = vi.hoisted(() => vi.fn())
+
+vi.mock('@/core/server/auth/ory/find-identity', () => ({
+ resolveOryIdentity: resolveIdentityMock,
+}))
+
+vi.mock('@/core/server/auth/ory/refresh-token', () => ({
+ refreshOryToken: refreshOryTokenMock,
+}))
+
+const { resolveOryJwt, applyTokenToSession } = await import(
+ '@/core/server/auth/ory/auth-callbacks'
+)
+
+function makeJwt(claims: Record): string {
+ const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString(
+ 'base64url'
+ )
+ const payload = Buffer.from(JSON.stringify(claims)).toString('base64url')
+ return `${header}.${payload}.sig`
+}
+
+const nowSeconds = Math.floor(Date.now() / 1000)
+
+describe('resolveOryJwt', () => {
+ beforeEach(() => {
+ resolveIdentityMock.mockReset()
+ refreshOryTokenMock.mockReset()
+ })
+
+ it('persists Ory tokens and the resolved Kratos id on fresh sign-in', async () => {
+ resolveIdentityMock.mockResolvedValue({ id: 'kratos-uuid' })
+
+ const result = await resolveOryJwt({
+ token: { sub: 'e2b-user-id', error: 'StalePoison' } as JWT,
+ account: {
+ provider: 'ory',
+ type: 'oidc',
+ providerAccountId: 'x',
+ access_token: 'at',
+ refresh_token: 'rt',
+ id_token: makeJwt({ email: 'ada@example.test' }),
+ expires_at: 1234,
+ },
+ profile: { sub: 'profile-sub' },
+ })
+
+ expect(resolveIdentityMock).toHaveBeenCalledWith({
+ subjects: ['profile-sub', 'e2b-user-id'],
+ email: 'ada@example.test',
+ })
+ expect(result).toMatchObject({
+ sub: 'e2b-user-id',
+ accessToken: 'at',
+ refreshToken: 'rt',
+ expiresAt: 1234,
+ identityId: 'kratos-uuid',
+ error: undefined,
+ })
+ })
+
+ it('leaves identityId undefined when resolution fails (sign-in not blocked)', async () => {
+ resolveIdentityMock.mockResolvedValue(null)
+
+ const result = await resolveOryJwt({
+ token: {} as JWT,
+ account: {
+ provider: 'ory',
+ type: 'oidc',
+ providerAccountId: 'x',
+ access_token: 'at',
+ },
+ })
+
+ expect(result.identityId).toBeUndefined()
+ expect(result.accessToken).toBe('at')
+ })
+
+ it('stops retrying once the token carries a refresh error', async () => {
+ const token = { error: 'RefreshTokenError', sub: 'x' } as JWT
+
+ const result = await resolveOryJwt({ token, account: null })
+
+ expect(result).toBe(token)
+ expect(refreshOryTokenMock).not.toHaveBeenCalled()
+ })
+
+ it('refreshes when the access token is near expiry', async () => {
+ refreshOryTokenMock.mockResolvedValue({ accessToken: 'fresh' })
+
+ const result = await resolveOryJwt({
+ token: { expiresAt: nowSeconds + 30 } as JWT,
+ account: null,
+ })
+
+ expect(refreshOryTokenMock).toHaveBeenCalled()
+ expect(result).toEqual({ accessToken: 'fresh' })
+ })
+
+ it('leaves a still-valid token untouched', async () => {
+ const token = { expiresAt: nowSeconds + 3600 } as JWT
+
+ const result = await resolveOryJwt({ token, account: null })
+
+ expect(result).toBe(token)
+ expect(refreshOryTokenMock).not.toHaveBeenCalled()
+ })
+})
+
+describe('applyTokenToSession', () => {
+ it('projects the token fields onto the session', () => {
+ const session = { user: { id: 'placeholder' } } as Session
+
+ const result = applyTokenToSession(session, {
+ sub: 'e2b-user-id',
+ accessToken: 'at',
+ idToken: 'it',
+ identityId: 'kratos-uuid',
+ error: undefined,
+ } as JWT)
+
+ expect(result.user.id).toBe('e2b-user-id')
+ expect(result.accessToken).toBe('at')
+ expect(result.idToken).toBe('it')
+ expect(result.identityId).toBe('kratos-uuid')
+ })
+})
diff --git a/tests/unit/auth-ory-find-identity.test.ts b/tests/unit/auth-ory-find-identity.test.ts
new file mode 100644
index 000000000..5648f5f19
--- /dev/null
+++ b/tests/unit/auth-ory-find-identity.test.ts
@@ -0,0 +1,162 @@
+import { ResponseError } from '@ory/client-fetch'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const loggerMocks = vi.hoisted(() => ({
+ error: vi.fn(),
+ warn: vi.fn(),
+ info: vi.fn(),
+ debug: vi.fn(),
+}))
+
+const getIdentityMock = vi.hoisted(() => vi.fn())
+const getIdentityByExternalIDMock = vi.hoisted(() => vi.fn())
+const listIdentitiesMock = vi.hoisted(() => vi.fn())
+
+vi.mock('@/core/shared/clients/logger/logger', () => ({
+ l: loggerMocks,
+ serializeErrorForLog: vi.fn((error: unknown) => error),
+}))
+
+vi.mock('@/core/server/auth/ory/client', () => ({
+ getOryIdentityApi: () => ({
+ getIdentity: getIdentityMock,
+ getIdentityByExternalID: getIdentityByExternalIDMock,
+ listIdentities: listIdentitiesMock,
+ }),
+}))
+
+const { resolveOryIdentity, findOryIdentityBySubject, findOryIdentityByEmail } =
+ await import('@/core/server/auth/ory/find-identity')
+
+function notFound(): ResponseError {
+ return new ResponseError(new Response(null, { status: 404 }), 'not found')
+}
+
+describe('findOryIdentityBySubject', () => {
+ beforeEach(() => {
+ getIdentityMock.mockReset()
+ getIdentityByExternalIDMock.mockReset()
+ loggerMocks.error.mockClear()
+ })
+
+ it('resolves by Kratos id without an external_id lookup', async () => {
+ getIdentityMock.mockResolvedValue({ id: 'sub-is-kratos-id' })
+
+ const identity = await findOryIdentityBySubject('sub-is-kratos-id')
+
+ expect(identity).toEqual({ id: 'sub-is-kratos-id' })
+ expect(getIdentityByExternalIDMock).not.toHaveBeenCalled()
+ })
+
+ it('falls back to external_id when the id lookup 404s', async () => {
+ getIdentityMock.mockRejectedValue(notFound())
+ getIdentityByExternalIDMock.mockResolvedValue({ id: 'kratos-uuid' })
+
+ const identity = await findOryIdentityBySubject('legacy-id')
+
+ expect(getIdentityByExternalIDMock).toHaveBeenCalledWith({
+ externalID: 'legacy-id',
+ })
+ expect(identity).toEqual({ id: 'kratos-uuid' })
+ })
+
+ it('returns null without a terminal error log when both miss', async () => {
+ getIdentityMock.mockRejectedValue(notFound())
+ getIdentityByExternalIDMock.mockRejectedValue(notFound())
+
+ const identity = await findOryIdentityBySubject('ghost')
+
+ expect(identity).toBeNull()
+ // the terminal not_found error belongs to resolveOryIdentity, not here
+ expect(loggerMocks.error).not.toHaveBeenCalled()
+ })
+})
+
+describe('findOryIdentityByEmail', () => {
+ beforeEach(() => {
+ listIdentitiesMock.mockReset()
+ loggerMocks.error.mockClear()
+ })
+
+ it('queries by credentials identifier and prefers an exact email match', async () => {
+ listIdentitiesMock.mockResolvedValue([
+ { id: 'other', traits: { email: 'someone@else.test' } },
+ { id: 'match', traits: { email: 'Ada@Example.test' } },
+ ])
+
+ const identity = await findOryIdentityByEmail('ada@example.test')
+
+ expect(listIdentitiesMock).toHaveBeenCalledWith({
+ credentialsIdentifier: 'ada@example.test',
+ pageSize: 2,
+ })
+ expect(identity?.id).toBe('match')
+ })
+
+ it('returns null when no identity has that credential', async () => {
+ listIdentitiesMock.mockResolvedValue([])
+
+ const identity = await findOryIdentityByEmail('nobody@example.test')
+
+ expect(identity).toBeNull()
+ })
+})
+
+describe('resolveOryIdentity', () => {
+ beforeEach(() => {
+ getIdentityMock.mockReset()
+ getIdentityByExternalIDMock.mockReset()
+ listIdentitiesMock.mockReset()
+ loggerMocks.error.mockClear()
+ })
+
+ it('tries subjects in order and returns the first hit', async () => {
+ getIdentityMock
+ .mockRejectedValueOnce(notFound()) // profile.sub by id
+ .mockResolvedValueOnce({ id: 'kratos-uuid' }) // token.sub by id
+ getIdentityByExternalIDMock.mockRejectedValue(notFound())
+
+ const identity = await resolveOryIdentity({
+ subjects: ['profile-sub', 'token-sub'],
+ })
+
+ expect(identity).toEqual({ id: 'kratos-uuid' })
+ expect(listIdentitiesMock).not.toHaveBeenCalled()
+ })
+
+ it('falls back to email when every subject misses', async () => {
+ getIdentityMock.mockRejectedValue(notFound())
+ getIdentityByExternalIDMock.mockRejectedValue(notFound())
+ listIdentitiesMock.mockResolvedValue([
+ { id: 'kratos-uuid', traits: { email: 'ada@example.test' } },
+ ])
+
+ const identity = await resolveOryIdentity({
+ subjects: ['e2b-user-id'],
+ email: 'ada@example.test',
+ })
+
+ expect(identity?.id).toBe('kratos-uuid')
+ })
+
+ it('de-dupes falsy/duplicate subjects and logs not_found when all strategies fail', async () => {
+ getIdentityMock.mockRejectedValue(notFound())
+ getIdentityByExternalIDMock.mockRejectedValue(notFound())
+ listIdentitiesMock.mockResolvedValue([])
+
+ const identity = await resolveOryIdentity({
+ subjects: ['x', 'x', null, undefined],
+ email: 'ghost@example.test',
+ })
+
+ expect(identity).toBeNull()
+ // 'x' resolved once despite duplicates → 1 id + 1 external_id call
+ expect(getIdentityMock).toHaveBeenCalledTimes(1)
+ expect(loggerMocks.error).toHaveBeenCalledWith(
+ expect.objectContaining({
+ key: 'auth_provider:resolve_identity:not_found',
+ }),
+ expect.any(String)
+ )
+ })
+})
diff --git a/tests/unit/auth-ory-flows.test.ts b/tests/unit/auth-ory-flows.test.ts
new file mode 100644
index 000000000..6eb5cc712
--- /dev/null
+++ b/tests/unit/auth-ory-flows.test.ts
@@ -0,0 +1,156 @@
+import { ResponseError } from '@ory/client-fetch'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const loggerMocks = vi.hoisted(() => ({
+ error: vi.fn(),
+ warn: vi.fn(),
+ info: vi.fn(),
+ debug: vi.fn(),
+}))
+
+const patchIdentityMock = vi.hoisted(() => vi.fn())
+
+vi.mock('@/core/shared/clients/logger/logger', () => ({
+ l: loggerMocks,
+ serializeErrorForLog: vi.fn((error: unknown) => error),
+}))
+
+vi.mock('@/core/server/auth/ory/client', () => ({
+ getOryIdentityApi: () => ({ patchIdentity: patchIdentityMock }),
+}))
+
+const { oryAuthFlows } = await import('@/core/server/auth/ory/flows')
+
+function oryError(
+ status: number,
+ body: Record
+): ResponseError {
+ return new ResponseError(
+ new Response(JSON.stringify(body), { status }),
+ 'Response returned an error code'
+ )
+}
+
+describe('oryAuthFlows.updateUser', () => {
+ beforeEach(() => {
+ patchIdentityMock.mockReset()
+ loggerMocks.error.mockClear()
+ })
+
+ it('patches only the provided traits and returns the mapped user', async () => {
+ patchIdentityMock.mockResolvedValue({
+ id: 'identity-1',
+ traits: { email: 'new@example.test', name: 'Ada' },
+ credentials: { password: {} },
+ })
+
+ const result = await oryAuthFlows.updateUser({
+ identityId: 'identity-1',
+ name: 'Ada',
+ email: 'new@example.test',
+ })
+
+ expect(patchIdentityMock).toHaveBeenCalledWith({
+ id: 'identity-1',
+ jsonPatch: [
+ { op: 'replace', path: '/traits/name', value: 'Ada' },
+ { op: 'replace', path: '/traits/email', value: 'new@example.test' },
+ ],
+ })
+ expect(result).toEqual({
+ ok: true,
+ user: expect.objectContaining({
+ id: 'identity-1',
+ email: 'new@example.test',
+ name: 'Ada',
+ // `password` credential is normalized to the `email` provider vocabulary
+ providers: ['email'],
+ }),
+ })
+ })
+
+ it('patches the password credential config when a password is provided', async () => {
+ patchIdentityMock.mockResolvedValue({
+ id: 'identity-1',
+ traits: { email: 'a@b.test' },
+ credentials: { password: {} },
+ })
+
+ await oryAuthFlows.updateUser({
+ identityId: 'identity-1',
+ password: 'super-secret',
+ })
+
+ expect(patchIdentityMock).toHaveBeenCalledWith({
+ id: 'identity-1',
+ jsonPatch: [
+ {
+ op: 'replace',
+ path: '/credentials/password/config/password',
+ value: 'super-secret',
+ },
+ ],
+ })
+ })
+
+ it('maps a 409 conflict to email_exists', async () => {
+ patchIdentityMock.mockRejectedValue(
+ oryError(409, {
+ error: { code: 409, reason: 'identity address already exists' },
+ })
+ )
+
+ const result = await oryAuthFlows.updateUser({
+ identityId: 'identity-1',
+ email: 'taken@example.test',
+ })
+
+ expect(result).toEqual({
+ ok: false,
+ code: 'email_exists',
+ message: undefined,
+ })
+ })
+
+ it('maps a 400 password policy violation to weak_password', async () => {
+ patchIdentityMock.mockRejectedValue(
+ oryError(400, {
+ error: {
+ code: 400,
+ reason: 'the password does not fulfill the password policy',
+ message: 'password too short',
+ },
+ })
+ )
+
+ const result = await oryAuthFlows.updateUser({
+ identityId: 'identity-1',
+ password: 'short',
+ })
+
+ expect(result).toEqual({
+ ok: false,
+ code: 'weak_password',
+ message: 'password too short',
+ })
+ })
+
+ it('rethrows unclassified Ory errors as unexpected', async () => {
+ patchIdentityMock.mockRejectedValue(
+ oryError(500, { error: { code: 500, reason: 'internal error' } })
+ )
+
+ await expect(
+ oryAuthFlows.updateUser({ identityId: 'identity-1', name: 'X' })
+ ).rejects.toBeInstanceOf(ResponseError)
+ expect(loggerMocks.error).toHaveBeenCalled()
+ })
+
+ it('rethrows non-Ory errors', async () => {
+ patchIdentityMock.mockRejectedValue(new Error('network down'))
+
+ await expect(
+ oryAuthFlows.updateUser({ identityId: 'identity-1', name: 'X' })
+ ).rejects.toThrow('network down')
+ })
+})
diff --git a/tests/unit/auth-ory-freshness.test.ts b/tests/unit/auth-ory-freshness.test.ts
new file mode 100644
index 000000000..d562687c5
--- /dev/null
+++ b/tests/unit/auth-ory-freshness.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from 'vitest'
+import {
+ isReauthFresh,
+ REAUTH_FRESHNESS_WINDOW_SECONDS,
+ readAuthTime,
+} from '@/core/server/auth/ory/freshness'
+
+function makeIdToken(claims: Record): string {
+ const header = Buffer.from(
+ JSON.stringify({ alg: 'RS256', typ: 'JWT' })
+ ).toString('base64url')
+ const payload = Buffer.from(JSON.stringify(claims)).toString('base64url')
+ return `${header}.${payload}.signature`
+}
+
+describe('readAuthTime', () => {
+ it('returns null for undefined token', () => {
+ expect(readAuthTime(undefined)).toBeNull()
+ })
+
+ it('returns null when auth_time claim is missing', () => {
+ expect(readAuthTime(makeIdToken({ sub: 'user-1' }))).toBeNull()
+ })
+
+ it('returns null when auth_time is not a number', () => {
+ expect(readAuthTime(makeIdToken({ auth_time: 'nope' }))).toBeNull()
+ })
+
+ it('returns the auth_time epoch seconds when present', () => {
+ expect(readAuthTime(makeIdToken({ auth_time: 1_700_000_000 }))).toBe(
+ 1_700_000_000
+ )
+ })
+
+ it('returns null for a malformed token', () => {
+ expect(readAuthTime('not-a-jwt')).toBeNull()
+ })
+})
+
+describe('isReauthFresh', () => {
+ const now = 1_700_000_000
+
+ it('is true when auth_time is within the window', () => {
+ const token = makeIdToken({ auth_time: now - 60 })
+ expect(isReauthFresh(token, now)).toBe(true)
+ })
+
+ it('is true exactly at the window boundary', () => {
+ const token = makeIdToken({
+ auth_time: now - REAUTH_FRESHNESS_WINDOW_SECONDS,
+ })
+ expect(isReauthFresh(token, now)).toBe(true)
+ })
+
+ it('is false when auth_time is older than the window', () => {
+ const token = makeIdToken({
+ auth_time: now - REAUTH_FRESHNESS_WINDOW_SECONDS - 1,
+ })
+ expect(isReauthFresh(token, now)).toBe(false)
+ })
+
+ it('is false when there is no id_token', () => {
+ expect(isReauthFresh(undefined, now)).toBe(false)
+ })
+})
diff --git a/tests/unit/auth-ory-identity.test.ts b/tests/unit/auth-ory-identity.test.ts
new file mode 100644
index 000000000..8cea7e7a2
--- /dev/null
+++ b/tests/unit/auth-ory-identity.test.ts
@@ -0,0 +1,57 @@
+import type { Identity } from '@ory/client-fetch'
+import { describe, expect, it } from 'vitest'
+import { fromOryIdentity } from '@/core/server/auth/ory/identity'
+
+function identity(partial: Partial): Identity {
+ return {
+ id: 'identity-1',
+ schema_id: 'default',
+ schema_url: '',
+ traits: {},
+ ...partial,
+ } as Identity
+}
+
+describe('fromOryIdentity providers normalization', () => {
+ it('maps the Kratos `password` credential to `email`', () => {
+ const user = fromOryIdentity(identity({ credentials: { password: {} } }))
+ expect(user.providers).toEqual(['email'])
+ })
+
+ it('maps `password` to `email` and preserves other keys like `oidc`', () => {
+ const user = fromOryIdentity(
+ identity({ credentials: { password: {}, oidc: {} } })
+ )
+ expect(user.providers).toEqual(['email', 'oidc'])
+ })
+
+ it('leaves oauth-only identities without the email provider', () => {
+ const user = fromOryIdentity(identity({ credentials: { oidc: {} } }))
+ expect(user.providers).toEqual(['oidc'])
+ })
+
+ it('returns no providers when credentials are absent', () => {
+ const user = fromOryIdentity(identity({ credentials: undefined }))
+ expect(user.providers).toEqual([])
+ })
+})
+
+describe('fromOryIdentity traits', () => {
+ it('reads the flat `name` trait (the project schema shape)', () => {
+ const user = fromOryIdentity(
+ identity({
+ traits: { email: 'ada@example.test', name: 'Ada Lovelace' },
+ credentials: { password: {} },
+ })
+ )
+ expect(user.email).toBe('ada@example.test')
+ expect(user.name).toBe('Ada Lovelace')
+ })
+
+ it('falls back to a nested { first, last } name', () => {
+ const user = fromOryIdentity(
+ identity({ traits: { name: { first: 'Ada', last: 'Lovelace' } } })
+ )
+ expect(user.name).toBe('Ada Lovelace')
+ })
+})
diff --git a/tests/unit/auth-ory-provider-account.test.ts b/tests/unit/auth-ory-provider-account.test.ts
new file mode 100644
index 000000000..2bc109ba9
--- /dev/null
+++ b/tests/unit/auth-ory-provider-account.test.ts
@@ -0,0 +1,224 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const loggerMocks = vi.hoisted(() => ({
+ error: vi.fn(),
+ warn: vi.fn(),
+ info: vi.fn(),
+ debug: vi.fn(),
+}))
+
+const authjsMock = vi.hoisted(() => vi.fn())
+const updateUserMock = vi.hoisted(() => vi.fn())
+const revokeSessionsMock = vi.hoisted(() => vi.fn())
+const resolveIdentityMock = vi.hoisted(() => vi.fn())
+
+vi.mock('@/core/shared/clients/logger/logger', () => ({
+ l: loggerMocks,
+ serializeErrorForLog: vi.fn((error: unknown) => error),
+}))
+
+vi.mock('@/auth', () => ({ auth: authjsMock }))
+
+vi.mock('@/core/server/auth/ory/flows', () => ({
+ oryAuthFlows: { updateUser: updateUserMock },
+}))
+
+vi.mock('@/core/server/auth/ory/find-identity', () => ({
+ resolveOryIdentity: resolveIdentityMock,
+}))
+
+vi.mock('@/core/server/auth/ory/kratos-session', () => ({
+ revokeKratosSessionsForIdentity: revokeSessionsMock,
+}))
+
+const { oryAuthProvider } = await import('@/core/server/auth/ory/provider')
+
+function makeIdToken(claims: Record): string {
+ const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString(
+ 'base64url'
+ )
+ const payload = Buffer.from(JSON.stringify(claims)).toString('base64url')
+ return `${header}.${payload}.sig`
+}
+
+const nowSeconds = Math.floor(Date.now() / 1000)
+
+describe('oryAuthProvider account operations', () => {
+ beforeEach(() => {
+ authjsMock.mockReset()
+ updateUserMock.mockReset()
+ revokeSessionsMock.mockReset()
+ resolveIdentityMock.mockReset()
+ // Vanilla case: the OIDC subject is the Kratos identity id.
+ resolveIdentityMock.mockResolvedValue({ id: 'identity-1' })
+ loggerMocks.error.mockClear()
+ })
+
+ describe('startReauthForAccountSettings', () => {
+ it('redirects through oauth-start with the reauth intent', async () => {
+ const dispatch = await oryAuthProvider.startReauthForAccountSettings()
+
+ expect(dispatch).toEqual({
+ kind: 'redirect',
+ to: '/api/auth/oauth-start?intent=reauth&returnTo=%2Fdashboard%2Faccount%3Freauth%3D1',
+ })
+ })
+ })
+
+ describe('updateUser', () => {
+ it('throws when there is no authenticated session', async () => {
+ authjsMock.mockResolvedValue(null)
+
+ await expect(oryAuthProvider.updateUser({ name: 'X' })).rejects.toThrow(
+ 'updateUser called without an authenticated Ory session'
+ )
+ })
+
+ it('forwards a name-only change without a freshness gate', async () => {
+ authjsMock.mockResolvedValue({
+ user: { id: 'identity-1' },
+ accessToken: 'a',
+ idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }),
+ })
+ updateUserMock.mockResolvedValue({ ok: true, user: { id: 'identity-1' } })
+
+ const result = await oryAuthProvider.updateUser({ name: 'Ada' })
+
+ expect(updateUserMock).toHaveBeenCalledWith({
+ identityId: 'identity-1',
+ name: 'Ada',
+ email: undefined,
+ password: undefined,
+ })
+ expect(result).toEqual({ ok: true, user: { id: 'identity-1' } })
+ })
+
+ it('uses the identity id cached on the session without a lookup', async () => {
+ authjsMock.mockResolvedValue({
+ user: { id: 'legacy-id' },
+ identityId: 'kratos-uuid',
+ accessToken: 'a',
+ idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }),
+ })
+ updateUserMock.mockResolvedValue({
+ ok: true,
+ user: { id: 'kratos-uuid' },
+ })
+
+ await oryAuthProvider.updateUser({ name: 'Ada' })
+
+ expect(resolveIdentityMock).not.toHaveBeenCalled()
+ expect(updateUserMock).toHaveBeenCalledWith(
+ expect.objectContaining({ identityId: 'kratos-uuid' })
+ )
+ })
+
+ it('patches the resolved Kratos id when the subject is an external_id', async () => {
+ authjsMock.mockResolvedValue({
+ user: { id: 'legacy-id' },
+ accessToken: 'a',
+ idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }),
+ })
+ resolveIdentityMock.mockResolvedValue({ id: 'kratos-uuid' })
+ updateUserMock.mockResolvedValue({
+ ok: true,
+ user: { id: 'kratos-uuid' },
+ })
+
+ await oryAuthProvider.updateUser({ name: 'Ada' })
+
+ expect(resolveIdentityMock).toHaveBeenCalledWith(
+ expect.objectContaining({ subjects: ['legacy-id'] })
+ )
+ expect(updateUserMock).toHaveBeenCalledWith(
+ expect.objectContaining({ identityId: 'kratos-uuid' })
+ )
+ })
+
+ it('throws when the Ory identity cannot be resolved', async () => {
+ authjsMock.mockResolvedValue({
+ user: { id: 'ghost' },
+ accessToken: 'a',
+ idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }),
+ })
+ resolveIdentityMock.mockResolvedValue(null)
+
+ await expect(oryAuthProvider.updateUser({ name: 'Ada' })).rejects.toThrow(
+ 'could not resolve an Ory identity'
+ )
+ expect(updateUserMock).not.toHaveBeenCalled()
+ })
+
+ it('requires reauth for a password change when auth_time is stale', async () => {
+ authjsMock.mockResolvedValue({
+ user: { id: 'identity-1' },
+ accessToken: 'a',
+ idToken: makeIdToken({ auth_time: nowSeconds - 10_000 }),
+ })
+
+ const result = await oryAuthProvider.updateUser({
+ password: 'new-secret',
+ })
+
+ expect(result).toEqual({ ok: false, code: 'reauthentication_needed' })
+ expect(updateUserMock).not.toHaveBeenCalled()
+ })
+
+ it('requires reauth for a password change when there is no id_token', async () => {
+ authjsMock.mockResolvedValue({
+ user: { id: 'identity-1' },
+ accessToken: 'a',
+ })
+
+ const result = await oryAuthProvider.updateUser({
+ password: 'new-secret',
+ })
+
+ expect(result).toEqual({ ok: false, code: 'reauthentication_needed' })
+ expect(updateUserMock).not.toHaveBeenCalled()
+ })
+
+ it('forwards a password change when auth_time is fresh', async () => {
+ authjsMock.mockResolvedValue({
+ user: { id: 'identity-1' },
+ accessToken: 'a',
+ idToken: makeIdToken({ auth_time: nowSeconds - 30 }),
+ })
+ updateUserMock.mockResolvedValue({ ok: true, user: { id: 'identity-1' } })
+
+ const result = await oryAuthProvider.updateUser({
+ password: 'new-secret',
+ })
+
+ expect(updateUserMock).toHaveBeenCalledWith({
+ identityId: 'identity-1',
+ name: undefined,
+ email: undefined,
+ password: 'new-secret',
+ })
+ expect(result).toEqual({ ok: true, user: { id: 'identity-1' } })
+ })
+ })
+
+ describe('signOutOtherSessions', () => {
+ it('revokes all Kratos sessions for the current identity', async () => {
+ authjsMock.mockResolvedValue({
+ user: { id: 'identity-1' },
+ accessToken: 'a',
+ })
+ revokeSessionsMock.mockResolvedValue(undefined)
+
+ await oryAuthProvider.signOutOtherSessions()
+
+ expect(revokeSessionsMock).toHaveBeenCalledWith('identity-1')
+ })
+
+ it('no-ops when there is no session', async () => {
+ authjsMock.mockResolvedValue(null)
+
+ await oryAuthProvider.signOutOtherSessions()
+
+ expect(revokeSessionsMock).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/tests/unit/auth-ory-provider-profile.test.ts b/tests/unit/auth-ory-provider-profile.test.ts
new file mode 100644
index 000000000..435576ec3
--- /dev/null
+++ b/tests/unit/auth-ory-provider-profile.test.ts
@@ -0,0 +1,121 @@
+import { ResponseError } from '@ory/client-fetch'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const loggerMocks = vi.hoisted(() => ({
+ error: vi.fn(),
+ warn: vi.fn(),
+ info: vi.fn(),
+ debug: vi.fn(),
+}))
+
+const authjsMock = vi.hoisted(() => vi.fn())
+const getIdentityMock = vi.hoisted(() => vi.fn())
+const getIdentityByExternalIDMock = vi.hoisted(() => vi.fn())
+
+vi.mock('@/core/shared/clients/logger/logger', () => ({
+ l: loggerMocks,
+ serializeErrorForLog: vi.fn((error: unknown) => error),
+}))
+
+vi.mock('@/auth', () => ({
+ auth: authjsMock,
+}))
+
+vi.mock('@/core/server/auth/ory/client', () => ({
+ getOryIdentityApi: () => ({
+ getIdentity: getIdentityMock,
+ getIdentityByExternalID: getIdentityByExternalIDMock,
+ }),
+}))
+
+const { oryAuthProvider } = await import('@/core/server/auth/ory/provider')
+
+describe('oryAuthProvider.getUserProfile', () => {
+ beforeEach(() => {
+ authjsMock.mockReset()
+ getIdentityMock.mockReset()
+ getIdentityByExternalIDMock.mockReset()
+ loggerMocks.error.mockClear()
+ })
+
+ it('returns the normalized profile from the live identity lookup', async () => {
+ authjsMock.mockResolvedValue({ user: { id: 'identity-1' } })
+ getIdentityMock.mockResolvedValue({
+ id: 'identity-1',
+ traits: { email: 'ada@example.test', name: 'Ada' },
+ credentials: { password: {} },
+ })
+
+ const profile = await oryAuthProvider.getUserProfile()
+
+ expect(getIdentityMock).toHaveBeenCalledWith({ id: 'identity-1' })
+ expect(profile).toEqual({
+ id: 'identity-1',
+ email: 'ada@example.test',
+ name: 'Ada',
+ avatarUrl: null,
+ providers: ['email'],
+ })
+ })
+
+ it('uses the identity id cached on the session, skipping external_id', async () => {
+ authjsMock.mockResolvedValue({
+ user: { id: 'legacy-id' },
+ identityId: 'kratos-uuid',
+ })
+ getIdentityMock.mockResolvedValue({
+ id: 'kratos-uuid',
+ traits: { email: 'ada@example.test' },
+ credentials: { password: {} },
+ })
+
+ const profile = await oryAuthProvider.getUserProfile()
+
+ expect(getIdentityMock).toHaveBeenCalledWith({ id: 'kratos-uuid' })
+ expect(getIdentityByExternalIDMock).not.toHaveBeenCalled()
+ expect(profile?.id).toBe('kratos-uuid')
+ })
+
+ it('returns null when there is no session', async () => {
+ authjsMock.mockResolvedValue(null)
+
+ const profile = await oryAuthProvider.getUserProfile()
+
+ expect(profile).toBeNull()
+ expect(getIdentityMock).not.toHaveBeenCalled()
+ })
+
+ it('falls back to external_id when the subject is not a Kratos id', async () => {
+ authjsMock.mockResolvedValue({ user: { id: 'legacy-id' } })
+ getIdentityMock.mockRejectedValue(
+ new ResponseError(new Response(null, { status: 404 }), 'not found')
+ )
+ getIdentityByExternalIDMock.mockResolvedValue({
+ id: 'kratos-uuid',
+ traits: { email: 'ada@example.test', name: 'Ada' },
+ credentials: { password: {} },
+ })
+
+ const profile = await oryAuthProvider.getUserProfile()
+
+ expect(getIdentityByExternalIDMock).toHaveBeenCalledWith({
+ externalID: 'legacy-id',
+ })
+ expect(profile?.id).toBe('kratos-uuid')
+ expect(profile?.providers).toEqual(['email'])
+ })
+
+ it('returns null when neither id nor external_id matches', async () => {
+ authjsMock.mockResolvedValue({ user: { id: 'missing' } })
+ getIdentityMock.mockRejectedValue(
+ new ResponseError(new Response(null, { status: 404 }), 'not found')
+ )
+ getIdentityByExternalIDMock.mockRejectedValue(
+ new ResponseError(new Response(null, { status: 404 }), 'not found')
+ )
+
+ const profile = await oryAuthProvider.getUserProfile()
+
+ expect(profile).toBeNull()
+ })
+})
From 4ad201955c5f8c359cb0ad9520b304d402c6f70a Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Thu, 28 May 2026 18:36:55 -0700
Subject: [PATCH 10/16] feat(account): user profile and account mutations over
tRPC
Replaces the user-update/access-token server actions with a tRPC user
router: a cached profile query (live Kratos lookup with a timeout fallback
to the session user), an update mutation returning a discriminated result,
and createAccessToken. The dashboard layout prefetches the profile and
team-gate injects it into DashboardContext; the account settings forms
consume the mutations and refresh the profile cache. Reauth remains a
redirect-throwing server action.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
src/app/dashboard/[teamSlug]/layout.tsx | 20 ++-
src/app/dashboard/[teamSlug]/team-gate.tsx | 17 ++-
src/core/application/user/queries.ts | 10 ++
src/core/server/actions/auth-actions.ts | 13 ++
src/core/server/actions/user-actions.ts | 140 ------------------
src/core/server/api/routers/index.ts | 2 +
src/core/server/api/routers/user.ts | 120 +++++++++++++++
.../dashboard/account/email-settings.tsx | 55 ++++---
.../dashboard/account/name-settings.tsx | 34 +++--
.../dashboard/account/password-settings.tsx | 60 ++++----
.../dashboard/account/reauth-dialog.tsx | 5 +-
.../dashboard/account/user-access-token.tsx | 18 +--
12 files changed, 268 insertions(+), 226 deletions(-)
create mode 100644 src/core/application/user/queries.ts
delete mode 100644 src/core/server/actions/user-actions.ts
create mode 100644 src/core/server/api/routers/user.ts
diff --git a/src/app/dashboard/[teamSlug]/layout.tsx b/src/app/dashboard/[teamSlug]/layout.tsx
index 389737aaa..99251f84d 100644
--- a/src/app/dashboard/[teamSlug]/layout.tsx
+++ b/src/app/dashboard/[teamSlug]/layout.tsx
@@ -6,6 +6,7 @@ import { COOKIE_KEYS } from '@/configs/cookies'
import { METADATA } from '@/configs/metadata'
import { AUTH_URLS } from '@/configs/urls'
import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries'
+import { DASHBOARD_USER_PROFILE_QUERY_OPTIONS } from '@/core/application/user/queries'
import { auth } from '@/core/server/auth'
import DashboardLayoutView from '@/features/dashboard/layouts/layout'
import Sidebar from '@/features/dashboard/sidebar/sidebar'
@@ -43,13 +44,24 @@ export default async function DashboardLayout({
throw redirect(AUTH_URLS.SIGN_IN)
}
- await prefetchAsync(
- trpc.teams.list.queryOptions(undefined, DASHBOARD_TEAMS_LIST_QUERY_OPTIONS)
- )
+ await Promise.all([
+ prefetchAsync(
+ trpc.teams.list.queryOptions(
+ undefined,
+ DASHBOARD_TEAMS_LIST_QUERY_OPTIONS
+ )
+ ),
+ prefetchAsync(
+ trpc.user.profile.queryOptions(
+ undefined,
+ DASHBOARD_USER_PROFILE_QUERY_OPTIONS
+ )
+ ),
+ ])
return (
-
+
diff --git a/src/app/dashboard/[teamSlug]/team-gate.tsx b/src/app/dashboard/[teamSlug]/team-gate.tsx
index 4278c92d0..db1ab0dbf 100644
--- a/src/app/dashboard/[teamSlug]/team-gate.tsx
+++ b/src/app/dashboard/[teamSlug]/team-gate.tsx
@@ -2,7 +2,7 @@
import { useQuery } from '@tanstack/react-query'
import { DASHBOARD_TEAMS_LIST_QUERY_OPTIONS } from '@/core/application/teams/queries'
-import type { AuthUser } from '@/core/server/auth'
+import { DASHBOARD_USER_PROFILE_QUERY_OPTIONS } from '@/core/application/user/queries'
import { DashboardContextProvider } from '@/features/dashboard/context'
import LoadingLayout from '@/features/dashboard/loading-layout'
import { useTRPC } from '@/trpc/client'
@@ -10,28 +10,33 @@ import Unauthorized from '../unauthorized'
interface DashboardTeamGateProps {
teamSlug: string
- user: AuthUser
children: React.ReactNode
}
export function DashboardTeamGate({
teamSlug,
- user,
children,
}: DashboardTeamGateProps) {
const trpc = useTRPC()
- const { data: teams, isPending } = useQuery(
+ const { data: teams, isPending: teamsPending } = useQuery(
trpc.teams.list.queryOptions(undefined, DASHBOARD_TEAMS_LIST_QUERY_OPTIONS)
)
- if (isPending) {
+ const { data: user, isPending: userPending } = useQuery(
+ trpc.user.profile.queryOptions(
+ undefined,
+ DASHBOARD_USER_PROFILE_QUERY_OPTIONS
+ )
+ )
+
+ if (teamsPending || userPending) {
return
}
const team = teams?.find((candidate) => candidate.slug === teamSlug)
- if (!team || !teams) {
+ if (!team || !teams || !user) {
return
}
diff --git a/src/core/application/user/queries.ts b/src/core/application/user/queries.ts
new file mode 100644
index 000000000..eaa1419d2
--- /dev/null
+++ b/src/core/application/user/queries.ts
@@ -0,0 +1,10 @@
+// Mirrors DASHBOARD_TEAMS_LIST_QUERY_OPTIONS: the profile is prefetched once in
+// the dashboard layout and treated as fresh on the client, so it isn't refetched
+// on every mount/focus. Cache updates after account mutations come from explicit
+// setQueryData calls in the account-settings forms.
+export const DASHBOARD_USER_PROFILE_QUERY_OPTIONS = {
+ staleTime: 5 * 60 * 1000,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+} as const
diff --git a/src/core/server/actions/auth-actions.ts b/src/core/server/actions/auth-actions.ts
index ff1d2cfdf..429eb7b17 100644
--- a/src/core/server/actions/auth-actions.ts
+++ b/src/core/server/actions/auth-actions.ts
@@ -369,3 +369,16 @@ export async function signOutAction(returnTo?: string) {
throw redirect(redirectTo)
}
+
+// Drives the account-settings re-authentication step. Supabase signs the user
+// out and bounces through /sign-in (which lands back on the account page with
+// ?reauth=1); Ory forces a fresh OAuth2 login via the oauth-start route.
+export async function reauthForAccountSettingsAction() {
+ const dispatch = await auth.startReauthForAccountSettings()
+
+ if (dispatch.kind === 'sign-out') {
+ return signOutAction(dispatch.returnTo)
+ }
+
+ throw redirect(dispatch.to)
+}
diff --git a/src/core/server/actions/user-actions.ts b/src/core/server/actions/user-actions.ts
deleted file mode 100644
index 78a97eb7b..000000000
--- a/src/core/server/actions/user-actions.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-'use server'
-
-import { revalidatePath } from 'next/cache'
-import { headers } from 'next/headers'
-import { returnValidationErrors } from 'next-safe-action'
-import { z } from 'zod'
-import { authActionClient } from '@/core/server/actions/client'
-import { auth } from '@/core/server/auth'
-import { supabaseAuthFlows } from '@/core/server/auth/supabase/flows'
-import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'
-import { generateE2BUserAccessToken } from '@/lib/utils/server'
-
-const UpdateUserSchema = z
- .object({
- email: z.email().optional(),
- password: z.string().min(8).optional(),
- name: z.string().min(1).max(100).optional(),
- })
- .refine(
- (data) => {
- return Boolean(data.email || data.password || data.name)
- },
- {
- message: 'At least one field must be provided (email, password, name)',
- path: [],
- }
- )
-
-export type UpdateUserSchemaType = z.infer
-
-export const updateUserAction = authActionClient
- .schema(UpdateUserSchema)
- .metadata({ actionName: 'updateUser' })
- .action(async ({ parsedInput, ctx }) => {
- const { user } = ctx
-
- // basic security check, that password does not equal e-mail
- if (parsedInput.password) {
- const passwordAsUserEmail =
- parsedInput.password.toLowerCase() === user?.email?.toLowerCase()
- const passwordAsEmail =
- parsedInput.password.toLowerCase() === parsedInput.email?.toLowerCase()
-
- if (passwordAsUserEmail || passwordAsEmail) {
- return returnValidationErrors(UpdateUserSchema, {
- password: {
- _errors: ['Password is too weak.'],
- },
- })
- }
- }
-
- const origin = (await headers()).get('origin')
-
- let emailRedirectTo: string | undefined
-
- if (parsedInput.email) {
- if (!origin) {
- throw new Error('Missing origin header for email update redirect')
- }
-
- const redirectUrl = new URL('/api/auth/email-callback', origin)
- redirectUrl.searchParams.set('new_email', parsedInput.email)
- emailRedirectTo = redirectUrl.toString()
- }
-
- const { data: updateData, error } = await supabaseAuthFlows.updateUser({
- email: parsedInput.email,
- password: parsedInput.password,
- name: parsedInput.name,
- emailRedirectTo,
- })
-
- if (!error) {
- // ensure other sessions are logged out if password was changed
- if (parsedInput.password) {
- const { error: signOutError } = await auth.signOut({ scope: 'others' })
-
- if (signOutError) {
- l.error(
- {
- key: 'update_user_action:sign_out_others_failed',
- user_id: user.id,
- error: serializeErrorForLog(signOutError),
- },
- 'failed to invalidate other sessions after password change'
- )
- }
- }
-
- revalidatePath('/dashboard', 'layout')
-
- return {
- user: updateData.user,
- }
- }
-
- switch (error?.code) {
- case 'email_address_invalid':
- return returnValidationErrors(UpdateUserSchema, {
- email: {
- _errors: ['Invalid e-mail address.'],
- },
- })
- case 'email_exists':
- return returnValidationErrors(UpdateUserSchema, {
- email: {
- _errors: ['E-mail already in use.'],
- },
- })
- case 'same_password':
- return returnValidationErrors(UpdateUserSchema, {
- password: {
- _errors: ['New password cannot be the same as the old password.'],
- },
- })
- case 'weak_password':
- return returnValidationErrors(UpdateUserSchema, {
- password: {
- _errors: ['Password is too weak.'],
- },
- })
- case 'reauthentication_needed':
- return {
- requiresReauth: true,
- }
- default:
- throw error
- }
- })
-
-export const getUserAccessTokenAction = authActionClient
- .metadata({ actionName: 'getUserAccessToken' })
- .action(async ({ ctx }) => {
- const { session } = ctx
-
- const token = await generateE2BUserAccessToken(session.access_token)
-
- return token
- })
diff --git a/src/core/server/api/routers/index.ts b/src/core/server/api/routers/index.ts
index 530503282..eb8ad34a8 100644
--- a/src/core/server/api/routers/index.ts
+++ b/src/core/server/api/routers/index.ts
@@ -6,6 +6,7 @@ import { sandboxesRouter } from './sandboxes'
import { supportRouter } from './support'
import { teamsRouter } from './teams'
import { templatesRouter } from './templates'
+import { userRouter } from './user'
export const trpcAppRouter = createTRPCRouter({
sandbox: sandboxRouter,
@@ -15,6 +16,7 @@ export const trpcAppRouter = createTRPCRouter({
billing: billingRouter,
support: supportRouter,
teams: teamsRouter,
+ user: userRouter,
})
export type TRPCAppRouter = typeof trpcAppRouter
diff --git a/src/core/server/api/routers/user.ts b/src/core/server/api/routers/user.ts
new file mode 100644
index 000000000..b6aeb02bb
--- /dev/null
+++ b/src/core/server/api/routers/user.ts
@@ -0,0 +1,120 @@
+import { TRPCError } from '@trpc/server'
+import { z } from 'zod'
+import type { AuthUser } from '@/core/server/auth'
+import { createAuthForHeaders } from '@/core/server/auth'
+import { createTRPCRouter } from '@/core/server/trpc/init'
+import { protectedProcedure } from '@/core/server/trpc/procedures'
+import { l } from '@/core/shared/clients/logger/logger'
+import { generateE2BUserAccessToken } from '@/lib/utils/server'
+
+// How long the live identity-provider profile lookup is allowed to take before
+// we fall back to the cheap session user. Keeps a slow Ory admin API out of the
+// critical render path for every dashboard page.
+const PROFILE_LOOKUP_TIMEOUT_MS = 3000
+
+const UpdateUserSchema = z
+ .object({
+ email: z.email().optional(),
+ password: z.string().min(8).optional(),
+ name: z.string().min(1).max(100).optional(),
+ })
+ .refine((data) => Boolean(data.email || data.password || data.name), {
+ message: 'At least one field must be provided (email, password, name)',
+ path: [],
+ })
+
+const TIMEOUT = Symbol('profile-lookup-timeout')
+
+function withTimeout(
+ promise: Promise,
+ ms: number
+): Promise {
+ return Promise.race([
+ promise,
+ new Promise((resolve) => {
+ setTimeout(() => resolve(TIMEOUT), ms)
+ }),
+ ])
+}
+
+export const userRouter = createTRPCRouter({
+ // Live profile (full traits + credential-derived providers). Prefetched once
+ // per dashboard load and injected into DashboardContext. The lookup is raced
+ // against a timeout and falls back to the cheap session user so the dashboard
+ // never hangs on the identity provider.
+ profile: protectedProcedure.query(async ({ ctx }): Promise => {
+ const provider = createAuthForHeaders(ctx.headers)
+
+ const result = await withTimeout(
+ provider.getUserProfile().catch(() => null),
+ PROFILE_LOOKUP_TIMEOUT_MS
+ )
+
+ if (result && result !== TIMEOUT) {
+ return result
+ }
+
+ l.error(
+ {
+ key: 'trpc_user_profile:fallback',
+ user_id: ctx.user.id,
+ context: { timed_out: result === TIMEOUT },
+ },
+ 'user profile lookup failed or timed out; falling back to session user'
+ )
+
+ return ctx.user
+ }),
+
+ update: protectedProcedure
+ .input(UpdateUserSchema)
+ .mutation(async ({ ctx, input }) => {
+ // Basic security check: a password must not equal the account email
+ // (current or the new one being set in the same request).
+ if (input.password) {
+ const password = input.password.toLowerCase()
+ const matchesCurrentEmail = password === ctx.user.email?.toLowerCase()
+ const matchesNewEmail =
+ input.email !== undefined && password === input.email.toLowerCase()
+
+ if (matchesCurrentEmail || matchesNewEmail) {
+ return { status: 'error' as const, code: 'weak_password' as const }
+ }
+ }
+
+ const provider = createAuthForHeaders(ctx.headers)
+ const result = await provider.updateUser({
+ email: input.email,
+ password: input.password,
+ name: input.name,
+ })
+
+ if (result.ok) {
+ // Invalidate other sessions when the password changed.
+ if (input.password) {
+ await provider.signOutOtherSessions()
+ }
+
+ return { status: 'ok' as const, user: result.user }
+ }
+
+ if (result.code === 'reauthentication_needed') {
+ return { status: 'reauth' as const }
+ }
+
+ return { status: 'error' as const, code: result.code }
+ }),
+
+ // Creates (POSTs) a fresh E2B access token — non-idempotent, fired on demand.
+ createAccessToken: protectedProcedure.mutation(async ({ ctx }) => {
+ try {
+ return await generateE2BUserAccessToken(ctx.session.access_token)
+ } catch (error) {
+ throw new TRPCError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: 'Failed to generate access token',
+ cause: error,
+ })
+ }
+ }),
+})
diff --git a/src/features/dashboard/account/email-settings.tsx b/src/features/dashboard/account/email-settings.tsx
index 6d363eb1f..65f1ae5d6 100644
--- a/src/features/dashboard/account/email-settings.tsx
+++ b/src/features/dashboard/account/email-settings.tsx
@@ -1,19 +1,19 @@
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useSearchParams } from 'next/navigation'
-import { useAction } from 'next-safe-action/hooks'
import { useEffect, useMemo } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { USER_MESSAGES } from '@/configs/user-messages'
-import { updateUserAction } from '@/core/server/actions/user-actions'
import {
defaultErrorToast,
defaultSuccessToast,
useToast,
} from '@/lib/hooks/use-toast'
import { cn } from '@/lib/utils'
+import { useTRPC } from '@/trpc/client'
import { Button } from '@/ui/primitives/button'
import {
Card,
@@ -49,6 +49,8 @@ export function EmailSettings({ className }: EmailSettingsProps) {
const { user } = useDashboard()
const searchParams = useSearchParams()
const { toast } = useToast()
+ const trpc = useTRPC()
+ const queryClient = useQueryClient()
const form = useForm({
resolver: zodResolver(formSchema),
@@ -65,25 +67,36 @@ export function EmailSettings({ className }: EmailSettingsProps) {
[user]
)
- const { execute: updateEmail, isPending } = useAction(updateUserAction, {
- onSuccess: () => {
- toast(
- defaultSuccessToast(USER_MESSAGES.emailUpdateVerification.message, {
- duration: USER_MESSAGES.emailUpdateVerification.timeoutMs,
- })
- )
- },
- onError: ({ error }) => {
- if (error.validationErrors?.fieldErrors?.email?.[0]) {
- form.setError('email', {
- message: error.validationErrors.fieldErrors.email?.[0],
- })
- return
- }
-
- toast(defaultErrorToast(error.serverError || 'Failed to update e-mail.'))
- },
- })
+ const { mutate: updateEmail, isPending } = useMutation(
+ trpc.user.update.mutationOptions({
+ onSuccess: (data) => {
+ if (data.status === 'ok') {
+ queryClient.setQueryData(trpc.user.profile.queryKey(), data.user)
+ toast(
+ defaultSuccessToast(USER_MESSAGES.emailUpdateVerification.message, {
+ duration: USER_MESSAGES.emailUpdateVerification.timeoutMs,
+ })
+ )
+ return
+ }
+
+ if (data.status === 'error' && data.code === 'email_exists') {
+ form.setError('email', { message: 'E-mail already in use.' })
+ return
+ }
+
+ if (data.status === 'error' && data.code === 'email_invalid') {
+ form.setError('email', { message: 'Invalid e-mail address.' })
+ return
+ }
+
+ toast(defaultErrorToast('Failed to update e-mail.'))
+ },
+ onError: () => {
+ toast(defaultErrorToast('Failed to update e-mail.'))
+ },
+ })
+ )
useEffect(() => {
if (
diff --git a/src/features/dashboard/account/name-settings.tsx b/src/features/dashboard/account/name-settings.tsx
index 44cb659f5..4bd493eec 100644
--- a/src/features/dashboard/account/name-settings.tsx
+++ b/src/features/dashboard/account/name-settings.tsx
@@ -1,17 +1,17 @@
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
-import { useAction } from 'next-safe-action/hooks'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { USER_MESSAGES } from '@/configs/user-messages'
-import { updateUserAction } from '@/core/server/actions/user-actions'
import {
defaultErrorToast,
defaultSuccessToast,
useToast,
} from '@/lib/hooks/use-toast'
import { cn } from '@/lib/utils'
+import { useTRPC } from '@/trpc/client'
import { Button } from '@/ui/primitives/button'
import {
Card,
@@ -49,6 +49,8 @@ export function NameSettings({ className }: NameSettingsProps) {
const { user } = useDashboard()
const { toast } = useToast()
+ const trpc = useTRPC()
+ const queryClient = useQueryClient()
const form = useForm({
resolver: zodResolver(formSchema),
@@ -60,18 +62,22 @@ export function NameSettings({ className }: NameSettingsProps) {
},
})
- const { execute: updateName, isPending } = useAction(updateUserAction, {
- onSuccess: async () => {
- toast(defaultSuccessToast(USER_MESSAGES.nameUpdated.message))
- },
- onError: ({ error }) => {
- toast(
- defaultErrorToast(
- error.serverError || USER_MESSAGES.failedUpdateName.message
- )
- )
- },
- })
+ const { mutate: updateName, isPending } = useMutation(
+ trpc.user.update.mutationOptions({
+ onSuccess: (data) => {
+ if (data.status === 'ok') {
+ queryClient.setQueryData(trpc.user.profile.queryKey(), data.user)
+ toast(defaultSuccessToast(USER_MESSAGES.nameUpdated.message))
+ return
+ }
+
+ toast(defaultErrorToast(USER_MESSAGES.failedUpdateName.message))
+ },
+ onError: () => {
+ toast(defaultErrorToast(USER_MESSAGES.failedUpdateName.message))
+ },
+ })
+ )
if (!user) return null
diff --git a/src/features/dashboard/account/password-settings.tsx b/src/features/dashboard/account/password-settings.tsx
index 6f240c90f..ebe64d419 100644
--- a/src/features/dashboard/account/password-settings.tsx
+++ b/src/features/dashboard/account/password-settings.tsx
@@ -1,18 +1,18 @@
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
-import { useAction } from 'next-safe-action/hooks'
+import { useMutation } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { USER_MESSAGES } from '@/configs/user-messages'
-import { updateUserAction } from '@/core/server/actions/user-actions'
import {
defaultErrorToast,
defaultSuccessToast,
useToast,
} from '@/lib/hooks/use-toast'
import { cn } from '@/lib/utils'
+import { useTRPC } from '@/trpc/client'
import { Button } from '@/ui/primitives/button'
import {
Card,
@@ -61,6 +61,7 @@ export function PasswordSettings({
const { user } = useDashboard()
const { toast } = useToast()
+ const trpc = useTRPC()
const [reauthDialogOpen, setReauthDialogOpen] = useState(false)
const [clientShowPasswordForm, setClientShowPasswordForm] = useState(
showPasswordChangeForm
@@ -83,33 +84,34 @@ export function PasswordSettings({
},
})
- const { execute: updatePassword, isPending } = useAction(updateUserAction, {
- onSuccess: ({ data }) => {
- if (data?.requiresReauth) {
- setReauthDialogOpen(true)
- return
- }
-
- toast(defaultSuccessToast(USER_MESSAGES.passwordUpdated.message))
-
- form.reset()
- setClientShowPasswordForm(false)
- window.history.replaceState({}, '', window.location.pathname)
- },
- onError: ({ error }) => {
- if (error.validationErrors?.fieldErrors?.password) {
- form.setError('confirmPassword', {
- message: error.validationErrors.fieldErrors.password?.[0],
- })
- } else {
- toast(
- defaultErrorToast(
- error.serverError || USER_MESSAGES.failedUpdatePassword.message
- )
- )
- }
- },
- })
+ const { mutate: updatePassword, isPending } = useMutation(
+ trpc.user.update.mutationOptions({
+ onSuccess: (data) => {
+ if (data.status === 'reauth') {
+ setReauthDialogOpen(true)
+ return
+ }
+
+ if (data.status === 'error') {
+ const message =
+ data.code === 'same_password'
+ ? 'New password cannot be the same as the old password.'
+ : 'Password is too weak.'
+ form.setError('confirmPassword', { message })
+ return
+ }
+
+ toast(defaultSuccessToast(USER_MESSAGES.passwordUpdated.message))
+
+ form.reset()
+ setClientShowPasswordForm(false)
+ window.history.replaceState({}, '', window.location.pathname)
+ },
+ onError: () => {
+ toast(defaultErrorToast(USER_MESSAGES.failedUpdatePassword.message))
+ },
+ })
+ )
function onSubmit(values: FormValues) {
updatePassword({ password: values.password })
diff --git a/src/features/dashboard/account/reauth-dialog.tsx b/src/features/dashboard/account/reauth-dialog.tsx
index c5d322709..c85da5d9d 100644
--- a/src/features/dashboard/account/reauth-dialog.tsx
+++ b/src/features/dashboard/account/reauth-dialog.tsx
@@ -1,7 +1,6 @@
'use client'
-import { PROTECTED_URLS } from '@/configs/urls'
-import { signOutAction } from '@/core/server/actions/auth-actions'
+import { reauthForAccountSettingsAction } from '@/core/server/actions/auth-actions'
import { AlertDialog } from '@/ui/alert-dialog'
interface ReauthDialogProps {
@@ -11,7 +10,7 @@ interface ReauthDialogProps {
export function ReauthDialog({ open, onOpenChange }: ReauthDialogProps) {
const handleReauth = () => {
- signOutAction(PROTECTED_URLS.ACCOUNT_SETTINGS)
+ reauthForAccountSettingsAction()
}
return (
diff --git a/src/features/dashboard/account/user-access-token.tsx b/src/features/dashboard/account/user-access-token.tsx
index 433b26c22..23ea04127 100644
--- a/src/features/dashboard/account/user-access-token.tsx
+++ b/src/features/dashboard/account/user-access-token.tsx
@@ -1,9 +1,9 @@
'use client'
-import { useAction } from 'next-safe-action/hooks'
+import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
-import { getUserAccessTokenAction } from '@/core/server/actions/user-actions'
import { defaultErrorToast, useToast } from '@/lib/hooks/use-toast'
+import { useTRPC } from '@/trpc/client'
import CopyButton from '@/ui/copy-button'
import { IconButton } from '@/ui/primitives/icon-button'
import { EyeIcon, EyeOffIcon } from '@/ui/primitives/icons'
@@ -16,22 +16,22 @@ interface UserAccessTokenProps {
export default function UserAccessToken({ className }: UserAccessTokenProps) {
const { toast } = useToast()
+ const trpc = useTRPC()
const [token, setToken] = useState()
const [isVisible, setIsVisible] = useState(false)
- const { execute: fetchToken, isPending } = useAction(
- getUserAccessTokenAction,
- {
- onSuccess: (result) => {
- if (result.data) {
- setToken(result.data.token)
+ const { mutate: fetchToken, isPending } = useMutation(
+ trpc.user.createAccessToken.mutationOptions({
+ onSuccess: (data) => {
+ if (data?.token) {
+ setToken(data.token)
setIsVisible(true)
}
},
onError: () => {
toast(defaultErrorToast('Failed to fetch access token'))
},
- }
+ })
)
return (
From 56f847177da2bd94d165f8ef7f4b4b2f0a118b0b Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Fri, 29 May 2026 10:01:23 -0700
Subject: [PATCH 11/16] fix(auth): set Ory password via updateIdentity so
Kratos hashes it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A JSON-Patch write to /credentials/password/config/password is accepted with
200 but stored raw — hashed_password is left untouched, so the change appeared
to succeed while the OLD password kept working and the new one never did.
Route password changes through updateIdentity (the credential import path),
which Kratos hashes; trait-only changes keep the lighter patch. Re-sends
schema_id/state/traits/external_id/metadata so the full update doesn't clobber
them, and preserves existing non-password credentials (e.g. oidc).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
src/core/server/auth/ory/flows.ts | 88 ++++++++++++++++++++++++-------
tests/unit/auth-ory-flows.test.ts | 47 ++++++++++++-----
2 files changed, 103 insertions(+), 32 deletions(-)
diff --git a/src/core/server/auth/ory/flows.ts b/src/core/server/auth/ory/flows.ts
index 413b7bb94..5157f8cd6 100644
--- a/src/core/server/auth/ory/flows.ts
+++ b/src/core/server/auth/ory/flows.ts
@@ -1,6 +1,10 @@
import 'server-only'
-import { type JsonPatch, JsonPatchOpEnum } from '@ory/client-fetch'
+import {
+ type Identity,
+ type JsonPatch,
+ JsonPatchOpEnum,
+} from '@ory/client-fetch'
import { l } from '@/core/shared/clients/logger/logger'
import type { UpdateUserErrorCode, UpdateUserResult } from '../types'
import { getOryIdentityApi } from './client'
@@ -21,13 +25,13 @@ export const oryAuthFlows = {
email,
password,
}: OryUpdateUserInput): Promise {
- const jsonPatch = buildIdentityPatches({ name, email, password })
-
try {
- const identity = await getOryIdentityApi().patchIdentity({
- id: identityId,
- jsonPatch,
- })
+ // A password change must go through updateIdentity (the credential import
+ // path) — see setPassword. Trait-only changes use the lighter patch.
+ const identity =
+ password !== undefined
+ ? await setPassword(identityId, { name, email, password })
+ : await patchTraits(identityId, { name, email })
return { ok: true, user: fromOryIdentity(identity) }
} catch (error) {
@@ -36,13 +40,66 @@ export const oryAuthFlows = {
},
}
+// Kratos only hashes a cleartext password when it runs through the credential
+// IMPORT pipeline (updateIdentity / createIdentity). A JSON-Patch write to
+// `/credentials/password/config/password` is accepted with 200 but stored raw —
+// `hashed_password` is left untouched, so the change appears to succeed while
+// the OLD password keeps working and the new one never does. So we set the
+// password via updateIdentity (PUT). Only the password credential is supplied,
+// which Kratos hashes; existing credentials (e.g. oidc) are preserved. We
+// re-send schema_id/state/traits/external_id/metadata to avoid clobbering them
+// on the full update.
+async function setPassword(
+ identityId: string,
+ { name, email, password }: Omit
+): Promise {
+ const api = getOryIdentityApi()
+ const current = await api.getIdentity({ id: identityId })
+
+ return api.updateIdentity({
+ id: identityId,
+ updateIdentityBody: {
+ schema_id: current.schema_id,
+ state: current.state ?? 'active',
+ traits: mergeTraits(current.traits, { name, email }),
+ external_id: current.external_id,
+ metadata_public: current.metadata_public,
+ metadata_admin: current.metadata_admin,
+ credentials: { password: { config: { password } } },
+ },
+ })
+}
+
+async function patchTraits(
+ identityId: string,
+ { name, email }: Pick
+): Promise {
+ const api = getOryIdentityApi()
+ const jsonPatch = buildTraitPatches({ name, email })
+
+ if (jsonPatch.length === 0) {
+ return api.getIdentity({ id: identityId })
+ }
+
+ return api.patchIdentity({ id: identityId, jsonPatch })
+}
+
+function mergeTraits(
+ current: unknown,
+ { name, email }: Pick
+): Record {
+ const traits = { ...((current as Record) ?? {}) }
+ if (name !== undefined) traits.name = name
+ if (email !== undefined) traits.email = email
+ return traits
+}
+
// Assumes a flat `name` trait. If the project's identity schema nests name as
-// `{ first, last }`, this patch path needs to target those sub-paths instead.
-function buildIdentityPatches({
+// `{ first, last }`, these patch paths need to target those sub-paths instead.
+function buildTraitPatches({
name,
email,
- password,
-}: Omit): JsonPatch[] {
+}: Pick): JsonPatch[] {
const patches: JsonPatch[] = []
if (name !== undefined) {
@@ -59,15 +116,6 @@ function buildIdentityPatches({
value: email,
})
}
- if (password !== undefined) {
- // The password-settings UI is only shown for identities that already have
- // the email/password credential, so the config object exists to replace.
- patches.push({
- op: JsonPatchOpEnum.Replace,
- path: '/credentials/password/config/password',
- value: password,
- })
- }
return patches
}
diff --git a/tests/unit/auth-ory-flows.test.ts b/tests/unit/auth-ory-flows.test.ts
index 6eb5cc712..1c0881aea 100644
--- a/tests/unit/auth-ory-flows.test.ts
+++ b/tests/unit/auth-ory-flows.test.ts
@@ -9,6 +9,8 @@ const loggerMocks = vi.hoisted(() => ({
}))
const patchIdentityMock = vi.hoisted(() => vi.fn())
+const getIdentityMock = vi.hoisted(() => vi.fn())
+const updateIdentityMock = vi.hoisted(() => vi.fn())
vi.mock('@/core/shared/clients/logger/logger', () => ({
l: loggerMocks,
@@ -16,7 +18,11 @@ vi.mock('@/core/shared/clients/logger/logger', () => ({
}))
vi.mock('@/core/server/auth/ory/client', () => ({
- getOryIdentityApi: () => ({ patchIdentity: patchIdentityMock }),
+ getOryIdentityApi: () => ({
+ patchIdentity: patchIdentityMock,
+ getIdentity: getIdentityMock,
+ updateIdentity: updateIdentityMock,
+ }),
}))
const { oryAuthFlows } = await import('@/core/server/auth/ory/flows')
@@ -34,6 +40,8 @@ function oryError(
describe('oryAuthFlows.updateUser', () => {
beforeEach(() => {
patchIdentityMock.mockReset()
+ getIdentityMock.mockReset()
+ updateIdentityMock.mockReset()
loggerMocks.error.mockClear()
})
@@ -69,8 +77,15 @@ describe('oryAuthFlows.updateUser', () => {
})
})
- it('patches the password credential config when a password is provided', async () => {
- patchIdentityMock.mockResolvedValue({
+ it('sets the password via updateIdentity (import path) so Kratos hashes it', async () => {
+ getIdentityMock.mockResolvedValue({
+ id: 'identity-1',
+ schema_id: 'default',
+ state: 'active',
+ traits: { email: 'a@b.test', name: 'Ada' },
+ external_id: 'legacy-id',
+ })
+ updateIdentityMock.mockResolvedValue({
id: 'identity-1',
traits: { email: 'a@b.test' },
credentials: { password: {} },
@@ -81,15 +96,17 @@ describe('oryAuthFlows.updateUser', () => {
password: 'super-secret',
})
- expect(patchIdentityMock).toHaveBeenCalledWith({
+ // not the raw patch — that writes cleartext without hashing
+ expect(patchIdentityMock).not.toHaveBeenCalled()
+ expect(updateIdentityMock).toHaveBeenCalledWith({
id: 'identity-1',
- jsonPatch: [
- {
- op: 'replace',
- path: '/credentials/password/config/password',
- value: 'super-secret',
- },
- ],
+ updateIdentityBody: expect.objectContaining({
+ schema_id: 'default',
+ state: 'active',
+ external_id: 'legacy-id',
+ traits: { email: 'a@b.test', name: 'Ada' },
+ credentials: { password: { config: { password: 'super-secret' } } },
+ }),
})
})
@@ -113,7 +130,13 @@ describe('oryAuthFlows.updateUser', () => {
})
it('maps a 400 password policy violation to weak_password', async () => {
- patchIdentityMock.mockRejectedValue(
+ getIdentityMock.mockResolvedValue({
+ id: 'identity-1',
+ schema_id: 'default',
+ state: 'active',
+ traits: { email: 'a@b.test' },
+ })
+ updateIdentityMock.mockRejectedValue(
oryError(400, {
error: {
code: 400,
From f002873c2cddfa38a8b50b7e4d9014e6de320229 Mon Sep 17 00:00:00 2001
From: ben-fornefeld
Date: Fri, 29 May 2026 10:01:30 -0700
Subject: [PATCH 12/16] fix(auth): harden account re-auth (hard-nav redirect +
gate email changes)
- Reauth returns the oauth-start URL for a client window.location navigation
instead of a server-action redirect(). The soft RSC navigation was
prefetching/re-invoking the side-effecting oauth-start GET, corrupting the
OAuth state/callback-url cookies so the post-reauth callback fell back to '/'.
- Require fresh re-authentication for EMAIL changes too (not just password):
otherwise a stolen session could take over the account by swapping the email
and resetting the password via the attacker's inbox. Wire the email form to
the reauth dialog and make the (now shared) dialog copy generic.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
src/core/server/actions/auth-actions.ts | 24 +++-
src/core/server/auth/ory/provider.ts | 9 +-
.../dashboard/account/email-settings.tsx | 116 ++++++++++--------
.../dashboard/account/reauth-dialog.tsx | 12 +-
tests/unit/auth-ory-provider-account.test.ts | 15 +++
5 files changed, 112 insertions(+), 64 deletions(-)
diff --git a/src/core/server/actions/auth-actions.ts b/src/core/server/actions/auth-actions.ts
index 429eb7b17..8ec1ea95a 100644
--- a/src/core/server/actions/auth-actions.ts
+++ b/src/core/server/actions/auth-actions.ts
@@ -370,15 +370,27 @@ export async function signOutAction(returnTo?: string) {
throw redirect(redirectTo)
}
-// Drives the account-settings re-authentication step. Supabase signs the user
-// out and bounces through /sign-in (which lands back on the account page with
-// ?reauth=1); Ory forces a fresh OAuth2 login via the oauth-start route.
-export async function reauthForAccountSettingsAction() {
+// Drives the account-settings re-authentication step and returns the URL the
+// client should HARD-navigate to. Supabase signs the user out and bounces
+// through /sign-in (which lands back on the account page with ?reauth=1); Ory
+// forces a fresh OAuth2 login via the oauth-start route.
+//
+// We deliberately return the URL instead of redirect()-ing: a server-action
+// redirect is a soft RSC navigation, which prefetches and re-invokes the
+// oauth-start GET (a side-effecting endpoint that mints OAuth state/pkce/
+// callback-url cookies). Those duplicate invocations corrupt the cookies so the
+// post-reauth callback loses its callbackUrl and falls back to "/". A single
+// window.location navigation on the client avoids that entirely.
+export async function reauthForAccountSettingsAction(): Promise<{
+ url: string
+}> {
const dispatch = await auth.startReauthForAccountSettings()
if (dispatch.kind === 'sign-out') {
- return signOutAction(dispatch.returnTo)
+ // Supabase: clear the session server-side, then hand back the sign-in URL.
+ const { redirectTo } = await auth.signOut({ returnTo: dispatch.returnTo })
+ return { url: redirectTo }
}
- throw redirect(dispatch.to)
+ return { url: dispatch.to }
}
diff --git a/src/core/server/auth/ory/provider.ts b/src/core/server/auth/ory/provider.ts
index 4a254e55b..2e0f80afa 100644
--- a/src/core/server/auth/ory/provider.ts
+++ b/src/core/server/auth/ory/provider.ts
@@ -76,10 +76,13 @@ export const oryAuthProvider: AuthProvider = {
throw new Error('updateUser called without an authenticated Ory session')
}
- // Changing the password is privileged: require a recent active login so a
- // stolen dashboard session can't silently reset credentials. The caller
+ // Changing the password OR the email is privileged: require a recent active
+ // login so a stolen dashboard session can't silently take over the account
+ // (swap the email, then reset the password via the new inbox). The caller
// turns this into the forced OAuth2 re-auth round-trip.
- if (input.password !== undefined && !isReauthFresh(session.idToken)) {
+ const changesCredentials =
+ input.password !== undefined || input.email !== undefined
+ if (changesCredentials && !isReauthFresh(session.idToken)) {
return { ok: false, code: 'reauthentication_needed' }
}
diff --git a/src/features/dashboard/account/email-settings.tsx b/src/features/dashboard/account/email-settings.tsx
index 65f1ae5d6..68eae510c 100644
--- a/src/features/dashboard/account/email-settings.tsx
+++ b/src/features/dashboard/account/email-settings.tsx
@@ -3,7 +3,7 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useSearchParams } from 'next/navigation'
-import { useEffect, useMemo } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { USER_MESSAGES } from '@/configs/user-messages'
@@ -32,6 +32,7 @@ import {
} from '@/ui/primitives/form'
import { Input } from '@/ui/primitives/input'
import { useDashboard } from '../context'
+import { ReauthDialog } from './reauth-dialog'
const formSchema = z.object({
email: z.email('Invalid e-mail address'),
@@ -51,6 +52,7 @@ export function EmailSettings({ className }: EmailSettingsProps) {
const { toast } = useToast()
const trpc = useTRPC()
const queryClient = useQueryClient()
+ const [reauthDialogOpen, setReauthDialogOpen] = useState(false)
const form = useForm({
resolver: zodResolver(formSchema),
@@ -70,6 +72,11 @@ export function EmailSettings({ className }: EmailSettingsProps) {
const { mutate: updateEmail, isPending } = useMutation(
trpc.user.update.mutationOptions({
onSuccess: (data) => {
+ if (data.status === 'reauth') {
+ setReauthDialogOpen(true)
+ return
+ }
+
if (data.status === 'ok') {
queryClient.setQueryData(trpc.user.profile.queryKey(), data.user)
toast(
@@ -130,55 +137,62 @@ export function EmailSettings({ className }: EmailSettingsProps) {
if (!user || !hasEmailProvider) return null
return (
-
-
+ <>
+
+
+
+
+ >
)
}
diff --git a/src/features/dashboard/account/reauth-dialog.tsx b/src/features/dashboard/account/reauth-dialog.tsx
index c85da5d9d..6e15f4061 100644
--- a/src/features/dashboard/account/reauth-dialog.tsx
+++ b/src/features/dashboard/account/reauth-dialog.tsx
@@ -9,8 +9,12 @@ interface ReauthDialogProps {
}
export function ReauthDialog({ open, onOpenChange }: ReauthDialogProps) {
- const handleReauth = () => {
- reauthForAccountSettingsAction()
+ const handleReauth = async () => {
+ // Hard navigation (not the Next router): oauth-start is a side-effecting GET
+ // that must run exactly once, so a soft RSC navigation would corrupt the
+ // OAuth flow. See reauthForAccountSettingsAction.
+ const { url } = await reauthForAccountSettingsAction()
+ window.location.href = url
}
return (
@@ -20,8 +24,8 @@ export function ReauthDialog({ open, onOpenChange }: ReauthDialogProps) {
title="Re-authentication Required"
description={
- To change your password, you'll need to{' '}
- re-authenticate for security.
+ To make this change, you'll need to re-authenticate{' '}
+ for security.