Repository navigation
Derive the ID-token issuer from presets and OIDC discovery - #271
Conversation
Core mechanism: OIDC discovery for explicit-endpoint static JWKS providers (src/lib/discovery.ts, reusing the existing pinned/bounded fetch), and a tenant-specific binding for Azure alias authorities pinned to exactly one tenant GUID (src/lib/azureIssuer.ts), collapsing into the already-safe single-tenant jwks-rsa path rather than ever consulting a shared key pool. - config.ts: buildProviderConfig dispatches configure() on the resolved preset identity (fixes the 'microsoft' alias bug), resolves Azure issuer binding, defers the #231 §4 hard-fail to background discovery when the authorizationUrl is https, and warns when a Graph UserInfo fetchEmail config is missing the profile scope. - OAuthProvider.ts: verifyIdToken always verifies the signature before checking issuer, so a forged token can never consume the bounded first-login discovery wait; a discovered issuer is cached onto the config. - index.ts: discovery is scheduled immediately after the provider registry becomes live, never for a build that's later discarded. - okta.ts: configure() accepts an optional custom authorization server id. - azure.ts: configure() no longer sets a literal (wrong) issuer for the common/organizations/consumers aliases. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…erver preset (#264) Also adds a retry-cooldown test override to discovery.ts so the failure-then-reload recovery path is actually exercised, not just assumed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…g, and the Azure constructor-level safety net (#264) Uses jwks-rsa's injectable fetcher to exercise real RS256 jwt.verify calls without network I/O, proving (not just asserting) that a forged token can never consume the bounded first-login discovery wait, that audience is still enforced on the discovery-eligible branch, and that OAuthProvider's constructor resolves the safe Azure tenant binding even when buildProviderConfig never ran (the TenantManager bypass path). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A rejected reload (the reserved 'mcp' provider name) never fetches; a reload whose later log call throws still schedules discovery for the already-published registry, since the registry swap it's tied to runs before that later call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…hServer/Azure-bypass paths (#264); fix lint Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nt adoption (#264) CHANGELOG [Unreleased] Added/Changed entries; docs/configuration.md's Account-Adoption Gate section and docs/providers.md's Okta/Azure/Custom OIDC sections updated so operators know issuer is usually unnecessary, how to pin it when they want to, and what changes for an operator who previously hit the #231 §4 startup hard-fail. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Triage of the cross-model pre-push review (codex + cursor-grok + harper-domain) against #264's implementation. Addresses every surviving major/minor finding plus two nits worth the diff: - Azure pin docs (major): the documented multi-tenant-pin example claimed "still accepts sign-ins from any tenant", but resolveAzureIssuerBinding actually collapses the provider to single-tenant — every other tenant's token now hard-fails verification, and Graph's /v1.0/me fallback can't rescue the login (no `email` field). Docs corrected to describe the real (intended) behavior instead of changing the behavior. - A same-tenant Azure issuer pin on a real tenant-GUID jwksUri is now canonicalized (trailing slash, case, the older sts.windows.net form, and arrays mixing those) instead of compared byte-for-byte, since jwt.verify itself does exact string comparison. An array naming more than one tenant still throws. - A throwing advisory logger in resolveAzureIssuerBinding no longer aborts the alias-pin rewrite. - The pin-wins-over-shortcut precedence (domain/tenantId vs explicit issuer) no longer lets an unresolved ${VAR} placeholder, null, or '' win over the shortcut-derived issuer. - validateIssuerForJwks's Azure carve-out now keys off jwksUri shape, not declared provider type — a generic/Okta/Auth0 provider manually pointed at Azure's shared alias host no longer hard-fails with a misleading "authorizationUrl" error; it's treated the same as an unpinned Azure alias. - OIDC discovery's candidatePrefixes now always includes the root prefix, even for a bare "/" authorizationUrl path (zero segments) or a path deeper than MAX_DISCOVERY_PREFIXES — both previously skipped root entirely. - Discovery opts out of CIMD's private/loopback-address SSRF block (new `allowPrivateAddresses` option on fetchPinnedBoundedJson/ checkHostSsrf, off by default for every other caller): the endpoints discovery probes are operator-configured, not attacker-controlled, the same trust level as jwksUri itself, which jwks-rsa already fetches ungated. Unblocks self-hosted IdPs on a private network. - docs/configuration.md's discovery retry note overclaimed automatic recovery; corrected to describe the actual reload-gated, per-worker-thread cooldown semantics. - Okta's authServer now rejects an explicit '' or null instead of silently falling back to the org authorization server. - Trimmed duplicate/narrating comments in src/index.ts and providers/azure.ts; fixed a broken anchor link and a garbled sentence in docs/providers.md. Also fixes a pre-existing test-timing bug in discovery.test.js found while re-running the suite: the "never resolves" fetch mock's dangling discovery attempt could race the next microtask against afterEach's mock reset and end up invoking the real network fetch instead of the mock. Coverage added for every behavioral fix above (azureIssuer.test.js, config.test.js, discovery.test.js, cimd.test.js, okta.test.js). npm test (1719 passing), lint, format:check, and tsc all clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces automatic ID-token issuer derivation via OIDC discovery for statically configured, explicit-endpoint JWKS providers, and adds support for Okta custom authorization servers. It also improves Azure multi-tenant account adoption safety by allowing operators to pin a single tenant GUID, which redirects verification to that tenant's specific JWKS endpoint. Feedback on the changes suggests passing the raw error object directly to the logger in src/index.ts and src/lib/discovery.ts rather than formatting it as a string, ensuring that stack traces and other diagnostic metadata are preserved in the logs.
# Conflicts: # CHANGELOG.md # src/index.ts
# Conflicts: # CLAUDE.md
Adopts the maintainer's #267 precedent (logger?.error?.('message:', error)) on the three spots Gemini flagged: scheduling discovery from a published provider registry (src/index.ts), a crashed discovery attempt, and a discovery attempt that failed to start (src/lib/discovery.ts). All three previously collapsed the error to error.message/String(error), discarding the stack trace. None of these paths ever carries a token, credential, or response body — only operator-configured URLs/provider names and generic transport/parse errors — so passing the raw object through is safe. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Reviewed; no blockers found. Suggestions (non-blocking)
|
|
Reviewed; no blockers found. One non-blocking suggestion is inline: the documented Azure |
…n forms, skip-vs-fail, ordering test) Four findings from the post-ready Claude review, all confirmed real: - discovery.ts shared CIMD's fail-fast, process-wide 2-permit DNS gate via fetchPinnedBoundedJson -> checkHostSsrf -> boundedDnsLookup. Starting several discovery attempts at once (the normal startup path) meant the 3rd and later synchronously saw `temporarily_unavailable` on their first lookup, which was then treated identically to "no document at this prefix" and cached as a definitive failure for the 5-minute cooldown — misleading logs included, since checkHostSsrf never logged the real cause. fetchDiscoveryDocument now retries that specific rejection within the same discovery attempt's overall budget instead of advancing to the next prefix, and the final failure log names capacity exhaustion when that's actually what happened. CIMD's own fail-fast behavior for its attacker-driven client_id path is unchanged — only discovery's operator-config path retries. Verified the updated concurrency test fails without this fix (both the general case and a new, deterministic DNS-capacity-contention test) and passes with it. - A same-tenant Azure issuer pin was being rewritten to the canonical v2 form regardless of which form it started in, silently breaking a v1 authorize-endpoint config pinned in the older sts.windows.net form (which form a real token's `iss` is actually depends on the authorize endpoint, not the jwksUri). Each pin value is now canonicalized (case, trailing slash) within its own form instead of collapsed to one; an array mixing v1 and v2 now keeps each element in its own form. Added v1-pin, v2-pin, and mixed-array tests. - A provider with an invalid Azure issuer pin (a stale pin, one copied from docs, one missing /v2.0) threw out of initializeProviders' single per-provider loop with no try/catch, aborting every other declared provider's startup — inconsistent with #259/#260's "skip the bad provider, keep the others" precedent for every other kind of per-provider misconfiguration. resolveAzureIssuerBinding's throws now use a dedicated AzureIssuerBindingError so initializeProviders can downgrade only this specific failure mode to skip-with-logged-error, while every other buildProviderConfig failure (e.g. #231 §4's issuer-required check) keeps today's fail-the-whole-reload behavior. CHANGELOG corrected: Azure was NOT previously unaffected by the pin-wins-over-shortcut change (its configure() returned issuer unconditionally too), and an upgrade note documents the new stale-pin failure mode and the skip, not abort-all, decision. - The forged-token-ordering test relied on a timing proxy (a real 30ms fetch delay against a 3s bound) that would have passed either way: once discovery settles, awaitDiscoveredIssuer returns the cached result immediately regardless of which call "consumed" the bounded-await slot, so the previous version proved nothing about ordering. Rewritten to hold the fetch open on a manually-released promise, assert the forged call rejects while discovery is still pending, start the valid call, confirm IT is still genuinely pending (not already settled) before releasing the fetch, then assert issuerValidated === true. Verified this version fails when the ordering is actually reversed (confirmed by temporarily moving the discovery-await before jwt.verify and reverting) and passes with the shipped ordering. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…egative-cache dynamic pin failures Gemini suggested extending alias authorities to accept v1 (sts.windows.net) issuer pins, overlapping fix 4's earlier decision to preserve whatever form a same-tenant pin used on a real-tenant-GUID jwksUri. Re-running the pre-push review against that combination surfaced the actual bug both fixes shared: neither checked the pin's form against what the configured `authorizationUrl` actually issues. The Azure preset always generates a v2 authorizationUrl regardless of tenantId/pin, so a v1 pin on a preset-derived config now boots (my earlier fix accepted it) but fails jwt.verify on every real token — worse than the original problem (silently rewriting a v1 pin to v2), since it's now silently WRONG instead of silently overwritten. Fix: resolveAzureIssuerBinding now derives the one issuer form a real token from `authorizationUrl` would actually carry (v2 for '.../oauth2/v2.0/ authorize', v1 for '.../oauth2/authorize'; no additional constraint for an unrecognized shape, e.g. a B2C custom-policy URL) and requires the pin to match it, on BOTH the real-tenant-GUID path and the alias-authority path (which now accepts v1 pins too, consistently). isAzureJwksUri is narrowed to match: it previously exempted ANY login.microsoftonline.com jwksUri from #231 §4's issuer-required check and from generic discovery eligibility, including the older, non-v2.0 JWKS shape resolveAzureIssuerBinding never touches at all — that let such a config boot silently, issuerValidated permanently false, with no error. It now only exempts the exact shape this module actually recognizes and resolves (or safely leaves alone/throws for), so an unrecognized Azure JWKS shape falls through to the normal startup check or to generic discovery, which Azure also supports. Also from this round's review: guarded the new skip-path's logger call in initializeProviders (a throwing logger must not turn a skip back into a failed reload, same pattern already used elsewhere in this change). Separately, extended the same AzureIssuerBindingError handling to the two dynamic (onResolveProvider) resolution call sites (resource.ts's REST GET handler, index.ts's session-validation middleware) — previously these only logged a generic "Error resolving provider" and re-ran the resolve hook (rebuilding and re-throwing) on every single request for that provider. Both now log the specific tenant-mismatch cause and name it as a dynamic- resolution failure, and DynamicProviderCache gained a 30-second negative cache (independent of the operator-configured success TTL, which can be much longer or forever and would be the wrong duration to also gate failure recovery) so a bad config doesn't re-run the hook on every request while cooling down, but still recovers quickly once fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…log flooding, avoid caching a mismatched discovered issuer Further findings from re-running the pre-push review against the previous commit, all real: - isAzureJwksUri's narrowing (df56ad5) was itself still too broad: it exempted ANY exact-shape Azure v2.0 keys-endpoint segment from #231 §4 and generic discovery, including a tenant-DOMAIN segment (e.g. contoso.onmicrosoft.com — Azure accepts a verified domain name here, not only a GUID) that resolveAzureIssuerBinding never recognizes or binds an issuer for. That let such a config boot silently, issuerValidated permanently false, no error — the exact #231 §4 gap this change exists to close. A real-tenant-GUID segment never needed the exemption either: resolveAzureIssuerBinding always sets a usable issuer for it (or throws), so hasUsableIssuer already short-circuits every caller first. Narrowed the exemption to the one case that actually needs it: an UNPINNED shared alias authority (common/organizations/consumers). - Both dynamic-resolution call sites (resource.ts, index.ts) looked up the new Azure-pin failure cache unconditionally on every request, including ones already resolved from the static registry, and logged at error level on every cached hit during the 30s cooldown — contradicting the CHANGELOG's own "doesn't re-log the same cause" claim and able to flood logs for a busy misconfigured provider. The lookup is now gated behind `!providerData` first; a cached hit logs at debug level (the cause was already logged at error level once, when recorded). - OAuthProvider.verifyIdToken cached a discovered issuer onto config.issuer even when the token's own `iss` disagreed with it (e.g. a non-compliant IdP whose real tokens differ from its own discovery document) — every FUTURE login then took the now-configured-issuer branch and failed jwt.verify's issuer check against a value its real tokens never carry, breaking logins that worked fine (just without adoption) before. Now only caches on an actual match. - Three remaining spots still described an Azure issuer pin as narrowing adoption on a provider that stays multi-tenant (docs/configuration.md's two tables, CHANGELOG's Added entry) rather than the implemented behavior (pinning collapses the provider to single-tenant; every other tenant's token now fails verification outright). Corrected, and the CHANGELOG entry now mentions the v1 pin form too. Added/updated tests for the tenant-domain exemption gap (azureIssuer.test.js, config.test.js), the mismatched-discovered-issuer caching bug (OAuthProvider.test.js), and updated the isAzureJwksUri unit tests for the corrected (narrower) semantics. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ment the wrong-form failure; add middleware cooldown test Three more findings from Claude's review of df56ad5, all real: - The real-tenant-GUID path's UNPINNED branch still hardcoded the v2 issuer form regardless of authorizationUrl, even though the pinned branch (added in df56ad5) already derives and enforces the form from authorizationUrl. An explicit-endpoint config with a v1 authorizationUrl, a v2-shaped jwksUri, and no issuer pin booted and set a v2 issuer that every real (v1) token's iss would never match — the mirror image of the v1-pin bug df56ad5 fixed, just without a pin to trip the new check. azureDerivedIssuer now derives the same form the pinned branch would have required, mirroring azureIssuerMatchesAuthorizeForm's logic. - The CHANGELOG's Azure upgrade note only covered a pin naming the wrong TENANT; it didn't mention df56ad5's new same-tenant-wrong-FORM failure (a v1 pin, or a same-tenant [v2, v1] array, on a preset-derived — always v2 — config). An operator who pinned the sts.windows.net form (e.g. copied from a Graph access token's iss) would read the note, conclude their same-tenant pin was fine, and be surprised when the provider failed to load. Documented both failure messages. - Added the middleware-side test Claude's review pointed out was missing (only resource.ts's sibling had one): onResolveProvider returning a bad Azure pin through the session-validation middleware clears the session, the cooldown prevents a second (independent) request from re-running the hook, and a request after the cooldown re-runs it. Caught a real test bug while writing this: reusing one mutable request object across two "requests" hid the cooldown logic entirely, because clearOAuthSession's in-memory clear on the first call made the second call exit at the existing "no OAuth session data" early return before ever reaching the new code — fixed by giving each call its own session object, matching how Harper actually loads one per request. Deferred (non-blocking, more than a one-line fix): Claude also found that the alias-authority path only recognizes the v2 keys-endpoint shape, so a pinned v1-shaped alias config (`.../common/discovery/keys`, Azure's actual v1 metadata jwks_uri) goes unbound and verifies against the shared key pool directly — the exact case src/lib/DESIGN.md's Azure invariant rules out. Needs the same throw/redirect/discovery-exclusion logic the v2 alias path already has, mirrored for a second shape, with its own tests — added as a follow-up to #272's body rather than rushed into this already-large branch's final round. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A YAML list (scope: [openid, profile, email, User.Read]) survives expandEnvVarsDeep as an array, not a string. checkGraphProfileScope is documented "Warns (never throws)" but called .split() on it directly, throwing a TypeError before the profile-inclusion check ever ran — even when the list DID contain 'profile'. initializeProviders only catches AzureIssuerBindingError, so this took down every other provider too, and rejected a live reload carrying it. On main the same value never threw at startup (it reached URLSearchParams and was comma-joined there). Skip non-string scope values, keeping the existing null/unset -> '' default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An explicit-endpoint config whose authorizationUrl names a shared alias (or a different real tenant) while jwksUri names one specific real tenant GUID directly now derives and enforces that one tenant's issuer, the same way a pin would — correct, and the secure direction, but a silent behavior change: on main this exact shape had no issuer check at all, so any tenant whose token verified against those keys could sign in; now only that one tenant's tokens verify. azureDerivedIssuer now also compares authorizationUrl's own tenant segment against jwksUri's, and logs a startup warning (never throws) naming the provider, the effective tenant, and the fix (match authorizationUrl/jwksUri, or pin issuer explicitly) when they differ. Qualified CHANGELOG.md's "completely unaffected" claim to exclude this specific shape. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd unsafe pin advice An UNPINNED Azure shared-alias config using the older v1 keys shape (.../common/discovery/keys, no v2.0 — Azure's actual v1 metadata jwks_uri) was byte-identical on main. Since isAzureJwksUri only recognized the v2 alias shape, this config lost its "intentionally issuer-less" exemption under #264/#271: it fell into background OIDC discovery, which fails for it, and the resulting log told the operator to pin issuer explicitly. Following that advice lands on a PINNED v1-alias config, which this module also didn't recognize — so the pin would bind to Azure's shared, tenant-independent v1 key pool with issuerValidated: true, exactly the case src/lib/DESIGN.md's Azure invariant says must never happen. This is a real regression (a working config breaks) compounded by unsafe advice (the fix it suggests lands on a security hole), not just an edge case. Fix: recognize the v1 alias shape alongside the v2 one. - Unpinned: excluded from discovery and left untouched, same as the v2 alias — byte-identical to main. - Pinned: throws AzureIssuerBindingError (fail closed), naming the fix (point jwksUri at the tenant's own keys endpoint instead of the shared one). Static providers get the existing skip-with-logged-error treatment; dynamic resolution already has the cooldown from the previous round. Updated CHANGELOG and #272 (the item there is now "fixed; redirect instead of throw remains as an optional follow-up"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves conflicts in CHANGELOG.md (Added/Changed sections) and src/lib/OAuthProvider.ts (import ordering) brought in by #270's merge to main.
…on dynamic resolution The authorizationUrl/jwksUri tenant-mismatch warning added in f3d4972 compared authorizationUrl's tenant segment against jwksUri's GUID even when the segment was a verified Azure tenant domain (e.g. contoso.onmicrosoft.com), which can never equal a GUID as a plain string — false-positiving on every such config. The warning now only fires when the segment is itself an alias or another real GUID. Separately, resource.ts and index.ts never passed a logger to buildProviderConfig for a dynamically resolved (onResolveProvider) provider, so this warning (and checkGraphProfileScope's) never reached the log for that path at all. Both call sites now pass a logger, wrapped via DynamicProviderCache.wrapLoggerForDynamicResolution so a repeated identical warning for the same provider logs once rather than on every request. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…NSUMERS_TENANT_ID pair authorizationUrl's 'consumers' alias and jwksUri's fixed AZURE_CONSUMERS_TENANT_ID GUID name the same tenant, but the strings are never equal — the tenant-mismatch warning fixed moments ago for domain segments had the same false-positive here. Exclude this one alias/GUID pair explicitly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
{ ...logger } in wrapLoggerForDynamicResolution only copies own
enumerable properties, so a class-based logger (methods on its
prototype) lost info/error/debug through the wrapper — only warn
survived, since it was always replaced explicitly. Delegate every
method explicitly instead.
Also corrects the CHANGELOG's prior claim that checkGraphProfileScope's
advisory warning now reaches the log for a dynamically resolved
provider: it doesn't, since that check only runs under
enforceIssuerForJwks, which neither dynamic call site sets.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…scope, drop inaccurate checkGraphProfileScope mention Doc-only. The warning (CHANGELOG.md, Added + Fixed) only reaches the log for a dynamically resolved provider when its onResolveProvider hook returns a raw, unbuilt config this plugin builds itself — a hook that calls buildProviderConfig directly derives the issuer, and would need its own logger, before this plugin ever sees the result. Also drops the checkGraphProfileScope parenthetical entirely rather than correcting it in place — it only ever runs for statically declared providers, so it has no bearing on this entry. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Almost no OAuth provider configuration should ever need to set
issuerexplicitly: presets now derive it fromdomain/tenantId/authServerwherever Okta, Auth0, or Azure make it knowable, and any other explicit-endpoint JWKS provider derives it automatically via background OIDC discovery.Closes #264
Unresolved from review: the planning design deliberately deviates from #264's own sketch for Azure multi-tenant validation (pin-and-redirect to a tenant-exclusive endpoint, instead of validating a shared-pool token by matching its own
tidclaim against a template) — see entry 1 below and the design-decision comment on #264 for the full rationale; this is a framing verdict I resolved rather than one cleared by review, so it is called out here rather than left to be found in the diff.For the human reviewer
/common,/organizations) token by template — acceptisswhenever it equalshttps://login.microsoftonline.com/{tid}/v2.0for the token's owntidclaim. Nine rounds of planning review converged on a different mechanism instead: the operator pinsissuerto exactly one real tenant GUID, andresolveAzureIssuerBinding(src/lib/azureIssuer.ts) redirects bothjwksUriandissuerto that one tenant's own, non-shared endpoint — collapsing the case into the already-safe single-tenant code path, with no new verification branch. Why: Microsoft's shared/common//organizationsJWKS response is one key set across every tenant on that authority; a key's cryptographic validity alone doesn't prove which tenant issued it, only a per-keyissuerproperty in the raw JWKS response does, and that property isn't reachable throughjwks-rsa's public API (what this plugin verifies with). Template-matching the token's owntidagainst its ownissonly checks the token's self-reported claims cohere with each other — it does not close that gap. Reversibility: changing the pin semantics is cheap now (nothing has shipped), a breaking change once a release carries the current docs' description of it — which is why the docs fix in this PR (below) matters as much as the code. Accepted by maintainer. Pinning is opt-in, so unpinned multi-tenant sign-in is unchanged. The next step is to keep sign-in open to all tenants while allowing adoption for an allowlist of tenants, verified through each signing key'sissuer. That's tracked in Azure multi-tenant: allow account adoption for an allowlist of tenants without restricting sign-in #272.jwksUrishape instead of declaredprovidertype (validateIssuerForJwksinsrc/lib/config.ts)? Ageneric/Okta/Auth0 provider manually pointed atlogin.microsoftonline.com's shared alias host now gets the same "intentionally issuer-less, never hard-fail" treatment asprovider: azure/microsoft, sinceresolveAzureIssuerBindingalready runs unconditionally ahead of this check regardless of declared type. I believe this is the right generalization (the old behavior was an inconsistency, not an intentional distinction), but it does mean a non-Azure-declared provider against that host now boots instead of failing fast with a (previously misleading) startup error. Accepted by maintainer.allowPrivateAddressesonfetchPinnedBoundedJson/checkHostSsrfinsrc/lib/mcp/cimd.ts, used only bysrc/lib/discovery.ts)? The endpoints discovery probes are the operator's ownauthorizationUrl/jwksUri— already the same trust level asjwksUriitself, whichjwks-rsafetches with no SSRF gate at all — so blocking a private/loopback address only breaks discovery for a self-hosted IdP on a private network (Docker, a VPC) for no safety benefit over that already-ungated fetch. Every other CIMD caller is unaffected (the option defaults tofalse). I'm confident in this one but it's a widening of what was previously a blanket gate, so it belongs in front of a human. Accepted by maintainer.issuerautomatically so operators almost never set it) is independently useful and narrowly scoped to issuer derivation; I agree it's worth doing as specified. Two items were added mid-implementation at the calling session's direction rather than Derive the ID-token issuer from presets and OIDC discovery so operators rarely set it #264's own text: Okta'sauthServerpreset shortcut (a natural extension of "derive the issuer from the shortcut," not a scope stretch) and the Microsoft Graphprofile-scope startup warning (closes a gap the just-merged fix: harden OAuth account-adoption gate (issuer fail-fast, UserInfo sub check, lookup-error quarantine) #263 left open, and is a warning, not new enforcement). Neither seemed large enough to warrant a separate PR, but both are real additions to Derive the ID-token issuer from presets and OIDC discovery so operators rarely set it #264's stated scope, not just its implementation, and a reviewer may reasonably disagree on where to draw that line. Accepted by maintainer.Changes
Core issuer-derivation logic
src/lib/azureIssuer.ts(new):resolveAzureIssuerBinding— derives a direct issuer for a real Azure tenant GUID, canonicalizes/validates an operator pin against it (same tenant in any recognized Azure issuer form, string or array), and redirects a pinned shared-alias authority (/common,/organizations,/consumers) to that one tenant's own, non-shared JWKS endpoint. Throws for an array pin or a pin naming a different tenant; a throwing advisory logger cannot abort the rewrite.src/lib/discovery.ts(new): OIDC Discovery 1.0 for explicit-endpoint JWKS providers with no usableissuer— bounded, cached, concurrency-capped, same-origin-only probing of ancestor path-prefixes, strict four-field validation (issuer/authorization_endpoint/jwks_uri/token_endpoint) against the configured endpoints, fire-and-forget at startup, a short bounded await for the first adoption-eligible login only. Both its own catch blocks (a crashed discovery attempt, a failed-to-start attempt) now also pass the rawerrorobject to the logger instead oferror.message(two more Gemini threads, same fix: redirect /login with a reason code on CSRF storage failure #267 precedent) — neither ever carries a token, credential, or response body; only operator-configured URLs/provider names and generic transport/parse errors.src/lib/config.ts: dispatches presetconfigure()correctly for an explicitprovider: 'microsoft'option; an explicit, usableissuernow wins over a shortcut-derived one (excluding an unresolved${VAR}placeholder,null, or''); callsresolveAzureIssuerBinding; relaxes the Follow-up hardening after the account-adoption gate (GHSA-vf58 / 2.6.0) #231 §4 startup hard-fail to defer to discovery for anhttpsexplicit-endpoint provider; the Azure-host exclusion from that hard-fail now keys offjwksUrishape, not declared provider type; adds the Graphprofile-scope startup warning.src/lib/OAuthProvider.ts: callsresolveAzureIssuerBindingin its own constructor (the one point every construction path, includingTenantManager's, is guaranteed to cross —TenantManager.registerTenantbuilds its config without going throughbuildProviderConfigat all); restructuresverifyIdTokenso ID-token signature verification always runs before awaiting discovery, so a forged token can't consume the bounded first-login wait; only caches a discovered issuer ontoconfig.issuerwhen it actually matches the verified token's owniss— caching a mismatch would have made every future login (including ones that previously worked fine without adoption) failjwt.verify's issuer check.src/lib/dynamicProviderCache.ts: adds a 30-second negative cache for a dynamically-resolved (onResolveProvider) provider's invalid Azure issuer pin, independent of the operator-configured success TTL (which can be much longer or forever) — so a bad config doesn't re-run the resolve hook on every single request while cooling down, but still recovers within 30s once fixed.src/lib/resource.ts: the REST GET handler's dynamic-resolution catch now distinguishes anAzureIssuerBindingError(logs the specific tenant/form mismatch, records the 30s negative-cache entry) from any other resolution failure (unchanged generic handling); the negative-cache lookup itself is gated on!providerDatafirst, and a cached hit logs atdebug, noterror, to avoid flooding logs on every request during the cooldown.src/types.ts: extends the presetconfigure()signature with an optional second argument (Okta'sauthServer).Presets
src/lib/providers/azure.ts: stops setting a literal, incorrectissuerfor the shared tenant aliases (previouslytenantId: 'common'produced anissuerno real multi-tenant token'sisscould ever equal).src/lib/providers/okta.ts:configure(domain, authServer?)derives path-inclusive endpoints and issuer for a named custom authorization server; an explicit''/nullauthServernow reachesvalidateOktaAuthServerand is rejected, instead of silently falling back to the org authorization server.src/lib/providers/validation.ts: addsvalidateOktaAuthServer.src/lib/tenantManager.ts: threads a per-tenantauthServerthrough to the Okta preset'sconfigure().Shared fetch infrastructure
src/lib/mcp/cimd.ts: addsallowPrivateAddressestofetchPinnedBoundedJson/checkHostSsrf, defaulting tofalsefor every existing caller — onlydiscovery.tsopts in, since the endpoints it probes are operator-configured, not attacker-controlled.Bootstrap and docs
src/index.ts: schedules OIDC discovery immediately once a (re)built provider registry is published, before anything later in the same function could retroactively make the schedule "too late" — seesrc/lib/DESIGN.mdfor the exact reasoning; passes the rawerrorobject to the logger on that catch instead of collapsing it toerror.message(per a Gemini review thread, consistent with fix: redirect /login with a reason code on CSRF storage failure #267's precedent — preserves the stack trace; this catch never carries a token or credential). Also merges forward fix: redirect /login with a reason code on CSRF storage failure #267/fix: don't report a failed OAuth session clear as a completed logout #268's unrelatedtoHttpResponse/CSRF-redirect import additions frommainwith no further change.src/lib/DESIGN.md(new): three non-obvious invariants from this change — the Azure never-touch-the-shared-key-pool rationale, the discovery-kickoff placement rationale, and whyazureIssuer.ts/discovery.tsavoid importingconfig.ts/OAuthProvider.ts.AGENTS.md: adds the sameDESIGN.mdpointer to docs: add maintainer guide and canonical AGENTS.md #269's new canonical agent-guidance file (CLAUDE.mditself is now just@AGENTS.md, merged forward frommainwith no further change — my own one-lineDESIGN.mdpointer there dropped out of this diff since both sides now agree).docs/configuration.md: documents the relaxed Follow-up hardening after the account-adoption gate (GHSA-vf58 / 2.6.0) #231 §4 upgrade behavior and corrects the discovery-retry note, which previously overclaimed automatic recovery within the cooldown window.docs/providers.md: documents Okta'sauthServer, discovery for custom OIDC providers, and corrects the Azure multi-tenant pin example, which previously described the template-match behavior from Derive the ID-token issuer from presets and OIDC discovery so operators rarely set it #264's sketch rather than the implemented single-tenant collapse.CHANGELOG.md:[Unreleased]entries for all of the above under### Added/### Changed.Tests (behavior and regression coverage; see Verification for what each proves)
test/lib/azureIssuer.test.js(new)test/lib/discovery.test.js(new)test/lib/OAuthProvider.test.jstest/options-watcher.test.jstest/lib/tenantManager.test.jstest/lib/config.test.jstest/lib/providers/okta.test.jstest/lib/providers/azure.test.jstest/lib/mcp/cimd.test.jstest/lib/dynamicProviderCache.test.js: the 30s Azure-pin negative-cache's record/expire/clear behavior, independent of the success TTL.test/lib/OAuthResource.caching.test.js: the REST GET handler's dynamic-resolution path — 500 on a bad pin, no caching, the cooldown, and re-resolution after it elapses.test/sessionValidationMiddleware.test.js(new file in this PR's diff, extended in this round): the session-validation middleware's own copy of the same dynamic-resolution path — the stale session is cleared, the cooldown prevents re-resolving on an independent second request, and a request after the cooldown re-resolves. Caught a real test-authoring bug while writing this (reusing one mutable request object hid the cooldown logic entirely); fixed by giving each call its own session object.Final round (
db51cf5…1365802): mergedmainafter feat: let an app choose which GitHub email becomes the login identity #270; fixed a regression againstmainwhere an unpinned config using Azure's v1 shared-alias keys (/{common|organizations|consumers}/discovery/keys) fell into OIDC discovery (it's now excluded like the v2 alias; a pinned v1 alias fails closed with a fix-it error); fixed two bugs in the new tenant-mismatch warning (false positives for verified-domain andconsumerssegments; it never fired foronResolveProviderproviders because no logger was passed, which is now wired through a per-provider deduping wrapper); and fixed that wrapper dropping a class-based logger's prototype methods. CHANGELOG qualified to match.Verification
npm test(1828 passing, 2 skipped, 0 failing — includes #267/#268's tests merged forward frommain),npm run lint(0 errors),npm run format:check(clean),npx tsc --noEmit(clean) — all run against the full branch, not just the latest commit. New/extended coverage for every behavioral change: Azure issuer pin canonicalization, v1-vs-v2 form validation againstauthorizationUrl(both the pinned and unpinned branches, and the shared-alias path), and the tenant-domain/real-tenant-GUIDisAzureJwksUriexemption narrowing (test/lib/azureIssuer.test.js,test/lib/config.test.js); the explicit-issuer-pin-vs-shortcut-derived-issuer precedence including the${VAR}-placeholder case, and the Azure-pin skip-vs-fail-allinitializeProvidersbehavior (test/lib/config.test.js); OIDC discovery's root-prefix coverage, private-address allowance, and DNS-capacity-contention retry (test/lib/discovery.test.js); the newallowPrivateAddressesoption's default-off behavior for every other CIMD caller (test/lib/mcp/cimd.test.js); Okta'sauthServerempty-string/null rejection (test/lib/providers/okta.test.js); the discovered-issuer-mismatch-is-never-cached fix and a corrected (previously timing-dependent, proved-nothing) forged-token-ordering test (test/lib/OAuthProvider.test.js); and the dynamic-provider negative-cache on both call sites (test/lib/dynamicProviderCache.test.js,test/lib/OAuthResource.caching.test.js,test/sessionValidationMiddleware.test.js). No end-to-end callback-through-adoption-gate test exists for the Azure pin-and-redirect path specifically — the unit tests exerciseresolveAzureIssuerBindingandOAuthProvider's signature/issuer-check ordering separately, not the full login flow with a real Entra token; this is the same limitation the pre-push review's own verification noted. Every review thread across four rounds (three Claude, one Gemini) is addressed and resolved; the alias-authority path's v1-JWKS-shape gap, first deferred to #272, is fixed here instead (it was a regression againstmainfor unpinned configs).Complexity: complicated
Review-Coverage: authored=claude; ran=codex,cursor-grok,cursor-composer; adjudicated=domain; declined=gemini,cursor-kimi,cursor-muse; rounds=7; full=1 @ 1365802
Human-Review-Need: 3 (decisions: azure-domain-authorize-silent, azure-v1-alias-pin-reject, azure-guid-jwks-derives-issuer, dynamic-warn-dedup-lifetime, azure-pin-skip-on-reload, azure-pin-semantics, relax-231-hard-fail, retry-only-on-reload) @ cf8ea43