Skip to content

fix(api): add opt-in OIDC_DEFAULT_TENANT fallback (fail-closed by default) - #4

Merged
syzygyhack merged 2 commits into
syzygyhack:mainfrom
wangmingzhou1986:fix/oidc-default-tenant
Jul 27, 2026
Merged

fix(api): add opt-in OIDC_DEFAULT_TENANT fallback (fail-closed by default)#4
syzygyhack merged 2 commits into
syzygyhack:mainfrom
wangmingzhou1986:fix/oidc-default-tenant

Conversation

@wangmingzhou1986

Copy link
Copy Markdown
Contributor

Problem

In production (NODE_ENV=production), every authenticated request fails with 401 Authentication required, even with a fully valid bearer token.

The root cause is not signature/JWKS verification — jose.jwtVerify succeeds with the exact issuer/audience/JWKS (reproduced standalone). It is tenant resolution:

  • OidcAuthenticator defaults to tenantClaim = "tenant_id" and defaultTenantId = null.
  • packages/api/src/server.ts calls authenticator.configure({...}) with only issuer, clientId, jwksUri — it never passes defaultTenantId.
  • After a successful jwtVerify, extractUser() reads claims["tenant_id"]; a stock Keycloak access token has no such claim → tenantId = nullAuthenticationError("MISSING_TENANT") → mapped to 401.

The repo's own deploy/docker-compose.yaml Keycloak issues tokens without a tenant_id claim, so the default production deployment cannot authenticate any user.

Reproduce

  1. NODE_ENV=production; wire OIDC_ISSUER / OIDC_CLIENT_ID / OIDC_JWKS_URI to the bundled Keycloak.
  2. Obtain a token via password grant for any realm user.
  3. curl /graphql -H "authorization: Bearer <token>" -d '{"query":"{ __typename }"}'401 UNAUTHENTICATED.

Fix

Add an OIDC_DEFAULT_TENANT fallback (default default) in the authenticator.configure({...}) call, so tokens without a tenant_id claim resolve to a sane tenant. Multi-tenant deployments can still map real tenants via a tenant_id claim / Keycloak protocol mapper.

authenticator.configure({
  issuer: oidcIssuer,
  clientId: process.env['OIDC_CLIENT_ID'] ?? 'openfoundry',
  jwksUri: process.env['OIDC_JWKS_URI'] ?? `${oidcIssuer}/protocol/openid-connect/certs`,
  defaultTenantId: process.env['OIDC_DEFAULT_TENANT'] ?? 'default', // <-- added
});

Verified

After the fix + image rebuild: no token → 401; a valid token → authenticated (tenant default); the full governed-action pipeline (create / validate / ReBAC deny / audit) works end-to-end.

Fixes #1.

@syzygyhack

Copy link
Copy Markdown
Owner

Thanks for your PR. Please review the comment #1 (comment) so we can decide how to proceed.

…s + tests

Per review on syzygyhack#1: drop the implicit `?? 'default'` (fail-open) in favor of an
explicit opt-in env knob. Unset arrives as undefined and configure() normalizes
it to null, preserving the documented fail-closed posture (claimless tokens are
rejected). Single-tenant deployments set OIDC_DEFAULT_TENANT=default; multi-tenant
deployments leave it unset and map real tenants via a tenant_id claim.

- packages/api/src/server.ts: opt-in wiring + env doc.
- deploy/README.md, .env.example, docker-compose.yaml, Helm values + configmap:
  document/surface the knob (fail-closed by default).
- oidc-authenticator.test.ts: assert unset stays fail-closed and set opts into
  the fallback tenant.
@wangmingzhou1986

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — agreed on both points. Pushed a revision (9d31308).

Fail-open → opt-in. Dropped the implicit ?? 'default'; server.ts now wires defaultTenantId: process.env['OIDC_DEFAULT_TENANT']. Unset arrives as undefined, which configure() normalizes to null, so the documented fail-closed posture is preserved (a claimless token still 401s with MISSING_TENANT). A real tenant_id claim always wins; single-tenant deployments opt in with OIDC_DEFAULT_TENANT=default.

Docs + surfaces. Documented the knob in deploy/README.md (and softened the "mandatory" note to mention the opt-in), .env.example, docker-compose.yaml, and Helm — values.yaml (auth.oidc.defaultTenant) + configmap.yaml, emitted conditionally so an empty value stays fail-closed.

Test. Added oidc-authenticator.test.ts cases: one asserting that leaving OIDC_DEFAULT_TENANT unset stays fail-closed (MISSING_TENANT), and one asserting an explicit value opts into the fallback tenant.

On the framing: you're right that a fresh bundle authenticates — the realm now ships the tenant_id mapper. Our environment almost certainly predates it (the Keycloak DB wasn't re-imported after the mapper was added), which matches your "realm import only runs against a fresh DB" note. I'll retitle #1 to something like "IdPs that don't emit a tenant_id claim are rejected (no opt-in fallback)" to reflect that this is about claimless IdPs rather than the default bundle. Thanks again!

@wangmingzhou1986 wangmingzhou1986 changed the title fix(api): OIDC auth 401s in production when IdP tokens lack a tenant_id claim fix(api): add opt-in OIDC_DEFAULT_TENANT fallback (fail-closed by default) Jul 27, 2026
@syzygyhack

Copy link
Copy Markdown
Owner

Verified locally on 9d31308:

  • Fail-closed holdsconfigure() normalizes defaultTenantId ?? null, so unset env → undefinednullMISSING_TENANT. Good call pinning that undefined hop with a test rather than assuming it.
  • Helm default stays safe — empty defaultTenant omits the key entirely rather than emitting an empty value; matches the existing jwksUri pattern.
  • @openfoundry/security 105 passing (both new cases), typecheck 29/29, build 15/15, no drift beyond the 7 intended files.

Your persisted-realm diagnosis is right, realm import only runs against a fresh Keycloak DB, so environments predating the tenant_id mapper still lack it; re-import or down -v is the fix there.

Nice work. I handled the Trivy check, it's just CVE drift, so unrelated. LGTM!

@syzygyhack
syzygyhack merged commit bf2ad62 into syzygyhack:main Jul 27, 2026
5 of 6 checks passed
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.

bug(api): IdPs that do not emit a tenant_id claim are rejected in production (no opt-in fallback)

2 participants