mirror of
https://github.com/Gimanh/taskview-community.git
synced 2026-09-11 21:38:56 +00:00
Merge pull request #92 from Gimanh/feat/4745-public-api-url
feat: public API URL fix for sso callback
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user