Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,28 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
### Auth provider: supabase (default) or ory
# AUTH_PROVIDER=supabase

### Ory Network SDK URL (required when AUTH_PROVIDER=ory)
### Ory Network configuration (required when AUTH_PROVIDER=ory)
### SDK URL of the Ory Network project (or custom domain like https://auth.e2b.dev)
# ORY_SDK_URL=https://your-project.projects.oryapis.com
### OAuth2 client credentials issued by Ory for this dashboard deployment
# ORY_OAUTH2_CLIENT_ID=
# ORY_OAUTH2_CLIENT_SECRET=
### Access-token audience requested from Ory. Must match infra AUTH_PROVIDER_CONFIG.jwt[].issuer.audiences.
# ORY_OAUTH2_AUDIENCE=https://api.e2b.dev
### Ory project admin API token used by oryAuthAdmin (IdentityApi lookups)
# ORY_PROJECT_API_TOKEN=
### Dashboard API admin token used to bootstrap newly signed-in Ory users
# DASHBOARD_API_ADMIN_TOKEN=

### Auth.js configuration (required when AUTH_PROVIDER=ory)
### Generate with `npx auth secret` or `openssl rand -hex 32`. Used to encrypt the JWT session cookie.
# AUTH_SECRET=
### Set to 1 outside Vercel-hosted production to allow Auth.js to trust the Host header
# AUTH_TRUST_HOST=1

### Legacy Supabase bootstrap fallback used by dashboard route team resolution.
### Ory sign-in bootstrap does not depend on this flag.
# ENABLE_USER_BOOTSTRAP=0

### Billing API URL (Required if NEXT_PUBLIC_INCLUDE_BILLING=1)
# BILLING_API_URL=https://billing.e2b.dev
Expand Down
18 changes: 18 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.203.0",
"@opentelemetry/semantic-conventions": "^1.36.0",
"@ory/client-fetch": "^1.22.37",
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
Expand Down Expand Up @@ -111,6 +112,7 @@
"motion": "^12.23.25",
"nanoid": "^5.0.9",
"next": "^16.2.7",
"next-auth": "^5.0.0-beta.31",
"next-safe-action": "^8.0.11",
"next-themes": "^0.4.6",
"nuqs": "^2.7.0",
Expand Down
20 changes: 20 additions & 0 deletions scripts/check-app-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,25 @@ const schema = serverSchema
path: ['PLAIN_API_KEY'],
}
)
.refine(
(data) => {
if (data.AUTH_PROVIDER !== 'ory') return true

return Boolean(
data.AUTH_SECRET &&
data.ORY_SDK_URL &&
data.ORY_OAUTH2_CLIENT_ID &&
data.ORY_OAUTH2_CLIENT_SECRET &&
data.ORY_OAUTH2_AUDIENCE &&
data.ORY_PROJECT_API_TOKEN &&
data.DASHBOARD_API_ADMIN_TOKEN
)
},
{
message:
'AUTH_PROVIDER=ory requires AUTH_SECRET, ORY_SDK_URL, ORY_OAUTH2_CLIENT_ID, ORY_OAUTH2_CLIENT_SECRET, ORY_OAUTH2_AUDIENCE, ORY_PROJECT_API_TOKEN, and DASHBOARD_API_ADMIN_TOKEN',
path: ['AUTH_PROVIDER'],
}
)

validateEnv(schema)
48 changes: 48 additions & 0 deletions src/app/api/auth/oauth-recover/route.ts
Original file line number Diff line number Diff line change
@@ -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
}
41 changes: 41 additions & 0 deletions src/app/api/auth/oauth-start/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { signIn } from '@/auth'
import { normalizeOryReturnTo } from '@/core/server/auth/ory/build-start-url'
import {
readOrySignupMetadataFromHeaders,
setOrySignupMetadataCookie,
} from '@/core/server/auth/ory/signup-metadata'

// Server-side entry point for the Ory OAuth2 flow. Pages redirect here
// instead of rendering a client-side form so that Auth.js can set its
// state/PKCE cookies (only allowed in route handlers / server actions
// / middleware) without any client JS in the loop.
//
// `intent=signup` forwards `prompt=registration` to Hydra, which routes
// to its registration UI (`urls.registration`, default `/ui/registration`)
// instead of the login UI.
//
// `intent=reauth` forwards `prompt=login`, forcing Hydra to redo the login
// flow even with an active session so we get a fresh `auth_time`. Used to
// re-authenticate before sensitive account changes (password).
// https://www.ory.com/docs/oauth2-oidc/authorization-code-flow
export async function GET(request: Request) {
const url = new URL(request.url)
const intent = url.searchParams.get('intent')
const redirectTo =
normalizeOryReturnTo(url.searchParams.get('returnTo')) ?? '/dashboard'

const authorizationParams =
intent === 'signup'
? { prompt: 'registration' }
: intent === 'reauth'
? { prompt: 'login' }
: undefined

if (intent === 'signup') {
await setOrySignupMetadataCookie(
readOrySignupMetadataFromHeaders(request.headers)
)
}

await signIn('ory', { redirectTo }, authorizationParams)
Comment thread
ben-fornefeld marked this conversation as resolved.
}
3 changes: 3 additions & 0 deletions src/app/api/auth/oauth/[...nextauth]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { handlers } from '@/auth'

export const { GET, POST } = handlers
51 changes: 51 additions & 0 deletions src/app/api/auth/oauth/bootstrap-failed/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import 'server-only'

import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { signOut } from '@/auth'
import {
buildOryLogoutUrl,
ORY_BOOTSTRAP_FAILURE_ID_TOKEN_COOKIE,
ORY_POST_LOGOUT_PATH,
} from '@/core/server/auth/ory/signout'
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'

export async function GET(request: NextRequest) {
const origin = request.nextUrl.origin
const idToken = request.cookies.get(
ORY_BOOTSTRAP_FAILURE_ID_TOKEN_COOKIE
)?.value

try {
await signOut({ redirect: false })
} catch (error) {
l.warn(
{
key: 'oauth_bootstrap_failed:authjs_sign_out:error',
error: serializeErrorForLog(error),
},
'Auth.js signOut() failed after Ory bootstrap failure'
)
}

const logoutUrl = idToken ? buildOryLogoutUrl({ idToken, origin }) : null

if (!logoutUrl) {
l.error(
{
key: 'oauth_bootstrap_failed:missing_logout_context',
context: {
has_id_token: !!idToken,
has_ory_sdk_url: !!process.env.ORY_SDK_URL,
},
},
'Could not perform Ory logout after bootstrap failure'
)
}

const response = NextResponse.redirect(
logoutUrl ?? new URL(ORY_POST_LOGOUT_PATH, origin)
)
response.cookies.delete(ORY_BOOTSTRAP_FAILURE_ID_TOKEN_COOKIE)
return response
}
57 changes: 57 additions & 0 deletions src/app/api/auth/oauth/signout-flow/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import 'server-only'

import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { auth, signOut } from '@/auth'
import { revokeKratosSessionsForIdentity } from '@/core/server/auth/ory/kratos-session'
import {
buildOryLogoutUrl,
ORY_POST_LOGOUT_PATH,
} from '@/core/server/auth/ory/signout'
import { l, serializeErrorForLog } from '@/core/shared/clients/logger/logger'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Agentic Security Review
Severity: MEDIUM
The new sign-out flow is implemented as an unauthenticated GET route but performs state-changing actions (signOut plus Ory session revocation). Because browsers can be induced to make cross-site GET navigations, this enables logout CSRF.

Impact: An attacker can force a logged-in victim to be signed out and have their Ory sessions revoked, causing involuntary session disruption without user intent.

Fix in Cursor Fix in Web

Reviewed by Cursor Security Reviewer for commit 0651a6e. Configure here.

export async function GET(request: NextRequest) {
const origin = request.nextUrl.origin
const postLogoutUrl = new URL(ORY_POST_LOGOUT_PATH, origin)

let idToken: string | undefined
let identityId: string | undefined
try {
const session = await auth()
idToken = session?.idToken
// The Kratos identity id resolved at sign-in — NOT the OIDC subject (which
// is the E2B user id) — so we revoke the right identity's Kratos sessions.
identityId = session?.identityId
} catch (error) {
l.warn(
{
key: 'oauth_signout:read_session:error',
error: serializeErrorForLog(error),
},
'failed to read Auth.js session before sign-out'
)
}

try {
await signOut({ redirect: false })
} catch (error) {
l.warn(
{
key: 'oauth_signout:authjs_sign_out:error',
error: serializeErrorForLog(error),
},
'Auth.js signOut() failed'
)
}

if (identityId) {
await revokeKratosSessionsForIdentity(identityId)
}

const logoutUrl = idToken ? buildOryLogoutUrl({ idToken, origin }) : null
if (!logoutUrl) {
return NextResponse.redirect(postLogoutUrl)
}

return NextResponse.redirect(logoutUrl.toString())
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { cookies } from 'next/headers'
import { type NextRequest, NextResponse } from 'next/server'
import { SUPABASE_AUTH_HEADERS } from '@/configs/api'
import { authHeaders } from '@/configs/api'
import { COOKIE_KEYS } from '@/configs/cookies'
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
import { createUserTeamsRepository } from '@/core/modules/teams/user-teams-repository.server'
Expand Down Expand Up @@ -59,7 +59,7 @@ async function hasSandboxInTeam(
},
},
headers: {
...SUPABASE_AUTH_HEADERS(accessToken, teamId),
...authHeaders(accessToken, teamId),
},
cache: 'no-store',
})
Expand Down
Loading
Loading