diff --git a/api/src/App.ts b/api/src/App.ts index 4773b3f..15fdb75 100644 --- a/api/src/App.ts +++ b/api/src/App.ts @@ -6,6 +6,7 @@ import errorHandler from './middlewares/error-handler'; import routes from './routes'; import passport, { initPassportLogin } from './tv-modules/auth/strategies/passport-login'; import { LoginMethods } from './tv-modules/auth/LoginMethods'; +import { PublicApiUrl } from './modules/public-url'; import cookieParser from 'cookie-parser'; import { registerAllEventHandlers, startAllWorkers } from './core/all-events'; @@ -15,6 +16,7 @@ export default class App { constructor(port: number) { LoginMethods.validateOnStartup(); + PublicApiUrl.validateOnStartup(); this.app = express(); this.port = port; diff --git a/api/src/modules/public-url.ts b/api/src/modules/public-url.ts new file mode 100644 index 0000000..d234f36 --- /dev/null +++ b/api/src/modules/public-url.ts @@ -0,0 +1,28 @@ +import type { Request } from 'express'; + +export class PublicApiUrl { + static configured(): string | null { + const raw = process.env.API_PUBLIC_URL; + if (!raw || !raw.trim()) return null; + return raw.trim().replace(/\/+$/, ''); + } + + static base(req: Request): string { + return PublicApiUrl.configured() ?? `${req.protocol}://${req.get('host')}`; + } + + static validateOnStartup(): void { + const raw = process.env.API_PUBLIC_URL; + if (!raw || !raw.trim()) return; + + let parsed: URL; + try { + parsed = new URL(raw.trim()); + } catch { + throw new Error(`API_PUBLIC_URL is not a valid URL: "${raw}"`); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`API_PUBLIC_URL must be an http(s) URL, got: "${raw}"`); + } + } +} diff --git a/api/src/tv-modules/sso/SsoController.ts b/api/src/tv-modules/sso/SsoController.ts index 6053bd6..6ab75c8 100644 --- a/api/src/tv-modules/sso/SsoController.ts +++ b/api/src/tv-modules/sso/SsoController.ts @@ -3,6 +3,7 @@ import { type } from 'arktype' import { hashSync } from 'bcryptjs' import type { Request, Response } from 'express' import { $logger } from '../../modules/logget' +import { PublicApiUrl } from '../../modules/public-url' import { logError } from '../../utils/api' import { generateString, isEmail } from '../../utils/helpers' import AuthModel from '../auth/AuthModel' @@ -140,6 +141,16 @@ export class SsoController { }) } + getPublicUrls = async (req: Request, res: Response) => { + const base = PublicApiUrl.base(req) + return res.tvJson({ + apiBaseUrl: base, + callbackUrlTemplate: `${base}/module/sso/callback/{id}`, + scimEndpointUrl: `${base}/scim/v2`, + apiPublicUrlConfigured: PublicApiUrl.configured() !== null, + }) + } + listConfigs = async (req: Request, res: Response) => { const orgId = Number(req.query.organizationId) if (!orgId) return res.status(400).tvJson({ message: 'organizationId is required' }) diff --git a/api/src/tv-modules/sso/SsoRoutes.ts b/api/src/tv-modules/sso/SsoRoutes.ts index 2db430a..0ece0bc 100644 --- a/api/src/tv-modules/sso/SsoRoutes.ts +++ b/api/src/tv-modules/sso/SsoRoutes.ts @@ -26,6 +26,7 @@ export default class SsoRoutes implements Routable { this.router.get('/callback/:configId', [RequireLoginMethod('sso')], this.controller.handleCallback) this.router.post('/callback/:configId', [RequireLoginMethod('sso')], this.controller.handleCallback) + this.router.get('/admin/public-urls', [IsLoggedIn], this.controller.getPublicUrls) this.router.get('/admin/metadata', [IsLoggedIn, IsOrgAdmin], this.controller.parseMetadata) this.router.get('/admin/configs', [IsLoggedIn, IsOrgAdmin], this.controller.listConfigs) this.router.post('/admin/configs', [IsLoggedIn, IsOrgAdmin], this.controller.createConfig) diff --git a/docs/2.features/11.sso.md b/docs/2.features/11.sso.md index 8dc29b8..fd7b5fd 100644 --- a/docs/2.features/11.sso.md +++ b/docs/2.features/11.sso.md @@ -35,6 +35,10 @@ Go to your organization's settings → **SSO** tab. You need the **admin** or ** | IdP Certificate | Your IdP's public signing certificate (base64, without BEGIN/END headers) | | ACS URL (Callback) | The URL where your IdP sends SAML responses. Shown after creating the config - copy it to your IdP | +::callout{icon="i-lucide-network" color="warning"} +**Running behind a reverse proxy?** Set [`API_PUBLIC_URL`](/docs/configuration/environment-variables#application) to the public address of your API server. The ACS/Callback URL and the SCIM endpoint shown on this screen are built from it — without the variable they fall back to the address your browser used, which behind a proxy can be an internal host that your IdP cannot reach. +:: + **Using Metadata URL (recommended):** Instead of filling fields manually, paste your IdP's metadata URL and click **Sync**. This auto-fills the IdP SSO URL, Certificate, and Logout URL from the metadata XML. diff --git a/docs/4.configuration/1.environment-variables.md b/docs/4.configuration/1.environment-variables.md index 832f638..95c5ed5 100644 --- a/docs/4.configuration/1.environment-variables.md +++ b/docs/4.configuration/1.environment-variables.md @@ -29,6 +29,7 @@ These must match your PostgreSQL setup. | `APP_PORT` | No | `1401` | Port the API server listens on | | `PM2_INSTANCES` | No | `2` | Number of API worker processes (PM2 cluster mode). Accepts a number or `max` (one worker per CPU core). When using `max`, set `DB_POOL_MAX` yourself so the connection budget above still fits. | | `APP_URL` | Yes | https://app.taskview.tech | Full URL of the web app (e.g. `https://tasks.company.com`). Used for OAuth redirects and email links. | +| `API_PUBLIC_URL` | No | - | Public URL of the **API server** as external systems see it (e.g. `https://api.company.com`). Used to build the SSO callback/ACS URL and the SCIM endpoint shown in organization settings. Set it when the API runs behind a reverse proxy — otherwise those URLs are derived from the browser's address and may show an internal host that your IdP cannot reach. The server refuses to start if the value is not a valid http(s) URL. | | `TRUST_PROXY` | No | `false` | Set when running behind a reverse proxy so `X-Forwarded-Proto`/`X-Forwarded-For` are honoured (correct `https` URLs, real client IP). Use the number of proxies in front of the app (`1` for a single Caddy/nginx), or an IP/subnet list (`10.0.0.0/8`, `uniquelocal`). Leave unset for direct access. Avoid `true` (trusts any hop, allows header spoofing). | ## Web app diff --git a/taskview-packages/taskview-api/src/api/__tests__/docker/docker-compose.yml b/taskview-packages/taskview-api/src/api/__tests__/docker/docker-compose.yml index 98ed3b6..1abf2ae 100644 --- a/taskview-packages/taskview-api/src/api/__tests__/docker/docker-compose.yml +++ b/taskview-packages/taskview-api/src/api/__tests__/docker/docker-compose.yml @@ -36,6 +36,8 @@ services: # The test suite runs against a closed instance (see registration-flag.test.ts); # no other test creates accounts through public registration paths. ALLOW_PUBLIC_REGISTRATION: "false" + # IdP-facing URLs are built from this base (see sso-public-urls.test.ts) + API_PUBLIC_URL: "https://api.public.example" extra_hosts: - "host.docker.internal:host-gateway" healthcheck: diff --git a/taskview-packages/taskview-api/src/api/__tests__/sso-public-urls.test.ts b/taskview-packages/taskview-api/src/api/__tests__/sso-public-urls.test.ts new file mode 100644 index 0000000..b030691 --- /dev/null +++ b/taskview-packages/taskview-api/src/api/__tests__/sso-public-urls.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { TvApi } from '@/tv' +import { initApi } from './init-api' + +// The test stack runs the API with API_PUBLIC_URL=https://api.public.example +// (set in docker/docker-compose.yml). The URLs an admin copies into an IdP +// must be built from that base — not from whatever host the request came in on. + +let user1Api: TvApi + +beforeAll(async () => { + const init = await initApi() + user1Api = init.$tvApi +}) + +describe('SSO public URLs (API_PUBLIC_URL)', () => { + it('builds IdP-facing URLs from API_PUBLIC_URL, not from the request host', async () => { + const urls = await user1Api.sso.getPublicUrls() + + expect(urls.apiPublicUrlConfigured).toBe(true) + expect(urls.apiBaseUrl).toBe('https://api.public.example') + expect(urls.callbackUrlTemplate).toBe('https://api.public.example/module/sso/callback/{id}') + expect(urls.scimEndpointUrl).toBe('https://api.public.example/scim/v2') + }) +}) diff --git a/taskview-packages/taskview-api/src/api/sso.ts b/taskview-packages/taskview-api/src/api/sso.ts index 8ba48c8..176ff76 100644 --- a/taskview-packages/taskview-api/src/api/sso.ts +++ b/taskview-packages/taskview-api/src/api/sso.ts @@ -5,6 +5,7 @@ import type { SsoConfigArgCreate, SsoConfigArgUpdate, SsoProviderPublic, + SsoPublicUrls, } from './sso.types' export default class TvSsoApi extends TvApiBase { @@ -36,6 +37,12 @@ export default class TvSsoApi extends TvApiBase { ) } + public async getPublicUrls() { + return this.request( + this.$axios.get>(`${this.moduleUrl}/admin/public-urls`) + ) + } + public async parseMetadata(url: string) { return this.request( this.$axios.get>(`${this.moduleUrl}/admin/metadata`, { diff --git a/taskview-packages/taskview-api/src/api/sso.types.ts b/taskview-packages/taskview-api/src/api/sso.types.ts index 6706a54..0d4683c 100644 --- a/taskview-packages/taskview-api/src/api/sso.types.ts +++ b/taskview-packages/taskview-api/src/api/sso.types.ts @@ -81,3 +81,10 @@ export type SsoProviderPublic = { displayName: string protocol: string } + +export type SsoPublicUrls = { + apiBaseUrl: string + callbackUrlTemplate: string + scimEndpointUrl: string + apiPublicUrlConfigured: boolean +} diff --git a/web/src/components/features/organizations/parts/OrgSsoSettings.vue b/web/src/components/features/organizations/parts/OrgSsoSettings.vue index d9c7de7..b3f8500 100644 --- a/web/src/components/features/organizations/parts/OrgSsoSettings.vue +++ b/web/src/components/features/organizations/parts/OrgSsoSettings.vue @@ -44,7 +44,7 @@ import { storeToRefs } from 'pinia' import { $tvApi } from '@/plugins/axios' import { additionalUrlStore } from '@/stores/additional-url.store' import { useAdditionalServer } from '@/composables/useAdditionalServer' -import type { SsoConfig } from 'taskview-api' +import type { SsoConfig, SsoPublicUrls } from 'taskview-api' import OrgSsoConfigCard from './OrgSsoConfigCard.vue' import OrgSsoConfigForm from './OrgSsoConfigForm.vue' @@ -78,13 +78,21 @@ const form = reactive({ oidcCallbackUrl: '', }) -const apiBaseUrl = computed(() => mainServer.value || window.location.origin) -const callbackUrlPlaceholder = computed(() => `${apiBaseUrl.value}/module/sso/callback/${ssoConfig.value?.id ?? '{id}'}`) -const activeCallbackUrl = computed(() => ssoConfig.value ? `${apiBaseUrl.value}/module/sso/callback/${ssoConfig.value.id}` : '') -const scimEndpointUrl = computed(() => `${apiBaseUrl.value}/scim/v2`) +// URLs the admin copies into the IdP come from the server (API_PUBLIC_URL aware); +// the browser-derived base is only a fallback for older API servers. +const publicUrls = ref(null) + +const apiBaseUrl = computed(() => publicUrls.value?.apiBaseUrl ?? (mainServer.value || window.location.origin)) +const callbackUrlPlaceholder = computed(() => { + const template = publicUrls.value?.callbackUrlTemplate ?? `${apiBaseUrl.value}/module/sso/callback/{id}` + return template.replace('{id}', String(ssoConfig.value?.id ?? '{id}')) +}) +const activeCallbackUrl = computed(() => ssoConfig.value ? callbackUrlPlaceholder.value : '') +const scimEndpointUrl = computed(() => publicUrls.value?.scimEndpointUrl ?? `${apiBaseUrl.value}/scim/v2`) onMounted(async () => { await useAdditionalServer() + publicUrls.value = await $tvApi.sso.getPublicUrls().catch(() => null) }) watch(() => props.organizationId, () => fetchConfig(), { immediate: true })