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..3194b06f4 100644 --- a/scripts/check-app-env.ts +++ b/scripts/check-app-env.ts @@ -5,16 +5,23 @@ 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 +// 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) .refine( @@ -81,6 +88,32 @@ const schema = serverSchema 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 + + 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 ORY_PROJECT_API_TOKEN (Ory Network) or both ORY_KRATOS_ADMIN_URL and ORY_HYDRA_ADMIN_URL (self-hosted)', + 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(),