Repository navigation
Azure multi-tenant: allow account adoption for an allowlist of tenants without restricting sign-in #272
Description
Since #271, a multi-tenant Azure provider (/common, /organizations) still lets anyone sign in, but those logins can't adopt an existing account. To get adoption, an operator pins one tenant (issuer). The provider then becomes single-tenant: it uses that tenant's own JWKS and issuer, and users from other tenants can't sign in through it at all.
That is secure, since it never adopts across tenants; any tenant can set any email on its own users. But it forces a choice between "anyone can sign in" and "my tenant's users can adopt their accounts".
Goal: keep multi-tenant sign-in for everyone, and allow account adoption only for tokens from an operator-allowlisted set of tenants, for example azure: { adoptionTenants: ['<tenant-guid>', …] }.
Security requirement. Adoption must trust the token's tenant only when that tenant is cryptographically established, not self-reported. A token's tid matching its own iss (https://login.microsoftonline.com/{tid}/v2.0) only shows the token's claims agree with each other. The established check is to also verify that the signing key's issuer property in Microsoft's JWKS response is valid for that tid. Some keys are tenant-specific, and the common key set carries a per-key issuer template. That property isn't exposed through jwks-rsa's public API, so this needs either our own bounded, pinned JWKS fetch and parse (we already have one for client keys in src/lib/mcp/jwksFetcher.ts) or a library that exposes it.
Design questions:
- the key-issuer source and cache;
- how
adoptionTenantsinteracts with an existing single-tenant pin; - startup validation of tenant GUIDs;
- the reason code and log when a non-allowlisted tenant's login is non-adoptable;
- docs on the trade-off.
Tests:
- an allowlisted tenant's genuine token adopts;
- a non-allowlisted tenant signs in but doesn't adopt;
- a token whose
iss/tidagree but whose signing key's issuer doesn't match is rejected for adoption; - tokens from the shared key set behave correctly;
- performance: no per-login network fetch beyond the cached JWKS.
Needs a design note and planning review before implementation, because it adds a new verification branch.
Resolved in #271 (was tracked here as a deferred follow-up; the safety gap was fixed, a smaller enhancement remains): resolveAzureIssuerBinding's alias-authority path originally only recognized the v2 keys-endpoint shape (.../common/discovery/v2.0/keys). Azure's v1 metadata document for the same shared authority advertises a DIFFERENT jwks_uri shape (.../common/discovery/keys, no v2.0), which went unrecognized — an UNPINNED v1-path alias config lost its "intentionally issuer-less, byte-identical to today" exemption (falling into generic OIDC discovery, which fails for it, and telling the operator to pin issuer), and a PINNED one on that shape would have verified against the shared, tenant-independent key pool directly with issuerValidated: true — exactly the case src/lib/DESIGN.md's Azure invariant says must never happen.
Fixed: isAzureJwksUri/resolveAzureIssuerBinding now recognize the v1 alias shape alongside the v2 one. Unpinned, it's excluded from discovery and left untouched — byte-identical to main, matching the v2 alias. Pinned, it throws AzureIssuerBindingError (fail-closed, same skip-with-logged-error treatment as any other invalid Azure pin) rather than silently binding to the shared pool.
Remaining, smaller follow-up (optional): redirect a pinned v1-alias config to the tenant's own non-shared v1 (or v2) keys endpoint instead of throwing — the same convenience the v2 alias path already has for a pinned tenant. Not done in #271 since the safety fix (fail closed) was the urgent part; this is a nice-to-have, not a gap.
Diagnostic follow-up (from #271 review): log a warning when azureAuthorizeIssuerHost returns null for an Azure-looking host during issuer derivation, since that host decides which issuer form is derived by default.
Activity
fix: derive the unpinned GUID issuer form from authorizationUrl; docu…
fix: recognize the v1 shared-alias JWKS shape to avoid a regression a…
Derive the ID-token issuer from presets and OIDC discovery (#271)