From a836d3639b327884b495e25f8c5862d44f94255d Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Mon, 6 Jul 2026 09:42:03 +0300 Subject: [PATCH] Route CMS OIDC calls through internal proxy --- infra/.env.example | 3 ++ infra/.env.synology.example | 3 ++ infra/docker-compose.yml | 3 ++ server/admin-server.mjs | 76 ++++++++++++++++++++++++++++++++----- 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/infra/.env.example b/infra/.env.example index 6277300..cd046e7 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -39,6 +39,9 @@ CMS_BOOTSTRAP_ADMIN_NAME=DCTOUCH CMS Admin CMS_AUTH_ENABLED=true CMS_BASE_URL=http://cms.local.nodedc:8210 CMS_OIDC_ISSUER=http://cms-auth.local.nodedc:8210/application/o/dc-cms/ +CMS_OIDC_INTERNAL_BASE_URL=http://reverse-proxy +CMS_OIDC_INTERNAL_HOST=cms-auth.local.nodedc +CMS_OIDC_INTERNAL_PROTO=http CMS_OIDC_CLIENT_ID=dc-cms CMS_OIDC_CLIENT_SECRET=replace-with-random-secret CMS_OIDC_REDIRECT_URI=http://cms.local.nodedc:8210/auth/callback diff --git a/infra/.env.synology.example b/infra/.env.synology.example index 476e19f..60a4ace 100644 --- a/infra/.env.synology.example +++ b/infra/.env.synology.example @@ -40,6 +40,9 @@ CMS_BOOTSTRAP_ADMIN_NAME=DCTOUCH CMS Admin CMS_AUTH_ENABLED=true CMS_BASE_URL=https://cms.dcserve.ru CMS_OIDC_ISSUER=https://auth.dcserve.ru/application/o/dc-cms/ +CMS_OIDC_INTERNAL_BASE_URL=http://reverse-proxy +CMS_OIDC_INTERNAL_HOST=auth.dcserve.ru +CMS_OIDC_INTERNAL_PROTO=https CMS_OIDC_CLIENT_ID=dc-cms CMS_OIDC_CLIENT_SECRET=replace-with-random-secret CMS_OIDC_REDIRECT_URI=https://cms.dcserve.ru/auth/callback diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 2e1737a..0458eca 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -38,6 +38,9 @@ services: SITE_ROOT: /sites/nodedc CMS_AUTH_ENABLED: ${CMS_AUTH_ENABLED:-true} CMS_OIDC_ISSUER: ${CMS_OIDC_ISSUER:?cms oidc issuer required} + CMS_OIDC_INTERNAL_BASE_URL: ${CMS_OIDC_INTERNAL_BASE_URL:-} + CMS_OIDC_INTERNAL_HOST: ${CMS_OIDC_INTERNAL_HOST:-} + CMS_OIDC_INTERNAL_PROTO: ${CMS_OIDC_INTERNAL_PROTO:-} CMS_OIDC_CLIENT_ID: ${CMS_OIDC_CLIENT_ID:?cms oidc client id required} CMS_OIDC_CLIENT_SECRET: ${CMS_OIDC_CLIENT_SECRET:?cms oidc client secret required} CMS_OIDC_REDIRECT_URI: ${CMS_OIDC_REDIRECT_URI:?cms oidc redirect uri required} diff --git a/server/admin-server.mjs b/server/admin-server.mjs index 9ba62b0..ec1be63 100644 --- a/server/admin-server.mjs +++ b/server/admin-server.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; import { dirname } from "node:path"; import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; -import { createRemoteJWKSet, jwtVerify } from "jose"; +import { createLocalJWKSet, jwtVerify } from "jose"; const cmsRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const port = Number(process.env.PORT || 8090); @@ -210,6 +210,9 @@ const authConfig = { enabled: boolEnv(process.env.CMS_AUTH_ENABLED, false), baseUrl: String(process.env.CMS_BASE_URL || "").trim(), issuer: trailingSlashUrl(process.env.CMS_OIDC_ISSUER), + internalBaseUrl: String(process.env.CMS_OIDC_INTERNAL_BASE_URL || "").trim(), + internalHost: String(process.env.CMS_OIDC_INTERNAL_HOST || "").trim(), + internalProto: String(process.env.CMS_OIDC_INTERNAL_PROTO || "").trim(), clientId: String(process.env.CMS_OIDC_CLIENT_ID || "").trim(), clientSecret: String(process.env.CMS_OIDC_CLIENT_SECRET || "").trim(), redirectUri: String(process.env.CMS_OIDC_REDIRECT_URI || "").trim(), @@ -231,6 +234,7 @@ let discoveryCache = null; let discoveryPromise = null; let jwksCache = null; let jwksUriCache = ""; +let jwksPromise = null; function randomToken(byteLength = 32) { return randomBytes(byteLength).toString("base64url"); @@ -353,14 +357,56 @@ function assertAuthConfigured() { } } +function oidcInternalFetchUrl(rawUrl) { + const publicUrl = new URL(rawUrl); + if (!authConfig.internalBaseUrl) return publicUrl; + + const internalBaseUrl = new URL(authConfig.internalBaseUrl); + internalBaseUrl.pathname = publicUrl.pathname; + internalBaseUrl.search = publicUrl.search; + internalBaseUrl.hash = ""; + return internalBaseUrl; +} + +function oidcFetchHeaders(headers) { + const nextHeaders = new Headers(headers || {}); + if (!authConfig.internalBaseUrl) return nextHeaders; + + const publicIssuer = new URL(authConfig.issuer); + const forwardedHost = authConfig.internalHost || publicIssuer.host; + const forwardedProto = authConfig.internalProto || publicIssuer.protocol.replace(/:$/, ""); + nextHeaders.set("host", forwardedHost); + nextHeaders.set("x-forwarded-host", forwardedHost); + nextHeaders.set("x-forwarded-proto", forwardedProto); + return nextHeaders; +} + +async function fetchOidc(rawUrl, options = {}) { + return fetch(oidcInternalFetchUrl(rawUrl), { + ...options, + headers: oidcFetchHeaders(options.headers), + }); +} + +async function fetchOidcJson(rawUrl, options = {}) { + const response = await fetchOidc(rawUrl, { + ...options, + headers: { + accept: "application/json", + ...(options.headers || {}), + }, + }); + const payload = await response.json().catch(() => null); + return { response, payload }; +} + async function loadOidcDiscovery() { assertAuthConfigured(); if (discoveryCache) return discoveryCache; if (!discoveryPromise) { discoveryPromise = (async () => { const wellKnownUrl = new URL(".well-known/openid-configuration", authConfig.issuer); - const response = await fetch(wellKnownUrl); - const payload = await response.json().catch(() => null); + const { response, payload } = await fetchOidcJson(wellKnownUrl); if (!response.ok || !payload) { throw new Error(`Не удалось загрузить OIDC discovery: ${response.status}`); } @@ -373,12 +419,24 @@ async function loadOidcDiscovery() { return discoveryPromise; } -function jwksForDiscovery(discovery) { - if (!jwksCache || jwksUriCache !== discovery.jwks_uri) { +async function jwksForDiscovery(discovery) { + if (jwksCache && jwksUriCache === discovery.jwks_uri) return jwksCache; + + if (!jwksPromise || jwksUriCache !== discovery.jwks_uri) { jwksUriCache = discovery.jwks_uri; - jwksCache = createRemoteJWKSet(new URL(discovery.jwks_uri)); + jwksPromise = (async () => { + const { response, payload } = await fetchOidcJson(discovery.jwks_uri); + if (!response.ok || !payload) { + throw new Error(`Не удалось загрузить OIDC JWKS: ${response.status}`); + } + jwksCache = createLocalJWKSet(payload); + return jwksCache; + })().finally(() => { + jwksPromise = null; + }); } - return jwksCache; + + return jwksPromise; } async function exchangeAuthorizationCode(code, codeVerifier, redirectUri) { @@ -390,7 +448,7 @@ async function exchangeAuthorizationCode(code, codeVerifier, redirectUri) { code_verifier: codeVerifier, }); const credentials = Buffer.from(`${encodeURIComponent(authConfig.clientId)}:${encodeURIComponent(authConfig.clientSecret)}`).toString("base64"); - const response = await fetch(discovery.token_endpoint, { + const response = await fetchOidc(discovery.token_endpoint, { method: "POST", headers: { authorization: `Basic ${credentials}`, @@ -432,7 +490,7 @@ async function verifyTokens(tokens, pendingLogin) { const discovery = await loadOidcDiscovery(); const issuer = discovery.issuer || authConfig.issuer.replace(/\/?$/, "/"); - const { payload } = await jwtVerify(tokens.id_token, jwksForDiscovery(discovery), { + const { payload } = await jwtVerify(tokens.id_token, await jwksForDiscovery(discovery), { issuer, audience: authConfig.clientId, });