Merge pull request #92 from Gimanh/feat/4745-public-api-url

feat: public API URL fix for sso callback
This commit is contained in:
Nikolai Giman
2026-07-18 14:53:34 +02:00
committed by GitHub
11 changed files with 101 additions and 5 deletions
+2
View File
@@ -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;
+28
View File
@@ -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}"`);
}
}
}
+11
View File
@@ -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' })
+1
View File
@@ -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)
+4
View File
@@ -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.
@@ -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
@@ -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:
@@ -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')
})
})
@@ -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<AppResponse<SsoPublicUrls>>(`${this.moduleUrl}/admin/public-urls`)
)
}
public async parseMetadata(url: string) {
return this.request(
this.$axios.get<AppResponse<{ samlEntryPoint: string, samlCert: string, samlLogoutUrl: string }>>(`${this.moduleUrl}/admin/metadata`, {
@@ -81,3 +81,10 @@ export type SsoProviderPublic = {
displayName: string
protocol: string
}
export type SsoPublicUrls = {
apiBaseUrl: string
callbackUrlTemplate: string
scimEndpointUrl: string
apiPublicUrlConfigured: boolean
}
@@ -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<SsoPublicUrls | null>(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 })