Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions backend/security/src/routes/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions backend/security/tests/security-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
9 changes: 9 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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/<slug>/* -> 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)

Expand Down
Loading
Loading