Skip to content

feat(security): per-tenant OIDC clients and tenant-scoped Authentik config (#434 stage 2) - #448

Merged
izzywdev merged 3 commits into
masterfrom
feat/security-broker-stage2
Jul 29, 2026
Merged

feat(security): per-tenant OIDC clients and tenant-scoped Authentik config (#434 stage 2)#448
izzywdev merged 3 commits into
masterfrom
feat/security-broker-stage2

Conversation

@izzywdev

Copy link
Copy Markdown
Owner

Stage 2 of #434, on top of #441. Closes the acceptance criterion "no process.env.AUTHENTIK_* read remains outside the tenant registry."

What changed

  • oidc.tsOIDCService now takes an AuthentikTenant instead of reading env at import time. One instance per tenant, keyed by tenant id rather than host, because several hosts can map to one tenant (live./marketplace.mendysrobotics.com) and must share a single discovery cache and init/backoff state instead of racing into duplicate discovery calls. The oidcService singleton is replaced by getOidcService() / getOidcServiceFor(tenant).
  • initializeAllTenants() — warms every configured tenant at boot and starts each one's self-heal loop. Failures are logged, not thrown: one tenant's Authentik being down must not stop the others coming up, nor reset their state.
  • The in-cluster endpoint rewrite (the iss subtlety) now uses tenant.baseUrl, so each tenant's server-side token/userinfo/jwks calls land on its own authentik Service.
  • authentikPassword.ts, accountApi.ts, machine-identity.ts, AuthentikIdentityProvider.ts, routes/auth.ts, routes/invitations.ts — base URL, issuer, redirect URI, admin token, client credentials and enrollment flow slug all resolve from the tenant.

Two judgement calls worth reviewing

Left on the environment deliberately: AUTHENTIK_AUTH_FLOW_SLUG (authentik's own built-in flow slug, identical in every instance) and AUTHENTIK_FLOW_TIMEOUT_MS (a tuning knob). Neither is tenant-identifying. Everything that is now comes from the registry.

AuthentikIdentityProvider binds oidc through a lazy getter, not at construction. The provider is built once, outside any request, so capturing an instance would permanently pin it to whichever tenant was ambient at construction time. An injected override still wins, so test injection is unaffected.

Test changes, and why they were needed

The removed singleton was part of six suites' mocking contract, so this is a real API change, not incidental churn:

  • Four mock the module. Their factories now build the object once and expose both oidcService and getOidcService: () => oidcService backed by the same object, so every existing assertion still addresses exactly what the code under test receives. Nothing below the mock line changed.
  • Two use the real module (oidc-lazy-reinit re-requires it per test after resetModules; oidc-google-signin imports it) and now call getOidcService(). The per-test isolation lazy-reinit depends on is preserved — a fresh module means a fresh per-tenant instance map, hence a fresh client.

Verification

result
Tenant registry + 8 Authentik/OIDC suites 124 passed / 2 failed
Baseline (master) for the same 8 suites 96 passed / 2 failed
96 + 28 new registry tests 124 — exact match, no regression
tsc --noEmit clean (one pre-existing unrelated eventPublisher error)
process.env.AUTHENTIK_* outside the registry none

The 2 failures are google-brokered-signin and authentik-provider, which reproduce on clean master and are pre-existing — I confirmed that earlier by running the same set against a stashed tree. They are real failures on master today and worth their own look, but not from this work.

Still open on #434

Mounting tenantContext on the auth routers; tenant-scoped session claims at mint time; the chart wiring (per-tenant env replacing the flat AUTHENTIK_* block in templates/security.yaml, plus routing /api/v1/security/* and /api/auth/* from the Mendys hosts); and the per-tenant Google client decision (shared client vs one each — a consent-screen branding call).

Related: #428, #433, #439, #441, izzywdev/FuzeInfra#421, izzywdev/MendysRobotics#253.

🤖 Generated with Claude Code

fuzeone-bot Bot and others added 2 commits July 29, 2026 10:56
…nfig [skip ci]

Stage 2 of #434. SOURCE COMPLETE, TESTS NOT YET UPDATED — see below. Pushed to
avoid stranding the work; not ready for review.

Removes the last process.env.AUTHENTIK_* reads outside the registry:
  - oidc.ts: OIDCService now takes an AuthentikTenant instead of reading env at
    import time. One instance per tenant, keyed by tenant id (several hosts can
    map to one tenant and must share a discovery cache and init/backoff state).
    The former `oidcService` singleton is replaced by getOidcService() /
    getOidcServiceFor(tenant), plus initializeAllTenants() which warms every
    tenant at boot and starts each one's self-heal loop independently — one
    tenant's Authentik being down must not block or reset another's.
  - The in-cluster endpoint rewrite now uses tenant.baseUrl, so each tenant's
    server-side token/userinfo/jwks calls land on ITS OWN authentik Service.
  - authentikPassword.ts, accountApi.ts, machine-identity.ts,
    AuthentikIdentityProvider.ts, routes/auth.ts, routes/invitations.ts: base
    URL, issuer, redirect URI, admin token, client credentials and enrollment
    flow slug all resolve from the tenant. AUTHENTIK_AUTH_FLOW_SLUG and
    AUTHENTIK_FLOW_TIMEOUT_MS stay on the environment deliberately — the former
    is authentik's own built-in slug, identical in every instance; the latter is
    a tuning knob.
  - AuthentikIdentityProvider binds `oidc` through a lazy getter rather than at
    construction: the provider is built once, outside any request, so capturing
    an instance would pin it to whichever tenant was ambient at construction.

tsc --noEmit is clean (one pre-existing unrelated eventPublisher error).

KNOWN BROKEN: 7 suites mock the module as
`jest.mock('../src/services/oidc', () => ({ oidcService: {...} }))`. That export
no longer exists, so the mocks resolve undefined and the suites fail with
"Cannot read properties of undefined". 12 failed / 56 passed against a baseline
of 2 failed / 96 passed. This is a real contract change that must be carried
through those mocks — NOT a flake, and not safe to merge until done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session-Id: 55a394fa-d5ba-4da4-b41f-b0ef56fa6ddf
Completes the stage-2 contract change. The former `oidcService` singleton no
longer exists, so six suites needed updating:

  - Four mock the module. Their factories now build the object once and expose
    both `oidcService` and `getOidcService: () => oidcService`, backed by the
    SAME object, so every existing assertion still addresses exactly what the
    code under test receives — the mock shape is unchanged below that line.
  - Two use the REAL module (oidc-lazy-reinit re-requires it per test after
    resetModules; oidc-google-signin imports it) and now call getOidcService().
    The per-test isolation lazy-reinit relies on is preserved: a fresh module
    means a fresh per-tenant instance Map, hence a fresh client.

Result: 124 passed / 2 failed across the tenant registry plus the 8
Authentik/OIDC suites. That is exactly the 96/2 baseline plus the 28 new
registry tests — the same two pre-existing failures
(google-brokered-signin, authentik-provider) that reproduce on clean master,
and no regression. tsc --noEmit clean.

No `process.env.AUTHENTIK_*` read remains outside the registry, which closes
that acceptance criterion of #434.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session-Id: 55a394fa-d5ba-4da4-b41f-b0ef56fa6ddf
@github-actions
github-actions Bot enabled auto-merge (squash) July 29, 2026 10:31
@github-actions

Copy link
Copy Markdown
Contributor

Automated code review (gate-code-review)

Credit balance is too low

Report-only — this check never blocks merge.

invitation.role as 'owner' | 'admin' | 'member' | 'viewer'
)
} catch (permitErr) {
console.error(
#455 ("un-break sign-in — right-size the timeouts, 503 for outages") rewrote
authentikPassword.ts and its test heavily enough that git could not align them
at all — both came through as whole-file conflicts (line 1 to EOF). Rather than
hand-merge two ~900-line files, I took master's versions wholesale, so ALL of
#455's login fix is preserved verbatim, then re-applied my tenant edits on top:

  - authentikBaseUrl(), redirectUri(), enrollmentFlowSlug() and the admin token
    resolve from the current tenant instead of process.env
  - oidcService -> getOidcService()
  - the test's oidc mock exposes getOidcService() backed by the same object

#455's three NEW timeout knobs (AUTHENTIK_LOGIN_DEADLINE_MS,
AUTHENTIK_SLOW_HOP_WARN_MS, AUTHENTIK_LOGIN_WARN_MS) are deliberately left on
the environment — like AUTHENTIK_FLOW_TIMEOUT_MS they are tuning values, not
tenant-identifying configuration.

Verified after the merge: tsc --noEmit clean; 129 passed / 2 failed across the
tenant registry plus the 8 Authentik/OIDC suites. The 2 are the same
pre-existing failures that reproduce on clean master. The count rose from 124
to 129 because #455 added 5 tests to the password-login suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session-Id: 55a394fa-d5ba-4da4-b41f-b0ef56fa6ddf
@izzywdev
izzywdev merged commit c0af83f into master Jul 29, 2026
52 checks passed
@izzywdev
izzywdev deleted the feat/security-broker-stage2 branch July 29, 2026 22:12
izzywdev pushed a commit that referenced this pull request Jul 29, 2026
…r-menu-clplz8

Third merge from master. Two conflicts, both resolved in master's favour, plus
one latent defect of my own that the merge brought into view.

**`services/chat-service/src/rag/index-docs.ts`** — master fixed the SAME
`Awaited<ReturnType<typeof fs.readdir>>` bug I had just fixed, with the same
root-cause diagnosis (ReturnType resolves to readdir's Buffer overload under
@types/node 24) and an equivalent remedy: infer from the call rather than
annotate. Took master's version byte-for-byte and dropped my now-unneeded
`type Dirent` import. It is their file and their fix; divergence here buys
nothing.

**`package-lock.json`** — master removed the stale nested
`services/chat-service/node_modules/@types/node@18.19.0`, which is exactly the
lockfile reconciliation my previous commit flagged as needed but out of scope.
Resolved by taking master's lock wholesale and re-running `npm install` to
re-derive this branch's `services/notification-service` subtree, rather than
hand-editing lock JSON. Net effect: there is now exactly ONE `@types/node` in the
tree, the root `24.13.3` — every nested Node-18 copy is gone, so notification-
service resolves the version its manifest asks for and the class of failure that
broke CI cannot recur silently in these workspaces.

**`frontend/e2e/post-prod/live-smoke.spec.ts`** — not a conflict; a spec my
earlier sweep missed because it lives under `e2e/post-prod/` rather than
`tests/`. It seeded the bare `authToken` key before app boot. That still boots
today, but only because the account vault's one-time legacy migration sweeps it
— the same "upgrade path used as a write channel" trap already fixed in
WhiteLabelLoginCard. This one matters more: it is the POST-PRODUCTION synthetic
that verifies the live deployment, so it would have gone quietly red against prod
the day that migration is retired. Now routed through the shared
`seedMockSession` helper, whose docstring is generalised since it now seeds a
real prod token as well as fixtures.

Verified after the merge:
- frontend `tsc --noEmit` clean; vitest 136/136 across 19 files
- backend 91/91 (app-installations, notification-proxy, apps, provisioning,
  root-org-admin) against real Postgres, `--runInBand`
- chat-service 131 passed / 2 skipped across 24 suites; `tsc` clean
- notification-service `tsc` clean, 35/35, now resolving root 24.13.3
- master's new `@fuzefront/auth-ui` 17/17
- no duplicate migration ordinals: 014, 015, 016, 017
- `packages/auth-ui` touches no storage, so #458 introduces no bare-token write

Note for anyone running the backend suites locally: master's Permit ReBAC work
makes `PERMIT_API_KEY` mandatory at import time, so they need
`PERMIT_API_KEY=ci-no-real-permit-calls` (what CI passes) or every suite fails in
setup before a single test runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KynhzG6wKxUnR8KWwK8un
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants