Skip to content

Derive the ID-token issuer from presets and OIDC discovery - #271

Merged
heskew merged 22 commits into
mainfrom
feat/issuer-discovery
Oct 2, 2026
Merged

heskew merged 22 commits into
mainfrom
feat/issuer-discovery

Conversation

@heskew

@heskew heskew commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Almost no OAuth provider configuration should ever need to set issuer explicitly: presets now derive it from domain/tenantId/authServer wherever 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 tid claim 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

  1. Is the Azure multi-tenant requirement itself warranted as specified, and is the implemented mechanism the right one? Derive the ID-token issuer from presets and OIDC discovery so operators rarely set it #264 proposed validating a multi-tenant (/common, /organizations) token by template — accept iss whenever it equals https://login.microsoftonline.com/{tid}/v2.0 for the token's own tid claim. Nine rounds of planning review converged on a different mechanism instead: the operator pins issuer to exactly one real tenant GUID, and resolveAzureIssuerBinding (src/lib/azureIssuer.ts) redirects both jwksUri and issuer to 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//organizations JWKS 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-key issuer property in the raw JWKS response does, and that property isn't reachable through jwks-rsa's public API (what this plugin verifies with). Template-matching the token's own tid against its own iss only 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's issuer. That's tracked in Azure multi-tenant: allow account adoption for an allowlist of tenants without restricting sign-in #272.
  2. Is it correct to exclude the Azure-host carve-out by jwksUri shape instead of declared provider type (validateIssuerForJwks in src/lib/config.ts)? A generic/Okta/Auth0 provider manually pointed at login.microsoftonline.com's shared alias host now gets the same "intentionally issuer-less, never hard-fail" treatment as provider: azure/microsoft, since resolveAzureIssuerBinding already 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.
  3. Is opting discovery out of CIMD's private/loopback-address SSRF block the right trust boundary (allowPrivateAddresses on fetchPinnedBoundedJson/checkHostSsrf in src/lib/mcp/cimd.ts, used only by src/lib/discovery.ts)? The endpoints discovery probes are the operator's own authorizationUrl/jwksUri — already the same trust level as jwksUri itself, which jwks-rsa fetches 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 to false). 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.
  4. Is the task as handed warranted, and is it the right size for this branch? Derive the ID-token issuer from presets and OIDC discovery so operators rarely set it #264's own goal (derive issuer automatically 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's authServer preset shortcut (a natural extension of "derive the issuer from the shortcut," not a scope stretch) and the Microsoft Graph profile-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 usable issuer — 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 raw error object to the logger instead of error.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 preset configure() correctly for an explicit provider: 'microsoft' option; an explicit, usable issuer now wins over a shortcut-derived one (excluding an unresolved ${VAR} placeholder, null, or ''); calls resolveAzureIssuerBinding; 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 an https explicit-endpoint provider; the Azure-host exclusion from that hard-fail now keys off jwksUri shape, not declared provider type; adds the Graph profile-scope startup warning.
  • src/lib/OAuthProvider.ts: calls resolveAzureIssuerBinding in its own constructor (the one point every construction path, including TenantManager's, is guaranteed to cross — TenantManager.registerTenant builds its config without going through buildProviderConfig at all); restructures verifyIdToken so 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 onto config.issuer when it actually matches the verified token's own iss — caching a mismatch would have made every future login (including ones that previously worked fine without adoption) fail jwt.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 an AzureIssuerBindingError (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 !providerData first, and a cached hit logs at debug, not error, to avoid flooding logs on every request during the cooldown.
  • src/types.ts: extends the preset configure() signature with an optional second argument (Okta's authServer).

Presets

  • src/lib/providers/azure.ts: stops setting a literal, incorrect issuer for the shared tenant aliases (previously tenantId: 'common' produced an issuer no real multi-tenant token's iss could ever equal).
  • src/lib/providers/okta.ts: configure(domain, authServer?) derives path-inclusive endpoints and issuer for a named custom authorization server; an explicit ''/null authServer now reaches validateOktaAuthServer and is rejected, instead of silently falling back to the org authorization server.
  • src/lib/providers/validation.ts: adds validateOktaAuthServer.
  • src/lib/tenantManager.ts: threads a per-tenant authServer through to the Okta preset's configure().

Shared fetch infrastructure

  • src/lib/mcp/cimd.ts: adds allowPrivateAddresses to fetchPinnedBoundedJson/checkHostSsrf, defaulting to false for every existing caller — only discovery.ts opts in, since the endpoints it probes are operator-configured, not attacker-controlled.

Bootstrap and docs

Tests (behavior and regression coverage; see Verification for what each proves)

Verification

npm test (1828 passing, 2 skipped, 0 failing — includes #267/#268's tests merged forward from main), 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 against authorizationUrl (both the pinned and unpinned branches, and the shared-alias path), and the tenant-domain/real-tenant-GUID isAzureJwksUri exemption 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-all initializeProviders behavior (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 new allowPrivateAddresses option's default-off behavior for every other CIMD caller (test/lib/mcp/cimd.test.js); Okta's authServer empty-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 exercise resolveAzureIssuerBinding and OAuthProvider'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 against main for 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

heskew and others added 7 commits October 2, 2026 11:01
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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/index.ts Outdated
Comment thread src/lib/discovery.ts Outdated
Comment thread src/lib/discovery.ts Outdated
heskew and others added 3 commits October 2, 2026 12:12
# Conflicts:
#	CHANGELOG.md
#	src/index.ts
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>
@heskew
heskew marked this pull request as ready for review October 2, 2026 19:24
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

Suggestions (non-blocking)

  • src/lib/azureIssuer.ts:241 — The error message for an invalid Azure issuer pin could specifically mention the /v2.0 suffix requirement for v2 endpoints, as this is a common OIDC mismatch in Entra ID configurations. (Clarity)
  • src/lib/discovery.ts:182 — The 30s cooldown for discovery failures is hardcoded; while appropriate for the majority of use cases, consider moving it to a constant at the top of the file alongside DISCOVERY_RETRY_COOLDOWN_MS for easier maintainability. (Maintainability)
  • src/lib/dynamicProviderCache.ts:132 — The wrapLoggerForDynamicResolution deduping logic is per-provider-name; if an operator reloads a bad config with a different error message, it will log once again. This is correct, but adding the message to the cache key (as you have done in shouldWarnOnce) ensures that if the same provider starts failing with a new warning, it won't be suppressed. (Robustness)

Comment thread src/lib/discovery.ts Outdated
Comment thread CHANGELOG.md Outdated
Comment thread test/lib/OAuthProvider.test.js
Comment thread src/lib/azureIssuer.ts Outdated
@claude

claude Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found. One non-blocking suggestion is inline: the documented Azure ?appid= keys URL is not covered by the new shared-alias guard.

…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>
Comment thread src/lib/azureIssuer.ts
Comment thread CHANGELOG.md Outdated
…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>
Comment thread src/lib/azureIssuer.ts Outdated
Comment thread CHANGELOG.md Outdated
Comment thread src/lib/azureIssuer.ts
Comment thread src/index.ts Outdated
…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>
Comment thread src/lib/config.ts
…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>
Comment thread src/lib/azureIssuer.ts Outdated
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>
Comment thread src/lib/azureIssuer.ts Outdated
Comment thread src/lib/azureIssuer.ts Outdated
Comment thread src/lib/resource.ts Outdated
heskew and others added 5 commits October 2, 2026 14:31
…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>
Comment thread CHANGELOG.md Outdated
…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>
Comment thread src/lib/azureIssuer.ts
Comment thread src/lib/discovery.ts
Comment thread src/lib/dynamicProviderCache.ts
Comment thread src/lib/azureIssuer.ts
@heskew
heskew merged commit da07557 into main Oct 2, 2026
22 checks passed
@heskew
heskew deleted the feat/issuer-discovery branch October 2, 2026 23:00
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.

Derive the ID-token issuer from presets and OIDC discovery so operators rarely set it

1 participant