diff --git a/backend/security/src/routes/security.ts b/backend/security/src/routes/security.ts index 577bcfdc0..ad8a3e1ec 100644 --- a/backend/security/src/routes/security.ts +++ b/backend/security/src/routes/security.ts @@ -144,6 +144,53 @@ function sendError(res: Response, err: unknown): void { // Retry-After marks it explicitly retryable; both errors are transient // from the caller's point of view (an unavailable provider recovers, and // an undriveable flow stage is retryable via the SSO button). + // + // THE RESPONSE DELIBERATELY DOES NOT SAY WHICH OF THE TWO IT IS, so the + // SERVER LOG MUST. `PROVIDER_UNAVAILABLE` covers two conditions that call + // for opposite responses from whoever is paged: + // + // AuthentikUnavailableError the identity provider is unreachable or too + // slow -> a platform incident, everyone is + // affected, go look at the provider. + // UnsupportedFlowStageError THIS account's login flow presented a stage + // a server cannot drive (MFA, consent, a + // prompt) -> nothing is down, browser/SSO + // sign-in still works, and only callers using + // the programmatic password grant are + // affected. `err.message` carries the + // offending stage component. + // + // Until 2026-08-26 this branch logged NOTHING and both rendered as one + // opaque 503. That cost real time: a monitoring account whose flow gained + // an undriveable stage was escalated as "production authentication is + // down" while browser sign-in was working the whole time. The stage name + // was in the thrown error and was being discarded one line from here. + // + // The BODY stays generic on purpose and is not what changed. The provider's + // raw message can name internal hosts and flow slugs, and this endpoint is + // unauthenticated -- `security-routes.test.ts` pins that the message is + // never echoed. Diagnosis belongs in the log, which is already trusted with + // it; the sibling legacy route (routes/auth.ts) has logged exactly this + // split since it was written, so this brings the two into line rather than + // inventing a scheme. + // + // Newlines are stripped before logging: `err.message` is provider-derived + // and must not be able to forge extra log lines. + const detail = String((err as Error)?.message ?? '').replace(/[\r\n]+/g, ' ') + if (name === 'UnsupportedFlowStageError') { + console.warn( + '[security] createSession 503 PROVIDER_UNAVAILABLE: undriveable Authentik flow stage ' + + '(NOT an outage -- browser/SSO sign-in is unaffected; this account needs it). ' + + JSON.stringify({ stage: detail }) + ) + } else { + console.error( + '[security] createSession 503 PROVIDER_UNAVAILABLE: Authentik unreachable or too slow ' + + '(platform incident -- all password sign-ins affected). ' + + JSON.stringify({ detail }) + ) + } + res.setHeader('Retry-After', '5') res.status(503).json({ error: 'Authentication unavailable', code: 'PROVIDER_UNAVAILABLE' }) return diff --git a/backend/security/tests/security-routes.test.ts b/backend/security/tests/security-routes.test.ts index 290ee46b8..0e3bc681e 100644 --- a/backend/security/tests/security-routes.test.ts +++ b/backend/security/tests/security-routes.test.ts @@ -336,6 +336,68 @@ describe('POST /session (password login)', () => { expect(JSON.stringify(res.body)).not.toContain('bad day') }) } + + // The body is deliberately identical for both errors (above), so the LOG is + // the only thing that tells an operator which one happened -- and they call + // for opposite responses: a provider outage pages everyone, an undriveable + // flow stage affects one account and nothing is down. Before 2026-08-26 this + // branch logged nothing at all, and the ambiguity turned a broken monitoring + // credential into a reported production outage. These pin the split. + describe('server-side diagnosis (the body cannot carry it, so the log must)', () => { + const rejectWith = (name: string, message: string) => { + const err = new Error(message) + err.name = name + return fakeProvider({ passwordLogin: jest.fn().mockRejectedValue(err) }) + } + + it('names the undriveable stage, and says it is NOT an outage', async () => { + // @fuzequality api createSession + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await request(makeApp(rejectWith('UnsupportedFlowStageError', 'ak-stage-authenticator-validate'))) + .post('/api/v1/security/session') + .send({ email: 'x', password: 'y' }) + + const logged = warn.mock.calls.map(c => c.join(' ')).join('\n') + expect(logged).toContain('ak-stage-authenticator-validate') + expect(logged).toContain('NOT an outage') + } finally { + warn.mockRestore() + } + }) + + it('reports a provider outage distinctly, at error level', async () => { + // @fuzequality api createSession + const error = jest.spyOn(console, 'error').mockImplementation(() => {}) + try { + await request(makeApp(rejectWith('AuthentikUnavailableError', 'connect ETIMEDOUT'))) + .post('/api/v1/security/session') + .send({ email: 'x', password: 'y' }) + + const logged = error.mock.calls.map(c => c.join(' ')).join('\n') + expect(logged).toContain('platform incident') + expect(logged).not.toContain('NOT an outage') + } finally { + error.mockRestore() + } + }) + + it('strips newlines so a provider message cannot forge log lines', async () => { + // @fuzequality api createSession + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await request(makeApp(rejectWith('UnsupportedFlowStageError', 'ak-stage-x\n[security] FORGED'))) + .post('/api/v1/security/session') + .send({ email: 'x', password: 'y' }) + + const logged = warn.mock.calls.map(c => c.join(' ')).join('\n') + expect(logged).toContain('FORGED') // still recorded, not dropped + expect(logged).not.toMatch(/\n\[security\] FORGED/) // but not on its own line + } finally { + warn.mockRestore() + } + }) + }) }) describe('GET /session (me) — bearer enforcement', () => { diff --git a/backend/src/index.ts b/backend/src/index.ts index fa9d9f501..6efdedc52 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -16,6 +16,7 @@ import internalRoutes from './routes/internal' import billingRoutes, { billingWebhookRouter } from './routes/billing' import appRegistryRoutes from './routes/appRegistry' import appRegistryProxyRoutes from './routes/app-registry' +import federatedProxyRoutes from './routes/federatedProxy' import flagsRoutes from './routes/flags' import portalRoutes from './routes/portal' import adminPortalRoutes from './routes/adminPortals' @@ -343,6 +344,14 @@ app.use('/api/v1/app-registry', appRegistryRoutes) // federated app (e.g. the built-in Clock) can mount. Forwards the platform JWT // verbatim; the applications-service does its own authn/authz. app.use('/api/v1/app-registry', appRegistryProxyRoutes) +// Same-origin federated asset proxy: /apps//* -> the remote's in-cluster +// Service. Without this the path falls through the ingress `/` rule to the +// frontend, whose SPA fallback answers with 200 + index.html — a remoteEntry +// that is HTML, which is precisely what the portal census reports. Operator +// allowlist only (FEDERATED_PROXY_UPSTREAMS); see routes/federatedProxy.ts for +// why it is not derived from the registry. +app.use('/apps', federatedProxyRoutes) + // Internal, secret-guarded provisioning endpoint (NOT exposed via public ingress). app.use('/internal', internalRoutes) diff --git a/backend/src/routes/federatedProxy.ts b/backend/src/routes/federatedProxy.ts new file mode 100644 index 000000000..954eaa936 --- /dev/null +++ b/backend/src/routes/federatedProxy.ts @@ -0,0 +1,429 @@ +// Same-origin reverse proxy for federated remotes: `/apps//*`. +// +// WHY THIS EXISTS. `frontend/src/utils/loadFederatedApp.ts:71` resolves every +// remote against the portal's own origin — +// +// const resolved = new URL(remoteEntry, origin) +// +// — so a registered `integration.remoteEntry` of `/apps/finance/remoteEntry.js` +// is fetched from `https://app.fuzefront.com/apps/finance/remoteEntry.js`. +// Nothing served that path. It fell through the ingress `/` rule to the +// frontend, whose SPA fallback answers ANY unmatched path with 200 + index.html +// — which is exactly why the census scores `fuzequality` as "remoteEntry +// returned 200 but is HTML". A green healthcheck in front of a blank panel. +// +// WHY NOT AN INGRESS PER REMOTE, which is what `.Values.federatedApps` builds. +// A Kubernetes Ingress may only name a Service in its OWN namespace, and every +// family product deploys to its own (`fuzemarket`, `fuzequality`, …). The +// documented escape hatch — an ExternalName Service — is refused by Traefik +// unless `allowExternalNameServices` is set, and it is false by default. So +// that mechanism cannot route a single out-of-namespace remote, which is why it +// has sat as `[]` in every values file since it was written and why only +// `clock` (same namespace, hand-written block) was ever reachable. +// +// A reverse proxy has no such restriction: cross-namespace Service DNS +// (`http://..svc.cluster.local`) is an ordinary HTTP call. It also puts +// every remote on the portal's own origin for free, which is what the +// same-origin/no-mixed-content rule wants anyway. +// +// TRUST MODEL — read before adding a "convenient" lookup here. +// The upstream map is operator configuration ONLY. It is deliberately NOT +// derived from the app registry, even though the registry knows every remote's +// URL: any authenticated product can self-register and edit its own manifest +// (`PUT /apps/{slug}`), so deriving the proxy target from registry data would +// let a registered app point this proxy at an arbitrary in-cluster address and +// read the response — a textbook SSRF, unauthenticated at that, since a +// `