From 419f634e5c0b3d3e809db6af9fb648422f6a6251 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Wed, 27 May 2026 14:26:57 -0600 Subject: [PATCH] feat(auth): add opt-in private oidc issuer override (#113) Allow self-hosted deployments to explicitly trust private/local OIDC issuer hosts while keeping production defaults locked down. Keep SSRF protections unchanged outside OIDC issuer and discovery validation paths. Closes #107 --- .env.example | 4 ++ server/.env.example | 3 ++ server/src/common/utils/ssrf.utils.test.ts | 28 +++++++++++++ server/src/common/utils/ssrf.utils.ts | 17 +++++--- server/src/config/config.test.ts | 9 ++++ server/src/config/config.ts | 9 ++++ server/src/config/env.validation.test.ts | 20 +++++++++ server/src/config/env.validation.ts | 8 ++++ .../app-settings/app-settings.service.test.ts | 22 ++++++++-- .../app-settings/app-settings.service.ts | 4 +- .../oidc-provider.service.test.ts | 41 ++++++++++++++++++- .../app-settings/oidc-provider.service.ts | 4 +- .../auth/oidc/oidc-discovery.service.test.ts | 14 +++++-- .../auth/oidc/oidc-discovery.service.ts | 7 ++-- 14 files changed, 170 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index 8f77059cd..cc4a25d7f 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,10 @@ NODE_MAX_OLD_SPACE_SIZE=2048 # Log level (default: info). Set to debug for verbose output. # LOG_LEVEL=info +# Allow OIDC issuer/discovery URLs that resolve to private/local addresses. +# Default false. Enable only in trusted self-hosted networks. +# OIDC_ALLOW_LOCAL_ISSUERS=false + # Optional: allow Cloudflare Web Analytics beacon when using automatic setup. # Keep unset/false to preserve strict CSP (`script-src 'self'`). # CSP_ALLOW_CLOUDFLARE_INSIGHTS=false diff --git a/server/.env.example b/server/.env.example index 23b3d896d..d79dd73f1 100644 --- a/server/.env.example +++ b/server/.env.example @@ -35,6 +35,9 @@ APP_DATA_PATH=../local/data # OIDC_JWKS_CACHE_TTL_SECS=3600 # OIDC_CLOCK_TOLERANCE_SECS=30 # OIDC_TOKEN_EXCHANGE_TIMEOUT_MS=10000 +# Allow OIDC issuer/discovery URLs that resolve to private/local addresses in production. +# Default false. Keep false unless you intentionally trust your private network. +# OIDC_ALLOW_LOCAL_ISSUERS=false # App URL (used for building password reset links in emails) APP_URL=http://localhost:5173 diff --git a/server/src/common/utils/ssrf.utils.test.ts b/server/src/common/utils/ssrf.utils.test.ts index 8e791d2e1..82747e1ed 100644 --- a/server/src/common/utils/ssrf.utils.test.ts +++ b/server/src/common/utils/ssrf.utils.test.ts @@ -22,6 +22,11 @@ describe('ensureSafeRemoteHost', () => { await expect(ensureSafeRemoteHost('localhost')).rejects.toBeInstanceOf(BadRequestException); }); + it('allows localhost when allowLocal is enabled', async () => { + await expect(ensureSafeRemoteHost('localhost', { allowLocal: true })).resolves.toBeUndefined(); + expect(lookupMock).not.toHaveBeenCalled(); + }); + it('throws BadRequestException for private IPv4 (10.x.x.x)', async () => { lookupMock.mockResolvedValue([{ address: '10.0.0.1', family: 4 }] as never); await expect(ensureSafeRemoteHost('internal')).rejects.toBeInstanceOf(BadRequestException); @@ -59,6 +64,24 @@ describe('ensureSafeRemoteHost', () => { lookupMock.mockResolvedValue([{ address: '::1', family: 6 }] as never); await expect(ensureSafeRemoteHost('ip6-localhost')).rejects.toBeInstanceOf(BadRequestException); }); + + it('allows private IPv4 literals when allowPrivate is enabled', async () => { + await expect(ensureSafeRemoteHost('192.168.1.10', { allowPrivate: true })).resolves.toBeUndefined(); + expect(lookupMock).not.toHaveBeenCalled(); + }); + + it('allows private DNS resolutions when allowPrivate is enabled', async () => { + lookupMock.mockResolvedValue([{ address: '10.0.0.7', family: 4 }] as never); + await expect(ensureSafeRemoteHost('internal', { allowPrivate: true })).resolves.toBeUndefined(); + }); + + it('allows mixed public/private DNS resolutions when allowPrivate is enabled', async () => { + lookupMock.mockResolvedValue([ + { address: '93.184.216.34', family: 4 }, + { address: '10.0.0.1', family: 4 }, + ] as never); + await expect(ensureSafeRemoteHost('mixed', { allowPrivate: true })).resolves.toBeUndefined(); + }); }); describe('ensureSafeUrl', () => { @@ -83,4 +106,9 @@ describe('ensureSafeUrl', () => { lookupMock.mockResolvedValue([{ address: '192.168.1.1', family: 4 }] as never); await expect(ensureSafeUrl('https://internal.lan')).rejects.toBeInstanceOf(BadRequestException); }); + + it('allows private targets when allowPrivate is enabled', async () => { + lookupMock.mockResolvedValue([{ address: '192.168.1.1', family: 4 }] as never); + await expect(ensureSafeUrl('https://internal.lan', { allowPrivate: true })).resolves.toBeInstanceOf(URL); + }); }); diff --git a/server/src/common/utils/ssrf.utils.ts b/server/src/common/utils/ssrf.utils.ts index dd3b797b1..fdc52cfa0 100644 --- a/server/src/common/utils/ssrf.utils.ts +++ b/server/src/common/utils/ssrf.utils.ts @@ -4,7 +4,12 @@ import { isIP } from 'net'; const SAFE_PROTOCOLS = new Set(['http:', 'https:']); -export async function ensureSafeUrl(rawUrl: string, options?: { allowLocal?: boolean }): Promise { +export interface SafeRemoteHostOptions { + allowLocal?: boolean; + allowPrivate?: boolean; +} + +export async function ensureSafeUrl(rawUrl: string, options?: SafeRemoteHostOptions): Promise { const candidate = rawUrl.trim(); if (!candidate) throw new BadRequestException('Invalid URL'); @@ -23,15 +28,15 @@ export async function ensureSafeUrl(rawUrl: string, options?: { allowLocal?: boo return parsed; } -export async function ensureSafeRemoteHost(hostname: string, options?: { allowLocal?: boolean }): Promise { +export async function ensureSafeRemoteHost(hostname: string, options?: SafeRemoteHostOptions): Promise { const normalizedHost = hostname.trim().toLowerCase(); if (!normalizedHost) throw new BadRequestException('URL host is required'); if (normalizedHost === 'localhost' || normalizedHost.endsWith('.localhost') || normalizedHost.endsWith('.local')) { - if (!options?.allowLocal) { + if (!options?.allowLocal && !options?.allowPrivate) { throw new BadRequestException('URL host is not allowed'); } - return; // explicitly local host, allowed in non-production — skip DNS/IP checks + return; // explicitly local host, allowed when local/private override is enabled } // URL.hostname wraps IPv6 in brackets (e.g. [::1]); strip them for isIP/range checks @@ -39,7 +44,7 @@ export async function ensureSafeRemoteHost(hostname: string, options?: { allowLo const ipFamily = isIP(bareHost); if (ipFamily > 0) { - if (isPrivateOrLocalAddress(bareHost)) { + if (isPrivateOrLocalAddress(bareHost) && !options?.allowPrivate) { throw new BadRequestException('URL host is not allowed'); } return; @@ -53,7 +58,7 @@ export async function ensureSafeRemoteHost(hostname: string, options?: { allowLo } if (resolved.length === 0) throw new BadRequestException('Unable to resolve URL host'); - if (resolved.some((entry) => isPrivateOrLocalAddress(entry.address))) { + if (resolved.some((entry) => isPrivateOrLocalAddress(entry.address)) && !options?.allowPrivate) { throw new BadRequestException('URL host is not allowed'); } } diff --git a/server/src/config/config.test.ts b/server/src/config/config.test.ts index e27a24e2f..53c6c3a93 100644 --- a/server/src/config/config.test.ts +++ b/server/src/config/config.test.ts @@ -9,6 +9,7 @@ function resetEnv(): void { delete process.env.NODE_ENV; delete process.env.APP_URL; delete process.env.APP_VERSION; + delete process.env.OIDC_ALLOW_LOCAL_ISSUERS; delete process.env.DATABASE_URL; delete process.env.JWT_SECRET; delete process.env.JWT_EXPIRES_IN; @@ -40,6 +41,7 @@ describe('config', () => { nodeEnv: 'development', appUrl: 'http://localhost:5173', version: 'Local build', + oidcAllowLocalIssuers: false, }); }); @@ -47,14 +49,21 @@ describe('config', () => { process.env.NODE_ENV = 'production'; process.env.APP_URL = 'https://bookorbit.local'; process.env.APP_VERSION = 'v2.3.4'; + process.env.OIDC_ALLOW_LOCAL_ISSUERS = 'true'; expect(appConfig()).toEqual({ nodeEnv: 'production', appUrl: 'https://bookorbit.local', version: 'v2.3.4', + oidcAllowLocalIssuers: true, }); }); + it('falls back to false when OIDC_ALLOW_LOCAL_ISSUERS is invalid', () => { + process.env.OIDC_ALLOW_LOCAL_ISSUERS = 'maybe'; + expect(appConfig().oidcAllowLocalIssuers).toBe(false); + }); + it('uses defaults for database, auth, email, and migration config', () => { expect(dbConfig().url).toBe('postgres://bookorbit:bookorbit@localhost:5432/bookorbit'); expect(authConfig()).toEqual({ diff --git a/server/src/config/config.ts b/server/src/config/config.ts index a06eb70ff..d1fc55dc6 100644 --- a/server/src/config/config.ts +++ b/server/src/config/config.ts @@ -5,6 +5,7 @@ export const appConfig = registerAs('app', () => ({ nodeEnv: process.env.NODE_ENV ?? 'development', appUrl: process.env.APP_URL ?? 'http://localhost:5173', version: process.env.APP_VERSION ?? 'Local build', + oidcAllowLocalIssuers: parseBooleanFlag(process.env.OIDC_ALLOW_LOCAL_ISSUERS, false), })); export const dbConfig = registerAs('db', () => ({ @@ -50,3 +51,11 @@ function parsePositiveInteger(value: string | undefined, fallback: number): numb } return Math.floor(parsed); } + +function parseBooleanFlag(value: string | undefined, fallback: boolean): boolean { + const normalized = value?.trim().toLowerCase(); + if (!normalized) return fallback; + if (['true', '1', 'yes', 'on'].includes(normalized)) return true; + if (['false', '0', 'no', 'off'].includes(normalized)) return false; + return fallback; +} diff --git a/server/src/config/env.validation.test.ts b/server/src/config/env.validation.test.ts index da6557c01..a9e4c2be4 100644 --- a/server/src/config/env.validation.test.ts +++ b/server/src/config/env.validation.test.ts @@ -58,4 +58,24 @@ describe('validateEnv', () => { }), ).toThrow('DATABASE_URL must be a valid PostgreSQL connection string'); }); + + it('accepts boolean-like values for OIDC_ALLOW_LOCAL_ISSUERS', () => { + for (const OIDC_ALLOW_LOCAL_ISSUERS of ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off']) { + expect(() => + validateEnv({ + ...BASE_ENV, + OIDC_ALLOW_LOCAL_ISSUERS, + }), + ).not.toThrow(); + } + }); + + it('rejects invalid OIDC_ALLOW_LOCAL_ISSUERS values', () => { + expect(() => + validateEnv({ + ...BASE_ENV, + OIDC_ALLOW_LOCAL_ISSUERS: 'maybe', + }), + ).toThrow('OIDC_ALLOW_LOCAL_ISSUERS must be one of true/false/1/0/yes/no/on/off'); + }); }); diff --git a/server/src/config/env.validation.ts b/server/src/config/env.validation.ts index f3b9cc1bd..bbc0b6045 100644 --- a/server/src/config/env.validation.ts +++ b/server/src/config/env.validation.ts @@ -39,6 +39,14 @@ const envSchema = z.object({ TRUST_PROXY: z.string().optional(), EMAIL_ENCRYPTION_KEY: z.string().optional(), LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).optional(), + OIDC_ALLOW_LOCAL_ISSUERS: z + .string() + .trim() + .toLowerCase() + .refine((val) => ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'].includes(val), { + message: 'OIDC_ALLOW_LOCAL_ISSUERS must be one of true/false/1/0/yes/no/on/off', + }) + .optional(), CSP_ALLOW_CLOUDFLARE_INSIGHTS: z .string() .trim() diff --git a/server/src/modules/app-settings/app-settings.service.test.ts b/server/src/modules/app-settings/app-settings.service.test.ts index 089d2912f..20e5054e6 100644 --- a/server/src/modules/app-settings/app-settings.service.test.ts +++ b/server/src/modules/app-settings/app-settings.service.test.ts @@ -22,8 +22,14 @@ function makeRepo(): jest.Mocked { } as unknown as jest.Mocked; } -function makeConfig(nodeEnv = 'development'): ConfigService { - return { get: vi.fn().mockReturnValue(nodeEnv) } as unknown as ConfigService; +function makeConfig(nodeEnv = 'development', oidcAllowLocalIssuers = false): ConfigService { + return { + get: vi.fn().mockImplementation((key: string) => { + if (key === 'app.nodeEnv') return nodeEnv; + if (key === 'app.oidcAllowLocalIssuers') return oidcAllowLocalIssuers; + return undefined; + }), + } as unknown as ConfigService; } describe('AppSettingsService', () => { @@ -285,7 +291,7 @@ describe('AppSettingsService', () => { it('passes allowLocal: true when nodeEnv is development', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(discoveryDoc) })); await service.testOidcConnection('https://auth.bookorbit.app:9093'); - expect(vi.mocked(ensureSafeUrl)).toHaveBeenCalledWith('https://auth.bookorbit.app:9093', { allowLocal: true }); + expect(vi.mocked(ensureSafeUrl)).toHaveBeenCalledWith('https://auth.bookorbit.app:9093', { allowLocal: true, allowPrivate: true }); vi.unstubAllGlobals(); }); @@ -293,7 +299,15 @@ describe('AppSettingsService', () => { const prodService = new AppSettingsService(repo, makeConfig('production')); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(discoveryDoc) })); await prodService.testOidcConnection('https://kc.example.com/realms/main'); - expect(vi.mocked(ensureSafeUrl)).toHaveBeenCalledWith('https://kc.example.com/realms/main', { allowLocal: false }); + expect(vi.mocked(ensureSafeUrl)).toHaveBeenCalledWith('https://kc.example.com/realms/main', { allowLocal: false, allowPrivate: false }); + vi.unstubAllGlobals(); + }); + + it('passes allowLocal: true when production override is enabled', async () => { + const prodService = new AppSettingsService(repo, makeConfig('production', true)); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(discoveryDoc) })); + await prodService.testOidcConnection('https://kc.example.com/realms/main'); + expect(vi.mocked(ensureSafeUrl)).toHaveBeenCalledWith('https://kc.example.com/realms/main', { allowLocal: true, allowPrivate: true }); vi.unstubAllGlobals(); }); }); diff --git a/server/src/modules/app-settings/app-settings.service.ts b/server/src/modules/app-settings/app-settings.service.ts index d5a32cd7e..69c6d2fab 100644 --- a/server/src/modules/app-settings/app-settings.service.ts +++ b/server/src/modules/app-settings/app-settings.service.ts @@ -141,7 +141,9 @@ export class AppSettingsService { throw new BadRequestException('Issuer URI is not configured'); } - const parsedIssuer = await ensureSafeUrl(uri, { allowLocal: this.config.get('app.nodeEnv') !== 'production' }); + const isProduction = this.config.get('app.nodeEnv') === 'production'; + const allowPrivateOidcIssuers = !isProduction || this.config.get('app.oidcAllowLocalIssuers') === true; + const parsedIssuer = await ensureSafeUrl(uri, { allowLocal: allowPrivateOidcIssuers, allowPrivate: allowPrivateOidcIssuers }); const normalizedIssuer = parsedIssuer.href.replace(/\/$/, ''); const discoveryUrl = `${normalizedIssuer}/.well-known/openid-configuration`; diff --git a/server/src/modules/app-settings/oidc-provider.service.test.ts b/server/src/modules/app-settings/oidc-provider.service.test.ts index 9b9e6c0eb..756defb75 100644 --- a/server/src/modules/app-settings/oidc-provider.service.test.ts +++ b/server/src/modules/app-settings/oidc-provider.service.test.ts @@ -5,6 +5,7 @@ vi.mock('../../common/utils/ssrf.utils', () => ({ ensureSafeUrl: vi.fn().mockImplementation((url: string) => Promise.resolve(new URL(url.replace(/\/$/, '')))), })); +import { ensureSafeUrl } from '../../common/utils/ssrf.utils'; import { OidcProviderRepository } from './oidc-provider.repository'; import { OidcProviderService } from './oidc-provider.service'; @@ -27,8 +28,14 @@ function makeRepo(): jest.Mocked { } as unknown as jest.Mocked; } -function makeConfig(nodeEnv = 'development'): ConfigService { - return { get: vi.fn().mockReturnValue(nodeEnv) } as unknown as ConfigService; +function makeConfig(nodeEnv = 'development', oidcAllowLocalIssuers = false): ConfigService { + return { + get: vi.fn().mockImplementation((key: string) => { + if (key === 'app.nodeEnv') return nodeEnv; + if (key === 'app.oidcAllowLocalIssuers') return oidcAllowLocalIssuers; + return undefined; + }), + } as unknown as ConfigService; } describe('OidcProviderService', () => { @@ -170,4 +177,34 @@ describe('OidcProviderService', () => { await expect(service.deleteGroupMapping('keycloak', 999)).rejects.toThrow(NotFoundException); }); }); + + describe('testConnection', () => { + const discoveryDoc = { + issuer: 'https://kc.example.com/realms/main', + authorization_endpoint: 'https://kc.example.com/realms/main/protocol/openid-connect/auth', + }; + + it('passes allowLocal/allowPrivate=true in development', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(discoveryDoc) })); + await service.testConnection('https://kc.example.com/realms/main'); + expect(vi.mocked(ensureSafeUrl)).toHaveBeenCalledWith('https://kc.example.com/realms/main', { allowLocal: true, allowPrivate: true }); + vi.unstubAllGlobals(); + }); + + it('passes allowLocal/allowPrivate=false in production by default', async () => { + const prodService = new OidcProviderService(repo, makeConfig('production')); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(discoveryDoc) })); + await prodService.testConnection('https://kc.example.com/realms/main'); + expect(vi.mocked(ensureSafeUrl)).toHaveBeenCalledWith('https://kc.example.com/realms/main', { allowLocal: false, allowPrivate: false }); + vi.unstubAllGlobals(); + }); + + it('passes allowLocal/allowPrivate=true in production when override is enabled', async () => { + const prodService = new OidcProviderService(repo, makeConfig('production', true)); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(discoveryDoc) })); + await prodService.testConnection('https://kc.example.com/realms/main'); + expect(vi.mocked(ensureSafeUrl)).toHaveBeenCalledWith('https://kc.example.com/realms/main', { allowLocal: true, allowPrivate: true }); + vi.unstubAllGlobals(); + }); + }); }); diff --git a/server/src/modules/app-settings/oidc-provider.service.ts b/server/src/modules/app-settings/oidc-provider.service.ts index 7caffcbf2..6f6e095fc 100644 --- a/server/src/modules/app-settings/oidc-provider.service.ts +++ b/server/src/modules/app-settings/oidc-provider.service.ts @@ -106,7 +106,9 @@ export class OidcProviderService { async testConnection(issuerUri: string) { if (!issuerUri) throw new BadRequestException('Issuer URI is required'); - const parsed = await ensureSafeUrl(issuerUri, { allowLocal: this.config.get('app.nodeEnv') !== 'production' }); + const isProduction = this.config.get('app.nodeEnv') === 'production'; + const allowPrivateOidcIssuers = !isProduction || this.config.get('app.oidcAllowLocalIssuers') === true; + const parsed = await ensureSafeUrl(issuerUri, { allowLocal: allowPrivateOidcIssuers, allowPrivate: allowPrivateOidcIssuers }); const normalized = parsed.href.replace(/\/$/, ''); const discoveryUrl = `${normalized}/.well-known/openid-configuration`; diff --git a/server/src/modules/auth/oidc/oidc-discovery.service.test.ts b/server/src/modules/auth/oidc/oidc-discovery.service.test.ts index 054c18de1..b900d22fe 100644 --- a/server/src/modules/auth/oidc/oidc-discovery.service.test.ts +++ b/server/src/modules/auth/oidc/oidc-discovery.service.test.ts @@ -17,10 +17,11 @@ const RAW_DOC = { backchannel_logout_supported: true, }; -function makeService(nodeEnv = 'development') { +function makeService(nodeEnv = 'development', oidcAllowLocalIssuers = false) { const configService = { get: vi.fn().mockImplementation((key: string) => { if (key === 'app.nodeEnv') return nodeEnv; + if (key === 'app.oidcAllowLocalIssuers') return oidcAllowLocalIssuers; return undefined; }), }; @@ -215,14 +216,21 @@ describe('OidcDiscoveryService', () => { mockFetchSuccess(); const service = makeService('development'); await service.getDiscoveryDoc('https://idp.example.com'); - expect(ensureSafeUrl).toHaveBeenCalledWith(expect.any(String), { allowLocal: true }); + expect(ensureSafeUrl).toHaveBeenCalledWith(expect.any(String), { allowLocal: true, allowPrivate: true }); }); it('passes allowLocal=false in production environment', async () => { mockFetchSuccess(); const service = makeService('production'); await service.getDiscoveryDoc('https://idp.example.com'); - expect(ensureSafeUrl).toHaveBeenCalledWith(expect.any(String), { allowLocal: false }); + expect(ensureSafeUrl).toHaveBeenCalledWith(expect.any(String), { allowLocal: false, allowPrivate: false }); + }); + + it('passes allowLocal=true in production when OIDC_ALLOW_LOCAL_ISSUERS override is enabled', async () => { + mockFetchSuccess(); + const service = makeService('production', true); + await service.getDiscoveryDoc('https://idp.example.com'); + expect(ensureSafeUrl).toHaveBeenCalledWith(expect.any(String), { allowLocal: true, allowPrivate: true }); }); }); }); diff --git a/server/src/modules/auth/oidc/oidc-discovery.service.ts b/server/src/modules/auth/oidc/oidc-discovery.service.ts index cbba2f7dd..db37149bb 100644 --- a/server/src/modules/auth/oidc/oidc-discovery.service.ts +++ b/server/src/modules/auth/oidc/oidc-discovery.service.ts @@ -33,10 +33,11 @@ export class OidcDiscoveryService { private readonly logger = new Logger(OidcDiscoveryService.name); private readonly cache = new Map(); private readonly TTL: number; - private readonly allowLocal: boolean; + private readonly allowPrivateOidcIssuers: boolean; constructor(private readonly configService: ConfigService) { - this.allowLocal = this.configService.get('app.nodeEnv') !== 'production'; + const isProduction = this.configService.get('app.nodeEnv') === 'production'; + this.allowPrivateOidcIssuers = !isProduction || this.configService.get('app.oidcAllowLocalIssuers') === true; this.TTL = this.configService.get('oidcRuntime.discoveryCacheTtlMs') ?? 60 * 60 * 1000; } @@ -62,7 +63,7 @@ export class OidcDiscoveryService { } // P0-4: Validate all endpoint URLs are SSRF-safe - const ssrfOpts = { allowLocal: this.allowLocal }; + const ssrfOpts = { allowLocal: this.allowPrivateOidcIssuers, allowPrivate: this.allowPrivateOidcIssuers }; await Promise.all([ ensureSafeUrl(raw.token_endpoint, ssrfOpts), ensureSafeUrl(raw.jwks_uri, ssrfOpts),