From 3d6ba32e49f4908d54a802772ffbd5a9bf0735aa Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Tue, 9 Jun 2026 12:43:31 +0200 Subject: [PATCH 1/2] feat: support self-hosted ory via kratos admin url --- .env.example | 4 +++ scripts/check-app-env.ts | 42 +++++++++++++++++++++++++++++- src/core/server/auth/ory/client.ts | 33 +++++++++++++++-------- src/lib/env.ts | 2 ++ 4 files changed, 69 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 970fef612..b57faafb9 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,10 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key # ORY_OAUTH2_AUDIENCE=https://api.e2b.dev ### Ory project admin API token used by oryAuthAdmin (IdentityApi lookups) # ORY_PROJECT_API_TOKEN= +### Self-hosted Ory admin endpoints (alternative to ORY_PROJECT_API_TOKEN). +### Set both when running self-hosted; leave unset to use Ory Network with the PAT above. +# ORY_KRATOS_ADMIN_URL=http://localhost:4434 +# ORY_HYDRA_ADMIN_URL=http://localhost:4445 ### Dashboard API admin token used to bootstrap newly signed-in Ory users # DASHBOARD_API_ADMIN_TOKEN= diff --git a/scripts/check-app-env.ts b/scripts/check-app-env.ts index bc395cc56..ad36a58d4 100644 --- a/scripts/check-app-env.ts +++ b/scripts/check-app-env.ts @@ -5,16 +5,34 @@ import { clientSchema, serverSchema, validateEnv } from '../src/lib/env' const projectDir = process.cwd() loadEnvConfig(projectDir) +// Always required when AUTH_PROVIDER=ory, regardless of deploy target. const oryRequiredEnvVars = [ 'AUTH_SECRET', 'ORY_SDK_URL', 'ORY_OAUTH2_CLIENT_ID', 'ORY_OAUTH2_CLIENT_SECRET', 'ORY_OAUTH2_AUDIENCE', - 'ORY_PROJECT_API_TOKEN', 'DASHBOARD_API_ADMIN_TOKEN', ] as const +// Identity admin surface (Kratos): pick exactly one. +// - ORY_PROJECT_API_TOKEN: Ory Network. Bearer for the unified SDK host. +// - ORY_KRATOS_ADMIN_URL: self-hosted Kratos admin (gated by network). +// At least one must be set so IdentityApi calls can resolve. +const oryIdentityAdminEnvVars = [ + 'ORY_PROJECT_API_TOKEN', + 'ORY_KRATOS_ADMIN_URL', +] as const + +// OAuth2 admin surface (Hydra): pick exactly one. +// - ORY_PROJECT_API_TOKEN: Ory Network. Bearer for the unified SDK host. +// - ORY_HYDRA_ADMIN_URL: self-hosted Hydra admin (gated by network). +// At least one must be set so OAuth2Api session revocations can resolve. +const oryOAuth2AdminEnvVars = [ + 'ORY_PROJECT_API_TOKEN', + 'ORY_HYDRA_ADMIN_URL', +] as const + const schema = serverSchema .merge(clientSchema) .refine( @@ -81,6 +99,28 @@ const schema = serverSchema path: ['AUTH_PROVIDER'], }) } + + const hasIdentityAdmin = oryIdentityAdminEnvVars.some( + (envVar) => !!data[envVar] + ) + if (!hasIdentityAdmin) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `AUTH_PROVIDER=ory requires either ${oryIdentityAdminEnvVars.join(' (Ory Network) or ')} (self-hosted Kratos admin)`, + path: ['AUTH_PROVIDER'], + }) + } + + const hasOAuth2Admin = oryOAuth2AdminEnvVars.some( + (envVar) => !!data[envVar] + ) + if (!hasOAuth2Admin) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `AUTH_PROVIDER=ory requires either ${oryOAuth2AdminEnvVars.join(' (Ory Network) or ')} (self-hosted Hydra admin)`, + path: ['AUTH_PROVIDER'], + }) + } }) validateEnv(schema) diff --git a/src/core/server/auth/ory/client.ts b/src/core/server/auth/ory/client.ts index c7fcbff59..f2a04e874 100644 --- a/src/core/server/auth/ory/client.ts +++ b/src/core/server/auth/ory/client.ts @@ -5,35 +5,46 @@ import { Configuration, IdentityApi, OAuth2Api } from '@ory/client-fetch' let cachedIdentityApi: IdentityApi | null = null let cachedOAuth2Api: OAuth2Api | null = null -// the IdentityApi requires the Ory project admin token (PAT). callers should -// ensure ORY_PROJECT_API_TOKEN is set at deploy time when AUTH_PROVIDER=ory. +// IdentityApi resolution: +// 1. ORY_KRATOS_ADMIN_URL — self-hosted Kratos admin (e.g. local devenv :4434). +// 2. ORY_SDK_URL — Ory Network (identity admin co-located on the SDK host). +// +// OAuth2Api resolution: +// 1. ORY_HYDRA_ADMIN_URL — self-hosted Hydra admin (e.g. local devenv :4445). +// 2. ORY_SDK_URL — Ory Network (OAuth2 admin co-located on the SDK host). +// +// The PAT is attached only when configured: Ory Network gates on it, +// self-hosted admin surfaces are gated by network reachability instead. export function getOryIdentityApi(): IdentityApi { if (cachedIdentityApi) return cachedIdentityApi - cachedIdentityApi = new IdentityApi(getOryConfiguration()) + cachedIdentityApi = new IdentityApi( + getOryConfiguration(process.env.ORY_KRATOS_ADMIN_URL) + ) + return cachedIdentityApi } export function getOryOAuth2Api(): OAuth2Api { if (cachedOAuth2Api) return cachedOAuth2Api - cachedOAuth2Api = new OAuth2Api(getOryConfiguration()) + cachedOAuth2Api = new OAuth2Api( + getOryConfiguration(process.env.ORY_HYDRA_ADMIN_URL) + ) return cachedOAuth2Api } -function getOryConfiguration(): Configuration { - const basePath = process.env.ORY_SDK_URL - const accessToken = process.env.ORY_PROJECT_API_TOKEN +function getOryConfiguration(basePathOverride?: string): Configuration { + const basePath = basePathOverride ?? process.env.ORY_SDK_URL if (!basePath) { throw new Error('ORY_SDK_URL is not configured') } - if (!accessToken) { - throw new Error('ORY_PROJECT_API_TOKEN is not configured') - } + + const accessToken = process.env.ORY_PROJECT_API_TOKEN return new Configuration({ basePath: basePath.replace(/\/$/, ''), - accessToken, + ...(accessToken ? { accessToken } : {}), }) } diff --git a/src/lib/env.ts b/src/lib/env.ts index bd3cc08f0..54cf0ebb9 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -22,6 +22,8 @@ export const serverSchema = z.object({ ORY_OAUTH2_CLIENT_SECRET: z.string().min(1).optional(), ORY_OAUTH2_AUDIENCE: z.string().min(1).optional(), ORY_PROJECT_API_TOKEN: z.string().min(1).optional(), + ORY_KRATOS_ADMIN_URL: z.url().optional(), + ORY_HYDRA_ADMIN_URL: z.url().optional(), OTEL_SERVICE_NAME: z.string().optional(), OTEL_EXPORTER_OTLP_ENDPOINT: z.url().optional(), From 3112a4268e5f676215d7aa9228063baaf0016818 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Tue, 9 Jun 2026 14:54:25 +0200 Subject: [PATCH 2/2] fix(env): enforce ory deploy-mode coherence Identity and OAuth2 admin checks ran independently and each accepted ORY_PROJECT_API_TOKEN, so a self-hosted env with a leftover PAT and only ORY_KRATOS_ADMIN_URL passed validation. At runtime getOryOAuth2Api() then fell back to the public ORY_SDK_URL, silently misrouting session revocations away from the self-hosted Hydra admin port. Replace both any-of checks with a single mode-coherence rule: any self-hosted admin URL requires both, otherwise require the project token. Addresses codex review on PR #368. --- scripts/check-app-env.ts | 57 ++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/scripts/check-app-env.ts b/scripts/check-app-env.ts index ad36a58d4..3194b06f4 100644 --- a/scripts/check-app-env.ts +++ b/scripts/check-app-env.ts @@ -15,23 +15,12 @@ const oryRequiredEnvVars = [ 'DASHBOARD_API_ADMIN_TOKEN', ] as const -// Identity admin surface (Kratos): pick exactly one. -// - ORY_PROJECT_API_TOKEN: Ory Network. Bearer for the unified SDK host. -// - ORY_KRATOS_ADMIN_URL: self-hosted Kratos admin (gated by network). -// At least one must be set so IdentityApi calls can resolve. -const oryIdentityAdminEnvVars = [ - 'ORY_PROJECT_API_TOKEN', - 'ORY_KRATOS_ADMIN_URL', -] as const - -// OAuth2 admin surface (Hydra): pick exactly one. -// - ORY_PROJECT_API_TOKEN: Ory Network. Bearer for the unified SDK host. -// - ORY_HYDRA_ADMIN_URL: self-hosted Hydra admin (gated by network). -// At least one must be set so OAuth2Api session revocations can resolve. -const oryOAuth2AdminEnvVars = [ - 'ORY_PROJECT_API_TOKEN', - 'ORY_HYDRA_ADMIN_URL', -] as const +// Admin surface resolution must be mode-coherent: +// - Ory Network: ORY_PROJECT_API_TOKEN (bearer for the unified SDK host +// covers both Kratos and Hydra admin). +// - Self-hosted: BOTH ORY_KRATOS_ADMIN_URL and ORY_HYDRA_ADMIN_URL +// (each admin surface lives on its own port; either alone +// leaks the other call back to the public ORY_SDK_URL). const schema = serverSchema .merge(clientSchema) @@ -100,24 +89,28 @@ const schema = serverSchema }) } - const hasIdentityAdmin = oryIdentityAdminEnvVars.some( - (envVar) => !!data[envVar] - ) - if (!hasIdentityAdmin) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `AUTH_PROVIDER=ory requires either ${oryIdentityAdminEnvVars.join(' (Ory Network) or ')} (self-hosted Kratos admin)`, - path: ['AUTH_PROVIDER'], - }) - } + const hasKratosAdmin = !!data.ORY_KRATOS_ADMIN_URL + const hasHydraAdmin = !!data.ORY_HYDRA_ADMIN_URL + const isSelfHosted = hasKratosAdmin || hasHydraAdmin + const hasProjectToken = !!data.ORY_PROJECT_API_TOKEN - const hasOAuth2Admin = oryOAuth2AdminEnvVars.some( - (envVar) => !!data[envVar] - ) - if (!hasOAuth2Admin) { + if (isSelfHosted) { + const missingSelfHostedVars: string[] = [] + if (!hasKratosAdmin) missingSelfHostedVars.push('ORY_KRATOS_ADMIN_URL') + if (!hasHydraAdmin) missingSelfHostedVars.push('ORY_HYDRA_ADMIN_URL') + + if (missingSelfHostedVars.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Self-hosted Ory is missing ${missingSelfHostedVars.join(', ')}`, + path: ['AUTH_PROVIDER'], + }) + } + } else if (!hasProjectToken) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `AUTH_PROVIDER=ory requires either ${oryOAuth2AdminEnvVars.join(' (Ory Network) or ')} (self-hosted Hydra admin)`, + message: + 'AUTH_PROVIDER=ory requires ORY_PROJECT_API_TOKEN (Ory Network) or both ORY_KRATOS_ADMIN_URL and ORY_HYDRA_ADMIN_URL (self-hosted)', path: ['AUTH_PROVIDER'], }) }