-
Notifications
You must be signed in to change notification settings - Fork 2
Pact MultiTenantOAuth
options.oauth (see OAuth) configures every provider
instance at construction — the right shape for a fixed set of global
providers (Google/GitHub sign-in for every user, say). It stops fitting
the moment provider instances are one per tenant instead: each
customer brings their own enterprise identity provider (their own
Microsoft Entra ID tenant, their own Google Workspace, their own generic
OIDC issuer), added, edited, and removed while the app keeps running —
you cannot know that set at construction, and you cannot restart every
running process every time one customer changes their SSO settings.
This page covers registering, updating, and removing an OAuth provider instance AFTER construction, and the one thing you still have to build yourself: making sure every running instance of your app agrees on the current set.
- The three runtime methods
- Naming convention
- Validate at write time, not at first login
- Propagating to every running instance
- A worked shape
- What NOT to do
import { Pact } from '@tundralibs/pact';
const pact = Pact.create({
bits: { READ: 1n },
modulePermissions: { Post: ['READ'] },
hooks: {},
});
// Register — or replace — one provider instance. Runs the SAME
// validation the constructor's `oauth` option does: a bad config
// throws `INVALID_OPTION` immediately, never silently succeeds.
pact.updateOAuth('acme:entra', {
kind: 'OIDC',
issuer: 'https://login.microsoftonline.com/acme-tenant-id/v2.0',
clientId: 'acme-app-registration-id',
clientSecret: 'secret', // load from your own secret store
redirectUri: 'https://app.example.com/auth/acme-entra/callback',
});
// Every currently-registered instance name — whatever the constructor
// declared, plus everything added since, minus everything removed.
pact.oauthProviders; // readonly string[]
// Remove one. A later oauthRedirect()/oauthLogin() against that name
// fails UNKNOWN_PROVIDER, same as a name that was never configured.
pact.removeOAuth('acme:entra');Calling updateOAuth again with a name that already exists REPLACES
that instance — the usual case is a tenant editing their IdP settings,
not adding a second one under the same name. OAuthClient instances are
immutable, so a login already in flight against the OLD client finishes
against it; nothing about an in-progress redirect/callback pair breaks
mid-flight when you replace the provider it started with.
Both updateOAuth and removeOAuth, like the constructor's own oauth
loop, do no network I/O — even the OIDC kind only resolves its
discovery document lazily, on first oauthRedirect/oauthLogin call.
Registering a thousand tenant IdPs costs nothing until a user from one of
them actually tries to log in.
oauthRedirect(name) / oauthLogin(name, ...) take whatever string you
registered the provider under — pact has no opinion on its shape. For a
per-tenant deployment, a composite name that encodes the tenant keeps
static and dynamic providers on the same footing and keeps routing
simple:
<tenantSlug>:<kind> acme:entra, acme:workspace, globex:oidc
Your login route becomes /auth/:tenant/:kind/redirect and
/auth/:tenant/:kind/callback, both resolving straight to
`${tenant}:${kind}` — no separate lookup table mapping a route
parameter to a provider name.
The same prefix lets the oauthIdentifier hook give each tenant its own
account for one email, and lets grants be scoped per tenant. See
Tenants.
The whole point of building every OAuthClient eagerly (constructor or
updateOAuth, same code path) is that a bad config fails at THAT
moment — "so a typo fails at boot, not at first login" is pact's
existing principle for the static case (OAuth).
For a dynamic provider, the equivalent moment is wherever a tenant
registers or edits their IdP settings, and that admin action is the
natural place to enforce it:
// The admin-facing "save this tenant's SSO config" handler:
async function saveTenantIdp(tenantSlug: string, config: TenantIdpInput) {
const name = `${tenantSlug}:${config.kind}`;
try {
pact.updateOAuth(name, toProviderConfig(config)); // throws on a bad config
} catch (error) {
return { status: 400, body: { error: String(error) } };
}
// Only reaching here means pact accepted it — NOW persist it.
await db.tenantIdps.upsert({ tenantSlug, ...config });
return { status: 200 };
}Validate-then-persist, never persist-then-validate: a config that fails
updateOAuth never reaches your database, so every OTHER instance's
reconciliation pass (next section) can trust that anything it reads back
out is known-good — it never has to re-run the validation your admin
handler already did.
updateOAuth/removeOAuth only mutate the ONE Pact instance that
calls them. In a single-process deployment that is the whole story. In a
horizontally-scaled one, the other running instances still hold whatever
they last knew — a request routed to a DIFFERENT instance than the one
that handled the admin write sees the OLD config (or none) until
something tells that instance to catch up.
Default recommendation: a periodic reconciliation loop, not pub/sub.
Every instance, on a timer (a minute is generally short enough — this is
an admin action, not a request-hot path), diffs pact.oauthProviders
against the source of truth (your own database table of tenant IdPs) and
calls updateOAuth/removeOAuth for whatever changed:
async function reconcileOAuthProviders() {
const rows = await db.tenantIdps.findAll(); // your own source of truth
const wanted = new Map(rows.map((r) => [`${r.tenantSlug}:${r.kind}`, r]));
for (const name of pact.oauthProviders) {
if (!wanted.has(name)) pact.removeOAuth(name);
}
for (const [name, row] of wanted) {
pact.updateOAuth(name, toProviderConfig(row)); // cheap even if unchanged
}
}
// Run once before serving traffic — "boot" is just poll zero — then on
// a timer (@tundralibs/cronus, or your framework's own scheduler).
await reconcileOAuthProviders();
setInterval(reconcileOAuthProviders, 60_000);This one loop covers BOTH cases with no extra code: a freshly-started instance hydrates its full set on the first run, and a long-running instance picks up any change on the next tick. It needs no additional infrastructure (no broker, no pub/sub channel) and is self-healing — an instance that missed a change for any reason (a restart mid-update, a dropped message on some OTHER mechanism) corrects itself on its very next poll, which a fire-and-forget notification alone cannot promise.
Re-running updateOAuth for an unchanged row is harmless — it just
replaces the OAuthClient with an equivalent one — so the loop above
doesn't need to diff row CONTENT, only row PRESENCE. If reconciliation
cost becomes a real concern at a very large tenant count, add a
updatedAt filter to only re-fetch rows that changed since the last
poll; that's a query optimization, not a change to the shape.
If a minute of worst-case propagation lag to instances OTHER than the
one that handled the write is too slow for your use case, add a pub/sub
notification (Redis, or your queue of choice) that triggers an
OUT-OF-BAND run of the same reconcileOAuthProviders function on every
instance when a change happens — a latency optimization layered on top
of the loop, not a replacement for it. Keep the periodic poll regardless,
as the self-healing fallback for whenever the notification itself is
missed.
Putting the pieces together, a per-tenant OAuth deployment is:
-
Your own table of tenant → IdP config (issuer, client id/secret,
redirect URI, kind). Pact does not store this — it is the input to
updateOAuth, not something pact tracks on your behalf. -
An admin write path that calls
updateOAuthsynchronously, returns 400 onINVALID_OPTION, and only then writes to that table. -
A reconciliation loop, run once at startup and then on a timer,
that diffs
pact.oauthProvidersagainst the table and callsupdateOAuth/removeOAuthto catch this instance up. -
Composite provider names (
<tenant>:<kind>) so your login routes need no separate lookup between a route parameter and a provider name. - (Optional) a pub/sub trigger that runs step 3 out-of-band for lower propagation latency, with the timer kept as the fallback.
-
Don't build an
OAuthClientyourself outsideupdateOAuth. The class is exported for pact's own internal use;updateOAuthis the supported way to register one, and it stays in sync with whatever validation the constructor'soauthloop does as pact evolves. -
Don't persist a tenant's config before
updateOAuthaccepts it. Persist-then-validate lets a bad config sit in your database, silently broken, until the affected tenant's first login attempt discovers it — exactly the failure mode eager construction exists to prevent. - Don't skip the periodic loop because you added pub/sub. A message bus can drop a message (a redeploy mid-publish, a broker restart); the loop is what makes a missed notification a non-event instead of a standing inconsistency between instances.